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 95a866f95..8b4aa83ac 100644 --- a/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs +++ b/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs @@ -43,6 +43,28 @@ function buildTauriArguments(argv, devUrl = readAgcDevEndpoint().url) { ]; } +// `agc_cocos_execute` 与 Cocos 编辑器适配器只在 `cocos-editor-execute` feature 下 +// 注册。开发构建默认在 Windows 打开它,否则 Agent 的工具清单里根本没有该工具, +// 只能退化成改写脚本。可用 AGC_DEV_CARGO_FEATURES(逗号分隔)覆盖,传空串即关闭。 +function readDevCargoFeatures(env = process.env) { + const override = env.AGC_DEV_CARGO_FEATURES; + if (override !== undefined) { + return override + .split(',') + .map((value) => value.trim()) + .filter(Boolean); + } + return process.platform === 'win32' ? ['cocos-editor-execute'] : []; +} + +function withDevCargoFeatures(argv, features = readDevCargoFeatures()) { + if (features.length === 0) return argv; + if (argv.some((value) => value === '--features' || value === '-f')) { + return argv; + } + return [`--features=${features.join(',')}`, ...argv]; +} + function spawnTauriCli(argv, { env = process.env } = {}) { return spawnChild(process.execPath, [tauriCliPath, ...argv], { cwd: appRoot, @@ -106,7 +128,10 @@ async function runTauriDev( shutdownRequested.then(() => false), ]); if (!prepared || shutdownSignal) return 1; - const tauriArguments = buildTauriArguments(argv, endpoint.url); + const tauriArguments = buildTauriArguments( + withDevCargoFeatures(argv), + endpoint.url, + ); child = spawnCli(tauriArguments, { env: withAgcDevEndpointEnv(endpoint), }); @@ -193,6 +218,7 @@ export { isDirectModuleExecution, runTauriDev, spawnTauriCli, + withDevCargoFeatures, }; if (isDirectModuleExecution()) { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs index 42777dcfa..86021122c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs @@ -1,3 +1,4 @@ +use super::runtime_actions::acquire_game_creator_agent_runtime_project_write_lock_with_wait; use crate::config::prepare_game_creator_private_path_for_read; use crate::project::{ append_jsonl_line_unlocked, enforce_project_permission_policy, project_append_lock_for, @@ -204,7 +205,15 @@ fn append_direct_project_history_item_at_with_user_policy( if !allow_user_item && is_direct_project_codex_user_item(item) { return Ok(()); } - let _project_lock = crate::project::acquire_project_write_lock(root, "conversation.write")?; + // DirectProject 历史与 `agc_write_file` 共用项目写锁。文件写入在锁内要跑 Windows + // 私有路径准备与原子替换,现场实测一次 2.6KB 写入占锁 5.5 秒;零等待取锁会让 + // 流式历史落盘在写文件期间直接失败,并把整轮判成“项目正在被其他写操作占用” + // (持锁方 commandId=direct-codex.file.write、ownerIsSelf=true)。这里与其它写入口 + // 保持同一档有界等待;主路径已在阻塞线程池中执行。 + let _project_lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "conversation.write", + )?; let path = history_path(root); let history_exists = prepare_game_creator_private_path_for_read(&path, false, "DirectProject 历史")?; @@ -429,6 +438,50 @@ mod tests { assert_eq!(items, vec![item]); } + #[test] + fn append_waits_for_a_same_process_project_writer() { + // 用 canonical 临时根:Windows 上 `%TEMP%` 的 8.3 短路径会让私有路径所有者 + // 校验把测试目录判成“不属于当前用户”。 + let temp_root = std::env::temp_dir() + .canonicalize() + .unwrap_or_else(|_| std::env::temp_dir()); + let root = temp_root.join(format!( + "genarrative-agc-history-wait-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|value| value.as_millis()) + .unwrap_or_default() + )); + std::fs::create_dir_all(&root).expect("create project root"); + crate::init_local_game_project_at(&root, "history-wait", "历史等待").expect("init project"); + // 模拟 `agc_write_file`:它持锁期间历史落盘必须排队等待,而不是零等待失败后 + // 把整轮判成“项目正在被其他写操作占用”。 + let holder = crate::project::acquire_project_write_lock(&root, "direct-codex.file.write") + .expect("hold project write lock"); + let worker_root = root.clone(); + let worker = std::thread::spawn(move || { + append_direct_project_history_item_at( + &worker_root, + &serde_json::json!({ + "type": "message", + "role": "assistant", + "id": "waits-for-writer", + "content": [{"type": "output_text", "text": "排队等待"}] + }), + ) + }); + std::thread::sleep(std::time::Duration::from_millis(300)); + drop(holder); + worker + .join() + .expect("append worker") + .expect("history append must wait for the writer"); + let items = read_direct_project_history_items_at(&root).expect("read history"); + assert_eq!(items.len(), 1); + let _ = std::fs::remove_dir_all(&root); + } + #[test] fn codex_user_echo_is_filtered_but_agc_user_message_is_persisted() { let root = tempfile::tempdir().expect("temp project"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs index 277678ff7..2896c7c27 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs @@ -1481,12 +1481,28 @@ fn bridge_write_file(root: &Path, arguments: &Value) -> Value { // 而 `file.write / file.patch / file.delete` 等写入口用的是约 10 秒有界等待。 // 这是用户直接触发、失败即整轮无法落盘的项目写入通道,必须和其它写入口同语义: // 短暂重叠排队等成功,只有预算耗尽才报出带持锁方身份的错误。 + let acquire_started = std::time::Instant::now(); let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( root, "direct-codex.file.write", )?; + let lock_wait_ms = acquire_started.elapsed().as_millis(); + let write_started = std::time::Instant::now(); let written = write_local_project_file_at(root, &path, content)?; + let write_ms = write_started.elapsed().as_millis(); + let revision_started = std::time::Instant::now(); let revision = advance_agent_runtime_project_revision_locked(root)?; + // 现场一次 2.6KB 写入实测 5.5 秒。只在明显偏慢时记账,正常写入不刷日志。 + if lock_wait_ms + write_ms > 200 { + app_log!( + "direct.file.write.timing path={} bytes={} lockWaitMs={} writeMs={} revisionMs={}", + written.path, + content.len(), + lock_wait_ms, + write_ms, + revision_started.elapsed().as_millis() + ); + } Ok::<_, String>(json!({ "status": "completed", "path": written.path, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs index 0dcd215a4..02f180582 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs @@ -1388,7 +1388,12 @@ fn external_mcp_record_response(root: &Path, arguments: &Value) -> Value { true, ); } - let _project_lock = match acquire_project_write_lock(root, "conversation.write") { + // 与 DirectProject 历史落盘同一档有界等待:`agc_write_file` 持锁期间可能持续数秒, + // 零等待取锁会让 Codex 返回记录直接丢失。调用方已把本函数放进阻塞线程池。 + let _project_lock = match acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "conversation.write", + ) { Ok(lock) => lock, Err(error) => { return mcp_tool_result(format!("项目对话锁不可用:{error}"), Vec::new(), true) @@ -1622,7 +1627,22 @@ async fn handle_direct_tools_mcp_request(root: &Path, request: Value) -> Option< let result = match tool { "client.session.info" => external_mcp_session_info(root), "conversation.record_codex_response" => { - external_mcp_record_response(root, &arguments) + // 取锁等待是同步轮询(最多约 10 秒),必须放到阻塞线程池, + // 否则会占住 runtime worker。 + let journal_root = root.to_path_buf(); + let journal_arguments = arguments.clone(); + match tokio::task::spawn_blocking(move || { + external_mcp_record_response(&journal_root, &journal_arguments) + }) + .await + { + Ok(result) => result, + Err(error) => mcp_tool_result( + format!("Codex 返回记录任务未返回:{error}"), + Vec::new(), + true, + ), + } } "conversation.list" => external_mcp_conversation_list(root, &arguments), "conversation.read" => external_mcp_conversation_read(root, &arguments), 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 a338aeef1..7b8308410 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 @@ -13,6 +13,7 @@ import { import { buildTauriArguments, runTauriDev as runTauriDevImpl, + withDevCargoFeatures, } from '../scripts/start-tauri-dev.mjs'; const testEndpoint = { @@ -40,6 +41,19 @@ async function waitForFile(path: string, timeoutMs = 5000) { } describe('AI 游戏创作 Tauri dev 启动参数', () => { + test('开发构建默认带上 Cocos 编辑器 feature', () => { + expect( + withDevCargoFeatures(['--no-watch'], ['cocos-editor-execute']), + ).toEqual(['--features=cocos-editor-execute', '--no-watch']); + expect(withDevCargoFeatures(['--no-watch'], [])).toEqual(['--no-watch']); + expect( + withDevCargoFeatures( + ['--features', 'custom-feature'], + ['cocos-editor-execute'], + ), + ).toEqual(['--features', 'custom-feature']); + }); + test('普通 dev 参数原样交给 Tauri CLI', () => { expect(buildTauriArguments(['--no-watch'], testEndpoint.url)).toEqual([ 'dev',