diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/design_runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/design_runtime.rs index c81f84ec1..fbb42a22a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/design_runtime.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/design_runtime.rs @@ -15,6 +15,43 @@ const DESIGN_ACTIVE_LOCK: &str = ".agent/design-agent/active.lock"; const DESIGN_PANIC_PUBLIC_ERROR: &str = "策划运行发生内部错误,本轮已中断,可直接重试;若反复出现请反馈。"; +// 运行段经 task-local 携带项目根,panic hook 据此把位置和负载写进私有 design_debug。 +// task-local 而非 thread-local:多线程 runtime 下 future 会跨 worker 迁移。 +tokio::task_local! { + static DESIGN_PANIC_ROOT: Option; +} + +fn ensure_design_panic_hook() { + static ONCE: std::sync::Once = std::sync::Once::new(); + ONCE.call_once(|| { + let previous = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + if let Ok(Some(root)) = DESIGN_PANIC_ROOT.try_with(Clone::clone) { + let location = info + .location() + .map(|location| { + format!( + "{}:{}:{}", + location.file(), + location.line(), + location.column() + ) + }) + .unwrap_or_else(|| "未知位置".to_string()); + design_debug( + &root, + "panic", + json!({ + "location": location, + "error": info.payload_as_str().unwrap_or("未知 panic 负载"), + }), + ); + } + previous(info); + })); + }); +} + #[derive(Clone, Debug, Deserialize, Serialize)] #[serde( tag = "type", @@ -982,13 +1019,15 @@ async fn finish_design_command( // panic 边界:运行期 panic 转成普通失败,交给既有错误分支恢复(重读检查点、 // 写 last_error、发最终 view)。否则 unwind 会杀死 command task,IPC 永不 // 返回(前端停在工作态),会话停在无错误的 pending,用户只能看到无声的重试。 - let outcome = - std::panic::AssertUnwindSafe(run_design_loop(root, resources, &mut session, &mut emit)) - .catch_unwind() - .await; + ensure_design_panic_hook(); + let run = DESIGN_PANIC_ROOT.scope( + Some(root.to_path_buf()), + run_design_loop(root, resources, &mut session, &mut emit), + ); + let outcome = std::panic::AssertUnwindSafe(run).catch_unwind().await; let result = match outcome { Ok(result) => result, - Err(payload) => Err(design_panic_error(root, payload)), + Err(payload) => Err(design_panic_error(payload)), }; if let Err(error) = result { // 从最后一个持久检查点恢复,防止写后未记结果被误认为已完成。 @@ -1010,14 +1049,9 @@ async fn finish_design_command( Ok(view) } -// panic 负载可能包含路径或内容片段,公开文案固定;细节只进私有 design_debug。 -fn design_panic_error(root: &Path, payload: Box) -> String { - let detail = payload - .downcast_ref::<&str>() - .map(|value| (*value).to_string()) - .or_else(|| payload.downcast_ref::().cloned()) - .unwrap_or_else(|| "未知 panic 负载".to_string()); - design_debug(root, "panic", json!({"error": detail})); +// panic 负载可能包含路径或内容片段,公开文案固定;位置和负载由 panic hook 写进私有 +// design_debug(task-local 提供项目根),不进入用户可见消息。 +fn design_panic_error(_payload: Box) -> String { DESIGN_PANIC_PUBLIC_ERROR.to_string() } @@ -1665,6 +1699,7 @@ mod tests { #[tokio::test(flavor = "current_thread")] async fn design_tool_panic_becomes_visible_retryable_error() { let (_temp, root, resources) = init_design_project(); + std::env::set_var("GENARRATIVE_AGC_DESIGN_DEBUG", "1"); let panic_call = platform_llm::LlmToolCall { id: "call-panic".into(), name: "design_test__panic".into(), @@ -1705,6 +1740,30 @@ mod tests { "panic 不得写半个工具输出" ); + // design_debug 经独立线程落盘,轮询等待 panic 记录出现。 + let debug_dir = root.join(".debug/design-agent"); + let mut panic_record = None; + for _ in 0..100 { + panic_record = fs::read_dir(&debug_dir).ok().and_then(|entries| { + entries.flatten().find_map(|entry| { + let path = entry.path(); + let name = path.file_name()?.to_string_lossy().into_owned(); + if name.ends_with("-panic.json") { + fs::read_to_string(path).ok() + } else { + None + } + }) + }); + if panic_record.is_some() { + break; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + let panic_record = panic_record.expect("panic hook 必须把位置和负载写入 design_debug"); + assert!(panic_record.contains("design_runtime.rs")); + assert!(panic_record.contains("注入的策划工具 panic")); + let view = continue_design_agent_at(&root, &resources, "turn-retry", DesignInput::Retry, |_| {}) .await diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index c505ceb32..0828b8fda 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -8,6 +8,8 @@ `finish_design_command` 的运行段包在 `catch_unwind` 边界内:任何运行期 panic 被转成一次普通失败,由既有错误分支从最后一个持久检查点恢复,向会话写入固定公开文案"策划运行发生内部错误,本轮已中断,可直接重试;若反复出现请反馈。",并以 `running=false、可重试、lastError 有值` 的视图正常返回。panic 负载只写入私有 design_debug,不进入用户可见消息;continue、recover_uncertain 与 decide 三个入口共享同一边界。边界不改变工具错误、Provider 瞬态重试和批次不确定恢复的既有语义。 +运行段通过 task-local 携带项目根;首次运行时安装的 panic hook 在 panic 瞬间把代码位置(file:line:column)和负载追加写入私有 design_debug,随后交给原 hook 维持既有 stderr 输出。hook 只在策划运行段内生效(其它任务无 task-local 上下文时直接透传),多线程 runtime 下 future 跨 worker 迁移也能正确归因。 + ## 资源画布交互与工作台状态同步 - 工作台向窗口标题栏发布正在运行的项目时,输入未变化不得形成重复发布与清理的渲染循环;打开项目动作始终使用当前工作台处理逻辑,退出工作台后清除其标题栏状态。