From 1a2331bdb1ae6b23a7ca13d70c0ce5eceef91f10 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Tue, 15 Sep 2026 18:17:46 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=20direct-codex=20=E5=9B=9E?= =?UTF-8?q?=E5=90=88=E7=BB=88=E6=AD=A2=E4=B8=8E=E6=8E=A8=E7=90=86=E6=A1=A3?= =?UTF-8?q?=E5=86=99=E5=85=A5=E5=91=BD=E4=BB=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 cancel_direct_codex_turn:按项目登记正在运行的 direct-codex 回合句柄,终止时复用 CodexTurnStartCancellation 只发 turn/interrupt;无活动回合、clientTurnId 不匹配、进程已退出都返回可读原因。 新增 select_game_creator_reasoning_effort:按 select_game_creator_model 的既有配置写入模式,只改 llm.reasoningEffort 后落盘,读接口语义不变。 codex_app_server.rs 追加 DirectCodexActiveTurnTable 注册表与回合登记/注销 guard,并补 2 个单测(选中/注销哪一轮、注册键归一化);既有事件与命令语义不动。 --- .../src-tauri/src/agent.rs | 3 +- .../src-tauri/src/agent/codex_app_server.rs | 192 ++++++++++++++++++ .../src-tauri/src/commands.rs | 23 +++ .../src-tauri/src/main.rs | 2 + 4 files changed, 219 insertions(+), 1 deletion(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent.rs b/apps/ai-game-creator-shell/src-tauri/src/agent.rs index 62a2a0b0b..3689fa8f3 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -35,7 +35,8 @@ mod runtime_tools; mod skill_pack; use codex_app_server::*; pub(crate) use codex_app_server::{ - direct_game_creator_codex_chat_at, direct_game_creator_home_codex_chat, + cancel_direct_codex_turn_at, direct_game_creator_codex_chat_at, + direct_game_creator_home_codex_chat, }; use codex_cli::*; pub(crate) use codex_cli::{ diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs index e8d536ae1..656ec3373 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs @@ -216,6 +216,12 @@ impl CodexTurnStartCancellation { self.maybe_interrupt(); } + /// app-server 连接是否还活着:句柄只剩 Weak 时说明进程已被回收,此时"终止"必须 + /// 明确报错,而不是静默成功让界面以为回合已经停了。 + fn app_server_alive(&self) -> bool { + self.inner.strong_count() > 0 + } + fn cancel(&self) { self.cancelled.store(true, Ordering::Release); self.maybe_interrupt(); @@ -2752,6 +2758,17 @@ impl CodexAppServerConnection { } let turn_start_cancellation = Arc::new(CodexTurnStartCancellation::new(&self.inner, &thread_id)); + // Direct 回合登记为"可终止":终止命令只作用在这一轮上,回合结束时自动注销。 + let _active_turn_guard = direct_tool_call_turn_id + .as_deref() + .filter(|_| self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject) + .map(|turn_id| { + register_active_direct_codex_turn( + direct_codex_active_turn_key(history_root), + turn_id, + Arc::clone(&turn_start_cancellation), + ) + }); let mut turn_start_guard = CodexTurnStartGuard { cancellation: Arc::clone(&turn_start_cancellation), armed: true, @@ -3059,6 +3076,135 @@ impl Drop for CodexTurnStartGuard { } } +/// Direct 回合中断表:与具体取消句柄解耦的最小实现,"选哪一轮 / 注销哪一轮"可单测。 +struct DirectCodexActiveTurnTable { + entries: HashMap, +} + +impl DirectCodexActiveTurnTable { + fn new() -> Self { + Self { + entries: HashMap::new(), + } + } + + fn register(&mut self, key: std::path::PathBuf, client_turn_id: &str, value: T) { + self.entries + .insert(key, (client_turn_id.to_string(), value)); + } + + /// 只有当前登记项仍是本回合的句柄时才注销,避免旧回合的收尾清掉后来注册的回合。 + fn unregister(&mut self, key: &Path, is_same: impl Fn(&T) -> bool) { + if self + .entries + .get(key) + .is_some_and(|(_, value)| is_same(value)) + { + self.entries.remove(key); + } + } + + /// 选中要终止的回合:没有活动回合、或前端给的 clientTurnId 与活动回合不一致时都返回 + /// 可读原因,绝不误伤另一个回合。 + fn select(&self, key: &Path, client_turn_id: Option<&str>) -> Result<&(String, T), String> { + let active = self + .entries + .get(key) + .ok_or_else(|| "当前项目没有正在运行的陶泥儿回合,无法终止".to_string())?; + if let Some(expected) = client_turn_id + .map(str::trim) + .filter(|value| !value.is_empty()) + { + if active.0 != expected { + return Err("正在运行的是另一个陶泥儿回合,已拒绝终止".to_string()); + } + } + Ok(active) + } +} + +/// 正在运行的 Direct 回合中断句柄,按项目根(canonical,去掉 Windows `\\?\` 前缀)索引。 +/// +/// `CodexTurnStartCancellation` 本身已经能在 turn/start 响应到达**前后**发出 +/// `turn/interrupt`;这里只是把它留一个 Tauri 命令取得到的引用,回合结束后由 +/// [`DirectCodexActiveTurnGuard`] 移除。只做新增:不改既有事件、命令语义。 +static GAME_CREATOR_DIRECT_CODEX_ACTIVE_TURNS: OnceLock< + std::sync::Mutex>>, +> = OnceLock::new(); + +fn direct_codex_active_turns( +) -> &'static std::sync::Mutex>> { + GAME_CREATOR_DIRECT_CODEX_ACTIVE_TURNS + .get_or_init(|| std::sync::Mutex::new(DirectCodexActiveTurnTable::new())) +} + +/// 注册键:与 Direct 回合用的 `codex_root` 同一形态(canonical 且去掉 `\\?\` 前缀), +/// 这样前端传进来的项目路径与注册时的路径一定落到同一个键上。 +fn direct_codex_active_turn_key(root: &Path) -> std::path::PathBuf { + let canonical = std::fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf()); + match canonical + .to_str() + .and_then(|value| value.strip_prefix("\\\\?\\")) + { + Some(stripped) => std::path::PathBuf::from(stripped), + None => canonical, + } +} + +struct DirectCodexActiveTurnGuard { + key: std::path::PathBuf, + cancellation: Arc, +} + +impl Drop for DirectCodexActiveTurnGuard { + fn drop(&mut self) { + let Some(active_turns) = GAME_CREATOR_DIRECT_CODEX_ACTIVE_TURNS.get() else { + return; + }; + let Ok(mut entries) = active_turns.lock() else { + return; + }; + let cancellation = Arc::clone(&self.cancellation); + entries.unregister(&self.key, |current| Arc::ptr_eq(current, &cancellation)); + } +} + +/// 把一个 Direct 回合登记为"可终止",返回的 guard 在回合结束时注销它。 +fn register_active_direct_codex_turn( + key: std::path::PathBuf, + client_turn_id: &str, + cancellation: Arc, +) -> DirectCodexActiveTurnGuard { + if let Ok(mut entries) = direct_codex_active_turns().lock() { + entries.register(key.clone(), client_turn_id, Arc::clone(&cancellation)); + } + DirectCodexActiveTurnGuard { key, cancellation } +} + +/// 终止当前项目正在运行的 Direct 回合。 +/// +/// 只向正在跑的 Codex app-server 回合发 `turn/interrupt`(app-server 随后回 +/// `turn/completed status=interrupted`,正在 await 的那个回合命令会带着可读原因返回), +/// 不动任何既有事件或命令语义。 +pub(crate) fn cancel_direct_codex_turn_at( + root: &Path, + client_turn_id: Option<&str>, +) -> Result<(), String> { + let key = direct_codex_active_turn_key(root); + let cancellation = { + let entries = direct_codex_active_turns() + .lock() + .map_err(|_| "Direct 回合中断表已损坏,无法终止".to_string())?; + let (_, cancellation) = entries.select(&key, client_turn_id)?; + if !cancellation.app_server_alive() { + return Err("陶泥儿执行进程已退出,无法终止本轮;请重新发送这条消息".to_string()); + } + Arc::clone(cancellation) + }; + cancellation.cancel(); + Ok(()) +} + struct CodexThreadLease { connection: CodexAppServerConnection, key: CodexNodeThreadKey, @@ -4071,6 +4217,52 @@ pub(crate) fn build_direct_codex_history_prompt( mod tests { use super::*; + /// 终止只作用在"当前项目正在跑的那一轮"上:没有活动回合 / clientTurnId 不匹配都要 + /// 返回可读原因,不能误伤别人;注销也只注销本回合自己的句柄。 + #[test] + fn direct_codex_active_turn_table_selects_only_the_running_turn() { + let mut table: DirectCodexActiveTurnTable = DirectCodexActiveTurnTable::new(); + let key = std::path::PathBuf::from("C:/projects/direct-turn-demo"); + assert_eq!( + table.select(&key, None).expect_err("no active turn"), + "当前项目没有正在运行的陶泥儿回合,无法终止" + ); + + table.register(key.clone(), "turn-a", 1); + assert_eq!(table.select(&key, None).expect("active turn").0, "turn-a"); + assert_eq!(table.select(&key, Some("turn-a")).expect("same turn").1, 1); + assert_eq!( + table + .select(&key, Some("turn-b")) + .expect_err("another running turn"), + "正在运行的是另一个陶泥儿回合,已拒绝终止" + ); + + // 句柄已被后来的回合替换:旧回合收尾不得注销新回合。 + table.register(key.clone(), "turn-b", 2); + table.unregister(&key, |value| *value == 1); + assert_eq!(table.select(&key, None).expect("newer turn").0, "turn-b"); + table.unregister(&key, |value| *value == 2); + assert!(table.select(&key, None).is_err()); + } + + /// 注册键:前端传的项目路径与回合注册时的路径必须归一化成同一个键(Windows 上 + /// `canonicalize` 会带 `\\?\` 前缀,去掉后两边才相等)。 + #[test] + fn direct_codex_active_turn_key_normalizes_windows_prefix() { + let root = tempfile::tempdir().expect("temp dir"); + let canonical = std::fs::canonicalize(root.path()).expect("canonical root"); + let expected = canonical + .to_str() + .and_then(|value| value.strip_prefix("\\\\?\\")) + .map(std::path::PathBuf::from) + .unwrap_or(canonical); + let key = direct_codex_active_turn_key(root.path()); + assert_eq!(key, expected); + // 归一化后的键不再带 Windows 扩展长度前缀:前端传进来的普通路径才能命中同一个键。 + assert!(!key.to_string_lossy().starts_with("\\\\?\\")); + } + #[test] fn direct_item_activities_are_closed_safe_categories() { let allowed = [ diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index 4e28e1de7..e2608eb19 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -1989,6 +1989,29 @@ pub(crate) fn write_game_creator_app_config( persist_game_creator_app_config(config, overlays, false) } +#[tauri::command] +pub(crate) fn cancel_direct_codex_turn( + project_path: String, + client_turn_id: Option, +) -> Result<(), String> { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "agent.kill")?; + cancel_direct_codex_turn_at(root, client_turn_id.as_deref()) +} + +#[tauri::command] +pub(crate) fn select_game_creator_reasoning_effort( + effort: String, +) -> Result { + let _guard = GAME_CREATOR_CONFIG_WRITE_LOCK + .lock() + .map_err(|_| "配置写入锁不可用")?; + let effort = game_creator_llm_reasoning_effort_name(&effort, "llm.reasoningEffort")?; + let (mut config, overlays) = load_game_creator_app_config_for_write()?; + config.llm.reasoning_effort = effort; + persist_game_creator_app_config(config, overlays, false) +} + #[tauri::command] pub(crate) fn select_game_creator_model( model_id: String, diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index af5c97c77..7f1d60fdc 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -2632,6 +2632,8 @@ fn main() { chat_with_game_creator_role_agent, chat_with_game_creator_role_agent_stream, chat_with_game_creator_direct_codex, + cancel_direct_codex_turn, + select_game_creator_reasoning_effort, start_planning_session_v2, continue_planning_session_v2, decide_planning_artifact_v2,