diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions.rs index a58c5ab6c..cb72cf053 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions.rs @@ -106,6 +106,7 @@ pub(crate) use project_gates::{ }; #[cfg(test)] pub(crate) use project_gates::{ + ensure_current_autonomous_ready_child_mutation_at_locked, supervisor_collaboration_policy_completion_blocker_for_test_at, supervisor_orchestrator_mutation_block_after_dispatch_for_test, }; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_audit.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_audit.rs index 4e02a26a2..102b6ff4c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_audit.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_audit.rs @@ -50,12 +50,17 @@ pub(crate) fn append_agent_runtime_tool_call_record( action: &AgentRuntimeToolAction, observation: &AgentRuntimeToolObservation, action_id: Option<&str>, + action_fingerprint: Option<&str>, ) { let record = AgentRuntimeToolCallRecord { action_id: action_id.map(ToString::to_string), tool: observation.tool.clone(), status: observation.status.clone(), - action_fingerprint: Some(agent_runtime_tool_action_fingerprint(action, task)), + action_fingerprint: Some( + action_fingerprint + .map(ToString::to_string) + .unwrap_or_else(|| agent_runtime_tool_action_fingerprint(action, task)), + ), input_summary: agent_runtime_tool_action_input_summary(root, action), reason: action .reason diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_execution.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_execution.rs index ddf2f9d82..a18c518f0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_execution.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_execution.rs @@ -132,7 +132,7 @@ pub(crate) async fn execute_game_creator_agent_runtime_tool_action_with_pending_ false, || observe_agent_runtime_memory(root, agent_id, &action.input), ), - "memory.write" => observe_agent_runtime_memory_write(root, agent_id, &action.input), + "memory.write" => observe_agent_runtime_memory_write(root, agent_id, run_id, &action.input), "conversation.read" => observe_agent_runtime_project_snapshot_with_lock( root, agent_id, @@ -285,8 +285,8 @@ pub(crate) async fn execute_game_creator_agent_runtime_tool_action_with_pending_ false, || observe_agent_runtime_task_list(root, agent_id, run_id), ), - "task.create" => observe_agent_runtime_task_create(root, agent_id, &action.input), - "task.update" => observe_agent_runtime_task_update(root, agent_id, &action.input), + "task.create" => observe_agent_runtime_task_create(root, agent_id, run_id, &action.input), + "task.update" => observe_agent_runtime_task_update(root, agent_id, run_id, &action.input), "command.exec" => { observe_agent_runtime_command_exec( root, @@ -386,7 +386,9 @@ pub(crate) async fn execute_game_creator_agent_runtime_tool_action_with_pending_ ) .await } - "blackboard.write" => observe_agent_runtime_blackboard_write(root, agent_id, &action.input), + "blackboard.write" => { + observe_agent_runtime_blackboard_write(root, agent_id, run_id, &action.input) + } "agent.message" => { observe_agent_runtime_agent_message(root, agent_id, run_id, &action.input) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_projection.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_projection.rs index 730acdc89..9410885e8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_projection.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_projection.rs @@ -38,6 +38,7 @@ pub(in crate::agent) fn persist_game_creator_agent_user_input_wait_at( &pending.action, &waiting_observation, Some(&pending.action_id), + Some(&pending.action_fingerprint), ); runtime.pending_tool_action = Some(pending.summary()); runtime.status = "waiting-for-user-input".to_string(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/autonomous_policy.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/autonomous_policy.rs index a03b17699..d2e4dfcd4 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/autonomous_policy.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/autonomous_policy.rs @@ -536,34 +536,13 @@ pub(in crate::agent) fn autonomous_manifest_dag_in_progress_at( root: &Path, ) -> Result { let manifest = read_manifest_for_project(root)?; - let source = read_game_creator_agent_runtime_at(root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID) - .ok() - .filter(|runtime| { - runtime.state.run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD - && agent_runtime_supervisor_source_is_trusted(&runtime.state.source) - }) - .map(|runtime| runtime.state.source) - .or_else(|| { - let path = game_creator_agent_runtime_task_path( - root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - ); - read_all_game_creator_agent_runtime_tasks(&path) - .ok() - .map(latest_game_creator_agent_runtime_tasks) - .and_then(|records| { - records.into_iter().rev().find_map(|record| { - (record.run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD - && record.parent_run_id.is_none() - && agent_runtime_supervisor_source_is_trusted(&record.source)) - .then_some(record.source) - }) - }) - }) - .ok_or_else(|| { - "无法解析当前自主构建根 Run 的可信 source,拒绝按 GUI 完整 DAG 回退".to_string() - })?; - let seed_task_ids = autonomous_manifest_seed_tasks_for_source(&source) + let current_root = current_autonomous_game_build_root_task_at(root)?.ok_or_else(|| { + "无法解析当前自主构建根 Run 的可信 source,拒绝按 GUI 完整 DAG 回退".to_string() + })?; + if !autonomous_game_build_root_task_is_active(¤t_root) { + return Ok(false); + } + let seed_task_ids = autonomous_manifest_seed_tasks_for_source(¤t_root.source) .into_iter() .map(|task| task.id) .collect::>(); @@ -572,19 +551,58 @@ pub(in crate::agent) fn autonomous_manifest_dag_in_progress_at( .iter() .filter(|task| seed_task_ids.contains(&task.id)) .collect::>(); - let started = seed_tasks - .iter() - .any(|task| task.status != GameCreationAppTaskStatus::Pending); let running = seed_tasks .iter() .any(|task| task.status == GameCreationAppTaskStatus::Running); - let completed = seed_tasks - .iter() - .all(|task| task.status == GameCreationAppTaskStatus::Completed); let failed = seed_tasks .iter() .any(|task| task.status == GameCreationAppTaskStatus::Failed); - Ok(started && running && !completed && !failed) + let active_child = autonomous_manifest_parent_has_active_ready_task_at( + root, + ¤t_root.run_id, + &seed_task_ids, + )?; + Ok((running || active_child) && !failed) +} + +fn autonomous_manifest_parent_has_active_ready_task_at( + root: &Path, + parent_run_id: &str, + seed_task_ids: &BTreeSet, +) -> Result { + for task_id in seed_task_ids { + let records = + latest_game_creator_agent_runtime_tasks(read_all_game_creator_agent_runtime_tasks( + &game_creator_agent_runtime_task_path(root, task_id), + )?); + for record in records { + if record.source != "agent-ready-task-scheduler" + || record.parent_agent_id.as_deref() + != Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID) + || record.parent_run_id.as_deref() != Some(parent_run_id) + || game_creator_agent_runtime_terminal_status(&record).is_some() + { + continue; + } + if record.run_id != autonomous_manifest_ready_task_run_id(parent_run_id, task_id) { + return Err(format!( + "当前自主构建父 Run 的活跃 child runId 不符合确定性绑定:taskId={task_id}" + )); + } + let state = agent_runtime_state_from_task_record(&record); + let binding = autonomous_manifest_ready_task_parent_binding_for_state_at(root, &state)? + .ok_or_else(|| { + format!("当前自主构建父 Run 的活跃 child 缺少父绑定:taskId={task_id}") + })?; + if binding.root_run_id != parent_run_id { + return Err(format!( + "当前自主构建父 Run 的活跃 child rootRunId 不一致:taskId={task_id}" + )); + } + return Ok(true); + } + } + Ok(false) } pub(crate) fn validate_agent_runtime_autonomous_plan_liveness( diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/parallel_read.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/parallel_read.rs index a85194f36..92514d7ba 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/parallel_read.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/parallel_read.rs @@ -776,6 +776,7 @@ pub(in crate::agent) fn project_game_creator_agent_runtime_parallel_read_batch( &pending.action, &observation, Some(&pending.action_id), + Some(&pending.action_fingerprint), ); runtime.pending_tool_action = None; runtime.status = "running".to_string(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs index 9a9c7d6af..782127e40 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs @@ -142,6 +142,7 @@ pub(crate) fn prepare_agent_runtime_project_mutation_locked( blocker.detail.unwrap_or_default() )); } + ensure_current_autonomous_ready_child_mutation_at_locked(root, agent_id, run_id)?; let mut revision = read_game_creator_agent_runtime_project_revision(root)?; let mut gate = read_game_creator_agent_runtime_verification_gate(root, agent_id, run_id)?; let next_revision = revision @@ -176,6 +177,159 @@ pub(crate) fn prepare_agent_runtime_project_mutation_locked( Ok(next_revision) } +pub(crate) fn ensure_current_autonomous_ready_child_mutation_at_locked( + root: &Path, + agent_id: &str, + run_id: &str, +) -> Result<(), String> { + let normalized_agent_id = match normalize_game_creator_runtime_agent_id(agent_id) { + Ok(agent_id) => agent_id, + Err(_) => return Ok(()), + }; + let binding = + read_game_creator_agent_runtime_run_profile_binding(root, &normalized_agent_id, run_id)?; + let Some(binding) = binding else { + let task = read_latest_game_creator_agent_runtime_task_by_run_id( + root, + &normalized_agent_id, + run_id, + )?; + if task + .as_ref() + .is_some_and(|task| task.run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD) + { + return Err("autonomous Run 项目修改缺少 Run Profile binding,已失败关闭".to_string()); + } + return Ok(()); + }; + if binding.profile != AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD { + return Ok(()); + } + if binding.agent_id != normalized_agent_id + || binding.run_id != run_id + || binding.root_agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID + { + return Err("autonomous Run 项目修改的 Run Profile 绑定身份不一致".to_string()); + } + let task = + read_latest_game_creator_agent_runtime_task_by_run_id(root, &normalized_agent_id, run_id)? + .ok_or_else(|| { + "autonomous Run 项目修改缺少 durable task journal,已失败关闭".to_string() + })?; + if task.agent_id != binding.agent_id + || task.run_id != binding.run_id + || task.source != binding.source + || task.run_profile != binding.profile + || task.run_profile_binding_fingerprint != binding.binding_fingerprint + || task.parent_agent_id != binding.parent_agent_id + || task.parent_run_id != binding.parent_run_id + { + return Err("autonomous Run 项目修改的 durable task journal 与绑定不一致".to_string()); + } + let is_root = binding.agent_id == binding.root_agent_id + && binding.run_id == binding.root_run_id + && binding.parent_agent_id.is_none() + && binding.parent_run_id.is_none(); + if is_root { + if task.parent_agent_id.is_some() + || task.parent_run_id.is_some() + || !agent_runtime_supervisor_source_is_trusted(&task.source) + { + return Err("autonomous 根 Run 项目修改的 durable identity 不一致".to_string()); + } + } else { + let parent_agent_id = binding + .parent_agent_id + .as_deref() + .ok_or_else(|| "autonomous 派生 Run 项目修改缺少 parentAgentId".to_string())?; + let parent_run_id = binding + .parent_run_id + .as_deref() + .ok_or_else(|| "autonomous 派生 Run 项目修改缺少 parentRunId".to_string())?; + let parent_binding = read_game_creator_agent_runtime_run_profile_binding( + root, + parent_agent_id, + parent_run_id, + )? + .ok_or_else(|| "autonomous 派生 Run 项目修改缺少父 Run Profile binding".to_string())?; + if binding.parent_binding_fingerprint.as_deref() + != Some(parent_binding.binding_fingerprint.as_str()) + || parent_binding.profile != AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + || parent_binding.root_agent_id != binding.root_agent_id + || parent_binding.root_run_id != binding.root_run_id + { + return Err( + "autonomous 派生 Run 项目修改的父 binding 或 root identity 不一致".to_string(), + ); + } + if binding.source == "agent-ready-task-scheduler" { + let state = agent_runtime_state_from_task_record(&task); + let ready_binding = + autonomous_manifest_ready_task_parent_binding_for_state_at(root, &state)? + .ok_or_else(|| { + "autonomous ready-task 项目修改缺少确定性父 Run 绑定".to_string() + })?; + if ready_binding != binding + || state.run_id + != autonomous_manifest_ready_task_run_id( + &binding.root_run_id, + &normalized_agent_id, + ) + { + return Err("autonomous ready-task 项目修改的确定性父子身份不一致".to_string()); + } + } else if binding.source == "agent-delegate" + && task + .delegation_id + .as_deref() + .is_none_or(|delegation_id| delegation_id.trim().is_empty()) + { + return Err("autonomous agent-delegate 项目修改缺少 delegationId".to_string()); + } + } + if task.status != "running" || game_creator_agent_runtime_terminal_status(&task).is_some() { + return Err("autonomous Run 项目修改要求当前 durable task 仍为 running".to_string()); + } + let current_root = current_autonomous_game_build_root_task_at(root)? + .ok_or_else(|| "autonomous Run 项目修改时当前根 Run 已不存在".to_string())?; + if current_root.run_id != binding.root_run_id { + return Err(format!( + "autonomous Run 已被更新根 Run 取代:currentRunId={}", + current_root.run_id + )); + } + let current_root_binding = read_game_creator_agent_runtime_run_profile_binding( + root, + ¤t_root.agent_id, + ¤t_root.run_id, + )? + .ok_or_else(|| "autonomous Run 当前根缺少 Run Profile binding".to_string())?; + if current_root.agent_id != binding.root_agent_id + || current_root.source != current_root_binding.source + || current_root.run_profile != AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + || current_root.parent_agent_id.is_some() + || current_root.parent_run_id.is_some() + || current_root.delegation_id.is_some() + || current_root_binding.agent_id != binding.root_agent_id + || current_root_binding.run_id != binding.root_run_id + || current_root_binding.root_agent_id != current_root_binding.agent_id + || current_root_binding.root_run_id != current_root_binding.run_id + || current_root_binding.parent_agent_id.is_some() + || current_root_binding.parent_run_id.is_some() + || current_root_binding.binding_fingerprint != current_root.run_profile_binding_fingerprint + || (is_root && current_root_binding.binding_fingerprint != binding.binding_fingerprint) + { + return Err("autonomous Run 当前根 journal 与 binding 不一致".to_string()); + } + if !autonomous_game_build_root_task_is_active(¤t_root) { + return Err(format!( + "autonomous Run 当前根已不再活跃:status={} phase={}", + current_root.status, current_root.phase + )); + } + Ok(()) +} + pub(crate) fn begin_agent_runtime_project_verification_locked( root: &Path, agent_id: &str, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_action_batch.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_action_batch.rs index d09fcccbe..22c22330f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_action_batch.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_action_batch.rs @@ -689,6 +689,7 @@ pub(in crate::agent) fn persist_game_creator_agent_runtime_provider_batch_waitin &pending.action, observation, Some(&pending.action_id), + Some(&pending.action_fingerprint), ); complete_agent_runtime_active_plan_step( runtime, @@ -788,6 +789,7 @@ pub(in crate::agent) fn project_game_creator_agent_runtime_provider_batch_abort( &pending.action, &observation, Some(&pending.action_id), + Some(&pending.action_fingerprint), ); complete_agent_runtime_active_plan_step(runtime, "failed", &observation_summary); runtime.pending_tool_action = None; 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 bb36a99d4..bbd08e8c7 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 @@ -101,6 +101,8 @@ pub(crate) const AGENT_RUNTIME_ISOLATED_JOIN_SOURCE: &str = "agent-isolated-join pub(crate) const AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE: &str = "project-supervisor-gui"; pub(crate) const AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE: &str = "project-supervisor-cli"; pub(crate) const AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE: &str = "project-supervisor-game-chat"; +pub(super) const GAME_CHAT_FIXED_TASK_GRAPH_STALLED_ERROR: &str = + "game-chat 首版固定任务图无法继续推进,拒绝回退到普通 Provider 协作波"; pub(crate) fn agent_runtime_supervisor_source_is_trusted(source: &str) -> bool { matches!( @@ -277,8 +279,12 @@ pub(crate) use provider_recovery::{ }; #[cfg(test)] pub(crate) use provider_recovery::{ + drive_waiting_autonomous_manifest_parent_wake_budget_for_test, ensure_waiting_provider_retry_records_for_test, + mark_autonomous_manifest_parent_wake_needs_reconciliation_for_test, + prepare_waiting_autonomous_manifest_parent_for_test, probe_static_delegate_parent_wake_singleflight_coalescing, + repair_autonomous_manifest_parent_wake_reconciliation_projection_for_test, }; pub(crate) use recovery_scan::{ cleanup_game_creator_agent_runtime_completed_finalizations_at, @@ -293,10 +299,12 @@ pub(crate) use task_queue::{ run_game_creator_agent_background_task_with_context, spawn_next_game_creator_agent_background_task_drain, spawn_next_game_creator_agent_background_task_drain_with_lock, + spawn_started_game_creator_agent_background_task_drain_with_lock, }; #[cfg(test)] pub(crate) use task_start::start_game_creator_agent_background_task_with_session_lane_hook_at; pub(crate) use task_start::{ + autonomous_game_build_root_task_is_active, current_autonomous_game_build_root_task_at, notify_external_agent_runner_after_background_task_enqueue, project_autonomous_manifest_ready_task_terminal_at, schedule_autonomous_game_build_ready_tasks_at, schedule_game_creator_agent_ready_tasks_at, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs index 29e951e8d..4529c63f1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs @@ -584,8 +584,14 @@ pub(in crate::agent) fn read_game_creator_agent_runtime_with_session_filter_at( } let recent_events = read_recent_game_creator_agent_runtime_events_for_session(&event_path, session_id)?; - let task_snapshot = - read_game_creator_agent_runtime_task_snapshot_for_session(&task_path, session_id)?; + let task_snapshot = read_game_creator_agent_runtime_task_snapshot_for_session( + &task_path, + session_id, + (!state.run_id.trim().is_empty()).then_some(state.run_id.as_str()), + )?; + if state.started_at == 0 { + state.started_at = task_snapshot.run_started_at.unwrap_or(state.updated_at); + } state.task_queue = task_snapshot.task_queue.clone(); let response_stream = visible_game_creator_agent_runtime_response_stream_at(root, &state).unwrap_or(None); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/game_chat_fast_path.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/game_chat_fast_path.rs index 90578f9a4..1ffbd304d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/game_chat_fast_path.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/game_chat_fast_path.rs @@ -12,6 +12,7 @@ pub(crate) const GAME_CHAT_FIRST_PLAYABLE_HARD_BUDGET_ERROR_PREFIX: &str = "game-chat-first-playable-hard-budget-exhausted"; const FALLBACK_THEME_MARKER: &str = "__GAME_CHAT_THEME__"; +pub(super) const GAME_CHAT_CODE_COMPLETION_REPAIR_STEP: &str = "修复 Runtime 完成门诊断并重新验证"; #[derive(Clone, Debug, Eq, PartialEq)] pub(crate) struct GameChatFastPathBudget { @@ -313,6 +314,134 @@ fn game_chat_fast_path_has_art_manifest(root: &Path) -> bool { }) } +fn game_chat_english_words(task: &str) -> Vec<&str> { + task.split(|character: char| !character.is_ascii_alphanumeric()) + .filter(|word| !word.is_empty()) + .collect() +} + +fn game_chat_english_words_contain_phrase(words: &[&str], phrase: &[&str]) -> bool { + words + .windows(phrase.len()) + .any(|candidate| candidate == phrase) +} + +fn game_chat_english_application_is_negated(words: &[&str], application_index: usize) -> bool { + let prefix = &words[application_index.saturating_sub(4)..application_index]; + prefix + .iter() + .any(|word| matches!(*word, "not" | "never" | "dont")) + || game_chat_english_words_contain_phrase(prefix, &["don", "t"]) + || ["refuse", "refuses", "refused"] + .iter() + .any(|refusal| prefix.ends_with(&[*refusal]) || prefix.ends_with(&[*refusal, "to"])) +} + +fn game_chat_chinese_application_is_negated(clause: &str, application_index: usize) -> bool { + let prefix = clause[..application_index].trim_end(); + [ + "不要", "不", "别", "勿", "请勿", "禁止", "拒绝", "避免", "无需", "无须", "不能", "不可", + "不得", "不应", + ] + .iter() + .any(|negation| prefix.ends_with(negation)) +} + +fn game_chat_chinese_clause_requests_existing_art_application(clause: &str) -> bool { + let names_art = ["美术资源", "美术素材", "已有素材", "现有素材"] + .iter() + .any(|marker| clause.contains(marker)); + if !names_art { + return false; + } + + let explicitly_names_existing_art = ["已有美术", "现有美术", "已有素材", "现有素材"] + .iter() + .any(|marker| clause.contains(marker)); + let requests_new_art = [ + "全新美术", + "新的美术", + "新美术", + "全新素材", + "新的素材", + "新素材", + "重新生成美术", + "重做美术", + ] + .iter() + .any(|marker| clause.contains(marker)); + if requests_new_art && !explicitly_names_existing_art { + return false; + } + + ["替换", "换成", "接入", "使用", "应用", "复用"] + .iter() + .any(|application| { + clause.match_indices(application).any(|(index, _)| { + // “换成” is also a suffix of “替换成”; the latter must be + // judged once at the beginning of the complete action. + !(*application == "换成" && clause[..index].ends_with('替')) + && !game_chat_chinese_application_is_negated(clause, index) + }) + }) +} + +fn game_chat_explicit_existing_art_reuse_intent(task: &str) -> bool { + let normalized = task.trim().to_ascii_lowercase(); + let english_words = game_chat_english_words(&normalized); + let requests_existing_art_in_chinese = normalized + .split(|character: char| { + matches!( + character, + ',' | '。' | ';' | ';' | ',' | '.' | '!' | '!' | '?' | '?' | '\n' | '\r' + ) + }) + .any(game_chat_chinese_clause_requests_existing_art_application); + let requests_new_art_in_english = [["new", "art"], ["fresh", "art"], ["regenerate", "art"]] + .iter() + .any(|phrase| game_chat_english_words_contain_phrase(&english_words, phrase)); + let requests_application_in_english = english_words.iter().enumerate().any(|(index, word)| { + matches!(*word, "replace" | "use" | "apply" | "reuse") + && !game_chat_english_application_is_negated(&english_words, index) + }); + let names_art_in_english = [ + &["art", "asset"][..], + &["art", "assets"][..], + &["spritesheet"][..], + &["spritesheets"][..], + &["sprite", "sheet"][..], + &["sprite", "sheets"][..], + ] + .iter() + .any(|phrase| game_chat_english_words_contain_phrase(&english_words, phrase)); + requests_existing_art_in_chinese + || (!requests_new_art_in_english && requests_application_in_english && names_art_in_english) +} + +pub(in crate::agent) fn game_chat_existing_art_reuse_refinement_intent_at( + root: &Path, + task: &str, +) -> Result { + if !game_chat_explicit_existing_art_reuse_intent(task) + || game_chat_fallback_targets_initial_placeholder(root)? + { + return Ok(false); + } + Ok(true) +} + +pub(in crate::agent) fn game_chat_existing_art_reuse_refinement_is_valid_at( + root: &Path, + task: &str, +) -> Result { + if !game_chat_existing_art_reuse_refinement_intent_at(root, task)? { + return Ok(false); + } + Ok(game_chat_fast_path_has_visual_asset(root, "art-director") + && game_chat_fast_path_has_visual_asset(root, "art-asset-plan") + && game_chat_fast_path_has_art_manifest(root)) +} + pub(crate) fn game_chat_fast_path_scheduled_art_contract_repair_is_authorized_at( root: &Path, agent_id: &str, @@ -727,6 +856,165 @@ fn game_chat_fast_path_verified_delivery_plan( } } +fn game_chat_fast_path_completion_repair_plan( + runtime: &AgentRuntimeState, + blocker: &AgentRuntimeToolObservation, +) -> Option { + if !agent_runtime_has_structured_plan(runtime) + || runtime + .plan_steps + .iter() + .any(|step| step.title == GAME_CHAT_CODE_COMPLETION_REPAIR_STEP) + || runtime + .plan_steps + .iter() + .any(|step| step.status == AGENT_RUNTIME_PLAN_STATUS_FAILED) + { + return None; + } + let first_non_terminal_index = runtime.plan_steps.iter().position(|step| { + step.status != AGENT_RUNTIME_PLAN_STATUS_COMPLETED + && step.status != AGENT_RUNTIME_PLAN_STATUS_FAILED + }); + if first_non_terminal_index.is_none() + && runtime.plan_steps.len() >= AGENT_RUNTIME_PLAN_STEP_LIMIT + { + return None; + } + let mut repair_inserted = false; + let mut steps = runtime + .plan_steps + .iter() + .enumerate() + .map(|(index, step)| { + if Some(index) == first_non_terminal_index { + repair_inserted = true; + return AgentRuntimePlanUpdateStep { + step: GAME_CHAT_CODE_COMPLETION_REPAIR_STEP.to_string(), + status: AGENT_RUNTIME_PLAN_STATUS_IN_PROGRESS.to_string(), + }; + } + AgentRuntimePlanUpdateStep { + step: step.title.clone(), + status: if step.status == AGENT_RUNTIME_PLAN_STATUS_COMPLETED { + AGENT_RUNTIME_PLAN_STATUS_COMPLETED + } else { + AGENT_RUNTIME_PLAN_STATUS_PENDING + } + .to_string(), + } + }) + .collect::>(); + if !repair_inserted { + steps.push(AgentRuntimePlanUpdateStep { + step: GAME_CHAT_CODE_COMPLETION_REPAIR_STEP.to_string(), + status: AGENT_RUNTIME_PLAN_STATUS_IN_PROGRESS.to_string(), + }); + } + Some(AgentRuntimeToolPlan { + thinking_summary: "静态检查已通过,但 Runtime 完成门仍有明确诊断;重新打开修复计划并交给 Code Agent 处理。" + .to_string(), + plan_update: Some(AgentRuntimePlanUpdate { + explanation: format!( + "{}:{}", + blocker.summary, + blocker.detail.as_deref().unwrap_or("请按完成门诊断继续修复") + ), + steps, + }), + plan: Vec::new(), + actions: Vec::new(), + response: String::new(), + }) +} + +pub(super) fn game_chat_fast_path_external_repair_observation_at( + root: &Path, + runtime: &AgentRuntimeState, +) -> Option { + if runtime.agent_id != "code-prototype" + || !agent_runtime_has_structured_plan(runtime) + || runtime + .plan_steps + .iter() + .any(|step| step.status == AGENT_RUNTIME_PLAN_STATUS_FAILED) + || (!runtime.plan_steps.iter().any(|step| { + step.title == GAME_CHAT_CODE_COMPLETION_REPAIR_STEP + || step + .title + .starts_with(&format!("{GAME_CHAT_CODE_COMPLETION_REPAIR_STEP}(")) + }) && runtime.plan_steps.iter().any(|step| { + step.status != AGENT_RUNTIME_PLAN_STATUS_COMPLETED + && step.status != AGENT_RUNTIME_PLAN_STATUS_FAILED + })) + { + return None; + } + autonomous_game_build_completion_blocker_at_locked(root, runtime).map(|mut blocker| { + blocker.summary = format!( + "{};结构化计划窗口已终态,进入外部 repair lane,只执行读取、实际 mutation 与重新验证", + blocker.summary + ); + blocker + }) +} + +fn game_chat_fast_path_current_run_owns_mutation( + root: &Path, + runtime: &AgentRuntimeState, + gate: &AgentRuntimeVerificationGate, + revision: u64, +) -> Result { + let Some(tool) = gate + .last_mutation_tool + .as_deref() + .filter(|_| gate.mutation_revision == Some(revision)) + else { + return Ok(false); + }; + let Some(last_mutation_call) = runtime + .recent_tool_calls + .iter() + .rev() + .find(|call| call.tool == tool) + else { + return Ok(false); + }; + let (Some(action_id), Some(action_fingerprint)) = ( + last_mutation_call.action_id.as_deref(), + last_mutation_call.action_fingerprint.as_deref(), + ) else { + return Ok(false); + }; + if last_mutation_call.status != "ok" + || !is_valid_agent_runtime_action_id(action_id) + || !is_valid_agent_runtime_action_fingerprint(action_fingerprint) + { + return Ok(false); + } + let (records, _) = + read_agent_db_records_bounded(root, AGENT_RUNTIME_ACTION_HISTORY_MAX_DB_BYTES)?; + Ok(records.iter().rev().any(|record| { + record.get("recordType").and_then(serde_json::Value::as_str) + == Some(AGENT_RUNTIME_ACTION_RECEIPT_RECORD_TYPE) + && record.get("agentId").and_then(serde_json::Value::as_str) + == Some(runtime.agent_id.as_str()) + && record.get("taskId").and_then(serde_json::Value::as_str) + == Some(runtime.task_id.as_str()) + && record.get("sessionId").and_then(serde_json::Value::as_str) + == Some(runtime.session_id.as_str()) + && record.get("runId").and_then(serde_json::Value::as_str) + == Some(runtime.run_id.as_str()) + && record.get("actionId").and_then(serde_json::Value::as_str) == Some(action_id) + && record + .get("actionFingerprint") + .and_then(serde_json::Value::as_str) + == Some(action_fingerprint) + && record.get("tool").and_then(serde_json::Value::as_str) == Some(tool) + && record.get("status").and_then(serde_json::Value::as_str) == Some("ok") + })) +} + fn game_chat_fast_path_current_revision_is_verified( root: &Path, runtime: &AgentRuntimeState, @@ -898,13 +1186,29 @@ pub(crate) fn game_chat_fast_path_plan_at( } } "code-prototype" => { + if agent_runtime_has_structured_plan(runtime) + && runtime + .plan_steps + .iter() + .any(|step| step.status == AGENT_RUNTIME_PLAN_STATUS_FAILED) + { + return Err( + "game-chat code-prototype 结构化计划已包含 failed 步骤,拒绝继续空转" + .to_string(), + ); + } let revision = read_game_creator_agent_runtime_project_revision(root)?; let gate = read_game_creator_agent_runtime_verification_gate( root, &runtime.agent_id, &runtime.run_id, )?; - let owns_current_mutation = gate.mutation_revision == Some(revision.revision); + let owns_current_mutation = game_chat_fast_path_current_run_owns_mutation( + root, + runtime, + &gate, + revision.revision, + )?; let current_revision_verified = owns_current_mutation && gate.verified_revision == Some(revision.revision) && gate.last_verification_status.as_deref() @@ -914,6 +1218,13 @@ pub(crate) fn game_chat_fast_path_plan_at( == Some(AGENT_RUNTIME_VERIFICATION_STATUS_FAILED); if current_revision_verified { + if let Some(blocker) = + autonomous_game_build_completion_blocker_at_locked(root, runtime) + { + return Ok(game_chat_fast_path_completion_repair_plan( + runtime, &blocker, + )); + } return Ok(Some(game_chat_fast_path_verified_delivery_plan( runtime, "首个可玩版本代码已生成并通过静态自检。", @@ -1650,6 +1961,53 @@ mod tests { ); } + #[test] + fn existing_art_reuse_intent_requires_whole_english_action_words_and_rejects_negation() { + let accepted = [ + "Use existing art assets in the current game.", + "Please REUSE the existing art assets.", + "Replace placeholders with current art assets.", + "Apply the existing spritesheet to the UI.", + "Reuse the sprite-sheet for the falling blocks.", + "Use existing art assets; do not generate new ones.", + "使用现有素材,不要重新生成。", + "请复用已有美术资源。", + "不要重新生成美术,继续接入现有素材。", + ]; + for task in accepted { + assert!( + game_chat_explicit_existing_art_reuse_intent(task), + "expected existing-art reuse intent: {task}" + ); + } + + let rejected = [ + "Do not use existing art assets.", + "Do NOT use the existing art assets.", + "Don't reuse existing art assets.", + "Never apply the existing spritesheet.", + "Do not replace the UI with existing art assets.", + "We refuse to use existing art assets.", + "Refuse art assets.", + "Misuse art assets.", + "These are useful art assets.", + "Discuss art assets because they exist.", + "Create new art assets.", + "Regenerate art assets.", + "不要复用已有素材。", + "不要接入现有素材。", + "不应用已有美术资源。", + "别替换成现有素材。", + "不要使用现有素材,改为重做美术。", + ]; + for task in rejected { + assert!( + !game_chat_explicit_existing_art_reuse_intent(task), + "expected no existing-art reuse intent: {task}" + ); + } + } + #[test] fn art_slice_completion_validation_rejects_tampering_and_duplicate_pixels() { let temporary = tempfile::tempdir().expect("create slice validation project"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/interaction.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/interaction.rs index ad6fe1c7c..eaea0fbd3 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/interaction.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/interaction.rs @@ -891,6 +891,7 @@ pub(in crate::agent) fn advance_game_creator_agent_runtime_provider_batch_gate( &next_pending.action, &observation, Some(&next_pending.action_id), + Some(&next_pending.action_fingerprint), ); activate_agent_runtime_plan_step( runtime, 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 87d745f73..e05675cb8 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 @@ -112,9 +112,7 @@ async fn request_game_creator_agent_tool_plan_with_game_chat_budget_at( .map(RequestedAgentRuntimeToolPlanOutcome::Ready); } if runtime.agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { - return Err( - "game-chat 首版固定任务图无法继续推进,拒绝回退到普通 Provider 协作波".to_string(), - ); + return Err(GAME_CHAT_FIXED_TASK_GRAPH_STALLED_ERROR.to_string()); } let Some(timeout) = game_chat_fast_path_provider_timeout(&budget) else { if runtime.agent_id == "code-prototype" { @@ -127,13 +125,17 @@ async fn request_game_creator_agent_tool_plan_with_game_chat_budget_at( } return Err("game-chat 首版软预算已耗尽,拒绝继续请求 Provider".to_string()); }; + let mut provider_observations = observations.to_vec(); + if let Some(blocker) = game_chat_fast_path_external_repair_observation_at(root, runtime) { + provider_observations.push(blocker); + } let provider_request = request_game_creator_agent_background_tool_plan_at( root, &runtime.agent_id, &runtime.session_id, &runtime.run_id, task, - observations, + &provider_observations, loop_index, runtime.applied_steer_cursor, ); @@ -714,10 +716,10 @@ async fn run_game_creator_agent_background_task_pass_without_deadline( } else { false }; - let autonomous_manifest_can_wait = agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID + let autonomous_manifest_parent_can_wait = agent_id + == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID && runtime.run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD && !game_chat_hard_budget_expired - && !autonomous_registered_derived_visuals_need_repair_at(&root) && !game_creator_agent_runtime_provider_action_batch_exists( &root, &agent_id, @@ -732,32 +734,47 @@ async fn run_game_creator_agent_background_task_pass_without_deadline( && isolated_join_completion_blocker_at(&root, &agent_id, &runtime.run_id).is_none() && static_delegate_completion_blocker_at(&root, &agent_id, &runtime.run_id) .is_none(); - if autonomous_manifest_can_wait { - if let Err(error) = schedule_autonomous_game_build_ready_tasks_at( - &root, - &agent_id, - &runtime.run_id, - 3, - ) { - return fail_game_creator_agent_background_context_at( - &root, - &agent_id, - &session_id, - runtime, - &format!("调度自主构建 manifest 任务失败:{error}"), - ); - } - let manifest_in_progress = match autonomous_manifest_dag_in_progress_at(&root) { - Ok(value) => value, - Err(error) => { - return fail_game_creator_agent_background_context_at( + if autonomous_manifest_parent_can_wait { + // 已登记但损坏的派生视觉需要先由父 Run 规划修复,因此此时不再调度新的 + // manifest child;但已经持久化并运行的 child 仍是当前 DAG 的活跃工作, + // 父 Run 必须继续等待,不能提前落入 game-chat fixed-graph-stalled。 + let scheduled_ready_tasks = + if autonomous_registered_derived_visuals_need_repair_at(&root) { + Vec::new() + } else { + match schedule_autonomous_game_build_ready_tasks_at( &root, &agent_id, - &session_id, - runtime, - &format!("读取自主构建 manifest 等待屏障失败:{error}"), - ); + &runtime.run_id, + 3, + ) { + Ok(tasks) => tasks, + Err(error) => { + return fail_game_creator_agent_background_context_at( + &root, + &agent_id, + &session_id, + runtime, + &format!("调度自主构建 manifest 任务失败:{error}"), + ); + } + } + }; + let manifest_in_progress = if scheduled_ready_tasks.is_empty() { + match autonomous_manifest_dag_in_progress_at(&root) { + Ok(value) => value, + Err(error) => { + return fail_game_creator_agent_background_context_at( + &root, + &agent_id, + &session_id, + runtime, + &format!("读取自主构建 manifest 等待屏障失败:{error}"), + ); + } } + } else { + true }; if manifest_in_progress { let blocker = @@ -1264,9 +1281,12 @@ async fn run_game_creator_agent_background_task_pass_without_deadline( &runtime.run_id, ); let verified_delivery = match verification_gate { - Ok(gate) => agent_runtime_autonomous_verified_delivery_allows_plan_completion( - &agent_id, &gate, - ), + Ok(gate) => { + agent_runtime_autonomous_verified_delivery_allows_plan_completion( + &agent_id, &gate, + ) && autonomous_game_build_completion_blocker_at_locked(&root, &runtime) + .is_none() + } Err(error) => { return fail_game_creator_agent_background_context_at( &root, @@ -2700,6 +2720,9 @@ async fn run_game_creator_agent_background_task_pass_without_deadline( observation_action_identity .as_ref() .map(|identity| identity.0.as_str()), + observation_action_identity + .as_ref() + .map(|identity| identity.1.as_str()), ); if observation.is_waiting_for_confirmation() { let mut pending_action = durable_action diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_tests.rs index 5f1996351..b5adfbc2c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_tests.rs @@ -203,7 +203,7 @@ fn queue_game_chat_fast_path_child( } #[test] -fn autonomous_parent_keeps_planning_before_scheduling_registered_legacy_derived_visuals() { +fn autonomous_parent_waits_for_active_child_while_registered_derived_visuals_need_repair() { let root = std::env::temp_dir().join(format!( "genarrative-agent-main-loop-legacy-{}-{}", std::process::id(), @@ -219,6 +219,35 @@ fn autonomous_parent_keeps_planning_before_scheduling_registered_legacy_derived_ register_autonomous_recovery_visual_fixture(&root, "assets/ui-prototype.png", "ui-prototype"); assert!(autonomous_registered_derived_visuals_need_repair_at(&root)); + let (mut parent_state, _child_state) = queue_game_chat_fast_path_child( + &root, + "game-chat-derived-visual-repair-parent", + "继续当前俄罗斯方块并修复派生视觉", + "code-prototype", + ); + assert!( + autonomous_manifest_dag_in_progress_at(&root) + .expect("read active child while derived visuals need repair"), + "an already scheduled durable child must keep the manifest DAG active" + ); + let mut observations = Vec::new(); + let continuation = AgentRuntimeContinuationContext::default(); + let mut context_tracker = AgentRuntimeContextWindowTracker::from_continuation(&continuation); + let blocker = autonomous_game_build_completion_blocker_at_locked(&root, &parent_state) + .expect("active manifest child must block parent completion"); + persist_waiting_autonomous_manifest_parent_context_at( + &root, + &mut parent_state, + "继续当前俄罗斯方块并修复派生视觉", + &AgentRuntimeToolPlan::default(), + &mut observations, + 0, + &mut context_tracker, + blocker, + ) + .expect("persist parent wait despite derived visual repair"); + assert_eq!(parent_state.phase, "waiting-for-manifest-tasks"); + fs::remove_dir_all(root).ok(); } @@ -231,6 +260,32 @@ fn prepare_autonomous_completion_evidence( .expect("read autonomous completion contract") .expect("autonomous completion contract exists"); let revision = { + let latest = read_latest_game_creator_agent_runtime_task_by_run_id( + root, + &state.agent_id, + &state.run_id, + ) + .expect("read autonomous run before completion fixture mutation") + .expect("autonomous run exists before completion fixture mutation"); + match latest.status.as_str() { + "running" if game_creator_agent_runtime_terminal_status(&latest).is_none() => {} + "pending" + if latest.phase == "queued" + && game_creator_agent_runtime_terminal_status(&latest).is_none() => + { + let mut running = agent_runtime_state_from_task_record(&latest); + running.status = "running".to_string(); + running.phase = "planning".to_string(); + append_game_creator_agent_runtime_task(root, &running) + .expect("append durable running autonomous run before completion mutation"); + } + status => { + panic!( + "completion fixture refuses to revive terminal autonomous run: status={status}, phase={}", + latest.phase + ) + } + } let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( root, "test.autonomous.final_reply.mutate", @@ -615,6 +670,680 @@ fn game_chat_art_asset_plan_uses_deterministic_icon_spritesheet_generation() { assert!(completion_plan.response.contains("透明核心美术图集已生成")); } +#[test] +fn game_chat_code_completion_blocker_reopens_active_repair_before_delivery() { + let temporary = tempfile::tempdir().expect("create game-chat code repair root"); + let root = temporary.path().join("project"); + init_local_game_project_at(&root, "game-chat-code-repair", "水晶俄罗斯方块") + .expect("init game-chat code repair project"); + let (root_state, mut child_state) = queue_game_chat_fast_path_child( + &root, + "game-chat-code-repair-root", + "继续当前俄罗斯方块,把 UI 和方块替换成现有美术资源", + "code-prototype", + ); + child_state.status = "running".to_string(); + child_state.phase = "planning".to_string(); + append_game_creator_agent_runtime_task(&root, &child_state) + .expect("append running code repair child"); + register_game_chat_art_spec_fixture(&root); + register_game_chat_art_spritesheet_fixture(&root); + fs::write( + root.join(AGENT_RUNTIME_GAME_INDEX_PATH), + "", + ) + .expect("write code entry without art slices"); + let mut revision = + read_game_creator_agent_runtime_project_revision(&root).expect("read project revision"); + revision.revision = 1; + write_game_creator_agent_runtime_project_revision(&root, &revision) + .expect("write project revision"); + let mut gate = + default_agent_runtime_verification_gate(&root, &child_state.agent_id, &child_state.run_id) + .expect("read code verification gate"); + gate.requires_verification = true; + gate.mutation_revision = Some(revision.revision); + gate.last_mutation_tool = Some("file.patch".to_string()); + gate.verified_revision = Some(revision.revision); + gate.last_verification_tool = Some("game.static_smoke".to_string()); + gate.last_verification_status = Some(AGENT_RUNTIME_VERIFICATION_STATUS_PASSED.to_string()); + write_game_creator_agent_runtime_verification_gate(&root, &gate) + .expect("write code verification gate"); + child_state.applied_steer_cursor = 2; + let successful_patch_action = AgentRuntimeToolAction { + tool: "file.patch".to_string(), + reason: None, + input: serde_json::json!({ + "path": AGENT_RUNTIME_GAME_INDEX_PATH, + "oldText": "before", + "newText": "after", + "expectedReplacements": 1, + }), + }; + let successful_patch_fingerprint = agent_runtime_pending_tool_action_fingerprint( + &successful_patch_action, + &child_state.current_task, + child_state.applied_steer_cursor, + ); + let successful_patch_action_id = + agent_runtime_tool_action_id(&child_state.run_id, 1, 0, 1, &successful_patch_fingerprint); + let successful_patch_observation = AgentRuntimeToolObservation { + tool: "file.patch".to_string(), + status: "ok".to_string(), + summary: "已局部修改 game/index.html(1 处替换)".to_string(), + detail: None, + }; + let successful_patch_task = child_state.current_task.clone(); + append_agent_runtime_tool_call_record( + &root, + &mut child_state, + &successful_patch_task, + &successful_patch_action, + &successful_patch_observation, + Some(&successful_patch_action_id), + Some(&successful_patch_fingerprint), + ); + let successful_patch_call = child_state + .recent_tool_calls + .last() + .expect("successful patch call exists"); + append_agent_runtime_action_receipt( + &root, + &child_state, + successful_patch_call + .action_id + .as_deref() + .expect("successful patch action id"), + successful_patch_call + .action_fingerprint + .as_deref() + .expect("successful patch fingerprint"), + "file.patch", + AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO, + None, + &successful_patch_observation, + ) + .expect("persist run-bound successful patch receipt"); + apply_agent_runtime_plan_update( + &mut child_state, + &AgentRuntimePlanUpdate { + explanation: "原计划已完成实现和 smoke,准备试玩与交付".to_string(), + steps: (1..=6) + .map(|index| AgentRuntimePlanUpdateStep { + step: format!("原计划步骤 {index}"), + status: match index { + 1..=3 => AGENT_RUNTIME_PLAN_STATUS_COMPLETED, + 4 => AGENT_RUNTIME_PLAN_STATUS_IN_PROGRESS, + _ => AGENT_RUNTIME_PLAN_STATUS_PENDING, + } + .to_string(), + }) + .collect(), + }, + ) + .expect("persist active original structured plan"); + let bound_at = read_game_creator_agent_runtime_run_profile_binding( + &root, + &root_state.agent_id, + &root_state.run_id, + ) + .expect("read game-chat root binding") + .expect("game-chat root binding exists") + .bound_at; + + let repair_plan = + game_chat_fast_path_plan_at(&root, &child_state, &child_state.current_task, bound_at) + .expect("evaluate code repair fast path") + .expect("completion blocker must reopen a repair plan"); + assert!(repair_plan.actions.is_empty()); + assert!(repair_plan.response.is_empty()); + let repair_update = repair_plan.plan_update.expect("repair plan update exists"); + assert_eq!(repair_update.steps.len(), 6); + assert_eq!( + repair_update.steps.get(3).map(|step| step.step.as_str()), + Some(GAME_CHAT_CODE_COMPLETION_REPAIR_STEP) + ); + assert_eq!( + repair_update.steps.get(3).map(|step| step.status.as_str()), + Some(AGENT_RUNTIME_PLAN_STATUS_IN_PROGRESS) + ); + assert!(repair_update.steps[..3] + .iter() + .all(|step| step.status == AGENT_RUNTIME_PLAN_STATUS_COMPLETED)); + assert!(repair_update.steps[4..] + .iter() + .all(|step| step.status == AGENT_RUNTIME_PLAN_STATUS_PENDING)); + assert!(repair_update.explanation.contains("player.png")); + + let mut full_completed_plan = child_state.clone(); + full_completed_plan.plan_revision = full_completed_plan.plan_revision.saturating_add(1); + full_completed_plan.plan_steps = (1..=AGENT_RUNTIME_PLAN_STEP_LIMIT) + .map(|index| AgentRuntimePlanStep { + index: index as u32, + title: format!("已完成步骤 {index}"), + status: AGENT_RUNTIME_PLAN_STATUS_COMPLETED.to_string(), + detail: None, + updated_at: unix_timestamp(), + }) + .collect(); + full_completed_plan.active_plan_step_index = None; + assert!( + game_chat_fast_path_plan_at( + &root, + &full_completed_plan, + &full_completed_plan.current_task, + bound_at, + ) + .expect("evaluate full completed repair window") + .is_none(), + "an eight-step completed plan must not append an illegal ninth repair step" + ); + let external_repair = + game_chat_fast_path_external_repair_observation_at(&root, &full_completed_plan) + .expect("full completed plan must expose its blocker to Provider repair"); + assert!(external_repair.summary.contains("外部 repair lane")); + assert!(external_repair + .detail + .as_deref() + .is_some_and(|detail| detail.contains("player.png"))); + + let mut failed_plan = full_completed_plan.clone(); + failed_plan.plan_steps[0].status = AGENT_RUNTIME_PLAN_STATUS_FAILED.to_string(); + let failed_error = + game_chat_fast_path_plan_at(&root, &failed_plan, &failed_plan.current_task, bound_at) + .expect_err("failed structured plan with a completion blocker must fail closed"); + assert!(failed_error.contains("failed 步骤")); + + apply_agent_runtime_plan_update(&mut child_state, &repair_update) + .expect("apply completion repair step"); + + assert!( + game_chat_fast_path_plan_at(&root, &child_state, &child_state.current_task, bound_at,) + .expect("evaluate provider repair handoff") + .is_none(), + "an active repair step must hand control back to Provider planning" + ); + + fs::write( + root.join(AGENT_RUNTIME_GAME_INDEX_PATH), + render_game_chat_fast_path_html("制作水晶俄罗斯方块小游戏"), + ) + .expect("write code entry with all art slices"); + let mut repaired_but_failed_plan = child_state.clone(); + repaired_but_failed_plan.plan_steps[0].status = AGENT_RUNTIME_PLAN_STATUS_FAILED.to_string(); + let repaired_failed_error = game_chat_fast_path_plan_at( + &root, + &repaired_but_failed_plan, + &repaired_but_failed_plan.current_task, + bound_at, + ) + .expect_err("a failed structured step must remain terminal after autonomous blockers clear"); + assert!(repaired_failed_error.contains("failed 步骤")); + let delivery = + game_chat_fast_path_plan_at(&root, &child_state, &child_state.current_task, bound_at) + .expect("evaluate repaired code delivery") + .expect("fully repaired code may use deterministic delivery"); + assert!(delivery.actions.is_empty()); + assert!(delivery.response.contains("代码已生成并通过静态自检")); +} + +#[test] +fn game_chat_code_failed_patch_revision_does_not_count_as_owned_mutation() { + let temporary = tempfile::tempdir().expect("create game-chat failed patch root"); + let root = temporary.path().join("project"); + init_local_game_project_at(&root, "game-chat-failed-patch", "水晶俄罗斯方块") + .expect("init game-chat failed patch project"); + let (root_state, mut child_state) = queue_game_chat_fast_path_child( + &root, + "game-chat-failed-patch-root", + "继续当前俄罗斯方块,把 UI 和方块替换成现有美术资源", + "code-prototype", + ); + fs::write( + root.join(AGENT_RUNTIME_GAME_INDEX_PATH), + "", + ) + .expect("write existing game entry"); + let mut revision = + read_game_creator_agent_runtime_project_revision(&root).expect("read project revision"); + revision.revision = 1; + write_game_creator_agent_runtime_project_revision(&root, &revision) + .expect("write project revision"); + let mut gate = + default_agent_runtime_verification_gate(&root, &child_state.agent_id, &child_state.run_id) + .expect("read code verification gate"); + gate.requires_verification = true; + gate.mutation_revision = Some(revision.revision); + gate.last_mutation_tool = Some("file.patch".to_string()); + gate.verified_revision = Some(revision.revision); + gate.last_verification_tool = Some("game.static_smoke".to_string()); + gate.last_verification_status = Some(AGENT_RUNTIME_VERIFICATION_STATUS_PASSED.to_string()); + write_game_creator_agent_runtime_verification_gate(&root, &gate) + .expect("write failed patch verification gate"); + let prior_action = AgentRuntimeToolAction { + tool: "file.patch".to_string(), + reason: None, + input: serde_json::json!({ + "path": AGENT_RUNTIME_GAME_INDEX_PATH, + "oldText": "older-before", + "newText": "older-after", + "expectedReplacements": 1, + }), + }; + let prior_action_fingerprint = + agent_runtime_tool_action_fingerprint(&prior_action, &child_state.current_task); + child_state + .recent_tool_calls + .push(AgentRuntimeToolCallRecord { + action_id: Some("action-222222222222222222222222".to_string()), + tool: "file.patch".to_string(), + status: "ok".to_string(), + action_fingerprint: Some(prior_action_fingerprint.clone()), + input_summary: None, + reason: None, + summary: "较早的同 Run patch 曾成功".to_string(), + detail: None, + updated_at: child_state.started_at, + }); + append_agent_runtime_action_receipt( + &root, + &child_state, + "action-222222222222222222222222", + &prior_action_fingerprint, + "file.patch", + AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO, + None, + &AgentRuntimeToolObservation { + tool: "file.patch".to_string(), + status: "ok".to_string(), + summary: "较早的同 Run patch 曾成功".to_string(), + detail: None, + }, + ) + .expect("persist earlier successful patch receipt"); + child_state + .recent_tool_calls + .push(AgentRuntimeToolCallRecord { + action_id: Some("action-333333333333333333333333".to_string()), + tool: "file.patch".to_string(), + status: "failed".to_string(), + action_fingerprint: Some(agent_runtime_tool_action_fingerprint( + &AgentRuntimeToolAction { + tool: "file.patch".to_string(), + reason: None, + input: serde_json::json!({ + "path": AGENT_RUNTIME_GAME_INDEX_PATH, + "oldText": "missing", + "newText": "replacement", + "expectedReplacements": 1, + }), + }, + &child_state.current_task, + )), + input_summary: None, + reason: None, + summary: "oldText 匹配数不符:期望 1,实际 0;文件未修改".to_string(), + detail: None, + updated_at: child_state.started_at, + }); + let bound_at = read_game_creator_agent_runtime_run_profile_binding( + &root, + &root_state.agent_id, + &root_state.run_id, + ) + .expect("read game-chat root binding") + .expect("game-chat root binding exists") + .bound_at; + + assert!( + game_chat_fast_path_plan_at(&root, &child_state, &child_state.current_task, bound_at) + .expect("evaluate failed patch fast path") + .is_none(), + "a failed file.patch must hand control back to Provider instead of authorizing smoke or delivery" + ); + child_state.plan_revision = 1; + child_state.plan_steps = vec![AgentRuntimePlanStep { + index: 0, + title: "无法改写的失败步骤".to_string(), + status: AGENT_RUNTIME_PLAN_STATUS_FAILED.to_string(), + detail: None, + updated_at: unix_timestamp(), + }]; + let failed_plan_error = + game_chat_fast_path_plan_at(&root, &child_state, &child_state.current_task, bound_at) + .expect_err("failed plan must close even when the latest mutation is not owned"); + assert!(failed_plan_error.contains("failed 步骤")); +} + +#[test] +fn game_chat_code_cannot_borrow_successful_mutation_receipt_from_another_run() { + let temporary = tempfile::tempdir().expect("create cross-run mutation receipt root"); + let root = temporary.path().join("project"); + init_local_game_project_at(&root, "game-chat-cross-run-receipt", "水晶俄罗斯方块") + .expect("init cross-run mutation receipt project"); + let (root_state, mut child_state) = queue_game_chat_fast_path_child( + &root, + "game-chat-cross-run-receipt-root", + "继续当前俄罗斯方块,把 UI 和方块替换成现有美术资源", + "code-prototype", + ); + fs::write( + root.join(AGENT_RUNTIME_GAME_INDEX_PATH), + "", + ) + .expect("write existing game entry"); + let mut revision = + read_game_creator_agent_runtime_project_revision(&root).expect("read project revision"); + revision.revision = 1; + write_game_creator_agent_runtime_project_revision(&root, &revision) + .expect("write project revision"); + let mut gate = + default_agent_runtime_verification_gate(&root, &child_state.agent_id, &child_state.run_id) + .expect("read code verification gate"); + gate.requires_verification = true; + gate.mutation_revision = Some(revision.revision); + gate.last_mutation_tool = Some("file.patch".to_string()); + gate.verified_revision = Some(revision.revision); + gate.last_verification_tool = Some("game.static_smoke".to_string()); + gate.last_verification_status = Some(AGENT_RUNTIME_VERIFICATION_STATUS_PASSED.to_string()); + write_game_creator_agent_runtime_verification_gate(&root, &gate) + .expect("write current verification gate"); + let action = AgentRuntimeToolAction { + tool: "file.patch".to_string(), + reason: None, + input: serde_json::json!({ + "path": AGENT_RUNTIME_GAME_INDEX_PATH, + "oldText": "before", + "newText": "after", + "expectedReplacements": 1, + }), + }; + let action_id = "action-444444444444444444444444"; + let action_fingerprint = + agent_runtime_tool_action_fingerprint(&action, &child_state.current_task); + child_state + .recent_tool_calls + .push(AgentRuntimeToolCallRecord { + action_id: Some(action_id.to_string()), + tool: "file.patch".to_string(), + status: "ok".to_string(), + action_fingerprint: Some(action_fingerprint.clone()), + input_summary: None, + reason: None, + summary: "旧 Run 成功修改了文件".to_string(), + detail: None, + updated_at: child_state.started_at.saturating_add(1), + }); + let mut old_run = child_state.clone(); + old_run.run_id = "autonomous-ready-code-prototype-old-run".to_string(); + append_agent_runtime_action_receipt( + &root, + &old_run, + action_id, + &action_fingerprint, + "file.patch", + AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO, + None, + &AgentRuntimeToolObservation { + tool: "file.patch".to_string(), + status: "ok".to_string(), + summary: "旧 Run 成功修改了文件".to_string(), + detail: None, + }, + ) + .expect("persist old-run mutation receipt"); + let bound_at = read_game_creator_agent_runtime_run_profile_binding( + &root, + &root_state.agent_id, + &root_state.run_id, + ) + .expect("read game-chat root binding") + .expect("game-chat root binding exists") + .bound_at; + + assert!( + game_chat_fast_path_plan_at(&root, &child_state, &child_state.current_task, bound_at) + .expect("evaluate cross-run receipt fast path") + .is_none(), + "a successful file.patch receipt owned by another run must not authorize smoke or delivery" + ); +} + +#[test] +fn game_chat_existing_art_reuse_refinement_preserves_art_graph_and_tetris_scenario() { + let _config_guard = crate::tests::write_test_local_config( + r#"{"editorApi":{"apiKey":"game-chat-art-reuse-key"}}"#.to_string(), + ); + let temporary = tempfile::tempdir().expect("create art reuse refinement root"); + let root = temporary.path().join("project"); + init_local_game_project_at(&root, "game-chat-art-reuse", "水晶俄罗斯方块") + .expect("init art reuse refinement project"); + let root_session = resolve_agent_conversation_session_id_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + None, + true, + ) + .expect("resolve art reuse root session"); + let original = append_unique_game_creator_agent_runtime_pending_task( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &root_session, + "我想做个俄罗斯方块,要水晶风格的", + "game-chat-art-reuse-original", + AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + None, + ) + .expect("queue original Tetris root"); + fs::write( + root.join(AGENT_RUNTIME_GAME_INDEX_PATH), + render_game_chat_fast_path_html("水晶俄罗斯方块"), + ) + .expect("write existing Tetris game"); + register_game_chat_art_spec_fixture(&root); + register_game_chat_art_spritesheet_fixture(&root); + fs::write( + root.join("assets/manifest.art.json"), + game_chat_fast_path_art_manifest_content(), + ) + .expect("write reusable art manifest"); + for task in new_game_creation_app_seed_tasks() { + update_manifest_task_status_at(&root, &task.id, GameCreationAppTaskStatus::Completed) + .unwrap_or_else(|error| panic!("complete reusable task {}: {error}", task.id)); + } + append_game_creator_agent_runtime_task_record( + &root, + &AgentRuntimeTaskRecord { + status: "failed".to_string(), + phase: "failed".to_string(), + current_action: "等待增量修改".to_string(), + terminal_detail: Some("test refinement boundary".to_string()), + error: Some("test refinement boundary".to_string()), + updated_at: unix_timestamp(), + ..original + }, + ) + .expect("close original Tetris root"); + + let refinement = append_unique_game_creator_agent_runtime_pending_task( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &root_session, + "把ui和方块替换成美术资源", + "game-chat-art-reuse-refinement", + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + None, + ) + .expect("queue explicit existing-art refinement"); + let refinement_contract = read_autonomous_completion_contract( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &refinement.run_id, + ) + .expect("read art reuse refinement contract") + .expect("art reuse refinement contract exists"); + assert_eq!( + refinement_contract.playtest_scenario, + BrowserPlaytestScenario::TetrisV1, + "the incremental instruction must retain the original Tetris playtest contract" + ); + assert!(refinement_contract + .baseline_artifacts + .iter() + .any(|artifact| { + artifact.path == "assets/manifest.art.json" + && artifact.sha256 + == format!( + "{:x}", + Sha256::digest(game_chat_fast_path_art_manifest_content().as_bytes()) + ) + })); + let manifest = read_manifest_for_project(&root).expect("read art reuse refinement manifest"); + let statuses = manifest + .tasks + .iter() + .map(|task| (task.id.as_str(), task.status.clone())) + .collect::>(); + for task_id in ["art-director", "art-asset-plan"] { + assert_eq!( + statuses.get(task_id), + Some(&GameCreationAppTaskStatus::Completed), + "validated existing art must stay completed for explicit reuse: {task_id}" + ); + } + for task_id in [ + "design-director", + "code-director", + "code-prototype", + "preview-readiness", + "preview-playtest", + ] { + assert_eq!( + statuses.get(task_id), + Some(&GameCreationAppTaskStatus::Pending), + "the refinement must reopen the non-art work: {task_id}" + ); + } + let ready = autonomous_manifest_ready_task_ids( + &manifest.tasks, + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + ); + assert!(!ready + .iter() + .any(|task_id| matches!(task_id.as_str(), "art-director" | "art-asset-plan"))); + + let refinement_state = agent_runtime_state_from_task_record(&refinement); + let parent_blocker = + autonomous_game_build_completion_blocker_at_locked(&root, &refinement_state) + .expect("pending non-art work must still block the parent"); + assert!(!parent_blocker.detail.as_deref().is_some_and(|detail| { + detail.contains("assets/manifest.art.json(unchanged-from-run-baseline)") + })); + + let player_slice_path = root.join("assets/art-spritesheet-slices/player.png"); + let player_slice = fs::read(&player_slice_path).expect("read reusable player slice"); + fs::remove_file(&player_slice_path).expect("remove reusable player slice"); + let invalid_art_blocker = + autonomous_game_build_completion_blocker_at_locked(&root, &refinement_state) + .expect("broken reusable art must block the parent"); + let invalid_art_detail = invalid_art_blocker + .detail + .as_deref() + .expect("broken reusable art blocker detail"); + assert!( + invalid_art_detail.contains("code-prototype(pending)") + && invalid_art_detail.contains("assets/art-spritesheet-slices/manifest.json(invalid:") + && invalid_art_detail.contains("assets/manifest.art.json(unchanged-from-run-baseline)"), + "unexpected broken-art completion blocker: {invalid_art_detail}" + ); + fs::write(&player_slice_path, player_slice).expect("restore reusable player slice"); + + append_game_creator_agent_runtime_task_record( + &root, + &AgentRuntimeTaskRecord { + status: "failed".to_string(), + phase: "failed".to_string(), + current_action: "等待普通新需求".to_string(), + terminal_detail: Some("test new-goal boundary".to_string()), + error: Some("test new-goal boundary".to_string()), + updated_at: unix_timestamp(), + ..refinement + }, + ) + .expect("close art reuse refinement root"); + append_unique_game_creator_agent_runtime_pending_task( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &root_session, + "做一个全新的太空收集游戏", + "game-chat-art-reuse-new-goal", + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + None, + ) + .expect("queue unrelated new game goal"); + let new_goal_manifest = + read_manifest_for_project(&root).expect("read unrelated new-goal manifest"); + for task_id in ["art-director", "art-asset-plan"] { + assert_eq!( + new_goal_manifest + .tasks + .iter() + .find(|task| task.id == task_id) + .expect("new-goal art task exists") + .status, + GameCreationAppTaskStatus::Pending, + "an unrelated new goal must not claim old-theme art: {task_id}" + ); + } + + append_game_creator_agent_runtime_task_record( + &root, + &AgentRuntimeTaskRecord { + status: "failed".to_string(), + phase: "failed".to_string(), + current_action: "等待全新美术需求".to_string(), + terminal_detail: Some("test new-art boundary".to_string()), + error: Some("test new-art boundary".to_string()), + updated_at: unix_timestamp(), + ..read_latest_game_creator_agent_runtime_task_by_run_id( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "game-chat-art-reuse-new-goal", + ) + .expect("read unrelated new-goal root") + .expect("unrelated new-goal root exists") + }, + ) + .expect("close unrelated new-goal root"); + append_unique_game_creator_agent_runtime_pending_task( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &root_session, + "使用全新美术资源重新设计这个游戏", + "game-chat-art-reuse-new-art", + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + None, + ) + .expect("queue explicit new-art goal"); + let new_art_manifest = read_manifest_for_project(&root).expect("read new-art manifest"); + for task_id in ["art-director", "art-asset-plan"] { + assert_eq!( + new_art_manifest + .tasks + .iter() + .find(|task| task.id == task_id) + .expect("new-art task exists") + .status, + GameCreationAppTaskStatus::Pending, + "an explicit new-art request must not reuse old art: {task_id}" + ); + } +} + #[test] fn game_chat_legacy_spritesheet_without_private_receipt_uses_same_owner_repair_plan() { let _config_guard = crate::tests::write_test_local_config( @@ -816,12 +1545,16 @@ fn game_chat_code_prototype_ignores_art_director_global_revision_before_its_own_ let root = temporary.path().join("project"); init_local_game_project_at(&root, "game-chat-code-revision", "太空飞船收集能量") .expect("init game-chat code revision project"); - let (root_state, art_director_state) = queue_game_chat_fast_path_child( + let (root_state, mut art_director_state) = queue_game_chat_fast_path_child( &root, "game-chat-code-revision-root", "制作太空飞船收集能量小游戏", "art-director", ); + art_director_state.status = "running".to_string(); + art_director_state.phase = "planning".to_string(); + append_game_creator_agent_runtime_task(&root, &art_director_state) + .expect("append running art-director child"); { let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( &root, @@ -895,7 +1628,7 @@ fn game_chat_code_prototype_ignores_art_director_global_revision_before_its_own_ } #[test] -fn game_chat_existing_game_requires_code_mutation_before_smoke_and_then_converges() { +fn game_chat_existing_game_requires_code_mutation_and_full_completion_before_delivery() { let temporary = tempfile::tempdir().expect("create existing game code root"); let root = temporary.path().join("project"); init_local_game_project_at(&root, "game-chat-existing-code", "水晶俄罗斯方块") @@ -911,6 +1644,10 @@ fn game_chat_existing_game_requires_code_mutation_before_smoke_and_then_converge "code-prototype", ); code_state.loop_iteration = 2; + code_state.status = "running".to_string(); + code_state.phase = "planning".to_string(); + append_game_creator_agent_runtime_task(&root, &code_state) + .expect("append running existing-game code child"); let bound_at = read_game_creator_agent_runtime_run_profile_binding( &root, &root_state.agent_id, @@ -963,16 +1700,17 @@ fn game_chat_existing_game_requires_code_mutation_before_smoke_and_then_converge .expect("finish existing game smoke"); } - let delivery = game_chat_fast_path_plan_at( - &root, - &code_state, - &code_state.current_task, - bound_at + GAME_CHAT_FIRST_PLAYABLE_SOFT_BUDGET_SECONDS, - ) - .expect("evaluate existing game after code mutation") - .expect("verified code child uses deterministic delivery"); - assert!(delivery.actions.is_empty()); - assert!(delivery.response.contains("通过静态自检")); + assert!( + game_chat_fast_path_plan_at( + &root, + &code_state, + &code_state.current_task, + bound_at + GAME_CHAT_FIRST_PLAYABLE_SOFT_BUDGET_SECONDS, + ) + .expect("evaluate existing game after code mutation") + .is_none(), + "static smoke alone must return control to Provider until the full completion gate passes" + ); let code_gate = read_game_creator_agent_runtime_verification_gate( &root, &code_state.agent_id, @@ -981,14 +1719,6 @@ fn game_chat_existing_game_requires_code_mutation_before_smoke_and_then_converge .expect("read converged code gate"); assert_eq!(code_gate.mutation_revision, Some(1)); assert_eq!(code_gate.verified_revision, Some(1)); - validate_agent_runtime_autonomous_specialist_response_delivery( - &code_state.agent_id, - &code_state.run_id, - false, - &code_gate, - &delivery, - ) - .expect("specialist delivery accepts the code child's own verified mutation"); } #[test] @@ -1203,6 +1933,63 @@ fn standard_specialist_empty_plan_has_no_deterministic_final_reply_fallback() { .is_none()); } +#[test] +fn legacy_runtime_hydrates_started_at_from_the_full_task_journal() { + const RUN_ID: &str = "legacy-runtime-started-at-run"; + let temporary = tempfile::tempdir().expect("create legacy started-at root"); + let root = temporary.path().join("project"); + init_local_game_project_at(&root, "legacy-started-at", "恢复旧 Run 开始时间") + .expect("init legacy started-at project"); + let state = start_game_creator_agent_runtime_task_at( + &root, + "code-director", + "恢复旧 Run 开始时间", + RUN_ID, + "test", + "开始旧 Run", + vec!["读取旧 Run journal".to_string()], + ) + .expect("start legacy started-at runtime"); + assert!(state.started_at > 0); + + let session_path = game_creator_agent_runtime_session_path(&root, "code-director"); + let mut legacy = serde_json::from_str::( + &fs::read_to_string(&session_path).expect("read started-at runtime state"), + ) + .expect("parse started-at runtime state"); + legacy + .as_object_mut() + .expect("runtime state object") + .remove("startedAt"); + fs::write( + &session_path, + serde_json::to_vec_pretty(&legacy).expect("serialize legacy runtime state"), + ) + .expect("write legacy runtime state without startedAt"); + + let hydrated = read_game_creator_agent_runtime_at(&root, "code-director") + .expect("read hydrated legacy runtime"); + let earliest = read_all_game_creator_agent_runtime_tasks( + &game_creator_agent_runtime_task_path(&root, "code-director"), + ) + .expect("read legacy runtime journal") + .into_iter() + .filter(|record| record.run_id == RUN_ID) + .map(|record| record.updated_at) + .min() + .expect("legacy runtime journal has records"); + assert_eq!(hydrated.state.started_at, earliest); + let latest = + read_latest_game_creator_agent_runtime_task_by_run_id(&root, "code-director", RUN_ID) + .expect("read latest legacy task") + .expect("latest legacy task exists"); + assert_eq!( + agent_runtime_state_from_task_record(&latest).started_at, + 0, + "a latest task projection must not masquerade as the durable Run start", + ); +} + #[test] fn autonomous_manifest_waiting_context_persists_without_finishing_parent_run() { const RUN_ID: &str = "autonomous-manifest-waiting-parent"; @@ -1270,6 +2057,415 @@ fn autonomous_manifest_waiting_context_persists_without_finishing_parent_run() { })); } +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn game_chat_first_wave_scheduler_starts_every_child_and_remains_idempotent() { + const PARENT_RUN_ID: &str = "game-chat-first-wave-liveness-parent"; + const PARENT_TASK: &str = "继续完成俄罗斯方块"; + const HISTORICAL_CODE_RUN_ID: &str = "game-chat-first-wave-historical-code"; + const FIRST_WAVE: [&str; 3] = ["design-director", "art-director", "code-director"]; + let _config_guard = crate::tests::write_test_local_config("{}".to_string()); + let temporary = tempfile::tempdir().expect("create game-chat first-wave root"); + let root = temporary.path().join("project"); + init_local_game_project_at(&root, "game-chat-first-wave", PARENT_TASK) + .expect("init game-chat first-wave project"); + + let historical_code = start_game_creator_agent_runtime_task_at( + &root, + "code-director", + "历史程序拆解任务", + HISTORICAL_CODE_RUN_ID, + "test-history", + "执行历史程序拆解", + vec!["完成历史程序拆解".to_string()], + ) + .expect("start historical code-director run"); + finish_game_creator_agent_runtime_turn_at(&root, historical_code, "历史程序拆解已完成") + .expect("complete historical code-director run"); + + let parent_lane = try_acquire_game_creator_agent_runtime_task_lock( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + ) + .expect("acquire game-chat parent lane") + .expect("game-chat parent lane is free"); + start_game_creator_supervisor_background_task_for_session_at( + &root, + None, + PARENT_TASK, + PARENT_RUN_ID, + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD, + ) + .expect("queue game-chat parent"); + assert!(FIRST_WAVE.iter().all(|agent_id| { + game_creator_agent_runtime_task_lock_is_available(&root, agent_id) + .expect("probe unoccupied first-wave child lane") + })); + + let scheduled = schedule_autonomous_game_build_ready_tasks_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + PARENT_RUN_ID, + 3, + ) + .expect("schedule game-chat first wave"); + assert_eq!(scheduled.len(), FIRST_WAVE.len()); + assert_eq!( + scheduled + .iter() + .map(|result| result.state.agent_id.as_str()) + .collect::>(), + FIRST_WAVE + .into_iter() + .collect::>() + ); + + schedule_autonomous_game_build_ready_tasks_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + PARENT_RUN_ID, + 3, + ) + .expect("idempotently reschedule game-chat first wave"); + + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + loop { + let all_started = FIRST_WAVE.iter().all(|agent_id| { + let run_id = autonomous_manifest_ready_task_run_id(PARENT_RUN_ID, agent_id); + let records = read_all_game_creator_agent_runtime_tasks( + &game_creator_agent_runtime_task_path(&root, agent_id), + ) + .expect("read first-wave child journal"); + let wrote_running = records + .iter() + .any(|record| record.run_id == run_id && record.status == "running"); + let wrote_turn_started = read_game_creator_agent_runtime_at(&root, agent_id) + .expect("read first-wave child runtime") + .recent_events + .iter() + .any(|event| event.run_id == run_id && event.event_type == "turn.started"); + wrote_running && wrote_turn_started + }); + if all_started { + break; + } + assert!( + std::time::Instant::now() < deadline, + "game-chat first-wave child remained queued without running/turn.started" + ); + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + } + + for agent_id in FIRST_WAVE { + let expected_run_id = autonomous_manifest_ready_task_run_id(PARENT_RUN_ID, agent_id); + let logical_runs = latest_game_creator_agent_runtime_tasks( + read_all_game_creator_agent_runtime_tasks(&game_creator_agent_runtime_task_path( + &root, agent_id, + )) + .expect("read idempotent first-wave child journal"), + ) + .into_iter() + .filter(|record| { + record.source == "agent-ready-task-scheduler" + && record.parent_run_id.as_deref() == Some(PARENT_RUN_ID) + }) + .collect::>(); + assert_eq!( + logical_runs.len(), + 1, + "duplicate logical run for {agent_id}" + ); + assert_eq!(logical_runs[0].run_id, expected_run_id); + } + + cancel_game_creator_agent_runtime_task_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + PARENT_RUN_ID, + ) + .expect("cancel queued game-chat parent after liveness assertion"); + let release_deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + loop { + let all_released = FIRST_WAVE.iter().all(|agent_id| { + game_creator_agent_runtime_task_lock_is_available(&root, agent_id) + .expect("probe first-wave child lane release") + }); + if all_released { + break; + } + assert!( + std::time::Instant::now() < release_deadline, + "game-chat first-wave child lane did not release after the no-provider fixture failed" + ); + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + } + drop(parent_lane); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn game_chat_first_wave_scheduler_starts_child_when_scheduled_audit_fails() { + const PARENT_RUN_ID: &str = "game-chat-first-wave-audit-failure-parent"; + const PARENT_TASK: &str = "继续完成俄罗斯方块"; + const FIRST_AGENT_ID: &str = "design-director"; + let _config_guard = crate::tests::write_test_local_config("{}".to_string()); + let temporary = tempfile::tempdir().expect("create scheduled-audit failure root"); + let root = temporary.path().join("project"); + init_local_game_project_at(&root, "game-chat-first-wave-audit", PARENT_TASK) + .expect("init scheduled-audit failure project"); + + let parent_lane = try_acquire_game_creator_agent_runtime_task_lock( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + ) + .expect("acquire scheduled-audit parent lane") + .expect("scheduled-audit parent lane is free"); + start_game_creator_supervisor_background_task_for_session_at( + &root, + None, + PARENT_TASK, + PARENT_RUN_ID, + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD, + ) + .expect("queue scheduled-audit parent"); + let failure_marker = root.join(".agent/runtime/test-fail-next-agent-db-record"); + fs::write( + &failure_marker, + "agent.runtime.autonomous_ready_task.scheduled\n", + ) + .expect("inject scheduled audit failure"); + + let scheduled = schedule_autonomous_game_build_ready_tasks_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + PARENT_RUN_ID, + 1, + ) + .expect("nonessential scheduled audit failure must not block child execution"); + assert_eq!(scheduled.len(), 1); + assert_eq!(scheduled[0].state.agent_id, FIRST_AGENT_ID); + assert!(!failure_marker.exists()); + let run_id = autonomous_manifest_ready_task_run_id(PARENT_RUN_ID, FIRST_AGENT_ID); + let records = read_all_game_creator_agent_runtime_tasks(&game_creator_agent_runtime_task_path( + &root, + FIRST_AGENT_ID, + )) + .expect("read scheduled-audit child journal"); + assert!(records + .iter() + .any(|record| record.run_id == run_id && record.status == "running")); + assert!(read_game_creator_agent_runtime_at(&root, FIRST_AGENT_ID) + .expect("read scheduled-audit child runtime") + .recent_events + .iter() + .any(|event| event.run_id == run_id && event.event_type == "turn.started")); + + cancel_game_creator_agent_runtime_task_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + PARENT_RUN_ID, + ) + .expect("cancel scheduled-audit parent"); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + while !game_creator_agent_runtime_task_lock_is_available(&root, FIRST_AGENT_ID) + .expect("probe scheduled-audit child lane") + { + assert!( + std::time::Instant::now() < deadline, + "scheduled-audit child lane did not release", + ); + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + } + drop(parent_lane); +} + +#[tokio::test(flavor = "current_thread")] +async fn game_chat_first_wave_scheduler_fails_closed_when_child_first_poll_times_out() { + const PARENT_RUN_ID: &str = "game-chat-first-wave-timeout-parent"; + const PARENT_TASK: &str = "继续完成俄罗斯方块"; + const DELAYED_AGENT_ID: &str = "code-director"; + const FIRST_WAVE: [&str; 3] = ["design-director", "art-director", DELAYED_AGENT_ID]; + let _config_guard = crate::tests::write_test_local_config("{}".to_string()); + let temporary = tempfile::tempdir().expect("create delayed first-wave root"); + let root = temporary.path().join("project"); + init_local_game_project_at(&root, "game-chat-first-wave-timeout", PARENT_TASK) + .expect("init delayed first-wave project"); + + let parent_lane = try_acquire_game_creator_agent_runtime_task_lock( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + ) + .expect("acquire delayed first-wave parent lane") + .expect("delayed first-wave parent lane is free"); + start_game_creator_supervisor_background_task_for_session_at( + &root, + None, + PARENT_TASK, + PARENT_RUN_ID, + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD, + ) + .expect("queue delayed first-wave parent"); + fs::write( + root.join(format!( + ".agent/runtime/test-delay-started-task-first-poll-{DELAYED_AGENT_ID}" + )), + "2500", + ) + .expect("write delayed child first-poll marker"); + + let error = schedule_autonomous_game_build_ready_tasks_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + PARENT_RUN_ID, + 3, + ) + .expect_err("delayed child first poll must fail the scheduler call"); + assert!(error.contains(DELAYED_AGENT_ID)); + assert!(error.contains("execution 启动失败")); + + for agent_id in ["design-director", "art-director"] { + let run_id = autonomous_manifest_ready_task_run_id(PARENT_RUN_ID, agent_id); + let records = read_all_game_creator_agent_runtime_tasks( + &game_creator_agent_runtime_task_path(&root, agent_id), + ) + .expect("read started sibling journal"); + assert!(records + .iter() + .any(|record| record.run_id == run_id && record.status == "running")); + assert!(read_game_creator_agent_runtime_at(&root, agent_id) + .expect("read started sibling runtime") + .recent_events + .iter() + .any(|event| event.run_id == run_id && event.event_type == "turn.started")); + } + + let delayed_run_id = autonomous_manifest_ready_task_run_id(PARENT_RUN_ID, DELAYED_AGENT_ID); + let delayed = read_latest_game_creator_agent_runtime_task_by_run_id( + &root, + DELAYED_AGENT_ID, + &delayed_run_id, + ) + .expect("read delayed child journal") + .expect("delayed child journal exists"); + assert_eq!(delayed.status, "failed"); + assert_eq!(delayed.phase, "failed"); + assert_eq!( + read_manifest_for_project(&root) + .expect("read manifest after delayed child failure") + .tasks + .into_iter() + .find(|task| task.id == DELAYED_AGENT_ID) + .expect("delayed manifest task exists") + .status, + GameCreationAppTaskStatus::Failed, + ); + assert!( + game_creator_agent_runtime_task_lock_is_available(&root, DELAYED_AGENT_ID,) + .expect("probe delayed child lane after timeout") + ); + + cancel_game_creator_agent_runtime_task_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + PARENT_RUN_ID, + ) + .expect("cancel delayed first-wave parent"); + let release_deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + loop { + let all_released = FIRST_WAVE.iter().all(|agent_id| { + game_creator_agent_runtime_task_lock_is_available(&root, agent_id) + .expect("probe delayed first-wave child lane release") + }); + if all_released { + break; + } + assert!( + std::time::Instant::now() < release_deadline, + "delayed first-wave sibling lane did not release" + ); + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + } + drop(parent_lane); +} + +#[test] +fn game_chat_first_wave_scheduler_projects_child_start_failure() { + const PARENT_RUN_ID: &str = "game-chat-first-wave-start-failure-parent"; + const PARENT_TASK: &str = "继续完成俄罗斯方块"; + const FAILED_AGENT_ID: &str = "design-director"; + let temporary = tempfile::tempdir().expect("create failed-start first-wave root"); + let root = temporary.path().join("project"); + init_local_game_project_at(&root, "game-chat-first-wave-start-failure", PARENT_TASK) + .expect("init failed-start first-wave project"); + + let parent_lane = try_acquire_game_creator_agent_runtime_task_lock( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + ) + .expect("acquire failed-start first-wave parent lane") + .expect("failed-start first-wave parent lane is free"); + start_game_creator_supervisor_background_task_for_session_at( + &root, + None, + PARENT_TASK, + PARENT_RUN_ID, + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD, + ) + .expect("queue failed-start first-wave parent"); + fs::write( + root.join(format!( + ".agent/runtime/test-fail-autonomous-ready-task-start-{FAILED_AGENT_ID}" + )), + "fail", + ) + .expect("write child start failure marker"); + + let error = schedule_autonomous_game_build_ready_tasks_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + PARENT_RUN_ID, + 1, + ) + .expect_err("injected child start failure must fail the scheduler call"); + assert!(error.contains(FAILED_AGENT_ID)); + assert!(error.contains("child 启动失败")); + + let failed_run_id = autonomous_manifest_ready_task_run_id(PARENT_RUN_ID, FAILED_AGENT_ID); + let failed = read_latest_game_creator_agent_runtime_task_by_run_id( + &root, + FAILED_AGENT_ID, + &failed_run_id, + ) + .expect("read failed-start child journal") + .expect("failed-start child journal exists"); + assert_eq!(failed.status, "failed"); + assert_eq!(failed.phase, "failed"); + assert_eq!( + read_manifest_for_project(&root) + .expect("read manifest after child start failure") + .tasks + .into_iter() + .find(|task| task.id == FAILED_AGENT_ID) + .expect("failed-start manifest task exists") + .status, + GameCreationAppTaskStatus::Failed, + ); + assert!( + game_creator_agent_runtime_task_lock_is_available(&root, FAILED_AGENT_ID) + .expect("probe failed-start child lane") + ); + + cancel_game_creator_agent_runtime_task_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + PARENT_RUN_ID, + ) + .expect("cancel failed-start first-wave parent"); + drop(parent_lane); +} + #[tokio::test] async fn missing_completed_visual_asset_fails_same_child_without_retry() { const PARENT_RUN_ID: &str = "autonomous-visual-recovery-parent"; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_execution.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_execution.rs index e7370c78f..6fa4290dc 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_execution.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_execution.rs @@ -606,6 +606,7 @@ async fn continue_game_creator_agent_pending_tool_action_within_stack_boundary( &action, &observation, Some(&pending.action_id), + Some(&pending.action_fingerprint), ); complete_agent_runtime_active_plan_step( &mut runtime, @@ -662,6 +663,7 @@ async fn continue_game_creator_agent_pending_tool_action_within_stack_boundary( &action, &observation, Some(&pending.action_id), + Some(&pending.action_fingerprint), ); complete_agent_runtime_active_plan_step( &mut runtime, @@ -1093,6 +1095,7 @@ pub(in crate::agent) fn mark_game_creator_agent_runtime_tool_observation_needs_r &pending.action, observation, Some(&pending.action_id), + Some(&pending.action_fingerprint), ); // needs-reconciliation 是外部结果未知边界,不是结构化计划步骤的确定失败。 // 保持 active,后续同一 action 对账成功时才能完成该步骤,并让持久 context diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/provider_recovery.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/provider_recovery.rs index 241b646ed..1c2f55a5e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/provider_recovery.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/provider_recovery.rs @@ -1,5 +1,27 @@ use super::*; +const AUTONOMOUS_MANIFEST_PARENT_WAKE_MAX_ATTEMPTS: usize = 200; +const AUTONOMOUS_MANIFEST_PARENT_WAKE_RETRY_DELAY_MS: u64 = 10; +const AUTONOMOUS_MANIFEST_PARENT_WAKE_RECONCILIATION_ATTEMPTS: usize = 8; +const AUTONOMOUS_MANIFEST_PARENT_WAKE_RECONCILIATION_DELAY_MS: u64 = 100; +const AUTONOMOUS_MANIFEST_PARENT_WAKE_RECONCILIATION_PENDING_ACTION: &str = + "项目任务图唤醒终态等待持久化"; +const AUTONOMOUS_MANIFEST_PARENT_WAKE_RECONCILIATION_ACTION_ID: &str = + "autonomous-manifest-parent-wake-reconciliation"; +const AUTONOMOUS_MANIFEST_PARENT_WAKE_RECONCILIATION_EVENT_TYPE: &str = + "autonomous_manifest.parent_wake.needs_reconciliation"; +const AUTONOMOUS_MANIFEST_PARENT_WAKE_RECONCILIATION_AUDIT_TYPE: &str = + "agent.runtime.autonomous_manifest.parent_wake.needs_reconciliation"; +const AUTONOMOUS_MANIFEST_PARENT_WAKE_RECONCILIATION_SUMMARY: &str = + "项目任务图已停止自动唤醒,等待开发者核对。"; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum AutonomousManifestParentWakeReconciliationOutcome { + Projected, + NoLongerApplicable, + Deferred, +} + pub(crate) fn autonomous_manifest_parent_wake_error_is_transient(error: &str) -> bool { let normalized = error.to_ascii_lowercase(); error.starts_with("项目正在被其他写操作占用:") @@ -106,7 +128,21 @@ pub(crate) fn schedule_waiting_autonomous_manifest_parent_wake_after_lane_releas tauri::async_runtime::spawn(async move { let mut singleflight = singleflight; loop { - drive_waiting_autonomous_manifest_parent_wake_pass(&root, &agent_id, &run_id).await; + if let Err(error) = + drive_waiting_autonomous_manifest_parent_wake_pass(&root, &agent_id, &run_id).await + { + let error = redact_agent_runtime_project_paths(&root, &error, 500); + let _ = append_agent_db_record( + &root, + serde_json::json!({ + "recordType": "agent.runtime.autonomous_manifest.parent_wake.persistence_failed", + "agentId": agent_id, + "runId": run_id, + "error": error, + }), + ); + eprintln!("项目任务图自动唤醒状态持久化失败:{error}"); + } if !singleflight.finish_pass() { return; } @@ -118,43 +154,150 @@ async fn drive_waiting_autonomous_manifest_parent_wake_pass( root: &Path, agent_id: &str, run_id: &str, -) { - for _ in 0..200 { - tokio::time::sleep(Duration::from_millis(10)).await; - let Ok(Some(task)) = - read_latest_game_creator_agent_runtime_task_by_run_id(root, agent_id, run_id) - else { - return; - }; +) -> Result<(), String> { + drive_waiting_autonomous_manifest_parent_wake_pass_with_budget( + root, + agent_id, + run_id, + AUTONOMOUS_MANIFEST_PARENT_WAKE_MAX_ATTEMPTS, + AUTONOMOUS_MANIFEST_PARENT_WAKE_RETRY_DELAY_MS, + AUTONOMOUS_MANIFEST_PARENT_WAKE_RECONCILIATION_ATTEMPTS, + AUTONOMOUS_MANIFEST_PARENT_WAKE_RECONCILIATION_DELAY_MS, + true, + ) + .await +} + +async fn drive_waiting_autonomous_manifest_parent_wake_pass_with_budget( + root: &Path, + agent_id: &str, + run_id: &str, + max_attempts: usize, + retry_delay_ms: u64, + reconciliation_attempts: usize, + reconciliation_delay_ms: u64, + request_deferred_rerun: bool, +) -> Result<(), String> { + if let Some(deferred_error) = + read_autonomous_manifest_parent_wake_reconciliation_signal_at(root, agent_id, run_id)? + { + return settle_autonomous_manifest_parent_wake_needs_reconciliation_at( + root, + agent_id, + run_id, + &deferred_error, + reconciliation_attempts, + reconciliation_delay_ms, + request_deferred_rerun, + ) + .await; + } + for _ in 0..max_attempts { + if retry_delay_ms > 0 { + tokio::time::sleep(Duration::from_millis(retry_delay_ms)).await; + } + let task = + match read_latest_game_creator_agent_runtime_task_by_run_id(root, agent_id, run_id) { + Ok(Some(task)) => task, + Ok(None) => return Ok(()), + Err(error) if autonomous_manifest_parent_wake_error_is_transient(&error) => { + continue; + } + Err(error) => { + return Err(format!( + "读取项目任务图父 durable task 失败,不能当作任务不存在:{error}" + )); + } + }; if task.status != "running" || task.phase != "waiting-for-manifest-tasks" { - return; + return Ok(()); } match wake_waiting_autonomous_manifest_parent_run_at(root, &task) { - Ok(true) => return, + Ok(true) => return Ok(()), Ok(false) => match autonomous_manifest_dag_in_progress_at(root) { - Ok(true) => return, + Ok(true) => return Ok(()), Ok(false) => {} Err(error) if autonomous_manifest_parent_wake_error_is_transient(&error) => { continue; } Err(error) => { - let _ = mark_autonomous_manifest_parent_wake_needs_reconciliation_at( - root, agent_id, run_id, &error, - ); - return; + return settle_autonomous_manifest_parent_wake_needs_reconciliation_at( + root, + agent_id, + run_id, + &error, + reconciliation_attempts, + reconciliation_delay_ms, + request_deferred_rerun, + ) + .await; } }, Err(error) if autonomous_manifest_parent_wake_error_is_transient(&error) => { continue; } Err(error) => { - let _ = mark_autonomous_manifest_parent_wake_needs_reconciliation_at( - root, agent_id, run_id, &error, - ); - return; + return settle_autonomous_manifest_parent_wake_needs_reconciliation_at( + root, + agent_id, + run_id, + &error, + reconciliation_attempts, + reconciliation_delay_ms, + request_deferred_rerun, + ) + .await; } } } + settle_autonomous_manifest_parent_wake_needs_reconciliation_at( + root, + agent_id, + run_id, + &format!("项目任务图自动唤醒在 {max_attempts} 次重试预算内持续遇到瞬态冲突"), + reconciliation_attempts, + reconciliation_delay_ms, + request_deferred_rerun, + ) + .await +} + +async fn settle_autonomous_manifest_parent_wake_needs_reconciliation_at( + root: &Path, + agent_id: &str, + run_id: &str, + error: &str, + attempts: usize, + retry_delay_ms: u64, + request_deferred_rerun: bool, +) -> Result<(), String> { + let attempts = attempts.max(1); + for attempt in 0..attempts { + match try_mark_autonomous_manifest_parent_wake_needs_reconciliation_at( + root, agent_id, run_id, error, + ) { + Ok(AutonomousManifestParentWakeReconciliationOutcome::Projected) + | Ok(AutonomousManifestParentWakeReconciliationOutcome::NoLongerApplicable) => { + return Ok(()) + } + Ok(AutonomousManifestParentWakeReconciliationOutcome::Deferred) => {} + Err(projection_error) + if autonomous_manifest_parent_wake_error_is_transient(&projection_error) + && attempt + 1 < attempts => {} + Err(projection_error) => return Err(projection_error), + } + if attempt + 1 < attempts && retry_delay_ms > 0 { + tokio::time::sleep(Duration::from_millis(retry_delay_ms)).await; + } + } + if request_deferred_rerun { + schedule_waiting_autonomous_manifest_parent_wake_after_lane_release( + root.to_path_buf(), + agent_id.to_string(), + run_id.to_string(), + ); + } + Ok(()) } pub(crate) fn schedule_waiting_static_delegate_parent_wake_after_lane_release( @@ -457,18 +600,680 @@ pub(in crate::agent) fn mark_autonomous_manifest_parent_wake_needs_reconciliatio run_id: &str, error: &str, ) -> Result<(), String> { - let Some(_runtime_lock) = - try_acquire_game_creator_agent_runtime_task_lock_with_wait(root, agent_id)? - else { - return Err(format!( - "无法取得父 Agent execution lane 以记录 manifest reconciliation:{agent_id}" - )); + match try_mark_autonomous_manifest_parent_wake_needs_reconciliation_at( + root, agent_id, run_id, error, + )? { + AutonomousManifestParentWakeReconciliationOutcome::Projected + | AutonomousManifestParentWakeReconciliationOutcome::NoLongerApplicable => Ok(()), + AutonomousManifestParentWakeReconciliationOutcome::Deferred => Err(format!( + "父 Agent execution lane 仍被占用;manifest reconciliation 恢复信号已持久化:{agent_id}" + )), + } +} + +fn read_autonomous_manifest_parent_wake_reconciliation_signal_at( + root: &Path, + agent_id: &str, + run_id: &str, +) -> Result, String> { + let path = game_creator_agent_runtime_event_path(root, agent_id); + let file = match File::open(&path) { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => { + return Err(format!( + "读取项目任务图唤醒恢复信号失败:{}: {error}", + path.display() + )) + } }; - let mut runtime = read_game_creator_agent_runtime_at(root, agent_id)?.state; - if runtime.run_id != run_id || runtime.phase != "waiting-for-manifest-tasks" { + let mut signal = None; + for line in BufReader::new(file).lines() { + let line = line.map_err(|error| { + format!( + "读取项目任务图唤醒恢复信号失败:{}: {error}", + path.display() + ) + })?; + if line.trim().is_empty() { + continue; + } + let event = serde_json::from_str::(&line).map_err(|error| { + format!( + "解析项目任务图唤醒恢复信号失败:{}: {error}", + path.display() + ) + })?; + if event.run_id == run_id + && event.event_type == "autonomous_manifest.parent_wake.reconciliation_deferred" + { + signal = Some(event.detail.unwrap_or_else(|| { + "项目任务图自动唤醒终态曾因 execution lane 忙而延迟持久化".to_string() + })); + } else if event.run_id == run_id + && event.event_type == "autonomous_manifest.parent_wake.reconciliation_resolved" + { + signal = None; + } + } + Ok(signal) +} + +fn resolve_autonomous_manifest_parent_wake_reconciliation_signal_at( + root: &Path, + state: &AgentRuntimeState, + reason: &str, +) -> Result<(), String> { + if read_autonomous_manifest_parent_wake_reconciliation_signal_at( + root, + &state.agent_id, + &state.run_id, + )? + .is_none() + { return Ok(()); } - let error = sanitize_agent_runtime_text(error, 500); + append_game_creator_agent_runtime_event( + root, + state, + "autonomous_manifest.parent_wake.reconciliation_resolved", + &state.status, + &state.phase, + "项目任务图唤醒恢复信号已由锁内最新状态复核收束。", + Some(reason), + ) +} + +fn persist_autonomous_manifest_parent_wake_reconciliation_signal_at( + root: &Path, + agent_id: &str, + run_id: &str, + error: &str, +) -> Result { + let Some(task) = read_latest_game_creator_agent_runtime_task_by_run_id(root, agent_id, run_id)? + else { + return Ok(AutonomousManifestParentWakeReconciliationOutcome::NoLongerApplicable); + }; + if task.status != "running" || task.phase != "waiting-for-manifest-tasks" { + resolve_autonomous_manifest_parent_wake_reconciliation_signal_for_task_at( + root, + &task, + "旧项目任务图父 task 已终止,延迟唤醒恢复信号不再适用。", + )?; + return Ok(AutonomousManifestParentWakeReconciliationOutcome::NoLongerApplicable); + } + if let Ok(runtime) = read_raw_autonomous_manifest_parent_runtime_state_at(root, agent_id) { + if autonomous_manifest_parent_wake_runtime_identity_is_usable(&runtime) + && (runtime.run_id != task.run_id || runtime.session_id != task.session_id) + { + resolve_autonomous_manifest_parent_wake_reconciliation_signal_for_task_at( + root, + &task, + "Runtime 已由新 run 接管,旧项目任务图父唤醒恢复信号已 superseded。", + )?; + return Ok(AutonomousManifestParentWakeReconciliationOutcome::NoLongerApplicable); + } + if autonomous_manifest_parent_wake_runtime_identity_matches_task(&runtime, &task) + && (runtime.status != "running" || runtime.phase != "waiting-for-manifest-tasks") + { + resolve_autonomous_manifest_parent_wake_reconciliation_signal_for_task_at( + root, + &task, + "Runtime 已离开 waiting 状态,旧项目任务图父唤醒恢复信号不再适用。", + )?; + return Ok(AutonomousManifestParentWakeReconciliationOutcome::NoLongerApplicable); + } + } + let signal_state = agent_runtime_state_from_task_record(&AgentRuntimeTaskRecord { + current_action: AUTONOMOUS_MANIFEST_PARENT_WAKE_RECONCILIATION_PENDING_ACTION.to_string(), + error: Some(redact_agent_runtime_project_paths(root, error, 500)), + updated_at: unix_timestamp(), + ..task + }); + let signal_exists = + read_autonomous_manifest_parent_wake_reconciliation_signal_at(root, agent_id, run_id)? + .is_some(); + if !signal_exists { + append_game_creator_agent_runtime_event( + root, + &signal_state, + "autonomous_manifest.parent_wake.reconciliation_deferred", + "running", + "waiting-for-manifest-tasks", + "项目任务图唤醒终态等待 execution lane 持久化,Runner 重启时仍按当前 waiting task 恢复。", + signal_state.error.as_deref(), + )?; + let _ = append_agent_db_record( + root, + serde_json::json!({ + "recordType": "agent.runtime.autonomous_manifest.parent_wake.reconciliation_deferred", + "agentId": signal_state.agent_id, + "taskId": signal_state.task_id, + "sessionId": signal_state.session_id, + "runId": signal_state.run_id, + "error": signal_state.error, + }), + ); + } + emit_game_creator_agent_runtime_update(root, agent_id); + Ok(AutonomousManifestParentWakeReconciliationOutcome::Deferred) +} + +fn read_raw_autonomous_manifest_parent_runtime_state_at( + root: &Path, + agent_id: &str, +) -> Result { + let path = game_creator_agent_runtime_session_path(root, agent_id); + let content = fs::read_to_string(&path).map_err(|error| { + format!( + "读取项目任务图父 Runtime 原始状态失败:{}: {error}", + path.display() + ) + })?; + serde_json::from_str::(&content).map_err(|error| { + format!( + "解析项目任务图父 Runtime 原始状态失败:{}: {error}", + path.display() + ) + }) +} + +fn write_raw_autonomous_manifest_parent_runtime_state_at( + root: &Path, + state: &AgentRuntimeState, +) -> Result<(), String> { + let path = game_creator_agent_runtime_session_path(root, &state.agent_id); + let parent = path + .parent() + .ok_or_else(|| "项目任务图父 Runtime 状态路径缺少父目录".to_string())?; + fs::create_dir_all(parent).map_err(|error| { + format!( + "创建项目任务图父 Runtime 状态目录失败:{}: {error}", + parent.display() + ) + })?; + let content = serde_json::to_string_pretty(state) + .map_err(|error| format!("序列化项目任务图父 Runtime 状态失败:{error}"))?; + let temp_path = path.with_file_name(format!( + ".{}.tmp.{}.{}", + path.file_name() + .and_then(|value| value.to_str()) + .unwrap_or("runtime.json"), + std::process::id(), + unix_timestamp_nanos() + )); + fs::write(&temp_path, format!("{content}\n")).map_err(|error| { + format!( + "写入项目任务图父 Runtime 临时状态失败:{}: {error}", + temp_path.display() + ) + })?; + fs::rename(&temp_path, &path).map_err(|error| { + let _ = fs::remove_file(&temp_path); + format!( + "替换项目任务图父 Runtime 状态失败:{} -> {}: {error}", + temp_path.display(), + path.display() + ) + }) +} + +fn append_autonomous_manifest_parent_wake_reconciliation_task_at_locked( + root: &Path, + expected: &AgentRuntimeTaskRecord, + reconciliation: &AgentRuntimeTaskRecord, +) -> Result<(), String> { + let _journal_lock = + acquire_game_creator_agent_runtime_task_journal_lock(root, &expected.agent_id)?; + let latest = read_latest_game_creator_agent_runtime_task_by_run_id( + root, + &expected.agent_id, + &expected.run_id, + )? + .ok_or_else(|| "项目任务图父 durable task 在终态持久化前消失".to_string())?; + if latest.agent_id != expected.agent_id + || latest.task_id != expected.task_id + || latest.session_id != expected.session_id + || latest.run_id != expected.run_id + || latest.source != expected.source + || latest.run_profile != expected.run_profile + || latest.run_profile_binding_fingerprint != expected.run_profile_binding_fingerprint + || latest.parent_agent_id != expected.parent_agent_id + || latest.parent_run_id != expected.parent_run_id + || latest.delegation_id != expected.delegation_id + || latest.task != expected.task + || latest.status != "running" + || latest.phase != "waiting-for-manifest-tasks" + { + return Err("项目任务图父 durable task 在终态持久化前已推进".to_string()); + } + let path = game_creator_agent_runtime_task_path(root, &expected.agent_id); + let line = serde_json::to_string(reconciliation) + .map_err(|error| format!("序列化项目任务图父 reconciliation task 失败:{error}"))?; + append_jsonl_line(&path, &line, "项目任务图父 reconciliation task") +} + +fn autonomous_manifest_parent_wake_reconciliation_task_is_commit_marker( + task: &AgentRuntimeTaskRecord, +) -> bool { + task.status == "failed" + && task.phase == "needs-reconciliation" + && task.current_action == "项目任务图唤醒需要人工核对" + && task + .error + .as_deref() + .is_some_and(|error| !error.trim().is_empty()) +} + +fn autonomous_manifest_parent_wake_runtime_identity_matches_task( + runtime: &AgentRuntimeState, + task: &AgentRuntimeTaskRecord, +) -> bool { + runtime.agent_id == task.agent_id + && runtime.task_id == task.task_id + && runtime.session_id == task.session_id + && runtime.run_id == task.run_id + && runtime.source == task.source + && runtime.run_profile == task.run_profile + && runtime.run_profile_binding_fingerprint == task.run_profile_binding_fingerprint + && runtime.parent_agent_id == task.parent_agent_id + && runtime.parent_run_id == task.parent_run_id + && runtime.delegation_id == task.delegation_id + && runtime.goal_id == task.goal_id + && runtime.goal_revision == task.goal_revision + && runtime.current_task == task.task +} + +fn autonomous_manifest_parent_wake_runtime_identity_is_usable(runtime: &AgentRuntimeState) -> bool { + !runtime.agent_id.trim().is_empty() + && !runtime.task_id.trim().is_empty() + && !runtime.session_id.trim().is_empty() + && !runtime.run_id.trim().is_empty() + && !runtime.source.trim().is_empty() + && !runtime.run_profile.trim().is_empty() + && !runtime.run_profile_binding_fingerprint.trim().is_empty() + && !runtime.current_task.trim().is_empty() +} + +fn resolve_autonomous_manifest_parent_wake_reconciliation_signal_for_task_at( + root: &Path, + task: &AgentRuntimeTaskRecord, + reason: &str, +) -> Result<(), String> { + resolve_autonomous_manifest_parent_wake_reconciliation_signal_at( + root, + &agent_runtime_state_from_task_record(task), + reason, + ) +} + +fn autonomous_manifest_parent_wake_reconciliation_event_exists_exactly_once( + root: &Path, + runtime: &AgentRuntimeState, +) -> Result { + let path = game_creator_agent_runtime_event_path(root, &runtime.agent_id); + let file = match File::open(&path) { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false), + Err(error) => { + return Err(format!( + "读取项目任务图父 reconciliation event 失败:{}: {error}", + path.display() + )) + } + }; + let expected_detail = runtime + .error + .as_deref() + .map(|error| sanitize_agent_runtime_text(error, 500)); + let mut exact_matches = 0usize; + for line in BufReader::new(file).lines() { + let line = line.map_err(|error| { + format!( + "读取项目任务图父 reconciliation event 失败:{}: {error}", + path.display() + ) + })?; + if line.trim().is_empty() { + continue; + } + let event = serde_json::from_str::(&line).map_err(|error| { + format!( + "解析项目任务图父 reconciliation event 失败:{}: {error}", + path.display() + ) + })?; + if event.run_id != runtime.run_id + || event.event_type != AUTONOMOUS_MANIFEST_PARENT_WAKE_RECONCILIATION_EVENT_TYPE + { + continue; + } + if event.agent_id != runtime.agent_id + || event.task_id != runtime.task_id + || event.session_id != runtime.session_id + || event.source != runtime.source + || event.action_id.is_some() + || event.status != "failed" + || event.phase != "needs-reconciliation" + || event.summary != AUTONOMOUS_MANIFEST_PARENT_WAKE_RECONCILIATION_SUMMARY + || event.detail != expected_detail + { + return Err(format!( + "项目任务图父 reconciliation event 内容冲突:agentId={} runId={}", + runtime.agent_id, runtime.run_id + )); + } + exact_matches = exact_matches.saturating_add(1); + if exact_matches > 1 { + return Err(format!( + "项目任务图父 reconciliation event 重复:agentId={} runId={}", + runtime.agent_id, runtime.run_id + )); + } + } + Ok(exact_matches == 1) +} + +fn autonomous_manifest_parent_wake_reconciliation_audit_exists_exactly_once( + root: &Path, + runtime: &AgentRuntimeState, +) -> Result { + let path = root.join(".agent/agent.db"); + let file = match File::open(&path) { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false), + Err(error) => { + return Err(format!( + "读取项目任务图父 reconciliation audit 失败:{}: {error}", + path.display() + )) + } + }; + let expected_error = runtime + .error + .as_deref() + .map(|error| serde_json::Value::String(error.to_string())) + .unwrap_or(serde_json::Value::Null); + let mut exact_matches = 0usize; + for line in BufReader::new(file).lines() { + let line = line.map_err(|error| { + format!( + "读取项目任务图父 reconciliation audit 失败:{}: {error}", + path.display() + ) + })?; + if line.trim().is_empty() { + continue; + } + let record = serde_json::from_str::(&line).map_err(|error| { + format!( + "解析项目任务图父 reconciliation audit 失败:{}: {error}", + path.display() + ) + })?; + if record.get("recordType").and_then(serde_json::Value::as_str) + != Some(AUTONOMOUS_MANIFEST_PARENT_WAKE_RECONCILIATION_AUDIT_TYPE) + || record.get("agentId").and_then(serde_json::Value::as_str) + != Some(runtime.agent_id.as_str()) + || record.get("runId").and_then(serde_json::Value::as_str) + != Some(runtime.run_id.as_str()) + || record.get("actionId").and_then(serde_json::Value::as_str) + != Some(AUTONOMOUS_MANIFEST_PARENT_WAKE_RECONCILIATION_ACTION_ID) + { + continue; + } + if record.get("taskId").and_then(serde_json::Value::as_str) + != Some(runtime.task_id.as_str()) + || record.get("sessionId").and_then(serde_json::Value::as_str) + != Some(runtime.session_id.as_str()) + || record.get("source").and_then(serde_json::Value::as_str) + != Some(runtime.source.as_str()) + || record.get("status").and_then(serde_json::Value::as_str) + != Some("needs-reconciliation") + || record.get("error") != Some(&expected_error) + { + return Err(format!( + "项目任务图父 reconciliation audit 内容冲突:agentId={} runId={}", + runtime.agent_id, runtime.run_id + )); + } + exact_matches = exact_matches.saturating_add(1); + if exact_matches > 1 { + return Err(format!( + "项目任务图父 reconciliation audit 重复:agentId={} runId={}", + runtime.agent_id, runtime.run_id + )); + } + } + Ok(exact_matches == 1) +} + +fn project_autonomous_manifest_parent_wake_reconciliation_commit_at_locked( + root: &Path, + task: &AgentRuntimeTaskRecord, +) -> Result<(), String> { + if !autonomous_manifest_parent_wake_reconciliation_task_is_commit_marker(task) { + return Err("项目任务图父 reconciliation task 不是合法提交标记".to_string()); + } + let mut runtime = match read_raw_autonomous_manifest_parent_runtime_state_at( + root, + &task.agent_id, + ) { + Ok(runtime) + if autonomous_manifest_parent_wake_runtime_identity_is_usable(&runtime) + && autonomous_manifest_parent_wake_runtime_identity_matches_task( + &runtime, task, + ) => + { + runtime + } + Ok(runtime) if autonomous_manifest_parent_wake_runtime_identity_is_usable(&runtime) => { + return Err(format!( + "项目任务图父 reconciliation task 与 Runtime state 身份冲突:taskRun={} stateRun={}", + task.run_id, runtime.run_id + )); + } + Ok(_) | Err(_) => agent_runtime_state_from_task_record(task), + }; + runtime.status = task.status.clone(); + runtime.phase = task.phase.clone(); + runtime.current_action = task.current_action.clone(); + runtime.waiting_on = "开发者核对 manifest、子任务终态与父 run 状态".to_string(); + runtime.next_step = "修复损坏或冲突的任务图状态后显式恢复该 run".to_string(); + runtime.error = task.error.clone(); + runtime.updated_at = task.updated_at; + refresh_game_creator_agent_runtime_task_queue(root, &mut runtime) + .map_err(|error| format!("修复项目任务图父 reconciliation queue 失败:{error}"))?; + write_raw_autonomous_manifest_parent_runtime_state_at(root, &runtime) + .map_err(|error| format!("修复项目任务图父 reconciliation state 失败:{error}"))?; + if !autonomous_manifest_parent_wake_reconciliation_event_exists_exactly_once(root, &runtime)? { + append_game_creator_agent_runtime_event( + root, + &runtime, + AUTONOMOUS_MANIFEST_PARENT_WAKE_RECONCILIATION_EVENT_TYPE, + "failed", + "needs-reconciliation", + AUTONOMOUS_MANIFEST_PARENT_WAKE_RECONCILIATION_SUMMARY, + runtime.error.as_deref(), + ) + .map_err(|error| format!("修复项目任务图父 reconciliation event 失败:{error}"))?; + } + if !autonomous_manifest_parent_wake_reconciliation_audit_exists_exactly_once(root, &runtime)? { + append_agent_db_record( + root, + serde_json::json!({ + "recordType": AUTONOMOUS_MANIFEST_PARENT_WAKE_RECONCILIATION_AUDIT_TYPE, + "agentId": runtime.agent_id, + "taskId": runtime.task_id, + "sessionId": runtime.session_id, + "runId": runtime.run_id, + "actionId": AUTONOMOUS_MANIFEST_PARENT_WAKE_RECONCILIATION_ACTION_ID, + "source": runtime.source, + "status": "needs-reconciliation", + "error": runtime.error, + }), + ) + .map_err(|error| format!("修复项目任务图父 reconciliation audit 失败:{error}"))?; + } + resolve_autonomous_manifest_parent_wake_reconciliation_signal_at( + root, + &runtime, + "needs-reconciliation 已完成持久化。", + ) + .map_err(|error| format!("收束项目任务图父 reconciliation 恢复信号失败:{error}"))?; + emit_game_creator_agent_runtime_update(root, &task.agent_id); + Ok(()) +} + +pub(in crate::agent) fn repair_autonomous_manifest_parent_wake_reconciliation_projection_at( + root: &Path, + agent_id: &str, +) -> Result, String> { + let path = game_creator_agent_runtime_task_path(root, agent_id); + let latest = + latest_game_creator_agent_runtime_tasks(read_all_game_creator_agent_runtime_tasks(&path)?); + let current_run_id = read_raw_autonomous_manifest_parent_runtime_state_at(root, agent_id) + .ok() + .filter(autonomous_manifest_parent_wake_runtime_identity_is_usable) + .map(|runtime| runtime.run_id); + let marker = current_run_id + .as_deref() + .and_then(|run_id| latest.iter().find(|task| task.run_id == run_id)) + .or_else(|| current_run_id.is_none().then(|| latest.last()).flatten()); + let Some(marker) = marker + .filter(|task| autonomous_manifest_parent_wake_reconciliation_task_is_commit_marker(task)) + else { + return Ok(None); + }; + let _project_lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "runtime.autonomous.parent_wake.reconciliation-repair", + )?; + project_autonomous_manifest_parent_wake_reconciliation_commit_at_locked(root, marker)?; + read_game_creator_agent_runtime_at(root, agent_id).map(Some) +} + +fn try_mark_autonomous_manifest_parent_wake_needs_reconciliation_at( + root: &Path, + agent_id: &str, + run_id: &str, + error: &str, +) -> Result { + let Some(runtime_lock) = + try_acquire_game_creator_agent_runtime_task_lock_with_wait(root, agent_id) + .map_err(|error| format!("取得项目任务图父 Agent execution lane 失败:{error}"))? + else { + return persist_autonomous_manifest_parent_wake_reconciliation_signal_at( + root, agent_id, run_id, error, + ); + }; + let project_lock = match acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "runtime.autonomous.parent_wake.reconciliation", + ) { + Ok(lock) => lock, + Err(lock_error) => { + drop(runtime_lock); + return persist_autonomous_manifest_parent_wake_reconciliation_signal_at( + root, + agent_id, + run_id, + &format!("{error};终态复核项目锁失败:{lock_error}"), + ); + } + }; + let Some(task) = read_latest_game_creator_agent_runtime_task_by_run_id(root, agent_id, run_id) + .map_err(|error| format!("读取项目任务图父 durable task 失败:{error}"))? + else { + return Ok(AutonomousManifestParentWakeReconciliationOutcome::NoLongerApplicable); + }; + if autonomous_manifest_parent_wake_reconciliation_task_is_commit_marker(&task) { + project_autonomous_manifest_parent_wake_reconciliation_commit_at_locked(root, &task)?; + return Ok(AutonomousManifestParentWakeReconciliationOutcome::Projected); + } + if task.status != "running" || task.phase != "waiting-for-manifest-tasks" { + resolve_autonomous_manifest_parent_wake_reconciliation_signal_for_task_at( + root, + &task, + "旧项目任务图父 task 已终止,延迟唤醒恢复信号不再适用。", + )?; + return Ok(AutonomousManifestParentWakeReconciliationOutcome::NoLongerApplicable); + } + let mut runtime = match read_raw_autonomous_manifest_parent_runtime_state_at(root, agent_id) { + Ok(runtime) if autonomous_manifest_parent_wake_runtime_identity_is_usable(&runtime) => { + runtime + } + Ok(_) | Err(_) => agent_runtime_state_from_task_record(&task), + }; + if runtime.run_id != run_id || runtime.session_id != task.session_id { + resolve_autonomous_manifest_parent_wake_reconciliation_signal_for_task_at( + root, + &task, + "Runtime 已由新 run 接管,旧项目任务图父唤醒恢复信号已 superseded。", + )?; + return Ok(AutonomousManifestParentWakeReconciliationOutcome::NoLongerApplicable); + } + if game_creator_agent_runtime_cancel_requested_for(root, agent_id, run_id) { + mark_game_creator_agent_runtime_cancelled_at_locked( + root, + &mut runtime, + "Agent 后台任务已按开发者请求取消", + Some("取消请求在项目任务图唤醒终态投影前生效。"), + )?; + resolve_autonomous_manifest_parent_wake_reconciliation_signal_at( + root, + &runtime, + "开发者取消请求优先于旧唤醒失败。", + )?; + drop(project_lock); + drop(runtime_lock); + return Ok(AutonomousManifestParentWakeReconciliationOutcome::NoLongerApplicable); + } + if runtime.status != "running" || runtime.phase != "waiting-for-manifest-tasks" { + resolve_autonomous_manifest_parent_wake_reconciliation_signal_for_task_at( + root, + &task, + "Runtime 已离开 waiting 状态,旧项目任务图父唤醒恢复信号不再适用。", + )?; + return Ok(AutonomousManifestParentWakeReconciliationOutcome::NoLongerApplicable); + } + let context_task_error = + validate_agent_runtime_context_task_parameter(root, &runtime, &task.task).err(); + let reconciliation_error = match autonomous_manifest_dag_in_progress_at(root) { + Ok(true) => { + resolve_autonomous_manifest_parent_wake_reconciliation_signal_at( + root, + &runtime, + "manifest 或绑定 child 已有更新进展,旧唤醒失败不再适用。", + )?; + drop(project_lock); + drop(runtime_lock); + return Ok(AutonomousManifestParentWakeReconciliationOutcome::NoLongerApplicable); + } + Ok(false) => context_task_error + .map(|context_error| format!("{error};父 run 任务上下文复核失败:{context_error}")) + .unwrap_or_else(|| error.to_string()), + Err(recheck_error) + if autonomous_manifest_parent_wake_error_is_transient(&recheck_error) => + { + drop(project_lock); + drop(runtime_lock); + return persist_autonomous_manifest_parent_wake_reconciliation_signal_at( + root, + agent_id, + run_id, + &format!("{error};锁内复核 manifest 任务图仍遇到瞬态冲突:{recheck_error}"), + ); + } + Err(recheck_error) => context_task_error + .map(|context_error| { + format!( + "{error};父 run 任务上下文复核失败:{context_error};锁内复核 manifest 任务图失败:{recheck_error}" + ) + }) + .unwrap_or_else(|| format!("{error};锁内复核 manifest 任务图失败:{recheck_error}")), + }; + let error = redact_agent_runtime_project_paths(root, &reconciliation_error, 500); runtime.status = "failed".to_string(); runtime.phase = "needs-reconciliation".to_string(); runtime.current_action = "项目任务图唤醒需要人工核对".to_string(); @@ -476,31 +1281,91 @@ pub(in crate::agent) fn mark_autonomous_manifest_parent_wake_needs_reconciliatio runtime.next_step = "修复损坏或冲突的任务图状态后显式恢复该 run".to_string(); runtime.error = Some(error.clone()); runtime.updated_at = unix_timestamp(); - append_game_creator_agent_runtime_task(root, &runtime)?; - refresh_game_creator_agent_runtime_task_queue(root, &mut runtime)?; - write_game_creator_agent_runtime_state(root, &runtime)?; - let _ = append_game_creator_agent_runtime_event( + let reconciliation_task = AgentRuntimeTaskRecord { + status: "failed".to_string(), + phase: "needs-reconciliation".to_string(), + current_action: runtime.current_action.clone(), + terminal_detail: Some(error.clone()), + error: Some(error.clone()), + updated_at: runtime.updated_at, + ..task.clone() + }; + append_autonomous_manifest_parent_wake_reconciliation_task_at_locked( root, - &runtime, - "autonomous_manifest.parent_wake.needs_reconciliation", - "failed", - "needs-reconciliation", - "项目任务图已停止自动唤醒,等待开发者核对。", - Some(&error), - ); - let _ = append_agent_db_record( + &task, + &reconciliation_task, + ) + .map_err(|error| format!("持久化项目任务图父 reconciliation task 失败:{error}"))?; + project_autonomous_manifest_parent_wake_reconciliation_commit_at_locked( root, - serde_json::json!({ - "recordType": "agent.runtime.autonomous_manifest.parent_wake.needs_reconciliation", - "agentId": runtime.agent_id, - "taskId": runtime.task_id, - "sessionId": runtime.session_id, - "runId": runtime.run_id, - "error": error, - }), - ); - emit_game_creator_agent_runtime_update(root, agent_id); - Ok(()) + &reconciliation_task, + )?; + drop(project_lock); + drop(runtime_lock); + Ok(AutonomousManifestParentWakeReconciliationOutcome::Projected) +} + +#[cfg(test)] +pub(crate) async fn drive_waiting_autonomous_manifest_parent_wake_budget_for_test( + root: &Path, + agent_id: &str, + run_id: &str, + max_attempts: usize, +) -> Result<(), String> { + drive_waiting_autonomous_manifest_parent_wake_pass_with_budget( + root, + agent_id, + run_id, + max_attempts, + 0, + 1, + 0, + false, + ) + .await +} + +#[cfg(test)] +pub(crate) fn prepare_waiting_autonomous_manifest_parent_for_test( + root: &Path, + agent_id: &str, + run_id: &str, +) -> Result { + let task = read_latest_game_creator_agent_runtime_task_by_run_id(root, agent_id, run_id)? + .ok_or_else(|| "测试缺少待准备的自主构建父任务".to_string())?; + let mut state = agent_runtime_state_from_task_record(&task); + state.status = "running".to_string(); + state.phase = "waiting-for-manifest-tasks".to_string(); + state.current_action = "等待项目专业任务图收束".to_string(); + state.waiting_on = "manifest 子任务完成、失败或依赖阻塞".to_string(); + state.next_step = "子任务终态后自动唤醒当前父 run".to_string(); + state.updated_at = unix_timestamp(); + append_game_creator_agent_runtime_task(root, &state)?; + write_game_creator_agent_runtime_state(root, &state)?; + Ok(state) +} + +#[cfg(test)] +pub(crate) fn mark_autonomous_manifest_parent_wake_needs_reconciliation_for_test( + root: &Path, + agent_id: &str, + run_id: &str, + error: &str, +) -> Result<&'static str, String> { + try_mark_autonomous_manifest_parent_wake_needs_reconciliation_at(root, agent_id, run_id, error) + .map(|outcome| match outcome { + AutonomousManifestParentWakeReconciliationOutcome::Projected => "projected", + AutonomousManifestParentWakeReconciliationOutcome::NoLongerApplicable => "obsolete", + AutonomousManifestParentWakeReconciliationOutcome::Deferred => "deferred", + }) +} + +#[cfg(test)] +pub(crate) fn repair_autonomous_manifest_parent_wake_reconciliation_projection_for_test( + root: &Path, + agent_id: &str, +) -> Result, String> { + repair_autonomous_manifest_parent_wake_reconciliation_projection_at(root, agent_id) } pub(in crate::agent) fn persist_waiting_static_delegate_parent_context_at( diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/recovery_scan.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/recovery_scan.rs index 974216689..6df4b5238 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/recovery_scan.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/recovery_scan.rs @@ -670,6 +670,19 @@ pub(in crate::agent) fn resume_game_creator_agent_background_tasks_unredacted_at resume_external_agent_runner(root)?; return read_game_creator_agent_runtimes_at(root); } + if let Some(runtime_lock) = try_acquire_game_creator_agent_runtime_task_lock( + root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + )? { + if let Some(result) = repair_autonomous_manifest_parent_wake_reconciliation_projection_at( + root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + )? { + drop(runtime_lock); + return Ok(vec![result]); + } + drop(runtime_lock); + } cleanup_orphaned_platform_art_generation_runtime_states_at(root)?; let agent_ids = collect_game_creator_agent_runtime_agent_ids(root)?; if !current_game_creator_agent_runtime_finalization_exists_at(root, &agent_ids)? { @@ -977,16 +990,26 @@ pub(in crate::agent) fn resume_game_creator_agent_background_tasks_unredacted_at } } if task.phase == "waiting-for-manifest-tasks" { - if let Err(error) = - schedule_autonomous_game_build_ready_tasks_at(root, &task.agent_id, &task.run_id, 3) - { - drop(runtime_lock); - mark_autonomous_manifest_parent_wake_needs_reconciliation_at( - root, - &agent_id, - &task.run_id, - &format!("Runner 重启恢复 manifest 任务图失败:{error}"), - )?; + let scheduled_ready_tasks = match schedule_autonomous_game_build_ready_tasks_at( + root, + &task.agent_id, + &task.run_id, + 3, + ) { + Ok(tasks) => tasks, + Err(error) => { + drop(runtime_lock); + mark_autonomous_manifest_parent_wake_needs_reconciliation_at( + root, + &agent_id, + &task.run_id, + &format!("Runner 重启恢复 manifest 任务图失败:{error}"), + )?; + resumed.push(read_game_creator_agent_runtime_at(root, &agent_id)?); + continue; + } + }; + if !scheduled_ready_tasks.is_empty() { resumed.push(read_game_creator_agent_runtime_at(root, &agent_id)?); continue; } 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 38d5a9f5e..06833591c 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 @@ -203,6 +203,79 @@ pub(crate) fn spawn_next_game_creator_agent_background_task_drain_with_lock( }); } +pub(crate) fn spawn_started_game_creator_agent_background_task_drain_with_lock( + root: &Path, + agent_id: &str, + first_task: String, + first_state: AgentRuntimeState, + runtime_lock: AgentRuntimeTaskLock, +) -> Result<(), (String, AgentRuntimeTaskLock)> { + debug_assert!(!external_agent_runner_owns_background_execution()); + #[cfg(test)] + let first_poll_delay = { + let path = root.join(format!( + ".agent/runtime/test-delay-started-task-first-poll-{agent_id}" + )); + let delay = fs::read_to_string(&path) + .ok() + .and_then(|value| value.trim().parse::().ok()) + .map(|milliseconds| Duration::from_millis(milliseconds.min(5_000))) + .unwrap_or_default(); + let _ = fs::remove_file(path); + delay + }; + let root = root.to_path_buf(); + let agent_id = agent_id.to_string(); + let (first_poll_sender, first_poll_receiver) = std::sync::mpsc::sync_channel(1); + let (runtime_lock_sender, runtime_lock_receiver) = std::sync::mpsc::sync_channel(1); + let worker_name = format!( + "agent-ready-task-{}", + sanitize_agent_runtime_text(&agent_id, 48) + ); + if let Err(error) = std::thread::Builder::new() + .name(worker_name) + .stack_size(16 * 1024 * 1024) + .spawn(move || { + #[cfg(test)] + if !first_poll_delay.is_zero() { + std::thread::sleep(first_poll_delay); + } + tauri::async_runtime::block_on(async move { + if first_poll_sender.send(()).is_err() { + return; + } + let Ok(runtime_lock) = runtime_lock_receiver.recv() else { + return; + }; + let _runtime_lock = runtime_lock; + drain_game_creator_agent_background_tasks(root, agent_id, first_task, first_state) + .await; + }); + }) + { + return Err(( + format!("创建 Agent Runtime child execution worker 失败:{error}"), + runtime_lock, + )); + } + if first_poll_receiver + .recv_timeout(Duration::from_secs(2)) + .is_err() + { + return Err(( + "Agent Runtime child execution future 未在 2 秒内开始轮询".to_string(), + runtime_lock, + )); + } + if let Err(error) = runtime_lock_sender.send(runtime_lock) { + return Err(( + "Agent Runtime child execution future 在接管执行锁前已退出".to_string(), + error.0, + )); + } + Ok(()) +} + pub(in crate::agent) fn fail_game_creator_agent_background_context_at( root: &Path, agent_id: &str, 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 22304db10..4ef1b0148 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 @@ -616,7 +616,7 @@ fn validate_autonomous_game_build_ready_task_parent_at( Ok(binding) } -fn autonomous_game_build_root_task_is_active(task: &AgentRuntimeTaskRecord) -> bool { +pub(crate) fn autonomous_game_build_root_task_is_active(task: &AgentRuntimeTaskRecord) -> bool { matches!( task.status.as_str(), "pending" | "running" | "waiting-for-confirmation" | "waiting-for-user-input" @@ -626,7 +626,7 @@ fn autonomous_game_build_root_task_is_active(task: &AgentRuntimeTaskRecord) -> b ) } -fn current_autonomous_game_build_root_task_at( +pub(crate) fn current_autonomous_game_build_root_task_at( root: &Path, ) -> Result, String> { let path = game_creator_agent_runtime_task_path(root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID); @@ -1042,8 +1042,11 @@ pub(crate) fn schedule_autonomous_game_build_ready_tasks_at( } let mut results = Vec::new(); - for (task, result, record, needs_notification, runtime_lock) in scheduled { - append_agent_db_record( + for (task, mut result, record, needs_notification, runtime_lock) in scheduled { + // This record is diagnostic only. The durable child task and manifest + // transition already exist, so an audit sink failure must not strand + // the child in queued before its execution worker is started. + let _ = append_agent_db_record( root, serde_json::json!({ "recordType": "agent.runtime.autonomous_ready_task.scheduled", @@ -1059,14 +1062,117 @@ pub(crate) fn schedule_autonomous_game_build_ready_tasks_at( "role": task.role.clone(), "recovered": needs_notification, }), - )?; + ); if game_creator_agent_runtime_terminal_status(&record).is_none() { if let Some(runtime_lock) = runtime_lock { - spawn_next_game_creator_agent_background_task_drain_with_lock( - root, - &record.agent_id, - runtime_lock, - ); + if external_agent_runner_owns_background_execution() { + spawn_next_game_creator_agent_background_task_drain_with_lock( + root, + &record.agent_id, + runtime_lock, + ); + } else { + match (|| { + #[cfg(test)] + { + let injected_failure = root.join(format!( + ".agent/runtime/test-fail-autonomous-ready-task-start-{}", + record.agent_id + )); + if fs::remove_file(injected_failure).is_ok() { + return Err( + "测试注入 autonomous ready-task child 启动失败".to_string() + ); + } + } + start_game_creator_agent_runtime_task_for_session_at( + root, + &record.agent_id, + Some(&record.session_id), + &record.task, + &record.run_id, + &record.source, + "后台任务从队列开始执行", + game_creator_agent_background_task_default_plan(), + ) + })() { + Ok(state) => { + let _ = append_game_creator_agent_background_task_started_record( + root, &state, + ); + let launch = + spawn_started_game_creator_agent_background_task_drain_with_lock( + root, + &record.agent_id, + record.task.clone(), + state.clone(), + runtime_lock, + ); + match launch { + Ok(()) => { + result.state = state.clone(); + result.task_queue = state.task_queue.clone(); + } + Err((error, runtime_lock)) => { + let error = format!( + "autonomous ready-task child execution 启动失败:taskId={};{error}", + task.id + ); + let failure = fail_game_creator_agent_runtime_turn_at( + root, state, &error, + ); + let error = match failure.and_then(|failed| { + project_autonomous_manifest_ready_task_terminal_at( + root, &failed, + )?; + Ok(failed) + }) { + Ok(failed) => { + result.task_queue = failed.task_queue.clone(); + result.state = failed; + error + } + Err(persistence_error) => format!( + "{error};失败状态或任务图收口失败:{persistence_error}" + ), + }; + drop(runtime_lock); + if first_error.is_none() { + first_error = Some(error); + } + } + } + } + Err(error) => { + let error = format!( + "autonomous ready-task child 启动失败:taskId={};{error}", + task.id + ); + let fallback = agent_runtime_state_from_task_record(&record); + let failure = + fail_game_creator_agent_runtime_turn_at(root, fallback, &error); + let error = match failure.and_then(|failed| { + project_autonomous_manifest_ready_task_terminal_at(root, &failed)?; + Ok(failed) + }) { + Ok(failed) => { + result.task_queue = failed.task_queue.clone(); + result.state = failed; + error + } + Err(persistence_error) => { + format!( + "{error};失败状态或任务图收口失败:{persistence_error}" + ) + } + }; + drop(runtime_lock); + if first_error.is_none() { + first_error = Some(error); + } + } + } + } } } if needs_notification diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs index adc96c656..7ecadee1e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs @@ -2,9 +2,10 @@ use super::*; use oxc_allocator::Allocator as JavascriptAllocator; use oxc_ast::ast::{ Argument as JavascriptArgument, ArrowFunctionExpression as JavascriptArrowFunctionExpression, - AssignmentOperator as JavascriptAssignmentOperator, BindingPattern as JavascriptBindingPattern, - BindingProperty as JavascriptBindingProperty, CallExpression as JavascriptCallExpression, - Class as JavascriptClass, ComputedMemberExpression as JavascriptComputedMemberExpression, + AssignmentOperator as JavascriptAssignmentOperator, BinaryOperator as JavascriptBinaryOperator, + BindingPattern as JavascriptBindingPattern, BindingProperty as JavascriptBindingProperty, + CallExpression as JavascriptCallExpression, Class as JavascriptClass, + ComputedMemberExpression as JavascriptComputedMemberExpression, Declaration as JavascriptDeclaration, ExportAllDeclaration as JavascriptExportAllDeclaration, ExportDeclaration as JavascriptExportDeclaration, ExportDefaultDeclarationKind as JavascriptExportDefaultDeclarationKind, @@ -14,19 +15,20 @@ use oxc_ast::ast::{ ImportDeclarationSpecifier as JavascriptImportDeclarationSpecifier, ImportExpression as JavascriptImportExpression, MethodDefinition as JavascriptMethodDefinition, ModuleExportName as JavascriptModuleExportName, ObjectExpression as JavascriptObjectExpression, - ObjectProperty as JavascriptObjectProperty, PropertyDefinition as JavascriptPropertyDefinition, - RegExpLiteral as JavascriptRegExpLiteral, Statement as JavascriptStatement, - StaticMemberExpression as JavascriptStaticMemberExpression, + ObjectProperty as JavascriptObjectProperty, Program as JavascriptProgram, + PropertyDefinition as JavascriptPropertyDefinition, RegExpLiteral as JavascriptRegExpLiteral, + Statement as JavascriptStatement, StaticMemberExpression as JavascriptStaticMemberExpression, StringLiteral as JavascriptStringLiteral, TemplateElement as JavascriptTemplateElement, UnaryExpression as JavascriptUnaryExpression, UnaryOperator as JavascriptUnaryOperator, + VariableDeclaration as JavascriptVariableDeclaration, VariableDeclarator as JavascriptVariableDeclarator, }; use oxc_ast_visit::Visit as VisitJavascript; use oxc_parser::Parser as JavascriptParser; use oxc_semantic::{ ReferenceId as JavascriptReferenceId, ScopeFlags as JavascriptScopeFlags, - Scoping as JavascriptScoping, SemanticBuilder as JavascriptSemanticBuilder, - SymbolId as JavascriptSymbolId, + ScopeId as JavascriptScopeId, Scoping as JavascriptScoping, + SemanticBuilder as JavascriptSemanticBuilder, SymbolId as JavascriptSymbolId, }; use oxc_span::{GetSpan as JavascriptGetSpan, SourceType as JavascriptSourceType}; @@ -3722,13 +3724,316 @@ fn named_javascript_function_ranges(content: &str) -> NamedJavascriptFunctionRan } } +struct JavascriptDirectFunctionInvocationCollector<'a> { + scoping: &'a JavascriptScoping, + known_promise_symbols: &'a BTreeSet, + content_len: usize, + binding_ranges: BTreeMap>, + expression_ranges: BTreeMap<(usize, usize), Vec>, + alias_events: BTreeMap>, + ranges: &'a mut [NamedJavascriptFunctionRange], +} + +#[derive(Clone, Copy)] +enum JavascriptDirectCallableAliasValue { + Symbol(JavascriptSymbolId), + Expression((usize, usize)), + Cleared, +} + +#[derive(Clone, Copy)] +struct JavascriptDirectCallableAliasEvent { + position: usize, + scope: Option<(usize, usize)>, + value: JavascriptDirectCallableAliasValue, +} + +struct JavascriptDirectCallableAliasCollector<'a> { + scoping: &'a JavascriptScoping, + function_ranges: &'a [NamedJavascriptFunctionRange], + conditional_ranges: &'a [std::ops::Range], + events: BTreeMap>, +} + +impl JavascriptDirectCallableAliasCollector<'_> { + fn value(&self, expression: &JavascriptExpression<'_>) -> JavascriptDirectCallableAliasValue { + let expression = javascript_unwrap_parenthesized_expression(expression); + if let Some(identifier) = expression.get_identifier_reference() { + return identifier + .reference_id + .get() + .and_then(|reference_id| self.scoping.get_reference(reference_id).symbol_id()) + .map(JavascriptDirectCallableAliasValue::Symbol) + .unwrap_or(JavascriptDirectCallableAliasValue::Cleared); + } + if matches!( + expression, + JavascriptExpression::FunctionExpression(_) + | JavascriptExpression::ArrowFunctionExpression(_) + ) { + let span = expression.span(); + return JavascriptDirectCallableAliasValue::Expression(( + span.start as usize, + span.end as usize, + )); + } + JavascriptDirectCallableAliasValue::Cleared + } + + fn record( + &mut self, + symbol_id: JavascriptSymbolId, + expression: &JavascriptExpression<'_>, + position: usize, + ) { + let value = if self + .conditional_ranges + .iter() + .any(|range| range.contains(&position)) + { + JavascriptDirectCallableAliasValue::Cleared + } else { + self.value(expression) + }; + self.events + .entry(symbol_id) + .or_default() + .push(JavascriptDirectCallableAliasEvent { + position, + scope: javascript_alias_scope_at(self.function_ranges, position), + value, + }); + } + + fn finish(mut self) -> BTreeMap> { + for events in self.events.values_mut() { + events.sort_by_key(|event| event.position); + } + self.events + } +} + +impl<'a> VisitJavascript<'a> for JavascriptDirectCallableAliasCollector<'_> { + fn visit_variable_declarator(&mut self, declarator: &JavascriptVariableDeclarator<'a>) { + if let (Some(identifier), Some(initializer)) = + (declarator.id.get_binding_identifier(), &declarator.init) + { + if let Some(symbol_id) = identifier.symbol_id.get() { + self.record(symbol_id, initializer, declarator.span.start as usize); + } + } + oxc_ast_visit::walk::walk_variable_declarator(self, declarator); + } + + fn visit_assignment_expression(&mut self, assignment: &oxc_ast::ast::AssignmentExpression<'a>) { + if assignment.operator.is_assign() { + if let oxc_ast::ast::AssignmentTarget::AssignmentTargetIdentifier(identifier) = + &assignment.left + { + if let Some(symbol_id) = identifier + .reference_id + .get() + .and_then(|reference_id| self.scoping.get_reference(reference_id).symbol_id()) + { + self.record(symbol_id, &assignment.right, assignment.span.start as usize); + } + } + } + oxc_ast_visit::walk::walk_assignment_expression(self, assignment); + } +} + +impl JavascriptDirectFunctionInvocationCollector<'_> { + fn callable_indices_for_symbol( + &self, + symbol_id: JavascriptSymbolId, + at: usize, + scope: Option<(usize, usize)>, + visiting: &mut BTreeSet, + ) -> Vec { + if !visiting.insert(symbol_id) { + return Vec::new(); + } + let indices = if let Some(event) = self + .alias_events + .get(&symbol_id) + .into_iter() + .flatten() + .filter(|event| event.position <= at && (event.scope == scope || event.scope.is_none())) + .max_by_key(|event| (usize::from(event.scope == scope), event.position)) + { + match event.value { + JavascriptDirectCallableAliasValue::Symbol(source) => { + self.callable_indices_for_symbol(source, at, scope, visiting) + } + JavascriptDirectCallableAliasValue::Expression(span) => self + .expression_ranges + .get(&span) + .cloned() + .unwrap_or_default(), + JavascriptDirectCallableAliasValue::Cleared => Vec::new(), + } + } else { + self.binding_ranges + .get(&(self.scoping.symbol_span(symbol_id).start as usize)) + .cloned() + .unwrap_or_default() + }; + visiting.remove(&symbol_id); + indices + } + + fn callable_indices(&self, expression: &JavascriptExpression<'_>, at: usize) -> Vec { + let expression = javascript_unwrap_parenthesized_expression(expression); + let scope = javascript_alias_scope_at(self.ranges, at); + if let Some(identifier) = expression.get_identifier_reference() { + return identifier + .reference_id + .get() + .and_then(|reference_id| self.scoping.get_reference(reference_id).symbol_id()) + .map(|symbol_id| { + self.callable_indices_for_symbol(symbol_id, at, scope, &mut BTreeSet::new()) + }) + .unwrap_or_default(); + } + if matches!( + expression, + JavascriptExpression::FunctionExpression(_) + | JavascriptExpression::ArrowFunctionExpression(_) + ) { + let span = expression.span(); + return self + .expression_ranges + .get(&(span.start as usize, span.end as usize)) + .cloned() + .unwrap_or_default(); + } + Vec::new() + } + + fn record(&mut self, indices: Vec, invocation: usize, synchronous: bool) { + let state_at = if synchronous { + invocation + } else { + javascript_asynchronous_state_position(self.ranges, self.content_len, invocation) + }; + for index in indices { + self.ranges[index].invocations.push(invocation); + self.ranges[index].invocation_state_positions.push(( + invocation, + state_at, + !synchronous, + )); + if synchronous { + self.ranges[index].synchronous_invocations.push(invocation); + } + } + } +} + +impl<'a> VisitJavascript<'a> for JavascriptDirectFunctionInvocationCollector<'_> { + fn visit_call_expression(&mut self, call: &JavascriptCallExpression<'a>) { + let invocation = javascript_invocation_effect_position(call.span); + self.record( + self.callable_indices(&call.callee, call.span.start as usize), + invocation, + true, + ); + let synchronous_callback = javascript_known_callback_is_synchronous(call); + for index in javascript_known_callback_argument_indices( + call, + self.scoping, + self.known_promise_symbols, + ) { + if let Some(expression) = call + .arguments + .get(*index) + .and_then(|argument| argument.as_expression()) + { + self.record( + self.callable_indices(expression, call.span.start as usize), + invocation, + synchronous_callback, + ); + } + } + oxc_ast_visit::walk::walk_call_expression(self, call); + } +} + +fn direct_named_javascript_function_ranges( + content: &str, + program: &JavascriptProgram<'_>, + scoping: &JavascriptScoping, +) -> NamedJavascriptFunctionRanges { + let literal_false_ranges = JavascriptLiteralFalseRangeIndex::analyze(content); + let mut definitions = JavascriptFunctionDefinitionCollector::default(); + definitions.visit_program(program); + definitions + .ranges + .sort_by_key(|range| (range.start, range.end)); + definitions.ranges.dedup_by(|left, right| { + left.name == right.name && left.start == right.start && left.end == right.end + }); + let mut binding_ranges = BTreeMap::>::new(); + let mut expression_ranges = BTreeMap::<(usize, usize), Vec>::new(); + for (index, range) in definitions.ranges.iter().enumerate() { + if let Some(binding_start) = range.binding_start { + binding_ranges.entry(binding_start).or_default().push(index); + } + if range.name.starts_with("\0anonymous-") { + expression_ranges + .entry((range.start, range.end)) + .or_default() + .push(index); + } + } + let conditional_ranges = javascript_conditional_execution_ranges(content); + let alias_events = { + let mut aliases = JavascriptDirectCallableAliasCollector { + scoping, + function_ranges: &definitions.ranges, + conditional_ranges: &conditional_ranges, + events: BTreeMap::new(), + }; + aliases.visit_program(program); + aliases.finish() + }; + let mut invocations = JavascriptDirectFunctionInvocationCollector { + scoping, + known_promise_symbols: &javascript_known_native_promise_symbols(program, scoping), + content_len: content.len(), + binding_ranges, + expression_ranges, + alias_events, + ranges: &mut definitions.ranges, + }; + invocations.visit_program(program); + for range in &mut definitions.ranges { + range.invocations.sort_unstable(); + range.invocations.dedup(); + range.invocation_state_positions.sort_unstable(); + range.invocation_state_positions.dedup(); + range.synchronous_invocations.sort_unstable(); + range.synchronous_invocations.dedup(); + } + NamedJavascriptFunctionRanges { + ranges: definitions.ranges, + literal_false_ranges, + } +} + fn javascript_named_function_is_reachable( content: &str, ranges: &NamedJavascriptFunctionRanges, function_index: usize, - visiting: &mut BTreeSet, + visited: &mut BTreeSet, ) -> bool { - if !visiting.insert(function_index) { + // The invocation graph is fixed for this query, so a node that was already + // examined cannot gain reachability through a different incoming path. + // Keeping nodes visited for the full traversal bounds heavy fan-in render + // graphs to one visit per function while still breaking cycles. + if !visited.insert(function_index) { return false; } let definition = &ranges[function_index]; @@ -3749,13 +4054,11 @@ fn javascript_named_function_is_reachable( .min_by_key(|(_, range)| range.end - range.start) .map(|(index, _)| index); if parent.is_none_or(|index| { - javascript_named_function_is_reachable(content, ranges, index, visiting) + javascript_named_function_is_reachable(content, ranges, index, visited) }) { - visiting.remove(&function_index); return true; } } - visiting.remove(&function_index); false } @@ -3822,8 +4125,376 @@ fn identifier_before(content: &str, position: usize) -> Option { struct JavascriptCanvasDraw { position: usize, image: JavascriptSymbolId, - canvas_dimensions: (f64, f64), arguments: Vec, + has_visible_destination: bool, + has_visible_tile_grid_destination: bool, +} + +#[derive(Default)] +struct JavascriptNumericConstantCollector { + values: BTreeMap, +} + +impl<'a> VisitJavascript<'a> for JavascriptNumericConstantCollector { + fn visit_variable_declaration(&mut self, declaration: &JavascriptVariableDeclaration<'a>) { + if declaration.kind.is_const() { + for declarator in &declaration.declarations { + let Some(identifier) = declarator.id.get_binding_identifier() else { + continue; + }; + let value = declarator.init.as_ref().and_then(|initializer| { + if let JavascriptExpression::NumericLiteral(literal) = initializer { + literal.value.is_finite().then_some(literal.value) + } else { + None + } + }); + if let (Some(symbol_id), Some(value)) = (identifier.symbol_id.get(), value) { + self.values.insert(symbol_id, value); + } + } + } + oxc_ast_visit::walk::walk_variable_declaration(self, declaration); + } +} + +fn javascript_numeric_constants( + program: &JavascriptProgram<'_>, +) -> BTreeMap { + let mut collector = JavascriptNumericConstantCollector::default(); + collector.visit_program(program); + collector.values +} + +#[derive(Clone, Copy, Debug, PartialEq)] +struct JavascriptNumericBounds { + minimum: f64, + maximum: f64, +} + +impl JavascriptNumericBounds { + fn point(value: f64) -> Option { + value.is_finite().then_some(Self { + minimum: value, + maximum: value, + }) + } + + fn combine(self, other: Self, operator: JavascriptBinaryOperator) -> Option { + let candidates = match operator { + JavascriptBinaryOperator::Addition => [ + self.minimum + other.minimum, + self.minimum + other.maximum, + self.maximum + other.minimum, + self.maximum + other.maximum, + ], + JavascriptBinaryOperator::Subtraction => [ + self.minimum - other.minimum, + self.minimum - other.maximum, + self.maximum - other.minimum, + self.maximum - other.maximum, + ], + JavascriptBinaryOperator::Multiplication => [ + self.minimum * other.minimum, + self.minimum * other.maximum, + self.maximum * other.minimum, + self.maximum * other.maximum, + ], + JavascriptBinaryOperator::Division + if !(other.minimum..=other.maximum).contains(&0.0) => + { + [ + self.minimum / other.minimum, + self.minimum / other.maximum, + self.maximum / other.minimum, + self.maximum / other.maximum, + ] + } + _ => return None, + }; + let minimum = candidates.into_iter().fold(f64::INFINITY, f64::min); + let maximum = candidates.into_iter().fold(f64::NEG_INFINITY, f64::max); + (minimum.is_finite() && maximum.is_finite()).then_some(Self { minimum, maximum }) + } + + fn merge(self, other: Self) -> Self { + Self { + minimum: self.minimum.min(other.minimum), + maximum: self.maximum.max(other.maximum), + } + } +} + +fn javascript_identifier_symbol( + scoping: &JavascriptScoping, + identifier: &oxc_ast::ast::IdentifierReference<'_>, +) -> Option { + identifier + .reference_id + .get() + .and_then(|reference_id| scoping.get_reference(reference_id).symbol_id()) +} + +fn javascript_numeric_expression_bounds( + expression: &JavascriptExpression<'_>, + scoping: &JavascriptScoping, + constants: &BTreeMap, + dynamic_bounds: &BTreeMap, +) -> Option { + let expression = javascript_unwrap_parenthesized_expression(expression); + match expression { + JavascriptExpression::NumericLiteral(literal) => { + JavascriptNumericBounds::point(literal.value) + } + JavascriptExpression::Identifier(identifier) => { + let symbol_id = javascript_identifier_symbol(scoping, identifier)?; + constants + .get(&symbol_id) + .copied() + .and_then(JavascriptNumericBounds::point) + .or_else(|| dynamic_bounds.get(&symbol_id).copied()) + } + JavascriptExpression::UnaryExpression(unary) => { + let value = javascript_numeric_expression_bounds( + &unary.argument, + scoping, + constants, + dynamic_bounds, + )?; + match unary.operator { + JavascriptUnaryOperator::UnaryPlus => Some(value), + JavascriptUnaryOperator::UnaryNegation => Some(JavascriptNumericBounds { + minimum: -value.maximum, + maximum: -value.minimum, + }), + _ => None, + } + } + JavascriptExpression::BinaryExpression(binary) => { + javascript_numeric_expression_bounds(&binary.left, scoping, constants, dynamic_bounds)? + .combine( + javascript_numeric_expression_bounds( + &binary.right, + scoping, + constants, + dynamic_bounds, + )?, + binary.operator, + ) + } + _ => None, + } +} + +struct JavascriptLoopBoundCollector<'a> { + scoping: &'a JavascriptScoping, + constants: &'a BTreeMap, + bounds: BTreeMap, +} + +impl<'a> VisitJavascript<'a> for JavascriptLoopBoundCollector<'_> { + fn visit_for_statement(&mut self, statement: &oxc_ast::ast::ForStatement<'a>) { + let candidate = (|| { + let declaration = match statement.init.as_ref()? { + oxc_ast::ast::ForStatementInit::VariableDeclaration(declaration) => declaration, + _ => return None, + }; + let declarator = declaration.declarations.first()?; + let identifier = declarator.id.get_binding_identifier()?; + let symbol_id = identifier.symbol_id.get()?; + let initial = javascript_numeric_expression_bounds( + declarator.init.as_ref()?, + self.scoping, + self.constants, + &self.bounds, + )?; + if initial.minimum != initial.maximum { + return None; + } + let JavascriptExpression::BinaryExpression(test) = statement.test.as_ref()? else { + return None; + }; + let left = test.left.get_identifier_reference()?; + if javascript_identifier_symbol(self.scoping, left) != Some(symbol_id) { + return None; + } + let limit = javascript_numeric_expression_bounds( + &test.right, + self.scoping, + self.constants, + &self.bounds, + )?; + if limit.minimum != limit.maximum { + return None; + } + let JavascriptExpression::UpdateExpression(update) = statement.update.as_ref()? else { + return None; + }; + if update.operator.as_str() != "++" { + return None; + } + let oxc_ast::ast::SimpleAssignmentTarget::AssignmentTargetIdentifier(updated) = + &update.argument + else { + return None; + }; + if updated + .reference_id + .get() + .and_then(|reference_id| self.scoping.get_reference(reference_id).symbol_id()) + != Some(symbol_id) + { + return None; + } + let maximum = match test.operator { + JavascriptBinaryOperator::LessThan => limit.maximum - 1.0, + JavascriptBinaryOperator::LessEqualThan => limit.maximum, + _ => return None, + }; + (maximum >= initial.minimum).then_some(( + symbol_id, + JavascriptNumericBounds { + minimum: initial.minimum, + maximum, + }, + )) + })(); + if let Some((symbol_id, bounds)) = candidate { + self.bounds.insert(symbol_id, bounds); + } + oxc_ast_visit::walk::walk_for_statement(self, statement); + } +} + +#[derive(Default)] +struct JavascriptParameterBoundState { + seen: bool, + invalid: bool, + bounds: Option, +} + +struct JavascriptFunctionParameterCollector { + parameters: BTreeMap>, +} + +impl JavascriptFunctionParameterCollector { + fn record( + &mut self, + function_symbol: Option, + parameters: &oxc_ast::ast::FormalParameters<'_>, + ) { + let Some(function_symbol) = function_symbol else { + return; + }; + let parameter_symbols = parameters + .items + .iter() + .filter_map(|parameter| parameter.pattern.get_binding_identifier()) + .filter_map(|identifier| identifier.symbol_id.get()) + .collect::>(); + if !parameter_symbols.is_empty() { + self.parameters.insert(function_symbol, parameter_symbols); + } + } +} + +impl<'a> VisitJavascript<'a> for JavascriptFunctionParameterCollector { + fn visit_function(&mut self, function: &JavascriptFunction<'a>, flags: JavascriptScopeFlags) { + self.record( + function + .id + .as_ref() + .and_then(|identifier| identifier.symbol_id.get()), + &function.params, + ); + oxc_ast_visit::walk::walk_function(self, function, flags); + } + + fn visit_variable_declarator(&mut self, declarator: &JavascriptVariableDeclarator<'a>) { + if let (Some(binding), Some(initializer)) = ( + declarator.id.get_binding_identifier(), + declarator.init.as_ref(), + ) { + let parameters = match initializer { + JavascriptExpression::FunctionExpression(function) => Some(&function.params), + JavascriptExpression::ArrowFunctionExpression(function) => Some(&function.params), + _ => None, + }; + if let Some(parameters) = parameters { + self.record(binding.symbol_id.get(), parameters); + } + } + oxc_ast_visit::walk::walk_variable_declarator(self, declarator); + } +} + +struct JavascriptParameterCallBoundCollector<'a> { + scoping: &'a JavascriptScoping, + content: &'a str, + ranges: &'a NamedJavascriptFunctionRanges, + constants: &'a BTreeMap, + loop_bounds: &'a BTreeMap, + parameters: &'a BTreeMap>, + states: BTreeMap, + direct_invocations: BTreeMap>, +} + +impl<'a> VisitJavascript<'a> for JavascriptParameterCallBoundCollector<'_> { + fn visit_call_expression(&mut self, call: &JavascriptCallExpression<'a>) { + let position = call.span.start as usize; + if javascript_position_is_reachable(self.content, self.ranges, position) { + let function_symbol = call + .callee + .get_identifier_reference() + .and_then(|identifier| javascript_identifier_symbol(self.scoping, identifier)); + if let Some(function_symbol) = function_symbol { + self.direct_invocations + .entry(function_symbol) + .or_default() + .insert(javascript_invocation_effect_position(call.span)); + } + if let Some(parameters) = + function_symbol.and_then(|symbol| self.parameters.get(&symbol)) + { + for (index, parameter) in parameters.iter().enumerate() { + let value = call + .arguments + .get(index) + .and_then(JavascriptArgument::as_expression) + .and_then(|argument| { + javascript_numeric_expression_bounds( + argument, + self.scoping, + self.constants, + self.loop_bounds, + ) + }); + let state = self.states.entry(*parameter).or_default(); + state.seen = true; + match (state.invalid, state.bounds, value) { + (_, _, None) => { + state.invalid = true; + state.bounds = None; + } + (false, None, Some(bounds)) => { + state.bounds = Some(bounds); + } + (false, Some(current), Some(bounds)) => { + state.bounds = Some(current.merge(bounds)); + } + _ => {} + } + } + } + } + oxc_ast_visit::walk::walk_call_expression(self, call); + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +struct JavascriptCanvasBinding { + identity: Option, + dimensions: (f64, f64), } struct JavascriptCanvasVisualCollector<'a> { @@ -3834,8 +4505,12 @@ struct JavascriptCanvasVisualCollector<'a> { visible_canvases: &'a [(Option, (f64, f64))], asset_element_ids: &'a BTreeSet, asset_path: &'a str, - canvas_events: BTreeMap>>, - context_events: BTreeMap>>, + numeric_constants: &'a BTreeMap, + numeric_bounds: &'a BTreeMap, + scope_stack: Vec, + canvas_events: BTreeMap>>, + context_events: + BTreeMap>>, source_events: BTreeMap>>, draws: Vec, } @@ -3882,17 +4557,17 @@ impl JavascriptCanvasVisualCollector<'_> { }) } - fn canvas_expression_dimensions( + fn canvas_expression_binding( &self, expression: &JavascriptExpression<'_>, at: usize, - ) -> Option<(f64, f64)> { + ) -> Option { match expression { JavascriptExpression::Identifier(identifier) => self .symbol_for_identifier(identifier) .and_then(|symbol_id| self.event_value_at(&self.canvas_events, symbol_id, at)), JavascriptExpression::ParenthesizedExpression(parenthesized) => { - self.canvas_expression_dimensions(&parenthesized.expression, at) + self.canvas_expression_binding(&parenthesized.expression, at) } JavascriptExpression::CallExpression(call) => { if let Some(id) = self.global_document_call_argument(call, "getElementById") { @@ -3900,33 +4575,46 @@ impl JavascriptCanvasVisualCollector<'_> { .visible_canvases .iter() .find(|(canvas_id, _)| canvas_id.as_deref() == Some(id)) - .map(|(_, dimensions)| *dimensions) - .filter(|(width, height)| *width > 0.0 && *height > 0.0); + .map(|(_, dimensions)| JavascriptCanvasBinding { + identity: None, + dimensions: *dimensions, + }) + .filter(|binding| { + binding.dimensions.0 > 0.0 && binding.dimensions.1 > 0.0 + }); } let selector = self.global_document_call_argument(call, "querySelector")?; if selector == "canvas" { return self .visible_canvases .first() - .map(|(_, dimensions)| *dimensions) - .filter(|(width, height)| *width > 0.0 && *height > 0.0); + .map(|(_, dimensions)| JavascriptCanvasBinding { + identity: None, + dimensions: *dimensions, + }) + .filter(|binding| { + binding.dimensions.0 > 0.0 && binding.dimensions.1 > 0.0 + }); } let id = selector.strip_prefix('#')?; self.visible_canvases .iter() .find(|(canvas_id, _)| canvas_id.as_deref() == Some(id)) - .map(|(_, dimensions)| *dimensions) - .filter(|(width, height)| *width > 0.0 && *height > 0.0) + .map(|(_, dimensions)| JavascriptCanvasBinding { + identity: None, + dimensions: *dimensions, + }) + .filter(|binding| binding.dimensions.0 > 0.0 && binding.dimensions.1 > 0.0) } _ => None, } } - fn context_expression_dimensions( + fn context_expression_binding( &self, expression: &JavascriptExpression<'_>, at: usize, - ) -> Option<(f64, f64)> { + ) -> Option { if let JavascriptExpression::Identifier(identifier) = expression { return self .symbol_for_identifier(identifier) @@ -3937,7 +4625,7 @@ impl JavascriptCanvasVisualCollector<'_> { }; let member = call.callee.as_member_expression()?; (member.static_property_name()? == "getContext") - .then(|| self.canvas_expression_dimensions(member.object(), at)) + .then(|| self.canvas_expression_binding(member.object(), at)) .flatten() } @@ -3948,6 +4636,20 @@ impl JavascriptCanvasVisualCollector<'_> { at: usize, ) -> Option { let events = events_by_symbol.get(&symbol_id)?; + if events.iter().all(|event| event.value.is_none()) { + return None; + } + // Canvas handles and Image sources are normally initialized once at the + // top level before any render function is declared. In that common + // case there is no interprocedural state to resolve: walking every + // synchronous invocation graph for every identifier can grow + // exponentially on a real game loop with many mutually-calling helper + // functions. Only take this shortcut when every event is an + // unconditional top-level write that precedes the use, so later or + // scoped writes still use the conservative alias analysis below. + if let Some(value) = javascript_stable_ancestor_event_value(events, self.ranges, at) { + return Some(value); + } let selection = javascript_alias_event_indices_at( events, self.ranges, @@ -3971,6 +4673,378 @@ impl JavascriptCanvasVisualCollector<'_> { .then_some(value) } + fn numeric_expression_bounds( + &self, + expression: &JavascriptExpression<'_>, + canvas: JavascriptCanvasBinding, + at: usize, + ) -> Option { + let expression = javascript_unwrap_parenthesized_expression(expression); + match expression { + JavascriptExpression::StaticMemberExpression(member) => { + let extent = match member.property.name.as_str() { + "width" => canvas.dimensions.0, + "height" => canvas.dimensions.1, + _ => return None, + }; + let owns_context = match &member.object { + JavascriptExpression::Identifier(identifier) => { + self.symbol_for_identifier(identifier) + .and_then(|symbol_id| { + self.event_value_at(&self.canvas_events, symbol_id, at) + }) + == Some(canvas) + } + JavascriptExpression::StaticMemberExpression(context_canvas) + if context_canvas.property.name == "canvas" => + { + match &context_canvas.object { + JavascriptExpression::Identifier(identifier) => { + self.symbol_for_identifier(identifier) + .and_then(|symbol_id| { + self.event_value_at(&self.context_events, symbol_id, at) + }) + == Some(canvas) + } + _ => false, + } + } + _ => false, + }; + owns_context + .then(|| JavascriptNumericBounds::point(extent)) + .flatten() + } + JavascriptExpression::BinaryExpression(binary) => self + .numeric_expression_bounds(&binary.left, canvas, at)? + .combine( + self.numeric_expression_bounds(&binary.right, canvas, at)?, + binary.operator, + ), + JavascriptExpression::UnaryExpression(unary) => { + let value = self.numeric_expression_bounds(&unary.argument, canvas, at)?; + match unary.operator { + JavascriptUnaryOperator::UnaryPlus => Some(value), + JavascriptUnaryOperator::UnaryNegation => Some(JavascriptNumericBounds { + minimum: -value.maximum, + maximum: -value.minimum, + }), + _ => None, + } + } + JavascriptExpression::CallExpression(call) => { + self.canvas_coordinate_clamp_bounds(call, canvas, at) + } + _ => javascript_numeric_expression_bounds( + expression, + self.scoping, + self.numeric_constants, + self.numeric_bounds, + ), + } + } + + fn global_math_call_arguments<'a>( + &self, + call: &'a JavascriptCallExpression<'a>, + expected_method: &str, + ) -> Option>> { + let member = call.callee.as_member_expression()?; + if member.static_property_name()? != expected_method { + return None; + } + let JavascriptExpression::Identifier(math) = member.object() else { + return None; + }; + if math.name != "Math" + || math.reference_id.get().is_none_or(|reference_id| { + self.scoping + .get_reference(reference_id) + .symbol_id() + .is_some() + }) + { + return None; + } + call.arguments + .iter() + .map(JavascriptArgument::as_expression) + .collect::>>() + } + + fn canvas_coordinate_clamp_bounds( + &self, + call: &JavascriptCallExpression<'_>, + canvas: JavascriptCanvasBinding, + at: usize, + ) -> Option { + let outer = self.global_math_call_arguments(call, "min")?; + if outer.len() != 2 { + return None; + } + for (upper_expression, lower_clamp_expression) in + [(outer[0], outer[1]), (outer[1], outer[0])] + { + let Some(upper) = self.numeric_expression_bounds(upper_expression, canvas, at) else { + continue; + }; + let JavascriptExpression::CallExpression(lower_clamp) = + javascript_unwrap_parenthesized_expression(lower_clamp_expression) + else { + continue; + }; + let Some(inner) = self.global_math_call_arguments(lower_clamp, "max") else { + continue; + }; + if inner.len() != 2 { + continue; + } + let lower = inner.iter().find_map(|candidate| { + self.numeric_expression_bounds(candidate, canvas, at) + .filter(|bounds| bounds.minimum == 0.0 && bounds.maximum == 0.0) + }); + let Some(lower) = lower else { + continue; + }; + if lower.minimum.is_finite() + && upper.minimum.is_finite() + && upper.maximum.is_finite() + && lower.minimum <= upper.minimum + { + return Some(JavascriptNumericBounds { + minimum: lower.minimum, + maximum: upper.maximum, + }); + } + } + None + } + + fn expression_symbols( + &self, + expression: &JavascriptExpression<'_>, + ) -> BTreeSet { + let mut collector = JavascriptIdentifierSymbolCollector { + scoping: self.scoping, + symbols: BTreeSet::new(), + }; + collector.visit_expression(expression); + collector.symbols + } + + fn call_destination_expressions<'a>( + &self, + call: &'a JavascriptCallExpression<'a>, + ) -> Option<( + &'a JavascriptExpression<'a>, + &'a JavascriptExpression<'a>, + &'a JavascriptExpression<'a>, + &'a JavascriptExpression<'a>, + )> { + let (x_index, y_index, width_index, height_index) = match call.arguments.len() { + 5 => (1, 2, 3, 4), + 9 => (5, 6, 7, 8), + _ => return None, + }; + Some(( + call.arguments.get(x_index)?.as_expression()?, + call.arguments.get(y_index)?.as_expression()?, + call.arguments.get(width_index)?.as_expression()?, + call.arguments.get(height_index)?.as_expression()?, + )) + } + + fn call_has_visible_destination( + &self, + call: &JavascriptCallExpression<'_>, + canvas: JavascriptCanvasBinding, + at: usize, + ) -> bool { + let Some((x, y, width, height)) = self.call_destination_expressions(call) else { + return false; + }; + let bounds = self + .numeric_expression_bounds(x, canvas, at) + .zip(self.numeric_expression_bounds(y, canvas, at)) + .zip( + self.numeric_expression_bounds(width, canvas, at) + .zip(self.numeric_expression_bounds(height, canvas, at)), + ); + if let Some(((x, y), (width, height))) = bounds { + if width.minimum >= 16.0 + && height.minimum >= 16.0 + && width.minimum * height.minimum >= 512.0 + && x.maximum < canvas.dimensions.0 + && y.maximum < canvas.dimensions.1 + && x.minimum + width.minimum > 0.0 + && y.minimum + height.minimum > 0.0 + { + return true; + } + } + let arguments = call + .arguments + .iter() + .filter_map(|argument| self.argument_source(argument)) + .collect::>(); + if arguments.len() != call.arguments.len() { + return false; + } + let argument_refs = arguments.iter().map(String::as_str).collect::>(); + let contains_canvas_dimension = argument_refs.iter().any(|argument| { + let compact = argument + .chars() + .filter(|character| !character.is_ascii_whitespace()) + .collect::() + .to_ascii_lowercase(); + [ + "canvas.width", + "canvas.height", + "gamecanvas.width", + "gamecanvas.height", + "ctx.canvas.width", + "ctx.canvas.height", + "context.canvas.width", + "context.canvas.height", + ] + .iter() + .any(|marker| compact.contains(marker)) + }); + !contains_canvas_dimension + && draw_image_has_visible_destination(&argument_refs, canvas.dimensions) + } + + fn call_has_visible_tile_grid_destination( + &self, + call: &JavascriptCallExpression<'_>, + canvas: JavascriptCanvasBinding, + ) -> bool { + let Some((x, y, width, height)) = self.call_destination_expressions(call) else { + return false; + }; + let Some(scope_id) = self.scope_stack.last().copied() else { + return false; + }; + let Some(cols_symbol) = self.scoping.find_binding(scope_id, "COLS".into()) else { + return false; + }; + let Some(rows_symbol) = self.scoping.find_binding(scope_id, "ROWS".into()) else { + return false; + }; + let Some(cols) = self.numeric_constants.get(&cols_symbol).copied() else { + return false; + }; + let Some(rows) = self.numeric_constants.get(&rows_symbol).copied() else { + return false; + }; + if cols < 1.0 || rows < 1.0 { + return false; + } + let x_symbols = self.expression_symbols(x); + let y_symbols = self.expression_symbols(y); + let width_symbols = self.expression_symbols(width); + let height_symbols = self.expression_symbols(height); + let coordinate_symbols = x_symbols + .union(&y_symbols) + .copied() + .collect::>(); + let dimension_symbols = width_symbols + .union(&height_symbols) + .copied() + .collect::>(); + let Some(cell_symbol) = + coordinate_symbols + .intersection(&dimension_symbols) + .find(|symbol| { + self.numeric_constants + .get(symbol) + .is_some_and(|value| (16.0..=128.0).contains(value)) + }) + else { + return false; + }; + let x_uses_grid = x_symbols.contains(cell_symbol) || width_symbols.contains(&cols_symbol); + let y_uses_grid = y_symbols.contains(cell_symbol) || height_symbols.contains(&rows_symbol); + if !x_uses_grid || !y_uses_grid { + return false; + } + let mut grid_bounds = self.numeric_bounds.clone(); + // A dynamic grid index whose earlier data flow cannot be reduced to a + // direct call argument or a counted loop is modeled over the complete + // board axis. This is deliberately not a zero fallback: the entire + // destination envelope must fit the visible Canvas. Known parameter + // and loop bounds remain authoritative, so an off-board reachable call + // such as drawPiece(1000, 1000) still fails closed. + for symbol in &x_symbols { + if !self.numeric_constants.contains_key(symbol) && !grid_bounds.contains_key(symbol) { + grid_bounds.insert( + *symbol, + JavascriptNumericBounds { + minimum: 0.0, + maximum: cols - 1.0, + }, + ); + } + } + for symbol in &y_symbols { + if !self.numeric_constants.contains_key(symbol) && !grid_bounds.contains_key(symbol) { + grid_bounds.insert( + *symbol, + JavascriptNumericBounds { + minimum: 0.0, + maximum: rows - 1.0, + }, + ); + } + } + let Some(((x, y), (width, height))) = javascript_numeric_expression_bounds( + x, + self.scoping, + self.numeric_constants, + &grid_bounds, + ) + .zip(javascript_numeric_expression_bounds( + y, + self.scoping, + self.numeric_constants, + &grid_bounds, + )) + .zip( + javascript_numeric_expression_bounds( + width, + self.scoping, + self.numeric_constants, + &grid_bounds, + ) + .zip(javascript_numeric_expression_bounds( + height, + self.scoping, + self.numeric_constants, + &grid_bounds, + )), + ) else { + return false; + }; + width.minimum >= 16.0 + && height.minimum >= 16.0 + && width.minimum * height.minimum >= 512.0 + && x.minimum >= 0.0 + && y.minimum >= 0.0 + && x.maximum + width.maximum <= canvas.dimensions.0 + && y.maximum + height.maximum <= canvas.dimensions.1 + } + + fn source_matches_at(&self, symbol_id: JavascriptSymbolId, at: usize) -> bool { + let Some(events) = self.source_events.get(&symbol_id) else { + return false; + }; + if events.iter().all(|event| event.value != Some(true)) { + return false; + } + self.event_value_at(&self.source_events, symbol_id, at) + .unwrap_or(false) + } + fn expression_selects_asset_element(&self, expression: &JavascriptExpression<'_>) -> bool { let JavascriptExpression::CallExpression(call) = expression else { return false; @@ -3992,7 +5066,10 @@ impl JavascriptCanvasVisualCollector<'_> { let scope = javascript_alias_scope_at(self.ranges, position); let conditional = javascript_position_is_conditionally_executed(self.conditional_ranges, position); - let canvas = self.canvas_expression_dimensions(initializer, position); + let mut canvas = self.canvas_expression_binding(initializer, position); + if let Some(binding) = &mut canvas { + binding.identity.get_or_insert(symbol_id); + } self.canvas_events .entry(symbol_id) .or_default() @@ -4002,7 +5079,7 @@ impl JavascriptCanvasVisualCollector<'_> { value: canvas, conditional, }); - let context = self.context_expression_dimensions(initializer, position); + let context = self.context_expression_binding(initializer, position); self.context_events .entry(symbol_id) .or_default() @@ -4016,7 +5093,7 @@ impl JavascriptCanvasVisualCollector<'_> { || initializer .get_identifier_reference() .and_then(|identifier| self.symbol_for_identifier(identifier)) - .and_then(|source| self.event_value_at(&self.source_events, source, position)) + .map(|source| self.source_matches_at(source, position)) .unwrap_or(false); self.source_events .entry(symbol_id) @@ -4047,7 +5124,34 @@ impl JavascriptCanvasVisualCollector<'_> { } } +struct JavascriptIdentifierSymbolCollector<'a> { + scoping: &'a JavascriptScoping, + symbols: BTreeSet, +} + +impl<'a> VisitJavascript<'a> for JavascriptIdentifierSymbolCollector<'_> { + fn visit_identifier_reference(&mut self, identifier: &oxc_ast::ast::IdentifierReference<'a>) { + if let Some(symbol_id) = javascript_identifier_symbol(self.scoping, identifier) { + self.symbols.insert(symbol_id); + } + } +} + impl<'a> VisitJavascript<'a> for JavascriptCanvasVisualCollector<'_> { + fn enter_scope( + &mut self, + _flags: JavascriptScopeFlags, + scope_id: &std::cell::Cell>, + ) { + if let Some(scope_id) = scope_id.get() { + self.scope_stack.push(scope_id); + } + } + + fn leave_scope(&mut self) { + self.scope_stack.pop(); + } + fn visit_variable_declarator(&mut self, declarator: &JavascriptVariableDeclarator<'a>) { if let (Some(identifier), Some(initializer)) = ( declarator.id.get_binding_identifier(), @@ -4125,13 +5229,13 @@ impl<'a> VisitJavascript<'a> for JavascriptCanvasVisualCollector<'_> { .static_property_name() .is_some_and(|name| name == "drawImage") { - let dimensions = match member.object() { + let canvas = match member.object() { JavascriptExpression::Identifier(identifier) => self .symbol_for_identifier(identifier) .and_then(|symbol_id| { self.event_value_at(&self.context_events, symbol_id, position) }), - expression => self.context_expression_dimensions(expression, position), + expression => self.context_expression_binding(expression, position), }; let image = call .arguments @@ -4139,7 +5243,7 @@ impl<'a> VisitJavascript<'a> for JavascriptCanvasVisualCollector<'_> { .and_then(JavascriptArgument::as_expression) .and_then(JavascriptExpression::get_identifier_reference) .and_then(|identifier| self.symbol_for_identifier(identifier)); - if let (Some(canvas_dimensions), Some(image)) = (dimensions, image) { + if let (Some(canvas), Some(image)) = (canvas, image) { let arguments = call .arguments .iter() @@ -4149,7 +5253,10 @@ impl<'a> VisitJavascript<'a> for JavascriptCanvasVisualCollector<'_> { self.draws.push(JavascriptCanvasDraw { position, image, - canvas_dimensions, + has_visible_destination: self + .call_has_visible_destination(call, canvas, position), + has_visible_tile_grid_destination: self + .call_has_visible_tile_grid_destination(call, canvas), arguments, }); } @@ -4168,6 +5275,11 @@ fn javascript_canvas_visual_draws( asset_path: &str, is_module: bool, ) -> Vec { + if !javascript_may_reference_visual_asset(content, asset_element_ids, asset_path) + || !javascript_may_reference_canvas_draw(content) + { + return Vec::new(); + } let allocator = JavascriptAllocator::default(); let parsed = JavascriptParser::new(&allocator, content, javascript_source_type(is_module)).parse(); @@ -4178,9 +5290,88 @@ fn javascript_canvas_visual_draws( if !semantic.diagnostics.is_empty() { return Vec::new(); } - let ranges = named_javascript_function_ranges(content); + // Large classic game scripts use the bounded direct-call graph. The full + // callable-alias fixed point remains available for compact and module + // fixtures, but can otherwise explode on ordinary render/update helper + // graphs before a completion gate can report its result. The bounded + // graph is deliberately fail-closed: unsupported alias calls simply do + // not produce visual evidence. + let ranges = if !is_module && content.len() >= 8 * 1024 { + direct_named_javascript_function_ranges( + content, + &parsed.program, + semantic.semantic.scoping(), + ) + } else { + named_javascript_function_ranges(content) + }; let conditional_ranges = javascript_conditional_execution_ranges(content); - let namespace_write_assignments = javascript_module_analysis(content, is_module) + let numeric_constants = javascript_numeric_constants(&parsed.program); + let mut loop_bound_collector = JavascriptLoopBoundCollector { + scoping: semantic.semantic.scoping(), + constants: &numeric_constants, + bounds: BTreeMap::new(), + }; + loop_bound_collector.visit_program(&parsed.program); + let mut parameter_collector = JavascriptFunctionParameterCollector { + parameters: BTreeMap::new(), + }; + parameter_collector.visit_program(&parsed.program); + let mut parameter_bound_collector = JavascriptParameterCallBoundCollector { + scoping: semantic.semantic.scoping(), + content, + ranges: &ranges, + constants: &numeric_constants, + loop_bounds: &loop_bound_collector.bounds, + parameters: ¶meter_collector.parameters, + states: BTreeMap::new(), + direct_invocations: BTreeMap::new(), + }; + parameter_bound_collector.visit_program(&parsed.program); + for (function_symbol, parameters) in ¶meter_collector.parameters { + let binding_start = semantic + .semantic + .scoping() + .symbol_span(*function_symbol) + .start as usize; + let has_unmodeled_reachable_invocation = ranges + .iter() + .filter(|range| range.binding_start == Some(binding_start)) + .flat_map(|range| range.invocations.iter().copied()) + .any(|invocation| { + javascript_position_is_reachable(content, &ranges, invocation) + && !parameter_bound_collector + .direct_invocations + .get(function_symbol) + .is_some_and(|direct| direct.contains(&invocation)) + }); + if has_unmodeled_reachable_invocation { + for parameter in parameters { + let state = parameter_bound_collector + .states + .entry(*parameter) + .or_default(); + state.seen = true; + state.invalid = true; + state.bounds = None; + } + } + } + let parameter_bound_states = std::mem::take(&mut parameter_bound_collector.states); + drop(parameter_bound_collector); + let mut numeric_bounds = loop_bound_collector.bounds; + numeric_bounds.extend( + parameter_bound_states + .into_iter() + .filter_map(|(symbol, state)| { + (state.seen && !state.invalid) + .then_some(state.bounds.map(|bounds| (symbol, bounds))) + .flatten() + }), + ); + let namespace_write_assignments = javascript_may_contain_import_syntax(content) + .then(|| javascript_module_analysis(content, is_module)) + .flatten() .map(|analysis| { let dynamic_members = analysis .dynamic_import_member_ranges @@ -4208,6 +5399,9 @@ fn javascript_canvas_visual_draws( visible_canvases, asset_element_ids, asset_path, + numeric_constants: &numeric_constants, + numeric_bounds: &numeric_bounds, + scope_stack: Vec::new(), canvas_events: BTreeMap::new(), context_events: BTreeMap::new(), source_events: BTreeMap::new(), @@ -4227,10 +5421,18 @@ fn javascript_canvas_visual_draws( }) { return false; } - collector + let matches = collector .source_events .get(&draw.image) .and_then(|events| { + if events.iter().all(|event| event.value != Some(true)) { + return None; + } + if let Some(value) = + javascript_stable_ancestor_event_value(events, &ranges, draw.position) + { + return Some(vec![value]); + } javascript_alias_event_values( events, &ranges, @@ -4239,10 +5441,126 @@ fn javascript_canvas_visual_draws( &conditional_ranges, draw.position, ) + .map(|values| values.into_iter().copied().collect::>()) }) - .is_some_and(|values| !values.is_empty() && values.into_iter().all(|value| *value)) + .is_some_and(|values| !values.is_empty() && values.into_iter().all(|value| value)); + matches }) - .collect() + .collect::>() +} + +fn javascript_may_reference_canvas_draw(content: &str) -> bool { + if content.contains("drawImage") { + return true; + } + if !content.as_bytes().contains(&b'\\') { + return false; + } + javascript_escape_decoded_candidate_text(content) + .is_none_or(|decoded| decoded.contains("drawImage")) +} + +fn javascript_may_reference_visual_asset( + content: &str, + asset_element_ids: &BTreeSet, + asset_path: &str, +) -> bool { + let asset_file_name = asset_path + .rsplit('/') + .next() + .filter(|file_name| !file_name.is_empty()); + if asset_file_name.is_none_or(|file_name| content.contains(file_name)) { + return true; + } + if asset_element_ids + .iter() + .any(|element_id| !element_id.is_empty() && content.contains(element_id)) + { + return true; + } + if !content.as_bytes().contains(&b'\\') { + return false; + } + // Oxc compares decoded identifier/property and StringLiteral values below. + // Decode the same escape families for this negative-only prefilter so + // `art['s\x72c']='player\x2epng'` and escaped DOM selectors remain + // candidates. If decoding is uncertain, keep the parser path rather than + // risk rejecting evidence that Oxc would accept. + let Some(decoded) = javascript_escape_decoded_candidate_text(content) else { + return true; + }; + asset_file_name.is_some_and(|file_name| decoded.contains(file_name)) + || asset_element_ids + .iter() + .any(|element_id| !element_id.is_empty() && decoded.contains(element_id)) +} + +fn javascript_escape_decoded_candidate_text(content: &str) -> Option { + fn fixed_hex_value( + characters: &mut std::iter::Peekable>, + count: usize, + ) -> Option { + let mut value = 0_u32; + for _ in 0..count { + value = value.checked_mul(16)?; + value = value.checked_add(characters.next()?.to_digit(16)?)?; + } + Some(value) + } + + let mut decoded = String::with_capacity(content.len()); + let mut characters = content.chars().peekable(); + while let Some(character) = characters.next() { + if character != '\\' { + decoded.push(character); + continue; + } + let escaped = characters.next()?; + let value = match escaped { + 'x' => char::from_u32(fixed_hex_value(&mut characters, 2)?)?, + 'u' if characters.peek() == Some(&'{') => { + characters.next(); + let mut value = 0_u32; + let mut digits = 0_usize; + loop { + let digit = characters.next()?; + if digit == '}' { + break; + } + digits += 1; + if digits > 6 { + return None; + } + value = value.checked_mul(16)?; + value = value.checked_add(digit.to_digit(16)?)?; + } + if digits == 0 { + return None; + } + char::from_u32(value)? + } + 'u' => char::from_u32(fixed_hex_value(&mut characters, 4)?)?, + '\n' => continue, + '\r' => { + if characters.peek() == Some(&'\n') { + characters.next(); + } + continue; + } + 'b' => '\u{0008}', + 'f' => '\u{000c}', + 'n' => '\n', + 'r' => '\r', + 't' => '\t', + 'v' => '\u{000b}', + '0' if characters.peek().is_some_and(|next| next.is_ascii_digit()) => return None, + '0' => '\0', + '1'..='9' => return None, + value => value, + }; + decoded.push(value); + } + Some(decoded) } fn draw_image_metrics(arguments: &[&str], asset_dimensions: (u32, u32)) -> (bool, bool, bool) { @@ -4443,13 +5761,13 @@ fn game_index_visibly_uses_visual_asset( } if requirement == VisualAssetUsageRequirement::CanvasDraw && matches!(arguments.len(), 5 | 9) - && draw_image_has_visible_destination(&arguments, draw.canvas_dimensions) + && (draw.has_visible_destination || draw.has_visible_tile_grid_destination) { return true; } if requirement == VisualAssetUsageRequirement::AtlasCanvasCrop && arguments.len() == 9 - && draw_image_has_visible_destination(&arguments, draw.canvas_dimensions) + && draw.has_visible_destination { return true; } @@ -4506,11 +5824,30 @@ fn draw_image_has_visible_destination(arguments: &[&str], canvas_dimensions: (f6 _ => return false, }; let parse = |value: &str| value.trim().parse::().ok(); + let canvas_dimension = |value: &str, axis: &str, extent: f64| { + let compact = value + .chars() + .filter(|character| !character.is_ascii_whitespace()) + .collect::() + .to_ascii_lowercase(); + [ + format!("canvas.{axis}"), + format!("gamecanvas.{axis}"), + format!("ctx.canvas.{axis}"), + format!("context.canvas.{axis}"), + ] + .contains(&compact) + .then_some(extent) + }; let dimensions = parse(arguments[width_index]) .or_else(|| dynamic_canvas_dimension_fallback(arguments[width_index])) + .or_else(|| canvas_dimension(arguments[width_index], "width", canvas_dimensions.0)) .zip( parse(arguments[height_index]) - .or_else(|| dynamic_canvas_dimension_fallback(arguments[height_index])), + .or_else(|| dynamic_canvas_dimension_fallback(arguments[height_index])) + .or_else(|| { + canvas_dimension(arguments[height_index], "height", canvas_dimensions.1) + }), ); let Some((width, height)) = dimensions else { return false; @@ -4575,24 +5912,62 @@ fn autonomous_manifest_parent_completion_gaps_at( } else { None }; + let reuse_existing_art = if binding.source == AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE { + let root_task = read_latest_game_creator_agent_runtime_task_by_run_id( + root, + &contract.agent_id, + &contract.run_id, + )? + .ok_or_else(|| "自主构建根 Supervisor Run 缺少任务记录".to_string())?; + let effective_task = autonomous_effective_root_task_at( + root, + &contract.agent_id, + &contract.run_id, + &root_task.task, + )?; + if contract.task_sha256 != format!("{:x}", Sha256::digest(effective_task.as_bytes())) { + return Err("自主构建根 Supervisor Run 任务语义与完成合同不一致".to_string()); + } + game_chat_existing_art_reuse_refinement_is_valid_at(root, &effective_task)? + } else { + false + }; let mut missing_tasks = Vec::new(); let mut missing_paths = Vec::new(); for seed_task in &seed_tasks { - match manifest.tasks.iter().find(|task| task.id == seed_task.id) { - Some(task) if task.status == GameCreationAppTaskStatus::Completed => {} - Some(task) => missing_tasks.push(format!( - "{}({})", - seed_task.id, - game_creation_app_task_status_label(&task.status) - )), - None => missing_tasks.push(format!("{}(missing)", seed_task.id)), + let completed = match manifest.tasks.iter().find(|task| task.id == seed_task.id) { + Some(task) if task.status == GameCreationAppTaskStatus::Completed => true, + Some(task) => { + missing_tasks.push(format!( + "{}({})", + seed_task.id, + game_creation_app_task_status_label(&task.status) + )); + false + } + None => { + missing_tasks.push(format!("{}(missing)", seed_task.id)); + false + } + }; + // A non-terminal task status is already a complete blocker. Expensive + // artifact and Canvas validation belongs to the completed task's + // acceptance gate and must not stall an earlier dependency wave. + if !completed { + continue; } - missing_paths.extend(autonomous_manifest_owner_artifact_gaps_at( + let mut owner_artifact_gaps = autonomous_manifest_owner_artifact_gaps_at( root, &seed_task.id, contract.baseline_index_sha256.as_deref(), &contract.baseline_artifacts, - )?); + )?; + if reuse_existing_art && seed_task.id == "art-asset-plan" { + owner_artifact_gaps.retain(|gap| { + gap.summary != "assets/manifest.art.json(unchanged-from-run-baseline)" + }); + } + missing_paths.extend(owner_artifact_gaps); let requires_visual_registration = if binding.source == AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE { matches!(seed_task.id.as_str(), "art-director" | "art-asset-plan") @@ -4610,6 +5985,16 @@ fn autonomous_manifest_parent_completion_gaps_at( ))); } } + if binding.source == AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE + && seed_task.id == "art-asset-plan" + { + if let Err(error) = game_chat_fast_path_validated_art_slices(root) { + missing_paths.push(AutonomousManifestArtifactGap::new(format!( + "assets/art-spritesheet-slices/manifest.json(invalid:{})", + sanitize_agent_runtime_text(&error, 240) + ))); + } + } if seed_task.id == "code-prototype" { if let Some(gap) = autonomous_code_prototype_art_asset_reference_gap_at( root, @@ -4664,21 +6049,31 @@ fn autonomous_manifest_ready_task_completion_blocker_at_locked( )); } }; - // game-chat 可能在 UI 完成项目 hydration 前并行启动 source-aware lane 的首波 - // ready child,随后初始化写回会短暂把这些零依赖任务恢复成 Pending。child binding、owner - // artifact 和验证门仍能确认当前 run,因此仅允许当前 source 的零依赖首波收束, - // 再由 terminal projection 写入权威 Completed 状态。后续 preview 与中间任务继续 - // 严格要求 Running/Completed,不得借 hydration 例外越过依赖。 - // GUI/CLI 以及后续 preview 任务继续严格要求 Running/Completed。 - let game_chat_hydration_pending = root_source == AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE + // game-chat 启动 ready child 后,项目 hydration 或其它持有旧 manifest 快照的并发写回 + // 可能把刚写入的 Running 短暂覆盖成 Pending。只允许“当前最新且仍活跃的根 Run”下、 + // 使用确定性 runId 且 durable journal 与当前 state 同步处于 Running 的真实 child 穿过 + // 收束前检查;终态投影只接受 state 与 durable journal 同为 Completed。queued Pending、 + // waiting、failed、needs-reconciliation、旧父 Run、伪造绑定和 GUI/CLI 均失败关闭。 + let game_chat_current_child_pending = if root_source + == AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE && task.status == GameCreationAppTaskStatus::Pending - && crate::agent::autonomous_manifest_seed_tasks_for_source(&root_source) - .iter() - .any(|seed_task| seed_task.id == state.agent_id && seed_task.dependencies.is_empty()); + { + match game_chat_current_ready_child_pending_status_is_valid_at(root, state, &binding) { + Ok(value) => value, + Err(error) => { + return Some(autonomous_completion_blocker( + "autonomous ready-task Pending 状态身份不可用", + error, + )); + } + } + } else { + false + }; if !matches!( task.status, GameCreationAppTaskStatus::Running | GameCreationAppTaskStatus::Completed - ) && !game_chat_hydration_pending + ) && !game_chat_current_child_pending { return Some(autonomous_completion_blocker( "autonomous ready-task manifest 状态不允许完成", @@ -4887,6 +6282,61 @@ fn autonomous_manifest_ready_task_completion_blocker_at_locked( )) } +fn game_chat_current_ready_child_pending_status_is_valid_at( + root: &Path, + state: &AgentRuntimeState, + binding: &AgentRuntimeRunProfileBinding, +) -> Result { + let Some(current_root) = current_autonomous_game_build_root_task_at(root)? else { + return Ok(false); + }; + if current_root.run_id != binding.root_run_id + || current_root.source != AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE + || !autonomous_game_build_root_task_is_active(¤t_root) + || state.run_id + != autonomous_manifest_ready_task_run_id(&binding.root_run_id, &state.agent_id) + { + return Ok(false); + } + let Some(latest_child) = read_latest_game_creator_agent_runtime_task_by_run_id( + root, + &state.agent_id, + &state.run_id, + )? + else { + return Ok(false); + }; + let identities_match = latest_child.parent_agent_id.as_deref() + == Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID) + && latest_child.parent_run_id.as_deref() == Some(binding.root_run_id.as_str()) + && latest_child.source == "agent-ready-task-scheduler"; + if !identities_match { + return Ok(false); + } + Ok(match (state.status.as_str(), state.phase.as_str()) { + ("running", phase) + if !matches!( + phase, + "waiting-for-confirmation" + | "waiting-for-user-input" + | "needs-reconciliation" + | "failed" + | "cancelled" + | "budget-exhausted" + | "completed" + ) => + { + latest_child.status == "running" + && latest_child.phase == phase + && game_creator_agent_runtime_terminal_status(&latest_child).is_none() + } + ("completed", "completed") => { + latest_child.status == "completed" && latest_child.phase == "completed" + } + _ => false, + }) +} + pub(in crate::agent) fn is_lowercase_sha256(value: &str) -> bool { value.len() == 64 && value @@ -5345,6 +6795,250 @@ fn failed_terminal_autonomous_root_contract_before_task_at( })) } +fn previous_specific_playtest_scenario_for_art_reuse_refinement_at( + root: &Path, + task: &AgentRuntimeTaskRecord, +) -> Result, String> { + if task.source != AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE + || !game_chat_existing_art_reuse_refinement_intent_at(root, &task.task)? + { + return Ok(None); + } + let records = read_all_game_creator_agent_runtime_tasks( + &game_creator_agent_runtime_task_path(root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID), + )?; + let mut ordered_run_ids = Vec::new(); + let mut seen_run_ids = BTreeSet::new(); + for record in &records { + if record.agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID + && record.session_id == task.session_id + && record.run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + && record.parent_agent_id.is_none() + && record.parent_run_id.is_none() + && agent_runtime_supervisor_source_is_trusted(&record.source) + && seen_run_ids.insert(record.run_id.clone()) + { + ordered_run_ids.push(record.run_id.clone()); + } + } + let Some(current_index) = ordered_run_ids + .iter() + .position(|run_id| run_id == &task.run_id) + else { + return Err("美术复用增量任务未出现在当前 Session 的根 Run journal 中".to_string()); + }; + let latest_roots = latest_game_creator_agent_runtime_tasks(records); + for previous_run_id in ordered_run_ids[..current_index].iter().rev() { + let Some(previous) = latest_roots + .iter() + .find(|record| record.run_id == *previous_run_id) + else { + continue; + }; + let effective_task = autonomous_effective_root_task_at( + root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &previous.run_id, + &previous.task, + )?; + let scenario = classify_autonomous_playtest_scenario(&effective_task); + if scenario != BrowserPlaytestScenario::GenericV1 { + return Ok(Some(scenario)); + } + // source-aware continuation may legitimately move an existing project from the GUI + // entry into game-chat. Skip only semantically empty continuations/refinements while + // looking for that project's last specific scenario. A newer detailed generic request + // is a real project pivot and stops the search so it cannot borrow an older game's type. + if !is_pure_autonomous_continuation_intent(&effective_task) + && !game_chat_existing_art_reuse_refinement_intent_at(root, &effective_task)? + { + return Ok(None); + } + } + Ok(None) +} + +fn scheduled_child_reconciliation_cancel_retries_by_parent_at( + root: &Path, + task_id: &str, +) -> Result, String> { + let records = read_all_game_creator_agent_runtime_tasks( + &game_creator_agent_runtime_task_path(root, task_id), + )?; + let reconciliation_run_ids = records + .iter() + .filter(|record| record.status == "failed" && record.phase == "needs-reconciliation") + .map(|record| record.run_id.clone()) + .collect::>(); + let latest = latest_game_creator_agent_runtime_tasks(records.clone()); + let mut retries_by_parent = BTreeMap::new(); + for child in latest.into_iter().filter(|child| { + child.task_id == task_id + && child.parent_agent_id.as_deref() == Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID) + && child.source == "agent-ready-task-scheduler" + && child.run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + }) { + let Some(parent_run_id) = child.parent_run_id.clone() else { + continue; + }; + let retryable = child.status == "cancelled" + && child.phase == "cancelled" + && reconciliation_run_ids.contains(&child.run_id) + && game_creator_agent_runtime_cancel_requested_for( + root, + &child.agent_id, + &child.run_id, + ); + // latest_game_creator_agent_runtime_tasks retains journal run order; + // replacing here makes the last actually scheduled child for a parent + // authoritative without rescanning the unbounded journal per parent. + retries_by_parent.insert(parent_run_id, (child.run_id, retryable)); + } + Ok(retries_by_parent) +} + +fn autonomous_root_failed_before_manifest_scheduling(parent: &AgentRuntimeTaskRecord) -> bool { + parent.error.as_deref() == Some(GAME_CHAT_FIXED_TASK_GRAPH_STALLED_ERROR) +} + +fn reset_cancelled_reconciliation_manifest_tasks_for_continuation_at( + root: &Path, + task: &AgentRuntimeTaskRecord, + expected_task_sha256: &str, +) -> Result<(), String> { + let root_records = read_all_game_creator_agent_runtime_tasks( + &game_creator_agent_runtime_task_path(root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID), + )?; + let mut ordered_run_ids = Vec::new(); + let mut seen_run_ids = BTreeSet::new(); + for record in &root_records { + if record.agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID + && record.session_id == task.session_id + && record.run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + && record.parent_agent_id.is_none() + && record.parent_run_id.is_none() + && record.source == task.source + && seen_run_ids.insert(record.run_id.clone()) + { + ordered_run_ids.push(record.run_id.clone()); + } + } + let Some(current_index) = ordered_run_ids + .iter() + .position(|run_id| run_id == &task.run_id) + else { + return Err("自主构建续跑任务未出现在当前 Session 的根 Run journal 中".to_string()); + }; + let latest_roots = latest_game_creator_agent_runtime_tasks(root_records); + let mut eligible_parents = Vec::new(); + for parent_run_id in ordered_run_ids[..current_index].iter().rev() { + let Some(parent) = latest_roots + .iter() + .find(|record| record.run_id == *parent_run_id) + else { + continue; + }; + if !matches!( + game_creator_agent_runtime_terminal_status(parent), + Some("failed" | "cancelled" | "budget-exhausted") + ) { + continue; + } + let Some(contract) = read_autonomous_completion_contract( + root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + )? + else { + continue; + }; + if contract.task_sha256 == expected_task_sha256 { + eligible_parents.push(parent.clone()); + } + } + + let allowed_task_ids = autonomous_manifest_seed_tasks_for_source(&task.source) + .into_iter() + .map(|task| task.id) + .collect::>(); + let _lock = acquire_project_write_lock( + root, + "runtime.autonomous.manifest.reconciliation_cancel_retry", + )?; + let (manifest_path, mut manifest) = read_or_create_manifest(root)?; + ensure_manifest_seed_tasks(root, &mut manifest); + let failed_task_ids = manifest + .tasks + .iter() + .filter(|manifest_task| { + allowed_task_ids.contains(&manifest_task.id) + && manifest_task.status == GameCreationAppTaskStatus::Failed + }) + .map(|manifest_task| manifest_task.id.clone()) + .collect::>(); + let mut retryable = Vec::new(); + for task_id in failed_task_ids { + let retries_by_parent = + scheduled_child_reconciliation_cancel_retries_by_parent_at(root, &task_id)?; + for parent in &eligible_parents { + if let Some((child_run_id, retryable_after_cancel)) = + retries_by_parent.get(&parent.run_id) + { + if *retryable_after_cancel { + retryable.push((task_id.clone(), parent.run_id.clone(), child_run_id.clone())); + } + // The most recent root that actually scheduled this manifest task is + // authoritative. A newer ordinary failure must not borrow an older + // reconciliation cancel tombstone. + break; + } + // A historical successor that failed on the already-failed fixed graph + // did not attempt this task. Any other no-child failure is authoritative: + // it may be a scheduler failure and must block an older paid-action retry. + if !autonomous_root_failed_before_manifest_scheduling(parent) { + break; + } + } + } + if retryable.is_empty() { + return Ok(()); + } + + let mut reset = Vec::new(); + for (task_id, parent_run_id, child_run_id) in retryable { + if !game_creator_agent_runtime_cancel_requested_for(root, &task_id, &child_run_id) { + continue; + } + let Some(manifest_task) = manifest.tasks.iter_mut().find(|task| task.id == task_id) else { + continue; + }; + if manifest_task.status != GameCreationAppTaskStatus::Failed { + continue; + } + manifest_task.status = GameCreationAppTaskStatus::Pending; + reset.push(serde_json::json!({ + "taskId": task_id, + "parentRunId": parent_run_id, + "cancelledRunId": child_run_id, + })); + } + if reset.is_empty() { + return Ok(()); + } + write_manifest(&manifest_path, &manifest)?; + append_agent_db_record( + root, + serde_json::json!({ + "recordType": "agent.runtime.autonomous_manifest.reconciliation_cancel_retry", + "agentId": task.agent_id, + "sessionId": task.session_id, + "runId": task.run_id, + "tasks": reset, + }), + )?; + Ok(()) +} + pub(in crate::agent) fn autonomous_effective_root_task_at( root: &Path, agent_id: &str, @@ -6875,6 +8569,35 @@ fn javascript_alias_scope_at( .map(|range| (range.start, range.end)) } +fn javascript_stable_ancestor_event_value( + events: &[JavascriptAliasEvent], + ranges: &[NamedJavascriptFunctionRange], + at: usize, +) -> Option { + let event_scope = events.first()?.scope; + if !events + .iter() + .all(|event| event.scope == event_scope && !event.conditional && event.position <= at) + { + return None; + } + if let Some((start, end)) = event_scope { + if !(start..end).contains(&at) { + return None; + } + } + let latest = events.iter().max_by_key(|event| event.position)?; + if ranges.iter().any(|range| { + range.synchronous_invocations.iter().any(|invocation| { + *invocation <= latest.position + && javascript_alias_scope_at(ranges, *invocation) == event_scope + }) + }) { + return None; + } + latest.value +} + #[derive(Clone, Default)] struct JavascriptAliasSelection { indices: BTreeSet, @@ -9153,6 +10876,13 @@ fn reachable_local_javascript_module_sources( content: &str, allow_static_imports: bool, ) -> Vec { + // `import` and `export` are ASCII, case-sensitive JavaScript keywords. If + // both exact byte sequences are absent, neither an import nor a re-export + // dependency can exist, so avoid the substantially more expensive + // whole-program semantic analysis used to prove reachability. + if !javascript_may_contain_module_dependency_syntax(content) { + return Vec::new(); + } let Some(analysis) = javascript_module_analysis(content, allow_static_imports) else { return Vec::new(); }; @@ -9174,6 +10904,14 @@ fn reachable_local_javascript_module_sources( sources } +fn javascript_may_contain_import_syntax(content: &str) -> bool { + content.contains("import") +} + +fn javascript_may_contain_module_dependency_syntax(content: &str) -> bool { + javascript_may_contain_import_syntax(content) || content.contains("export") +} + fn insert_javascript_top_level_declaration( declarations: &mut BTreeMap, content: &str, @@ -12477,10 +14215,15 @@ pub(in crate::agent) fn ensure_autonomous_completion_contract_for_task_at( .as_ref() .map(|inherited| inherited.contract.task_sha256.clone()) .unwrap_or_else(|| format!("{:x}", Sha256::digest(task.task.as_bytes()))); - let expected_playtest_scenario = inherited - .as_ref() - .map(|inherited| classify_autonomous_playtest_scenario(&inherited.task)) - .unwrap_or_else(|| classify_autonomous_playtest_scenario(&task.task)); + let expected_playtest_scenario = if let Some(inherited) = inherited.as_ref() { + classify_autonomous_playtest_scenario(&inherited.task) + } else if let Some(previous) = + previous_specific_playtest_scenario_for_art_reuse_refinement_at(root, task)? + { + previous + } else { + classify_autonomous_playtest_scenario(&task.task) + }; if let Some(mut existing) = read_autonomous_completion_contract(root, &task.agent_id, &task.run_id)? { @@ -12498,6 +14241,13 @@ pub(in crate::agent) fn ensure_autonomous_completion_contract_for_task_at( } return Ok(()); } + if inherited.is_some() { + reset_cancelled_reconciliation_manifest_tasks_for_continuation_at( + root, + task, + &expected_task_sha256, + )?; + } let (baseline_revision, baseline_index_sha256, baseline_artifacts, playtest_scenario) = if let Some(inherited) = inherited.as_ref() { ( @@ -12569,6 +14319,8 @@ fn reset_autonomous_manifest_seed_tasks_at( task: &AgentRuntimeTaskRecord, ) -> Result<(), String> { let _lock = acquire_project_write_lock(root, "runtime.autonomous.manifest.reset")?; + let reuse_existing_art = task.source == AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE + && game_chat_existing_art_reuse_refinement_is_valid_at(root, &task.task)?; let (manifest_path, mut manifest) = read_or_create_manifest(root)?; ensure_manifest_seed_tasks(root, &mut manifest); let seed_task_ids = new_game_creation_app_seed_tasks() @@ -12577,7 +14329,13 @@ fn reset_autonomous_manifest_seed_tasks_at( .collect::>(); for manifest_task in &mut manifest.tasks { if seed_task_ids.contains(&manifest_task.id) { - manifest_task.status = GameCreationAppTaskStatus::Pending; + manifest_task.status = if reuse_existing_art + && matches!(manifest_task.id.as_str(), "art-director" | "art-asset-plan") + { + GameCreationAppTaskStatus::Completed + } else { + GameCreationAppTaskStatus::Pending + }; } } write_manifest(&manifest_path, &manifest)?; @@ -12588,6 +14346,11 @@ fn reset_autonomous_manifest_seed_tasks_at( "agentId": task.agent_id, "runId": task.run_id, "taskCount": seed_task_ids.len(), + "reusedCompletedTasks": if reuse_existing_art { + serde_json::json!(["art-director", "art-asset-plan"]) + } else { + serde_json::json!([]) + }, }), )?; Ok(()) @@ -13134,6 +14897,266 @@ pub(in crate::agent) fn autonomous_game_build_completion_blocker_at_locked( mod visible_destination_tests { use super::*; + #[test] + fn canvas_asset_analysis_requires_a_filename_or_bound_element() { + let no_elements = BTreeSet::new(); + let bound_elements = BTreeSet::from(["player-art".to_string()]); + assert!(!javascript_may_reference_visual_asset( + "const render = () => context.clearRect(0, 0, 320, 180);", + &no_elements, + "assets/art-spritesheet-slices/player.png", + )); + assert!(javascript_may_reference_visual_asset( + "player.src = '../assets/./art-spritesheet-slices/player.png?revision=2';", + &no_elements, + "assets/art-spritesheet-slices/player.png", + )); + assert!(javascript_may_reference_visual_asset( + "const player=document.getElementById('player-art');context.drawImage(player,0,0,64,64);", + &bound_elements, + "assets/art-spritesheet-slices/player.png", + )); + assert!(!javascript_may_reference_visual_asset( + "const render = () => context.clearRect(0, 0, 320, 180);", + &bound_elements, + "assets/art-spritesheet-slices/player.png", + )); + assert!(javascript_may_reference_visual_asset( + r"const art=document.getElementById('player\x2dart');", + &bound_elements, + "assets/art-spritesheet-slices/player.png", + )); + assert!(javascript_may_reference_visual_asset( + r"art.src='../assets/art-spritesheet-slices/player\x2epng';", + &no_elements, + "assets/art-spritesheet-slices/player.png", + )); + assert!(!javascript_may_reference_visual_asset( + r"const digits=/\d+/;context.clearRect(0,0,320,180);", + &bound_elements, + "assets/art-spritesheet-slices/player.png", + )); + } + + #[test] + fn canvas_asset_analysis_skips_loaded_but_undrawn_images() { + assert!(!javascript_may_reference_canvas_draw( + "const art=new Image();art.src='../assets/player.png';" + )); + assert!(javascript_may_reference_canvas_draw( + r"context['draw\x49mage'](art,0,0,64,64);" + )); + } + + #[test] + fn canvas_asset_analysis_bounds_stable_top_level_aliases_in_branching_game_loops() { + let root = tempfile::tempdir().expect("create branching game loop fixture"); + let mut script = String::from( + "const canvas=document.getElementById('game');\n\ + const context=canvas.getContext('2d');\n\ + const art=new Image();\n\ + art.src='../assets/player.png';\n", + ); + script.push_str("/*"); + script.push_str(&"bounded-game-loop-fixture".repeat(400)); + script.push_str("*/\n"); + for level in 0..48 { + if level == 47 { + script.push_str("function frame47(){context.drawImage(art,0,0,64,64);}\n"); + } else { + script.push_str(&format!( + "function frame{level}(){{frame{}();frame{}();}}\n", + level + 1, + level + 1, + )); + } + } + script.push_str("frame0();"); + let html = format!( + "" + ); + let started = std::time::Instant::now(); + assert!(game_index_visibly_uses_visual_asset( + root.path(), + html.as_bytes(), + "assets/player.png", + (64, 64), + VisualAssetUsageRequirement::CanvasDraw, + )); + assert!( + started.elapsed() < std::time::Duration::from_secs(5), + "fan-in Canvas reachability exceeded the bounded execution budget: {:?}", + started.elapsed() + ); + } + + #[test] + fn canvas_asset_analysis_large_direct_calls_do_not_invoke_ordinary_arguments() { + let root = tempfile::tempdir().expect("create ordinary callback argument fixture"); + let base = "const canvas=document.getElementById('game');\n\ + const context=canvas.getContext('2d');\n\ + const art=new Image();\n\ + art.src='../assets/player.png';\n\ + function hiddenRender(){context.drawImage(art,0,0,64,64);}\n\ + function remember(callback){return callback.name;}\n\ + remember(hiddenRender);\n"; + for large in [false, true] { + let mut script = base.to_string(); + if large { + script.push_str("/*"); + script.push_str(&"ordinary-argument-padding".repeat(400)); + script.push_str("*/"); + assert!(script.len() >= 8 * 1024); + } else { + assert!(script.len() < 8 * 1024); + } + let html = format!( + "" + ); + assert!(!game_index_visibly_uses_visual_asset( + root.path(), + html.as_bytes(), + "assets/player.png", + (64, 64), + VisualAssetUsageRequirement::CanvasDraw, + )); + } + } + + #[test] + fn canvas_asset_analysis_reads_outer_asset_state_at_call_sites_across_size_boundary() { + let root = tempfile::tempdir().expect("create outer asset state fixture"); + let base = "const canvas=document.getElementById('game');\n\ + const context=canvas.getContext('2d');\n\ + let art;\n\ + function render(){context.drawImage(art,0,0,64,64);}\n\ + function initializeArt(){\n\ + art=new Image();\n\ + art.src='../assets/player.png';\n\ + }\n\ + function boot(){\n\ + initializeArt();\n\ + render();\n\ + }\n\ + boot();\n"; + for large in [false, true] { + let mut script = base.to_string(); + if large { + script.push_str("/*"); + script.push_str(&"outer-state-padding".repeat(500)); + script.push_str("*/"); + assert!(script.len() >= 8 * 1024); + } else { + assert!(script.len() < 8 * 1024); + } + let html = format!( + "" + ); + assert!(game_index_visibly_uses_visual_asset( + root.path(), + html.as_bytes(), + "assets/player.png", + (64, 64), + VisualAssetUsageRequirement::CanvasDraw, + )); + } + } + + #[test] + fn canvas_asset_analysis_preserves_top_level_function_aliases_across_size_boundary() { + let root = tempfile::tempdir().expect("create aliased render callback fixture"); + let base = "const canvas=document.getElementById('game');\n\ + const context=canvas.getContext('2d');\n\ + const art=new Image();\n\ + art.src='../assets/player.png';\n\ + function render(){context.drawImage(art,0,0,64,64);}\n\ + const frame=render;\n\ + requestAnimationFrame(frame);\n"; + for large in [false, true] { + let mut script = base.to_string(); + if large { + script.push_str("/*"); + script.push_str(&"aliased-render-padding".repeat(500)); + script.push_str("*/"); + assert!(script.len() >= 8 * 1024); + } else { + assert!(script.len() < 8 * 1024); + } + let html = format!( + "" + ); + assert!(game_index_visibly_uses_visual_asset( + root.path(), + html.as_bytes(), + "assets/player.png", + (64, 64), + VisualAssetUsageRequirement::CanvasDraw, + )); + } + } + + #[test] + fn canvas_asset_analysis_bounds_false_only_histories_in_large_render_graphs() { + let root = tempfile::tempdir().expect("create large render graph fixture"); + let mut script = String::from( + "const canvas=document.getElementById('game');\n\ + const context=canvas.getContext('2d');\n\ + const playerArt=new Image();\n\ + const otherArt=new Image();\n\ + playerArt.src='../assets/player.png';\n\ + otherArt.src='../assets/other.png';\n\ + let active={x:1};\n\ + function drawPlayer(piece){if(piece){context.drawImage(playerArt,0,0,64,64);}}\n", + ); + for index in 0..96 { + script.push_str(&format!( + "function helper{index}(piece){{const alias{index}=piece;if(alias{index}){{context.drawImage(otherArt,{index},0,8,8);}}}}\n" + )); + } + script.push_str("function draw(){"); + for index in 0..96 { + script.push_str(&format!("helper{index}(active);")); + } + script.push_str("drawPlayer(active);requestAnimationFrame(draw);}draw();"); + script.push_str("/*"); + script.push_str(&"large-render-padding".repeat(900)); + script.push_str("*/"); + assert!(script.len() >= 24 * 1024); + let html = format!( + "" + ); + let started = std::time::Instant::now(); + assert!(game_index_visibly_uses_visual_asset( + root.path(), + html.as_bytes(), + "assets/player.png", + (64, 64), + VisualAssetUsageRequirement::CanvasDraw, + )); + assert!( + started.elapsed() < std::time::Duration::from_secs(5), + "large render graph Canvas analysis exceeded the bounded execution budget: {:?}", + started.elapsed() + ); + } + + #[test] + fn canvas_asset_analysis_accepts_decoded_string_literal_paths() { + let root = tempfile::tempdir().expect("create escaped asset path fixture"); + for html in [ + br#""#.as_slice(), + br#""#.as_slice(), + ] { + assert!(game_index_visibly_uses_visual_asset( + root.path(), + html, + "assets/player.png", + (64, 64), + VisualAssetUsageRequirement::CanvasDraw, + )); + } + } + fn mixed_case_main_loop_html(invocation: &str) -> Vec { format!( "" @@ -13417,6 +15440,97 @@ mod visible_destination_tests { &["image", "-32", "16", "64", "64"], canvas, )); + assert!(draw_image_has_visible_destination( + &["image", "0", "0", "canvas.width", "canvas.height"], + canvas, + )); + } + + #[test] + fn canvas_asset_analysis_accepts_only_owner_bound_global_math_clamps() { + let root = tempfile::tempdir().expect("create owner-bound clamp fixture"); + let valid = br#""#; + assert!(game_index_visibly_uses_visual_asset( + root.path(), + valid, + "assets/player.png", + (64, 64), + VisualAssetUsageRequirement::CanvasDraw, + )); + + let wrong_canvas = br#""#; + assert!(!game_index_visibly_uses_visual_asset( + root.path(), + wrong_canvas, + "assets/player.png", + (64, 64), + VisualAssetUsageRequirement::CanvasDraw, + )); + + let shadowed_math = br#""#; + assert!(!game_index_visibly_uses_visual_asset( + root.path(), + shadowed_math, + "assets/player.png", + (64, 64), + VisualAssetUsageRequirement::CanvasDraw, + )); + } + + #[test] + fn canvas_asset_analysis_resolves_tile_grid_constants_inside_an_iife() { + let root = tempfile::tempdir().expect("create tile-grid Canvas fixture"); + let html = br#""#; + assert!(game_index_visibly_uses_visual_asset( + root.path(), + html, + "assets/player.png", + (64, 64), + VisualAssetUsageRequirement::CanvasDraw, + )); + } + + #[test] + fn canvas_asset_analysis_proves_tile_grid_loop_bounds() { + let root = tempfile::tempdir().expect("create tile-grid loop fixture"); + let html = br#""#; + assert!(game_index_visibly_uses_visual_asset( + root.path(), + html, + "assets/player.png", + (64, 64), + VisualAssetUsageRequirement::CanvasDraw, + )); + } + + #[test] + fn canvas_asset_analysis_accepts_game01_draw_shapes() { + let root = tempfile::tempdir().expect("create game01 Canvas-shape fixture"); + let html = br#""#; + let html = String::from_utf8(html.to_vec()) + .expect("game01 draw-shape fixture is UTF-8") + .replace( + "", + &format!("/*{}*/", "x".repeat(9 * 1024)), + ); + assert!(html.len() > 8 * 1024); + for asset_path in [ + "assets/art-spritesheet-slices/player.png", + "assets/art-spritesheet-slices/blocks-and-targets.png", + "assets/art-spritesheet-slices/obstacles-and-scene.png", + "assets/art-spritesheet-slices/feedback-effects.png", + ] { + assert!( + game_index_visibly_uses_visual_asset( + root.path(), + html.as_bytes(), + asset_path, + (64, 64), + VisualAssetUsageRequirement::CanvasDraw, + ), + "game01 draw shape must remain visible: {asset_path}" + ); + } } #[test] diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion_contract_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion_contract_tests.rs index 2cd3b3af9..dc23bc85a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion_contract_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion_contract_tests.rs @@ -439,6 +439,17 @@ fn queue_autonomous_manifest_child_fixture( } fn advance_game_index_revision(root: &Path, state: &AgentRuntimeState, html: &str) -> u64 { + let latest = + read_latest_game_creator_agent_runtime_task_by_run_id(root, &state.agent_id, &state.run_id) + .expect("read autonomous run before project mutation") + .expect("autonomous run exists before project mutation"); + if latest.status != "running" { + let mut running = state.clone(); + running.status = "running".to_string(); + running.phase = "planning".to_string(); + append_game_creator_agent_runtime_task(root, &running) + .expect("append durable running autonomous run before project mutation"); + } let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( root, "test.autonomous.mutate", @@ -767,6 +778,20 @@ fn append_failed_autonomous_root_projection( root: &Path, record: &AgentRuntimeTaskRecord, phase: &str, +) { + append_failed_autonomous_root_projection_with_error( + root, + record, + phase, + &format!("terminal phase={phase}"), + ); +} + +fn append_failed_autonomous_root_projection_with_error( + root: &Path, + record: &AgentRuntimeTaskRecord, + phase: &str, + error: &str, ) { append_game_creator_agent_runtime_task_record( root, @@ -774,8 +799,8 @@ fn append_failed_autonomous_root_projection( status: "failed".to_string(), phase: phase.to_string(), current_action: "测试中的自主构建已失败".to_string(), - terminal_detail: Some(format!("terminal phase={phase}")), - error: Some(format!("terminal phase={phase}")), + terminal_detail: Some(error.to_string()), + error: Some(error.to_string()), updated_at: unix_timestamp(), ..record.clone() }, @@ -1093,6 +1118,281 @@ fn gui_and_cli_pure_continue_inherit_only_within_the_same_source() { } } +#[test] +fn continuation_requeues_only_manifest_failures_cancelled_after_reconciliation() { + let original_task = "做一个水晶主题的俄罗斯方块,完成旋转、消行与重开"; + let (_temporary, root, original_state, original_contract) = autonomous_fixture_with_source( + original_task, + "reconciliation-cancel-original", + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + ); + let original_record = read_latest_game_creator_agent_runtime_task_by_run_id( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &original_state.run_id, + ) + .expect("read reconciliation original root") + .expect("reconciliation original root exists"); + let child = queue_autonomous_manifest_child_fixture(&root, &original_state, "design-director"); + append_game_creator_agent_runtime_task_record( + &root, + &AgentRuntimeTaskRecord { + status: "failed".to_string(), + phase: "needs-reconciliation".to_string(), + current_action: "测试中的工具结果需要人工核对".to_string(), + terminal_detail: Some("unknown tool outcome".to_string()), + error: Some("unknown tool outcome".to_string()), + updated_at: unix_timestamp(), + ..child.clone() + }, + ) + .expect("append child reconciliation projection"); + write_game_creator_agent_runtime_cancel_request( + &root, + &child.agent_id, + &child.run_id, + "测试中已人工核对并取消旧动作", + ) + .expect("persist reconciliation cancel tombstone"); + append_game_creator_agent_runtime_task_record( + &root, + &AgentRuntimeTaskRecord { + status: "cancelled".to_string(), + phase: "cancelled".to_string(), + current_action: "测试中的旧动作已取消".to_string(), + terminal_detail: Some("cancelled after reconciliation".to_string()), + error: None, + updated_at: unix_timestamp(), + ..child + }, + ) + .expect("append reconciled child cancellation"); + update_manifest_task_status_at(&root, "design-director", GameCreationAppTaskStatus::Failed) + .expect("project cancelled child into failed manifest task"); + update_manifest_task_status_at(&root, "code-director", GameCreationAppTaskStatus::Failed) + .expect("prepare unrelated failed manifest task"); + append_failed_autonomous_root_projection(&root, &original_record, "failed"); + + let first_continuation = append_unique_game_creator_agent_runtime_pending_task( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &original_state.session_id, + "继续", + "reconciliation-cancel-first-continuation", + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + None, + ) + .expect("queue continuation after reconciliation cancel"); + let statuses = read_manifest_for_project(&root) + .expect("read recovered reconciliation manifest") + .tasks + .into_iter() + .map(|task| (task.id, task.status)) + .collect::>(); + assert_eq!( + statuses.get("design-director"), + Some(&GameCreationAppTaskStatus::Pending), + "the explicitly cancelled reconciliation child must receive a new run on continuation" + ); + assert_eq!( + statuses.get("code-director"), + Some(&GameCreationAppTaskStatus::Failed), + "ordinary failures must remain closed instead of being retried implicitly" + ); + let inherited_contract = read_autonomous_completion_contract( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &first_continuation.run_id, + ) + .expect("read recovered continuation contract") + .expect("recovered continuation contract exists"); + assert_eq!( + inherited_contract.task_sha256, + original_contract.task_sha256 + ); + + update_manifest_task_status_at(&root, "design-director", GameCreationAppTaskStatus::Failed) + .expect("restore failed manifest fixture without an intermediate child"); + append_failed_autonomous_root_projection_with_error( + &root, + &first_continuation, + "failed", + GAME_CHAT_FIXED_TASK_GRAPH_STALLED_ERROR, + ); + let second_continuation = append_unique_game_creator_agent_runtime_pending_task( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &original_state.session_id, + "继续", + "reconciliation-cancel-second-continuation", + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + None, + ) + .expect("queue continuation across an intermediate parent without a child"); + assert_eq!( + read_manifest_for_project(&root) + .expect("read manifest after intermediate continuation") + .tasks + .into_iter() + .find(|task| task.id == "design-director") + .expect("design task remains present") + .status, + GameCreationAppTaskStatus::Pending, + "an intermediate failed parent without a child must not hide the reconciled cancellation" + ); + + let second_state = agent_runtime_state_from_task_record(&second_continuation); + let ordinary_failure = + queue_autonomous_manifest_child_fixture(&root, &second_state, "design-director"); + append_game_creator_agent_runtime_task_record( + &root, + &AgentRuntimeTaskRecord { + status: "failed".to_string(), + phase: "failed".to_string(), + current_action: "测试中的较新普通失败".to_string(), + terminal_detail: Some("ordinary failure".to_string()), + error: Some("ordinary failure".to_string()), + updated_at: unix_timestamp(), + ..ordinary_failure + }, + ) + .expect("append newer ordinary child failure"); + update_manifest_task_status_at(&root, "design-director", GameCreationAppTaskStatus::Failed) + .expect("project newer ordinary failure"); + append_failed_autonomous_root_projection(&root, &second_continuation, "failed"); + append_unique_game_creator_agent_runtime_pending_task( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &original_state.session_id, + "继续", + "reconciliation-cancel-third-continuation", + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + None, + ) + .expect("queue continuation after newer ordinary failure"); + assert_eq!( + read_manifest_for_project(&root) + .expect("read manifest after ordinary failure") + .tasks + .into_iter() + .find(|task| task.id == "design-director") + .expect("design task remains present") + .status, + GameCreationAppTaskStatus::Failed, + "a newer ordinary child failure must block an older reconciliation cancel tombstone" + ); +} + +#[test] +fn continuation_does_not_borrow_cancelled_reconciliation_after_newer_schedule_failure() { + let original_task = "做一个水晶主题的俄罗斯方块,完成旋转、消行与重开"; + let (_temporary, root, original_state, _) = autonomous_fixture_with_source( + original_task, + "reconciliation-schedule-failure-original", + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + ); + let original_record = read_latest_game_creator_agent_runtime_task_by_run_id( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &original_state.run_id, + ) + .expect("read schedule-failure original root") + .expect("schedule-failure original root exists"); + let child = queue_autonomous_manifest_child_fixture(&root, &original_state, "design-director"); + append_game_creator_agent_runtime_task_record( + &root, + &AgentRuntimeTaskRecord { + status: "failed".to_string(), + phase: "needs-reconciliation".to_string(), + current_action: "测试中的工具结果需要人工核对".to_string(), + terminal_detail: Some("unknown tool outcome".to_string()), + error: Some("unknown tool outcome".to_string()), + updated_at: unix_timestamp(), + ..child.clone() + }, + ) + .expect("append schedule-failure child reconciliation projection"); + write_game_creator_agent_runtime_cancel_request( + &root, + &child.agent_id, + &child.run_id, + "测试中已人工核对并取消旧动作", + ) + .expect("persist schedule-failure reconciliation cancel tombstone"); + append_game_creator_agent_runtime_task_record( + &root, + &AgentRuntimeTaskRecord { + status: "cancelled".to_string(), + phase: "cancelled".to_string(), + current_action: "测试中的旧动作已取消".to_string(), + terminal_detail: Some("cancelled after reconciliation".to_string()), + error: None, + updated_at: unix_timestamp(), + ..child + }, + ) + .expect("append schedule-failure reconciled child cancellation"); + update_manifest_task_status_at(&root, "design-director", GameCreationAppTaskStatus::Failed) + .expect("project schedule-failure cancelled child"); + append_failed_autonomous_root_projection(&root, &original_record, "failed"); + + let first_continuation = append_unique_game_creator_agent_runtime_pending_task( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &original_state.session_id, + "继续", + "reconciliation-schedule-failure-first-continuation", + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + None, + ) + .expect("queue first continuation after reconciliation cancel"); + assert_eq!( + read_manifest_for_project(&root) + .expect("read first schedule-failure continuation manifest") + .tasks + .into_iter() + .find(|task| task.id == "design-director") + .expect("design task remains present") + .status, + GameCreationAppTaskStatus::Pending + ); + + update_manifest_task_status_at(&root, "design-director", GameCreationAppTaskStatus::Failed) + .expect("project newer scheduler failure without a child"); + append_failed_autonomous_root_projection_with_error( + &root, + &first_continuation, + "failed", + "autonomous ready-task scheduler failed before child journal persistence", + ); + append_unique_game_creator_agent_runtime_pending_task( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &original_state.session_id, + "继续", + "reconciliation-schedule-failure-second-continuation", + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + None, + ) + .expect("queue continuation after newer scheduler failure"); + assert_eq!( + read_manifest_for_project(&root) + .expect("read manifest after newer scheduler failure") + .tasks + .into_iter() + .find(|task| task.id == "design-director") + .expect("design task remains present") + .status, + GameCreationAppTaskStatus::Failed, + "a newer scheduler failure without a child must block an older reconciliation tombstone" + ); +} + #[test] fn game_chat_detailed_new_request_after_failure_resets_manifest() { let (_temporary, root, original_state, original_contract) = @@ -5565,6 +5865,90 @@ fn game_chat_code_prototype_requires_cropped_spritesheet_use() { assert!(autonomous_game_build_completion_blocker_at_locked(&root, &code_state).is_none()); } +#[test] +fn canvas_visual_gate_rejects_scoped_alias_rebinding_in_large_scripts() { + let _config_guard = crate::tests::write_test_local_config( + r#"{"editorApi":{"apiKey":"large-canvas-alias-key"}}"#.to_string(), + ); + let (_temporary, root, parent_state, _contract) = autonomous_fixture_with_source( + "创建一轮需要 Canvas 美术的小游戏", + "large-canvas-alias-parent", + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + ); + update_manifest_task_status_at(&root, "code-prototype", GameCreationAppTaskStatus::Running) + .expect("mark code prototype running"); + let code_state = agent_runtime_state_from_task_record( + &queue_autonomous_manifest_child_fixture(&root, &parent_state, "code-prototype"), + ); + let padding = "x".repeat(9 * 1024); + let html = format!( + "" + ); + assert!(html.len() > 8 * 1024); + advance_game_index_revision(&root, &code_state, &html); + assert!(autonomous_game_build_completion_blocker_at_locked(&root, &code_state).is_some()); +} + +#[test] +fn canvas_visual_gate_rejects_off_canvas_tile_grid_call_arguments() { + let _config_guard = crate::tests::write_test_local_config( + r#"{"editorApi":{"apiKey":"off-canvas-grid-key"}}"#.to_string(), + ); + let (_temporary, root, parent_state, _contract) = autonomous_fixture_with_source( + "创建一轮需要 Canvas 美术的小游戏", + "off-canvas-grid-parent", + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + ); + update_manifest_task_status_at(&root, "code-prototype", GameCreationAppTaskStatus::Running) + .expect("mark code prototype running"); + let code_state = agent_runtime_state_from_task_record( + &queue_autonomous_manifest_child_fixture(&root, &parent_state, "code-prototype"), + ); + let html = ""; + advance_game_index_revision(&root, &code_state, html); + assert!(autonomous_game_build_completion_blocker_at_locked(&root, &code_state).is_some()); +} + +#[test] +fn canvas_visual_gate_resolves_numeric_constants_by_symbol_scope() { + let _config_guard = crate::tests::write_test_local_config( + r#"{"editorApi":{"apiKey":"scoped-grid-key"}}"#.to_string(), + ); + let (_temporary, root, parent_state, _contract) = autonomous_fixture_with_source( + "创建一轮需要 Canvas 美术的小游戏", + "scoped-grid-parent", + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + ); + update_manifest_task_status_at(&root, "code-prototype", GameCreationAppTaskStatus::Running) + .expect("mark code prototype running"); + let code_state = agent_runtime_state_from_task_record( + &queue_autonomous_manifest_child_fixture(&root, &parent_state, "code-prototype"), + ); + let html = ""; + advance_game_index_revision(&root, &code_state, html); + assert!(autonomous_game_build_completion_blocker_at_locked(&root, &code_state).is_none()); +} + +#[test] +fn canvas_visual_gate_rejects_dimensions_from_a_shadow_canvas_object() { + let _config_guard = crate::tests::write_test_local_config( + r#"{"editorApi":{"apiKey":"shadow-canvas-key"}}"#.to_string(), + ); + let (_temporary, root, parent_state, _contract) = autonomous_fixture_with_source( + "创建一轮需要 Canvas 美术的小游戏", + "shadow-canvas-parent", + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + ); + update_manifest_task_status_at(&root, "code-prototype", GameCreationAppTaskStatus::Running) + .expect("mark code prototype running"); + let code_state = agent_runtime_state_from_task_record( + &queue_autonomous_manifest_child_fixture(&root, &parent_state, "code-prototype"), + ); + let html = ""; + advance_game_index_revision(&root, &code_state, html); + assert!(autonomous_game_build_completion_blocker_at_locked(&root, &code_state).is_some()); +} + #[test] fn cli_code_prototype_keeps_registered_canvas_spritesheet_gate_when_editor_is_configured() { let _config_guard = crate::tests::write_test_local_config( @@ -5735,6 +6119,10 @@ fn game_chat_initial_directors_can_converge_after_hydration_restores_manifest_to .unwrap_or_else(|error| panic!("restore initial {task_id} to pending: {error}")); let record = queue_autonomous_manifest_child_fixture(&root, &parent_state, task_id); let mut state = agent_runtime_state_from_task_record(&record); + state.status = "running".to_string(); + state.phase = "planning".to_string(); + append_game_creator_agent_runtime_task(&root, &state) + .unwrap_or_else(|error| panic!("append running {task_id}: {error}")); assert!( autonomous_game_build_completion_blocker_at_locked(&root, &state).is_none(), @@ -5742,6 +6130,8 @@ fn game_chat_initial_directors_can_converge_after_hydration_restores_manifest_to ); state.status = "completed".to_string(); state.phase = "completed".to_string(); + append_game_creator_agent_runtime_task(&root, &state) + .unwrap_or_else(|error| panic!("append completed {task_id}: {error}")); assert!( project_autonomous_manifest_ready_task_terminal_at(&root, &state) .unwrap_or_else(|error| panic!("project completed {task_id}: {error}")) @@ -5761,7 +6151,7 @@ fn game_chat_initial_directors_can_converge_after_hydration_restores_manifest_to } #[test] -fn game_chat_later_code_child_rejects_pending_then_projects_verified_completion() { +fn current_game_chat_code_child_survives_pending_manifest_drift_and_projects_completion() { let (_temporary, root, parent_state, _contract) = autonomous_fixture_with_source( "创建一轮星空收集游戏", "game-chat-code-pending-parent", @@ -5772,20 +6162,27 @@ fn game_chat_later_code_child_rejects_pending_then_projects_verified_completion( let code_record = queue_autonomous_manifest_child_fixture(&root, &parent_state, "code-prototype"); let mut code_state = agent_runtime_state_from_task_record(&code_record); + code_state.status = "running".to_string(); + code_state.phase = "planning".to_string(); + append_game_creator_agent_runtime_task(&root, &code_state) + .expect("append running game-chat code child"); advance_game_index_revision(&root, &code_state, cropped_spritesheet_game_html()); - let blocker = autonomous_game_build_completion_blocker_at_locked(&root, &code_state) - .expect("later code child must keep the strict manifest status gate"); - assert!(blocker - .detail - .as_deref() - .is_some_and(|detail| detail.contains("status=pending"))); + assert!( + autonomous_manifest_dag_in_progress_at(&root) + .expect("read current game-chat DAG with a pending-manifest child"), + "the durable current child must keep the parent DAG in progress" + ); + assert!( + autonomous_game_build_completion_blocker_at_locked(&root, &code_state).is_none(), + "the current bound child must survive a stale manifest snapshot restored to pending" + ); - update_manifest_task_status_at(&root, "code-prototype", GameCreationAppTaskStatus::Running) - .expect("restore the later code child to its authoritative running state"); mark_verification_passed(&root, &code_state, "game.static_smoke"); code_state.status = "completed".to_string(); code_state.phase = "completed".to_string(); + append_game_creator_agent_runtime_task(&root, &code_state) + .expect("append completed game-chat code child"); assert!( project_autonomous_manifest_ready_task_terminal_at(&root, &code_state) .expect("project completed game-chat code child") @@ -5828,7 +6225,61 @@ fn game_chat_later_code_child_rejects_pending_then_projects_verified_completion( } #[test] -fn game_chat_preview_child_still_rejects_pending_manifest_status() { +fn queued_game_chat_child_cannot_borrow_pending_manifest_tolerance() { + let (_temporary, root, parent_state, _contract) = autonomous_fixture_with_source( + "创建一轮星空收集游戏", + "game-chat-queued-pending-parent", + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + ); + update_manifest_task_status_at(&root, "code-prototype", GameCreationAppTaskStatus::Pending) + .expect("leave queued code prototype pending"); + let code_record = + queue_autonomous_manifest_child_fixture(&root, &parent_state, "code-prototype"); + let code_state = agent_runtime_state_from_task_record(&code_record); + + let blocker = autonomous_game_build_completion_blocker_at_locked(&root, &code_state) + .expect("queued child must not satisfy the running drift contract"); + assert!(blocker + .detail + .as_deref() + .is_some_and(|detail| detail.contains("status=pending"))); +} + +#[test] +fn active_game_chat_child_keeps_dag_in_progress_after_completed_manifest_drift() { + let (_temporary, root, parent_state, _contract) = autonomous_fixture_with_source( + "创建一轮星空收集游戏", + "game-chat-completed-manifest-drift-parent", + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + ); + for task in autonomous_manifest_seed_tasks_for_source(AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE) + { + update_manifest_task_status_at(&root, &task.id, GameCreationAppTaskStatus::Completed) + .unwrap_or_else(|error| panic!("complete {}: {error}", task.id)); + } + let code_record = + queue_autonomous_manifest_child_fixture(&root, &parent_state, "code-prototype"); + let mut code_state = agent_runtime_state_from_task_record(&code_record); + code_state.status = "running".to_string(); + code_state.phase = "planning".to_string(); + append_game_creator_agent_runtime_task(&root, &code_state) + .expect("append active game-chat code child"); + + assert!( + autonomous_manifest_dag_in_progress_at(&root) + .expect("read DAG with active child and stale completed manifest"), + "a current active child must keep the DAG in progress" + ); + update_manifest_task_status_at(&root, "art-director", GameCreationAppTaskStatus::Failed) + .expect("fail one manifest task"); + assert!( + !autonomous_manifest_dag_in_progress_at(&root).expect("read failed DAG with active child"), + "a manifest failure must still fail closed" + ); +} + +#[test] +fn game_chat_preview_child_with_pending_manifest_still_requires_current_verification() { let (_temporary, root, parent_state, _contract) = autonomous_fixture_with_source( "创建一轮星空收集游戏", "game-chat-preview-pending-parent", @@ -5842,14 +6293,443 @@ fn game_chat_preview_child_still_rejects_pending_manifest_status() { .expect("leave preview readiness pending"); let preview_record = queue_autonomous_manifest_child_fixture(&root, &parent_state, "preview-readiness"); - let preview_state = agent_runtime_state_from_task_record(&preview_record); + let mut preview_state = agent_runtime_state_from_task_record(&preview_record); + preview_state.status = "running".to_string(); + preview_state.phase = "planning".to_string(); + append_game_creator_agent_runtime_task(&root, &preview_state) + .expect("append running preview child"); let blocker = autonomous_game_build_completion_blocker_at_locked(&root, &preview_state) - .expect("preview child must keep the strict manifest status gate"); + .expect("preview child must still require its current revision verification"); + assert!(blocker.summary.contains("game.static_smoke")); +} + +#[test] +fn stale_game_chat_child_cannot_borrow_pending_tolerance_from_a_newer_root() { + let (_temporary, root, parent_state, _contract) = autonomous_fixture_with_source( + "创建一轮星空收集游戏", + "game-chat-stale-pending-parent", + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + ); + update_manifest_task_status_at(&root, "code-prototype", GameCreationAppTaskStatus::Pending) + .expect("restore old child manifest status to pending"); + let code_record = + queue_autonomous_manifest_child_fixture(&root, &parent_state, "code-prototype"); + let mut code_state = agent_runtime_state_from_task_record(&code_record); + code_state.status = "running".to_string(); + code_state.phase = "planning".to_string(); + advance_game_index_revision(&root, &code_state, cropped_spritesheet_game_html()); + assert!(autonomous_game_build_completion_blocker_at_locked(&root, &code_state).is_none()); + + append_unique_game_creator_agent_runtime_pending_task( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &parent_state.session_id, + "创建一轮新的独立玩法", + "game-chat-newer-current-parent", + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + None, + ) + .expect("queue newer current game-chat root"); + + let blocker = autonomous_game_build_completion_blocker_at_locked(&root, &code_state) + .expect("stale child must not inherit pending tolerance from the newer root"); assert!(blocker .detail .as_deref() .is_some_and(|detail| detail.contains("status=pending"))); + assert!( + !autonomous_manifest_dag_in_progress_at(&root) + .expect("read DAG after the current root changes"), + "the old child must not keep the new root DAG alive" + ); +} + +#[test] +fn stale_game_chat_child_cannot_mutate_after_a_newer_root_is_created() { + let (_temporary, root, parent_state, _contract) = autonomous_fixture_with_source( + "创建一轮星空收集游戏", + "game-chat-stale-mutation-parent", + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + ); + let code_record = + queue_autonomous_manifest_child_fixture(&root, &parent_state, "code-prototype"); + let mut code_state = agent_runtime_state_from_task_record(&code_record); + code_state.status = "running".to_string(); + code_state.phase = "planning".to_string(); + append_game_creator_agent_runtime_task(&root, &code_state) + .expect("append running old code child"); + let original = ""; + fs::write(root.join(AGENT_RUNTIME_GAME_INDEX_PATH), original) + .expect("write original game entry"); + let revision_before = read_game_creator_agent_runtime_project_revision(&root) + .expect("read revision before stale mutation"); + + append_unique_game_creator_agent_runtime_pending_task( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &parent_state.session_id, + "创建一轮新的独立玩法", + "game-chat-newer-mutation-parent", + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + None, + ) + .expect("queue newer current root before old child mutation"); + let action = AgentRuntimeToolAction { + tool: "file.patch".to_string(), + reason: Some("旧 child 不得污染新根 Run".to_string()), + input: serde_json::json!({ + "path": AGENT_RUNTIME_GAME_INDEX_PATH, + "oldText": "const owner='old'", + "newText": "const owner='stale-child'", + "expectedReplacements": 1, + }), + }; + let observation = observe_agent_runtime_file_patch( + &root, + &code_state.agent_id, + &code_state.run_id, + &action, + "stale-child-action-fingerprint", + None, + ); + + assert_ne!(observation.status, "ok"); + assert!(observation + .detail + .as_deref() + .is_some_and(|detail| detail.contains("更新根 Run"))); + assert_eq!( + fs::read_to_string(root.join(AGENT_RUNTIME_GAME_INDEX_PATH)) + .expect("read game entry after blocked stale mutation"), + original + ); + assert_eq!( + read_game_creator_agent_runtime_project_revision(&root) + .expect("read revision after blocked stale mutation") + .revision, + revision_before.revision + ); +} + +#[test] +fn stale_game_chat_child_cannot_mutate_manifest_or_memory_after_a_newer_root() { + let (_temporary, root, parent_state, _contract) = autonomous_fixture_with_source( + "创建一轮星空收集游戏", + "game-chat-stale-context-parent", + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + ); + let child_record = + queue_autonomous_manifest_child_fixture(&root, &parent_state, "code-prototype"); + let mut child_state = agent_runtime_state_from_task_record(&child_record); + child_state.status = "running".to_string(); + child_state.phase = "planning".to_string(); + append_game_creator_agent_runtime_task(&root, &child_state) + .expect("append running stale context child"); + append_unique_game_creator_agent_runtime_pending_task( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &parent_state.session_id, + "创建一轮新的独立玩法", + "game-chat-newer-context-parent", + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + None, + ) + .expect("queue newer root before stale context mutations"); + + let persisted_bytes = |relative_path: &str| fs::read(root.join(relative_path)).ok(); + let manifest_before = persisted_bytes(".agent/manifest.json"); + let agent_db_before = persisted_bytes(".agent/agent.db"); + let project_memory_before = persisted_bytes("memory/project.md"); + let blackboard_before = persisted_bytes(PROJECT_BLACKBOARD_MEMORY_PATH); + let revision_before = read_game_creator_agent_runtime_project_revision(&root) + .expect("read revision before stale context mutations"); + + let observations = [ + observe_agent_runtime_task_create( + &root, + &child_state.agent_id, + &child_state.run_id, + &serde_json::json!({ + "taskId": "stale-child-created-task", + "title": "旧 child 创建的任务" + }), + ), + observe_agent_runtime_task_update( + &root, + &child_state.agent_id, + &child_state.run_id, + &serde_json::json!({"taskId": "code-prototype", "status": "failed"}), + ), + observe_agent_runtime_memory_write( + &root, + &child_state.agent_id, + &child_state.run_id, + &serde_json::json!({"scope": "project", "content": "旧 child 记忆污染"}), + ), + observe_agent_runtime_blackboard_write( + &root, + &child_state.agent_id, + &child_state.run_id, + &serde_json::json!({"content": "旧 child 黑板污染"}), + ), + ]; + + for observation in observations { + assert_ne!( + observation.status, "ok", + "unexpected observation: {observation:?}" + ); + assert!( + observation.summary.contains("更新根 Run"), + "stale action must report the superseding root: {observation:?}" + ); + } + assert_eq!(persisted_bytes(".agent/manifest.json"), manifest_before); + assert_eq!(persisted_bytes(".agent/agent.db"), agent_db_before); + assert_eq!(persisted_bytes("memory/project.md"), project_memory_before); + assert_eq!( + persisted_bytes(PROJECT_BLACKBOARD_MEMORY_PATH), + blackboard_before + ); + assert_eq!( + read_game_creator_agent_runtime_project_revision(&root) + .expect("read revision after stale context mutations") + .revision, + revision_before.revision + ); +} + +#[test] +fn ready_child_binding_without_journal_fails_closed_for_every_project_write() { + let (_temporary, root, parent_state, _contract) = autonomous_fixture_with_source( + "创建一轮星空收集游戏", + "game-chat-missing-child-journal-parent", + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + ); + let child_record = + queue_autonomous_manifest_child_fixture(&root, &parent_state, "code-prototype"); + let mut child_state = agent_runtime_state_from_task_record(&child_record); + child_state.status = "running".to_string(); + child_state.phase = "planning".to_string(); + append_game_creator_agent_runtime_task(&root, &child_state) + .expect("append running child before deleting journal"); + fs::remove_file(game_creator_agent_runtime_task_path( + &root, + &child_state.agent_id, + )) + .expect("remove child journal while retaining binding"); + + let original = ""; + fs::write(root.join(AGENT_RUNTIME_GAME_INDEX_PATH), original) + .expect("write current game entry"); + let manifest_before = fs::read(root.join(".agent/manifest.json")).expect("read manifest"); + let revision_before = read_game_creator_agent_runtime_project_revision(&root) + .expect("read revision before missing-journal writes"); + let patch_action = AgentRuntimeToolAction { + tool: "file.patch".to_string(), + reason: None, + input: serde_json::json!({ + "path": AGENT_RUNTIME_GAME_INDEX_PATH, + "oldText": "const owner='current'", + "newText": "const owner='orphan'", + "expectedReplacements": 1 + }), + }; + let observations = [ + observe_agent_runtime_file_patch( + &root, + &child_state.agent_id, + &child_state.run_id, + &patch_action, + "missing-journal-file-patch", + None, + ), + observe_agent_runtime_task_create( + &root, + &child_state.agent_id, + &child_state.run_id, + &serde_json::json!({"taskId": "orphan-task", "title": "孤儿任务"}), + ), + observe_agent_runtime_task_update( + &root, + &child_state.agent_id, + &child_state.run_id, + &serde_json::json!({"taskId": "code-prototype", "status": "failed"}), + ), + observe_agent_runtime_memory_write( + &root, + &child_state.agent_id, + &child_state.run_id, + &serde_json::json!({"scope": "project", "content": "孤儿记忆"}), + ), + observe_agent_runtime_blackboard_write( + &root, + &child_state.agent_id, + &child_state.run_id, + &serde_json::json!({"content": "孤儿黑板"}), + ), + ]; + for observation in observations { + assert_ne!( + observation.status, "ok", + "unexpected observation: {observation:?}" + ); + let text = format!( + "{} {}", + observation.summary, + observation.detail.unwrap_or_default() + ); + assert!( + text.contains("journal"), + "missing journal must fail closed: {text}" + ); + } + assert_eq!( + fs::read_to_string(root.join(AGENT_RUNTIME_GAME_INDEX_PATH)) + .expect("read game after missing-journal writes"), + original + ); + assert_eq!( + fs::read(root.join(".agent/manifest.json")).expect("read manifest after writes"), + manifest_before + ); + assert_eq!( + read_game_creator_agent_runtime_project_revision(&root) + .expect("read revision after missing-journal writes") + .revision, + revision_before.revision + ); +} + +#[test] +fn ready_child_journal_without_binding_fails_closed_before_project_write() { + let (_temporary, root, parent_state, _contract) = autonomous_fixture_with_source( + "创建一轮星空收集游戏", + "game-chat-missing-child-binding-parent", + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + ); + let child_record = + queue_autonomous_manifest_child_fixture(&root, &parent_state, "code-prototype"); + let mut child_state = agent_runtime_state_from_task_record(&child_record); + child_state.status = "running".to_string(); + child_state.phase = "planning".to_string(); + append_game_creator_agent_runtime_task(&root, &child_state) + .expect("append running child before deleting binding"); + fs::remove_file(game_creator_agent_runtime_run_profile_binding_path( + &root, + &child_state.agent_id, + &child_state.run_id, + )) + .expect("remove child binding while retaining journal"); + + let original = ""; + fs::write(root.join(AGENT_RUNTIME_GAME_INDEX_PATH), original) + .expect("write current game entry"); + let revision_before = read_game_creator_agent_runtime_project_revision(&root) + .expect("read revision before missing-binding write"); + let patch_action = AgentRuntimeToolAction { + tool: "file.patch".to_string(), + reason: None, + input: serde_json::json!({ + "path": AGENT_RUNTIME_GAME_INDEX_PATH, + "oldText": "const owner='current'", + "newText": "const owner='orphan'", + "expectedReplacements": 1 + }), + }; + + let observation = observe_agent_runtime_file_patch( + &root, + &child_state.agent_id, + &child_state.run_id, + &patch_action, + "missing-binding-file-patch", + None, + ); + + assert_ne!(observation.status, "ok", "{observation:?}"); + let text = format!( + "{} {}", + observation.summary, + observation.detail.unwrap_or_default() + ); + assert!( + text.contains("binding"), + "missing binding must fail closed: {text}" + ); + assert_eq!( + fs::read_to_string(root.join(AGENT_RUNTIME_GAME_INDEX_PATH)) + .expect("read game after missing-binding write"), + original + ); + assert_eq!( + read_game_creator_agent_runtime_project_revision(&root) + .expect("read revision after missing-binding write") + .revision, + revision_before.revision + ); +} + +#[test] +fn autonomous_root_creation_waits_for_the_project_write_lock() { + let (_temporary, root, parent_state, _contract) = autonomous_fixture_with_source( + "创建一轮星空收集游戏", + "game-chat-project-lock-parent", + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + ); + let project_lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + &root, + "test.hold-before-new-root", + ) + .expect("hold project lock before creating a newer root"); + let requested_run_id = "game-chat-project-lock-new-root"; + let root_for_thread = root.clone(); + let session_id = parent_state.session_id.clone(); + let (started_tx, started_rx) = std::sync::mpsc::channel(); + let (result_tx, result_rx) = std::sync::mpsc::channel(); + let creator = std::thread::spawn(move || { + started_tx.send(()).expect("announce root creation attempt"); + let result = append_unique_game_creator_agent_runtime_pending_task( + &root_for_thread, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &session_id, + "创建一轮新的独立玩法", + requested_run_id, + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + None, + ); + result_tx.send(result).expect("return root creation result"); + }); + started_rx + .recv_timeout(std::time::Duration::from_secs(1)) + .expect("root creator started"); + std::thread::sleep(std::time::Duration::from_millis(50)); + assert!(matches!( + result_rx.try_recv(), + Err(std::sync::mpsc::TryRecvError::Empty) + )); + assert!( + read_game_creator_agent_runtime_run_profile_binding( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + requested_run_id, + ) + .expect("read blocked root binding") + .is_none(), + "new root binding must not cross the held project lock" + ); + drop(project_lock); + let created = result_rx + .recv_timeout(std::time::Duration::from_secs(2)) + .expect("root creation finishes after lock release") + .expect("create newer root after lock release"); + creator.join().expect("join root creator"); + assert_eq!(created.run_id, requested_run_id); } #[test] 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 12f7ff72f..f56a0ae7e 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 @@ -251,6 +251,11 @@ pub(super) fn start_game_creator_agent_runtime_task_for_session_in_session_lane_ .ok() .map(|result| result.state); let mut state = default_game_creator_agent_runtime_state(&agent_id, &run_id); + state.started_at = queued_task_record + .as_ref() + .map(|record| record.updated_at) + .filter(|updated_at| *updated_at > 0) + .unwrap_or_else(unix_timestamp); state.session_id = session_id; state.source = source.trim().to_string(); let (run_profile, run_profile_binding_fingerprint) = agent_runtime_run_profile_identity_at( @@ -286,6 +291,9 @@ pub(super) fn start_game_creator_agent_runtime_task_for_session_in_session_lane_ && previous_state.source == state.source && previous_state.current_task == state.current_task; if same_runtime_run { + if previous_state.started_at > 0 { + state.started_at = previous_state.started_at; + } state.loop_iteration = previous_state.loop_iteration; state.max_loop_iterations = previous_state.max_loop_iterations; state.tool_action_budget = previous_state.tool_action_budget; @@ -1453,6 +1461,7 @@ pub(crate) fn default_game_creator_agent_runtime_state( context_usage: AgentRuntimeContextUsage::default(), last_response: None, error: None, + started_at: 0, updated_at: unix_timestamp(), } } @@ -2823,6 +2832,17 @@ pub(super) fn append_unique_game_creator_agent_runtime_pending_task( requested_run_profile: Option<&str>, task_link: Option<&AgentRuntimeTaskLink>, ) -> Result { + let autonomous_root_project_lock = (agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID + && task_link.is_none() + && requested_run_profile + .is_some_and(|profile| profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD)) + .then(|| { + acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "runtime.autonomous-root.create", + ) + }) + .transpose()?; let _journal_lock = acquire_game_creator_agent_runtime_task_journal_lock(root, agent_id)?; let run_id = unique_game_creator_agent_runtime_run_id(root, agent_id, requested_run_id)?; let run_profile_binding = bind_game_creator_agent_runtime_run_profile_at( @@ -2865,6 +2885,8 @@ pub(super) fn append_unique_game_creator_agent_runtime_pending_task( updated_at: unix_timestamp(), }; append_game_creator_agent_runtime_task_record_unlocked(root, &record)?; + drop(_journal_lock); + drop(autonomous_root_project_lock); if let Err(error) = ensure_autonomous_completion_contract_for_task_at(root, &record) { let public_error = redact_agent_runtime_project_paths(root, &error, 500); let failed = AgentRuntimeTaskRecord { @@ -2882,7 +2904,7 @@ pub(super) fn append_unique_game_creator_agent_runtime_pending_task( Ok(record) } -pub(super) fn append_or_read_exact_game_creator_agent_runtime_pending_task( +pub(crate) fn append_or_read_exact_game_creator_agent_runtime_pending_task( root: &Path, agent_id: &str, session_id: &str, @@ -3111,19 +3133,32 @@ pub(super) fn read_recent_game_creator_agent_runtime_events_for_session( pub(super) struct AgentRuntimeTaskSnapshot { pub(super) task_queue: AgentRuntimeTaskQueueSummary, pub(super) recent_tasks: Vec, + pub(super) run_started_at: Option, } pub(super) fn read_game_creator_agent_runtime_task_snapshot( path: &Path, ) -> Result { - read_game_creator_agent_runtime_task_snapshot_for_session(path, None) + read_game_creator_agent_runtime_task_snapshot_for_session(path, None, None) } pub(super) fn read_game_creator_agent_runtime_task_snapshot_for_session( path: &Path, session_id: Option<&str>, + run_id: Option<&str>, ) -> Result { let records = read_all_game_creator_agent_runtime_tasks(path)?; + let run_started_at = run_id.and_then(|run_id| { + records + .iter() + .filter(|record| { + record.run_id == run_id + && session_id.map_or(true, |session_id| record.session_id == session_id) + }) + .map(|record| record.updated_at) + .filter(|updated_at| *updated_at > 0) + .min() + }); let latest = latest_game_creator_agent_runtime_tasks(records) .into_iter() .filter(|record| session_id.map_or(true, |session_id| record.session_id == session_id)) @@ -3136,6 +3171,7 @@ pub(super) fn read_game_creator_agent_runtime_task_snapshot_for_session( Ok(AgentRuntimeTaskSnapshot { task_queue, recent_tasks: recent, + run_started_at, }) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/context.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/context.rs index e7c30e8c6..60524d81a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/context.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/context.rs @@ -26,6 +26,7 @@ pub(in crate::agent) fn observe_agent_runtime_memory( pub(in crate::agent) fn observe_agent_runtime_memory_write( root: &Path, agent_id: &str, + run_id: &str, input: &serde_json::Value, ) -> AgentRuntimeToolObservation { let scope = input @@ -105,6 +106,16 @@ pub(in crate::agent) fn observe_agent_runtime_memory_write( }; } }; + if let Err(error) = + ensure_current_autonomous_ready_child_mutation_at_locked(root, agent_id, run_id) + { + return AgentRuntimeToolObservation { + tool: "memory.write".to_string(), + status: "failed".to_string(), + summary: sanitize_agent_runtime_text(&error, 240), + detail: None, + }; + } if let Err(error) = advance_agent_runtime_project_revision_locked(root) { return agent_runtime_revision_advance_failure_observation(root, "memory.write", &error); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delegation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delegation.rs index cb3addee3..ee903145f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delegation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delegation.rs @@ -3,6 +3,7 @@ use super::*; pub(in crate::agent) fn observe_agent_runtime_blackboard_write( root: &Path, agent_id: &str, + run_id: &str, input: &serde_json::Value, ) -> AgentRuntimeToolObservation { let content = agent_runtime_tool_input_text(input, &["content", "summary", "message"]); @@ -25,6 +26,16 @@ pub(in crate::agent) fn observe_agent_runtime_blackboard_write( }; } }; + if let Err(error) = + ensure_current_autonomous_ready_child_mutation_at_locked(root, agent_id, run_id) + { + return AgentRuntimeToolObservation { + tool: "blackboard.write".to_string(), + status: "failed".to_string(), + summary: sanitize_agent_runtime_text(&error, 240), + detail: None, + }; + } if let Err(error) = advance_agent_runtime_project_revision_locked(root) { return agent_runtime_revision_advance_failure_observation( root, @@ -112,6 +123,16 @@ pub(crate) fn observe_agent_runtime_agent_message( }; } }; + if let Err(error) = + ensure_current_autonomous_ready_child_mutation_at_locked(root, agent_id, run_id) + { + return AgentRuntimeToolObservation { + tool: "agent.message".to_string(), + status: "failed".to_string(), + summary: sanitize_agent_runtime_text(&error, 240), + detail: None, + }; + } let content = truncate_agent_runtime_text(sanitize_prompt_context(&content).as_str(), 1_200); let message = format!("来自 {agent_id} 的定向消息:{content}"); let result = resolve_agent_conversation_session_id_at(root, &target_agent_id, None, true) @@ -397,6 +418,16 @@ pub(crate) fn observe_agent_runtime_agent_delegate( detail: None, }; } + if let Err(error) = + ensure_current_autonomous_ready_child_mutation_at_locked(root, agent_id, parent_run_id) + { + return AgentRuntimeToolObservation { + tool: "agent.delegate".to_string(), + status: "failed".to_string(), + summary: sanitize_agent_runtime_text(&error, 240), + detail: None, + }; + } let action_identity = action_id .filter(|value| !value.trim().is_empty()) .map(str::to_string) 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 893a3be2c..aca5bde8d 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 @@ -359,13 +359,13 @@ pub(in crate::agent) fn wake_waiting_autonomous_manifest_parent_run_at( return Err("manifest parent-wake 只允许自主构建根 Supervisor".to_string()); } - schedule_autonomous_game_build_ready_tasks_at( + let scheduled_ready_tasks = schedule_autonomous_game_build_ready_tasks_at( root, ¤t_task.agent_id, ¤t_task.run_id, 3, )?; - if autonomous_manifest_dag_in_progress_at(root)? { + if !scheduled_ready_tasks.is_empty() || autonomous_manifest_dag_in_progress_at(root)? { return Ok(false); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/media.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/media.rs index b38f08aa2..292ae1c98 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/media.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/media.rs @@ -746,47 +746,50 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio { return blocker; } - let recovery_result = match acquire_game_creator_agent_runtime_project_write_lock_with_wait( - root, - "canvas.asset_generate.recover", - ) { - Ok(recovery_lock) => { - let result = - options.recover_interrupted_strict_transaction_locked_at(root, &recovery_lock); - drop(recovery_lock); - result - } - Err(error) => Err(error), - }; - if let Err(error) = recovery_result { + let pre_request_result = + match acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "canvas.asset_generate.recover", + ) { + Ok(recovery_lock) => { + let result = ensure_current_autonomous_ready_child_mutation_at_locked( + root, agent_id, run_id, + ) + .and_then(|()| { + options + .recover_interrupted_strict_transaction_locked_at(root, &recovery_lock) + .map(|_| ()) + }) + .and_then(|()| { + if !options.replace_existing && !resumes_durable_generation { + prepare_platform_art_asset_output_path(root, options.output_path.as_deref()) + .map(|_| ()) + } else { + Ok(()) + } + }); + drop(recovery_lock); + result + } + Err(error) => Err(error), + }; + if let Err(error) = pre_request_result { return AgentRuntimeToolObservation { tool: "canvas.asset_generate".to_string(), - status: AGENT_RUNTIME_TOOL_OBSERVATION_STATUS_NEEDS_RECONCILIATION.to_string(), + status: if resumes_durable_generation { + AGENT_RUNTIME_TOOL_OBSERVATION_STATUS_NEEDS_RECONCILIATION + } else { + "failed" + } + .to_string(), summary: redact_agent_runtime_project_paths( root, - &format!("无法在短时项目写锁内恢复中断的平台图集事务:{error}"), + &format!("canvas.asset_generate 外部请求前置校验失败,未发起新请求:{error}"), 240, ), detail: None, }; } - if let Some(blocker) = - supervisor_orchestrator_mutation_block_at(root, agent_id, run_id, "canvas.asset_generate") - { - return blocker; - } - if !options.replace_existing && !resumes_durable_generation { - if let Err(error) = - prepare_platform_art_asset_output_path(root, options.output_path.as_deref()) - { - return AgentRuntimeToolObservation { - tool: "canvas.asset_generate".to_string(), - status: "failed".to_string(), - summary: redact_agent_runtime_project_paths(root, &error, 240), - detail: None, - }; - } - } let runtime_context = pending_action.map(platform_art_generation_runtime_context_from_pending); let prepared = match request_platform_art_asset_with_runtime_options_at( root, @@ -827,12 +830,23 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio Err(error) => { return AgentRuntimeToolObservation { tool: "canvas.asset_generate".to_string(), - status: "failed".to_string(), + status: AGENT_RUNTIME_TOOL_OBSERVATION_STATUS_NEEDS_RECONCILIATION.to_string(), summary: redact_agent_runtime_project_paths(root, &error, 240), detail: None, }; } }; + if let Err(error) = + ensure_current_autonomous_ready_child_mutation_at_locked(root, agent_id, run_id) + { + return AgentRuntimeToolObservation { + tool: "canvas.asset_generate".to_string(), + status: AGENT_RUNTIME_TOOL_OBSERVATION_STATUS_NEEDS_RECONCILIATION.to_string(), + summary: "External Editor 已产生 durable 结果,但当前根 Run 已变化,未提交本地素材" + .to_string(), + detail: Some(redact_agent_runtime_project_paths(root, &error, 500)), + }; + } if let Err(error) = options.recover_interrupted_strict_transaction_locked_at(root, &_lock) { return AgentRuntimeToolObservation { tool: "canvas.asset_generate".to_string(), @@ -848,7 +862,17 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio if let Some(blocker) = supervisor_orchestrator_mutation_block_at(root, agent_id, run_id, "canvas.asset_generate") { - return blocker; + return AgentRuntimeToolObservation { + tool: "canvas.asset_generate".to_string(), + status: AGENT_RUNTIME_TOOL_OBSERVATION_STATUS_NEEDS_RECONCILIATION.to_string(), + summary: "External Editor 已产生 durable 结果,但本地提交策略已变化,需人工核对" + .to_string(), + detail: Some(format!( + "{}:{}", + blocker.summary, + blocker.detail.unwrap_or_default() + )), + }; } if options.replace_existing { let output_path = options @@ -863,12 +887,11 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio output_path, ) { - return AgentRuntimeToolObservation { - tool: "canvas.asset_generate".to_string(), - status: "failed".to_string(), - summary: redact_agent_runtime_project_paths(root, &error, 240), - detail: None, - }; + return canvas_durable_result_reconciliation_observation( + root, + "External Editor 已产生 durable 结果,但替换资格复检失败,未提交本地素材", + &error, + ); } } let mutation_revision = match prepare_agent_runtime_project_mutation_locked( @@ -879,9 +902,9 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio ) { Ok(revision) => revision, Err(error) => { - return agent_runtime_revision_advance_failure_observation( + return canvas_durable_result_reconciliation_observation( root, - "canvas.asset_generate", + "External Editor 已产生 durable 结果,但项目 revision 准备失败,未提交本地素材", &error, ); } @@ -925,12 +948,11 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio finish_agent_runtime_project_verification_locked(root, &revision, gate, true) }); if let Err(error) = verification { - return AgentRuntimeToolObservation { - tool: "canvas.asset_generate".to_string(), - status: "failed".to_string(), - summary: "美术素材已生成,但无法提交当前 revision 的验证凭证".to_string(), - detail: Some(redact_agent_runtime_project_paths(root, &error, 500)), - }; + return canvas_durable_result_reconciliation_observation( + root, + "美术素材已生成,但无法提交当前 revision 的验证凭证", + &error, + ); } let _ = append_agent_db_record( root, @@ -989,6 +1011,19 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio } } +fn canvas_durable_result_reconciliation_observation( + root: &Path, + summary: &str, + error: &str, +) -> AgentRuntimeToolObservation { + AgentRuntimeToolObservation { + tool: "canvas.asset_generate".to_string(), + status: AGENT_RUNTIME_TOOL_OBSERVATION_STATUS_NEEDS_RECONCILIATION.to_string(), + summary: summary.to_string(), + detail: Some(redact_agent_runtime_project_paths(root, error, 500)), + } +} + fn platform_art_generation_observation_status( root: &Path, agent_id: &str, @@ -1022,6 +1057,139 @@ pub(crate) async fn observe_agent_runtime_platform_art_asset_generation_after_di mod platform_art_generation_observation_tests { use super::*; + #[tokio::test] + async fn stale_ready_child_is_rejected_before_external_canvas_request() { + let temporary = tempfile::tempdir().expect("create stale canvas generation project"); + let root = temporary.path().join("project"); + init_local_game_project_at(&root, "stale-canvas-generation", "创建游戏") + .expect("init stale canvas generation project"); + let supervisor_session = resolve_agent_conversation_session_id_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + None, + true, + ) + .expect("resolve stale canvas supervisor session"); + let parent = append_unique_game_creator_agent_runtime_pending_task( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &supervisor_session, + "创建游戏", + "stale-canvas-parent", + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + None, + ) + .expect("queue stale canvas parent"); + let child_agent_id = "art-director"; + let child_session = + resolve_agent_conversation_session_id_at(&root, child_agent_id, None, true) + .expect("resolve stale canvas child session"); + let child_run_id = autonomous_manifest_ready_task_run_id(&parent.run_id, child_agent_id); + let child = append_unique_game_creator_agent_runtime_pending_task( + &root, + child_agent_id, + &child_session, + "生成统一视觉规范图", + &child_run_id, + "agent-ready-task-scheduler", + None, + Some(&AgentRuntimeTaskLink { + parent_agent_id: Some(parent.agent_id.clone()), + parent_run_id: Some(parent.run_id.clone()), + delegation_id: None, + }), + ) + .expect("queue stale canvas child"); + let mut child_state = agent_runtime_state_from_task_record(&child); + child_state.status = "running".to_string(); + child_state.phase = "planning".to_string(); + append_game_creator_agent_runtime_task(&root, &child_state) + .expect("append running stale canvas child"); + append_unique_game_creator_agent_runtime_pending_task( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &supervisor_session, + "创建另一轮游戏", + "stale-canvas-new-parent", + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + None, + ) + .expect("queue newer root before canvas generation"); + + let listener = + std::net::TcpListener::bind("127.0.0.1:0").expect("bind stale canvas request fixture"); + listener + .set_nonblocking(true) + .expect("set stale canvas fixture nonblocking"); + let base_url = format!("http://{}", listener.local_addr().expect("fixture address")); + let _config_guard = crate::tests::write_test_local_config( + serde_json::json!({ + "editorApi": {"baseUrl": base_url, "apiKey": "stale-canvas-test-key"} + }) + .to_string(), + ); + let (request_tx, request_rx) = std::sync::mpsc::channel(); + let (stop_tx, stop_rx) = std::sync::mpsc::channel(); + let server = std::thread::spawn(move || loop { + if stop_rx.try_recv().is_ok() { + break; + } + match listener.accept() { + Ok((_stream, _)) => { + request_tx + .send(()) + .expect("capture unexpected canvas request"); + } + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + std::thread::sleep(std::time::Duration::from_millis(5)); + } + Err(error) => panic!("accept stale canvas request: {error}"), + } + }); + let revision_before = read_game_creator_agent_runtime_project_revision(&root) + .expect("read revision before stale canvas generation"); + let asset_before = fs::read(root.join(AGENT_RUNTIME_ART_SPEC_PATH)).ok(); + + let observation = observe_agent_runtime_platform_art_asset_generation( + &root, + child_agent_id, + &child.run_id, + "生成统一视觉规范图", + &serde_json::json!({"prompt": "生成统一视觉规范图"}), + None, + ) + .await; + + stop_tx.send(()).expect("stop stale canvas fixture"); + server.join().expect("join stale canvas fixture"); + assert_eq!(observation.status, "failed", "{observation:?}"); + assert!( + observation.summary.contains("更新根 Run"), + "{observation:?}" + ); + assert!( + request_rx.try_recv().is_err(), + "stale child sent an external request" + ); + assert!(!game_creator_agent_runtime_external_generation_exists( + &root, + child_agent_id, + &child.run_id + )); + assert_eq!( + fs::read(root.join(AGENT_RUNTIME_ART_SPEC_PATH)).ok(), + asset_before + ); + assert_eq!( + read_game_creator_agent_runtime_project_revision(&root) + .expect("read revision after stale canvas generation") + .revision, + revision_before.revision + ); + } + #[test] fn canvas_asset_kind_validation_accepts_shared_catalog() { for asset_kind in AGENT_RUNTIME_CANVAS_ASSET_KINDS { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/task_ops.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/task_ops.rs index 630fd80fe..c8a2811ad 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/task_ops.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/task_ops.rs @@ -327,6 +327,7 @@ mod tests { pub(in crate::agent) fn observe_agent_runtime_task_create( root: &Path, agent_id: &str, + run_id: &str, input: &serde_json::Value, ) -> AgentRuntimeToolObservation { let task_id = agent_runtime_tool_input_text(input, &["taskId", "task_id", "id"]); @@ -402,6 +403,16 @@ pub(in crate::agent) fn observe_agent_runtime_task_create( }; } }; + if let Err(error) = + ensure_current_autonomous_ready_child_mutation_at_locked(root, agent_id, run_id) + { + return AgentRuntimeToolObservation { + tool: "task.create".to_string(), + status: "failed".to_string(), + summary: redact_agent_runtime_project_paths(root, &error, 240), + detail: None, + }; + } let status_label = agent_runtime_task_status_label(&status); let result = create_manifest_task_at( root, @@ -460,6 +471,7 @@ pub(in crate::agent) fn observe_agent_runtime_task_create( pub(in crate::agent) fn observe_agent_runtime_task_update( root: &Path, agent_id: &str, + run_id: &str, input: &serde_json::Value, ) -> AgentRuntimeToolObservation { let task_id = agent_runtime_tool_input_text(input, &["taskId", "task_id", "id"]); @@ -495,6 +507,16 @@ pub(in crate::agent) fn observe_agent_runtime_task_update( }; } }; + if let Err(error) = + ensure_current_autonomous_ready_child_mutation_at_locked(root, agent_id, run_id) + { + return AgentRuntimeToolObservation { + tool: "task.update".to_string(), + status: "failed".to_string(), + summary: redact_agent_runtime_project_paths(root, &error, 240), + detail: None, + }; + } if status == GameCreationAppTaskStatus::Completed { if let Some(blocker) = visual_asset_completion_blocker_at_locked(root, task_id.as_str(), None) diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index d0f317a6b..31b13e9cd 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -276,6 +276,8 @@ struct AgentRuntimeState { #[serde(default)] error: Option, #[serde(default)] + started_at: u64, + #[serde(default)] updated_at: u64, } diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/delegation.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/delegation.rs index 6810c002a..7c670c11e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/delegation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/delegation.rs @@ -1638,15 +1638,15 @@ async fn canvas_replacement_rejects_parent_run_that_terminates_during_external_r None, ) .expect("bind game-chat parent run profile"); - bind_game_creator_agent_runtime_run_profile_at( + let repair_binding = bind_game_creator_agent_runtime_run_profile_at( &root, child_agent_id, &repair_run_id, "agent-delegate", None, Some(&AgentRuntimeTaskLink { - parent_agent_id: Some(parent_binding.agent_id), - parent_run_id: Some(parent_binding.run_id), + parent_agent_id: Some(parent_binding.agent_id.clone()), + parent_run_id: Some(parent_binding.run_id.clone()), delegation_id: Some(repair_id.to_string()), }), ) @@ -1707,9 +1707,9 @@ async fn canvas_replacement_rejects_parent_run_that_terminates_during_external_r task_id: parent_agent_id.to_string(), session_id: parent_session_id.to_string(), run_id: parent_run_id.to_string(), - source: "agent-background-task".to_string(), - run_profile: default_agent_runtime_run_profile(), - run_profile_binding_fingerprint: String::new(), + source: parent_binding.source.clone(), + run_profile: parent_binding.profile.clone(), + run_profile_binding_fingerprint: parent_binding.binding_fingerprint.clone(), parent_agent_id: None, parent_run_id: None, delegation_id: None, @@ -1733,9 +1733,9 @@ async fn canvas_replacement_rejects_parent_run_that_terminates_during_external_r task_id: child_agent_id.to_string(), session_id: child_session_id.to_string(), run_id: repair_run_id.clone(), - source: "agent-delegate".to_string(), - run_profile: default_agent_runtime_run_profile(), - run_profile_binding_fingerprint: String::new(), + source: repair_binding.source.clone(), + run_profile: repair_binding.profile.clone(), + run_profile_binding_fingerprint: repair_binding.binding_fingerprint.clone(), parent_agent_id: Some(parent_agent_id.to_string()), parent_run_id: Some(parent_run_id.to_string()), delegation_id: Some(repair_id.to_string()), @@ -1818,8 +1818,15 @@ async fn canvas_replacement_rejects_parent_run_that_terminates_during_external_r .expect("release mock generation response"); let observation = request.join().expect("join canvas request"); - assert_eq!(observation.status, "failed", "{observation:?}"); - assert!(observation.summary.contains("未授权")); + assert_eq!( + observation.status, AGENT_RUNTIME_TOOL_OBSERVATION_STATUS_NEEDS_RECONCILIATION, + "{observation:?}" + ); + assert!(observation.summary.contains("当前根 Run")); + assert!(observation + .detail + .as_deref() + .is_some_and(|detail| detail.contains("不再活跃"))); assert_eq!( fs::read(root.join(output_path)).expect("read preserved image"), b"original-image" @@ -2357,3 +2364,399 @@ fn visual_specialist_delegation_degrades_to_text_artifacts_without_editor_api_ke drop(target_lock); fs::remove_dir_all(root).ok(); } + +fn write_running_task_for_profile_binding( + root: &Path, + binding: &AgentRuntimeRunProfileBinding, + delegation_id: Option<&str>, +) { + write_agent_runtime_task_record_for_test( + root, + &AgentRuntimeTaskRecord { + goal_id: None, + goal_revision: 0, + goal_status: None, + schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(), + agent_id: binding.agent_id.clone(), + task_id: binding.agent_id.clone(), + session_id: format!("agent-session-{}", binding.agent_id), + run_id: binding.run_id.clone(), + source: binding.source.clone(), + run_profile: binding.profile.clone(), + run_profile_binding_fingerprint: binding.binding_fingerprint.clone(), + parent_agent_id: binding.parent_agent_id.clone(), + parent_run_id: binding.parent_run_id.clone(), + delegation_id: delegation_id.map(str::to_string), + task: format!("执行 {} 的受控任务", binding.agent_id), + status: "running".to_string(), + phase: "action".to_string(), + current_action: "执行受项目锁保护的写操作".to_string(), + terminal_detail: None, + error: None, + updated_at: unix_timestamp(), + }, + ); +} + +fn bind_running_autonomous_root_for_guard_test( + root: &Path, + run_id: &str, +) -> AgentRuntimeRunProfileBinding { + let binding = bind_game_creator_agent_runtime_run_profile_at( + root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + None, + ) + .expect("bind autonomous root"); + write_running_task_for_profile_binding(root, &binding, None); + binding +} + +fn autonomous_ready_run_id_for_guard_test(parent_run_id: &str, task_id: &str) -> String { + let identity = format!("{parent_run_id}\n{task_id}\nagent-ready-task-scheduler"); + let fingerprint = format!("{:x}", Sha256::digest(identity.as_bytes())); + format!( + "autonomous-ready-{}-{}", + task_id, + fingerprint.chars().take(20).collect::() + ) +} + +#[test] +fn autonomous_direct_child_collaboration_mutations_require_the_current_root() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-current-root-direct", "当前根直系 child") + .expect("project init"); + let root_binding = bind_running_autonomous_root_for_guard_test(&root, "autonomous-root-a"); + ensure_current_autonomous_ready_child_mutation_at_locked( + &root, + &root_binding.agent_id, + &root_binding.run_id, + ) + .expect("current autonomous root remains eligible"); + + let child_agent_id = "code-director"; + let child_run_id = autonomous_ready_run_id_for_guard_test(&root_binding.run_id, child_agent_id); + let child_binding = bind_game_creator_agent_runtime_run_profile_at( + &root, + child_agent_id, + &child_run_id, + "agent-ready-task-scheduler", + None, + Some(&AgentRuntimeTaskLink { + parent_agent_id: Some(root_binding.agent_id.clone()), + parent_run_id: Some(root_binding.run_id.clone()), + delegation_id: None, + }), + ) + .expect("bind direct ready child"); + write_running_task_for_profile_binding(&root, &child_binding, None); + ensure_current_autonomous_ready_child_mutation_at_locked(&root, child_agent_id, &child_run_id) + .expect("current direct ready child remains eligible"); + + let active_message = observe_agent_runtime_agent_message( + &root, + child_agent_id, + &child_run_id, + &serde_json::json!({ + "agentId": "art-director", + "content": "当前根下的协作消息" + }), + ); + assert_eq!(active_message.status, "ok", "{active_message:?}"); + let before_stale_message = read_local_conversation_at(&root, Some("art-director")) + .expect("read target conversation") + .messages + .len(); + + bind_running_autonomous_root_for_guard_test(&root, "autonomous-root-b"); + let stale_message = observe_agent_runtime_agent_message( + &root, + child_agent_id, + &child_run_id, + &serde_json::json!({ + "agentId": "art-director", + "content": "旧根 child 不得追加这条消息" + }), + ); + assert_eq!(stale_message.status, "failed", "{stale_message:?}"); + assert!( + stale_message.summary.contains("更新根"), + "{stale_message:?}" + ); + assert_eq!( + read_local_conversation_at(&root, Some("art-director")) + .expect("read unchanged target conversation") + .messages + .len(), + before_stale_message, + "stale child must fail before conversation append" + ); + + let action_id = "stale-direct-child-delegate"; + let project_lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + &root, + "test.stale-direct-child-delegate", + ) + .expect("acquire delegate project lock"); + let stale_delegate = observe_agent_runtime_agent_delegate( + &root, + child_agent_id, + &child_run_id, + Some(action_id), + &serde_json::json!({ + "agentId": "art-director", + "task": "旧根 child 不得创建委派", + "runId": "stale-direct-child-target" + }), + ); + drop(project_lock); + assert_eq!(stale_delegate.status, "failed", "{stale_delegate:?}"); + assert!( + stale_delegate.summary.contains("更新根"), + "{stale_delegate:?}" + ); + let delegation_id = + agent_runtime_delegation_id(child_agent_id, &child_run_id, "art-director", action_id); + assert!( + read_latest_game_creator_agent_runtime_task_by_delegation_id( + &root, + "art-director", + &delegation_id, + ) + .expect("read rejected direct child delegation") + .is_none(), + "stale child must fail before delegated task creation" + ); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn autonomous_delegate_descendant_inherits_and_enforces_the_current_root_guard() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-current-root-descendant", "当前根委派后代") + .expect("project init"); + let root_binding = bind_running_autonomous_root_for_guard_test(&root, "descendant-root-a"); + let ready_agent_id = "code-director"; + let ready_run_id = autonomous_ready_run_id_for_guard_test(&root_binding.run_id, ready_agent_id); + let ready_binding = bind_game_creator_agent_runtime_run_profile_at( + &root, + ready_agent_id, + &ready_run_id, + "agent-ready-task-scheduler", + None, + Some(&AgentRuntimeTaskLink { + parent_agent_id: Some(root_binding.agent_id.clone()), + parent_run_id: Some(root_binding.run_id.clone()), + delegation_id: None, + }), + ) + .expect("bind ready parent"); + write_running_task_for_profile_binding(&root, &ready_binding, None); + + let descendant_agent_id = "quality-review"; + let descendant_run_id = "delegated-quality-descendant"; + let descendant_action_id = "quality-descendant-delegation"; + let descendant_target_lock = + try_acquire_game_creator_agent_runtime_task_lock(&root, descendant_agent_id) + .expect("acquire descendant target lane") + .expect("descendant target lane available"); + let project_lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + &root, + "test.current-descendant-delegate", + ) + .expect("acquire current descendant delegate project lock"); + let delegated = observe_agent_runtime_agent_delegate( + &root, + ready_agent_id, + &ready_run_id, + Some(descendant_action_id), + &serde_json::json!({ + "agentId": descendant_agent_id, + "task": "在当前自主根下执行只读质量检查", + "runId": descendant_run_id + }), + ); + drop(project_lock); + assert_eq!(delegated.status, "ok", "{delegated:?}"); + let descendant_delegation_id = agent_runtime_delegation_id( + ready_agent_id, + &ready_run_id, + descendant_agent_id, + descendant_action_id, + ); + let descendant_task = read_latest_game_creator_agent_runtime_task_by_delegation_id( + &root, + descendant_agent_id, + &descendant_delegation_id, + ) + .expect("read delegated descendant") + .expect("delegated descendant exists"); + let descendant_binding = read_game_creator_agent_runtime_run_profile_binding( + &root, + descendant_agent_id, + &descendant_task.run_id, + ) + .expect("read delegated descendant binding") + .expect("delegated descendant binding exists"); + assert_eq!( + descendant_binding.parent_binding_fingerprint.as_deref(), + Some(ready_binding.binding_fingerprint.as_str()) + ); + assert_eq!(descendant_binding.root_run_id, root_binding.run_id); + write_running_task_for_profile_binding( + &root, + &descendant_binding, + Some(&descendant_delegation_id), + ); + ensure_current_autonomous_ready_child_mutation_at_locked( + &root, + descendant_agent_id, + descendant_run_id, + ) + .expect("current delegated descendant remains eligible"); + let active_message = observe_agent_runtime_agent_message( + &root, + descendant_agent_id, + descendant_run_id, + &serde_json::json!({ + "agentId": "design-director", + "content": "当前根 descendant 的协作消息" + }), + ); + assert_eq!(active_message.status, "ok", "{active_message:?}"); + let messages_before_stale = read_local_conversation_at(&root, Some("design-director")) + .expect("read descendant target conversation") + .messages + .len(); + + bind_running_autonomous_root_for_guard_test(&root, "descendant-root-b"); + let stale_message = observe_agent_runtime_agent_message( + &root, + descendant_agent_id, + descendant_run_id, + &serde_json::json!({ + "agentId": "design-director", + "content": "旧根 descendant 不得写入" + }), + ); + assert_eq!(stale_message.status, "failed", "{stale_message:?}"); + assert!( + stale_message.summary.contains("更新根"), + "{stale_message:?}" + ); + assert_eq!( + read_local_conversation_at(&root, Some("design-director")) + .expect("read unchanged descendant target conversation") + .messages + .len(), + messages_before_stale, + "stale delegated descendant must fail before conversation mutation" + ); + + let project_lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + &root, + "test.stale-descendant-delegate", + ) + .expect("acquire descendant delegate project lock"); + let stale_delegate = observe_agent_runtime_agent_delegate( + &root, + descendant_agent_id, + descendant_run_id, + Some("stale-descendant-delegate"), + &serde_json::json!({ + "agentId": "art-director", + "task": "旧根 descendant 不得继续派生", + "runId": "stale-descendant-target" + }), + ); + drop(project_lock); + assert_eq!(stale_delegate.status, "failed", "{stale_delegate:?}"); + assert!( + stale_delegate.summary.contains("更新根"), + "{stale_delegate:?}" + ); + drop(descendant_target_lock); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn standard_delegate_collaboration_mutations_remain_compatible() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-standard-delegate", "标准委派兼容") + .expect("project init"); + let parent_agent_id = "design-director"; + let parent_run_id = "standard-delegate-parent"; + let parent_binding = bind_game_creator_agent_runtime_run_profile_at( + &root, + parent_agent_id, + parent_run_id, + "agent-background-task", + Some(AGENT_RUNTIME_RUN_PROFILE_STANDARD), + None, + ) + .expect("bind standard parent"); + write_running_task_for_profile_binding(&root, &parent_binding, None); + + let message = observe_agent_runtime_agent_message( + &root, + parent_agent_id, + parent_run_id, + &serde_json::json!({ + "agentId": "code-director", + "content": "标准 Run 继续发送协作消息" + }), + ); + assert_eq!(message.status, "ok", "{message:?}"); + + let target_agent_id = "art-director"; + let target_lock = try_acquire_game_creator_agent_runtime_task_lock(&root, target_agent_id) + .expect("acquire standard delegate target lane") + .expect("standard delegate target lane available"); + let action_id = "standard-delegate-action"; + let project_lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + &root, + "test.standard-delegate", + ) + .expect("acquire standard delegate project lock"); + let delegated = observe_agent_runtime_agent_delegate( + &root, + parent_agent_id, + parent_run_id, + Some(action_id), + &serde_json::json!({ + "agentId": target_agent_id, + "task": "标准 Run 的兼容委派", + "runId": "standard-delegate-child" + }), + ); + drop(project_lock); + assert_eq!(delegated.status, "ok", "{delegated:?}"); + let delegation_id = + agent_runtime_delegation_id(parent_agent_id, parent_run_id, target_agent_id, action_id); + let child = read_latest_game_creator_agent_runtime_task_by_delegation_id( + &root, + target_agent_id, + &delegation_id, + ) + .expect("read standard delegated child") + .expect("standard delegated child exists"); + assert_eq!(child.run_profile, AGENT_RUNTIME_RUN_PROFILE_STANDARD); + let child_binding = + read_game_creator_agent_runtime_run_profile_binding(&root, target_agent_id, &child.run_id) + .expect("read standard child binding") + .expect("standard child binding exists"); + assert_eq!(child_binding.profile, AGENT_RUNTIME_RUN_PROFILE_STANDARD); + assert_eq!( + child_binding.parent_binding_fingerprint.as_deref(), + Some(parent_binding.binding_fingerprint.as_str()) + ); + drop(target_lock); + + fs::remove_dir_all(root).ok(); +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/recovery.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/recovery.rs index a255f8748..95016ff38 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/recovery.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/recovery.rs @@ -1,5 +1,835 @@ use super::super::*; +fn prepare_waiting_autonomous_manifest_parent(root: &Path, run_id: &str) -> AgentRuntimeState { + const TASK: &str = "等待自主构建项目任务图收束"; + init_local_game_project_at(root, "project-1", TASK).expect("project init"); + bind_game_creator_agent_runtime_run_profile_at( + root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + None, + ) + .expect("bind autonomous parent fixture profile"); + let task = start_game_creator_agent_runtime_task_at( + root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + TASK, + run_id, + AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, + "等待项目专业任务图收束", + Vec::new(), + ) + .expect("start autonomous parent fixture without notifying Runner"); + assert_eq!(task.run_id, run_id); + prepare_waiting_autonomous_manifest_parent_for_test( + root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + ) + .expect("persist waiting autonomous parent state") +} + +fn commit_autonomous_manifest_parent_reconciliation_marker( + root: &Path, + run_id: &str, + error: &str, +) -> (AgentRuntimeState, AgentRuntimeState) { + let stale_state = prepare_waiting_autonomous_manifest_parent(root, run_id); + let mut committed = stale_state.clone(); + committed.status = "failed".to_string(); + committed.phase = "needs-reconciliation".to_string(); + committed.current_action = "项目任务图唤醒需要人工核对".to_string(); + committed.waiting_on = "开发者核对 manifest、子任务终态与父 run 状态".to_string(); + committed.next_step = "修复损坏或冲突的任务图状态后显式恢复该 run".to_string(); + committed.error = Some(error.to_string()); + committed.updated_at = unix_timestamp().saturating_add(1); + append_game_creator_agent_runtime_task(root, &committed) + .expect("commit terminal reconciliation task marker"); + (stale_state, committed) +} + +fn autonomous_manifest_parent_runtime_state_path(root: &Path) -> PathBuf { + root.join(".agent") + .join("runtime") + .join("agents") + .join(format!("{}.json", GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID)) +} + +#[tokio::test] +async fn autonomous_manifest_parent_wake_budget_exhaustion_is_projected() { + let root = unique_project_path(); + let run_id = "autonomous-parent-wake-budget-exhausted"; + prepare_waiting_autonomous_manifest_parent(&root, run_id); + + drive_waiting_autonomous_manifest_parent_wake_budget_for_test( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + 0, + ) + .await + .expect("budget exhaustion reconciliation must persist"); + + let state = read_game_creator_agent_runtime_at(&root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID) + .expect("read reconciled autonomous parent") + .state; + assert_eq!(state.status, "failed"); + assert_eq!(state.phase, "needs-reconciliation"); + assert!(state + .error + .as_deref() + .is_some_and(|error| error.contains("0 次重试预算"))); + + fs::remove_dir_all(root).ok(); +} + +#[tokio::test] +async fn autonomous_manifest_parent_wake_task_journal_read_error_is_not_treated_as_absent() { + let root = unique_project_path(); + let run_id = "autonomous-parent-wake-corrupt-task-journal"; + prepare_waiting_autonomous_manifest_parent(&root, run_id); + let task_path = + game_creator_agent_runtime_task_path(&root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID); + let mut journal = fs::OpenOptions::new() + .append(true) + .open(&task_path) + .expect("open task journal for corruption injection"); + journal + .write_all(b"{not-valid-json}\n") + .expect("append terminated corrupt task journal record"); + drop(journal); + + let error = drive_waiting_autonomous_manifest_parent_wake_budget_for_test( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + 1, + ) + .await + .expect_err("corrupt durable task journal must not be treated as an absent task"); + assert!(error.contains("durable task")); + assert!(error.contains("JSON")); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn autonomous_manifest_parent_wake_terminal_task_checkpoint_recovers_state_after_restart() { + let root = unique_project_path(); + let run_id = "autonomous-parent-wake-committed-state-stale"; + let stale_state = prepare_waiting_autonomous_manifest_parent(&root, run_id); + let mut committed = stale_state.clone(); + committed.status = "failed".to_string(); + committed.phase = "needs-reconciliation".to_string(); + committed.current_action = "项目任务图唤醒需要人工核对".to_string(); + committed.waiting_on = "开发者核对 manifest、子任务终态与父 run 状态".to_string(); + committed.next_step = "修复损坏或冲突的任务图状态后显式恢复该 run".to_string(); + committed.error = Some("测试注入 task 已提交但 state 仍陈旧".to_string()); + committed.updated_at = unix_timestamp().saturating_add(1); + let mut historical = committed.clone(); + historical.run_id = "autonomous-parent-wake-historical-reconciliation".to_string(); + historical.updated_at = committed.updated_at.saturating_sub(1); + append_jsonl_line( + &game_creator_agent_runtime_task_path(&root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID), + &serde_json::to_string(&historical).expect("serialize historical reconciliation marker"), + "历史项目任务图 reconciliation task fixture", + ) + .expect("append an older run reconciliation marker"); + append_game_creator_agent_runtime_task(&root, &committed) + .expect("commit terminal reconciliation task marker"); + + let before = + read_game_creator_agent_runtime_at(&root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID) + .expect("read stale state before restart repair"); + assert_eq!(before.state.status, "running"); + assert_eq!(before.state.phase, "waiting-for-manifest-tasks"); + assert_eq!(before.task_queue.failed, 2); + + let first = resume_game_creator_agent_background_tasks_at(&root) + .expect("restart must repair projections from terminal task commit marker"); + assert!(first.iter().any(|result| { + result.state.agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID + && result.state.run_id == run_id + && result.state.phase == "needs-reconciliation" + })); + let repaired = + read_game_creator_agent_runtime_at(&root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID) + .expect("read repaired state after restart"); + assert_eq!(repaired.state.status, "failed"); + assert_eq!(repaired.state.phase, "needs-reconciliation"); + assert_eq!(repaired.task_queue.running, 0); + assert_eq!(repaired.task_queue.failed, 2); + + resume_game_creator_agent_background_tasks_at(&root) + .expect("repeated restart repair must remain idempotent"); + let task_records = read_all_game_creator_agent_runtime_tasks( + &game_creator_agent_runtime_task_path(&root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID), + ) + .expect("read task markers after repeated restart repair"); + assert_eq!( + task_records + .iter() + .filter(|task| { + task.run_id == run_id + && task.status == "failed" + && task.phase == "needs-reconciliation" + && task.current_action == "项目任务图唤醒需要人工核对" + }) + .count(), + 1 + ); + assert_eq!( + task_records + .iter() + .filter(|task| { + task.run_id == historical.run_id + && task.status == "failed" + && task.phase == "needs-reconciliation" + }) + .count(), + 1, + "an older run marker must remain historical evidence without blocking current repair" + ); + let events = fs::read_to_string(game_creator_agent_runtime_event_path( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + )) + .expect("read repaired reconciliation events"); + assert_eq!( + events + .lines() + .filter(|line| { + serde_json::from_str::(line) + .ok() + .and_then(|event| event["eventType"].as_str().map(str::to_string)) + .as_deref() + == Some("autonomous_manifest.parent_wake.needs_reconciliation") + }) + .count(), + 1 + ); + let agent_db = fs::read_to_string(root.join(".agent/agent.db")) + .expect("read repaired reconciliation audit"); + assert_eq!( + agent_db + .lines() + .filter(|line| { + serde_json::from_str::(line) + .ok() + .and_then(|record| record["recordType"].as_str().map(str::to_string)) + .as_deref() + == Some("agent.runtime.autonomous_manifest.parent_wake.needs_reconciliation") + }) + .count(), + 1 + ); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn autonomous_manifest_parent_wake_terminal_marker_rebuilds_only_unusable_state_identity() { + for variant in ["empty-object", "empty-run", "missing", "corrupt-json"] { + let root = unique_project_path(); + let run_id = format!("autonomous-parent-wake-rebuild-{variant}"); + let (stale_state, _) = commit_autonomous_manifest_parent_reconciliation_marker( + &root, + &run_id, + "测试注入可恢复的 Runtime state 身份损坏", + ); + let state_path = autonomous_manifest_parent_runtime_state_path(&root); + match variant { + "empty-object" => fs::write(&state_path, b"{}\n").expect("write empty object state"), + "empty-run" => { + let mut empty_run = stale_state.clone(); + empty_run.run_id.clear(); + fs::write( + &state_path, + serde_json::to_vec_pretty(&empty_run).expect("serialize empty-run state"), + ) + .expect("write empty-run state"); + } + "missing" => fs::remove_file(&state_path).expect("remove state before repair"), + "corrupt-json" => { + fs::write(&state_path, b"{not-valid-json}\n").expect("write corrupt state") + } + _ => unreachable!(), + } + + let repaired = repair_autonomous_manifest_parent_wake_reconciliation_projection_for_test( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + ) + .expect("unusable state identity must rebuild from terminal task marker") + .expect("terminal task marker must be selected"); + assert_eq!(repaired.state.run_id, run_id); + assert_eq!(repaired.state.status, "failed"); + assert_eq!(repaired.state.phase, "needs-reconciliation"); + fs::remove_dir_all(root).ok(); + } + + let root = unique_project_path(); + let old_run_id = "autonomous-parent-wake-old-marker"; + let (mut current_state, _) = commit_autonomous_manifest_parent_reconciliation_marker( + &root, + old_run_id, + "测试注入不应覆盖新 run 的历史 marker", + ); + current_state.run_id = "autonomous-parent-wake-current-run".to_string(); + current_state.session_id = "agent-session-current-run".to_string(); + current_state.status = "running".to_string(); + current_state.phase = "reasoning".to_string(); + fs::write( + autonomous_manifest_parent_runtime_state_path(&root), + serde_json::to_vec_pretty(¤t_state).expect("serialize valid current run state"), + ) + .expect("write valid current run state"); + + assert!( + repair_autonomous_manifest_parent_wake_reconciliation_projection_for_test( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + ) + .expect("historical marker scan must remain safe") + .is_none(), + "a complete current run identity must prevent an older marker from being projected" + ); + let preserved = serde_json::from_str::( + &fs::read_to_string(autonomous_manifest_parent_runtime_state_path(&root)) + .expect("read preserved raw current run state"), + ) + .expect("parse preserved raw current run state"); + assert_eq!(preserved.run_id, current_state.run_id); + assert_eq!(preserved.phase, "reasoning"); + fs::remove_dir_all(root).ok(); +} + +#[test] +fn autonomous_manifest_parent_wake_terminal_projection_rejects_conflicting_or_duplicate_records() { + const EVENT_TYPE: &str = "autonomous_manifest.parent_wake.needs_reconciliation"; + const AUDIT_TYPE: &str = "agent.runtime.autonomous_manifest.parent_wake.needs_reconciliation"; + const ACTION_ID: &str = "autonomous-manifest-parent-wake-reconciliation"; + + let root = unique_project_path(); + let run_id = "autonomous-parent-wake-conflicting-event"; + let (_, committed) = commit_autonomous_manifest_parent_reconciliation_marker( + &root, + run_id, + "测试注入 event 冲突", + ); + append_jsonl_line( + &game_creator_agent_runtime_event_path(&root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID), + &serde_json::to_string(&serde_json::json!({ + "schemaVersion": AGENT_RUNTIME_SCHEMA_VERSION, + "agentId": committed.agent_id, + "taskId": committed.task_id, + "sessionId": committed.session_id, + "runId": committed.run_id, + "source": committed.source, + "eventType": EVENT_TYPE, + "status": "running", + "phase": "waiting-for-manifest-tasks", + "summary": "错误的 reconciliation 投影", + "detail": committed.error, + })) + .expect("serialize conflicting reconciliation event"), + "conflicting reconciliation event fixture", + ) + .expect("append conflicting reconciliation event"); + let error = repair_autonomous_manifest_parent_wake_reconciliation_projection_for_test( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + ) + .expect_err("same-key conflicting event must fail closed"); + assert!(error.contains("event 内容冲突")); + fs::remove_dir_all(root).ok(); + + let root = unique_project_path(); + let run_id = "autonomous-parent-wake-conflicting-audit"; + commit_autonomous_manifest_parent_reconciliation_marker(&root, run_id, "测试注入 audit 冲突"); + append_agent_db_record( + &root, + serde_json::json!({ + "recordType": AUDIT_TYPE, + "agentId": GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "taskId": "wrong-task", + "sessionId": "wrong-session", + "runId": run_id, + "actionId": ACTION_ID, + "source": "wrong-source", + "status": "completed", + "error": "wrong-error", + }), + ) + .expect("append conflicting reconciliation audit"); + let error = repair_autonomous_manifest_parent_wake_reconciliation_projection_for_test( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + ) + .expect_err("same-key conflicting audit must fail closed"); + assert!(error.contains("audit 内容冲突")); + fs::remove_dir_all(root).ok(); + + let root = unique_project_path(); + let run_id = "autonomous-parent-wake-duplicate-event"; + commit_autonomous_manifest_parent_reconciliation_marker(&root, run_id, "测试注入 event 重复"); + repair_autonomous_manifest_parent_wake_reconciliation_projection_for_test( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + ) + .expect("initial reconciliation projection") + .expect("initial projection result"); + let event_path = + game_creator_agent_runtime_event_path(&root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID); + let event_line = fs::read_to_string(&event_path) + .expect("read reconciliation events") + .lines() + .find(|line| line.contains(EVENT_TYPE)) + .expect("find reconciliation event") + .to_string(); + append_jsonl_line( + &event_path, + &event_line, + "duplicate reconciliation event fixture", + ) + .expect("append duplicate reconciliation event"); + let error = repair_autonomous_manifest_parent_wake_reconciliation_projection_for_test( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + ) + .expect_err("duplicate reconciliation event must fail closed"); + assert!(error.contains("event 重复")); + fs::remove_dir_all(root).ok(); + + let root = unique_project_path(); + let run_id = "autonomous-parent-wake-duplicate-audit"; + commit_autonomous_manifest_parent_reconciliation_marker(&root, run_id, "测试注入 audit 重复"); + repair_autonomous_manifest_parent_wake_reconciliation_projection_for_test( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + ) + .expect("initial reconciliation projection") + .expect("initial projection result"); + let audit_path = root.join(".agent/agent.db"); + let audit_line = fs::read_to_string(&audit_path) + .expect("read reconciliation audits") + .lines() + .find(|line| line.contains(AUDIT_TYPE)) + .expect("find reconciliation audit") + .to_string(); + append_jsonl_line( + &audit_path, + &audit_line, + "duplicate reconciliation audit fixture", + ) + .expect("append duplicate reconciliation audit"); + let error = repair_autonomous_manifest_parent_wake_reconciliation_projection_for_test( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + ) + .expect_err("duplicate reconciliation audit must fail closed"); + assert!(error.contains("audit 重复")); + fs::remove_dir_all(root).ok(); +} + +#[test] +fn autonomous_manifest_parent_wake_structural_dag_error_is_projected() { + let root = unique_project_path(); + let run_id = "autonomous-parent-wake-structural-dag-error"; + prepare_waiting_autonomous_manifest_parent(&root, run_id); + fs::write(root.join(".agent/manifest.json"), b"{") + .expect("corrupt manifest before terminal projection"); + + assert_eq!( + mark_autonomous_manifest_parent_wake_needs_reconciliation_for_test( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + "Runner 读取 manifest 任务图失败", + ) + .expect("structural DAG error must be projected instead of dropped"), + "projected" + ); + let task = read_latest_game_creator_agent_runtime_task_by_run_id( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + ) + .expect("read structurally reconciled task") + .expect("structurally reconciled task exists"); + assert_eq!(task.status, "failed"); + assert_eq!(task.phase, "needs-reconciliation"); + assert!(task + .error + .as_deref() + .is_some_and(|error| error.contains("manifest"))); + let resumed = resume_game_creator_agent_background_tasks_at(&root) + .expect("committed reconciliation must repair before corrupt manifest collection"); + assert_eq!(resumed.len(), 1); + assert_eq!(resumed[0].state.run_id, run_id); + assert_eq!(resumed[0].state.phase, "needs-reconciliation"); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn autonomous_manifest_parent_wake_busy_lane_keeps_durable_recovery_signal() { + let root = unique_project_path(); + let run_id = "autonomous-parent-wake-busy-lane"; + prepare_waiting_autonomous_manifest_parent(&root, run_id); + let busy_lane = try_acquire_game_creator_agent_runtime_task_lock( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + ) + .expect("acquire busy parent lane") + .expect("parent lane is free before injection"); + + assert_eq!( + mark_autonomous_manifest_parent_wake_needs_reconciliation_for_test( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + "测试注入预算耗尽", + ) + .expect("busy lane must persist deferred recovery signal"), + "deferred" + ); + let task = read_latest_game_creator_agent_runtime_task_by_run_id( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + ) + .expect("read waiting task after deferred signal") + .expect("waiting task remains present"); + assert_eq!(task.status, "running"); + assert_eq!(task.phase, "waiting-for-manifest-tasks"); + let events = fs::read_to_string(game_creator_agent_runtime_event_path( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + )) + .expect("read deferred recovery event"); + assert!(events.contains("autonomous_manifest.parent_wake.reconciliation_deferred")); + assert!(events.contains("测试注入预算耗尽")); + + drop(busy_lane); + assert_eq!( + mark_autonomous_manifest_parent_wake_needs_reconciliation_for_test( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + "测试注入预算耗尽", + ) + .expect("released lane must allow terminal projection"), + "projected" + ); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn autonomous_manifest_parent_wake_deferred_signal_rebuilds_unusable_state_before_projection() { + for variant in ["empty-object", "empty-run", "missing", "corrupt-json"] { + let root = unique_project_path(); + let run_id = format!("autonomous-parent-wake-deferred-rebuild-{variant}"); + let waiting_state = prepare_waiting_autonomous_manifest_parent(&root, &run_id); + let busy_lane = try_acquire_game_creator_agent_runtime_task_lock( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + ) + .expect("acquire busy parent lane") + .expect("parent lane is free before deferred signal injection"); + assert_eq!( + mark_autonomous_manifest_parent_wake_needs_reconciliation_for_test( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &run_id, + "测试注入 deferred 后 state 身份损坏", + ) + .expect("busy lane must persist deferred signal"), + "deferred" + ); + drop(busy_lane); + + let state_path = autonomous_manifest_parent_runtime_state_path(&root); + match variant { + "empty-object" => fs::write(&state_path, b"{}\n").expect("write empty object state"), + "empty-run" => { + let mut empty_run = waiting_state.clone(); + empty_run.run_id.clear(); + fs::write( + &state_path, + serde_json::to_vec_pretty(&empty_run).expect("serialize empty-run state"), + ) + .expect("write empty-run state"); + } + "missing" => fs::remove_file(&state_path).expect("remove deferred state"), + "corrupt-json" => { + fs::write(&state_path, b"{not-valid-json}\n").expect("write corrupt deferred state") + } + _ => unreachable!(), + } + + assert_eq!( + mark_autonomous_manifest_parent_wake_needs_reconciliation_for_test( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &run_id, + "测试注入 deferred 后 state 身份损坏", + ) + .expect("unusable state must rebuild from durable task and project marker"), + "projected" + ); + let repaired = + read_game_creator_agent_runtime_at(&root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID) + .expect("read rebuilt deferred projection"); + assert_eq!(repaired.state.run_id, run_id); + assert_eq!(repaired.state.status, "failed"); + assert_eq!(repaired.state.phase, "needs-reconciliation"); + let events = fs::read_to_string(game_creator_agent_runtime_event_path( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + )) + .expect("read rebuilt deferred events"); + assert!(events.contains("autonomous_manifest.parent_wake.reconciliation_deferred")); + assert!(events.contains("autonomous_manifest.parent_wake.reconciliation_resolved")); + fs::remove_dir_all(root).ok(); + } +} + +#[test] +fn autonomous_manifest_parent_wake_rechecks_cancel_before_terminal_projection() { + let root = unique_project_path(); + let run_id = "autonomous-parent-wake-cancel-race"; + prepare_waiting_autonomous_manifest_parent(&root, run_id); + let stale_lane = try_acquire_game_creator_agent_runtime_task_lock( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + ) + .expect("acquire stale parent lane") + .expect("parent lane is free before stale signal injection"); + assert_eq!( + mark_autonomous_manifest_parent_wake_needs_reconciliation_for_test( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + "测试注入过期唤醒失败", + ) + .expect("persist stale wake signal while lane is busy"), + "deferred" + ); + drop(stale_lane); + write_game_creator_agent_runtime_cancel_request( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + "测试注入终态投影前取消", + ) + .expect("persist cancel tombstone"); + + assert_eq!( + mark_autonomous_manifest_parent_wake_needs_reconciliation_for_test( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + "测试注入过期唤醒失败", + ) + .expect("cancel tombstone must win terminal projection race"), + "obsolete" + ); + let state = read_game_creator_agent_runtime_at(&root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID) + .expect("read cancelled autonomous parent") + .state; + assert_eq!(state.status, "cancelled"); + assert_eq!(state.phase, "cancelled"); + let events = fs::read_to_string(game_creator_agent_runtime_event_path( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + )) + .expect("read cancellation race events"); + assert!(events.contains("autonomous_manifest.parent_wake.reconciliation_deferred")); + assert!(events.contains("autonomous_manifest.parent_wake.reconciliation_resolved")); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn autonomous_manifest_parent_wake_resolves_deferred_signal_when_old_lane_is_terminal() { + for (status, phase) in [("completed", "completed"), ("cancelled", "cancelled")] { + let root = unique_project_path(); + let run_id = format!("autonomous-parent-wake-old-lane-{status}"); + let mut old_state = prepare_waiting_autonomous_manifest_parent(&root, &run_id); + let stale_lane = try_acquire_game_creator_agent_runtime_task_lock( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + ) + .expect("acquire old parent lane") + .expect("old parent lane is initially free"); + assert_eq!( + mark_autonomous_manifest_parent_wake_needs_reconciliation_for_test( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &run_id, + "测试注入旧 lane 延迟恢复信号", + ) + .expect("persist deferred signal while old lane is busy"), + "deferred" + ); + drop(stale_lane); + old_state.status = status.to_string(); + old_state.phase = phase.to_string(); + old_state.current_action = "旧 lane 已终止".to_string(); + old_state.updated_at = unix_timestamp().saturating_add(1); + append_game_creator_agent_runtime_task(&root, &old_state) + .expect("append terminal old task record"); + + assert_eq!( + mark_autonomous_manifest_parent_wake_needs_reconciliation_for_test( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &run_id, + "测试注入旧 lane 延迟恢复信号", + ) + .expect("terminal old lane must settle deferred signal"), + "obsolete" + ); + let events = fs::read_to_string(game_creator_agent_runtime_event_path( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + )) + .expect("read settled old-lane events"); + assert!(events.contains("autonomous_manifest.parent_wake.reconciliation_deferred")); + assert!(events.contains("autonomous_manifest.parent_wake.reconciliation_resolved")); + fs::remove_dir_all(root).ok(); + } +} + +#[test] +fn autonomous_manifest_parent_wake_resolves_deferred_signal_after_new_run_takes_over() { + let root = unique_project_path(); + let old_run_id = "autonomous-parent-wake-superseded-old-run"; + let old_state = prepare_waiting_autonomous_manifest_parent(&root, old_run_id); + let stale_lane = try_acquire_game_creator_agent_runtime_task_lock( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + ) + .expect("acquire old parent lane") + .expect("old parent lane is initially free"); + assert_eq!( + mark_autonomous_manifest_parent_wake_needs_reconciliation_for_test( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + old_run_id, + "测试注入将被新 run 取代的恢复信号", + ) + .expect("persist deferred signal before new run takeover"), + "deferred" + ); + drop(stale_lane); + + let new_run_id = "autonomous-parent-wake-current-new-run"; + bind_game_creator_agent_runtime_run_profile_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + new_run_id, + AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + None, + ) + .expect("bind complete new run profile"); + let mut new_state = start_game_creator_agent_runtime_task_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &old_state.current_task, + new_run_id, + AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, + "新 run 接管旧 parent wake", + Vec::new(), + ) + .expect("start complete new run identity"); + new_state.status = "running".to_string(); + new_state.phase = "reasoning".to_string(); + write_game_creator_agent_runtime_state(&root, &new_state) + .expect("persist complete new run identity"); + + assert_eq!( + mark_autonomous_manifest_parent_wake_needs_reconciliation_for_test( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + old_run_id, + "测试注入将被新 run 取代的恢复信号", + ) + .expect("new run takeover must settle old deferred signal"), + "obsolete" + ); + let events = fs::read_to_string(game_creator_agent_runtime_event_path( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + )) + .expect("read new-run takeover events"); + assert!(events.contains("autonomous_manifest.parent_wake.reconciliation_deferred")); + assert!(events.contains("autonomous_manifest.parent_wake.reconciliation_resolved")); + assert!(events.contains("superseded")); + let preserved = + read_game_creator_agent_runtime_at(&root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID) + .expect("read current new run after settling old signal"); + assert_eq!(preserved.state.run_id, new_state.run_id); + assert_eq!(preserved.state.phase, "reasoning"); + fs::remove_dir_all(root).ok(); +} + +#[test] +fn autonomous_manifest_parent_wake_rechecks_child_progress_before_terminal_projection() { + let root = unique_project_path(); + let run_id = "autonomous-parent-wake-child-progress-race"; + prepare_waiting_autonomous_manifest_parent(&root, run_id); + let stale_lane = try_acquire_game_creator_agent_runtime_task_lock( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + ) + .expect("acquire stale parent lane") + .expect("parent lane is free before stale signal injection"); + assert_eq!( + mark_autonomous_manifest_parent_wake_needs_reconciliation_for_test( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + "测试注入过期唤醒失败", + ) + .expect("persist stale wake signal while lane is busy"), + "deferred" + ); + drop(stale_lane); + update_manifest_task_status_at(&root, "design-director", GameCreationAppTaskStatus::Running) + .expect("advance child manifest progress before terminal projection"); + + assert_eq!( + mark_autonomous_manifest_parent_wake_needs_reconciliation_for_test( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + "测试注入过期唤醒失败", + ) + .expect("fresh child progress must win terminal projection race"), + "obsolete" + ); + let state = read_game_creator_agent_runtime_at(&root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID) + .expect("read still-waiting autonomous parent") + .state; + assert_eq!(state.status, "running"); + assert_eq!(state.phase, "waiting-for-manifest-tasks"); + let events = fs::read_to_string(game_creator_agent_runtime_event_path( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + )) + .expect("read child-progress race events"); + assert!(events.contains("autonomous_manifest.parent_wake.reconciliation_deferred")); + assert!(events.contains("autonomous_manifest.parent_wake.reconciliation_resolved")); + + fs::remove_dir_all(root).ok(); +} + #[tokio::test] async fn project_supervisor_parent_wake_is_singleflight_and_projects_structural_errors() { let root = unique_project_path(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/command_runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/command_runtime.rs index d84a78b3b..b06cfa55b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/command_runtime.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/command_runtime.rs @@ -3754,6 +3754,7 @@ fn process_session_public_observations_and_receipts_exclude_pty_and_stdin_bodies &poll_action, &poll_observation, Some(&poll_pending.action_id), + Some(&poll_pending.action_fingerprint), ); append_agent_runtime_action_receipt( &root, @@ -3811,6 +3812,7 @@ fn process_session_public_observations_and_receipts_exclude_pty_and_stdin_bodies &stdin_action, &stdin_observation, Some(&stdin_pending.action_id), + Some(&stdin_pending.action_fingerprint), ); append_agent_runtime_action_receipt( &root, 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 136e1097f..65a3090da 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 @@ -4149,6 +4149,7 @@ fn persist_process_action_observation_for_test( action, observation, Some(&pending.action_id), + Some(&pending.action_fingerprint), ); append_agent_runtime_action_receipt( root, diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/planning_strategy/autonomous_build.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/planning_strategy/autonomous_build.rs index 909dccd90..d3ec1a800 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/planning_strategy/autonomous_build.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/planning_strategy/autonomous_build.rs @@ -533,6 +533,21 @@ fn bind_autonomous_specialist_runtime_for_test( child_run_id: &str, task: &str, ) -> AgentRuntimeState { + for (candidate_agent_id, candidate_run_id) in [ + (GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, parent_run_id), + (agent_id, child_run_id), + ] { + assert!( + read_all_game_creator_agent_runtime_tasks(&game_creator_agent_runtime_task_path( + root, + candidate_agent_id, + )) + .expect("read autonomous fixture tasks before binding") + .into_iter() + .all(|record| record.run_id != candidate_run_id), + "autonomous fixture refuses to reuse existing runId {candidate_run_id}" + ); + } bind_game_creator_agent_runtime_run_profile_at( root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, @@ -542,21 +557,39 @@ fn bind_autonomous_specialist_runtime_for_test( None, ) .expect("bind autonomous parent profile"); + start_game_creator_agent_runtime_task_at( + root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + task, + parent_run_id, + AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, + "调度专业 Agent", + Vec::new(), + ) + .expect("start durable autonomous parent runtime"); let child_link = AgentRuntimeTaskLink { parent_agent_id: Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string()), parent_run_id: Some(parent_run_id.to_string()), delegation_id: Some(format!("{child_run_id}-delivery")), }; - bind_game_creator_agent_runtime_run_profile_at( + let child_session_id = resolve_agent_conversation_session_id_at(root, agent_id, None, true) + .expect("resolve autonomous specialist session"); + let (pending_child, created) = append_or_read_exact_game_creator_agent_runtime_pending_task( root, agent_id, + &child_session_id, + task, child_run_id, "agent-delegate", None, - Some(&child_link), + &child_link, ) - .expect("bind autonomous specialist profile"); - start_game_creator_agent_runtime_task_at( + .expect("append linked autonomous specialist pending task"); + assert!(created, "autonomous specialist pending task must be new"); + assert_eq!(pending_child.parent_agent_id, child_link.parent_agent_id); + assert_eq!(pending_child.parent_run_id, child_link.parent_run_id); + assert_eq!(pending_child.delegation_id, child_link.delegation_id); + let runtime = start_game_creator_agent_runtime_task_at( root, agent_id, task, @@ -565,7 +598,11 @@ fn bind_autonomous_specialist_runtime_for_test( "执行专业交付", Vec::new(), ) - .expect("start autonomous specialist runtime") + .expect("start autonomous specialist runtime"); + assert_eq!(runtime.parent_agent_id, child_link.parent_agent_id); + assert_eq!(runtime.parent_run_id, child_link.parent_run_id); + assert_eq!(runtime.delegation_id, child_link.delegation_id); + runtime } #[tokio::test] @@ -2554,30 +2591,7 @@ async fn autonomous_game_build_repairs_post_mutation_read_loop_into_verification ) .expect("project init"); let parent_run_id = "autonomous-post-mutation-parent"; - bind_game_creator_agent_runtime_run_profile_at( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - parent_run_id, - AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, - Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), - None, - ) - .expect("bind autonomous parent profile"); let child_run_id = "autonomous-post-mutation-child"; - let child_link = AgentRuntimeTaskLink { - parent_agent_id: Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string()), - parent_run_id: Some(parent_run_id.to_string()), - delegation_id: Some("autonomous-post-mutation-delivery".to_string()), - }; - bind_game_creator_agent_runtime_run_profile_at( - &root, - "code-prototype", - child_run_id, - "agent-delegate", - None, - Some(&child_link), - ) - .expect("bind autonomous child profile"); let (sender, receiver) = mpsc::channel(); let read_arguments = serde_json::json!({"reason": "继续重复读取项目", "input": {}}).to_string(); @@ -2618,16 +2632,13 @@ async fn autonomous_game_build_repairs_post_mutation_read_loop_into_verification }} }}"# )); - let runtime = start_game_creator_agent_runtime_task_at( + let runtime = bind_autonomous_specialist_runtime_for_test( &root, + parent_run_id, "code-prototype", - "修复失败验证并完成原型", child_run_id, - "agent-delegate", - "根据验证诊断继续", - vec!["修复验证失败".to_string(), "重新执行验证".to_string()], - ) - .expect("start autonomous child runtime"); + "修复失败验证并完成原型", + ); let revision = prepare_agent_runtime_project_mutation_locked( &root, "code-prototype", @@ -3121,30 +3132,7 @@ async fn autonomous_game_build_verified_revision_forces_response_only_delivery() ) .expect("project init"); let parent_run_id = "autonomous-verified-delivery-parent"; - bind_game_creator_agent_runtime_run_profile_at( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - parent_run_id, - AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, - Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), - None, - ) - .expect("bind autonomous parent profile"); let child_run_id = "autonomous-verified-delivery-child"; - let child_link = AgentRuntimeTaskLink { - parent_agent_id: Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string()), - parent_run_id: Some(parent_run_id.to_string()), - delegation_id: Some("autonomous-verified-delivery".to_string()), - }; - bind_game_creator_agent_runtime_run_profile_at( - &root, - "code-prototype", - child_run_id, - "agent-delegate", - None, - Some(&child_link), - ) - .expect("bind autonomous child profile"); let incomplete_plan = serde_json::json!({ "explanation": "实现和验证已完成,准备交付", @@ -3188,20 +3176,13 @@ async fn autonomous_game_build_verified_revision_forces_response_only_delivery() }} }}"# )); - let mut runtime = start_game_creator_agent_runtime_task_at( + let mut runtime = bind_autonomous_specialist_runtime_for_test( &root, + parent_run_id, "code-prototype", - "完成可玩原型、验证当前 revision 并交付专业结论", child_run_id, - "agent-delegate", - "准备已验证交付", - vec![ - "完成实现".to_string(), - "验证原型".to_string(), - "交付结论".to_string(), - ], - ) - .expect("start autonomous verified runtime"); + "完成可玩原型、验证当前 revision 并交付专业结论", + ); apply_agent_runtime_plan_update( &mut runtime, &AgentRuntimePlanUpdate { diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/recovery.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/recovery.rs index 204b8d047..1fa8efa64 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/recovery.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/recovery.rs @@ -1717,6 +1717,7 @@ async fn background_agent_runtime_repairs_terminal_receipt_through_reconciliatio &pending.action, &observation, Some(&pending.action_id), + Some(&pending.action_fingerprint), ); complete_agent_runtime_active_plan_step(&mut state, "completed", &observation_summary); state.status = "running".to_string(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/support.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/support.rs index 4d31702f1..4cc6d88c5 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/support.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/support.rs @@ -45,8 +45,8 @@ pub(super) use crate::{ append_agent_runtime_tool_call_record, append_game_creator_agent_runtime_action_event, append_game_creator_agent_runtime_task, append_game_creator_agent_runtime_task_projection_once, append_local_conversation_message_at, append_local_conversation_message_for_session_at, - append_local_permission_log_at, apply_agent_runtime_plan_update, - await_game_creator_agent_runtime_provider_request, + append_local_permission_log_at, append_or_read_exact_game_creator_agent_runtime_pending_task, + apply_agent_runtime_plan_update, await_game_creator_agent_runtime_provider_request, begin_agent_runtime_project_verification_locked, bind_game_creator_agent_runtime_run_profile_at, build_game_creation_seed_task_graph, build_repository_startup_context_at, cancel_game_creator_agent_runtime_task_at, @@ -78,6 +78,7 @@ pub(super) use crate::{ read_recent_game_creator_agent_runtime_events, redact_secret_tokens, reject_game_creator_agent_runtime_task, reject_game_creator_agent_runtime_task_at, render_evaluator_findings, request_game_creator_agent_background_tool_plan_for_test, + resolve_agent_conversation_session_id_at, resolve_game_creator_agent_runtime_retry_configuration_at, resume_game_creator_agent_background_tasks_at, resume_game_creator_agent_runtime_tasks, retry_game_creator_agent_runtime_task_at, schedule_game_creator_agent_ready_tasks_at, diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 57ccaf972..e875761e6 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -10813,6 +10813,7 @@ export function App({ runtimeConfigOpen={runtimeConfigOpen} runtimeError={projectSupervisorRuntimeError} transientReply={projectSupervisorTransientReply} + transientReplyUpdatedAt={projectSupervisorResponseStream?.updatedAt} hasConversationControls={projectSupervisorHasConversationControls} hiddenConversationCount={hiddenConversationCount} needsUserInput={projectSupervisorNeedsUserInput} @@ -10850,6 +10851,7 @@ export function App({ runtimeConfigOpen={runtimeConfigOpen} runtimeError={projectSupervisorRuntimeError} transientReply={projectSupervisorTransientReply} + transientReplyUpdatedAt={projectSupervisorResponseStream?.updatedAt} hasConversationControls={projectSupervisorHasConversationControls} hiddenConversationCount={hiddenConversationCount} needsUserInput={projectSupervisorNeedsUserInput} diff --git a/apps/ai-game-creator-shell/src/app/types.ts b/apps/ai-game-creator-shell/src/app/types.ts index 8cec58340..b86ccae78 100644 --- a/apps/ai-game-creator-shell/src/app/types.ts +++ b/apps/ai-game-creator-shell/src/app/types.ts @@ -187,6 +187,7 @@ export interface AgentRuntimeState { contextUsage?: AgentRuntimeContextUsage; lastResponse: string | null; error: string | null; + startedAt?: number; updatedAt: number; recentEvents?: AgentRuntimeEventRecord[]; recentTasks?: AgentRuntimeTaskRecord[]; 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 32e2714c7..0ba7e8648 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 @@ -258,6 +258,11 @@ export function normalizeAgentRuntimeState( waitingOn: state.waitingOn ?? agentRuntimeWaitingOnFromPhase(state.phase), nextStep: state.nextStep ?? agentRuntimeNextStepFromPhase(state.phase), loopIteration: state.loopIteration ?? previous?.loopIteration ?? 0, + startedAt: + state.startedAt ?? + (previousPlanState?.startedAt && previousPlanState.startedAt > 0 + ? previousPlanState.startedAt + : undefined), maxLoopIterations: state.maxLoopIterations ?? previous?.maxLoopIterations ?? 3, toolActionBudget: state.toolActionBudget ?? previous?.toolActionBudget ?? 3, 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 2bef6498e..3307c9cfb 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 @@ -76,6 +76,22 @@ const GAME_CHAT_INTERNAL_RUNTIME_EVENT_TYPES = new Set([ 'agent.runtime.tool.response', 'agent.runtime.tool.result', ]); +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); +const GAME_CHAT_EXPECTED_WAIT_STATES = new Set([ + 'waiting-for-user-input', + 'waiting-for-confirmation', + 'waiting-for-provider-retry', + 'waiting-for-visual-asset', + 'waiting-for-process-session', + 'waiting-for-delegate-receipts', + 'waiting-for-isolated-join', + 'waiting-for-manifest-tasks', + 'paused', + 'pausing', + 'pause-requested', +]); export type GameChatProgressEvidence = { key: string; @@ -101,14 +117,28 @@ export type GameChatResultImage = { path: string; }; -export function formatGameChatMessageTimestamp(updatedAt: number | null | undefined) { - if (!Number.isFinite(updatedAt) || (updatedAt ?? 0) <= 0) { - return '时间未知'; +function gameChatMessageTimestampMilliseconds( + timestamp: number | null | undefined, +) { + if (!Number.isFinite(timestamp) || (timestamp ?? 0) <= 0) { + return null; } const milliseconds = - (updatedAt ?? 0) < 1_000_000_000_000 - ? (updatedAt ?? 0) * 1000 - : (updatedAt ?? 0); + (timestamp ?? 0) < 1_000_000_000_000 + ? (timestamp ?? 0) * 1000 + : (timestamp ?? 0); + return Number.isFinite(new Date(milliseconds).getTime()) + ? milliseconds + : null; +} + +export function formatGameChatMessageTimestamp( + updatedAt: number | null | undefined, +) { + const milliseconds = gameChatMessageTimestampMilliseconds(updatedAt); + if (milliseconds === null) { + return '时间未知'; + } return new Date(milliseconds).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', @@ -117,14 +147,56 @@ export function formatGameChatMessageTimestamp(updatedAt: number | null | undefi }); } +function gameChatTimestampMilliseconds(timestamp: number | null | undefined) { + const milliseconds = gameChatMessageTimestampMilliseconds(timestamp); + if (milliseconds === null) { + return null; + } + return milliseconds >= GAME_CHAT_EARLIEST_RUNTIME_TIMESTAMP_MS + ? milliseconds + : null; +} + +function formatGameChatDuration(durationMs: number) { + const totalSeconds = Math.max(0, Math.floor(durationMs / 1000)); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + return [ + hours > 0 ? `${hours} 小时` : null, + minutes > 0 ? `${minutes} 分` : null, + `${seconds} 秒`, + ] + .filter(Boolean) + .join(' '); +} + +function gameChatRuntimeActivityTimes(runtime: AgentRuntimeState) { + return [ + runtime.updatedAt, + ...(runtime.recentEvents ?? []) + .filter((event) => event.runId === runtime.runId) + .map((event) => event.updatedAt), + ] + .map(gameChatTimestampMilliseconds) + .filter((timestamp): timestamp is number => timestamp !== null); +} + +function gameChatRuntimeIsExpectedWait(runtime: AgentRuntimeState) { + return Boolean( + runtime.pendingToolAction || + runtime.userInputRequest || + [runtime.status, runtime.phase, runtime.goalStatus].some((state) => + GAME_CHAT_EXPECTED_WAIT_STATES.has(state ?? ''), + ), + ); +} + function gameChatMessageDateTime(updatedAt: number | null | undefined) { - if (!Number.isFinite(updatedAt) || (updatedAt ?? 0) <= 0) { + const milliseconds = gameChatMessageTimestampMilliseconds(updatedAt); + if (milliseconds === null) { return undefined; } - const milliseconds = - (updatedAt ?? 0) < 1_000_000_000_000 - ? (updatedAt ?? 0) * 1000 - : (updatedAt ?? 0); return new Date(milliseconds).toISOString(); } @@ -258,10 +330,7 @@ export function gameChatMudPointInterruptionText( 'agent-ready-task-scheduler', ].includes(childRuntime.source), ); - const relatedRuntimes = [ - runtime, - ...childRuntimes, - ]; + const relatedRuntimes = [runtime, ...childRuntimes]; return relatedRuntimes.some( (relatedRuntime) => relatedRuntime.error && @@ -803,6 +872,7 @@ type SupervisorChatOnlyViewProps = { runtimeConfigOpen: boolean; runtimeError: string; transientReply: string; + transientReplyUpdatedAt?: number | null; hasConversationControls: boolean; hiddenConversationCount: number; needsUserInput: boolean; @@ -846,6 +916,7 @@ export function SupervisorChatOnlyView({ runtimeConfigOpen, runtimeError, transientReply, + transientReplyUpdatedAt = null, hasConversationControls, hiddenConversationCount, needsUserInput, @@ -866,6 +937,7 @@ export function SupervisorChatOnlyView({ onConfirmNonEmptyProjectCreate, }: SupervisorChatOnlyViewProps) { const [showRuntimeDetails, setShowRuntimeDetails] = useState(false); + const [runtimeClockNow, setRuntimeClockNow] = useState(() => Date.now()); const [resultImagePreviews, setResultImagePreviews] = useState< GameChatResultImagePreview[] >([]); @@ -915,17 +987,86 @@ export function SupervisorChatOnlyView({ .map((image) => `${image.key}:${image.mediaType}`) .join('\n'); const collaboratingRuntimes = useMemo( - () => projectSupervisorCollaboratingAgentRuntimes(runtime, runtimeByAgentId), + () => + projectSupervisorCollaboratingAgentRuntimes(runtime, runtimeByAgentId), [runtime, runtimeByAgentId], ); - const attentionAgentCount = collaboratingRuntimes.filter((candidate) => - ['failed', 'needs-reconciliation'].includes(candidate.status) || - ['failed', 'needs-reconciliation'].includes(candidate.phase), + const attentionAgentCount = collaboratingRuntimes.filter( + (candidate) => + ['failed', 'needs-reconciliation'].includes(candidate.status) || + ['failed', 'needs-reconciliation'].includes(candidate.phase), ).length; - const latestActivityAt = [ - runtime?.updatedAt ?? 0, - ...collaboratingRuntimes.map((candidate) => candidate.updatedAt), - ].reduce((latest, candidate) => Math.max(latest, candidate), 0); + const runtimeTerminal = Boolean( + runtime && isAgentRuntimeTerminalState(runtime), + ); + const runtimeRunId = runtime?.runId ?? null; + const latestParentActivityAt = runtime + ? gameChatRuntimeActivityTimes(runtime).reduce( + (latest, candidate) => Math.max(latest, candidate), + 0, + ) + : 0; + const latestActivityAt = runtime + ? [runtime, ...collaboratingRuntimes] + .flatMap(gameChatRuntimeActivityTimes) + .reduce((latest, candidate) => Math.max(latest, candidate), 0) + : 0; + const activeCollaboratingRuntimes = collaboratingRuntimes.filter( + (candidate) => !isAgentRuntimeTerminalState(candidate), + ); + const activeRuntimeLanes = runtime + ? activeCollaboratingRuntimes.length > 0 + ? activeCollaboratingRuntimes + : [runtime] + : []; + const nonWaitingRuntimeLaneActivity = activeRuntimeLanes + .filter((candidate) => !gameChatRuntimeIsExpectedWait(candidate)) + .map((candidate) => + gameChatRuntimeActivityTimes(candidate).reduce( + (latest, activityAt) => Math.max(latest, activityAt), + 0, + ), + ) + .filter((activityAt) => activityAt > 0); + const runStartedAt = + runtime && runtimeRunId + ? (gameChatTimestampMilliseconds(runtime.startedAt) ?? + gameChatRuntimeActivityTimes(runtime).reduce( + (earliest, candidate) => Math.min(earliest, candidate), + Number.POSITIVE_INFINITY, + )) + : Number.POSITIVE_INFINITY; + const elapsedRuntimeMs = Number.isFinite(runStartedAt) + ? Math.max( + 0, + (runtimeTerminal && latestParentActivityAt > 0 + ? latestParentActivityAt + : runtimeClockNow) - runStartedAt, + ) + : null; + const inactiveRuntimeMs = + nonWaitingRuntimeLaneActivity.length > 0 + ? Math.max( + ...nonWaitingRuntimeLaneActivity.map((activityAt) => + Math.max(0, runtimeClockNow - activityAt), + ), + ) + : null; + const expectedRuntimeWait = Boolean( + needsUserInput || + pendingConfirmation || + pendingCommand || + (activeRuntimeLanes.length > 0 && + activeRuntimeLanes.every(gameChatRuntimeIsExpectedWait)), + ); + const runtimeAppearsStalled = Boolean( + running && + runtime && + !runtimeTerminal && + !expectedRuntimeWait && + inactiveRuntimeMs !== null && + inactiveRuntimeMs > GAME_CHAT_RUNTIME_STALL_THRESHOLD_MS, + ); const runStateLabel = (() => { if (gameChatInterruptionText) { return '本轮已中断'; @@ -940,6 +1081,9 @@ export function SupervisorChatOnlyView({ return '正在启动'; } if (running) { + if (runtimeAppearsStalled) { + return '运行中 · 疑似停滞'; + } return attentionAgentCount > 0 ? '运行中 · 有异常' : '运行中'; } if (runtime?.status === 'completed' || runtime?.phase === 'completed') { @@ -953,15 +1097,20 @@ export function SupervisorChatOnlyView({ } return '未运行'; })(); - const runStateTone = gameChatInterruptionText || runtimeError - ? 'danger' - : needsUserInput || pendingConfirmation || pendingCommand || attentionAgentCount > 0 - ? 'warning' - : running || synchronizingAcceptedRun - ? 'active' - : runtime?.status === 'completed' || runtime?.phase === 'completed' - ? 'complete' - : 'idle'; + const runStateTone = + gameChatInterruptionText || runtimeError + ? 'danger' + : needsUserInput || + pendingConfirmation || + pendingCommand || + runtimeAppearsStalled || + attentionAgentCount > 0 + ? 'warning' + : running || synchronizingAcceptedRun + ? 'active' + : runtime?.status === 'completed' || runtime?.phase === 'completed' + ? 'complete' + : 'idle'; const embeddedPreviewUrl = preview ? resolveEmbeddedPreviewUrl({ status: 'running', url: preview.url }) : null; @@ -980,6 +1129,16 @@ export function SupervisorChatOnlyView({ useEffect(() => { setShowRuntimeDetails(false); }, [projectPath, runtime?.runId]); + useEffect(() => { + setRuntimeClockNow(Date.now()); + if (!gameChatMode || !runtimeRunId || runtimeTerminal) { + return undefined; + } + const interval = window.setInterval(() => { + setRuntimeClockNow(Date.now()); + }, GAME_CHAT_RUNTIME_CLOCK_INTERVAL_MS); + return () => window.clearInterval(interval); + }, [gameChatMode, runtimeRunId, runtimeTerminal]); useEffect(() => { if (!showRuntimeDetails) { return undefined; @@ -1105,21 +1264,29 @@ export function SupervisorChatOnlyView({
- - {supervisorProgress?.taskProgress || status} - + {supervisorProgress?.taskProgress || status} {supervisorProgress?.currentWork || (projectReady ? '等待新的运行事件' : '请选择项目目录')}
+ {runtimeAppearsStalled && inactiveRuntimeMs !== null ? ( + + {`${formatGameChatDuration(inactiveRuntimeMs)}无新进度`} + + ) : null} {supervisorProgress?.activeAgents.length ? ( {`${supervisorProgress.activeAgents.length} 个专业 Agent 活跃`} ) : null} @@ -1187,7 +1354,15 @@ export function SupervisorChatOnlyView({ aria-live="polite" data-runtime-owned="true" > - {transientReply} + {transientReply} + {gameChatMode ? ( + + ) : null}

) : null} {running && !transientReply && !gameChatMode ? ( @@ -1464,7 +1639,9 @@ export function SupervisorChatOnlyView({ {runtimeEvents.length > 0 ? ( runtimeEvents.map((item) => (
-