diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/recovery_scan.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/recovery_scan.rs index 60b4ac18e..e8f816f9e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/recovery_scan.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/recovery_scan.rs @@ -2176,6 +2176,21 @@ mod plan_gdd_approval_wait_recovery_tests { mod plan_session_recovery_gate_tests { use super::*; + /// 这条用例原来伪造一个 `source=agent-background-task`、且**没有 Run Profile + /// 绑定**的策划任务。但 `agent_runtime_tool_policy_snapshot_for_run_at` 早在 + /// `M1A-2`(`09c7d7af8`)就按 agent 身份挡死了这种 run——那道门是刻意的 + /// fail-closed 收窄(放宽它,伪造的策划 run 就能拿到默认宽工具面),于是被告 + /// 根本造不出来。用例自 `M1C-2b` 写下起一天都没绿过,只是当时的定向门禁过滤器 + /// (`planning_clarification_*`)恰好匹配不到它的名字。 + /// + /// 重写时试过「事后删掉父绑定」这种更贴现实的破坏方式,实测走不到本门:读路径 + /// 的 tool policy 收容(`entrypoints.rs` 里 `Run Profile 工具策略需要人工核对`) + /// 会先把 run 打成 failed,`read_recoverable_...` 随即不再认它。也就是说**绑定 + /// 层的破坏根本到不了 session 门**——能到这里的只有「绑定完好、task record 自身 + /// 不满足 D11 契约」这一类持久不一致,这正是本门存在的理由。 + /// + /// 所以改成:绑定全部合法(创建门放行),但 task record 缺 `delegationId` + /// (裸 start 不带 task link 时就是这个形状)。合同一字未改。 #[test] fn planning_recovery_contains_session_identity_conflict_before_provider() { let temporary = @@ -2183,13 +2198,40 @@ mod plan_session_recovery_gate_tests { let root = temporary.path(); init_local_game_project_at(root, "plan-recovery-gate", "策划恢复门冲突收敛") .expect("init project"); + let parent_run_id = "plan-session-recovery-conflict-root"; + let child_run_id = "plan-session-recovery-conflict-run"; + 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 supervisor plan root"); + bind_game_creator_agent_runtime_run_profile_at( + root, + GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + child_run_id, + "agent-delegate", + 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: Some("plan-session-recovery-conflict-delegation".to_string()), + }), + ) + .expect("bind delegated planning child"); + // 裸 start 的 task link 取自已排队的 record,这里没有,于是 task record 上 + // 的 parentRunId/delegationId 是空的——绑定说这是委派子 run,task record 却 + // 讲不出自己属于哪次委派。 start_game_creator_agent_runtime_task_at( root, GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - "不具备 Supervisor 委派身份的伪造策划任务", - "plan-session-recovery-conflict-run", - "agent-background-task", - "准备执行伪造策划任务", + "缺少委派链的策划任务", + child_run_id, + "agent-delegate", + "准备执行策划任务", vec!["不得发起 Provider 请求".to_string()], ) .expect("persist recoverable planning task"); @@ -2202,14 +2244,24 @@ mod plan_session_recovery_gate_tests { .expect("planning task is returned as contained"); assert_eq!(contained.state.status, "failed"); assert_eq!(contained.state.phase, "needs-reconciliation"); - assert!(contained - .state - .error - .as_deref() - .is_some_and(|error| error.contains("PLAN_SOURCE_PROFILE_MISMATCH"))); + let contained_error = contained.state.error.as_deref().unwrap_or_default(); + assert!( + contained_error.contains("PLAN_SOURCE_PROFILE_MISMATCH"), + "身份冲突必须落在 planning coordinator 的类型化合同里,实际为:{contained_error}" + ); let public_audit = fs::read_to_string(root.join(".agent/agent.db")).unwrap_or_default(); - assert!(public_audit.contains("agent.runtime.plan.session_recovery.needs_reconciliation")); + let record_types = public_audit + .split("\"recordType\"") + .skip(1) + .filter_map(|chunk| chunk.split('"').nth(1).map(str::to_string)) + .collect::>(); + assert!( + public_audit.contains("agent.runtime.plan.session_recovery.needs_reconciliation"), + "必须由 session 恢复门收容,实际审计记录类型:{record_types:?};\ + currentAction={}", + contained.state.current_action + ); assert!( !public_audit.contains(AGENT_RUNTIME_PROVIDER_REQUEST_LIFECYCLE_RECORD_TYPE), "session identity 冲突必须在任何 Provider lifecycle 之前停止"