From 006846e667222c015690d2f90351c7c49de4dfa5 Mon Sep 17 00:00:00 2001 From: AIGameCreator App Date: Sat, 25 Jul 2026 14:07:54 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8DSwarm=E8=BF=9E=E7=BB=AD?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1=E6=81=A2=E5=A4=8D=E4=B8=8E=E7=BB=88=E6=80=81?= =?UTF-8?q?=E5=BD=92=E5=B1=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 以启动 mutation 返回的 acceptedRunId 建立不可变 turn baseline 拆分队列忙碌与 Runtime 可追加状态并恢复 cancelled canonical 后的 pending 任务 按 finalization message ID 和完整 task journal 隔离连续 B/C 对话、失败扫描与报告计数 以 agentId 和 runId 聚合专业 Agent 终态并保持 journal 失败优先 补齐重复投递、恢复幂等、连续轮次和历史 repair 回归及项目文档 --- .../src/agent/runtime_driver/recovery_scan.rs | 87 +- .../src/agent/runtime_driver/task_start.rs | 10 +- .../src-tauri/src/agent/runtime_protocol.rs | 4 +- .../agent/runtime_protocol/finalization.rs | 2 +- .../src/agent/runtime_protocol/steering.rs | 29 +- .../src-tauri/src/agent/runtime_tools.rs | 2 +- .../src/agent/runtime_tools/delivery.rs | 2 +- .../src-tauri/src/swarm_cli/conversation.rs | 97 ++- .../src-tauri/src/swarm_cli/report.rs | 198 ++++- .../src/swarm_cli/terminal_classification.rs | 334 +++++++- .../src-tauri/src/swarm_cli/tests.rs | 783 +++++++++++++++++- .../src-tauri/src/swarm_cli/turn_dispatch.rs | 219 +++-- .../src-tauri/src/swarm_cli/turn_wait.rs | 98 ++- .../src/tests/runtime_actions/recovery.rs | 85 +- .../shared-memory/decision-log.md | 1 + docs/project-memory/shared-memory/pitfalls.md | 8 + ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 1 + 17 files changed, 1764 insertions(+), 196 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/recovery_scan.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/recovery_scan.rs index 7c19b4524..27d4460d4 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/recovery_scan.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/recovery_scan.rs @@ -278,54 +278,63 @@ pub(in crate::agent) fn resume_game_creator_agent_background_tasks_unredacted_at } AgentRuntimeFinalizationResume::NotFound(runtime_lock) => runtime_lock, }; - if let Some(result) = + let cancelled_with_queued_work = if let Some(result) = reconcile_game_creator_agent_control_before_resume_at(root, &agent_id)? { - if result.state.phase != "cancelled" { - resumed.push(result); - } - drop(runtime_lock); - continue; - } - if let Some(result) = - reconcile_game_creator_agent_process_sessions_after_restart_at(root, &agent_id)? - { - resumed.push(result); - drop(runtime_lock); - continue; - } - let runtime_lock = match resume_game_creator_agent_parallel_read_batch_at( - root, - &agent_id, - runtime_lock, - )? { - AgentRuntimePendingActionResume::Handled(result) => { - resumed.push(result); + let cancelled_with_queued_work = result.state.phase == "cancelled" + && result.task_queue.pending > 0 + && result.task_queue.running == 0; + if !cancelled_with_queued_work { + if result.state.phase != "cancelled" { + resumed.push(result); + } + drop(runtime_lock); continue; } - AgentRuntimePendingActionResume::NotFound(runtime_lock) => runtime_lock, + true + } else { + false }; - let runtime_lock = match resume_game_creator_agent_pending_tool_action_at( - root, - &agent_id, - runtime_lock, - )? { - AgentRuntimePendingActionResume::Handled(result) => { + let runtime_lock = if cancelled_with_queued_work { + runtime_lock + } else { + if let Some(result) = + reconcile_game_creator_agent_process_sessions_after_restart_at(root, &agent_id)? + { resumed.push(result); + drop(runtime_lock); continue; } - AgentRuntimePendingActionResume::NotFound(runtime_lock) => runtime_lock, - }; - let runtime_lock = match resume_game_creator_agent_provider_action_batch_at( - root, - &agent_id, - runtime_lock, - )? { - AgentRuntimePendingActionResume::Handled(result) => { - resumed.push(result); - continue; + let runtime_lock = match resume_game_creator_agent_parallel_read_batch_at( + root, + &agent_id, + runtime_lock, + )? { + AgentRuntimePendingActionResume::Handled(result) => { + resumed.push(result); + continue; + } + AgentRuntimePendingActionResume::NotFound(runtime_lock) => runtime_lock, + }; + let runtime_lock = match resume_game_creator_agent_pending_tool_action_at( + root, + &agent_id, + runtime_lock, + )? { + AgentRuntimePendingActionResume::Handled(result) => { + resumed.push(result); + continue; + } + AgentRuntimePendingActionResume::NotFound(runtime_lock) => runtime_lock, + }; + match resume_game_creator_agent_provider_action_batch_at(root, &agent_id, runtime_lock)? + { + AgentRuntimePendingActionResume::Handled(result) => { + resumed.push(result); + continue; + } + AgentRuntimePendingActionResume::NotFound(runtime_lock) => runtime_lock, } - AgentRuntimePendingActionResume::NotFound(runtime_lock) => runtime_lock, }; let Some(task) = read_recoverable_runnable_game_creator_agent_runtime_task(root, &agent_id)? diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs index 9ea32e039..f241b74e1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs @@ -46,7 +46,10 @@ pub(crate) fn start_game_creator_agent_background_task_for_session_at( start_game_creator_agent_background_task_with_run_id_for_session_at( root, agent_id, session_id, task, run_id, ) - .map(|(result, _run_id)| result) + .map(|(mut result, accepted_run_id)| { + result.accepted_run_id = Some(accepted_run_id); + result + }) } #[cfg(test)] @@ -132,7 +135,10 @@ pub(crate) fn start_game_creator_supervisor_background_task_for_session_at( source, Some(run_profile), ) - .map(|(result, _run_id)| result) + .map(|(mut result, accepted_run_id)| { + result.accepted_run_id = Some(accepted_run_id); + result + }) } pub(in crate::agent) fn start_game_creator_agent_background_task_with_source_at( diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol.rs index a9274bc23..e44788a1c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol.rs @@ -42,6 +42,7 @@ pub(crate) use context_window::{ }; #[allow(unused_imports)] pub(crate) use finalization::{ + game_creator_agent_runtime_finalization_message_id, game_creator_agent_runtime_finalization_path, read_game_creator_agent_runtime_finalization_journal, remove_game_creator_agent_runtime_finalization_journal, @@ -77,7 +78,8 @@ pub(crate) use run_configuration::{ read_game_creator_agent_runtime_run_profile_binding, }; pub(crate) use steering::{ - consume_game_creator_agent_runtime_steers, game_creator_agent_runtime_steer_ledger_path, + consume_game_creator_agent_runtime_steers, game_creator_agent_runtime_accepts_steer, + game_creator_agent_runtime_steer_ledger_path, interrupt_game_creator_agent_runtime_provider_request_at, render_game_creator_agent_runtime_steers_for_prompt, steer_game_creator_agent_runtime_task_at, steer_game_creator_agent_runtime_task_for_profile_at, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/finalization.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/finalization.rs index 65e4b1ceb..62eef0b60 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/finalization.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/finalization.rs @@ -21,7 +21,7 @@ pub(crate) fn game_creator_agent_runtime_finalization_path( )) } -pub(in crate::agent) fn game_creator_agent_runtime_finalization_message_id( +pub(crate) fn game_creator_agent_runtime_finalization_message_id( agent_id: &str, session_id: &str, run_id: &str, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/steering.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/steering.rs index 7eda6d8db..c47237829 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/steering.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/steering.rs @@ -429,9 +429,36 @@ pub(in crate::agent) fn ensure_game_creator_agent_runtime_steer_audit( ) } +pub(crate) fn game_creator_agent_runtime_accepts_steer(state: &AgentRuntimeState) -> bool { + !state.agent_id.starts_with("child-") + && state.source != AGENT_RUNTIME_ISOLATED_CHILD_SOURCE + && state.status != "waiting-for-user-input" + && state.phase != "waiting-for-user-input" + && !matches!( + state.status.as_str(), + "completed" | "failed" | "cancelled" | "cancelling" + ) + && !matches!( + state.phase.as_str(), + "completed" + | "failed" + | "cancelled" + | "cancelling" + | "finalizing" + | "needs-reconciliation" + ) + && matches!( + state.status.as_str(), + "running" | "waiting-for-confirmation" + ) +} + pub(in crate::agent) fn validate_agent_runtime_steer_target_state( state: &AgentRuntimeState, ) -> Result<(), String> { + if game_creator_agent_runtime_accepts_steer(state) { + return Ok(()); + } if state.agent_id.starts_with("child-") || state.source == AGENT_RUNTIME_ISOLATED_CHILD_SOURCE { return Err("动态隔离子 Agent 暂不接受运行中追加指令".to_string()); } @@ -462,7 +489,7 @@ pub(in crate::agent) fn validate_agent_runtime_steer_target_state( state.status )); } - Ok(()) + unreachable!("可追加状态已在函数入口返回") } pub(crate) fn steer_game_creator_agent_runtime_task_at( 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 ae5f04d43..6ebd51adc 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 @@ -44,7 +44,7 @@ pub(crate) use delegation::{ }; pub(crate) use delivery::{ agent_runtime_delegation_id, dispatch_isolated_agent_join_at, - publish_game_creator_agent_delegate_result, + game_creator_agent_runtime_terminal_status, publish_game_creator_agent_delegate_result, }; #[allow(unused_imports)] pub(crate) use isolated_joins::{ diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delivery.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delivery.rs index 66f724513..fdb2fdc27 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delivery.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delivery.rs @@ -37,7 +37,7 @@ pub(in crate::agent) fn agent_runtime_delegate_receipt_run_id(delegation_id: &st ) } -pub(in crate::agent) fn game_creator_agent_runtime_terminal_status( +pub(crate) fn game_creator_agent_runtime_terminal_status( task: &AgentRuntimeTaskRecord, ) -> Option<&'static str> { match task.phase.as_str() { diff --git a/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/conversation.rs b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/conversation.rs index 99ccc026b..a2afff4c8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/conversation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/conversation.rs @@ -153,12 +153,37 @@ pub(super) fn read_turn_conversation_snapshot( if baseline.previous_message_count > conversation.messages.len() { return Err("Swarm turn 对话 baseline 超出当前 Session 消息数".to_string()); } - let (mut metrics, final_reply) = summarize_new_assistant_messages( + let target_message_id = game_creator_agent_runtime_finalization_message_id( + parent_agent_id, + session_id, + &baseline.parent_run_id, + ); + if let Some(journal) = read_game_creator_agent_runtime_finalization_journal( + root, + parent_agent_id, + &baseline.parent_run_id, + )? { + if journal.agent_id != parent_agent_id + || journal.session_id != session_id + || journal.run_id != baseline.parent_run_id + || journal.message_id != target_message_id + { + return Err("Swarm finalization 与目标 parent Session/run 不匹配".to_string()); + } + } + let (mut metrics, final_reply) = summarize_scoped_new_assistant_messages( conversation .messages .iter() .skip(baseline.previous_message_count) - .map(|message| (message.role.as_str(), message.content.as_str())), + .map(|message| { + ( + message.role.as_str(), + message.content.as_str(), + message.message_id.as_deref(), + ) + }), + Some(&target_message_id), ); let mut final_reply = final_reply.map(str::to_string); let recovered_before_observation = baseline.recovered_assistant.is_some(); @@ -198,14 +223,46 @@ pub(super) fn read_turn_conversation_metrics( pub(super) fn summarize_new_assistant_messages<'a>( messages: impl IntoIterator, ) -> (SwarmTurnConversationMetrics, Option<&'a str>) { - let mut new_assistant_message_count = 0; - let mut final_reply = None; - for (role, content) in messages { - if role == "assistant" { - new_assistant_message_count += 1; - final_reply = Some(content); + summarize_scoped_new_assistant_messages( + messages + .into_iter() + .map(|(role, content)| (role, content, None)), + None, + ) +} + +pub(super) fn summarize_scoped_new_assistant_messages<'a>( + messages: impl IntoIterator)>, + target_message_id: Option<&str>, +) -> (SwarmTurnConversationMetrics, Option<&'a str>) { + let mut scoped_count = 0; + let mut scoped_reply = None; + let mut legacy_count = 0; + let mut legacy_reply = None; + let mut identified_assistant_exists = false; + for (role, content, message_id) in messages { + if role != "assistant" { + continue; + } + match message_id { + Some(message_id) if target_message_id == Some(message_id) => { + identified_assistant_exists = true; + scoped_count += 1; + scoped_reply = Some(content); + } + None => { + legacy_count += 1; + legacy_reply = Some(content); + } + Some(_) => identified_assistant_exists = true, } } + let (new_assistant_message_count, final_reply) = + if target_message_id.is_some() && (scoped_count > 0 || identified_assistant_exists) { + (scoped_count, scoped_reply) + } else { + (legacy_count, legacy_reply) + }; ( SwarmTurnConversationMetrics { new_assistant_message_count, @@ -229,9 +286,10 @@ pub(super) fn print_new_parent_reply( writeln!(output, "[本轮结束] 父 Agent 回复已在恢复前持久化。") .map_err(|error| format!("写入终端失败:{error}"))?; } else { - print_settled_parent_reply( + print_settled_parent_reply_for_run( parent_agent_id, session_id, + Some(&baseline.parent_run_id), snapshot.final_reply.as_deref(), observer, output, @@ -246,12 +304,31 @@ pub(super) fn print_settled_parent_reply( reply: Option<&str>, observer: &SwarmRuntimeObserver, output: &mut W, +) -> Result<(), String> { + print_settled_parent_reply_for_run(parent_agent_id, session_id, None, reply, observer, output) +} + +pub(super) fn print_settled_parent_reply_for_run( + parent_agent_id: &str, + session_id: &str, + target_run_id: Option<&str>, + reply: Option<&str>, + observer: &SwarmRuntimeObserver, + output: &mut W, ) -> Result<(), String> { let Some(reply) = reply else { return writeln!(output, "[本轮结束] 父 Agent 未产生新的最终回复。") .map_err(|error| format!("写入终端失败:{error}")); }; - if observer.parent_reply_was_fully_streamed(parent_agent_id, session_id, reply) { + let stream_belongs_to_target = target_run_id.is_none_or(|target_run_id| { + observer + .response_streams + .get(parent_agent_id) + .is_some_and(|cursor| cursor.identity.run_id == target_run_id) + }); + if stream_belongs_to_target + && observer.parent_reply_was_fully_streamed(parent_agent_id, session_id, reply) + { writeln!(output, "[本轮结束] 父 Agent 回复已完整流式输出。") .map_err(|error| format!("写入终端失败:{error}")) } else { diff --git a/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/report.rs b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/report.rs index e2469b0f9..a587ca7c0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/report.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/report.rs @@ -52,53 +52,189 @@ pub(super) struct SwarmTurnReport { pub(super) reconciliation_agent_count: usize, } +#[derive(Default)] +struct SwarmTurnRuntimeMetrics { + runtime_count: usize, + busy_runtime_count: usize, + pending_task_count: u64, + running_task_count: u64, + waiting_for_confirmation_count: u64, + waiting_for_user_input_count: u64, +} + +struct SwarmTurnRuntimeSnapshot { + agent_id: String, + run_id: String, + status: String, + phase: String, + updated_at: u64, +} + +fn runtime_state_belongs_to_turn( + state: &AgentRuntimeState, + parent_agent_id: &str, + session_id: &str, + parent_run_id: &str, +) -> bool { + (state.agent_id == parent_agent_id + && state.session_id == session_id + && state.run_id == parent_run_id) + || (state.parent_agent_id.as_deref() == Some(parent_agent_id) + && state.parent_run_id.as_deref() == Some(parent_run_id)) +} + +fn runtime_task_belongs_to_turn( + task: &AgentRuntimeTaskRecord, + parent_agent_id: &str, + session_id: &str, + parent_run_id: &str, +) -> bool { + (task.agent_id == parent_agent_id + && task.session_id == session_id + && task.run_id == parent_run_id) + || (task.parent_agent_id.as_deref() == Some(parent_agent_id) + && task.parent_run_id.as_deref() == Some(parent_run_id)) +} + +fn upsert_turn_runtime_snapshot( + snapshots: &mut Vec, + candidate: SwarmTurnRuntimeSnapshot, +) { + if candidate.run_id.trim().is_empty() { + return; + } + if let Some(existing) = snapshots.iter_mut().find(|snapshot| { + snapshot.agent_id == candidate.agent_id && snapshot.run_id == candidate.run_id + }) { + if candidate.updated_at >= existing.updated_at { + *existing = candidate; + } + } else { + snapshots.push(candidate); + } +} + +fn scoped_swarm_turn_runtime_metrics( + parent_agent_id: &str, + session_id: &str, + parent_run_id: Option<&str>, + runtimes: &[AgentRuntimeResult], +) -> Result { + let Some(parent_run_id) = parent_run_id else { + return Ok(SwarmTurnRuntimeMetrics::default()); + }; + let mut snapshots = Vec::new(); + for runtime in runtimes { + let journal_tasks = if runtime.task_path.trim().is_empty() { + None + } else { + Some(read_all_game_creator_agent_runtime_tasks(Path::new( + &runtime.task_path, + ))?) + }; + let tasks = journal_tasks.as_deref().unwrap_or(&runtime.recent_tasks); + for task in tasks { + if runtime_task_belongs_to_turn(task, parent_agent_id, session_id, parent_run_id) { + upsert_turn_runtime_snapshot( + &mut snapshots, + SwarmTurnRuntimeSnapshot { + agent_id: task.agent_id.clone(), + run_id: task.run_id.clone(), + status: task.status.clone(), + phase: task.phase.clone(), + updated_at: task.updated_at, + }, + ); + } + } + if runtime_state_belongs_to_turn(&runtime.state, parent_agent_id, session_id, parent_run_id) + { + upsert_turn_runtime_snapshot( + &mut snapshots, + SwarmTurnRuntimeSnapshot { + agent_id: runtime.state.agent_id.clone(), + run_id: runtime.state.run_id.clone(), + status: runtime.state.status.clone(), + phase: runtime.state.phase.clone(), + updated_at: runtime.state.updated_at, + }, + ); + } + } + + let mut metrics = SwarmTurnRuntimeMetrics { + runtime_count: snapshots.len(), + ..SwarmTurnRuntimeMetrics::default() + }; + for snapshot in snapshots { + if matches!( + snapshot.status.as_str(), + "pending" + | "running" + | "waiting-for-confirmation" + | "waiting-for-user-input" + | "cancelling" + ) || snapshot.phase == "needs-reconciliation" + { + metrics.busy_runtime_count += 1; + } + match snapshot.status.as_str() { + "pending" => metrics.pending_task_count += 1, + "running" => metrics.running_task_count += 1, + "waiting-for-confirmation" => metrics.waiting_for_confirmation_count += 1, + "waiting-for-user-input" => metrics.waiting_for_user_input_count += 1, + _ => {} + } + } + Ok(metrics) +} + pub(super) fn build_swarm_turn_report( outcome: SwarmTurnReportOutcome, parent_agent_id: &str, session_id: &str, + expected_parent_run_id: Option<&str>, runtimes: &[AgentRuntimeResult], conversation_metrics: SwarmTurnConversationMetrics, reconciliation_agent_count: usize, -) -> SwarmTurnReport { - let parent_run_id = runtimes - .iter() - .find(|runtime| { - runtime.state.agent_id == parent_agent_id && runtime.state.session_id == session_id - }) - .map(|runtime| runtime.state.run_id.trim()) +) -> Result { + let parent_run_id = expected_parent_run_id + .map(str::trim) .filter(|run_id| !run_id.is_empty()) - .map(str::to_string); - SwarmTurnReport { + .map(str::to_string) + .or_else(|| { + runtimes + .iter() + .find(|runtime| { + runtime.state.agent_id == parent_agent_id + && runtime.state.session_id == session_id + }) + .map(|runtime| runtime.state.run_id.trim()) + .filter(|run_id| !run_id.is_empty()) + .map(str::to_string) + }); + let runtime_metrics = scoped_swarm_turn_runtime_metrics( + parent_agent_id, + session_id, + parent_run_id.as_deref(), + runtimes, + )?; + Ok(SwarmTurnReport { schema_version: SWARM_TURN_REPORT_SCHEMA_VERSION, outcome, parent_agent_id: parent_agent_id.to_string(), session_id: session_id.to_string(), parent_run_id, - runtime_count: runtimes.len(), - busy_runtime_count: runtimes - .iter() - .filter(|runtime| runtime_is_busy(runtime)) - .count(), - pending_task_count: runtimes - .iter() - .map(|runtime| u64::from(runtime.task_queue.pending)) - .sum(), - running_task_count: runtimes - .iter() - .map(|runtime| u64::from(runtime.task_queue.running)) - .sum(), - waiting_for_confirmation_count: runtimes - .iter() - .map(|runtime| u64::from(runtime.task_queue.waiting_for_confirmation)) - .sum(), - waiting_for_user_input_count: runtimes - .iter() - .map(|runtime| u64::from(runtime.task_queue.waiting_for_user_input)) - .sum(), + runtime_count: runtime_metrics.runtime_count, + busy_runtime_count: runtime_metrics.busy_runtime_count, + pending_task_count: runtime_metrics.pending_task_count, + running_task_count: runtime_metrics.running_task_count, + waiting_for_confirmation_count: runtime_metrics.waiting_for_confirmation_count, + waiting_for_user_input_count: runtime_metrics.waiting_for_user_input_count, new_assistant_message_count: conversation_metrics.new_assistant_message_count, final_reply_chars: conversation_metrics.final_reply_chars, reconciliation_agent_count, - } + }) } pub(super) fn print_turn_outcome( diff --git a/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/terminal_classification.rs b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/terminal_classification.rs index 39dc51df3..155ac9ac3 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/terminal_classification.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/terminal_classification.rs @@ -34,6 +34,205 @@ pub(super) fn swarm_parent_runtime<'a>( }) } +pub(super) fn swarm_parent_runtime_for_run<'a>( + parent_agent_id: &str, + session_id: &str, + run_profile: &str, + expected_run_id: &str, + runtimes: &'a [AgentRuntimeResult], +) -> Option<&'a AgentRuntimeResult> { + swarm_parent_runtime(parent_agent_id, session_id, run_profile, runtimes) + .filter(|runtime| runtime.state.run_id == expected_run_id) +} + +pub(super) fn swarm_parent_steer_target<'a>( + parent_agent_id: &str, + session_id: &str, + run_profile: &str, + expected_run_id: Option<&str>, + runtimes: &'a [AgentRuntimeResult], +) -> Option<&'a AgentRuntimeResult> { + swarm_parent_runtime(parent_agent_id, session_id, run_profile, runtimes).filter(|runtime| { + expected_run_id.is_none_or(|run_id| runtime.state.run_id == run_id) + && game_creator_agent_runtime_accepts_steer(&runtime.state) + }) +} + +pub(super) fn matching_pending_swarm_run_id<'a>( + runtime: &'a AgentRuntimeResult, + session_id: &str, + run_profile: &str, + message: &str, +) -> Option<&'a str> { + let message = message.trim(); + runtime + .recent_tasks + .iter() + .rev() + .find(|task| { + task.session_id == session_id + && task.run_profile == run_profile + && task.status == "pending" + && task.phase == "queued" + && task.task.trim() == message + }) + .map(|task| task.run_id.as_str()) +} + +pub(super) fn next_pending_swarm_task<'a>( + runtime: &'a AgentRuntimeResult, + session_id: &str, + run_profile: &str, +) -> Option<&'a AgentRuntimeTaskRecord> { + runtime.recent_tasks.iter().find(|task| { + task.session_id == session_id + && task.run_profile == run_profile + && task.status == "pending" + && task.phase == "queued" + }) +} + +pub(super) fn swarm_parent_task_for_run<'a>( + parent_agent_id: &str, + session_id: &str, + run_profile: &str, + expected_run_id: &str, + runtimes: &'a [AgentRuntimeResult], +) -> Option<&'a AgentRuntimeTaskRecord> { + swarm_parent_runtime(parent_agent_id, session_id, run_profile, runtimes).and_then(|runtime| { + runtime.recent_tasks.iter().find(|task| { + task.agent_id == parent_agent_id + && task.session_id == session_id + && task.run_profile == run_profile + && task.run_id == expected_run_id + }) + }) +} + +pub(super) fn runtime_result_from_task_record(task: &AgentRuntimeTaskRecord) -> AgentRuntimeResult { + let mut state = default_game_creator_agent_runtime_state(&task.agent_id, &task.run_id); + state.task_id = task.task_id.clone(); + state.session_id = task.session_id.clone(); + state.source = task.source.clone(); + state.run_profile = task.run_profile.clone(); + state.run_profile_binding_fingerprint = task.run_profile_binding_fingerprint.clone(); + state.parent_agent_id = task.parent_agent_id.clone(); + state.parent_run_id = task.parent_run_id.clone(); + state.delegation_id = task.delegation_id.clone(); + state.goal_id = task.goal_id.clone(); + state.goal_revision = task.goal_revision; + state.goal_status = task.goal_status.clone(); + state.current_task = task.task.clone(); + state.status = task.status.clone(); + state.phase = task.phase.clone(); + state.current_action = task.current_action.clone(); + state.error = task.error.clone(); + state.updated_at = task.updated_at; + AgentRuntimeResult { + state, + accepted_run_id: None, + session_path: String::new(), + event_path: String::new(), + task_path: String::new(), + task_queue: AgentRuntimeTaskQueueSummary::default(), + recent_events: Vec::new(), + recent_tasks: vec![task.clone()], + response_stream: None, + user_input_request: None, + } +} + +pub(super) fn swarm_parent_runtime_snapshot_for_run( + root: &Path, + parent_agent_id: &str, + session_id: &str, + run_profile: &str, + expected_run_id: &str, + runtimes: &[AgentRuntimeResult], +) -> Result, String> { + if let Some(runtime) = swarm_parent_runtime_for_run( + parent_agent_id, + session_id, + run_profile, + expected_run_id, + runtimes, + ) { + return Ok(Some(runtime.clone())); + } + if let Some(task) = read_latest_game_creator_agent_runtime_task_by_run_id( + root, + parent_agent_id, + expected_run_id, + )? { + if task.agent_id != parent_agent_id + || task.session_id != session_id + || task.run_profile != run_profile + || task.run_id != expected_run_id + { + return Err("Swarm parent task journal 与目标 Session/run 不匹配".to_string()); + } + return Ok(Some(runtime_result_from_task_record(&task))); + } + Ok(swarm_parent_task_for_run( + parent_agent_id, + session_id, + run_profile, + expected_run_id, + runtimes, + ) + .map(runtime_result_from_task_record)) +} + +pub(super) fn swarm_turn_is_busy( + root: &Path, + parent_agent_id: &str, + session_id: &str, + run_profile: &str, + expected_run_id: &str, + runtimes: &[AgentRuntimeResult], +) -> Result { + let Some(parent) = swarm_parent_runtime_snapshot_for_run( + root, + parent_agent_id, + session_id, + run_profile, + expected_run_id, + runtimes, + )? + else { + return Ok(false); + }; + Ok(matches!( + parent.state.status.as_str(), + "pending" + | "running" + | "waiting-for-confirmation" + | "waiting-for-user-input" + | "cancelling" + ) || parent.state.phase == "needs-reconciliation") +} + +pub(super) fn swarm_current_runtimes_for_run( + parent_agent_id: &str, + session_id: &str, + run_profile: &str, + expected_run_id: &str, + runtimes: &[AgentRuntimeResult], +) -> Vec { + runtimes + .iter() + .filter(|runtime| { + (runtime.state.agent_id == parent_agent_id + && runtime.state.session_id == session_id + && runtime.state.run_profile == run_profile + && runtime.state.run_id == expected_run_id) + || (runtime.state.parent_agent_id.as_deref() == Some(parent_agent_id) + && runtime.state.parent_run_id.as_deref() == Some(expected_run_id)) + }) + .cloned() + .collect() +} + pub(super) fn runtime_terminal_failure_kind(runtime: &AgentRuntimeResult) -> Option<&'static str> { if runtime.state.phase == "needs-reconciliation" { None @@ -41,7 +240,9 @@ pub(super) fn runtime_terminal_failure_kind(runtime: &AgentRuntimeResult) -> Opt Some("budget-exhausted") } else if runtime.state.status == "cancelled" || runtime.state.phase == "cancelled" { Some("cancelled") - } else if runtime.state.status == "failed" { + } else if runtime.state.phase == "completion-contract-failed" { + Some("completion-contract-failed") + } else if runtime.state.status == "failed" || runtime.state.phase == "failed" { Some("failed") } else { None @@ -135,13 +336,58 @@ pub(super) fn scan_swarm_terminal_failures_at( parent_agent_id: &str, session_id: &str, run_profile: &str, + expected_parent_run_id: &str, runtimes: &[AgentRuntimeResult], ) -> SwarmTerminalFailureScan { let mut scan = SwarmTerminalFailureScan::default(); - let Some(parent) = swarm_parent_runtime(parent_agent_id, session_id, run_profile, runtimes) - else { - return scan; + let canonical_parent = swarm_parent_runtime_for_run( + parent_agent_id, + session_id, + run_profile, + expected_parent_run_id, + runtimes, + ); + let journal_parent_task = match read_latest_game_creator_agent_runtime_task_by_run_id( + root, + parent_agent_id, + expected_parent_run_id, + ) { + Ok(Some(task)) + if task.agent_id == parent_agent_id + && task.session_id == session_id + && task.run_profile == run_profile + && task.run_id == expected_parent_run_id => + { + Some(task) + } + Ok(Some(_)) => { + scan.reconciliation_agents.push(parent_agent_id.to_string()); + return scan; + } + Ok(None) => None, + Err(_) => { + scan.reconciliation_agents.push(parent_agent_id.to_string()); + return scan; + } }; + let parent_task = journal_parent_task.as_ref().or_else(|| { + swarm_parent_task_for_run( + parent_agent_id, + session_id, + run_profile, + expected_parent_run_id, + runtimes, + ) + }); + if canonical_parent.is_none() + && parent_task.is_none_or(|task| game_creator_agent_runtime_terminal_status(task).is_none()) + { + return scan; + } + let parent = canonical_parent + .cloned() + .or_else(|| parent_task.map(runtime_result_from_task_record)) + .expect("target parent runtime or terminal task exists"); let claimed_deliveries = match claimed_static_delegate_deliveries_at( root, &parent.state.agent_id, @@ -154,16 +400,79 @@ pub(super) fn scan_swarm_terminal_failures_at( return scan; } }; - if let Some(kind) = runtime_terminal_failure_kind(parent) { + if let Some(kind) = runtime_terminal_failure_kind(&parent) { scan.failed_agents .push(format!("{}:{kind}", parent.state.agent_id)); } - for child in runtimes.iter().filter(|runtime| { + let task_matches_parent = |task: &AgentRuntimeTaskRecord| { + task.source == "agent-delegate" + && task.parent_agent_id.as_deref() == Some(parent.state.agent_id.as_str()) + && task.parent_run_id.as_deref() == Some(parent.state.run_id.as_str()) + }; + let mut specialist_agent_ids = runtimes + .iter() + .map(|runtime| runtime.state.agent_id.clone()) + .filter(|agent_id| agent_id != &parent.state.agent_id) + .collect::>(); + specialist_agent_ids.extend( + claimed_deliveries + .iter() + .map(|delivery| delivery.target_agent_id.clone()), + ); + + // recent_tasks remains a compatibility fallback for in-memory callers, while every + // discoverable specialist journal below overwrites it with append-order latest records. + let mut latest_child_tasks_by_identity = + BTreeMap::<(String, String), AgentRuntimeTaskRecord>::new(); + for runtime in runtimes { + for task in runtime + .recent_tasks + .iter() + .filter(|task| task_matches_parent(task)) + { + latest_child_tasks_by_identity + .insert((task.agent_id.clone(), task.run_id.clone()), task.clone()); + } + } + for agent_id in specialist_agent_ids { + let path = game_creator_agent_runtime_task_path(root, &agent_id); + let tasks = match read_all_game_creator_agent_runtime_tasks(&path) { + Ok(tasks) => tasks, + Err(_) => { + scan.reconciliation_agents.push(agent_id); + continue; + } + }; + for task in tasks.into_iter().filter(|task| task_matches_parent(task)) { + if task.agent_id != agent_id { + scan.reconciliation_agents.push(agent_id.clone()); + continue; + } + latest_child_tasks_by_identity + .insert((task.agent_id.clone(), task.run_id.clone()), task); + } + } + + let mut failed_children_by_identity = latest_child_tasks_by_identity + .into_iter() + .filter_map(|(identity, task)| { + let runtime = runtime_result_from_task_record(&task); + runtime_terminal_failure_kind(&runtime).map(|_| (identity, runtime)) + }) + .collect::>(); + for runtime in runtimes.iter().filter(|runtime| { runtime.state.source == "agent-delegate" && runtime.state.parent_agent_id.as_deref() == Some(parent.state.agent_id.as_str()) && runtime.state.parent_run_id.as_deref() == Some(parent.state.run_id.as_str()) - && runtime_terminal_failure_kind(runtime).is_some() }) { + if runtime_terminal_failure_kind(runtime).is_some() { + failed_children_by_identity.insert( + (runtime.state.agent_id.clone(), runtime.state.run_id.clone()), + runtime.clone(), + ); + } + } + for child in failed_children_by_identity.values() { let Some(delegation_id) = child .state .delegation_id @@ -187,7 +496,7 @@ pub(super) fn scan_swarm_terminal_failures_at( }; let successful_repair = original_delivery_has_successful_repair(&delivery, &claimed_deliveries); - match classify_failed_specialist(parent, child, Some(&delivery), successful_repair) { + match classify_failed_specialist(&parent, child, Some(&delivery), successful_repair) { SwarmSpecialistFailureDisposition::Recoverable => {} SwarmSpecialistFailureDisposition::Failed => scan.failed_agents.push(format!( "{}:{}", @@ -388,10 +697,11 @@ pub(super) fn build_reconciliation_turn_outcome( SwarmTurnReportOutcome::NeedsReconciliation, parent_agent_id, session_id, + Some(&conversation_baseline.parent_run_id), runtimes, conversation_metrics, agent_ids.len(), - ); + )?; Ok(SwarmTurnOutcome::NeedsReconciliation { agent_ids, report }) } @@ -411,10 +721,11 @@ pub(super) fn build_failed_turn_outcome( SwarmTurnReportOutcome::Failed, parent_agent_id, session_id, + Some(&conversation_baseline.parent_run_id), runtimes, conversation_metrics, 0, - ); + )?; Ok(SwarmTurnOutcome::Failed { agent_ids, report }) } @@ -437,9 +748,10 @@ pub(super) fn build_incomplete_turn_outcome( SwarmTurnReportOutcome::Incomplete, parent_agent_id, session_id, + Some(&conversation_baseline.parent_run_id), runtimes, conversation_metrics, 0, - ); + )?; Ok(SwarmTurnOutcome::Incomplete { reasons, report }) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/tests.rs index 834f98ff0..9d9ff52e5 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/tests.rs @@ -273,6 +273,244 @@ fn parent_runtime_matching_is_scoped_to_requested_profile() { ); } +#[test] +fn completed_parent_with_pending_task_is_busy_but_not_a_steer_target() { + let session_id = "session-pending-after-completed"; + let run_profile = AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD; + let mut completed = runtime("idle", "completed", 1); + completed.state.agent_id = GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(); + completed.state.session_id = session_id.to_string(); + completed.state.run_id = "run-completed-before-pending".to_string(); + completed.state.run_profile = run_profile.to_string(); + completed.recent_tasks.push( + serde_json::from_value(serde_json::json!({ + "agentId": GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "taskId": GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "sessionId": session_id, + "runId": "run-pending-after-completed", + "source": AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, + "runProfile": run_profile, + "task": "继续补齐游戏功能", + "status": "pending", + "phase": "queued" + })) + .expect("deserialize pending task fixture"), + ); + let runtimes = vec![completed]; + + assert!(runtime_is_busy(&runtimes[0])); + assert!( + swarm_parent_steer_target( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + session_id, + run_profile, + None, + &runtimes, + ) + .is_none(), + "queued work must not make the completed canonical run steerable" + ); + assert_eq!( + matching_pending_swarm_run_id(&runtimes[0], session_id, run_profile, "继续补齐游戏功能",), + Some("run-pending-after-completed") + ); +} + +#[test] +fn new_turn_uses_accepted_run_id_instead_of_stale_canonical_state() { + let mut started = runtime("cancelled", "cancelled", 1); + started.state.agent_id = GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(); + started.state.run_id = "run-old-cancelled".to_string(); + started.accepted_run_id = Some("run-new-pending".to_string()); + + assert_eq!( + accepted_swarm_run_id(&started, "run-requested"), + "run-new-pending" + ); + started.accepted_run_id = None; + assert_eq!( + accepted_swarm_run_id(&started, "run-requested"), + "run-requested" + ); +} + +#[test] +fn queued_start_returns_actual_accepted_run_id_after_collision() { + let root = std::env::temp_dir().join(format!( + "swarm-cli-accepted-run-id-{}-{}", + std::process::id(), + unix_millis() + )); + init_local_game_project_at(&root, "project-accepted-run-id", "Accepted run ID") + .expect("initialize accepted run ID project"); + let runtime_lock = try_acquire_game_creator_agent_runtime_task_lock( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + ) + .expect("acquire supervisor runtime lock") + .expect("supervisor runtime lock available"); + + let first = start_game_creator_supervisor_background_task_for_session_at( + &root, + None, + "第一条排队任务", + "run-collision", + AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, + AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD, + ) + .expect("queue first colliding run"); + let second = start_game_creator_supervisor_background_task_for_session_at( + &root, + None, + "第二条排队任务", + "run-collision", + AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, + AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD, + ) + .expect("queue second colliding run"); + + assert_eq!(first.accepted_run_id.as_deref(), Some("run-collision")); + let second_run_id = second + .accepted_run_id + .as_deref() + .expect("second accepted run ID"); + assert_ne!(second_run_id, "run-collision"); + assert!(second_run_id.starts_with("run-collision-dup-")); + let serialized = serde_json::to_value(&second).expect("serialize queued start result"); + assert_eq!(serialized["acceptedRunId"], second_run_id); + assert!(serialized.get("accepted_run_id").is_none()); + + drop(runtime_lock); + fs::remove_dir_all(root).ok(); +} + +#[test] +fn completed_target_turn_settles_after_canonical_advances_to_next_run() { + let root = std::env::temp_dir().join(format!( + "swarm-cli-consecutive-snapshot-{}-{}", + std::process::id(), + unix_millis() + )); + init_local_game_project_at( + &root, + "project-consecutive-snapshot", + "Consecutive run snapshot", + ) + .expect("initialize consecutive snapshot project"); + let session_id = "session-consecutive-runs"; + let run_profile = AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD; + let completed_task = serde_json::from_value::(serde_json::json!({ + "agentId": GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "taskId": GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "sessionId": session_id, + "runId": "run-target-completed", + "source": AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, + "runProfile": run_profile, + "task": "先完成这一轮", + "status": "completed", + "phase": "completed", + "currentAction": "本轮已经完成" + })) + .expect("deserialize completed target task"); + let next_task = serde_json::from_value::(serde_json::json!({ + "agentId": GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "taskId": GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "sessionId": session_id, + "runId": "run-next-running", + "source": AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, + "runProfile": run_profile, + "task": "随后执行下一轮", + "status": "running", + "phase": "planning", + "currentAction": "下一轮正在执行" + })) + .expect("deserialize next running task"); + let mut current = runtime("running", "planning", 0); + current.state.agent_id = GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(); + current.state.session_id = session_id.to_string(); + current.state.run_id = "run-next-running".to_string(); + current.state.run_profile = run_profile.to_string(); + let task_path = + game_creator_agent_runtime_task_path(&root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID); + fs::create_dir_all(task_path.parent().expect("task journal parent")) + .expect("create task journal parent"); + fs::write( + &task_path, + format!( + "{}\n{}\n", + serde_json::to_string(&completed_task).expect("serialize completed target task"), + serde_json::to_string(&next_task).expect("serialize next target task"), + ), + ) + .expect("persist full task journal"); + current.recent_tasks = vec![next_task]; + let runtimes = vec![current]; + + assert!(!swarm_turn_is_busy( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + session_id, + run_profile, + "run-target-completed", + &runtimes, + ) + .expect("read completed target busy state")); + assert!(swarm_turn_is_busy( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + session_id, + run_profile, + "run-next-running", + &runtimes, + ) + .expect("read next target busy state")); + let snapshot = swarm_parent_runtime_snapshot_for_run( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + session_id, + run_profile, + "run-target-completed", + &runtimes, + ) + .expect("read target task journal") + .expect("recover completed target from task journal"); + assert!(parent_runtime_completed(&snapshot)); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn running_parent_remains_the_only_valid_swarm_steer_target() { + let session_id = "session-running-steer"; + let run_profile = AGENT_RUNTIME_RUN_PROFILE_STANDARD; + let mut running = runtime("running", "waiting-for-provider-retry", 0); + running.state.agent_id = GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(); + running.state.session_id = session_id.to_string(); + running.state.run_id = "run-running-steer".to_string(); + running.state.run_profile = run_profile.to_string(); + let runtimes = vec![running]; + + assert_eq!( + swarm_parent_steer_target( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + session_id, + run_profile, + Some("run-running-steer"), + &runtimes, + ) + .map(|runtime| runtime.state.run_id.as_str()), + Some("run-running-steer") + ); + assert!(swarm_parent_steer_target( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + session_id, + run_profile, + Some("another-run"), + &runtimes, + ) + .is_none()); +} + #[test] fn parses_chat_commands_without_stealing_normal_messages() { assert_eq!(parse_swarm_chat_input(" "), None); @@ -696,6 +934,162 @@ fn recovered_assistant_cannot_overlap_a_new_terminal_reply() { fs::remove_dir_all(root).ok(); } +#[test] +fn conversation_snapshot_scopes_consecutive_run_replies_by_message_id() { + let target_message_id = game_creator_agent_runtime_finalization_message_id( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "session-consecutive", + "run-b", + ); + let next_message_id = game_creator_agent_runtime_finalization_message_id( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "session-consecutive", + "run-c", + ); + let (metrics, reply) = summarize_scoped_new_assistant_messages( + [ + ( + "assistant", + "B 的最终回复", + Some(target_message_id.as_str()), + ), + ("assistant", "C 的最终回复", Some(next_message_id.as_str())), + ], + Some(&target_message_id), + ); + assert_eq!(metrics.new_assistant_message_count, 1); + assert_eq!(metrics.final_reply_chars, "B 的最终回复".chars().count()); + assert_eq!(reply, Some("B 的最终回复")); + + let (missing_metrics, missing_reply) = summarize_scoped_new_assistant_messages( + [("assistant", "C 的最终回复", Some(next_message_id.as_str()))], + Some(&target_message_id), + ); + assert_eq!(missing_metrics, SwarmTurnConversationMetrics::default()); + assert_eq!(missing_reply, None); + + let (legacy_metrics, legacy_reply) = summarize_scoped_new_assistant_messages( + [("assistant", "旧格式回复", None)], + Some(&target_message_id), + ); + assert_eq!(legacy_metrics.new_assistant_message_count, 1); + assert_eq!(legacy_reply, Some("旧格式回复")); +} + +#[test] +fn wait_for_turn_keeps_consecutive_run_reply_and_report_scoped() { + let root = std::env::temp_dir().join(format!( + "swarm-cli-consecutive-wait-{}-{}", + std::process::id(), + unix_millis() + )); + init_local_game_project_at(&root, "project-consecutive-wait", "Consecutive wait") + .expect("initialize consecutive wait project"); + let parent_agent_id = GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID; + let before = append_local_conversation_message_at( + &root, + Some(parent_agent_id), + LocalConversationMessage { + role: "user".to_string(), + content: "先完成 B".to_string(), + agent_id: None, + }, + ) + .expect("append B user turn"); + let session_id = before.session_id.expect("active supervisor session"); + let baseline = new_swarm_turn_conversation_baseline(before.messages.len(), "run-b"); + for (run_id, reply) in [("run-b", "B 的最终回复"), ("run-c", "C 的最终回复")] { + append_local_conversation_message_for_session_idempotent_at( + &root, + Some(parent_agent_id), + Some(&session_id), + LocalConversationMessage { + role: "assistant".to_string(), + content: reply.to_string(), + agent_id: None, + }, + &game_creator_agent_runtime_finalization_message_id( + parent_agent_id, + &session_id, + run_id, + ), + ) + .expect("append consecutive assistant reply"); + } + + let task_path = game_creator_agent_runtime_task_path(&root, parent_agent_id); + fs::create_dir_all(task_path.parent().expect("consecutive journal parent")) + .expect("create consecutive journal parent"); + let tasks = [("run-b", "先完成 B"), ("run-c", "再完成 C")] + .into_iter() + .map(|(run_id, task)| { + serde_json::from_value::(serde_json::json!({ + "agentId": parent_agent_id, + "taskId": parent_agent_id, + "sessionId": session_id, + "runId": run_id, + "source": AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, + "runProfile": AGENT_RUNTIME_RUN_PROFILE_STANDARD, + "task": task, + "status": "completed", + "phase": "completed", + "currentAction": "本轮已完成", + "updatedAt": if run_id == "run-b" { 100 } else { 200 } + })) + .expect("deserialize consecutive task") + }) + .collect::>(); + fs::write( + &task_path, + tasks + .iter() + .map(|task| serde_json::to_string(task).expect("serialize consecutive task")) + .collect::>() + .join("\n") + + "\n", + ) + .expect("persist consecutive task journal"); + let mut current = default_game_creator_agent_runtime_state(parent_agent_id, "run-c"); + current.session_id = session_id.clone(); + current.source = AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE.to_string(); + current.run_profile = AGENT_RUNTIME_RUN_PROFILE_STANDARD.to_string(); + current.status = "idle".to_string(); + current.phase = "completed".to_string(); + current.current_task = "再完成 C".to_string(); + current.current_action = "C 已完成".to_string(); + current.updated_at = 200; + write_game_creator_agent_runtime_state(&root, ¤t) + .expect("persist consecutive canonical state"); + + let (_tx, rx) = mpsc::channel(); + let mut observer = SwarmRuntimeObserver::default(); + let mut output = Vec::new(); + let outcome = wait_for_swarm_turn( + &root, + parent_agent_id, + &session_id, + AGENT_RUNTIME_RUN_PROFILE_STANDARD, + baseline, + &rx, + &mut output, + &mut observer, + Duration::from_millis(1), + Duration::ZERO, + ) + .expect("settle B after canonical advanced to C"); + let SwarmTurnOutcome::Settled(report) = outcome else { + panic!("B must settle independently: {outcome:?}"); + }; + assert_eq!(report.parent_run_id.as_deref(), Some("run-b")); + assert_eq!(report.new_assistant_message_count, 1); + assert_eq!(report.runtime_count, 1); + let output = String::from_utf8(output).expect("consecutive wait output is utf-8"); + assert!(output.contains("B 的最终回复")); + assert!(!output.contains("C 的最终回复")); + + fs::remove_dir_all(root).ok(); +} + #[test] fn confirmation_prompt_propagates_eof_as_closed_input() { let (tx, rx) = mpsc::channel(); @@ -930,6 +1324,7 @@ fn observer_failure_scan_waits_for_original_repair_and_fails_repair_child() { &parent.state.agent_id, &parent.state.session_id, AGENT_RUNTIME_RUN_PROFILE_STANDARD, + &parent.state.run_id, &[parent.clone(), child.clone()], ); assert!(original_scan.failed_agents.is_empty()); @@ -957,6 +1352,7 @@ fn observer_failure_scan_waits_for_original_repair_and_fails_repair_child() { &parent.state.agent_id, &parent.state.session_id, AGENT_RUNTIME_RUN_PROFILE_STANDARD, + &parent.state.run_id, &[parent.clone(), child], ); assert_eq!(repair_scan.failed_agents, vec!["code-prototype:failed"]); @@ -966,6 +1362,232 @@ fn observer_failure_scan_waits_for_original_repair_and_fails_repair_child() { fs::remove_dir_all(root).ok(); } +#[test] +fn failure_scan_reads_all_historical_runs_for_the_same_specialist() { + let root = std::env::temp_dir().join(format!( + "swarm-cli-historical-repair-scan-{}-{}", + std::process::id(), + unix_millis() + )); + init_local_game_project_at(&root, "project-historical-repair", "Historical repair scan") + .expect("initialize historical repair project"); + let mut parent = runtime("running", "waiting-for-delegate-receipts", 0); + parent.state.agent_id = GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(); + parent.state.session_id = "session-historical-parent".to_string(); + parent.state.run_id = "run-historical-parent".to_string(); + let acceptance = vec!["交付可运行原型".to_string()]; + let original = new_static_delegate_delivery_with_contract( + &parent.state.agent_id, + &parent.state.session_id, + &parent.state.run_id, + "action-historical-original", + "delivery-historical-original", + "code-prototype", + "session-historical-child", + "run-historical-original", + &acceptance, + &[], + None, + ); + let repair = new_static_delegate_delivery_with_contract( + &parent.state.agent_id, + &parent.state.session_id, + &parent.state.run_id, + "action-historical-repair", + "delivery-historical-repair", + "code-prototype", + "session-historical-child", + "run-historical-repair", + &acceptance, + &[], + Some("delivery-historical-original"), + ); + let quality_failure = new_static_delegate_delivery_with_contract( + &parent.state.agent_id, + &parent.state.session_id, + &parent.state.run_id, + "action-historical-quality", + "delivery-historical-quality", + "quality-review", + "session-historical-quality", + "run-historical-repair", + &acceptance, + &[], + Some("delivery-historical-quality-original"), + ); + create_or_read_static_delegate_delivery_at(&root, &original) + .expect("persist historical original delivery"); + create_or_read_static_delegate_delivery_at(&root, &repair) + .expect("persist historical repair delivery"); + create_or_read_static_delegate_delivery_at(&root, &quality_failure) + .expect("persist historical quality delivery"); + + let original_task = serde_json::from_value::(serde_json::json!({ + "agentId": "code-prototype", + "taskId": "code-prototype", + "sessionId": "session-historical-child", + "runId": "run-historical-original", + "source": "agent-delegate", + "runProfile": AGENT_RUNTIME_RUN_PROFILE_STANDARD, + "parentAgentId": GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "parentRunId": "run-historical-parent", + "delegationId": "delivery-historical-original", + "task": "原始委派", + "status": "failed", + "phase": "failed", + "currentAction": "原始委派失败", + "updatedAt": 100 + })) + .expect("deserialize historical original task"); + let repair_task = serde_json::from_value::(serde_json::json!({ + "agentId": "code-prototype", + "taskId": "code-prototype", + "sessionId": "session-historical-child", + "runId": "run-historical-repair", + "source": "agent-delegate", + "runProfile": AGENT_RUNTIME_RUN_PROFILE_STANDARD, + "parentAgentId": GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "parentRunId": "run-historical-parent", + "delegationId": "delivery-historical-repair", + "task": "修复委派", + "status": "failed", + "phase": "completion-contract-failed", + "currentAction": "修复交付未通过合同", + "updatedAt": 200 + })) + .expect("deserialize historical repair task"); + let task_path = game_creator_agent_runtime_task_path(&root, "code-prototype"); + fs::create_dir_all(task_path.parent().expect("specialist journal parent")) + .expect("create specialist journal parent"); + fs::write( + &task_path, + format!( + "{}\n{}\n", + serde_json::to_string(&original_task).expect("serialize original task"), + serde_json::to_string(&repair_task).expect("serialize repair task"), + ), + ) + .expect("persist specialist task journal"); + + let quality_task = serde_json::from_value::(serde_json::json!({ + "agentId": "quality-review", + "taskId": "quality-review", + "sessionId": "session-historical-quality", + "runId": "run-historical-repair", + "source": "agent-delegate", + "runProfile": AGENT_RUNTIME_RUN_PROFILE_STANDARD, + "parentAgentId": GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "parentRunId": "run-historical-parent", + "delegationId": "delivery-historical-quality", + "task": "质量修复复验", + "status": "failed", + "phase": "failed", + "currentAction": "质量修复失败", + "updatedAt": 210 + })) + .expect("deserialize historical quality task"); + let quality_task_path = game_creator_agent_runtime_task_path(&root, "quality-review"); + fs::create_dir_all(quality_task_path.parent().expect("quality journal parent")) + .expect("create quality journal parent"); + fs::write( + &quality_task_path, + format!( + "{}\n", + serde_json::to_string(&quality_task).expect("serialize quality task"), + ), + ) + .expect("persist quality task journal"); + + let mut current_specialist = runtime("running", "planning", 0); + current_specialist.state.agent_id = "code-prototype".to_string(); + current_specialist.state.session_id = "session-historical-child".to_string(); + current_specialist.state.run_id = "run-historical-repair".to_string(); + current_specialist.state.source = "agent-delegate".to_string(); + current_specialist.state.parent_agent_id = Some(parent.state.agent_id.clone()); + current_specialist.state.parent_run_id = Some(parent.state.run_id.clone()); + current_specialist.state.updated_at = 50; + let mut current_quality = runtime("idle", "completed", 0); + current_quality.state.agent_id = "quality-review".to_string(); + current_quality.state.run_id = "run-later-quality".to_string(); + let scan = scan_swarm_terminal_failures_at( + &root, + &parent.state.agent_id, + &parent.state.session_id, + AGENT_RUNTIME_RUN_PROFILE_STANDARD, + &parent.state.run_id, + &[parent.clone(), current_specialist, current_quality], + ); + assert_eq!( + scan.failed_agents, + vec![ + "code-prototype:completion-contract-failed", + "quality-review:failed", + ] + ); + assert!(scan.incomplete_reasons.is_empty()); + assert!(scan.reconciliation_agents.is_empty()); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn failure_scan_ignores_cancelled_parent_and_children_from_another_run() { + let root = std::env::temp_dir().join(format!( + "swarm-cli-cross-run-failure-scan-{}-{}", + std::process::id(), + unix_millis() + )); + init_local_game_project_at(&root, "project-cross-run-scan", "Cross-run failure scan") + .expect("initialize cross-run failure scan project"); + let session_id = "session-cross-run"; + let run_profile = AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD; + let mut old_parent = runtime("cancelled", "cancelled", 1); + old_parent.state.agent_id = GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(); + old_parent.state.session_id = session_id.to_string(); + old_parent.state.run_id = "run-old-cancelled".to_string(); + old_parent.state.run_profile = run_profile.to_string(); + + let mut old_child = runtime("failed", "budget-exhausted", 0); + old_child.state.agent_id = "art-asset-plan".to_string(); + old_child.state.session_id = "session-old-child".to_string(); + old_child.state.run_id = "run-old-child".to_string(); + old_child.state.source = "agent-delegate".to_string(); + old_child.state.parent_agent_id = Some(old_parent.state.agent_id.clone()); + old_child.state.parent_run_id = Some(old_parent.state.run_id.clone()); + + let runtimes = vec![old_parent.clone(), old_child]; + let new_turn_scan = scan_swarm_terminal_failures_at( + &root, + &old_parent.state.agent_id, + session_id, + run_profile, + "run-new-pending", + &runtimes, + ); + assert!(new_turn_scan.failed_agents.is_empty()); + assert!(new_turn_scan.incomplete_reasons.is_empty()); + assert!(new_turn_scan.reconciliation_agents.is_empty()); + + let old_turn_scan = scan_swarm_terminal_failures_at( + &root, + &old_parent.state.agent_id, + session_id, + run_profile, + &old_parent.state.run_id, + &runtimes, + ); + assert!(old_turn_scan + .failed_agents + .iter() + .any(|agent| agent == "project-supervisor:cancelled")); + assert!(old_turn_scan + .failed_agents + .iter() + .any(|agent| agent == "art-asset-plan:budget-exhausted")); + + fs::remove_dir_all(root).ok(); +} + #[test] fn missing_confirmation_sidecar_is_reported_as_reconciliation() { let broken = runtime("waiting-for-confirmation", "waiting-for-confirmation", 0); @@ -977,20 +1599,40 @@ fn missing_confirmation_sidecar_is_reported_as_reconciliation() { #[test] fn turn_report_counts_runtime_and_conversation_snapshots() { - let mut parent = runtime("running", "response", 2); + let mut parent = runtime("running", "response", 0); parent.state.agent_id = "project-supervisor".to_string(); parent.state.session_id = "session-report".to_string(); parent.state.run_id = "run-parent".to_string(); - parent.task_queue.running = 1; - parent.task_queue.waiting_for_confirmation = 2; - let mut child = runtime("idle", "completed", 3); - child.state.agent_id = "child-code".to_string(); - child.task_queue.waiting_for_user_input = 1; + let mut pending_child = runtime("pending", "queued", 0); + pending_child.state.agent_id = "child-code".to_string(); + pending_child.state.run_id = "run-child-pending".to_string(); + pending_child.state.parent_agent_id = Some("project-supervisor".to_string()); + pending_child.state.parent_run_id = Some("run-parent".to_string()); - let mut idle = runtime("idle", "completed", 0); - idle.state.agent_id = "design-review".to_string(); - let runtimes = vec![parent, child, idle]; + let mut confirmation_child = runtime("waiting-for-confirmation", "waiting-for-confirmation", 0); + confirmation_child.state.agent_id = "child-design".to_string(); + confirmation_child.state.run_id = "run-child-confirmation".to_string(); + confirmation_child.state.parent_agent_id = Some("project-supervisor".to_string()); + confirmation_child.state.parent_run_id = Some("run-parent".to_string()); + + let mut input_child = runtime("waiting-for-user-input", "waiting-for-user-input", 0); + input_child.state.agent_id = "child-test".to_string(); + input_child.state.run_id = "run-child-input".to_string(); + input_child.state.parent_agent_id = Some("project-supervisor".to_string()); + input_child.state.parent_run_id = Some("run-parent".to_string()); + + let mut next_turn = runtime("running", "planning", 0); + next_turn.state.agent_id = "project-supervisor".to_string(); + next_turn.state.session_id = "session-report".to_string(); + next_turn.state.run_id = "run-next-turn".to_string(); + let runtimes = vec![ + parent, + pending_child, + confirmation_child, + input_child, + next_turn, + ]; let (conversation_metrics, final_reply) = summarize_new_assistant_messages([ ("user", "请继续"), ("assistant", "阶段回复"), @@ -1003,27 +1645,88 @@ fn turn_report_counts_runtime_and_conversation_snapshots() { SwarmTurnReportOutcome::NeedsReconciliation, "project-supervisor", "session-report", + Some("run-parent"), &runtimes, conversation_metrics, 1, - ); + ) + .expect("build scoped turn report"); assert_eq!(report.schema_version, SWARM_TURN_REPORT_SCHEMA_VERSION); assert_eq!(report.outcome, SwarmTurnReportOutcome::NeedsReconciliation); assert_eq!(report.parent_agent_id, "project-supervisor"); assert_eq!(report.session_id, "session-report"); assert_eq!(report.parent_run_id.as_deref(), Some("run-parent")); - assert_eq!(report.runtime_count, 3); - assert_eq!(report.busy_runtime_count, 2); - assert_eq!(report.pending_task_count, 5); + assert_eq!(report.runtime_count, 4); + assert_eq!(report.busy_runtime_count, 4); + assert_eq!(report.pending_task_count, 1); assert_eq!(report.running_task_count, 1); - assert_eq!(report.waiting_for_confirmation_count, 2); + assert_eq!(report.waiting_for_confirmation_count, 1); assert_eq!(report.waiting_for_user_input_count, 1); assert_eq!(report.new_assistant_message_count, 2); assert_eq!(report.final_reply_chars, "最终🙂".chars().count()); assert_eq!(report.reconciliation_agent_count, 1); } +#[test] +fn turn_report_prefers_expected_run_over_stale_canonical_state() { + let root = std::env::temp_dir().join(format!( + "swarm-cli-report-full-journal-{}-{}", + std::process::id(), + unix_millis() + )); + init_local_game_project_at(&root, "project-report-journal", "Report full journal") + .expect("initialize report journal project"); + let mut old_parent = runtime("cancelled", "cancelled", 1); + old_parent.state.agent_id = GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(); + old_parent.state.session_id = "session-report-stale".to_string(); + old_parent.state.run_id = "run-old-cancelled".to_string(); + let task_path = + game_creator_agent_runtime_task_path(&root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID); + fs::create_dir_all(task_path.parent().expect("report journal parent")) + .expect("create report journal parent"); + let pending_task = serde_json::from_value::(serde_json::json!({ + "agentId": GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "taskId": GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "sessionId": "session-report-stale", + "runId": "run-new-pending", + "source": AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, + "runProfile": AGENT_RUNTIME_RUN_PROFILE_STANDARD, + "task": "等待下一轮", + "status": "pending", + "phase": "queued", + "currentAction": "等待 Runner", + "updatedAt": 200 + })) + .expect("deserialize report pending task"); + fs::write( + &task_path, + format!( + "{}\n", + serde_json::to_string(&pending_task).expect("serialize report pending task"), + ), + ) + .expect("persist report task journal"); + old_parent.task_path = task_path.to_string_lossy().into_owned(); + + let report = build_swarm_turn_report( + SwarmTurnReportOutcome::Failed, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "session-report-stale", + Some("run-new-pending"), + &[old_parent], + SwarmTurnConversationMetrics::default(), + 0, + ) + .expect("build stale canonical turn report"); + + assert_eq!(report.parent_run_id.as_deref(), Some("run-new-pending")); + assert_eq!(report.runtime_count, 1); + assert_eq!(report.pending_task_count, 1); + + fs::remove_dir_all(root).ok(); +} + #[test] fn turn_report_json_is_single_line_and_omits_sensitive_bodies_and_paths() { let sensitive_reply = concat!( @@ -1037,10 +1740,12 @@ fn turn_report_json_is_single_line_and_omits_sensitive_bodies_and_paths() { SwarmTurnReportOutcome::Settled, "project-supervisor", "session-safe", + None, &[], conversation_metrics, 0, - ); + ) + .expect("build safe turn report"); let json = serde_json::to_string(&report).expect("serialize turn report"); let value = serde_json::from_str::(&json).expect("parse turn report"); let object = value.as_object().expect("turn report is an object"); @@ -1096,10 +1801,12 @@ fn turn_outcome_prints_all_terminal_reports_but_not_quit() { SwarmTurnReportOutcome::Settled, "project-supervisor", "session-settled", + None, &[], metrics, 0, - ); + ) + .expect("build settled turn report"); let mut settled_output = Vec::new(); print_turn_outcome( SwarmTurnOutcome::Settled(settled_report), @@ -1115,10 +1822,12 @@ fn turn_outcome_prints_all_terminal_reports_but_not_quit() { SwarmTurnReportOutcome::Failed, "project-supervisor", "session-failed", + None, &[], metrics, 0, - ); + ) + .expect("build failed turn report"); let mut failed_output = Vec::new(); print_turn_outcome( SwarmTurnOutcome::Failed { @@ -1136,10 +1845,12 @@ fn turn_outcome_prints_all_terminal_reports_but_not_quit() { SwarmTurnReportOutcome::Incomplete, "project-supervisor", "session-incomplete", + None, &[], metrics, 0, - ); + ) + .expect("build incomplete turn report"); let mut incomplete_output = Vec::new(); print_turn_outcome( SwarmTurnOutcome::Incomplete { @@ -1158,10 +1869,12 @@ fn turn_outcome_prints_all_terminal_reports_but_not_quit() { SwarmTurnReportOutcome::NeedsReconciliation, "project-supervisor", "session-reconciliation", + None, &[], metrics, 2, - ); + ) + .expect("build reconciliation turn report"); let mut reconciliation_output = Vec::new(); print_turn_outcome( SwarmTurnOutcome::NeedsReconciliation { @@ -1539,10 +2252,12 @@ fn settled_parent_reply_is_not_repeated_after_complete_stream() { SwarmTurnReportOutcome::Settled, "code-prototype", "session-test", + None, &[], conversation_metrics, 0, - ); + ) + .expect("build streamed reply turn report"); print_turn_outcome(SwarmTurnOutcome::Settled(report), &mut output) .expect("print settled report after stream"); @@ -1551,6 +2266,34 @@ fn settled_parent_reply_is_not_repeated_after_complete_stream() { assert!(output.contains("父 Agent 回复已完整流式输出")); assert!(output.contains(SWARM_TURN_REPORT_PREFIX)); + let mut next_run_stream = response_stream( + "slot-next", + 14, + 1, + AGENT_RUNTIME_RESPONSE_STREAM_STATUS_READY, + "下一轮回复", + ); + next_run_stream.run_id = "run-next".to_string(); + let mut next_run_observer = SwarmRuntimeObserver::default(); + let mut next_run_output = Vec::new(); + next_run_observer + .print_changes( + &[runtime_with_response_stream(next_run_stream)], + &mut next_run_output, + ) + .expect("observe next run stream"); + print_settled_parent_reply_for_run( + "code-prototype", + "session-test", + Some("run-target"), + Some("上一轮最终回复"), + &next_run_observer, + &mut next_run_output, + ) + .expect("next run stream must not suppress target reply"); + let next_run_output = String::from_utf8(next_run_output).expect("next run output is utf-8"); + assert!(next_run_output.contains("Agent> 上一轮最终回复")); + let mut fallback = Vec::new(); print_settled_parent_reply( "code-prototype", diff --git a/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/turn_dispatch.rs b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/turn_dispatch.rs index 590d66d95..2477acf33 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/turn_dispatch.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/turn_dispatch.rs @@ -12,52 +12,46 @@ pub(super) fn handle_swarm_user_turn( ) -> Result { let before = read_local_conversation_for_session_at(root, Some(parent_agent_id), Some(session_id))?; - if let Some(goal) = read_game_creator_agent_goal_at(root, parent_agent_id, session_id)? { - match goal.status.as_str() { - AGENT_GOAL_STATUS_ACTIVE => { - return steer_and_wait_for_swarm_turn( - root, - parent_agent_id, - session_id, - run_profile, - &goal.run_id, - message, - before.messages.len(), - input, - output, - "Goal 已追加", - ); + let active_goal_run_id = + if let Some(goal) = read_game_creator_agent_goal_at(root, parent_agent_id, session_id)? { + match goal.status.as_str() { + AGENT_GOAL_STATUS_ACTIVE => Some(goal.run_id), + AGENT_GOAL_STATUS_PAUSE_REQUESTED | AGENT_GOAL_STATUS_PAUSED => { + print_swarm_goal_error(output, "当前 Goal 已暂停;请先输入 /goal resume。")?; + return Ok(SwarmChatFlow::Continue); + } + AGENT_GOAL_STATUS_CLEARING => { + print_swarm_goal_error(output, "当前 Goal 正在清理,暂不接受新消息。")?; + return Ok(SwarmChatFlow::Continue); + } + AGENT_GOAL_STATUS_NEEDS_RECONCILIATION => { + print_swarm_goal_error( + output, + "当前 Goal 需要人工 reconciliation,暂不接受新消息。", + )?; + return Ok(SwarmChatFlow::Continue); + } + AGENT_GOAL_STATUS_COMPLETED | AGENT_GOAL_STATUS_CLEARED => None, + status => { + print_swarm_goal_error( + output, + &format!("当前 Goal 状态未知,已阻止发送:{status}"), + )?; + return Ok(SwarmChatFlow::Continue); + } } - AGENT_GOAL_STATUS_PAUSE_REQUESTED | AGENT_GOAL_STATUS_PAUSED => { - print_swarm_goal_error(output, "当前 Goal 已暂停;请先输入 /goal resume。")?; - return Ok(SwarmChatFlow::Continue); - } - AGENT_GOAL_STATUS_CLEARING => { - print_swarm_goal_error(output, "当前 Goal 正在清理,暂不接受新消息。")?; - return Ok(SwarmChatFlow::Continue); - } - AGENT_GOAL_STATUS_NEEDS_RECONCILIATION => { - print_swarm_goal_error( - output, - "当前 Goal 需要人工 reconciliation,暂不接受新消息。", - )?; - return Ok(SwarmChatFlow::Continue); - } - AGENT_GOAL_STATUS_COMPLETED | AGENT_GOAL_STATUS_CLEARED => {} - status => { - print_swarm_goal_error( - output, - &format!("当前 Goal 状态未知,已阻止发送:{status}"), - )?; - return Ok(SwarmChatFlow::Continue); - } - } - } + } else { + None + }; - let runtimes = read_game_creator_agent_runtimes_at(root)?; - if let Some(runtime) = swarm_parent_runtime(parent_agent_id, session_id, run_profile, &runtimes) - .filter(|runtime| runtime_is_busy(runtime)) - { + let mut runtimes = read_game_creator_agent_runtimes_at(root)?; + if let Some(runtime) = swarm_parent_steer_target( + parent_agent_id, + session_id, + run_profile, + active_goal_run_id.as_deref(), + &runtimes, + ) { return steer_and_wait_for_swarm_turn( root, parent_agent_id, @@ -68,19 +62,102 @@ pub(super) fn handle_swarm_user_turn( before.messages.len(), input, output, - "运行中输入已排队", + if active_goal_run_id.is_some() { + "Goal 已追加" + } else { + "运行中输入已排队" + }, ); } + let pending_message_is_latest = before + .messages + .last() + .is_some_and(|item| item.role == "user" && item.content.trim() == message.trim()); + let matching_pending_run_id = pending_message_is_latest + .then(|| swarm_parent_runtime(parent_agent_id, session_id, run_profile, &runtimes)) + .flatten() + .and_then(|runtime| { + matching_pending_swarm_run_id(runtime, session_id, run_profile, message) + }) + .map(str::to_string); + let parent_has_queued_work = + swarm_parent_runtime(parent_agent_id, session_id, run_profile, &runtimes) + .is_some_and(runtime_is_busy); + if parent_has_queued_work { + require_external_agent_runner_for_cli_runtime_write(root)?; + resume_game_creator_agent_background_tasks_at(root)?; + runtimes = read_game_creator_agent_runtimes_at(root)?; + + if let Some(run_id) = matching_pending_run_id { + writeln!( + output, + "[恢复] 该消息已在 run={run_id} 落盘,继续观察原任务,不重复追加。" + ) + .map_err(|error| format!("写入终端失败:{error}"))?; + let conversation_baseline = new_swarm_turn_conversation_baseline( + before.messages.len().saturating_sub(1), + &run_id, + ); + return wait_and_print_swarm_turn( + root, + parent_agent_id, + session_id, + run_profile, + conversation_baseline, + input, + output, + ); + } + + if let Some(runtime) = swarm_parent_steer_target( + parent_agent_id, + session_id, + run_profile, + active_goal_run_id.as_deref(), + &runtimes, + ) { + return steer_and_wait_for_swarm_turn( + root, + parent_agent_id, + session_id, + run_profile, + &runtime.state.run_id, + message, + before.messages.len(), + input, + output, + if active_goal_run_id.is_some() { + "Goal 已恢复并追加" + } else { + "排队任务已恢复,输入已追加" + }, + ); + } + } + + if let Some(goal_run_id) = active_goal_run_id { + print_swarm_goal_error( + output, + &format!( + "当前 Goal run={goal_run_id} 没有可追加的运行态;请先输入 /resume 检查恢复结果。" + ), + )?; + return Ok(SwarmChatFlow::Continue); + } + let action = if game_creator_agent_uses_interaction_kernel(parent_agent_id) { let Some(runtime_lock) = try_acquire_game_creator_agent_runtime_task_lock(root, parent_agent_id)? else { let current_runtimes = read_game_creator_agent_runtimes_at(root)?; - if let Some(runtime) = - swarm_parent_runtime(parent_agent_id, session_id, run_profile, ¤t_runtimes) - .filter(|runtime| runtime_is_busy(runtime)) - { + if let Some(runtime) = swarm_parent_steer_target( + parent_agent_id, + session_id, + run_profile, + None, + ¤t_runtimes, + ) { return steer_and_wait_for_swarm_turn( root, parent_agent_id, @@ -322,14 +399,15 @@ fn start_and_wait_for_swarm_turn( requested_run_id.clone(), )?, }; + let accepted_run_id = accepted_swarm_run_id(&started, &requested_run_id); writeln!( output, "[已投递] agent={} session={} run={}", - parent_agent_id, started.state.session_id, requested_run_id + parent_agent_id, started.state.session_id, accepted_run_id ) .map_err(|error| format!("写入终端失败:{error}"))?; let conversation_baseline = - new_swarm_turn_conversation_baseline(previous_message_count, &started.state.run_id); + new_swarm_turn_conversation_baseline(previous_message_count, accepted_run_id); wait_and_print_swarm_turn( root, parent_agent_id, @@ -341,6 +419,19 @@ fn start_and_wait_for_swarm_turn( ) } +pub(super) fn accepted_swarm_run_id( + started: &AgentRuntimeResult, + requested_run_id: &str, +) -> String { + started + .accepted_run_id + .as_deref() + .map(str::trim) + .filter(|run_id| !run_id.is_empty()) + .unwrap_or(requested_run_id) + .to_string() +} + pub(super) fn handle_swarm_resume_turn( root: &Path, parent_agent_id: &str, @@ -359,20 +450,40 @@ pub(super) fn handle_swarm_resume_turn( let current_runtimes = read_game_creator_agent_runtimes_at(root)?; let Some(parent) = swarm_parent_runtime(parent_agent_id, session_id, run_profile, ¤t_runtimes) - .filter(|runtime| runtime_is_busy(runtime)) else { writeln!(output, "[恢复] 当前 Session 没有可恢复的运行任务。") .map_err(|error| format!("写入终端失败:{error}"))?; return Ok(SwarmChatFlow::Continue); }; + let pending = next_pending_swarm_task(parent, session_id, run_profile); + let (target_run_id, target_status, target_phase) = + if game_creator_agent_runtime_accepts_steer(&parent.state) + || parent.state.status == "pending" + { + ( + parent.state.run_id.as_str(), + parent.state.status.as_str(), + parent.state.phase.as_str(), + ) + } else if let Some(task) = pending { + ( + task.run_id.as_str(), + task.status.as_str(), + task.phase.as_str(), + ) + } else { + writeln!(output, "[恢复] 当前 Session 没有可恢复的运行任务。") + .map_err(|error| format!("写入终端失败:{error}"))?; + return Ok(SwarmChatFlow::Continue); + }; writeln!( output, "[恢复] 继续观察 run={} status={} phase={}", - parent.state.run_id, parent.state.status, parent.state.phase + target_run_id, target_status, target_phase ) .map_err(|error| format!("写入终端失败:{error}"))?; let mut conversation_baseline = - new_swarm_turn_conversation_baseline(previous_message_count, &parent.state.run_id); + new_swarm_turn_conversation_baseline(previous_message_count, target_run_id); capture_recovered_swarm_assistant_at( root, parent_agent_id, diff --git a/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/turn_wait.rs b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/turn_wait.rs index ebd997c09..6d631df35 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/turn_wait.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/turn_wait.rs @@ -27,11 +27,18 @@ pub(super) fn wait_for_swarm_turn( let mut input_closed = false; loop { let runtimes = read_game_creator_agent_runtimes_at(root)?; - let changed = observer.print_changes(&runtimes, output)?; + let turn_runtimes = swarm_current_runtimes_for_run( + parent_agent_id, + session_id, + run_profile, + &conversation_baseline.parent_run_id, + &runtimes, + ); + let changed = observer.print_changes(&turn_runtimes, output)?; if changed { stable_since = None; } - let mut reconciliation = swarm_reconciliation_agents(&runtimes); + let mut reconciliation = swarm_reconciliation_agents(&turn_runtimes); if !reconciliation.is_empty() { observer.close_response_line(output)?; return build_reconciliation_turn_outcome( @@ -44,7 +51,13 @@ pub(super) fn wait_for_swarm_turn( ); } if !input_closed { - match observer.resolve_confirmations(root, parent_agent_id, &runtimes, input, output)? { + match observer.resolve_confirmations( + root, + parent_agent_id, + &turn_runtimes, + input, + output, + )? { SwarmConfirmationResolution::Handled => { stable_since = None; recovery_scan_required = true; @@ -61,7 +74,7 @@ pub(super) fn wait_for_swarm_turn( match observer.resolve_user_input_requests( root, parent_agent_id, - &runtimes, + &turn_runtimes, input, output, )? { @@ -80,7 +93,7 @@ pub(super) fn wait_for_swarm_turn( } } let pending_interactions = - swarm_unhandled_interaction_reasons(parent_agent_id, &runtimes, input_closed); + swarm_unhandled_interaction_reasons(parent_agent_id, &turn_runtimes, input_closed); if !pending_interactions.is_empty() { observer.close_response_line(output)?; return build_incomplete_turn_outcome( @@ -97,6 +110,7 @@ pub(super) fn wait_for_swarm_turn( parent_agent_id, session_id, run_profile, + &conversation_baseline.parent_run_id, &runtimes, ); if !failure_scan.reconciliation_agents.is_empty() { @@ -135,7 +149,15 @@ pub(super) fn wait_for_swarm_turn( if last_runner_check.elapsed() >= Duration::from_secs(2) { let runner = read_external_agent_runner_status(); last_runner_check = Instant::now(); - if runtimes_are_busy(&runtimes) && (!runner.enabled || !runner.running) { + if swarm_turn_is_busy( + root, + parent_agent_id, + session_id, + run_profile, + &conversation_baseline.parent_run_id, + &runtimes, + )? && (!runner.enabled || !runner.running) + { reconciliation.push("external-runner".to_string()); observer.close_response_line(output)?; return build_reconciliation_turn_outcome( @@ -148,7 +170,14 @@ pub(super) fn wait_for_swarm_turn( ); } } - if runtimes_are_busy(&runtimes) { + if swarm_turn_is_busy( + root, + parent_agent_id, + session_id, + run_profile, + &conversation_baseline.parent_run_id, + &runtimes, + )? { stable_since = None; recovery_scan_required = true; } else { @@ -175,13 +204,37 @@ pub(super) fn wait_for_swarm_turn( session_id, &conversation_baseline, )?; - let parent_runtime = - swarm_parent_runtime(parent_agent_id, session_id, run_profile, &runtimes); - let completion_blockers = parent_runtime - .map(|parent| swarm_parent_completion_contract_blockers_at(root, parent)) - .unwrap_or_else(|| vec!["parent-runtime-missing".to_string()]); + let parent_runtime_is_current = swarm_parent_runtime_for_run( + parent_agent_id, + session_id, + run_profile, + &conversation_baseline.parent_run_id, + &runtimes, + ) + .is_some(); + let parent_runtime = swarm_parent_runtime_snapshot_for_run( + root, + parent_agent_id, + session_id, + run_profile, + &conversation_baseline.parent_run_id, + &runtimes, + )?; + let completion_blockers = if parent_runtime_is_current { + parent_runtime + .as_ref() + .map(|parent| swarm_parent_completion_contract_blockers_at(root, parent)) + .unwrap_or_else(|| vec!["parent-runtime-missing".to_string()]) + } else if parent_runtime + .as_ref() + .is_some_and(parent_runtime_completed) + { + Vec::new() + } else { + vec!["parent-runtime-missing".to_string()] + }; match classify_swarm_turn_terminal( - parent_runtime, + parent_runtime.as_ref(), conversation_metrics, 0, 0, @@ -210,10 +263,11 @@ pub(super) fn wait_for_swarm_turn( SwarmTurnReportOutcome::Settled, parent_agent_id, session_id, + Some(&conversation_baseline.parent_run_id), &runtimes, conversation_metrics, 0, - ); + )?; return Ok(SwarmTurnOutcome::Settled(report)); } SwarmTurnTerminalClassification::Failed => { @@ -227,6 +281,7 @@ pub(super) fn wait_for_swarm_turn( "{}:{}", parent_agent_id, parent_runtime + .as_ref() .map(|runtime| runtime.state.phase.as_str()) .unwrap_or("missing") )], @@ -236,7 +291,7 @@ pub(super) fn wait_for_swarm_turn( let mut reasons = completion_blockers; append_swarm_terminal_snapshot_reasons( &mut reasons, - parent_runtime, + parent_runtime.as_ref(), conversation_metrics, ); return build_incomplete_turn_outcome( @@ -284,12 +339,13 @@ pub(super) fn wait_for_swarm_turn( .map_err(|error| format!("写入终端失败:{error}"))?; } SwarmChatInput::Message(message) => { - if let Some(parent) = runtimes.iter().find(|runtime| { - runtime.state.agent_id == parent_agent_id - && runtime.state.session_id == session_id - && runtime.state.run_profile == run_profile - && matches!(runtime.state.status.as_str(), "pending" | "running") - }) { + if let Some(parent) = swarm_parent_steer_target( + parent_agent_id, + session_id, + run_profile, + Some(&conversation_baseline.parent_run_id), + &runtimes, + ) { let steer_id = format!("swarm-steer-{}", unix_millis()); let result = steer_game_creator_agent_runtime_task( root.display().to_string(), 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 e4e024188..3d1d27ab9 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 @@ -1195,7 +1195,7 @@ fn background_agent_runtime_reconciliation_without_ledger_still_blocks_recovery( } #[tokio::test] -async fn background_agent_runtime_recovers_pending_task() { +async fn background_agent_runtime_recovers_pending_task_after_cancelled_canonical_run() { let root = unique_project_path(); init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init"); let (sender, receiver) = mpsc::channel(); @@ -1219,6 +1219,49 @@ async fn background_agent_runtime_recovers_pending_task() { }} }}"# )); + let session_id = "agent-session-design-director"; + let mut cancelled = + default_game_creator_agent_runtime_state("design-director", "design-cancelled-run"); + cancelled.session_id = session_id.to_string(); + cancelled.status = "cancelled".to_string(); + cancelled.phase = "cancelled".to_string(); + cancelled.current_task = "此前任务已取消".to_string(); + cancelled.current_action = "此前任务已经取消".to_string(); + write_game_creator_agent_runtime_state(&root, &cancelled) + .expect("persist cancelled canonical runtime"); + write_agent_runtime_task_record_for_test( + &root, + &AgentRuntimeTaskRecord { + goal_id: None, + goal_revision: 0, + goal_status: None, + schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(), + agent_id: "design-director".to_string(), + task_id: "design-director".to_string(), + session_id: session_id.to_string(), + run_id: "design-cancelled-run".to_string(), + source: "agent-background-task".to_string(), + run_profile: default_agent_runtime_run_profile(), + run_profile_binding_fingerprint: String::new(), + parent_agent_id: None, + parent_run_id: None, + delegation_id: None, + task: "此前任务已取消".to_string(), + status: "cancelled".to_string(), + phase: "cancelled".to_string(), + current_action: "此前任务已经取消".to_string(), + terminal_detail: Some("此前任务取消".to_string()), + error: None, + updated_at: unix_timestamp().saturating_sub(120), + }, + ); + crate::write_game_creator_agent_runtime_cancel_request( + &root, + "design-director", + "design-cancelled-run", + "保留旧 run 取消 tombstone", + ) + .expect("persist old cancellation tombstone"); let task_record = AgentRuntimeTaskRecord { goal_id: None, goal_revision: 0, @@ -1226,7 +1269,7 @@ async fn background_agent_runtime_recovers_pending_task() { 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(), + session_id: session_id.to_string(), run_id: "design-pending-recover-run".to_string(), source: "agent-background-task".to_string(), run_profile: default_agent_runtime_run_profile(), @@ -1243,11 +1286,33 @@ async fn background_agent_runtime_recovers_pending_task() { updated_at: unix_timestamp().saturating_sub(60), }; write_agent_runtime_task_record_for_test(&root, &task_record); + crate::append_local_conversation_message_for_session_idempotent_at( + &root, + Some("design-director"), + Some(session_id), + LocalConversationMessage { + role: "user".to_string(), + content: "恢复排队后台任务".to_string(), + agent_id: None, + }, + "runtime-pending-after-completed", + ) + .expect("persist queued user message once"); + + let before = read_game_creator_agent_runtime_at(&root, "design-director") + .expect("read completed runtime with pending queue"); + assert_eq!(before.state.run_id, "design-cancelled-run"); + assert_eq!(before.state.status, "cancelled"); + assert_eq!(before.state.phase, "cancelled"); + assert_eq!(before.task_queue.pending, 1); let resumed = resume_game_creator_agent_background_tasks_at(&root).expect("resume pending task"); assert_eq!(resumed.len(), 1); - assert_eq!(resumed[0].state.run_id, "design-pending-recover-run"); + assert_eq!( + resumed[0].state.run_id, "design-pending-recover-run", + "unexpected recovery snapshot: {resumed:?}" + ); assert_eq!(resumed[0].state.status, "running"); assert_eq!( resumed[0].state.current_action, @@ -1266,6 +1331,20 @@ async fn background_agent_runtime_recovers_pending_task() { ); let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("agent db"); assert!(agent_db.contains("\"recoveredFromStatus\":\"pending\"")); + let conversation = + read_local_conversation_for_session_at(&root, Some("design-director"), Some(session_id)) + .expect("read recovered conversation"); + assert_eq!( + conversation + .messages + .iter() + .filter(|message| { + message.role == "user" && message.content == "恢复排队后台任务" + }) + .count(), + 1, + "recovery must not duplicate the already-persisted user turn" + ); fs::remove_dir_all(root).ok(); } diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 40e3869be..b4fbf6a62 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -5301,3 +5301,4 @@ - 并发边界:External Editor 项目、素材库、生图和下载请求都在项目锁外执行,请求阶段只能读取现有 manifest 快照,不能补 seed task 或重写 manifest;下载完成后先按声明的图片类型校验 PNG/JPEG/WebP/GIF 魔数,再取得项目写锁,复核协作边界与输出路径并提交文件、manifest、revision 和验证凭证。专业 Agent 的完成门禁要求同一 run 的 `verifiedRevision >= mutationRevision`,不能被并行 Agent 的后续全局 revision 误判为过期,也允许更晚 revision 的复验覆盖本人修改;当前全局 revision 的集成验证仍由 Project Supervisor 精确负责。 - 复用边界:已有规范素材只有同时满足固定路径、`art-spritesheet / image/* / canvas` 登记、本地普通文件存在且 PNG 签名有效时才跳过生成;空文件、伪 PNG 或损坏占位必须重新进入美术委派。 - 恢复边界:首批 policy snapshot、durable provider batch 与恢复校验继续绑定同一 required Agent 集合;格式修复必须根据当前 policy 补齐可选的 `art-asset-plan` 固定产物合同,不能只修复程序与质量委派后绕过美术交付。 +- 2026-07-25 决策:Swarm CLI 的 `busy` 仅表示当前 Agent 仍有运行、等待、reconciliation 或排队工作,不能作为 steer 目标判定。steer 能力统一复用 Runtime 协议层门禁;terminal canonical run 即使汇总出 pending queue 也永远不可 steer。CLI 发现旧 completed/cancelled run 后仍有 pending run 时先通知独立 Runner 恢复;旧 cancelled run 的 tombstone 不能让恢复扫描跳过后续 pending。新 turn 以 mutation 返回的 `acceptedRunId` 为权威 baseline,失败、交互、收束和 `turn.report.parentRunId` 都只归属该 run;canonical 已推进到下一 run 时从 append-only task journal 恢复目标 run 终态。相同且已落盘的最后一条用户消息只恢复观察原 run,不能重复写 conversation、steer ledger 或 task ledger。active Goal 也必须精确匹配当前 Runtime 身份与可 steer 状态,不能只依据 Goal 的 `active` 字符串直接追加。连续 run 的 assistant 回复按 `agentId + sessionId + runId` 派生的 finalization message ID 归属,失败终态按 `(agentId, runId)` 聚合且完整 task journal 优先于可能滞后的 state 投影;`turn.report` 的运行和队列计数同样读取完整 journal 并仅统计目标 parent run 及其直接 children。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 9d29cabc7..72b4f35c3 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -3611,3 +3611,11 @@ - 处理:正式 AppData 只作只读配置来源。每次人工测试在系统临时根创建 `0700` sentinel 隔离目录,只把主配置和可选 local overlay 私有复制为 `0600` 普通文件;不得复制 endpoint、lock、`.previous` 或其它状态。LLM 检查与 Swarm CLI 全部使用隔离目录。退出时通过内部 CLI 请求 `runner.shutdown_if_idle`,确认隔离 endpoint 消失后才删除配置;仍有任务或无法确认退出时同时保留测试项目和隔离配置并报告路径。正式 Runner 的 PID、bootId、端口和 executable fingerprint 必须保持不变。 - 验证:单元测试覆盖私有 inode、权限、local overlay、禁止复制 endpoint/lock/备份、符号链接拒绝、sentinel 清理和 endpoint 存在时拒绝删除;真实 smoke 使用隔离 AppData 启动并收束空闲 Runner,前后比较正式 endpoint 身份且确认正式 PID 存活,再检查本轮 `/tmp` 项目和隔离配置均已清理。 - 关联:`apps/ai-game-creator-shell/scripts/agent-swarm-test-chat.mjs`、`apps/ai-game-creator-shell/tests/agentSwarmTestEntry.test.ts`、`apps/ai-game-creator-shell/src-tauri/src/runner/client.rs`、`apps/ai-game-creator-shell/src-tauri/src/cli.rs`。 + +## Swarm 队列 busy 不能直接当成 canonical run 可 steer + +- 现象:继续已有项目时,Runtime state 仍指向旧的 `idle / completed` 或 `cancelled / cancelled` run A,但 task ledger 已有更新的 `pending / queued` run B;CLI 打印“已投递 B”后却立刻把 A 及其历史子 Agent 的 cancelled/budget-exhausted 报成 B 的失败。 +- 原因:旧 `runtime_is_busy` 同时包含当前 state 和队列汇总,调用方看到 `task_queue.pending > 0` 后仍从 canonical state 反推 steer、失败扫描和 turn report 的 runId;取消 tombstone 还会让恢复扫描在处理 A 后无条件跳过 B。底层拒绝 terminal steer 和保留 A 的真实失败历史都是正确行为,不能通过放宽门禁或删除历史记录修复。 +- 处理:保留 queue busy 用于 Runner 存活判断,另由 Runtime 协议层提供唯一 steerable 判定。start mutation 返回实际 `acceptedRunId`,CLI 以它建立不可变 turn baseline;失败、reconciliation、用户交互、收束和报告只观察该 run。canonical 已推进到后续 run 时从 task journal 读取目标 run 的最终记录。旧 cancelled canonical 若仍有 pending 且无 running,恢复扫描跳过旧 run 的 pending action 恢复,直接启动队首 pending。若输入与已落盘 pending task 及最后一条 user 消息相同,则只观察原 run。Goal 路径也必须核对同一 Agent、Session、runId、Run Profile 和 steerable 状态。连续 run 的回复必须按确定性 finalization message ID 过滤;历史 specialist 失败必须以 `(agentId, runId)` 为键读取完整 journal,不能让滞后的非失败 state 删除 journal 已记录的失败;报告计数也不能退回 `recent_tasks` 的 12 条窗口。 +- 验证:构造 cancelled run A、保留 A cancel tombstone、pending run B 和单份已落盘用户消息,证明恢复后 B 进入 running 并完成且 conversation 不重复。另覆盖观察 B 时忽略 A 及 A 子任务失败、观察 A 时仍正常失败、B 完成后 canonical 已推进到 C 仍可从 journal 收束 B、`turn.report.parentRunId` 始终为 baseline,以及 expected Goal runId 不一致时不选中目标。 +- 关联:`apps/ai-game-creator-shell/src-tauri/src/swarm_cli/turn_dispatch.rs`、`apps/ai-game-creator-shell/src-tauri/src/swarm_cli/terminal_classification.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/steering.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/recovery_scan.rs`。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index e9a8abcfb..5e706bbc9 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -717,3 +717,4 @@ game-project/ - 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`。 +- 2026-07-25 补充:Swarm CLI 将“Agent 执行通道仍忙”和“canonical run 可接受 steer”拆为两个判定。`task_queue.pending > 0` 继续用于 Runner 存活观察,但只有底层 steer 门禁认可的 `running / waiting-for-confirmation` Runtime 才能接收新指令;`completed/cancelled run A + pending run B` 必须先通过 Runner 恢复 B,禁止把消息追加到 A。start mutation 必须返回实际 `acceptedRunId`,CLI 以它建立 turn baseline,失败扫描、交互、收束和 `turn.report` 都只认 baseline run;若 Runner 已连续推进到 C,则从 task journal 读取 B 的终态。旧 A 的 cancel tombstone 不得阻断队首 B 恢复。若用户重复输入的内容正是已经落盘的 pending 任务且它仍是对话最后一条 user 消息,CLI 只观察原 run,不再追加第二份对话或创建新任务;active Goal 同样必须同时匹配 Agent、Session、runId、Run Profile 和可 steer 状态。连续 B/C 的 assistant 回复按 finalization message ID 归属,observer 不输出非目标 run 的状态或流;历史失败和报告计数都读取完整 task journal,失败聚合使用 `(agentId, runId)`,journal 失败终态不能被滞后的 state 投影覆盖。