From 48b482afafeee15409cc48391d20b82bcb7ed04e Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Tue, 15 Sep 2026 20:32:29 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=87=8D=E8=BF=9B=E4=BC=9A?= =?UTF-8?q?=E8=AF=9D=E6=97=A0=E6=B3=95=E6=81=A2=E5=A4=8D=E8=BF=90=E8=A1=8C?= =?UTF-8?q?=E4=B8=AD=E7=9A=84=20Direct=20=E5=9B=9E=E5=90=88=EF=BC=9ARust?= =?UTF-8?q?=20=E5=A2=9E=E5=8A=A0=E6=B4=BB=E8=B7=83=E5=9B=9E=E5=90=88?= =?UTF-8?q?=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,