diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs index a1657d25d..acd19576d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs @@ -1,5 +1,86 @@ use super::*; +struct PlanGddBlockerRuntimeProjection { + phase: &'static str, + current_action: &'static str, + waiting_on: &'static str, + next_step: &'static str, +} + +/// `runtime.plan_gdd` blocker 的类型化子状态 → 运行时投影。 +/// +/// 抽成纯函数是为了让这条分支可测:主循环整个文件此前没有 `mod tests`,而原来的 +/// 判别是对 detail 做 `contains("approvalPending=awaiting_decision")`——三个 blocked +/// 子状态里只有一个含这个子串,另外两个会掉进 else 被打成 needs-reconciliation, +/// 把最正常的早期推进态和收尾态当成故障停掉。判不出 kind 时保持 fail-closed。 +fn plan_gdd_blocker_runtime_projection( + kind: Option, +) -> PlanGddBlockerRuntimeProjection { + match kind { + Some(PlanGddCompletionBlockerKind::SubmissionNotStarted) => { + PlanGddBlockerRuntimeProjection { + phase: "planning", + current_action: "推进本根 Run 的 Fast GDD 提交", + waiting_on: "策划子 Agent 完成本根 Run 的 plan.submit_gdd", + next_step: "调用 agent.delegate 派出策划子 Agent;上一根 Run 遗留的 game/fast_gdd.md 或 Acceptance Graph 不能代替本根提交", + } + } + Some(PlanGddCompletionBlockerKind::AwaitingApprovalDecision) => { + PlanGddBlockerRuntimeProjection { + phase: "waiting-for-user-input", + current_action: "等待 Fast GDD 审批决定", + waiting_on: "用户在审批卡选择批准、修改或退回", + next_step: "等待 decide_game_creator_plan_gdd;不得重新提交同一 GDD 或自行创建审批 pending", + } + } + Some(PlanGddCompletionBlockerKind::ReceiptAnchorCleanupPending) => { + PlanGddBlockerRuntimeProjection { + phase: "planning", + current_action: "等待 Fast GDD 审批投影收尾", + waiting_on: "原 plan.submit_gdd 恢复锚点由审批投影清理", + next_step: "等待审批投影恢复清理锚点后继续;不得重新提交同一 GDD 或自行创建审批 pending", + } + } + Some(PlanGddCompletionBlockerKind::NeedsReconciliation) | None => { + PlanGddBlockerRuntimeProjection { + phase: "needs-reconciliation", + current_action: "Fast GDD 审批投影需要人工核对", + waiting_on: + "planning pending、receipt、原提交锚点、terminal observation、audit 与 session 的精确身份", + next_step: "先恢复或核对现有 durable 事实,不能请求新 Provider 计划", + } + } + } +} + +/// 只有「等用户决定」和「要人工核对」才终结本轮后台任务;尚未提交与锚点收尾都是 +/// 继续推进态,和 `runtime.plan_update` 一样不产生等待态,让本轮循环继续。 +fn plan_gdd_blocker_waiting_kind( + kind: Option, +) -> Option<( + &'static str, + &'static str, + &'static str, + AgentBackgroundTaskOutcome, +)> { + match kind { + Some(PlanGddCompletionBlockerKind::AwaitingApprovalDecision) => Some(( + "agent.runtime.plan.gdd.waiting", + "waiting-for-user-input", + "Fast GDD 审批等待状态持久化失败", + AgentBackgroundTaskOutcome::WaitingForUserInput, + )), + Some(PlanGddCompletionBlockerKind::NeedsReconciliation) | None => Some(( + "agent.runtime.plan.gdd.reconciliation", + "needs-reconciliation", + "Fast GDD 审批投影需要人工核对", + AgentBackgroundTaskOutcome::NeedsReconciliation, + )), + Some(PlanGddCompletionBlockerKind::SubmissionNotStarted) + | Some(PlanGddCompletionBlockerKind::ReceiptAnchorCleanupPending) => None, + } +} + pub(in crate::agent) fn autonomous_registered_derived_visuals_need_repair_at(root: &Path) -> bool { let Ok(manifest) = read_manifest_for_project(root) else { return false; @@ -1889,6 +1970,9 @@ async fn run_game_creator_agent_background_task_pass_without_deadline( } if plan.actions.is_empty() { + // blocked 的 plan_gdd blocker 有三种截然不同的继续推进态,phase 与 + // next_step 必须按类型化子状态选,不能回去猜 detail 字符串。 + let mut plan_gdd_blocker_kind: Option = None; let completion_blocker = structured_plan_completion_blocker(&runtime) .or_else(|| { provider_action_batch_completion_blocker_at_locked( @@ -1898,7 +1982,11 @@ async fn run_game_creator_agent_background_task_pass_without_deadline( ) }) .or_else(|| { - plan_gdd_completion_blocker_at_locked(&root, &agent_id, &runtime.run_id) + plan_gdd_typed_completion_blocker_at_locked(&root, &agent_id, &runtime.run_id) + .map(|blocker| { + plan_gdd_blocker_kind = Some(blocker.kind); + blocker.observation + }) }) .or_else(|| game_creator_agent_goal_completion_blocker_at_locked(&root, &runtime)) .or_else(|| goal_contract_acceptance_completion_blocker_at_locked(&root, &runtime)) @@ -1948,22 +2036,11 @@ async fn run_game_creator_agent_background_task_pass_without_deadline( runtime.next_step = "先恢复原批次,不能请求新计划或提交最终回复".to_string(); } else if blocker.tool == "runtime.plan_gdd" { runtime.status = "running".to_string(); - if blocker.status == "blocked" - && blocker.detail.as_deref().is_some_and(|detail| { - detail.contains("approvalPending=awaiting_decision") - }) - { - runtime.phase = "waiting-for-user-input".to_string(); - runtime.current_action = "等待 Fast GDD 审批决定".to_string(); - runtime.waiting_on = "用户在审批卡选择批准、修改或退回".to_string(); - runtime.next_step = "等待 decide_game_creator_plan_gdd;不得重新提交同一 GDD 或自行创建审批 pending".to_string(); - } else { - runtime.phase = "needs-reconciliation".to_string(); - runtime.current_action = "Fast GDD 审批投影需要人工核对".to_string(); - runtime.waiting_on = "planning pending、receipt、原提交锚点、terminal observation、audit 与 session 的精确身份".to_string(); - runtime.next_step = - "先恢复或核对现有 durable 事实,不能请求新 Provider 计划".to_string(); - } + let projection = plan_gdd_blocker_runtime_projection(plan_gdd_blocker_kind); + runtime.phase = projection.phase.to_string(); + runtime.current_action = projection.current_action.to_string(); + runtime.waiting_on = projection.waiting_on.to_string(); + runtime.next_step = projection.next_step.to_string(); } else if blocker.tool == "runtime.collaboration_policy" { runtime.status = "running".to_string(); runtime.phase = "planning".to_string(); @@ -2126,23 +2203,7 @@ async fn run_game_creator_agent_background_task_pass_without_deadline( AgentBackgroundTaskOutcome::WaitingForDelegateReceipts, )) } else if observation.tool == "runtime.plan_gdd" { - if observation.status == "blocked" - && detail.contains("approvalPending=awaiting_decision") - { - Some(( - "agent.runtime.plan.gdd.waiting", - "waiting-for-user-input", - "Fast GDD 审批等待状态持久化失败", - AgentBackgroundTaskOutcome::WaitingForUserInput, - )) - } else { - Some(( - "agent.runtime.plan.gdd.reconciliation", - "needs-reconciliation", - "Fast GDD 审批投影需要人工核对", - AgentBackgroundTaskOutcome::NeedsReconciliation, - )) - } + plan_gdd_blocker_waiting_kind(plan_gdd_blocker_kind) } else { None } @@ -4211,3 +4272,78 @@ async fn run_game_creator_agent_background_task_pass_without_deadline( } } } + +#[cfg(test)] +mod plan_gdd_blocker_projection_tests { + use super::*; + + /// 三个 blocked 子状态必须落到各自的 phase。旧的子串判别只认得 + /// AwaitingApprovalDecision,另外两个会被打成 needs-reconciliation。 + #[test] + fn each_blocked_kind_projects_its_own_phase() { + assert_eq!( + plan_gdd_blocker_runtime_projection(Some( + PlanGddCompletionBlockerKind::SubmissionNotStarted + )) + .phase, + "planning" + ); + assert_eq!( + plan_gdd_blocker_runtime_projection(Some( + PlanGddCompletionBlockerKind::AwaitingApprovalDecision + )) + .phase, + "waiting-for-user-input" + ); + assert_eq!( + plan_gdd_blocker_runtime_projection(Some( + PlanGddCompletionBlockerKind::ReceiptAnchorCleanupPending + )) + .phase, + "planning" + ); + } + + /// 判不出 kind 与显式的人工核对一样,保持 fail-closed。 + #[test] + fn reconciliation_and_unknown_kind_stay_fail_closed() { + assert_eq!( + plan_gdd_blocker_runtime_projection(Some( + PlanGddCompletionBlockerKind::NeedsReconciliation + )) + .phase, + "needs-reconciliation" + ); + assert_eq!( + plan_gdd_blocker_runtime_projection(None).phase, + "needs-reconciliation" + ); + assert!(matches!( + plan_gdd_blocker_waiting_kind(None), + Some((_, _, _, AgentBackgroundTaskOutcome::NeedsReconciliation)) + )); + } + + /// 继续推进态不产生等待态,本轮后台任务不该在这里终结。 + #[test] + fn only_user_decision_and_reconciliation_end_the_background_task() { + assert!(matches!( + plan_gdd_blocker_waiting_kind(Some( + PlanGddCompletionBlockerKind::AwaitingApprovalDecision + )), + Some((_, "waiting-for-user-input", _, _)) + )); + assert!(matches!( + plan_gdd_blocker_waiting_kind(Some(PlanGddCompletionBlockerKind::NeedsReconciliation)), + Some((_, "needs-reconciliation", _, _)) + )); + assert!(plan_gdd_blocker_waiting_kind(Some( + PlanGddCompletionBlockerKind::SubmissionNotStarted + )) + .is_none()); + assert!(plan_gdd_blocker_waiting_kind(Some( + PlanGddCompletionBlockerKind::ReceiptAnchorCleanupPending + )) + .is_none()); + } +} 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 4827b3228..2fc3e0f43 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 @@ -819,12 +819,79 @@ fn durable_process_session_recovery_exists_at(root: &Path) -> bool { false } +/// Fast GDD approval 投影恢复失败时,把 fail-closed 收敛到受影响的那个 run。 +/// +/// 返回 `Ok(true)` 表示已经把策划根 Supervisor 标成 needs-reconciliation,调用方可以 +/// 继续扫描其余 Agent;`Ok(false)` 表示当前没有可归属的 run,无法精确收敛,调用方必须 +/// 把原错误照旧上抛,保持全局 fail-closed。 +fn contain_plan_gdd_approval_recovery_failure_at(root: &Path, error: &str) -> Result { + let Some(_runtime_lock) = try_acquire_game_creator_agent_runtime_task_lock( + root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + )? + else { + return Ok(false); + }; + let mut runtime = + read_game_creator_agent_runtime_at(root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID)?.state; + if runtime.run_id.trim().is_empty() + || matches!( + runtime.phase.as_str(), + "completed" | "cancelled" | "needs-reconciliation" + ) + { + return Ok(false); + } + let error = sanitize_agent_runtime_text(error, 500); + runtime.status = "failed".to_string(); + runtime.phase = "needs-reconciliation".to_string(); + runtime.current_action = "Fast GDD 审批投影恢复需要人工核对".to_string(); + runtime.waiting_on = "开发者核对 planning pending、receipt 与原提交锚点".to_string(); + runtime.next_step = "修复审批投影后显式恢复该 run".to_string(); + runtime.error = Some(error.clone()); + runtime.updated_at = unix_timestamp(); + append_game_creator_agent_runtime_task(root, &runtime)?; + refresh_game_creator_agent_runtime_task_queue(root, &mut runtime)?; + write_game_creator_agent_runtime_state(root, &runtime)?; + let _ = append_game_creator_agent_runtime_event( + root, + &runtime, + "plan.gdd.approval_recovery.needs_reconciliation", + "failed", + "needs-reconciliation", + "Fast GDD 审批投影恢复已停止自动重放,等待开发者核对。", + Some(&error), + ); + let _ = append_agent_db_record( + root, + serde_json::json!({ + "recordType": "agent.runtime.plan.gdd.approval_recovery.needs_reconciliation", + "agentId": runtime.agent_id, + "taskId": runtime.task_id, + "sessionId": runtime.session_id, + "runId": runtime.run_id, + "error": error, + }), + ); + emit_game_creator_agent_runtime_update(root, &runtime.agent_id); + Ok(true) +} + pub(in crate::agent) fn resume_game_creator_agent_background_tasks_unredacted_at( root: &Path, ) -> Result, String> { validate_project_root(root)?; - reconcile_plan_gdd_approval_projections_at(root) - .map_err(|error| format!("恢复 GDD approval 投影失败:{error}"))?; + // 这是一条只覆盖策划根 Supervisor 的窄投影恢复,却挂在整轮 resume 的最前面。 + // 原来用 `?` 强传播:一次 Fast GDD 投影失败会掐掉全项目所有 Agent 的恢复——而它 + // 本身正是 receipt 投影失败后的重试入口,掐掉它等于连兜底一起废掉。改成把 + // fail-closed 精确收敛到受影响的那个 run,其余 Agent 照常恢复;实在无法归属时 + // 才退回原来的全局上抛。 + if let Err(error) = reconcile_plan_gdd_approval_projections_at(root) { + let error = format!("恢复 GDD approval 投影失败:{error}"); + if !contain_plan_gdd_approval_recovery_failure_at(root, &error)? { + return Err(error); + } + } if external_agent_runner_owns_background_execution() { resume_external_agent_runner(root)?; return read_game_creator_agent_runtimes_at(root); @@ -1842,4 +1909,62 @@ mod plan_gdd_approval_wait_recovery_tests { "status 写成 waiting-for-user-input 才是恢复扫描让路的外部输入等待" ); } + + /// Fast GDD approval 投影恢复失败,不能再掐掉整轮 resume。 + /// + /// 它挂在 `resume_game_creator_agent_background_tasks_unredacted_at` 的第一行, + /// 原来用 `?` 强传播;而这条 reconcile 本身正是 receipt 投影失败后的重试入口, + /// 掐掉它等于连兜底一起废掉。现在 fail-closed 精确收敛到策划根 Supervisor 这个 run。 + #[test] + fn failed_plan_gdd_approval_recovery_contains_itself_instead_of_aborting_the_scan() { + let temporary = crate::tests::canonical_test_tempdir("plan-gdd-approval-recovery-contain-"); + let root = temporary.path(); + init_local_game_project_at(root, "plan-gdd-approval-contain", "审批投影恢复失败收敛") + .expect("init project"); + let runtime = start_game_creator_agent_runtime_task_at( + root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "推进立项策划", + "plan-gdd-approval-contain-run", + "agent-ready-task-scheduler", + "准备推进立项策划", + vec!["推进立项策划".to_string()], + ) + .expect("start plan-root supervisor runtime"); + + let planning_directory = root.join(".agent/planning"); + fs::create_dir_all(&planning_directory).expect("create planning directory"); + fs::write(planning_directory.join("gdd.v1.json"), b"{") + .expect("corrupt the GDD lineage so approval recovery fails"); + assert!(reconcile_plan_gdd_approval_projections_at(root).is_err()); + + let resumed = resume_game_creator_agent_background_tasks_at(root) + .expect("窄投影恢复失败不能把整轮 resume 掐掉"); + assert!(resumed + .iter() + .all(|result| result.state.phase != "completed")); + + let contained = read_game_creator_agent_runtime_at(root, &runtime.agent_id) + .expect("read contained supervisor runtime"); + assert_eq!(contained.state.phase, "needs-reconciliation"); + assert!( + contained + .state + .error + .as_deref() + .is_some_and(|error| error.contains("恢复 GDD approval 投影失败")), + "unexpected error: {:?}", + contained.state.error + ); + let audit_count = read_agent_db_records_bounded(root, 1024 * 1024) + .expect("read approval recovery audit") + .0 + .iter() + .filter(|record| { + record.get("recordType").and_then(|value| value.as_str()) + == Some("agent.runtime.plan.gdd.approval_recovery.needs_reconciliation") + }) + .count(); + assert_eq!(audit_count, 1); + } } 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 078645d70..9238c6d70 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 @@ -1288,16 +1288,69 @@ pub(crate) fn decide_plan_gdd_at( const PLAN_GDD_COMPLETION_BLOCKER_TOOL: &str = "runtime.plan_gdd"; +/// 为什么 `runtime.plan_gdd` 的 blocker 需要一个类型化的子状态: +/// +/// `status == "needs-reconciliation"` 已经把「要人工核对」和「继续推进」分开了, +/// 但 `"blocked"` 一侧有三种彼此完全不同的继续推进态,驱动侧必须区分才能选对 +/// phase 与 next_step。原来的做法是在 `main_loop` 里对 detail 做 +/// `contains("approvalPending=awaiting_decision")`:三个 blocked 里只有一个含这个 +/// 子串,另外两个会掉进 else 被打成 needs-reconciliation——把最正常的早期推进态和 +/// 收尾态当成故障停掉。子状态判别必须由构造方给出,不能让消费方去猜字符串。 +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum PlanGddCompletionBlockerKind { + /// 根 Run 还没提交 Fast GDD,下一步是 `agent.delegate`。 + SubmissionNotStarted, + /// Fast GDD 已提交,等待用户在审批卡上做决定。 + AwaitingApprovalDecision, + /// receipt 已落盘,原 `plan.submit_gdd` 恢复锚点还没清理完。 + ReceiptAnchorCleanupPending, + /// 其余一律要人工核对。 + NeedsReconciliation, +} + +pub(crate) struct PlanGddCompletionBlocker { + pub(crate) observation: AgentRuntimeToolObservation, + pub(crate) kind: PlanGddCompletionBlockerKind, +} + fn plan_gdd_completion_blocker( status: &str, summary: impl Into, detail: impl Into, -) -> AgentRuntimeToolObservation { - AgentRuntimeToolObservation { - tool: PLAN_GDD_COMPLETION_BLOCKER_TOOL.to_string(), - status: status.to_string(), - summary: summary.into(), - detail: Some(detail.into()), +) -> PlanGddCompletionBlocker { + debug_assert_ne!( + status, "blocked", + "blocked 子状态必须走 plan_gdd_blocked_completion_blocker 显式给出 kind" + ); + PlanGddCompletionBlocker { + observation: AgentRuntimeToolObservation { + tool: PLAN_GDD_COMPLETION_BLOCKER_TOOL.to_string(), + status: status.to_string(), + summary: summary.into(), + detail: Some(detail.into()), + }, + kind: PlanGddCompletionBlockerKind::NeedsReconciliation, + } +} + +fn plan_gdd_blocked_completion_blocker( + kind: PlanGddCompletionBlockerKind, + summary: impl Into, + detail: impl Into, +) -> PlanGddCompletionBlocker { + debug_assert_ne!( + kind, + PlanGddCompletionBlockerKind::NeedsReconciliation, + "blocked blocker 不能声明成人工核对" + ); + PlanGddCompletionBlocker { + observation: AgentRuntimeToolObservation { + tool: PLAN_GDD_COMPLETION_BLOCKER_TOOL.to_string(), + status: "blocked".to_string(), + summary: summary.into(), + detail: Some(detail.into()), + }, + kind, } } @@ -1585,11 +1638,22 @@ fn plan_root_completion_identity_at( /// committed GDD therefore remains blocked until the pending/receipt, /// generic child anchors, terminal observation, decision audit and planning /// session all form one exact durable state. +/// 只要 blocker 本身,不关心 blocked 的子状态。驱动侧(`main_loop`)必须改用 +/// `plan_gdd_typed_completion_blocker_at_locked`,否则又要去猜 detail 字符串。 pub(crate) fn plan_gdd_completion_blocker_at_locked( root: &Path, agent_id: &str, run_id: &str, ) -> Option { + plan_gdd_typed_completion_blocker_at_locked(root, agent_id, run_id) + .map(|blocker| blocker.observation) +} + +pub(crate) fn plan_gdd_typed_completion_blocker_at_locked( + root: &Path, + agent_id: &str, + run_id: &str, +) -> Option { let is_plan_root = match plan_root_completion_identity_at(root, agent_id, run_id) { Ok(value) => value, Err(error) => { @@ -1629,8 +1693,8 @@ pub(crate) fn plan_gdd_completion_blocker_at_locked( GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, run_id, ) { - Ok(Some(_)) => Some(plan_gdd_completion_blocker( - "blocked", + Ok(Some(_)) => Some(plan_gdd_blocked_completion_blocker( + PlanGddCompletionBlockerKind::SubmissionNotStarted, "当前立项策划根 Run 尚未提交 Fast GDD,不能收束任务", format!( "rootRunId={run_id} · nextRequiredAction=agent.delegate;上一根 Run 遗留的 game/fast_gdd.md 或 Acceptance Graph 不能代替本根提交" @@ -1818,8 +1882,8 @@ pub(crate) fn plan_gdd_completion_blocker_at_locked( ), )); } - return Some(plan_gdd_completion_blocker( - "blocked", + return Some(plan_gdd_blocked_completion_blocker( + PlanGddCompletionBlockerKind::AwaitingApprovalDecision, "Fast GDD 已提交,等待用户审批决定,不能收束任务", format!( "gddVersion={} · approvalPending=awaiting_decision · childPending={} · childBatch={};pending 只能由验收取证通过后的 acceptance-gate caller 创建", @@ -1852,8 +1916,8 @@ pub(crate) fn plan_gdd_completion_blocker_at_locked( } } if pending_anchor != PlanGddAnchorState::Absent || batch_anchor != PlanGddAnchorState::Absent { - return Some(plan_gdd_completion_blocker( - "blocked", + return Some(plan_gdd_blocked_completion_blocker( + PlanGddCompletionBlockerKind::ReceiptAnchorCleanupPending, "Fast GDD receipt 已提交,但原 plan.submit_gdd 恢复锚点尚未清理", format!( "gddVersion={} · terminalObservation={} · childPending={} · childBatch={}", diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_submit.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_submit.rs index d8667748e..5a7d4da17 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_submit.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_submit.rs @@ -3267,6 +3267,17 @@ mod tests { .expect("awaiting approval must block completion"); assert_eq!(blocker.status, "blocked"); assert!(blocker.summary.contains("等待用户审批")); + // 驱动侧按类型化子状态选 phase:这一条必须是「等用户决定」,不能被当成人工核对。 + assert_eq!( + plan_gdd_typed_completion_blocker_at_locked( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &context.root_run_id, + ) + .expect("awaiting approval blocker") + .kind, + PlanGddCompletionBlockerKind::AwaitingApprovalDecision + ); let decision_input = approval_input( &gdd, @@ -3377,6 +3388,18 @@ mod tests { .expect("current root without submission must block"); assert_eq!(blocker.status, "blocked"); assert!(blocker.summary.contains("尚未提交 Fast GDD")); + // 这是策划最正常的早期推进态,下一步是 agent.delegate。它的 detail 里没有 + // approvalPending 字段,旧的子串判别会把它打成 needs-reconciliation 停掉整条 run。 + assert_eq!( + plan_gdd_typed_completion_blocker_at_locked( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &context.root_run_id, + ) + .expect("submission-not-started blocker") + .kind, + PlanGddCompletionBlockerKind::SubmissionNotStarted + ); cleanup_fixture(root); } @@ -3421,6 +3444,16 @@ mod tests { "unexpected blocker: {}", blocker.summary ); + assert_eq!( + plan_gdd_typed_completion_blocker_at_locked( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &context.root_run_id, + ) + .expect("mismatched pending blocker") + .kind, + PlanGddCompletionBlockerKind::NeedsReconciliation + ); cleanup_fixture(root); }