diff --git a/apps/ai-game-creator-shell/scripts/agent-swarm-test-chat.mjs b/apps/ai-game-creator-shell/scripts/agent-swarm-test-chat.mjs index d10cfdcac..756e17422 100644 --- a/apps/ai-game-creator-shell/scripts/agent-swarm-test-chat.mjs +++ b/apps/ai-game-creator-shell/scripts/agent-swarm-test-chat.mjs @@ -253,12 +253,24 @@ export function defaultRuntimeConfigDirCandidates({ ), ); } else { + const configuredRoot = environment.XDG_CONFIG_HOME; + const posixAbsoluteConfiguredRoot = + configuredRoot && path.posix.isAbsolute(configuredRoot); + const hostAbsoluteConfiguredRoot = + configuredRoot && + !posixAbsoluteConfiguredRoot && + path.isAbsolute(configuredRoot); const configRoot = - environment.XDG_CONFIG_HOME && - path.posix.isAbsolute(environment.XDG_CONFIG_HOME) - ? environment.XDG_CONFIG_HOME + configuredRoot && + (posixAbsoluteConfiguredRoot || hostAbsoluteConfiguredRoot) + ? configuredRoot : path.posix.join(homeDirectory, '.config'); - pushUnique(candidates, path.posix.join(configRoot, appIdentifier)); + pushUnique( + candidates, + hostAbsoluteConfiguredRoot + ? path.join(configRoot, appIdentifier) + : path.posix.join(configRoot, appIdentifier), + ); } return candidates; } diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs index 9b1a0a318..a541abce3 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -1594,7 +1594,8 @@ for (const snippet of [ 'const GAME_CREATOR_LOCAL_CONFIG_FILE_NAME: &str = "game-creator.config.local.json"', 'const DEFAULT_GAME_CREATOR_APP_CONFIG_JSON: &str = include_str!("../../game-creator.config.json")', 'fn configure_game_creator_runtime_config_dir(', - 'app.path().app_config_dir()?', + 'game_creator_runtime_config_dir()', + '.unwrap_or_else(|| app.path().app_config_dir())?', 'fn load_game_creator_app_config()', 'fn read_game_creator_app_config()', 'fn write_game_creator_app_config(', diff --git a/apps/ai-game-creator-shell/scripts/process-session-real-e2e-fixture.mjs b/apps/ai-game-creator-shell/scripts/process-session-real-e2e-fixture.mjs index 2caf00aea..7eedae4cb 100644 --- a/apps/ai-game-creator-shell/scripts/process-session-real-e2e-fixture.mjs +++ b/apps/ai-game-creator-shell/scripts/process-session-real-e2e-fixture.mjs @@ -46,6 +46,8 @@ export function buildProcessSessionFixtureSource({ ' if (!echoed && line === challenge) {', ' echoed = true;', " console.log(echoPrefix + ' ' + challenge);", + " } else if (line === challenge + ':stop') {", + ' stop();', ' }', ' }', '});', 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 6b03ae39c..2f7026531 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 @@ -4,8 +4,9 @@ import fs from 'node:fs/promises'; import http from 'node:http'; import os from 'node:os'; import path from 'node:path'; +import { fileURLToPath } from 'node:url'; -const appRoot = path.resolve(new URL('..', import.meta.url).pathname); +const appRoot = fileURLToPath(new URL('..', import.meta.url)); const localConfigPath = path.join(appRoot, 'game-creator.config.local.json'); const projectRoot = path.join( os.tmpdir(), @@ -884,7 +885,28 @@ function readBrowserDom(url) { } function resolveChromeBin() { + const windowsRoot = path.parse(os.homedir()).root; for (const candidate of [ + path.join( + windowsRoot, + 'Program Files/Google/Chrome/Application/chrome.exe', + ), + path.join( + windowsRoot, + 'Program Files (x86)/Google/Chrome/Application/chrome.exe', + ), + path.join( + os.homedir(), + 'AppData/Local/Google/Chrome/Application/chrome.exe', + ), + path.join( + windowsRoot, + 'Program Files/Microsoft/Edge/Application/msedge.exe', + ), + path.join( + windowsRoot, + 'Program Files (x86)/Microsoft/Edge/Application/msedge.exe', + ), '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', '/Applications/Chromium.app/Contents/MacOS/Chromium', '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge', diff --git a/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs b/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs index c0bf687f5..87452d7b5 100644 --- a/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs +++ b/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs @@ -45,12 +45,16 @@ function buildTauriArguments(argv, devUrl = readAgcDevEndpoint().url) { if (separatorIndex < 0) { return ['dev', ...args, '--config', configOverride]; } + const separatedArguments = args.slice(separatorIndex); + if (separatedArguments[1] !== '--') { + separatedArguments.unshift('--'); + } return [ 'dev', ...args.slice(0, separatorIndex), '--config', configOverride, - ...args.slice(separatorIndex), + ...separatedArguments, ]; } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent.rs b/apps/ai-game-creator-shell/src-tauri/src/agent.rs index 7354d0283..8bda8dd61 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -22,6 +22,9 @@ mod runtime_state; mod runtime_tools; use codex_app_server::*; use codex_cli::*; +pub(crate) use codex_cli::{ + game_creator_codex_cli_executable_path, game_creator_codex_cli_version_identity, +}; pub(crate) use generation::*; pub(crate) use interaction::*; pub(crate) use prompt::*; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs index 08348c123..ede836cf6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs @@ -7,7 +7,6 @@ use std::sync::{Arc, OnceLock, Weak}; use tokio::io::{AsyncBufRead, AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; use tokio::sync::{mpsc, oneshot, Mutex}; -const GAME_CREATOR_CODEX_APP_SERVER_EXECUTABLE: &str = "codex"; const GAME_CREATOR_CODEX_APP_SERVER_PROVIDER_ID: &str = "genarrative_agc"; const GAME_CREATOR_CODEX_APP_SERVER_API_KEY_ENV: &str = "GENARRATIVE_AGC_CODEX_API_KEY"; const GAME_CREATOR_CODEX_APP_SERVER_PROTOCOL: &str = "genarrative-codex-app-server.v2"; @@ -482,10 +481,8 @@ fn configure_game_creator_codex_app_server_command( "plugins", "remote_plugin", "shell_tool", - "skill_search", "tool_suggest", "unified_exec", - "view_image", "workspace_dependencies", ] { command.arg("--disable").arg(feature); @@ -608,12 +605,9 @@ impl CodexAppServerConnection { llm: &GameCreatorLlmConfig, credential: &CodexAppServerCredential, ) -> Result { - Self::spawn_with_executable_and_credential( - llm, - credential, - std::ffi::OsStr::new(GAME_CREATOR_CODEX_APP_SERVER_EXECUTABLE), - ) - .await + let executable = game_creator_codex_cli_executable_path() + .map_err(platform_llm::LlmError::InvalidConfig)?; + Self::spawn_with_executable_and_credential(llm, credential, executable.as_os_str()).await } async fn spawn_with_executable( @@ -1748,6 +1742,41 @@ mod tests { assert!(game_creator_codex_app_server_validate_llm_config(&llm).is_err()); } + #[test] + fn codex_app_server_command_uses_only_current_cli_feature_flags() { + let mut command = tokio::process::Command::new("codex"); + configure_game_creator_codex_app_server_command(&mut command, &test_llm()) + .expect("configure app-server command"); + let arguments = command + .as_std() + .get_args() + .map(|argument| argument.to_string_lossy().into_owned()) + .collect::>(); + assert!(arguments + .windows(2) + .any(|pair| pair == ["--disable", "shell_tool"])); + assert!(!arguments.iter().any(|argument| argument == "skill_search")); + assert!(!arguments.iter().any(|argument| argument == "view_image")); + } + + #[cfg(windows)] + #[test] + fn codex_app_server_current_cli_accepts_configured_arguments() { + let executable = game_creator_codex_cli_executable_path().expect("Codex CLI executable"); + let mut command = std::process::Command::new(executable); + let mut configured = tokio::process::Command::new("codex"); + configure_game_creator_codex_app_server_command(&mut configured, &test_llm()) + .expect("configure app-server command"); + command.args(configured.as_std().get_args()); + command.arg("--help").stdin(Stdio::null()); + let output = command.output().expect("run Codex app-server help"); + assert!( + output.status.success(), + "configured app-server arguments must be accepted: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + #[test] fn codex_app_server_pool_key_isolated_by_credentials_and_route() { let mut base = test_llm(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_cli.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_cli.rs index d26107510..d0c2877d2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_cli.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_cli.rs @@ -1,4 +1,5 @@ use super::*; +use std::path::{Path, PathBuf}; use std::process::Stdio; use sha2::{Digest, Sha256}; @@ -9,29 +10,128 @@ const GAME_CREATOR_CODEX_CLI_PROMPT_MAX_BYTES: usize = 4 * 1024 * 1024; const GAME_CREATOR_CODEX_CLI_STDOUT_MAX_BYTES: usize = 4 * 1024 * 1024; const GAME_CREATOR_CODEX_CLI_STDERR_MAX_BYTES: usize = 256 * 1024; +fn game_creator_codex_cli_executable_candidates_for( + app_data: Option<&Path>, + local_app_data: Option<&Path>, + runtime_config_dir: Option<&Path>, + path: Option<&std::ffi::OsStr>, +) -> Vec { + let mut candidates = Vec::new(); + #[cfg(windows)] + { + fn append_native_npm_candidates(candidates: &mut Vec, npm_root: &Path) { + let vendor_root = npm_root + .join("node_modules") + .join("@openai") + .join("codex") + .join("node_modules") + .join("@openai") + .join("codex-win32-x64") + .join("vendor"); + if let Ok(entries) = std::fs::read_dir(vendor_root) { + let mut targets = entries + .filter_map(Result::ok) + .map(|entry| entry.path().join("bin").join("codex.exe")) + .collect::>(); + targets.sort(); + candidates.extend(targets); + } + } + + fn append_desktop_codex_candidates(candidates: &mut Vec, local_app_data: &Path) { + let bin_root = local_app_data.join("OpenAI").join("Codex").join("bin"); + if let Ok(entries) = std::fs::read_dir(bin_root) { + let mut targets = entries + .filter_map(Result::ok) + .map(|entry| entry.path().join("codex.exe")) + .collect::>(); + targets.sort(); + targets.reverse(); + candidates.extend(targets); + } + } + + if let Some(app_data) = app_data { + append_native_npm_candidates(&mut candidates, &app_data.join("npm")); + } + if let Some(local_app_data) = local_app_data { + append_desktop_codex_candidates(&mut candidates, local_app_data); + } + if let Some(app_data) = runtime_config_dir.and_then(Path::parent) { + append_native_npm_candidates(&mut candidates, &app_data.join("npm")); + if let Some(user_profile) = app_data.parent() { + append_desktop_codex_candidates(&mut candidates, &user_profile.join("Local")); + } + } + if let Some(path) = path { + for entry in std::env::split_paths(&path) { + append_native_npm_candidates(&mut candidates, &entry); + candidates.push(entry.join("codex.exe")); + } + } + } + candidates.push(PathBuf::from(GAME_CREATOR_CODEX_CLI_EXECUTABLE)); + candidates +} + +fn game_creator_codex_cli_executable_candidates() -> Vec { + game_creator_codex_cli_executable_candidates_for( + std::env::var_os("APPDATA").as_deref().map(Path::new), + std::env::var_os("LOCALAPPDATA").as_deref().map(Path::new), + game_creator_runtime_config_dir().as_deref(), + std::env::var_os("PATH").as_deref(), + ) +} + +fn game_creator_codex_cli_version_at(executable: &Path) -> Result { + let output = std::process::Command::new(executable) + .arg("--version") + .stdin(Stdio::null()) + .stderr(Stdio::null()) + .output() + .map_err(|error| error.to_string())?; + if !output.status.success() { + return Err(format!("版本检查退出状态为 {}", output.status)); + } + let version = std::str::from_utf8(&output.stdout) + .map_err(|_| "版本信息不是 UTF-8".to_string())? + .trim(); + if !version.starts_with("codex-cli ") || version.len() > 120 { + return Err("返回了无法识别的版本信息".to_string()); + } + Ok(version.to_string()) +} + +pub(crate) fn game_creator_codex_cli_executable_path() -> Result { + let mut last_error = None; + let mut seen = std::collections::HashSet::new(); + for candidate in game_creator_codex_cli_executable_candidates() { + let identity = candidate.to_string_lossy().to_ascii_lowercase(); + if !seen.insert(identity) { + continue; + } + match game_creator_codex_cli_version_at(&candidate) { + Ok(_) => return Ok(candidate), + Err(error) => last_error = Some(error), + } + } + Err(format!( + "Codex CLI 未安装或当前 Agent Runner 无法启动;已检查 PATH 和 npm 全局安装目录{}", + last_error + .map(|error| format!("(最后错误:{error})")) + .unwrap_or_default() + )) +} + struct CodexCliStderrSummary { byte_len: usize, sha256: String, classification: &'static str, } -pub(in crate::agent) fn game_creator_codex_cli_version_identity() -> Result { - let output = std::process::Command::new(GAME_CREATOR_CODEX_CLI_EXECUTABLE) - .arg("--version") - .stdin(Stdio::null()) - .stderr(Stdio::null()) - .output() - .map_err(|_| "Codex CLI 未安装或不在当前 Agent Runner PATH 中".to_string())?; - if !output.status.success() { - return Err("Codex CLI 版本检查失败".to_string()); - } - let version = std::str::from_utf8(&output.stdout) - .map_err(|_| "Codex CLI 版本信息不是 UTF-8".to_string())? - .trim(); - if !version.starts_with("codex-cli ") || version.len() > 120 { - return Err("Codex CLI 返回了无法识别的版本信息".to_string()); - } - Ok(version.to_string()) +pub(crate) fn game_creator_codex_cli_version_identity() -> Result { + let executable = game_creator_codex_cli_executable_path()?; + game_creator_codex_cli_version_at(&executable) } pub(in crate::agent) fn game_creator_codex_cli_reasoning_effort( @@ -546,11 +646,9 @@ async fn request_game_creator_agent_codex_cli_with_executable( pub(in crate::agent) async fn request_game_creator_agent_codex_cli( request: LlmRunRequest, ) -> Result { - request_game_creator_agent_codex_cli_with_executable( - std::ffi::OsStr::new(GAME_CREATOR_CODEX_CLI_EXECUTABLE), - request, - ) - .await + let executable = + game_creator_codex_cli_executable_path().map_err(platform_llm::LlmError::InvalidConfig)?; + request_game_creator_agent_codex_cli_with_executable(executable.as_os_str(), request).await } #[cfg(test)] @@ -567,6 +665,103 @@ mod tests { ]) } + #[cfg(windows)] + #[test] + fn codex_cli_candidates_prefer_sorted_native_npm_targets_before_path() { + let temp = tempfile::tempdir().expect("temp dir"); + let app_data = temp.path().join("app-data"); + let vendor = app_data + .join("npm/node_modules/@openai/codex/node_modules/@openai/codex-win32-x64/vendor"); + std::fs::create_dir_all(vendor.join("z-target/bin")).expect("z target"); + std::fs::create_dir_all(vendor.join("a-target/bin")).expect("a target"); + let path_dir = temp.path().join("path"); + std::fs::create_dir_all(&path_dir).expect("path dir"); + + let candidates = game_creator_codex_cli_executable_candidates_for( + Some(&app_data), + None, + None, + Some(path_dir.as_os_str()), + ); + assert_eq!( + candidates[0], + vendor.join("a-target/bin/codex.exe"), + "native npm targets must be deterministic and precede PATH" + ); + assert_eq!(candidates[1], vendor.join("z-target/bin/codex.exe")); + assert_eq!(candidates[2], path_dir.join("codex.exe")); + assert_eq!(candidates.last(), Some(&PathBuf::from("codex"))); + } + + #[cfg(windows)] + #[test] + fn codex_cli_candidates_discover_native_npm_target_from_path_without_appdata() { + let temp = tempfile::tempdir().expect("temp dir"); + let npm_root = temp.path().join("npm"); + let native = npm_root + .join("node_modules/@openai/codex/node_modules/@openai/codex-win32-x64/vendor") + .join("x86_64-pc-windows-msvc/bin/codex.exe"); + std::fs::create_dir_all(native.parent().expect("native parent")) + .expect("native target directory"); + + let candidates = game_creator_codex_cli_executable_candidates_for( + None, + None, + None, + Some(npm_root.as_os_str()), + ); + assert_eq!(candidates[0], native); + assert_eq!(candidates[1], npm_root.join("codex.exe")); + } + + #[cfg(windows)] + #[test] + fn codex_cli_candidates_discover_native_npm_target_from_runtime_config_dir() { + let temp = tempfile::tempdir().expect("temp dir"); + let app_data = temp.path().join("roaming"); + let config_dir = app_data.join("world.genarrative.ai-game-creator"); + let native = app_data + .join("npm/node_modules/@openai/codex/node_modules/@openai/codex-win32-x64/vendor") + .join("x86_64-pc-windows-msvc/bin/codex.exe"); + std::fs::create_dir_all(native.parent().expect("native parent")) + .expect("native target directory"); + + let candidates = + game_creator_codex_cli_executable_candidates_for(None, None, Some(&config_dir), None); + assert_eq!(candidates[0], native); + } + + #[cfg(windows)] + #[test] + fn codex_cli_candidates_discover_desktop_native_target() { + let temp = tempfile::tempdir().expect("temp dir"); + let local_app_data = temp.path().join("local"); + let older = local_app_data.join("OpenAI/Codex/bin/111/codex.exe"); + let newer = local_app_data.join("OpenAI/Codex/bin/222/codex.exe"); + std::fs::create_dir_all(older.parent().expect("older parent")).expect("older dir"); + std::fs::create_dir_all(newer.parent().expect("newer parent")).expect("newer dir"); + + let candidates = game_creator_codex_cli_executable_candidates_for( + None, + Some(&local_app_data), + None, + None, + ); + assert_eq!(candidates[0], newer); + assert_eq!(candidates[1], older); + } + + #[cfg(windows)] + #[test] + fn codex_cli_resolver_finds_current_native_install() { + let executable = game_creator_codex_cli_executable_path().expect("Codex CLI executable"); + assert!(executable.is_absolute()); + assert_eq!( + game_creator_codex_cli_version_identity().expect("Codex CLI version"), + game_creator_codex_cli_version_at(&executable).expect("same executable version") + ); + } + #[test] fn codex_cli_mode_renders_runtime_messages_and_structured_tool_contract() { let prompt = render_game_creator_codex_cli_prompt(&tool_request()).expect("render prompt"); 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 8c1375ad2..0fe8bab76 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 @@ -2265,7 +2265,19 @@ struct TrustedPlatformArtTransactionDirectory { impl TrustedPlatformArtTransactionDirectory { fn open_anchored(root: &Path, path: &Path) -> Result { - let parent = TrustedPlatformArtRecoveryParent::open(root, path, false)?; + // The transaction leaf is a directory. Anchor and validate its parent using a + // missing sibling path so the regular-file recovery preflight does not reject the + // directory itself before the dedicated directory validation below. + let anchor_target = path.with_file_name(".art-spritesheet-contract-transaction.anchor"); + let parent = TrustedPlatformArtRecoveryParent::open(root, &anchor_target, false)?; + #[cfg(unix)] + let parent = TrustedPlatformArtRecoveryParent { + leaf: path + .file_name() + .ok_or_else(|| "平台图集事务目录缺少叶子文件名".to_string())? + .to_os_string(), + ..parent + }; #[cfg(unix)] { use std::os::unix::ffi::OsStrExt; @@ -3658,11 +3670,14 @@ fn sync_strict_platform_art_contract_state_at( path.display() )); } - Ok(_) => fs::File::open(&path) - .and_then(|file| file.sync_all()) - .map_err(|error| { - format!("同步平台图集合同文件失败:{}: {error}", path.display()) - })?, + Ok(_) => { + #[cfg(unix)] + fs::File::open(&path) + .and_then(|file| file.sync_all()) + .map_err(|error| { + format!("同步平台图集合同文件失败:{}: {error}", path.display()) + })?; + } Err(error) if error.kind() == std::io::ErrorKind::NotFound && !require_complete => {} Err(error) if error.kind() == std::io::ErrorKind::NotFound => { return Err(format!("平台图集提交缺少完整合同文件:{}", path.display())); @@ -4698,6 +4713,7 @@ impl PlatformArtSliceContractRollback { &journal, "平台图集事务 journal", )?; + #[cfg(unix)] trusted_transaction_directory .handle .sync_all() @@ -4794,6 +4810,7 @@ impl PlatformArtSliceContractRollback { )); } } + #[cfg(unix)] trusted_transaction_directory.handle.sync_all().map_err(|error| { format!( "{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} 平台图集合同已提交,但同步 prepared marker 清理失败:{error}" @@ -7852,6 +7869,7 @@ mod canvas_generation_tests { drop(project_lock); } + #[cfg(unix)] #[test] fn durable_strict_contract_transaction_rejects_same_length_snapshot_rewrite_during_read() { let temporary = tempfile::tempdir().expect("create concurrent snapshot project"); @@ -8322,6 +8340,7 @@ mod canvas_generation_tests { assert!(!root.join(STRICT_PLATFORM_ART_TRANSACTION_PATH).exists()); } + #[cfg(unix)] #[test] fn durable_strict_contract_transaction_rejects_replaced_transaction_directory() { let temporary = tempfile::tempdir().expect("create replaced transaction directory fixture"); @@ -8689,6 +8708,7 @@ mod canvas_generation_tests { .expect("open sparse main sheet"); main.set_len(STRICT_PLATFORM_ART_TRANSACTION_MAX_SNAPSHOT_BYTES + 1) .expect("create oversized sparse main sheet"); + drop(main); let error = match PlatformArtSliceContractRollback::capture(root, "oversized-snapshot") { Ok(_) => panic!("oversized sparse snapshot must fail before an unbounded read"), diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/run_lifecycle.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/run_lifecycle.rs index d70ede095..697fd2c81 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/run_lifecycle.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/run_lifecycle.rs @@ -93,9 +93,10 @@ pub(crate) async fn control_agent_run_at( let prompt = resumed_agent_run_prompt(&previous_trace.goal, action, detail); let generated = generate_local_game_draft_at(root, &prompt, progress).await?; let trace = read_latest_agent_run_trace(root)?; + let game_index_path = generated.game_index_path.replace('\\', "/"); let message = format!( "{},已重新运行为 {}:{}", - control_result.message, trace.run_id, generated.game_index_path + control_result.message, trace.run_id, game_index_path ); let event = if action == "retry" { "agent.retry.run" 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 4e6d8528d..731d40ef0 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 @@ -214,7 +214,9 @@ pub(in crate::agent) fn validate_root_goal_contract_control_plan_at( || !plan.plan.is_empty() || !plan.response.trim().is_empty() { - return Err("根 Project Supervisor 必须先把自己对当前用户最终意图的理解作为本轮唯一动作提交 agent.goal_contract;固定规则只提供上下文,不能先调度、委派、修改项目或回复完成".to_string()); + return Err(format!( + "{AGENT_RUNTIME_ROOT_GOAL_CONTRACT_REQUIRED_ERROR_PREFIX};固定规则只提供上下文,不能先调度、委派、修改项目或回复完成" + )); } return Ok(()); } @@ -236,6 +238,29 @@ pub(in crate::agent) fn validate_root_goal_contract_control_plan_at( Ok(()) } +pub(super) const AGENT_RUNTIME_ROOT_GOAL_CONTRACT_REQUIRED_ERROR_PREFIX: &str = + "根 Project Supervisor 必须先把自己对当前用户最终意图的理解作为本轮唯一动作提交 agent.goal_contract"; + +pub(in crate::agent) fn restrict_agent_runtime_root_goal_contract_tools( + request: &mut LlmRunRequest, +) -> Result<(), String> { + let goal_contract_function = native_runtime_function_name("agent.goal_contract") + .ok_or_else(|| "无法生成根 Goal Contract 工具函数名".to_string())?; + request + .function_tools + .retain(|tool| tool.name == goal_contract_function); + if request.function_tools.len() != 1 { + return Err("根 Goal Contract 工具目录缺少 agent.goal_contract".to_string()); + } + request.max_output_tokens = Some( + request + .max_output_tokens + .unwrap_or(AGENT_RUNTIME_AUTONOMOUS_FORCED_ACTION_MAX_OUTPUT_TOKENS) + .min(AGENT_RUNTIME_AUTONOMOUS_FORCED_ACTION_MAX_OUTPUT_TOKENS), + ); + Ok(()) +} + pub(super) const AGENT_RUNTIME_AUTONOMOUS_SUPERVISOR_DELIVERY_CONVERGENCE_LIVENESS_ERROR_PREFIX: &str = "自主构建 Project Supervisor 必须先收束已有专业 Agent 委派"; pub(super) const AGENT_RUNTIME_AUTONOMOUS_PREVIEW_AFTER_STATIC_LIVENESS_ERROR_PREFIX: &str = @@ -1630,6 +1655,35 @@ pub(in crate::agent) fn agent_runtime_protocol_error_requires_supervisor_collabo mod tests { use super::*; + #[test] + fn root_goal_contract_repair_catalog_contains_only_goal_contract() { + let catalog = GameCreatorMcpCatalog { + fingerprint: String::new(), + servers: Vec::new(), + tools: Vec::new(), + }; + let mut request = LlmRunRequest::new(Vec::new()) + .with_function_tools( + build_agent_runtime_native_function_tools(&catalog) + .expect("build native function tools"), + ) + .with_tool_choice(platform_llm::LlmToolChoice::Required); + + restrict_agent_runtime_root_goal_contract_tools(&mut request) + .expect("restrict root Goal Contract tools"); + + assert_eq!(request.function_tools.len(), 1); + assert_eq!( + request.function_tools[0].name, + native_runtime_function_name("agent.goal_contract") + .expect("goal contract function name") + ); + assert_eq!( + request.tool_choice, + Some(platform_llm::LlmToolChoice::Required) + ); + } + fn autonomous_initial_delegate( agent_id: &str, expected_artifacts: &[&str], 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 f3a72dfd6..0ed74be4c 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 @@ -105,6 +105,8 @@ pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request( let root_goal_contract_context = render_game_creator_agent_runtime_goal_contract_for_prompt_at(root, agent_id, run_id)? .unwrap_or_else(|| "null".to_string()); + let root_goal_contract_required = + root_control_authority && root_goal_contract_context == "null"; let acceptance_graph_context = render_game_creator_agent_runtime_acceptance_graph_for_prompt_at(root, agent_id, run_id)? .unwrap_or_else(|| "null".to_string()); @@ -328,8 +330,10 @@ pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request( .any(is_agent_runtime_project_mutation_observation) }); if autonomous_game_build && plan_rejection_needs_repair { - let supervisor_orchestrator_repair = if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { - let policy = resolve_supervisor_collaboration_policy_for_run_at(root, agent_id, run_id)?.policy; + let supervisor_orchestrator_repair = if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID + { + let policy = + resolve_supervisor_collaboration_policy_for_run_at(root, agent_id, run_id)?.policy; let state = read_supervisor_collaboration_state_at(root, agent_id, run_id)?; policy.orchestrator_only_after_delegation && state.has_collaboration() } else { @@ -338,15 +342,25 @@ pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request( let repair_tools: &[&str] = if supervisor_orchestrator_repair { &["agent.delegate", "agent.run_status"] } else { - &["file.write", "file.patch", "file.delete", "project.patchset", "project.restore", "canvas.asset_generate"] + &[ + "file.write", + "file.patch", + "file.delete", + "project.patchset", + "project.restore", + "canvas.asset_generate", + ] }; - let mut allowed_function_names = BTreeSet::from([AGENT_RUNTIME_RESPOND_FUNCTION_NAME.to_string()]); + let mut allowed_function_names = + BTreeSet::from([AGENT_RUNTIME_RESPOND_FUNCTION_NAME.to_string()]); for tool in repair_tools { if let Some(name) = native_runtime_function_name(tool) { allowed_function_names.insert(name); } } - request.function_tools.retain(|tool| allowed_function_names.contains(&tool.name)); + request + .function_tools + .retain(|tool| allowed_function_names.contains(&tool.name)); request.messages.push(LlmMessage::user(if supervisor_orchestrator_repair { "上一轮 runtime.plan_update 被拒绝。本轮 Supervisor 已进入协作编排模式,只能调用 agent.run_status 或 agent.delegate 继续收束,或在证据足够时 respond_to_user;禁止再次规划、读取、搜索、验证或直接修改项目。" } else { @@ -367,6 +381,12 @@ pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request( .function_tools .retain(|tool| tool.name != project_verify_function); } + if root_goal_contract_required { + restrict_agent_runtime_root_goal_contract_tools(&mut request)?; + request.messages.push(LlmMessage::user( + "当前根 Run 尚未冻结 Goal Contract。本轮唯一可用工具是 agent.goal_contract;必须且只能调用一次,用 outcome 具体概括当前用户最终意图,acceptanceNodes 至少提交一项可核对标准。每个 requiredEvidence 必须选择在该标准所有合法结果下都能成功产生回执的工具;环境探测可能以 rejected/failed 表示正常否定结果时,不得把该探测工具写成必需成功回执(例如非 Git 项目不得要求 git.inspect 成功,应使用 project.index 的成功回执证明 isRepository=false)。nonNegotiables、preferences、forbiddenAssumptions、openQuestions 没有内容时传空数组。不得调用 update_agent_plan、respond_to_user 或任何其他动作,不得输出普通文本。", + )); + } request = apply_game_creator_llm_web_search( apply_game_creator_llm_reasoning_effort(request, &llm)?, &llm, @@ -567,15 +587,14 @@ mod tests { game_creator_project_supervisor_chat_system_prompt, init_local_game_project_at, provider_command_exec_contract, provider_command_start_contract, required_runtime_prompt_section, resolve_agent_conversation_session_id_at, - start_game_creator_agent_runtime_task_at, AgentRuntimeTaskLink, AgentRuntimeToolObservation, - AgentRuntimeToolPlan, - GameCreatorMcpCatalog, GameCreatorMcpCatalogTool, - AGENT_RUNTIME_COMPLETION_BLOCKER_TOOL_PLAN_PROTOCOL, - AGENT_RUNTIME_RESPOND_FUNCTION_NAME, + start_game_creator_agent_runtime_task_at, AgentRuntimeGoalContractAcceptanceNodeDraft, + AgentRuntimeGoalContractDraft, AgentRuntimeTaskLink, AgentRuntimeToolObservation, + AgentRuntimeToolPlan, GameCreatorMcpCatalog, GameCreatorMcpCatalogTool, + AGENT_RUNTIME_COMPLETION_BLOCKER_TOOL_PLAN_PROTOCOL, AGENT_RUNTIME_RESPOND_FUNCTION_NAME, AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD, AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, - AGENT_RUNTIME_UPDATE_PLAN_FUNCTION_NAME, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, RUNTIME_PROMPT_SUPERVISOR_CHAT_COMPOSITION, + AGENT_RUNTIME_UPDATE_PLAN_FUNCTION_NAME, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + RUNTIME_PROMPT_SUPERVISOR_CHAT_COMPOSITION, }; fn native_input_required_fields( @@ -621,6 +640,27 @@ mod tests { vec!["立即修改 game/index.html".to_string()], ) .expect("start task"); + crate::agent::create_game_creator_agent_runtime_goal_contract_at( + &root, + &binding.agent_id, + &binding.run_id, + &state.current_task, + &AgentRuntimeGoalContractDraft { + outcome: "修复现有游戏".to_string(), + non_negotiables: Vec::new(), + preferences: Vec::new(), + forbidden_assumptions: Vec::new(), + open_questions: Vec::new(), + acceptance_nodes: vec![AgentRuntimeGoalContractAcceptanceNodeDraft { + criterion_id: "repair-game".to_string(), + criterion: "完成项目修改".to_string(), + required: true, + required_evidence: vec!["file.patch".to_string()], + dependencies: Vec::new(), + }], + }, + ) + .expect("create goal contract"); let catalog = GameCreatorMcpCatalog { fingerprint: String::new(), servers: Vec::new(), @@ -772,7 +812,7 @@ mod tests { } #[test] - fn trusted_root_supervisor_receives_dynamic_goal_control_tools() { + fn trusted_root_supervisor_first_turn_only_receives_goal_contract_tool() { let directory = crate::tests::canonical_test_tempdir("provider-goal-control-"); let root = directory.path().join("project"); init_local_game_project_at(&root, "goal-control-project", "完成可验证游戏") @@ -816,6 +856,7 @@ mod tests { assert!(prompt.contains("动态目标协议:agent.goal_contract")); assert!(prompt.contains("固定规则、关键词、资产探测和专家建议只能作为上下文")); assert!(prompt.contains("未提交的 passed 节点保持不变")); + assert_eq!(request.function_tools.len(), 1); assert_eq!( native_input_required_fields(&request, "agent.goal_contract"), [ @@ -827,10 +868,9 @@ mod tests { "acceptanceNodes" ] ); - assert_eq!( - native_input_required_fields(&request, "agent.acceptance_update"), - ["contractFingerprint", "evaluations"] - ); + assert!(request.messages.iter().any(|message| message + .content + .contains("本轮唯一可用工具是 agent.goal_contract"))); } #[test] diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_tool_plan.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_tool_plan.rs index 50719d29b..3754f1f80 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_tool_plan.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_tool_plan.rs @@ -856,11 +856,14 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at && protocol_error .starts_with(AGENT_RUNTIME_AUTONOMOUS_TRUNCATED_SCAFFOLD_ERROR_PREFIX) && !request.function_tools.is_empty(); + let force_root_goal_contract = protocol_error + .starts_with(AGENT_RUNTIME_ROOT_GOAL_CONTRACT_REQUIRED_ERROR_PREFIX); let force_supervisor_initial_collaboration = agent_runtime_protocol_error_requires_supervisor_collaboration_repair( &protocol_error, ) && !request.function_tools.is_empty(); - if force_supervisor_initial_collaboration + if force_root_goal_contract + || force_supervisor_initial_collaboration || force_autonomous_specialist_mutation_only || force_autonomous_specialist_verification_only || force_autonomous_response_plan_completion @@ -887,7 +890,12 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at .retain(|tool| tool.name != project_verify_function); } } - if force_supervisor_initial_collaboration { + if force_root_goal_contract { + restrict_agent_runtime_root_goal_contract_tools(&mut request)?; + request.messages.push(LlmMessage::user(format!( + "上一条输出不符合工具计划协议:{protocol_error}\n当前根 Run 尚未冻结 Goal Contract。本次修复的原生工具目录只保留 agent.goal_contract;必须且只能调用一次,用 outcome 具体概括当前用户最终意图,acceptanceNodes 至少提交一项可核对标准。每个 requiredEvidence 必须选择在该标准所有合法结果下都能成功产生回执的工具;环境探测可能以 rejected/failed 表示正常否定结果时,不得把该探测工具写成必需成功回执(例如非 Git 项目不得要求 git.inspect 成功,应使用 project.index 的成功回执证明 isRepository=false)。nonNegotiables、preferences、forbiddenAssumptions、openQuestions 没有内容时传空数组。不得调用 update_agent_plan、respond_to_user 或任何其他动作,不得输出普通文本、解释、markdown 或代码围栏。" + ))); + } else if force_supervisor_initial_collaboration { supervisor_collaboration_repair_active = true; if let Some(actions) = supervisor_collaboration_candidate_actions.take() { supervisor_collaboration_repair_actions = 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 97a320ab4..b979bc23c 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 @@ -301,8 +301,7 @@ pub(crate) use provider_recovery::{ #[cfg(test)] pub(crate) use provider_recovery::{ drive_waiting_autonomous_manifest_parent_wake_budget_for_test, - ensure_static_delegate_user_input_wait_at, - ensure_waiting_provider_retry_records_for_test, + ensure_static_delegate_user_input_wait_at, 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, 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 9832033f8..e02eacbd6 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 @@ -387,12 +387,13 @@ fn game_chat_main_without_asset_audit_fixture(root: &Path) -> String { async fn game_chat_main_agent_delegates_only_real_missing_art_and_limits_children_to_assets() { let temporary = tempfile::tempdir().expect("create game-chat art child root"); let root = temporary.path().join("project"); - let (_main, mut child, _delegation_id, _child_lane) = game_chat_main_art_child_fixture_with_lane( - &root, - "art-asset-plan", - &["core-spritesheet"], - true, - ); + let (_main, mut child, _delegation_id, _child_lane) = + game_chat_main_art_child_fixture_with_lane( + &root, + "art-asset-plan", + &["core-spritesheet"], + true, + ); assert_eq!(child.agent_id, "art-asset-plan"); assert_eq!(child.source, "agent-delegate"); assert_eq!(child.parent_agent_id.as_deref(), Some("code-prototype")); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/acceptance_graph.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/acceptance_graph.rs index 4abdc0db3..e59e42a88 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/acceptance_graph.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/acceptance_graph.rs @@ -1350,6 +1350,50 @@ mod tests { &contract_plan, ) .expect("sole goal contract action is allowed"); + let contract_with_extra_action = AgentRuntimeToolPlan { + actions: vec![action("agent.goal_contract"), action("file.list")], + ..AgentRuntimeToolPlan::default() + }; + assert!(validate_root_goal_contract_control_plan_at( + &root, + &binding.agent_id, + &binding.run_id, + &contract_with_extra_action, + ) + .expect_err("extra action before Goal Contract must fail") + .contains("必须先")); + let contract_with_plan_update = AgentRuntimeToolPlan { + actions: vec![action("agent.goal_contract")], + plan_update: Some(AgentRuntimePlanUpdate { + explanation: "不应与合同同轮".to_string(), + steps: vec![AgentRuntimePlanUpdateStep { + step: "不应先规划".to_string(), + status: "in_progress".to_string(), + }], + }), + ..AgentRuntimeToolPlan::default() + }; + assert!(validate_root_goal_contract_control_plan_at( + &root, + &binding.agent_id, + &binding.run_id, + &contract_with_plan_update, + ) + .expect_err("plan update before Goal Contract must fail") + .contains("必须先")); + let contract_with_response = AgentRuntimeToolPlan { + actions: vec![action("agent.goal_contract")], + response: "不应先回复".to_string(), + ..AgentRuntimeToolPlan::default() + }; + assert!(validate_root_goal_contract_control_plan_at( + &root, + &binding.agent_id, + &binding.run_id, + &contract_with_response, + ) + .expect_err("response before Goal Contract must fail") + .contains("必须先")); let contract_with_legacy_plan = AgentRuntimeToolPlan { actions: vec![action("agent.goal_contract")], plan: vec!["先执行旧式计划".to_string()], 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 8ca059907..52aa4a66d 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 @@ -2336,12 +2336,18 @@ pub(super) fn try_open_game_creator_agent_runtime_task_lock_file( if let Some(component) = component { current.push(component); if !current.exists() { - fs::create_dir(¤t).map_err(|error| { - format!( - "创建 Agent Runtime 锁目录失败:{}: {error}", - current.display() - ) - })?; + if let Err(error) = fs::create_dir(¤t) { + // 另一并发锁请求可能在 exists 与 create_dir 之间创建同一目录; + // 下方元数据检查仍是权威校验,并会拒绝普通文件或 reparse point。 + if error.kind() != std::io::ErrorKind::AlreadyExists + && error.raw_os_error() != Some(183) + { + return Err(format!( + "创建 Agent Runtime 锁目录失败:{}: {error}", + current.display() + )); + } + } } } let metadata = fs::symlink_metadata(¤t).map_err(|error| { @@ -4129,6 +4135,10 @@ pub(super) fn redact_agent_runtime_project_paths_raw(root: &Path, value: &str) - let root_display = root.to_string_lossy(); if !root_display.is_empty() { redacted = redacted.replace(root_display.as_ref(), "$PROJECT_ROOT"); + #[cfg(windows)] + if let Some(non_verbatim_root) = root_display.strip_prefix(r"\\?\") { + redacted = redacted.replace(non_verbatim_root, "$PROJECT_ROOT"); + } } if let Ok(canonical_root) = root.canonicalize() { let canonical_display = canonical_root.to_string_lossy(); @@ -4148,6 +4158,10 @@ pub(super) fn redact_agent_runtime_project_paths_preserving_tail( let root_display = root.to_string_lossy(); if !root_display.is_empty() { redacted = redacted.replace(root_display.as_ref(), "$PROJECT_ROOT"); + #[cfg(windows)] + if let Some(non_verbatim_root) = root_display.strip_prefix(r"\\?\") { + redacted = redacted.replace(non_verbatim_root, "$PROJECT_ROOT"); + } } if let Ok(canonical_root) = root.canonicalize() { let canonical_display = canonical_root.to_string_lossy(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/browser/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/browser/tests.rs index 309a9456b..086f410d2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/browser/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/browser/tests.rs @@ -842,7 +842,7 @@ fn result_serializes_with_camel_case_evidence_paths() { #[test] fn persisted_report_uses_only_relative_evidence_paths() { - let evidence_root = PathBuf::from("/tmp/browser-evidence"); + let evidence_root = std::env::temp_dir().join("browser-evidence"); let result = BrowserValidationResult { schema_version: RESULT_SCHEMA_VERSION.to_string(), url: "http://127.0.0.1:34567/".to_string(), diff --git a/apps/ai-game-creator-shell/src-tauri/src/cli.rs b/apps/ai-game-creator-shell/src-tauri/src/cli.rs index eec0c244f..076a4f0aa 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/cli.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/cli.rs @@ -807,6 +807,26 @@ fn strip_agent_runtime_cli_private_paths(value: &mut serde_json::Value) { } } +pub(crate) fn start_cli_agent_task_at( + project_path: &Path, + agent_id: &str, + task: &str, + run_id: &str, +) -> Result { + if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { + start_game_creator_supervisor_background_task_for_session_at( + project_path, + None, + task, + run_id, + AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, + AGENT_RUNTIME_RUN_PROFILE_STANDARD, + ) + } else { + start_game_creator_agent_background_task_at(project_path, agent_id, task, run_id) + } +} + fn serialize_agent_runtime_cli_payload(payload: &T) -> Result { let mut value = serde_json::to_value(payload) .map_err(|error| format!("序列化 Agent Runtime 状态失败:{error}"))?; @@ -906,12 +926,7 @@ pub(crate) fn run_cli_command(command: CliCommand) -> Result<(), String> { .map_err(|error| format!("创建 CLI runtime 失败:{error}"))?; let run_id = format!("cli-{agent_id}-{}", unix_millis()); let terminal = runtime.block_on(async { - let started = start_game_creator_agent_background_task_at( - &project_path, - &agent_id, - &task, - &run_id, - )?; + let started = start_cli_agent_task_at(&project_path, &agent_id, &task, &run_id)?; let canonical_run_id = started.state.run_id.clone(); let deadline = std::time::Instant::now() + Duration::from_secs(600); loop { diff --git a/apps/ai-game-creator-shell/src-tauri/src/command_exec.rs b/apps/ai-game-creator-shell/src-tauri/src/command_exec.rs index adeec51c4..873662bca 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/command_exec.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/command_exec.rs @@ -631,6 +631,14 @@ fn resolve_project_command_executable_from_path( } let executable = executable.ok_or_else(|| format!("command.exec 找不到受信任的 {program} 可执行文件"))?; + #[cfg(windows)] + let safe_directories = safe_directories + .into_iter() + .map(|directory| { + let directory = directory.to_string_lossy(); + PathBuf::from(directory.strip_prefix(r"\\?\").unwrap_or(&directory)) + }) + .collect::>(); let safe_path = std::env::join_paths(safe_directories) .map_err(|error| format!("构造 command.exec 安全 PATH 失败:{error}"))?; Ok((executable, safe_path)) @@ -1149,6 +1157,10 @@ pub(crate) fn prepare_project_command_launch_spec( ), (OsString::from("CARGO_NET_OFFLINE"), OsString::from("true")), (OsString::from("CARGO_TERM_COLOR"), OsString::from("never")), + // 受控命令不得继承用户级 Cargo rustc-wrapper(例如 sccache); + // 隔离 HOME/CARGO_HOME 下这类包装器既不可复现,也可能无法启动。 + (OsString::from("RUSTC_WRAPPER"), OsString::new()), + (OsString::from("RUSTC_WORKSPACE_WRAPPER"), OsString::new()), (OsString::from("npm_config_audit"), OsString::from("false")), (OsString::from("npm_config_fund"), OsString::from("false")), ( @@ -1213,10 +1225,18 @@ pub(crate) fn prepare_project_command_launch_spec( environment.push(( OsString::from("ComSpec"), PathBuf::from(system_root) - .join("System32/cmd.exe") + .join("System32") + .join("cmd.exe") .into_os_string(), )); } + #[cfg(windows)] + for (_, value) in &mut environment { + let rendered = value.to_string_lossy(); + if let Some(without_prefix) = rendered.strip_prefix(r"\\?\") { + *value = OsString::from(without_prefix); + } + } let arguments = project_command_actual_arguments(spec); #[cfg(target_os = "linux")] @@ -1247,10 +1267,71 @@ pub(crate) fn prepare_project_command_launch_spec( } #[cfg(not(target_os = "linux"))] { + #[cfg(windows)] + let (executable, arguments, cwd) = { + fn without_windows_verbatim_prefix(path: PathBuf) -> PathBuf { + let value = path.to_string_lossy(); + PathBuf::from(value.strip_prefix(r"\\?\").unwrap_or(&value)) + } + let is_npm_batch = spec + .executable + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.eq_ignore_ascii_case("npm.cmd")); + if is_npm_batch { + let npm_directory = spec.executable.parent().ok_or_else(|| { + ProjectCommandError::new( + ProjectCommandErrorStage::Preflight, + "command.exec 无法定位 Windows npm 安装目录", + ) + })?; + let node_executable = npm_directory.join("node.exe"); + let npm_cli = npm_directory.join("node_modules/npm/bin/npm-cli.js"); + if !node_executable.is_file() || !npm_cli.is_file() { + return Err(ProjectCommandError::new( + ProjectCommandErrorStage::Preflight, + "command.exec Windows npm 安装缺少 node.exe 或 npm-cli.js", + )); + } + let node_executable = fs::canonicalize(node_executable).map_err(|error| { + ProjectCommandError::new( + ProjectCommandErrorStage::Preflight, + format!("command.exec 定位 Windows node.exe 失败:{error}"), + ) + })?; + let npm_cli = fs::canonicalize(npm_cli).map_err(|error| { + ProjectCommandError::new( + ProjectCommandErrorStage::Preflight, + format!("command.exec 定位 Windows npm-cli.js 失败:{error}"), + ) + })?; + let node_executable = without_windows_verbatim_prefix(node_executable); + let npm_cli = without_windows_verbatim_prefix(npm_cli); + let mut node_arguments = vec![npm_cli.into_os_string()]; + node_arguments.extend(arguments.into_iter().map(OsString::from)); + ( + node_executable, + node_arguments, + without_windows_verbatim_prefix(spec.cwd.clone()), + ) + } else { + ( + without_windows_verbatim_prefix(spec.executable.clone()), + arguments.into_iter().map(OsString::from).collect(), + without_windows_verbatim_prefix(spec.cwd.clone()), + ) + } + }; + #[cfg(not(windows))] + let (executable, arguments, cwd) = ( + spec.executable.clone(), + arguments.into_iter().map(OsString::from).collect(), + spec.cwd.clone(), + ); Ok(ProjectCommandLaunchSpec { - executable: spec.executable.clone(), - arguments: arguments.into_iter().map(OsString::from).collect(), - cwd: spec.cwd.clone(), + executable, + arguments, + cwd, environment, sandbox_backend: "legacy-host-restricted".to_string(), sandbox_mode: "fixed-command".to_string(), @@ -1308,7 +1389,10 @@ pub(crate) fn stage_project_command_launch_spec( } } -fn configure_project_command_process_group(command: &mut tokio::process::Command) { +fn configure_project_command_process_group( + command: &mut tokio::process::Command, + launch: &ProjectCommandLaunchSpec, +) { #[cfg(unix)] { use std::os::unix::process::CommandExt; @@ -1316,6 +1400,13 @@ fn configure_project_command_process_group(command: &mut tokio::process::Command } #[cfg(windows)] { + let npm_cli_host = launch + .arguments + .first() + .and_then(|argument| Path::new(argument).file_name()) + .and_then(|name| name.to_str()) + .is_some_and(|name| name.eq_ignore_ascii_case("npm-cli.js")); + let _ = npm_cli_host; crate::configure_windows_background_tokio_command(command, true); } } @@ -1347,7 +1438,7 @@ where for (name, value) in &staged.launch.environment { command.env(name, value); } - configure_project_command_process_group(&mut command); + configure_project_command_process_group(&mut command, &staged.launch); #[cfg(target_os = "linux")] staged .gate @@ -1803,6 +1894,12 @@ where Err(_) => { let termination = match terminate_project_command_process_group(&mut child).await { Ok(termination) => termination, + #[cfg(windows)] + Err(error) if child.try_wait().ok().flatten().is_some() => { + format!( + "请求终止受控进程组后主进程已回收(taskkill 未找到已退出进程:{error})" + ) + } Err(error) => { stdout_task.abort(); stderr_task.abort(); @@ -2144,7 +2241,13 @@ mod tests { "expected rejection for {program} {args:?}" ); } - let absolute = vec!["test".to_string(), "/tmp/outside.rs".to_string()]; + let absolute = vec![ + "test".to_string(), + std::env::temp_dir() + .join("outside.rs") + .to_string_lossy() + .into_owned(), + ]; assert!(resolve_project_command_spec_at(root, "cargo", &absolute, ".", 30).is_err()); let sensitive = vec!["status".to_string(), ".agent/agent.db".to_string()]; assert!(resolve_project_command_spec_at(root, "git", &sensitive, ".", 30).is_err()); @@ -2512,6 +2615,18 @@ raise SystemExit(code)' ); } + #[cfg(windows)] + #[test] + fn project_command_safe_path_uses_win32_compatible_directories() { + let dir = command_project("windows-safe-path"); + let raw_path = std::env::var_os("PATH").expect("PATH"); + let (_, safe_path) = + resolve_project_command_executable_from_path(dir.path(), "node", &raw_path) + .expect("resolve node executable"); + assert!(std::env::split_paths(&safe_path) + .all(|directory| { !directory.as_os_str().to_string_lossy().starts_with(r"\\?\") })); + } + #[cfg(not(target_os = "linux"))] #[test] fn project_command_injects_git_safety_options_before_pathspec_separator() { diff --git a/apps/ai-game-creator-shell/src-tauri/src/config.rs b/apps/ai-game-creator-shell/src-tauri/src/config.rs index 27f8ad432..04ce73338 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/config.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/config.rs @@ -368,23 +368,12 @@ pub(crate) fn game_creator_codex_app_server_llm_route_error( } pub(crate) fn check_game_creator_codex_cli_available() -> Result<(), String> { - let output = std::process::Command::new("codex") - .arg("--version") - .stdin(std::process::Stdio::null()) - .output() - .map_err(|_| "Codex CLI 未安装或不在当前客户端 PATH 中".to_string())?; - if !output.status.success() { - return Err("Codex CLI 版本检查失败".to_string()); - } - let version = String::from_utf8_lossy(&output.stdout); - if !version.trim().starts_with("codex-cli ") { - return Err("Codex CLI 返回了无法识别的版本信息".to_string()); - } - Ok(()) + crate::agent::game_creator_codex_cli_version_identity().map(|_| ()) } fn check_game_creator_codex_app_server_available() -> Result<(), String> { - let output = std::process::Command::new("codex") + let executable = crate::agent::game_creator_codex_cli_executable_path()?; + let output = std::process::Command::new(executable) .args(["app-server", "--help"]) .stdin(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) @@ -860,6 +849,11 @@ pub(crate) fn windows_private_dacl_security_information( } else { 0 } + | if initialize_owner && !owner_matches { + OWNER_SECURITY_INFORMATION + } else { + 0 + } } #[cfg(windows)] @@ -1251,7 +1245,10 @@ fn secure_windows_game_creator_path_for_current_user_with_owner_policy( pub(crate) fn configure_game_creator_runtime_config_dir( app: &tauri::AppHandle, ) -> Result<(), Box> { - let config_dir = prepare_game_creator_runtime_config_dir(&app.path().app_config_dir()?) + let requested_config_dir = game_creator_runtime_config_dir() + .map(Ok) + .unwrap_or_else(|| app.path().app_config_dir())?; + let config_dir = prepare_game_creator_runtime_config_dir(&requested_config_dir) .map_err(std::io::Error::other)?; let config_path = config_dir.join(GAME_CREATOR_CONFIG_FILE_NAME); if !config_path.exists() { 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 049030c67..2d533e865 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -2008,6 +2008,10 @@ fn main() { } } + if let Some(config_dir) = runtime_config_dir { + set_game_creator_runtime_config_dir(config_dir); + } + let mut tauri_context = tauri::generate_context!(); let startup_log = if cfg!(all(not(debug_assertions), feature = "game-chat-release")) { let path = initialize_game_chat_startup_log(&tauri_context.config().identifier); diff --git a/apps/ai-game-creator-shell/src-tauri/src/process_session/io.rs b/apps/ai-game-creator-shell/src-tauri/src/process_session/io.rs index ad9c6b2d9..04a74476d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/process_session/io.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/process_session/io.rs @@ -206,6 +206,9 @@ where } let mut bytes = data.as_bytes().to_vec(); if append_newline { + #[cfg(windows)] + bytes.extend_from_slice(b"\r\n"); + #[cfg(not(windows))] bytes.push(b'\n'); } if bytes.len() > PROCESS_SESSION_MAX_STDIN_BYTES { @@ -265,7 +268,7 @@ where } Ok(ProcessSessionStdinResult { process_id: process_id.to_string(), - bytes_written: bytes.len(), + bytes_written: data.len() + usize::from(append_newline), content_sha256, stdin_open: output.stdin_open, eof, diff --git a/apps/ai-game-creator-shell/src-tauri/src/process_session/lifecycle.rs b/apps/ai-game-creator-shell/src-tauri/src/process_session/lifecycle.rs index d5e5f166d..8a5e21147 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/process_session/lifecycle.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/process_session/lifecycle.rs @@ -50,10 +50,22 @@ pub(crate) fn validate_process_session_command_spec( Ok(()) } -fn process_session_command_builder( +pub(super) fn process_session_command_builder( launch: &ProjectCommandLaunchSpec, #[cfg(target_os = "linux")] bridge: &ProcessSessionBridgeServer, ) -> Result { + #[cfg(windows)] + let is_npm_launch = launch + .executable + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.eq_ignore_ascii_case("npm.cmd")) + || launch.arguments.first().is_some_and(|argument| { + std::path::Path::new(argument) + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.eq_ignore_ascii_case("npm-cli.js")) + }); #[cfg(target_os = "linux")] let mut command = { let current_executable = std::env::current_exe() @@ -76,16 +88,72 @@ fn process_session_command_builder( }; #[cfg(not(target_os = "linux"))] let mut command = { - let mut command = CommandBuilder::new(&launch.executable); - command.args(&launch.arguments); - command + #[cfg(windows)] + { + if launch + .executable + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.eq_ignore_ascii_case("npm.cmd")) + { + let npm_directory = launch + .executable + .parent() + .ok_or_else(|| "command.start 无法定位 Windows npm 安装目录".to_string())?; + let node_executable = npm_directory.join("node.exe"); + let npm_cli = npm_directory.join("node_modules/npm/bin/npm-cli.js"); + if !node_executable.is_file() || !npm_cli.is_file() { + return Err( + "command.start Windows npm 安装缺少 node.exe 或 npm-cli.js".to_string() + ); + } + let node_executable = node_executable.to_string_lossy(); + let npm_cli = npm_cli.to_string_lossy(); + let mut command = CommandBuilder::new( + node_executable + .strip_prefix(r"\\?\") + .unwrap_or(&node_executable), + ); + command.arg(npm_cli.strip_prefix(r"\\?\").unwrap_or(&npm_cli)); + command.args(&launch.arguments); + command + } else { + let mut command = CommandBuilder::new(&launch.executable); + command.args(&launch.arguments); + command + } + } + #[cfg(not(windows))] + { + let mut command = CommandBuilder::new(&launch.executable); + command.args(&launch.arguments); + command + } }; + #[cfg(windows)] + { + let cwd = launch.cwd.to_string_lossy(); + command.cwd(cwd.strip_prefix(r"\\?\").unwrap_or(&cwd)); + } + #[cfg(not(windows))] command.cwd(&launch.cwd); command.env_clear(); #[cfg(not(target_os = "linux"))] for (name, value) in &launch.environment { command.env(name, value); } + #[cfg(windows)] + if is_npm_launch { + let node_executable = launch.executable.to_string_lossy(); + let node_executable = node_executable.strip_prefix(r"\\?\").unwrap_or(&node_executable); + command.env("npm_node_execpath", node_executable); + command.env("NODE", node_executable); + command.env("npm_config_node_gyp", ""); + // Windows 环境变量名不区分大小写。先移除继承的拼写,避免 + // CommandBuilder 更新值后仍保留 `ComSpec` 而隐藏 npm 的小写键。 + command.env_remove("ComSpec"); + command.env("npm_config_script_shell", r"C:\Windows\System32\cmd.exe"); + } #[cfg(target_os = "linux")] { command.env( @@ -770,28 +838,6 @@ where ) }) .map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Execution, error))?; - let reader = pair - .master - .try_clone_reader() - .map_err(|error| { - process_session_launch_failed( - root, - &mut durable_record, - format!("克隆 command.start PTY reader 失败:{error}"), - ) - }) - .map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Execution, error))?; - let writer = pair - .master - .take_writer() - .map_err(|error| { - process_session_launch_failed( - root, - &mut durable_record, - format!("取得 command.start PTY writer 失败:{error}"), - ) - }) - .map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Execution, error))?; let command = process_session_command_builder(launch) .map_err(|error| process_session_launch_failed(root, &mut durable_record, error)) .map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Execution, error))?; @@ -807,6 +853,30 @@ where }) .map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Execution, error))?; drop(pair.slave); + let reader = pair.master.try_clone_reader().map_err(|error| { + let _ = child.kill(); + let _ = child.wait(); + ProjectCommandError::new( + ProjectCommandErrorStage::Execution, + process_session_launch_failed( + root, + &mut durable_record, + format!("克隆 command.start PTY reader 失败:{error}"), + ), + ) + })?; + let writer = pair.master.take_writer().map_err(|error| { + let _ = child.kill(); + let _ = child.wait(); + ProjectCommandError::new( + ProjectCommandErrorStage::Execution, + process_session_launch_failed( + root, + &mut durable_record, + format!("取得 command.start PTY writer 失败:{error}"), + ), + ) + })?; #[cfg(windows)] let windows_job = match WindowsProcessJob::assign(child.as_ref()) { Ok(job) => job, @@ -1014,21 +1084,129 @@ impl AnsiStripper { } } +#[cfg(windows)] +#[derive(Default)] +pub(super) struct AnsiTerminalRepositionDetector { + state: u8, +} + +#[cfg(windows)] +impl AnsiTerminalRepositionDetector { + pub(super) fn push(&mut self, byte: u8) -> bool { + match self.state { + 0 if byte == 0x1b => self.state = 1, + 1 if byte == b'[' => self.state = 2, + 1 => self.state = 0, + 2 if (0x40..=0x7e).contains(&byte) => { + self.state = 0; + return matches!(byte, b'A'..=b'H' | b'f'); + } + 2 => {} + _ => self.state = 0, + } + false + } +} + fn drain_process_session_output( live: Arc, mut reader: Box, ) { let mut buffer = [0u8; 4096]; let mut pending = Vec::new(); + let mut pending_logical_line_bytes = 0usize; let mut ansi = AnsiStripper::default(); let mut output_limit = false; + #[cfg(windows)] + let mut conpty_cursor_query_match = 0usize; + #[cfg(windows)] + let mut conpty_cursor_replied = false; + #[cfg(windows)] + let mut terminal_reposition = AnsiTerminalRepositionDetector::default(); + #[cfg(windows)] + let mut conpty_soft_wrap = false; loop { match reader.read(&mut buffer) { Ok(0) => break, Ok(read) => { for byte in &buffer[..read] { + #[cfg(windows)] + { + const CONPTY_CURSOR_QUERY: &[u8] = b"\x1b[6n"; + if !conpty_cursor_replied + && *byte == CONPTY_CURSOR_QUERY[conpty_cursor_query_match] + { + conpty_cursor_query_match += 1; + if conpty_cursor_query_match == CONPTY_CURSOR_QUERY.len() { + conpty_cursor_query_match = 0; + let reply_result = live + .writer + .lock() + .map_err(|_| "process session stdin 锁已损坏".to_string()) + .and_then(|mut writer| { + let Some(writer) = writer.as_mut() else { + // 终止线程会先关闭 stdin;此时 ConPTY 可能仍把启动期 + // 光标查询交给 reader。进程树已经进入收束阶段,无需再 + // 把无法回复查询升级成 needs-reconciliation。 + return Ok(()); + }; + writer + .write_all(b"\x1b[1;1R") + .and_then(|()| writer.flush()) + .map_err(|error| { + format!("回复 Windows ConPTY 光标查询失败:{error}") + }) + }); + if let Err(error) = reply_result { + if let Ok(mut output) = live.output.lock() { + output.status = "failed".to_string(); + output.needs_reconciliation = true; + output.stdin_open = false; + let detail = format!( + "\n\n" + ); + if output.text.len().saturating_add(detail.len()) + <= PROCESS_SESSION_MAX_OUTPUT_BYTES + { + output.text.push_str(&detail); + } + live.output_changed.notify_all(); + } + let _ = live.control.send(ProcessControl::Terminate); + return; + } + conpty_cursor_replied = true; + } + } else if !conpty_cursor_replied { + conpty_cursor_query_match = + usize::from(*byte == CONPTY_CURSOR_QUERY[0]); + } + } + #[cfg(windows)] + let ends_terminal_reposition = terminal_reposition.push(*byte); let before = pending.len(); ansi.push(*byte, &mut pending); + let visible_bytes = pending.len().saturating_sub(before); + if visible_bytes > 0 + && !matches!(pending.last(), Some(b'\n' | b'\r')) + { + pending_logical_line_bytes = + pending_logical_line_bytes.saturating_add(visible_bytes); + if pending_logical_line_bytes > PROCESS_SESSION_MAX_PENDING_LINE_BYTES { + output_limit = true; + break; + } + } + #[cfg(windows)] + if ends_terminal_reposition && !pending.is_empty() { + pending.push(b'\n'); + if !append_process_output_line(&live, &pending) { + output_limit = true; + break; + } + pending.clear(); + continue; + } if pending.len() == before { continue; } @@ -1037,10 +1215,36 @@ fn drain_process_session_output( output_limit = true; break; } + #[cfg(windows)] + { + // ConPTY materializes an automatic terminal-width wrap as CR/LF. + // It is a display boundary, not an application line terminator, so + // it must not reset the logical-line safety limit. A real short line + // still resets at CR; the immediately following LF preserves that + // decision. + const PROCESS_SESSION_PTY_COLS: usize = 120; + match pending.last() { + Some(b'\r') => { + conpty_soft_wrap = pending_logical_line_bytes + >= PROCESS_SESSION_PTY_COLS; + if !conpty_soft_wrap { + pending_logical_line_bytes = 0; + } + } + Some(b'\n') => { + if !conpty_soft_wrap { + pending_logical_line_bytes = 0; + } + conpty_soft_wrap = false; + } + _ => {} + } + } + #[cfg(not(windows))] + { + pending_logical_line_bytes = 0; + } pending.clear(); - } else if pending.len() > PROCESS_SESSION_MAX_PENDING_LINE_BYTES { - output_limit = true; - break; } } if output_limit { diff --git a/apps/ai-game-creator-shell/src-tauri/src/process_session/recovery.rs b/apps/ai-game-creator-shell/src-tauri/src/process_session/recovery.rs index 24d62ab1f..da3de5e8e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/process_session/recovery.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/process_session/recovery.rs @@ -121,8 +121,8 @@ pub(crate) fn terminate_process_sessions_for_run_at( let terminal = terminate_process_session_at(root, &identity, &record.process_id, None)?; if terminal.status == "running" || terminal.needs_reconciliation { return Err(format!( - "进程会话 {} 尚未形成可信终态,不能把 run 标记为已取消", - record.process_id + "进程会话 {} 尚未形成可信终态(status={},needsReconciliation={}),不能把 run 标记为已取消", + record.process_id, terminal.status, terminal.needs_reconciliation )); } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/process_session/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/process_session/tests.rs index 5230b3cb2..6b778c1c6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/process_session/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/process_session/tests.rs @@ -366,6 +366,69 @@ fn process_session_ansi_stripper_handles_split_csi_and_osc() { assert_eq!(String::from_utf8(visible).expect("utf8"), "ABC\n"); } +#[cfg(windows)] +#[test] +fn process_session_terminal_reposition_detector_ignores_color_sequences() { + let mut detector = AnsiTerminalRepositionDetector::default(); + let color = b"\x1b[31m"; + assert!(!color.iter().any(|byte| detector.push(*byte))); + + let mut reposition = AnsiTerminalRepositionDetector::default(); + let sequence = b"\x1b[5;1H"; + assert_eq!( + sequence + .iter() + .filter(|byte| reposition.push(**byte)) + .count(), + 1 + ); +} + +#[cfg(windows)] +#[test] +fn process_session_windows_npm_builder_uses_node_cli_and_native_script_shell() { + let directory = tempfile::tempdir().expect("temp project"); + let root = directory.path(); + init_local_game_project_at(root, "npm-builder-project", "Npm Builder Project") + .expect("initialize project"); + fs::write( + root.join("package.json"), + r#"{"scripts":{"dev":"node fixture.js"}}"#, + ) + .expect("write package.json"); + let spec = resolve_project_command_spec_at( + root, + "npm", + &["run".to_string(), "dev".to_string()], + ".", + 30, + ) + .expect("resolve npm command"); + let launch = prepare_project_command_launch_spec(root, &spec).expect("prepare npm launch"); + let command = process_session_command_builder(&launch).expect("build npm PTY command"); + let argv = command.get_argv(); + + assert!(argv[0].to_string_lossy().ends_with("node.exe")); + assert!(argv[1] + .to_string_lossy() + .replace('\\', "/") + .ends_with("node_modules/npm/bin/npm-cli.js")); + assert!(argv + .iter() + .all(|argument| !argument.to_string_lossy().starts_with(r"\\?\"))); + assert_eq!( + command.get_env("npm_config_script_shell"), + Some(std::ffi::OsStr::new(r"C:\Windows\System32\cmd.exe")) + ); + assert_eq!( + command.get_env("NODE"), + command.get_env("npm_node_execpath") + ); + assert!(command + .get_cwd() + .is_some_and(|cwd| !cwd.to_string_lossy().starts_with(r"\\?\"))); +} + #[test] fn process_session_real_pty_streams_stdin_and_terminates() { let _guard = process_session_test_guard(); @@ -515,6 +578,7 @@ setInterval(() => {}, 1000); "{:?}", transcript.output ); + #[cfg(unix)] assert!( transcript_lines.contains(&"STOPPED"), "{:?}", @@ -1337,7 +1401,11 @@ process.stdin.resume(); } } assert_eq!(poll.status, "exited", "tail: {tail}"); + #[cfg(not(windows))] assert!(tail.contains("EOF"), "tail: {tail}"); + // Closing a ConPTY input pipe closes the attached Windows console. Unlike a Unix PTY, + // Node's console stdin does not emit its stream-level `end` callback before that terminal + // close, so the portable contract here is the trusted `exited` terminal state above. clear_process_session_registry_for_tests(); } @@ -1429,7 +1497,8 @@ fn process_session_overlong_unterminated_line_is_stopped() { let fingerprint = project_command_source_fingerprint(root).expect("source fingerprint"); let mut poll = start_process_session_at(root, identity.clone(), &spec, fingerprint).expect("start"); - for _ in 0..30 { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + while std::time::Instant::now() < deadline { if poll.status != "running" { break; } @@ -1439,7 +1508,7 @@ fn process_session_overlong_unterminated_line_is_stopped() { &poll.process_id, Some(&poll.next_cursor), Some(8_000), - Some(250), + Some(100), ) .expect("poll output limit"); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/agent_db.rs b/apps/ai-game-creator-shell/src-tauri/src/project/agent_db.rs index 3f12dbdd4..56a9cc65e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/agent_db.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/agent_db.rs @@ -767,11 +767,7 @@ fn nt_open_windows_agent_db_relative( &mut io_status, std::ptr::null_mut(), FILE_ATTRIBUTE_NORMAL, - if directory { - FILE_SHARE_READ | FILE_SHARE_WRITE - } else { - 0 - }, + FILE_SHARE_READ | FILE_SHARE_WRITE, if create { FILE_OPEN_IF } else { FILE_OPEN }, create_options, std::ptr::null_mut(), @@ -3447,7 +3443,7 @@ fn try_open_project_append_os_lock(path: &Path, error_label: &str) -> Result + ) || matches!(error.raw_os_error(), Some(32 | 33)) => { Ok(None) } @@ -3479,12 +3475,12 @@ pub(super) fn append_jsonl_line_unlocked( .create(true) .read(true) .write(true) - .append(true) .open(path) .map_err(|error| format!("打开{error_label}失败:{}: {error}", path.display()))?; repair_truncated_jsonl_tail_unlocked(&mut file, path, error_label)?; let framed = format!("{line}\n"); - file.write_all(framed.as_bytes()) + file.seek(SeekFrom::End(0)) + .and_then(|_| file.write_all(framed.as_bytes())) .and_then(|_| file.flush()) .and_then(|_| file.sync_data()) .map_err(|error| format!("写入{error_label}失败:{}: {error}", path.display())) diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/agent_db/security_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/project/agent_db/security_tests.rs index 20d51c937..6d5d560f1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/agent_db/security_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/agent_db/security_tests.rs @@ -2127,6 +2127,33 @@ fn incomplete_provider_request_query_uses_the_agent_db_append_lock() { fs::remove_dir_all(root).ok(); } +#[cfg(windows)] +#[test] +fn windows_agent_db_read_handle_can_coexist_with_an_open_writer() { + let root = unique_agent_db_test_root("windows-shared-read-write"); + append_agent_db_record_fixture( + &root, + serde_json::json!({"recordType": "test.windows-shared-read-write"}), + ) + .expect("append shared-handle fixture"); + let directory = open_agent_db_directory(&root, false) + .expect("open Agent DB directory") + .expect("Agent DB directory exists"); + let writer = open_agent_db_storage(directory, true, false) + .expect("open Agent DB writer") + .expect("Agent DB exists"); + + let (records, _) = read_agent_db_records_bounded(&root, u64::MAX) + .expect("read Agent DB while writer handle remains open"); + assert!(records.iter().any(|record| { + record.get("recordType").and_then(serde_json::Value::as_str) + == Some("test.windows-shared-read-write") + })); + + drop(writer); + fs::remove_dir_all(root).ok(); +} + #[test] fn incomplete_provider_request_query_rejects_non_target_duplicate_reversed_and_multi_terminal_sequences( ) { diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas.rs b/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas.rs index 83e61551c..28b5f56bb 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas.rs @@ -1025,29 +1025,50 @@ fn try_acquire_asset_canvas_draft_lock( .parent() .ok_or_else(|| "素材画布锁缺少父目录".to_string())?; fs::create_dir_all(parent).map_err(|_| "创建素材画布锁目录失败".to_string())?; - match fs::OpenOptions::new() - .create(true) - .read(true) - .write(true) - .share_mode(0) - .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT) - .open(&path) - { - Ok(file) => { - validate_windows_regular_file_handle(&file, "素材画布锁")?; - crate::secure_windows_game_creator_path_for_current_user(&path, false, true)?; - Ok(Some(AssetCanvasDraftLock { _file: file })) + let open_lock = |create_new| { + let mut options = fs::OpenOptions::new(); + options + .read(true) + .write(true) + .share_mode(0) + .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT); + if create_new { + options.create_new(true); } - Err(error) - if matches!( - error.kind(), - std::io::ErrorKind::PermissionDenied | std::io::ErrorKind::WouldBlock - ) => - { - Ok(None) - } - Err(_) => Err("获取素材画布系统文件锁失败".to_string()), + options.open(&path) + }; + let (file, created) = match open_lock(true) { + Ok(file) => (file, true), + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => match open_lock(false) { + Ok(file) => (file, false), + Err(error) if windows_file_lock_is_contended(&error) => { + return Ok(None); + } + Err(_) => return Err("获取素材画布系统文件锁失败".to_string()), + }, + Err(error) if windows_file_lock_is_contended(&error) => return Ok(None), + Err(_) => return Err("获取素材画布系统文件锁失败".to_string()), + }; + validate_windows_regular_file_handle(&file, "素材画布锁")?; + if created { + crate::initialize_windows_game_creator_file_owner_for_current_user(&path)?; + } else { + crate::secure_windows_game_creator_path_for_current_user(&path, false, true)?; } + Ok(Some(AssetCanvasDraftLock { _file: file })) +} + +#[cfg(windows)] +fn windows_file_lock_is_contended(error: &std::io::Error) -> bool { + const ERROR_SHARING_VIOLATION: i32 = 32; + const ERROR_LOCK_VIOLATION: i32 = 33; + matches!( + error.kind(), + std::io::ErrorKind::PermissionDenied | std::io::ErrorKind::WouldBlock + ) || matches!( + error.raw_os_error(), + Some(ERROR_SHARING_VIOLATION | ERROR_LOCK_VIOLATION) + ) } #[cfg(not(any(unix, windows)))] diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs b/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs index 7bbc799b0..a49f17b04 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs @@ -82,6 +82,21 @@ fn project_write_lock_can_be_reclaimed(path: &Path) -> bool { project_write_lock_age_seconds(path, &metadata) > PROJECT_WRITE_LOCK_STALE_AFTER_SECONDS } +fn project_write_lock_open_error_is_contention(error: &std::io::Error) -> bool { + if error.kind() == std::io::ErrorKind::AlreadyExists { + return true; + } + #[cfg(windows)] + { + // Windows can report an existing or delete-pending create_new target as + // ACCESS_DENIED instead of ALREADY_EXISTS while another thread drops it. + return error.kind() == std::io::ErrorKind::PermissionDenied + || matches!(error.raw_os_error(), Some(5 | 32 | 33)); + } + #[cfg(not(windows))] + false +} + pub(crate) fn acquire_project_write_lock( root: &Path, command_id: &str, @@ -118,7 +133,7 @@ pub(crate) fn acquire_project_write_lock( }); } Err(error) - if error.kind() == std::io::ErrorKind::AlreadyExists + if project_write_lock_open_error_is_contention(&error) && !retried_after_reclaim && project_write_lock_can_be_reclaimed(&path) => { @@ -127,7 +142,7 @@ pub(crate) fn acquire_project_write_lock( })?; retried_after_reclaim = true; } - Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { + Err(error) if project_write_lock_open_error_is_contention(&error) => { return Err(format!("项目正在被其他写操作占用:{}", path.display())); } Err(error) => { 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 4f1e01b7e..c078f5b02 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 @@ -150,7 +150,7 @@ fn try_open_manifest_write_lock_file(path: &Path) -> Result, String if matches!( error.kind(), std::io::ErrorKind::PermissionDenied | std::io::ErrorKind::WouldBlock - ) => + ) || matches!(error.raw_os_error(), Some(32 | 33)) => { Ok(None) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/resource_layout.rs b/apps/ai-game-creator-shell/src-tauri/src/project/resource_layout.rs index d4c23f8e9..8c587703c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/resource_layout.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/resource_layout.rs @@ -246,6 +246,7 @@ fn try_open_resource_layout_write_lock_file(root: &Path) -> Result, )); } } + let existed = path.exists(); match fs::OpenOptions::new() .create(true) .read(true) @@ -256,14 +257,18 @@ fn try_open_resource_layout_write_lock_file(root: &Path) -> Result, { Ok(file) => { validate_windows_regular_file_handle(&file, "资源布局锁")?; - crate::secure_windows_game_creator_path_for_current_user(&path, false, true)?; + if existed { + crate::secure_windows_game_creator_path_for_current_user(&path, false, true)?; + } else { + crate::initialize_windows_game_creator_file_owner_for_current_user(&path)?; + } Ok(Some(file)) } Err(error) if matches!( error.kind(), std::io::ErrorKind::PermissionDenied | std::io::ErrorKind::WouldBlock - ) => + ) || matches!(error.raw_os_error(), Some(32 | 33)) => { Ok(None) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/verification.rs b/apps/ai-game-creator-shell/src-tauri/src/project/verification.rs index 30835b4d9..d8cd83355 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/verification.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/verification.rs @@ -511,6 +511,8 @@ where } Err(_) => { terminate_project_verification_process_tree(&mut child).await; + stdout_task.abort(); + stderr_task.abort(); (None, true) } }; @@ -518,10 +520,26 @@ where collect_project_verification_output_task(stdout_task, "stdout"), collect_project_verification_output_task(stderr_task, "stderr"), ); - let stdout = stdout - .map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Execution, error))?; - let stderr = stderr - .map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Execution, error))?; + let stdout = match stdout { + Ok(output) => output, + Err(_) if timed_out => String::new(), + Err(error) => { + return Err(ProjectCommandError::new( + ProjectCommandErrorStage::Execution, + error, + )); + } + }; + let stderr = match stderr { + Ok(output) => output, + Err(_) if timed_out => String::new(), + Err(error) => { + return Err(ProjectCommandError::new( + ProjectCommandErrorStage::Execution, + error, + )); + } + }; let mut sections = Vec::new(); if !stdout.trim().is_empty() { sections.push(format!("stdout:\n{}", stdout.trim())); diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/endpoint.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/endpoint.rs index ccfe7b7af..4b140ab53 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/endpoint.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/endpoint.rs @@ -596,10 +596,11 @@ pub(super) fn open_external_agent_runner_endpoint_file(path: &Path) -> Result AgentRuntimeResult { let deadline = Instant::now() + Duration::from_secs(10); let mut terminal = wait_for_agent_runtime_terminal_and_lane_release_async( - root, agent_id, run_id, runtime_status, phase, + root, + agent_id, + run_id, + runtime_status, + phase, ) .await; let mut stable_samples = 0_u8; @@ -4117,8 +4121,14 @@ fn wait_for_tool_plan_handoff_test_stop( expected_entries: usize, ) -> crate::tool_plan_handoff::AgentRuntimeToolPlanHandoffLedger { for _ in 0..250 { - let handoff = crate::tool_plan_handoff::read_for_run_at(root, agent_id, run_id) - .expect("read tool-plan handoff after test stop"); + let handoff = match crate::tool_plan_handoff::read_for_run_at(root, agent_id, run_id) { + Ok(handoff) => handoff, + Err(error) if error.contains("仍由活跃写入句柄持有") => { + std::thread::sleep(Duration::from_millis(20)); + continue; + } + Err(error) => panic!("read tool-plan handoff after test stop: {error}"), + }; if let Some(handoff) = handoff { if handoff.entries.len() == expected_entries && game_creator_agent_runtime_task_lock_is_available(root, agent_id) @@ -5610,7 +5620,7 @@ async fn background_agent_runtime_marks_response_plan_step_failed_when_final_rep "response": "" }) .to_string(); - let base_url = spawn_mock_llm_server_responses(vec![plan_json]); + let base_url = spawn_mock_llm_tool_plan_then_invalid_final_reply(plan_json); let _config_guard = write_test_local_config(format!( r#"{{ "agentLlm": {{ diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs index bed788c5a..7a8b75405 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs @@ -585,6 +585,20 @@ async fn background_agent_runtime_preview_start_respects_project_policy() { ))); assert!(!root.join(".agent/logs/preview.log").exists()); + cancel_game_creator_agent_runtime_task_at( + &root, + "code-prototype", + "code-preview-policy-run", + ) + .expect("cancel waiting preview policy task"); + wait_for_agent_runtime_terminal_and_lane_release( + &root, + "code-prototype", + "code-preview-policy-run", + "cancelled", + "cancelled", + ); + fs::remove_dir_all(root).ok(); } @@ -1123,11 +1137,19 @@ async fn generate_local_game_draft_fails_after_max_passes_without_final_artifact trace["passPlans"].as_array().unwrap().len() == usize::from(GAME_CREATOR_AGENT_LOOP_MAX_PASSES) ); - assert!(trace["artifacts"] + let artifact_paths = trace["artifacts"] .as_array() .unwrap() .iter() - .any(|artifact| artifact["path"] == ".agent/passes/pass-3/game.html")); + .filter_map(|artifact| artifact["path"].as_str()) + .map(|path| path.replace('\\', "/")) + .collect::>(); + assert!( + artifact_paths + .iter() + .any(|path| path == ".agent/passes/pass-3/game.html"), + "unexpected max-pass artifact paths: {artifact_paths:?}" + ); assert!(!trace["steps"] .as_array() .unwrap() @@ -3017,16 +3039,21 @@ fn game_chat_initial_window_url_is_applied_before_tauri_creates_the_client() { #[test] fn workspace_window_project_path_requires_absolute_path() { - assert!(validate_workspace_window_project_path(" /tmp/game ").is_ok()); + let absolute = std::env::temp_dir().join("game"); + let padded_absolute = format!(" {} ", absolute.display()); + assert!(validate_workspace_window_project_path(&padded_absolute).is_ok()); assert!(validate_workspace_window_project_path("relative-game") .expect_err("relative path should be rejected") .contains("绝对路径")); assert!(validate_workspace_window_project_path(" ") .expect_err("empty path should be rejected") .contains("绝对路径")); - assert!(validate_workspace_window_project_path("/tmp/game\nnext") - .expect_err("control character path should be rejected") - .contains("控制字符")); + let control_character_path = format!("{}\nnext", absolute.display()); + assert!( + validate_workspace_window_project_path(&control_character_path) + .expect_err("control character path should be rejected") + .contains("控制字符") + ); } #[test] @@ -3090,6 +3117,36 @@ fn cli_agent_run_requires_project_and_prompt() { initialize: true, } ); + + let root = unique_project_path(); + init_local_game_project_at(&root, "cli-supervisor-source", "CLI Supervisor 来源") + .expect("init CLI Supervisor project"); + let started = start_cli_agent_task_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "检查项目", + "cli-supervisor-source-run", + ) + .expect("start CLI Supervisor task"); + assert_eq!(started.state.source, AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE); + assert_eq!( + started.state.run_profile, + AGENT_RUNTIME_RUN_PROFILE_STANDARD + ); + cancel_game_creator_agent_runtime_task_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "cli-supervisor-source-run", + ) + .expect("cancel CLI Supervisor source task"); + wait_for_agent_runtime_terminal_and_lane_release( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "cli-supervisor-source-run", + "cancelled", + "cancelled", + ); + fs::remove_dir_all(&root).ok(); let agent_enqueue = parse_cli_command(&[ "--agent-enqueue".to_string(), "--init".to_string(), diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs index e36d3c1ff..fa253f251 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs @@ -494,10 +494,16 @@ async fn mcp_runtime_write_tool_waits_for_confirmation_and_executes_once() { .recv_timeout(Duration::from_secs(5)) .expect("receive MCP observation followup"); assert!(followup_request.contains(&format!("mutated:{mutation_value}"))); - let terminal = wait_for_agent_runtime_idle(&root, "code-prototype"); - assert_eq!(terminal.phase, "completed"); + let terminal = wait_for_agent_runtime_terminal_and_lane_release( + &root, + "code-prototype", + "mcp-runtime-confirm-run", + "idle", + "completed", + ); + assert_eq!(terminal.state.phase, "completed"); assert_eq!( - terminal.last_response.as_deref(), + terminal.state.last_response.as_deref(), Some("MCP 写工具已确认并且只执行了一次。") ); assert_eq!( @@ -720,10 +726,16 @@ async fn mcp_executing_sidecar_recovers_after_client_loss_without_replay() { .recv_timeout(Duration::from_secs(5)) .expect("receive recovered MCP observation"); assert!(followup_request.contains(&format!("mutated:{mutation_value}"))); - let terminal = wait_for_agent_runtime_idle(&root, "code-prototype"); - assert_eq!(terminal.phase, "completed"); + let terminal = wait_for_agent_runtime_terminal_and_lane_release( + &root, + "code-prototype", + "mcp-sidecar-recovery-run", + "idle", + "completed", + ); + assert_eq!(terminal.state.phase, "completed"); assert_eq!( - terminal.last_response.as_deref(), + terminal.state.last_response.as_deref(), Some("MCP 已从私有 sidecar 恢复,没有重放远端写工具。") ); assert_eq!( @@ -3618,10 +3630,16 @@ async fn background_agent_runtime_resumes_approved_auto_action_once_without_llm_ .expect("replan after recovered auto observation"); assert!(replan_request.contains("已写入 Agent 记忆 design-director")); assert!(receiver.recv_timeout(Duration::from_millis(200)).is_err()); - let runtime = wait_for_agent_runtime_idle(&root, "design-director"); - assert_eq!(runtime.run_id, "design-auto-approved-recovery-run"); + let runtime = wait_for_agent_runtime_terminal_and_lane_release( + &root, + "design-director", + "design-auto-approved-recovery-run", + "idle", + "completed", + ); + assert_eq!(runtime.state.run_id, "design-auto-approved-recovery-run"); assert_eq!( - runtime.last_response.as_deref(), + runtime.state.last_response.as_deref(), Some("恢复后只写入了一次私有记忆。") ); let memory = read_local_agent_memory_at(&root, "design-director").expect("agent memory"); @@ -3956,9 +3974,17 @@ async fn provider_transient_retry_transport_failure_closes_then_stable_retry_suc .recv_timeout(Duration::from_millis(100)) .is_err()); - let runtime = wait_for_agent_runtime_idle(&root, "design-director"); - assert_eq!(runtime.phase, "completed"); - assert_eq!(runtime.last_response.as_deref(), Some("瞬态失败后已完成")); + let runtime = wait_for_agent_runtime_terminal_and_lane_release( + &root, + "design-director", + run_id, + "idle", + "completed", + ); + assert_eq!( + runtime.state.last_response.as_deref(), + Some("瞬态失败后已完成") + ); let records = read_agent_db_records_for_test(&root); let lifecycle = records @@ -5048,9 +5074,15 @@ async fn provider_transient_retry_zero_max_retries_stops_after_first_failure() { request_notice_receiver .recv_timeout(Duration::from_secs(5)) .expect("first physical Provider request"); - let runtime = wait_for_agent_runtime_idle(&root, "design-director"); - assert_eq!(runtime.phase, "failed"); + let runtime = wait_for_agent_runtime_terminal_and_lane_release( + &root, + "design-director", + run_id, + "failed", + "failed", + ); assert!(runtime + .state .error .as_deref() .is_some_and(|error| error.contains("kind=transport"))); diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/planning_strategy/tool_planning.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/planning_strategy/tool_planning.rs index d8978756a..1ce915e4e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/planning_strategy/tool_planning.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/planning_strategy/tool_planning.rs @@ -1365,6 +1365,7 @@ async fn background_agent_runtime_task_executes_plan_tool_observation_loop() { } assert!(runtime_result .task_path + .replace('\\', "/") .ends_with(".agent/runtime/tasks/design-director.jsonl")); assert!(runtime_result .recent_tasks diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/policy.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/policy.rs index f65168688..09b5f0df0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/policy.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/policy.rs @@ -384,6 +384,19 @@ async fn background_agent_runtime_can_confirm_and_continue_waiting_tool_actions( let runtime_lock_path = root.join(".agent/runtime/locks/design-director.lock"); fs::create_dir_all(runtime_lock_path.parent().expect("runtime lock parent")) .expect("runtime lock dir"); + for _ in 0..250 { + if game_creator_agent_runtime_task_lock_is_available(&root, "design-director") + .expect("probe released confirmation lane") + { + break; + } + std::thread::sleep(Duration::from_millis(20)); + } + assert!( + game_creator_agent_runtime_task_lock_is_available(&root, "design-director") + .expect("confirm released confirmation lane"), + "confirmation state was visible before its runtime lane released" + ); fs::write( &runtime_lock_path, serde_json::json!({ 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 1fa8efa64..d4c274484 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 @@ -1007,10 +1007,15 @@ async fn background_agent_runtime_read_only_action_survives_cross_agent_revision .recv_timeout(Duration::from_secs(2)) .expect("replan after latest read observation"); assert!(replan_request.contains("read-only action observes the latest revision")); - let runtime = wait_for_agent_runtime_idle(&root, "design-director"); - assert_eq!(runtime.phase, "completed"); - assert_eq!(runtime.error, None); - assert!(runtime.recent_tool_calls.iter().any(|call| { + let runtime = wait_for_agent_runtime_terminal_and_lane_release( + &root, + "design-director", + "design-read-only-revision-drift-run", + "idle", + "completed", + ); + assert_eq!(runtime.state.error, None); + assert!(runtime.state.recent_tool_calls.iter().any(|call| { call.tool == "file.read" && call.status == "ok" && call.action_id.is_some() })); assert_eq!( @@ -1106,10 +1111,15 @@ async fn background_agent_runtime_reconciliation_blocks_queue_until_manual_cance .expect("queued task starts after reconciliation cancel"); assert!(followup_request.contains("人工核对解除后执行的任务")); assert!(receiver.recv_timeout(Duration::from_millis(200)).is_err()); - let runtime = wait_for_agent_runtime_idle(&root, "design-director"); - assert_eq!(runtime.run_id, "design-reconciliation-after-cancel-run"); + let runtime = wait_for_agent_runtime_terminal_and_lane_release( + &root, + "design-director", + "design-reconciliation-after-cancel-run", + "idle", + "completed", + ); assert_eq!( - runtime.last_response.as_deref(), + runtime.state.last_response.as_deref(), Some("核对解除后,后续任务已按顺序完成。") ); let runtime_result = @@ -1323,10 +1333,15 @@ async fn background_agent_runtime_recovers_pending_task_after_cancelled_canonica .recv_timeout(Duration::from_secs(2)) .expect("pending task plan request"); assert!(plan_request.contains("恢复排队后台任务")); - let runtime = wait_for_agent_runtime_idle(&root, "design-director"); - assert_eq!(runtime.status, "idle"); + let runtime = wait_for_agent_runtime_terminal_and_lane_release( + &root, + "design-director", + "design-pending-recover-run", + "idle", + "completed", + ); assert_eq!( - runtime.last_response.as_deref(), + runtime.state.last_response.as_deref(), Some("已恢复并完成排队后台任务。") ); let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("agent db"); @@ -1446,11 +1461,15 @@ async fn background_agent_runtime_recovers_stale_running_before_pending_task() { .recv_timeout(Duration::from_secs(2)) .expect("pending plan request after recovered running"); assert!(second_request.contains("后续排队任务")); - let runtime = wait_for_agent_runtime_idle(&root, "design-director"); - assert_eq!(runtime.run_id, "design-pending-after-stale-run"); - assert_eq!(runtime.status, "idle"); + let runtime = wait_for_agent_runtime_terminal_and_lane_release( + &root, + "design-director", + "design-pending-after-stale-run", + "idle", + "completed", + ); assert_eq!( - runtime.last_response.as_deref(), + runtime.state.last_response.as_deref(), Some("后续排队任务已完成。") ); let runtime_result = @@ -1551,11 +1570,17 @@ async fn background_agent_runtime_recovers_stale_running_task() { .recv_timeout(Duration::from_secs(2)) .expect("recovered plan request"); assert!(plan_request.contains("恢复上一进程遗留任务")); - let runtime = wait_for_agent_runtime_idle(&root, "design-director"); - assert_eq!(runtime.status, "idle"); - assert_eq!(runtime.phase, "completed"); + let runtime = wait_for_agent_runtime_terminal_and_lane_release( + &root, + "design-director", + "design-recover-run", + "idle", + "completed", + ); + assert_eq!(runtime.state.status, "idle"); + assert_eq!(runtime.state.phase, "completed"); assert_eq!( - runtime.last_response.as_deref(), + runtime.state.last_response.as_deref(), Some("已恢复并完成上一进程遗留的后台任务。") ); let runtime_result = @@ -1787,10 +1812,16 @@ async fn background_agent_runtime_repairs_terminal_receipt_through_reconciliatio .recv_timeout(Duration::from_secs(5)) .expect("replan after receipt repair"); assert!(request.contains("已读取项目文件摘要,恢复时不得重放")); - let runtime = wait_for_agent_runtime_idle(&root, "design-director"); - assert_eq!(runtime.phase, "completed"); + let runtime = wait_for_agent_runtime_terminal_and_lane_release( + &root, + "design-director", + &state.run_id, + "idle", + "completed", + ); assert_eq!( runtime + .state .recent_tool_calls .iter() .filter(|record| record.action_id.as_deref() == Some(pending.action_id.as_str())) @@ -2059,10 +2090,15 @@ async fn background_agent_runtime_resume_commands_distinguish_auto_and_confirmed confirm_resume_game_creator_agent_runtime_tasks(root.to_string_lossy().into_owned()) .expect("explicit confirmation may resume under confirm policy"); assert_eq!(resumed.len(), 1); - let completed = wait_for_agent_runtime_idle(&root, "design-director"); - assert_eq!(completed.run_id, "design-confirm-run"); + let completed = wait_for_agent_runtime_terminal_and_lane_release( + &root, + "design-director", + "design-confirm-run", + "idle", + "completed", + ); assert_eq!( - completed.last_response.as_deref(), + completed.state.last_response.as_deref(), Some("已确认恢复默认策略下的后台任务。") ); @@ -2231,9 +2267,15 @@ async fn background_agent_runtime_resumes_observed_auto_action_without_reexecuti .expect("replan from durable observation"); assert!(replan_request.contains("已写入 Agent 记忆 design-director")); assert!(receiver.recv_timeout(Duration::from_millis(200)).is_err()); - let runtime = wait_for_agent_runtime_idle(&root, "design-director"); + let runtime = wait_for_agent_runtime_terminal_and_lane_release( + &root, + "design-director", + "design-auto-observed-recovery-run", + "idle", + "completed", + ); assert_eq!( - runtime.last_response.as_deref(), + runtime.state.last_response.as_deref(), Some("已从观察继续,没有重放工具。") ); let memory = read_local_agent_memory_at(&root, "design-director").expect("agent memory"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/task_lifecycle.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/task_lifecycle.rs index d44047373..f7b91b02b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/task_lifecycle.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/task_lifecycle.rs @@ -1338,6 +1338,7 @@ async fn role_agent_legacy_alias_maps_to_canonical_task_runtime_and_route() { assert_eq!(alias_read.task_queue.running, 1); assert!(alias_read .session_path + .replace('\\', "/") .ends_with(".agent/runtime/agents/art-asset-plan.json")); let runtimes = read_game_creator_agent_runtimes_at(&root).expect("read all runtimes"); assert!(runtimes 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 d77a1e92c..e6908bf47 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 @@ -2593,7 +2593,7 @@ async fn runtime_v11_closure_repository_context_drift_replans_before_auto_mutati } assert_eq!( fs::read_to_string(root.join("AGENTS.md")).expect("read drifted rules"), - "drifted rules\n" + "drifted rules\\n" ); fs::remove_dir_all(root).ok(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/sessions.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/sessions.rs index 27936aee5..2b6dc6663 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/sessions.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/sessions.rs @@ -58,9 +58,11 @@ async fn role_agent_runtime_turn_persists_session_events_and_index() { read_game_creator_agent_runtime_at(&root, "art-director").expect("read runtime state"); assert!(result .session_path + .replace('\\', "/") .ends_with(".agent/runtime/agents/art-director.json")); assert!(result .event_path + .replace('\\', "/") .ends_with(".agent/runtime/events/art-director.jsonl")); assert_eq!( result.state.last_response.as_deref(), @@ -836,13 +838,19 @@ fn local_agent_memory_reads_private_memory_by_task_id() { let read = read_local_agent_memory_at(&root, "design-director").expect("read agent memory"); assert_eq!(read.task_id, "design-director"); - assert!(read.path.ends_with("memory/agents/design/director.md")); + assert!(read + .path + .replace('\\', "/") + .ends_with("memory/agents/design/director.md")); assert_eq!(read.content, "# 策划 Director 私有记忆\n"); assert!(read.exists); let missing = read_local_agent_memory_at(&root, "art-asset-plan").expect("read missing memory"); assert_eq!(missing.task_id, "art-asset-plan"); - assert!(missing.path.ends_with("memory/agents/art/asset.md")); + assert!(missing + .path + .replace('\\', "/") + .ends_with("memory/agents/art/asset.md")); assert!(!missing.exists); fs::remove_dir_all(root).ok(); @@ -859,7 +867,10 @@ fn local_agent_memory_writes_private_memory_by_task_id() { ) .expect("write agent memory"); assert_eq!(written.task_id, "design-director"); - assert!(written.path.ends_with("memory/agents/design/director.md")); + assert!(written + .path + .replace('\\', "/") + .ends_with("memory/agents/design/director.md")); assert!(written.exists); assert_eq!( written.content, @@ -970,7 +981,10 @@ fn local_conversation_can_read_and_append_project_and_agent_messages() { }, ) .expect("append project conversation"); - assert!(project.path.ends_with(".agent/conversations/project.jsonl")); + assert!(project + .path + .replace('\\', "/") + .ends_with(".agent/conversations/project.jsonl")); assert_eq!(project.agent_id, None); assert_eq!(project.messages[0].content, "做一个像素动作游戏"); @@ -986,6 +1000,7 @@ fn local_conversation_can_read_and_append_project_and_agent_messages() { .expect("append agent conversation"); assert!(agent .path + .replace('\\', "/") .ends_with(".agent/conversations/agents/design-director.jsonl")); assert_eq!(agent.agent_id.as_deref(), Some("design-director")); assert_eq!( @@ -1229,10 +1244,12 @@ fn agent_conversation_sessions_preserve_legacy_and_isolate_new_history() { assert_eq!(legacy_history.messages[0].content, "legacy history"); assert_eq!(new_history.messages.len(), 1); assert_eq!(new_history.messages[0].content, "new session only"); - assert!(legacy_history.path.ends_with("design-director.jsonl")); - assert!(new_history - .path - .ends_with(&format!("design-director/sessions/{new_session_id}.jsonl"))); + let legacy_history_path = legacy_history.path.replace('\\', "/"); + let new_history_path = new_history.path.replace('\\', "/"); + assert!(legacy_history_path.ends_with("design-director.jsonl")); + assert!(new_history_path.ends_with(&format!( + "design-director/sessions/{new_session_id}.jsonl" + ))); let legacy_context = render_local_conversation_prompt_context_for_session( &root, Some("design-director"), diff --git a/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/storage_windows.rs b/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/storage_windows.rs index b7cef4e2d..de3454007 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/storage_windows.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/storage_windows.rs @@ -906,6 +906,11 @@ pub(super) fn write_ledger_at_windows( temporary_file .sync_all() .map_err(|error| format!("同步 Windows tool-plan 成功响应交接账本失败:{error}"))?; + // The temporary file was opened exclusively so recovery can distinguish an active + // atomic write from a stale temp file. After the rename it is already the primary + // ledger, therefore retaining that exclusive handle makes a fully committed ledger + // briefly unreadable to another runtime thread on Windows. + drop(temporary_file); storage.verify()?; Ok(()) } @@ -1012,11 +1017,19 @@ pub(super) fn list_at_windows( "tool-plan 成功响应交接临时文件 run hash 无效:{file_name}" )); } - remove_windows_tool_plan_file_at( + // A concurrent writer owns atomic temp files exclusively. Seeing one + // during a recovery scan is normal: leave it alone and let that writer + // rename it, while still cleaning stale temps that can be acquired. + let cleanup = remove_windows_tool_plan_file_at( &agent_directory, &file_name, "tool-plan 成功响应交接原子临时文件", - )?; + ); + if let Err(error) = cleanup { + if !error.contains("仍由活跃写入句柄持有") { + return Err(error); + } + } } None => { return Err(format!( diff --git a/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/tests.rs index c2162ab9e..a8eb1ab98 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/tests.rs @@ -770,7 +770,11 @@ fn tool_plan_handoff_reports_file_uri_and_flattened_path_shapes() { "newText": "new", }), "#/path", - "exact-absolute", + if cfg!(windows) { + "exact-platform-absolute" + } else { + "exact-absolute" + }, ), ( serde_json::json!({ @@ -778,7 +782,11 @@ fn tool_plan_handoff_reports_file_uri_and_flattened_path_shapes() { "opaqueProviderField": "/tmp/private.html", }), "#/field", - "exact-absolute", + if cfg!(windows) { + "exact-platform-absolute" + } else { + "exact-absolute" + }, ), ( serde_json::json!({ @@ -786,7 +794,11 @@ fn tool_plan_handoff_reports_file_uri_and_flattened_path_shapes() { "12345678901234567890": "/tmp/private.html", }), "#/field", - "exact-absolute", + if cfg!(windows) { + "exact-platform-absolute" + } else { + "exact-absolute" + }, ), ] .into_iter() @@ -2017,10 +2029,11 @@ fn tool_plan_handoff_list_preserves_exclusively_open_windows_temp_file() { let temp_path = tool_plan_handoff_path(project.path(), &identity.agent_id, &identity.run_id) .with_file_name(&temp_name); - let error = list_at(project.path()).expect_err("exclusive temp must keep recovery busy"); + let ledgers = list_at(project.path()).expect("active exclusive temp must not block recovery"); + assert_eq!(ledgers.len(), 1); assert!( temp_path.exists(), - "active Windows temp must remain: {error}" + "active Windows temp must remain while its writer is alive" ); drop(temp_file); diff --git a/apps/ai-game-creator-shell/tests/agentSwarmTestEntry.test.ts b/apps/ai-game-creator-shell/tests/agentSwarmTestEntry.test.ts index 09a825ff8..37290c083 100644 --- a/apps/ai-game-creator-shell/tests/agentSwarmTestEntry.test.ts +++ b/apps/ai-game-creator-shell/tests/agentSwarmTestEntry.test.ts @@ -583,6 +583,10 @@ describe('terminal configuration wizard persistence', () => { it('moves the default LLM to primary config and lets later GUI saves win', async () => { await withTemporaryRoot(async (root) => { + const persistenceOptions = + process.platform === 'win32' + ? { secureWindowsPath: async () => {} } + : {}; const configDir = path.join(root, appIdentifier); const primaryPath = path.join(configDir, configFileName); const localPath = path.join(configDir, localConfigFileName); @@ -605,7 +609,11 @@ describe('terminal configuration wizard persistence', () => { model: 'wizard-model', apiKind: 'openai_chat', }); - await writeGameCreatorWizardConfig(state, wizardConfig); + await writeGameCreatorWizardConfig( + state, + wizardConfig, + persistenceOptions, + ); const sanitizedLocal = JSON.parse(await readFile(localPath, 'utf8')); expect(sanitizedLocal.llm).toBeUndefined(); @@ -625,7 +633,11 @@ describe('terminal configuration wizard persistence', () => { apiKey: 'gui-key', model: 'gui-model', }; - await writeGameCreatorConfigAtomically(primaryPath, guiConfig); + await writeGameCreatorConfigAtomically( + primaryPath, + guiConfig, + persistenceOptions, + ); const afterGui = await readGameCreatorWizardConfigState(configDir); expect(afterGui.effectiveConfig.llm.apiKey).toBe('gui-key'); expect(afterGui.effectiveConfig.llm.model).toBe('gui-model'); diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts index 225618f58..8dc2312cb 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts @@ -4831,7 +4831,9 @@ export function registerProjectSupervisorSurfaceTests() { within(policyConfirmation).getByRole('button', { name: '确认' }), ); - expect(await screen.findByLabelText('游戏运行')).not.toBeNull(); + expect( + await screen.findByLabelText('游戏运行', {}, { timeout: 3000 }), + ).not.toBeNull(); expect(invoke).toHaveBeenCalledWith('start_local_game_preview', { projectPath, }); diff --git a/apps/ai-game-creator-shell/tests/processSessionRealE2eFixture.test.ts b/apps/ai-game-creator-shell/tests/processSessionRealE2eFixture.test.ts index 01eb4ee0a..0a80c86b2 100644 --- a/apps/ai-game-creator-shell/tests/processSessionRealE2eFixture.test.ts +++ b/apps/ai-game-creator-shell/tests/processSessionRealE2eFixture.test.ts @@ -101,7 +101,11 @@ describe('process-session real E2E fixture', () => { waitForLine((line) => line === `${echoPrefix} ${challenge}`, 'echo'), ).resolves.toBe(`${echoPrefix} ${challenge}`); - expect(child.kill('SIGTERM')).toBe(true); + if (process.platform === 'win32') { + child.stdin.write(`${challenge}:stop\n`); + } else { + expect(child.kill('SIGTERM')).toBe(true); + } await expect( waitForLine((line) => line === stoppedMarker, 'stopped marker'), ).resolves.toBe(stoppedMarker); diff --git a/apps/ai-game-creator-shell/tests/start-tauri-dev.test.ts b/apps/ai-game-creator-shell/tests/start-tauri-dev.test.ts index 43cca5548..65da40de7 100644 --- a/apps/ai-game-creator-shell/tests/start-tauri-dev.test.ts +++ b/apps/ai-game-creator-shell/tests/start-tauri-dev.test.ts @@ -63,6 +63,23 @@ describe('AI 游戏创作 Tauri dev 启动参数', () => { ]); }); + test('单个启动器分隔符后的参数进入应用而不是 Cargo', () => { + expect( + buildTauriArguments( + ['--', '--config-dir', 'C:\\temp\\agc-dev-config'], + testEndpoint.url, + ), + ).toEqual([ + 'dev', + '--config', + '{"build":{"devUrl":"http://127.0.0.1:10005/"}}', + '--', + '--', + '--config-dir', + 'C:\\temp\\agc-dev-config', + ]); + }); + test('game-chat 参数进入应用参数区且保留项目参数', () => { expect( buildTauriArguments( diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index d2dc73a9a..4898c283e 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -923,6 +923,7 @@ game-project/ - 2026-07-28 Project Supervisor 首批协作 repair 累积约定:针对每轮只返回单个 function call 的 Provider,Runtime 从首次触发协作缺口的响应开始,跨文本 JSON、OpenAI Chat tool call 与 OpenAI Responses function call 修复轮次累积合法的 `agent.delegate / agent.spawn_isolated`;同一 `agentId` 以最新响应覆盖旧 action,唯一 `agent.spawn_isolated` 槽位也以最新响应覆盖,禁止把修正版追加成同批第二个 spawn。每轮根据累计结果计算尚缺的静态 Agent,并仅在缺失集合含明确 Agent ID 时收窄下一轮 function schema 的 `agentId` enum;`missingStaticAgents=none` 是空集合哨兵,不是 Agent ID。只有累计首批满足完整协作合同时才成批提交,已满足的 Agent 不得因后续修复重复派发。 - 2026-07-28 pending / provider action 安全持久化约定:自然语言任务中的裸短语 `api key` 不是泄密证据,不能据此拒绝 action;否则 `agent.delegate` 的“不要暴露 External Editor API Key”等安全指令会被误判。API Key 赋值只允许完整受控状态或固定无密钥降级说明,禁止用安全状态前缀放行后续任意内容;`none-but-secret`、`not configured; actual value ...` 等必须失败关闭。Markdown 装饰、反引号或环境限定标签不能改变赋值语义,`**API Key**:`、`` `API Key`: ``、`API Key(生产):` 仍必须进入同一检测。持久化前继续检测结构化 `apiKey / api_key`、`Authorization / Cookie`、`token / Bearer` 标记和已知 secret token 形状,命中真实凭据时仍失败关闭。 - 2026-07-28 Windows Provider retry 恢复修正:`provider_retry::list_at` 从绝对路径剥离项目 root 后,按路径组件重组成 `/` 分隔的 portable UTF-8 相对路径,再交给 Runtime JSON sidecar 读取器。不能直接使用 Windows `Path::to_str()` 的反斜杠文本,否则应用重启、Runner recovery scan 和正式 `--agent-resume` 都无法推进已到期的 `waiting-for-provider-retry` run。全部 provider retry 列举、previous 恢复、去重和路径冲突回归必须在真实 Windows 通过。 +- 2026-08-12 Windows Codex 启动链修正:AGC 不再直接依赖可能命中 WindowsApps shim 的 `codex` 命令,而是逐个执行 `--version` 验证候选,优先发现 npm 安装中的原生 `codex.exe`,并回退到 Codex Desktop 的原生 CLI;app-server 启动参数只关闭当前 CLI 仍支持的 feature flag。可信根 Project Supervisor 尚未冻结 Goal Contract 时,首轮和协议修复轮都只广告 `agent.goal_contract`,禁止计划更新、回复或其它动作抢跑。GUI 显式传入的 `--config-dir` 必须贯穿 Tauri 与 Cargo 的参数分隔并作为应用参数保留,setup 优先复用该目录,避免开发版或发布版误占默认 AppData 的 GUI owner lock。发布验收必须使用 release/安装目录 EXE 启动真实 Runner,并核对 CLI 版本、app-server 生命周期、Goal Contract 持久化、后续项目观察以及最终 completed/idle 状态,不能只以构建成功或 mock 测试代替。 - `npm run agc:test:chat` 未显式指定配置且找不到 AppData 配置时,只在 stdin / stdout 都是 TTY 时询问并启动同一 `agc:config --configure-only` 向导,非 TTY 或显式无效 `--config-dir` 直接失败。测试环境只把主配置和存在时的 local overlay 复制到带随机 sentinel 的单次隔离 AppData;副本必须是独立的无符号链接普通文件,POSIX 权限为目录 `0700` / 文件 `0600`,不复制正式 Runner endpoint、lock 或其它 AppData。自动任务默认 50 分钟且可用 `--timeout-minutes` 显式设置;超时或信号会终止独立子进程树,POSIX 先向进程组发送 `SIGTERM`、等待 10 秒后发送 `SIGKILL` 并再等待 5 秒,Windows 使用 `taskkill /T` 并在强制阶段追加 `/F`。超时和信号分别以 `124 / 130 / 143` 失败退出,隔离 Runner 收束另有 20 秒上限;Runner 未空闲或收束失败时保留隔离配置和项目,验收未完成但 Runner 已安全退出时只保留一次性项目证据,不把中断报告为成功,也不误删正式 AppData。 - 自动验收现在严格要求 manifest 恰好包含固定 16 个不重复 task ID 且全部为 `completed`,并逐任务核对当前父 Run 下唯一 logical run、一次 started、一次 completed、零 failed / cancelled 和一次 manifest projection;七份基础正式产物存在并满足文件 / JSON / 非占位入口检查,配置画布 API Key 时再增加 `art-spec / ui-prototype / art-spritesheet` 三张图片。PNG 验收不止检查 magic / IHDR / 比例,还会校验 chunk CRC、zlib 解压、scanline 长度、索引色 PLTE 和未知 critical chunk。Runtime 根 Supervisor 的完成合同已升级为 `game-creator-autonomous-completion-contract.v2`,`baselineArtifacts` 必填并纳入指纹,旧 v1 或缺基线合同失败关闭;最终门禁要求最后一次验证工具是 `game.static_smoke`、状态通过且 `verifiedRevision == currentRevision`。`preview.validate` 回执必须绑定同一 Agent、run、current revision、当前 `game/index.html` 摘要、固定试玩场景、持久浏览器报告以及 desktop / mobile 两张截图的路径、摘要和 PNG 身份,任一证据缺失、变化、过期或来自其它 run / revision 都阻止最终回复。旧两图合同的确定性证据不替代新三图 DAG 验收;新合同实现后必须新起独立单轮。 - `design-foundation` 已增加专属职责边界:项目文件只允许写 `memory/project.md` 与 `game/game_design.md`;配置 External Editor API Key 且合同要求界面原型时,只额外允许固定 `assets/ui-prototype.png`。它不得创建、修改、删除或补丁 `game/index.html`,不得改动其它程序实现、发布、音频或美术素材,也不得调用 `preview.start`、`preview.validate`、`game.static_smoke`,或借 `command.exec / command.start / command.run_limited` 启动预览服务、浏览器、Playwright 和桌面 / 移动试玩。程序和质量 Agent 的共享 Runtime 工具合同不因此缩减;有 / 无画布配置和其它 Agent 不受影响的聚焦回归为 `3/3` 通过。 @@ -1059,6 +1060,13 @@ game-project/ ## 2026-08-11 通用 Goal Contract 与动态 Acceptance Graph +## 2026-08-13 Windows 本地运行与恢复稳定性 + +- Windows 原子文件事务在 rename 安装成功后必须立即释放临时文件句柄;恢复扫描遇到仍被活跃 writer 独占的临时文件时保留该文件并继续扫描已提交账本,不能让单个 `ERROR_SHARING_VIOLATION / ERROR_LOCK_VIOLATION` 阻断整个恢复。新建目录与安装关键 sidecar 后仍按既有平台能力同步文件和目录,不能把 Windows 目录 `sync_all` 失败误判为业务提交失败。 +- `.agent/project.lock` 的 `create_new` 在 Windows 目标存在或处于 delete-pending 竞争时,可能返回 `ACCESS_DENIED(5)`、sharing violation(32) 或 lock violation(33),这些结果统一投影为“项目正在被其他写操作占用”并进入既有有界等待;其他权限错误继续失败关闭。Runtime 测试若在终态后立即二次恢复,必须同时等待 `status/phase` 终态和 Agent execution lane 释放,不能只观察 state JSON。 +- Windows 子进程启动把 `npm.cmd` 解析为当前 Node 与 `npm-cli.js` 的显式 argv,保留 CRLF/ANSI/ConPTY 处理和 Job Object 生命周期;项目验证使用隔离 Cargo target wrapper,避免开发 GUI 或旧 runner 持有测试需要替换的 EXE。Agent DB 打开继续允许同进程读写共享并修复唯一 JSONL 残尾,不能用默认独占句柄破坏并发读取。 +- Node ESM 脚本必须用 `fileURLToPath()` 把 `import.meta.url` 转为 Windows 本地路径,禁止直接把 URL pathname 交给 `path.resolve()`;真实 agent-run smoke 的浏览器探测覆盖 Windows Chrome/Edge 固定安装位置。开发态 smoke 在旧安装版持有默认 AppData GUI owner 时使用独立 `--config-dir`,不得终止用户现有客户端。 + - 2026-08-12 计划拒绝恢复:结构化 `runtime.plan_update` 被 Runtime 拒绝后,下一轮 Provider 请求按请求级目录收窄到实际项目 mutation 与 `respond_to_user`(已进入协作编排的 Supervisor 保留 `agent.delegate / agent.run_status`),并明确禁止再次规划、读取、搜索或验证;后续已有真实 mutation observation 后解除临时目录,不改变持久 executable policy。 - Goal Contract 绑定 project、可信根 Run Profile、source task SHA-256 和不可变 fingerprint;同一根 Run 只允许幂等重放完全相同的合同,语义变化必须进入新根 Run。已有合同的根 Supervisor 收到 steer 时,Runtime 必须按旧 rootRunId 串行化转换并在持锁后重验 Session 当前权威 Run,再取消并确认旧 rootRunId 的静态、ready、isolated 整棵树已进入终态或 `needs-reconciliation`;旧树未停稳时拒绝启动 replacement,停稳后才在同一 Session、source 和 Run Profile 创建唯一的新根 Run,不能把新增要求塞进旧合同继续完成。合同摘要作为 `decision` 投影到共享黑板,JSON sidecar 才是权威源;黑板冲突条目和专家事实仍追加保留。