diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/prompt_context.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/prompt_context.rs index 3d471a55b..878a882b5 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/prompt_context.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/prompt_context.rs @@ -156,6 +156,13 @@ pub(crate) fn render_local_conversation_prompt_context_for_session( agent_label: &str, ) { for message in conversation.messages { + if message.message_id.as_deref().is_some_and(|message_id| { + message_id + .trim() + .starts_with(AGENT_RUNTIME_PUBLIC_STATUS_MESSAGE_ID_PREFIX) + }) { + continue; + } let content = sanitize_prompt_context(&message.content) .split_whitespace() .collect::>() diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs index bee9686f1..5b5839a95 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs @@ -240,7 +240,7 @@ pub(in crate::agent) use pending_execution::*; pub(in crate::agent) use pending_recovery::*; pub(in crate::agent) use provider_recovery::*; pub(in crate::agent) use recovery_scan::*; -pub(in crate::agent) use task_queue::*; +pub(crate) use task_queue::*; pub(in crate::agent) use task_start::*; #[cfg(test)] diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs index 8ba7f9c94..cae060141 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs @@ -353,10 +353,139 @@ fn latest_game_chat_deadline_runtime_at( .unwrap_or(fallback) } +struct GameChatAbsoluteDeadlinePublicStates { + root: AgentRuntimeState, + child: Option, +} + +fn resolve_game_chat_absolute_deadline_public_states_at( + root: &Path, + runtime: &AgentRuntimeState, + phase: &str, +) -> Result { + let binding = read_game_creator_agent_runtime_run_profile_binding( + root, + &runtime.agent_id, + &runtime.run_id, + )? + .ok_or_else(|| "game-chat 绝对硬截止缺少当前 Run Profile 绑定".to_string())?; + if binding.agent_id != runtime.agent_id + || binding.run_id != runtime.run_id + || binding.root_agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID + || binding.root_run_id.trim().is_empty() + || binding.profile != AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + { + return Err("game-chat 绝对硬截止当前 Run Profile 绑定身份不一致".to_string()); + } + let root_binding = read_game_creator_agent_runtime_run_profile_binding( + root, + &binding.root_agent_id, + &binding.root_run_id, + )? + .ok_or_else(|| "game-chat 绝对硬截止缺少根 Run Profile 绑定".to_string())?; + if root_binding.agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID + || root_binding.run_id != binding.root_run_id + || root_binding.root_agent_id != root_binding.agent_id + || root_binding.root_run_id != root_binding.run_id + || root_binding.parent_agent_id.is_some() + || root_binding.parent_run_id.is_some() + || root_binding.source != AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE + || root_binding.profile != AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + { + return Err("game-chat 绝对硬截止根 Run Profile 绑定身份不一致".to_string()); + } + let root_task = read_latest_game_creator_agent_runtime_task_by_run_id( + root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &root_binding.run_id, + )? + .ok_or_else(|| "game-chat 绝对硬截止缺少根 Supervisor 任务".to_string())?; + if root_task.parent_agent_id.is_some() + || root_task.parent_run_id.is_some() + || root_task.session_id.trim().is_empty() + || root_task.run_profile_binding_fingerprint != root_binding.binding_fingerprint + { + return Err("game-chat 绝对硬截止根 Supervisor 任务身份不一致".to_string()); + } + let mut root_state = agent_runtime_state_from_task_record(&root_task); + root_state.status = "failed".to_string(); + root_state.phase = phase.to_string(); + + if runtime.agent_id == root_state.agent_id && runtime.run_id == root_state.run_id { + if runtime.session_id != root_state.session_id + || runtime.parent_agent_id.is_some() + || runtime.parent_run_id.is_some() + || binding.parent_agent_id.is_some() + || binding.parent_run_id.is_some() + || runtime.run_profile_binding_fingerprint != root_binding.binding_fingerprint + { + return Err("game-chat 绝对硬截止当前根 Runtime 身份不一致".to_string()); + } + return Ok(GameChatAbsoluteDeadlinePublicStates { + root: root_state, + child: None, + }); + } + + if runtime.agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID + || runtime.session_id.trim().is_empty() + || runtime.parent_agent_id.as_deref() != Some(root_state.agent_id.as_str()) + || runtime.parent_run_id.as_deref() != Some(root_state.run_id.as_str()) + || binding.parent_agent_id.as_deref() != Some(root_state.agent_id.as_str()) + || binding.parent_run_id.as_deref() != Some(root_state.run_id.as_str()) + || runtime.run_profile_binding_fingerprint != binding.binding_fingerprint + { + return Err("game-chat 绝对硬截止当前专业 Agent Runtime 身份不一致".to_string()); + } + let child_task = read_latest_game_creator_agent_runtime_task_by_run_id( + root, + &runtime.agent_id, + &runtime.run_id, + )? + .ok_or_else(|| "game-chat 绝对硬截止缺少当前专业 Agent 任务".to_string())?; + if child_task.session_id != runtime.session_id + || child_task.parent_agent_id != runtime.parent_agent_id + || child_task.parent_run_id != runtime.parent_run_id + || child_task.run_profile_binding_fingerprint != binding.binding_fingerprint + { + return Err("game-chat 绝对硬截止当前专业 Agent 任务身份不一致".to_string()); + } + + let mut child_state = agent_runtime_state_from_task_record(&child_task); + child_state.status = "failed".to_string(); + child_state.phase = phase.to_string(); + Ok(GameChatAbsoluteDeadlinePublicStates { + root: root_state, + child: Some(child_state), + }) +} + +fn append_resolved_game_chat_absolute_deadline_public_messages_at( + root: &Path, + states: &GameChatAbsoluteDeadlinePublicStates, + error: &str, +) -> Result<(), String> { + append_game_creator_agent_runtime_terminal_public_message_at(root, &states.root, error)?; + if let Some(child) = states.child.as_ref() { + append_game_creator_agent_runtime_terminal_public_message_at(root, child, error)?; + } + Ok(()) +} + +pub(super) fn append_game_chat_absolute_deadline_public_messages_at( + root: &Path, + runtime: &AgentRuntimeState, + phase: &str, + error: &str, +) -> Result<(), String> { + let states = resolve_game_chat_absolute_deadline_public_states_at(root, runtime, phase)?; + append_resolved_game_chat_absolute_deadline_public_messages_at(root, &states, error) +} + pub(super) fn finish_game_chat_absolute_deadline_timeout_at( root: &Path, - agent_id: &str, - session_id: &str, + _agent_id: &str, + _session_id: &str, fallback: AgentRuntimeState, ) -> AgentBackgroundTaskOutcome { let mut runtime = latest_game_chat_deadline_runtime_at(root, fallback); @@ -387,6 +516,31 @@ pub(super) fn finish_game_chat_absolute_deadline_timeout_at( GAME_CHAT_FIRST_PLAYABLE_HARD_BUDGET_SECONDS ) }; + // The absolute deadline may itself be handling a broken Runtime state + // projection. Commit the stable user-facing terminal outcome before any + // reconciliation/failure projection, then retry the same idempotent + // message after cleanup below in case the first conversation write was + // transient. + let public_terminal_phase = if preserves_external_reconciliation { + "needs-reconciliation" + } else { + "failed" + }; + let public_states = match resolve_game_chat_absolute_deadline_public_states_at( + root, + &runtime, + public_terminal_phase, + ) { + Ok(states) => states, + Err(_) => return AgentBackgroundTaskOutcome::Finished, + }; + let initial_public_status_error = + append_resolved_game_chat_absolute_deadline_public_messages_at( + root, + &public_states, + &error, + ) + .err(); let terminal_failure_error = if let Some(pending) = pending_action .as_ref() .filter(|_| preserves_external_reconciliation) @@ -448,6 +602,9 @@ pub(super) fn finish_game_chat_absolute_deadline_timeout_at( if let Some(error) = terminal_failure_error { cleanup_errors.push(sanitize_agent_runtime_text(&error, 160)); } + if let Some(error) = initial_public_status_error { + cleanup_errors.push(sanitize_agent_runtime_text(&error, 160)); + } let _ = append_agent_db_record( root, serde_json::json!({ @@ -465,15 +622,10 @@ pub(super) fn finish_game_chat_absolute_deadline_timeout_at( "cleanupErrorCount": cleanup_errors.len(), }), ); - let _ = append_local_conversation_message_for_session_at( + let _ = append_resolved_game_chat_absolute_deadline_public_messages_at( root, - Some(agent_id), - Some(session_id), - LocalConversationMessage { - role: "assistant".to_string(), - content: game_creator_agent_runtime_failure_conversation_message(agent_id, &error), - agent_id: None, - }, + &public_states, + &error, ); AgentBackgroundTaskOutcome::Finished } @@ -1168,19 +1320,24 @@ async fn run_game_creator_agent_background_task_pass_without_deadline( } if error.starts_with(GAME_CHAT_FIRST_PLAYABLE_HARD_BUDGET_ERROR_PREFIX) { let error = redact_agent_runtime_error(&root, &error, 500); + let public_runtime = runtime.clone(); + let public_states = + match resolve_game_chat_absolute_deadline_public_states_at( + &root, + &public_runtime, + "budget-exhausted", + ) { + Ok(states) => states, + Err(_) => return AgentBackgroundTaskOutcome::Finished, + }; + let initial_public_status = + append_resolved_game_chat_absolute_deadline_public_messages_at( + &root, + &public_states, + &error, + ); let failed_runtime = fail_game_creator_agent_runtime_budget_at(&root, runtime, &error); - let _ = append_local_conversation_message_for_session_at( - &root, - Some(&agent_id), - Some(&session_id), - LocalConversationMessage { - role: "assistant".to_string(), - content: "首版素材生成已达到七十五分钟硬上限,本轮已停止,不会继续在后台运行。" - .to_string(), - agent_id: None, - }, - ); if let Ok(runtime) = failed_runtime { let _ = append_game_creator_agent_background_task_failed_audit( &root, @@ -1188,23 +1345,19 @@ async fn run_game_creator_agent_background_task_pass_without_deadline( AGENT_RUNTIME_BACKGROUND_FAILURE_KIND_BUDGET, ); } + if initial_public_status.is_err() { + let _ = + append_resolved_game_chat_absolute_deadline_public_messages_at( + &root, + &public_states, + &error, + ); + } return AgentBackgroundTaskOutcome::Finished; } let error = redact_agent_runtime_error(&root, &error, 500); let failed_runtime = fail_game_creator_agent_runtime_turn_at(&root, runtime, &error); - let _ = append_local_conversation_message_for_session_at( - &root, - Some(&agent_id), - Some(&session_id), - LocalConversationMessage { - role: "assistant".to_string(), - content: game_creator_agent_runtime_failure_conversation_message( - &agent_id, &error, - ), - agent_id: None, - }, - ); if let Ok(runtime) = failed_runtime { let _ = append_game_creator_agent_background_task_failed_audit( &root, @@ -2388,18 +2541,6 @@ async fn run_game_creator_agent_background_task_pass_without_deadline( Err(error) => { let error = redact_agent_runtime_error(&root, &error, 500); let _ = fail_game_creator_agent_runtime_turn_at(&root, runtime, &error); - let _ = append_local_conversation_message_for_session_at( - &root, - Some(&agent_id), - Some(&session_id), - LocalConversationMessage { - role: "assistant".to_string(), - content: game_creator_agent_runtime_failure_conversation_message( - &agent_id, &error, - ), - agent_id: None, - }, - ); return AgentBackgroundTaskOutcome::Finished; } } @@ -2514,18 +2655,6 @@ async fn run_game_creator_agent_background_task_pass_without_deadline( { let error = redact_agent_runtime_error(&root, &error, 500); let _ = fail_game_creator_agent_runtime_turn_at(&root, runtime, &error); - let _ = append_local_conversation_message_for_session_at( - &root, - Some(&agent_id), - Some(&session_id), - LocalConversationMessage { - role: "assistant".to_string(), - content: game_creator_agent_runtime_failure_conversation_message( - &agent_id, &error, - ), - agent_id: None, - }, - ); return AgentBackgroundTaskOutcome::Finished; } let pre_execution_drift = @@ -2779,18 +2908,6 @@ async fn run_game_creator_agent_background_task_pass_without_deadline( { let error = redact_agent_runtime_error(&root, &error, 500); let _ = fail_game_creator_agent_runtime_turn_at(&root, runtime, &error); - let _ = append_local_conversation_message_for_session_at( - &root, - Some(&agent_id), - Some(&session_id), - LocalConversationMessage { - role: "assistant".to_string(), - content: game_creator_agent_runtime_failure_conversation_message( - &agent_id, &error, - ), - agent_id: None, - }, - ); return AgentBackgroundTaskOutcome::Finished; } if let Err(error) = @@ -3298,16 +3415,6 @@ async fn run_game_creator_agent_background_task_pass_without_deadline( }; let error = redact_agent_runtime_error(&root, &error, 500); let failed_runtime = fail_game_creator_agent_runtime_budget_at(&root, runtime, &error); - let _ = append_local_conversation_message_for_session_at( - &root, - Some(&agent_id), - Some(&session_id), - LocalConversationMessage { - role: "assistant".to_string(), - content: format!("后台任务未完成:{error}"), - agent_id: None, - }, - ); if let Ok(runtime) = failed_runtime { let _ = append_game_creator_agent_background_task_failed_audit( &root, @@ -3598,18 +3705,6 @@ async fn run_game_creator_agent_background_task_pass_without_deadline( let error = redact_agent_runtime_error(&root, &error, 500); let failed_runtime = fail_game_creator_agent_runtime_turn_at(&root, runtime, &error); - let _ = append_local_conversation_message_for_session_at( - &root, - Some(&agent_id), - Some(&session_id), - LocalConversationMessage { - role: "assistant".to_string(), - content: game_creator_agent_runtime_failure_conversation_message( - &agent_id, &error, - ), - agent_id: None, - }, - ); if let Ok(runtime) = failed_runtime { let _ = append_game_creator_agent_background_task_failed_audit( &root, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_deadline_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_deadline_tests.rs index 84866b305..29d7b8aed 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_deadline_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_deadline_tests.rs @@ -1,6 +1,6 @@ use super::main_loop::{ - await_game_chat_absolute_deadline_at, finish_game_chat_absolute_deadline_timeout_at, - game_chat_absolute_deadline_from_bound_at, + append_game_chat_absolute_deadline_public_messages_at, await_game_chat_absolute_deadline_at, + finish_game_chat_absolute_deadline_timeout_at, game_chat_absolute_deadline_from_bound_at, }; use super::*; @@ -62,21 +62,248 @@ async fn game_chat_absolute_deadline_returns_an_in_flight_result_before_expiry() assert_eq!(result.expect("in-flight action completes"), "completed"); } +#[test] +fn game_chat_absolute_deadline_rejects_forged_root_runtime_identity() { + let temporary = crate::tests::canonical_test_tempdir("game-chat-deadline-forged-root-"); + let root = temporary.path().join("project"); + init_local_game_project_at(&root, "deadline-forged-root", "硬截止伪根身份测试") + .expect("project init"); + let root_session = resolve_agent_conversation_session_id_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + None, + true, + ) + .expect("resolve game-chat root session"); + let root_record = append_unique_game_creator_agent_runtime_pending_task( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &root_session, + "生成首版可玩游戏", + "game-chat-deadline-forged-root-run", + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + None, + ) + .expect("queue autonomous game-chat root"); + + let mut forged_session = agent_runtime_state_from_task_record(&root_record); + forged_session.session_id = "forged-root-session".to_string(); + let error = append_game_chat_absolute_deadline_public_messages_at( + &root, + &forged_session, + "failed", + GAME_CHAT_FIRST_PLAYABLE_HARD_BUDGET_ERROR_PREFIX, + ) + .expect_err("forged root session must fail closed"); + assert!(error.contains("当前根 Runtime 身份不一致")); + let outcome = finish_game_chat_absolute_deadline_timeout_at( + &root, + &forged_session.agent_id, + &forged_session.session_id, + forged_session.clone(), + ); + assert!(matches!(outcome, AgentBackgroundTaskOutcome::Finished)); + let root_after_forged_finish = read_latest_game_creator_agent_runtime_task_by_run_id( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &root_record.run_id, + ) + .expect("read authoritative root after forged finish") + .expect("authoritative root task remains present"); + assert_eq!(root_after_forged_finish.session_id, root_session); + assert_eq!(root_after_forged_finish.status, "pending"); + + let mut forged_parent = agent_runtime_state_from_task_record(&root_record); + forged_parent.parent_run_id = Some("forged-parent-run".to_string()); + let error = append_game_chat_absolute_deadline_public_messages_at( + &root, + &forged_parent, + "failed", + GAME_CHAT_FIRST_PLAYABLE_HARD_BUDGET_ERROR_PREFIX, + ) + .expect_err("root runtime carrying a parent identity must fail closed"); + assert!(error.contains("当前根 Runtime 身份不一致")); + + let child_session = resolve_agent_conversation_session_id_at(&root, "art-director", None, true) + .expect("resolve child session"); + let child_record = append_unique_game_creator_agent_runtime_pending_task( + &root, + "art-director", + &child_session, + "执行美术审计", + "game-chat-deadline-forged-child-run", + "agent-ready-task-scheduler", + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + Some(&AgentRuntimeTaskLink { + parent_agent_id: Some(root_record.agent_id.clone()), + parent_run_id: Some(root_record.run_id.clone()), + delegation_id: None, + }), + ) + .expect("queue autonomous game-chat child"); + let mut forged_child_session = agent_runtime_state_from_task_record(&child_record); + forged_child_session.session_id = "forged-child-session".to_string(); + let error = append_game_chat_absolute_deadline_public_messages_at( + &root, + &forged_child_session, + "failed", + GAME_CHAT_FIRST_PLAYABLE_HARD_BUDGET_ERROR_PREFIX, + ) + .expect_err("child runtime with the wrong session must fail closed"); + assert!(error.contains("当前专业 Agent 任务身份不一致")); + + let project_conversation = + read_local_conversation_at(&root, None).expect("read forged root conversation"); + assert_eq!( + project_conversation + .messages + .iter() + .filter(|message| { + message.message_id.as_deref().is_some_and(|message_id| { + message_id.starts_with(AGENT_RUNTIME_PUBLIC_STATUS_MESSAGE_ID_PREFIX) + }) + }) + .count(), + 0, + "identity conflicts must not create a second project terminal message" + ); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn game_chat_absolute_deadline_deduplicates_root_terminal_across_children() { + let temporary = crate::tests::canonical_test_tempdir("game-chat-deadline-multi-child-"); + let root = temporary.path().join("project"); + init_local_game_project_at(&root, "deadline-multi-child", "硬截止多 child 幂等测试") + .expect("project init"); + let root_session = resolve_agent_conversation_session_id_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + None, + true, + ) + .expect("resolve game-chat root session"); + let root_record = append_unique_game_creator_agent_runtime_pending_task( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &root_session, + "生成首版可玩游戏", + "game-chat-deadline-multi-child-root-run", + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + None, + ) + .expect("queue autonomous game-chat root"); + let task_link = AgentRuntimeTaskLink { + parent_agent_id: Some(root_record.agent_id.clone()), + parent_run_id: Some(root_record.run_id.clone()), + delegation_id: None, + }; + let mut children = Vec::new(); + for (agent_id, run_id) in [ + ("art-director", "game-chat-deadline-multi-child-art"), + ("code-prototype", "game-chat-deadline-multi-child-code"), + ] { + let session_id = resolve_agent_conversation_session_id_at(&root, agent_id, None, true) + .expect("resolve child session"); + let record = append_unique_game_creator_agent_runtime_pending_task( + &root, + agent_id, + &session_id, + "执行专业任务", + run_id, + "agent-ready-task-scheduler", + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + Some(&task_link), + ) + .expect("queue autonomous game-chat child"); + children.push(agent_runtime_state_from_task_record(&record)); + } + let private_error = format!( + "{GAME_CHAT_FIRST_PLAYABLE_HARD_BUDGET_ERROR_PREFIX}: operationId=secret-operation-id pendingAction=private-action" + ); + for child in &children { + append_game_chat_absolute_deadline_public_messages_at( + &root, + child, + "needs-reconciliation", + &private_error, + ) + .expect("append idempotent root and child deadline terminals"); + } + + let project_conversation = + read_local_conversation_at(&root, None).expect("read multi-child root conversation"); + let root_public_statuses = project_conversation + .messages + .iter() + .filter(|message| { + message.message_id.as_deref().is_some_and(|message_id| { + message_id.starts_with(AGENT_RUNTIME_PUBLIC_STATUS_MESSAGE_ID_PREFIX) + }) + }) + .collect::>(); + assert_eq!(root_public_statuses.len(), 1); + assert_eq!( + root_public_statuses[0].content, + "项目总控 Agent 执行失败,请稍后重试" + ); + assert!(!root_public_statuses[0] + .content + .contains("secret-operation-id")); + assert!(!root_public_statuses[0].content.contains("pendingAction")); + for child in &children { + assert!(!root_public_statuses[0].content.contains(&child.session_id)); + assert!(!root_public_statuses[0].content.contains(&child.run_id)); + let child_conversation = read_local_conversation_for_session_at( + &root, + Some(&child.agent_id), + Some(&child.session_id), + ) + .expect("read child deadline conversation"); + assert_eq!( + child_conversation + .messages + .iter() + .filter(|message| { + message.message_id.as_deref().is_some_and(|message_id| { + message_id.starts_with(AGENT_RUNTIME_PUBLIC_STATUS_MESSAGE_ID_PREFIX) + }) + }) + .count(), + 1 + ); + } + + fs::remove_dir_all(root).ok(); +} + #[tokio::test] async fn game_chat_absolute_deadline_preserves_external_generation_for_same_action_resume() { let temporary = crate::tests::canonical_test_tempdir("game-chat-deadline-reconciliation-"); let root = temporary.path().join("project"); init_local_game_project_at(&root, "deadline-reconciliation", "硬截止收尾测试") .expect("project init"); - bind_game_creator_agent_runtime_run_profile_at( + let root_session = resolve_agent_conversation_session_id_at( &root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + None, + true, + ) + .expect("resolve game-chat root session"); + let root_record = append_unique_game_creator_agent_runtime_pending_task( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &root_session, + "生成首版可玩游戏", "game-chat-deadline-reconciliation-root-run", - AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), None, ) - .expect("bind autonomous game-chat root profile"); + .expect("queue autonomous game-chat root"); bind_game_creator_agent_runtime_run_profile_at( &root, "art-director", @@ -84,8 +311,8 @@ async fn game_chat_absolute_deadline_preserves_external_generation_for_same_acti "agent-ready-task-scheduler", Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), Some(&AgentRuntimeTaskLink { - parent_agent_id: Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string()), - parent_run_id: Some("game-chat-deadline-reconciliation-root-run".to_string()), + parent_agent_id: Some(root_record.agent_id.clone()), + parent_run_id: Some(root_record.run_id.clone()), delegation_id: None, }), ) @@ -100,6 +327,8 @@ async fn game_chat_absolute_deadline_preserves_external_generation_for_same_acti vec!["执行外部图片生成".to_string()], ) .expect("start runtime"); + runtime.parent_agent_id = Some(root_record.agent_id.clone()); + runtime.parent_run_id = Some(root_record.run_id.clone()); runtime.loop_iteration = 1; let action = AgentRuntimeToolAction { tool: "canvas.asset_generate".to_string(), @@ -227,6 +456,42 @@ async fn game_chat_absolute_deadline_preserves_external_generation_for_same_acti assert!(agent_db.contains("\"externalGenerationRecordPreserved\":true")); assert!(agent_db.contains("agent.runtime.tool_action.needs_reconciliation")); assert!(!agent_db.contains("test-operation-id")); + let conversation = read_local_conversation_for_session_at( + &root, + Some(&runtime.agent_id), + Some(&runtime.session_id), + ) + .expect("read reconciliation deadline conversation"); + let public_statuses = conversation + .messages + .iter() + .filter(|message| { + message.message_id.as_deref().is_some_and(|message_id| { + message_id.starts_with(AGENT_RUNTIME_PUBLIC_STATUS_MESSAGE_ID_PREFIX) + }) + }) + .collect::>(); + assert_eq!(public_statuses.len(), 1); + assert_eq!( + public_statuses[0].content, + "专业 Agent 执行失败,请稍后重试" + ); + let project_conversation = + read_local_conversation_at(&root, None).expect("read root deadline public conversation"); + let root_public_statuses = project_conversation + .messages + .iter() + .filter(|message| { + message.message_id.as_deref().is_some_and(|message_id| { + message_id.starts_with(AGENT_RUNTIME_PUBLIC_STATUS_MESSAGE_ID_PREFIX) + }) + }) + .collect::>(); + assert_eq!(root_public_statuses.len(), 1); + assert_eq!( + root_public_statuses[0].content, + "项目总控 Agent 执行失败,请稍后重试" + ); fs::remove_dir_all(root).ok(); } @@ -237,6 +502,37 @@ fn game_chat_absolute_deadline_still_cleans_local_action_recovery() { let root = temporary.path().join("project"); init_local_game_project_at(&root, "deadline-local-cleanup", "硬截止本地清理测试") .expect("project init"); + let root_session = resolve_agent_conversation_session_id_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + None, + true, + ) + .expect("resolve local deadline root session"); + let root_record = append_unique_game_creator_agent_runtime_pending_task( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &root_session, + "生成首版可玩游戏", + "game-chat-deadline-local-root-run", + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + None, + ) + .expect("queue local deadline game-chat root"); + bind_game_creator_agent_runtime_run_profile_at( + &root, + "code-prototype", + "game-chat-deadline-local-cleanup-run", + "agent-ready-task-scheduler", + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + Some(&AgentRuntimeTaskLink { + parent_agent_id: Some(root_record.agent_id.clone()), + parent_run_id: Some(root_record.run_id.clone()), + delegation_id: None, + }), + ) + .expect("bind local deadline child profile"); let mut runtime = start_game_creator_agent_runtime_task_at( &root, "code-prototype", @@ -247,6 +543,8 @@ fn game_chat_absolute_deadline_still_cleans_local_action_recovery() { vec!["执行首版写入".to_string()], ) .expect("start runtime"); + runtime.parent_agent_id = Some(root_record.agent_id.clone()); + runtime.parent_run_id = Some(root_record.run_id.clone()); runtime.loop_iteration = 1; let action = AgentRuntimeToolAction { tool: "file.write".to_string(), @@ -311,6 +609,38 @@ fn game_chat_absolute_deadline_still_cleans_local_action_recovery() { )); let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("agent db"); assert!(agent_db.contains("\"reconciliationPreserved\":false")); + let conversation = read_local_conversation_for_session_at( + &root, + Some(&runtime.agent_id), + Some(&runtime.session_id), + ) + .expect("read local deadline conversation"); + assert_eq!( + conversation + .messages + .iter() + .filter(|message| { + message.message_id.as_deref().is_some_and(|message_id| { + message_id.starts_with(AGENT_RUNTIME_PUBLIC_STATUS_MESSAGE_ID_PREFIX) + }) + }) + .count(), + 1 + ); + let project_conversation = + read_local_conversation_at(&root, None).expect("read local root deadline conversation"); + assert_eq!( + project_conversation + .messages + .iter() + .filter(|message| { + message.message_id.as_deref().is_some_and(|message_id| { + message_id.starts_with(AGENT_RUNTIME_PUBLIC_STATUS_MESSAGE_ID_PREFIX) + }) + }) + .count(), + 1 + ); fs::remove_dir_all(root).ok(); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_queue.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_queue.rs index 06833591c..da9d9b3fa 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_queue.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_queue.rs @@ -9,17 +9,15 @@ pub(in crate::agent) fn game_creator_agent_background_task_default_plan() -> Vec ] } -pub(in crate::agent) fn agent_runtime_background_task_message_id( +pub(crate) fn agent_runtime_background_task_message_id( agent_id: &str, session_id: &str, run_id: &str, - source: &str, + _source: &str, ) -> String { - let identity = format!("{agent_id}\n{session_id}\n{run_id}\n{source}"); - let fingerprint = format!("{:x}", Sha256::digest(identity.as_bytes())); format!( "runtime-task-{}", - fingerprint.chars().take(32).collect::() + game_creator_agent_runtime_message_correlation_id(agent_id, session_id, run_id) ) } @@ -279,7 +277,7 @@ pub(crate) fn spawn_started_game_creator_agent_background_task_drain_with_lock( pub(in crate::agent) fn fail_game_creator_agent_background_context_at( root: &Path, agent_id: &str, - session_id: &str, + _session_id: &str, runtime: AgentRuntimeState, error: &str, ) -> AgentBackgroundTaskOutcome { @@ -295,16 +293,6 @@ pub(in crate::agent) fn fail_game_creator_agent_background_context_at( } let error = redact_agent_runtime_error(root, error, 500); let failed_runtime = fail_game_creator_agent_runtime_turn_at(root, runtime, &error); - let _ = append_local_conversation_message_for_session_at( - root, - Some(agent_id), - Some(session_id), - LocalConversationMessage { - role: "assistant".to_string(), - content: game_creator_agent_runtime_failure_conversation_message(agent_id, &error), - agent_id: None, - }, - ); if let Ok(runtime) = failed_runtime { let _ = append_agent_db_record( root, 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 f9cabe2ee..7ee37cb57 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 @@ -278,7 +278,18 @@ pub(in crate::agent) fn start_game_creator_agent_background_task_with_link_in_se .as_deref() .is_some_and(|value| !value.trim().is_empty()) }); - let (pending_task, pending_task_created) = if is_static_supervisor_delegate { + let requires_public_start_status = agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID + && task_link + .and_then(|link| link.parent_agent_id.as_deref()) + .is_none() + && task_link + .and_then(|link| link.parent_run_id.as_deref()) + .is_none() + && !matches!( + source, + AGENT_RUNTIME_DELEGATE_RECEIPT_SOURCE | AGENT_RUNTIME_ISOLATED_JOIN_SOURCE + ); + let (mut pending_task, pending_task_created) = if is_static_supervisor_delegate { append_or_read_exact_game_creator_agent_runtime_pending_task( root, &agent_id, @@ -291,16 +302,29 @@ pub(in crate::agent) fn start_game_creator_agent_background_task_with_link_in_se )? } else { ( - append_unique_game_creator_agent_runtime_pending_task( - root, - &agent_id, - &session_id, - task, - run_id, - source, - run_profile, - task_link, - )?, + if requires_public_start_status { + append_unique_game_creator_agent_runtime_public_status_preparing_task( + root, + &agent_id, + &session_id, + task, + run_id, + source, + run_profile, + task_link, + )? + } else { + append_unique_game_creator_agent_runtime_pending_task( + root, + &agent_id, + &session_id, + task, + run_id, + source, + run_profile, + task_link, + )? + }, true, ) }; @@ -332,7 +356,17 @@ pub(in crate::agent) fn start_game_creator_agent_background_task_with_link_in_se ) .map(|_| ()) }; - if let Err(error) = conversation_result { + let conversation_error = match conversation_result { + Ok(()) => None, + Err(error) => { + match game_creator_agent_runtime_task_has_user_message_at(root, &pending_task) { + Ok(true) => None, + Ok(false) => Some(error), + Err(identity_error) => Some(identity_error), + } + } + }; + if let Some(error) = conversation_error { let error = redact_agent_runtime_project_paths(root, &error, 500); let failed_task = AgentRuntimeTaskRecord { status: "failed".to_string(), @@ -359,6 +393,37 @@ pub(in crate::agent) fn start_game_creator_agent_background_task_with_link_in_se return Err(format!("后台任务用户消息落盘失败,任务未执行:{error}")); } } + if requires_public_start_status { + if let Err(error) = + ensure_game_creator_agent_runtime_accepted_public_status_at(root, &pending_task) + { + let error = redact_agent_runtime_project_paths(root, &error, 500); + let terminal_result = + fail_game_creator_agent_runtime_public_start_status_at(root, &pending_task, &error); + let _ = append_agent_db_record( + root, + serde_json::json!({ + "recordType": "agent.runtime.background_task.queue_warning", + "agentId": pending_task.agent_id, + "sessionId": pending_task.session_id, + "runId": pending_task.run_id, + "warningKind": "public-status-write-failed", + "error": sanitize_agent_runtime_text(&error, 240), + }), + ); + terminal_result?; + return Err(format!("后台任务启动确认落盘失败,任务未执行:{error}")); + } + let queued_task = AgentRuntimeTaskRecord { + status: "pending".to_string(), + phase: "queued".to_string(), + current_action: "等待当前后台任务完成".to_string(), + updated_at: unix_timestamp(), + ..pending_task.clone() + }; + append_game_creator_agent_runtime_task_record(root, &queued_task)?; + pending_task = queued_task; + } if !pending_task_created && game_creator_agent_runtime_terminal_status(&pending_task).is_some() { return read_game_creator_agent_runtime_for_session_at(root, &agent_id, Some(&session_id)) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_state.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_state.rs index f56a0ae7e..6dd236e66 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_state.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_state.rs @@ -3,6 +3,122 @@ use super::*; static AGENT_RUNTIME_EVENT_ID_SEQUENCE: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); +pub(crate) const AGENT_RUNTIME_PUBLIC_STATUS_MESSAGE_ID_PREFIX: &str = "runtime-public-status-"; + +fn game_creator_agent_runtime_public_status_message_id( + agent_id: &str, + session_id: &str, + run_id: &str, + status: &str, +) -> String { + let correlation_id = + game_creator_agent_runtime_message_correlation_id(agent_id, session_id, run_id); + let status_fingerprint = format!("{:x}", Sha256::digest(status.as_bytes())); + format!( + "{AGENT_RUNTIME_PUBLIC_STATUS_MESSAGE_ID_PREFIX}{correlation_id}-{}", + status_fingerprint.chars().take(16).collect::() + ) +} + +pub(crate) fn game_creator_agent_runtime_message_correlation_id( + agent_id: &str, + session_id: &str, + run_id: &str, +) -> String { + let identity = format!("{agent_id}\n{session_id}\n{run_id}"); + let fingerprint = format!("{:x}", Sha256::digest(identity.as_bytes())); + fingerprint.chars().take(32).collect::() +} + +pub(crate) fn game_creator_agent_runtime_accepted_public_message(agent_id: &str) -> String { + if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { + "任务已接收,项目总控 Agent 正在启动处理。".to_string() + } else { + "任务已接收,专业 Agent 正在启动处理。".to_string() + } +} + +pub(crate) fn append_game_creator_agent_runtime_public_status_message_at( + root: &Path, + agent_id: &str, + session_id: &str, + run_id: &str, + status: &str, + content: &str, +) -> Result<(), String> { + let message_id = + game_creator_agent_runtime_public_status_message_id(agent_id, session_id, run_id, status); + append_local_conversation_message_for_session_idempotent_at( + root, + None, + None, + LocalConversationMessage { + role: "assistant".to_string(), + content: content.to_string(), + agent_id: None, + }, + &message_id, + ) + .map(|_| ()) +} + +pub(crate) fn append_game_creator_agent_runtime_terminal_public_message_at( + root: &Path, + state: &AgentRuntimeState, + error: &str, +) -> Result<(), String> { + let content = game_creator_agent_runtime_failure_conversation_message(&state.agent_id, error); + let status = if error.contains(GAME_CHAT_FIRST_PLAYABLE_HARD_BUDGET_ERROR_PREFIX) { + "game-chat-hard-deadline" + } else if state.phase == "budget-exhausted" { + "budget-exhausted" + } else if state.phase == "needs-reconciliation" { + "needs-reconciliation" + } else { + "failed" + }; + if state.agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID + && (state.parent_agent_id.is_some() || state.parent_run_id.is_some()) + { + // Receipt and isolated-join continuations are internal Supervisor + // work. Their backend-owned Runtime event may remain visible, but a + // second Session message would duplicate that same terminal outcome + // in the formal project chat. + return Ok(()); + } + if state.agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID + && state.parent_agent_id.is_none() + && state.parent_run_id.is_none() + { + return append_game_creator_agent_runtime_public_status_message_at( + root, + &state.agent_id, + &state.session_id, + &state.run_id, + status, + &content, + ); + } + let message_id = game_creator_agent_runtime_public_status_message_id( + &state.agent_id, + &state.session_id, + &state.run_id, + status, + ); + append_local_conversation_message_for_session_idempotent_at( + root, + Some(&state.agent_id), + Some(&state.session_id), + LocalConversationMessage { + role: "assistant".to_string(), + content, + agent_id: None, + }, + &message_id, + ) + .map(|_| ()) +} + fn new_game_creator_agent_runtime_event_id( state: &AgentRuntimeState, event_type: &str, @@ -1224,12 +1340,6 @@ pub(crate) fn fail_game_creator_agent_runtime_turn_at( mut state: AgentRuntimeState, error: &str, ) -> Result { - write_non_terminal_isolated_child_cancel_tombstones_for_parent_at( - root, - &state.agent_id, - &state.run_id, - "父 Agent 任务失败,取消动态隔离子任务", - )?; state.pending_tool_action = None; state.status = "failed".to_string(); state.phase = "failed".to_string(); @@ -1238,6 +1348,17 @@ pub(crate) fn fail_game_creator_agent_runtime_turn_at( state.next_step = "等待开发者处理失败".to_string(); let public_error = redact_agent_runtime_error(root, error, 500); state.error = Some(public_error.clone()); + // The public terminal message is deliberately committed before the + // remaining Runtime projections. Even if a task/event/state write is the + // failing subsystem, the user still receives one stable failure outcome. + let public_status_result = + append_game_creator_agent_runtime_terminal_public_message_at(root, &state, &public_error); + write_non_terminal_isolated_child_cancel_tombstones_for_parent_at( + root, + &state.agent_id, + &state.run_id, + "父 Agent 任务失败,取消动态隔离子任务", + )?; fail_agent_runtime_remaining_plan_steps(&mut state, &public_error); let _ = refresh_game_creator_agent_runtime_tool_policy(root, &mut state); state.updated_at = unix_timestamp(); @@ -1267,6 +1388,9 @@ pub(crate) fn fail_game_creator_agent_runtime_turn_at( remove_game_creator_agent_runtime_confirmations(root, &state.agent_id, &state.run_id)?; remove_game_creator_agent_runtime_provider_recovery_at(root, &state.agent_id, &state.run_id)?; publish_game_creator_agent_delegate_result_for_state(root, &state, state.error.as_deref()); + if public_status_result.is_err() { + append_game_creator_agent_runtime_terminal_public_message_at(root, &state, &public_error)?; + } Ok(state) } @@ -1275,12 +1399,6 @@ pub(crate) fn fail_game_creator_agent_runtime_budget_at( mut state: AgentRuntimeState, error: &str, ) -> Result { - write_non_terminal_isolated_child_cancel_tombstones_for_parent_at( - root, - &state.agent_id, - &state.run_id, - "父 Agent 任务预算耗尽,取消动态隔离子任务", - )?; state.pending_tool_action = None; state.status = "failed".to_string(); state.phase = "budget-exhausted".to_string(); @@ -1289,6 +1407,14 @@ pub(crate) fn fail_game_creator_agent_runtime_budget_at( state.next_step = "调整任务范围后重试".to_string(); let public_error = redact_agent_runtime_error(root, error, 500); state.error = Some(public_error.clone()); + let public_status_result = + append_game_creator_agent_runtime_terminal_public_message_at(root, &state, &public_error); + write_non_terminal_isolated_child_cancel_tombstones_for_parent_at( + root, + &state.agent_id, + &state.run_id, + "父 Agent 任务预算耗尽,取消动态隔离子任务", + )?; fail_agent_runtime_remaining_plan_steps(&mut state, &public_error); let _ = refresh_game_creator_agent_runtime_tool_policy(root, &mut state); state.updated_at = unix_timestamp(); @@ -1318,6 +1444,9 @@ pub(crate) fn fail_game_creator_agent_runtime_budget_at( remove_game_creator_agent_runtime_confirmations(root, &state.agent_id, &state.run_id)?; remove_game_creator_agent_runtime_provider_recovery_at(root, &state.agent_id, &state.run_id)?; publish_game_creator_agent_delegate_result_for_state(root, &state, state.error.as_deref()); + if public_status_result.is_err() { + append_game_creator_agent_runtime_terminal_public_message_at(root, &state, &public_error)?; + } Ok(state) } @@ -1466,7 +1595,7 @@ pub(crate) fn default_game_creator_agent_runtime_state( } } -pub(super) fn agent_runtime_state_from_task_record( +pub(crate) fn agent_runtime_state_from_task_record( record: &AgentRuntimeTaskRecord, ) -> AgentRuntimeState { let mut state = default_game_creator_agent_runtime_state(&record.agent_id, &record.run_id); @@ -2822,7 +2951,7 @@ pub(super) fn stop_game_creator_agent_runtime_if_cancel_requested( true } -pub(super) fn append_unique_game_creator_agent_runtime_pending_task( +pub(crate) fn append_unique_game_creator_agent_runtime_pending_task( root: &Path, agent_id: &str, session_id: &str, @@ -2831,6 +2960,59 @@ pub(super) fn append_unique_game_creator_agent_runtime_pending_task( source: &str, requested_run_profile: Option<&str>, task_link: Option<&AgentRuntimeTaskLink>, +) -> Result { + append_unique_game_creator_agent_runtime_task_with_initial_state( + root, + agent_id, + session_id, + task, + requested_run_id, + source, + requested_run_profile, + task_link, + "pending", + "queued", + "等待当前后台任务完成", + ) +} + +pub(crate) fn append_unique_game_creator_agent_runtime_public_status_preparing_task( + root: &Path, + agent_id: &str, + session_id: &str, + task: &str, + requested_run_id: &str, + source: &str, + requested_run_profile: Option<&str>, + task_link: Option<&AgentRuntimeTaskLink>, +) -> Result { + append_unique_game_creator_agent_runtime_task_with_initial_state( + root, + agent_id, + session_id, + task, + requested_run_id, + source, + requested_run_profile, + task_link, + "preparing", + "public-status-pending", + "等待持久化启动确认", + ) +} + +fn append_unique_game_creator_agent_runtime_task_with_initial_state( + root: &Path, + agent_id: &str, + session_id: &str, + task: &str, + requested_run_id: &str, + source: &str, + requested_run_profile: Option<&str>, + task_link: Option<&AgentRuntimeTaskLink>, + initial_status: &str, + initial_phase: &str, + initial_current_action: &str, ) -> Result { let autonomous_root_project_lock = (agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID && task_link.is_none() @@ -2877,9 +3059,9 @@ pub(super) fn append_unique_game_creator_agent_runtime_pending_task( parent_run_id: task_link.parent_run_id, delegation_id: task_link.delegation_id, task: sanitize_agent_runtime_text(task, task_max_chars), - status: "pending".to_string(), - phase: "queued".to_string(), - current_action: "等待当前后台任务完成".to_string(), + status: initial_status.to_string(), + phase: initial_phase.to_string(), + current_action: initial_current_action.to_string(), terminal_detail: None, error: None, updated_at: unix_timestamp(), @@ -3185,9 +3367,158 @@ pub(super) fn read_next_pending_game_creator_agent_runtime_task( if records.iter().any(|record| record.status == "running") { return Ok(None); } - Ok(records - .into_iter() - .find(|record| record.status == "pending")) + for record in records { + if record.status == "pending" { + return Ok(Some(record)); + } + if let Some(queued) = + promote_accepted_game_creator_agent_runtime_preparing_task_at(root, &record)? + { + return Ok(Some(queued)); + } + } + Ok(None) +} + +fn promote_accepted_game_creator_agent_runtime_preparing_task_at( + root: &Path, + record: &AgentRuntimeTaskRecord, +) -> Result, String> { + if record.status != "preparing" || record.phase != "public-status-pending" { + return Ok(None); + } + if !game_creator_agent_runtime_preparing_task_has_accepted_status_at(root, record)? { + if !game_creator_agent_runtime_task_has_user_message_at(root, record)? { + return Ok(None); + } + if let Err(error) = + ensure_game_creator_agent_runtime_accepted_public_status_at(root, record) + { + fail_game_creator_agent_runtime_public_start_status_at(root, record, &error)?; + return Err(format!("恢复 Agent Runtime 启动确认失败:{error}")); + } + } + let queued = AgentRuntimeTaskRecord { + status: "pending".to_string(), + phase: "queued".to_string(), + current_action: "等待当前后台任务完成".to_string(), + updated_at: unix_timestamp(), + ..record.clone() + }; + append_game_creator_agent_runtime_task_record(root, &queued)?; + Ok(Some(queued)) +} + +fn game_creator_agent_runtime_preparing_task_has_accepted_status_at( + root: &Path, + record: &AgentRuntimeTaskRecord, +) -> Result { + if record.status != "preparing" || record.phase != "public-status-pending" { + return Ok(false); + } + let message_id = game_creator_agent_runtime_public_status_message_id( + &record.agent_id, + &record.session_id, + &record.run_id, + "accepted", + ); + let Some(message) = + read_local_conversation_message_by_id_for_session_at(root, None, None, &message_id)? + else { + return Ok(false); + }; + if message.role != "assistant" + || message.content != game_creator_agent_runtime_accepted_public_message(&record.agent_id) + { + return Err(format!( + "Agent Runtime 启动确认恢复身份冲突:runId={}", + record.run_id + )); + } + Ok(true) +} + +pub(crate) fn game_creator_agent_runtime_task_has_user_message_at( + root: &Path, + record: &AgentRuntimeTaskRecord, +) -> Result { + let message_id = agent_runtime_background_task_message_id( + &record.agent_id, + &record.session_id, + &record.run_id, + &record.source, + ); + let Some(message) = read_local_conversation_message_by_id_for_session_without_touch_at( + root, + Some(&record.agent_id), + Some(&record.session_id), + &message_id, + )? + else { + return Ok(false); + }; + if message.role != "user" || message.content != record.task { + return Err(format!( + "Agent Runtime 启动用户消息身份冲突:runId={}", + record.run_id + )); + } + Ok(true) +} + +pub(crate) fn ensure_game_creator_agent_runtime_accepted_public_status_at( + root: &Path, + record: &AgentRuntimeTaskRecord, +) -> Result<(), String> { + if game_creator_agent_runtime_preparing_task_has_accepted_status_at(root, record)? { + return Ok(()); + } + let append_result = append_game_creator_agent_runtime_public_status_message_at( + root, + &record.agent_id, + &record.session_id, + &record.run_id, + "accepted", + &game_creator_agent_runtime_accepted_public_message(&record.agent_id), + ); + match append_result { + Ok(()) => Ok(()), + Err(_error) + if game_creator_agent_runtime_preparing_task_has_accepted_status_at(root, record)? => + { + // The conversation append is authoritative. A later audit write + // may fail after the message is already durable; do not strand a + // visible accepted task solely because its auxiliary audit needs + // repair. + Ok(()) + } + Err(error) => Err(error), + } +} + +pub(crate) fn fail_game_creator_agent_runtime_public_start_status_at( + root: &Path, + record: &AgentRuntimeTaskRecord, + error: &str, +) -> Result<(), String> { + let error = redact_agent_runtime_project_paths(root, error, 500); + let failed_task = AgentRuntimeTaskRecord { + status: "failed".to_string(), + phase: "public-status-write-failed".to_string(), + current_action: "启动确认未落盘,后台任务未执行".to_string(), + terminal_detail: Some(error.clone()), + error: Some(error.clone()), + updated_at: unix_timestamp(), + ..record.clone() + }; + let mut failed_state = agent_runtime_state_from_task_record(&failed_task); + failed_state.status = failed_task.status.clone(); + failed_state.phase = failed_task.phase.clone(); + let public_status_result = + append_game_creator_agent_runtime_terminal_public_message_at(root, &failed_state, &error); + append_game_creator_agent_runtime_task_record(root, &failed_task)?; + publish_game_creator_agent_delegate_result(root, &failed_task, Some(&error)); + public_status_result } pub(super) fn suppress_game_creator_agent_delegate_receipt_for_terminal_parent( @@ -3340,7 +3671,7 @@ pub(super) fn close_game_creator_agent_delegate_receipt_before_start( Ok(()) } -pub(super) fn read_next_runnable_game_creator_agent_runtime_task( +pub(crate) fn read_next_runnable_game_creator_agent_runtime_task( root: &Path, agent_id: &str, ) -> Result, String> { @@ -3404,10 +3735,17 @@ pub(super) fn read_recoverable_game_creator_agent_runtime_task( 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)?); - let task_states = records - .iter() - .map(|record| match record.status.as_str() { + let mut task_states = Vec::with_capacity(records.len()); + for record in &records { + let state = match record.status.as_str() { "pending" => agent_runtime_core::RecoverableTaskState::Pending, + "preparing" + if game_creator_agent_runtime_preparing_task_has_accepted_status_at( + root, record, + )? || game_creator_agent_runtime_task_has_user_message_at(root, record)? => + { + agent_runtime_core::RecoverableTaskState::Pending + } "running" => agent_runtime_core::RecoverableTaskState::Running, "waiting-for-confirmation" => { agent_runtime_core::RecoverableTaskState::WaitingForConfirmation @@ -3416,8 +3754,9 @@ pub(super) fn read_recoverable_game_creator_agent_runtime_task( agent_runtime_core::RecoverableTaskState::WaitingForUserInput } _ => agent_runtime_core::RecoverableTaskState::Other, - }) - .collect::>(); + }; + task_states.push(state); + } match agent_runtime_core::next_recovery_step(&task_states) { agent_runtime_core::RecoveryStep::ResumeRunning { index } | agent_runtime_core::RecoveryStep::StartPending { index } => { @@ -3433,9 +3772,15 @@ pub(super) fn read_recoverable_runnable_game_creator_agent_runtime_task( agent_id: &str, ) -> Result, String> { loop { - let Some(task) = read_recoverable_game_creator_agent_runtime_task(root, agent_id)? else { + let Some(mut task) = read_recoverable_game_creator_agent_runtime_task(root, agent_id)? + else { return Ok(None); }; + if let Some(queued) = + promote_accepted_game_creator_agent_runtime_preparing_task_at(root, &task)? + { + task = queued; + } if suppress_game_creator_agent_delegate_receipt_for_terminal_parent(root, &task)? { continue; } @@ -3527,6 +3872,7 @@ fn validate_game_creator_agent_runtime_task_status_phase( if !matches!( record.status.as_str(), "idle" + | "preparing" | "pending" | "running" | "waiting-for-confirmation" @@ -3547,6 +3893,7 @@ fn validate_game_creator_agent_runtime_task_status_phase( if !matches!( record.phase.as_str(), "idle" + | "public-status-pending" | "queued" | "planning" | "llm" @@ -3574,6 +3921,7 @@ fn validate_game_creator_agent_runtime_task_status_phase( | "needs-reconciliation" | "completion-contract-failed" | "conversation-write-failed" + | "public-status-write-failed" | "parent-terminal" | "parent-link-missing" ) { 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 c29828a72..ad7a46f72 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 @@ -44,7 +44,11 @@ pub(crate) fn game_creator_agent_runtime_terminal_status( "completed" => Some("completed"), "budget-exhausted" => Some("budget-exhausted"), "cancelled" => Some("cancelled"), - "failed" | "conversation-write-failed" if task.status == "failed" => Some("failed"), + "failed" | "conversation-write-failed" | "public-status-write-failed" + if task.status == "failed" => + { + Some("failed") + } _ => None, } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/context_compaction.rs b/apps/ai-game-creator-shell/src-tauri/src/context_compaction.rs index f5898594a..b6c254298 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/context_compaction.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/context_compaction.rs @@ -378,6 +378,13 @@ fn render_conversation_messages( messages .iter() .filter_map(|message| { + if message.message_id.as_deref().is_some_and(|message_id| { + message_id + .trim() + .starts_with(AGENT_RUNTIME_PUBLIC_STATUS_MESSAGE_ID_PREFIX) + }) { + return None; + } let content = normalize_conversation_content(&message.content); (!content.is_empty()).then(|| format!("- [{label} / {}] {content}", message.role)) }) diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/agent_db.rs b/apps/ai-game-creator-shell/src-tauri/src/project/agent_db.rs index 05528b20f..22fd4ca0a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/agent_db.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/agent_db.rs @@ -987,6 +987,29 @@ fn take_agent_db_record_failure_injection( } } +#[cfg(test)] +fn take_conversation_audit_failure_injection(root: &Path, message_id: &str) -> Result<(), String> { + let failure_path = root.join(".agent/runtime/test-fail-next-agent-db-record"); + match fs::read_to_string(&failure_path) { + Ok(expected) => { + let expected = expected.trim(); + let Some(message_id_prefix) = expected.strip_prefix("conversation.message:") else { + return Ok(()); + }; + if !message_id.starts_with(message_id_prefix) { + return Ok(()); + } + fs::remove_file(&failure_path) + .map_err(|error| format!("清理 Agent DB 测试失败注入标记失败:{error}"))?; + Err(format!( + "测试注入 conversation.message 审计失败:{message_id_prefix}" + )) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(format!("读取 Agent DB 测试失败注入标记失败:{error}")), + } +} + fn append_agent_db_record_internal(root: &Path, record: serde_json::Value) -> Result<(), String> { #[cfg(test)] take_agent_db_record_failure_injection( @@ -4153,6 +4176,15 @@ pub(super) fn ensure_conversation_message_audit_at( )? { return Ok(()); } + #[cfg(test)] + take_conversation_audit_failure_injection(root, message_id)?; + #[cfg(test)] + take_agent_db_record_failure_injection( + root, + audit_record + .get("recordType") + .and_then(serde_json::Value::as_str), + )?; let append_class = agent_db_record_append_class(&audit_record); let line = serialize_agent_db_record(audit_record)?; validate_agent_db_append_class_record_size(append_class, &line)?; diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/conversation.rs b/apps/ai-game-creator-shell/src-tauri/src/project/conversation.rs index 4077a5f01..3e40c51b4 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/conversation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/conversation.rs @@ -1055,6 +1055,29 @@ pub(crate) fn read_local_conversation_message_by_id_for_session_at( agent_id: Option<&str>, session_id: Option<&str>, message_id: &str, +) -> Result, String> { + read_local_conversation_message_by_id_for_session_internal_at( + root, agent_id, session_id, message_id, true, + ) +} + +pub(crate) fn read_local_conversation_message_by_id_for_session_without_touch_at( + root: &Path, + agent_id: Option<&str>, + session_id: Option<&str>, + message_id: &str, +) -> Result, String> { + read_local_conversation_message_by_id_for_session_internal_at( + root, agent_id, session_id, message_id, false, + ) +} + +fn read_local_conversation_message_by_id_for_session_internal_at( + root: &Path, + agent_id: Option<&str>, + session_id: Option<&str>, + message_id: &str, + touch_session: bool, ) -> Result, String> { let message_id = normalize_local_conversation_message_id(message_id)?; let (path, normalized_agent_id, normalized_session_id) = @@ -1068,17 +1091,19 @@ pub(crate) fn read_local_conversation_message_by_id_for_session_at( normalized_session_id.as_deref(), &message_id, )?; - if let (Some(agent_id), Some(session_id)) = ( - normalized_agent_id.as_deref(), - normalized_session_id.as_deref(), - ) { - touch_agent_conversation_session_at( - root, - agent_id, - session_id, - records.len() as u64, - false, - )?; + if touch_session { + if let (Some(agent_id), Some(session_id)) = ( + normalized_agent_id.as_deref(), + normalized_session_id.as_deref(), + ) { + touch_agent_conversation_session_at( + root, + agent_id, + session_id, + records.len() as u64, + false, + )?; + } } Ok(matched_index.map(|index| records[index].to_public_record())) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs index de98c3b49..888893f6a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs @@ -5578,9 +5578,10 @@ async fn background_agent_runtime_marks_unconverged_loop_budget_exhausted() { .expect("read budget conversation"); assert!(conversation.messages.iter().any(|message| { message.role == "assistant" - && message - .content - .contains("后台任务未完成:loop-budget-exhausted") + && message.content == "专业 Agent 执行失败,请稍后重试" + && message.message_id.as_deref().is_some_and(|message_id| { + message_id.starts_with(AGENT_RUNTIME_PUBLIC_STATUS_MESSAGE_ID_PREFIX) + }) })); let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("agent db"); assert!(agent_db.contains("\"failureKind\":\"loop-budget-exhausted\"")); diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_state.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_state.rs index 077839dc5..5fb1cd29f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_state.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_state.rs @@ -209,15 +209,24 @@ fn runtime_task_reader_accepts_rare_persisted_phases() { let brief = runtime_task_json_line("brief-run", "running", "brief"); let parent_link_missing = runtime_task_json_line("parent-link-missing-run", "failed", "parent-link-missing"); - fs::write(&path, format!("{brief}\n{parent_link_missing}\n")) - .expect("write rare persisted task phases"); + let public_status_write_failed = runtime_task_json_line( + "public-status-write-failed-run", + "failed", + "public-status-write-failed", + ); + fs::write( + &path, + format!("{brief}\n{parent_link_missing}\n{public_status_write_failed}\n"), + ) + .expect("write rare persisted task phases"); let records = read_all_game_creator_agent_runtime_tasks(&path) .expect("known persisted task phases must remain readable"); - assert_eq!(records.len(), 2); + assert_eq!(records.len(), 3); assert_eq!(records[0].phase, "brief"); assert_eq!(records[1].phase, "parent-link-missing"); + assert_eq!(records[2].phase, "public-status-write-failed"); fs::remove_dir_all(root).ok(); } @@ -1028,9 +1037,13 @@ async fn background_final_reply_failure_keeps_private_conversation_and_hashes_pu Some("design-director"), Some(&runtime.session_id), ) - .expect("read private failure conversation"); + .expect("read Runtime-owned public failure conversation"); assert!(conversation.messages.iter().any(|message| { - message.role == "assistant" && message.content == "专业 Agent 服务请求失败,请稍后重试" + message.role == "assistant" + && message.content == "专业 Agent 服务请求失败,请稍后重试" + && message.message_id.as_deref().is_some_and(|message_id| { + message_id.starts_with(AGENT_RUNTIME_PUBLIC_STATUS_MESSAGE_ID_PREFIX) + }) })); fs::remove_dir_all(root).ok(); @@ -1196,20 +1209,24 @@ fn structured_plan_state_write_failure_stops_before_context_and_audit() { assert!(!records.iter().any(|record| { record["recordType"] == "agent.runtime.plan_update" && record["runId"] == run_id })); - let conversation = read_local_conversation_for_session_at( + let agent_conversation = read_local_conversation_for_session_at( &root, Some("design-director"), Some("agent-session-design-director"), ) - .expect("read state write failure conversation"); - assert!(!conversation.messages.iter().any(|message| { + .expect("read state write failure Agent conversation"); + assert!(!agent_conversation.messages.iter().any(|message| { message.role == "assistant" && message.content.contains("该回复不得落盘") })); - assert!(conversation.messages.iter().any(|message| { - message.role == "assistant" && message.content == "专业 Agent 执行失败,请稍后重试" + assert!(agent_conversation.messages.iter().any(|message| { + message.role == "assistant" + && message.content == "专业 Agent 执行失败,请稍后重试" + && message.message_id.as_deref().is_some_and(|message_id| { + message_id.starts_with(AGENT_RUNTIME_PUBLIC_STATUS_MESSAGE_ID_PREFIX) + }) })); - let conversation_text = - serde_json::to_string(&conversation).expect("serialize state write failure conversation"); + let conversation_text = serde_json::to_string(&agent_conversation) + .expect("serialize state write failure conversation"); assert!(!conversation_text.contains("absolute-path")); assert!(!conversation_text.contains("redacted sensitive context")); @@ -2876,27 +2893,35 @@ fn runtime_v11_closure_started_join_cannot_be_reclaimed_by_parent() { fn background_task_does_not_execute_when_user_message_cannot_persist() { let root = unique_project_path(); init_local_game_project_at(&root, "project-1", "后台任务对话一致性测试").expect("project init"); - let session_id = resolve_agent_conversation_session_id_at(&root, "design-director", None, true) - .expect("resolve agent session"); + let session_id = resolve_agent_conversation_session_id_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + None, + true, + ) + .expect("resolve Supervisor session"); fs::write( root.join(".agent/runtime/test-fail-next-background-conversation"), b"fail once\n", ) .expect("arm one-shot background conversation failure"); - let runtime_lock = try_acquire_game_creator_agent_runtime_task_lock(&root, "design-director") - .expect("acquire runtime lock") - .expect("runtime lock available"); + let runtime_lock = try_acquire_game_creator_agent_runtime_task_lock( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + ) + .expect("acquire runtime lock") + .expect("runtime lock available"); let result = start_game_creator_agent_background_task_for_session_at( &root, - "design-director", + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, Some(&session_id), "这条任务必须先持久化对话", "conversation-write-failure-run", ); assert!(result.is_err()); - let task_log = fs::read_to_string(root.join(".agent/runtime/tasks/design-director.jsonl")) + let task_log = fs::read_to_string(root.join(".agent/runtime/tasks/project-supervisor.jsonl")) .expect("read failed queued task log"); let task = task_log .lines() @@ -2909,9 +2934,12 @@ fn background_task_does_not_execute_when_user_message_cannot_persist() { assert!(!root .join(".agent/runtime/test-fail-next-background-conversation") .exists()); - let conversation = - read_local_conversation_for_session_at(&root, Some("design-director"), Some(&session_id)) - .expect("read conversation after injected user write failure"); + let conversation = read_local_conversation_for_session_at( + &root, + Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID), + Some(&session_id), + ) + .expect("read conversation after injected user write failure"); assert!(conversation .messages .iter() @@ -2921,6 +2949,612 @@ fn background_task_does_not_execute_when_user_message_cannot_persist() { fs::remove_dir_all(root).ok(); } +#[test] +fn background_task_closes_when_user_message_identity_conflicts() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "后台任务用户消息身份冲突测试") + .expect("project init"); + let session_id = resolve_agent_conversation_session_id_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + None, + true, + ) + .expect("resolve Supervisor session"); + let run_id = "conversation-identity-conflict-run"; + let message_id = agent_runtime_background_task_message_id( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &session_id, + run_id, + "agent-background-task", + ); + append_local_conversation_message_for_session_idempotent_at( + &root, + Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID), + Some(&session_id), + LocalConversationMessage { + role: "user".to_string(), + content: "占用同一消息身份的冲突内容".to_string(), + agent_id: None, + }, + &message_id, + ) + .expect("append conflicting user message"); + let runtime_lock = try_acquire_game_creator_agent_runtime_task_lock( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + ) + .expect("acquire runtime lock") + .expect("runtime lock available"); + + let result = start_game_creator_agent_background_task_for_session_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + Some(&session_id), + "不能借用冲突消息执行", + run_id, + ); + + assert!(result.is_err()); + let latest = read_latest_game_creator_agent_runtime_task_by_run_id( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + ) + .expect("read identity-conflicted task") + .expect("identity-conflicted task exists"); + assert_eq!(latest.status, "failed"); + assert_eq!(latest.phase, "conversation-write-failed"); + assert!(read_next_runnable_game_creator_agent_runtime_task( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + ) + .expect("read runnable task after identity conflict") + .is_none()); + + drop(runtime_lock); + fs::remove_dir_all(root).ok(); +} + +#[test] +fn background_task_does_not_execute_when_public_start_status_cannot_persist() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "后台任务启动确认一致性测试") + .expect("project init"); + let project_conversation_path = root.join(".agent/conversations/project.jsonl"); + if project_conversation_path.is_file() { + fs::remove_file(&project_conversation_path).expect("remove project conversation fixture"); + } + fs::create_dir_all(&project_conversation_path) + .expect("replace project conversation file with directory"); + let runtime_lock = try_acquire_game_creator_agent_runtime_task_lock( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + ) + .expect("acquire runtime lock") + .expect("runtime lock available"); + + let result = start_game_creator_agent_background_task_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "这条任务必须先公开确认启动", + "public-start-status-write-failure-run", + ); + + assert!(result.is_err()); + let task_log = fs::read_to_string(root.join(".agent/runtime/tasks/project-supervisor.jsonl")) + .expect("read failed queued task log"); + let tasks = task_log + .lines() + .filter_map(|line| serde_json::from_str::(line).ok()) + .filter(|task| task.run_id == "public-start-status-write-failure-run") + .collect::>(); + assert_eq!( + tasks + .iter() + .map(|task| (task.status.as_str(), task.phase.as_str())) + .collect::>(), + vec![ + ("preparing", "public-status-pending"), + ("failed", "public-status-write-failed"), + ] + ); + let task = tasks.last().expect("failed queued task exists"); + assert_eq!(task.status, "failed"); + assert_eq!(task.phase, "public-status-write-failed"); + assert!(read_next_runnable_game_creator_agent_runtime_task( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + ) + .expect("read runnable task after public status failure") + .is_none()); + let conversation = + read_local_conversation_at(&root, Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID)) + .expect("read Agent conversation after public status failure"); + assert!(conversation.messages.iter().any(|message| { + message.role == "user" && message.content == "这条任务必须先公开确认启动" + })); + assert!(conversation + .messages + .iter() + .all(|message| message.role != "assistant")); + + drop(runtime_lock); + fs::remove_dir_all(root).ok(); +} + +#[test] +fn supervisor_start_continues_when_accepted_is_durable_but_audit_write_fails() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "启动确认审计失败恢复测试") + .expect("project init"); + let runtime_lock = try_acquire_game_creator_agent_runtime_task_lock( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + ) + .expect("acquire runtime lock") + .expect("runtime lock available"); + let failure_path = root.join(".agent/runtime/test-fail-next-agent-db-record"); + fs::write( + &failure_path, + b"conversation.message:runtime-public-status-\n", + ) + .expect("inject accepted conversation audit failure"); + let run_id = "public-start-status-audit-failure-run"; + + start_game_creator_agent_background_task_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "accepted 已落盘时必须继续入队", + run_id, + ) + .expect("durable accepted message remains authoritative"); + + assert!(!failure_path.exists()); + let latest = read_latest_game_creator_agent_runtime_task_by_run_id( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + ) + .expect("read queued task") + .expect("queued task exists"); + assert_eq!(latest.status, "pending"); + assert_eq!(latest.phase, "queued"); + let project_conversation = + read_local_conversation_at(&root, None).expect("read accepted project conversation"); + assert_eq!( + project_conversation + .messages + .iter() + .filter(|message| { + message.message_id.as_deref().is_some_and(|message_id| { + message_id.starts_with(AGENT_RUNTIME_PUBLIC_STATUS_MESSAGE_ID_PREFIX) + }) + }) + .count(), + 1 + ); + + drop(runtime_lock); + fs::remove_dir_all(root).ok(); +} + +#[test] +fn supervisor_start_continues_when_user_message_is_durable_but_audit_write_fails() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "启动用户消息审计失败恢复测试") + .expect("project init"); + let runtime_lock = try_acquire_game_creator_agent_runtime_task_lock( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + ) + .expect("acquire runtime lock") + .expect("runtime lock available"); + let failure_path = root.join(".agent/runtime/test-fail-next-agent-db-record"); + fs::write(&failure_path, b"conversation.message:runtime-task-\n") + .expect("inject user conversation audit failure"); + let run_id = "user-message-audit-failure-run"; + + start_game_creator_agent_background_task_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "用户消息已落盘时必须继续入队", + run_id, + ) + .expect("durable user message remains authoritative"); + + assert!(!failure_path.exists()); + let latest = read_latest_game_creator_agent_runtime_task_by_run_id( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + ) + .expect("read queued task") + .expect("queued task exists"); + assert_eq!(latest.status, "pending"); + assert_eq!(latest.phase, "queued"); + let supervisor_conversation = read_local_conversation_for_session_at( + &root, + Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID), + Some(&latest.session_id), + ) + .expect("read durable user conversation"); + assert_eq!( + supervisor_conversation + .messages + .iter() + .filter(|message| message.role == "user" + && message.content == "用户消息已落盘时必须继续入队") + .count(), + 1 + ); + let project_conversation = + read_local_conversation_at(&root, None).expect("read accepted project conversation"); + assert_eq!( + project_conversation + .messages + .iter() + .filter(|message| message.content + == game_creator_agent_runtime_accepted_public_message( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + )) + .count(), + 1 + ); + + drop(runtime_lock); + fs::remove_dir_all(root).ok(); +} + +#[test] +fn supervisor_background_task_persists_one_public_start_status_before_execution() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "Supervisor 启动确认测试") + .expect("project init"); + let runtime_lock = try_acquire_game_creator_agent_runtime_task_lock( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + ) + .expect("acquire runtime lock") + .expect("runtime lock available"); + let run_id = "supervisor-public-start-status-run"; + + let queued = start_game_creator_agent_background_task_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "先确认启动,再执行长任务", + run_id, + ) + .expect("queue Supervisor task"); + let task_log = fs::read_to_string(root.join(".agent/runtime/tasks/project-supervisor.jsonl")) + .expect("read Supervisor start gate task log"); + let task_states = task_log + .lines() + .filter_map(|line| serde_json::from_str::(line).ok()) + .filter(|task| task.run_id == run_id) + .map(|task| (task.status, task.phase)) + .collect::>(); + assert_eq!( + task_states, + vec![ + ("preparing".to_string(), "public-status-pending".to_string(),), + ("pending".to_string(), "queued".to_string()), + ] + ); + append_game_creator_agent_runtime_public_status_message_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &queued.state.session_id, + run_id, + "accepted", + &game_creator_agent_runtime_accepted_public_message( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + ), + ) + .expect("repeat accepted public status idempotently"); + + let project_conversation = + read_local_conversation_at(&root, None).expect("read public start status"); + let public_statuses = project_conversation + .messages + .iter() + .filter(|message| { + message.message_id.as_deref().is_some_and(|message_id| { + message_id.starts_with(AGENT_RUNTIME_PUBLIC_STATUS_MESSAGE_ID_PREFIX) + }) + }) + .collect::>(); + assert_eq!(public_statuses.len(), 1); + assert_eq!( + public_statuses[0].content, + "任务已接收,项目总控 Agent 正在启动处理。" + ); + let supervisor_conversation = read_local_conversation_for_session_at( + &root, + Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID), + Some(&queued.state.session_id), + ) + .expect("read Supervisor prompt conversation"); + assert_eq!(supervisor_conversation.messages.len(), 1); + assert_eq!(supervisor_conversation.messages[0].role, "user"); + assert_eq!( + supervisor_conversation.messages[0].content, + "先确认启动,再执行长任务" + ); + let prompt_context = render_local_conversation_prompt_context_for_session( + &root, + Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID), + Some(&queued.state.session_id), + ) + .expect("render Supervisor prompt conversation"); + assert!(prompt_context.contains("先确认启动,再执行长任务")); + assert!(!prompt_context.contains("任务已接收")); + let runtime_prompt_history = prepare_game_creator_agent_runtime_prompt_history( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &queued.state.session_id, + run_id, + &[], + 1_000, + ) + .expect("render Runtime prompt history"); + assert!(runtime_prompt_history + .context + .contains("先确认启动,再执行长任务")); + assert!(!runtime_prompt_history.context.contains("任务已接收")); + + drop(runtime_lock); + fs::remove_dir_all(root).ok(); +} + +#[test] +fn accepted_supervisor_preparing_task_recovers_to_runnable_queue() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "Supervisor 启动确认恢复测试") + .expect("project init"); + let session_id = resolve_agent_conversation_session_id_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + None, + true, + ) + .expect("resolve Supervisor session"); + let run_id = "supervisor-public-start-status-recovery-run"; + let preparing = append_unique_game_creator_agent_runtime_public_status_preparing_task( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &session_id, + "验证 accepted 后可恢复入队", + run_id, + "agent-background-task", + None, + None, + ) + .expect("append preparing task"); + assert_eq!(preparing.status, "preparing"); + let task_path = root.join(".agent/runtime/tasks/project-supervisor.jsonl"); + let task_log_before_preflight = fs::read(&task_path).expect("read preparing task journal"); + assert!( + !has_recoverable_game_creator_agent_background_tasks_at(&root) + .expect("preflight task before accepted status") + ); + assert_eq!( + fs::read(&task_path).expect("reread preparing task journal"), + task_log_before_preflight + ); + assert!(resume_game_creator_agent_background_tasks_at(&root) + .expect("scan task before accepted status") + .is_empty()); + + let user_message_id = agent_runtime_background_task_message_id( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &session_id, + run_id, + "agent-background-task", + ); + append_local_conversation_message_for_session_idempotent_at( + &root, + Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID), + Some(&session_id), + LocalConversationMessage { + role: "user".to_string(), + content: "验证 accepted 后可恢复入队".to_string(), + agent_id: None, + }, + &user_message_id, + ) + .expect("append persisted user message before accepted crash window"); + let task_log_after_user_message = + fs::read(&task_path).expect("read user-persisted preparing journal"); + assert!( + has_recoverable_game_creator_agent_background_tasks_at(&root) + .expect("preflight user-persisted preparing task") + ); + assert_eq!( + fs::read(&task_path).expect("reread user-persisted preparing journal"), + task_log_after_user_message + ); + let resumed = resume_game_creator_agent_background_tasks_at(&root) + .expect("repair accepted status and recover preparing task through Runner scan"); + assert_eq!(resumed.len(), 1); + assert_eq!(resumed[0].state.run_id, run_id); + let task_log = fs::read_to_string(root.join(".agent/runtime/tasks/project-supervisor.jsonl")) + .expect("read recovered task journal"); + let recovered_states = task_log + .lines() + .filter_map(|line| serde_json::from_str::(line).ok()) + .filter(|task| task.run_id == run_id) + .map(|task| (task.status, task.phase)) + .collect::>(); + assert!(recovered_states.contains(&("pending".to_string(), "queued".to_string()))); + let project_conversation = + read_local_conversation_at(&root, None).expect("read repaired accepted public status"); + assert!(project_conversation.messages.iter().any(|message| { + message.content + == game_creator_agent_runtime_accepted_public_message( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + ) + && message.message_id.as_deref().is_some_and(|message_id| { + message_id.starts_with(AGENT_RUNTIME_PUBLIC_STATUS_MESSAGE_ID_PREFIX) + }) + })); + drop(wait_to_acquire_agent_runtime_lock( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + )); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn supervisor_terminal_failure_persists_one_public_status() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "Supervisor 终态失败公开消息测试") + .expect("project init"); + let state = start_game_creator_agent_runtime_task_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "验证终态失败公开消息", + "supervisor-terminal-public-status-run", + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + "准备验证失败收束", + vec!["验证终态失败公开消息".to_string()], + ) + .expect("start Supervisor runtime"); + let failed = fail_game_creator_agent_runtime_turn_at(&root, state, "private provider failure") + .expect("fail Supervisor runtime"); + append_game_creator_agent_runtime_terminal_public_message_at( + &root, + &failed, + "private provider failure", + ) + .expect("repeat terminal public status idempotently"); + + let project_conversation = + read_local_conversation_at(&root, None).expect("read public terminal status"); + let public_statuses = project_conversation + .messages + .iter() + .filter(|message| { + message.message_id.as_deref().is_some_and(|message_id| { + message_id.starts_with(AGENT_RUNTIME_PUBLIC_STATUS_MESSAGE_ID_PREFIX) + }) + }) + .collect::>(); + assert_eq!(public_statuses.len(), 1); + assert_eq!( + public_statuses[0].content, + "项目总控 Agent 执行失败,请稍后重试" + ); + assert!(!serde_json::to_string(&project_conversation) + .expect("serialize public terminal conversation") + .contains("private provider failure")); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn supervisor_terminal_failure_retries_one_shot_public_status_write() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "Supervisor 终态公开消息重试测试") + .expect("project init"); + let state = start_game_creator_agent_runtime_task_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "验证终态公开消息瞬时失败重试", + "supervisor-terminal-public-status-retry-run", + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + "准备验证失败收束", + vec!["验证终态公开消息瞬时失败重试".to_string()], + ) + .expect("start Supervisor runtime"); + let failure_path = root.join(".agent/runtime/test-fail-next-conversation-append"); + fs::write(&failure_path, b"assistant\n").expect("arm one-shot public status failure"); + + let failed = fail_game_creator_agent_runtime_turn_at(&root, state, "private provider failure") + .expect("retry and fail Supervisor runtime"); + + assert_eq!(failed.status, "failed"); + assert!(!failure_path.exists()); + let project_conversation = + read_local_conversation_at(&root, None).expect("read retried public terminal status"); + assert_eq!( + project_conversation + .messages + .iter() + .filter(|message| message.content == "项目总控 Agent 执行失败,请稍后重试") + .count(), + 1 + ); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn supervisor_continuation_failure_stays_out_of_project_conversation() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "Supervisor continuation 终态边界测试") + .expect("project init"); + let session_id = resolve_agent_conversation_session_id_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + None, + true, + ) + .expect("resolve Supervisor session"); + let parent = append_unique_game_creator_agent_runtime_pending_task( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &session_id, + "Supervisor parent task", + "supervisor-continuation-parent-run", + "agent-background-task", + None, + None, + ) + .expect("append Supervisor parent task"); + let continuation = append_unique_game_creator_agent_runtime_pending_task( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &session_id, + "Supervisor receipt continuation", + "supervisor-continuation-child-run", + "agent-delegate-receipt", + None, + Some(&AgentRuntimeTaskLink { + parent_agent_id: None, + parent_run_id: Some(parent.run_id.clone()), + delegation_id: Some("supervisor-continuation-delegation".to_string()), + }), + ) + .expect("append Supervisor continuation task"); + let state = agent_runtime_state_from_task_record(&continuation); + + fail_game_creator_agent_runtime_turn_at(&root, state, "private continuation failure") + .expect("fail Supervisor continuation"); + + let project_conversation = + read_local_conversation_at(&root, None).expect("read project conversation"); + assert!(project_conversation.messages.iter().all(|message| { + !message.message_id.as_deref().is_some_and(|message_id| { + message_id.starts_with(AGENT_RUNTIME_PUBLIC_STATUS_MESSAGE_ID_PREFIX) + }) + })); + let supervisor_conversation = read_local_conversation_for_session_at( + &root, + Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID), + Some(&session_id), + ) + .expect("read Supervisor continuation conversation"); + assert!(supervisor_conversation.messages.iter().all(|message| { + !message.message_id.as_deref().is_some_and(|message_id| { + message_id.starts_with(AGENT_RUNTIME_PUBLIC_STATUS_MESSAGE_ID_PREFIX) + }) + })); + + fs::remove_dir_all(root).ok(); +} + #[tokio::test] async fn background_task_recovers_when_assistant_message_cannot_persist() { let root = unique_project_path(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/sessions.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/sessions.rs index 2f73eaf39..27936aee5 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/sessions.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/sessions.rs @@ -2422,6 +2422,30 @@ fn local_conversation_prompt_context_scopes_agent_messages() { }, ) .expect("append other agent conversation"); + append_local_conversation_message_for_session_idempotent_at( + &root, + None, + None, + LocalConversationMessage { + role: "assistant".to_string(), + content: "任务已接收,项目总控 Agent 正在启动处理。".to_string(), + agent_id: None, + }, + "runtime-public-status-project-accepted", + ) + .expect("append Runtime-owned project status"); + append_local_conversation_message_for_session_idempotent_at( + &root, + Some("art-asset-plan"), + None, + LocalConversationMessage { + role: "assistant".to_string(), + content: "专业 Agent 执行失败,请稍后重试".to_string(), + agent_id: None, + }, + "runtime-public-status-agent-failed", + ) + .expect("append Runtime-owned Agent status"); let project_context = render_local_conversation_prompt_context(&root, None).expect("project context"); @@ -2430,6 +2454,7 @@ fn local_conversation_prompt_context_scopes_agent_messages() { assert!(project_context.contains("[project / user] 希望主角用月光厨房做弹幕躲避")); assert!(!project_context.contains("[art-asset-plan / assistant]")); assert!(!project_context.contains("美术建议:霓虹锅铲和月亮灶台")); + assert!(!project_context.contains("任务已接收")); let art_context = render_local_conversation_prompt_context(&root, Some("art-asset-plan")) .expect("art agent context"); @@ -2439,6 +2464,8 @@ fn local_conversation_prompt_context_scopes_agent_messages() { assert!(art_context.contains("[redacted sensitive context]")); assert!(!art_context.contains("secret-token")); assert!(!art_context.contains("策划建议:只保留三种输入")); + assert!(!art_context.contains("任务已接收")); + assert!(!art_context.contains("专业 Agent 执行失败")); fs::remove_dir_all(root).ok(); } diff --git a/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts b/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts index 0ba7e8648..4307d42cd 100644 --- a/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts +++ b/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts @@ -21,6 +21,28 @@ import type { TauriInvoke, } from '../../app/types'; +const AGENT_RUNTIME_PUBLIC_STATUS_MESSAGE_ID_PREFIX = 'runtime-public-status-'; +const AGENT_RUNTIME_TASK_MESSAGE_ID_PREFIX = 'runtime-task-'; +const AGENT_RUNTIME_MESSAGE_CORRELATION_PATTERN = /^[0-9a-f]{32}$/; + +function agentRuntimeMessageCorrelationId(messageId: string | null | undefined) { + const normalized = messageId?.trim() ?? ''; + const prefix = normalized.startsWith( + AGENT_RUNTIME_PUBLIC_STATUS_MESSAGE_ID_PREFIX, + ) + ? AGENT_RUNTIME_PUBLIC_STATUS_MESSAGE_ID_PREFIX + : normalized.startsWith(AGENT_RUNTIME_TASK_MESSAGE_ID_PREFIX) + ? AGENT_RUNTIME_TASK_MESSAGE_ID_PREFIX + : null; + if (!prefix) { + return null; + } + const correlationId = normalized.slice(prefix.length).split('-', 1)[0] ?? ''; + return AGENT_RUNTIME_MESSAGE_CORRELATION_PATTERN.test(correlationId) + ? correlationId + : null; +} + export function createLocalConversationDraftMessage( content: string, updatedAt = Date.now(), @@ -1221,17 +1243,56 @@ export function mergeProjectSupervisorConversation( projectRecords: LocalConversationMessageRecord[], supervisorRecords: LocalConversationMessageRecord[], ): ChatMessage[] { + const supervisorRecordIndexByCorrelation = new Map(); + supervisorRecords.forEach((record, index) => { + if (record.role !== 'user') { + return; + } + const correlationId = agentRuntimeMessageCorrelationId(record.messageId); + if (correlationId && !supervisorRecordIndexByCorrelation.has(correlationId)) { + supervisorRecordIndexByCorrelation.set(correlationId, index); + } + }); const records = [ - ...projectRecords.map((record, index) => ({ - record, - runtimeOwned: false, - stableIndex: index, - })), - ...supervisorRecords.map((record, index) => ({ - record, - runtimeOwned: true, - stableIndex: projectRecords.length + index, - })), + ...projectRecords.map((record, index) => { + const runtimeOwned = Boolean( + record.messageId + ?.trim() + .startsWith(AGENT_RUNTIME_PUBLIC_STATUS_MESSAGE_ID_PREFIX), + ); + const accepted = + runtimeOwned && + record.content === '任务已接收,项目总控 Agent 正在启动处理。'; + const correlationId = agentRuntimeMessageCorrelationId(record.messageId); + const supervisorRecordIndex = correlationId + ? supervisorRecordIndexByCorrelation.get(correlationId) + : undefined; + const runtimeRecordIndex = + supervisorRecordIndex ?? supervisorRecords.length + index; + return { + record, + runtimeOwned, + sameSecondLane: runtimeOwned ? 1 : 0, + sameSecondOrder: runtimeOwned + ? runtimeRecordIndex * 4 + (accepted ? 1 : 2) + : index, + stableIndex: index, + }; + }), + ...supervisorRecords.map((record, index) => { + const userMessage = record.role === 'user'; + return { + record, + runtimeOwned: true, + sameSecondLane: 1, + // Conversation timestamps have second precision. Runtime task and + // public status IDs carry the same opaque run correlation digest, so + // status ordering follows its actual task record instead of guessing + // from independent user/accepted/terminal category counters. + sameSecondOrder: index * 4 + (userMessage ? 0 : 3), + stableIndex: projectRecords.length + index, + }; + }), ] .filter( ({ record }) => record.role === 'user' || record.role === 'assistant', @@ -1239,6 +1300,8 @@ export function mergeProjectSupervisorConversation( .sort( (left, right) => left.record.updatedAt - right.record.updatedAt || + left.sameSecondLane - right.sameSecondLane || + left.sameSecondOrder - right.sameSecondOrder || left.stableIndex - right.stableIndex, ); const seen = new Set(); diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/SupervisorChatOnlyView.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/SupervisorChatOnlyView.tsx index 3307c9cfb..0a53d307b 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/SupervisorChatOnlyView.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/SupervisorChatOnlyView.tsx @@ -12,6 +12,7 @@ import type { import { useEffect, useMemo, useState } from 'react'; import type { GameCreationAppManifest } from '../../../../../packages/shared/src/contracts/gameCreationApp'; +import { PROJECT_SUPERVISOR_AGENT_ID } from '../../app/constants'; import type { AgentRuntimeEventRecord, AgentRuntimeResponseStream, @@ -54,6 +55,7 @@ type RuntimeControlProps = ComponentProps< export type GameChatRuntimeEvent = { key: string; agentLabel: string; + rootSupervisorRuntime: boolean; event: AgentRuntimeEventRecord; }; @@ -76,6 +78,14 @@ const GAME_CHAT_INTERNAL_RUNTIME_EVENT_TYPES = new Set([ 'agent.runtime.tool.response', 'agent.runtime.tool.result', ]); +const GAME_CHAT_ROOT_RUNTIME_OWNED_EVENT_TYPES = new Set([ + // Runtime-owned accepted status is persisted once by the backend. Keeping + // turn.started in the event transcript would show a second synthetic start + // message and make a single accepted task look like two user-visible turns. + 'turn.started', + 'turn.failed', + 'turn.budget_exhausted', +]); const GAME_CHAT_RUNTIME_CLOCK_INTERVAL_MS = 10_000; const GAME_CHAT_RUNTIME_STALL_THRESHOLD_MS = 5 * 60 * 1000; const GAME_CHAT_EARLIEST_RUNTIME_TIMESTAMP_MS = Date.UTC(2020, 0, 1); @@ -674,6 +684,10 @@ function collectGameChatRuntimeEventsInternal( deduplicated.set(key, { key, agentLabel: source.label, + rootSupervisorRuntime: + source.runtime.agentId === PROJECT_SUPERVISOR_AGENT_ID && + source.runtime.parentAgentId == null && + source.runtime.parentRunId == null, event, }); } @@ -705,7 +719,9 @@ function gameChatRuntimeEventMessageText(item: GameChatRuntimeEvent) { if ( !eventId || !publicText || - GAME_CHAT_INTERNAL_RUNTIME_EVENT_TYPES.has(eventType) + GAME_CHAT_INTERNAL_RUNTIME_EVENT_TYPES.has(eventType) || + (item.rootSupervisorRuntime && + GAME_CHAT_ROOT_RUNTIME_OWNED_EVENT_TYPES.has(eventType)) ) { return null; } diff --git a/apps/ai-game-creator-shell/tests/agentRuntimeModel.test.ts b/apps/ai-game-creator-shell/tests/agentRuntimeModel.test.ts index 367cca150..41a1fd167 100644 --- a/apps/ai-game-creator-shell/tests/agentRuntimeModel.test.ts +++ b/apps/ai-game-creator-shell/tests/agentRuntimeModel.test.ts @@ -5,10 +5,12 @@ import type { AgentRuntimeResult, AgentRuntimeState, ChatMessage, + LocalConversationMessageRecord, } from '../src/app/types'; import { formatAgentRuntimeEvent, mergeGameChatRuntimeResponseMessagesIntoHistory, + mergeProjectSupervisorConversation, MUD_POINT_INSUFFICIENT_INTERRUPTION_MESSAGE, projectRuntimeVisibleCurrentWork, projectRuntimeVisibleError, @@ -18,6 +20,184 @@ import { submitProjectSupervisorRuntimeTask, } from '../src/features/agent-runtime/model'; +describe('Runtime-owned public statuses', () => { + test('keeps backend status messages visible without treating them as client-authored conversation', () => { + const projectRecords: LocalConversationMessageRecord[] = [ + { + schemaVersion: 'game-creator-conversation.v1', + role: 'assistant', + content: '任务已接收,项目总控 Agent 正在启动处理。', + agentId: null, + messageId: + 'runtime-public-status-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-accepted', + updatedAt: 2, + }, + { + schemaVersion: 'game-creator-conversation.v1', + role: 'assistant', + content: '普通项目消息', + agentId: null, + messageId: 'project-message-1', + updatedAt: 3, + }, + ]; + const supervisorRecords: LocalConversationMessageRecord[] = [ + { + schemaVersion: 'game-creator-conversation.v1', + role: 'user', + content: '请继续完成当前游戏', + agentId: 'project-supervisor', + messageId: 'runtime-task-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + updatedAt: 2, + }, + ]; + + const messages = mergeProjectSupervisorConversation( + projectRecords, + supervisorRecords, + ); + + expect(messages).toEqual([ + expect.objectContaining({ + text: '请继续完成当前游戏', + runtimeOwned: true, + }), + expect.objectContaining({ + text: '任务已接收,项目总控 Agent 正在启动处理。', + runtimeOwned: true, + }), + expect.objectContaining({ + text: '普通项目消息', + runtimeOwned: false, + }), + ]); + }); + + test('interleaves rapid same-second tasks with their accepted and terminal statuses', () => { + const projectRecords: LocalConversationMessageRecord[] = [ + { + schemaVersion: 'game-creator-conversation.v1', + role: 'assistant', + content: '任务已接收,项目总控 Agent 正在启动处理。', + agentId: null, + messageId: + 'runtime-public-status-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-accepted', + updatedAt: 9, + }, + { + schemaVersion: 'game-creator-conversation.v1', + role: 'assistant', + content: '项目总控 Agent 执行失败,请稍后重试', + agentId: null, + messageId: + 'runtime-public-status-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-failed', + updatedAt: 9, + }, + { + schemaVersion: 'game-creator-conversation.v1', + role: 'assistant', + content: '任务已接收,项目总控 Agent 正在启动处理。', + agentId: null, + messageId: + 'runtime-public-status-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-accepted', + updatedAt: 9, + }, + { + schemaVersion: 'game-creator-conversation.v1', + role: 'assistant', + content: '项目总控 Agent 已达到执行预算,请缩小任务范围后重试', + agentId: null, + messageId: + 'runtime-public-status-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-budget', + updatedAt: 9, + }, + ]; + const supervisorRecords: LocalConversationMessageRecord[] = [ + { + schemaVersion: 'game-creator-conversation.v1', + role: 'user', + content: '第一条任务', + agentId: 'project-supervisor', + messageId: 'runtime-task-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + updatedAt: 9, + }, + { + schemaVersion: 'game-creator-conversation.v1', + role: 'user', + content: '第二条任务', + agentId: 'project-supervisor', + messageId: 'runtime-task-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + updatedAt: 9, + }, + ]; + + expect( + mergeProjectSupervisorConversation(projectRecords, supervisorRecords).map( + (message) => message.text, + ), + ).toEqual([ + '第一条任务', + '任务已接收,项目总控 Agent 正在启动处理。', + '项目总控 Agent 执行失败,请稍后重试', + '第二条任务', + '任务已接收,项目总控 Agent 正在启动处理。', + '项目总控 Agent 已达到执行预算,请缩小任务范围后重试', + ]); + }); + + test('keeps an older sparse terminal before a new same-second task', () => { + const projectRecords: LocalConversationMessageRecord[] = [ + { + schemaVersion: 'game-creator-conversation.v1', + role: 'assistant', + content: '项目总控 Agent 执行失败,请稍后重试', + agentId: null, + messageId: + 'runtime-public-status-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-failed', + updatedAt: 9, + }, + { + schemaVersion: 'game-creator-conversation.v1', + role: 'assistant', + content: '任务已接收,项目总控 Agent 正在启动处理。', + agentId: null, + messageId: + 'runtime-public-status-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-accepted', + updatedAt: 9, + }, + ]; + const supervisorRecords: LocalConversationMessageRecord[] = [ + { + schemaVersion: 'game-creator-conversation.v1', + role: 'user', + content: '旧任务', + agentId: 'project-supervisor', + messageId: 'runtime-task-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + updatedAt: 8, + }, + { + schemaVersion: 'game-creator-conversation.v1', + role: 'user', + content: '新任务', + agentId: 'project-supervisor', + messageId: 'runtime-task-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + updatedAt: 9, + }, + ]; + + expect( + mergeProjectSupervisorConversation(projectRecords, supervisorRecords).map( + (message) => message.text, + ), + ).toEqual([ + '旧任务', + '项目总控 Agent 执行失败,请稍后重试', + '新任务', + '任务已接收,项目总控 Agent 正在启动处理。', + ]); + }); +}); + describe('Game Chat stream identity and source', () => { test('response stream identity binds run, slot, and revision', () => { const base: AgentRuntimeResponseStream = { diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts index f0ab823d9..7919a671d 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts @@ -3689,6 +3689,20 @@ export function registerProjectSupervisorSurfaceTests() { const runtime = gameChatRuntimeState({ runId, recentEvents: [ + gameChatRuntimeEvent({ + runId, + eventType: 'turn.started', + summary: 'Synthetic start event must stay out of chat', + updatedAt: 30, + }), + gameChatRuntimeEvent({ + runId, + eventType: 'turn.failed', + status: 'failed', + phase: 'failed', + summary: 'Root terminal event is covered by durable public status', + updatedAt: 35, + }), gameChatRuntimeEvent({ runId, eventType: 'turn.progress', @@ -3737,11 +3751,35 @@ export function registerProjectSupervisorSurfaceTests() { }), ], }); + const designRuntime = gameChatRuntimeState({ + agentId: 'design-director', + taskId: 'design-director', + sessionId: 'design-director-session', + runId: 'design-director-child-run', + source: 'agent-delegate', + parentAgentId: 'project-supervisor', + parentRunId: runId, + recentEvents: [ + gameChatRuntimeEvent({ + agentId: 'design-director', + runId: 'design-director-child-run', + eventType: 'turn.started', + summary: 'Design Agent started and must stay visible', + updatedAt: 32, + }), + ], + updatedAt: 32, + }); - const messages = gameChatRuntimeEventMessages(runtime, {}); + const messages = gameChatRuntimeEventMessages(runtime, { + 'design-director': designRuntime, + }); - expect(messages.map((message) => message.updatedAt)).toEqual([40, 60, 80]); + expect(messages.map((message) => message.updatedAt)).toEqual([ + 32, 40, 60, 80, + ]); expect(messages.map((message) => message.text)).toEqual([ + expect.stringContaining('Design Agent started and must stay visible'), expect.stringContaining('Generated prototype progress'), expect.stringContaining('code agent repair collision'), expect.stringContaining('preview.validate:ok'), @@ -3760,6 +3798,47 @@ export function registerProjectSupervisorSurfaceTests() { expect(messages.map((message) => message.text).join('\n')).not.toContain( 'agent.runtime.tool.request', ); + + for (const continuation of [ + { kind: 'receipt', source: 'agent-delegate-receipt' }, + { kind: 'isolated join', source: 'agent-isolated-join' }, + ]) { + const continuationRunId = `supervisor-${continuation.kind.replaceAll(' ', '-')}-continuation`; + const supervisorContinuation = gameChatRuntimeState({ + runId: continuationRunId, + source: continuation.source, + parentAgentId: null, + parentRunId: runId, + recentEvents: [ + gameChatRuntimeEvent({ + runId: continuationRunId, + eventType: 'turn.started', + summary: `Supervisor ${continuation.kind} continuation started`, + updatedAt: 100, + }), + gameChatRuntimeEvent({ + runId: continuationRunId, + eventType: 'turn.failed', + status: 'failed', + phase: 'failed', + summary: `Supervisor ${continuation.kind} continuation failed`, + updatedAt: 110, + }), + ], + }); + expect( + gameChatRuntimeEventMessages(supervisorContinuation, {}).map( + (message) => message.text, + ), + ).toEqual([ + expect.stringContaining( + `Supervisor ${continuation.kind} continuation started`, + ), + expect.stringContaining( + `Supervisor ${continuation.kind} continuation failed`, + ), + ]); + } }); it('turns only professional final-reply streams into labeled game-chat messages', () => { diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 1594847f2..ef4dfac4d 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -79,6 +79,17 @@ --- +## 2026-08-06 AGC 根长任务启动与终态失败采用 Runtime 公开消息硬门 + +- 背景:AGC 已有模型 final-reply、Runtime `eventId/publicText` 和进度卡,但根任务“已入队”没有后端持久公开回执;失败消息又散落在 main loop 多个 `let _ = append conversation` 分支。Provider 或 Runtime 在首条公开事件前失败时可能零消息,同一次失败也可能被 `turn.failed` 和 conversation 重复播报。 +- 决策:Supervisor / 专业 Agent 继续负责业务语义,Runtime 只增加两项不依赖模型的最低可观测性。用户直接投递的 Project Supervisor 根后台任务先落为不可执行的 `preparing / public-status-pending`,再以 run 绑定的 `runtime-public-status-*` message ID 幂等写入启动确认,成功后才转为 `pending / queued`;恢复预检只验证业务状态且不改写 task/conversation,仅真实 resume 持有 Agent 锁后才能把已验证的 accepted 任务持久提升为 `pending / queued`,写入失败则把 task 落为 `failed / public-status-write-failed`。根 Supervisor 通过正式失败 / 预算耗尽收束或 game-chat 绝对硬期限进入 reconciliation 时,在其它终态投影前先写一条脱敏失败消息;专业 Agent 命中同一全局硬期限时,必须通过权威 Run Profile 与根 task 在项目 conversation 幂等写根终态,同时只在精确匹配 agent/run/session、两个 parent 和 binding 的专业 Agent Session 保留 child 终态。自称根 agent/run 但 session 或两个 parent 不符的 Runtime 必须失败关闭,不得生成第二条项目终态。该前缀的 Runtime 公开状态只供 UI 展示,不合入任何 Agent prompt。前端把它视为 Runtime-owned,同秒时排在触发它的 Supervisor 用户消息之后,并仅过滤重复的根 `turn.started / turn.failed / turn.budget_exhausted`,专业 Agent 启动事件仍可见。 +- 恢复补充(以本条为准):`preparing` 只要同 run 的用户消息或 accepted 任一已完整持久即属可恢复;preflight 不改写业务文件,resume 才在 Agent 锁内补写 accepted 并入队。用户消息或 accepted conversation 已存在而审计补写失败不得把任务改判为启动失败。根终态首次公开写入遇到瞬时失败时,完成终态投影后必须用同 message ID 再幂等写一次。带 parent 的 Supervisor receipt / isolated-join 续跑只保留一份 Runtime 公开终态,不再追加 Session 重复消息;`runtime-task-*` 与 `runtime-public-status-*` 共享同 run 的不透明关联摘要,多个同秒任务在 UI 中按实际 run 关联的 `user -> accepted -> terminal` 交错排序。 +- 边界:不改变 Supervisor 条件 Graph、`agent.route_manifest`、美术路由、Provider tool-plan / final-reply 协议、response stream 或最终 assistant 唯一性;不把 tool 参数、原始错误、路径、fingerprint、凭据和专业 Agent 私有失败诊断公开。Runtime 也不根据工具事件推断业务进展,后续若引入模型 `commentary/final` phase 应沿独立消息协议扩展。 +- 验证方式:覆盖 `preparing -> accepted -> pending` 顺序与崩溃恢复、启动确认幂等、启动确认无法持久化时任务零执行、公开状态不进入实际 Agent prompt、状态文件写入本身失败时仍先产生公开失败、根终态事件不重复进聊天、专业 Agent 启动事件仍可见、普通项目消息不被误标 Runtime-owned,并运行 Runtime 定向 Rust、AppSurface、模型测试、typecheck、编码和 diff 门禁。 +- 关联文档:`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`、`docs/project-memory/shared-memory/development-workflow.md`、`docs/project-memory/shared-memory/pitfalls.md`。 + +--- + ## 2026-08-05 game-chat 当前 ready child 对 manifest Pending 漂移保持活性 > 本条只保留 ready child 的 Pending 漂移处理;历史试玩类型继承与旧三 Director 首波验收已由 `2026-08-06 game-chat 固定规则只提供上下文,Supervisor 决定条件 Graph` 取代。 diff --git a/docs/project-memory/shared-memory/development-workflow.md b/docs/project-memory/shared-memory/development-workflow.md index e99430bfb..2514182e5 100644 --- a/docs/project-memory/shared-memory/development-workflow.md +++ b/docs/project-memory/shared-memory/development-workflow.md @@ -83,6 +83,16 @@ cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml real_ game-chat 迭代还必须确认以下行为:Supervisor ready 输出、本轮实际启动的 `design-director / art-director / art-asset-plan / code-director / code-prototype / preview-readiness / preview-playtest` 专业 Agent 各自产生 durable `final-reply`,条件路由跳过的节点只要求 manifest 正确投影为 completed,不伪造 Agent 回复;Rust 明确生成 `eventId + publicText` 的每条公开 Runtime 输出都作为独立 assistant 消息逐条固化在项目聊天。消息通过顶层 `messageId` 幂等追加,事件 / 轮询 / hydration 重放不重复。前端禁止从原始 `summary / detail`、tool plan、Provider / Runner 元数据、命令输出或路径自行拼接持久消息;UI 把一个父 Run 统一显示为“本轮生成进度”,最新状态也不得暴露内部“第 N 轮”,完整 GUI / CLI 任务图可显示 `x/14`,首版快车道显示 `x/7`,终态不保留运行中进度卡;可信 `project-supervisor-game-chat` 一轮完成 `preview-playtest` 后直接收束,不请求下一次 Provider tool-plan;所有调度入口均不得暴露或启动 `publish-strategy` / `publish-package`。固定关键词与资产探测只进入 Supervisor 的 advisory context;没有持久 Supervisor 路由时不得启动任何 child。只有程序侧审计判定存在真实缺口或 Supervisor 明确选择整体重做时才要求可用 External Editor API,并按 `art-spec.png -> icon-spritesheet -> art-spritesheet.png + iconImageSrcs 本地切片 -> code-prototype Canvas 使用四类切片` 补齐;`use-existing-art` 不得重复生成或扣费,但仍须通过相同正式资产和代码可见使用门禁。 +Project Supervisor 根后台任务还必须验证公开消息硬门:任务先落为不可执行的 `preparing / public-status-pending`,项目 conversation 中存在且仅存在一条同 run 的 `runtime-public-status-* / accepted` 后才能转为 `pending / queued`;恢复 preflight 只验证并临时分类可恢复状态,不改写 task/conversation,真实 resume 持有 Agent 锁后才可把 accepted preparing 持久提升为 `pending / queued`;否则不得执行。若该消息无法持久化,task 必须进入 `failed / public-status-write-failed` 且 Provider、工具与 child 调度均为零。这类 Runtime 公开状态不得合入 Agent prompt。根 Supervisor 通过正式失败 / 预算耗尽收束或 game-chat 绝对硬期限进入 reconciliation 时,即使后续 task/event/state 写入失败,也必须已经存在一条脱敏终态失败消息;专业 Agent 命中全局硬期限时,项目 conversation 仍必须幂等保留根终态,但 child 终态只能写入与权威 task 的 agent/run/session、两个 parent 和 binding 全部一致的私有 Session;自称根 agent/run 却 session 或 parent 不符时失败关闭。只有根 Supervisor 的 `turn.started / turn.failed / turn.budget_exhausted` 不得再形成第二条聊天消息,专业 Agent 的公开启动事件仍应显示。非 Supervisor 专业 Agent 的失败状态留在对应 Agent Session,不得把私有诊断送进项目 conversation。定向复验至少运行: + +提交前还要覆盖启动崩溃窗口:仅 `preparing` 时 preflight/resume 均不执行;用户消息已持久但 accepted 缺失时,preflight 只报可恢复且 task/conversation 不变,resume 才补写 accepted 并转为 `pending / queued`。还要验证用户消息或 accepted 已落盘但审计失败均继续入队、根终态首次公开写入瞬时失败会用同 ID 重试、receipt / isolated-join 失败只有一条公开结果,以及同秒连续任务通过共享的 run 关联摘要按 `user -> accepted -> terminal` 逐任务展示。 + +```bash +cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml public_start_status -- --test-threads=1 +cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml structured_plan_state_write_failure_stops_before_context_and_audit -- --test-threads=1 +npm run test -- apps/ai-game-creator-shell/tests/agentRuntimeModel.test.ts apps/ai-game-creator-shell/tests/appSurface.test.ts +``` + game-chat 条件快车道仍采用七任务口径,但执行顺序由持久合同控制。父 Run 与全部 child Run 共用 4200 秒软预算和 4500 秒累计硬上限;Supervisor 首轮先调用 `agent.route_manifest`,决策前零 child。`audit-existing-first` 的首波仅为 `design-director + code-director`,已登记但无效的旧派生视觉不得阻断这两项审计启动;code-director 必须先 `asset.list`,读取 Supervisor 已持久化的 authoritative 决策,再提交正式资产覆盖合同。覆盖完整时跳过图片生成,存在缺口时只开放缺口对应 owner,`regenerate-art` 才强制重新开放两个美术 owner;已登记但校验失败的固定资产只允许当前路由绑定的 canonical owner 原位替换。新生成仍按 `art-spec.png -> art-spritesheet.png + 独立切片` 推进,最后才允许 code-prototype 写入或局部修复入口。软预算后只允许使用已登记图集、当前 resourceId 对应切片清单的确定性本地 fallback、`game.static_smoke` 和 `preview.validate`;不得退回普通生图、猜测 atlas 网格或纯代码核心画面。完成门要求活动 Canvas 分别绘制 player、blocks-and-targets、obstacles-and-scene、feedback-effects 四类不同切片;整图 ``、CSS background、完整图集直绘、单个猜测裁切和路径诱饵均失败。对应定向测试至少包括:决策前零 child、旧无效视觉不阻断首波、首波无美术 child、code-director 收到真实持久策略、完整覆盖零生成、仅缺图集只运行 `art-asset-plan`、无效已登记资产由 owner 原位替换、art spec 缺失导致引用合同失效时同时补齐两个槽位、显式重做原位替换两个正式资产、旧 root/fingerprint/缺口或重复路由失败关闭,以及补齐后恢复 code-prototype。 失败续跑还必须覆盖同 Session 同 source 继承、跨 Session / 跨 source 不继承、首次与连续 successor 的 effective task / contract / scheduler 一致性,以及中英文纯继续短语使用同一识别函数。非占位入口的新 `code-prototype` 必须先产生本人 mutation 再 smoke;连续只读 smoke 不得收束。占位 fallback 只允许显式支持的真实玩法模板,俄罗斯方块必须验证棋盘、下落、旋转、锁定和消行语义,未知玩法必须失败关闭。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index a3fc8979f..c82d0f8c8 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -14,6 +14,15 @@ - 关联:相关文件、文档、提交或 Issue ``` +## Runtime 状态写失败不能发生在公开失败消息之前 + +- 现象:用户提交长任务后只看到运行失败或任务直接消失,聊天里一条有用消息都没有;另一些失败又同时出现 Runtime event 和 conversation 两条近似提示。 +- 原因:启动确认依赖实际 `turn.started`,任务只入队或 Runner 在 start transition 前失败时没有公开回执;终态失败先写 task/event/state,最后才由各 main-loop 分支尽力追加 assistant。坏掉的若正是状态文件,流程会在公开消息前返回;散落的 `let _` 又无法提供幂等身份。 +- 处理:用户直接投递的 Project Supervisor 根任务先落为不可执行的 `preparing / public-status-pending`,持久化同 run 的 `runtime-public-status-* / accepted` 后才转为 `pending / queued`;恢复 preflight 只验证业务状态,不改写 task/conversation,仅 resume 持有 Agent 锁后才可持久提升。根 Supervisor 通过正式失败 / 预算耗尽收束或 game-chat 绝对硬期限进入 reconciliation 时,先写相同协议的终态消息,再处理 Runtime 其它投影。专业 Agent 命中全局硬期限时,应由权威根 task 在项目 conversation 中幂等写根终态,child 仅在身份完整匹配时写私有 Session;根 agent/run 的 session 或两个 parent 冲突时必须失败关闭。消息正文只能来自封闭脱敏映射,prompt 构建必须按稳定前缀排除;前端按前缀标记 Runtime-owned,只过滤根 Supervisor 的 `turn.started / turn.failed / turn.budget_exhausted`,不吞掉专业 Agent 启动进度。模型仍负责业务 commentary/final,Runtime 的两条硬门不扩展成业务判断器。 +- 恢复补充:不能把“accepted 还没写完”等同于“用户从未投递”。用户消息已持久时,真实 resume 必须补写 accepted 后才入队;用户消息或 accepted conversation 已存在时,后续审计失败不得留下“正在启动”但永不执行的假状态,但同 message ID 的 role/content 冲突必须把 task 明确收束为 `conversation-write-failed`。根终态首次公开写入的瞬时失败必须在终态投影后用相同 message ID 重试。带 parent 的 Supervisor continuation 不得同时产生 Session 终态和 Runtime 事件两条公开消息;秒级时间戳下必须以 task/status message ID 共享的 run 关联摘要排序,不能用不同消息类别的计数猜测顺序。 +- 验证:任务 journal 必须显示 `preparing -> pending`,仅有 accepted 时恢复才可提升;破坏项目 conversation 时断言任务为 `public-status-write-failed` 且无可运行 pending;破坏 Runtime state 路径时断言公开失败已经存在;重复写同一 run/status 只有一条 message ID;渲染实际 prompt 断言不包含 Runtime 公开状态;AppSurface 证明根启动/失败事件不重复,专业 Agent 启动仍可见。 +- 关联:`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_state.rs`、`agent/runtime_driver/task_start.rs`、`src/features/agent-runtime/model.ts`、`src/features/project-workspace/SupervisorChatOnlyView.tsx`。 + ## manifest 被旧快照写回 Pending 时不能让父 Run 抛下真实运行中的 child - 现象:`code-prototype` 已有确定性 child run、running journal 和工具事件,父 Supervisor 却在几秒后以 fixed graph stalled 失败;child 随后完成代码与静态检查,但 completion gate 持续报告 `task=code-prototype status=pending`,最后 `loop-budget-exhausted`。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 70b78a49d..e3c7eb6b5 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -32,6 +32,8 @@ - Windows Runner 私有文件初始化:父 AppData 已归当前 `TokenUser` 后,新建 `agent-runner.lock`、endpoint 临时文件、project-owner 诊断临时文件与 real-E2E 私有文件的 owner 仍可能采用 token 默认 owner `Administrators`。固定 stale lock 只有在父目录已验证为当前用户 protected 私有 DACL、Windows 不共享独占句柄已取得、且句柄确认普通文件、非 reparse point、链接数为一时才允许修复;其它三类文件只允许在本进程 `create_new` 成功且仍持有同一独占句柄时初始化 `TokenUser` owner / DACL,再写入、原子安装并严格复核,初始化失败必须清理刚创建的文件。既有 durable endpoint / diagnostic 读取不得自动接管;活锁不得截断,只有 sharing / lock violation `32/33` 表示占用,access denied 等其它错误立即返回。父进程观察到 Runner 子进程退出后立即返回错误,不等待完整 30 秒 deadline。 - 启动诊断:独立 release 的 `startup.log` 和 `agent-runner.log` 只记录有界、脱敏的阶段与 stdout / stderr 摘要,凭据、AppData 路径和其它绝对路径不得原样落盘;单文件达到 256 KiB 后只轮转保留一份 `.previous.log`。`startup.log` 优先写独立 AppData,目录不可写时回退到系统 TEMP 下的 `Genarrative-Game-Chat-Diagnostics`;Tauri context、窗口 URL、AppData、Runner 或 `.setup()` / `.build()` 初始化失败时,Windows 必须显示可见错误对话框并给出诊断日志位置,不能只在无控制台 release 中静默退出。 - 对话与事件:窗口固定使用 `project-supervisor + autonomous-game-build`,继续复用 active Session、External Runner、持久 conversation、流式回复、same-run steer、工具确认与用户追问。以 `/` 开头的输入必须继续走现有内置命令解析,例如 `/preview` 只能生成 `preview.start` 确认卡,不得作为自主构建任务投递给 Supervisor。界面聚合当前 Supervisor 父 run 及其直接委派专业 Agent 的最新原始事件,按时间倒序稳定去重并标注 Agent;默认显示 4 条,可展开至最新 20 条。原始 `summary / detail` 仍只作 Runtime 状态投影,不直接写入 conversation。需要进入聊天的事件必须由 Rust 同步生成唯一 `eventId` 与安全 `publicText`;前端只按这两个字段形成独立 assistant 消息,无 `eventId`、空 `publicText`、legacy 事件和内部 tool / Provider / Runner 协议一律忽略。 +- 公开消息硬门:模型仍负责 Supervisor / 专业 Agent 回复的业务语义,Runtime 不根据 tool 或 Provider 事件自行补写业务结论;但用户直接投递的 Project Supervisor 根后台任务必须先落为不可执行的 `preparing / public-status-pending`,再以 `runtime-public-status-*` 稳定 message ID 把“任务已接收,正在启动处理”写入项目 conversation,成功后才转为 `pending / queued`;恢复预检只读,只能在验证到同 run accepted 消息后把该任务临时分类为可恢复,真实 resume 持有 Agent 锁后才可持久提升为 `pending / queued`;写入失败则落为 `failed / public-status-write-failed`,不得继续执行。这些 Runtime 公开状态只供 UI 展示,prompt 构建器必须按稳定前缀排除。根 Supervisor 通过正式失败 / 预算耗尽收束或 game-chat 绝对硬期限进入 reconciliation 时,必须在 task、event、state 等其它终态投影之前先幂等写入一条脱敏、用户可理解的失败消息;专业 Agent 命中该全局硬期限时,也必须通过权威 Run Profile 和根 task 将同一根终态写入项目 conversation,同时保留 child 私有 Session 状态;状态文件本身写坏也不能导致零公开结果。当前 Runtime 自称根 agent/run 时,其 session 和两个 parent 字段必须与权威根 task 一致;任一身份冲突必须失败关闭,不得以另一 session 派生第二条项目终态。前端把该前缀识别为 Runtime-owned,同秒时排在触发它的 Supervisor 用户消息之后,不二次持久化;仅根 Supervisor 的 `turn.started / turn.failed / turn.budget_exhausted` 只保留在 Runtime 详情和进度投影中,不能再生成第二条聊天消息,专业 Agent 的公开启动事件仍可见。该硬门不改变 final-reply 的唯一性;非 Supervisor 专业 Agent 的失败消息继续留在对应 Agent Session,不把私有诊断写进项目 conversation。 +- 启动恢复和续跑边界:本条取代上一条中“只有 accepted 才可恢复”的窄口径。若进程在 Supervisor 用户消息已持久、accepted 未持久之间崩溃,只读 preflight 可以把该 `preparing` 识别为可恢复,但不改写 task/conversation;真实 resume 持有 Agent 锁后必须先幂等补写 accepted,再提升为 `pending / queued`。用户消息或 accepted conversation 已落盘而辅助审计失败时,以 conversation 为公开真相继续入队,不留下“已接收但永不执行”的任务;根终态首次公开写入的瞬时失败必须在终态投影后用相同 message ID 重试。receipt / isolated-join 等带 parent 的 Supervisor continuation 不再另写 Session 终态,只保留单一后端公开事件;`runtime-task-*` 与 `runtime-public-status-*` 共享同 run 的不透明关联摘要,秒级时间戳下多个连续任务必须按实际 run 对应的 `user -> accepted -> terminal` 顺序交错展示。 - Supervisor 进度播报:聊天消息流内保留且只保留一条当前 run 的 Runtime-owned 播报卡,由客户端从 manifest 任务图、Supervisor 结构化计划、`loopIteration`、当前动作、直接委派专业 Agent 及其持久事件确定性整理;显示当前轮次、任务 / 计划进度、活跃 Agent、最近试玩与静态检查、返工决定、代码修改和截图检查证据。同一 run 原位更新,切换 run 时替换,不调用额外模型、不追加持久 conversation,也不改变最终 assistant 回复的唯一性;任意详情必须有界且不展示绝对路径、Provider 元数据或内部指纹。 - ready-task 启动活性:`background_task.queued`、`autonomous_ready_task.scheduled`、Runner heartbeat 或执行锁已移交都不等于 child 已启动。实际持有执行权的 Runner 必须在释放项目写锁后同步写入 child 的 running task、`turn.started` 与 started journal,再把已启动 state 和 per-Agent 执行锁交给已确认开始轮询的独立 execution worker;同步启动或 worker 接管失败时,要在仍持有执行锁期间依次把 child 和 manifest Graph 节点明确落为 failed,再释放锁并让 parent 收到调度错误。`autonomous_ready_task.scheduled` 只作诊断审计,其写入失败不能阻断 durable child 启动;external client 只 wake Runner,不在客户端抢占执行。Supervisor 进度卡通过 durable `startedAt`(旧 Run 从完整 task journal 恢复,最新 task-record fallback 保持 0)显示真实持续时间,并以父 Run 与当前关联专业 Agent 的最大事件时间计算运行态活跃度:运行超过 5 分钟无新事件时显示“运行中 · 疑似停滞”和静默时长;等待用户、等待确认、Provider retry、视觉资产、进程会话、pausing 与 paused 不误报。父 Run terminal 后,持续时间冻结在父 Run 自身最后活动,不随 child 晚到收口事件增长。消息时间统一校验为 JavaScript 可表示的 Date;越界值显示“时间未知”且不写无效 `datetime`。实时回复只显示 response stream 自己的 `updatedAt`,缺失时同样显示“时间未知”,不能借用其它 Runtime 活动时间或随前端时钟漂移。该提示只提供可观测性,不改变 Runtime/manifest 正式状态。 - ready-task manifest 漂移:父 Supervisor 必须分别判断“能否调度新节点”和“是否存在必须等待的工作”。派生视觉需要父规划修复时不再调度新 child,但当前最新且活跃的根 Run 下,只要存在确定性 runId、scheduler source、正确父绑定且 durable journal 为 queued/running 的 ready child,父 Run 就保持 `waiting-for-manifest-tasks`,不能因旧 hydration 快照把 manifest running 覆盖成 pending 而提前 fixed-graph-stalled。game-chat child 可在相同严格身份下容忍 pending 漂移;正式产物、Canvas、revision、`game.static_smoke` 与 `preview.validate` 门禁不放宽。GUI/CLI、旧父 Run、终态、确认/用户输入/reconciliation、伪造绑定或非确定性 runId 全部失败关闭;更新根 Run 后旧 child 不得继续维持新 DAG 或投影完成。 @@ -58,6 +60,7 @@ ## 2026-07-31 game-chat 输出、单轮预览与平台美术资源 - 对话输出:game-chat 的 Supervisor `ready` response stream 继续以稳定身份显示;七任务目录中本轮实际启动的专业 Agent,其 `requestKind=final-reply` 且 `status=ready|committed` 的非空安全回复分别以 Agent、Session、run、request slot 和 response revision 形成 durable message ID,并带 Agent 标签逐条追加到项目聊天。条件路由跳过的美术节点只要求 manifest 正确投影为 completed,不伪造 Agent 回复。每条 Rust `eventId + publicText` 公开输出同样形成独立 durable 消息。所有这些消息通过 `append_local_conversation_message` 的顶层 `messageId` 幂等写入,事件、轮询、React StrictMode 和 hydration 重放不重复;tool-plan、半成品 stream、原始事件 detail、命令正文、绝对路径、Provider / Runner 元数据、哈希和凭据不得进入聊天。普通 `supervisor-chat` 保持原有 transient response 行为。 +- 对话输出中的 `eventId + publicText` 只指需要独立进入聊天的进度事件;`turn.started` 和根 Run 终态失败事件由上一条 `runtime-public-status-*` 硬门覆盖,不得同时转成事件消息。专业 Agent child 的失败消息继续留在其 Agent Session,根项目聊天只接收 Supervisor 终态失败、明确公开进度和安全 final-reply,避免一项失败被 Runtime event 与 conversation 各播报一次。 - 单轮收束:game-chat source 只生成至 `preview-playtest` 的 manifest seed task,试玩完成后父 Run 直接进入完成门,不再调度 `publish-strategy` / `publish-package`;`agent.schedule_ready` 必须按当前 Supervisor Run 的持久 source/profile 选择同一 source-aware scheduler,不能绕过该边界。`task.list` 对同一 root source 必须从任务行、readyTaskIds 和统计中排除两个发布节点,`agent.delegate` 也必须按 root binding 拒绝直接委派这两个节点,不能让 Provider 用“读取完整 DAG 后手工委派”恢复已裁掉的发布阶段。完成门满足且 collaboration、Provider batch、进程会话、视觉资源等非验证屏障全部清零后,Runtime 必须用确定性回复直接收束结构化计划并结束父 Run,不再请求下一次 Provider 工具计划。普通 GUI / CLI 仍执行完整发布 DAG。 - 轮次展示:`loopIteration` 只是同一父 Run 内的 Provider / 工具规划循环,用于委派、回执、返工和验收,不是用户发起的游戏生成轮次。game-chat 的进度卡、当前工作和“最新状态”事件统一显示“本轮”,整个页面不向用户显示“第 N 轮”;完整 GUI / CLI pre-publish 任务图仍可显示 `x/14`,但首版只按七项任务显示 `x/7`(详见 2026-08-03 小节),不得把两个发布节点计入任一分母。父 Run 终态后移除运行中进度卡,只保留终态阶段记录与预览。 - 平台美术资源:game-chat 保留正式视觉 DAG,但是否进入生成节点由 2026-08-06 的持久条件路由决定。现有正式资源覆盖完整时直接复用;存在真实缺口或 Supervisor 明确选择整体重做时,`art-director` 才通过平台 `images/generations(kind=spec)` 生成并登记 `assets/art-spec.png`,`art-asset-plan` 再以该规范图的稳定 resourceId 调用 `icon-spritesheets/generations` 生成真实透明 `assets/art-spritesheet.png`,并把响应中的 `iconImageSrcs` 下载为本地独立切片,写入 `assets/art-spritesheet-slices/manifest.json`。`code-prototype` 必须等待复用或补齐后的图集与切片清单,并在活动 Canvas 中分别绘制玩家、方块/目标、障碍/场景与反馈四类切片;规范图只作 reference,纯代码核心画面、猜测图集等分网格、整图 `` / CSS 背景、完整图集直绘或只出现路径均不得完成。