diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs index 64c696479..bc0c140c1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs @@ -1072,8 +1072,19 @@ pub(in crate::agent) fn direct_taonier_regeneration_workflow_requires_recovery_a pub(in crate::agent) fn recover_direct_taonier_regeneration_workflow_at( root: &Path, ) -> Result { + // 锁只在**真的需要恢复**时才取:正在跑的那一轮整包重生成会整轮持有 `direct-codex-art`,入队 + // 命令若无条件去取它,整包重生成期间每条消息都要先在 worker 上同步阻塞约 1 秒(`..._with_wait` + // 是 `std::thread::sleep`),然后被 `HostStateUnavailable` 拒掉——而队列本该收下它。 + // 判据本身是只读的:没有工作流 / 状态不需要恢复时,这条路一个字都不写。 + if !direct_taonier_regeneration_workflow_requires_recovery_at(root)? { + return Ok(false); + } let _executor_lock = acquire_game_creator_agent_runtime_task_lock_with_wait(root, "direct-codex-art")?; + // 判据以**锁下那一份**为准:两次读之间真的重生成可能已经推进,或者别的进程已经恢复完了。 + if !direct_taonier_regeneration_workflow_requires_recovery_at(root)? { + return Ok(false); + } let Some(workflow) = read_direct_taonier_regeneration_workflow_at(root)? else { return Ok(false); }; @@ -5632,6 +5643,85 @@ mod tests { } /// 没有任何活动回合时,兜底释放给出可读原因,而不是静默成功。 + /// 不需要恢复时**不取**整轮任务锁。 + /// + /// 正在跑的那一轮整包重生成会整轮持有 `direct-codex-art`;入队命令的第一步就是这里,若无条件 + /// 取锁,整包重生成期间每条消息都要先在 worker 上同步阻塞约 1 秒,再被 `HostStateUnavailable` + /// 拒掉——队列本该收下它。判据本身只读,不需要恢复就一个字节都不写、一把锁都不取。 + #[test] + fn recovery_skips_the_executor_task_lock_when_nothing_needs_recovery() { + let root = tempfile::tempdir().expect("recovery lock probe"); + init_local_game_project_at(root.path(), "direct-recovery-lock-probe", "恢复不取锁") + .expect("init project"); + register_direct_taonier_art_package_fixture(root.path()); + // 有人正持有这一轮的任务锁:等价于"整包重生成正在跑"。 + let held = + try_acquire_game_creator_agent_runtime_task_lock(root.path(), "direct-codex-art") + .expect("probe the art task lock") + .expect("hold the art task lock"); + + // ① 完全没有工作流:不需要恢复,不去取锁。 + assert!( + !recover_direct_taonier_regeneration_workflow_at(root.path()) + .expect("an absent workflow needs no recovery") + ); + + // ② 正在正常进行中的整包重生成(InProgress、没有待恢复的替换锚点):这正是"整包重生成在跑" + // 的那一刻——不需要恢复,更**不该去取**那把整轮持有的锁。 + let in_progress = { + let _project_lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root.path(), + "direct-codex.test-in-progress-lock-probe", + ) + .expect("project write lock"); + new_direct_taonier_regeneration_workflow_locked( + root.path(), + &direct_taonier_regeneration_intent_sha256("正在跑的一套美术"), + &direct_taonier_regeneration_invocation_sha256("client-turn-in-progress-probe"), + DirectTaonierRegenerationWorkflowState::InProgress, + ) + .expect("build in-progress workflow") + }; + write_direct_taonier_regeneration_workflow_at(root.path(), &in_progress) + .expect("persist the in-progress workflow"); + assert!( + !direct_taonier_regeneration_workflow_requires_recovery_at(root.path()) + .expect("an in-progress workflow is discoverable") + ); + assert!( + !recover_direct_taonier_regeneration_workflow_at(root.path()) + .expect("a running regeneration needs no recovery"), + "整包重生成在跑时入队不该去取整轮持有的锁" + ); + + // ③ 真需要恢复(Resetting)时仍然要取锁:拿不到就如实报错,绝不当成"恢复过了"。 + let resetting = DirectTaonierRegenerationWorkflow { + state: DirectTaonierRegenerationWorkflowState::Resetting, + ..in_progress + }; + write_direct_taonier_regeneration_workflow_at(root.path(), &resetting) + .expect("persist the resetting workflow"); + assert!( + direct_taonier_regeneration_workflow_requires_recovery_at(root.path()) + .expect("a resetting workflow is discoverable") + ); + let blocked = recover_direct_taonier_regeneration_workflow_at(root.path()) + .expect_err("a needed recovery must still take the task lock"); + assert!(blocked.contains("direct-codex-art"), "{blocked}"); + + // ④ 锁一放开照旧恢复。 + drop(held); + assert!(recover_direct_taonier_regeneration_workflow_at(root.path()) + .expect("resetting recovery resumes once the lock is free")); + assert_eq!( + read_direct_taonier_regeneration_workflow_at(root.path()) + .expect("read recovered workflow") + .expect("recovered workflow remains") + .state, + DirectTaonierRegenerationWorkflowState::InProgress + ); + } + #[test] fn stale_turn_release_without_an_active_turn_says_so() { let root = tempfile::tempdir().expect("empty turn root"); diff --git a/docs/technical/【实施计划】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md b/docs/technical/【实施计划】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md index 5a9d29642..0523b4965 100644 --- a/docs/technical/【实施计划】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md +++ b/docs/technical/【实施计划】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md @@ -67,6 +67,8 @@ **入队侧**:`clientTurnId` 校验 → 工作流恢复 → 用户条目校验 → prompt 投影 → 前置条件 → 容量预判 → 工程准备 → 入队; 任何一步失败返回 typed 入队失败。容量预判只是一次提前的快速失败,权威判据仍在入队临界区里(工程准备是分钟级、会 在磁盘留产物的活,满了就不该先做它)。**放行侧**(第 2 步)从占用登记起。 + 其中"工作流恢复"只在**真的需要恢复**时才去取整轮任务锁(`direct-codex-art`):正在跑的那一轮整包重生成整轮持有 + 它,入队无条件取锁会先同步阻塞约 1 秒再被拒(见 ADR §6"入队不取整轮持有的锁")。 - 队列条目(宿主侧产物,只在内存):`PendingDirectTurn { client_turn_id, user_item: Value, prompt: String, creation_type: Option, at: u64 }`。 `prompt` 与 `creation_type` 是入队检查的产物,放行不再重算;事件里**不带** `prompt`。 - `agent/direct_thread_manager.rs`: