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 82f852bea..d1fa67463 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 @@ -302,8 +302,14 @@ pub(crate) fn ensure_plan_gdd_approval_pending_after_acceptance_locked( if agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID || run_id.trim().is_empty() { return Ok(PlanGddAcceptanceGateOutcome::NotApplicable); } + // 先识别、后校验。`read_..._binding`(不带 `_once`)会遍历并校验整条祖先链, + // 把它排在识别前面,等于让任何祖先绑定的毛病都抢先变成 Err——连「这根本不是 + // 策划根」都来不及说,于是别人的坏链变成了这道门的失败。`agent.run_status` + // 每次都会重跑本门,带父链的非策划 supervisor run 因此会被误伤。P4 + // (`cb5af31e7`)已在同文件的 `plan_root_completion_identity_at` 修过同一形状, + // 当时漏了这个姊妹函数。识别只看 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(PlanGddAcceptanceGateOutcome::NotApplicable); }; @@ -313,6 +319,8 @@ pub(crate) fn ensure_plan_gdd_approval_pending_after_acceptance_locked( if binding.profile != AGENT_RUNTIME_RUN_PROFILE_STANDARD { return Err("Fast GDD acceptance gate 的 plan 根 Run Profile 已漂移".to_string()); } + // 确认是策划根之后才走完整父链:严格度一点没降,只是不再作用到别人身上。 + read_game_creator_agent_runtime_run_profile_binding(root, agent_id, run_id)?; let gdds = read_plan_gdd_chain_locked(root).map_err(|error| error.to_string())?; let Some(gdd) = latest_plan_gdd_for_root(&gdds, run_id) else { // A contract may be updated before the planning child has submitted a @@ -2167,4 +2175,68 @@ mod tests { ); let _ = fs::remove_dir_all(root); } + + /// 同一形状的第二处:验收前置门。它比完成门更容易被踩到——`agent.run_status` + /// 每次 status observation 都会重跑这道门(见 `runtime_tools/run_status.rs` 的 + /// 「Re-run the locked gate on every plan-root status observation」),而那条路径 + /// 上没有任何上游守卫先把带父链的 run 挡掉,适用性完全交给门自己判。所以门必须 + /// 先说得出「这不是策划根」,才轮到校验祖先链。 + #[test] + fn broken_ancestor_binding_must_not_fail_the_acceptance_gate_for_a_non_plan_supervisor_run() { + let root = std::env::temp_dir().join(format!( + "genarrative-plan-acceptance-gate-identity-{}", + uuid::Uuid::new_v4().simple() + )); + init_local_game_project_at( + &root, + "project-test-acceptance-gate", + "acceptance gate identity", + ) + .expect("init project"); + + let parent_run_id = "acceptance-gate-isolated-join-parent"; + let child_run_id = "acceptance-gate-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"); + + 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 outcome = ensure_plan_gdd_approval_pending_after_acceptance_locked( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + child_run_id, + ); + assert!( + matches!(outcome, Ok(PlanGddAcceptanceGateOutcome::NotApplicable)), + "isolated-join run 的 source 不是立项策划,验收前置门只能判 NotApplicable,\ + 不能因为别人的祖先链坏掉就让整个 agent.run_status 失败:{:?}", + outcome.map(|outcome| format!("{outcome:?}")) + ); + let _ = fs::remove_dir_all(root); + } }