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 2fc3e0f43..f42df38e3 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 @@ -822,9 +822,17 @@ fn durable_process_session_recovery_exists_at(root: &Path) -> bool { /// Fast GDD approval 投影恢复失败时,把 fail-closed 收敛到受影响的那个 run。 /// /// 返回 `Ok(true)` 表示已经把策划根 Supervisor 标成 needs-reconciliation,调用方可以 -/// 继续扫描其余 Agent;`Ok(false)` 表示当前没有可归属的 run,无法精确收敛,调用方必须 -/// 把原错误照旧上抛,保持全局 fail-closed。 +/// 继续扫描其余 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, @@ -1967,4 +1975,54 @@ mod plan_gdd_approval_wait_recovery_tests { .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"); + let error = resume_game_creator_agent_background_tasks_at(root) + .expect_err("锁争用属于瞬时错误,必须照旧上抛而不是就地收敛"); + assert!( + error.contains("恢复 GDD approval 投影失败"), + "unexpected error: {error}" + ); + 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/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)