diff --git a/.codex/skills/genarrative-external-editor-api/references/requests-and-outputs.md b/.codex/skills/genarrative-external-editor-api/references/requests-and-outputs.md index cbd26dfa7..fe62ee1ee 100644 --- a/.codex/skills/genarrative-external-editor-api/references/requests-and-outputs.md +++ b/.codex/skills/genarrative-external-editor-api/references/requests-and-outputs.md @@ -138,10 +138,10 @@ Carry the current art spec in `generationInputs.artSpec` and reflect important c } ``` -The top-level `style` field is not the art spec's visual-style prose. It controls deterministic post-processing: +The top-level `style` field is not the art spec's visual-style prose. It appends a short server-side clause to the prompt sent to the provider and enables deterministic post-processing: -- Omitted, `null`, empty string, or `"none"`: disable post-processing without warning. -- `"pixelArt"`: enable pixel-art snapping for ordinary image generation, `kind: "character"`, and icon spritesheet generation. +- Omitted, `null`, empty string, or `"none"`: no clause is appended and no post-processing runs, without warning. +- `"pixelArt"`: append one short pixel-art line to the end of the prompt sent to the provider, and enable pixel-art snapping, for ordinary image generation, `kind: "character"`, and icon spritesheet generation. The line is appended, not substituted — the rest of your prompt is unchanged. For the exact per-kind wording, read the `style` field description in the OpenAPI document; it is the contract, and this guide deliberately does not copy it. - Unknown strings, or `"pixelArt"` on unsupported kinds such as `spec`, `quick-edit`, `ui-design`, or `publication-material`: continue without style processing and return `warning.code: "unsupported-image-style"`. - Non-string JSON values: malformed request, HTTP `400`. diff --git a/.gitea/workflows/project-ci.yml b/.gitea/workflows/project-ci.yml index 4bdbb49cd..e19b10e7e 100644 --- a/.gitea/workflows/project-ci.yml +++ b/.gitea/workflows/project-ci.yml @@ -161,9 +161,6 @@ jobs: - name: Install npm dependencies run: npm ci - - name: Check server-rs boundaries - run: npm run check:server-rs-ddd - - name: Prepare server-rs Rust dependencies shell: bash run: | @@ -181,6 +178,9 @@ jobs: sleep $((attempt * 2)) done + - name: Check server-rs boundaries + run: npm run check:server-rs-ddd + - name: Run server-rs workspace tests run: cargo test --locked --workspace --no-fail-fast --manifest-path server-rs/Cargo.toml diff --git a/apps/ai-game-creator-shell/scripts/smoke-agent-run-local-provider.mjs b/apps/ai-game-creator-shell/scripts/smoke-agent-run-local-provider.mjs index ad77d7f19..6b03ae39c 100644 --- a/apps/ai-game-creator-shell/scripts/smoke-agent-run-local-provider.mjs +++ b/apps/ai-game-creator-shell/scripts/smoke-agent-run-local-provider.mjs @@ -885,6 +885,10 @@ function readBrowserDom(url) { function resolveChromeBin() { for (const candidate of [ + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + '/Applications/Chromium.app/Contents/MacOS/Chromium', + '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge', + '/opt/google/chrome/chrome', '/usr/bin/google-chrome', '/usr/bin/google-chrome-stable', '/usr/bin/chromium', diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs index 4cc52b685..5c9613bdd 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs @@ -5708,7 +5708,7 @@ mod canvas_generation_tests { fn read_test_http_request(stream: &mut std::net::TcpStream) -> String { stream - .set_read_timeout(Some(Duration::from_secs(2))) + .set_read_timeout(Some(Duration::from_secs(10))) .expect("set request read timeout"); let mut bytes = Vec::new(); let mut buffer = [0_u8; 4096]; @@ -6702,7 +6702,7 @@ mod canvas_generation_tests { #[tokio::test] async fn recovery_scan_resumes_accepted_generation_on_default_worker_stack() { - let temporary = tempfile::tempdir().expect("create accepted scan project"); + let temporary = crate::tests::canonical_test_tempdir("accepted-generation-scan-"); let root = temporary.path(); init_local_game_project_at(root, "accepted-scan", "恢复扫描测试") .expect("init accepted scan project"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/external_generation_state.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/external_generation_state.rs index 7b13a6176..0de156f4f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/external_generation_state.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/external_generation_state.rs @@ -838,7 +838,7 @@ mod external_generation_state_tests { #[test] fn prepared_generation_state_reuses_identity_and_transitions_to_accepted() { - let temporary = tempfile::tempdir().expect("create generation ledger project"); + let temporary = crate::tests::canonical_test_tempdir("external-generation-ledger-"); let root = temporary.path(); init_local_game_project_at(root, "generation-ledger", "生成账本测试") .expect("init project"); @@ -955,7 +955,7 @@ mod external_generation_state_tests { #[test] fn legacy_completed_generation_persists_only_allowlisted_safe_download_fields() { - let temporary = tempfile::tempdir().expect("create legacy generation ledger project"); + let temporary = crate::tests::canonical_test_tempdir("legacy-generation-ledger-"); let root = temporary.path(); init_local_game_project_at(root, "legacy-generation-ledger", "旧同步生成账本测试") .expect("init project"); 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/pending_confirmation_ledger.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/pending_confirmation_ledger.rs index fa5493585..6de6a816e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/pending_confirmation_ledger.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/pending_confirmation_ledger.rs @@ -790,7 +790,7 @@ mod tests { #[test] fn generation_cleanup_failure_preserves_pending_identity_anchor() { - let temporary = tempfile::tempdir().expect("create pending cleanup project"); + let temporary = crate::tests::canonical_test_tempdir("pending-generation-cleanup-"); let root = temporary.path(); let run_id = "generation-cleanup-order-run"; init_local_game_project_at(root, "generation-cleanup-order", "生成账本清理顺序测试") 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_actions/provider_request_builders.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs index 3b711d32c..756cda481 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs @@ -463,7 +463,8 @@ mod tests { root_source: &str, suffix: &str, ) -> String { - let temporary = tempfile::tempdir().expect("temporary project root"); + let temporary = + crate::tests::canonical_test_tempdir(&format!("provider-role-overlay-{suffix}-")); let root = temporary.path().join("project"); init_local_game_project_at(&root, &format!("overlay-{suffix}"), "role overlay test") .expect("project init"); @@ -549,7 +550,7 @@ mod tests { const ORDINARY_NOTICE: &str = "除下方有界仓库启动上下文、当前 Session 未压缩对话尾部或历史压缩摘要外,项目记忆、资产和源码正文不会预加载"; const MEMORY_MARKER: &str = "supervisor-preloaded-context-marker"; - let directory = tempfile::tempdir().expect("temp project directory"); + let directory = crate::tests::canonical_test_tempdir("provider-request-project-"); let root = directory.path().join("project"); init_local_game_project_at(&root, "project-1", "项目总控预加载说明测试") .expect("project init"); @@ -806,7 +807,7 @@ mod tests { #[test] fn planning_request_advertises_only_native_mcp_functions() { - let directory = tempfile::tempdir().expect("temp project directory"); + let directory = crate::tests::canonical_test_tempdir("native-mcp-prompt-"); let root = directory.path().join("project"); init_local_game_project_at(&root, "project-mcp", "MCP 原生函数说明测试") .expect("project init"); 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..e5b49e607 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 @@ -2,6 +2,12 @@ use super::*; pub(super) static GAME_CREATOR_AGENT_RUNTIME_UPDATE_APP_HANDLE: OnceLock = OnceLock::new(); +pub(super) static GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINK: OnceLock< + std::sync::Mutex>, +> = OnceLock::new(); +#[cfg(test)] +pub(super) static GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINK_TEST_LOCK: std::sync::Mutex<()> = + std::sync::Mutex::new(()); pub(super) static STATIC_DELEGATE_PARENT_WAKE_SINGLEFLIGHT: OnceLock< std::sync::Mutex>, > = OnceLock::new(); @@ -101,6 +107,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!( @@ -234,15 +242,21 @@ pub(in crate::agent) use recovery_scan::*; pub(in crate::agent) use task_queue::*; pub(in crate::agent) use task_start::*; +#[cfg(test)] +pub(crate) use entrypoints::acquire_game_creator_manifest_invalidation_event_sink_test_guard; #[allow(unused_imports)] pub(crate) use entrypoints::{ chat_with_game_creator_agent_at, chat_with_game_creator_role_agent_at, chat_with_game_creator_role_agent_for_session_at, chat_with_game_creator_role_agent_runtime_at, chat_with_game_creator_role_agent_runtime_for_session_at, chat_with_game_creator_role_agent_stream_at, - chat_with_game_creator_role_agent_stream_for_session_at, generate_local_game_draft_at, - read_game_creator_agent_runtime_at, read_game_creator_agent_runtime_for_session_at, - read_game_creator_agent_runtimes_at, set_game_creator_agent_runtime_update_app_handle, + chat_with_game_creator_role_agent_stream_for_session_at, + configure_game_creator_manifest_invalidation_event_sink, + emit_game_creator_agent_runtime_update, game_creator_agent_runtime_update_event, + generate_local_game_draft_at, read_game_creator_agent_runtime_at, + read_game_creator_agent_runtime_for_session_at, read_game_creator_agent_runtimes_at, + set_game_creator_agent_runtime_update_app_handle, + start_game_creator_manifest_invalidation_event_sink, }; #[cfg(test)] pub(crate) use finalization::resume_game_creator_agent_finalization_for_test_at; @@ -277,8 +291,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 +311,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..16f9e93b2 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 @@ -1,30 +1,174 @@ use super::*; +const GAME_CREATOR_MANIFEST_INVALIDATION_RELAY_MAX_BYTES: u64 = 64 * 1024; + +fn lock_game_creator_manifest_invalidation_event_sink( +) -> std::sync::MutexGuard<'static, Option> { + GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINK + .get_or_init(|| Mutex::new(None)) + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) +} + pub(crate) fn set_game_creator_agent_runtime_update_app_handle(app: tauri::AppHandle) { let _ = GAME_CREATOR_AGENT_RUNTIME_UPDATE_APP_HANDLE.set(app); } -pub(in crate::agent) fn emit_game_creator_agent_runtime_update(root: &Path, agent_id: &str) { +pub(crate) fn start_game_creator_manifest_invalidation_event_sink( + app: tauri::AppHandle, +) -> Result { + let listener = TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)) + .map_err(|error| format!("绑定 manifest 失效事件接收端失败:{error}"))?; + let port = listener + .local_addr() + .map_err(|error| format!("读取 manifest 失效事件接收端失败:{error}"))? + .port(); + let token = format!( + "{}{}", + uuid::Uuid::new_v4().simple(), + uuid::Uuid::new_v4().simple() + ); + let expected_token = token.clone(); + thread::Builder::new() + .name("manifest-invalidation-event-sink".to_string()) + .spawn(move || { + for incoming in listener.incoming() { + let Ok(mut stream) = incoming else { + continue; + }; + let _ = stream.set_read_timeout(Some(Duration::from_millis(250))); + let mut payload = Vec::new(); + let mut limited = + (&mut stream).take(GAME_CREATOR_MANIFEST_INVALIDATION_RELAY_MAX_BYTES + 1); + if limited.read_to_end(&mut payload).is_err() + || payload.len() as u64 > GAME_CREATOR_MANIFEST_INVALIDATION_RELAY_MAX_BYTES + { + continue; + } + let Ok(envelope) = serde_json::from_slice::< + GameCreatorManifestInvalidationRelayEnvelope, + >(&payload) else { + continue; + }; + if envelope.token != expected_token { + continue; + } + let _ = app.emit("game-creator-manifest-invalidated", envelope.event); + } + }) + .map_err(|error| format!("启动 manifest 失效事件接收端失败:{error}"))?; + Ok(GameCreatorManifestInvalidationEventSink { port, token }) +} + +pub(crate) fn configure_game_creator_manifest_invalidation_event_sink( + port: u16, + token: &str, +) -> Result<(), String> { + if port == 0 { + return Err("manifest 失效事件接收端口无效".to_string()); + } + let token = token.trim(); + if token.len() != 64 || !token.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err("manifest 失效事件接收令牌无效".to_string()); + } + *lock_game_creator_manifest_invalidation_event_sink() = + Some(GameCreatorManifestInvalidationEventSink { + port, + token: token.to_string(), + }); + Ok(()) +} + +#[cfg(test)] +pub(crate) struct GameCreatorManifestInvalidationEventSinkTestGuard { + _isolation: std::sync::MutexGuard<'static, ()>, +} + +#[cfg(test)] +impl GameCreatorManifestInvalidationEventSinkTestGuard { + pub(crate) fn configure(&self, port: u16, token: &str) -> Result<(), String> { + configure_game_creator_manifest_invalidation_event_sink(port, token) + } + + pub(crate) fn configured_sink(&self) -> Option { + lock_game_creator_manifest_invalidation_event_sink().clone() + } +} + +#[cfg(test)] +impl Drop for GameCreatorManifestInvalidationEventSinkTestGuard { + fn drop(&mut self) { + *lock_game_creator_manifest_invalidation_event_sink() = None; + } +} + +#[cfg(test)] +pub(crate) fn acquire_game_creator_manifest_invalidation_event_sink_test_guard( +) -> GameCreatorManifestInvalidationEventSinkTestGuard { + let isolation = GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINK_TEST_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + GameCreatorManifestInvalidationEventSinkTestGuard { + _isolation: isolation, + } +} + +fn relay_game_creator_manifest_invalidation(root: &Path, agent_id: &str) -> Result<(), String> { + let sink = lock_game_creator_manifest_invalidation_event_sink().clone(); + let Some(sink) = sink else { + return Ok(()); + }; + let envelope = GameCreatorManifestInvalidationRelayEnvelope { + token: sink.token, + event: GameCreatorManifestInvalidatedEvent { + project_path: root.to_string_lossy().into_owned(), + agent_id: agent_id.to_string(), + }, + }; + let payload = serde_json::to_vec(&envelope) + .map_err(|error| format!("序列化 manifest 失效事件失败:{error}"))?; + if payload.len() as u64 > GAME_CREATOR_MANIFEST_INVALIDATION_RELAY_MAX_BYTES { + return Err("manifest 失效事件超过大小上限".to_string()); + } + let address = std::net::SocketAddrV4::new(std::net::Ipv4Addr::LOCALHOST, sink.port).into(); + let mut stream = TcpStream::connect_timeout(&address, Duration::from_millis(100)) + .map_err(|error| format!("连接 manifest 失效事件接收端失败:{error}"))?; + stream + .set_write_timeout(Some(Duration::from_millis(100))) + .map_err(|error| format!("配置 manifest 失效事件发送超时失败:{error}"))?; + stream + .write_all(&payload) + .map_err(|error| format!("发送 manifest 失效事件失败:{error}")) +} + +pub(crate) fn game_creator_agent_runtime_update_event( + root: &Path, + runtime: AgentRuntimeResult, +) -> GameCreatorAgentRuntimeUpdateEvent { + GameCreatorAgentRuntimeUpdateEvent { + project_path: root.to_string_lossy().into_owned(), + agent_id: runtime.state.agent_id.clone(), + run_id: runtime.state.run_id.clone(), + status: runtime.state.status.clone(), + phase: runtime.state.phase.clone(), + manifest_invalidated: true, + runtime, + } +} + +pub(crate) fn emit_game_creator_agent_runtime_update(root: &Path, agent_id: &str) { + if GAME_CREATOR_AGENT_RUNTIME_UPDATE_APP_HANDLE.get().is_none() { + let _ = relay_game_creator_manifest_invalidation(root, agent_id); + } let Some(app) = GAME_CREATOR_AGENT_RUNTIME_UPDATE_APP_HANDLE.get() else { return; }; let Ok(runtime) = read_game_creator_agent_runtime_at(root, agent_id) else { return; }; - let agent_id = runtime.state.agent_id.clone(); - let run_id = runtime.state.run_id.clone(); - let status = runtime.state.status.clone(); - let phase = runtime.state.phase.clone(); let _ = app.emit( "game-creator-agent-runtime-update", - GameCreatorAgentRuntimeUpdateEvent { - project_path: root.to_string_lossy().into_owned(), - agent_id, - run_id, - status, - phase, - runtime, - }, + game_creator_agent_runtime_update_event(root, runtime), ); } @@ -584,8 +728,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_deadline_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_deadline_tests.rs index 3033d5a20..84866b305 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_deadline_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_deadline_tests.rs @@ -64,14 +64,8 @@ async fn game_chat_absolute_deadline_returns_an_in_flight_result_before_expiry() #[tokio::test] async fn game_chat_absolute_deadline_preserves_external_generation_for_same_action_resume() { - let root = std::env::temp_dir().join(format!( - "genarrative-game-chat-deadline-reconciliation-{}-{}", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("system clock") - .as_nanos() - )); + let temporary = crate::tests::canonical_test_tempdir("game-chat-deadline-reconciliation-"); + let root = temporary.path().join("project"); init_local_game_project_at(&root, "deadline-reconciliation", "硬截止收尾测试") .expect("project init"); bind_game_creator_agent_runtime_run_profile_at( @@ -239,14 +233,8 @@ async fn game_chat_absolute_deadline_preserves_external_generation_for_same_acti #[test] fn game_chat_absolute_deadline_still_cleans_local_action_recovery() { - let root = std::env::temp_dir().join(format!( - "genarrative-game-chat-deadline-local-cleanup-{}-{}", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("system clock") - .as_nanos() - )); + let temporary = crate::tests::canonical_test_tempdir("game-chat-deadline-local-cleanup-"); + let root = temporary.path().join("project"); init_local_game_project_at(&root, "deadline-local-cleanup", "硬截止本地清理测试") .expect("project init"); let mut runtime = start_game_creator_agent_runtime_task_at( 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..d5f6484cb 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,11 +1933,68 @@ 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"; const TASK: &str = "生成完整小游戏并完成项目任务图"; - let temporary = tempfile::tempdir().expect("create manifest waiting root"); + let temporary = crate::tests::canonical_test_tempdir("manifest-waiting-"); let root = temporary.path().join("project"); init_local_game_project_at(&root, "manifest-waiting-project", TASK) .expect("init manifest waiting project"); @@ -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"; @@ -1421,7 +2617,7 @@ async fn autonomous_supervisor_converged_final_reply_deserialize_commits_fallbac const TASK: &str = "生成一个可完成静态检查和双视口试玩的塔防游戏"; const TEST_KEY: &str = "autonomous-final-reply-fallback-key"; - let temporary = tempfile::tempdir().expect("create autonomous fallback root"); + let temporary = crate::tests::canonical_test_tempdir("autonomous-fallback-"); let root = temporary.path().join("project"); init_local_game_project_at(&root, "autonomous-fallback-project", TASK) .expect("init autonomous fallback project"); 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..407d3c871 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 @@ -13,7 +13,6 @@ where .await .expect("pending continuation task must exist") } - async fn run_after_pending_stack_boundary( future: std::pin::Pin + Send + 'static>>, ) -> T @@ -150,7 +149,6 @@ fn persist_game_creator_agent_runtime_continuation_reconciliation_emergency_at( ); emit_game_creator_agent_runtime_update(root, &runtime.agent_id); } - pub(crate) async fn continue_game_creator_agent_pending_tool_action( root: PathBuf, agent_id: String, @@ -606,6 +604,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 +661,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 +1093,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/pending_recovery.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_recovery.rs index 246cc05a1..1bc5adaa9 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_recovery.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_recovery.rs @@ -1332,7 +1332,7 @@ mod pending_recovery_tests { #[test] fn observed_unknown_canvas_generation_returns_to_same_approved_action() { - let temporary = tempfile::tempdir().expect("create prepared pending project"); + let temporary = crate::tests::canonical_test_tempdir("prepared-pending-"); let root = temporary.path(); init_local_game_project_at(root, "prepared-pending", "原俄罗斯方块项目") .expect("init prepared pending project"); @@ -1413,7 +1413,7 @@ mod pending_recovery_tests { #[test] fn legacy_executing_canvas_generation_returns_to_same_approved_action() { - let temporary = tempfile::tempdir().expect("create executing prepared project"); + let temporary = crate::tests::canonical_test_tempdir("executing-prepared-"); let root = temporary.path(); init_local_game_project_at(root, "executing-prepared", "旧版俄罗斯方块项目") .expect("init executing prepared project"); @@ -1490,7 +1490,7 @@ mod pending_recovery_tests { #[test] fn observed_postprocessing_failure_resumes_from_accepted_generation() { - let temporary = tempfile::tempdir().expect("create accepted recovery project"); + let temporary = crate::tests::canonical_test_tempdir("accepted-recovery-"); let root = temporary.path(); init_local_game_project_at(root, "accepted-recovery", "俄罗斯方块素材后处理恢复") .expect("init accepted recovery project"); @@ -1560,7 +1560,7 @@ mod pending_recovery_tests { #[test] fn canvas_reconciliation_keeps_the_context_plan_step_active() { - let temporary = tempfile::tempdir().expect("create reconciliation context project"); + let temporary = crate::tests::canonical_test_tempdir("reconciliation-context-"); let root = temporary.path(); init_local_game_project_at(root, "reconciliation-context", "俄罗斯方块恢复上下文") .expect("init reconciliation context project"); 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..c15a2e2fc 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; } @@ -1221,7 +1244,7 @@ mod orphaned_external_generation_recovery_tests { #[test] fn recovery_scan_preserves_active_generation_orphan_then_cleans_terminal_legacy_orphan() { - let temporary = tempfile::tempdir().expect("create orphan generation recovery project"); + let temporary = crate::tests::canonical_test_tempdir("orphan-generation-recovery-"); let root = temporary.path(); let run_id = "orphan-generation-recovery-run"; init_local_game_project_at(root, "orphan-generation-recovery", "孤儿生成账本恢复测试") 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..e835ec140 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 @@ -22,7 +22,7 @@ fn autonomous_fixture_with_source( AgentRuntimeState, AgentRuntimeAutonomousCompletionContract, ) { - let temporary = tempfile::tempdir().expect("create autonomous fixture root"); + let temporary = crate::tests::canonical_test_tempdir("autonomous-fixture-"); let root = temporary.path().join("project"); init_local_game_project_at(&root, "autonomous-project", task).expect("init project"); let session_id = resolve_agent_conversation_session_id_at( @@ -223,7 +223,7 @@ fn autonomous_fixture_with_setup( AgentRuntimeState, AgentRuntimeAutonomousCompletionContract, ) { - let temporary = tempfile::tempdir().expect("create autonomous fixture root"); + let temporary = crate::tests::canonical_test_tempdir("autonomous-setup-fixture-"); let root = temporary.path().join("project"); init_local_game_project_at(&root, "autonomous-project", task).expect("init project"); setup(&root); @@ -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_protocol/context_bundle.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/context_bundle.rs index b9ed6e360..9f2187da8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/context_bundle.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/context_bundle.rs @@ -826,10 +826,11 @@ mod tests { .duration_since(UNIX_EPOCH) .expect("system clock should be after epoch") .as_nanos(); - let root = std::env::temp_dir().join(format!( - "genarrative-context-window-boundary-{}-{unique}", + let temporary = crate::tests::canonical_test_tempdir(&format!( + "context-window-boundary-{}-{unique}-", std::process::id() )); + let root = temporary.path().join("project"); init_local_game_project_at(&root, "project-1", "上下文窗口边界恢复项目") .expect("project init"); let mut runtime = start_game_creator_agent_runtime_task_at( 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/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index 5c9f77367..bff274d4b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -204,6 +204,17 @@ pub(crate) fn read_local_project_resource_canvas_layout( read_project_resource_canvas_layout_at(Path::new(project_path.trim()), mode) } +#[tauri::command] +pub(crate) fn read_local_project_resource_graph( + project_path: String, + expected_project_id: String, + resources: Vec, +) -> Result { + let root = validated_local_project_directory_path(project_path.trim())?; + enforce_project_auto_permission_policy(&root, "asset.list")?; + read_project_resource_graph_at(&root, expected_project_id.trim(), resources) +} + #[tauri::command] pub(crate) fn update_local_project_resource_canvas_layout( project_path: String, @@ -1166,6 +1177,66 @@ pub(crate) fn read_local_project_image_preview( load_local_project_image_preview(root, &normalized_path) } +#[tauri::command] +pub(crate) fn read_local_project_text_preview( + project_path: String, + relative_path: String, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_auto_permission_policy(root, "file.read")?; + let normalized_path = normalize_relative_path(relative_path.trim())?; + let manifest = read_manifest(&root.join(".agent/manifest.json"))?; + let is_registered_document = manifest.assets.iter().any(|asset| { + asset.local_path == normalized_path + && is_supported_project_text_resource(&asset.local_path, &asset.media_type) + }) || manifest.tasks.iter().any(|task| { + task.status == GameCreationAppTaskStatus::Completed + && task.artifacts.iter().any(|path| path == &normalized_path) + && is_supported_project_text_resource(&normalized_path, "") + }); + if !is_registered_document { + return Err("只能读取当前项目已登记的文档资源".to_string()); + } + load_local_project_text_preview(root, &normalized_path) +} + +#[tauri::command] +pub(crate) fn read_local_project_media_preview( + project_path: String, + relative_path: String, + category: String, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_auto_permission_policy(root, "file.read")?; + let normalized_path = normalize_relative_path(relative_path.trim())?; + let manifest = read_manifest(&root.join(".agent/manifest.json"))?; + let kind = match category.trim() { + "art" => ProjectMediaPreviewKind::Art, + "audio" => ProjectMediaPreviewKind::Audio, + _ => return Err("媒体预览类别只支持 art 或 audio".to_string()), + }; + let is_registered_media = manifest.assets.iter().any(|asset| { + asset.local_path == normalized_path + && match kind { + ProjectMediaPreviewKind::Art => { + is_supported_project_art_media_resource(&asset.local_path, &asset.media_type) + } + ProjectMediaPreviewKind::Audio => { + is_supported_project_audio_resource(&asset.local_path, &asset.media_type) + } + } + }) || (kind == ProjectMediaPreviewKind::Art + && manifest.tasks.iter().any(|task| { + task.status == GameCreationAppTaskStatus::Completed + && task.artifacts.iter().any(|path| path == &normalized_path) + && is_supported_project_art_media_resource(&normalized_path, "") + })); + if !is_registered_media { + return Err("只能预览当前项目已登记的媒体资源".to_string()); + } + load_local_project_media_preview(root, &normalized_path, kind) +} + #[tauri::command] pub(crate) fn write_local_project_file( project_path: String, diff --git a/apps/ai-game-creator-shell/src-tauri/src/image_inspect.rs b/apps/ai-game-creator-shell/src-tauri/src/image_inspect.rs index 6ab5a5979..5d6362ba5 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/image_inspect.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/image_inspect.rs @@ -204,7 +204,10 @@ fn validate_agent_runtime_inspection_path( Ok(()) } -fn validate_agent_runtime_inspection_ancestors(root: &Path, path: &Path) -> Result<(), String> { +pub(crate) fn validate_agent_runtime_inspection_ancestors( + root: &Path, + path: &Path, +) -> Result<(), String> { let relative = path .strip_prefix(root) .map_err(|_| "image.inspect 图片路径超出项目目录".to_string())?; @@ -450,7 +453,7 @@ fn metadata_is_windows_reparse_point(_metadata: &fs::Metadata) -> bool { } #[cfg(unix)] -fn same_open_file_snapshot(left: &fs::Metadata, right: &fs::Metadata) -> bool { +pub(crate) fn same_open_file_snapshot(left: &fs::Metadata, right: &fs::Metadata) -> bool { use std::os::unix::fs::MetadataExt; left.dev() == right.dev() && left.ino() == right.ino() @@ -463,12 +466,12 @@ fn same_open_file_snapshot(left: &fs::Metadata, right: &fs::Metadata) -> bool { } #[cfg(not(unix))] -fn same_open_file_snapshot(left: &fs::Metadata, right: &fs::Metadata) -> bool { +pub(crate) fn same_open_file_snapshot(left: &fs::Metadata, right: &fs::Metadata) -> bool { left.len() == right.len() && left.modified().ok() == right.modified().ok() } #[cfg(unix)] -fn same_open_file_identity( +pub(crate) fn same_open_file_identity( _left_file: &fs::File, left: &fs::Metadata, _right_file: &fs::File, @@ -479,7 +482,7 @@ fn same_open_file_identity( } #[cfg(windows)] -fn same_open_file_identity( +pub(crate) fn same_open_file_identity( left_file: &fs::File, _left: &fs::Metadata, right_file: &fs::File, @@ -489,7 +492,7 @@ fn same_open_file_identity( } #[cfg(not(any(unix, windows)))] -fn same_open_file_identity( +pub(crate) fn same_open_file_identity( _left_file: &fs::File, left: &fs::Metadata, _right_file: &fs::File, 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..66ba1a5ed 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -23,8 +23,9 @@ use reqwest::header; use serde::{Deserialize, Serialize}; use shared_contracts::game_creation_app::{ new_game_creation_app_manifest, new_game_creation_app_seed_tasks, - GameCreationAgentArtifactTrace, GameCreationAgentCapabilityDescriptor, - GameCreationAgentPassPlanTrace, GameCreationAgentRepairRouteTrace, GameCreationAgentRunStep, + validate_game_iteration_versions, GameCreationAgentArtifactTrace, + GameCreationAgentCapabilityDescriptor, GameCreationAgentPassPlanTrace, + GameCreationAgentRepairRouteTrace, GameCreationAgentRunStep, GameCreationAgentRunTaskGraphTrace, GameCreationAgentRunTrace, GameCreationAgentToolCallTrace, GameCreationAppAgentGroup, GameCreationAppAssetManifestEntry, GameCreationAppAssetSource, GameCreationAppAssetSourceKind, GameCreationAppCommandRunState, @@ -72,6 +73,7 @@ mod project; mod provider_handoff; mod provider_retry; mod repository_context; +mod resource_inspect; mod runner; mod swarm_cli; mod tool_plan_handoff; @@ -101,6 +103,7 @@ use preview::*; use process_session::*; use project::*; use repository_context::*; +use resource_inspect::*; use runner::*; use swarm_cli::*; use user_input::*; @@ -276,6 +279,8 @@ struct AgentRuntimeState { #[serde(default)] error: Option, #[serde(default)] + started_at: u64, + #[serde(default)] updated_at: u64, } @@ -626,9 +631,30 @@ struct GameCreatorAgentRuntimeUpdateEvent { run_id: String, status: String, phase: String, + manifest_invalidated: bool, runtime: AgentRuntimeResult, } +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +struct GameCreatorManifestInvalidatedEvent { + project_path: String, + agent_id: String, +} + +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +struct GameCreatorManifestInvalidationRelayEnvelope { + token: String, + event: GameCreatorManifestInvalidatedEvent, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct GameCreatorManifestInvalidationEventSink { + port: u16, + token: String, +} + #[derive(Clone, Debug, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] struct GameCreatorAgentProgressEvent { @@ -1848,6 +1874,7 @@ mod game_chat_release_client_exit_tests { } } +#[cfg(not(test))] fn main() { let mut args = std::env::args().skip(1).collect::>(); #[cfg(target_os = "linux")] @@ -2088,7 +2115,10 @@ fn main() { format!("启动 Agent Runner 失败:{error}"), ) })?; - attach_external_agent_runner_gui_owner() + set_game_creator_agent_runtime_update_app_handle(app.handle().clone()); + let manifest_event_sink = + start_game_creator_manifest_invalidation_event_sink(app.handle().clone())?; + attach_external_agent_runner_gui_owner(&manifest_event_sink) .inspect_err(|error| { if let Some(path) = setup_log.as_deref() { let details = @@ -2109,7 +2139,6 @@ fn main() { if let Some(path) = setup_log.as_deref() { let _ = append_bounded_diagnostic_line(path, "startup.runner.start.complete"); } - set_game_creator_agent_runtime_update_app_handle(app.handle().clone()); #[cfg(all(debug_assertions, not(test)))] if game_chat_launch.is_none() { open_developer_window(app.handle())?; @@ -2170,6 +2199,8 @@ fn main() { list_local_project_files, read_local_project_file, read_local_project_image_preview, + read_local_project_text_preview, + read_local_project_media_preview, write_local_project_file, delete_local_project_file, read_local_game_memory, @@ -2201,6 +2232,7 @@ fn main() { stop_local_game_preview_if_matches, get_local_game_preview_status, read_local_project_resource_canvas_layout, + read_local_project_resource_graph, update_local_project_resource_canvas_layout, get_local_game_project_revision, get_local_game_manifest diff --git a/apps/ai-game-creator-shell/src-tauri/src/mcp.rs b/apps/ai-game-creator-shell/src-tauri/src/mcp.rs index b4d79f7f9..28684775e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/mcp.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/mcp.rs @@ -1980,7 +1980,10 @@ mod tests { } fn mcp_test_project(label: &str) -> PathBuf { - let root = std::env::temp_dir().join(format!( + let temp_root = std::env::temp_dir() + .canonicalize() + .expect("canonicalize MCP test temp root"); + let root = temp_root.join(format!( "game-creator-mcp-{label}-{}-{}", std::process::id(), MCP_TEST_PROJECT_COUNTER.fetch_add(1, Ordering::Relaxed) diff --git a/apps/ai-game-creator-shell/src-tauri/src/project.rs b/apps/ai-game-creator-shell/src-tauri/src/project.rs index aee2ea42a..1099d78e8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project.rs @@ -10,6 +10,7 @@ mod export; mod filesystem; mod manifest; mod memory; +mod resource_dependency_graph; mod resource_layout; mod verification; @@ -20,5 +21,6 @@ pub(crate) use export::*; pub(crate) use filesystem::*; pub(crate) use manifest::*; pub(crate) use memory::*; +pub(crate) use resource_dependency_graph::*; pub(crate) use resource_layout::*; pub(crate) use verification::*; diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs b/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs index 91fb6a9ff..80216aa36 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs @@ -1,5 +1,169 @@ use super::*; +const MANIFEST_LOCK_WAIT_ATTEMPTS: usize = 500; +const MANIFEST_LOCK_WAIT_MILLIS: u64 = 10; + +static MANIFEST_LOCK_OPEN_GUARD: OnceLock> = OnceLock::new(); + +#[derive(Debug)] +struct ManifestWriteLock { + _file: File, +} + +fn manifest_lock_path(path: &Path) -> PathBuf { + path.with_file_name(format!( + ".{}.lock", + path.file_name() + .and_then(|value| value.to_str()) + .unwrap_or("manifest.json") + )) +} + +fn acquire_manifest_write_lock(path: &Path) -> Result { + for attempt in 0..MANIFEST_LOCK_WAIT_ATTEMPTS { + if let Some(file) = try_open_manifest_write_lock_file(path)? { + return Ok(ManifestWriteLock { _file: file }); + } + if attempt + 1 < MANIFEST_LOCK_WAIT_ATTEMPTS { + std::thread::sleep(Duration::from_millis(MANIFEST_LOCK_WAIT_MILLIS)); + } + } + Err("manifest 正在被其他进程写入,请稍后重试".to_string()) +} + +#[cfg(unix)] +fn try_open_manifest_write_lock_file(path: &Path) -> Result, String> { + use std::os::fd::AsRawFd; + use std::os::unix::fs::{MetadataExt, OpenOptionsExt, PermissionsExt}; + + let _open_guard = MANIFEST_LOCK_OPEN_GUARD + .get_or_init(|| Mutex::new(())) + .lock() + .map_err(|_| "manifest 锁安全打开门禁已损坏".to_string())?; + let lock_path = manifest_lock_path(path); + let mut options = fs::OpenOptions::new(); + options + .create(true) + .read(true) + .write(true) + .mode(0o600) + .custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW); + let file = options + .open(&lock_path) + .map_err(|error| format!("安全打开 manifest 锁失败:{}: {error}", lock_path.display()))?; + let metadata = file.metadata().map_err(|error| { + format!( + "读取 manifest 锁句柄元数据失败:{}: {error}", + lock_path.display() + ) + })?; + // SAFETY: geteuid takes no arguments and has no memory safety preconditions. + let effective_user_id = unsafe { libc::geteuid() }; + if !metadata.is_file() || metadata.uid() != effective_user_id || metadata.nlink() != 1 { + return Err(format!( + "manifest 锁必须是当前用户持有的无硬链接普通文件:{}", + lock_path.display() + )); + } + file.set_permissions(fs::Permissions::from_mode(0o600)) + .map_err(|error| format!("收紧 manifest 锁权限失败:{}: {error}", lock_path.display()))?; + let path_metadata = fs::symlink_metadata(&lock_path) + .map_err(|error| format!("复核 manifest 锁路径失败:{}: {error}", lock_path.display()))?; + if path_metadata.file_type().is_symlink() + || path_metadata.dev() != metadata.dev() + || path_metadata.ino() != metadata.ino() + { + return Err(format!( + "manifest 锁路径在安全打开期间发生替换:{}", + lock_path.display() + )); + } + let verified = file + .metadata() + .map_err(|error| format!("复核 manifest 锁句柄失败:{}: {error}", lock_path.display()))?; + if verified.uid() != effective_user_id + || verified.nlink() != 1 + || verified.permissions().mode() & 0o777 != 0o600 + { + return Err(format!( + "manifest 锁必须由当前用户持有且权限为 0600:{}", + lock_path.display() + )); + } + // SAFETY: flock observes only the live fd owned by `file`; dropping it releases the lock. + if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } == 0 { + return Ok(Some(file)); + } + let error = std::io::Error::last_os_error(); + if error.kind() == std::io::ErrorKind::WouldBlock { + Ok(None) + } else { + Err(format!( + "获取 manifest 系统文件锁失败:{}: {error}", + lock_path.display() + )) + } +} + +#[cfg(windows)] +fn try_open_manifest_write_lock_file(path: &Path) -> Result, String> { + use std::os::windows::fs::{MetadataExt, OpenOptionsExt}; + + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400; + const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; + + let _open_guard = MANIFEST_LOCK_OPEN_GUARD + .get_or_init(|| Mutex::new(())) + .lock() + .map_err(|_| "manifest 锁安全打开门禁已损坏".to_string())?; + let lock_path = manifest_lock_path(path); + if let Ok(metadata) = fs::symlink_metadata(&lock_path) { + if metadata.file_type().is_symlink() + || !metadata.is_file() + || metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 + { + return Err(format!( + "manifest 锁必须是普通文件且不能是 reparse point:{}", + lock_path.display() + )); + } + } + match fs::OpenOptions::new() + .create(true) + .read(true) + .write(true) + .share_mode(0) + .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT) + .open(&lock_path) + { + Ok(file) => { + validate_windows_regular_file_handle(&file, "manifest 锁")?; + crate::secure_windows_game_creator_path_for_current_user(&lock_path, false, true)?; + Ok(Some(file)) + } + Err(error) + if matches!( + error.kind(), + std::io::ErrorKind::PermissionDenied | std::io::ErrorKind::WouldBlock + ) => + { + Ok(None) + } + Err(error) => Err(format!( + "获取 manifest 系统文件锁失败:{}: {error}", + lock_path.display() + )), + } +} + +#[cfg(not(any(unix, windows)))] +fn try_open_manifest_write_lock_file(path: &Path) -> Result, String> { + Err(format!( + "当前平台不支持 manifest 系统文件锁:{}", + manifest_lock_path(path).display() + )) +} + pub(crate) fn init_local_game_project_at( root: &Path, project_id: &str, @@ -631,8 +795,11 @@ pub(crate) fn read_manifest(path: &Path) -> Result( @@ -709,12 +876,39 @@ pub(crate) fn write_manifest( path: &Path, manifest: &GameCreationAppManifest, ) -> Result<(), String> { + write_manifest_with_lock_hook(path, manifest, || {}) +} + +fn write_manifest_with_lock_hook( + path: &Path, + manifest: &GameCreationAppManifest, + after_lock: F, +) -> Result<(), String> +where + F: FnOnce(), +{ + validate_game_iteration_versions(&manifest.versions) + .map_err(|error| format!("校验 manifest 项目版本失败:{error}"))?; let payload = serde_json::to_string_pretty(manifest) .map_err(|error| format!("序列化 manifest 失败:{error}"))?; if let Some(parent) = path.parent() { fs::create_dir_all(parent) .map_err(|error| format!("创建 manifest 目录失败:{}: {error}", parent.display()))?; } + let _write_lock = acquire_manifest_write_lock(path)?; + after_lock(); + if manifest_storage_exists(path)? { + let existing = read_manifest(path)?; + if existing.versions.len() > manifest.versions.len() + || existing + .versions + .iter() + .zip(&manifest.versions) + .any(|(existing, candidate)| existing != candidate) + { + return Err("项目版本记录写入后不可修改、删除或重排".to_string()); + } + } match fs::symlink_metadata(path) { Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => { return Err("manifest 必须是普通文件".to_string()); @@ -745,7 +939,12 @@ pub(crate) fn write_manifest( temp_path.display() ) })?; - install_manifest_temp_with(path, &temp_path, |from, to| fs::rename(from, to)) + install_manifest_temp_with(path, &temp_path, |from, to| fs::rename(from, to))?; + let installed = read_manifest(path)?; + if installed != *manifest { + return Err("manifest 安装后回读与待写入内容不一致".to_string()); + } + Ok(()) } pub(crate) fn sanitize_file_name(file_name: &str) -> String { diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/manifest/recovery_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/project/manifest/recovery_tests.rs index c82b0144d..cf4c331fc 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/manifest/recovery_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/manifest/recovery_tests.rs @@ -1,4 +1,7 @@ use super::*; +use shared_contracts::game_creation_app::{ + GameIterationVersion, GameIterationVersionCreatedReason, GameIterationVersionResourceBinding, +}; fn unique_manifest_test_root(test_name: &str) -> PathBuf { std::env::temp_dir().join(format!( @@ -40,6 +43,126 @@ fn manifest_read_and_project_write_recover_previous_file() { fs::remove_dir_all(root).ok(); } +fn version_fixture( + version_id: &str, + parent_version_id: Option<&str>, + project_revision: u64, + created_reason: GameIterationVersionCreatedReason, +) -> GameIterationVersion { + GameIterationVersion { + version_id: version_id.to_string(), + parent_version_id: parent_version_id.map(str::to_string), + project_revision, + resource_bindings: vec![GameIterationVersionResourceBinding { + slot_id: "player".to_string(), + resource_id: "asset-player".to_string(), + }], + created_reason, + created_at: project_revision, + } +} + +#[test] +fn manifest_versions_are_append_only_at_the_storage_boundary() { + let root = unique_manifest_test_root("versions-append-only"); + let manifest_path = root.join(".agent/manifest.json"); + let mut manifest = new_game_creation_app_manifest("project-versioned", "版本项目"); + manifest.versions.push(version_fixture( + "version-root", + None, + 1, + GameIterationVersionCreatedReason::Initial, + )); + write_manifest(&manifest_path, &manifest).expect("write initial version"); + + manifest.versions.push(version_fixture( + "version-child", + Some("version-root"), + 2, + GameIterationVersionCreatedReason::AgentRevision, + )); + write_manifest(&manifest_path, &manifest).expect("append child version"); + + let stable_payload = fs::read(&manifest_path).expect("read stable manifest bytes"); + manifest.versions[0].resource_bindings[0].resource_id = "asset-mutated".to_string(); + let error = + write_manifest(&manifest_path, &manifest).expect_err("reject mutation of existing version"); + assert!(error.contains("不可修改、删除或重排"), "{error}"); + assert_eq!( + fs::read(&manifest_path).expect("read untouched manifest bytes"), + stable_payload + ); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn concurrent_manifest_write_cannot_overwrite_an_installed_version_with_a_stale_snapshot() { + let root = unique_manifest_test_root("versions-concurrent-append-only"); + let manifest_path = root.join(".agent/manifest.json"); + let mut stale_manifest = new_game_creation_app_manifest("project-versioned", "并发版本项目"); + stale_manifest.versions.push(version_fixture( + "version-root", + None, + 1, + GameIterationVersionCreatedReason::Initial, + )); + write_manifest(&manifest_path, &stale_manifest).expect("write initial version"); + + let mut newer_manifest = stale_manifest.clone(); + newer_manifest.versions.push(version_fixture( + "version-child", + Some("version-root"), + 2, + GameIterationVersionCreatedReason::AgentRevision, + )); + let (newer_locked_tx, newer_locked_rx) = mpsc::channel(); + let (release_newer_tx, release_newer_rx) = mpsc::channel(); + let newer_path = manifest_path.clone(); + let newer_writer = std::thread::spawn(move || { + write_manifest_with_lock_hook(&newer_path, &newer_manifest, || { + newer_locked_tx + .send(()) + .expect("signal newer lock acquired"); + release_newer_rx.recv().expect("release newer writer"); + }) + }); + newer_locked_rx + .recv_timeout(Duration::from_secs(2)) + .expect("newer writer acquires manifest lock"); + + let (stale_started_tx, stale_started_rx) = mpsc::channel(); + let stale_path = manifest_path.clone(); + let stale_writer = std::thread::spawn(move || { + stale_started_tx + .send(()) + .expect("signal stale writer started"); + write_manifest(&stale_path, &stale_manifest) + }); + stale_started_rx + .recv_timeout(Duration::from_secs(2)) + .expect("stale writer starts while newer writer holds lock"); + release_newer_tx.send(()).expect("release newer writer"); + + newer_writer + .join() + .expect("join newer writer") + .expect("install newer manifest"); + let stale_error = stale_writer + .join() + .expect("join stale writer") + .expect_err("reject stale manifest after newer version is installed"); + assert!( + stale_error.contains("不可修改、删除或重排"), + "{stale_error}" + ); + let installed = read_manifest(&manifest_path).expect("read final manifest"); + assert_eq!(installed.versions.len(), 2); + assert_eq!(installed.versions[1].version_id, "version-child"); + + fs::remove_dir_all(root).ok(); +} + #[test] fn manifest_install_uses_previous_when_direct_replace_fails() { let root = unique_manifest_test_root("replace-fallback"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/resource_dependency_graph.rs b/apps/ai-game-creator-shell/src-tauri/src/project/resource_dependency_graph.rs new file mode 100644 index 000000000..5a4a36fb1 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/project/resource_dependency_graph.rs @@ -0,0 +1,1023 @@ +use super::*; +use std::collections::{BTreeMap, BTreeSet}; + +const RESOURCE_GRAPH_AGENT_DB_READ_BYTES: u64 = 32 * 1024 * 1024; + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ProjectResourceGraphNodeInput { + pub resource_id: String, + #[serde(default)] + pub manifest_asset_id: Option, + #[serde(default)] + pub producer_task_id: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ProjectResourceReferenceEdge { + pub id: String, + pub kind: String, + pub source_resource_id: String, + pub target_resource_id: String, + pub cyclic: bool, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ProjectResourceTaskFlow { + pub id: String, + pub kind: String, + pub source_task_id: String, + pub target_task_id: String, + pub source_resource_ids: Vec, + pub target_resource_ids: Vec, + pub cyclic: bool, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ProjectResourceConnectionIndex { + pub resource_id: String, + pub upstream_reference_resource_ids: Vec, + pub downstream_reference_resource_ids: Vec, + pub reference_edge_ids: Vec, + pub task_flow_ids: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ProjectResourceProducerAssignment { + pub resource_id: String, + pub task_id: String, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ProjectResourceDependencyDepth { + pub resource_id: String, + pub dependency_depth: u32, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ProjectResourceGraphReadModel { + pub resource_ids: Vec, + pub reference_edges: Vec, + pub task_flows: Vec, + pub connection_index: Vec, + pub producer_assignments: Vec, + pub dependency_depths: Vec, + pub unresolved_reference_resource_ids: Vec, + pub cyclic_resource_ids: Vec, + pub cyclic_task_ids: Vec, + pub producer_mapping_truncated: bool, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct DirectedEdge { + id: String, + source_id: String, + target_id: String, +} + +#[derive(Debug, Default)] +struct CycleAnalysis { + cyclic_node_ids: BTreeSet, + cyclic_edge_ids: BTreeSet, + component_by_node: BTreeMap, +} + +#[derive(Debug, Default)] +struct MutableConnectionIndex { + upstream_reference_resource_ids: BTreeSet, + downstream_reference_resource_ids: BTreeSet, + reference_edge_ids: BTreeSet, + task_flow_ids: BTreeSet, +} + +fn stable_edge_id(kind: &str, source_id: &str, target_id: &str) -> String { + let pair = serde_json::to_string(&(source_id, target_id)) + .expect("serializing two resource graph identifiers cannot fail"); + format!("{kind}:{pair}") +} + +fn analyze_directed_cycles<'a>( + node_ids: impl IntoIterator, + edges: &[DirectedEdge], +) -> CycleAnalysis { + let mut nodes = node_ids.into_iter().cloned().collect::>(); + for edge in edges { + nodes.insert(edge.source_id.clone()); + nodes.insert(edge.target_id.clone()); + } + + let mut adjacency = nodes + .iter() + .map(|node_id| (node_id.clone(), Vec::::new())) + .collect::>(); + let mut reverse_adjacency = adjacency.clone(); + for edge in edges { + adjacency + .entry(edge.source_id.clone()) + .or_default() + .push(edge.target_id.clone()); + reverse_adjacency + .entry(edge.target_id.clone()) + .or_default() + .push(edge.source_id.clone()); + } + + let mut visited = BTreeSet::new(); + let mut finish_order = Vec::with_capacity(nodes.len()); + for root in &nodes { + if !visited.insert(root.clone()) { + continue; + } + let mut stack = vec![(root.clone(), 0usize)]; + while let Some((node_id, next_index)) = stack.last_mut() { + let neighbors = adjacency.get(node_id).map(Vec::as_slice).unwrap_or(&[]); + if let Some(next) = neighbors.get(*next_index) { + *next_index += 1; + if visited.insert(next.clone()) { + stack.push((next.clone(), 0)); + } + } else { + let completed = node_id.clone(); + stack.pop(); + finish_order.push(completed); + } + } + } + + let mut component_by_node = BTreeMap::::new(); + let mut component_sizes = Vec::::new(); + for root in finish_order.into_iter().rev() { + if component_by_node.contains_key(&root) { + continue; + } + let component_id = component_sizes.len(); + let mut size = 0usize; + let mut stack = vec![root.clone()]; + component_by_node.insert(root, component_id); + while let Some(node_id) = stack.pop() { + size += 1; + for neighbor in reverse_adjacency + .get(&node_id) + .map(Vec::as_slice) + .unwrap_or(&[]) + { + if !component_by_node.contains_key(neighbor) { + component_by_node.insert(neighbor.clone(), component_id); + stack.push(neighbor.clone()); + } + } + } + component_sizes.push(size); + } + + let mut result = CycleAnalysis::default(); + for edge in edges { + let source_component = component_by_node.get(&edge.source_id); + let target_component = component_by_node.get(&edge.target_id); + if source_component.is_some() + && source_component == target_component + && (component_sizes + .get(source_component.copied().unwrap_or_default()) + .copied() + .unwrap_or_default() + > 1 + || edge.source_id == edge.target_id) + { + result.cyclic_node_ids.insert(edge.source_id.clone()); + result.cyclic_node_ids.insert(edge.target_id.clone()); + result.cyclic_edge_ids.insert(edge.id.clone()); + } + } + result.component_by_node = component_by_node; + result +} + +fn dependency_depth_by_node( + analysis: &CycleAnalysis, + edges: &[DirectedEdge], + minimum_depth_by_node: &BTreeMap, +) -> BTreeMap { + let component_count = analysis + .component_by_node + .values() + .copied() + .max() + .map_or(0, |max_component| max_component + 1); + let mut outgoing = vec![BTreeSet::::new(); component_count]; + let mut indegree = vec![0usize; component_count]; + for edge in edges { + let Some(&source_component) = analysis.component_by_node.get(&edge.source_id) else { + continue; + }; + let Some(&target_component) = analysis.component_by_node.get(&edge.target_id) else { + continue; + }; + if source_component != target_component + && outgoing[source_component].insert(target_component) + { + indegree[target_component] += 1; + } + } + + let mut ready = indegree + .iter() + .enumerate() + .filter_map(|(component, degree)| (*degree == 0).then_some(component)) + .collect::>(); + let mut depth_by_component = vec![0u32; component_count]; + for (node_id, minimum_depth) in minimum_depth_by_node { + let Some(component) = analysis.component_by_node.get(node_id) else { + continue; + }; + depth_by_component[*component] = depth_by_component[*component].max(*minimum_depth); + } + while let Some(component) = ready.pop_first() { + for &target in &outgoing[component] { + depth_by_component[target] = + depth_by_component[target].max(depth_by_component[component].saturating_add(1)); + indegree[target] -= 1; + if indegree[target] == 0 { + ready.insert(target); + } + } + } + + analysis + .component_by_node + .iter() + .map(|(node_id, component)| { + ( + node_id.clone(), + depth_by_component + .get(*component) + .copied() + .unwrap_or_default(), + ) + }) + .collect() +} + +fn audit_asset_producers( + records: &[serde_json::Value], + task_ids: &BTreeSet, +) -> BTreeMap { + let mut candidates = BTreeMap::>::new(); + for record in records { + if record.get("recordType").and_then(serde_json::Value::as_str) + != Some("agent.runtime.canvas.asset_generate") + { + continue; + } + let Some(asset_id) = record + .get("assetId") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + else { + continue; + }; + let Some(agent_id) = record + .get("agentId") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| task_ids.contains(*value)) + else { + continue; + }; + candidates + .entry(asset_id.to_string()) + .or_default() + .insert(agent_id.to_string()); + } + candidates + .into_iter() + .filter_map(|(asset_id, agents)| { + (agents.len() == 1).then(|| (asset_id, agents.into_iter().next().unwrap_or_default())) + }) + .collect() +} + +pub(crate) fn build_project_resource_graph( + manifest: &GameCreationAppManifest, + resources: Vec, + agent_db_records: &[serde_json::Value], + producer_mapping_truncated: bool, +) -> ProjectResourceGraphReadModel { + let resource_by_id = resources + .into_iter() + .filter_map(|mut resource| { + resource.resource_id = resource.resource_id.trim().to_string(); + (!resource.resource_id.is_empty()).then_some((resource.resource_id.clone(), resource)) + }) + .collect::>(); + let task_by_id = manifest + .tasks + .iter() + .map(|task| (task.id.clone(), task)) + .collect::>(); + let task_ids = task_by_id.keys().cloned().collect::>(); + let manifest_asset_by_id = manifest + .assets + .iter() + .map(|asset| (asset.id.clone(), asset)) + .collect::>(); + let audit_producer_by_asset_id = if producer_mapping_truncated { + BTreeMap::new() + } else { + audit_asset_producers(agent_db_records, &task_ids) + }; + + let mut resource_ids_by_manifest_asset = BTreeMap::>::new(); + for resource in resource_by_id.values() { + if let Some(asset_id) = resource + .manifest_asset_id + .as_deref() + .map(str::trim) + .filter(|asset_id| manifest_asset_by_id.contains_key(*asset_id)) + { + resource_ids_by_manifest_asset + .entry(asset_id.to_string()) + .or_default() + .push(resource.resource_id.clone()); + } + } + + let mut producer_by_resource_id = BTreeMap::::new(); + for resource in resource_by_id.values() { + let producer = if let Some(asset_id) = resource + .manifest_asset_id + .as_deref() + .map(str::trim) + .filter(|asset_id| { + resource_ids_by_manifest_asset + .get(*asset_id) + .is_some_and(|resource_ids| resource_ids.len() == 1) + }) { + audit_producer_by_asset_id.get(asset_id).cloned() + } else { + resource + .producer_task_id + .as_deref() + .map(str::trim) + .filter(|task_id| task_ids.contains(*task_id)) + .map(ToOwned::to_owned) + }; + if let Some(producer) = producer { + producer_by_resource_id.insert(resource.resource_id.clone(), producer); + } + } + + let mut resources_by_task = BTreeMap::>::new(); + for (resource_id, task_id) in &producer_by_resource_id { + resources_by_task + .entry(task_id.clone()) + .or_default() + .push(resource_id.clone()); + } + + let mut resources_by_external_id = BTreeMap::>::new(); + for (asset_id, resource_ids) in &resource_ids_by_manifest_asset { + if resource_ids.len() != 1 { + continue; + } + let Some(external_resource_id) = manifest_asset_by_id + .get(asset_id) + .and_then(|asset| asset.source.resource_id.as_deref()) + .map(str::trim) + .filter(|value| !value.is_empty()) + else { + continue; + }; + resources_by_external_id + .entry(external_resource_id.to_string()) + .or_default() + .push(resource_ids[0].clone()); + } + + let mut unresolved_reference_resource_ids = BTreeSet::new(); + let mut reference_edge_by_id = BTreeMap::::new(); + for (asset_id, target_resource_ids) in &resource_ids_by_manifest_asset { + if target_resource_ids.len() != 1 { + continue; + } + let Some(asset) = manifest_asset_by_id.get(asset_id) else { + continue; + }; + let target_resource_id = &target_resource_ids[0]; + for external_reference_id in asset + .source + .reference_resource_ids + .iter() + .map(|value| value.trim()) + .filter(|value| !value.is_empty()) + .collect::>() + { + let source_candidates = resources_by_external_id + .get(external_reference_id) + .map(Vec::as_slice) + .unwrap_or(&[]); + if source_candidates.len() != 1 { + unresolved_reference_resource_ids.insert(external_reference_id.to_string()); + continue; + } + let source_resource_id = &source_candidates[0]; + if !resource_by_id.contains_key(source_resource_id) + || !resource_by_id.contains_key(target_resource_id) + { + continue; + } + let id = stable_edge_id("asset-reference", source_resource_id, target_resource_id); + reference_edge_by_id.insert( + id.clone(), + ProjectResourceReferenceEdge { + id, + kind: "asset-reference".to_string(), + source_resource_id: source_resource_id.clone(), + target_resource_id: target_resource_id.clone(), + cyclic: false, + }, + ); + } + } + let reference_directed_edges = reference_edge_by_id + .values() + .map(|edge| DirectedEdge { + id: edge.id.clone(), + source_id: edge.source_resource_id.clone(), + target_id: edge.target_resource_id.clone(), + }) + .collect::>(); + let reference_cycles = + analyze_directed_cycles(resource_by_id.keys(), &reference_directed_edges); + let reference_edges = reference_edge_by_id + .into_values() + .map(|mut edge| { + edge.cyclic = reference_cycles.cyclic_edge_ids.contains(&edge.id); + edge + }) + .collect::>(); + + let task_dependency_edges = manifest + .tasks + .iter() + .flat_map(|target_task| { + target_task + .dependencies + .iter() + .collect::>() + .into_iter() + .filter(|source_task_id| task_by_id.contains_key(*source_task_id)) + .map(|source_task_id| DirectedEdge { + id: stable_edge_id("task-flow", source_task_id, &target_task.id), + source_id: source_task_id.clone(), + target_id: target_task.id.clone(), + }) + .collect::>() + }) + .collect::>(); + let task_cycles = analyze_directed_cycles(task_by_id.keys(), &task_dependency_edges); + let task_dependency_depths = + dependency_depth_by_node(&task_cycles, &task_dependency_edges, &BTreeMap::new()); + let minimum_resource_dependency_depths = producer_by_resource_id + .iter() + .filter_map(|(resource_id, task_id)| { + task_dependency_depths + .get(task_id) + .copied() + .map(|depth| (resource_id.clone(), depth)) + }) + .collect::>(); + let resource_dependency_depths = dependency_depth_by_node( + &reference_cycles, + &reference_directed_edges, + &minimum_resource_dependency_depths, + ); + let task_flows = task_dependency_edges + .iter() + .filter_map(|edge| { + let source_resource_ids = resources_by_task.get(&edge.source_id)?; + let target_resource_ids = resources_by_task.get(&edge.target_id)?; + (!source_resource_ids.is_empty() && !target_resource_ids.is_empty()).then(|| { + ProjectResourceTaskFlow { + id: edge.id.clone(), + kind: "task-flow".to_string(), + source_task_id: edge.source_id.clone(), + target_task_id: edge.target_id.clone(), + source_resource_ids: source_resource_ids.clone(), + target_resource_ids: target_resource_ids.clone(), + cyclic: task_cycles.cyclic_edge_ids.contains(&edge.id), + } + }) + }) + .collect::>(); + + let mut connection_by_resource_id = resource_by_id + .keys() + .map(|resource_id| (resource_id.clone(), MutableConnectionIndex::default())) + .collect::>(); + for edge in &reference_edges { + if let Some(target) = connection_by_resource_id.get_mut(&edge.target_resource_id) { + target + .upstream_reference_resource_ids + .insert(edge.source_resource_id.clone()); + target.reference_edge_ids.insert(edge.id.clone()); + } + if let Some(source) = connection_by_resource_id.get_mut(&edge.source_resource_id) { + source + .downstream_reference_resource_ids + .insert(edge.target_resource_id.clone()); + source.reference_edge_ids.insert(edge.id.clone()); + } + } + for flow in &task_flows { + for resource_id in flow + .source_resource_ids + .iter() + .chain(flow.target_resource_ids.iter()) + { + if let Some(index) = connection_by_resource_id.get_mut(resource_id) { + index.task_flow_ids.insert(flow.id.clone()); + } + } + } + + ProjectResourceGraphReadModel { + resource_ids: resource_by_id.keys().cloned().collect(), + reference_edges, + task_flows, + connection_index: connection_by_resource_id + .into_iter() + .map(|(resource_id, index)| ProjectResourceConnectionIndex { + resource_id, + upstream_reference_resource_ids: index + .upstream_reference_resource_ids + .into_iter() + .collect(), + downstream_reference_resource_ids: index + .downstream_reference_resource_ids + .into_iter() + .collect(), + reference_edge_ids: index.reference_edge_ids.into_iter().collect(), + task_flow_ids: index.task_flow_ids.into_iter().collect(), + }) + .collect(), + producer_assignments: producer_by_resource_id + .into_iter() + .map(|(resource_id, task_id)| ProjectResourceProducerAssignment { + resource_id, + task_id, + }) + .collect(), + dependency_depths: resource_dependency_depths + .into_iter() + .map( + |(resource_id, dependency_depth)| ProjectResourceDependencyDepth { + resource_id, + dependency_depth, + }, + ) + .collect(), + unresolved_reference_resource_ids: unresolved_reference_resource_ids.into_iter().collect(), + cyclic_resource_ids: reference_cycles.cyclic_node_ids.into_iter().collect(), + cyclic_task_ids: task_cycles.cyclic_node_ids.into_iter().collect(), + producer_mapping_truncated, + } +} + +pub(crate) fn read_project_resource_graph_at( + root: &Path, + expected_project_id: &str, + resources: Vec, +) -> Result { + let manifest = read_existing_manifest_for_project(root)?; + if manifest.project_id != expected_project_id { + return Err("资源依赖图项目身份不匹配".to_string()); + } + let (records, truncated) = + read_agent_db_records_bounded(root, RESOURCE_GRAPH_AGENT_DB_READ_BYTES)?; + Ok(build_project_resource_graph( + &manifest, resources, &records, truncated, + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn resource( + resource_id: &str, + manifest_asset_id: Option<&str>, + producer_task_id: Option<&str>, + ) -> ProjectResourceGraphNodeInput { + ProjectResourceGraphNodeInput { + resource_id: resource_id.to_string(), + manifest_asset_id: manifest_asset_id.map(ToOwned::to_owned), + producer_task_id: producer_task_id.map(ToOwned::to_owned), + } + } + + fn asset( + id: &str, + external_resource_id: Option<&str>, + references: &[&str], + external_task_id: Option<&str>, + ) -> GameCreationAppAssetManifestEntry { + GameCreationAppAssetManifestEntry { + id: id.to_string(), + kind: "test".to_string(), + media_type: "image/png".to_string(), + local_path: format!("assets/{id}.png"), + source: GameCreationAppAssetSource { + kind: GameCreationAppAssetSourceKind::Canvas, + canvas_project_id: None, + resource_id: external_resource_id.map(ToOwned::to_owned), + asset_object_id: None, + task_id: external_task_id.map(ToOwned::to_owned), + prompt: None, + model: None, + generation_route: None, + generation_kind: None, + reference_resource_ids: references.iter().map(|value| value.to_string()).collect(), + }, + } + } + + fn task(id: &str, dependencies: &[&str]) -> GameCreationAppTaskState { + GameCreationAppTaskState { + id: id.to_string(), + title: id.to_string(), + group: GameCreationAppAgentGroup::Art, + role: "test".to_string(), + dependencies: dependencies.iter().map(|value| value.to_string()).collect(), + artifacts: Vec::new(), + acceptance_criteria: Vec::new(), + status: GameCreationAppTaskStatus::Completed, + } + } + + fn manifest( + tasks: Vec, + assets: Vec, + ) -> GameCreationAppManifest { + let mut manifest = new_game_creation_app_manifest("graph-project", "Graph project"); + manifest.tasks = tasks; + manifest.assets = assets; + manifest + } + + #[test] + fn graph_uses_runtime_agent_identity_instead_of_external_task_id() { + let manifest = manifest( + vec![ + task("art-director", &[]), + task("design-foundation", &["art-director"]), + ], + vec![ + asset("spec", Some("external-spec"), &[], Some("task-1")), + asset( + "ui", + Some("external-ui"), + &["external-spec"], + Some("task-2"), + ), + ], + ); + let records = vec![ + serde_json::json!({ + "recordType": "agent.runtime.canvas.asset_generate", + "assetId": "spec", + "agentId": "art-director" + }), + serde_json::json!({ + "recordType": "agent.runtime.canvas.asset_generate", + "assetId": "ui", + "agentId": "design-foundation" + }), + ]; + let graph = build_project_resource_graph( + &manifest, + vec![ + resource("asset:spec", Some("spec"), None), + resource("asset:ui", Some("ui"), None), + ], + &records, + false, + ); + + assert_eq!(graph.reference_edges.len(), 1); + assert_eq!(graph.task_flows.len(), 1); + assert_eq!(graph.task_flows[0].source_task_id, "art-director"); + assert_eq!(graph.task_flows[0].target_task_id, "design-foundation"); + assert_eq!( + graph + .producer_assignments + .iter() + .map(|assignment| (assignment.resource_id.as_str(), assignment.task_id.as_str())) + .collect::>(), + BTreeMap::from([ + ("asset:spec", "art-director"), + ("asset:ui", "design-foundation"), + ]), + ); + assert_eq!( + graph + .dependency_depths + .iter() + .map(|depth| (depth.resource_id.as_str(), depth.dependency_depth)) + .collect::>(), + BTreeMap::from([("asset:spec", 0), ("asset:ui", 1)]), + ); + assert!(graph + .producer_assignments + .iter() + .all(|assignment| assignment.task_id != "task-1" && assignment.task_id != "task-2")); + } + + #[test] + fn graph_omits_task_flow_without_reliable_runtime_producer_evidence() { + let manifest = manifest( + vec![ + task("art-director", &[]), + task("design-foundation", &["art-director"]), + ], + vec![ + asset("spec", Some("external-spec"), &[], Some("art-director")), + asset( + "ui", + Some("external-ui"), + &["external-spec"], + Some("design-foundation"), + ), + ], + ); + let graph = build_project_resource_graph( + &manifest, + vec![ + resource("asset:spec", Some("spec"), None), + resource("asset:ui", Some("ui"), None), + ], + &[], + false, + ); + + assert_eq!(graph.reference_edges.len(), 1); + assert!(graph.task_flows.is_empty()); + assert!(graph.producer_assignments.is_empty()); + assert_eq!( + graph + .dependency_depths + .iter() + .map(|depth| (depth.resource_id.as_str(), depth.dependency_depth)) + .collect::>(), + BTreeMap::from([("asset:spec", 0), ("asset:ui", 1)]), + ); + } + + #[test] + fn graph_fails_closed_for_audit_producers_when_agent_db_tail_is_truncated() { + let manifest = manifest( + vec![ + task("art-director", &[]), + task("design-foundation", &["art-director"]), + ], + vec![ + asset("spec", Some("external-spec"), &[], Some("task-1")), + asset( + "ui", + Some("external-ui"), + &["external-spec"], + Some("task-2"), + ), + ], + ); + let graph = build_project_resource_graph( + &manifest, + vec![ + resource("asset:spec", Some("spec"), None), + resource("asset:ui", Some("ui"), None), + ], + &[ + serde_json::json!({ + "recordType": "agent.runtime.canvas.asset_generate", + "assetId": "spec", + "agentId": "art-director" + }), + serde_json::json!({ + "recordType": "agent.runtime.canvas.asset_generate", + "assetId": "ui", + "agentId": "design-foundation" + }), + ], + true, + ); + + assert!(graph.producer_mapping_truncated); + assert!(graph.producer_assignments.is_empty()); + assert!(graph.task_flows.is_empty()); + assert_eq!(graph.reference_edges.len(), 1); + assert_eq!(graph.reference_edges[0].source_resource_id, "asset:spec"); + assert_eq!(graph.reference_edges[0].target_resource_id, "asset:ui"); + assert_eq!( + graph + .dependency_depths + .iter() + .map(|depth| (depth.resource_id.as_str(), depth.dependency_depth)) + .collect::>(), + BTreeMap::from([("asset:spec", 0), ("asset:ui", 1)]), + ); + } + + #[test] + fn graph_aggregates_flows_filters_missing_resources_and_detects_cycles_iteratively() { + let manifest = manifest( + vec![task("task-a", &["task-b"]), task("task-b", &["task-a"])], + vec![ + asset( + "a", + Some("external-a"), + &["external-b", "missing"], + Some("task-1"), + ), + asset("b", Some("external-b"), &["external-a"], Some("task-2")), + ], + ); + let records = vec![ + serde_json::json!({"recordType": "agent.runtime.canvas.asset_generate", "assetId": "a", "agentId": "task-a"}), + serde_json::json!({"recordType": "agent.runtime.canvas.asset_generate", "assetId": "b", "agentId": "task-b"}), + ]; + let graph = build_project_resource_graph( + &manifest, + vec![ + resource("asset:a", Some("a"), None), + resource("asset:b", Some("b"), None), + resource("task-a:artifact", None, Some("task-a")), + resource("task-b:artifact", None, Some("task-b")), + ], + &records, + false, + ); + + assert_eq!(graph.reference_edges.len(), 2); + assert!(graph.reference_edges.iter().all(|edge| edge.cyclic)); + assert_eq!(graph.task_flows.len(), 2); + assert!(graph.task_flows.iter().all(|flow| flow.cyclic)); + assert_eq!(graph.unresolved_reference_resource_ids, vec!["missing"]); + assert_eq!(graph.cyclic_resource_ids, vec!["asset:a", "asset:b"]); + assert_eq!(graph.cyclic_task_ids, vec!["task-a", "task-b"]); + assert!( + graph + .task_flows + .iter() + .all(|flow| flow.source_resource_ids.len() == 2 + && flow.target_resource_ids.len() == 2) + ); + } + + #[test] + fn graph_handles_4096_task_chain_without_recursive_traversal_or_cartesian_edges() { + let tasks = (0..4096) + .map(|index| { + let id = format!("task:{index}"); + let dependencies = if index == 0 { + Vec::new() + } else { + vec![format!("task:{}", index - 1)] + }; + GameCreationAppTaskState { + id: id.clone(), + title: id, + group: GameCreationAppAgentGroup::Code, + role: "test".to_string(), + dependencies, + artifacts: Vec::new(), + acceptance_criteria: Vec::new(), + status: GameCreationAppTaskStatus::Completed, + } + }) + .collect::>(); + let resources = (0..4096) + .map(|index| { + resource( + &format!("resource:{index}"), + None, + Some(&format!("task:{index}")), + ) + }) + .collect::>(); + let graph = + build_project_resource_graph(&manifest(tasks, Vec::new()), resources, &[], false); + + assert_eq!(graph.task_flows.len(), 4095); + assert_eq!(graph.connection_index.len(), 4096); + assert!(graph + .connection_index + .iter() + .all(|index| index.task_flow_ids.len() <= 2)); + assert_eq!( + graph + .dependency_depths + .iter() + .find(|depth| depth.resource_id == "resource:4095") + .map(|depth| depth.dependency_depth), + Some(4095), + ); + } + + #[test] + fn dependency_depth_collapses_cycles_before_following_downstream_tasks() { + let graph = build_project_resource_graph( + &manifest( + vec![ + task("source", &[]), + task("cycle-a", &["source", "cycle-b"]), + task("cycle-b", &["cycle-a"]), + task("target", &["cycle-b"]), + ], + Vec::new(), + ), + vec![ + resource("source-resource", None, Some("source")), + resource("cycle-a-resource", None, Some("cycle-a")), + resource("cycle-b-resource", None, Some("cycle-b")), + resource("target-resource", None, Some("target")), + ], + &[], + false, + ); + + let depths = graph + .dependency_depths + .iter() + .map(|depth| (depth.resource_id.as_str(), depth.dependency_depth)) + .collect::>(); + assert_eq!(depths["source-resource"], 0); + assert_eq!(depths["cycle-a-resource"], 1); + assert_eq!(depths["cycle-b-resource"], 1); + assert_eq!(depths["target-resource"], 2); + } + + #[test] + fn dependency_depth_uses_reference_sccs_after_task_depth_floors() { + let graph = build_project_resource_graph( + &manifest( + vec![ + task("source-task", &[]), + task("late-task", &["source-task"]), + ], + vec![ + asset("base", Some("external-base"), &[], None), + asset( + "cycle-a", + Some("external-cycle-a"), + &["external-base", "external-cycle-b"], + None, + ), + asset( + "cycle-b", + Some("external-cycle-b"), + &["external-cycle-a"], + None, + ), + asset( + "target", + Some("external-target"), + &["external-cycle-b"], + None, + ), + ], + ), + vec![ + resource("asset:base", Some("base"), None), + resource("asset:cycle-a", Some("cycle-a"), None), + resource("asset:cycle-b", Some("cycle-b"), None), + resource("asset:target", Some("target"), None), + ], + &[ + serde_json::json!({"recordType": "agent.runtime.canvas.asset_generate", "assetId": "base", "agentId": "source-task"}), + serde_json::json!({"recordType": "agent.runtime.canvas.asset_generate", "assetId": "cycle-a", "agentId": "late-task"}), + ], + false, + ); + + let depths = graph + .dependency_depths + .iter() + .map(|depth| (depth.resource_id.as_str(), depth.dependency_depth)) + .collect::>(); + assert_eq!(depths["asset:base"], 0); + assert_eq!(depths["asset:cycle-a"], 1); + assert_eq!(depths["asset:cycle-b"], 1); + assert_eq!(depths["asset:target"], 2); + assert_eq!( + graph.cyclic_resource_ids, + vec!["asset:cycle-a", "asset:cycle-b"] + ); + assert_eq!(graph.task_flows.len(), 1); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/resource_inspect.rs b/apps/ai-game-creator-shell/src-tauri/src/resource_inspect.rs new file mode 100644 index 000000000..129b01f75 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/resource_inspect.rs @@ -0,0 +1,441 @@ +use crate::image_inspect::{ + same_open_file_identity, same_open_file_snapshot, validate_agent_runtime_inspection_ancestors, +}; +use crate::project::{ + normalize_relative_path, open_project_snapshot_regular_file, + reject_sensitive_project_file_read, resolve_local_project_path, +}; +use base64::Engine as _; +use serde::Serialize; +use std::io::Read; +use std::path::Path; + +const PROJECT_TEXT_PREVIEW_MAX_FILE_BYTES: u64 = 2 * 1024 * 1024; +const PROJECT_MEDIA_PREVIEW_MAX_FILE_BYTES: u64 = 32 * 1024 * 1024; + +#[derive(Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct LocalProjectTextPreview { + pub(crate) path: String, + pub(crate) media_type: String, + pub(crate) byte_len: u64, + pub(crate) content: String, +} + +#[derive(Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct LocalProjectMediaPreview { + pub(crate) path: String, + pub(crate) media_type: String, + pub(crate) byte_len: u64, + pub(crate) data_url: String, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum ProjectMediaPreviewKind { + Art, + Audio, +} + +pub(crate) fn is_supported_project_text_resource(path: &str, media_type: &str) -> bool { + let media_type = media_type.trim().to_ascii_lowercase(); + matches!( + path_extension(path).as_deref(), + Some("md" | "markdown" | "mdx" | "txt" | "json" | "yaml" | "yml" | "toml") + ) && (media_type.is_empty() + || media_type.starts_with("text/") + || media_type.contains("json") + || media_type.contains("yaml") + || matches!( + media_type.as_str(), + "项目文档" | "application/toml" | "application/mdx" + )) +} + +pub(crate) fn is_supported_project_art_media_resource(path: &str, media_type: &str) -> bool { + let media_type = media_type.trim().to_ascii_lowercase(); + matches!( + path_extension(path).as_deref(), + Some("gif" | "svg" | "avif" | "bmp" | "mp4" | "webm" | "mov") + ) || media_type.starts_with("video/") + || media_type == "image/svg+xml" +} + +pub(crate) fn is_supported_project_audio_resource(path: &str, media_type: &str) -> bool { + let media_type = media_type.trim().to_ascii_lowercase(); + matches!( + path_extension(path).as_deref(), + Some("mp3" | "wav" | "ogg" | "m4a" | "aac" | "flac" | "opus") + ) || media_type.starts_with("audio/") +} + +pub(crate) fn load_local_project_text_preview( + root: &Path, + relative_path: &str, +) -> Result { + let normalized = normalize_relative_path(relative_path.trim())?; + reject_sensitive_project_file_read(&normalized)?; + let media_type = project_text_media_type(&normalized) + .ok_or_else(|| "文档预览只支持 Markdown、文本、JSON、YAML 和 TOML".to_string())?; + let bytes = read_stable_project_resource( + root, + &normalized, + PROJECT_TEXT_PREVIEW_MAX_FILE_BYTES, + "项目文档", + )?; + let content = + String::from_utf8(bytes).map_err(|_| "文档预览只支持 UTF-8 编码的文本文件".to_string())?; + Ok(LocalProjectTextPreview { + path: normalized, + media_type: media_type.to_string(), + byte_len: content.len() as u64, + content, + }) +} + +pub(crate) fn load_local_project_media_preview( + root: &Path, + relative_path: &str, + kind: ProjectMediaPreviewKind, +) -> Result { + let normalized = normalize_relative_path(relative_path.trim())?; + reject_sensitive_project_file_read(&normalized)?; + let bytes = read_stable_project_resource( + root, + &normalized, + PROJECT_MEDIA_PREVIEW_MAX_FILE_BYTES, + "项目媒体资源", + )?; + if bytes.is_empty() { + return Err("媒体文件为空,无法预览".to_string()); + } + let media_type = detect_project_media_type(&normalized, &bytes, kind)?; + Ok(LocalProjectMediaPreview { + path: normalized, + media_type: media_type.to_string(), + byte_len: bytes.len() as u64, + data_url: format!( + "data:{media_type};base64,{}", + base64::engine::general_purpose::STANDARD.encode(bytes) + ), + }) +} + +fn read_stable_project_resource( + root: &Path, + normalized: &str, + max_bytes: u64, + label: &str, +) -> Result, String> { + let absolute = resolve_local_project_path(root, normalized)?; + validate_agent_runtime_inspection_ancestors(root, &absolute)?; + let (mut file, initial_metadata) = open_project_snapshot_regular_file(&absolute, label)?; + if initial_metadata.len() > max_bytes { + return Err(format!("{label}不能超过 {} MiB", max_bytes / 1024 / 1024)); + } + let mut bytes = Vec::with_capacity(initial_metadata.len() as usize); + file.by_ref() + .take(max_bytes + 1) + .read_to_end(&mut bytes) + .map_err(|error| format!("读取{label}失败:{normalized}: {error}"))?; + if bytes.len() as u64 > max_bytes { + return Err(format!("{label}不能超过 {} MiB", max_bytes / 1024 / 1024)); + } + let final_metadata = file + .metadata() + .map_err(|error| format!("复核{label}失败:{normalized}: {error}"))?; + if initial_metadata.len() != bytes.len() as u64 + || final_metadata.len() != bytes.len() as u64 + || !same_open_file_snapshot(&initial_metadata, &final_metadata) + { + return Err(format!("{label}读取期间发生漂移:{normalized}")); + } + let (reopened, reopened_metadata) = open_project_snapshot_regular_file(&absolute, label)?; + if !same_open_file_identity(&file, &initial_metadata, &reopened, &reopened_metadata)? { + return Err(format!("{label}路径读取期间发生替换:{normalized}")); + } + Ok(bytes) +} + +fn project_text_media_type(path: &str) -> Option<&'static str> { + match path_extension(path).as_deref()? { + "md" | "markdown" | "mdx" => Some("text/markdown"), + "txt" => Some("text/plain"), + "json" => Some("application/json"), + "yaml" | "yml" => Some("application/yaml"), + "toml" => Some("application/toml"), + _ => None, + } +} + +fn detect_project_media_type( + path: &str, + bytes: &[u8], + kind: ProjectMediaPreviewKind, +) -> Result<&'static str, String> { + if kind == ProjectMediaPreviewKind::Art && path_extension(path).as_deref() == Some("svg") { + validate_safe_svg(bytes)?; + return Ok("image/svg+xml"); + } + if kind == ProjectMediaPreviewKind::Art { + if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") { + return Ok("image/gif"); + } + if bytes.starts_with(b"BM") { + return Ok("image/bmp"); + } + if is_avif(bytes) { + return Ok("image/avif"); + } + if is_iso_base_media(bytes) { + return Ok(if path_extension(path).as_deref() == Some("mov") { + "video/quicktime" + } else { + "video/mp4" + }); + } + if bytes.starts_with(&[0x1a, 0x45, 0xdf, 0xa3]) { + return Ok("video/webm"); + } + return Err("美术媒体预览只支持 GIF、安全 SVG、AVIF、BMP、MP4、WebM 或 MOV".to_string()); + } + + if looks_like_id3(bytes) || looks_like_mp3_frame(bytes) { + Ok("audio/mpeg") + } else if bytes.len() >= 12 && bytes.starts_with(b"RIFF") && &bytes[8..12] == b"WAVE" { + Ok("audio/wav") + } else if bytes.starts_with(b"OggS") { + Ok("audio/ogg") + } else if bytes.starts_with(b"fLaC") { + Ok("audio/flac") + } else if is_avif(bytes) { + Err("音乐音效文件签名与登记类型不一致".to_string()) + } else if is_iso_base_media(bytes) { + Ok("audio/mp4") + } else if looks_like_aac_adts(bytes) { + Ok("audio/aac") + } else { + Err("音乐音效预览只支持 MP3、WAV、OGG、M4A、AAC、FLAC 或 Opus".to_string()) + } +} + +fn validate_safe_svg(bytes: &[u8]) -> Result<(), String> { + let text = std::str::from_utf8(bytes).map_err(|_| "SVG 必须使用 UTF-8 编码".to_string())?; + let lower = text.to_ascii_lowercase(); + if !lower.contains(" bool { + let mut remaining = text; + while let Some(index) = remaining.find("href") { + let after_name = &remaining[index + 4..]; + let Some(after_equals) = after_name.trim_start().strip_prefix('=') else { + remaining = after_name; + continue; + }; + let value = after_equals.trim_start(); + let value = value + .strip_prefix('\'') + .or_else(|| value.strip_prefix('"')) + .unwrap_or(value) + .trim_start(); + if !value.starts_with('#') { + return true; + } + remaining = after_name; + } + false +} + +fn contains_unsafe_svg_url(text: &str) -> bool { + let mut remaining = text; + while let Some(index) = remaining.find("url(") { + let value = remaining[index + 4..].trim_start(); + let value = value + .strip_prefix('\'') + .or_else(|| value.strip_prefix('"')) + .unwrap_or(value) + .trim_start(); + if !value.starts_with('#') { + return true; + } + remaining = &remaining[index + 4..]; + } + false +} + +fn contains_svg_event_handler(text: &str) -> bool { + let bytes = text.as_bytes(); + let mut index = 0usize; + while index + 3 < bytes.len() { + if bytes[index].is_ascii_whitespace() && bytes[index + 1..].starts_with(b"on") { + let mut cursor = index + 3; + while cursor < bytes.len() && bytes[cursor].is_ascii_alphabetic() { + cursor += 1; + } + while cursor < bytes.len() && bytes[cursor].is_ascii_whitespace() { + cursor += 1; + } + if cursor < bytes.len() && bytes[cursor] == b'=' { + return true; + } + } + index += 1; + } + false +} + +fn is_iso_base_media(bytes: &[u8]) -> bool { + bytes.len() >= 12 && &bytes[4..8] == b"ftyp" +} + +fn is_avif(bytes: &[u8]) -> bool { + is_iso_base_media(bytes) + && (&bytes[8..12] == b"avif" + || &bytes[8..12] == b"avis" + || bytes[8..].windows(4).any(|brand| brand == b"avif")) +} + +fn looks_like_mp3_frame(bytes: &[u8]) -> bool { + bytes.len() >= 4 + && bytes[0] == 0xff + && bytes[1] & 0xe0 == 0xe0 + && bytes[1] & 0x06 != 0 + && bytes[2] & 0xf0 != 0xf0 + && bytes[2] & 0x0c != 0x0c +} + +fn looks_like_id3(bytes: &[u8]) -> bool { + if bytes.len() < 10 || !bytes.starts_with(b"ID3") || bytes[3] == 0xff || bytes[4] == 0xff { + return false; + } + let size_bytes = &bytes[6..10]; + if size_bytes.iter().any(|byte| byte & 0x80 != 0) { + return false; + } + let tag_size = size_bytes + .iter() + .fold(0usize, |size, byte| (size << 7) | usize::from(*byte)); + 10usize + .checked_add(tag_size) + .is_some_and(|required| required <= bytes.len()) +} + +fn looks_like_aac_adts(bytes: &[u8]) -> bool { + bytes.len() >= 2 && bytes[0] == 0xff && bytes[1] & 0xf6 == 0xf0 +} + +fn path_extension(path: &str) -> Option { + Path::new(path) + .extension() + .and_then(|extension| extension.to_str()) + .map(str::to_ascii_lowercase) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + #[test] + fn text_preview_requires_utf8_and_a_supported_extension() { + let root = tempfile::tempdir().expect("temp root"); + fs::create_dir_all(root.path().join("docs")).expect("docs dir"); + fs::write(root.path().join("docs/design.md"), "# 设计\n\n正文").expect("markdown"); + fs::write(root.path().join("docs/legacy.txt"), [0xff, 0xfe]).expect("legacy text"); + fs::write(root.path().join("docs/page.html"), "

unsafe

").expect("html"); + + let preview = + load_local_project_text_preview(root.path(), "docs/design.md").expect("load markdown"); + assert_eq!(preview.media_type, "text/markdown"); + assert!(preview.content.contains("正文")); + assert!(load_local_project_text_preview(root.path(), "docs/legacy.txt").is_err()); + assert!(load_local_project_text_preview(root.path(), "docs/page.html").is_err()); + } + + #[test] + fn media_preview_accepts_safe_svg_and_rejects_active_svg() { + let root = tempfile::tempdir().expect("temp root"); + fs::create_dir_all(root.path().join("assets")).expect("assets dir"); + fs::write( + root.path().join("assets/icon.svg"), + "", + ) + .expect("svg"); + fs::write( + root.path().join("assets/active.svg"), + "", + ) + .expect("active svg"); + fs::write( + root.path().join("assets/external.svg"), + "", + ) + .expect("external svg"); + + let preview = load_local_project_media_preview( + root.path(), + "assets/icon.svg", + ProjectMediaPreviewKind::Art, + ) + .expect("safe svg"); + assert_eq!(preview.media_type, "image/svg+xml"); + assert!(preview.data_url.starts_with("data:image/svg+xml;base64,")); + assert!(load_local_project_media_preview( + root.path(), + "assets/active.svg", + ProjectMediaPreviewKind::Art, + ) + .is_err()); + assert!(load_local_project_media_preview( + root.path(), + "assets/external.svg", + ProjectMediaPreviewKind::Art, + ) + .is_err()); + } + + #[cfg(unix)] + #[test] + fn resource_preview_rejects_symlink_and_hardlink_files() { + use std::os::unix::fs::symlink; + + let root = tempfile::tempdir().expect("temp root"); + let outside = tempfile::tempdir().expect("outside"); + fs::create_dir_all(root.path().join("docs")).expect("docs dir"); + let source = outside.path().join("source.md"); + fs::write(&source, "secret").expect("source"); + symlink(&source, root.path().join("docs/link.md")).expect("symlink"); + fs::hard_link(&source, root.path().join("docs/hard.md")).expect("hardlink"); + + assert!(load_local_project_text_preview(root.path(), "docs/link.md").is_err()); + assert!(load_local_project_text_preview(root.path(), "docs/hard.md").is_err()); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs index 155d49037..e4d35d654 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs @@ -1,5 +1,8 @@ use super::{dispatch::*, endpoint::*, project_owner::*, protocol::*, state::*}; -use crate::{AgentRuntimeContextCompactionResult, GameCreatorMcpCatalog}; +use crate::{ + AgentRuntimeContextCompactionResult, GameCreatorManifestInvalidationEventSink, + GameCreatorMcpCatalog, +}; use serde_json::Value; use sha2::{Digest as _, Sha256}; use std::ffi::OsString; @@ -982,7 +985,9 @@ pub(crate) fn shutdown_external_agent_runner() -> Result<(), String> { shutdown_external_agent_runner_at(&config_dir) } -pub(crate) fn attach_external_agent_runner_gui_owner() -> Result<(), String> { +pub(crate) fn attach_external_agent_runner_gui_owner( + event_sink: &GameCreatorManifestInvalidationEventSink, +) -> Result<(), String> { EXTERNAL_AGENT_RUNNER_GUI_OWNER_REQUIRED_CLIENT .store(true, std::sync::atomic::Ordering::Release); let _configure = lock_unpoisoned(external_agent_runner_configure_lock()); @@ -991,21 +996,33 @@ pub(crate) fn attach_external_agent_runner_gui_owner() -> Result<(), String> { register_external_agent_runner_gui_owner_attachment( external_agent_runner_gui_owner_attachment_state(), &config_dir, - ExternalAgentRunnerRequestParams::default(), + ExternalAgentRunnerRequestParams { + event_sink_port: Some(event_sink.port), + event_sink_token: Some(event_sink.token.clone()), + ..ExternalAgentRunnerRequestParams::default() + }, ); ensure_external_agent_runner(&config_dir).map(|_| ()) } +pub(super) fn validate_external_agent_runner_gui_owner_attachment_result( + result: &Value, +) -> Result<(), String> { + if result.get("attached").and_then(Value::as_bool) == Some(true) + && result.get("eventSinkAttached").and_then(Value::as_bool) == Some(true) + { + Ok(()) + } else { + Err("Agent Runner attach_gui_owner 响应未确认 owner 与事件接收端".to_string()) + } +} + fn attach_external_agent_runner_gui_owner_at( endpoint: &ExternalAgentRunnerEndpoint, params: ExternalAgentRunnerRequestParams, ) -> Result<(), String> { let result = send_external_agent_runner_request(endpoint, "runner.attach_gui_owner", params)?; - if result.get("attached").and_then(Value::as_bool) == Some(true) { - Ok(()) - } else { - Err("Agent Runner attach_gui_owner 响应未确认 owner".to_string()) - } + validate_external_agent_runner_gui_owner_attachment_result(&result) } fn attach_registered_external_agent_runner_gui_owner_if_needed( @@ -1276,6 +1293,8 @@ pub(super) fn send_external_agent_runner_runtime_request_with_stable_identity( run_id: run_id.map(str::to_string), action_id: action_id.map(str::to_string), steer_id: steer_id.map(str::to_string), + event_sink_port: None, + event_sink_token: None, }; match stable_identity { Some(stable_identity) => { diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/dispatch.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/dispatch.rs index 5bc8667ef..aa17ffefe 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/dispatch.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/dispatch.rs @@ -1,4 +1,5 @@ use super::{endpoint::*, project_owner::*, protocol::*, state::*}; +use crate::configure_game_creator_manifest_invalidation_event_sink; use serde::Deserialize; use serde_json::json; use sha2::{Digest as _, Sha256}; @@ -587,10 +588,27 @@ pub(super) fn dispatch_external_agent_runner_runtime_request( "runner.attach_gui_owner" => { match external_agent_runner_gui_owner_is_locked(&state.gui_owner_lock_path) { Ok(true) => { + let event_sink = request + .params + .event_sink_port + .zip(request.params.event_sink_token.as_deref()) + .ok_or_else(|| { + "Agent Runner GUI owner 缺少 manifest 事件接收端".to_string() + }) + .and_then(|(port, token)| { + configure_game_creator_manifest_invalidation_event_sink(port, token) + }); + if let Err(error) = event_sink { + return ExternalAgentRunnerResponse::failure( + &request.request_id, + "event-sink-invalid", + redact_runner_secret(&error, &token), + ); + } state.gui_owner_attached.store(true, Ordering::Release); ExternalAgentRunnerResponse::success( &request.request_id, - json!({ "attached": true }), + json!({ "attached": true, "eventSinkAttached": true }), ) } Ok(false) => ExternalAgentRunnerResponse::failure( diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/protocol.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/protocol.rs index 3eda73886..451309200 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/protocol.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/protocol.rs @@ -9,7 +9,7 @@ use std::sync::atomic::{AtomicBool, AtomicU64}; use std::sync::{Mutex, OnceLock}; use std::time::Duration; -pub(crate) const EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION: u32 = 4; +pub(crate) const EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION: u32 = 5; pub(super) const EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME: &str = "agent-runner.endpoint.json"; pub(super) const EXTERNAL_AGENT_RUNNER_LOCK_FILE_NAME: &str = "agent-runner.lock"; @@ -264,6 +264,10 @@ pub(super) struct ExternalAgentRunnerRequestParams { pub(super) action_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub(super) steer_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(super) event_sink_port: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(super) event_sink_token: Option, } #[derive(Deserialize, Serialize)] diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs index e4fc14bf5..4e28d62ac 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs @@ -85,7 +85,7 @@ fn context_compaction_client_uses_long_response_timeout_without_widening_other_m assert!( external_agent_runner_client_read_timeout("mcp.status") > EXTERNAL_AGENT_RUNNER_IO_TIMEOUT ); - assert_eq!(EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION, 4); + assert_eq!(EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION, 5); } fn test_endpoint(token: &str, boot_id: &str, port: u16) -> ExternalAgentRunnerEndpoint { @@ -556,8 +556,11 @@ fn runner_endpoint_rejects_hard_links() { fn gui_owner_registration_replays_once_for_each_runner_boot() { let state = Mutex::new(ExternalAgentRunnerGuiOwnerAttachmentState::default()); let config_dir = PathBuf::from("registered-gui-appdata"); + let event_sink_port = 31_317; + let event_sink_token = "a".repeat(64); let params = ExternalAgentRunnerRequestParams { - action_id: Some("registered-owner-params".to_string()), + event_sink_port: Some(event_sink_port), + event_sink_token: Some(event_sink_token.clone()), ..ExternalAgentRunnerRequestParams::default() }; register_external_agent_runner_gui_owner_attachment(&state, &config_dir, params); @@ -575,7 +578,12 @@ fn gui_owner_registration_replays_once_for_each_runner_boot() { |endpoint, params| { calls.borrow_mut().push(( endpoint.boot_id.clone(), - params.action_id.expect("registered params are retained"), + params + .event_sink_port + .expect("registered sink port is retained"), + params + .event_sink_token + .expect("registered sink token is retained"), )); Ok(()) }, @@ -601,7 +609,12 @@ fn gui_owner_registration_replays_once_for_each_runner_boot() { |endpoint, params| { calls.borrow_mut().push(( endpoint.boot_id.clone(), - params.action_id.expect("registered params are replayed"), + params + .event_sink_port + .expect("registered sink port is replayed"), + params + .event_sink_token + .expect("registered sink token is replayed"), )); Ok(()) }, @@ -613,11 +626,13 @@ fn gui_owner_registration_replays_once_for_each_runner_boot() { vec![ ( "gui-owner-boot-a".to_string(), - "registered-owner-params".to_string() + event_sink_port, + event_sink_token.clone(), ), ( "gui-owner-boot-b".to_string(), - "registered-owner-params".to_string() + event_sink_port, + event_sink_token, ), ] ); @@ -670,15 +685,117 @@ fn gui_owner_registration_failed_replay_remains_pending_for_same_boot() { assert_eq!(attempts.get(), 2); } +#[test] +fn gui_owner_registration_missing_event_sink_confirmation_retries_same_boot() { + let state = Mutex::new(ExternalAgentRunnerGuiOwnerAttachmentState::default()); + let config_dir = PathBuf::from("missing-sink-confirmation-appdata"); + register_external_agent_runner_gui_owner_attachment( + &state, + &config_dir, + ExternalAgentRunnerRequestParams { + event_sink_port: Some(31_322), + event_sink_token: Some("c".repeat(64)), + ..ExternalAgentRunnerRequestParams::default() + }, + ); + let endpoint = test_endpoint( + "missing-sink-confirmation-runner-token", + "missing-sink-confirmation-boot", + 31_322, + ); + let attempts = std::cell::Cell::new(0_u32); + + attach_registered_external_agent_runner_gui_owner_if_needed_with( + &state, + &config_dir, + &endpoint, + |_, _| { + attempts.set(attempts.get() + 1); + validate_external_agent_runner_gui_owner_attachment_result(&json!({ + "attached": true + })) + }, + ) + .expect_err("missing eventSinkAttached must fail"); + attach_registered_external_agent_runner_gui_owner_if_needed_with( + &state, + &config_dir, + &endpoint, + |_, _| { + attempts.set(attempts.get() + 1); + validate_external_agent_runner_gui_owner_attachment_result(&json!({ + "attached": true, + "eventSinkAttached": true + })) + }, + ) + .expect("same boot retries after missing event sink confirmation"); + assert_eq!(attempts.get(), 2); +} + +#[test] +fn gui_owner_registration_false_event_sink_confirmation_retries_same_boot() { + let state = Mutex::new(ExternalAgentRunnerGuiOwnerAttachmentState::default()); + let config_dir = PathBuf::from("false-sink-confirmation-appdata"); + register_external_agent_runner_gui_owner_attachment( + &state, + &config_dir, + ExternalAgentRunnerRequestParams { + event_sink_port: Some(31_323), + event_sink_token: Some("d".repeat(64)), + ..ExternalAgentRunnerRequestParams::default() + }, + ); + let endpoint = test_endpoint( + "false-sink-confirmation-runner-token", + "false-sink-confirmation-boot", + 31_323, + ); + let attempts = std::cell::Cell::new(0_u32); + + attach_registered_external_agent_runner_gui_owner_if_needed_with( + &state, + &config_dir, + &endpoint, + |_, _| { + attempts.set(attempts.get() + 1); + validate_external_agent_runner_gui_owner_attachment_result(&json!({ + "attached": true, + "eventSinkAttached": false + })) + }, + ) + .expect_err("false eventSinkAttached must fail"); + attach_registered_external_agent_runner_gui_owner_if_needed_with( + &state, + &config_dir, + &endpoint, + |_, _| { + attempts.set(attempts.get() + 1); + validate_external_agent_runner_gui_owner_attachment_result(&json!({ + "attached": true, + "eventSinkAttached": true + })) + }, + ) + .expect("same boot retries after false event sink confirmation"); + assert_eq!(attempts.get(), 2); +} + #[test] fn gui_owner_registration_does_not_cross_config_dirs() { let state = Mutex::new(ExternalAgentRunnerGuiOwnerAttachmentState::default()); let registered_config_dir = PathBuf::from("registered-gui-appdata"); let other_config_dir = PathBuf::from("other-gui-appdata"); + let event_sink_token = "e".repeat(64); register_external_agent_runner_gui_owner_attachment( &state, ®istered_config_dir, - ExternalAgentRunnerRequestParams::default(), + ExternalAgentRunnerRequestParams { + event_sink_port: Some(31_324), + event_sink_token: Some(event_sink_token.clone()), + ..ExternalAgentRunnerRequestParams::default() + }, ); let endpoint = test_endpoint( "gui-owner-config-token-gui-owner-config-token", @@ -698,8 +815,13 @@ fn gui_owner_registration_does_not_cross_config_dirs() { &state, ®istered_config_dir, &endpoint, - |_, _| { + |_, params| { calls.set(calls.get() + 1); + assert_eq!(params.event_sink_port, Some(31_324)); + assert_eq!( + params.event_sink_token.as_deref(), + Some(event_sink_token.as_str()) + ); Ok(()) }, ) @@ -732,7 +854,9 @@ fn gui_owner_lock_allows_only_one_frontend_process_per_appdata() { } #[test] -fn attached_gui_owner_loss_forces_runner_shutdown() { +fn manifest_invalidation_sink_isolation_gui_owner_attach_configures_and_cleans_up() { + let sink_guard = crate::acquire_game_creator_manifest_invalidation_event_sink_test_guard(); + assert_eq!(sink_guard.configured_sink(), None); let directory = unique_test_directory(); let config_dir = private_runner_test_config_dir(&directory); let token = "gui-owner-monitor-token-gui-owner-monitor-token"; @@ -748,12 +872,23 @@ fn attached_gui_owner_loss_forces_runner_shutdown() { request_id: "gui-owner-attach-1".to_string(), token: token.to_string(), method: "runner.attach_gui_owner".to_string(), - params: ExternalAgentRunnerRequestParams::default(), + params: ExternalAgentRunnerRequestParams { + event_sink_port: Some(31_318), + event_sink_token: Some("b".repeat(64)), + ..ExternalAgentRunnerRequestParams::default() + }, }, &state, ); assert!(attached.ok); assert!(state.gui_owner_attached.load(Ordering::Acquire)); + assert_eq!( + sink_guard.configured_sink(), + Some(crate::GameCreatorManifestInvalidationEventSink { + port: 31_318, + token: "b".repeat(64), + }) + ); assert!( !external_agent_runner_shutdown_if_gui_owner_lost(&state).expect("owner remains present") ); @@ -764,6 +899,9 @@ fn attached_gui_owner_loss_forces_runner_shutdown() { assert!(state.draining.load(Ordering::Acquire)); assert!(state.force_shutdown_requested.load(Ordering::Acquire)); assert!(state.shutdown_requested.load(Ordering::Acquire)); + drop(sink_guard); + let cleanup_guard = crate::acquire_game_creator_manifest_invalidation_event_sink_test_guard(); + assert_eq!(cleanup_guard.configured_sink(), None); } #[test] 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..e1b84a8c2 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(); @@ -1606,5 +2436,10 @@ async fn project_supervisor_resume_rechecks_delegate_policy_after_delivery_reser .expect("read barrier after rejecting reserved delivery") .is_clear()); + let released = + wait_for_agent_runtime_lane_release_async(&root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID) + .await; + assert_eq!(released.state.run_id, parent_run_id); + fs::remove_dir_all(root).ok(); } 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..de98c3b49 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 @@ -3,15 +3,159 @@ use base64::Engine as _; use serde_json::Value; use sha2::{Digest as _, Sha256}; use std::collections::{BTreeMap, BTreeSet}; -use std::io::{Read, Write}; +use std::io::{self, Read, Write}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, Barrier, Condvar, Mutex as StdMutex, MutexGuard as StdMutexGuard}; -use std::time::{SystemTime, UNIX_EPOCH}; +use std::time::{Instant, SystemTime, UNIX_EPOCH}; use zip::write::SimpleFileOptions; static TEST_PROJECT_COUNTER: AtomicU64 = AtomicU64::new(0); static TEST_MOCK_PORT_COUNTER: AtomicU64 = AtomicU64::new(20_000); static TEST_CONFIG_LOCK: StdMutex<()> = StdMutex::new(()); +const MANIFEST_INVALIDATION_RELAY_TEST_ACCEPT_TIMEOUT: Duration = Duration::from_millis(500); +const MANIFEST_INVALIDATION_RELAY_TEST_PAYLOAD_TIMEOUT: Duration = Duration::from_millis(500); +const MANIFEST_INVALIDATION_RELAY_TEST_MAX_BYTES: usize = 64 * 1024; + +fn read_manifest_invalidation_relay_payload_with_deadline( + listener: &TcpListener, +) -> io::Result> { + listener.set_nonblocking(true)?; + let accept_deadline = Instant::now() + MANIFEST_INVALIDATION_RELAY_TEST_ACCEPT_TIMEOUT; + let (mut stream, _) = loop { + match listener.accept() { + Ok(accepted) => break accepted, + Err(error) if error.kind() == io::ErrorKind::WouldBlock => { + if Instant::now() >= accept_deadline { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "manifest invalidation relay accept timed out", + )); + } + std::thread::yield_now(); + } + Err(error) if error.kind() == io::ErrorKind::Interrupted => continue, + Err(error) => return Err(error), + } + }; + + stream.set_nonblocking(true)?; + let payload_deadline = Instant::now() + MANIFEST_INVALIDATION_RELAY_TEST_PAYLOAD_TIMEOUT; + let mut payload = Vec::new(); + let mut buffer = [0_u8; 4096]; + loop { + match stream.read(&mut buffer) { + Ok(0) => return Ok(payload), + Ok(read) => { + payload.extend_from_slice(&buffer[..read]); + if payload.len() > MANIFEST_INVALIDATION_RELAY_TEST_MAX_BYTES { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "manifest invalidation relay payload exceeded test limit", + )); + } + } + Err(error) if error.kind() == io::ErrorKind::WouldBlock => { + if Instant::now() >= payload_deadline { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "manifest invalidation relay payload timed out", + )); + } + std::thread::yield_now(); + } + Err(error) if error.kind() == io::ErrorKind::Interrupted => continue, + Err(error) => return Err(error), + } + } +} + +#[test] +fn manifest_invalidation_sink_isolation_relays_non_supervisor_runtime_update() { + let sink_guard = acquire_game_creator_manifest_invalidation_event_sink_test_guard(); + let root = unique_project_path(); + init_local_game_project_at(&root, "runtime-event-contract", "Runtime 事件合同测试") + .expect("init runtime event contract project"); + let runtime = read_game_creator_agent_runtime_at(&root, "art-asset-plan") + .expect("read non-Supervisor runtime"); + let event = game_creator_agent_runtime_update_event(&root, runtime); + let serialized = serde_json::to_value(event).expect("serialize runtime update event"); + + assert_eq!(serialized["agentId"], "art-asset-plan"); + assert_eq!(serialized["manifestInvalidated"], true); + + let relay_listener = TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)) + .expect("bind manifest invalidation relay fixture"); + let relay_port = relay_listener + .local_addr() + .expect("read manifest invalidation relay fixture address") + .port(); + let relay_token = "a".repeat(64); + sink_guard + .configure(relay_port, &relay_token) + .expect("configure manifest invalidation relay fixture"); + emit_game_creator_agent_runtime_update(&root, "art-asset-plan"); + let relay_payload = read_manifest_invalidation_relay_payload_with_deadline(&relay_listener) + .expect("receive manifest invalidation relay within deadline"); + let relay: GameCreatorManifestInvalidationRelayEnvelope = + serde_json::from_slice(&relay_payload).expect("parse manifest invalidation relay"); + assert_eq!(relay.token, relay_token); + assert_eq!(relay.event.project_path, root.to_string_lossy()); + assert_eq!(relay.event.agent_id, "art-asset-plan"); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn manifest_invalidation_sink_isolation_bounds_timeouts_and_cleans_up_with_raii() { + let cleanup_listener = TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)) + .expect("bind manifest invalidation cleanup fixture"); + let cleanup_port = cleanup_listener + .local_addr() + .expect("read manifest invalidation cleanup fixture address") + .port(); + let cleanup_token = "b".repeat(64); + let unwind = std::panic::catch_unwind(|| { + let sink_guard = acquire_game_creator_manifest_invalidation_event_sink_test_guard(); + sink_guard + .configure(cleanup_port, &cleanup_token) + .expect("configure manifest invalidation cleanup fixture"); + assert_eq!( + sink_guard.configured_sink(), + Some(GameCreatorManifestInvalidationEventSink { + port: cleanup_port, + token: cleanup_token.clone(), + }) + ); + panic!("exercise manifest invalidation sink guard unwind cleanup"); + }); + assert!(unwind.is_err()); + + let sink_guard = acquire_game_creator_manifest_invalidation_event_sink_test_guard(); + assert_eq!(sink_guard.configured_sink(), None); + + let empty_listener = TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)) + .expect("bind empty manifest invalidation relay fixture"); + let accept_started = Instant::now(); + let accept_error = read_manifest_invalidation_relay_payload_with_deadline(&empty_listener) + .expect_err("missing relay must time out"); + assert_eq!(accept_error.kind(), io::ErrorKind::TimedOut); + assert!(accept_started.elapsed() < Duration::from_secs(2)); + + let stalled_listener = TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)) + .expect("bind stalled manifest invalidation relay fixture"); + let stalled_stream = TcpStream::connect( + stalled_listener + .local_addr() + .expect("read stalled manifest invalidation relay fixture address"), + ) + .expect("connect stalled manifest invalidation relay fixture"); + let payload_started = Instant::now(); + let payload_error = read_manifest_invalidation_relay_payload_with_deadline(&stalled_listener) + .expect_err("incomplete relay payload must time out"); + assert_eq!(payload_error.kind(), io::ErrorKind::TimedOut); + assert!(payload_started.elapsed() < Duration::from_secs(2)); + drop(stalled_stream); +} #[test] fn gui_final_exit_is_the_only_run_event_that_requests_runner_shutdown() { @@ -143,6 +287,16 @@ fn unique_project_path() -> PathBuf { )) } +pub(crate) fn canonical_test_tempdir(prefix: &str) -> tempfile::TempDir { + let temp_root = std::env::temp_dir() + .canonicalize() + .expect("canonicalize test temp root"); + tempfile::Builder::new() + .prefix(prefix) + .tempdir_in(temp_root) + .expect("create test temp directory under canonical root") +} + fn agent_goal_sidecar_path_for_test(root: &Path, agent_id: &str, session_id: &str) -> PathBuf { let path_key = |value: &str| { format!("{:x}", Sha256::digest(value.as_bytes())) @@ -4149,6 +4303,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/project_tools.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/project_tools.rs index 4ff135ddf..141878dab 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/project_tools.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/project_tools.rs @@ -6090,3 +6090,107 @@ fn local_project_image_preview_obeys_auto_file_read_policy() { fs::remove_dir_all(root).ok(); } + +#[test] +fn local_project_resource_previews_require_registered_safe_resources() { + let root = unique_project_path(); + init_local_game_project_at(&root, "resource-preview-policy", "资源预览策略项目") + .expect("project init"); + fs::create_dir_all(root.join("assets")).expect("asset dir"); + fs::create_dir_all(root.join("game")).expect("game dir"); + fs::write(root.join("game/design.md"), "# 玩法设计\n\n安全正文").expect("project document"); + fs::write( + root.join("assets/icon.svg"), + "", + ) + .expect("svg resource"); + fs::write( + root.join("assets/bgm.mp3"), + [b'I', b'D', b'3', 4, 0, 0, 0, 0, 0, 0], + ) + .expect("audio resource"); + fs::write(root.join("game/unregistered.md"), "不应读取").expect("unregistered document"); + + let source = || GameCreationAppAssetSource { + kind: GameCreationAppAssetSourceKind::Generated, + canvas_project_id: None, + resource_id: None, + asset_object_id: None, + task_id: None, + prompt: None, + model: None, + generation_route: None, + generation_kind: None, + reference_resource_ids: Vec::new(), + }; + register_local_asset_at( + &root, + "game/design.md", + "design-document", + "text/markdown", + "generated", + source(), + ) + .expect("register document"); + register_local_asset_at( + &root, + "assets/icon.svg", + "icon", + "image/svg+xml", + "generated", + source(), + ) + .expect("register svg"); + register_local_asset_at( + &root, + "assets/bgm.mp3", + "bgm", + "audio/mpeg", + "generated", + source(), + ) + .expect("register audio"); + + let document = read_local_project_text_preview( + root.to_string_lossy().into_owned(), + "game/design.md".to_string(), + ) + .expect("read registered document"); + assert_eq!(document.media_type, "text/markdown"); + assert!(document.content.contains("安全正文")); + + let svg = read_local_project_media_preview( + root.to_string_lossy().into_owned(), + "assets/icon.svg".to_string(), + "art".to_string(), + ) + .expect("read registered svg"); + assert_eq!(svg.media_type, "image/svg+xml"); + let audio = read_local_project_media_preview( + root.to_string_lossy().into_owned(), + "assets/bgm.mp3".to_string(), + "audio".to_string(), + ) + .expect("read registered audio"); + assert_eq!(audio.media_type, "audio/mpeg"); + + let unregistered_error = read_local_project_text_preview( + root.to_string_lossy().into_owned(), + "game/unregistered.md".to_string(), + ) + .expect_err("unregistered document rejected"); + assert!(unregistered_error.contains("已登记的文档资源")); + assert!(read_local_project_text_preview( + root.to_string_lossy().into_owned(), + "../outside.md".to_string(), + ) + .is_err()); + assert!(read_local_project_media_preview( + root.to_string_lossy().into_owned(), + "assets/bgm.mp3".to_string(), + "art".to_string(), + ) + .is_err()); + + fs::remove_dir_all(root).ok(); +} 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-tauri/src/tests/runtime_state.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_state.rs index 040b2c83b..077839dc5 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_state.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_state.rs @@ -3008,11 +3008,23 @@ async fn background_task_recovers_when_assistant_message_cannot_persist() { .messages .iter() .all(|message| message.role != "assistant")); - let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("agent db"); - let audit_records = agent_db - .lines() - .filter_map(|line| serde_json::from_str::(line).ok()) - .collect::>(); + let mut audit_records = Vec::new(); + for _ in 0..100 { + let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("agent db"); + audit_records = agent_db + .lines() + .filter_map(|line| serde_json::from_str::(line).ok()) + .collect::>(); + if audit_records.iter().any(|record| { + record.get("recordType").and_then(Value::as_str) + == Some("agent.runtime.background_task.finalization_pending") + && record.get("runId").and_then(Value::as_str) + == Some("assistant-conversation-write-failure-run") + }) { + break; + } + std::thread::sleep(Duration::from_millis(20)); + } assert!(audit_records.iter().any(|record| { record.get("recordType").and_then(Value::as_str) == Some("agent.runtime.background_task.finalization_pending") diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 57ccaf972..fc097ae68 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -53,6 +53,7 @@ import type { GameCreatorAgentRuntimeUpdateEvent, GameCreatorChatAgentReply, GameCreatorLlmConfigStatus, + GameCreatorManifestInvalidatedEvent, GameCreatorRoleAgentChatStreamEvent, GenerateLocalGameDraftResult, ImportCanvasExportResult, @@ -566,6 +567,10 @@ type AppProps = { supervisorChatOnly?: boolean; gameChatOnly?: boolean; initialSupervisorMessage?: string; + onManifestChange?: ( + projectPath: string, + manifest: GameCreationAppManifest, + ) => void; onPreviewChange?: (preview: GameCreationAppPreviewState | null) => void; onAgentRuntimeSummariesChange?: ( summaries: ProjectAgentRuntimeSummary[], @@ -580,6 +585,7 @@ export function App({ supervisorChatOnly = false, gameChatOnly = false, initialSupervisorMessage = '', + onManifestChange, onPreviewChange, onAgentRuntimeSummariesChange, onAgentResultsChange, @@ -607,6 +613,16 @@ export function App({ ); const localProjectPathRef = useRef(null); localProjectPathRef.current = localProject?.projectPath ?? null; + const manifestRefreshMountedRef = useRef(true); + const manifestRefreshStatesRef = useRef( + new Map< + string, + { + pending: boolean; + inFlight: Promise | null; + } + >(), + ); const [manifest, setManifest] = useState( initialProjectManifest ?? seedManifest, ); @@ -813,6 +829,77 @@ export function App({ const projectSupervisorResponseStreamRef = useRef(null); projectSupervisorResponseStreamRef.current = projectSupervisorResponseStream; + + const refreshManifest = useCallback( + ( + nextProjectPath = localProjectPathRef.current ?? '', + ): Promise => { + const invoke = resolveTauriInvoke(); + if (!invoke || !nextProjectPath) { + return Promise.resolve(); + } + + const refreshStates = manifestRefreshStatesRef.current; + let refreshState = refreshStates.get(nextProjectPath); + if (!refreshState) { + refreshState = { pending: false, inFlight: null }; + refreshStates.set(nextProjectPath, refreshState); + } + refreshState.pending = true; + if (refreshState.inFlight) { + return refreshState.inFlight; + } + + const activeRefreshState = refreshState; + const refreshPromise = (async () => { + try { + while (activeRefreshState.pending) { + activeRefreshState.pending = false; + const projectScopeVersion = projectScopeVersionRef.current; + try { + const nextManifest = await invoke( + 'get_local_game_manifest', + { projectPath: nextProjectPath }, + ); + if ( + manifestRefreshMountedRef.current && + localProjectPathRef.current === nextProjectPath && + projectScopeVersionRef.current === projectScopeVersion + ) { + setManifest(nextManifest); + } + } catch { + // Dev-only convenience; command errors are surfaced by the action that triggered them. + } + if ( + !manifestRefreshMountedRef.current || + localProjectPathRef.current !== nextProjectPath || + projectScopeVersionRef.current !== projectScopeVersion + ) { + activeRefreshState.pending = false; + } + } + } finally { + activeRefreshState.inFlight = null; + if (!activeRefreshState.pending) { + refreshStates.delete(nextProjectPath); + } + } + })(); + activeRefreshState.inFlight = refreshPromise; + return refreshPromise; + }, + [], + ); + + useEffect(() => { + const refreshStates = manifestRefreshStatesRef.current; + manifestRefreshMountedRef.current = true; + return () => { + manifestRefreshMountedRef.current = false; + refreshStates.clear(); + }; + }, []); const projectSupervisorRuntimeSyncingRef = useRef(new Set()); const projectSupervisorRefreshConversationRef = useRef< | (( @@ -1301,6 +1388,9 @@ export function App({ if (payload.projectPath !== localProjectPathRef.current) { return; } + if (payload.manifestInvalidated) { + void refreshManifest(payload.projectPath); + } const nextRuntime = agentRuntimeStateFromResult(payload.runtime); if (gameChatOnly && payload.agentId !== PROJECT_SUPERVISOR_AGENT_ID) { appendGameChatFinalReplyMessages(payload.projectPath, [ @@ -1390,10 +1480,43 @@ export function App({ }, [ appendGameChatFinalReplyMessages, gameChatOnly, + refreshManifest, updateProjectSupervisorResponseStream, updateProjectSupervisorRuntime, ]); + useEffect(() => { + const listen = window.__TAURI__?.event?.listen; + if (!listen) { + return; + } + let cleanup: (() => void) | null = null; + let disposed = false; + void listen( + 'game-creator-manifest-invalidated', + (event) => { + if (event.payload.projectPath !== localProjectPathRef.current) { + return; + } + void refreshManifest(event.payload.projectPath); + }, + ) + .then((unlisten) => { + if (disposed) { + unlisten(); + return; + } + cleanup = unlisten; + }) + .catch(() => { + // In-process Runtime events continue to carry the same invalidation signal. + }); + return () => { + disposed = true; + cleanup?.(); + }; + }, [refreshManifest]); + useEffect(() => { const invoke = resolveTauriInvoke(); const nextProjectPath = localProject?.projectPath ?? null; @@ -9984,25 +10107,6 @@ export function App({ } } - async function refreshManifest( - nextProjectPath = resolveChatProjectPath(localProject) ?? '', - ) { - const invoke = resolveTauriInvoke(); - if (!invoke || !nextProjectPath) { - return; - } - - try { - const nextManifest = await invoke( - 'get_local_game_manifest', - { projectPath: nextProjectPath }, - ); - setManifest(nextManifest); - } catch { - // Dev-only convenience; command errors are surfaced by the action that triggered them. - } - } - async function loadAgentRunTraceFile( relativePath: string, nextProjectPath = resolveChatProjectPath(localProject) ?? '', @@ -10442,6 +10546,18 @@ export function App({ const professionalResultCandidateKey = professionalResultCandidates .map((candidate) => `${candidate.agentId}:${candidate.runtimeUpdatedAt}`) .join('|'); + useEffect(() => { + const nextProjectPath = localProject?.projectPath; + if (!projectSupervisorOnly || !nextProjectPath || !onManifestChange) { + return; + } + onManifestChange(nextProjectPath, manifest); + }, [ + localProject?.projectPath, + manifest, + onManifestChange, + projectSupervisorOnly, + ]); useEffect(() => { const invoke = resolveTauriInvoke(); const nextProjectPath = localProject?.projectPath ?? null; @@ -10813,6 +10929,7 @@ export function App({ runtimeConfigOpen={runtimeConfigOpen} runtimeError={projectSupervisorRuntimeError} transientReply={projectSupervisorTransientReply} + transientReplyUpdatedAt={projectSupervisorResponseStream?.updatedAt} hasConversationControls={projectSupervisorHasConversationControls} hiddenConversationCount={hiddenConversationCount} needsUserInput={projectSupervisorNeedsUserInput} @@ -10850,6 +10967,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..dc33606dc 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[]; @@ -426,9 +427,15 @@ export interface GameCreatorAgentRuntimeUpdateEvent { runId: string; status: string; phase: string; + manifestInvalidated: boolean; runtime: AgentRuntimeResult; } +export interface GameCreatorManifestInvalidatedEvent { + projectPath: string; + agentId: string; +} + export const gameCreatorLlmReasoningEfforts = [ 'default', 'low', 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/app-shell/WorkspaceLauncher.tsx b/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx index e9a84d703..7cd31249c 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx +++ b/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx @@ -1,4 +1,4 @@ -import { Fragment, useState } from 'react'; +import { Fragment, useCallback, useState } from 'react'; import { launcherNotifications } from '../../app/constants'; import { closeDialogOnEscape } from '../../app/dialogs'; @@ -45,6 +45,7 @@ export function WorkspaceLauncherShell({ projectPath, setProjectPath, currentProjectContext, + setCurrentProjectContext, activeProjectPreview, setActiveProjectPreview, activeProjectAgentRuntimeSummaries, @@ -55,6 +56,24 @@ export function WorkspaceLauncherShell({ createHomeDraft, openProject, } = homeProject; + const syncActiveProjectManifest = useCallback( + ( + sourceProjectPath: string, + manifest: NonNullable['manifest'], + ) => { + setCurrentProjectContext((current) => { + if ( + !current || + current.projectPath !== sourceProjectPath || + current.manifest === manifest + ) { + return current; + } + return { ...current, manifest }; + }); + }, + [setCurrentProjectContext], + ); function showLauncherNotice(title: string) { setLauncherNotice({ @@ -163,6 +182,7 @@ export function WorkspaceLauncherShell({ initialProjectPath={currentProjectContext.projectPath} initialProjectManifest={currentProjectContext.manifest} projectSupervisorOnly + onManifestChange={syncActiveProjectManifest} onPreviewChange={setActiveProjectPreview} onAgentRuntimeSummariesChange={ setActiveProjectAgentRuntimeSummaries diff --git a/apps/ai-game-creator-shell/src/features/app-shell/model.ts b/apps/ai-game-creator-shell/src/features/app-shell/model.ts index 624d2bec5..c615e387d 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/model.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/model.ts @@ -34,6 +34,10 @@ export type ProjectSupervisorComponentProps = { initialProjectPath?: string; initialProjectManifest?: GameCreationAppManifest; projectSupervisorOnly?: boolean; + onManifestChange?: ( + projectPath: string, + manifest: GameCreationAppManifest, + ) => void; onPreviewChange?: (preview: GameCreationAppPreviewState | null) => void; onAgentRuntimeSummariesChange?: ( summaries: ProjectAgentRuntimeSummary[], diff --git a/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts b/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts index 9d15570cb..055d266f0 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts @@ -430,6 +430,7 @@ export function useHomeProjectCreation({ projectPath, setProjectPath, currentProjectContext, + setCurrentProjectContext, activeProjectPreview, setActiveProjectPreview, activeProjectAgentRuntimeSummaries, 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) => (
-