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 155bf3571..13e7d63a6 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,94 @@ 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)` 表示不该、或无法精确收敛,调用方必须把原错误照旧 +/// 上抛,保持全局 fail-closed。 +fn contain_plan_gdd_approval_recovery_failure_at(root: &Path, error: &str) -> Result { + // 瞬时错误绝不能收敛成 needs-reconciliation。最常见的就是 `.agent/project.lock` + // 正被另一个写操作占用——什么都没坏,下一轮扫描重试即可;把它标成人工核对等于 + // 用一次转瞬即逝的锁争用永久停掉策划根 run,比原来的强传播更糟。这里照旧上抛, + // 调用方把它变成 recovery_pending 并在下一轮重试,与本函数出现之前的行为一致。 + // 判据复用委派唤醒那条既有的瞬时特征串,避免两处各写一套导致分类漂移。 + if static_delegate_parent_wake_error_is_transient(error) { + return Ok(false); + } + 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 投影失败后的重试入口,掐掉它等于连兜底一起废掉。 + // + // 失败必须分三路,不能两路。此前把「瞬时」和「归属不了」并成同一个 false, + // 结果瞬时锁争用走了全局上抛,让这条可反复调用的恢复入口整轮失败——而锁被占 + // 恰恰说明别处正在推进,是最不该失败的时候。 + if let Err(error) = reconcile_plan_gdd_approval_projections_at(root) { + let error = format!("恢复 GDD approval 投影失败:{error}"); + if static_delegate_parent_wake_error_is_transient(&error) { + // 瞬时争用(最常见的是 `.agent/project.lock` 正被另一个写操作占用): + // 跳过本轮投影恢复,其余恢复照常走,下一轮 resume 重试。与下面拿不到 + // runtime task 锁时直接跳过的处理是同一套语义,因此同样不落审计。 + } else if !contain_plan_gdd_approval_recovery_failure_at(root, &error)? { + // 持久失败但归属不到具体 run:只能退回原来的全局上抛。 + return Err(error); + } + } if external_agent_runner_owns_background_execution() { resume_external_agent_runner(root)?; return read_game_creator_agent_runtimes_at(root); @@ -1770,3 +1852,183 @@ mod orphaned_external_generation_recovery_tests { ); } } + +#[cfg(test)] +mod plan_gdd_approval_wait_recovery_tests { + use super::*; + + /// Fast GDD 审批等待期,根 Supervisor 到底还能不能被恢复扫描拉起。 + /// + /// 判据不在 `phase` 上:`read_recoverable_game_creator_agent_runtime_task` 按 + /// task record 的 **status** 分类,而 record 的 status 由 + /// `game_creator_agent_runtime_task_status` 从 `state.status` 推导。审批等待 + /// (`main_loop` 的 `runtime.plan_gdd` 分支)只改 phase、保留 `status="running"`; + /// 真正的澄清等待(`action_projection`)才会把 status 一并写成 + /// `waiting-for-user-input`,那才是恢复扫描要让路的外部输入等待。 + /// + /// 这个区别决定了审批决定之后还有没有生产路径驱动父 run:审批命令只调通用 + /// wake,一旦有人把审批等待也写成 `status="waiting-for-user-input"`,通用 wake + /// 会静默变成 no-op,用户点了批准/修改/退回之后不会有任何东西继续跑。这条测试 + /// 把这个区别钉成不变量。 + #[test] + fn plan_gdd_approval_wait_stays_recoverable_while_real_user_input_wait_does_not() { + let temporary = crate::tests::canonical_test_tempdir("plan-gdd-approval-wait-recovery-"); + let root = temporary.path(); + init_local_game_project_at(root, "plan-gdd-approval-wait", "Fast GDD 审批等待恢复判定") + .expect("init project"); + let mut runtime = start_game_creator_agent_runtime_task_at( + root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "推进立项策划", + "plan-gdd-approval-wait-run", + "agent-ready-task-scheduler", + "准备推进立项策划", + vec!["推进立项策划".to_string()], + ) + .expect("start plan-root supervisor runtime"); + + // main_loop.rs 的 Fast GDD 审批等待形状。 + runtime.status = "running".to_string(); + runtime.phase = "waiting-for-user-input".to_string(); + runtime.current_action = "等待 Fast GDD 审批决定".to_string(); + runtime.waiting_on = "用户在审批卡选择批准、修改或退回".to_string(); + runtime.updated_at = unix_timestamp(); + append_game_creator_agent_runtime_task(root, &runtime).expect("append approval wait task"); + refresh_game_creator_agent_runtime_task_queue(root, &mut runtime) + .expect("refresh approval wait task queue"); + write_game_creator_agent_runtime_state(root, &runtime).expect("write approval wait state"); + + let task = + read_recoverable_runnable_game_creator_agent_runtime_task(root, &runtime.agent_id) + .expect("read approval wait task") + .expect("审批等待态必须仍可被恢复扫描拉起,否则审批决定后没有生产路径驱动父 run"); + assert_eq!(task.status, "running"); + assert_eq!(task.phase, "waiting-for-user-input"); + assert!(has_recoverable_game_creator_agent_background_tasks_at(root) + .expect("public preflight must agree with the recoverable task read")); + + // 对照组:真正的用户输入等待把 status 一并写成 waiting-for-user-input。 + runtime.status = "waiting-for-user-input".to_string(); + runtime.updated_at = unix_timestamp(); + append_game_creator_agent_runtime_task(root, &runtime) + .expect("append user input wait task"); + refresh_game_creator_agent_runtime_task_queue(root, &mut runtime) + .expect("refresh user input wait task queue"); + write_game_creator_agent_runtime_state(root, &runtime) + .expect("write user input wait state"); + + assert!( + read_recoverable_runnable_game_creator_agent_runtime_task(root, &runtime.agent_id) + .expect("read user input wait task") + .is_none(), + "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); + } + + /// 瞬时错误不能被收敛成 needs-reconciliation。 + /// + /// `.agent/project.lock` 正被另一个写操作占用时什么都没坏,下一轮扫描重试即可; + /// 把它标成人工核对,等于用一次转瞬即逝的锁争用永久停掉策划根 run——那比这条 + /// 收敛出现之前的强传播还糟。此时必须照旧上抛,由调用方转成 recovery_pending。 + #[test] + fn transient_plan_gdd_approval_recovery_failure_is_retried_instead_of_reconciled() { + let temporary = + crate::tests::canonical_test_tempdir("plan-gdd-approval-recovery-transient-"); + let root = temporary.path(); + init_local_game_project_at(root, "plan-gdd-approval-transient", "审批投影恢复瞬时失败") + .expect("init project"); + let runtime = start_game_creator_agent_runtime_task_at( + root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "推进立项策划", + "plan-gdd-approval-transient-run", + "agent-ready-task-scheduler", + "准备推进立项策划", + vec!["推进立项策划".to_string()], + ) + .expect("start plan-root supervisor runtime"); + + let held = acquire_project_write_lock(root, "test.hold-project-write-lock") + .expect("hold the project write lock"); + // 锁被占说明别处正在推进,这恰恰是 resume 最不该失败的时候:本轮跳过投影 + // 恢复即可,整轮 resume 必须照常成功。上抛会让 resume 这个可反复调用的恢复 + // 入口在一次普通锁争用下整体失败——真实调用方就是连着调它来断言幂等的。 + resume_game_creator_agent_background_tasks_at(root) + .expect("瞬时锁争用只能跳过本轮投影恢复,不得让整轮 resume 失败"); + drop(held); + + let contained = read_game_creator_agent_runtime_at(root, &runtime.agent_id) + .expect("read supervisor runtime after transient failure"); + assert_ne!( + contained.state.phase, "needs-reconciliation", + "瞬时锁争用不得把策划根 run 永久停掉" + ); + assert!(contained.state.error.is_none()); + assert!(!read_agent_db_records_bounded(root, 1024 * 1024) + .expect("read approval recovery audit") + .0 + .iter() + .any( + |record| record.get("recordType").and_then(|value| value.as_str()) + == Some("agent.runtime.plan.gdd.approval_recovery.needs_reconciliation") + )); + } +} 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_storage.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_storage.rs index 3d4d55378..29f3d1b99 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_storage.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_storage.rs @@ -2913,8 +2913,10 @@ pub(crate) fn validate_plan_gdd_index_against_gdds_and_approvals( } fn approval_directory_is_present(root: &Path) -> Result { - let path = resolve_local_project_path(root, PLAN_GDD_APPROVAL_DIR) - .map_err(|error| PlanningStorageError::new("PLAN_INVALID_PATH", error))?; + let path = resolve_planning_path(root, PLAN_GDD_APPROVAL_DIR)?; + // 下面这次 stat 仍然必要:`resolve_planning_path` 只保证解析那一刻整条链 + // 可信,而判定「目录存在」要读的是使用时刻的那一项,顺带还要排掉链接以外 + // 的另一种不可信形态——普通文件占位。 match fs::symlink_metadata(path) { Ok(metadata) => { if planning_metadata_is_link_or_reparse(&metadata) || !metadata.is_dir() { @@ -3166,8 +3168,7 @@ pub(crate) fn read_plan_gdd_approvals( pub(crate) fn read_plan_gdd_approvals_locked( root: &Path, ) -> Result, PlanningStorageError> { - let approvals_root = resolve_local_project_path(root, PLAN_GDD_APPROVAL_DIR) - .map_err(|error| PlanningStorageError::new("PLAN_INVALID_PATH", error))?; + let approvals_root = resolve_planning_path(root, PLAN_GDD_APPROVAL_DIR)?; let metadata = match fs::symlink_metadata(&approvals_root) { Ok(metadata) => metadata, Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), @@ -3228,8 +3229,7 @@ pub(crate) fn read_plan_gdd_approval_for_version_locked( return Err(invalid("approval receipt version 越界")); } let relative = format!("{PLAN_GDD_APPROVAL_DIR}/v{version}.json"); - let path = resolve_local_project_path(root, &relative) - .map_err(|error| PlanningStorageError::new("PLAN_INVALID_PATH", error))?; + let path = resolve_planning_path(root, &relative)?; match fs::symlink_metadata(&path) { Ok(_) => { let bytes = @@ -4032,8 +4032,7 @@ pub(crate) fn read_plan_gdd_approval_pending( pub(crate) fn read_plan_gdd_approval_pending_locked( root: &Path, ) -> Result, PlanningStorageError> { - let path = resolve_local_project_path(root, PLAN_GDD_APPROVAL_PENDING_PATH) - .map_err(|error| PlanningStorageError::new("PLAN_INVALID_PATH", error))?; + let path = resolve_planning_path(root, PLAN_GDD_APPROVAL_PENDING_PATH)?; match fs::symlink_metadata(&path) { Ok(_) => { let bytes = read_regular_planning_file(&path, "GDD approval pending")?; @@ -4061,8 +4060,7 @@ pub(crate) fn write_plan_gdd_approval_pending_atomic_locked( value: &PlanGddApprovalPendingV1, ) -> Result<(), PlanningStorageError> { let bytes = canonical_plan_gdd_approval_pending_bytes(value)?; - let target = resolve_local_project_path(root, PLAN_GDD_APPROVAL_PENDING_PATH) - .map_err(|error| PlanningStorageError::new("PLAN_INVALID_PATH", error))?; + let target = resolve_planning_path(root, PLAN_GDD_APPROVAL_PENDING_PATH)?; let parent = ensure_planning_parent(&target)?; if let Ok(_) = fs::symlink_metadata(&target) { verify_regular_planning_file(&target, "现有 GDD approval pending")?; @@ -4094,8 +4092,7 @@ pub(crate) fn write_plan_gdd_approval_pending_atomic_locked( pub(crate) fn remove_plan_gdd_approval_pending_locked( root: &Path, ) -> Result<(), PlanningStorageError> { - let path = resolve_local_project_path(root, PLAN_GDD_APPROVAL_PENDING_PATH) - .map_err(|error| PlanningStorageError::new("PLAN_INVALID_PATH", error))?; + let path = resolve_planning_path(root, PLAN_GDD_APPROVAL_PENDING_PATH)?; match fs::symlink_metadata(&path) { Ok(_) => { verify_regular_planning_file(&path, "GDD approval pending")?; @@ -5331,4 +5328,76 @@ mod tests { "PLAN_UNTRUSTED_PATH" ); } + + /// M1C-1 新增的审批路径最初绕过 `resolve_planning_path`,直接用通用解析器。 + /// 通用解析器只认 `is_symlink()`,把结果一律映射成 `PLAN_INVALID_PATH`;而 + /// 规划解析器认的是 `FILE_ATTRIBUTE_REPARSE_POINT` 全量重解析标记,并把被 + /// 篡改的路径如实报成 `PLAN_UNTRUSTED_PATH`。审批回执正是 GDD 完成门的判据, + /// 它的路径分类必须和 GDD/session 一致,否则调用方按错误码分流时会把「路径 + /// 不可信」当成「路径写错了」。 + #[cfg(any(unix, windows))] + #[test] + fn approval_paths_classify_a_linked_planning_root_as_untrusted_not_merely_invalid() { + let directory = tempfile::tempdir().expect("temp root"); + let root = directory.path(); + // 旁路目录留在项目内:真正要挡的是「planning 根被指向别处」,不是「逃出根」。 + let decoy = root.join("decoy-planning"); + fs::create_dir_all(decoy.join("approvals")).expect("decoy approvals"); + fs::create_dir_all(root.join(".agent")).expect("agent dir"); + let planning_link = { + let mut path = root.to_path_buf(); + for part in PLAN_STORAGE_ROOT.split('/') { + path.push(part); + } + path + }; + #[cfg(unix)] + std::os::unix::fs::symlink(&decoy, &planning_link).expect("planning symlink"); + // 用 junction 而不是 `symlink_dir`:后者要开发者模式/管理员权限,普通开发 + // 机上建不起来,用例会静默跳过成永远通过的空壳;junction 无需提权,而且它 + // 正是本仓各处点名要挡的那种 Windows 重解析点。 + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + let status = std::process::Command::new("cmd") + .arg("/C") + .raw_arg(format!( + "mklink /J \"{}\" \"{}\"", + planning_link.display(), + decoy.display() + )) + .status() + .expect("spawn mklink"); + assert!(status.success(), "junction 建不起来则本用例失去判据"); + } + + // 写入路径不在列:它的父链早已由 `ensure_planning_parent` 逐段校验, + // 本来就会报 PLAN_UNTRUSTED_PATH,不构成这次改动的判据。 + assert_eq!( + approval_directory_is_present(root).unwrap_err().code(), + "PLAN_UNTRUSTED_PATH" + ); + assert_eq!( + read_plan_gdd_approvals_locked(root).unwrap_err().code(), + "PLAN_UNTRUSTED_PATH" + ); + assert_eq!( + read_plan_gdd_approval_for_version_locked(root, 1) + .unwrap_err() + .code(), + "PLAN_UNTRUSTED_PATH" + ); + assert_eq!( + read_plan_gdd_approval_pending_locked(root) + .unwrap_err() + .code(), + "PLAN_UNTRUSTED_PATH" + ); + assert_eq!( + remove_plan_gdd_approval_pending_locked(root) + .unwrap_err() + .code(), + "PLAN_UNTRUSTED_PATH" + ); + } } 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..07f17d13a 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 @@ -1252,6 +1252,14 @@ fn validate_current_session_cas( "plan.submit_gdd 必须绑定当前活跃策划子 Run", )); } + // 这条 phase 判据实际只可能看到 `collecting`:上面的 activeRunId 判据要求 + // session 绑着当前策划子 run,而 schema 不变量禁止 `awaiting_user_input`、 + // `awaiting_gdd_approval`、`revision_requested`、`approved`、`rejected`、 + // `recovery_required` 保留 activeRunId(planning_storage.rs 的 + // 「session 进入审批/终态/recovery_required 后不得保留 activeRunId」)。 + // 因此 revise/reject 之后能不能重做,不由这条门决定,而由 M1C-2b 的 continuation + // 起点 writer 决定——它必须以新 activeRunId 写 revision+1 successor,phase 只能落回 + // `collecting`。这里保留 `revision_requested` 作为既有冗余,不再新增更多不可达分支。 if !matches!(session.phase.as_str(), "collecting" | "revision_requested") { return Err(submit_error( "PLAN_PENDING_GDD_EXISTS", @@ -3267,6 +3275,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 +3396,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 +3452,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); } @@ -3502,6 +3543,66 @@ mod tests { cleanup_fixture(root); } + /// reject 之后能不能在同一 lineage 重做,**不由提交门的 phase 判据决定**。 + /// + /// 提交门要求 session 的 activeRunId 等于当前策划子 run,而 schema 不变量禁止 + /// `rejected`(以及 `revision_requested`、`approved`、`awaiting_*`)保留 + /// activeRunId——两者互斥,所以终态 phase 永远到不了那条 phase 判据。真正决定重做 + /// 能力的是 M1C-2b 的 continuation 起点 writer:技术方案 §8.6 要求它「以新 + /// activeRunId 写 revision+1 successor」,而唯一能同时带 activeRunId 又过提交门的 + /// phase 只有 `collecting`。 + /// + /// 这条测试把该约束锁住,免得日后有人以为「把 rejected 加进提交门允许集」就能让 + /// reject 重做——那只会多一个不可达分支,真正的续跑仍然起不来。 + #[test] + fn rejected_session_needs_a_collecting_continuation_not_a_wider_submit_gate() { + let (root, context, input) = submit_fixture(); + execute_plan_submit_gdd(&root, &context, &input).expect("submit v1"); + let gdd = read_plan_gdd_chain(&root) + .expect("read submitted GDD") + .pop() + .expect("GDD exists"); + create_plan_gdd_approval_pending_at(&root, &gdd).expect("create approval pending"); + decide_plan_gdd_at( + &root, + &approval_input( + &gdd, + "reject", + "gdd-response-00000000-0000-4000-8000-000000000040", + Some("当前方向需要重新梳理".to_string()), + ), + ) + .expect("commit reject receipt"); + + let session = read_plan_session_with_recovery(&root) + .expect("read session after reject") + .expect("session exists after reject"); + assert_eq!(session.phase, "rejected"); + assert!(session.active_run_id.is_none()); + + // 终态 session 直接挂 activeRunId 连指纹都算不出来——schema 层就禁止。 + let mut forged = session.clone(); + forged.active_run_id = Some(context.created_by_run_id.clone()); + let error = + plan_session_fingerprint(&forged).expect_err("终态 session 不得保留 activeRunId"); + assert_eq!(error.code(), "PLAN_INVALID_SCHEMA"); + + // continuation 起点把 phase 落回 collecting 之后,同一 lineage 才能继续提交。 + let mut continuation = session.clone(); + continuation.session_revision += 1; + continuation.previous_fingerprint = Some(session.session_fingerprint.clone()); + continuation.phase = "collecting".to_string(); + continuation.active_run_id = Some(context.created_by_run_id.clone()); + continuation.session_fingerprint = + plan_session_fingerprint(&continuation).expect("continuation session fp"); + let mut next_context = context.clone(); + next_context.source_session_revision = continuation.session_revision; + next_context.source_session_fingerprint = continuation.session_fingerprint.clone(); + validate_current_session_cas(&continuation, &next_context, &input) + .expect("reject 之后的 continuation 必须能提交同一 lineage 的下一版本"); + cleanup_fixture(root); + } + #[test] fn approval_actions_rebuild_receipt_aware_index_and_are_idempotent() { for (action, comment, expected_status) in [ diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delivery.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delivery.rs index 603a0128d..1a63dcad1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delivery.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delivery.rs @@ -315,6 +315,14 @@ pub(in crate::agent) fn wake_waiting_static_delegate_parent_run_at( if barrier.has_waiting() { return Ok(false); } + // 用户修订是一条显式的 Supervisor 决策边界:在它派出续作之前,父 run 绝不能 + // 被自动恢复。这条语义由 `has_waiting()` 承担(它把 userRevisionPending 计入 + // 等待),所以上面那道门已经覆盖。断言把这份跨文件依赖钉在使用现场——若哪天 + // `has_waiting()` 不再计入该计数,这里会立刻炸而不是静默跨过决策边界。 + debug_assert_eq!( + barrier.user_revision_pending_count, 0, + "userRevisionPending 必须已被 has_waiting() 拦下,否则父 run 会越过用户修订边界自动恢复" + ); let state = read_game_creator_agent_runtime_for_session_at( root, ¤t_task.agent_id, @@ -337,11 +345,6 @@ pub(in crate::agent) fn wake_waiting_static_delegate_parent_run_at( ensure_static_delegate_user_input_wait_at(root, &mut state, &deliveries)?; return Ok(true); } - if barrier.user_revision_pending_count > 0 { - // A user revision is an explicit Supervisor decision boundary. Do - // not auto-resume the parent before it dispatches the continuation. - return Ok(false); - } let state = advance_game_creator_agent_runtime_turn_at( root, state, diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/agent_db/security_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/project/agent_db/security_tests.rs index 345d00975..cd6314dc6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/agent_db/security_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/agent_db/security_tests.rs @@ -2465,13 +2465,19 @@ fn agent_db_capacity_reserves_terminal_receipt_space_without_rotation() { #[test] fn ordinary_append_soft_limit_preserves_action_receipt_record_slots() { + // 守恒律必须覆盖全部预留车道。M1C-1 新增了 planning 决策车道 + // (`AGENT_DB_PLAN_GDD_DECISION_RESERVE_BYTES`,(16 KiB+1)×128)并从 + // `AGENT_DB_MAX_ORDINARY_APPEND_BYTES` 里扣掉,这条断言却还停在两车道, + // 差额恰好是新车道的 2_097_280 字节。少一条车道就意味着这条断言不再能证明 + // 「普通追加不会吃掉任何终态预留」——它才是这个测试存在的理由。 assert_eq!( AGENT_DB_MAX_ORDINARY_APPEND_BYTES + AGENT_DB_LIFECYCLE_TERMINAL_RESERVE_BYTES - + AGENT_DB_TERMINAL_RESERVE_BYTES, + + AGENT_DB_TERMINAL_RESERVE_BYTES + + AGENT_DB_PLAN_GDD_DECISION_RESERVE_BYTES, AGENT_DB_MAX_ACTION_RECEIPT_SCAN_BYTES ); - assert_eq!(AGENT_DB_MAX_ORDINARY_APPEND_RECORDS, 999_808); + assert_eq!(AGENT_DB_MAX_ORDINARY_APPEND_RECORDS, 999_680); assert_eq!( AGENT_DB_MAX_SCAN_RECORDS - usize::try_from(AGENT_DB_TERMINAL_RESERVE_RECORDS) diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/static_deliveries.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/static_deliveries.rs index 8c4eb9e04..03b38f715 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/static_deliveries.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/static_deliveries.rs @@ -86,6 +86,50 @@ fn mark_and_claim_static_delegate_needs_repair( .expect("claim needs-repair receipt"); } +/// 把一条已认领的 delivery 改写成「用户修订」形态,用于模拟审批卡上的 revise。 +/// +/// M1C-1 之后 `UserRevisionRequested` 与 `EvidenceReady` 共用同一套客观证据要求 +/// (terminal completed、无缺失产物、验证已满足),所以模拟修订不能只翻 +/// `contract_status`:沿用 needs-repair 的证据落盘时会被判成「evidence-ready/ +/// user-revision-requested 与客观证据冲突」。这里先让 expected_artifacts 真实落盘, +/// 再按真实证据重建 structuredResult——用户是在**已交付**的产物上要求修订。 +fn rewrite_claimed_static_delegate_as_user_revision_requested( + root: &Path, + delegation_id: &str, + expected_artifacts: &[String], +) { + for artifact in expected_artifacts { + let path = root.join(artifact); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).expect("create delivered artifact parent"); + } + fs::write(&path, b"# user revision evidence\n").expect("write delivered artifact"); + } + let mut result = build_static_delegate_structured_result_at( + root, + "completed", + expected_artifacts, + false, + None, + None, + None, + None, + ) + .expect("build delivered result"); + assert_eq!( + result.contract_status, + StaticDelegateContractStatus::EvidenceReady, + "模拟用户修订之前,客观证据必须先真实达到 evidence-ready" + ); + result.contract_status = StaticDelegateContractStatus::UserRevisionRequested; + let mut delivery = read_static_delegate_delivery_at(root, delegation_id) + .expect("read claimed delivery") + .expect("claimed delivery exists"); + delivery.structured_result = Some(result); + write_static_delegate_delivery_at(root, &delivery) + .expect("persist simulated approval revision status"); +} + /// 把一条已 dispatched 的 delivery 标记为 needs-user-input 终态并认领。 /// 注意:即便这一跳是纯粹的用户澄清、不产出文件,structuredResult 也必须完整覆盖 /// delivery 记录自身声明的 expected_artifacts(校验在 write_static_delegate_delivery_at @@ -3893,16 +3937,7 @@ fn user_revision_continuation_passes_real_gate_at_depth_one_but_stays_single_chi "user-revision-real-gate-d1-claim", ); - let mut d1_user_revision = read_static_delegate_delivery_at(&root, &d1_id) - .expect("read claimed D1") - .expect("claimed D1 exists"); - d1_user_revision - .structured_result - .as_mut() - .expect("D1 structured result") - .contract_status = StaticDelegateContractStatus::UserRevisionRequested; - write_static_delegate_delivery_at(&root, &d1_user_revision) - .expect("persist simulated approval revision status"); + rewrite_claimed_static_delegate_as_user_revision_requested(&root, &d1_id, &expected_artifacts); // D1 的链上 depth 已经是 1;只有 UserRevisionRequested 分支能让这次真实委派继续。 let d2_action = "user-revision-real-gate-d2-revision-action"; @@ -4065,16 +4100,7 @@ fn concurrent_user_revision_dispatch_creates_exactly_one_delivery() { &expected_artifacts, "concurrent-user-revision-d1-claim", ); - let mut d1_user_revision = read_static_delegate_delivery_at(&root, &d1_id) - .expect("read claimed D1") - .expect("claimed D1 exists"); - d1_user_revision - .structured_result - .as_mut() - .expect("D1 structured result") - .contract_status = StaticDelegateContractStatus::UserRevisionRequested; - write_static_delegate_delivery_at(&root, &d1_user_revision) - .expect("persist simulated approval revision status"); + rewrite_claimed_static_delegate_as_user_revision_requested(&root, &d1_id, &expected_artifacts); let start = Arc::new(Barrier::new(3)); let mut workers = Vec::new(); diff --git a/apps/preview-deployer-web/src/PreviewDeployerApp.test.tsx b/apps/preview-deployer-web/src/PreviewDeployerApp.test.tsx index d0b68bc21..21f2b900d 100644 --- a/apps/preview-deployer-web/src/PreviewDeployerApp.test.tsx +++ b/apps/preview-deployer-web/src/PreviewDeployerApp.test.tsx @@ -24,6 +24,8 @@ beforeEach(() => { vi.mocked(api.createSession).mockResolvedValue({ authenticated: true }); vi.mocked(api.deleteSession).mockResolvedValue(undefined); vi.mocked(api.listDeployments).mockResolvedValue([]); + vi.mocked(api.searchBranches).mockResolvedValue([]); + vi.mocked(api.searchCommits).mockResolvedValue([]); vi.mocked(api.createDeployment).mockResolvedValue({ id: 'preview-1', branch: 'master', @@ -86,6 +88,7 @@ test('shows health and web url, then confirms uninstall', async () => { resolvedCommit: '1234567890abcdef', status: 'running', health: 'healthy', + webPort: 8400, webUrl: 'http://192.168.35.82:8400', createdAt: 1_787_270_400, updatedAt: 1_787_270_460, @@ -94,6 +97,7 @@ test('shows health and web url, then confirms uninstall', async () => { render(); expect(await screen.findByText('健康')).toBeTruthy(); + expect(screen.getByText('端口 8400')).toBeTruthy(); expect( screen.getByRole('link', { name: /打开 Web/u }).getAttribute('href'), ).toBe('http://192.168.35.82:8400'); @@ -106,3 +110,86 @@ test('shows health and web url, then confirms uninstall', async () => { expect(api.uninstallDeployment).toHaveBeenCalledWith('preview-2'); }); }); + +test('shows debounced branch results and selects one with the keyboard', async () => { + const user = userEvent.setup(); + vi.mocked(api.searchBranches).mockResolvedValue([ + { name: 'feature/search-one', commitHash: '111111111111' }, + { name: 'feature/search-two', commitHash: '222222222222' }, + ]); + render(); + + const branchInput = await screen.findByRole('combobox', { name: '分支名' }); + await user.clear(branchInput); + await user.type(branchInput, 'feature/search'); + + expect( + await screen.findByRole('option', { name: /feature\/search-one/u }), + ).toBeTruthy(); + expect(api.searchBranches).toHaveBeenLastCalledWith( + 'feature/search', + expect.any(AbortSignal), + ); + await user.keyboard('{ArrowDown}{ArrowDown}{Enter}'); + expect((branchInput as HTMLInputElement).value).toBe('feature/search-two'); +}); + +test('searches commits in the current branch and selects a result', async () => { + const user = userEvent.setup(); + vi.mocked(api.searchCommits).mockResolvedValue([ + { + commitHash: 'aa5221abcdef0123456789', + shortHash: 'aa5221a', + subject: '补充预览搜索', + }, + ]); + render(); + + const commitInput = await screen.findByRole('combobox', { + name: 'Commit Hash', + }); + await user.type(commitInput, 'aa5221a'); + + const option = await screen.findByRole('option', { + name: /aa5221a.*补充预览搜索/u, + }); + expect(api.searchCommits).toHaveBeenCalledWith( + 'master', + 'aa5221a', + expect.any(AbortSignal), + ); + await user.click(option); + expect((commitInput as HTMLInputElement).value).toBe( + 'aa5221abcdef0123456789', + ); +}); + +test('clears commit and ignores stale commit responses when branch changes', async () => { + const user = userEvent.setup(); + let resolveOldSearch: ((items: api.CommitRef[]) => void) | undefined; + vi.mocked(api.searchCommits).mockImplementation( + () => + new Promise((resolve) => { + resolveOldSearch = resolve; + }), + ); + render(); + + const branchInput = await screen.findByRole('combobox', { name: '分支名' }); + const commitInput = screen.getByRole('combobox', { name: 'Commit Hash' }); + await user.type(commitInput, 'abcdef1'); + await waitFor(() => expect(api.searchCommits).toHaveBeenCalled()); + await user.clear(branchInput); + await user.type(branchInput, 'feature/new'); + + expect((commitInput as HTMLInputElement).value).toBe(''); + resolveOldSearch?.([ + { + commitHash: 'abcdef1234567890', + shortHash: 'abcdef1', + subject: '旧分支提交', + }, + ]); + await Promise.resolve(); + expect(screen.queryByText('旧分支提交')).toBeNull(); +}); diff --git a/apps/preview-deployer-web/src/PreviewDeployerApp.tsx b/apps/preview-deployer-web/src/PreviewDeployerApp.tsx index 290e6d3cf..45f7e7d80 100644 --- a/apps/preview-deployer-web/src/PreviewDeployerApp.tsx +++ b/apps/preview-deployer-web/src/PreviewDeployerApp.tsx @@ -13,15 +13,27 @@ import { TriangleAlert, XCircle, } from 'lucide-react'; -import { FormEvent, useCallback, useEffect, useMemo, useState } from 'react'; +import { + FormEvent, + KeyboardEvent as ReactKeyboardEvent, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; import { + type BranchRef, + type CommitRef, createDeployment, createSession, deleteSession, getSession, listDeployments, PreviewDeployerApiError, + searchBranches, + searchCommits, uninstallDeployment, } from './api'; import type { @@ -32,6 +44,7 @@ import type { import { validateBranch, validateCommitHash } from './validation'; const POLL_INTERVAL_MS = 5000; +const SEARCH_DEBOUNCE_MS = 300; const STATUS_LABELS: Record = { queued: '排队中', @@ -66,6 +79,16 @@ export function PreviewDeployerApp() { const [submitting, setSubmitting] = useState(false); const [notice, setNotice] = useState(''); const [error, setError] = useState(''); + const [branchSearch, setBranchSearch] = + useState>(emptySearchState()); + const [commitSearch, setCommitSearch] = + useState>(emptySearchState()); + const [branchActiveIndex, setBranchActiveIndex] = useState(-1); + const [commitActiveIndex, setCommitActiveIndex] = useState(-1); + const [branchSearchEnabled, setBranchSearchEnabled] = useState(false); + const [commitSearchEnabled, setCommitSearchEnabled] = useState(false); + const branchRequestId = useRef(0); + const commitRequestId = useRef(0); const [pendingUninstall, setPendingUninstall] = useState(null); const [uninstallingId, setUninstallingId] = useState(''); @@ -129,6 +152,94 @@ export function PreviewDeployerApp() { return () => window.clearInterval(interval); }, [refresh, sessionStatus]); + useEffect(() => { + const query = branch.trim(); + const requestId = ++branchRequestId.current; + const controller = new AbortController(); + if (sessionStatus !== 'authenticated' || !branchSearchEnabled || !query) { + setBranchSearch(emptySearchState()); + setBranchActiveIndex(-1); + return () => controller.abort(); + } + setBranchSearch({ items: [], loading: true, open: true, error: '' }); + setBranchActiveIndex(-1); + const timer = window.setTimeout(() => { + void searchBranches(query, controller.signal) + .then((items) => { + if (requestId !== branchRequestId.current) return; + setBranchSearch({ items, loading: false, open: true, error: '' }); + }) + .catch((searchError) => { + if ( + controller.signal.aborted || + requestId !== branchRequestId.current + ) + return; + if (isUnauthorized(searchError)) { + handleUnauthorized(); + return; + } + setBranchSearch({ + items: [], + loading: false, + open: true, + error: formatError(searchError), + }); + }); + }, SEARCH_DEBOUNCE_MS); + return () => { + window.clearTimeout(timer); + controller.abort(); + }; + }, [branch, branchSearchEnabled, sessionStatus]); + + useEffect(() => { + const query = commitHash.trim(); + const selectedBranch = branch.trim(); + const requestId = ++commitRequestId.current; + const controller = new AbortController(); + if ( + sessionStatus !== 'authenticated' || + !commitSearchEnabled || + !query || + validateBranch(selectedBranch) + ) { + setCommitSearch(emptySearchState()); + setCommitActiveIndex(-1); + return () => controller.abort(); + } + setCommitSearch({ items: [], loading: true, open: true, error: '' }); + setCommitActiveIndex(-1); + const timer = window.setTimeout(() => { + void searchCommits(selectedBranch, query, controller.signal) + .then((items) => { + if (requestId !== commitRequestId.current) return; + setCommitSearch({ items, loading: false, open: true, error: '' }); + }) + .catch((searchError) => { + if ( + controller.signal.aborted || + requestId !== commitRequestId.current + ) + return; + if (isUnauthorized(searchError)) { + handleUnauthorized(); + return; + } + setCommitSearch({ + items: [], + loading: false, + open: true, + error: formatError(searchError), + }); + }); + }, SEARCH_DEBOUNCE_MS); + return () => { + window.clearTimeout(timer); + controller.abort(); + }; + }, [branch, commitHash, commitSearchEnabled, sessionStatus]); + const runningCount = useMemo( () => deployments.filter((deployment) => deployment.status === 'running') @@ -356,36 +467,166 @@ export function PreviewDeployerApp() { className="deploy-form" onSubmit={(event) => void handleSubmit(event)} > -