From 48b482afafeee15409cc48391d20b82bcb7ed04e Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Tue, 15 Sep 2026 20:32:29 +0800 Subject: [PATCH 1/3] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=87=8D=E8=BF=9B?= =?UTF-8?q?=E4=BC=9A=E8=AF=9D=E6=97=A0=E6=B3=95=E6=81=A2=E5=A4=8D=E8=BF=90?= =?UTF-8?q?=E8=A1=8C=E4=B8=AD=E7=9A=84=20Direct=20=E5=9B=9E=E5=90=88?= =?UTF-8?q?=EF=BC=9ARust=20=E5=A2=9E=E5=8A=A0=E6=B4=BB=E8=B7=83=E5=9B=9E?= =?UTF-8?q?=E5=90=88=E5=8F=AA=E8=AF=BB=E6=8E=A2=E6=B5=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 read_direct_codex_active_turn 只读命令:返回当前登记的 { clientTurnId, startedAt },供前端重进会话时接管正在跑的回合 DirectTaonierActiveInvocation 增加 started_at_ms 登记时刻,供只读探测与后续兜底释放判断这一轮的年龄 不改守卫互斥语义与 Drop 释放时机:探测既不进入也不抢占 把命令注册进 main.rs 的 invoke_handler 新增用例:登记中的回合能被只读探测看到、探测本身不占有不释放、Drop 之后回到 None --- .../src-tauri/src/agent/direct_runtime.rs | 83 +++++++++++++++++++ .../src-tauri/src/main.rs | 1 + 2 files changed, 84 insertions(+) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs index 8ed0e2608..fdb024e53 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs @@ -263,12 +263,18 @@ fn direct_taonier_regeneration_invocation_sha256(invocation_id: &str) -> String #[derive(Debug)] struct DirectTaonierActiveInvocation { invocation_id: String, + /// 登记时刻(Unix 毫秒)。只读探测与"终止"兜底都用它判断这一轮的年龄。 + started_at_ms: u64, } static DIRECT_TAONIER_ACTIVE_INVOCATIONS: OnceLock< Mutex>, > = OnceLock::new(); +fn direct_taonier_active_now_millis() -> u64 { + unix_millis().min(u128::from(u64::MAX)) as u64 +} + #[derive(Debug)] pub(crate) struct DirectTaonierActiveInvocationGuard { root: PathBuf, @@ -300,6 +306,7 @@ impl DirectTaonierActiveInvocationGuard { root.clone(), DirectTaonierActiveInvocation { invocation_id: invocation_id.to_string(), + started_at_ms: direct_taonier_active_now_millis(), }, ); } @@ -345,6 +352,46 @@ pub(crate) fn direct_taonier_active_invocation_id_at(root: &Path) -> Result Result, String> { + let root = root + .canonicalize() + .map_err(|error| format!("无法锚定 Direct 调用项目目录:{error}"))?; + Ok(DIRECT_TAONIER_ACTIVE_INVOCATIONS + .get_or_init(|| Mutex::new(HashMap::new())) + .lock() + .map_err(|_| "Direct 调用身份锁已损坏".to_string())? + .get(&root) + .map(|active| DirectActiveTurnView { + client_turn_id: active.invocation_id.clone(), + started_at: active.started_at_ms, + })) +} + +/// 只读命令:读取该项目当前登记的 Direct 活跃回合,供前端重进会话时恢复。 +/// +/// 不进入、不抢占、不释放守卫;没有回合时返回 `null`。 +#[tauri::command] +pub(crate) fn read_direct_codex_active_turn( + project_path: String, +) -> Result, String> { + read_direct_taonier_active_invocation_at(Path::new(project_path.trim())) +} + fn direct_taonier_regeneration_project_id(root: &Path) -> Result { let project_id = read_manifest(&root.join(".agent/manifest.json"))? .project_id @@ -4771,6 +4818,42 @@ mod tests { .expect("lost-response replay after the original turn finishes"); } + #[test] + fn read_direct_active_turn_reports_the_registered_turn_and_disappears_after_drop() { + let root = tempfile::tempdir().expect("active invocation root"); + assert_eq!( + read_direct_taonier_active_invocation_at(root.path()).expect("read idle project"), + None + ); + + let first = DirectTaonierActiveInvocationGuard::enter(root.path(), "client-turn-read-1") + .expect("first client turn"); + let running = read_direct_taonier_active_invocation_at(root.path()) + .expect("read running project") + .expect("running turn is visible to the read-only probe"); + assert_eq!(running.client_turn_id, "client-turn-read-1"); + assert!(running.started_at > 0, "{running:?}"); + // camelCase 契约:前端按 `clientTurnId` / `startedAt` 取值。 + assert_eq!( + serde_json::to_value(&running).expect("serialize view"), + serde_json::json!({ + "clientTurnId": "client-turn-read-1", + "startedAt": running.started_at, + }) + ); + // 只读探测不占有、不释放:探测之后同项目第二次进入仍然被拒。 + let duplicate = + DirectTaonierActiveInvocationGuard::enter(root.path(), "client-turn-read-2") + .expect_err("read-only probe must not take over the project"); + assert!(!duplicate.starts_with(DIRECT_CODEX_TURN_ALREADY_RUNNING_PREFIX)); + + drop(first); + assert_eq!( + read_direct_taonier_active_invocation_at(root.path()).expect("read idle project"), + None + ); + } + #[test] fn direct_success_reply_is_persisted_once_with_the_stable_client_turn_identity() { let root = tempfile::tempdir().expect("temp dir"); 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 631e6d162..1ade8ccb3 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -2669,6 +2669,7 @@ fn main() { chat_with_game_creator_role_agent, chat_with_game_creator_role_agent_stream, chat_with_game_creator_direct_codex, + read_direct_codex_active_turn, cancel_direct_codex_turn, select_game_creator_reasoning_effort, start_planning_session_v2, From d833ca9d32ba344962257786f72b437afbaeeb75 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Tue, 15 Sep 2026 20:33:01 +0800 Subject: [PATCH 2/3] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=87=8D=E8=BF=9B?= =?UTF-8?q?=E4=BC=9A=E8=AF=9D=E8=A2=AB=E5=A0=B5=E6=AD=BB=EF=BC=9A=E7=BB=88?= =?UTF-8?q?=E6=AD=A2=E5=8F=AF=E5=85=9C=E5=BA=95=E9=87=8A=E6=94=BE=E6=AE=8B?= =?UTF-8?q?=E7=95=99=E5=AE=88=E5=8D=AB=EF=BC=8C=E5=B9=B6=E6=8A=8A=E6=8B=92?= =?UTF-8?q?=E7=BB=9D=E6=96=87=E6=A1=88=E6=94=B9=E6=88=90=E5=8F=AF=E6=93=8D?= =?UTF-8?q?=E4=BD=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cancel_direct_codex_turn 返回值改为 DirectTurnCancelView{outcome, message, clientTurnId},界面能区分"已发中断"与"守卫已被兜底释放" cancel_direct_codex_turn_at 在拿不到可中断的活句柄(执行进程已退出 / 从没进执行器)时释放本地守卫,返回可读原因;"正在跑的是另一轮"仍保持原拒绝语义,什么都不释放 新增 release_stale_direct_taonier_active_invocation:释放条件写进注释——登记存在、clientTurnId 一致、调用方已确认执行器没有活句柄、"从没进执行器"时登记年龄还要超过 60 秒启动窗口 DirectTaonierStaleGuardReason 给出两种兜底原因与可读文案;DirectCodexActiveTurnTable 增加 registered_client_turn_id,另一轮拒绝文案提成共用常量 把"已有另一条 Direct 客户端回合正在运行"补成可操作文案:可在输入盒点终止,或等它结束后再发送(不改错误分类与前缀语义) 新增用例:同项目第二次进入仍被拒、clientTurnId 不匹配不释放、启动窗口内不释放、老过窗口释放后可再次进入、原守卫 Drop 不影响后来登记的回合 --- .../src-tauri/src/agent.rs | 2 +- .../src-tauri/src/agent/codex_app_server.rs | 108 ++++++++++- .../src-tauri/src/agent/direct_runtime.rs | 175 +++++++++++++++++- .../src-tauri/src/commands.rs | 2 +- 4 files changed, 271 insertions(+), 16 deletions(-) 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 3689fa8f3..0c5645fee 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -36,7 +36,7 @@ mod skill_pack; use codex_app_server::*; pub(crate) use codex_app_server::{ cancel_direct_codex_turn_at, direct_game_creator_codex_chat_at, - direct_game_creator_home_codex_chat, + direct_game_creator_home_codex_chat, DirectTurnCancelView, }; 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 38969e686..8a2104a6f 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 @@ -3105,13 +3105,23 @@ impl DirectCodexActiveTurnTable { .filter(|value| !value.is_empty()) { if active.0 != expected { - return Err("正在运行的是另一个陶泥儿回合,已拒绝终止".to_string()); + return Err(DIRECT_CODEX_ANOTHER_TURN_RUNNING_MESSAGE.to_string()); } } Ok(active) } + + /// 当前登记在这一轮上的 clientTurnId;没有任何登记时返回 `None`。 + fn registered_client_turn_id(&self, key: &Path) -> Option<&str> { + self.entries + .get(key) + .map(|(client_turn_id, _)| client_turn_id.as_str()) + } } +/// "正在跑的是另一轮"的统一文案:`select` 与"终止"兜底路径共用,保证两处拒绝语义一致。 +const DIRECT_CODEX_ANOTHER_TURN_RUNNING_MESSAGE: &str = "正在运行的是另一个陶泥儿回合,已拒绝终止"; + /// 正在运行的 Direct 回合中断句柄,按项目根(canonical,去掉 Windows `\\?\` 前缀)索引。 /// /// `CodexTurnStartCancellation` 本身已经能在 turn/start 响应到达**前后**发出 @@ -3170,28 +3180,106 @@ fn register_active_direct_codex_turn( DirectCodexActiveTurnGuard { key, cancellation } } +/// 已向正在跑的回合发出中断:界面等这一轮自己的收尾复位。 +pub(crate) const DIRECT_TURN_CANCEL_OUTCOME_INTERRUPTED: &str = "interrupted"; +/// 这一轮已经没有人替它收尾,本地守卫已被兜底释放:界面必须自己复位。 +pub(crate) const DIRECT_TURN_CANCEL_OUTCOME_RELEASED: &str = "released"; + +/// `cancel_direct_codex_turn` 的返回值:界面据此决定是自己复位,还是等回合自己收尾。 +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct DirectTurnCancelView { + /// [`DIRECT_TURN_CANCEL_OUTCOME_INTERRUPTED`] 或 + /// [`DIRECT_TURN_CANCEL_OUTCOME_RELEASED`]。 + pub(crate) outcome: String, + /// 给用户看的可读结果。 + pub(crate) message: String, + /// 被终止 / 被释放的 clientTurnId。 + pub(crate) client_turn_id: String, +} + +/// "终止"这一步要作用在哪:发中断,还是走残留守卫兜底释放。 +enum DirectCodexTurnCancelTarget { + /// app-server 侧还有活句柄:正常发 `turn/interrupt`。 + Interrupt(Arc), + /// app-server 侧已经拿不到可中断的活句柄;带上是哪种情况。 + Stale(DirectTaonierStaleGuardReason), +} + /// 终止当前项目正在运行的 Direct 回合。 /// -/// 只向正在跑的 Codex app-server 回合发 `turn/interrupt`(app-server 随后回 +/// 正常路径:只向正在跑的 Codex app-server 回合发 `turn/interrupt`(app-server 随后回 /// `turn/completed status=interrupted`,正在 await 的那个回合命令会带着可读原因返回), /// 不动任何既有事件或命令语义。 +/// +/// 兜底路径:app-server 侧已经拿不到可中断的活句柄时,说明这一轮不会再有人替它收尾。 +/// 只发中断会让本地守卫(`DirectTaonierActiveInvocationGuard`)永远留在进程内,用户此后 +/// 每条消息都会被"已有另一条回合正在运行"拒绝——这正是"重进会话被堵死"的死锁形态。 +/// 这时显式释放这条守卫并把可读原因返回给界面。释放条件见 +/// [`release_stale_direct_taonier_active_invocation`] 的注释;"正在跑的是另一轮"仍然 +/// 保持原拒绝语义,什么都不释放。 pub(crate) fn cancel_direct_codex_turn_at( root: &Path, client_turn_id: Option<&str>, -) -> Result<(), String> { +) -> Result { let key = direct_codex_active_turn_key(root); - let cancellation = { + let expected = client_turn_id + .map(str::trim) + .filter(|value| !value.is_empty()); + { 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()); + if let (Some(registered), Some(expected)) = + (entries.registered_client_turn_id(&key), expected) + { + if registered != expected { + return Err(DIRECT_CODEX_ANOTHER_TURN_RUNNING_MESSAGE.to_string()); + } + } + } + let target = { + let entries = direct_codex_active_turns() + .lock() + .map_err(|_| "Direct 回合中断表已损坏,无法终止".to_string())?; + match entries.select(&key, client_turn_id) { + Ok((_, cancellation)) if cancellation.app_server_alive() => { + DirectCodexTurnCancelTarget::Interrupt(Arc::clone(cancellation)) + } + Ok(_) => { + DirectCodexTurnCancelTarget::Stale(DirectTaonierStaleGuardReason::ExecutorExited) + } + Err(_) => DirectCodexTurnCancelTarget::Stale( + DirectTaonierStaleGuardReason::NeverReachedExecutor, + ), } - Arc::clone(cancellation) }; - cancellation.cancel(); - Ok(()) + match target { + DirectCodexTurnCancelTarget::Interrupt(cancellation) => { + cancellation.cancel(); + Ok(DirectTurnCancelView { + outcome: DIRECT_TURN_CANCEL_OUTCOME_INTERRUPTED.to_string(), + message: "已向正在运行的回合发出终止".to_string(), + client_turn_id: client_turn_id + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or_default() + .to_string(), + }) + } + DirectCodexTurnCancelTarget::Stale(reason) => { + let released = + release_stale_direct_taonier_active_invocation(root, client_turn_id, reason)?; + Ok(DirectTurnCancelView { + outcome: DIRECT_TURN_CANCEL_OUTCOME_RELEASED.to_string(), + message: format!( + "{},已释放这一轮的占用,可以直接重新发送消息", + reason.message() + ), + client_turn_id: released, + }) + } + } } struct CodexThreadLease { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs index fdb024e53..381ce175a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs @@ -263,7 +263,9 @@ fn direct_taonier_regeneration_invocation_sha256(invocation_id: &str) -> String #[derive(Debug)] struct DirectTaonierActiveInvocation { invocation_id: String, - /// 登记时刻(Unix 毫秒)。只读探测与"终止"兜底都用它判断这一轮的年龄。 + /// 登记时刻(Unix 毫秒)。只用于判断一条登记的年龄:见 + /// [`DIRECT_TAONIER_STALE_GUARD_MIN_AGE_MS`] 与 + /// [`release_stale_direct_taonier_active_invocation`]。 started_at_ms: u64, } @@ -271,6 +273,12 @@ static DIRECT_TAONIER_ACTIVE_INVOCATIONS: OnceLock< Mutex>, > = OnceLock::new(); +/// 一条"app-server 侧完全没有登记"的守卫,只有在存在时间超过这个量级后才允许被 +/// "终止"兜底释放。一轮 Direct 回合在进入 app-server 之前只做本地准备(读配置、 +/// 读 manifest、拼系统提示、开审计),是秒级的;超过这个窗口还没登记,说明这一轮 +/// 不可能再进入执行器,守卫是残留。 +const DIRECT_TAONIER_STALE_GUARD_MIN_AGE_MS: u64 = 60_000; + fn direct_taonier_active_now_millis() -> u64 { unix_millis().min(u128::from(u64::MAX)) as u64 } @@ -297,8 +305,9 @@ impl DirectTaonierActiveInvocationGuard { "{DIRECT_CODEX_TURN_ALREADY_RUNNING_PREFIX} 当前 Direct 客户端回合仍在运行,已拒绝并发复用同一 clientTurnId" ) } else { - "当前项目已有另一条 Direct 客户端回合正在运行,已拒绝混用付费生成身份" - .to_string() + format!( + "当前项目已有另一条 Direct 客户端回合正在运行,已拒绝混用付费生成身份;可在输入盒点「终止」结束它,或等它结束后再发送" + ) }); } None => { @@ -364,7 +373,8 @@ pub(crate) struct DirectActiveTurnView { /// 只读探测:该项目当前登记的 Direct 活跃回合(`None` = 没有回合在跑)。 /// -/// 只读,不改变互斥语义与释放时机:守卫仍然只由回合自己的 `Drop` 释放。 +/// 只读,不改变互斥语义与释放时机:守卫仍然只由回合自己的 `Drop` 或 +/// [`release_stale_direct_taonier_active_invocation`] 释放。 pub(crate) fn read_direct_taonier_active_invocation_at( root: &Path, ) -> Result, String> { @@ -392,6 +402,73 @@ pub(crate) fn read_direct_codex_active_turn( read_direct_taonier_active_invocation_at(Path::new(project_path.trim())) } +/// "终止"拿不到可中断句柄时的分类,决定是否允许强制释放本地守卫。 +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum DirectTaonierStaleGuardReason { + /// app-server 侧登记着这一轮,但执行进程已经退出:这一轮不可能再有收尾。 + ExecutorExited, + /// app-server 侧完全没有这一轮的登记:只有过了正常启动窗口才允许释放。 + NeverReachedExecutor, +} + +impl DirectTaonierStaleGuardReason { + pub(crate) fn message(self) -> &'static str { + match self { + Self::ExecutorExited => "陶泥儿执行进程已退出", + Self::NeverReachedExecutor => "这一轮 Direct 回合没有进入执行器", + } + } +} + +/// 强制释放某项目登记的 Direct 活跃回合占用("终止"的兜底出口)。 +/// +/// 释放条件(四条必须同时成立,这段注释就是契约): +/// 1. 项目路径能 canonicalize,且守卫表里确实登记了这一轮; +/// 2. 传了 `expected_client_turn_id` 时必须与登记一致——绝不误伤另一条回合; +/// 3. 调用方已确认 app-server 侧没有可中断的活句柄,即 `reason` 成立; +/// 4. `reason == NeverReachedExecutor` 时,这条登记的年龄必须超过 +/// [`DIRECT_TAONIER_STALE_GUARD_MIN_AGE_MS`],排除"刚进入、还在本地准备阶段" +/// 的正常启动窗口——那种情况下这一轮马上就会去执行器,释放等于放开并发。 +/// +/// 移除后原守卫的 `Drop` 变成空操作(`invocation_id` 已不在表里),所以释放是幂等的; +/// 释放只影响"能否开始新回合",不动任何正在跑的回合事件。 +pub(crate) fn release_stale_direct_taonier_active_invocation( + root: &Path, + expected_client_turn_id: Option<&str>, + reason: DirectTaonierStaleGuardReason, +) -> Result { + let root = root + .canonicalize() + .map_err(|error| format!("无法锚定 Direct 调用项目目录:{error}"))?; + let mut active = DIRECT_TAONIER_ACTIVE_INVOCATIONS + .get_or_init(|| Mutex::new(HashMap::new())) + .lock() + .map_err(|_| "Direct 调用身份锁已损坏,无法释放".to_string())?; + let Some(existing) = active.get(&root) else { + return Err("当前项目没有正在运行的陶泥儿回合,无法终止".to_string()); + }; + if let Some(expected) = expected_client_turn_id + .map(str::trim) + .filter(|value| !value.is_empty()) + { + if existing.invocation_id != expected { + return Err("正在运行的是另一条 Direct 客户端回合,已拒绝终止".to_string()); + } + } + if reason == DirectTaonierStaleGuardReason::NeverReachedExecutor { + let age_ms = direct_taonier_active_now_millis().saturating_sub(existing.started_at_ms); + if age_ms < DIRECT_TAONIER_STALE_GUARD_MIN_AGE_MS { + return Err(format!( + "这一轮 Direct 客户端回合刚开始 {} 秒、还在准备中,暂不能强制释放;请稍后再试", + age_ms / 1000 + )); + } + } + let released = existing.invocation_id.clone(); + active.remove(&root); + Ok(released) +} + fn direct_taonier_regeneration_project_id(root: &Path) -> Result { let project_id = read_manifest(&root.join(".agent/manifest.json"))? .project_id @@ -4854,6 +4931,96 @@ mod tests { ); } + #[test] + fn stale_guard_release_requires_a_matching_identity_and_only_after_the_start_window() { + let root = tempfile::tempdir().expect("active invocation root"); + let first = DirectTaonierActiveInvocationGuard::enter(root.path(), "client-turn-stale-1") + .expect("first client turn"); + + // ① clientTurnId 不匹配:拒绝,且不误伤正在跑的那一轮。 + let mismatch = release_stale_direct_taonier_active_invocation( + root.path(), + Some("client-turn-stale-2"), + DirectTaonierStaleGuardReason::ExecutorExited, + ) + .expect_err("another turn must not be released"); + assert!(mismatch.contains("另一条"), "{mismatch}"); + assert!( + DirectTaonierActiveInvocationGuard::enter(root.path(), "client-turn-stale-2").is_err() + ); + + // ② 刚登记、还没进执行器:正常启动窗口内不许释放(释放等于放开并发)。 + let young = release_stale_direct_taonier_active_invocation( + root.path(), + Some("client-turn-stale-1"), + DirectTaonierStaleGuardReason::NeverReachedExecutor, + ) + .expect_err("a freshly registered turn is still starting"); + assert!(young.contains("暂不能强制释放"), "{young}"); + + // ③ 同一条登记老过窗口:判定为残留守卫,释放后同项目可以再次进入。 + backdate_active_direct_invocation(root.path(), DIRECT_TAONIER_STALE_GUARD_MIN_AGE_MS + 1); + let released = release_stale_direct_taonier_active_invocation( + root.path(), + Some("client-turn-stale-1"), + DirectTaonierStaleGuardReason::NeverReachedExecutor, + ) + .expect("stale guard is released"); + assert_eq!(released, "client-turn-stale-1"); + let second = DirectTaonierActiveInvocationGuard::enter(root.path(), "client-turn-stale-2") + .expect("a new turn can start once the stale guard is released"); + // 释放是幂等的:原 guard 的 Drop 不会影响后来登记的那一轮。 + drop(first); + let still_running = read_direct_taonier_active_invocation_at(root.path()) + .expect("read running project") + .expect("the newer turn survives the stale guard drop"); + assert_eq!(still_running.client_turn_id, "client-turn-stale-2"); + drop(second); + } + + #[test] + fn stale_guard_release_after_the_executor_exited_frees_the_project() { + let root = tempfile::tempdir().expect("active invocation root"); + let first = DirectTaonierActiveInvocationGuard::enter(root.path(), "client-turn-exited-1") + .expect("client turn"); + let released = release_stale_direct_taonier_active_invocation( + root.path(), + None, + DirectTaonierStaleGuardReason::ExecutorExited, + ) + .expect("executor exited: this guard is residue"); + assert_eq!(released, "client-turn-exited-1"); + assert_eq!( + read_direct_taonier_active_invocation_at(root.path()).expect("read idle project"), + None + ); + let _second = + DirectTaonierActiveInvocationGuard::enter(root.path(), "client-turn-exited-2") + .expect("a new turn can start after the residue is released"); + drop(first); + + // 没有任何登记时给出可读原因,而不是静默成功。 + let empty = tempfile::tempdir().expect("empty invocation root"); + let nothing = release_stale_direct_taonier_active_invocation( + empty.path(), + None, + DirectTaonierStaleGuardReason::ExecutorExited, + ) + .expect_err("nothing to release"); + assert!(nothing.contains("没有正在运行"), "{nothing}"); + } + + /// 把某项目当前登记的活跃回合往前拨 `age_ms`,用于覆盖"守卫年龄"分支。 + fn backdate_active_direct_invocation(root: &Path, age_ms: u64) { + let root = root.canonicalize().expect("canonical root"); + let mut active = DIRECT_TAONIER_ACTIVE_INVOCATIONS + .get_or_init(|| Mutex::new(HashMap::new())) + .lock() + .expect("active invocation lock"); + let entry = active.get_mut(&root).expect("registered invocation"); + entry.started_at_ms = entry.started_at_ms.saturating_sub(age_ms); + } + #[test] fn direct_success_reply_is_persisted_once_with_the_stable_client_turn_identity() { let root = tempfile::tempdir().expect("temp dir"); 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 09fafbb2a..026fb00d9 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -1997,7 +1997,7 @@ pub(crate) fn write_game_creator_app_config( pub(crate) fn cancel_direct_codex_turn( project_path: String, client_turn_id: Option, -) -> Result<(), String> { +) -> Result { 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()) From e788bf0feac86c5591b26fe953b064d8271badf9 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Tue, 15 Sep 2026 20:33:25 +0800 Subject: [PATCH 3/3] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=87=8D=E8=BF=9B?= =?UTF-8?q?=E4=BC=9A=E8=AF=9D=E8=A2=AB=E5=A0=B5=E6=AD=BB=EF=BC=9A=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=E6=89=93=E5=BC=80=E9=A1=B9=E7=9B=AE=E6=97=B6=E6=8E=A5?= =?UTF-8?q?=E7=AE=A1=E4=BB=8D=E5=9C=A8=E8=BF=90=E8=A1=8C=E7=9A=84=20Direct?= =?UTF-8?q?=20=E5=9B=9E=E5=90=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 打开项目读项目对话之后调用 read_direct_codex_active_turn,返回回合时把它设回 activeDirectCodexTurnRef(字段与既有两处赋值同形),并置 directCodexStatus=running、chatAgentBusy=true,让过程卡、"任务执行中"与输入盒的终止按钮都出现 只读探测失败或没有回合等于没有回合,不影响打开项目;同一项目已接管时不重复接管,避免 /history 之类重读把 lastSequence 归零 恢复出来的回合挂 15 秒看门狗:窗口内一条本回合事件都没有,就在过程卡与输入盒提示"该回合已无响应,可在输入盒点「终止」结束它以继续";收到真实事件立刻撤掉提示与看门狗 终止按 cancel_direct_codex_turn 的返回值分流:outcome=released 说明守卫已被后端兜底释放、没有回合 promise 会回来复位,界面自己复位并展示后端可读原因;interrupted 仍等回合自身收尾 发消息若仍被"已有另一条 Direct 客户端回合正在运行"拒绝,延时接管那条回合并把出口写进提示(不与同 clientTurnId 的既有分类混用,新增独立判定) 新增 DirectActiveTurnView / DirectTurnCancelView 两个命令返回类型 --- apps/ai-game-creator-shell/src/App.tsx | 190 +++++++++++++++++++- apps/ai-game-creator-shell/src/app/types.ts | 26 +++ 2 files changed, 210 insertions(+), 6 deletions(-) diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index a65717054..d08172319 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -55,6 +55,8 @@ import type { DesignClarificationRequest, DesignEvent, DesignView, + DirectActiveTurnView, + DirectTurnCancelView, GameCreatorAgentRuntimeUpdateEvent, GameCreatorChatAgentReply, GameCreatorDirectToolCall, @@ -297,6 +299,18 @@ const DIRECT_CODEX_PRODUCT_RUNTIME = true; const DIRECT_CODEX_CONVERSATION_MESSAGE_ID_PREFIX = 'direct-codex:'; const DIRECT_CODEX_TURN_ALREADY_RUNNING_ERROR_PREFIX = 'direct-codex-turn-already-running:'; +/** 与 Rust 侧 `DirectTaonierActiveInvocationGuard::enter` 的 else 分支文案保持一致。 */ +const DIRECT_CODEX_ANOTHER_TURN_RUNNING_ERROR_MARKER = + '当前项目已有另一条 Direct 客户端回合正在运行'; +/** + * 恢复出来的回合多久没有任何事件就算"没响应"。Rust 守卫是进程内的:重进会话时它还在, + * 但 app-server 侧可能早就没了。这时界面必须给出明确动作,而不是让用户一直等。 + */ +const DIRECT_CODEX_RECOVERED_TURN_STALLED_MS = 15_000; +const DIRECT_CODEX_RECOVERED_TURN_STALLED_NOTICE = + '该回合已无响应,可在输入盒点「终止」结束它以继续'; +const DIRECT_CODEX_RECOVERED_TURN_STARTED_DETAIL = + '已恢复正在运行的回合,正在等待陶泥儿的最新进度'; function isDirectCodexAuthenticationRequired(error: unknown) { const message = error instanceof Error ? error.message : String(error); @@ -492,6 +506,16 @@ export function isDirectCodexTurnAlreadyRunningError(error: unknown) { .startsWith(DIRECT_CODEX_TURN_ALREADY_RUNNING_ERROR_PREFIX); } +/** + * 另一条 Direct 回合占着这个项目时的拒绝。它与上面那条同 clientTurnId 的拒绝分属不同 + * 错误分类(Rust 侧刻意不带前缀),但对界面是同一件事:本项目现在有一条我们没接管的 + * 回合在跑。所以这里单独判定,让它也走"接管它 + 告诉用户出口"的处理。 + */ +export function isDirectCodexAnotherTurnRunningError(error: unknown) { + const message = error instanceof Error ? error.message : String(error); + return message.includes(DIRECT_CODEX_ANOTHER_TURN_RUNNING_ERROR_MARKER); +} + /** * 用户点了"终止"以后,正在 await 的回合命令会带着 app-server 的中断原因返回 * (`Codex app-server turn 已中断`)。这类错误是用户主动取消,不是失败:界面要给 @@ -827,6 +851,15 @@ export function App({ receivedDirectUpdate: boolean; } | null>(null); const lastDirectCodexActivityRef = useRef(null); + /** + * 重进会话后从 Rust 恢复出来的回合:只有在恢复后的第一个窗口内一直收不到事件, + * 才判定"这一轮其实已经没响应",给出终止出口。收到任何一条本回合事件就撤掉。 + */ + const recoveredDirectCodexTurnRef = useRef<{ + projectPath: string; + turnId: string; + } | null>(null); + const recoveredDirectCodexTurnTimerRef = useRef(null); const directCodexConversationTurnSequenceRef = useRef(0); // 工具调用卡片:按 **id** 归并(实时增量 + 回读历史共用一份),同一 id 只渲染一次。 // 用 ref 做写入基准,避免同一批事件里多条增量互相覆盖。 @@ -899,6 +932,7 @@ export function App({ } function resetDirectCodexTurn() { + clearRecoveredDirectCodexTurnWatch(); activeDirectCodexTurnRef.current = null; lastDirectCodexActivityRef.current = null; setDirectCodexProgress(''); @@ -910,6 +944,106 @@ export function App({ setDirectCodexTransientReplyUpdatedAt(null); } + /** 撤掉"恢复出来的回合没响应"的看门狗;回合正常结束、被终止、或收到事件时都要撤。 */ + function clearRecoveredDirectCodexTurnWatch() { + if (recoveredDirectCodexTurnTimerRef.current !== null) { + window.clearTimeout(recoveredDirectCodexTurnTimerRef.current); + recoveredDirectCodexTurnTimerRef.current = null; + } + recoveredDirectCodexTurnRef.current = null; + } + + /** + * 给恢复出来的回合挂一个看门狗:一个窗口内没有任何本回合事件,就说明 app-server 侧 + * 其实已经没了、Rust 守卫是残留。这时把可读动作放到过程卡与输入盒提示上,用户点 + * 「终止」会走 `cancel_direct_codex_turn` 的兜底释放(见 handleCancelDirectCodexTurn)。 + * 收到任何一条本回合事件就由调用方撤掉它,绝不会覆盖真实的进度文案。 + */ + function watchRecoveredDirectCodexTurn(projectPath: string, turnId: string) { + clearRecoveredDirectCodexTurnWatch(); + recoveredDirectCodexTurnRef.current = { projectPath, turnId }; + recoveredDirectCodexTurnTimerRef.current = window.setTimeout(() => { + recoveredDirectCodexTurnTimerRef.current = null; + const watch = recoveredDirectCodexTurnRef.current; + const activeTurn = activeDirectCodexTurnRef.current; + if ( + !watch || + watch.projectPath !== projectPath || + watch.turnId !== turnId || + activeTurn?.projectPath !== projectPath || + activeTurn.turnId !== turnId || + activeTurn.receivedDirectUpdate + ) { + return; + } + setDirectCodexStatus('running'); + setDirectCodexProgress(DIRECT_CODEX_RECOVERED_TURN_STALLED_NOTICE); + setDirectCodexProgressUpdatedAt(Date.now()); + setChatComposerNotice(DIRECT_CODEX_RECOVERED_TURN_STALLED_NOTICE); + }, DIRECT_CODEX_RECOVERED_TURN_STALLED_MS); + } + + /** + * 重进会话时接管仍在运行的 Direct 回合。 + * + * 背景:活跃回合的守卫(`DirectTaonierActiveInvocationGuard`)是 Rust 进程内的,重开 + * 项目时前端 `activeDirectCodexTurnRef` 是空的——界面既不订阅这一轮的事件,也不显示 + * 过程卡,用户再发消息只会被守卫拒绝。这里把后端登记的回合读回来重新接管。 + * + * 只读探测,不改后端回合本身;探测失败(老二进制没有这个命令、路径读不到)等于没有 + * 回合,不影响打开项目。 + */ + async function restoreRunningDirectCodexTurn(projectPath: string) { + if (!directCodexProductRuntime || !projectPath) { + return; + } + const invoke = resolveTauriInvoke(); + if (!invoke) { + return; + } + // 本组件已经接管这个项目:`/history` 之类的重复读取不能把 lastSequence 归零。 + if (activeDirectCodexTurnRef.current?.projectPath === projectPath) { + return; + } + let activeView: DirectActiveTurnView | null = null; + try { + activeView = await invoke( + 'read_direct_codex_active_turn', + { projectPath }, + ); + } catch { + return; + } + const clientTurnId = activeView?.clientTurnId?.trim(); + if (!clientTurnId) { + return; + } + if ( + localProjectPathRef.current !== projectPath || + activeDirectCodexTurnRef.current?.projectPath === projectPath + ) { + return; + } + activeDirectCodexTurnRef.current = { + projectPath, + turnId: clientTurnId, + // 与下面发起回合的两处赋值同形(`lastSequence: -1`):Rust 侧 emitter 的 sequence + // 从 1 开始,所以恢复后到达的第一批事件不会被 sequence 过滤丢掉。 + lastSequence: -1, + receivedDirectUpdate: false, + }; + setChatAgentBusy(true); + setDirectCodexStatus('running'); + setDirectCodexProcessKey(`${projectPath}\u0000${clientTurnId}`); + setDirectCodexProgress(DIRECT_CODEX_RECOVERED_TURN_STARTED_DETAIL); + setDirectCodexProgressUpdatedAt(Date.now()); + setDirectCodexTransientReply(''); + directCodexTransientReplyRef.current = ''; + setDirectCodexTransientReplyUpdatedAt(null); + setProjectSupervisorRuntimeError(''); + watchRecoveredDirectCodexTurn(projectPath, clientTurnId); + } + /** 工具调用卡片按项目维度作废:换项目 / 重开历史时整体替换,避免串项目。 */ function replaceDirectToolCalls(next: readonly GameCreatorDirectToolCall[]) { const normalized = next @@ -1959,6 +2093,19 @@ export function App({ } activeTurn.lastSequence = payload.sequence; activeTurn.receivedDirectUpdate = true; + // 恢复出来的回合只要回来一条真实事件,就不再是"没响应",撤掉看门狗与那句提示。 + if ( + recoveredDirectCodexTurnRef.current?.projectPath === + payload.projectPath && + recoveredDirectCodexTurnRef.current.turnId === payload.turnId + ) { + clearRecoveredDirectCodexTurnWatch(); + setChatComposerNotice((current) => + current === DIRECT_CODEX_RECOVERED_TURN_STALLED_NOTICE + ? '' + : current, + ); + } // 工具调用增量:字段可选,老事件(undefined)走原路径,行为不变。 if (payload.toolCalls?.length) { applyDirectToolCalls( @@ -3631,6 +3778,9 @@ export function App({ { projectPath: nextProjectPath }, ).catch(() => []); replaceDirectToolCalls(persistedToolCalls); + // 重进会话时 Rust 侧可能仍登记着上一条 Direct 回合。不接管的话界面既不显示 + // 过程卡也不给终止入口,用户再发消息只会被守卫拒绝("已有另一条回合正在运行")。 + await restoreRunningDirectCodexTurn(nextProjectPath); } let supervisorConversation: LocalConversationResult | null = null; let runtime: AgentRuntimeState | null = null; @@ -6658,12 +6808,21 @@ export function App({ await refreshDirectProjectManifest(directProjectPath); } } catch (error) { - if (isDirectCodexTurnAlreadyRunningError(error)) { + if ( + isDirectCodexTurnAlreadyRunningError(error) || + isDirectCodexAnotherTurnRunningError(error) + ) { if (localProjectPathRef.current === directProjectPath) { clearDirectCodexTransientReply(directProjectPath, clientTurnId); setProjectSupervisorRuntimeError( - '陶泥儿仍在处理这条消息,请稍候刷新对话。', + '陶泥儿仍在处理上一条消息,可在输入盒点「终止」结束它,或等它结束后再发送。', ); + // 兜底:出现这条拒绝说明本项目确实有回合在跑,而本组件此前没接管它 + // (重进会话的漏网情况)。放到当前任务之后再接管,避开本回合 finally + // 里 setChatAgentBusy(false) 的复位竞态。 + window.setTimeout(() => { + void restoreRunningDirectCodexTurn(directProjectPath); + }, 0); } return; } @@ -12112,11 +12271,30 @@ export function App({ setDirectCodexTurnCancelling(true); setChatComposerNotice('正在终止当前回合'); try { - await invoke('cancel_direct_codex_turn', { - projectPath: directProjectPath, - clientTurnId: activeTurn.turnId, - }); + const result = await invoke( + 'cancel_direct_codex_turn', + { + projectPath: directProjectPath, + clientTurnId: activeTurn.turnId, + }, + ); + const message = result?.message?.trim(); + if (result?.outcome === 'released') { + // 这一轮已经没有人替它收尾(执行进程已退出 / 从没进执行器),Rust 侧已强制释放 + // 守卫。没有会 return 的回合 promise 来复位界面,这里必须自己复位,否则过程卡 + // 与"任务执行中"会一直挂着,用户仍然发不出消息。 + resetDirectCodexTurn(); + setChatAgentBusy(false); + setProjectSupervisorRuntimeError(''); + setChatComposerNotice( + message ?? '已结束这一轮占用,可以直接重新发送消息', + ); + return; + } setDirectCodexProgress('正在终止当前回合'); + if (message) { + setChatComposerNotice(message); + } } catch (error) { const message = error instanceof Error ? error.message : String(error); setProjectSupervisorRuntimeError(`终止失败:${message}`); diff --git a/apps/ai-game-creator-shell/src/app/types.ts b/apps/ai-game-creator-shell/src/app/types.ts index cb5965504..6661b0649 100644 --- a/apps/ai-game-creator-shell/src/app/types.ts +++ b/apps/ai-game-creator-shell/src/app/types.ts @@ -1162,6 +1162,32 @@ export interface GameCreatorDirectTurnUpdateEvent { updatedAt: number; } +/** + * `read_direct_codex_active_turn` 的返回值:Rust 进程内当前登记的 Direct 活跃回合。 + * + * 重进会话时用它把前端的"当前活跃回合"接管回来,否则界面不知道有回合在跑, + * 既不显示过程卡也不给终止入口,用户再发消息只会被守卫拒绝。 + */ +export interface DirectActiveTurnView { + /** 与回合事件的 `turnId` 同一个身份。 */ + clientTurnId: string; + /** 这一轮登记的时刻(Unix 毫秒)。 */ + startedAt: number; +} + +/** `cancel_direct_codex_turn` 的返回值。 */ +export interface DirectTurnCancelView { + /** + * `interrupted` = 已向正在跑的回合发出中断,界面等这一轮自己的收尾复位; + * `released` = app-server 侧已无句柄,本轮守卫被兜底释放,界面必须自己复位。 + */ + outcome: string; + /** 给用户看的可读结果。 */ + message: string; + /** 被终止 / 被释放的 clientTurnId。 */ + clientTurnId: string; +} + export interface AgentRunControlResult { runId: string; status: string;