diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_approval.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_approval.rs index 9238c6d70..d25a6db2b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_approval.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_approval.rs @@ -1579,6 +1579,22 @@ fn plan_gdd_session_matches_receipt( }) } +// 这道门用 `PLAN_GDD_APPROVAL_SOURCE` 识别策划根,而随后的 +// `validate_project_supervisor_plan_root_binding_at` 用 `AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE` +// 复核同一条绑定。两个常量分别定义在 planning_storage 与 runtime_driver,值必须相同: +// 一旦分叉,每个策划根都会先通过识别、再被复核拒掉,全部塌成 needs-reconciliation, +// 而且没有任何测试会直接指向这个原因。钉成编译期条件。 +const _: () = { + let identified = PLAN_GDD_APPROVAL_SOURCE.as_bytes(); + let validated = AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE.as_bytes(); + assert!(identified.len() == validated.len()); + let mut index = 0; + while index < identified.len() { + assert!(identified[index] == validated[index]); + index += 1; + } +}; + fn plan_root_completion_identity_at( root: &Path, agent_id: &str, @@ -1587,8 +1603,15 @@ fn plan_root_completion_identity_at( if agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID || run_id.trim().is_empty() { return Ok(false); } + // 识别只看 run 自己那条绑定记录。链走版本会遍历并校验整条祖先链;把它排在识别 + // 前面,等于让任何祖先绑定的毛病都先变成 Err,而调用方会把 Err 一律翻成 + // needs-reconciliation——扣在一个下一行本来就会被判「不是策划根」的 run 头上,让它 + // 再也收束不了。被误伤的只可能是非策划 run:真正的策划根无父(下面 + // `validate_project_supervisor_plan_root_binding_at` 强制 parent 必须为空),链走 + // 对它本就是空转。两个入口对同一个 (agent, run) 返回的绑定值完全相同,差别仅在于 + // 是否顺带校验祖先链,所以识别判据一字未变。 let Some(binding) = - read_game_creator_agent_runtime_run_profile_binding(root, agent_id, run_id)? + read_game_creator_agent_runtime_run_profile_binding_once(root, agent_id, run_id.trim())? else { return Ok(false); }; @@ -1597,6 +1620,8 @@ fn plan_root_completion_identity_at( { return Ok(false); } + // 确认是策划根之后才走完整父链:严格度一点没降—— + // `validate_project_supervisor_plan_root_binding_at` 内部读的就是链走版本。 let binding = validate_project_supervisor_plan_root_binding_at(root, agent_id, run_id)?; let runtime = read_game_creator_agent_runtime_at(root, agent_id)?.state; if runtime.run_id != run_id @@ -2053,4 +2078,69 @@ mod tests { ); let _ = fs::remove_dir_all(root); } + + /// 祖先绑定坏掉不能让一个跟立项策划无关的 supervisor run 被判成「策划根身份不明」。 + /// + /// `plan_root_completion_identity_at` 读绑定走的是会遍历完整父链的入口,而识别 + /// Fast GDD 只看 run 自己那条记录的 source/profile。链走排在识别前面,于是任何 + /// 祖先绑定的问题都先变成 Err,再被完成门统一翻成 needs-reconciliation——扣在一个 + /// 下一行就会被判为「不是策划根」的 run 头上,让它再也收束不了。 + /// + /// 真正的策划根没有父(`validate_project_supervisor_plan_root_binding_at` 强制 + /// parent 必须为空),所以链走对它本来就是空转;会被链走误伤的只可能是非策划 run。 + #[test] + fn broken_ancestor_binding_must_not_make_a_non_plan_supervisor_run_a_plan_root_suspect() { + let root = std::env::temp_dir().join(format!( + "genarrative-plan-root-identity-{}", + uuid::Uuid::new_v4().simple() + )); + init_local_game_project_at(&root, "project-test-plan-root", "plan root identity") + .expect("init project"); + + let parent_run_id = "p4-isolated-join-parent"; + let child_run_id = "p4-isolated-join-child"; + bind_game_creator_agent_runtime_run_profile_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_STANDARD), + None, + ) + .expect("bind ancestor run"); + bind_game_creator_agent_runtime_run_profile_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + child_run_id, + AGENT_RUNTIME_ISOLATED_JOIN_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_STANDARD), + Some(&AgentRuntimeTaskLink { + parent_agent_id: Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string()), + parent_run_id: Some(parent_run_id.to_string()), + delegation_id: None, + }), + ) + .expect("bind isolated-join child run"); + + // 破坏祖先:子记录仍带着 parentBindingFingerprint,父绑定却没了,正好命中 + // 父链遍历里的「父绑定缺失」。子 run 自己那条记录始终是完好可读的。 + fs::remove_file(game_creator_agent_runtime_run_profile_binding_path( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + )) + .expect("remove ancestor binding"); + + let blocker = plan_gdd_typed_completion_blocker_at_locked( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + child_run_id, + ); + assert!( + blocker.is_none(), + "isolated-join run 的 source 不是立项策划,完成门不该拦它,更不该说它策划根身份不明:{:?}", + blocker.map(|blocker| blocker.kind) + ); + let _ = fs::remove_dir_all(root); + } }