From ddbca15e301793b16159b39d80122cfd7d0242b0 Mon Sep 17 00:00:00 2001 From: AIGameCreator App Date: Thu, 23 Jul 2026 13:52:06 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8DAgent=20Swarm=E8=81=8A?= =?UTF-8?q?=E5=A4=A9=E6=B5=8B=E8=AF=95Runner=E5=86=B2=E7=AA=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为人工聊天测试复制隔离 AppData,避免复用正式 Runner 新增空闲 Runner 安全收束与临时目录清理 补齐初始化占位页校验、回归测试和开发文档 --- .../scripts/agent-swarm-test-chat.mjs | 358 +++++++++++++++--- .../src-tauri/src/cli.rs | 36 +- .../src-tauri/src/runner.rs | 4 +- .../src-tauri/src/runner/client.rs | 67 +++- .../src-tauri/src/runner/tests.rs | 26 ++ .../tests/agentSwarmTestEntry.test.ts | 188 +++++++++ .../shared-memory/development-workflow.md | 2 +- docs/project-memory/shared-memory/pitfalls.md | 8 + ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 2 +- 9 files changed, 619 insertions(+), 72 deletions(-) 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 55625dfc8..8cf4173e4 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 @@ -1,7 +1,9 @@ import { spawn } from 'node:child_process'; import { randomUUID } from 'node:crypto'; +import { constants as fsConstants } from 'node:fs'; import { chmod, + copyFile, lstat, mkdir, mkdtemp, @@ -17,10 +19,18 @@ import { fileURLToPath, pathToFileURL } from 'node:url'; export const appIdentifier = 'world.genarrative.ai-game-creator'; export const configFileName = 'game-creator.config.json'; +export const localConfigFileName = 'game-creator.config.local.json'; +export const runnerEndpointFileName = 'agent-runner.endpoint.json'; export const testProjectPrefix = 'genarrative-agc-swarm-test-'; export const testProjectSentinelName = '.agc-swarm-test.json'; export const testProjectSentinelSchema = 'genarrative-agc-swarm-test-project.v1'; +export const testRuntimeConfigPrefix = 'genarrative-agc-swarm-config-'; +export const testRuntimeConfigSentinelName = '.agc-swarm-config.json'; +export const testRuntimeConfigSentinelSchema = + 'genarrative-agc-swarm-test-config.v1'; +export const ungeneratedGameEntryMarker = + '还没有生成游戏。回到聊天输入创意并确认生成后'; const appRoot = path.resolve(fileURLToPath(new URL('..', import.meta.url))); const cargoManifestPath = path.join(appRoot, 'src-tauri', 'Cargo.toml'); @@ -28,11 +38,11 @@ const cargoCommand = process.platform === 'win32' ? 'cargo.exe' : 'cargo'; export const usage = `用法:npm run agc:test:chat -- [选项] -自动读取客户端 AppData 配置、创建一次性项目并进入 Project Supervisor 自主测试。 +自动读取客户端 AppData 配置并复制到隔离目录,创建一次性项目后进入 Project Supervisor 自主测试。 Swarm 完成后启动本地试玩;按 Ctrl+C 停止预览并清理一次性项目。 选项: - --config-dir <绝对路径> 显式指定客户端 AppData 目录 + --config-dir <绝对路径> 显式指定客户端 AppData 配置来源目录 --project-dir <绝对路径> 使用已有项目或空目录,不自动删除 --keep-project 保留自动创建的一次性项目 --no-open 启动预览但不自动打开浏览器 @@ -167,6 +177,146 @@ export async function discoverRuntimeConfigDir( ); } +async function copyPrivateRuntimeConfigEntry( + sourceConfigDir, + runtimeConfigDir, + fileName, + required, +) { + const sourcePath = path.join(sourceConfigDir, fileName); + const sourceMetadata = await lstat(sourcePath).catch((error) => { + if (error?.code === 'ENOENT') return null; + throw error; + }); + if (!sourceMetadata) { + if (required) throw new Error(`配置来源缺少 ${fileName}`); + return false; + } + if (!sourceMetadata.isFile() || sourceMetadata.isSymbolicLink()) { + throw new Error(`配置来源必须是无符号链接普通文件:${fileName}`); + } + + const destinationPath = path.join(runtimeConfigDir, fileName); + await copyFile(sourcePath, destinationPath, fsConstants.COPYFILE_EXCL); + if (process.platform !== 'win32') await chmod(destinationPath, 0o600); + const [sourceMetadataAfterCopy, destinationMetadata] = await Promise.all([ + lstat(sourcePath), + lstat(destinationPath), + ]); + if ( + !sourceMetadataAfterCopy.isFile() || + sourceMetadataAfterCopy.isSymbolicLink() || + sourceMetadataAfterCopy.dev !== sourceMetadata.dev || + sourceMetadataAfterCopy.ino !== sourceMetadata.ino || + sourceMetadataAfterCopy.size !== sourceMetadata.size || + sourceMetadataAfterCopy.mtimeMs !== sourceMetadata.mtimeMs || + !destinationMetadata.isFile() || + destinationMetadata.isSymbolicLink() || + (process.platform !== 'win32' && + ((destinationMetadata.mode & 0o077) !== 0 || + (destinationMetadata.dev === sourceMetadata.dev && + destinationMetadata.ino === sourceMetadata.ino))) + ) { + throw new Error(`隔离配置副本身份或权限无效:${fileName}`); + } + return true; +} + +export async function prepareSwarmTestRuntimeConfig(sourceConfigDir, tempRoot) { + if (!path.isAbsolute(sourceConfigDir)) { + throw new Error('配置来源目录必须是绝对路径'); + } + const sourceMetadata = await lstat(sourceConfigDir).catch((error) => { + if (error?.code === 'ENOENT') return null; + throw error; + }); + if (!sourceMetadata?.isDirectory() || sourceMetadata.isSymbolicLink()) { + throw new Error(`配置来源必须是无符号链接普通目录:${sourceConfigDir}`); + } + const canonicalSourceConfigDir = await realpath(sourceConfigDir); + const canonicalTempRoot = await realpath( + path.resolve(tempRoot ?? os.tmpdir()), + ); + const runtimeConfigDir = await mkdtemp( + path.join(canonicalTempRoot, testRuntimeConfigPrefix), + ); + try { + if (process.platform !== 'win32') await chmod(runtimeConfigDir, 0o700); + const sentinelToken = randomUUID(); + await writeFile( + path.join(runtimeConfigDir, testRuntimeConfigSentinelName), + `${JSON.stringify({ + schemaVersion: testRuntimeConfigSentinelSchema, + token: sentinelToken, + })}\n`, + { flag: 'wx', mode: 0o600 }, + ); + await copyPrivateRuntimeConfigEntry( + canonicalSourceConfigDir, + runtimeConfigDir, + configFileName, + true, + ); + await copyPrivateRuntimeConfigEntry( + canonicalSourceConfigDir, + runtimeConfigDir, + localConfigFileName, + false, + ); + return { + path: await realpath(runtimeConfigDir), + sourcePath: canonicalSourceConfigDir, + owned: true, + sentinelToken, + }; + } catch (error) { + await rm(runtimeConfigDir, { recursive: true, force: true }); + throw error; + } +} + +export async function cleanupSwarmTestRuntimeConfig(runtimeConfig) { + if (!runtimeConfig?.owned) return false; + const sentinelPath = path.join( + runtimeConfig.path, + testRuntimeConfigSentinelName, + ); + if (!(await isRegularFileWithoutSymlink(sentinelPath))) { + throw new Error('拒绝清理:隔离配置哨兵缺失或类型无效'); + } + let sentinel; + try { + sentinel = JSON.parse(await readFile(sentinelPath, 'utf8')); + } catch (error) { + throw new Error(`拒绝清理:隔离配置哨兵无效:${error.message}`); + } + if ( + sentinel.schemaVersion !== testRuntimeConfigSentinelSchema || + sentinel.token !== runtimeConfig.sentinelToken + ) { + throw new Error('拒绝清理:隔离配置哨兵身份不匹配'); + } + const canonical = await realpath(runtimeConfig.path); + if ( + canonical !== runtimeConfig.path || + canonical === runtimeConfig.sourcePath || + !path.basename(canonical).startsWith(testRuntimeConfigPrefix) + ) { + throw new Error('拒绝清理:隔离配置目录身份不匹配'); + } + const endpointMetadata = await lstat( + path.join(canonical, runnerEndpointFileName), + ).catch((error) => { + if (error?.code === 'ENOENT') return null; + throw error; + }); + if (endpointMetadata) { + throw new Error('拒绝清理:隔离 Agent Runner 尚未退出'); + } + await rm(canonical, { recursive: true, force: false }); + return true; +} + async function ensureExplicitProject(projectDir) { if (!path.isAbsolute(projectDir)) { throw new Error('--project-dir 必须是绝对路径'); @@ -300,6 +450,25 @@ async function runInteractiveCargo(cliArguments, setActiveChild) { return result; } +export function parseRunnerShutdownOutput(output) { + const match = output.match(/^runner\.stopped=(true|false)$/m); + if (!match) throw new Error('Runner 收束命令缺少 stopped 状态'); + return match[1] === 'true'; +} + +async function shutdownSwarmTestRunner(runtimeConfig, setActiveChild) { + const result = await runCapturedCargo( + ['--config-dir', runtimeConfig.path, '--runner-shutdown-if-idle'], + setActiveChild, + ); + if (result.code !== 0 || result.signal) { + throw new Error( + `隔离 Agent Runner 收束失败:${result.stderr.trim() || result.stdout.trim() || `code=${result.code ?? ''} signal=${result.signal ?? ''}`}`, + ); + } + return parseRunnerShutdownOutput(result.stdout); +} + export function validatePreviewUrl(value) { const url = new URL(value); if ( @@ -317,6 +486,13 @@ export function validatePreviewUrl(value) { return url.toString(); } +export async function hasGeneratedGameEntry(projectPath) { + const gameEntryPath = path.join(projectPath, 'game', 'index.html'); + if (!(await isRegularFileWithoutSymlink(gameEntryPath))) return false; + const html = await readFile(gameEntryPath, 'utf8'); + return html.trim().length > 0 && !html.includes(ungeneratedGameEntryMarker); +} + export async function openPreviewUrl(url, platform = process.platform) { const validated = validatePreviewUrl(url); const command = @@ -426,11 +602,14 @@ async function runPreview( } export async function runSwarmTestChat(options) { - const configDir = await discoverRuntimeConfigDir(options.configDir); - const project = await prepareSwarmTestProject(options.projectDir); + const sourceConfigDir = await discoverRuntimeConfigDir(options.configDir); + let runtimeConfig = null; + let project = null; let activeChild = null; let receivedSignal = null; let phase = 'setup'; + let runnerMayHaveStarted = false; + let primaryError = null; const setActiveChild = (child) => { activeChild = child; }; @@ -454,82 +633,139 @@ export async function runSwarmTestChat(options) { process.on('SIGTERM', handleSignal); try { - console.log(`配置:${path.join(configDir, configFileName)}`); - console.log(`测试项目:${project.path}`); - if (options.dryRun) { - console.log('测试环境检查通过;未启动 LLM。'); - return; - } + session: { + runtimeConfig = await prepareSwarmTestRuntimeConfig(sourceConfigDir); + if (receivedSignal) break session; + project = await prepareSwarmTestProject(options.projectDir); + if (receivedSignal) break session; - console.log('\n正在检查 LLM 配置...'); - const llmStatus = await runCapturedCargo( - ['--config-dir', configDir, '--llm-status'], - setActiveChild, - ); - if (receivedSignal) return; - if (llmStatus.code !== 0 || llmStatus.signal) { - throw new Error( - `LLM 配置未就绪:${llmStatus.stderr.trim() || llmStatus.stdout.trim()}`, + console.log(`配置来源:${path.join(sourceConfigDir, configFileName)}`); + console.log( + `隔离运行配置:${path.join(runtimeConfig.path, configFileName)}`, ); - } - console.log('LLM 配置已就绪。'); - console.log( - '输入一条游戏需求并回车;提交后按 Ctrl+D,让 Swarm 自主完成。\n', - ); + console.log(`测试项目:${project.path}`); + if (options.dryRun) { + console.log('测试环境检查通过;未启动 LLM。'); + break session; + } - phase = 'chat'; - const chat = await runInteractiveCargo( - [ - '--config-dir', - configDir, - '--swarm-chat', - '--init', - '--autonomous-game-build', + console.log('\n正在检查 LLM 配置...'); + const llmStatus = await runCapturedCargo( + ['--config-dir', runtimeConfig.path, '--llm-status'], + setActiveChild, + ); + if (receivedSignal) break session; + if (llmStatus.code !== 0 || llmStatus.signal) { + throw new Error( + `LLM 配置未就绪:${llmStatus.stderr.trim() || llmStatus.stdout.trim()}`, + ); + } + console.log('LLM 配置已就绪。'); + console.log( + '输入一条游戏需求并回车;提交后按 Ctrl+D,让 Swarm 自主完成。\n', + ); + + phase = 'chat'; + runnerMayHaveStarted = true; + const chat = await runInteractiveCargo( + [ + '--config-dir', + runtimeConfig.path, + '--swarm-chat', + '--init', + '--autonomous-game-build', + project.path, + ], + setActiveChild, + ); + if (receivedSignal) break session; + if (chat.code !== 0 || chat.signal) { + throw new Error( + `Agent Swarm 未正常收束:code=${chat.code ?? ''} signal=${chat.signal ?? ''}`, + ); + } + phase = 'preview'; + if (!(await hasGeneratedGameEntry(project.path))) { + throw new Error( + 'Agent Swarm 已退出,但 game/index.html 仍是初始化占位页', + ); + } + + console.log('\nAgent Swarm 已收束,正在启动试玩...'); + await runPreview( project.path, - ], - setActiveChild, - ); - if (receivedSignal) return; - if (chat.code !== 0 || chat.signal) { - throw new Error( - `Agent Swarm 未正常收束:code=${chat.code ?? ''} signal=${chat.signal ?? ''}`, + options.openBrowser, + setActiveChild, + stopRequested, ); + phase = 'complete'; } - phase = 'preview'; - if ( - !(await isRegularFileWithoutSymlink( - path.join(project.path, 'game', 'index.html'), - )) - ) { - throw new Error('Agent Swarm 已退出,但未生成 game/index.html'); - } - - console.log('\nAgent Swarm 已收束,正在启动试玩...'); - await runPreview( - project.path, - options.openBrowser, - setActiveChild, - stopRequested, - ); - phase = 'complete'; + } catch (error) { + primaryError = error; } finally { process.off('SIGINT', handleSignal); process.off('SIGTERM', handleSignal); + let cleanupError = null; + let runnerStopped = true; + if (runtimeConfig && runnerMayHaveStarted) { + try { + runnerStopped = await shutdownSwarmTestRunner( + runtimeConfig, + setActiveChild, + ); + if (!runnerStopped) { + cleanupError = new Error( + '隔离 Agent Runner 仍有任务,已保留测试项目和隔离运行配置', + ); + } + } catch (error) { + runnerStopped = false; + cleanupError = error; + } + } const preserveFailedRun = - project.owned && + project?.owned && (phase === 'chat' || (phase === 'preview' && !receivedSignal)); - if (project.owned && (options.keepProject || preserveFailedRun)) { + const preserveForRunner = project?.owned && !runnerStopped; + if ( + project?.owned && + (options.keepProject || preserveFailedRun || preserveForRunner) + ) { if (preserveFailedRun && !options.keepProject) { console.warn( '测试尚未正常结束,为避免删除后台任务或失败证据,测试项目不会自动清理。', ); } console.log(`已保留测试项目:${project.path}`); - } else if (project.owned) { - await cleanupSwarmTestProject(project); - console.log('已清理一次性测试项目。'); + } else if (project?.owned) { + try { + await cleanupSwarmTestProject(project); + console.log('已清理一次性测试项目。'); + } catch (error) { + cleanupError ??= error; + } + } + if (runtimeConfig) { + if (runnerStopped) { + try { + await cleanupSwarmTestRuntimeConfig(runtimeConfig); + console.log('已清理隔离运行配置。'); + } catch (error) { + cleanupError ??= error; + } + } else { + console.warn(`已保留隔离运行配置:${runtimeConfig.path}`); + } + } + if (cleanupError) { + if (primaryError) { + console.warn(`测试现场清理未完成:${cleanupError.message}`); + } else { + primaryError = cleanupError; + } } } + if (primaryError) throw primaryError; } async function main() { 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 919c7f23a..76c1b95a3 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/cli.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/cli.rs @@ -109,6 +109,7 @@ pub(crate) enum CliCommand { project_path: PathBuf, }, RunnerStatus, + RunnerShutdownIfIdle, AgentRun { project_path: PathBuf, prompt: String, @@ -142,6 +143,7 @@ impl CliCommand { | Self::AgentGoalResume { .. } | Self::AgentGoalClear { .. } | Self::AgentResume { .. } + | Self::RunnerShutdownIfIdle ) } @@ -153,7 +155,8 @@ impl CliCommand { } pub(crate) fn requires_started_external_agent_runner(&self) -> bool { - self.requires_external_agent_runner() && !matches!(self, Self::AgentCancel { .. }) + self.requires_external_agent_runner() + && !matches!(self, Self::AgentCancel { .. } | Self::RunnerShutdownIfIdle) } fn project_path_mut(&mut self) -> Option<(&mut PathBuf, bool)> { @@ -193,7 +196,7 @@ impl CliCommand { | Self::AgentResume { project_path } | Self::PreviewServe { project_path } | Self::AgentRun { project_path, .. } => Some((project_path, false)), - Self::LlmStatus | Self::RunnerStatus => None, + Self::LlmStatus | Self::RunnerStatus | Self::RunnerShutdownIfIdle => None, } } } @@ -436,6 +439,12 @@ pub(crate) fn parse_cli_command(args: &[String]) -> Result, S } return Ok(Some(CliCommand::RunnerStatus)); } + if args.first().map(String::as_str) == Some("--runner-shutdown-if-idle") { + if args.len() != 1 { + return Err("用法:--runner-shutdown-if-idle".to_string()); + } + return Ok(Some(CliCommand::RunnerShutdownIfIdle)); + } if args.first().map(String::as_str) == Some("--agent-runtime-status") { if args.len() != 3 { return Err("用法:--agent-runtime-status <本地项目绝对路径> ".to_string()); @@ -1270,6 +1279,11 @@ pub(crate) fn run_cli_command(command: CliCommand) -> Result<(), String> { ); Ok(()) } + CliCommand::RunnerShutdownIfIdle => { + let stopped = shutdown_external_agent_runner_if_idle()?; + println!("runner.stopped={stopped}"); + Ok(()) + } CliCommand::AgentRun { project_path, prompt, @@ -1826,6 +1840,24 @@ mod tests { assert!(error.contains("--config-dir")); } + #[test] + fn parses_idle_runner_shutdown_without_starting_a_new_runner() { + let mut command = parse_cli_command(&["--runner-shutdown-if-idle".to_string()]) + .expect("parse idle Runner shutdown") + .expect("idle Runner shutdown command"); + + assert_eq!(command, CliCommand::RunnerShutdownIfIdle); + assert!(command.requires_external_agent_runner()); + assert!(!command.requires_started_external_agent_runner()); + let error = prepare_cli_command_paths(&mut command, None) + .expect_err("idle Runner shutdown must require config dir"); + assert!(error.contains("--config-dir")); + assert!(parse_cli_command( + &["--runner-shutdown-if-idle".to_string(), "extra".to_string(),] + ) + .is_err()); + } + #[test] fn parses_agent_context_compaction_with_optional_session() { assert_eq!( 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 7b55a868a..49e92ff3a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner.rs @@ -15,8 +15,8 @@ pub(crate) use client::{ read_external_agent_runner_mcp_catalog, read_external_agent_runner_status, require_external_agent_runner_configured_for_cli_runtime_write, require_external_agent_runner_for_cli_runtime_write, resume_external_agent_runner, - steer_external_agent_runner, wake_external_agent_runner_pending, - wake_external_agent_runner_pending_for_run, + shutdown_external_agent_runner_if_idle, steer_external_agent_runner, + wake_external_agent_runner_pending, wake_external_agent_runner_pending_for_run, }; #[cfg(windows)] pub(crate) use endpoint::validate_windows_regular_file_handle; diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs index 01cf75307..76fa2fd5b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs @@ -2,6 +2,7 @@ use super::{dispatch::*, endpoint::*, project_owner::*, protocol::*, state::*}; use crate::{AgentRuntimeContextCompactionResult, GameCreatorMcpCatalog}; use serde_json::Value; use sha2::{Digest as _, Sha256}; +use std::fs; use std::io::{self, Write}; use std::net::{Ipv4Addr, SocketAddrV4, TcpStream}; use std::path::Path; @@ -153,6 +154,18 @@ pub(super) fn retire_incompatible_external_agent_runner( endpoint_path: &Path, endpoint: &ExternalAgentRunnerEndpoint, ) -> Result<(), String> { + if !request_external_agent_runner_shutdown_if_idle_at(endpoint_path, endpoint)? { + return Err( + "Agent Runner 版本与当前客户端不一致,但旧 Runner 仍有任务,暂不能重启".to_string(), + ); + } + Ok(()) +} + +fn request_external_agent_runner_shutdown_if_idle_at( + endpoint_path: &Path, + endpoint: &ExternalAgentRunnerEndpoint, +) -> Result { let request_id = random_identifier(b"genarrative-agent-runner-upgrade-request-id")?; let result = send_external_agent_runner_request_with_protocol_and_id( endpoint, @@ -161,17 +174,19 @@ pub(super) fn retire_incompatible_external_agent_runner( "runner.shutdown_if_idle", ExternalAgentRunnerRequestParams::default(), )?; - if result.get("idle").and_then(Value::as_bool) != Some(true) { - return Err( - "Agent Runner 版本与当前客户端不一致,但旧 Runner 仍有任务,暂不能重启".to_string(), - ); + let idle = result + .get("idle") + .and_then(Value::as_bool) + .ok_or_else(|| "Agent Runner shutdown_if_idle 响应缺少 idle".to_string())?; + if !idle { + return Ok(false); } let deadline = Instant::now() + EXTERNAL_AGENT_RUNNER_START_TIMEOUT; loop { match read_external_agent_runner_endpoint(endpoint_path) { Ok(current) if current.boot_id == endpoint.boot_id => {} - _ => return Ok(()), + _ => return Ok(true), } if Instant::now() >= deadline { return Err("旧 Agent Runner 未在版本切换期限内退出".to_string()); @@ -180,6 +195,48 @@ pub(super) fn retire_incompatible_external_agent_runner( } } +pub(super) fn shutdown_external_agent_runner_if_idle_at(config_dir: &Path) -> Result { + let endpoint_path = external_agent_runner_endpoint_path(config_dir); + let lock_path = external_agent_runner_lock_path(config_dir); + let deadline = Instant::now() + EXTERNAL_AGENT_RUNNER_START_TIMEOUT; + loop { + match fs::symlink_metadata(&endpoint_path) { + Ok(metadata) if metadata.file_type().is_symlink() => { + return Err("Agent Runner endpoint 不允许符号链接".to_string()); + } + Ok(_) => break, + Err(error) if error.kind() == io::ErrorKind::NotFound => { + if let Some(lock) = + try_open_external_agent_runner_lock(&lock_path, "Agent Runner 单实例锁")? + { + drop(lock); + return Ok(true); + } + if Instant::now() >= deadline { + return Err( + "Agent Runner 启动锁仍被占用,但 endpoint 未在期限内就绪".to_string() + ); + } + thread::sleep(Duration::from_millis(50)); + } + Err(error) => { + return Err(format!( + "读取 Agent Runner endpoint 元数据失败:{}: {error}", + endpoint_path.display() + )); + } + } + } + let endpoint = read_external_agent_runner_endpoint(&endpoint_path)?; + request_external_agent_runner_shutdown_if_idle_at(&endpoint_path, &endpoint) +} + +pub(crate) fn shutdown_external_agent_runner_if_idle() -> Result { + let config_dir = external_agent_runner_config_dir() + .ok_or_else(|| "外部 Agent Runner 尚未配置 AppData;请显式传入 --config-dir".to_string())?; + shutdown_external_agent_runner_if_idle_at(&config_dir) +} + pub(super) fn wait_for_external_agent_runner( config_dir: &Path, child: &mut Child, diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs index 4a3350c7d..a887b86b0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs @@ -171,6 +171,32 @@ fn endpoint_reuse_requires_current_protocol_and_executable_identity() { ); } +#[test] +fn idle_runner_shutdown_treats_missing_endpoint_as_already_stopped() { + let directory = unique_test_directory(); + assert!(shutdown_external_agent_runner_if_idle_at(&directory.0) + .expect("missing endpoint should already be stopped")); +} + +#[cfg(unix)] +#[test] +fn idle_runner_shutdown_rejects_symlinked_endpoint() { + use std::os::unix::fs::symlink; + + let directory = unique_test_directory(); + let target = directory.0.join("endpoint-target.json"); + fs::write(&target, b"{}").expect("write endpoint target"); + symlink( + &target, + directory.0.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME), + ) + .expect("create endpoint symlink"); + + let error = shutdown_external_agent_runner_if_idle_at(&directory.0) + .expect_err("endpoint symlink must be rejected"); + assert!(error.contains("符号链接")); +} + #[test] fn framing_round_trips_length_prefixed_json() { let payload = br#"{"method":"runner.ping","requestId":"request-1"}"#; diff --git a/apps/ai-game-creator-shell/tests/agentSwarmTestEntry.test.ts b/apps/ai-game-creator-shell/tests/agentSwarmTestEntry.test.ts index 2150c548b..056e51091 100644 --- a/apps/ai-game-creator-shell/tests/agentSwarmTestEntry.test.ts +++ b/apps/ai-game-creator-shell/tests/agentSwarmTestEntry.test.ts @@ -19,14 +19,24 @@ import { appIdentifier, buildCargoCliArguments, cleanupSwarmTestProject, + cleanupSwarmTestRuntimeConfig, configFileName, defaultRuntimeConfigDirCandidates, discoverRuntimeConfigDir, + hasGeneratedGameEntry, + localConfigFileName, + parseRunnerShutdownOutput, parseSwarmTestArguments, prepareSwarmTestProject, + prepareSwarmTestRuntimeConfig, + runnerEndpointFileName, testProjectPrefix, testProjectSentinelName, testProjectSentinelSchema, + testRuntimeConfigPrefix, + testRuntimeConfigSentinelName, + testRuntimeConfigSentinelSchema, + ungeneratedGameEntryMarker, validatePreviewUrl, } from '../scripts/agent-swarm-test-chat.mjs'; @@ -245,6 +255,156 @@ describe('runtime config discovery', () => { }); }); +describe('isolated Swarm runtime config', () => { + it('privately copies only active config files and removes the owned directory', async () => { + await withTemporaryRoot(async (root) => { + const sourceConfigDir = path.join(root, 'source-config'); + await mkdir(sourceConfigDir); + await writeFile( + path.join(sourceConfigDir, configFileName), + '{"llm":{"apiKey":"fixture-credential"}}\n', + { mode: 0o600 }, + ); + await writeFile( + path.join(sourceConfigDir, localConfigFileName), + '{"llm":{"model":"fixture-model"}}\n', + { mode: 0o600 }, + ); + await writeFile( + path.join(sourceConfigDir, runnerEndpointFileName), + '{"mustNotCopy":true}\n', + ); + await writeFile( + path.join(sourceConfigDir, 'agent-runner.lock'), + 'must-not-copy\n', + ); + await writeFile( + path.join(sourceConfigDir, `.${configFileName}.previous`), + 'must-not-copy\n', + ); + const sourceMetadata = await lstat( + path.join(sourceConfigDir, configFileName), + ); + + const runtimeConfig = await prepareSwarmTestRuntimeConfig( + sourceConfigDir, + root, + ); + const runtimeEntries = (await readdir(runtimeConfig.path)).sort(); + const runtimeDirectoryMetadata = await lstat(runtimeConfig.path); + const primaryMetadata = await lstat( + path.join(runtimeConfig.path, configFileName), + ); + const localMetadata = await lstat( + path.join(runtimeConfig.path, localConfigFileName), + ); + const sentinel = JSON.parse( + await readFile( + path.join(runtimeConfig.path, testRuntimeConfigSentinelName), + 'utf8', + ), + ); + + expect(runtimeConfig.sourcePath).toBe(await realpath(sourceConfigDir)); + expect(path.basename(runtimeConfig.path)).toMatch( + new RegExp(`^${testRuntimeConfigPrefix}`), + ); + expect(runtimeEntries).toEqual( + [ + configFileName, + localConfigFileName, + testRuntimeConfigSentinelName, + ].sort(), + ); + expect( + await readFile(path.join(runtimeConfig.path, configFileName), 'utf8'), + ).toBe('{"llm":{"apiKey":"fixture-credential"}}\n'); + expect( + await readFile( + path.join(runtimeConfig.path, localConfigFileName), + 'utf8', + ), + ).toBe('{"llm":{"model":"fixture-model"}}\n'); + expect(sentinel).toEqual({ + schemaVersion: testRuntimeConfigSentinelSchema, + token: runtimeConfig.sentinelToken, + }); + if (process.platform !== 'win32') { + expect(runtimeDirectoryMetadata.mode & 0o077).toBe(0); + expect(primaryMetadata.mode & 0o077).toBe(0); + expect(localMetadata.mode & 0o077).toBe(0); + expect([primaryMetadata.dev, primaryMetadata.ino]).not.toEqual([ + sourceMetadata.dev, + sourceMetadata.ino, + ]); + } + expect( + await pathExists(path.join(runtimeConfig.path, runnerEndpointFileName)), + ).toBe(false); + + await expect(cleanupSwarmTestRuntimeConfig(runtimeConfig)).resolves.toBe( + true, + ); + expect(await pathExists(runtimeConfig.path)).toBe(false); + expect(await pathExists(sourceConfigDir)).toBe(true); + expect( + await readFile(path.join(sourceConfigDir, configFileName), 'utf8'), + ).toBe('{"llm":{"apiKey":"fixture-credential"}}\n'); + }); + }); + + it('rejects a symlinked local override without leaving a temporary directory', async () => { + if (process.platform === 'win32') return; + await withTemporaryRoot(async (root) => { + const sourceConfigDir = path.join(root, 'source-config'); + const localTarget = path.join(root, 'local-target.json'); + await mkdir(sourceConfigDir); + await writeFile(path.join(sourceConfigDir, configFileName), '{}\n'); + await writeFile(localTarget, '{}\n'); + await symlink( + localTarget, + path.join(sourceConfigDir, localConfigFileName), + ); + + await expect( + prepareSwarmTestRuntimeConfig(sourceConfigDir, root), + ).rejects.toThrow(localConfigFileName); + expect( + (await readdir(root)).filter((entry) => + entry.startsWith(testRuntimeConfigPrefix), + ), + ).toEqual([]); + }); + }); + + it('refuses to delete an isolated config while its Runner endpoint exists', async () => { + await withTemporaryRoot(async (root) => { + const sourceConfigDir = path.join(root, 'source-config'); + await mkdir(sourceConfigDir); + await writeFile(path.join(sourceConfigDir, configFileName), '{}\n'); + const runtimeConfig = await prepareSwarmTestRuntimeConfig( + sourceConfigDir, + root, + ); + const endpointPath = path.join( + runtimeConfig.path, + runnerEndpointFileName, + ); + await writeFile(endpointPath, '{}\n'); + + await expect( + cleanupSwarmTestRuntimeConfig(runtimeConfig), + ).rejects.toThrow('Runner'); + expect(await pathExists(runtimeConfig.path)).toBe(true); + + await rm(endpointPath); + await expect(cleanupSwarmTestRuntimeConfig(runtimeConfig)).resolves.toBe( + true, + ); + }); + }); +}); + describe('Swarm test project ownership', () => { it('creates a sentinel-owned project and removes it during cleanup', async () => { await withTemporaryRoot(async (root) => { @@ -329,6 +489,34 @@ describe('cargo CLI argument construction', () => { expect(cargoArguments[3]).toBe('--'); expect(cargoArguments.slice(4)).toEqual(cliArguments); }); + + it('parses the idle Runner shutdown marker', () => { + expect(parseRunnerShutdownOutput('runner.stopped=true\n')).toBe(true); + expect(parseRunnerShutdownOutput('runner.stopped=false\n')).toBe(false); + expect(() => parseRunnerShutdownOutput('runner.status=unknown\n')).toThrow( + 'stopped', + ); + }); +}); + +describe('generated game entry validation', () => { + it('rejects the initializer placeholder and accepts generated HTML', async () => { + await withTemporaryRoot(async (root) => { + const gameDir = path.join(root, 'game'); + const gameEntry = path.join(gameDir, 'index.html'); + await mkdir(gameDir); + + expect(await hasGeneratedGameEntry(root)).toBe(false); + await writeFile( + gameEntry, + `
${ungeneratedGameEntryMarker}
`, + ); + expect(await hasGeneratedGameEntry(root)).toBe(false); + + await writeFile(gameEntry, ''); + expect(await hasGeneratedGameEntry(root)).toBe(true); + }); + }); }); describe('preview URL validation', () => { diff --git a/docs/project-memory/shared-memory/development-workflow.md b/docs/project-memory/shared-memory/development-workflow.md index f1bf25d02..c152ddf91 100644 --- a/docs/project-memory/shared-memory/development-workflow.md +++ b/docs/project-memory/shared-memory/development-workflow.md @@ -168,7 +168,7 @@ npm run agc:test npm run agc:test:chat ``` -`agc:test` 委托确定性 lane-defense E2E,使用本地 loopback Provider 完成 Runtime、项目写入和真实浏览器 `37/37` 门禁,不消耗外部 Provider。`agc:test:chat` 按当前平台自动查找发布客户端 AppData 中的 `game-creator.config.json`,创建 sentinel 管理的一次性项目,进入 `project-supervisor + autonomous-game-build`;用户只输入一条需求并以 EOF 交付,收束后复用正式 localhost preview server 并打开试玩。正常收束后按 `Ctrl+C` 默认清理一次性项目;尚有后台任务或启动预览失败时保留现场,避免误删运行中项目。需要主动保留时显式追加 `-- --keep-project`,需要覆盖配置或项目时使用 `--config-dir` / `--project-dir` 绝对路径。脚本不得读取或打印 API Key,显式项目永不自动删除,非空且未初始化目录必须拒绝。 +`agc:test` 委托确定性 lane-defense E2E,使用本地 loopback Provider 完成 Runtime、项目写入和真实浏览器 `37/37` 门禁,不消耗外部 Provider。`agc:test:chat` 按当前平台自动查找发布客户端 AppData 中的 `game-creator.config.json`,把主配置和可选 local overlay 私有复制到 sentinel 管理的单次隔离 AppData,绝不复制正式 `agent-runner.endpoint.json`、Runner lock、备份或其它文件;随后创建一次性项目,进入 `project-supervisor + autonomous-game-build`。用户只输入一条需求并以 EOF 交付,收束后复用正式 localhost preview server 并打开试玩。正常收束后按 `Ctrl+C`,脚本先通过内部 CLI 仅关闭已经空闲的隔离 Runner,再清理隔离 AppData 和一次性项目;Runner 仍有任务或无法确认退出时必须同时保留项目与隔离配置并报告路径,不得触碰或强退正式客户端 Runner。需要主动保留项目时显式追加 `-- --keep-project`,需要覆盖配置来源或项目时使用 `--config-dir` / `--project-dir` 绝对路径。脚本不得读取或打印 API Key,显式项目永不自动删除,非空且未初始化目录必须拒绝。 修改 Supervisor 自主编排、`agent.message`、static delivery/claim/repair、Swarm CLI `turn.report`、Runner 恢复或 autonomous harness 后,先跑确定性收敛门禁,再运行真实终端 suite: diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 7bb01ea78..9d29cabc7 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -3603,3 +3603,11 @@ - 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`。 + +## Swarm 人工测试不能复用正式客户端 Runner AppData + +- 现象:`npm run agc:test:chat` 在进入聊天前报“Agent Runner 版本与当前客户端不一致,但旧 Runner 仍有任务,暂不能重启”;正式客户端仍能看到自己的待确认或委派任务,重复执行测试也持续失败。 +- 原因:Runner 复用身份同时绑定协议版本和当前可执行文件 SHA-256。`cargo run` 重新编译后的 debug 二进制与正在运行的 release Runner 指纹不同,而旧入口只隔离测试项目、仍把正式 AppData 直接传给 CLI,于是测试会向正式 endpoint 发升级探测。正式 Runner 有 pending action、Provider sidecar、进程会话或非终态队列时拒绝退出是正确的安全门禁,不能通过强退或放宽 idle 判定让测试通过。 +- 处理:正式 AppData 只作只读配置来源。每次人工测试在系统临时根创建 `0700` sentinel 隔离目录,只把主配置和可选 local overlay 私有复制为 `0600` 普通文件;不得复制 endpoint、lock、`.previous` 或其它状态。LLM 检查与 Swarm CLI 全部使用隔离目录。退出时通过内部 CLI 请求 `runner.shutdown_if_idle`,确认隔离 endpoint 消失后才删除配置;仍有任务或无法确认退出时同时保留测试项目和隔离配置并报告路径。正式 Runner 的 PID、bootId、端口和 executable fingerprint 必须保持不变。 +- 验证:单元测试覆盖私有 inode、权限、local overlay、禁止复制 endpoint/lock/备份、符号链接拒绝、sentinel 清理和 endpoint 存在时拒绝删除;真实 smoke 使用隔离 AppData 启动并收束空闲 Runner,前后比较正式 endpoint 身份且确认正式 PID 存活,再检查本轮 `/tmp` 项目和隔离配置均已清理。 +- 关联:`apps/ai-game-creator-shell/scripts/agent-swarm-test-chat.mjs`、`apps/ai-game-creator-shell/tests/agentSwarmTestEntry.test.ts`、`apps/ai-game-creator-shell/src-tauri/src/runner/client.rs`、`apps/ai-game-creator-shell/src-tauri/src/cli.rs`。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index cbabe58c4..11ae44d5f 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -86,7 +86,7 @@ V1.11 的受保护仓库控制目录同时包含 `.git / .agent / .agents / .cod V1.47 在只读工具边界和 batch v3/v2/v1 恢复终审修复后的最新独立真实外部轮次已完整 **PASS**:用户只输入一次任务后 stdin 立即 EOF,人工 approve / answer / steer 均为 `0`;一个原始专业任务失败后由唯一 repair 自行恢复,父 Supervisor 为 `idle / completed`,`turn.report=settled` 且只有 `1` 条 `44` 字符 assistant。项目 revision `0 -> 6`,`game/index.html` 为 `8080` bytes 且已变化,两次静态检查通过,desktop / mobile 的 `lane-defense-v1` 真实 Chrome 试玩为 `37/37`。`88` 个 Provider identity 全部 terminal,其中 `75 completed / 13 failed`,`12` 条 durable retry audit 与专业 repair 均自行恢复;open lifecycle、pending、confirmation、user-input、provider batch/retry/handoff/tool-plan handoff、finalization、reconciliation、duplicate 与各类泄漏终局均为 `0`,Runner、disposable 项目和隔离 AppData 已自动清理。该轮证明失败 attempt 可保留真实证据而循环仍能零人工干预收束,不能把它改写成 Provider 零失败。 -2026-07-23 起,开发验收提供两个根级短入口。`npm run agc:test` 直接委托现有确定性可玩塔防 E2E,不复制 Runtime harness;`npm run agc:test:chat` 自动发现 Tauri identifier `world.genarrative.ai-game-creator` 对应 AppData 中的 `game-creator.config.json`,创建带私有 sentinel 的一次性项目,先执行不回显密钥的 LLM 状态检查,再以 `--swarm-chat --init --autonomous-game-build` 进入真实 Project Supervisor。用户只输入需求并发送 EOF;正常收束且存在 `game/index.html` 后,通过仅开发 CLI `--preview-serve` 复用正式 localhost preview server,自动打开固定形态的 loopback 试玩地址。预览按 `Ctrl+C` 结束后默认验证 sentinel 并清理项目;尚未收束或预览启动失败时保留现场,避免删除后台任务和失败证据。`--keep-project` 可主动保留,显式 `--project-dir` 永不删除,非空未初始化目录拒绝,`--config-dir` / `--project-dir` 只接受绝对路径。该人工入口用于快速体验,不能替代真实外部 Provider E2E 的完整生命周期、隐私和残留门禁。 +2026-07-23 起,开发验收提供两个根级短入口。`npm run agc:test` 直接委托现有确定性可玩塔防 E2E,不复制 Runtime harness;`npm run agc:test:chat` 自动发现 Tauri identifier `world.genarrative.ai-game-creator` 对应 AppData,把 `game-creator.config.json` 和存在时的 `game-creator.config.local.json` 私有复制到单次 sentinel 隔离 AppData,绝不复制正式 Runner endpoint、lock、备份或其它文件,再创建带私有 sentinel 的一次性项目。LLM 状态检查和 `--swarm-chat --init --autonomous-game-build` 都只使用隔离 AppData,因此当前 debug 二进制指纹变化不会探测、退役或阻塞正在工作的正式客户端 Runner。用户只输入需求并发送 EOF;正常收束且存在 `game/index.html` 后,通过仅开发 CLI `--preview-serve` 复用正式 localhost preview server,自动打开固定形态的 loopback 试玩地址。预览按 `Ctrl+C` 结束后,脚本通过内部 `--runner-shutdown-if-idle` 只关闭已空闲的隔离 Runner,确认 endpoint 消失后再验证 sentinel 并清理隔离 AppData 和项目;隔离 Runner 仍有任务、退出失败、Swarm 未收束或预览启动失败时保留对应现场,不能强杀或误删。`--keep-project` 可主动保留项目但不额外保留已空闲的隔离配置;显式 `--project-dir` 永不删除,非空未初始化目录拒绝,`--config-dir` 只表示绝对配置来源目录,`--project-dir` 也只接受绝对路径。该人工入口用于快速体验,不能替代真实外部 Provider E2E 的完整生命周期、隐私和残留门禁。 2026-07-15 起,Runtime V1.1 文档的“V1.17 单 Agent 持久计划”作为后台工具规划进度的新事实源。`submit_agent_tool_plan` 新增 nullable `planUpdate={explanation,steps[{step,status}]}`;步骤只接受 `pending / in_progress / completed`,最多 8 步且至多一个 `in_progress`。结构化计划一旦建立,legacy `plan` 只作旧协议 fallback;终态步骤必须保留,`planRevision` 只在真实变化时单调递增,工具 action 下标不得自动完成结构化步骤,存在未完成步骤时不得写最终回复或 completed。