From 7b145b3067a7e0296a42c8759043b2d513679305 Mon Sep 17 00:00:00 2001 From: AIGameCreator App Date: Wed, 22 Jul 2026 20:57:37 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=87=AA=E4=B8=BB=E6=9E=84?= =?UTF-8?q?=E5=BB=BA=E7=AD=89=E5=BE=85=E7=A1=AE=E8=AE=A4=E5=AF=BC=E8=87=B4?= =?UTF-8?q?=E7=9A=84=E5=BE=AA=E7=8E=AF=E4=B8=AD=E6=96=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将自主模式剩余确认策略统一失败关闭并保持标准模式语义 迁移旧自主确认批次并在同一 Session 和 run 重新规划 补齐零执行审计、恢复回归和真实可玩验收记录 --- .../src/agent/runtime_actions/action_audit.rs | 12 +- .../agent/runtime_actions/action_execution.rs | 12 +- .../runtime_actions/provider_action_batch.rs | 10 +- .../runtime_actions/tool_policy_snapshot.rs | 9 + .../src/agent/runtime_driver/main_loop.rs | 10 +- .../agent/runtime_driver/pending_execution.rs | 32 ++- .../agent/runtime_driver/pending_recovery.rs | 123 ++++++++++ .../src-tauri/src/agent/runtime_tools.rs | 1 + .../src/agent/runtime_tools/policy.rs | 41 +++- .../planning_strategy/autonomous_build.rs | 138 ++++++++++- .../src/tests/runtime_actions/recovery.rs | 223 ++++++++++++++++++ .../shared-memory/decision-log.md | 7 + docs/project-memory/shared-memory/pitfalls.md | 8 + ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 3 + 14 files changed, 599 insertions(+), 30 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_audit.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_audit.rs index 422c1fdb1..59c1be4ef 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_audit.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_audit.rs @@ -25,17 +25,21 @@ pub(in crate::agent) fn agent_runtime_tool_policy_block_observation( tool: &str, blocked: AgentRuntimeToolPolicyBlock, ) -> AgentRuntimeToolObservation { - let (status, summary) = match blocked { - AgentRuntimeToolPolicyBlock::Denied(summary) => ("blocked", summary), + let (status, summary, detail) = match blocked { + AgentRuntimeToolPolicyBlock::Denied(summary) => ( + "blocked", + summary, + Some("runtimePolicyDecision=denied · zeroExecution=true".to_string()), + ), AgentRuntimeToolPolicyBlock::RequiresConfirmation(summary) => { - ("waiting-for-confirmation", summary) + ("waiting-for-confirmation", summary, None) } }; AgentRuntimeToolObservation { tool: tool.to_string(), status: status.to_string(), summary, - detail: None, + detail, } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_execution.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_execution.rs index bd46df656..2149ad925 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_execution.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_execution.rs @@ -101,9 +101,15 @@ pub(crate) async fn execute_game_creator_agent_runtime_tool_action_with_pending_ game_creator_mcp_action_policy_block_at(root, agent_id, action, confirmation_approved) .await }; - if let Some(blocked) = - strictest_agent_runtime_tool_policy_block(local_policy_block, mcp_policy_block) - { + let policy_block = fail_closed_agent_runtime_confirmation_for_run( + root, + agent_id, + run_id, + pending_action.map(|pending| pending.run_profile.as_str()), + pending_action.map(|pending| pending.run_profile_binding_fingerprint.as_str()), + strictest_agent_runtime_tool_policy_block(local_policy_block, mcp_policy_block), + ); + if let Some(blocked) = policy_block { return agent_runtime_tool_policy_block_observation(tool, blocked); } } else { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_action_batch.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_action_batch.rs index 6794f65dd..3fd0d552e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_action_batch.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_action_batch.rs @@ -418,7 +418,15 @@ pub(crate) async fn prepare_game_creator_agent_runtime_provider_action_batch( } else { game_creator_mcp_action_policy_block_at(root, &runtime.agent_id, action, false).await }; - match strictest_agent_runtime_tool_policy_block(local_policy_block, mcp_policy_block) { + let policy_block = fail_closed_agent_runtime_confirmation_for_run( + root, + &runtime.agent_id, + &runtime.run_id, + Some(&runtime.run_profile), + Some(&runtime.run_profile_binding_fingerprint), + strictest_agent_runtime_tool_policy_block(local_policy_block, mcp_policy_block), + ); + match policy_block { Some(blocked @ AgentRuntimeToolPolicyBlock::Denied(_)) => { let observation = agent_runtime_tool_policy_block_observation(action.tool.trim(), blocked); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_policy_snapshot.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_policy_snapshot.rs index 92a352a27..9f88a88db 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_policy_snapshot.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_policy_snapshot.rs @@ -150,5 +150,14 @@ pub(crate) fn agent_runtime_tool_policy_snapshot_for_run_at( snapshot.auto_tools.push(tool.to_string()); } } + for tool in std::mem::take(&mut snapshot.confirm_tools) { + if !snapshot + .denied_tools + .iter() + .any(|candidate| candidate == &tool) + { + snapshot.denied_tools.push(tool); + } + } Ok(snapshot) } 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 3824beea1..b0ffe4b6d 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 @@ -1803,8 +1803,14 @@ pub(in crate::agent) async fn run_game_creator_agent_background_task_pass_with_c ) .await }; - let policy_block = - strictest_agent_runtime_tool_policy_block(local_policy_block, mcp_policy_block); + let policy_block = fail_closed_agent_runtime_confirmation_for_run( + &root, + &agent_id, + &runtime.run_id, + Some(&runtime.run_profile), + Some(&runtime.run_profile_binding_fingerprint), + strictest_agent_runtime_tool_policy_block(local_policy_block, mcp_policy_block), + ); let mut durable_action = None; let observation = if let Some(blocked) = policy_block { agent_runtime_tool_policy_block_observation(action.tool.trim(), blocked) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_execution.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_execution.rs index 4e302d6c7..55e5976c2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_execution.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_execution.rs @@ -430,8 +430,16 @@ pub(crate) async fn continue_game_creator_agent_pending_tool_action( ); runtime.status = "running".to_string(); runtime.phase = "observation".to_string(); + let runtime_policy_rejected = auto_execution + && pending.status == AGENT_RUNTIME_PENDING_ACTION_STATUS_OBSERVED_REJECTED + && observation + .detail + .as_deref() + .is_some_and(|detail| detail.contains("runtimePolicyDecision=denied")); runtime.current_action = if observation.is_repository_context_drift() { format!("已作废启动上下文漂移前的旧工具 {}", observation.tool) + } else if runtime_policy_rejected { + format!("Runtime 策略已拒绝工具 {}", observation.tool) } else if auto_execution { format!("已执行自动工具 {}", observation.tool) } else if approved { @@ -505,7 +513,15 @@ pub(crate) async fn continue_game_creator_agent_pending_tool_action( "summary": observation.summary, "actionId": pending.action_id, "actionFingerprint": pending.action_fingerprint, - "decision": if auto_execution { "auto" } else if approved { "approved" } else { "rejected" }, + "decision": if runtime_policy_rejected { + "runtime-policy-rejected" + } else if auto_execution { + "auto" + } else if approved { + "approved" + } else { + "rejected" + }, }), ); if stop_game_creator_agent_runtime_if_cancel_requested(&root, &mut runtime) { @@ -522,12 +538,16 @@ pub(crate) async fn continue_game_creator_agent_pending_tool_action( )); } } - let continued_event_type = if auto_execution { + let continued_event_type = if runtime_policy_rejected { + "tool_action.rejected_by_policy" + } else if auto_execution { "tool_action.continued" } else { "tool_confirmation.continued" }; - let continued_summary = if auto_execution { + let continued_summary = if runtime_policy_rejected { + "Runtime 策略拒绝观察已持久化,Agent 将在同一 run 重新规划。" + } else if auto_execution { "自动工具动作观察已持久化,Agent 将在同一 run 继续规划。" } else { "待确认动作观察已持久化,Agent 将在同一 run 继续规划。" @@ -563,7 +583,11 @@ pub(crate) async fn continue_game_creator_agent_pending_tool_action( let batch_observation = AgentRuntimeToolObservation { tool: "runtime.provider_action_batch".to_string(), status: "blocked".to_string(), - summary: "开发者拒绝批次确认后,其余 Provider action 保持零执行".to_string(), + summary: if pending.is_auto() { + "Runtime 策略拒绝批次动作后,其余 Provider action 保持零执行".to_string() + } else { + "开发者拒绝批次确认后,其余 Provider action 保持零执行".to_string() + }, detail: Some(format!( "batchId={} · actionCount={} · rejectedActionIndex={}", batch.batch_id, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_recovery.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_recovery.rs index 94e4cdf96..2273f6798 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_recovery.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_recovery.rs @@ -296,6 +296,114 @@ pub(in crate::agent) fn suppress_static_delegate_reservation_for_rejected_pendin suppress_static_delegate_delivery_at(root, &expected).map(|_| ()) } +fn migrate_legacy_autonomous_confirmation_at( + root: &Path, + runtime: &AgentRuntimeState, + pending: &mut AgentRuntimePendingToolAction, +) -> Result { + let (run_profile, binding_fingerprint) = agent_runtime_run_profile_identity_at( + root, + &runtime.agent_id, + &runtime.run_id, + Some(&runtime.run_profile), + Some(&runtime.run_profile_binding_fingerprint), + )?; + if run_profile != AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + || pending.status != AGENT_RUNTIME_PENDING_ACTION_STATUS_PENDING + || pending.execution_mode != AGENT_RUNTIME_ACTION_EXECUTION_MODE_CONFIRMATION + { + return Ok(false); + } + if runtime.run_profile != run_profile + || runtime.run_profile_binding_fingerprint != binding_fingerprint + || pending.agent_id != runtime.agent_id + || pending.session_id != runtime.session_id + || pending.run_id != runtime.run_id + || pending.run_profile != run_profile + || pending.run_profile_binding_fingerprint != binding_fingerprint + { + return Err("自主构建旧确认动作的 Runtime/Run Profile 身份不一致".to_string()); + } + + let observation = AgentRuntimeToolObservation { + tool: pending.action.tool.trim().to_string(), + status: "blocked".to_string(), + summary: "自主构建模式不能等待人工确认;旧确认动作未执行".to_string(), + detail: Some( + "runtimePolicyDecision=denied · legacyAutonomousConfirmation=true · zeroExecution=true · 请改用 auto-safe 工具或省略该动作" + .to_string(), + ), + }; + pending.execution_mode = AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO.to_string(); + pending.status = AGENT_RUNTIME_PENDING_ACTION_STATUS_OBSERVED_REJECTED.to_string(); + pending.observation = Some(observation); + pending.updated_at = unix_timestamp(); + + if game_creator_agent_runtime_provider_action_batch_exists( + root, + &pending.agent_id, + &pending.run_id, + ) { + let mut batch = read_game_creator_agent_runtime_provider_action_batch( + root, + &pending.agent_id, + &pending.run_id, + )?; + let action_index = usize::try_from(pending.action_index).unwrap_or(usize::MAX); + let stored = batch + .actions + .get(action_index) + .ok_or_else(|| "自主构建旧确认批次缺少对应 actionIndex".to_string())? + .clone(); + if stored.action_id != pending.action_id + || stored.action_fingerprint != pending.action_fingerprint + || stored.action != pending.action + { + return Err("自主构建旧确认批次与 pending action 身份不一致".to_string()); + } + match batch.status.as_str() { + AGENT_RUNTIME_PROVIDER_ACTION_BATCH_STATUS_WAITING_CONFIRMATION => { + batch.actions[action_index] = pending.clone(); + batch.status = AGENT_RUNTIME_PROVIDER_ACTION_BATCH_STATUS_ABORTED.to_string(); + batch.updated_at = unix_timestamp(); + write_game_creator_agent_runtime_provider_action_batch(root, &batch)?; + } + AGENT_RUNTIME_PROVIDER_ACTION_BATCH_STATUS_ABORTED + if stored.status == AGENT_RUNTIME_PENDING_ACTION_STATUS_OBSERVED_REJECTED + && stored.observation == pending.observation => + { + *pending = stored; + } + status => { + return Err(format!( + "自主构建旧确认 pending 所属批次状态不能迁移:{status}" + )); + } + } + } + + write_game_creator_agent_runtime_pending_tool_action(root, pending)?; + append_game_creator_agent_runtime_action_event( + root, + runtime, + "tool_confirmation.autonomous_legacy_rejected", + "running", + "observation", + "自主构建旧确认动作已在零执行状态下拒绝,原 run 将自动重新规划。", + Some(&format!( + "tool={} · providerBatchAborted={}", + pending.action.tool, + game_creator_agent_runtime_provider_action_batch_exists( + root, + &pending.agent_id, + &pending.run_id, + ) + )), + &pending.action_id, + )?; + Ok(true) +} + pub(in crate::agent) fn resume_game_creator_agent_parallel_read_batch_at( root: &Path, agent_id: &str, @@ -574,6 +682,9 @@ pub(crate) fn resume_game_creator_agent_pending_tool_action_at( return read_game_creator_agent_runtime_at(root, agent_id) .map(AgentRuntimePendingActionResume::Handled); } + if migrate_legacy_autonomous_confirmation_at(root, &runtime, &mut pending)? { + can_repair_terminal_receipt = true; + } if pending.status == AGENT_RUNTIME_PENDING_ACTION_STATUS_WAITING_FOR_USER_INPUT { if runtime.run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD { let _ = cancel_game_creator_agent_user_input_request_for_pending_at(root, &pending); @@ -876,6 +987,18 @@ pub(in crate::agent) fn resume_game_creator_agent_provider_action_batch_at( remove_game_creator_agent_runtime_provider_action_batch(root, agent_id, &runtime.run_id)?; return Ok(AgentRuntimePendingActionResume::NotFound(runtime_lock)); } + if batch.status == AGENT_RUNTIME_PROVIDER_ACTION_BATCH_STATUS_WAITING_CONFIRMATION + && batch.run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + { + let mut pending = batch + .actions + .iter() + .find(|pending| pending.status == AGENT_RUNTIME_PENDING_ACTION_STATUS_PENDING) + .ok_or_else(|| "自主构建旧确认批次缺少 pending-confirmation 成员".to_string())? + .clone(); + migrate_legacy_autonomous_confirmation_at(root, &runtime, &mut pending)?; + return resume_game_creator_agent_pending_tool_action_at(root, agent_id, runtime_lock); + } match batch.status.as_str() { AGENT_RUNTIME_PROVIDER_ACTION_BATCH_STATUS_WAITING_CONFIRMATION => { let pending = batch diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools.rs index 09bbd4097..ae5f04d43 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools.rs @@ -58,6 +58,7 @@ pub(crate) use media::{ AGENT_RUNTIME_UI_PROTOTYPE_VALIDATION_PROFILE, }; pub(crate) use policy::{ + fail_closed_agent_runtime_confirmation_for_run, game_creator_agent_runtime_tool_policy_block_after_lock, game_creator_agent_runtime_tool_policy_rule_for_run, }; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/policy.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/policy.rs index df639f7e3..953157d30 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/policy.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/policy.rs @@ -38,15 +38,50 @@ pub(crate) fn game_creator_agent_runtime_tool_policy_rule_for_run( }; match blocked { Some(AgentRuntimeToolPolicyBlock::RequiresConfirmation(_)) - if run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD - && AGENT_RUNTIME_AUTONOMOUS_GAME_BUILD_AUTO_COMMAND_IDS.contains(&command_id) => + if run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD => { - None + if AGENT_RUNTIME_AUTONOMOUS_GAME_BUILD_AUTO_COMMAND_IDS.contains(&command_id) { + None + } else { + Some(AgentRuntimeToolPolicyBlock::Denied(format!( + "自主构建模式不能等待人工确认:{command_id};请改用 auto-safe 工具或省略该动作" + ))) + } } blocked => blocked, } } +pub(crate) fn fail_closed_agent_runtime_confirmation_for_run( + root: &Path, + agent_id: &str, + run_id: &str, + stored_profile: Option<&str>, + stored_binding_fingerprint: Option<&str>, + blocked: Option, +) -> Option { + let Some(AgentRuntimeToolPolicyBlock::RequiresConfirmation(reason)) = blocked else { + return blocked; + }; + let (run_profile, _) = match agent_runtime_run_profile_identity_at( + root, + agent_id, + run_id, + stored_profile, + stored_binding_fingerprint, + ) { + Ok(identity) => identity, + Err(error) => return Some(AgentRuntimeToolPolicyBlock::Denied(error)), + }; + if run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD { + Some(AgentRuntimeToolPolicyBlock::Denied(format!( + "自主构建模式不能等待人工确认:{reason};请改用 auto-safe 工具或省略该动作" + ))) + } else { + Some(AgentRuntimeToolPolicyBlock::RequiresConfirmation(reason)) + } +} + pub(in crate::agent) fn agent_runtime_effective_tool_policy_at( root: &Path, agent_id: &str, diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/planning_strategy/autonomous_build.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/planning_strategy/autonomous_build.rs index 55b4fcb9e..bc74b5015 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/planning_strategy/autonomous_build.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/planning_strategy/autonomous_build.rs @@ -56,6 +56,10 @@ fn autonomous_game_build_profile_auto_grants_only_scoped_build_actions() { .confirm_tools .iter() .any(|candidate| candidate == tool)); + assert!(!policy + .denied_tools + .iter() + .any(|candidate| candidate == tool)); } assert!(policy .denied_tools @@ -69,29 +73,53 @@ fn autonomous_game_build_profile_auto_grants_only_scoped_build_actions() { "command.terminate", ] { assert!(policy + .denied_tools + .iter() + .any(|candidate| candidate == tool)); + assert!(!policy .confirm_tools .iter() .any(|candidate| candidate == tool)); } - assert!(game_creator_agent_runtime_tool_policy_rule_for_run( + assert!(policy.confirm_tools.is_empty()); + let auto_rule = game_creator_agent_runtime_tool_policy_rule_for_run( &root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, run_id, Some(&binding.profile), Some(&binding.binding_fingerprint), "file.write", - ) - .is_none()); + ); + assert!(auto_rule.is_none()); + let denied = game_creator_agent_runtime_tool_policy_rule_for_run( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + Some(&binding.profile), + Some(&binding.binding_fingerprint), + "project.git_commit", + ); + let Some(AgentRuntimeToolPolicyBlock::Denied(reason)) = denied else { + panic!("autonomous non-auto-safe tool must be denied"); + }; + assert!(reason.contains("自主构建模式不能等待人工确认")); + assert!(reason.contains("auto-safe")); + assert!(reason.contains("省略")); + let dynamic_confirmation = crate::fail_closed_agent_runtime_confirmation_for_run( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + Some(&binding.profile), + Some(&binding.binding_fingerprint), + Some(AgentRuntimeToolPolicyBlock::RequiresConfirmation( + "MCP 工具配置要求用户确认:demo/write".to_string(), + )), + ); assert!(matches!( - game_creator_agent_runtime_tool_policy_rule_for_run( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - run_id, - Some(&binding.profile), - Some(&binding.binding_fingerprint), - "project.git_commit", - ), - Some(AgentRuntimeToolPolicyBlock::RequiresConfirmation(_)) + dynamic_confirmation, + Some(AgentRuntimeToolPolicyBlock::Denied(reason)) + if reason.contains("MCP 工具配置要求用户确认") + && reason.contains("不能等待人工确认") )); write_project_permission_policy_at( @@ -103,6 +131,77 @@ fn autonomous_game_build_profile_auto_grants_only_scoped_build_actions() { }, ) .expect("deny file write explicitly"); + let denied = game_creator_agent_runtime_tool_policy_rule_for_run( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + Some(&binding.profile), + Some(&binding.binding_fingerprint), + "file.write", + ); + let Some(AgentRuntimeToolPolicyBlock::Denied(reason)) = denied else { + panic!("explicit deny must remain denied"); + }; + assert!(reason.contains("项目权限策略拒绝执行")); + let policy = agent_runtime_tool_policy_snapshot_for_run_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + Some(&binding.profile), + Some(&binding.binding_fingerprint), + ) + .expect("autonomous policy snapshot after explicit deny"); + assert!(policy + .denied_tools + .iter() + .any(|candidate| candidate == "file.write")); + assert!(!policy + .auto_tools + .iter() + .any(|candidate| candidate == "file.write")); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn standard_profile_keeps_confirmation_policy_unchanged() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-standard-profile", "标准确认权限项目") + .expect("project init"); + let run_id = "standard-profile-root-run"; + let binding = bind_game_creator_agent_runtime_run_profile_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_STANDARD), + None, + ) + .expect("bind standard profile"); + let policy = agent_runtime_tool_policy_snapshot_for_run_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + Some(&binding.profile), + Some(&binding.binding_fingerprint), + ) + .expect("standard policy snapshot"); + + assert_eq!(policy.run_profile, AGENT_RUNTIME_RUN_PROFILE_STANDARD); + assert!(policy + .auto_tools + .iter() + .any(|tool| tool == GAME_CREATOR_USER_INPUT_REQUEST_TOOL)); + for tool in ["file.write", "project.git_commit", "command.exec"] { + assert!(policy + .confirm_tools + .iter() + .any(|candidate| candidate == tool)); + assert!(!policy + .denied_tools + .iter() + .any(|candidate| candidate == tool)); + } assert!(matches!( game_creator_agent_runtime_tool_policy_rule_for_run( &root, @@ -112,7 +211,20 @@ fn autonomous_game_build_profile_auto_grants_only_scoped_build_actions() { Some(&binding.binding_fingerprint), "file.write", ), - Some(AgentRuntimeToolPolicyBlock::Denied(_)) + Some(AgentRuntimeToolPolicyBlock::RequiresConfirmation(_)) + )); + assert!(matches!( + crate::fail_closed_agent_runtime_confirmation_for_run( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + Some(&binding.profile), + Some(&binding.binding_fingerprint), + Some(AgentRuntimeToolPolicyBlock::RequiresConfirmation( + "标准模式仍需确认".to_string(), + )), + ), + Some(AgentRuntimeToolPolicyBlock::RequiresConfirmation(_)) )); fs::remove_dir_all(root).ok(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/recovery.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/recovery.rs index 6ec5f1c24..e4e024188 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/recovery.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/recovery.rs @@ -1,5 +1,228 @@ use super::support::*; +#[tokio::test] +async fn autonomous_game_build_recovery_aborts_legacy_confirmation_batch_and_replans() { + let root = unique_project_path(); + init_local_game_project_at( + &root, + "project-autonomous-legacy-confirmation", + "自主构建旧确认恢复测试", + ) + .expect("project init"); + let parent_run_id = "autonomous-legacy-confirmation-parent"; + let parent_binding = bind_game_creator_agent_runtime_run_profile_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + None, + ) + .expect("bind autonomous parent profile"); + let child_run_id = "autonomous-legacy-confirmation-child"; + let child_link = AgentRuntimeTaskLink { + parent_agent_id: Some(parent_binding.agent_id.clone()), + parent_run_id: Some(parent_binding.run_id.clone()), + delegation_id: Some("autonomous-legacy-confirmation-delegation".to_string()), + }; + let child_binding = bind_game_creator_agent_runtime_run_profile_at( + &root, + "code-prototype", + child_run_id, + "agent-delegate", + None, + Some(&child_link), + ) + .expect("inherit autonomous child profile"); + let mut runtime = start_game_creator_agent_runtime_task_at( + &root, + "code-prototype", + "在无人确认的情况下继续完成可试玩项目", + child_run_id, + "agent-delegate", + "恢复旧确认批次", + vec!["拒绝不可自动执行的动作并重新规划".to_string()], + ) + .expect("start autonomous child runtime"); + assert_eq!(runtime.run_profile, child_binding.profile); + assert_eq!( + runtime.run_profile, + AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + ); + + let blocked_write_path = root.join("game/legacy-confirmation-must-not-run.txt"); + let plan = AgentRuntimeToolPlan { + thinking_summary: "旧版本先写文件再等待 git commit 确认".to_string(), + plan_update: None, + plan: vec!["写入文件".to_string(), "提交项目".to_string()], + actions: vec![ + AgentRuntimeToolAction { + tool: "file.write".to_string(), + reason: Some("验证整批零执行".to_string()), + input: serde_json::json!({ + "path": "game/legacy-confirmation-must-not-run.txt", + "content": "该文件不应出现" + }), + }, + AgentRuntimeToolAction { + tool: "project.git_commit".to_string(), + reason: Some("模拟旧版需要确认的动作".to_string()), + input: serde_json::json!({ "message": "旧确认批次" }), + }, + ], + response: String::new(), + }; + let preparation = prepare_game_creator_agent_runtime_provider_action_batch( + &root, + &runtime, + &runtime.current_task, + &plan, + &[], + &read_game_creator_agent_runtime_project_revision(&root).expect("project revision"), + &build_repository_startup_context_at(&root) + .expect("repository context") + .fingerprint, + ) + .await + .expect("prepare denied autonomous batch"); + let AgentRuntimeProviderActionBatchPreparation::Aborted { mut batch, .. } = preparation else { + panic!("confirmation-only autonomous tool must abort the batch"); + }; + let rejected_index = batch + .actions + .iter() + .position(|pending| pending.action.tool == "project.git_commit") + .expect("git commit batch member"); + let mut legacy_pending = batch.actions[rejected_index].clone(); + legacy_pending.execution_mode = AGENT_RUNTIME_ACTION_EXECUTION_MODE_CONFIRMATION.to_string(); + legacy_pending.status = crate::AGENT_RUNTIME_PENDING_ACTION_STATUS_PENDING.to_string(); + legacy_pending.observation = None; + legacy_pending.updated_at = unix_timestamp(); + batch.actions[rejected_index] = legacy_pending.clone(); + batch.status = "waiting-confirmation".to_string(); + batch.updated_at = unix_timestamp(); + crate::write_game_creator_agent_runtime_provider_action_batch(&root, &batch) + .expect("persist legacy waiting batch"); + write_game_creator_agent_runtime_pending_tool_action(&root, &legacy_pending) + .expect("persist legacy waiting pending"); + runtime.status = "waiting-for-confirmation".to_string(); + runtime.phase = "waiting-for-confirmation".to_string(); + runtime.current_action = "等待旧版人工确认".to_string(); + runtime.waiting_on = "开发者确认".to_string(); + runtime.next_step = "确认后继续".to_string(); + runtime.pending_tool_action = Some(legacy_pending.summary()); + runtime.updated_at = unix_timestamp(); + append_game_creator_agent_runtime_task(&root, &runtime).expect("append legacy waiting task"); + write_game_creator_agent_runtime_state(&root, &runtime).expect("write legacy waiting state"); + + let (request_sender, request_receiver) = mpsc::channel(); + let (release_sender, release_receiver) = mpsc::channel(); + let base_url = spawn_releasable_mock_llm_server_responses_with_capture( + vec![serde_json::json!({ + "thinkingSummary": "旧确认已拒绝,改用自动安全动作继续", + "planUpdate": { + "explanation": "旧确认动作不再阻塞自主构建", + "steps": [{ "step": "重新规划安全动作", "status": "completed" }] + }, + "plan": [], + "actions": [], + "response": "已跳过需要人工确认的动作并继续。" + }) + .to_string()], + request_sender, + release_receiver, + ); + let _config_guard = write_test_local_config(format!( + r#"{{ + "agentLlm": {{ + "code-prototype": {{ + "apiKey": "autonomous-recovery-key", + "baseUrl": {base_url:?}, + "model": "autonomous-recovery-model", + "apiKind": "openai_responses" + }} + }} +}}"# + )); + + let resumed = resume_game_creator_agent_background_tasks_at(&root) + .expect("scan and resume legacy autonomous batch"); + assert!(resumed.iter().any(|result| { + result.state.agent_id == "code-prototype" && result.state.run_id == child_run_id + })); + let request = request_receiver + .recv_timeout(Duration::from_secs(4)) + .expect("same run must request a replacement plan"); + assert!(request.contains("在无人确认的情况下继续完成可试玩项目")); + assert!( + !blocked_write_path.exists(), + "auto prefix must remain unexecuted" + ); + assert!( + !crate::game_creator_agent_runtime_provider_action_batch_path( + &root, + "code-prototype", + child_run_id, + ) + .exists() + ); + assert!(!game_creator_agent_runtime_pending_tool_action_exists( + &root, + "code-prototype", + child_run_id, + )); + let migrated = read_game_creator_agent_runtime_at(&root, "code-prototype") + .expect("read replanning runtime") + .state; + assert_eq!(migrated.session_id, runtime.session_id); + assert_eq!(migrated.run_id, runtime.run_id); + assert_eq!(migrated.run_profile, runtime.run_profile); + assert_eq!( + migrated.run_profile_binding_fingerprint, + runtime.run_profile_binding_fingerprint + ); + assert_ne!(migrated.phase, "waiting-for-confirmation"); + let events = fs::read_to_string(game_creator_agent_runtime_event_path( + &root, + "code-prototype", + )) + .expect("read migration events"); + assert!(events.contains("tool_confirmation.autonomous_legacy_rejected")); + assert!(events.contains("provider_action_batch.aborted")); + assert!(events.contains("tool_action.rejected_by_policy")); + let records = read_agent_db_records_for_test(&root); + assert!(records.iter().any(|record| { + record["recordType"] == "agent.runtime.tool_observation" + && record["runId"] == child_run_id + && record["decision"] == "runtime-policy-rejected" + })); + + let cancelling = + cancel_game_creator_agent_runtime_task_at(&root, "code-prototype", child_run_id) + .expect("cancel held replacement planning request"); + assert_eq!(cancelling.state.status, "cancelling"); + release_sender + .send(()) + .expect("release replacement planning"); + let mut cancelled = read_game_creator_agent_runtime_at(&root, "code-prototype") + .expect("read cancelling runtime") + .state; + for _ in 0..100 { + if cancelled.status == "cancelled" + && game_creator_agent_runtime_task_lock_is_available(&root, "code-prototype") + .expect("inspect recovery test lock") + { + break; + } + std::thread::sleep(Duration::from_millis(20)); + cancelled = read_game_creator_agent_runtime_at(&root, "code-prototype") + .expect("read cancelled runtime") + .state; + } + assert_eq!(cancelled.status, "cancelled"); + fs::remove_dir_all(root).ok(); +} + #[test] fn agent_runtime_run_status_rechecks_revision_inside_project_lock() { let root = unique_project_path(); diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 0c4fb0276..3b0596e0c 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -5268,3 +5268,10 @@ - 决策:pending action 在执行前发现 project revision 漂移时,必须持久化为 `blocked` observation,明确 `projectRevisionDrift=true / replanRequired=true / 旧动作未执行`,清理旧 pending,并让同一 Agent、同一 run 基于最新项目状态继续 planning。只有副作用可能已经发生、持久身份损坏或审计无法证明结果时才进入 `needs-reconciliation`。 - 锁内边界:`file.write` 与 `file.patch` 在取得项目写锁后再次核对 pending 身份、仓库上下文、revision 和 verification gate,避免预检后与另一 Agent 的项目修改交错。revision 漂移只拒绝旧动作,不忽略并发变化,也不直接执行可能覆盖他人结果的旧写入。 - 验收:三个定向回归、全部 `revision` 过滤测试 `32/32`、`cargo check --tests` 和 Linux 串行全量 `1147 passed / 5 ignored / 0 failed` 通过。确定性正式 E2E 继续以 `17/17` Provider lifecycle、revision `0 -> 2` 和 Chrome `37/37` 通过。新的独立真实 external-provider E2E 只写入一次塔防需求并立即 EOF,人工 approve / answer / steer 均为 `0`;父 turn `settled`,项目 revision `0 -> 8`,static smoke 和真实 Chrome `lane-defense-v1 37/37` 通过,唯一 Supervisor assistant 写入,pending、confirmation、user-input、reconciliation、sidecar、重复与敏感信息泄漏均为 `0`。 + +## 2026-07-22 自主构建禁止进入人工确认等待 + +- 背景:自主构建虽然禁止 `user.input_request`,但项目权限、Agent 权限或 MCP catalog 仍可能把 `blackboard.write`、`project.git_commit`、完整命令、资产生成等动作判为 `RequiresConfirmation`。Provider 多动作批次会因此整体停在 `waiting-for-confirmation`,无人干预目标无法继续。 +- 决策:`autonomous-game-build` 只允许固定 auto-safe 白名单消除默认确认;其余任何本地或 MCP 动态确认结果统一失败关闭为 `Denied`。批次含拒绝成员时先完整预检并持久化 `aborted`,整批工具保持零执行,再把拒绝 observation 交回同一 Session/run 重新规划。标准 profile 和显式 deny 保持原合同。 +- 恢复:旧自主 `pending-confirmation` 必须在校验持久 Run Profile、Runtime、Session/run、action identity 和 batch member 后迁移。先原子写 `aborted` batch,再写 `observed-rejected` pending 镜像;两次写入之间退出时由 batch 重建 pending。恢复后的状态和审计使用 `runtime-policy-rejected`,不能误报自动动作已执行或开发者主动拒绝。 +- 验收:自主构建测试 `20/20`、确认测试 `18/18`、旧等待批次完整恢复、批次零副作用、`cargo check --tests`、Rust 串行全量 `1149 passed / 5 ignored / 0 failed` 均通过。确定性 Runner + Chrome E2E 为 `17/17` Provider lifecycle、revision `0 -> 2`、试玩 `37/37`;独立外部 Provider E2E 为 `62/62`、revision `0 -> 5`、`game/index.html=7816 bytes`、试玩 `37/37`,两轮人工输入、等待态、残留、重复与泄漏均为 `0`。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 684fbb78a..0a460828e 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -3543,3 +3543,11 @@ - 处理:在 pending 标记为 executing 之前检查 revision 漂移;漂移时写入可恢复的 blocked observation,明确旧动作未执行并要求同 run 重新规划。文件写入和 patch 在项目锁内再做一次同样检查,防止预检后的竞态。只有动作可能已落盘、账本身份冲突、审计失败或持久记录损坏时继续失败关闭到 reconciliation。 - 验证:必须覆盖确认后 stale 动作和锁内 stale 动作两条路径,证明目标文件未改变、旧 pending 被收束、下一次 Provider planning 使用同一 run、最终状态可完成,并用单输入真实 external-provider E2E 验证并行专业 Agent 最终生成可试玩项目。 - 关联:`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/interaction.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_execution.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/file_ops.rs`。 + +## 自主模式不能保留任何 RequiresConfirmation 漏口 + +- 现象:`user.input_request` 已被拒绝,但模型选择项目权限或 MCP catalog 标记为确认的工具后,整个自主批次仍进入 `waiting-for-confirmation`;重启还会忠实恢复这个等待态,形成永久阻塞。 +- 原因:只在本地 command policy 中提升少量 auto-safe 工具,不能覆盖 MCP 动态 approval;只修改新请求策略,也不能处理旧版本已经持久化的 waiting batch。拒绝 observation 若仍带 `executionMode=auto`,通用续跑还可能把它错误投影为“已执行自动工具”。 +- 处理:最终合并后的本地/MCP policy block 必须再次经过持久 Run Profile gate;自主 profile 的所有剩余确认统一转为 deny,混合批次整体零执行并同 run 重规划。恢复迁移以 aborted batch 为提交点、pending 为镜像,并把自动策略拒绝显式审计为 `runtime-policy-rejected`。 +- 验证:同时覆盖 auto-safe 白名单、显式 deny、动态确认失败关闭、标准 profile 不变、完整恢复扫描、前缀副作用未发生、Session/run/profile identity 不变和 replacement planning 已发出;最后必须重新运行确定性与外部 Provider 的单输入可玩 E2E。 +- 关联:`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/policy.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_action_batch.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_recovery.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_execution.rs`。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index f4cb2ae58..09b3f604e 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -700,3 +700,6 @@ game-project/ - 2026-07-22 的 V1.45 修复专业 Agent 并行修改导致的 stale pending 中断。执行前发现 project revision 漂移不再进入 `needs-reconciliation`,而是以 `blocked + projectRevisionDrift + replanRequired` observation 回到同一 run 继续 planning;`file.write / file.patch` 还会在项目写锁内复核 pending 身份、仓库上下文、revision 与 verification gate。该语义不放宽并发写安全:旧动作始终不执行,副作用未知、账本冲突或持久化损坏仍失败关闭。 - V1.45 的独立真实 external-provider 验收只向 `--swarm-chat --init --autonomous-game-build` 写入一次“植物大战僵尸式塔防”需求后立即 EOF。最终 `status=PASS / turn.report=settled`,approve / answer / steer 为 `0`;两条初始 delivery 与一条 repair delivery 均被父 run 认领,项目 revision `0 -> 8`,`game/index.html` 为 `7814` bytes,static smoke、desktop / mobile 浏览器和 `lane-defense-v1 37/37` 全通过。唯一 Supervisor assistant 已提交,pending、confirmation、user-input、provider retry/handoff、tool-plan handoff、finalization、reconciliation、重复和全部隐私泄漏计数均为 `0`。 - V1.45 稳定树同时通过三个新增/更新定向回归、`revision 32/32`、`cargo check --tests`、Linux 串行全量 `1147 passed / 5 ignored / 0 failed`、`cargo fmt --check`、encoding 与 `git diff --check`。确定性正式 E2E 继续为 **PASS**:Provider lifecycle `17/17`、revision `0 -> 2`、Chrome `37/37`,终局残留与泄漏均为 `0`。 +- 2026-07-22 的 V1.46 消除自主构建的人工确认等待。`autonomous-game-build` 仅把固定 auto-safe 白名单提升为自动执行;项目级、Agent 级和 MCP catalog 动态策略产生的其余 `RequiresConfirmation` 一律转为 `Denied`,向同一 run 返回“改用 auto-safe 工具或省略动作”的 observation。显式 deny 继续优先,标准 profile 的确认语义不变;包含拒绝成员的 Provider action 批次在任何工具执行前整体 `aborted`,不能执行 auto 前缀。 +- V1.46 的 Runner 恢复会把旧版本已持久化的自主 `pending-confirmation / waiting-confirmation` 迁移为 `observed-rejected / aborted`。Provider batch 是原子提交点,pending 只是可重建镜像;批次先落盘、pending 后落盘之间强杀时,下次恢复从 aborted batch 补齐拒绝账本并继续原 Session/run。公共状态和审计使用 `runtime-policy-rejected`,不得写成“已执行自动工具”或“开发者拒绝”。 +- V1.46 稳定树通过自主构建过滤 `20/20`、确认相关 `18/18`、旧等待态完整恢复、既有批次零副作用、`cargo check --tests`、Rust 串行全量 `1149 passed / 5 ignored / 0 failed`、fmt 与 diff 检查。确定性正式 E2E 为 **PASS**:Provider lifecycle `17/17`、revision `0 -> 2`、Chrome `37/37`、人工输入与残留均为 `0`。新的独立外部 Provider 同轮 E2E 也为 **PASS**:单条任务后 EOF,approve / answer / steer 为 `0`,Provider lifecycle `62/62`,revision `0 -> 5`,`game/index.html` 为 `7816` bytes,static smoke、desktop / mobile 与 `lane-defense-v1 37/37` 全通过,唯一 Supervisor assistant,全部 sidecar、reconciliation、重复和泄漏计数均为 `0`。