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,