From aea75ef701d56e8decae2934db9e0f9a8ccd4094 Mon Sep 17 00:00:00 2001 From: AIGameCreator App Date: Fri, 10 Jul 2026 15:51:14 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A1=A5=E9=BD=90Agent=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E5=8A=A8=E4=BD=9C=E6=81=A2=E5=A4=8D=E5=B1=8F?= =?UTF-8?q?=E9=9A=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 自动工具动作使用耐久账本记录 approved、executing 与 observed 状态,按崩溃点恢复精确动作或观察 将 needs-reconciliation 提升为 Agent 队列屏障,并在系统锁内按 FIFO 启动 pending 任务 补充自动动作恢复、策略收紧、审计配对、核对队列与并发配置回归测试 同步 Agent Runtime 决策和实施计划,修正项目策略确认文案断言 --- .../src-tauri/src/agent.rs | 560 +++++++-- .../src-tauri/src/tests.rs | 1102 ++++++++++++++++- .../tests/rememberCommand.test.ts | 4 +- .../shared-memory/decision-log.md | 2 + ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 2 + 5 files changed, 1563 insertions(+), 107 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent.rs b/apps/ai-game-creator-shell/src-tauri/src/agent.rs index 611a602d7..237790f5c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -10,6 +10,8 @@ pub(crate) const AGENT_RUNTIME_PENDING_ACTION_STATUS_APPROVED: &str = "approved" pub(crate) const AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING: &str = "executing"; const AGENT_RUNTIME_PENDING_ACTION_STATUS_OBSERVED_APPROVED: &str = "observed-approved"; const AGENT_RUNTIME_PENDING_ACTION_STATUS_OBSERVED_REJECTED: &str = "observed-rejected"; +pub(crate) const AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO: &str = "auto"; +pub(crate) const AGENT_RUNTIME_ACTION_EXECUTION_MODE_CONFIRMATION: &str = "confirmation"; pub(crate) const AGENT_RUNTIME_ACTION_FINGERPRINT_VERSION: &str = "sha256-serde-json-v1"; pub(crate) fn set_game_creator_agent_runtime_update_app_handle(app: tauri::AppHandle) { @@ -413,6 +415,10 @@ fn resume_game_creator_agent_pending_tool_action_at( agent_id: &str, runtime_lock: AgentRuntimeTaskLock, ) -> Result { + if game_creator_agent_runtime_has_reconciliation_barrier(root, agent_id)? { + return read_game_creator_agent_runtime_at(root, agent_id) + .map(AgentRuntimePendingActionResume::Handled); + } let mut runtime = read_game_creator_agent_runtime_at(root, agent_id)?.state; if runtime.run_id.trim().is_empty() { return Ok(AgentRuntimePendingActionResume::NotFound(runtime_lock)); @@ -501,7 +507,11 @@ fn resume_game_creator_agent_pending_tool_action_at( "observation".to_string() }; runtime.current_action = if pending.status == AGENT_RUNTIME_PENDING_ACTION_STATUS_APPROVED { - format!("恢复执行已确认工具 {}", pending.action.tool) + if pending.is_auto() { + format!("恢复执行自动工具 {}", pending.action.tool) + } else { + format!("恢复执行已确认工具 {}", pending.action.tool) + } } else { format!("恢复工具观察 {}", pending.action.tool) }; @@ -512,10 +522,15 @@ fn resume_game_creator_agent_pending_tool_action_at( 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 resume_event_type = if pending.is_auto() { + "tool_action.resume" + } else { + "tool_confirmation.resume" + }; append_game_creator_agent_runtime_event( root, &runtime, - "tool_confirmation.resume", + resume_event_type, "running", runtime.phase.as_str(), "Runtime 正在同一 run 恢复待确认动作。", @@ -623,28 +638,38 @@ fn start_game_creator_agent_background_task_with_source_at( "task": pending_task.task, }), )?; - if let Ok(result) = read_game_creator_agent_runtime_at(root, &agent_id) { - if matches!( - result.state.status.as_str(), - "waiting-for-confirmation" | "cancelling" - ) { - emit_game_creator_agent_runtime_update(root, &agent_id); - return Ok((result, run_id)); - } - } let Some(runtime_lock) = try_acquire_game_creator_agent_runtime_task_lock(root, &agent_id)? else { let result = read_game_creator_agent_runtime_at(root, &agent_id)?; emit_game_creator_agent_runtime_update(root, &agent_id); return Ok((result, run_id)); }; + let result = read_game_creator_agent_runtime_at(root, &agent_id)?; + if matches!( + result.state.status.as_str(), + "waiting-for-confirmation" | "cancelling" + ) || game_creator_agent_runtime_has_reconciliation_barrier(root, &agent_id)? + { + emit_game_creator_agent_runtime_update(root, &agent_id); + return Ok((result, run_id)); + } + let Some(next_task) = read_next_pending_game_creator_agent_runtime_task(root, &agent_id)? + else { + emit_game_creator_agent_runtime_update(root, &agent_id); + return Ok((result, run_id)); + }; + let start_observation = if next_task.run_id == run_id { + "后台任务已投递" + } else { + "后台任务从队首开始执行" + }; let state = start_game_creator_agent_runtime_task_at( root, &agent_id, - task, - &run_id, - source, - "后台任务已投递", + &next_task.task, + &next_task.run_id, + &next_task.source, + start_observation, game_creator_agent_background_task_default_plan(), )?; append_game_creator_agent_background_task_started_record(root, &state)?; @@ -652,7 +677,7 @@ fn start_game_creator_agent_background_task_with_source_at( let result = read_game_creator_agent_runtime_at(root, &agent_id)?; let root = root.to_path_buf(); let background_agent_id = agent_id.clone(); - let background_task = task.to_string(); + let background_task = next_task.task; tauri::async_runtime::spawn(async move { let _runtime_lock = runtime_lock; drain_game_creator_agent_background_tasks( @@ -785,9 +810,7 @@ pub(crate) fn cancel_game_creator_agent_runtime_task_at( } let (_, task, has_pending_action) = resolve_game_creator_agent_runtime_cancel_target(root, &agent_id, &target_run_id)?; - if matches!(task.status.as_str(), "completed" | "cancelled") - || (task.status == "failed" && !has_pending_action) - { + if game_creator_agent_runtime_task_is_terminal_for_cancel(&task, has_pending_action) { return Err(format!( "Agent Runtime 任务已结束,不能取消:{target_run_id}" )); @@ -808,7 +831,7 @@ pub(crate) fn cancel_game_creator_agent_runtime_task_at( if task.status == "cancelled" { return Ok(current_result); } - if task.status == "completed" || (task.status == "failed" && !has_pending_action) { + if game_creator_agent_runtime_task_is_terminal_for_cancel(&task, has_pending_action) { remove_game_creator_agent_runtime_cancel_request(root, &agent_id, &target_run_id); return Err(format!( "Agent Runtime 任务已结束,不能取消:{target_run_id}" @@ -839,7 +862,7 @@ pub(crate) fn cancel_game_creator_agent_runtime_task_at( ); return read_game_creator_agent_runtime_at(root, &agent_id); } - if task.status == "completed" || (task.status == "failed" && !has_pending_action) { + if game_creator_agent_runtime_task_is_terminal_for_cancel(&task, has_pending_action) { remove_game_creator_agent_runtime_cancel_request(root, &agent_id, &target_run_id); return Err(format!( "Agent Runtime 任务已结束,不能取消:{target_run_id}" @@ -865,6 +888,14 @@ pub(crate) fn cancel_game_creator_agent_runtime_task_at( read_game_creator_agent_runtime_at(root, &agent_id) } +fn game_creator_agent_runtime_task_is_terminal_for_cancel( + task: &AgentRuntimeTaskRecord, + has_pending_action: bool, +) -> bool { + matches!(task.status.as_str(), "completed" | "cancelled") + || (task.status == "failed" && task.phase != "needs-reconciliation" && !has_pending_action) +} + fn resolve_game_creator_agent_runtime_cancel_target( root: &Path, agent_id: &str, @@ -960,7 +991,9 @@ pub(crate) fn retry_game_creator_agent_runtime_task_at( let task = read_latest_game_creator_agent_runtime_task_by_run_id(root, &agent_id, &target_run_id)? .ok_or_else(|| format!("未找到 Agent Runtime 任务:{target_run_id}"))?; - if game_creator_agent_runtime_pending_tool_action_path(root, &agent_id, &target_run_id).exists() + if task.phase == "needs-reconciliation" + || game_creator_agent_runtime_pending_tool_action_path(root, &agent_id, &target_run_id) + .exists() { return Err("Agent Runtime 仍保留待核对工具动作,请先核对项目状态并取消原任务".to_string()); } @@ -1234,6 +1267,7 @@ async fn continue_game_creator_agent_pending_tool_action( } let action = pending.action.clone(); let approved = pending.approved(); + let auto_execution = pending.is_auto(); let observation = match pending.status.as_str() { AGENT_RUNTIME_PENDING_ACTION_STATUS_APPROVED => { pending.status = AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING.to_string(); @@ -1249,6 +1283,11 @@ async fn continue_game_creator_agent_pending_tool_action( ); return; } + if auto_execution { + let _ = append_game_creator_agent_runtime_auto_tool_action_executing_record( + &root, &pending, + ); + } let observation = execute_game_creator_agent_runtime_tool_action( &root, &agent_id, @@ -1257,8 +1296,15 @@ async fn continue_game_creator_agent_pending_tool_action( &action, ) .await; - pending.status = AGENT_RUNTIME_PENDING_ACTION_STATUS_OBSERVED_APPROVED.to_string(); - pending.observation = Some(observation.clone()); + if observation.is_waiting_for_confirmation() && auto_execution { + pending.execution_mode = + AGENT_RUNTIME_ACTION_EXECUTION_MODE_CONFIRMATION.to_string(); + pending.status = AGENT_RUNTIME_PENDING_ACTION_STATUS_PENDING.to_string(); + pending.observation = None; + } else { + pending.status = AGENT_RUNTIME_PENDING_ACTION_STATUS_OBSERVED_APPROVED.to_string(); + pending.observation = Some(observation.clone()); + } pending.updated_at = unix_timestamp(); if let Err(error) = write_game_creator_agent_runtime_pending_tool_action(&root, &pending) @@ -1273,6 +1319,13 @@ async fn continue_game_creator_agent_pending_tool_action( ); return; } + if auto_execution { + let _ = append_game_creator_agent_runtime_auto_tool_action_observed_record( + &root, + &pending, + &observation, + ); + } observation } AGENT_RUNTIME_PENDING_ACTION_STATUS_OBSERVED_APPROVED @@ -1316,6 +1369,53 @@ async fn continue_game_creator_agent_pending_tool_action( return; } if observation.is_waiting_for_confirmation() { + if auto_execution && pending.status == AGENT_RUNTIME_PENDING_ACTION_STATUS_PENDING { + let observation_summary = observation.summary(); + runtime.observations.push(observation_summary.clone()); + append_agent_runtime_tool_call_record( + &root, + &mut runtime, + &pending.task, + &action, + &observation, + ); + complete_agent_runtime_active_plan_step( + &mut runtime, + "waiting-for-confirmation", + &observation_summary, + ); + runtime.status = "waiting-for-confirmation".to_string(); + runtime.phase = "waiting-for-confirmation".to_string(); + runtime.current_action = format!("等待确认工具 {}", observation.tool); + runtime.waiting_on = "开发者确认 Agent 工具动作".to_string(); + runtime.next_step = format!("确认或拒绝后继续 Agent 工具动作:{}", observation.tool); + runtime.pending_tool_action = Some(pending.summary()); + runtime.updated_at = unix_timestamp(); + let persistence = append_game_creator_agent_runtime_task(&root, &runtime) + .and_then(|_| refresh_game_creator_agent_runtime_task_queue(&root, &mut runtime)) + .and_then(|_| write_game_creator_agent_runtime_state(&root, &runtime)) + .and_then(|_| { + append_game_creator_agent_runtime_event( + &root, + &runtime, + "tool_confirmation.required_after_resume", + "waiting-for-confirmation", + "waiting-for-confirmation", + "恢复的自动工具动作因策略变化改为等待开发者确认。", + Some(&pending.action_id), + ) + }); + if persistence.is_err() { + let _ = mark_game_creator_agent_runtime_needs_reconciliation_at( + &root, + &mut runtime, + &pending, + "自动工具动作已安全停在确认 gate,但 waiting 状态落盘失败", + ); + } + emit_game_creator_agent_runtime_update(&root, &agent_id); + return; + } let error = "已批准的精确工具动作未通过确认 gate"; let _ = mark_game_creator_agent_runtime_needs_reconciliation_at( &root, @@ -1345,7 +1445,9 @@ async fn continue_game_creator_agent_pending_tool_action( ); runtime.status = "running".to_string(); runtime.phase = "observation".to_string(); - runtime.current_action = if approved { + runtime.current_action = if auto_execution { + format!("已执行自动工具 {}", observation.tool) + } else if approved { format!("已执行确认工具 {}", observation.tool) } else { format!("已拒绝工具 {}", observation.tool) @@ -1380,7 +1482,7 @@ async fn continue_game_creator_agent_pending_tool_action( "status": observation.status, "summary": observation.summary, "actionId": pending.action_id, - "decision": if approved { "approved" } else { "rejected" }, + "decision": if auto_execution { "auto" } else if approved { "approved" } else { "rejected" }, }), ) }); @@ -1394,21 +1496,33 @@ async fn continue_game_creator_agent_pending_tool_action( ); return; } - if let Some(command_id) = game_creator_agent_runtime_tool_command_id(&action.tool) { - let _ = fs::remove_file(game_creator_agent_runtime_tool_confirmation_path( - &root, - &agent_id, - &pending.run_id, - command_id, - )); + if !auto_execution { + if let Some(command_id) = game_creator_agent_runtime_tool_command_id(&action.tool) { + let _ = fs::remove_file(game_creator_agent_runtime_tool_confirmation_path( + &root, + &agent_id, + &pending.run_id, + command_id, + )); + } } + let continued_event_type = if auto_execution { + "tool_action.continued" + } else { + "tool_confirmation.continued" + }; + let continued_summary = if auto_execution { + "自动工具动作观察已持久化,Agent 将在同一 run 继续规划。" + } else { + "待确认动作观察已持久化,Agent 将在同一 run 继续规划。" + }; let _ = append_game_creator_agent_runtime_event( &root, &runtime, - "tool_confirmation.continued", + continued_event_type, "running", "observation", - "待确认动作观察已持久化,Agent 将在同一 run 继续规划。", + continued_summary, Some(&pending.action_id), ); let mut observations = pending.observations.clone(); @@ -1436,6 +1550,16 @@ fn mark_game_creator_agent_runtime_needs_reconciliation_at( pending: &AgentRuntimePendingToolAction, error: &str, ) -> Result<(), String> { + let event_type = if pending.is_auto() { + "tool_action.needs_reconciliation" + } else { + "tool_confirmation.needs_reconciliation" + }; + let record_type = if pending.is_auto() { + "agent.runtime.tool_action.needs_reconciliation" + } else { + "agent.runtime.tool_confirmation.needs_reconciliation" + }; runtime.status = "failed".to_string(); runtime.phase = "needs-reconciliation".to_string(); runtime.current_action = "工具动作结果需要人工核对".to_string(); @@ -1450,7 +1574,7 @@ fn mark_game_creator_agent_runtime_needs_reconciliation_at( append_game_creator_agent_runtime_event( root, runtime, - "tool_confirmation.needs_reconciliation", + event_type, "failed", "needs-reconciliation", "Runtime 无法证明待确认工具动作是否完整落盘,已停止自动重放。", @@ -1459,7 +1583,7 @@ fn mark_game_creator_agent_runtime_needs_reconciliation_at( append_agent_db_record( root, serde_json::json!({ - "recordType": "agent.runtime.tool_confirmation.needs_reconciliation", + "recordType": record_type, "agentId": runtime.agent_id, "taskId": runtime.task_id, "sessionId": runtime.session_id, @@ -1517,7 +1641,7 @@ async fn drain_game_creator_agent_background_tasks( first_state, ) .await - == AgentBackgroundTaskOutcome::WaitingForConfirmation + != AgentBackgroundTaskOutcome::Finished { return; } @@ -1526,6 +1650,10 @@ async fn drain_game_creator_agent_background_tasks( async fn drain_next_game_creator_agent_background_tasks(root: PathBuf, agent_id: String) { loop { + match game_creator_agent_runtime_has_reconciliation_barrier(&root, &agent_id) { + Ok(true) | Err(_) => break, + Ok(false) => {} + } let Some(next_task) = read_next_pending_game_creator_agent_runtime_task(&root, &agent_id) .ok() .flatten() @@ -1557,7 +1685,7 @@ async fn drain_next_game_creator_agent_background_tasks(root: PathBuf, agent_id: state, ) .await - == AgentBackgroundTaskOutcome::WaitingForConfirmation + != AgentBackgroundTaskOutcome::Finished { break; } @@ -1781,61 +1909,162 @@ async fn run_game_creator_agent_background_task_with_context( if stop_game_creator_agent_runtime_if_cancel_requested(&root, &mut runtime) { return AgentBackgroundTaskOutcome::Finished; } - let observation = execute_game_creator_agent_runtime_tool_action( + let mut prepared_action = Some(build_game_creator_agent_runtime_pending_tool_action( &root, - &agent_id, - runtime.run_id.as_str(), + &runtime, &task, + &plan, + &observations, action, - ) - .await; + action_index, + AGENT_RUNTIME_ACTION_EXECUTION_MODE_CONFIRMATION, + AGENT_RUNTIME_PENDING_ACTION_STATUS_PENDING, + None, + )); + let command_id = game_creator_agent_runtime_tool_command_id(action.tool.trim()); + let action_fingerprint = prepared_action + .as_ref() + .map(|pending| pending.action_fingerprint.clone()) + .unwrap_or_default(); + let action_task_context = prepared_action + .as_ref() + .map(|pending| pending.task.clone()) + .unwrap_or_else(|| sanitize_agent_runtime_text(&task, 1_200)); + let policy_block = command_id.and_then(|command_id| { + game_creator_agent_runtime_tool_policy_block( + &root, + &agent_id, + runtime.run_id.as_str(), + command_id, + &action_fingerprint, + ) + }); + let mut durable_action = None; + let observation = if let Some(blocked) = policy_block { + agent_runtime_tool_policy_block_observation(action.tool.trim(), blocked) + } else if command_id.is_some() { + let mut pending_action = prepared_action + .take() + .expect("prepared action exists for a whitelisted tool"); + pending_action.execution_mode = + AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO.to_string(); + pending_action.status = AGENT_RUNTIME_PENDING_ACTION_STATUS_APPROVED.to_string(); + pending_action.updated_at = unix_timestamp(); + if let Err(error) = + write_game_creator_agent_runtime_pending_tool_action(&root, &pending_action) + { + let _ = fail_game_creator_agent_runtime_turn_at(&root, runtime, &error); + let _ = append_local_conversation_message_at( + &root, + Some(&agent_id), + LocalConversationMessage { + role: "assistant".to_string(), + content: format!("后台任务失败:{error}"), + agent_id: None, + }, + ); + return AgentBackgroundTaskOutcome::Finished; + } + pending_action.status = AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING.to_string(); + pending_action.updated_at = unix_timestamp(); + if let Err(error) = + write_game_creator_agent_runtime_pending_tool_action(&root, &pending_action) + { + pending_action.status = + AGENT_RUNTIME_PENDING_ACTION_STATUS_APPROVED.to_string(); + let _ = mark_game_creator_agent_runtime_needs_reconciliation_at( + &root, + &mut runtime, + &pending_action, + &format!("自动工具动作尚未执行,但无法持久化 executing 状态:{error}"), + ); + return AgentBackgroundTaskOutcome::NeedsReconciliation; + } + let _ = append_game_creator_agent_runtime_auto_tool_action_executing_record( + &root, + &pending_action, + ); + let observation = execute_game_creator_agent_runtime_tool_action( + &root, + &agent_id, + runtime.run_id.as_str(), + &pending_action.task, + action, + ) + .await; + if observation.is_waiting_for_confirmation() { + pending_action.execution_mode = + AGENT_RUNTIME_ACTION_EXECUTION_MODE_CONFIRMATION.to_string(); + pending_action.status = AGENT_RUNTIME_PENDING_ACTION_STATUS_PENDING.to_string(); + pending_action.observation = None; + } else { + pending_action.status = + AGENT_RUNTIME_PENDING_ACTION_STATUS_OBSERVED_APPROVED.to_string(); + pending_action.observation = Some(observation.clone()); + } + pending_action.updated_at = unix_timestamp(); + if let Err(error) = + write_game_creator_agent_runtime_pending_tool_action(&root, &pending_action) + { + let _ = mark_game_creator_agent_runtime_needs_reconciliation_at( + &root, + &mut runtime, + &pending_action, + &format!("自动工具动作已返回,但无法持久化 observation:{error}"), + ); + return AgentBackgroundTaskOutcome::NeedsReconciliation; + } + let _ = append_game_creator_agent_runtime_auto_tool_action_observed_record( + &root, + &pending_action, + &observation, + ); + durable_action = Some(pending_action); + observation + } else { + execute_game_creator_agent_runtime_tool_action( + &root, + &agent_id, + runtime.run_id.as_str(), + &action_task_context, + action, + ) + .await + }; if stop_game_creator_agent_runtime_if_cancel_requested(&root, &mut runtime) { return AgentBackgroundTaskOutcome::Finished; } let observation_summary = observation.summary(); runtime.observations.push(observation_summary.clone()); - append_agent_runtime_tool_call_record(&root, &mut runtime, &task, action, &observation); + append_agent_runtime_tool_call_record( + &root, + &mut runtime, + &action_task_context, + action, + &observation, + ); if observation.is_waiting_for_confirmation() { - let action_fingerprint = agent_runtime_tool_action_fingerprint(action, &task); - let occurrence_nonce = unix_timestamp_nanos().min(u128::from(u64::MAX)) as u64; - let action_index = u32::try_from(action_index).unwrap_or(u32::MAX); - let action_id = agent_runtime_tool_action_id( - &runtime.run_id, - runtime.loop_iteration, - action_index, - occurrence_nonce, - &action_fingerprint, - ); - let now = unix_timestamp(); - let pending_action = AgentRuntimePendingToolAction { - schema_version: AGENT_RUNTIME_PENDING_ACTION_SCHEMA_VERSION.to_string(), - fingerprint_version: AGENT_RUNTIME_ACTION_FINGERPRINT_VERSION.to_string(), - agent_id: runtime.agent_id.clone(), - task_id: runtime.task_id.clone(), - session_id: runtime.session_id.clone(), - run_id: runtime.run_id.clone(), - source: runtime.source.clone(), - task: runtime.current_task.clone(), - loop_iteration: runtime.loop_iteration, - action_index, - occurrence_nonce, - thinking_summary: sanitize_agent_runtime_text(&plan.thinking_summary, 240), - plan: plan - .plan - .iter() - .map(|item| sanitize_agent_runtime_text(item, 180)) - .collect(), - fallback_response: sanitize_agent_runtime_text(&plan.response, 1_200), - observations: observations.clone(), - action: action.clone(), - action_id, - action_fingerprint, - input_summary: agent_runtime_tool_action_input_summary(&root, action), - status: AGENT_RUNTIME_PENDING_ACTION_STATUS_PENDING.to_string(), - observation: None, - created_at: now, - updated_at: now, - }; + let mut pending_action = durable_action.take().unwrap_or_else(|| { + prepared_action.take().unwrap_or_else(|| { + build_game_creator_agent_runtime_pending_tool_action( + &root, + &runtime, + &task, + &plan, + &observations, + action, + action_index, + AGENT_RUNTIME_ACTION_EXECUTION_MODE_CONFIRMATION, + AGENT_RUNTIME_PENDING_ACTION_STATUS_PENDING, + None, + ) + }) + }); + pending_action.execution_mode = + AGENT_RUNTIME_ACTION_EXECUTION_MODE_CONFIRMATION.to_string(); + pending_action.status = AGENT_RUNTIME_PENDING_ACTION_STATUS_PENDING.to_string(); + pending_action.observation = None; + pending_action.updated_at = unix_timestamp(); if let Err(error) = write_game_creator_agent_runtime_pending_tool_action(&root, &pending_action) { @@ -1865,6 +2094,7 @@ async fn run_game_creator_agent_background_task_with_context( ); } else { runtime.pending_tool_action = None; + runtime.phase = "observation".to_string(); complete_agent_runtime_active_plan_step( &mut runtime, if observation.status == "ok" { @@ -1923,6 +2153,15 @@ async fn run_game_creator_agent_background_task_with_context( ); return AgentBackgroundTaskOutcome::WaitingForConfirmation; } + if let Some(pending_action) = durable_action.as_ref() { + let _ = mark_game_creator_agent_runtime_needs_reconciliation_at( + &root, + &mut runtime, + pending_action, + &format!("工具 observation 已持久化,但 Runtime 状态落盘失败:{error}"), + ); + return AgentBackgroundTaskOutcome::NeedsReconciliation; + } let _ = fail_game_creator_agent_runtime_turn_at(&root, runtime, &error); return AgentBackgroundTaskOutcome::Finished; } @@ -2114,6 +2353,7 @@ pub(crate) const AGENT_RUNTIME_LOCK_STALE_AFTER_SECONDS: u64 = 300; enum AgentBackgroundTaskOutcome { Finished, WaitingForConfirmation, + NeedsReconciliation, } #[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] @@ -2171,6 +2411,8 @@ pub(crate) struct AgentRuntimePendingToolAction { pub(crate) action_id: String, pub(crate) action_fingerprint: String, pub(crate) input_summary: Option, + #[serde(default)] + pub(crate) execution_mode: String, pub(crate) status: String, pub(crate) observation: Option, pub(crate) created_at: u64, @@ -2211,6 +2453,107 @@ impl AgentRuntimePendingToolAction { | AGENT_RUNTIME_PENDING_ACTION_STATUS_OBSERVED_APPROVED ) } + + fn is_auto(&self) -> bool { + self.execution_mode == AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO + } +} + +fn build_game_creator_agent_runtime_pending_tool_action( + root: &Path, + runtime: &AgentRuntimeState, + task: &str, + plan: &AgentRuntimeToolPlan, + observations: &[AgentRuntimeToolObservation], + action: &AgentRuntimeToolAction, + action_index: usize, + execution_mode: &str, + status: &str, + observation: Option, +) -> AgentRuntimePendingToolAction { + let task = sanitize_agent_runtime_text(task, 1_200); + let action_fingerprint = agent_runtime_tool_action_fingerprint(action, &task); + let occurrence_nonce = unix_timestamp_nanos().min(u128::from(u64::MAX)) as u64; + let action_index = u32::try_from(action_index).unwrap_or(u32::MAX); + let action_id = agent_runtime_tool_action_id( + &runtime.run_id, + runtime.loop_iteration, + action_index, + occurrence_nonce, + &action_fingerprint, + ); + let now = unix_timestamp(); + AgentRuntimePendingToolAction { + schema_version: AGENT_RUNTIME_PENDING_ACTION_SCHEMA_VERSION.to_string(), + fingerprint_version: AGENT_RUNTIME_ACTION_FINGERPRINT_VERSION.to_string(), + agent_id: runtime.agent_id.clone(), + task_id: runtime.task_id.clone(), + session_id: runtime.session_id.clone(), + run_id: runtime.run_id.clone(), + source: runtime.source.clone(), + task, + loop_iteration: runtime.loop_iteration, + action_index, + occurrence_nonce, + thinking_summary: sanitize_agent_runtime_text(&plan.thinking_summary, 240), + plan: plan + .plan + .iter() + .map(|item| sanitize_agent_runtime_text(item, 180)) + .collect(), + fallback_response: sanitize_agent_runtime_text(&plan.response, 1_200), + observations: observations.to_vec(), + action: action.clone(), + action_id, + action_fingerprint, + input_summary: agent_runtime_tool_action_input_summary(root, action), + execution_mode: execution_mode.to_string(), + status: status.to_string(), + observation, + created_at: now, + updated_at: now, + } +} + +fn append_game_creator_agent_runtime_auto_tool_action_executing_record( + root: &Path, + pending: &AgentRuntimePendingToolAction, +) -> Result<(), String> { + append_agent_db_record( + root, + serde_json::json!({ + "recordType": "agent.runtime.tool_action.executing", + "agentId": pending.agent_id, + "taskId": pending.task_id, + "runId": pending.run_id, + "actionId": pending.action_id, + "actionFingerprint": pending.action_fingerprint, + "tool": pending.action.tool, + "executionMode": AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO, + "inputSummary": pending.input_summary, + }), + ) +} + +fn append_game_creator_agent_runtime_auto_tool_action_observed_record( + root: &Path, + pending: &AgentRuntimePendingToolAction, + observation: &AgentRuntimeToolObservation, +) -> Result<(), String> { + append_agent_db_record( + root, + serde_json::json!({ + "recordType": "agent.runtime.tool_action.observed", + "agentId": pending.agent_id, + "taskId": pending.task_id, + "runId": pending.run_id, + "actionId": pending.action_id, + "actionFingerprint": pending.action_fingerprint, + "tool": pending.action.tool, + "executionMode": AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO, + "observationStatus": observation.status, + }), + ) } impl AgentRuntimeToolObservation { @@ -2229,6 +2572,24 @@ enum AgentRuntimeToolPolicyBlock { RequiresConfirmation(String), } +fn agent_runtime_tool_policy_block_observation( + tool: &str, + blocked: AgentRuntimeToolPolicyBlock, +) -> AgentRuntimeToolObservation { + let (status, summary) = match blocked { + AgentRuntimeToolPolicyBlock::Denied(summary) => ("blocked", summary), + AgentRuntimeToolPolicyBlock::RequiresConfirmation(summary) => { + ("waiting-for-confirmation", summary) + } + }; + AgentRuntimeToolObservation { + tool: tool.to_string(), + status: status.to_string(), + summary, + detail: None, + } +} + fn append_agent_runtime_tool_call_record( root: &Path, runtime: &mut AgentRuntimeState, @@ -2689,18 +3050,7 @@ pub(crate) async fn execute_game_creator_agent_runtime_tool_action( command_id, &action_fingerprint, ) { - let (status, summary) = match blocked { - AgentRuntimeToolPolicyBlock::Denied(summary) => ("blocked", summary), - AgentRuntimeToolPolicyBlock::RequiresConfirmation(summary) => { - ("waiting-for-confirmation", summary) - } - }; - return AgentRuntimeToolObservation { - tool: tool.to_string(), - status: status.to_string(), - summary, - detail: None, - }; + return agent_runtime_tool_policy_block_observation(tool, blocked); } } else { return AgentRuntimeToolObservation { @@ -6062,6 +6412,22 @@ fn read_next_pending_game_creator_agent_runtime_task( .find(|record| record.status == "pending")) } +fn game_creator_agent_runtime_has_reconciliation_barrier( + root: &Path, + agent_id: &str, +) -> Result { + let runtime = read_game_creator_agent_runtime_at(root, agent_id)?; + if runtime.state.phase == "needs-reconciliation" { + return Ok(true); + } + let path = game_creator_agent_runtime_task_path(root, agent_id); + let records = + latest_game_creator_agent_runtime_tasks(read_all_game_creator_agent_runtime_tasks(&path)?); + Ok(records + .into_iter() + .any(|record| record.phase == "needs-reconciliation")) +} + fn read_latest_game_creator_agent_runtime_task_by_run_id( root: &Path, agent_id: &str, diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/tests.rs index ae6b4c924..94f548cff 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests.rs @@ -23,7 +23,7 @@ struct TestRuntimeConfigDirGuard { impl Drop for TestConfigGuard { fn drop(&mut self) { if let Some(previous) = &self.previous { - fs::write(&self.path, previous).expect("restore local config"); + replace_test_local_config(&self.path, previous); } else if self.path.exists() { fs::remove_file(&self.path).expect("remove local config"); } @@ -132,6 +132,7 @@ fn pending_tool_action_for_test( ), action_fingerprint, input_summary: Some("path=game/notes.txt".to_string()), + execution_mode: AGENT_RUNTIME_ACTION_EXECUTION_MODE_CONFIRMATION.to_string(), status: status.to_string(), observation, created_at: now, @@ -139,6 +140,143 @@ fn pending_tool_action_for_test( } } +fn persist_needs_reconciliation_runtime_for_test( + root: &Path, + run_id: &str, + with_pending_ledger: bool, +) -> AgentRuntimeState { + let mut state = start_game_creator_agent_runtime_task_at( + root, + "design-director", + "核对结果未知的自动工具动作", + run_id, + "agent-background-task", + "模拟 Runtime 需要人工核对", + vec!["等待人工核对动作副作用".to_string()], + ) + .expect("start reconciliation runtime"); + state.loop_iteration = 1; + if with_pending_ledger { + let action = AgentRuntimeToolAction { + tool: "file.write".to_string(), + reason: Some("结果未知的项目文件写入".to_string()), + input: serde_json::json!({ + "path": "game/reconciliation-side-effect.txt", + "content": "不允许在人工核对前重放" + }), + }; + let mut pending = pending_tool_action_for_test( + &state, + action, + AGENT_RUNTIME_PENDING_ACTION_STATUS_APPROVED, + None, + ); + pending.execution_mode = AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO.to_string(); + pending.input_summary = + Some("path=game/reconciliation-side-effect.txt, contentChars=12".to_string()); + write_game_creator_agent_runtime_pending_tool_action(root, &pending) + .expect("write reconciliation pending ledger"); + state.pending_tool_action = Some(pending.summary()); + } + state.status = "failed".to_string(); + state.phase = "needs-reconciliation".to_string(); + state.current_action = "工具动作结果需要人工核对".to_string(); + state.waiting_on = "开发者核对项目副作用".to_string(); + state.next_step = "核对后取消原任务".to_string(); + state.error = Some("模拟工具副作用结果未知".to_string()); + append_game_creator_agent_runtime_task(root, &state).expect("append reconciliation task"); + write_game_creator_agent_runtime_state(root, &state).expect("write reconciliation state"); + state +} + +fn read_agent_db_records_for_test(root: &Path) -> Vec { + fs::read_to_string(root.join(".agent/agent.db")) + .expect("agent db") + .lines() + .filter(|line| !line.trim().is_empty()) + .map(|line| serde_json::from_str::(line).expect("agent db record")) + .collect() +} + +fn assert_auto_tool_action_audit_pair( + records: &[Value], + run_id: &str, + action_id: &str, + action_fingerprint: &str, + tool: &str, + observation_status: &str, +) { + let matching = |record_type: &str| { + records + .iter() + .enumerate() + .filter(|(_, record)| { + record["recordType"] == record_type + && record["runId"] == run_id + && record["actionId"] == action_id + }) + .collect::>() + }; + let executing = matching("agent.runtime.tool_action.executing"); + let observed = matching("agent.runtime.tool_action.observed"); + assert_eq!(executing.len(), 1, "executing audit for {action_id}"); + assert_eq!(observed.len(), 1, "observed audit for {action_id}"); + let (executing_index, executing) = executing[0]; + let (observed_index, observed) = observed[0]; + assert!( + executing_index < observed_index, + "executing audit must precede observed audit for {action_id}" + ); + for record in [executing, observed] { + assert_eq!(record["runId"], run_id); + assert_eq!(record["actionId"], action_id); + assert_eq!(record["actionFingerprint"], action_fingerprint); + assert_eq!(record["tool"], tool); + assert_eq!( + record["executionMode"], + AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO + ); + } + assert_eq!(observed["observationStatus"], observation_status); +} + +fn append_auto_tool_action_audit_pair_for_test( + root: &Path, + pending: &AgentRuntimePendingToolAction, + observation: &AgentRuntimeToolObservation, +) { + append_agent_db_record( + root, + serde_json::json!({ + "recordType": "agent.runtime.tool_action.executing", + "agentId": pending.agent_id, + "taskId": pending.task_id, + "runId": pending.run_id, + "actionId": pending.action_id, + "actionFingerprint": pending.action_fingerprint, + "tool": pending.action.tool, + "executionMode": AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO, + "inputSummary": pending.input_summary, + }), + ) + .expect("append executing audit fixture"); + append_agent_db_record( + root, + serde_json::json!({ + "recordType": "agent.runtime.tool_action.observed", + "agentId": pending.agent_id, + "taskId": pending.task_id, + "runId": pending.run_id, + "actionId": pending.action_id, + "actionFingerprint": pending.action_fingerprint, + "tool": pending.action.tool, + "executionMode": AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO, + "observationStatus": observation.status, + }), + ) + .expect("append observed audit fixture"); +} + fn assert_pending_runtime_decision_revalidates_after_lock( decision: fn(&Path, &str, &str, &str, &str) -> Result, decision_name: &str, @@ -236,11 +374,24 @@ fn test_local_config_path() -> PathBuf { .join(GAME_CREATOR_LOCAL_CONFIG_FILE_NAME) } +fn replace_test_local_config(path: &Path, content: impl AsRef<[u8]>) { + let temp_path = path.with_file_name(format!( + ".{}.test.{}.{}", + path.file_name() + .and_then(|value| value.to_str()) + .unwrap_or("game-creator.config.local.json"), + std::process::id(), + TEST_PROJECT_COUNTER.fetch_add(1, Ordering::Relaxed) + )); + fs::write(&temp_path, content).expect("write temporary local config"); + fs::rename(&temp_path, path).expect("replace local config"); +} + fn write_test_local_config(content: String) -> TestConfigGuard { let lock = TEST_CONFIG_LOCK.lock().expect("test config lock"); let path = test_local_config_path(); let previous = fs::read(&path).ok(); - fs::write(&path, content).expect("write local config"); + replace_test_local_config(&path, content); TestConfigGuard { _lock: lock, path, @@ -1047,6 +1198,20 @@ fn spawn_releasable_mock_llm_server_responses_with_capture( response_contents: Vec, request_sender: mpsc::Sender, first_release_receiver: mpsc::Receiver<()>, +) -> String { + spawn_releasable_mock_llm_server_responses_with_capture_at( + response_contents, + request_sender, + 0, + first_release_receiver, + ) +} + +fn spawn_releasable_mock_llm_server_responses_with_capture_at( + response_contents: Vec, + request_sender: mpsc::Sender, + release_index: usize, + release_receiver: mpsc::Receiver<()>, ) -> String { let listener = TcpListener::bind(("127.0.0.1", 0)).expect("mock llm bind"); let base_url = format!("http://{}", listener.local_addr().expect("mock llm addr")); @@ -1057,8 +1222,10 @@ fn spawn_releasable_mock_llm_server_responses_with_capture( let read_len = stream.read(&mut request_buffer).unwrap_or(0); let _ = request_sender .send(String::from_utf8_lossy(&request_buffer[..read_len]).into_owned()); - if index == 0 { - let _ = first_release_receiver.recv_timeout(Duration::from_secs(3)); + if index == release_index { + release_receiver + .recv_timeout(Duration::from_secs(10)) + .expect("mock llm response release"); } let body = serde_json::json!({ "id": "resp_game_creator_mock", @@ -3724,6 +3891,37 @@ async fn background_agent_runtime_can_write_memory_and_project_files() { assert!(agent_db.contains("\"recordType\":\"agent.runtime.memory.write\"")); assert!(agent_db.contains("\"recordType\":\"agent.runtime.file.write\"")); assert!(agent_db.contains("\"path\":\"game/agent-notes.md\"")); + let records = read_agent_db_records_for_test(&root); + let executing_records = records + .iter() + .filter(|record| { + record["recordType"] == "agent.runtime.tool_action.executing" + && record["runId"] == "design-write-run" + }) + .collect::>(); + assert_eq!(executing_records.len(), 3); + let mut action_ids = executing_records + .iter() + .map(|record| record["actionId"].as_str().expect("action id").to_string()) + .collect::>(); + action_ids.sort(); + action_ids.dedup(); + assert_eq!(action_ids.len(), 3); + for executing in executing_records { + assert_auto_tool_action_audit_pair( + &records, + "design-write-run", + executing["actionId"].as_str().expect("action id"), + executing["actionFingerprint"] + .as_str() + .expect("action fingerprint"), + executing["tool"].as_str().expect("tool"), + "ok", + ); + } + assert!(!root + .join(".agent/runtime/pending-actions/design-director/design-write-run.json") + .exists()); fs::remove_dir_all(root).ok(); } @@ -5315,6 +5513,123 @@ fn pending_tool_action_identity_binds_task_context_and_occurrence() { assert_ne!(first_action_id, repeated_action_id); } +#[tokio::test] +async fn background_agent_runtime_keeps_auto_ledger_while_followup_plan_is_in_flight() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init"); + write_project_permission_policy_at( + &root, + ProjectPermissionPolicy { + denied_commands: Vec::new(), + confirm_commands: Vec::new(), + agent_policies: BTreeMap::new(), + }, + ) + .expect("allow runtime writes"); + let (request_sender, request_receiver) = mpsc::channel(); + let (release_sender, release_receiver) = mpsc::channel(); + let action_plan = serde_json::json!({ + "thinkingSummary": "先写入唯一动作标记", + "plan": ["写入私有记忆", "根据观察回复"], + "actions": [{ + "tool": "memory.write", + "reason": "验证后续规划期间仍保留动作账本", + "input": { + "scope": "agent", + "title": "规划中账本标记", + "content": "规划未完成时不能遗失这条动作账本。" + } + }], + "response": "" + }) + .to_string(); + let final_plan = serde_json::json!({ + "thinkingSummary": "已经收到写入观察", + "plan": [], + "actions": [], + "response": "动作账本已覆盖到后续规划完成。" + }) + .to_string(); + let base_url = spawn_releasable_mock_llm_server_responses_with_capture_at( + vec![action_plan, final_plan], + request_sender, + 1, + release_receiver, + ); + let _config_guard = write_test_local_config(format!( + r#"{{ + "agentLlm": {{ + "design-director": {{ + "apiKey": "design-key", + "baseUrl": {base_url:?}, + "model": "design-runtime-model", + "apiKind": "openai_responses" + }} + }} +}}"# + )); + + start_game_creator_agent_background_task_at( + &root, + "design-director", + "验证自动动作账本覆盖后续规划", + "design-auto-ledger-inflight-run", + ) + .expect("start background task"); + request_receiver + .recv_timeout(Duration::from_secs(2)) + .expect("initial plan request"); + let followup_request = request_receiver + .recv_timeout(Duration::from_secs(2)) + .expect("blocked followup plan request"); + assert!(followup_request.contains("已写入 Agent 记忆 design-director")); + let pending_path = root.join( + ".agent/runtime/pending-actions/design-director/design-auto-ledger-inflight-run.json", + ); + let pending: AgentRuntimePendingToolAction = serde_json::from_str( + &fs::read_to_string(&pending_path).expect("auto ledger while followup plan is blocked"), + ) + .expect("parse auto ledger"); + assert_eq!( + pending.execution_mode, + AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO + ); + assert_eq!(pending.status, "observed-approved"); + assert_eq!( + pending + .observation + .as_ref() + .map(|observation| observation.status.as_str()), + Some("ok") + ); + let memory = read_local_agent_memory_at(&root, "design-director").expect("agent memory"); + assert_eq!( + memory + .content + .matches("规划未完成时不能遗失这条动作账本。") + .count(), + 1 + ); + + release_sender.send(()).expect("release followup plan"); + let runtime = wait_for_agent_runtime_idle(&root, "design-director"); + assert_eq!( + runtime.last_response.as_deref(), + Some("动作账本已覆盖到后续规划完成。") + ); + assert!(!pending_path.exists()); + let memory = read_local_agent_memory_at(&root, "design-director").expect("agent memory"); + assert_eq!( + memory + .content + .matches("规划未完成时不能遗失这条动作账本。") + .count(), + 1 + ); + + fs::remove_dir_all(root).ok(); +} + #[tokio::test] async fn background_agent_runtime_resumes_approved_pending_action_without_llm_replay() { let root = unique_project_path(); @@ -5409,6 +5724,251 @@ async fn background_agent_runtime_resumes_approved_pending_action_without_llm_re fs::remove_dir_all(root).ok(); } +#[tokio::test] +async fn background_agent_runtime_resumes_approved_auto_action_once_without_llm_replay() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init"); + write_project_permission_policy_at( + &root, + ProjectPermissionPolicy { + denied_commands: Vec::new(), + confirm_commands: Vec::new(), + agent_policies: BTreeMap::new(), + }, + ) + .expect("allow runtime writes"); + let (sender, receiver) = mpsc::channel(); + let final_plan = serde_json::json!({ + "thinkingSummary": "已经恢复自动动作的观察", + "plan": [], + "actions": [], + "response": "恢复后只写入了一次私有记忆。" + }) + .to_string(); + let base_url = spawn_mock_llm_server_responses_with_capture(vec![final_plan], Some(sender)); + let _config_guard = write_test_local_config(format!( + r#"{{ + "agentLlm": {{ + "design-director": {{ + "apiKey": "design-key", + "baseUrl": {base_url:?}, + "model": "design-runtime-model", + "apiKind": "openai_responses" + }} + }} +}}"# + )); + let mut state = start_game_creator_agent_runtime_task_at( + &root, + "design-director", + "恢复自动私有记忆写入", + "design-auto-approved-recovery-run", + "agent-background-task", + "模拟 approved 自动动作落盘后进程退出", + vec!["写入一次私有记忆".to_string()], + ) + .expect("start runtime state"); + state.loop_iteration = 1; + let action = AgentRuntimeToolAction { + tool: "memory.write".to_string(), + reason: Some("记录唯一恢复标记".to_string()), + input: serde_json::json!({ + "scope": "agent", + "title": "自动恢复唯一标记", + "content": "这条自动动作只能写入一次。" + }), + }; + let mut pending = pending_tool_action_for_test( + &state, + action, + AGENT_RUNTIME_PENDING_ACTION_STATUS_APPROVED, + None, + ); + pending.execution_mode = AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO.to_string(); + pending.input_summary = Some("scope=agent, title=自动恢复唯一标记".to_string()); + write_game_creator_agent_runtime_pending_tool_action(&root, &pending) + .expect("write approved auto action"); + state.status = "running".to_string(); + state.phase = "action".to_string(); + state.pending_tool_action = Some(pending.summary()); + append_game_creator_agent_runtime_task(&root, &state).expect("append running task"); + write_game_creator_agent_runtime_state(&root, &state).expect("write running state"); + + let resumed = + resume_game_creator_agent_background_tasks_at(&root).expect("resume approved auto action"); + assert!(resumed.iter().any(|runtime| { + runtime.state.run_id == "design-auto-approved-recovery-run" + && runtime.state.status == "running" + })); + let replan_request = receiver + .recv_timeout(Duration::from_secs(2)) + .expect("replan after recovered auto observation"); + assert!(replan_request.contains("已写入 Agent 记忆 design-director")); + assert!(receiver.recv_timeout(Duration::from_millis(200)).is_err()); + let runtime = wait_for_agent_runtime_idle(&root, "design-director"); + assert_eq!(runtime.run_id, "design-auto-approved-recovery-run"); + assert_eq!( + runtime.last_response.as_deref(), + Some("恢复后只写入了一次私有记忆。") + ); + let memory = read_local_agent_memory_at(&root, "design-director").expect("agent memory"); + assert_eq!( + memory.content.matches("这条自动动作只能写入一次。").count(), + 1 + ); + assert!(resume_game_creator_agent_background_tasks_at(&root) + .expect("second recovery scan") + .is_empty()); + let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("agent db"); + assert_eq!( + agent_db + .matches("\"recordType\":\"agent.runtime.memory.write\"") + .count(), + 1 + ); + let records = read_agent_db_records_for_test(&root); + assert_auto_tool_action_audit_pair( + &records, + "design-auto-approved-recovery-run", + &pending.action_id, + &pending.action_fingerprint, + "memory.write", + "ok", + ); + assert!(!root + .join( + ".agent/runtime/pending-actions/design-director/design-auto-approved-recovery-run.json" + ) + .exists()); + + fs::remove_dir_all(root).ok(); +} + +#[tokio::test] +async fn background_agent_runtime_resumes_observed_auto_action_without_reexecution() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init"); + write_project_permission_policy_at( + &root, + ProjectPermissionPolicy { + denied_commands: Vec::new(), + confirm_commands: Vec::new(), + agent_policies: BTreeMap::new(), + }, + ) + .expect("allow runtime writes"); + let (sender, receiver) = mpsc::channel(); + let final_plan = serde_json::json!({ + "thinkingSummary": "已收到崩溃前持久化的观察", + "plan": [], + "actions": [], + "response": "已从观察继续,没有重放工具。" + }) + .to_string(); + let base_url = spawn_mock_llm_server_responses_with_capture(vec![final_plan], Some(sender)); + let _config_guard = write_test_local_config(format!( + r#"{{ + "agentLlm": {{ + "design-director": {{ + "apiKey": "design-key", + "baseUrl": {base_url:?}, + "model": "design-runtime-model", + "apiKind": "openai_responses" + }} + }} +}}"# + )); + let mut state = start_game_creator_agent_runtime_task_at( + &root, + "design-director", + "恢复已观察的自动私有记忆写入", + "design-auto-observed-recovery-run", + "agent-background-task", + "模拟 observation 落盘后进程退出", + vec!["复用既有工具观察".to_string()], + ) + .expect("start runtime state"); + state.loop_iteration = 1; + let input = serde_json::json!({ + "scope": "agent", + "title": "已观察恢复唯一标记", + "content": "这条已观察动作不能再次执行。" + }); + let action = AgentRuntimeToolAction { + tool: "memory.write".to_string(), + reason: Some("记录已完成动作".to_string()), + input: input.clone(), + }; + let observation = execute_game_creator_agent_runtime_tool_action( + &root, + "design-director", + &state.run_id, + &state.current_task, + &action, + ) + .await; + assert_eq!(observation.status, "ok"); + let mut pending = pending_tool_action_for_test( + &state, + action, + "observed-approved", + Some(observation.clone()), + ); + pending.execution_mode = AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO.to_string(); + pending.input_summary = Some("scope=agent, title=已观察恢复唯一标记".to_string()); + write_game_creator_agent_runtime_pending_tool_action(&root, &pending) + .expect("write observed auto action"); + append_auto_tool_action_audit_pair_for_test(&root, &pending, &observation); + state.status = "running".to_string(); + state.phase = "observation".to_string(); + state.pending_tool_action = Some(pending.summary()); + append_game_creator_agent_runtime_task(&root, &state).expect("append observed task"); + write_game_creator_agent_runtime_state(&root, &state).expect("write observed state"); + + resume_game_creator_agent_background_tasks_at(&root).expect("resume observed auto action"); + let replan_request = receiver + .recv_timeout(Duration::from_secs(2)) + .expect("replan from durable observation"); + assert!(replan_request.contains("已写入 Agent 记忆 design-director")); + assert!(receiver.recv_timeout(Duration::from_millis(200)).is_err()); + let runtime = wait_for_agent_runtime_idle(&root, "design-director"); + assert_eq!( + runtime.last_response.as_deref(), + Some("已从观察继续,没有重放工具。") + ); + let memory = read_local_agent_memory_at(&root, "design-director").expect("agent memory"); + assert_eq!( + memory + .content + .matches("这条已观察动作不能再次执行。") + .count(), + 1 + ); + let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("agent db"); + assert_eq!( + agent_db + .matches("\"recordType\":\"agent.runtime.memory.write\"") + .count(), + 1 + ); + let records = read_agent_db_records_for_test(&root); + assert_auto_tool_action_audit_pair( + &records, + "design-auto-observed-recovery-run", + &pending.action_id, + &pending.action_fingerprint, + "memory.write", + "ok", + ); + assert!(!root + .join( + ".agent/runtime/pending-actions/design-director/design-auto-observed-recovery-run.json" + ) + .exists()); + + fs::remove_dir_all(root).ok(); +} + #[test] fn background_agent_runtime_does_not_replay_interrupted_tool_execution() { let root = unique_project_path(); @@ -5438,18 +5998,30 @@ fn background_agent_runtime_does_not_replay_interrupted_tool_execution() { AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING, None, ); + pending.execution_mode = AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO.to_string(); pending.input_summary = Some("path=game/notes.txt, contentChars=7".to_string()); write_game_creator_agent_runtime_pending_tool_action(&root, &pending) .expect("write executing pending action"); + append_agent_db_record( + &root, + serde_json::json!({ + "recordType": "agent.runtime.tool_action.executing", + "agentId": pending.agent_id, + "taskId": pending.task_id, + "runId": pending.run_id, + "actionId": pending.action_id, + "actionFingerprint": pending.action_fingerprint, + "tool": pending.action.tool, + "executionMode": AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO, + "inputSummary": pending.input_summary, + }), + ) + .expect("append executing audit fixture"); state.status = "running".to_string(); state.phase = "action".to_string(); state.pending_tool_action = Some(pending.summary()); append_game_creator_agent_runtime_task(&root, &state).expect("append running task"); write_game_creator_agent_runtime_state(&root, &state).expect("write running state"); - let cancel_path = - root.join(".agent/runtime/cancel/design-director/design-executing-recovery-run.json"); - fs::create_dir_all(cancel_path.parent().expect("cancel parent")).expect("cancel dir"); - fs::write(&cancel_path, "{}\n").expect("write pre-restart cancel tombstone"); let resumed = resume_game_creator_agent_background_tasks_at(&root) .expect("inspect interrupted execution"); @@ -5464,8 +6036,21 @@ fn background_agent_runtime_does_not_replay_interrupted_tool_execution() { .error .as_deref() .is_some_and(|error| error.contains("不会自动重放"))); - assert!(cancel_path.exists()); assert!(!root.join("game/notes.txt").exists()); + let pending_path = root + .join(".agent/runtime/pending-actions/design-director/design-executing-recovery-run.json"); + let persisted: AgentRuntimePendingToolAction = + serde_json::from_str(&fs::read_to_string(&pending_path).expect("executing ledger remains")) + .expect("parse executing ledger"); + assert_eq!( + persisted.status, + AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING + ); + assert_eq!( + persisted.execution_mode, + AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO + ); + assert_eq!(persisted.action_id, pending.action_id); assert!(retry_game_creator_agent_runtime_task_at( &root, "design-director", @@ -5474,6 +6059,25 @@ fn background_agent_runtime_does_not_replay_interrupted_tool_execution() { ) .expect_err("reconciliation task cannot be retried before cancellation") .contains("请先核对项目状态并取消原任务")); + let records = read_agent_db_records_for_test(&root); + assert_eq!( + records + .iter() + .filter(|record| { + record["recordType"] == "agent.runtime.tool_action.executing" + && record["actionId"] == pending.action_id + }) + .count(), + 1 + ); + assert!(!records.iter().any(|record| { + record["recordType"] == "agent.runtime.tool_action.observed" + && record["actionId"] == pending.action_id + })); + assert!(records.iter().any(|record| { + record["recordType"] == "agent.runtime.tool_action.needs_reconciliation" + && record["actionId"] == pending.action_id + })); cancel_game_creator_agent_runtime_task_at( &root, "design-director", @@ -5487,6 +6091,396 @@ fn background_agent_runtime_does_not_replay_interrupted_tool_execution() { fs::remove_dir_all(root).ok(); } +#[test] +fn background_agent_runtime_needs_reconciliation_blocks_approved_auto_recovery() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init"); + let mut state = start_game_creator_agent_runtime_task_at( + &root, + "design-director", + "核对未确定是否执行的自动记忆写入", + "design-auto-reconciliation-barrier-run", + "agent-background-task", + "模拟 Runtime 已判定需要人工核对", + vec!["等待人工核对动作副作用".to_string()], + ) + .expect("start runtime state"); + state.loop_iteration = 1; + let action = AgentRuntimeToolAction { + tool: "memory.write".to_string(), + reason: Some("不允许在核对状态中恢复".to_string()), + input: serde_json::json!({ + "scope": "agent", + "title": "不应执行的恢复动作", + "content": "needs-reconciliation 不能执行这条动作。" + }), + }; + let mut pending = pending_tool_action_for_test( + &state, + action, + AGENT_RUNTIME_PENDING_ACTION_STATUS_APPROVED, + None, + ); + pending.execution_mode = AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO.to_string(); + pending.input_summary = Some("scope=agent, title=不应执行的恢复动作".to_string()); + write_game_creator_agent_runtime_pending_tool_action(&root, &pending) + .expect("write approved disk ledger"); + state.status = "failed".to_string(); + state.phase = "needs-reconciliation".to_string(); + state.current_action = "工具动作结果需要人工核对".to_string(); + state.waiting_on = "开发者核对项目副作用".to_string(); + state.next_step = "核对后取消原任务".to_string(); + state.error = Some("模拟 executing 状态落盘失败".to_string()); + state.pending_tool_action = Some(pending.summary()); + append_game_creator_agent_runtime_task(&root, &state).expect("append reconciliation task"); + write_game_creator_agent_runtime_state(&root, &state).expect("write reconciliation state"); + + let resumed = + resume_game_creator_agent_background_tasks_at(&root).expect("scan reconciliation runtime"); + let blocked = resumed + .iter() + .find(|runtime| runtime.state.agent_id == "design-director") + .expect("reconciliation runtime remains visible"); + assert_eq!(blocked.state.status, "failed"); + assert_eq!(blocked.state.phase, "needs-reconciliation"); + let memory = read_local_agent_memory_at(&root, "design-director").expect("agent memory"); + assert!(!memory + .content + .contains("needs-reconciliation 不能执行这条动作。")); + let pending_path = root.join( + ".agent/runtime/pending-actions/design-director/design-auto-reconciliation-barrier-run.json", + ); + let persisted: AgentRuntimePendingToolAction = serde_json::from_str( + &fs::read_to_string(&pending_path).expect("approved ledger remains for reconciliation"), + ) + .expect("parse approved ledger"); + assert_eq!( + persisted.status, + AGENT_RUNTIME_PENDING_ACTION_STATUS_APPROVED + ); + assert_eq!( + persisted.execution_mode, + AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO + ); + let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("agent db"); + assert!(!agent_db.contains("\"recordType\":\"agent.runtime.tool_action.executing\"")); + assert!(!agent_db.contains("\"recordType\":\"agent.runtime.memory.write\"")); + assert!( + game_creator_agent_runtime_task_lock_is_available(&root, "design-director") + .expect("runtime lock availability") + ); + + cancel_game_creator_agent_runtime_task_at( + &root, + "design-director", + "design-auto-reconciliation-barrier-run", + ) + .expect("cancel reconciliation task after manual check"); + fs::remove_dir_all(root).ok(); +} + +#[tokio::test] +async fn background_agent_runtime_reconciliation_blocks_queue_until_manual_cancel() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init"); + persist_needs_reconciliation_runtime_for_test(&root, "design-reconciliation-queue-run", true); + let (sender, receiver) = mpsc::channel(); + let final_plan = serde_json::json!({ + "thinkingSummary": "人工核对已完成,可以处理后续任务", + "plan": [], + "actions": [], + "response": "核对解除后,后续任务已按顺序完成。" + }) + .to_string(); + let base_url = spawn_mock_llm_server_responses_with_capture(vec![final_plan], Some(sender)); + let _config_guard = write_test_local_config(format!( + r#"{{ + "agentLlm": {{ + "design-director": {{ + "apiKey": "design-key", + "baseUrl": {base_url:?}, + "model": "design-runtime-model", + "apiKind": "openai_responses" + }} + }} +}}"# + )); + + let first_queued = start_game_creator_agent_background_task_at( + &root, + "design-director", + "人工核对期间排队后取消的任务", + "design-reconciliation-queued-cancel-run", + ) + .expect("queue first task behind reconciliation"); + assert_eq!(first_queued.state.run_id, "design-reconciliation-queue-run"); + assert_eq!(first_queued.state.phase, "needs-reconciliation"); + assert_eq!(first_queued.task_queue.pending, 1); + let second_queued = start_game_creator_agent_background_task_at( + &root, + "design-director", + "人工核对解除后执行的任务", + "design-reconciliation-after-cancel-run", + ) + .expect("queue second task behind reconciliation"); + assert_eq!( + second_queued.state.run_id, + "design-reconciliation-queue-run" + ); + assert_eq!(second_queued.state.phase, "needs-reconciliation"); + assert_eq!(second_queued.task_queue.pending, 2); + assert!(receiver.recv_timeout(Duration::from_millis(200)).is_err()); + + let after_queued_cancel = cancel_game_creator_agent_runtime_task_at( + &root, + "design-director", + "design-reconciliation-queued-cancel-run", + ) + .expect("cancel one queued task"); + assert_eq!(after_queued_cancel.state.phase, "needs-reconciliation"); + assert_eq!(after_queued_cancel.task_queue.pending, 1); + assert!(after_queued_cancel.recent_tasks.iter().any(|task| { + task.run_id == "design-reconciliation-queued-cancel-run" && task.status == "cancelled" + })); + assert!(after_queued_cancel.recent_tasks.iter().any(|task| { + task.run_id == "design-reconciliation-after-cancel-run" && task.status == "pending" + })); + assert!(receiver.recv_timeout(Duration::from_millis(200)).is_err()); + + cancel_game_creator_agent_runtime_task_at( + &root, + "design-director", + "design-reconciliation-queue-run", + ) + .expect("cancel reconciled run after manual check"); + let followup_request = receiver + .recv_timeout(Duration::from_secs(2)) + .expect("queued task starts after reconciliation cancel"); + assert!(followup_request.contains("人工核对解除后执行的任务")); + assert!(receiver.recv_timeout(Duration::from_millis(200)).is_err()); + let runtime = wait_for_agent_runtime_idle(&root, "design-director"); + assert_eq!(runtime.run_id, "design-reconciliation-after-cancel-run"); + assert_eq!( + runtime.last_response.as_deref(), + Some("核对解除后,后续任务已按顺序完成。") + ); + let runtime_result = + read_game_creator_agent_runtime_at(&root, "design-director").expect("runtime result"); + assert!(runtime_result.recent_tasks.iter().any(|task| { + task.run_id == "design-reconciliation-queue-run" && task.status == "cancelled" + })); + assert!(runtime_result.recent_tasks.iter().any(|task| { + task.run_id == "design-reconciliation-after-cancel-run" && task.status == "completed" + })); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn background_agent_runtime_reconciliation_without_ledger_still_blocks_recovery() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init"); + persist_needs_reconciliation_runtime_for_test( + &root, + "design-reconciliation-no-ledger-run", + false, + ); + let queued = start_game_creator_agent_background_task_at( + &root, + "design-director", + "账本缺失时也不能越过核对状态", + "design-reconciliation-no-ledger-pending-run", + ) + .expect("queue task behind ledgerless reconciliation"); + assert_eq!(queued.state.run_id, "design-reconciliation-no-ledger-run"); + assert_eq!(queued.state.phase, "needs-reconciliation"); + assert_eq!(queued.task_queue.pending, 1); + + let resumed = resume_game_creator_agent_background_tasks_at(&root) + .expect("resume scan respects ledgerless reconciliation"); + let blocked = resumed + .iter() + .find(|runtime| runtime.state.agent_id == "design-director") + .expect("reconciliation runtime remains visible"); + assert_eq!(blocked.state.run_id, "design-reconciliation-no-ledger-run"); + assert_eq!(blocked.state.phase, "needs-reconciliation"); + assert_eq!(blocked.task_queue.pending, 1); + std::thread::sleep(Duration::from_millis(50)); + let runtime = + read_game_creator_agent_runtime_at(&root, "design-director").expect("runtime state"); + assert_eq!(runtime.state.run_id, "design-reconciliation-no-ledger-run"); + assert_eq!(runtime.state.phase, "needs-reconciliation"); + assert!(runtime.recent_tasks.iter().any(|task| { + task.run_id == "design-reconciliation-no-ledger-pending-run" && task.status == "pending" + })); + assert!(retry_game_creator_agent_runtime_task_at( + &root, + "design-director", + "design-reconciliation-no-ledger-run", + "unsafe-ledgerless-retry-run", + ) + .expect_err("ledgerless reconciliation cannot be retried") + .contains("请先核对项目状态并取消原任务")); + + cancel_game_creator_agent_runtime_task_at( + &root, + "design-director", + "design-reconciliation-no-ledger-pending-run", + ) + .expect("cancel queued task while reconciliation remains"); + let cancelled = cancel_game_creator_agent_runtime_task_at( + &root, + "design-director", + "design-reconciliation-no-ledger-run", + ) + .expect("cancel ledgerless reconciliation after manual check"); + assert_eq!(cancelled.state.status, "cancelled"); + assert_eq!(cancelled.state.phase, "cancelled"); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn background_agent_runtime_pauses_auto_recovery_when_policy_becomes_stricter() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init"); + write_project_permission_policy_at( + &root, + ProjectPermissionPolicy { + denied_commands: Vec::new(), + confirm_commands: Vec::new(), + agent_policies: BTreeMap::new(), + }, + ) + .expect("allow file write before restart"); + let mut state = start_game_creator_agent_runtime_task_at( + &root, + "design-director", + "恢复后写入项目笔记", + "design-auto-policy-recovery-run", + "agent-background-task", + "模拟自动动作落盘后权限策略变严", + vec!["写入项目笔记".to_string()], + ) + .expect("start runtime state"); + assert!(state + .tool_policy + .auto_tools + .contains(&"file.write".to_string())); + state.loop_iteration = 1; + let action = AgentRuntimeToolAction { + tool: "file.write".to_string(), + reason: Some("写入恢复后的项目笔记".to_string()), + input: serde_json::json!({ + "path": "game/notes.txt", + "content": "策略变严后不应直接写入" + }), + }; + let mut pending = pending_tool_action_for_test( + &state, + action, + AGENT_RUNTIME_PENDING_ACTION_STATUS_APPROVED, + None, + ); + pending.execution_mode = AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO.to_string(); + pending.input_summary = Some("path=game/notes.txt, contentChars=12".to_string()); + write_game_creator_agent_runtime_pending_tool_action(&root, &pending) + .expect("write approved auto action"); + state.status = "running".to_string(); + state.phase = "action".to_string(); + state.pending_tool_action = Some(pending.summary()); + append_game_creator_agent_runtime_task(&root, &state).expect("append running task"); + write_game_creator_agent_runtime_state(&root, &state).expect("write running state"); + write_project_permission_policy_at( + &root, + ProjectPermissionPolicy { + denied_commands: Vec::new(), + confirm_commands: vec!["file.write".to_string()], + agent_policies: BTreeMap::new(), + }, + ) + .expect("tighten policy before recovery"); + + resume_game_creator_agent_background_tasks_at(&root) + .expect("resume auto action under stricter policy"); + let waiting = wait_for_agent_runtime_confirmation(&root, "design-director"); + assert_eq!(waiting.run_id, "design-auto-policy-recovery-run"); + assert_eq!(waiting.status, "waiting-for-confirmation"); + assert_eq!(waiting.phase, "waiting-for-confirmation"); + assert!(waiting + .observations + .iter() + .any(|item| item.contains("file.write:waiting-for-confirmation"))); + assert!(!root.join("game/notes.txt").exists()); + let mut runtime_lock_available = false; + for _ in 0..50 { + runtime_lock_available = + game_creator_agent_runtime_task_lock_is_available(&root, "design-director") + .expect("runtime lock availability"); + if runtime_lock_available { + break; + } + std::thread::sleep(Duration::from_millis(20)); + } + assert!( + runtime_lock_available, + "recovery worker must release its lock" + ); + let stable_waiting = + read_game_creator_agent_runtime_at(&root, "design-director").expect("stable waiting state"); + assert_eq!( + stable_waiting.state.run_id, + "design-auto-policy-recovery-run" + ); + assert_eq!(stable_waiting.state.status, "waiting-for-confirmation"); + assert_eq!(stable_waiting.state.phase, "waiting-for-confirmation"); + assert_eq!( + stable_waiting + .state + .pending_tool_action + .as_ref() + .map(|action| action.action_id.as_str()), + Some(pending.action_id.as_str()) + ); + assert!(!root.join("game/notes.txt").exists()); + let pending_path = root.join( + ".agent/runtime/pending-actions/design-director/design-auto-policy-recovery-run.json", + ); + let recovered_pending: AgentRuntimePendingToolAction = serde_json::from_str( + &fs::read_to_string(&pending_path).expect("read converted confirmation action"), + ) + .expect("parse converted confirmation action"); + assert_eq!( + recovered_pending.execution_mode, + AGENT_RUNTIME_ACTION_EXECUTION_MODE_CONFIRMATION + ); + assert_eq!(recovered_pending.status, "pending-confirmation"); + assert_eq!(recovered_pending.action_id, pending.action_id); + assert_eq!( + recovered_pending.action_fingerprint, + pending.action_fingerprint + ); + let records = read_agent_db_records_for_test(&root); + assert_auto_tool_action_audit_pair( + &records, + "design-auto-policy-recovery-run", + &pending.action_id, + &pending.action_fingerprint, + "file.write", + "waiting-for-confirmation", + ); + + let cancelled = cancel_game_creator_agent_runtime_task_at( + &root, + "design-director", + "design-auto-policy-recovery-run", + ) + .expect("cancel converted confirmation task"); + assert_eq!(cancelled.state.status, "cancelled"); + assert_eq!(cancelled.state.phase, "cancelled"); + fs::remove_dir_all(root).ok(); +} + #[tokio::test] async fn background_agent_runtime_can_confirm_and_continue_waiting_tool_action() { let root = unique_project_path(); @@ -6914,6 +7908,96 @@ async fn background_agent_runtime_run_status_respects_project_policy() { fs::remove_dir_all(root).ok(); } +#[tokio::test] +async fn background_agent_runtime_starts_oldest_pending_task_after_lock_acquisition() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init"); + write_agent_runtime_task_record_for_test( + &root, + &AgentRuntimeTaskRecord { + schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(), + agent_id: "design-director".to_string(), + task_id: "design-director".to_string(), + session_id: "agent-session-design-director".to_string(), + run_id: "design-fifo-first-run".to_string(), + source: "agent-background-task".to_string(), + task: "先进入队列的设计任务".to_string(), + status: "pending".to_string(), + phase: "queued".to_string(), + current_action: "等待当前后台任务完成".to_string(), + error: None, + updated_at: unix_timestamp(), + }, + ); + let (sender, receiver) = mpsc::channel(); + let base_url = spawn_mock_llm_server_responses_with_capture( + vec![ + serde_json::json!({ + "thinkingSummary": "先处理队首任务", + "plan": [], + "actions": [], + "response": "队首任务完成。" + }) + .to_string(), + serde_json::json!({ + "thinkingSummary": "再处理后入队任务", + "plan": [], + "actions": [], + "response": "后入队任务完成。" + }) + .to_string(), + ], + Some(sender), + ); + let _config_guard = write_test_local_config(format!( + r#"{{ + "agentLlm": {{ + "design-director": {{ + "apiKey": "design-key", + "baseUrl": {base_url:?}, + "model": "design-runtime-model", + "apiKind": "openai_responses" + }} + }} +}}"# + )); + + let started = start_game_creator_agent_background_task_at( + &root, + "design-director", + "后进入队列的设计任务", + "design-fifo-second-run", + ) + .expect("submit second task"); + assert_eq!(started.state.run_id, "design-fifo-first-run"); + assert_eq!(started.state.current_task, "先进入队列的设计任务"); + let first_request = receiver + .recv_timeout(Duration::from_secs(2)) + .expect("first queued task request"); + assert!(first_request.contains("先进入队列的设计任务")); + assert!(!first_request.contains("后台任务:\n后进入队列的设计任务")); + let second_request = receiver + .recv_timeout(Duration::from_secs(2)) + .expect("second queued task request"); + assert!(second_request.contains("后进入队列的设计任务")); + assert!(receiver.recv_timeout(Duration::from_millis(200)).is_err()); + let runtime = wait_for_agent_runtime_idle(&root, "design-director"); + assert_eq!(runtime.run_id, "design-fifo-second-run"); + assert_eq!(runtime.last_response.as_deref(), Some("后入队任务完成。")); + let result = + read_game_creator_agent_runtime_at(&root, "design-director").expect("runtime result"); + assert!(result + .recent_tasks + .iter() + .any(|task| { task.run_id == "design-fifo-first-run" && task.status == "completed" })); + assert!(result + .recent_tasks + .iter() + .any(|task| { task.run_id == "design-fifo-second-run" && task.status == "completed" })); + + fs::remove_dir_all(root).ok(); +} + #[tokio::test] async fn background_agent_runtime_queues_same_agent_tasks_and_drains_them() { let root = unique_project_path(); diff --git a/apps/ai-game-creator-shell/tests/rememberCommand.test.ts b/apps/ai-game-creator-shell/tests/rememberCommand.test.ts index 6c6615547..a5894e6a0 100644 --- a/apps/ai-game-creator-shell/tests/rememberCommand.test.ts +++ b/apps/ai-game-creator-shell/tests/rememberCommand.test.ts @@ -185,7 +185,9 @@ describe('AI 游戏创作聊天记忆命令', () => { }, '/tmp/game', ), - ).toBe('写入 /tmp/game/.agent/policy.json · 拒绝:file.write · 确认:无'); + ).toBe( + '写入 /tmp/game/.agent/policy.json · 拒绝:file.write · 确认:无 · Agent:无 Agent 独立策略', + ); }); it('describes canvas asset imports before confirmation', () => { diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 1587704eb..d51e24809 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -4072,6 +4072,8 @@ - 2026-07-10 调整:Agent Runtime 和本地对话使用 append-only JSONL 作为事实源时,进程内必须按目标文件路径串行追加整行。`.agent/agent.db`、`.agent/conversations/**/*.jsonl`、`.agent/runtime/events/*.jsonl`、`.agent/runtime/tasks/*.jsonl`、`.agent/activity.jsonl` 和 `.agent/output.jsonl` 统一走共享追加 helper,避免多个后台 Agent 并行完成时 JSON record 与换行交错。 - 2026-07-10 调整:Agent Runtime 待确认工具动作改用 durable `AgentRuntimePendingToolAction`。Runtime 将精确 `action` 输入、当前 task/run、loop 轮次、action 序号、计划、已有 observations 与后续 loop 所需上下文先做敏感内容和项目绝对路径校验,再通过临时文件替换原子写入 `.agent/runtime/pending-actions//.json`;公共 runtime state 的 `pendingToolAction` 只暴露 `actionId / actionFingerprint / tool / inputSummary / reason / requestedAt` 安全摘要,完整输入不进入公共状态。`actionFingerprint` 绑定工具名、完整输入 JSON 与实际执行使用的 task context;`actionId` 还绑定 run、loop、action 序号和 occurrence nonce,使同一 run 内输入相同的两次动作仍是两个不同发生。确认和拒绝都必须匹配 `runId + actionId`,Runtime 会重算指纹并与私有落盘动作及公共摘要交叉校验,不一致时失败关闭。确认通过后在同一 run 直接执行持久化的原 action,把真实 observation 接回后续 Agent loop,不创建新 run,也不让模型重复生成待确认动作;拒绝不执行工具,写入 `blocked` observation 后在同一 run 继续规划。待确认账本按 `pending-confirmation / approved / executing / observed-approved / observed-rejected` 迁移:重启时 `approved` 可恢复精确动作,已持久化 observation 可直接续 loop,`executing` 表示外部副作用结果未知,Runtime 必须进入 `failed / needs-reconciliation` 并禁止自动重放,开发者核对项目状态后只能先取消原任务。waiting run、完整待确认动作和安全摘要均已落盘,App 重启不会越过该 run 去启动后续任务;等待期间同 Agent 新任务只保持 `pending`,确认、拒绝或取消结束后再由同一 drain 串行排空。`.agent/runtime/` 是 Runtime 私有控制面,通用 `file.list / file.read / file.write / file.delete` 不得列出、读取、修改或删除;checkpoint/index/diff/restore 继续整体排除该目录。每 Agent 锁包含唯一 token,旧持有者析构时只删除自己的锁;Linux 上其他仍存活进程的锁不会因超过固定时长被抢占。确认、拒绝及工具 observation 分别写入 `agent.runtime.tool_confirmation.approved`、`agent.runtime.tool_confirmation.rejected` 和 `agent.runtime.tool_observation` 审计;pending 和 confirmation 文件只在 observation/终态可靠落盘后清理,失败清理会显式报错。 - 2026-07-10 调整:per-agent 互斥锁最终改用 OS 级文件锁,而不是依赖 JSON token、PID、超时和 `remove + create_new` 竞争所有权。Unix 使用非阻塞独占 `flock`,Windows 使用禁止共享的文件句柄;锁文件只保留诊断元数据并长期存在,进程退出会由 OS 释放所有权。确认、拒绝和取消必须先取得同一系统锁,再重新读取 runtime、latest task 和 durable pending action 后迁移状态;恢复入口也必须先拿锁,再读取 durable pending action 或 recoverable task,禁止用锁外旧快照覆盖并发结果。waiting 取消只短暂等待原 worker 释放系统锁,running 取消拿不到锁时只保留 tombstone,并由原 worker 在 LLM / 工具成功或失败返回后的检查点收束,不得根据 Runtime status 抢锁。这条最终实现取代上一条中的 token 删除和 Linux PID 存活判断描述。 +- 2026-07-10 调整:`AgentRuntimePendingToolAction` 同时作为白名单自动工具的精确动作账本,新增 `executionMode = auto | confirmation`。自动动作执行前必须依次持久化 `approved` 与 `executing`,工具返回后先持久化 `observed-approved` observation,再写 Runtime task/state/event/audit;账本必须覆盖后续 LLM replan,只有下一条精确动作以新账本接管,或当前 run 的 completed / failed / cancelled 终态可靠落盘后才能清理。App 在 `approved + auto` 崩溃点可恢复同一精确动作,在 `observed-approved + auto` 崩溃点只能复用 observation 继续规划,不得重放工具;`executing + auto` 一律进入 `failed / needs-reconciliation`。`needs-reconciliation` 是恢复硬屏障,即使磁盘账本因前一轮写入失败仍停在 `approved` 也不得继续执行或排空队列。恢复时若项目策略从 auto 收紧为 confirm,原 action 保持同一 actionId / fingerprint 并转回 `pending-confirmation + confirmation`,等待开发者决定。自动动作另写 `agent.runtime.tool_action.executing`、`agent.runtime.tool_action.observed` 和 `agent.runtime.tool_action.needs_reconciliation` 审计。 +- 2026-07-10 调整:`needs-reconciliation` 同时是整个 Agent 队列的准入屏障,不只保护当前 pending action。屏障存在时,通过开发窗口、`agent.delegate` 或 `agent.schedule_ready` 投递的新 run 只能追加为 `pending`,任何恢复和 drain 都不得启动后续任务;取消某个排队 run 也不能越过核对 run 去启动再后面的任务。新任务的 waiting / cancelling / reconciliation 准入判断必须在成功取得该 Agent 的 OS 锁后重新读取,禁止用锁外快照启动新 run;通过检查后也必须从 task JSONL 选择最早的 pending run 作为队首启动,不能直接启动当前调用方刚提交的 run。即使私有 pending ledger 意外缺失,也必须从 Runtime state 或 task JSONL 的最新 `needs-reconciliation` 记录识别屏障,继续禁止 retry;开发者人工核对后显式取消该 run,才允许既有 per-agent drain 按顺序恢复队列。 - 2026-07-10 调整:Agent Runtime 工具箱新增 `task.create`,用于让 Agent 把目标拆成新的 manifest 任务,而不只能更新 seed task。该工具默认 `confirm` 权限,写入前要求 taskId 唯一、依赖指向已有任务、列表长度受限,并写 `agent.runtime.task.create` 审计;策略要求确认或拒绝时不修改 `.agent/manifest.json`。 - 2026-07-10 调整:Agent Runtime 新增 `agent.schedule_ready` 调度入口,默认 `confirm` 权限。命令会扫描 `.agent/manifest.json` 中依赖已完成且仍为 `pending` 的 ready task,先把任务标成 `running`,再用 taskId 作为 Agent id 投递到既有后台队列,source 记为 `agent-ready-task-scheduler`,并写 `agent.runtime.ready_task.scheduled` 审计;后续执行仍走原 per-agent 锁、任务 JSONL、LLM loop、工具策略和事件流,不新增独立 worker。默认确认策略下该命令不会静默调度。 - 2026-07-10 调整:Agent Runtime state 新增 `recentToolCalls`,后台 loop 每次执行白名单工具后记录最近 20 条结构化动作,包含 tool、status、actionFingerprint、inputSummary、reason、summary、detail 和 updatedAt。状态面板展示最近动作与安全目标摘要时使用该字段,不解析 observation 文本;写入前继续过滤敏感上下文,不保存原始密钥、待写正文或任意未过滤输入。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index b45aa91e1..7ffe74b49 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -42,6 +42,8 @@ Agent Runtime 负责: - 2026-07-10 补充:本地 append-only JSONL 追加写入按目标文件路径做进程内串行化。`.agent/agent.db`、项目 / Agent 对话、Runtime events、Runtime tasks、Agent activity 和 output 都通过共享 helper 写入完整 JSON 行,防止多个后台 Agent 并行运行时 record 内容与换行交错;该约束服务于当前单客户端进程内并行,不把跨进程同项目写入作为 v1 支持目标。 - 2026-07-10 补充:后台 Agent 待确认工具动作改用 durable `AgentRuntimePendingToolAction`。完整记录包含精确工具 action、当前 task/run、loop 轮次、action 序号、计划、已有 observations 和续跑上下文;写入前拒绝密钥、Token、Cookie、App 配置痕迹和项目绝对路径,再通过临时文件替换原子写入 `.agent/runtime/pending-actions//.json`。公共 runtime state 的 `pendingToolAction` 只暴露 `actionId / actionFingerprint / tool / inputSummary / reason / requestedAt` 安全摘要。`actionFingerprint` 绑定工具名、完整输入 JSON 与实际执行 task context;`actionId` 再绑定 run、loop、action 序号和 occurrence nonce,防止同一 run 内相同输入的旧 UI 点击批准后一次动作。开发窗口和项目内 Agent 面板的“确认继续 / 拒绝并继续”都提交当前 `runId + actionId`,Runtime 与私有动作、公共摘要交叉校验后才迁移账本状态。确认后在同一 run 直接执行原 action 并把 observation 接回后续 loop,不创建新 run、不要求模型重复动作;拒绝不执行工具,写 `blocked` observation 后在同一 run 继续规划。账本状态使用 `pending-confirmation / approved / executing / observed-approved / observed-rejected`:重启可恢复 waiting、未执行的 approved action 或已落盘 observation;若进程中断在 `executing`,Runtime 进入 `failed / needs-reconciliation`,禁止自动重放外部副作用,开发者核对项目状态后先取消原任务。等待期间同 Agent 新任务保持 `pending`,重启不会越过 waiting run,确认、拒绝或取消后再串行排空。`.agent/runtime/` 作为私有控制面,不允许通用文件工具列出、读取、写入或删除;每 Agent 锁使用唯一 token,旧持有者不会删除替换后的新锁,Linux 上仍存活的其他进程锁不会按超时强占。 - 2026-07-10 补充:per-agent 锁最终采用 OS 级文件锁,取代上一条末尾的 token/PID/超时抢占方案。Unix 使用非阻塞独占 `flock`,Windows 使用禁止共享的文件句柄;`.agent/runtime/locks/.lock` 只保存诊断元数据并可长期存在,真正所有权随文件句柄和进程生命周期释放。任何确认、拒绝、恢复、取消和队列 drain 都必须使用同一系统锁;确认、拒绝和取消只能在拿锁后重新读取当前 runtime、task 与待确认动作再迁移状态,恢复也必须先拿锁再读取 durable pending action 或 recoverable task,不能用拿锁前的旧快照覆盖并发结果。waiting 状态只允许短暂等待原 worker 正常释放,不得按状态删除并重建锁文件;running 取消在拿不到锁时只保留取消 tombstone,由原 worker 在 LLM / 工具成功或失败返回后的检查点收束。 +- 2026-07-10 补充:白名单自动工具也必须使用 durable `AgentRuntimePendingToolAction`,并以 `executionMode = auto` 区别待开发者确认的动作。Runtime 在副作用前依次落盘 `approved`、`executing`,返回后落盘 `observed-approved` 和 observation;该账本继续覆盖下一轮 LLM planning,直到下一条精确动作接管或 completed / failed / cancelled 终态可靠落盘,不能在 observation 刚落盘时提前删除。恢复 `approved + auto` 时执行同一 action 一次,恢复 `observed-approved + auto` 时只把 observation 交回 Agent,恢复 `executing + auto` 时停止在 `failed / needs-reconciliation`;该核对阶段是硬屏障,即使磁盘仍是 `approved` 也禁止继续。恢复时策略由 auto 收紧为 confirm,则保留原 actionId / fingerprint 并转换成 `pending-confirmation + confirmation`。自动动作在 `.agent/agent.db` 写 `agent.runtime.tool_action.executing`、`agent.runtime.tool_action.observed` 和 `agent.runtime.tool_action.needs_reconciliation` 审计。 +- 2026-07-10 补充:`needs-reconciliation` 按 Agent 队列级屏障处理。该 Agent 的新聊天后台任务、delegate 和 ready-task 调度仍可入队,但只能保持 `pending`;恢复、正常 drain 和取消其他排队 run 后触发的 drain 都不得越过当前核对 run。新 run 只能在取得 per-agent OS 锁后重新读取 waiting / cancelling / reconciliation 状态并通过准入检查,不能在锁外检查后直接启动;锁内通过检查后统一从 task JSONL 选择最早 pending run,保证并发投递时仍按 FIFO 启动。屏障判定同时读取当前 Runtime state 与 task JSONL 最新记录,因此 pending ledger 缺失时也不放行、不允许 retry;开发者核对外部副作用后必须显式取消该 run,后续队列才继续。 - 2026-07-10 补充:后台任务工具箱已加入 `task.create`。Agent 可在 loop 中把拆解出的后续工作追加为 manifest 任务;Runtime 复用 `task.create` 策略和项目写锁,写入前校验 taskId 唯一、依赖指向已有任务、任务分组合法、标题 / 角色非空以及列表长度,并写入 `agent.runtime.task.create` 审计记录。策略要求确认或拒绝时不会修改 manifest。 - 2026-07-10 补充:Agent Runtime state 新增 `recentToolCalls`,每次后台工具执行后记录最近 20 条结构化工具动作,包含 tool、status、actionFingerprint、inputSummary、reason、summary、detail 和 updatedAt;开发窗口、项目内 Agent 对话弹窗和主窗口 Agent 状态列表可直接展示“最近动作”和安全目标摘要,不再只能从 observation 字符串里猜测 action / observation 对应关系。`inputSummary` 只保留相对路径、checkpoint id、目标 Agent、内容字符数等确认所需信息,不保存原始 API Key、待写正文、消息正文、素材 prompt 或任意未过滤输入。 - 2026-07-10 补充:Agent Runtime state 新增 `currentGoal` 和 `waitingOn`,把本轮目标与当前等待对象从 `currentTask / currentAction / nextStep` 中显式拆出来;后台任务启动、工具 observation、完成和失败都会刷新该状态,开发窗口、项目内 Agent 对话弹窗、主窗口 Agent 状态列表、`agent.run_status` observation 和下一轮 planning prompt 都展示同一份目标 / 等待信息,避免开发者只能从动作文本里猜 Agent 卡在 LLM、工具、同伴还是人工输入。