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 1c6b2c762..227681696 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 = @@ -1688,6 +1713,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 84ae2ea17..c1878cc71 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 @@ -136,6 +136,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()); @@ -407,8 +409,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 { @@ -417,15 +421,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 { @@ -446,6 +460,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, @@ -647,15 +667,14 @@ mod tests { new_game_creation_app_seed_tasks, provider_command_exec_contract, provider_command_start_contract, render_autonomous_manifest_ready_task_background_prompt, 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( @@ -701,6 +720,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(), @@ -1109,7 +1149,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", "完成可验证游戏") @@ -1153,6 +1193,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"), [ @@ -1164,10 +1205,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 d26240e62..5fb86a613 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 @@ -871,11 +871,14 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at && (force_autonomous_specialist_verification_only || force_autonomous_pending_verification || force_autonomous_reverify_after_mutation); + 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 @@ -914,7 +917,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 3ffecec69..2edc17e9d 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 @@ -311,8 +311,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 ecee106d9..57d1b3f51 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 @@ -1234,12 +1234,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 f35224676..cb4061ea6 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 b59e436f2..bbdfd5c99 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 @@ -4872,7 +4872,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/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index f603b9669..e5f766223 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -7264,7 +7264,7 @@ - 决策:生成请求勾选 `style="pixelArt"` 与已有图片手动 `POST /api/editor/images/pixel-art-snaps` 共用同一输出语义:snapper 直接编码并持久化唯一的逻辑分辨率 PNG,不再 nearest 恢复到源图、RGBA 输入、业务交付或 generation dialog 占位尺寸。成功输出宽高固定为 `(columns.len() - 1) × (rows.len() - 1)`,允许与输入、交付和占位尺寸不同;响应、project resource、账号素材和结果 layer 一律记录最终 PNG 的实际宽高。 - 保留边界:普通图片和角色在规整前执行的 Lanczos 交付尺寸归一继续保留;角色 / 图标的平底网格分析源与透明 RGBA 采样源仍必须同尺寸,Alpha 覆盖、Alpha 加权 RGB、二值 Alpha、P30 步长估算、确定性采样、输入上限、deadline、strict 无网格拒绝和失败降级尺寸守卫全部不变。这些约束保护输入坐标系、资源安全或失败路径,不构成成功输出与输入同尺寸的承诺。 - 持久化边界:数量增量保持不变。普通图片只保存一张最终逻辑主图;角色保留一张 provider 原图与一张最终透明逻辑主图;图标保留一张 provider 原图、一张最终透明逻辑图集和原有成功切片;手动完美像素只保存一张最终逻辑 PNG。不得另外保存输入尺寸恢复版、像素化前后双份主图、预览、诊断或报告,不修改 asset kind、队列类型、数据库 schema、路由或请求 / 响应字段形状。 -- 跨版本重放:手动入口算法指纹升为 `perfect-pixel-v2`。同一稳定 operation 已有结果时,candidate object key 相同才继续既有 exact replay;key 不同或既有稳定资源缺 key 时,必须在 preflight 与 OSS PUT 前返回 `409 + operationResultAlreadyExists=true`,由客户端 GET 权威项目收口,不得冒充本次请求已经设置 `resultPersistenceStarted`。preflight 到最终提交之间仍无数据库 reservation,滚动发布必须排空旧算法实例,不能把该护栏解释为消除了并发 TOCTOU。 +- 跨版本重放:手动入口算法指纹升为 `perfect-pixel-v2`。完成请求基础校验、owner-scoped 项目读取与占位验证后,只要同一稳定 `resourceId` 已存在,即在来源解析、OSS GET、像素规整、candidate object key、preflight 与 OSS PUT 前返回 `409 + operationResultAlreadyExists=true` 及已鉴权的稳定 `resultResourceId`,由客户端 GET 权威项目收口;前端按稳定资源 ID 定位后继续复核 task、项目、对象与 dialog/layer 关联,损坏 task 必须明确失败关闭为冲突,不得漏检后持续等待。不再按 candidate object key 继续 exact replay。该分支不带 `resultPersistenceStarted`,因为本请求尚未开始持久化。preflight 到最终提交之间仍无数据库 reservation,滚动发布必须排空旧算法实例,不能把该护栏解释为消除了并发 TOCTOU。 - 历史边界:本条覆盖 2026-07-28 首发决策中“逻辑结果 nearest 恢复交付尺寸 / 逻辑图不持久化”和 2026-07-30 手动入口中“右侧新增同尺寸 PNG / 不保存逻辑低分辨率图”的旧口径;旧条目作为历史记录保留,不回写改造。 - 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`、`docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md`、`docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md`、`docs/【编辑器】图片画布结构化持久化与迁移回滚方案-2026-07-19.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。 diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index db8847913..5c9479dc3 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -66,8 +66,8 @@ - 前端提交前先创建关闭 composer 的右侧生成占位,再解析或上传源图以取得稳定引用,随后把版本化 `perfectPixelOperation` 请求快照写入**本机账本**(占位本身只带 `perfectPixelOperationId` 标记)并 flush 当前项目布局,最后才发送 POST。`canvasCompletion.dialogId` 同时作为 operation identity、稳定 task identity 的输入和本地源图上传 ID;同一 operation 的上传路径与后续 POST 请求都不得随机漂移。`sourceImageSrc` 优先由当前图层已有的 `objectKey / resourceId / sourceAssetId` 解析;尚未登记的浏览器本地图片只执行 `ticket → OSS PUT → confirm → objectKey`,不为这条持久化输入换取 signed URL。一个 `AbortSignal` 必须贯穿源文件 fetch / 图片解析边界、ticket、PUT、confirm,完整上传 helper 的可选换签也必须透传同一 signal。正式请求不得包含 `data:` / `blob:`、signed URL 或普通外链。后端在读取源图前必须把该字段解析为当前 owner 已登记的私有 OSS object key,并核对 project / resource / asset 归属。 - 源准备与 operation journal 使用两段绝对预算:`ticket → PUT → confirm` 连同源解析共用 90 秒;confirm 成功后形成稳定 `perfectPixelOperation` 并**同步写入本机账本**(`perfectPixelOperationStore`,owner + project 双键的 localStorage),布局里只留 `perfectPixelOperationId` 标记。原先的 strict layout save 通道(60 秒绝对预算、revision ACK 前 POST 为零)已整体删除:账本不再寄生在用户布局上,本机写入不过网络也不受服务端校验影响,同样能保证请求可被追溯。被解除的是**客户端侧**「拿不到 revision ack 就拒发」这一层阻断;端到端依赖仍在——布局 PATCH 被校验拒绝、占位因此从未落库时,POST 仍会被服务端以 409 拒收。POST 前仍然 `await` 一次 best-effort 布局保存——服务端要求占位**此前已经持久化**,否则 `validate_editor_pixel_art_snap_placeholder_exists` 直接 409;但 best-effort 不再提供成功 ACK,因此客户端**无法证明**该前置已满足,只能提高满足它的概率(占位可能已由此前的自动保存落库,PATCH 也可能成功而 ACK 丢失)。该 flush 没有整体上限,所以 75 秒对账窗口必须在 flush 返回、authority 复核通过之后才锚定,且首次提交与人工重试同此口径;锚定只覆盖 `submittedAt / reconcileUntil`,按同一 `operationId` 覆盖账本,request 与 dialog / operation / task identity 逐字节不变。此阶段失败持久化为 `failed + perfectPixelOperation`,保留同一 `sourceImageSrc / dialogId / taskId / request`;重试请求必须与账本中的 POST JSON byte-for-byte 一致且不得重新上传。**明确接受的行为,不是缺口**:占位恢复可删除之后,用户删掉未收口占位再从源图发起会得到第二个 identity,旧的服务端操作若迟到落库就会多出一份素材,两个 `taskId` 无法幂等合并。按上文的优先级判据,这属于「已生成资源丢失关联」而非主链路故障,代价是用户自行删掉多余素材,**不得**通过让本机账本参与防重来「闭合」——那是被明令禁止的「禁止一张图处理两遍」。confirm 成功后浏览器在 operation 首次 PATCH 落库前立即崩溃仍可能留下 object-only 记录;完全消除该窗口需要服务端 durable upload journal,不属于当前前端修复。 - 该已有图片入口使用 strict 语义:只接受静态 PNG / JPEG / WebP,GIF、APNG、动画 WebP、图片序列及其它非静态媒体必须在处理前拒绝。strict 与生成风格复用完全相同的 legacy profile、峰值估算、单轴步长补全、walker、采样和编码;仅当横纵两轴都未检测到步长、legacy 即将使用 `min(width,height)/64` 统一网格兜底时拒绝。任一轴已检测到步长时,两条路径行为和输出必须一致。源图读取、解码、尺寸校验、排队、像素规整或 PNG 编码任一步失败 / 超时 / 不适用时,请求失败,不保留原图副本冒充成功,不执行最终 OSS PUT,也不创建 project resource、账号素材或结果图层。成功时只对唯一的逻辑分辨率 PNG 执行一次 OSS PUT,并至多各创建一个 `editor_project_resource` 和一个 `editor_asset`,再按 `canvasCompletion` 写回一个派生图层;resource、asset、响应与图层使用该 PNG 的实际宽高,不要求与源图或占位尺寸相等,也不得另存输入尺寸恢复版、诊断图或前后对比图。 -- strict 的本次结果事实零写入边界截至首个最终 PNG PUT:所有可预判的引用、归属、类型、静态编码、元数据、网格适用性和 CPU 处理错误必须在此前失败;前置 owner-scoped 项目 / 素材读取仍可能按既有语义懒建默认 canvas / folder,这些基础记录不属于本次完美像素结果。后端先纯计算精确 object key 和候选 project resource,再调用只读 SpacetimeDB preflight 校验自定义素材目录归属、复用权威 completion planner,并执行 legacy / structured 的 2 MiB 总量与 512 KiB 单项门禁;默认目录尚未创建时允许通过,preflight 不写库。preflight 与 PUT / HEAD / 原子 persist 共用 60 秒绝对 deadline;preflight 失败或超时不得 PUT,也不得带 `resultPersistenceStarted`。最终 PNG 的 OSS PUT / HEAD 位于数据库事务外;验证上传结果后,asset object、project resource、账号素材与可选 canvas completion 由单个受 runtime service identity 保护的 SpacetimeDB procedure 在一次事务中原子提交,并重新校验目录、布局、幂等身份与 revision。preflight 不加锁或 reservation,所以通过后若目录或画布并发漂移,最终事务仍可能在 PUT 后拒绝并留下 OSS 孤儿对象;这是本次最小修复明确保留的 TOCTOU 边界。operation 以 `owner + project + canvasCompletion.dialogId` 为作用域,task / object / resource / asset ID 稳定派生,object key 携带规范请求与输入 / 输出摘要形成的 fingerprint;同内容重放只返回原结果,输入漂移或部分既有事实失败关闭。HTTP timeout/drop 不能撤销已发往远端的 procedure,客户端仍须按稳定 `taskId / objectKey / resourceId` 对账,不能把未收到回包等同于未提交。 -- 手动入口的算法指纹随逻辑分辨率输出升级为 `perfect-pixel-v2`。若 owner-scoped 项目快照中同一稳定 resource 已存在,candidate object key 相同才继续 exact replay;key 不同或既有 resource 缺 key 时,后端必须在 preflight / OSS PUT 前返回 `operationResultAlreadyExists=true`,前端 initial 与 retry 两条 catch 都按稳定 task GET 项目对账。该标记表示旧权威结果已存在,不得与“本次 PUT 已开始”的 `resultPersistenceStarted` 混用;发布时仍须排空旧算法实例以规避 preflight 到提交之间的跨版本 TOCTOU。 +- strict 的本次结果事实零写入边界截至首个最终 PNG PUT:所有可预判的引用、归属、类型、静态编码、元数据、网格适用性和 CPU 处理错误必须在此前失败;前置 owner-scoped 项目 / 素材读取仍可能按既有语义懒建默认 canvas / folder,这些基础记录不属于本次完美像素结果。后端先纯计算精确 object key 和候选 project resource,再调用只读 SpacetimeDB preflight 校验自定义素材目录归属、复用权威 completion planner,并执行 legacy / structured 的 2 MiB 总量与 512 KiB 单项门禁;默认目录尚未创建时允许通过,preflight 不写库。preflight 与 PUT / HEAD / 原子 persist 共用 60 秒绝对 deadline;preflight 失败或超时不得 PUT,也不得带 `resultPersistenceStarted`。最终 PNG 的 OSS PUT / HEAD 位于数据库事务外;验证上传结果后,asset object、project resource、账号素材与可选 canvas completion 由单个受 runtime service identity 保护的 SpacetimeDB procedure 在一次事务中原子提交,并重新校验目录、布局、幂等身份与 revision。preflight 不加锁或 reservation,所以通过后若目录或画布并发漂移,最终事务仍可能在 PUT 后拒绝并留下 OSS 孤儿对象;这是本次最小修复明确保留的 TOCTOU 边界。operation 以 `owner + project + canvasCompletion.dialogId` 为作用域,task / object / resource / asset ID 稳定派生,object key 携带规范请求与输入 / 输出摘要形成的 fingerprint;一旦 owner-scoped 项目快照已发现同 operation 的稳定 resource,本次 POST 不再执行 candidate-key exact replay,而是直接返回 `operationResultAlreadyExists=true` 并交由 GET 对账;输入漂移或部分既有事实失败关闭。HTTP timeout/drop 不能撤销已发往远端的 procedure,客户端仍须按稳定 `taskId / objectKey / resourceId` 对账,不能把未收到回包等同于未提交。 +- 手动入口的算法指纹随逻辑分辨率输出升级为 `perfect-pixel-v2`。在完成请求基础校验、owner-scoped 项目读取与占位验证后,只要同一稳定 `resourceId` 已存在,后端必须在来源解析、OSS GET、像素规整、candidate object key、preflight 与 OSS PUT 前返回 `409 + operationResultAlreadyExists=true`,并携带已鉴权的稳定 `resultResourceId`;不再按 candidate object key 继续 exact replay。前端 initial 与 retry 两条 catch 都按稳定资源与 task GET 项目对账,由权威快照明确 `applied`、`dialog-missing` 或 `conflict`;即使稳定记录的 `taskId` 损坏,也必须据 `resultResourceId` 找到该记录并失败关闭为冲突,不得持续等待。该标记表示旧权威结果已存在,不得与“本次 PUT 已开始”的 `resultPersistenceStarted` 混用;发布时仍须排空旧算法实例以规避独立新操作在 preflight 到提交之间的跨版本 TOCTOU。 - `POST /api/editor/images/pixel-art-snaps` 是有副作用的 unsafe POST。客户端不得为它配置 `EDITOR_REQUEST_RETRY_OPTIONS`,请求字节可能已发出后不因 transport 异常或 `408 / 425 / 429 / 502 / 503 / 504` 自动重放;Bearer 中间件在 handler 前以 `401` 拒绝、刷新 token 后的既有认证恢复不属于业务副作用重放,保持通用行为。POST 回包中的 `project / resource / asset` 不是结果 verdict;首次成功回包、未知异常、人工 exact replay 和刷新恢复都只读取项目 GET。`perfectPixelOperation.submittedAt / reconcileUntil` 在 pre-POST flush 返回、authority 复核通过之后、POST 发出之前建立统一 75 秒绝对窗口(该 flush 没有整体上限,锚在它之前会让窗口在请求发出前就烧光),POST 回包不能续期;读取必须立即执行一次,随后退避间隔不超过 5 秒,窗口已过期时仍执行一次即时 GET。每次项目读取使用 `requestJson.deadlineAt` 覆盖缺 token 补票、业务 fetch、401 refresh、重试退避与响应体读取;窗口内单次最多 10 秒且不得越过 `reconcileUntil`,过期后的唯一即时读取最多额外 10 秒。固定判据为:匹配 task 的唯一 resource 加已收口 dialog / 关联图层才是画布成功;dialog 不存在但存在匹配 task resource 才是 asset-only 成功;dialog 仍 generating、dialog 不存在且无匹配 resource、项目始终不可读或窗口耗尽均保持 unknown。素材库刷新只在项目终态后 fire-and-forget,同步抛错、异步拒绝或永久挂起都不得阻塞 verdict、项目快照应用和执行锁释放。 - unknown 状态持久化为原 generation dialog 上的 `pending-confirmation + perfectPixelOperation`(账本在本机,布局只留 `perfectPixelOperationId`)。**用户可以随时删除该占位**,任何状态都不例外、也不弹确认:删除不撤销任何在途请求,结果照常落库并进素材库,服务端发现 dialog 已不在会返回 `DialogMissing`;封锁用户删除自己画布上的元素不是可接受的代价。删除后**结果不再自动回填画布**(服务端发现 dialog 已不在会返回 `DialogMissing`),这是用户主动放弃的结果,不得判定为缺陷;但对账本身不会因此停止——当前标签页已经在飞的 Promise 会继续读到终态,本机账本也会以孤儿身份在下次加载被读一次,结果确已落库时仍会提示用户去素材库取。未删除时用户可继续 GET 对账或显式按原 identity 重放。人工重试在 pre-POST flush **之后**才刷新观察窗口(同上一节的锚定口径),POST JSON 必须与持久请求 byte-for-byte 一致,不得按当前画布、目录、类型或标题重建,也不得创建第二个 dialog / task / object / resource / asset。hydrate 后只做 GET,不自动 POST、上传或重建请求。处理成功但事务内权威 dialog 已删除时,后端保留 object / resource / asset 并返回 asset-only 事实,canvas / revision 不变;前端只有在项目 GET 看见匹配 task resource 后才能提示“已保存到素材库”。现有布局 CAS 没有 deletion tombstone,completion 与其它已持久化布局编辑冲突时继续按权威 revision 守卫收口;尚未防抖落库的本地编辑合并不在本批范围。 - 删除 generation dialog 的按钮、快捷键和右键菜单必须在写画布历史、清选择或执行低层移除前经过同一请求保护入口。未收口完美像素 operation 与其它占位同样可被立即删除,写正常的 `delete-generation-result` 历史并清理 identity;删除确认只对**计费**生成成立(现成弹窗讲的是「已消耗的泥点不会返还」,而完美像素 `generation_cost_mud_points = 0`),判据收敛为具名的 `requiresGenerationDeleteConfirmation`。低层 `removeCanvasGenerationDialogById` 必须无条件删除——低层对上层抗命正是「占位未删却写出伪历史」的根因。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 7e4698ba1..5391b3aef 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -924,6 +924,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 验收;新合同实现后必须新起独立单轮。 - 2026-08-11 M0-3 将固定 owner 产物验证与可玩验收分离。真实 `init_local_game_project_at` 项目没有 `package.json`,默认 `game/index.html` 是无活动 `` 的占位页;`design-foundation / balance-seed / art-asset-plan / audio-asset-plan` 又全部位于 `code-prototype` 上游,因此 `project.verify` 不可用,`game.static_smoke` 只能检查尚未生成的游戏并必然失败。测试不得预写 `fake_llm_game_draft()` 把占位页替换成可玩页面后再证明活性;该夹具会提前完成下游职责并掩盖真实新项目死锁。 @@ -1064,6 +1065,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 才是权威源;黑板冲突条目和专家事实仍追加保留。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index d3c9496aa..07a0d37aa 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -249,12 +249,12 @@ npm run check:server-rs-ddd - 图标规范结构化分析里位于 `` / `` XML 元素内的数据必须转义 `& < > " '`;玩法润色、美术风格润色、规范图生图和图标 spritesheet 等自然语言 prompt 必须保留已经过边界校验的原文。图标 spritesheet 的 `iconDescriptions` 在请求边界执行独立合同:原始数组满足 OpenAPI `1..100`,去空后至少保留 1 条;单条最多 `200` 个 Unicode 字符、拼接后合计最多 `2000` 个 Unicode 字符且不超过 `6144` 个 UTF-8 字节;只有 `ValidatedEditorIconSpritesheetPrompt` 能进入 prompt builder,External v1 超限同步返回 `400`。 10. 已有静态图片的 `POST /api/editor/images/pixel-art-snaps` 是免费 inline 派生操作,不调用外部 provider、不创建 `external_generation_job`、不读写泥点 ledger,也不进入任务侧栏。免费不放宽 owner、稳定引用、输入上限、持久化或处理阶段零持久化门禁。 11. 主站编辑器生成队列使用同一次前端请求稳定复用的 `x-request-id`,按 namespace + owner + job kind + request id 生成唯一 `dedupe_key`;首次请求已入队但响应丢失时,重试必须返回原任务。同一幂等键携带不同 payload 返回 `409`,不得创建第二个任务或串到旧结果。外部 v1 的 `Idempotency-Key` 使用独立 namespace,不能与主站请求标识碰撞。幂等 payload 比较只对本次已迁移 sanitizer 的图片生成、图片修改、去背景、图标图集和 UI 提取任务,兼容“升级前旧任务仍含客户端 `generationInputs.references`、当前请求已删除该字段”的单向形状;当前请求仍含 references,或 job kind 属于音频 / 视频 / 角色动作等未迁移任务时必须完整比较,其余请求字段始终完全一致。 -12. `generationInputs.references` 是最终资产的服务端权威行引用,不接受客户端自报 provenance。图片生成类请求入队、完美像素及直接创建资源 / 素材时删除客户端 references;worker 和 inline 路径按本次真实参考图、当前 owner 的项目资源 / 素材记录重建 `refType/refId` 后再持久化。仅能证明 owned objectKey、但找不到对应资源或素材行时可以参与生成,不得制造虚假行引用;`title/label` 只作为展示快照,不提升为资源身份。完美像素为兼容升级前的未知结果重放,可继续用旧版 canonical 客户端输入计算 operation fingerprint;新操作持久化元数据只能使用服务端重建值,检测到 owner 项目中已存在同一稳定 task/resource 的历史结果时则复用该服务端既存 metadata 完成精确 compare-and-return。 +12. `generationInputs.references` 是最终资产的服务端权威行引用,不接受客户端自报 provenance。图片生成类请求入队、完美像素及直接创建资源 / 素材时删除客户端 references;worker 和 inline 路径按本次真实参考图、当前 owner 的项目资源 / 素材记录重建 `refType/refId` 后再持久化。仅能证明 owned objectKey、但找不到对应资源或素材行时可以参与生成,不得制造虚假行引用;`title/label` 只作为展示快照,不提升为资源身份。完美像素为兼容升级前的未知结果重放,可继续用旧版 canonical 客户端输入计算 operation fingerprint;新操作持久化元数据只能使用服务端重建值。owner-scoped 项目快照发现同一 operation 的稳定 result `resourceId` 时,HTTP 路径必须在来源解析、OSS 下载、规整、preflight 和 PUT 前直接返回 `409`,携带 `operationResultAlreadyExists=true` 与 `resultResourceId`,客户端仅以 GET-only 项目对账判定权威结果,不复用既存 metadata 作 exact compare-and-return。 ## 外部服务与资产 - 已有图片完美像素化:登录态 `POST /api/editor/images/pixel-art-snaps` 使用 `sourceImageSrc` 承载 `objectKey / resourceId / assetId` 候选稳定引用,要求 `projectId / canvasCompletion` 且 `canvasCompletion.dialogId` 必须非空,并可携带 `sourceResourceId / assetKind / generationInputs / assetFolderId / assetLabel`;BFF 必须在下载前将候选解析为当前 owner 已登记的私有 OSS object key,并校验 project / resource / asset 归属,拒绝 `data:` / `blob:`、signed URL、普通外链和音频、视频、图片序列等非静态栅格输入。归属校验有两条等价路径:带 `sourceResourceId` 且 `sourceImageSrc` 能免查确认指向同一张图(本身即该 objectKey 或就是该 resourceId)时,来源资源已随 owner-scoped 项目读取完成鉴权,直接断言 `resource.ownerUserId` 与 `resource.projectId` 后取用其 objectKey,不再按注册 ID 做全账号项目与素材库扫描;两个字段指向不同图片必须直接拒绝而不是退回扫描。其余情况仍走完整解析。跨记录的 asset_kind 扫描随扫描一并省略,按 `(bucket, objectKey)` 的存储类型点查两条路径都保留,动图仍由下载后的静态编码门禁按实际字节拒绝。编码门禁只接受静态 PNG / JPEG / WebP,明确拒绝 GIF、带 `acTL` 的 APNG 及带动画标志 / `ANIM` / `ANMF` chunk 的 WebP。处理复用 `platform-image` 纯内存 snapper、单边 `10000` 与总像素 `8294400` 上限,并发控制分两层:端点级并发闸最大 `4`、等待队列上限 `2048`,在首次 IO 之前取得,队列满返回 `503` 并带 `Retry-After`,等待超预算返回 `504`;内层是与生成风格共享的进程级 CPU 并发 `2`。30 秒总预算从 handler 入口起算,覆盖归属校验读取、OSS 下载、两层排队与规整全过程。OSS 读写共用带 `connect 10s / total 120s` 的进程级 HTTP 客户端。strict 与生成风格使用完全相同的 legacy profile、峰值估算、单轴步长补全、walker、采样和编码,唯一差异是横纵两轴都未检测到步长时,不执行 `min(width,height)/64` 统一网格兜底而返回不适用。任一轴已检测到步长时,两条路径行为和输出必须一致。读取、解码、校验、排队、规整、PNG 编码任一步失败 / 超时 / 不适用时,在最终持久化前返回错误,OSS PUT、asset object、project resource、账号素材和画布 layer 增量都必须为零。成功结果保留源图,只对最终 PNG 做一次 OSS PUT,并至多各创建一个 `editor_project_resource` 和一个 `editor_asset`;源图已有正式 project resource 时,结果资源以 `source_resource_id` 关联该资源,再按 `canvasCompletion` 尝试写入一个右侧派生 layer。completion 读取的权威 dialog 已删除时沿用现有语义跳过画布写入,不得用请求中的旧 placeholder 复活图层;已经成功落库的 resource / asset 可以保留。客户端回包时若本地 dialog 已删除,不应用完成快照;现有布局 CAS 没有 deletion tombstone,completion 先提交、删除保存后冲突的极端竞态仍按权威快照收口。客户端不得为该 unsafe POST 配置 `EDITOR_REQUEST_RETRY_OPTIONS`,请求字节可能已发送后不因 transport 异常或 `408 / 425 / 429 / 502 / 503 / 504` 自动重放;Bearer 中间件在 handler 前拒绝请求后的既有认证恢复继续保留。结果未知时先 GET 权威项目 / 素材快照。 -- 完美像素持久化边界:所有可判定的稳定引用、owner、项目、来源资源、素材类型、静态编码、元数据、网格适用性、排队、CPU、解码、规整和编码校验都必须在首个最终 PNG PUT 前完成。handler 先用纯 prepare 生成精确 object key 和候选 project resource,再调用只读 `preflight_editor_pixel_art_result_and_return`;preflight 校验自定义素材目录归属(尚未创建的默认目录允许通过)、复用权威 canvas completion planner,并对 legacy / structured 候选布局执行 2 MiB 总量和 512 KiB 单项门禁。preflight 与后续 PUT / HEAD / 原子 persist 共用同一份 60 秒绝对 deadline;preflight 失败或超时不得发送 PUT,也不得附加 `resultPersistenceStarted`。最终 PNG 的 OSS PUT / HEAD 仍位于数据库事务外;确认上传结果后,`asset_object + editor_project_resource + editor_asset + optional canvas completion` 必须由 `persist_editor_pixel_art_result_and_return` 在一次 `try_with_tx` 中原子提交,handler 不得先调用 `confirm_asset_object` 或三个旧分段 helper。最终 procedure 必须重新校验目录、布局、幂等身份和 revision,不能把 preflight 结果当成提交凭证。preflight 不创建锁或 reservation,因此通过后若目录或画布被并发修改,最终事务仍可能在 PUT 后拒绝并留下无引用 OSS object;当前不做破坏性删除补偿或历史孤儿清理。该原子保证只覆盖本次结果事实;前置 owner-scoped 项目 / 素材读取仍可沿用既有默认 canvas / folder 懒建语义,不把整个请求声明为数据库只读。operation 以规范化 `canvasCompletion.dialogId` 表示并由 owner / project 限定作用域;task ID 可由前端直接推导,object / resource / asset ID 按同一 operation 稳定派生,object key 必须包含覆盖规范输入、来源 / 输出摘要与算法版本的 64 位 fingerprint。完整同内容既有记录只读返回 `AlreadyApplied`,不得再次执行 layout CAS 或推进 revision;同 operation 输入漂移、稳定 ID / object location 冲突或 object/resource/asset 只有部分存在时必须整笔失败关闭并映射 `409`,不得补写或覆盖第一次事实。权威 dialog 已删除时 object/resource/asset 仍在同一事务提交,canvas / revision 不变并返回 `DialogMissing`。HTTP timeout/drop 不能撤销已经发往远端的 procedure,因此首个 PUT 后仍设置 `resultPersistenceStarted=true` 并按稳定身份对账;该标记不再表示数据库可能部分提交。 +- 完美像素持久化边界:所有可判定的稳定引用、owner、项目、来源资源、素材类型、静态编码、元数据、网格适用性、排队、CPU、解码、规整和编码校验都必须在首个最终 PNG PUT 前完成。handler 先用纯 prepare 生成精确 object key 和候选 project resource,再调用只读 `preflight_editor_pixel_art_result_and_return`;preflight 校验自定义素材目录归属(尚未创建的默认目录允许通过)、复用权威 canvas completion planner,并对 legacy / structured 候选布局执行 2 MiB 总量和 512 KiB 单项门禁。preflight 与后续 PUT / HEAD / 原子 persist 共用同一份 60 秒绝对 deadline;preflight 失败或超时不得发送 PUT,也不得附加 `resultPersistenceStarted`。最终 PNG 的 OSS PUT / HEAD 仍位于数据库事务外;确认上传结果后,`asset_object + editor_project_resource + editor_asset + optional canvas completion` 必须由 `persist_editor_pixel_art_result_and_return` 在一次 `try_with_tx` 中原子提交,handler 不得先调用 `confirm_asset_object` 或三个旧分段 helper。最终 procedure 必须重新校验目录、布局、幂等身份和 revision,不能把 preflight 结果当成提交凭证。preflight 不创建锁或 reservation,因此通过后若目录或画布被并发修改,最终事务仍可能在 PUT 后拒绝并留下无引用 OSS object;当前不做破坏性删除补偿或历史孤儿清理。该原子保证只覆盖本次结果事实;前置 owner-scoped 项目 / 素材读取仍可沿用既有默认 canvas / folder 懒建语义,不把整个请求声明为数据库只读。operation 以规范化 `canvasCompletion.dialogId` 表示并由 owner / project 限定作用域;task ID 可由前端直接推导,object / resource / asset ID 按同一 operation 稳定派生,object key 必须包含覆盖规范输入、来源 / 输出摘要与算法版本的 64 位 fingerprint。owner-scoped 项目快照发现同一 operation 的稳定 result `resourceId` 时,HTTP 路径必须在来源解析、OSS 下载、规整、preflight 和 PUT 前直接返回 `409`,携带 `operationResultAlreadyExists=true` 与 `resultResourceId`,并由客户端 GET-only 对账;本次请求不得附加 `resultPersistenceStarted`。`AlreadyApplied` 仅在 early guard 与最终 procedure 并发相遇时作为底层幂等兜底,复用既有 commit 且不得再次执行 layout CAS 或推进 revision;同 operation 输入漂移、稳定 ID / object location 冲突或 object/resource/asset 只有部分存在时必须整笔失败关闭并映射 `409`,不得补写或覆盖第一次事实。权威 dialog 已删除时 object/resource/asset 仍在同一事务提交,canvas / revision 不变并返回 `DialogMissing`。HTTP timeout/drop 不能撤销已经发往远端的 procedure,因此首个 PUT 后仍设置 `resultPersistenceStarted=true` 并按稳定身份对账;该标记不再表示数据库可能部分提交。 - 完美像素 unknown 与并发闸测试边界:上一条末句“结果未知时先 GET 权威项目 / 素材快照”的旧表述已撤回,项目 GET 才是唯一结果 verdict;素材刷新只允许在项目终态后 best-effort 触发,不能参与成功判断。无 dialog 只有同时存在匹配稳定 task 的唯一 resource 时才是 asset-only 成功,否则保持 unknown。过期预算用例只断言返回 `504`,不得读取进程级 `EDITOR_PIXEL_ART_SNAP_QUEUE_DEPTH` 的 before/after;queue guard 的 Drop 归还由独立用例覆盖。不得用相对断言、`--test-threads=1` 或全局串行锁掩盖并行竞态。 - LLM:通用 LLM 门面继续使用 `GENARRATIVE_LLM_*`;`platform-llm` 文本请求默认走 Responses,旧 `/api/llm/chat/completions` 代理和少数旧运行态聊天显式保留 Chat Completions 兼容协议;创意 Agent `gpt-5` Responses / Chat Completions 文本链路已于 2026-06 从 APIMart 迁移到 VectorEngine,使用 `VECTOR_ENGINE_BASE_URL` / `VECTOR_ENGINE_API_KEY` 构造 OpenAI-compatible client,`api-server` 会把未带 `/v1` 的 VectorEngine base URL 规范化到 `/v1` 后请求 `/responses`。`APIMART_BASE_URL` / `APIMART_API_KEY` 只作为历史残留,不再作为创意 Agent gpt-5 客户端来源;后续排障时优先确认 VectorEngine `/v1/models`、`/v1/chat/completions` 和 `/v1/responses` 可用性。 - LLM:通用 LLM 门面继续使用 `GENARRATIVE_LLM_*`;创意 Agent `gpt-5.4-mini` Chat Completions 文本链路已于 2026-06 从 APIMart 迁移到 VectorEngine,使用 `VECTOR_ENGINE_BASE_URL` / `VECTOR_ENGINE_API_KEY` 构造 OpenAI-compatible client,`api-server` 会把未带 `/v1` 的 VectorEngine base URL 规范化到 `/v1` 后请求 `/chat/completions`。通用 `/api/llm/chat/completions` 代理使用 `GENARRATIVE_LLM_PROVIDER=openai-compatible`、`GENARRATIVE_LLM_BASE_URL=https://api.vectorengine.cn/v1`、`GENARRATIVE_LLM_MODEL=gpt-5.4-mini`;未单独配置 `GENARRATIVE_LLM_API_KEY` 时可复用 `VECTOR_ENGINE_API_KEY`。`APIMART_BASE_URL` / `APIMART_API_KEY` 只作为历史残留,不再作为创意 Agent gpt-5.4-mini 客户端来源;后续排障时优先确认 VectorEngine `/v1/models`、`/v1/chat/completions` 和 `/v1/responses` 可用性。 diff --git a/scripts/check-maintenance-page.mjs b/scripts/check-maintenance-page.mjs index f0ba8e54f..0c1ece88e 100644 --- a/scripts/check-maintenance-page.mjs +++ b/scripts/check-maintenance-page.mjs @@ -19,7 +19,14 @@ import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const repoRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '..', +); +const bashExecutable = + process.platform === 'win32' + ? 'C:\\Program Files\\Git\\bin\\bash.exe' + : 'bash'; const failures = []; const requestedFiles = []; @@ -48,7 +55,10 @@ function validateDefaultPage(filePath) { } for (const [pattern, label] of [ [/(?:今天|今晚|明天|昨天|昨日)/u, '相对日期'], - [/(?:20\d{2}[-/.年]\d{1,2}(?:[-/.月]\d{1,2}日?)?|\d{1,2}月\d{1,2}日)/u, '具体日期'], + [ + /(?:20\d{2}[-/.年]\d{1,2}(?:[-/.月]\d{1,2}日?)?|\d{1,2}月\d{1,2}日)/u, + '具体日期', + ], [/(?:[01]?\d|2[0-3]):[0-5]\d/u, '具体维护时间'], ]) { if (pattern.test(source)) { @@ -58,15 +68,48 @@ function validateDefaultPage(filePath) { } function runScript(scriptPath, args, env) { - return spawnSync('bash', [scriptPath, ...args], { + const bashArgs = args.map((arg) => + path.isAbsolute(arg) ? toBashPath(arg) : arg, + ); + const bashEnv = Object.fromEntries( + Object.entries(env).map(([key, value]) => [ + key, + path.isAbsolute(value) ? toBashPath(value) : value, + ]), + ); + const envAssignments = Object.entries(bashEnv).map( + ([key, value]) => `${key}=${value}`, + ); + const commandArgs = [ + ...envAssignments, + 'bash', + toBashPath(scriptPath), + ...bashArgs, + ]; + const command = `exec env ${commandArgs.map(shellQuote).join(' ')}`; + return spawnSync(bashExecutable, ['-c', command], { cwd: repoRoot, - env: { ...process.env, ...env }, + env: process.env, encoding: 'utf8', }); } +function shellQuote(value) { + return `'${String(value).replaceAll("'", `'"'"'`)}'`; +} + +function toBashPath(filePath) { + const windowsDrive = /^([A-Za-z]):[\\/](.*)$/u.exec(filePath); + if (windowsDrive) { + return `/${windowsDrive[1].toLowerCase()}/${windowsDrive[2].replaceAll('\\', '/')}`; + } + return filePath; +} + function validateRuntimePageLifecycle() { - const tempRoot = mkdtempSync(path.join(os.tmpdir(), 'genarrative-maintenance-')); + const tempRoot = mkdtempSync( + path.join(os.tmpdir(), 'genarrative-maintenance-'), + ); const markerFile = path.join(tempRoot, 'state', 'enabled'); const runtimePageFile = path.join(tempRoot, 'state', 'page.html'); const sourcePageFile = path.join(tempRoot, 'announcement.html'); @@ -81,6 +124,9 @@ function validateRuntimePageLifecycle() { if (!onScriptSource.includes('replace_file_atomically')) { fail('maintenance-on 必须通过统一 helper 原子替换公告页和 marker。'); } + if (!onScriptSource.includes('install -m 0644')) { + fail('maintenance-on 安装运行态公告页时必须显式设置 0644 权限。'); + } if (/\bmv\s+-[^\s]*T\b/u.test(onScriptSource)) { fail('maintenance-on 不得使用 GNU mv 专属的 -T 参数。'); } @@ -95,7 +141,9 @@ function validateRuntimePageLifecycle() { env, ); if (enable.status !== 0) { - fail(`maintenance-on --page-file 执行失败: ${enable.stderr || enable.stdout}`); + fail( + `maintenance-on --page-file 执行失败: ${enable.stderr || enable.stdout}`, + ); return; } if (!existsSync(markerFile)) { @@ -107,7 +155,10 @@ function validateRuntimePageLifecycle() { if (readFileSync(runtimePageFile, 'utf8') !== announcement) { fail('运行态公告页内容与输入文件不一致。'); } - if ((statSync(runtimePageFile).mode & 0o777) !== 0o644) { + if ( + process.platform !== 'win32' && + (statSync(runtimePageFile).mode & 0o777) !== 0o644 + ) { fail('运行态公告页权限必须为 0644。'); } } @@ -130,7 +181,9 @@ function validateRuntimePageLifecycle() { chmodSync(runtimePageFile, 0o644); const genericEnable = runScript(onScript, ['generic maintenance'], env); if (genericEnable.status !== 0) { - fail(`通用 maintenance-on 执行失败: ${genericEnable.stderr || genericEnable.stdout}`); + fail( + `通用 maintenance-on 执行失败: ${genericEnable.stderr || genericEnable.stdout}`, + ); } if (existsSync(runtimePageFile)) { fail('新维护窗口未提供 --page-file 时必须清理残留公告页。'); @@ -265,10 +318,9 @@ function validateGatewayConfiguration() { } } -for (const filePath of - requestedFiles.length > 0 - ? requestedFiles - : [path.join(repoRoot, 'public/maintenance.html')]) { +for (const filePath of requestedFiles.length > 0 + ? requestedFiles + : [path.join(repoRoot, 'public/maintenance.html')]) { validateDefaultPage(filePath); } diff --git a/scripts/git-hooks.test.mjs b/scripts/git-hooks.test.mjs index 17d37b228..7a51eeb4f 100644 --- a/scripts/git-hooks.test.mjs +++ b/scripts/git-hooks.test.mjs @@ -145,7 +145,7 @@ test('pre-commit hook fixes staged imports and formatting without swallowing uns 'const original = { value: 1 };\nconst keep = 2;\n', ); assert.equal( - readFileSync(partialPath, 'utf8'), + readFileSync(partialPath, 'utf8').replaceAll('\r\n', '\n'), 'const original = { value: 1 };\nconst keep={unstaged:true}\n', ); assert.equal( @@ -160,53 +160,73 @@ test('pre-commit hook fixes staged imports and formatting without swallowing uns test('pre-push runs repository parity only for master updates', () => { const tempDir = mkdtempSync(join(tmpdir(), 'genarrative-pre-push-')); try { - const binDir = join(tempDir, 'bin'); - mkdirSync(binDir); - const npmLog = join(tempDir, 'npm.log'); - const fakeNpm = join(binDir, 'npm'); - const fakeGit = join(binDir, 'git'); - writeFileSync( - fakeNpm, - `#!/usr/bin/env bash\nprintf '%s\\n' "$*" >> "${npmLog}"\n`, + const npmLog = join(tempDir, 'repo', 'npm.log'); + const tempRepo = join(tempDir, 'repo'); + mkdirSync(tempRepo); + git(tempRepo, 'init', '--quiet'); + git(tempRepo, 'config', 'user.email', 'git-hooks-test@example.invalid'); + git(tempRepo, 'config', 'user.name', 'Git Hooks Test'); + writeFileSync(join(tempRepo, 'tracked.txt'), 'baseline\n'); + git(tempRepo, 'add', 'tracked.txt'); + git( + tempRepo, + '-c', + 'commit.gpgsign=false', + 'commit', + '--quiet', + '-m', + 'baseline', ); - chmodSync(fakeNpm, 0o755); + const localSha = git(tempRepo, 'rev-parse', 'HEAD').trim(); writeFileSync( - fakeGit, - '#!/usr/bin/env bash\n' + - 'if [[ "$1" == "rev-parse" && "$2" == "HEAD" ]]; then\n' + - ' printf "%s\\n" "1111111111111111111111111111111111111111"\n' + - ' exit 0\n' + - 'fi\n' + - 'if [[ "$1" == "diff" ]]; then exit 0; fi\n' + - 'exit 1\n', + join(tempRepo, 'git'), + `#!/usr/bin/env bash +if [[ "$1" == 'rev-parse' && "$2" == 'HEAD' ]]; then + printf '%s\\n' '${localSha}' + exit 0 +fi +if [[ "$1" == 'diff' ]]; then exit 0; fi +exit 1 +`, ); - chmodSync(fakeGit, 0o755); - const env = { - ...process.env, - PATH: `${binDir}${delimiter}${process.env.PATH ?? ''}`, + chmodSync(join(tempRepo, 'git'), 0o755); + writeFileSync( + join(tempRepo, 'pre-push-master.sh'), + readFileSync(join(repoRoot, 'scripts', 'pre-push-master.sh'), 'utf8'), + ); + writeFileSync( + join(tempRepo, 'npm'), + '#!/usr/bin/env bash\nprintf \'%s\\n\' "$*" >> npm.log\n', + ); + chmodSync(join(tempRepo, 'npm'), 0o755); + const spawnHook = (input) => { + writeFileSync(join(tempRepo, 'push.input'), input); + return spawnSync( + 'bash', + [ + '-c', + 'PATH="$PWD:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"; export PATH; source ./pre-push-master.sh origin example.invalid < push.input', + ], + { + cwd: tempRepo, + encoding: 'utf8', + env: process.env, + }, + ); }; - const hook = join(repoRoot, 'scripts', 'pre-push-master.sh'); - const featurePush = spawnSync('bash', [hook, 'origin', 'example.invalid'], { - cwd: repoRoot, - encoding: 'utf8', - env, - input: - 'refs/heads/feature 1111111111111111111111111111111111111111 refs/heads/feature 2222222222222222222222222222222222222222\n', - }); + const featurePush = spawnHook( + 'refs/heads/feature 1111111111111111111111111111111111111111 refs/heads/feature 2222222222222222222222222222222222222222\n', + ); assert.equal(featurePush.status, 0, featurePush.stderr); assert.equal(readFileOrEmpty(npmLog), ''); - const masterPush = spawnSync('bash', [hook, 'origin', 'example.invalid'], { - cwd: repoRoot, - encoding: 'utf8', - env, - input: - 'refs/heads/master 1111111111111111111111111111111111111111 refs/heads/master 2222222222222222222222222222222222222222\n', - }); + const masterPush = spawnHook( + `refs/heads/master ${localSha} refs/heads/master 2222222222222222222222222222222222222222\n`, + ); assert.equal(masterPush.status, 0, masterPush.stderr); assert.equal( readFileSync(npmLog, 'utf8'), - 'run check:repository-ci -- 2222222222222222222222222222222222222222 1111111111111111111111111111111111111111\n', + `run check:repository-ci -- 2222222222222222222222222222222222222222 ${localSha}\n`, ); } finally { rmSync(tempDir, { force: true, recursive: true }); @@ -254,6 +274,14 @@ function readFileOrEmpty(path) { } } +function toBashPath(path) { + const windowsDrive = /^([A-Za-z]):[\\/](.*)$/u.exec(path); + if (windowsDrive) { + return `/mnt/${windowsDrive[1].toLowerCase()}/${windowsDrive[2].replaceAll('\\', '/')}`; + } + return path; +} + function git(cwd, ...args) { return execFileSync('git', args, { cwd, encoding: 'utf8' }); } diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 4cdd11524..8d8733480 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -204,6 +204,7 @@ const EDITOR_PIXEL_ART_MAX_PERSISTENCE_DURATION: Duration = Duration::from_secs( /// 未知结果边界。这个 detail 字段只在该边界之后置位,客户端据此先读权威快照对账。 pub(crate) const EDITOR_RESULT_PERSISTENCE_STARTED_DETAIL: &str = "resultPersistenceStarted"; const EDITOR_OPERATION_RESULT_ALREADY_EXISTS_DETAIL: &str = "operationResultAlreadyExists"; +const EDITOR_OPERATION_RESULT_RESOURCE_ID_DETAIL: &str = "resultResourceId"; const EDITOR_PIXEL_ART_SNAP_ASSET_KIND: &str = "editor_pixel_art_snap"; const EDITOR_PIXEL_ART_SNAP_MODEL: &str = "Perfect Pixel"; const EDITOR_PIXEL_ART_SNAP_PROVIDER: &str = "Genarrative"; @@ -6489,9 +6490,8 @@ fn validate_editor_pixel_art_snap_placeholder_exists( return Ok(()); } // 中文注释:首个事务若以 DialogMissing 成功、但 HTTP 响应丢失,同 operation 的稳定 - // project resource 已存在,而占位按定义仍然不存在。该形状必须允许继续走到原子 procedure - // 的 exact compare-and-return;否则幂等重放会被这个处理前门禁反向拦成 409。 - let expected_task_id = format!("pixel-art-snap-{dialog_id}"); + // project resource 已存在,而占位按定义仍然不存在。该形状必须允许到达紧随其后的 + // stable-result guard,统一返回 GET-only 权威对账;否则会被这个处理前门禁反向拦住。 let expected_resource_id = format!( "{EDITOR_RESOURCE_ID_PREFIX}{}", editor_pixel_art_stable_record_suffix( @@ -6505,7 +6505,6 @@ fn validate_editor_pixel_art_snap_placeholder_exists( resource.resource_id == expected_resource_id && resource.owner_user_id == owner_user_id && resource.project_id == project_id - && resource.task_id.as_deref() == Some(expected_task_id.as_str()) }) { return Ok(()); } @@ -6524,16 +6523,6 @@ struct EditorPixelArtSourceResolution { object_key: String, asset_kind: Option, generation_input_reference: Option, - existing_result_generation_inputs: Option>, - // 外层 Some 表示稳定结果资源已存在;内层 None 保留“记录存在但缺 object_key”的损坏形状。 - existing_result_object_key: Option>, -} - -fn resolve_editor_pixel_art_persisted_generation_inputs( - authoritative: Option, - existing_result: Option>, -) -> Option { - existing_result.unwrap_or(authoritative) } fn push_editor_pixel_art_source_asset_kind( @@ -6607,20 +6596,7 @@ async fn resolve_editor_pixel_art_source_for_owner( project: &EditorProjectPayload, source_resource: Option<&EditorProjectResourcePayload>, requested_asset_kind: Option<&str>, - expected_result_resource_id: &str, - expected_result_task_id: &str, ) -> Result { - let existing_stable_resource = project - .resources - .iter() - .find(|resource| resource.resource_id.trim() == expected_result_resource_id); - let existing_result = existing_stable_resource.filter(|resource| { - resource.task_id.as_deref().map(str::trim) == Some(expected_result_task_id) - }); - let existing_result_generation_inputs = - existing_result.map(|resource| resource.generation_inputs.clone()); - let existing_result_object_key = existing_stable_resource - .map(|resource| normalize_optional_string(resource.object_key.clone())); let resolved_without_lookup = match source_resource { Some(source_resource) => resolve_editor_pixel_art_source_without_lookup( owner_user_id, @@ -6819,26 +6795,28 @@ async fn resolve_editor_pixel_art_source_for_owner( object_key, asset_kind, generation_input_reference, - existing_result_generation_inputs, - existing_result_object_key, }) } -fn ensure_editor_pixel_art_existing_result_matches_candidate_object_key( - existing_result_object_key: Option>, - candidate_object_key: &str, +fn ensure_editor_pixel_art_stable_result_is_absent( + resources: &[EditorProjectResourcePayload], + expected_result_resource_id: &str, ) -> Result<(), AppError> { - let Some(existing_result_object_key) = existing_result_object_key else { - return Ok(()); - }; - if existing_result_object_key == Some(candidate_object_key) { + if !resources + .iter() + .any(|resource| resource.resource_id.trim() == expected_result_resource_id) + { return Ok(()); } Err(editor_pixel_art_snap_failure( StatusCode::CONFLICT, - "同一完美像素操作已有其它权威结果,请先读取项目状态对账。", + "同一完美像素操作已有权威结果,请先读取项目状态对账。", ) - .with_detail_field(EDITOR_OPERATION_RESULT_ALREADY_EXISTS_DETAIL, json!(true))) + .with_detail_field(EDITOR_OPERATION_RESULT_ALREADY_EXISTS_DETAIL, json!(true)) + .with_detail_field( + EDITOR_OPERATION_RESULT_RESOURCE_ID_DETAIL, + json!(expected_result_resource_id), + )) } fn resolve_editor_pixel_art_asset_folder_id(asset_folder_id: Option) -> Option { @@ -7030,7 +7008,6 @@ pub async fn snap_editor_image_to_pixel_art( // 都在许可覆盖范围内,许可随 handler 返回自动释放。 let _snap_permit = acquire_editor_pixel_art_snap_permit(processing_deadline).await?; let owner_user_id = current_owner_user_id(&authenticated); - let expected_result_task_id = format!("pixel-art-snap-{dialog_id}"); let expected_result_resource_id = format!( "{EDITOR_RESOURCE_ID_PREFIX}{}", editor_pixel_art_stable_record_suffix( @@ -7066,6 +7043,14 @@ pub async fn snap_editor_image_to_pixel_art( project_id.as_str(), &payload.canvas_completion, )?; + // 中文注释:稳定 result resource 是 owner-scoped 项目快照中的权威完成事实。 + // 一旦它已存在,本请求不得再解析来源、读取 OSS 或重跑像素规整;旧响应丢失和 + // 跨版本 exact retry 都统一交由客户端 GET 项目快照判定 applied / DialogMissing / + // conflict。这里不附 resultPersistenceStarted:本请求尚未进入任何持久化副作用。 + ensure_editor_pixel_art_stable_result_is_absent( + project.resources.as_slice(), + expected_result_resource_id.as_str(), + )?; let source_resource = if let Some(source_resource_id) = source_resource_id.as_deref() { Some( project @@ -7090,8 +7075,6 @@ pub async fn snap_editor_image_to_pixel_art( &project, source_resource, payload.asset_kind.as_deref(), - expected_result_resource_id.as_str(), - expected_result_task_id.as_str(), ) .await }) @@ -7104,19 +7087,14 @@ pub async fn snap_editor_image_to_pixel_art( })??; let source_object_key = source.object_key; let asset_kind = source.asset_kind; - let existing_result_object_key = source.existing_result_object_key; let authoritative_generation_inputs = rebuild_editor_generation_inputs_with_authoritative_references( payload.generation_inputs.take(), source.generation_input_reference.into_iter().collect(), ); - // 旧结果已经落库时,重放必须携带原记录的 metadata 才能通过 SpacetimeDB 的精确 - // compare-and-return;这只复用已由服务端持久化的 owner-scoped 记录。新操作始终使用 - // 上面按已鉴权源重建的 references,不再接受客户端自报 provenance。 - payload.generation_inputs = resolve_editor_pixel_art_persisted_generation_inputs( - authoritative_generation_inputs, - source.existing_result_generation_inputs, - ); + // 新操作始终使用按已鉴权源重建的 references,不接受客户端自报 provenance。已有稳定 + // operation 已在上面的 owner-scoped 项目快照阶段返回 GET-only 对账,不会走到这里。 + payload.generation_inputs = authoritative_generation_inputs; let source_image = download_editor_persisted_image_object_within_deadline( &state, source_object_key.as_str(), @@ -7203,14 +7181,6 @@ pub async fn snap_editor_image_to_pixel_art( "genarrative", )?; let prepared_object_key = prepared_upload.storage_paths.object_key.clone(); - // 中文注释:算法版本变化会改变 fingerprint 与 object key,但 operation/dialog 和稳定记录 - // ID 保持不变。若旧版本结果已经落库,必须在任何 preflight/OSS PUT 之前失败关闭并对账。 - ensure_editor_pixel_art_existing_result_matches_candidate_object_key( - existing_result_object_key - .as_ref() - .map(|object_key| object_key.as_deref()), - prepared_object_key.as_str(), - )?; let image_src = editor_media_src_from_object_key(prepared_object_key.as_str()); let mut project_resource = EditorProjectResourceCreateRecordInput { resource_id: persistence_identity.resource_id.clone(), @@ -14223,26 +14193,30 @@ mod tests { } #[test] - fn perfect_pixel_replay_uses_existing_server_metadata_for_exact_compare() { - let authoritative = Some(json!({ + fn perfect_pixel_uses_authoritative_source_metadata_for_new_operations() { + let client_claimed = Some(json!({ "fields": [], - "references": [{"refType": "project-resource", "refId": "resource-owned"}] + "references": [{"refType": "asset", "refId": "asset-other-owner"}] })); - let historical = Some(json!({ - "fields": [], - "references": [{"refType": "asset", "refId": "legacy-client-value"}] - })); - assert_eq!( - resolve_editor_pixel_art_persisted_generation_inputs( - authoritative, - Some(historical.clone()), + rebuild_editor_generation_inputs_with_authoritative_references( + sanitize_editor_untrusted_generation_inputs(client_claimed), + vec![json!({ + "title": "原图", + "label": "当前项目资源", + "refType": "project-resource", + "refId": "resource-owned" + })], ), - historical - ); - assert_eq!( - resolve_editor_pixel_art_persisted_generation_inputs(None, Some(None)), - None + Some(json!({ + "fields": [], + "references": [{ + "title": "原图", + "label": "当前项目资源", + "refType": "project-resource", + "refId": "resource-owned" + }] + })) ); } @@ -16095,7 +16069,7 @@ mod tests { &completion, ) .is_ok(), - "DialogMissing 的同 operation 重放必须进入 procedure 做 exact compare" + "DialogMissing 的同 operation 重放必须进入稳定结果权威对账" ); } @@ -16130,13 +16104,19 @@ mod tests { "tokio::time::timeout_at(", ".get_editor_project", "validate_editor_pixel_art_snap_placeholder_exists", + // 中文注释:同一稳定 operation 已有资源时,必须在来源解析、OSS GET 和 CPU + // 规整前直接转入 GET-only 权威对账;旧资源损坏也由客户端 verdict 报告, + // 本请求不得尝试以新计算补写它。 + "ensure_editor_pixel_art_stable_result_is_absent(", "resolve_editor_pixel_art_source_for_owner", "完美像素来源归属校验超出处理预算。", "download_editor_persisted_image_object_within_deadline", "validate_editor_pixel_art_static_raster", "snap_editor_pixel_art_strict", "Some(processing_deadline)", - // 中文注释:prepare 只计算精确 object key;只读 preflight 与后续 + // 中文注释:prepare 只计算新操作的精确 object key;已有稳定 result 已在 + // 前面的 owner-scoped 项目快照分支返回,不能再依赖 candidate key 判定。 + // 只读 preflight 与后续 // PUT/HEAD/原子 persist 共用第二份 60 秒绝对 deadline。preflight 必须发生 // 在第一次外部写之前,避免已知的目录/布局拒绝留下 OSS 孤儿对象。 "prepare_editor_generated_image_object_data(", @@ -16150,6 +16130,15 @@ mod tests { ".persist_editor_pixel_art_result(", ], ); + assert_function_not_contains( + source, + "pub async fn snap_editor_image_to_pixel_art(", + "async fn validate_editor_background_removal_source", + &[ + "ensure_editor_pixel_art_existing_result_matches_candidate_object_key(", + "resolve_editor_pixel_art_persisted_generation_inputs(", + ], + ); assert_function_contains_in_order( source, "pub async fn snap_editor_image_to_pixel_art(", @@ -19596,25 +19585,46 @@ mod tests { } #[test] - fn explicit_pixel_art_snap_reconciles_existing_result_before_upload() { - let candidate = "pixel-art-snaps/v2.png"; + fn explicit_pixel_art_snap_reconciles_existing_stable_result_before_source_processing() { + let mut stable_result = editor_project_resource_for_canvas_test( + "editor-resource-stable-result", + "image", + 128, + 128, + ); + stable_result.resource_id = "editor-resource-stable-result".to_string(); + stable_result.task_id = Some("pixel-art-snap-corrupted-task".to_string()); + stable_result.object_key = None; + assert!( - ensure_editor_pixel_art_existing_result_matches_candidate_object_key(None, candidate,) + ensure_editor_pixel_art_stable_result_is_absent(&[], "editor-resource-stable-result") .is_ok() ); - assert!( - ensure_editor_pixel_art_existing_result_matches_candidate_object_key( - Some(Some(candidate)), - candidate, - ) - .is_ok() - ); - let error = ensure_editor_pixel_art_existing_result_matches_candidate_object_key( - Some(Some("pixel-art-snaps/v1.png")), - candidate, + let error = ensure_editor_pixel_art_stable_result_is_absent( + &[stable_result], + "editor-resource-stable-result", ) - .expect_err("different stable result must fail before upload"); + .expect_err("stable result must enter authority reconciliation before source processing"); assert_eq!(error.status_code(), StatusCode::CONFLICT); + assert_eq!( + error.details().and_then(|details| details + [EDITOR_OPERATION_RESULT_ALREADY_EXISTS_DETAIL] + .as_bool()), + Some(true) + ); + assert_eq!( + error + .details() + .and_then(|details| details[EDITOR_OPERATION_RESULT_RESOURCE_ID_DETAIL].as_str()), + Some("editor-resource-stable-result") + ); + assert_eq!( + error + .details() + .and_then(|details| details[EDITOR_RESULT_PERSISTENCE_STARTED_DETAIL].as_bool()), + None, + "this request has not entered result persistence" + ); } #[test] diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx index b78294d65..2c1c07237 100644 --- a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx @@ -1396,6 +1396,71 @@ describe('useImageCanvasGenerationWorkflow', () => { ).toMatchObject({ kind: 'pending', project: foreignProject }); }); + it('rejects an incomplete matching task resource instead of treating it as an applied result', () => { + const operationId = 'perfect-pixel-corrupted-resource'; + const project = createPerfectPixelProject(operationId, 'applied'); + project.resources[0] = { + ...project.resources[0]!, + objectKey: null, + }; + + expect( + inspectPerfectPixelProjectSnapshot(project, { + operationId, + taskId: `pixel-art-snap-${operationId}`, + }), + ).toMatchObject({ + kind: 'conflict', + project, + message: '完美像素任务资源记录不完整或不属于当前项目,无法自动确认结果。', + }); + }); + + it('uses the server-confirmed stable resource id to reject a corrupted task id', () => { + const operationId = 'perfect-pixel-corrupted-task'; + const project = createPerfectPixelProject(operationId, 'dialog-missing'); + project.resources[0] = { + ...project.resources[0]!, + taskId: 'pixel-art-snap-another-operation', + objectKey: null, + }; + + expect( + inspectPerfectPixelProjectSnapshot( + project, + { + operationId, + taskId: `pixel-art-snap-${operationId}`, + }, + project.resources[0]!.resourceId, + ), + ).toMatchObject({ + kind: 'conflict', + project, + message: '完美像素任务资源记录不完整或不属于当前项目,无法自动确认结果。', + }); + }); + + it('rejects a settled dialog whose generated layer points at another resource', () => { + const operationId = 'perfect-pixel-mismatched-layer'; + const project = createPerfectPixelProject(operationId, 'applied'); + project.layers[0] = { + ...project.layers[0]!, + resourceId: 'resource-other', + }; + + expect( + inspectPerfectPixelProjectSnapshot(project, { + operationId, + taskId: `pixel-art-snap-${operationId}`, + }), + ).toMatchObject({ + kind: 'conflict', + project, + message: '完美像素占位与任务资源的画布关联不一致,无法自动应用。', + }); + }); + it('opens a movable canvas generation placeholder and keeps toolbar state active', () => { render(); @@ -3265,6 +3330,81 @@ describe('useImageCanvasGenerationWorkflow', () => { }); }); + it('reconciles a retried existing-result marker against its corrupted stable resource as a conflict', async () => { + const applyProjectSnapshot = vi.fn(); + let corruptedProject: EditorProjectSnapshot | undefined; + snapImageToPerfectPixelsMock + .mockImplementationOnce(async (request: EditorPixelArtSnapInput) => + createMismatchedPerfectPixelResult(request), + ) + .mockImplementationOnce(async (request: EditorPixelArtSnapInput) => { + const operationId = request.canvasCompletion.dialogId; + corruptedProject = createPerfectPixelProject( + operationId, + 'dialog-missing', + ); + corruptedProject.resources[0] = { + ...corruptedProject.resources[0]!, + taskId: 'pixel-art-snap-corrupted-task', + objectKey: null, + }; + throw new ApiClientError({ + message: '同一完美像素操作已有权威结果,请先读取项目状态对账。', + status: 409, + code: 'HTTP_409', + details: { + operationResultAlreadyExists: true, + resultResourceId: corruptedProject.resources[0]!.resourceId, + }, + }); + }); + loadEditorProjectMock.mockImplementation(async () => { + const request = snapImageToPerfectPixelsMock.mock.calls.at(-1)?.[0] as + | EditorPixelArtSnapInput + | undefined; + return snapImageToPerfectPixelsMock.mock.calls.length === 1 + ? createConflictingPerfectPixelProject( + request!.canvasCompletion.dialogId, + ) + : corruptedProject!; + }); + + render( + {})} + />, + ); + + fireEvent.click(screen.getByRole('button', { name: '完美像素' })); + await waitFor(() => { + expect(screen.getByTestId('dialog').textContent).toContain( + 'pending-confirmation', + ); + }); + + fireEvent.click(screen.getByRole('button', { name: '重试完美像素' })); + await waitFor(() => { + expect(screen.getByTestId('dialog-error').textContent).toContain( + '完美像素任务资源记录不完整或不属于当前项目,无法自动确认结果。', + ); + }); + + expect(snapImageToPerfectPixelsMock).toHaveBeenCalledTimes(2); + expect(loadEditorProjectMock).toHaveBeenCalledTimes(2); + expect(applyProjectSnapshot).not.toHaveBeenCalled(); + expect(screen.getByTestId('dialog').textContent).toContain( + 'pending-confirmation', + ); + }); + it('anchors the first submission reconciliation window at POST time when the pre-POST flush is slow', async () => { // 中文注释:pre-POST flush 是服务端硬前置(占位未持久化会被 409 拒收),且没有整体 // 上限。窗口若锚在 flush 之前,慢保存会让 POST 带着已过期的 reconciliation deadline @@ -3801,6 +3941,65 @@ describe('useImageCanvasGenerationWorkflow', () => { expect(screen.getByTestId('dialog').textContent).not.toContain('failed'); }); + it('uses the server-confirmed stable resource id during existing-result reconciliation', async () => { + const applyProjectSnapshot = vi.fn(); + let reconciledProject: EditorProjectSnapshot | undefined; + snapImageToPerfectPixelsMock.mockImplementationOnce( + async (request: EditorPixelArtSnapInput) => { + const operationId = request.canvasCompletion.dialogId; + reconciledProject = createPerfectPixelProject( + operationId, + 'dialog-missing', + ); + reconciledProject.resources[0] = { + ...reconciledProject.resources[0]!, + taskId: 'pixel-art-snap-corrupted-task', + objectKey: null, + }; + throw new ApiClientError({ + message: '同一完美像素操作已有权威结果,请先读取项目状态对账。', + status: 409, + code: 'HTTP_409', + details: { + operationResultAlreadyExists: true, + resultResourceId: reconciledProject.resources[0]!.resourceId, + }, + }); + }, + ); + loadEditorProjectMock.mockImplementationOnce(async () => { + expect(reconciledProject).toBeDefined(); + return reconciledProject!; + }); + + render( + {})} + />, + ); + + fireEvent.click(screen.getByRole('button', { name: '完美像素' })); + + await waitFor(() => { + expect(screen.getByTestId('dialog-error').textContent).toContain( + '完美像素任务资源记录不完整或不属于当前项目,无法自动确认结果。', + ); + }); + expect(loadEditorProjectMock).toHaveBeenCalledTimes(1); + expect(applyProjectSnapshot).not.toHaveBeenCalled(); + expect(screen.getByTestId('dialog').textContent).toContain( + 'pending-confirmation', + ); + }); + it('skips reconciliation for a responded failure the server did not mark', async () => { // 中文注释:纯校验失败发生在任何 IO 之前,不可能留下对象或素材。服务端不置 // resultPersistenceStarted,客户端就不该多打两次读取,也不该附上「请核对素材库」 diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.ts b/src/components/image-editor/useImageCanvasGenerationWorkflow.ts index 26b9efab1..45ea3c89a 100644 --- a/src/components/image-editor/useImageCanvasGenerationWorkflow.ts +++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.ts @@ -334,9 +334,14 @@ export type PerfectPixelProjectVerdict = export function inspectPerfectPixelProjectSnapshot( project: EditorProjectSnapshot, operation: Pick, + resultResourceId?: string | null, ): PerfectPixelProjectVerdict { + const normalizedResultResourceId = resultResourceId?.trim() ?? ''; const matchingResources = project.resources.filter( - (resource) => resource.taskId?.trim() === operation.taskId, + (resource) => + resource.taskId?.trim() === operation.taskId || + (normalizedResultResourceId !== '' && + resource.resourceId.trim() === normalizedResultResourceId), ); const matchingDialogs = findCanvasGenerationDialogRecords( project, @@ -369,6 +374,20 @@ export function inspectPerfectPixelProjectSnapshot( message: '完美像素占位已收口,但权威项目缺少对应任务资源。', }; } + if ( + (normalizedResultResourceId !== '' && + resource.resourceId.trim() !== normalizedResultResourceId) || + resource.taskId?.trim() !== operation.taskId || + resource.projectId !== project.projectId || + !resource.objectKey?.trim() || + !resource.imageSrc.trim() + ) { + return { + kind: 'conflict', + project, + message: '完美像素任务资源记录不完整或不属于当前项目,无法自动确认结果。', + }; + } if (!dialog) { return { kind: 'dialog-missing', project, resource }; } @@ -436,7 +455,7 @@ function waitForPerfectPixelReconciliationDelay( async function reconcilePerfectPixelProject( projectId: string, operation: PerfectPixelOperationSnapshot, - options: { signal?: AbortSignal } = {}, + options: { signal?: AbortSignal; resultResourceId?: string | null } = {}, ): Promise { let attempt = 0; let hasAttemptedRead = false; @@ -478,6 +497,7 @@ async function reconcilePerfectPixelProject( const verdict = inspectPerfectPixelProjectSnapshot( latestProject, operation, + options.resultResourceId, ); if (verdict.kind !== 'pending') { return verdict; @@ -2785,6 +2805,11 @@ export function useImageCanvasGenerationWorkflow({ error instanceof ApiClientError && (error.details as { operationResultAlreadyExists?: unknown } | null) ?.operationResultAlreadyExists === true; + const existingResultResourceId = + operationResultAlreadyExists && error instanceof ApiClientError + ? (error.details as { resultResourceId?: unknown } | null) + ?.resultResourceId + : null; const outcomeMayBePersisted = perfectPixelPostAttempted && Boolean(perfectPixelDialogId) && @@ -2808,6 +2833,12 @@ export function useImageCanvasGenerationWorkflow({ const verdict = await reconcilePerfectPixelProject( normalizedProjectId, perfectPixelOperation, + { + resultResourceId: + typeof existingResultResourceId === 'string' + ? existingResultResourceId + : null, + }, ); if (!isPerfectPixelAuthorityCurrent(operationAuthority)) { return; @@ -3034,6 +3065,11 @@ export function useImageCanvasGenerationWorkflow({ error instanceof ApiClientError && (error.details as { operationResultAlreadyExists?: unknown } | null) ?.operationResultAlreadyExists === true; + const existingResultResourceId = + operationResultAlreadyExists && error instanceof ApiClientError + ? (error.details as { resultResourceId?: unknown } | null) + ?.resultResourceId + : null; const outcomeMayBePersisted = postAttempted && (!(error instanceof ApiClientError) || @@ -3044,6 +3080,12 @@ export function useImageCanvasGenerationWorkflow({ const verdict = await reconcilePerfectPixelProject( normalizedProjectId, retriedOperation, + { + resultResourceId: + typeof existingResultResourceId === 'string' + ? existingResultResourceId + : null, + }, ); if (!isPerfectPixelAuthorityCurrent(operationAuthority)) { return;