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 7be937069..069bfc3d1 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 @@ -73,6 +73,83 @@ fn claude_project_key(root: &Path) -> PathBuf { std::fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf()) } +/// Claude Code 把每个会话的轨迹持久化在 `/projects/<项目目录名>/.jsonl`, +/// 目录名由 cwd 派生:非字母数字字符统一替换成 `-`。 +/// +/// 实例:`C:\Users\...\projects\gameagent-0514673b` 对应 +/// `C--Users-...-projects-gameagent-0514673b`。 +fn claude_code_project_dir_name(root: &Path) -> String { + root.to_string_lossy() + .chars() + .map(|ch| if ch.is_ascii_alphanumeric() { ch } else { '-' }) + .collect() +} + +/// 返回某个会话目录里 mtime 最新的一份 cc 轨迹:`(修改时间, sessionId)`。 +fn newest_claude_code_session_in(dir: &Path) -> Option<(std::time::SystemTime, String)> { + let mut newest: Option<(std::time::SystemTime, String)> = None; + for entry in std::fs::read_dir(dir).ok()?.flatten() { + let path = entry.path(); + if path.extension().and_then(std::ffi::OsStr::to_str) != Some("jsonl") { + continue; + } + let Some(session_id) = path + .file_stem() + .and_then(std::ffi::OsStr::to_str) + .filter(|stem| !stem.is_empty()) + else { + continue; + }; + let Ok(modified) = entry.metadata().and_then(|metadata| metadata.modified()) else { + continue; + }; + if newest + .as_ref() + .map(|(at, _)| modified > *at) + .unwrap_or(true) + { + newest = Some((modified, session_id.to_string())); + } + } + newest +} + +/// 从隔离 home 里找出本项目最近的 cc 会话 id,供 Claude Agent SDK `resume` 使用。 +/// +/// 只认本项目目录(目录名由 cwd 派生,`resume` 也只会在那里查这份轨迹):进程内会话表 +/// 在用户"离开项目再进入"或重启客户端后必然为空,此时必须回退到磁盘,否则 cc 执行器 +/// 会以全新会话开工,模型看不到任何历史对话。现场:2026-10-07 用户反馈"离开后再进入 +/// 项目,cc 那边看不到历史对话",项目 `gameagent-0514673b` 的隔离 home 里留下了两份 +/// 独立轨迹(14:52 与 15:05),第二份的首次用户输入只有一句"现在呢"。 +fn latest_claude_code_session_id_at(home: &Path, root: &Path) -> Option { + let projects = home.join("claude").join("projects"); + let preferred = projects.join(claude_code_project_dir_name(root)); + newest_claude_code_session_in(&preferred).map(|(_, session_id)| session_id) +} + +/// 解析本回合要恢复的 cc 会话:进程内会话表优先,没有就回退到磁盘上最新的会话轨迹。 +/// +/// 返回 `(会话 id, 来源)`,来源只进应用日志,供"cc 看不到历史对话"这类反馈一眼分辨是 +/// 内存命中、磁盘回放,还是本项目根本没有历史会话。 +fn resolve_claude_direct_resume( + root: &Path, + home: &Path, +) -> Result<(Option, &'static str), String> { + let session_key = claude_project_key(root); + let in_memory = claude_direct_sessions() + .lock() + .map_err(|_| "Claude Code Direct 会话表不可用,无法恢复上一次会话".to_string())? + .get(&session_key) + .cloned(); + if let Some(session_id) = in_memory { + return Ok((Some(session_id), "memory")); + } + match latest_claude_code_session_id_at(home, root) { + Some(session_id) => Ok((Some(session_id), "disk")), + None => Ok((None, "none")), + } +} + fn kill_claude_code_process_tree(pid: u32) -> Result<(), String> { #[cfg(unix)] { @@ -1969,11 +2046,15 @@ pub(crate) async fn direct_game_creator_claude_code_chat_at( std::fs::create_dir_all(&home) .map_err(|error| format!("创建 cc Direct 隔离目录失败:{error}"))?; let session_key = claude_project_key(root); - let resume = claude_direct_sessions() - .lock() - .map_err(|_| "Claude Code Direct 会话表不可用,无法恢复上一次会话".to_string())? - .get(&session_key) - .cloned(); + let (resume, resume_source) = resolve_claude_direct_resume(root, &home)?; + app_log!( + "agent.direct_codex.claude_resume source={} session={}", + resume_source, + resume + .as_deref() + .map(|session_id| session_id.chars().take(8).collect::()) + .unwrap_or_else(|| "none".to_string()) + ); direct_turn_trace("claude-sidecar-start"); let payload = claude_direct_turn_payload( client_turn_id, @@ -2659,4 +2740,92 @@ mod tests { Some(&Some(CLAUDE_CODE_MCP_TOOL_TIMEOUT_MS.to_string())) ); } + + #[test] + fn claude_code_session_lookup_uses_the_project_directory_and_newest_trace() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("projects").join("gameagent-0514673b"); + std::fs::create_dir_all(&root).unwrap(); + let home = temp.path().join("home"); + let dir_name = claude_code_project_dir_name(&root); + // 目录名必须与 Claude Code 自己的派生规则一致:非字母数字统一换成 `-`, + // 否则 resume 会去一个不存在的目录里找会话。 + assert!(dir_name.contains("gameagent-0514673b")); + assert!(!dir_name.contains([':', '\\', '/'])); + let sessions = home.join("claude").join("projects").join(&dir_name); + std::fs::create_dir_all(&sessions).unwrap(); + + // 本项目还没有任何轨迹时不能凭空造出 resume。 + assert_eq!(latest_claude_code_session_id_at(&home, &root), None); + + let older = sessions.join("24c434aa-8f92-4615-9c74-46433eaa3a85.jsonl"); + let newer = sessions.join("f79dae28-ff58-4da3-8908-7a86a2a368e4.jsonl"); + std::fs::write(&older, "{}\n").unwrap(); + std::fs::write(&newer, "{}\n").unwrap(); + std::fs::write(sessions.join("notes.md"), "x").unwrap(); + std::fs::File::options() + .write(true) + .open(&older) + .unwrap() + .set_modified(std::time::SystemTime::now() - Duration::from_secs(600)) + .unwrap(); + std::fs::File::options() + .write(true) + .open(&newer) + .unwrap() + .set_modified(std::time::SystemTime::now()) + .unwrap(); + assert_eq!( + latest_claude_code_session_id_at(&home, &root).as_deref(), + Some("f79dae28-ff58-4da3-8908-7a86a2a368e4") + ); + + // 其他项目的轨迹再新也不能顶替本项目:SDK 只会在本项目的目录里查这份轨迹。 + let other = home + .join("claude") + .join("projects") + .join("C--other-project"); + std::fs::create_dir_all(&other).unwrap(); + std::fs::write( + other.join("00000000-0000-0000-0000-000000000000.jsonl"), + "{}\n", + ) + .unwrap(); + assert_eq!( + latest_claude_code_session_id_at(&home, &root).as_deref(), + Some("f79dae28-ff58-4da3-8908-7a86a2a368e4") + ); + } + + #[test] + fn claude_direct_resume_falls_back_to_disk_and_prefers_the_process_map() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("project"); + std::fs::create_dir_all(&root).unwrap(); + let home = temp.path().join("home"); + let sessions = home + .join("claude") + .join("projects") + .join(claude_code_project_dir_name(&root)); + std::fs::create_dir_all(&sessions).unwrap(); + std::fs::write(sessions.join("disk-session.jsonl"), "{}\n").unwrap(); + + // 冷启动(离开项目再进入 / 重启客户端)时进程表必然为空,此时必须回放磁盘轨迹, + // 否则 cc 执行器以全新会话开工,模型看不到任何历史对话。 + assert_eq!( + resolve_claude_direct_resume(&root, &home).unwrap(), + (Some("disk-session".to_string()), "disk") + ); + + let key = claude_project_key(&root); + claude_direct_sessions() + .lock() + .unwrap() + .insert(key.clone(), "memory-session".to_string()); + assert_eq!( + resolve_claude_direct_resume(&root, &home).unwrap(), + (Some("memory-session".to_string()), "memory") + ); + claude_direct_sessions().lock().unwrap().remove(&key); + } } diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index e3f38e736..278764859 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -6562,3 +6562,11 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - **CPU 边界**:发行网关是公开无鉴权端点,压缩按请求实时算。release 构建实测 level 6 为 1.3 MiB→10 ms、8 MiB→59 ms、64 MiB(单文件上限)→522 ms 纯 CPU,因此在 `RELEASE_COMPRESSION_FAST_ABOVE_BYTES`(2 MiB)以上改用 level 1(zlib 端实测 level 1 约为 level 6 的 1/3 耗时、压缩比只差约 3%)。若后续要再做减法,优先把压缩结果按 `(对象键, 资源路径)` 缓存,而不是放宽级别。 - **验证**:`cargo test -p api-server --bin api-server -- game_distribution`(新增 ETag 作用域、`If-None-Match` 列表/弱校验命中、`gzip;q=0` 拒绝、文本压缩与二进制/小文件不压、304 无正文、`*` 对包内缺失路径仍 404 等用例);`npx vitest run packages/shared/src/components/PlatformGameLoadingSurface.test.tsx`、`src/components/game-distribution/GameDistributionPages.test.tsx`、`apps/admin-web/src/pages/AdminGameDistributionReviewPage.test.tsx`;`npm run check:game-distribution-ops-rollback-e2e` 47 项通过(含压缩/Vary/ETag/304/不接受 gzip 四条新断言)。真实栈同链路复跑:`phaser.min.js` 1,375,976 B → 353,336 B(gzip,4 Mbps 下 2724 ms → 774 ms),用户端游戏画面 3298 ms → 1543 ms、后台试玩 4587 ms → 2694 ms。 - **关联**:`packages/shared/src/components/PlatformGameLoadingSurface.tsx`、`src/components/game-distribution/GamePlayPage.tsx`、`apps/admin-web/src/pages/AdminGameDistributionReviewPage.tsx`、`server-rs/crates/api-server/src/modules/game_distribution.rs`、`deploy/nginx/README.md`。 + +## 2026-10-07 cc 会话恢复只活在进程内存里,「离开项目再进入」就看不到历史对话 + +- **现象**:用户反馈「离开后再进入项目,cc 那边看不到历史对话」——聊天面板里的历史还在(`.agent/conversations/project.jsonl` 是 UI 的事实源),但模型完全不知道之前说过什么。 +- **根因**:`CLAUDE_DIRECT_SESSIONS` 是进程级 `Mutex>`,只负责把上一个回合的 `session_id` 交给 SDK 的 `resume`。用户离开项目再进入、重启客户端,或上一回合失败(失败路径根本不写这张表)之后,表里没有 id,cc 就以全新会话开工;而 SDK 自己的轨迹其实一直落在隔离 home 里:`<项目>/.agent/runtime/claude-code/home/claude/projects//.jsonl`。现场:项目 `gameagent-0514673b` 的隔离 home 里留下两份独立轨迹(14:52 与 15:05),第二份的首次用户输入只有一句「现在呢」;`gameagent-754f4779` 更累积到 8 份。 +- **现行口径**:恢复顺序固定为「进程内会话表 → 本项目目录下 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`。