From ff84b5a308f33dab45369248ad80188fed9b06dc Mon Sep 17 00:00:00 2001 From: menghao Date: Thu, 23 Jul 2026 10:50:02 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8DAI=E6=B8=B8=E6=88=8F=E5=88=9B?= =?UTF-8?q?=E4=BD=9C=E5=A3=B3=E8=B7=A8=E5=B9=B3=E5=8F=B0=E5=90=AF=E5=8A=A8?= =?UTF-8?q?=20(#107)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修复 macOS 下 Unix 文件身份比较和临时目录测试兼容 隔离 AI 游戏创作本地数据库与发布身份并阻止旧 schema 降级启动 完善 Tauri 开发栈错误传播和 POSIX 子进程树清理 跳过 macOS 不支持的进程指标回调以消除周期告警 补充开发调度测试、技术方案和团队排障记忆 Reviewed-on: http://genarrative-station/git/GenarrativeAI/Genarrative/pulls/107 Co-authored-by: menghao Co-committed-by: menghao --- .../scripts/start-dev-stack.mjs | 321 +++++++++++++----- .../src-tauri/src/agent.rs | 11 + .../src-tauri/src/collaboration.rs | 2 +- .../src-tauri/src/command_exec.rs | 16 +- .../src-tauri/src/process_session.rs | 51 ++- .../src-tauri/src/project.rs | 8 +- .../src-tauri/src/runner.rs | 4 +- .../src-tauri/src/tests.rs | 119 +++++-- .../src-tauri/src/tool_plan_handoff.rs | 4 +- .../tests/start-dev-stack.test.ts | 178 ++++++++++ docs/project-memory/shared-memory/pitfalls.md | 26 +- ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 4 + scripts/dev.mjs | 233 +++++++++---- scripts/dev.test.ts | 142 ++++++-- .../crates/api-server/src/process_metrics.rs | 6 + 15 files changed, 909 insertions(+), 216 deletions(-) create mode 100644 apps/ai-game-creator-shell/tests/start-dev-stack.test.ts diff --git a/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs b/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs index 60de90438..91bf6d1b2 100644 --- a/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs +++ b/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs @@ -11,9 +11,15 @@ const viteHost = '127.0.0.1'; const vitePort = 3080; const viteUrl = `http://${viteHost}:${vitePort}/`; const viteMarkerUrl = `${viteUrl}__agc_dev_server.json`; -const defaultApiTarget = process.env.RUST_SERVER_TARGET || 'http://127.0.0.1:8082'; +const defaultApiTarget = + process.env.RUST_SERVER_TARGET || 'http://127.0.0.1:8082'; const backendDatabase = 'genarrative-game-creator-dev'; +const backendSpacetimeDataDir = resolve( + repoRoot, + 'server-rs/.spacetimedb/ai-game-creator/data', +); const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm'; +const childLifecycles = new WeakMap(); function readJson(path) { if (!existsSync(path)) { @@ -53,44 +59,70 @@ function httpGetText(url, timeout = 1000) { async function isHttpReady(url) { const response = await httpGetText(url); - return Boolean(response && response.statusCode >= 200 && response.statusCode < 300); + return Boolean( + response && response.statusCode >= 200 && response.statusCode < 300, + ); } -function readBackendTargets({ requireAgcDatabase = false } = {}) { - const state = readJson(devStackStatePath); +function resolveBackendTargetsFromState( + state, + { + requireAgcBackend = false, + expectedDatabase = backendDatabase, + expectedSpacetimeDataDir = backendSpacetimeDataDir, + fallbackApiTarget = defaultApiTarget, + } = {}, +) { const apiServer = state?.services?.['api-server']; const spacetime = state?.services?.spacetime; const isActive = (service) => service && ['running', 'reused', 'starting'].includes(service.status ?? ''); const database = typeof state?.database === 'string' ? state.database : ''; - const hasMatchingDatabase = database === backendDatabase; - const canReuseState = !requireAgcDatabase || hasMatchingDatabase; + const spacetimeDataDir = + typeof state?.spacetimeDataDir === 'string' + ? resolve(state.spacetimeDataDir) + : ''; + const hasMatchingDatabase = database === expectedDatabase; + const hasMatchingDataDir = + Boolean(spacetimeDataDir) && + spacetimeDataDir === resolve(expectedSpacetimeDataDir); + const hasMatchingBackend = hasMatchingDatabase && hasMatchingDataDir; + const canReuseState = !requireAgcBackend || hasMatchingBackend; const apiUrl = canReuseState && isActive(apiServer) && apiServer.url ? apiServer.url - : requireAgcDatabase + : requireAgcBackend ? '' - : defaultApiTarget; + : fallbackApiTarget; const spacetimeUrl = canReuseState && isActive(spacetime) && spacetime.url ? spacetime.url - : requireAgcDatabase + : requireAgcBackend ? '' : 'http://127.0.0.1:3101'; return { apiUrl, spacetimeUrl, database, + spacetimeDataDir, hasMatchingDatabase, + hasMatchingDataDir, + hasMatchingBackend, }; } +function readBackendTargets({ requireAgcBackend = false } = {}) { + return resolveBackendTargetsFromState(readJson(devStackStatePath), { + requireAgcBackend, + }); +} + async function isBackendReady() { - const { apiUrl, spacetimeUrl, hasMatchingDatabase } = readBackendTargets({ - requireAgcDatabase: true, + const { apiUrl, spacetimeUrl, hasMatchingBackend } = readBackendTargets({ + requireAgcBackend: true, }); return ( - hasMatchingDatabase && + hasMatchingBackend && Boolean(apiUrl) && Boolean(spacetimeUrl) && (await isHttpReady(`${apiUrl}/healthz`)) && @@ -145,16 +177,90 @@ async function isExistingVitePairedWithBackend(apiTarget) { ); } -function spawnChild(command, args, options) { - return spawn(command, args, { +function spawnChild(command, args, options, spawnImpl = spawn) { + const useShell = process.platform === 'win32'; + const child = spawnImpl(command, args, { ...options, - shell: true, + shell: useShell, + // POSIX 下让每个长驻服务拥有独立进程组,退出时可以连同 npm、 + // Node、Cargo 及其子进程一起清理,避免残留订阅任务持续刷日志。 + detached: !useShell, stdio: 'inherit', }); + const lifecycle = { + failure: null, + promise: null, + // detached 子进程在 POSIX 下以自身 PID 作为 PGID。leader 退出后 + // child.pid 仍是清理其后代的唯一稳定句柄,必须随生命周期保留。 + processGroupId: !useShell && Number.isInteger(child.pid) ? child.pid : null, + }; + lifecycle.promise = new Promise((resolveLifecycle) => { + child.once('error', (error) => { + lifecycle.failure = { type: 'error', error }; + resolveLifecycle(lifecycle.failure); + }); + child.once('exit', (code, signal) => { + if (!lifecycle.failure) { + lifecycle.failure = { type: 'exit', code, signal }; + } + resolveLifecycle(lifecycle.failure); + }); + }); + childLifecycles.set(child, lifecycle); + return child; +} + +function readChildFailure(child) { + return childLifecycles.get(child)?.failure ?? null; +} + +function waitForChildTermination(child) { + const lifecycle = childLifecycles.get(child); + if (!lifecycle) { + return Promise.resolve({ + type: 'error', + error: new Error('子进程未注册生命周期监听'), + }); + } + return lifecycle.promise; +} + +function formatChildFailure(failure) { + if (failure?.type === 'error') { + return failure.error instanceof Error + ? failure.error.message + : String(failure.error); + } + return failure?.signal + ? `signal=${failure.signal}` + : `code=${failure?.code ?? 0}`; } function stopChild(child, signal = 'SIGTERM') { - if (!child || child.exitCode != null || child.signalCode != null) { + if (!child) { + return; + } + + if (process.platform !== 'win32') { + const processGroupId = childLifecycles.get(child)?.processGroupId; + if (Number.isInteger(processGroupId)) { + try { + process.kill(-processGroupId, signal); + return; + } catch (error) { + if (error?.code === 'ESRCH') { + return; + } + // leader 尚存活时保留 direct child fallback;leader 已退出则仍以 + // 负 PGID kill 的失败为准,不能误以为 descendants 已清理。 + if (child.exitCode != null || child.signalCode != null) { + return; + } + } + } + } + + if (child.exitCode != null || child.signalCode != null) { return; } try { @@ -166,47 +272,62 @@ function stopChild(child, signal = 'SIGTERM') { async function waitForBackendReady(backendChild, timeoutMs = 600_000) { const startedAt = Date.now(); - let backendExit = null; - backendChild?.on('exit', (code, signal) => { - backendExit = signal ? `signal=${signal}` : `code=${code ?? 0}`; - }); while (Date.now() - startedAt < timeoutMs) { if (await isBackendReady()) { return readBackendTargets(); } - if (backendExit) { - throw new Error(`配套后端启动失败: ${backendExit}`); + const failure = readChildFailure(backendChild); + if (failure) { + throw new Error(`配套后端启动失败: ${formatChildFailure(failure)}`); } - await new Promise((resolveWait) => setTimeout(resolveWait, 1000)); + await Promise.race([ + new Promise((resolveWait) => setTimeout(resolveWait, 1000)), + waitForChildTermination(backendChild), + ]); } throw new Error('等待配套后端和数据库启动超时'); } -async function ensureBackend() { - if (await isBackendReady()) { - const targets = readBackendTargets(); +async function ensureBackend({ + onBackendChild = () => {}, + checkBackendReady = isBackendReady, + resolveTargets = readBackendTargets, + spawnBackend = () => + spawnChild( + npm, + [ + '--prefix', + '../..', + 'run', + 'agc:backend', + '--', + '--database', + backendDatabase, + '--spacetime-data-dir', + backendSpacetimeDataDir, + '--no-interactive', + ], + { cwd: appRoot }, + ), + waitUntilReady = waitForBackendReady, +} = {}) { + if (await checkBackendReady()) { + const targets = resolveTargets(); console.log(`[ai-game-creator-shell] reuse backend ${targets.apiUrl}`); return { backendChild: null, targets }; } console.log('[ai-game-creator-shell] starting backend stack'); - const backendChild = spawnChild( - npm, - [ - '--prefix', - '../..', - 'run', - 'agc:backend', - '--', - '--database', - backendDatabase, - '--no-interactive', - ], - { cwd: appRoot }, - ); - const targets = await waitForBackendReady(backendChild); - console.log(`[ai-game-creator-shell] backend ready ${targets.apiUrl}`); - return { backendChild, targets }; + const backendChild = spawnBackend(); + try { + onBackendChild(backendChild); + const targets = await waitUntilReady(backendChild); + console.log(`[ai-game-creator-shell] backend ready ${targets.apiUrl}`); + return { backendChild, targets }; + } catch (error) { + stopChild(backendChild); + throw error; + } } async function startVite(apiTarget) { @@ -224,7 +345,9 @@ async function startVite(apiTarget) { (await isExistingVitePairedWithBackend(apiTarget)) && (await isExistingViteProxyReady()) ) { - console.log(`[ai-game-creator-shell] reuse existing Vite dev server ${viteUrl}`); + console.log( + `[ai-game-creator-shell] reuse existing Vite dev server ${viteUrl}`, + ); return null; } if (isAiGameCreatorServer(existing)) { @@ -244,40 +367,86 @@ async function startVite(apiTarget) { ); } -let backendChild = null; -let viteChild = null; +async function main() { + let backendChild = null; + let viteChild = null; + let shutdownSignal = ''; + const signalHandlers = new Map(); -for (const signal of ['SIGINT', 'SIGTERM']) { - process.on(signal, () => { - stopChild(viteChild, signal); - stopChild(backendChild, signal); - }); -} - -try { - const backend = await ensureBackend(); - backendChild = backend.backendChild; - viteChild = await startVite(backend.targets.apiUrl); - - const children = [backendChild, viteChild].filter(Boolean); - if (children.length === 0) { - process.exit(0); + for (const signal of ['SIGINT', 'SIGTERM']) { + const handler = () => { + shutdownSignal = signal; + stopChild(viteChild, signal); + stopChild(backendChild, signal); + }; + signalHandlers.set(signal, handler); + process.on(signal, handler); } - await new Promise((resolveExit) => { - for (const child of children) { - child.on('exit', (code, signal) => { - stopChild(viteChild); - stopChild(backendChild); - resolveExit(signal ? 1 : code ?? 0); - }); + try { + const backend = await ensureBackend({ + onBackendChild(child) { + backendChild = child; + if (shutdownSignal) { + stopChild(child, shutdownSignal); + } + }, + }); + backendChild = backend.backendChild; + if (shutdownSignal) { + throw new Error(`启动期收到 ${shutdownSignal},已停止配套后端`); } - }).then((code) => process.exit(code)); -} catch (error) { - stopChild(viteChild); - stopChild(backendChild); - console.error( - `[ai-game-creator-shell] ${error instanceof Error ? error.message : String(error)}`, - ); - process.exit(1); + + viteChild = await startVite(backend.targets.apiUrl); + if (shutdownSignal) { + stopChild(viteChild, shutdownSignal); + throw new Error(`启动期收到 ${shutdownSignal},已停止前端服务`); + } + + const children = [backendChild, viteChild].filter(Boolean); + if (children.length === 0) { + return 0; + } + + const failure = await Promise.race( + children.map((child) => waitForChildTermination(child)), + ); + stopChild(viteChild); + stopChild(backendChild); + return failure.type === 'error' || failure.signal ? 1 : (failure.code ?? 0); + } catch (error) { + stopChild(viteChild); + stopChild(backendChild); + console.error( + `[ai-game-creator-shell] ${error instanceof Error ? error.message : String(error)}`, + ); + return 1; + } finally { + for (const [signal, handler] of signalHandlers) { + process.off(signal, handler); + } + } +} + +function isDirectModuleExecution() { + return Boolean( + process.argv[1] && + resolve(process.argv[1]) === fileURLToPath(import.meta.url), + ); +} + +export { + ensureBackend, + formatChildFailure, + isDirectModuleExecution, + readChildFailure, + resolveBackendTargetsFromState, + spawnChild, + stopChild, + waitForBackendReady, + waitForChildTermination, +}; + +if (isDirectModuleExecution()) { + process.exitCode = await main(); } 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 6d3bb9b6d..aa8b447b8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -42605,6 +42605,9 @@ pub(crate) struct AgentRuntimeTaskLock { file: Option, } +#[cfg(unix)] +static AGENT_RUNTIME_LOCK_OPEN_GUARD: OnceLock> = OnceLock::new(); + impl Drop for AgentRuntimeTaskLock { fn drop(&mut self) { self.file.take(); @@ -42768,6 +42771,14 @@ fn try_open_game_creator_agent_runtime_task_lock_file( use std::os::fd::{AsRawFd, FromRawFd}; use std::os::unix::fs::{MetadataExt, OpenOptionsExt}; + // macOS 上两个线程首次并发创建同一套 mkdirat/openat 锁目录时,loser + // 可能在最终 O_CREAT 前短暂观察到 ENOENT。进程内只串行化安全打开阶段; + // 返回后的 flock 仍负责真实的跨线程、跨进程互斥。 + let _open_guard = AGENT_RUNTIME_LOCK_OPEN_GUARD + .get_or_init(|| Mutex::new(())) + .lock() + .map_err(|_| "Agent Runtime 锁安全打开门禁已损坏".to_string())?; + validate_project_root(root)?; let relative_path = normalize_relative_path(relative_path)?; let path = root.join(&relative_path); diff --git a/apps/ai-game-creator-shell/src-tauri/src/collaboration.rs b/apps/ai-game-creator-shell/src-tauri/src/collaboration.rs index d01d9626b..b8c68d893 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/collaboration.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/collaboration.rs @@ -568,7 +568,7 @@ pub(crate) fn bind_supervisor_collaboration_policy_snapshot_at( &lock_id, "collaboration-policy-snapshot", )? - .ok_or_else(|| "Project Supervisor 协作策略快照正被其他进程绑定".to_string())?; + .ok_or_else(|| "Project Supervisor 协作策略快照并发绑定冲突:正被其他进程绑定".to_string())?; let existing_binding = read_supervisor_collaboration_policy_snapshot_binding_at( root, parent_agent_id, 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 a34566216..936d1befb 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 @@ -832,9 +832,10 @@ fn validate_npm_arguments(arguments: &[String]) -> Result<(), String> { } if subcommand == "run" && arguments - .get(1) + .iter() + .skip(1) .map(String::as_str) - .filter(|value| !value.starts_with('-')) + .find(|value| !value.starts_with('-')) .is_none() { return Err("command.exec npm run 缺少脚本名".to_string()); @@ -2608,6 +2609,17 @@ raise SystemExit(code)' validate_npm_arguments(&command_args(&["run", "test:unit", "--", "sample.test",])) .is_ok() ); + assert!(validate_npm_arguments(&command_args(&[ + "run", + "--silent", + "--ignore-scripts", + "test:unit", + ])) + .is_ok()); + assert!( + validate_npm_arguments(&command_args(&["run", "--silent", "--ignore-scripts",])) + .is_err() + ); } #[tokio::test] diff --git a/apps/ai-game-creator-shell/src-tauri/src/process_session.rs b/apps/ai-game-creator-shell/src-tauri/src/process_session.rs index 726f8f9be..213bb76a0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/process_session.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/process_session.rs @@ -2125,6 +2125,12 @@ fn drain_process_session_output( } } if output_limit { + if let Ok(mut output) = live.output.lock() { + output.output_limit_exceeded = true; + output.status = "output-limit-exceeded".to_string(); + output.stdin_open = false; + live.output_changed.notify_all(); + } let _ = live.control.send(ProcessControl::OutputLimit); break; } @@ -3115,7 +3121,7 @@ mod tests { PROCESS_SESSION_TEST_LOCK .get_or_init(|| Mutex::new(())) .lock() - .expect("process session test lock") + .unwrap_or_else(std::sync::PoisonError::into_inner) } fn process_identity(project_id: &str) -> ProcessSessionIdentity { @@ -3130,6 +3136,22 @@ mod tests { } } + fn process_test_command_spec(root: &Path) -> ProjectCommandSpec { + fs::write( + root.join("package.json"), + r#"{"scripts":{"dev":"node fixture.js"}}"#, + ) + .expect("write process test package.json"); + resolve_project_command_spec_at( + root, + "npm", + &["run".to_string(), "dev".to_string()], + ".", + 30, + ) + .expect("resolve process test command") + } + #[test] fn process_session_cursor_preserves_unicode_boundaries() { let process_id = "proc-0123456789abcdef0123456789abcdef"; @@ -3239,8 +3261,7 @@ mod tests { &identity, &process_id, &format!("cmd-legacy-active-{index}"), - &resolve_project_command_spec_at(root, "bash", &["--version".to_string()], ".", 30) - .expect("resolve command"), + &process_test_command_spec(root), None, &"a".repeat(64), "running", @@ -3285,8 +3306,7 @@ mod tests { &identity, &process_id, &format!("cmd-legacy-terminal-{index}"), - &resolve_project_command_spec_at(root, "bash", &["--version".to_string()], ".", 30) - .expect("resolve command"), + &process_test_command_spec(root), None, &"b".repeat(64), "exited", @@ -3354,9 +3374,7 @@ mod tests { .expect("initialize project"); let identity = process_identity("v3-validation-project"); let process_id = "proc-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; - let spec = - resolve_project_command_spec_at(root, "bash", &["--version".to_string()], ".", 30) - .expect("resolve command"); + let spec = process_test_command_spec(root); let mut record = initial_process_session_record( &identity, process_id, @@ -4468,13 +4486,20 @@ process.stdin.resume(); let root = directory.path(); init_local_game_project_at(root, "stdin-race-project", "Stdin Race Project") .expect("initialize project"); + fs::write( + root.join("package.json"), + r#"{"scripts":{"dev":"node fixture.js"}}"#, + ) + .expect("write package.json"); + fs::write( + root.join("fixture.js"), + "process.stdout.write('READY\\n'); setInterval(() => {}, 1000);\n", + ) + .expect("write fixture"); let spec = resolve_project_command_spec_at( root, - "bash", - &[ - "-lc".to_string(), - "printf 'READY\\n'; while :; do sleep 1; done".to_string(), - ], + "npm", + &["run".to_string(), "dev".to_string()], ".", 30, ) diff --git a/apps/ai-game-creator-shell/src-tauri/src/project.rs b/apps/ai-game-creator-shell/src-tauri/src/project.rs index 8deee436d..e8c8f34c5 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project.rs @@ -336,8 +336,8 @@ fn verify_unix_agent_db_root(root: &Path, opened: &File) -> Result<(), String> { let metadata = opened .metadata() .map_err(|error| format!("复核 Agent DB 项目目录句柄失败:{error}"))?; - if stat.st_dev != metadata.dev() - || stat.st_ino != metadata.ino() + if stat.st_dev as u64 != metadata.dev() + || stat.st_ino as u64 != metadata.ino() || stat.st_mode & libc::S_IFMT != libc::S_IFDIR { return Err("Agent DB 项目目录在安全打开期间发生替换或不是普通目录".to_string()); @@ -380,8 +380,8 @@ fn verify_unix_agent_db_entry( } else { libc::S_IFREG }; - if stat.st_dev != metadata.dev() - || stat.st_ino != metadata.ino() + if stat.st_dev as u64 != metadata.dev() + || stat.st_ino as u64 != metadata.ino() || stat.st_mode & libc::S_IFMT != expected_type { return Err(format!("{label}在安全打开期间发生替换")); diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner.rs b/apps/ai-game-creator-shell/src-tauri/src/runner.rs index 0b16b09be..6dd33fb4b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner.rs @@ -1200,8 +1200,8 @@ fn verify_unix_project_owner_entry( } else { libc::S_IFREG }; - if stat.st_dev != opened_metadata.dev() - || stat.st_ino != opened_metadata.ino() + if stat.st_dev as u64 != opened_metadata.dev() + || stat.st_ino as u64 != opened_metadata.ino() || stat.st_mode & libc::S_IFMT != expected_type { return Err(format!("{label} 在安全打开期间发生替换")); diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/tests.rs index 0bfff5e40..a7658bf37 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests.rs @@ -820,9 +820,13 @@ async fn agent_goal_paused_edit_replans_old_confirmation_in_same_run() { .send(final_tool_plan_response("已按新目标收束,未执行旧动作。")) .expect("complete edited Goal"); - let completed = wait_for_agent_runtime_idle(&root, "code-prototype"); - assert_eq!(completed.phase, "completed"); - assert_eq!(completed.run_id, run_id); + wait_for_agent_runtime_terminal_and_lane_release( + &root, + "code-prototype", + run_id, + "idle", + "completed", + ); assert!(!root.join("game/paused-edit-stale.txt").exists()); assert_eq!( read_game_creator_agent_goal_at(&root, "code-prototype", &session_id) @@ -1286,7 +1290,10 @@ fn unique_project_path() -> PathBuf { .expect("system clock should be after epoch") .as_millis(); let counter = TEST_PROJECT_COUNTER.fetch_add(1, Ordering::Relaxed); - std::env::temp_dir().join(format!( + let temp_root = std::env::temp_dir() + .canonicalize() + .unwrap_or_else(|_| std::env::temp_dir()); + temp_root.join(format!( "genarrative-ai-game-creator-test-{}-{millis}-{counter}", std::process::id() )) @@ -1322,6 +1329,25 @@ fn wait_for_agent_runtime_idle(root: &Path, agent_id: &str) -> AgentRuntimeState runtime } +async fn wait_for_captured_mock_request( + receiver: &mpsc::Receiver, + description: &str, +) -> String { + let deadline = std::time::Instant::now() + Duration::from_secs(10); + loop { + match receiver.try_recv() { + Ok(request) => return request, + Err(mpsc::TryRecvError::Empty) if std::time::Instant::now() < deadline => { + tokio::time::sleep(Duration::from_millis(20)).await; + } + Err(mpsc::TryRecvError::Empty) => panic!("{description}: Timeout"), + Err(mpsc::TryRecvError::Disconnected) => { + panic!("{description}: capture channel disconnected") + } + } + } +} + fn wait_for_agent_runtime_terminal_and_lane_release( root: &Path, agent_id: &str, @@ -12783,10 +12809,13 @@ fn supervisor_collaboration_policy_snapshot_concurrent_conflict_has_one_winner() .collect::>(); assert_eq!(results.iter().filter(|result| result.is_ok()).count(), 1); assert_eq!(results.iter().filter(|result| result.is_err()).count(), 1); - assert!(results - .iter() - .filter_map(|result| result.as_ref().err()) - .all(|error| error.contains("冲突"))); + assert!( + results + .iter() + .filter_map(|result| result.as_ref().err()) + .all(|error| error.contains("冲突")), + "unexpected concurrent binding results: {results:?}" + ); let snapshot = read_supervisor_collaboration_policy_snapshot_for_test(&root, run_id); assert!(policies.contains(&snapshot.policy)); let (primary, previous) = @@ -17334,7 +17363,14 @@ async fn response_stream_disabled_keeps_direct_planning_reply_to_one_request() { run_id, ) .expect("start direct planning response task"); - let completed = wait_for_agent_runtime_idle(&root, "design-director"); + let completed = wait_for_agent_runtime_terminal_and_lane_release( + &root, + "design-director", + run_id, + "idle", + "completed", + ) + .state; assert_eq!(completed.status, "idle"); assert_eq!(completed.phase, "completed"); assert_eq!(completed.last_response.as_deref(), Some(direct_response)); @@ -20350,26 +20386,29 @@ async fn background_agent_runtime_reports_and_truncates_excess_tool_actions() { "design-tool-budget-run", ) .expect("start background task"); - receiver - .recv_timeout(Duration::from_secs(2)) - .expect("first planning request"); - let second_request = receiver - .recv_timeout(Duration::from_secs(2)) - .expect("second planning request"); + wait_for_captured_mock_request(&receiver, "first planning request").await; + let second_request = wait_for_captured_mock_request(&receiver, "second planning request").await; assert!(second_request.contains("runtime.tool_budget")); assert!(second_request.contains("本轮请求了 4 个工具动作,只执行前 3 个")); assert!(second_request.contains("project.index")); assert!(second_request.contains("task.list")); assert!(second_request.contains("asset.list")); - let completed = wait_for_agent_runtime_idle(&root, "design-director"); - assert_eq!(completed.phase, "completed"); + let completed = wait_for_agent_runtime_terminal_and_lane_release( + &root, + "design-director", + "design-tool-budget-run", + "idle", + "completed", + ); assert!(completed + .state .observations .iter() .any(|item| item.contains("本轮请求了 4 个工具动作,只执行前 3 个"))); - assert_eq!(completed.recent_tool_calls.len(), 3); + assert_eq!(completed.state.recent_tool_calls.len(), 3); assert!(completed + .state .recent_tool_calls .iter() .all(|action| action.tool != "memory.read")); @@ -36908,10 +36947,16 @@ async fn provider_retry_waiting_tool_plan_resumes_only_after_due_and_cleans_side assert_eq!(waiting.status, "running"); assert_eq!(waiting.run_id, run_id); assert_eq!(waiting.session_id, started.state.session_id); - assert!( - game_creator_agent_runtime_task_lock_is_available(&root, "design-director") - .expect("probe released Agent lane") - ); + let mut lane_released = false; + for _ in 0..250 { + lane_released = game_creator_agent_runtime_task_lock_is_available(&root, "design-director") + .expect("probe released Agent lane"); + if lane_released { + break; + } + std::thread::sleep(Duration::from_millis(20)); + } + assert!(lane_released, "Provider retry 等待投影后 Agent lane 应释放"); let retry = crate::provider_retry::read_for_run_at(&root, "design-director", run_id) .expect("read persisted Provider retry") .expect("persisted Provider retry exists"); @@ -62320,13 +62365,18 @@ async fn project_supervisor_parent_wake_is_singleflight_and_projects_structural_ .error .as_deref() .is_some_and(|error| error.contains("委派屏障"))); - let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("read agent db"); - assert_eq!( - agent_db + let deadline = std::time::Instant::now() + Duration::from_secs(3); + let audit_count = loop { + let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("read agent db"); + let count = agent_db .matches("agent.runtime.agent.delegate_parent_wake.needs_reconciliation") - .count(), - 1 - ); + .count(); + if count > 0 || std::time::Instant::now() >= deadline { + break count; + } + tokio::time::sleep(Duration::from_millis(20)).await; + }; + assert_eq!(audit_count, 1); fs::remove_dir_all(root).ok(); } @@ -63039,7 +63089,9 @@ async fn project_supervisor_resume_replays_executing_run_status_observation() { "apiKey": "project-supervisor-resume-key", "baseUrl": {base_url:?}, "model": "project-supervisor-resume-model", - "apiKind": "openai_responses" + "apiKind": "openai_responses", + "maxRetries": 1, + "retryBackoffMs": 100 }} }} }}"# @@ -63164,7 +63216,14 @@ async fn project_supervisor_resume_replays_executing_run_status_observation() { "ok", ); - let completed = wait_for_agent_runtime_idle(&root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID); + let completed = wait_for_agent_runtime_terminal_and_lane_release( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + "idle", + "completed", + ) + .state; assert_eq!(completed.phase, "completed"); assert_eq!( completed.last_response.as_deref(), diff --git a/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff.rs b/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff.rs index 866c761fa..2540b4de3 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff.rs @@ -1673,8 +1673,8 @@ fn verify_unix_tool_plan_entry( } else { libc::S_IFREG }; - if stat.st_dev != opened_metadata.dev() - || stat.st_ino != opened_metadata.ino() + if stat.st_dev as u64 != opened_metadata.dev() + || stat.st_ino as u64 != opened_metadata.ino() || stat.st_mode & libc::S_IFMT != expected_type { return Err(format!("{label} 在安全扫描期间发生替换")); diff --git a/apps/ai-game-creator-shell/tests/start-dev-stack.test.ts b/apps/ai-game-creator-shell/tests/start-dev-stack.test.ts new file mode 100644 index 000000000..0a6b36918 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/start-dev-stack.test.ts @@ -0,0 +1,178 @@ +import { EventEmitter } from 'node:events'; +import { existsSync, mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; + +import { describe, expect, test, vi } from 'vitest'; + +import { + ensureBackend, + resolveBackendTargetsFromState, + spawnChild, + stopChild, + waitForChildTermination, +} from '../scripts/start-dev-stack.mjs'; + +const expectedDatabase = 'genarrative-game-creator-dev'; +const expectedDataDir = resolve('server-rs/.spacetimedb/ai-game-creator/data'); + +function backendState(spacetimeDataDir?: string) { + return { + schemaVersion: spacetimeDataDir ? 2 : 1, + database: expectedDatabase, + ...(spacetimeDataDir ? { spacetimeDataDir } : {}), + services: { + 'api-server': { + status: 'running', + url: 'http://127.0.0.1:8082', + }, + spacetime: { + status: 'running', + url: 'http://127.0.0.1:3101', + }, + }, + }; +} + +async function waitForFile(path: string, timeoutMs = 5000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (existsSync(path)) { + return; + } + await new Promise((resolveWait) => setTimeout(resolveWait, 25)); + } + throw new Error(`等待测试进程标记超时: ${path}`); +} + +describe('AI 游戏创作配套后端复用门禁', () => { + test('旧状态缺少专用 data dir 时拒绝复用同名健康后端', () => { + const targets = resolveBackendTargetsFromState(backendState(), { + requireAgcBackend: true, + expectedDatabase, + expectedSpacetimeDataDir: expectedDataDir, + }); + + expect(targets.hasMatchingDatabase).toBe(true); + expect(targets.hasMatchingDataDir).toBe(false); + expect(targets.hasMatchingBackend).toBe(false); + expect(targets.apiUrl).toBe(''); + expect(targets.spacetimeUrl).toBe(''); + }); + + test('只有数据库名和专用 data dir 都匹配时才允许复用', () => { + const wrongDir = resolveBackendTargetsFromState( + backendState(resolve('server-rs/.spacetimedb/local/data')), + { + requireAgcBackend: true, + expectedDatabase, + expectedSpacetimeDataDir: expectedDataDir, + }, + ); + const matching = resolveBackendTargetsFromState( + backendState(expectedDataDir), + { + requireAgcBackend: true, + expectedDatabase, + expectedSpacetimeDataDir: expectedDataDir, + }, + ); + + expect(wrongDir.hasMatchingBackend).toBe(false); + expect(matching.hasMatchingBackend).toBe(true); + expect(matching.apiUrl).toBe('http://127.0.0.1:8082'); + expect(matching.spacetimeUrl).toBe('http://127.0.0.1:3101'); + }); +}); + +describe('AI 游戏创作启动子进程生命周期', () => { + const posixTest = process.platform === 'win32' ? test.skip : test; + + posixTest('npm 不可解析时进入受控 error 结果而不是未处理事件', async () => { + const child = spawnChild('genarrative-command-that-does-not-exist', [], { + cwd: process.cwd(), + }); + + const failure = await waitForChildTermination(child); + + expect(failure.type).toBe('error'); + expect(failure.error).toMatchObject({ code: 'ENOENT' }); + }); + + posixTest('leader 退出后仍按保留的 PGID 清理后代进程', async () => { + const tempDir = mkdtempSync(join(tmpdir(), 'agc-process-group-')); + const readyPath = join(tempDir, 'descendant-ready'); + const stoppedPath = join(tempDir, 'descendant-stopped'); + const descendantSource = ` + const { writeFileSync } = require('node:fs'); + const [readyPath, stoppedPath] = process.argv.slice(1); + process.on('SIGTERM', () => { + writeFileSync(stoppedPath, 'stopped'); + process.exit(0); + }); + writeFileSync(readyPath, 'ready'); + setInterval(() => {}, 1000); + `; + const leaderSource = ` + const { spawn } = require('node:child_process'); + const [readyPath, stoppedPath, descendantSource] = process.argv.slice(1); + const descendant = spawn( + process.execPath, + ['-e', descendantSource, readyPath, stoppedPath], + { stdio: 'ignore' }, + ); + descendant.unref(); + process.exit(42); + `; + let child; + try { + child = spawnChild( + process.execPath, + ['-e', leaderSource, readyPath, stoppedPath, descendantSource], + { cwd: process.cwd() }, + ); + + const failure = await waitForChildTermination(child); + expect(failure).toMatchObject({ type: 'exit', code: 42 }); + await waitForFile(readyPath); + + stopChild(child); + + await waitForFile(stoppedPath); + } finally { + if (Number.isInteger(child?.pid)) { + try { + process.kill(-child.pid, 'SIGKILL'); + } catch { + // 测试后代已经退出。 + } + } + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + test('后端句柄在 ready 等待前交给外层且异常时立即清理', async () => { + const child = Object.assign(new EventEmitter(), { + exitCode: null, + signalCode: null, + kill: vi.fn(), + }); + const onBackendChild = vi.fn(); + const waitUntilReady = vi.fn(async (receivedChild) => { + expect(receivedChild).toBe(child); + expect(onBackendChild).toHaveBeenCalledWith(child); + throw new Error('等待配套后端和数据库启动超时'); + }); + + await expect( + ensureBackend({ + checkBackendReady: async () => false, + spawnBackend: () => child, + onBackendChild, + waitUntilReady, + }), + ).rejects.toThrow('等待配套后端和数据库启动超时'); + + expect(child.kill).toHaveBeenCalledWith('SIGTERM'); + }); +}); diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 28286a2bd..a912da410 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -3267,8 +3267,8 @@ - 现象:target 注册了 SIGTERM 清理逻辑,但 `command.terminate` 只偶尔出现 stopped marker;耗时 300-500ms 的清理经常被提前截断。 - 原因:如果先向 wrapper/bwrap/trampoline/target 共用的外层进程组发送 SIGTERM,wrapper 会先退出,bwrap 的 die-with-parent 随即收走 namespace;名义上的 800ms 宽限并没有真正留给 target。 -- 处理:process-session target 在 child pre-exec 内暂时屏蔽 SIGTTOU,完成 setpgid + PTY slave tcsetpgrp 并恢复信号掩码后才 exec;不能先 spawn 到后台组再由 parent 设前台,否则 target 可能已经因 immediate read 收到 SIGTTIN。Runtime 通过两级私有控制通道请求 trampoline 只向 target group 发 SIGTERM。direct leader 退出后 trampoline 继续检查同组后代,外层 wrapper/bwrap 在最多 800ms 宽限期保持存活,超时才强杀 containment group。 -- 验证:使用直接 bash target 启动同组后台子进程;leader 在输出 READY 后自然退出,仍存活的子进程收到 TERM 后由 trap 延迟 400ms 写 marker 并退出,terminate 返回前 marker 必须存在。另跑 immediate stdin/EOF、Runner owner SIGKILL 和后代隔离用例,确认前台切组没有破坏交互或 fail-closed 回收。 +- 处理:process-session target 在 child pre-exec 内暂时屏蔽 SIGTTOU,完成 setpgid + PTY slave tcsetpgrp 并恢复信号掩码后才 exec;不能先 spawn 到后台组再由 parent 设前台,否则 target 可能已经因 immediate read 收到 SIGTTIN。Runtime 通过两级私有控制通道请求 trampoline 只向 target group 发 SIGTERM。direct leader 退出后 trampoline 继续检查同组后代,外层 wrapper/bwrap 在最多 800ms 宽限期保持存活,超时才强杀 containment group。reader 发现未换行输出超过上限时必须先原子投影 `output-limit-exceeded` 并唤醒 poll,再异步发送终止控制,不能让高负载下的 supervisor 调度延迟把已越界进程继续暴露为 `running`。 +- 验证:使用直接 bash target 启动同组后台子进程;leader 在输出 READY 后自然退出,仍存活的子进程收到 TERM 后由 trap 延迟 400ms 写 marker 并退出,terminate 返回前 marker 必须存在。正式 `command.exec` 测试夹具仍必须走允许的 `npm run` 等程序,不能为了构造 stdin race 绕过白名单直接解析 `bash -lc`。另跑 immediate stdin/EOF、Runner owner SIGKILL 和后代隔离用例,确认前台切组没有破坏交互或 fail-closed 回收;测试互斥锁在前序 panic 后应恢复 guard 继续报告后续独立结果,不能用 `PoisonError` 掩盖真实失败范围。 - 关联:`apps/ai-game-creator-shell/src-tauri/src/process_session.rs`、`process_session_bridge.rs`、`command_sandbox_trampoline.rs`。 ## 启动记录必须封闭状态组合,child 不能自行猜 durable commit 超时 @@ -3311,14 +3311,14 @@ - 处理:正式主聊天只路由到 `project-supervisor` active Session,活跃期输入继续 same-run steer;同一父 run 最多同时保留 3 个 `dispatched / ready` 静态专业委派,已预留的同 action delivery 恢复复用原 target Session/run,不另占名额。同一工具计划完成委派后,Runtime 在下一次 Provider planning 前直接持久化 `waiting-for-delegate-receipts` 并释放 lane,不让模型轮询等待。delivery 单向推进 `dispatched -> ready -> claimed-by-parent / suppressed`,claim 单向推进 `Prepared -> Committed -> Observed`;先持有 claim 锁,再对 delegationId 排序去重并按序取齐 delivery 锁,任一锁不可得时零状态推进。delivery / claim journal 与 pending observation 是事实源;Agent DB append 只能 best-effort,失败不得推翻已持久化结果。入队在 Session lane 内完成,Runner 通知在 lane 外发送;`agent.run_status` 保留 claim 身份校验但不绑定全局 project revision/fingerprint。 - 恢复门禁:只有 `project-supervisor` 的 executing `agent.delegate / agent.run_status` 可在项目锁内重验 durable pending、Session/run/action fingerprint、delivery/claim/child 身份和当前 policy 后补交;只有 delivery 预留且无 child 时,拒绝动作必须把该预留 CAS 为 suppressed。其他 executing 动作或副作用身份不明必须进入 `needs-reconciliation`。parent-wake 以 project/Agent/run 做 coalescing singleflight,新信号不能在已有 worker 退出窗口丢失;有界重试接受 lane 竞争、暂时连接、连接中止、broken pipe、unexpected EOF、资源暂不可用和超时类错误。损坏 journal、身份冲突及重启扫描中的损坏 barrier 直接投影 reconciliation。External Runner wake 用项目根、method、Agent、runId 和 loop iteration 派生稳定 requestId,目标未观察到、仍 waiting 或 lane 忙时返回不缓存的可重试错误。 - 身份与收束:子终态发布前同时核对 parent Agent/Session/run/action、delegationId 派生、target Agent/Session/run、child source 和 child 反向 parent/delegation 链接。错配 child 保持原 delivery 不变并记录冲突;父任务先进入 completed / failed / cancelled / budget-exhausted 时,终态写入路径 suppress 尚未认领的匹配 delivery,合法迟到 child 不能重新写 ready。父 run 在 waiting、ready-unclaimed 或 unobserved claim 任一非零时都不得 final;全部清零后仍由原 Supervisor Session/run 的 finalization journal 幂等写入唯一 assistant,不创建新 receipt run。 -- 验证:Rust 定向回归使用 `project_supervisor_` 前缀,覆盖 delivery/claim 状态机、同 action 幂等、第 4 个新委派拒绝与已预留委派复用/拒绝 suppression、后续 delivery 锁忙时零部分认领、Agent DB 故障后回执仍可重放、未 Observed 阻断 final、Provider planning 前 durable 等待、parent-wake coalescing/结构性错误、重启损坏 barrier、错配和迟到 child、executing `run_status` 续接与 delegate policy 重验;`agent_background_enqueue_notifies_only_after_session_lane_release` 覆盖入队锁序,Runner 内部测试覆盖定向 wake 与不缓存重试。真实 Provider 必须同时证明专业 Agent 时间区间重叠、父 run 仅一次 waiting、同一 Observed claim 认领全部回执、唯一 assistant、第二轮历史引用不新增委派和项目范围密钥扫描为 0。 +- 验证:Rust 定向回归使用 `project_supervisor_` 前缀,覆盖 delivery/claim 状态机、同 action 幂等、第 4 个新委派拒绝与已预留委派复用/拒绝 suppression、后续 delivery 锁忙时零部分认领、Agent DB 故障后回执仍可重放、未 Observed 阻断 final、Provider planning 前 durable 等待、parent-wake coalescing/结构性错误、重启损坏 barrier、错配和迟到 child、executing `run_status` 续接与 delegate policy 重验;本地 mock Provider 长套件应允许一次短间隔 connectivity 重试,并在断言前同时等待终态投影和 Agent lane 释放,避免端口瞬时波动或后台收尾窗口制造假失败。`agent_background_enqueue_notifies_only_after_session_lane_release` 覆盖入队锁序,Runner 内部测试覆盖定向 wake 与不缓存重试。真实 Provider 必须同时证明专业 Agent 时间区间重叠、父 run 仅一次 waiting、同一 Observed claim 认领全部回执、唯一 assistant、第二轮历史引用不新增委派和项目范围密钥扫描为 0。 - 关联:`docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md`、`apps/ai-game-creator-shell/src-tauri/src/delegation.rs`、`agent.rs`、`runner.rs`、`tests.rs`。 ## 父 run 协作策略不能在绑定后继续按全局 live policy 重验 - 现象:同一 Supervisor 父 run 已经持久化合法 collaboration batch,管理员随后修改或损坏 `.agent/collaboration-policy.json`,后续 spawn、claim、mutation、MCP 或 finalization 却突然改用新策略、进入 reconciliation;或者 snapshot 被删除后,Runtime 又按 live policy 把已有 run 当成未绑定 run。另一类症状是 contractless/v1 batch 被跳过、两个不安全 run ID 经字符替换落到同一 snapshot/锁 key,或旧 `Prepared / Committed` claim 因 snapshot/binding 不可读而不能重放 observation。 - 原因:把项目级 policy 当成每个动作的 live 执行事实,没有为父 run 设置明确线性化点、不可变策略快照和独立“曾绑定”记录;或者在 v2 batch 完整验真前就用 `contract.policy` 播种 snapshot。只对 run ID 做 lossy 规范化、让锁复用该路径片段,或用通用原子 replace 代替同一身份锁内 CAS,也会制造路径碰撞、并发覆盖和伪合同漂移。 -- 处理:V1.38 固定顺序为 `v2 batch -> snapshot -> binding sidecar -> action side effects`。snapshot 位于 `.agent/runtime/collaboration-policy-snapshots//.json`,其完整字段必须统一为 `schemaVersion / projectId / parentAgentId / parentRunId / boundFrom / policy / policyFingerprint / snapshotFingerprint / boundAt`;snapshot fingerprint 覆盖除 `snapshotFingerprint / boundAt` 外的全部稳定字段。独立 binding 位于 `.agent/runtime/collaboration-policy-snapshot-bindings//.json`,固定包含 `schemaVersion / projectId / parentAgentId / parentRunId / boundFrom / policyFingerprint / snapshotFingerprint / boundAt`,与 snapshot 逐字段交叉验证并持久证明“该 run 曾绑定”。 +- 处理:V1.38 固定顺序为 `v2 batch -> snapshot -> binding sidecar -> action side effects`。snapshot 位于 `.agent/runtime/collaboration-policy-snapshots//.json`,其完整字段必须统一为 `schemaVersion / projectId / parentAgentId / parentRunId / boundFrom / policy / policyFingerprint / snapshotFingerprint / boundAt`;snapshot fingerprint 覆盖除 `snapshotFingerprint / boundAt` 外的全部稳定字段。独立 binding 位于 `.agent/runtime/collaboration-policy-snapshot-bindings//.json`,固定包含 `schemaVersion / projectId / parentAgentId / parentRunId / boundFrom / policyFingerprint / snapshotFingerprint / boundAt`,与 snapshot 逐字段交叉验证并持久证明“该 run 曾绑定”。同一 run 并发绑定时,无论 loser 是读取到不同快照还是在 winner 持锁期间耗尽有界等待,都必须返回稳定的“并发绑定冲突”错误分类。Unix 同进程首次并发初始化安全锁路径时,需要短暂串行化 `mkdirat/openat` 打开阶段,规避 macOS loser 在最终 `O_CREAT` 前观察到瞬时 `ENOENT`;返回后的 `flock` 仍承担跨线程、跨进程互斥。 - 路径与恢复:不安全或规范化后变化的 Agent/run ID 使用有界安全前缀加原始 ID 稳定 SHA-256,锁 key 对完整 `parentAgentId + NUL + parentRunId` 计算稳定 SHA-256,不能只做字符替换。恢复顺序为 existing valid snapshot > 完整验真的 v2 batch contract > 符合严格状态门禁的 legacy 当前有效 policy;snapshot 缺 binding 可从 snapshot 补写,binding 存在但 snapshot 丢失只能按可信 v2 contract 和首次绑定身份恢复,无可信 v2 时禁止 live policy 重绑。contractless/v1 collaboration batch 必须先失败关闭。`legacy-current-project-policy` 只允许无 snapshot/binding、无可信 v2 contract,且不存在上述旧 batch,并由可信身份和状态明确证明属于 `pending / running / waiting-for-confirmation / waiting-for-user-input` 的旧父 run;terminal、`needs-reconciliation` 或身份/状态未知 run 的状态读取不得新建 snapshot。 - 漂移与 Claim:绑定后 global policy 的 `matched / drifted / unreadable` 只报告状态,不能改变后续动作或完成门禁;新 policy 只用于后续新父 run。旧 durable claim、未观察 claim 和 legacy claimed delivery 先按原 action/group 身份恢复且不得取得新 delivery;新的 claim 必须先成功解析 effective snapshot 并核对 binding,再执行 V1.35-V1.37 的全锁、预算、完整 observation 和 group 数量门禁。 - 真实 E2E 现场:正在运行的正式客户端可能在验收期间启动或重启正式 Runner,导致 source endpoint 身份真实变化。不得关闭 `sourceRunnerEndpointUnchanged` 门禁,也不得杀掉不属于验收器的进程;应把同一配置内容复制到仓库外的大容量磁盘私有目录,目录/文件权限分别为 `0700/0600`,不复制 endpoint、锁、会话或数据库,验收后删除。功能完整但 source endpoint 被外部改变的报告与后续干净清理报告不得拼接。 @@ -3500,3 +3500,21 @@ - 运维陷阱:从容器内运行 Compose 时,宿主 `/opt/gitea-stack` 必须挂到容器同名绝对路径;挂成 `/stack` 会让相对 bind source 被 daemon解析为宿主 `/stack/...`,表现为 Gitea进入空安装页、gateway 脚本“缺失”。发现后不要迁移空库,立即用同路径 mount 重建并核对原数据大小、installed 日志、仓库数和 API。切换前保留冷数据 tar、pg_dumpall 和原 compose/env/runner 配置,备份与 token 不提交 Git。 - 验证:同时检查 Gitea 版本、runner declare、外层 `Privileged=false`/无 CapAdd/无宿主 socket、inner job `Binds=[]`、`MaskedPaths=[]`、`ReadonlyPaths=[]`、固定 image digest、`/var/run/docker.sock` 不存在、公共 proxy 可用、直连公网/Postgres/metadata 失败,以及完整 bwrap canary。AI 原生壳的共享 Agent Runtime 后台锁 suite 固定单线程执行;并行全量出现锁或异步终态失败、逐项单线程全部通过时,修正 suite 调度口径,不放宽断言。最后重跑四个 CI job;checkout 成功但 apt/rustup/npm 同时失败时,先排 proxy/env,而不是改测试。 - 关联:`.gitea/workflows/project-ci.yml`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`、`docs/project-memory/shared-memory/development-workflow.md`。 + +## Unix 文件身份复核不能假定 Linux 的 `dev_t` 类型 + +- 现象:AI 游戏创作 Tauri 壳在 Linux CI 编译通过,但 macOS 上会在 Agent DB、External Runner owner 和 tool-plan handoff 的 `fstatat` 身份复核中报 `i32 == u64` 类型错误;Tauri 失败后配套后端收束,终端还可能短暂出现 SpacetimeDB 订阅连接失败的连锁日志。 +- 原因:`libc::stat.st_dev` 跟随平台 `dev_t`,macOS 为有符号整数,而 `std::os::unix::fs::MetadataExt::dev()` 统一返回 `u64`;直接比较会把 Linux 的类型偶合误当成 Unix 通用契约。 +- 处理:与 Rust 标准库的 Unix `MetadataExt` 实现保持一致,先把 `st_dev / st_ino` 规范为 `u64`,再与 `metadata.dev() / metadata.ino()` 比较;设备号、inode 和文件类型三重检查均必须保留。 +- macOS 测试夹具:`std::env::temp_dir()` 可能返回 `/var/folders/...`,而 `/var` 是系统兼容符号链接。需要真实项目根的 Runtime 测试应先 canonicalize 已存在的临时根目录,再创建唯一子目录;不得为了让夹具通过而放宽生产 Runtime 的项目根及祖先符号链接拒绝规则。 +- 验证:macOS 本机运行 `cargo check --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml`,并复跑 Agent DB、project owner 和 tool-plan handoff 的 Unix 相对句柄替换检测;Linux CI 继续覆盖原有安全回归。 +- 关联:`apps/ai-game-creator-shell/src-tauri/src/project.rs`、`runner.rs`、`tool_plan_handoff.rs`。 + +## AI 游戏创作壳不能用全局或匿名身份发布本地模块 + +- 现象:`npm run agc` 在发布模块时先访问 `auth.spacetimedb.com` 并以 401 失败;改成 `--anonymous` 后首次可能成功,但再次启动会因匿名 identity 变化而 403。若把 403 当成可忽略警告继续启动,api-server 会连接旧 schema,随后持续输出 `external_generation_job`、`profile_recharge_order_expiration_timer` 等缺表订阅失败,Tauri 也可能在后端就绪前退出或迟迟不弹窗。 +- 原因:本地 publish 默认继承开发者全局 SpacetimeDB 云端登录,离线时 standalone 无法校验 issuer;`--anonymous` 不是可跨进程持久复用的 owner identity;AI 游戏创作壳若再复用主站历史数据目录,还会继承旧数据库归属和旧 schema。 +- 处理:AI 游戏创作壳固定使用 gitignored 的独立数据目录;standalone 就绪后先从 `/v1/identity` 获取并按 data dir 而非监听端口持久化同一 API identity,再用数据目录内权限为 `0600` 的独立 `cli.toml` 执行 `spacetime login --token` 和 publish。旧端口作用域记录在同一 data dir 下身份唯一时迁移,存在多个不同身份时失败关闭,不能猜 owner。远程 server 继续使用正常登录配置;本地 publish 403 必须阻断 API/Vite,不得带旧 schema 降级启动。`.app/dev-stack.json` 记录规范化 data dir,独立壳复用后端时必须同时匹配数据库名、专用目录和健康状态;缺少目录字段的旧状态不得复用。POSIX 启动器在 `spawn` 后立即监听 `error / exit`、保存 detached leader 的 PGID、向外层登记句柄并用独立进程组收束 npm、Node、Cargo 和子进程;direct leader 先退出后仍向负 PGID 发信号清理后代,ready 前中断、超时或 ENOENT 也走统一清理,退出后确认 3080、8082、3101 均释放。 +- macOS 日志:api-server 进程指标当前只实现 Windows API 和 Linux `/proc`,macOS 必须跳过 observable callback 注册;不能每轮采集为每个指标重复打印“不支持平台”。Rust/Tauri 既有 `dead_code` warning 与一次性配置缺失提示不属于长驻重试日志。非 Linux `project.verify` 校验 `npm run` 参数时必须越过 `--silent`、`--ignore-scripts` 等前置选项定位真实脚本名,不能固定读取 `run` 后第一个参数,否则会在 macOS 将合法验证误报为“缺少脚本名”并引发 Runtime 测试级联失败。 +- 验证:定向测试覆盖同一 data dir 跨端口复用 identity、不同 data dir 隔离、旧 state/data dir 不匹配拒绝复用、spawn ENOENT 受控失败、direct leader 以 42 退出后同组 descendant 仍收到 TERM,以及后端 ready 前句柄已登记且超时清理。连续运行两次 `npm run agc`,两次都必须真实完成 module publish、`/v1/ping`、`/healthz`、Vite 3080 和 Tauri `Running`;稳定观察期间不得出现缺表订阅失败或进程指标平台告警,Ctrl-C 后三个端口和主 Tauri 进程均应释放。 +- 关联:`scripts/dev.mjs`、`apps/ai-game-creator-shell/scripts/start-dev-stack.mjs`、`server-rs/crates/api-server/src/process_metrics.rs`。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 1b98ce07e..0b99b701c 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -449,6 +449,7 @@ game-project/ - `apps/ai-game-creator-shell` 是独立 Tauri App,不复用 `apps/desktop-shell`。 - 独立客户端启动时先进入平台登录检查;未登录页默认展示手机号验证码登录,并保留密码登录切换。验证码登录调用平台后端 `/api/auth/phone/send-code` 与 `/api/auth/phone/login`,密码登录继续调用 `/api/auth/entry`;Tauri dev 下 `/api` 走固定 3080 Vite 代理,发布版静态窗口下登录请求默认直连本机配套 `http://127.0.0.1:8082` API,网络层失败时展示登录服务不可达提示,不裸露 WebView 的 `Load failed`。 +- `npm run agc` 的本地 SpacetimeDB owner identity 以独立 `spacetimeDataDir` 为作用域,不绑定可能漂移的监听端口;旧端口作用域记录仅在同一 data dir 下身份唯一时自动迁移,出现多个不同旧身份时失败关闭。`.app/dev-stack.json` 必须记录规范化 `spacetimeDataDir`,独立壳只复用数据库名和该目录同时匹配且健康的后端,旧 schema 状态或共享目录状态缺少此字段时不得复用。POSIX 子进程在 `spawn` 返回时立即登记 `error / exit` 生命周期、保存 detached leader 的 PGID 并把句柄交给外层;即使 direct leader 已先退出,也必须继续向负 PGID 发信号清理同组后代。后端 ready 前的 SIGINT、SIGTERM、超时或 ENOENT 都必须走同一进程组清理链路,不能遗留 npm、Cargo 或 SpacetimeDB。非 Linux Runtime 执行 `project.verify` 时,`npm run` 参数校验必须允许受控的 `--silent`、`--ignore-scripts` 位于脚本名前,并继续拒绝缺少真实脚本名的调用。 - Tauri Rust 入口保持薄壳:`src-tauri/src/main.rs` 只保留共享类型 / 常量、模块声明、CLI preflight、`tauri::Builder`、运行时配置初始化和 `invoke_handler` 清单;命令行入口放在 `cli.rs`,Tauri command 包装放在 `commands.rs`,运行时配置与 LLM 配置检查放在 `config.rs`,Agent loop 与生成编排放在 `agent.rs`,上传 / 画板 / 平台美术生成接入放在 `assets.rs`,本地项目文件、记忆、对话、权限、checkpoint、manifest 和通用路径工具放在 `project.rs`,本地 HTTP 预览与 preview 命令放在 `preview.rs`,旧窗口兼容命令放在 `windows.rs`,Rust 单测放在 `tests.rs`。后续继续拆分时保持 Tauri command 名、JSON 字段、`.agent/*` 路径和错误语义不变。 - 本地项目初始化会创建 `game/`、`assets/`、`memory/`、`memory/agents/`、`exports/`、`.agent/logs/`,写入 `.agent/manifest.json`,生成 append-only JSONL 本地项目索引 `.agent/agent.db`,并生成默认 `game/index.html`。 - v1 conversation 记录使用 append-only JSONL,每行带 `schemaVersion`、`role`、`content`、`agentId` 和 `updatedAt`,作为聊天历史和单 agent 对话历史的事实源;目录在首次写入时创建。 @@ -474,6 +475,9 @@ game-project/ - 终端可用 `npm run ai-game-creator-shell:agent-run -- /绝对项目路径 "游戏创作需求"` 跑一次真实 LLM 生成、落盘、`game.static_smoke` 和本地 HTTP 预览;发布 App 读取 Tauri 应用配置目录中的 `game-creator.config.json`,开发 CLI 无 AppHandle 时才读取仓库旁边的配置模板和 gitignored 本机覆盖文件,不把 API Key 写入仓库或项目文件。自动验证可加 `--no-wait`,例如 `npm run ai-game-creator-shell:agent-run -- --no-wait /tmp/genarrative-ai-game-test "像素风反弹弹幕厨房"`,生成预览 trace 后立即停止本地预览,避免终端卡在回车等待。默认 API kind 为 `openai_responses`;旧 Chat Completions 兼容网关设置 `llm.apiKind` 为 `openai_chat`,Anthropic Messages 网关设置 `llm.apiKind` 为 `anthropic`。真实 OpenAI-compatible 网关建议设置 `llm.stream` 为 `true` 跑 Planner 和 Generator,避免长请求非流式空闲断连。 - 终端可用 `npm run ai-game-creator-shell:agent-run:smoke` 跑一次无密钥本地端到端 smoke:脚本启动本机 OpenAI-compatible SSE 流式测试 provider,预置一个本地上传图片和一个本地上传音频,复用真实 `--agent-run`、Planner / Orchestrator / 角色 agent / Generator / Evaluator loop、本地落盘、`game.static_smoke` 和本地 HTTP 预览,并断言每次 provider 请求都使用 `stream: true`、Planner 与 Generator 分别命中自己的 `agentLlm` provider 配置、provider prompt 收到图片与音频资产上下文以及最近对话上下文、生成 HTML 引用这些资产、预览服务能用 `GET` 读取 `/assets/...`、用 `HEAD` 返回真实资源长度和对应 MIME、headless Chrome 打开预览后至少执行一帧游戏 JS,且通过确定性亮色探针采样证明 canvas 不是空白画布、`.agent/run.latest.json` 的 step group 覆盖 design / balance / art / audio / code / publishing 六组、第二轮会重跑 Evaluator 命中任务及其下游影响任务,未受影响角色 carry-over;随后脚本自动给 CLI 发送回车停止预览。该脚本只用于开发验证,不进入产品生成路径。 - `npm run ai-game-creator-shell:dev` 的 Tauri `devUrl` 固定为 `http://127.0.0.1:3080/`,Vite 必须 `strictPort` 对齐;`beforeDevCommand` 先复用已经跑在 3080 且页面标题为 `AI 游戏创作` 的本 app Vite server,否则才启动新的 Vite,若端口被其它服务占用则直接失败并提示释放端口。 +- AI 游戏创作 App 的本地后端使用 gitignored 的 `server-rs/.spacetimedb/ai-game-creator/data`,不复用主站旧 standalone 数据目录。启动器从本地 `/v1/identity` 获取并持久化 API identity,再通过数据目录内 `0600` 的独立 `dev-cli/cli.toml` 发布模块;不得读取或覆盖开发者全局 SpacetimeDB 登录,也不得回退到每次变化的 `--anonymous` 身份。发布失败时 API 和 Vite 不得继续启动旧 schema,避免 `external_generation_job` 等缺表订阅进入持续重试。 +- `start-dev-stack.mjs` 在 POSIX 下以独立进程组托管后端和 Vite,关闭 Tauri 或任一子进程失败时必须收束整组;macOS 不注册仅支持 Windows/Linux 的 api-server 进程指标 observable callback,避免每轮指标采集重复输出平台不支持告警。 +- Unix 下 Agent DB、External Runner owner 和 tool-plan handoff 的相对句柄复核必须同时比较设备号、inode 和文件类型;`libc::stat` 的 `st_dev / st_ino` 先按 Rust `MetadataExt` 的 Unix 口径规范为 `u64` 再比较,保持 Linux 和 macOS 的同一安全语义,不得为了通过 macOS 编译而删除路径替换检测。 - AI 游戏创作 App 的 Vite root 保持在 `apps/ai-game-creator-shell`,但开发服务器必须通过 `server.fs.allow: [repoRoot]` 允许加载 `packages/shared/src` 共享契约;配置自检同时守住该规则,避免 typecheck 通过后真实 Tauri WebView 因共享源码 403 变成白屏。Tauri 事件 capability 只向 `client`、`developer`、`main`、`launcher` 窗口开放 `core:event:allow-listen` 和 `core:event:allow-unlisten`;Runtime 事件仍由 Rust 发出,前端不获得 `emit` 权限。 - `.agent/manifest.json` 会保存 6 个专业组下 16 个组内角色任务状态,当前覆盖 `Director`、`Gameplay`、`Difficulty`、`Asset`、`Polish`、`SFX`、`Code`、`Review`、`Preview`、`Playtest`、`Publish`;程序组内显式包含 `quality-review` 质量评审 gate,由 Evaluator trace 标记完成;开发窗口的专业组面板读取 manifest,而不是前端硬编码。 - 主窗口的 agent 状态列表以 manifest 角色任务为底表,再合并最近 run trace 中 `taskGraph.tasks` 的任务状态、同 taskId / group / role 的最新 step 状态、输入输出路径、错误摘要、lifecycleStatus 和 `activeTaskIds` / `carriedTaskIds` / `readyTaskIds` 编排标记;如果 trace 缺失或过期,只展示 manifest 的静态任务状态和“暂无最近运行证据”。 diff --git a/scripts/dev.mjs b/scripts/dev.mjs index c1004be94..49ba11217 100644 --- a/scripts/dev.mjs +++ b/scripts/dev.mjs @@ -383,10 +383,11 @@ function buildDevStackSnapshot(runner, updatedAt = new Date().toISOString()) { } return { - schemaVersion: 1, + schemaVersion: 2, command: runner.command ?? 'all', repoRoot, database: runner.options.database, + spacetimeDataDir: resolve(runner.options.spacetimeDataDir), watch: Boolean(runner.options.watch), updatedAt, services, @@ -924,7 +925,7 @@ function readLinuxApiServerProcessSnapshot(pid) { if ( error?.code === 'ENOENT' || error?.code === 'EACCES' || - error?.code === 'EPERM' || + error?.code === 'EPERM' || error?.code === 'ESRCH' ) { return null; @@ -1124,7 +1125,11 @@ class DevRunner { this.command = command; ensureRequiredFiles(command); requireCommand('node'); - if (command === 'api-server' || command === 'all' || command === 'backend') { + if ( + command === 'api-server' || + command === 'all' || + command === 'backend' + ) { requireCommand('cargo'); } if ( @@ -1319,7 +1324,11 @@ class DevRunner { } } - if (command === 'all' || command === 'backend' || command === 'api-server') { + if ( + command === 'all' || + command === 'backend' || + command === 'api-server' + ) { portConfig.api = { host: options.apiHost, preferredPort: options.apiPort, @@ -1513,12 +1522,11 @@ class DevRunner { await this.publishSpacetimeModule(); } catch (error) { if (isSpacetimePublishPermissionError(error)) { - console.warn( - `[dev:spacetime] 本地发布被当前 identity 拒绝,保留已启动的 standalone: ${error.message}`, + throw new Error( + `本地数据库不属于当前隔离 identity,已停止启动以避免 API 使用旧 schema 后持续重试订阅。请改用独立本地数据目录,或在确认无需保留旧开发数据后重建该目录。详情: ${error.message}`, ); - } else { - throw error; } + throw error; } } } @@ -1645,8 +1653,10 @@ class DevRunner { async publishSpacetimeModule() { const env = buildLocalRustProcessEnv(this.baseEnv); this.prepareMigrationBootstrapSecret(env); + const cliConfigPath = await this.prepareLocalSpacetimeCliIdentity(env); const args = buildSpacetimePublishArgs({ + cliConfigPath, database: this.options.database, preserveDatabase: this.options.preserveDatabase, server: this.state.spacetimeServer, @@ -1660,6 +1670,48 @@ class DevRunner { }); } + async prepareLocalSpacetimeCliIdentity(env) { + if (!isLoopbackSpacetimeServer(this.state.spacetimeServer)) { + return ''; + } + + await this.ensureApiServerSpacetimeToken(); + const cliConfigPath = resolve( + this.options.spacetimeDataDir, + 'dev-cli', + 'cli.toml', + ); + ensureParentDir(cliConfigPath); + if ( + existsSync(cliConfigPath) && + resolveCurrentSpacetimeCliToken(cliConfigPath) === this.spacetimeApiToken + ) { + chmodSync(cliConfigPath, 0o600); + console.log('[dev:spacetime] 已复用隔离的本地发布 identity'); + return cliConfigPath; + } + await runForeground( + 'spacetime', + [ + '--config-path', + cliConfigPath, + 'login', + '--token', + this.spacetimeApiToken, + ], + { + cwd: serverRsDir, + env, + label: 'spacetime-login', + }, + ); + if (existsSync(cliConfigPath)) { + chmodSync(cliConfigPath, 0o600); + } + console.log('[dev:spacetime] 已配置隔离的本地发布 identity'); + return cliConfigPath; + } + prepareMigrationBootstrapSecret(env) { let runtimeServiceBootstrapSecret = ''; switch (this.options.migrationBootstrapSecretMode) { @@ -2447,10 +2499,85 @@ function normalizeSpacetimeServerForIdentity(serverUrl) { return url.href.replace(/\/$/u, ''); } -function resolveLocalSpacetimeApiIdentityPath(dataDir, serverUrl) { - const normalizedServer = normalizeSpacetimeServerForIdentity(serverUrl); - const serverKey = createHash('sha256').update(normalizedServer).digest('hex'); - return resolve(dataDir, 'dev-api-identities', `${serverKey}.json`); +function resolveLocalSpacetimeApiIdentityPath(dataDir) { + return resolve(dataDir, 'dev-api-identities', 'local-node.json'); +} + +function readLocalSpacetimeApiIdentityRecord(identityPath, expected = {}) { + const stat = lstatSync(identityPath); + if (!stat.isFile() || stat.isSymbolicLink()) { + throw new Error('记录不是普通文件'); + } + chmodSync(identityPath, 0o600); + const payload = JSON.parse(readFileSync(identityPath, 'utf8')); + const identity = + typeof payload.identity === 'string' ? payload.identity.trim() : ''; + const token = typeof payload.token === 'string' ? payload.token.trim() : ''; + if (!identity || !token) { + throw new Error('记录缺少 identity 或 token'); + } + + if (payload.schemaVersion === 2 && payload.scope === 'local-data-dir') { + return { identity, token }; + } + if ( + expected.allowLegacy && + payload.schemaVersion === 1 && + typeof payload.server === 'string' && + isLoopbackSpacetimeServer(payload.server) + ) { + return { identity, token }; + } + throw new Error('记录格式或 data dir 作用域不匹配'); +} + +function migrateLegacyLocalSpacetimeApiIdentity(dataDir) { + const identityDir = resolve(dataDir, 'dev-api-identities'); + if (!existsSync(identityDir)) { + return null; + } + + const candidates = []; + for (const entry of readdirSync(identityDir, { withFileTypes: true })) { + if (!entry.isFile() || !entry.name.endsWith('.json')) { + continue; + } + const candidatePath = resolve(identityDir, entry.name); + if (candidatePath === resolveLocalSpacetimeApiIdentityPath(dataDir)) { + continue; + } + try { + candidates.push( + readLocalSpacetimeApiIdentityRecord(candidatePath, { + allowLegacy: true, + }), + ); + } catch { + // 无效或非本地旧记录不参与迁移。 + } + } + + const uniqueCandidates = new Map( + candidates.map((candidate) => [ + `${candidate.identity}\n${candidate.token}`, + candidate, + ]), + ); + if (uniqueCandidates.size === 0) { + return null; + } + if (uniqueCandidates.size > 1) { + throw new Error( + '同一 SpacetimeDB data dir 下发现多个旧 API identity,无法安全判断数据库 owner;请保留正确 owner 记录后重试', + ); + } + + const [identity] = uniqueCandidates.values(); + writeLocalSpacetimeApiIdentity({ dataDir, ...identity }); + console.log( + '[dev:spacetime] 已将旧端口作用域 API identity 迁移到 data dir 作用域', + ); + return identity; } function resolveLocalSpacetimeRuntimeServiceBootstrapSecretPath( @@ -2600,37 +2727,13 @@ function readLocalSpacetimeApiIdentity({ dataDir, serverUrl }) { return null; } - const normalizedServer = normalizeSpacetimeServerForIdentity(serverUrl); - const identityPath = resolveLocalSpacetimeApiIdentityPath( - dataDir, - normalizedServer, - ); + const identityPath = resolveLocalSpacetimeApiIdentityPath(dataDir); if (!existsSync(identityPath)) { - return null; + return migrateLegacyLocalSpacetimeApiIdentity(dataDir); } try { - const stat = lstatSync(identityPath); - if (!stat.isFile() || stat.isSymbolicLink()) { - throw new Error('记录不是普通文件'); - } - chmodSync(identityPath, 0o600); - const payload = JSON.parse(readFileSync(identityPath, 'utf8')); - if ( - payload.schemaVersion !== 1 || - payload.server !== normalizedServer || - typeof payload.identity !== 'string' || - !payload.identity.trim() || - typeof payload.token !== 'string' || - !payload.token.trim() - ) { - throw new Error('记录格式或 server 绑定不匹配'); - } - - return { - identity: payload.identity.trim(), - token: payload.token.trim(), - }; + return readLocalSpacetimeApiIdentityRecord(identityPath); } catch (error) { console.warn( `[dev:spacetime] 本地 API identity 记录不可用,将重新创建: ${error.message}`, @@ -2639,17 +2742,8 @@ function readLocalSpacetimeApiIdentity({ dataDir, serverUrl }) { } } -function writeLocalSpacetimeApiIdentity({ - dataDir, - serverUrl, - identity, - token, -}) { - const normalizedServer = normalizeSpacetimeServerForIdentity(serverUrl); - const identityPath = resolveLocalSpacetimeApiIdentityPath( - dataDir, - normalizedServer, - ); +function writeLocalSpacetimeApiIdentity({ dataDir, identity, token }) { + const identityPath = resolveLocalSpacetimeApiIdentityPath(dataDir); const tempPath = `${identityPath}.${process.pid}.${randomHex(8)}.tmp`; ensureParentDir(identityPath); @@ -2657,8 +2751,8 @@ function writeLocalSpacetimeApiIdentity({ writeFileSync( tempPath, `${JSON.stringify({ - schemaVersion: 1, - server: normalizedServer, + schemaVersion: 2, + scope: 'local-data-dir', identity, token, })}\n`, @@ -2814,8 +2908,14 @@ function isLoopbackSpacetimeServer(serverUrl) { } } -function resolveCurrentSpacetimeCliToken() { - const result = spawnSync('spacetime', ['login', 'show', '--token'], { +function resolveCurrentSpacetimeCliToken(cliConfigPath = '') { + const args = [ + ...(cliConfigPath ? ['--config-path', cliConfigPath] : []), + 'login', + 'show', + '--token', + ]; + const result = spawnSync('spacetime', args, { cwd: repoRoot, encoding: 'utf8', shell: process.platform === 'win32', @@ -2839,13 +2939,21 @@ function trimPreview(text, maxLength = 300) { function runForeground(command, args, { cwd, env, label }) { return new Promise((resolveRun, rejectRun) => { + let capturedOutput = ''; + const capture = (chunk, target) => { + target.write(chunk); + capturedOutput = `${capturedOutput}${String(chunk)}`.slice(-32_768); + }; const child = spawn(command, args, { cwd, env, - stdio: 'inherit', + stdio: ['inherit', 'pipe', 'pipe'], shell: process.platform === 'win32', }); + child.stdout?.on('data', (chunk) => capture(chunk, process.stdout)); + child.stderr?.on('data', (chunk) => capture(chunk, process.stderr)); + child.on('error', rejectRun); child.on('exit', (code, signal) => { if (signal) { @@ -2854,7 +2962,12 @@ function runForeground(command, args, { cwd, env, label }) { } if (code !== 0) { - rejectRun(new Error(`[dev:${label}] 退出码: ${code}`)); + const detail = trimPreview(capturedOutput, 2_000); + rejectRun( + new Error( + `[dev:${label}] 退出码: ${code}${detail ? `: ${detail}` : ''}`, + ), + ); return; } @@ -2914,8 +3027,14 @@ function isDirectModuleExecution(argv1, moduleUrl, resolvePath = safeRealpath) { } } -function buildSpacetimePublishArgs({ database, server, preserveDatabase }) { +function buildSpacetimePublishArgs({ + cliConfigPath = '', + database, + server, + preserveDatabase, +}) { const args = [ + ...(cliConfigPath ? ['--config-path', cliConfigPath] : []), 'publish', database, '--server', diff --git a/scripts/dev.test.ts b/scripts/dev.test.ts index b6ebbac90..f6e09fd93 100644 --- a/scripts/dev.test.ts +++ b/scripts/dev.test.ts @@ -11,7 +11,7 @@ import { writeFileSync, } from 'node:fs'; import { tmpdir } from 'node:os'; -import { dirname, join } from 'node:path'; +import { dirname, join, resolve } from 'node:path'; import { afterEach, describe, expect, test, vi } from 'vitest'; @@ -510,9 +510,12 @@ describe('dev scheduler stack state file', () => { const snapshot = buildDevStackSnapshot(runner, updatedAt); - expect(snapshot.schemaVersion).toBe(1); + expect(snapshot.schemaVersion).toBe(2); expect(snapshot.command).toBe('web'); expect(snapshot.database).toBe('genarrative-test'); + expect(snapshot.spacetimeDataDir).toBe( + resolve('server-rs/.spacetimedb/local/data'), + ); expect(snapshot.services.web).toMatchObject({ status: 'running', pid: 4321, @@ -761,16 +764,20 @@ spacetimedb tool version 2.6.0; spacetimedb-lib version 2.6.0; ).toBe(false); }); - test('发布 spacetime-module 时忽略 spacetime.json 以免覆盖显式数据库', () => { + test('发布 spacetime-module 时使用隔离身份配置并忽略 spacetime.json', () => { const args = buildSpacetimePublishArgs({ + cliConfigPath: '/tmp/genarrative-cli.toml', database: 'xushi-p4wfr', preserveDatabase: false, server: 'http://127.0.0.1:3101', }); expect(args).toContain('--no-config'); + expect(args).not.toContain('--anonymous'); expect(args).toEqual( expect.arrayContaining([ + '--config-path', + '/tmp/genarrative-cli.toml', 'publish', 'xushi-p4wfr', '--server', @@ -780,6 +787,17 @@ spacetimedb tool version 2.6.0; spacetimedb-lib version 2.6.0; ); }); + test('远程 SpacetimeDB 发布继续使用默认登录身份', () => { + const args = buildSpacetimePublishArgs({ + database: 'xushi-p4wfr', + preserveDatabase: true, + server: 'https://spacetime.example.com', + }); + + expect(args).not.toContain('--anonymous'); + expect(args).not.toContain('--config-path'); + }); + test('手动刷新 spacetime 只重新发布模块,不重启 standalone 进程', async () => { const { explicitOptions, options } = parseArgs([], {}); const runner = new DevRunner(options, {}, explicitOptions); @@ -812,26 +830,18 @@ spacetimedb tool version 2.6.0; spacetimedb-lib version 2.6.0; expect(runner.publishSpacetimeModule).not.toHaveBeenCalled(); }); - test('本地 API identity 路径同时绑定 data dir 和规范化 server', () => { + test('本地 API identity 路径只绑定 data dir', () => { const first = resolveLocalSpacetimeApiIdentityPath( '/tmp/genarrative-data-a', - 'http://127.0.0.1:3101', ); - const normalizedEquivalent = resolveLocalSpacetimeApiIdentityPath( + const sameDataDir = resolveLocalSpacetimeApiIdentityPath( '/tmp/genarrative-data-a', - 'http://127.0.0.1:3101/', - ); - const otherServer = resolveLocalSpacetimeApiIdentityPath( - '/tmp/genarrative-data-a', - 'http://127.0.0.1:3102', ); const otherDataDir = resolveLocalSpacetimeApiIdentityPath( '/tmp/genarrative-data-b', - 'http://127.0.0.1:3101', ); - expect(normalizedEquivalent).toBe(first); - expect(otherServer).not.toBe(first); + expect(sameDataDir).toBe(first); expect(otherDataDir).not.toBe(first); expect(first).toContain(join('genarrative-data-a', 'dev-api-identities')); }); @@ -858,10 +868,7 @@ spacetimedb tool version 2.6.0; spacetimedb-lib version 2.6.0; await firstRunner.ensureApiServerSpacetimeToken(); - const identityPath = resolveLocalSpacetimeApiIdentityPath( - tempDir, - firstRunner.state.spacetimeServer, - ); + const identityPath = resolveLocalSpacetimeApiIdentityPath(tempDir); expect(firstRunner.spacetimeApiToken).toBe('local-api-token'); expect(firstRunner.baseEnv.GENARRATIVE_SPACETIME_TOKEN).toBeUndefined(); expect(globalThis.fetch).toHaveBeenCalledWith( @@ -869,8 +876,8 @@ spacetimedb tool version 2.6.0; spacetimedb-lib version 2.6.0; expect.objectContaining({ method: 'POST' }), ); expect(JSON.parse(readFileSync(identityPath, 'utf8'))).toMatchObject({ - schemaVersion: 1, - server: 'http://127.0.0.1:3101', + schemaVersion: 2, + scope: 'local-data-dir', identity: 'c200localidentity', token: 'local-api-token', }); @@ -880,7 +887,7 @@ spacetimedb tool version 2.6.0; spacetimedb-lib version 2.6.0; } const secondRunner = new DevRunner(options, {}, explicitOptions); - secondRunner.state.spacetimeServer = 'http://127.0.0.1:3101'; + secondRunner.state.spacetimeServer = 'http://127.0.0.1:3199'; globalThis.fetch = vi.fn(); await secondRunner.ensureApiServerSpacetimeToken(); @@ -899,6 +906,94 @@ spacetimedb tool version 2.6.0; spacetimedb-lib version 2.6.0; } }); + test('旧端口作用域 API identity 会迁移为 data dir 作用域', async () => { + const tempDir = mkdtempSync(join(tmpdir(), 'genarrative-api-identity-')); + try { + const legacyServer = 'http://127.0.0.1:3101'; + const legacyKey = createHash('sha256').update(legacyServer).digest('hex'); + const legacyPath = join( + tempDir, + 'dev-api-identities', + `${legacyKey}.json`, + ); + mkdirSync(dirname(legacyPath), { recursive: true }); + writeFileSync( + legacyPath, + `${JSON.stringify({ + schemaVersion: 1, + server: legacyServer, + identity: 'legacy-owner-identity', + token: 'legacy-owner-token', + })}\n`, + { mode: 0o600 }, + ); + const { explicitOptions, options } = parseArgs( + ['--spacetime-data-dir', tempDir], + {}, + ); + const runner = new DevRunner(options, {}, explicitOptions); + runner.state.spacetimeServer = 'http://127.0.0.1:3199'; + globalThis.fetch = vi.fn(); + + await runner.ensureApiServerSpacetimeToken(); + + expect(runner.spacetimeApiToken).toBe('legacy-owner-token'); + expect(globalThis.fetch).not.toHaveBeenCalled(); + expect( + JSON.parse( + readFileSync(resolveLocalSpacetimeApiIdentityPath(tempDir), 'utf8'), + ), + ).toMatchObject({ + schemaVersion: 2, + scope: 'local-data-dir', + identity: 'legacy-owner-identity', + }); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + test('同一 data dir 存在多个旧 identity 时失败关闭', async () => { + const tempDir = mkdtempSync(join(tmpdir(), 'genarrative-api-identity-')); + try { + for (const [port, identity] of [ + [3101, 'legacy-owner-a'], + [3199, 'legacy-owner-b'], + ] as const) { + const server = `http://127.0.0.1:${port}`; + const legacyPath = join( + tempDir, + 'dev-api-identities', + `${createHash('sha256').update(server).digest('hex')}.json`, + ); + mkdirSync(dirname(legacyPath), { recursive: true }); + writeFileSync( + legacyPath, + `${JSON.stringify({ + schemaVersion: 1, + server, + identity, + token: `${identity}-token`, + })}\n`, + { mode: 0o600 }, + ); + } + const { explicitOptions, options } = parseArgs( + ['--spacetime-data-dir', tempDir], + {}, + ); + const runner = new DevRunner(options, {}, explicitOptions); + globalThis.fetch = vi.fn(); + + await expect(runner.ensureApiServerSpacetimeToken()).rejects.toThrow( + '无法安全判断数据库 owner', + ); + expect(globalThis.fetch).not.toHaveBeenCalled(); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + test('外部显式 token 优先于已持久化的本地 API identity', async () => { const tempDir = mkdtempSync(join(tmpdir(), 'genarrative-api-identity-')); const originalToken = process.env.GENARRATIVE_SPACETIME_TOKEN; @@ -954,10 +1049,7 @@ spacetimedb tool version 2.6.0; spacetimedb-lib version 2.6.0; ); const runner = new DevRunner(options, {}, explicitOptions); runner.state.spacetimeServer = 'http://127.0.0.1:3101'; - const identityPath = resolveLocalSpacetimeApiIdentityPath( - tempDir, - runner.state.spacetimeServer, - ); + const identityPath = resolveLocalSpacetimeApiIdentityPath(tempDir); mkdirSync(dirname(identityPath), { recursive: true }); const linkedRecordPath = join(tempDir, 'linked-api-identity.json'); writeFileSync( diff --git a/server-rs/crates/api-server/src/process_metrics.rs b/server-rs/crates/api-server/src/process_metrics.rs index d61a7b15f..1ab2d5b90 100644 --- a/server-rs/crates/api-server/src/process_metrics.rs +++ b/server-rs/crates/api-server/src/process_metrics.rs @@ -8,6 +8,12 @@ use tracing::warn; // 进程指标只描述 api-server 自身,不携带请求、用户或作品维度,避免 OTLP 指标高基数膨胀。 pub(crate) fn register_process_metrics() { + // 当前采集实现依赖 Windows API 或 Linux /proc。macOS 等平台不注册 + // observable callbacks,避免每次 OTLP reader 采集时为每个指标重复告警。 + if !cfg!(any(windows, target_os = "linux")) { + return; + } + static REGISTERED: OnceLock<()> = OnceLock::new(); REGISTERED.get_or_init(register_process_metrics_once); }