diff --git a/apps/ai-game-creator-shell/package.json b/apps/ai-game-creator-shell/package.json index 879b1dbab..e2982bbc3 100644 --- a/apps/ai-game-creator-shell/package.json +++ b/apps/ai-game-creator-shell/package.json @@ -4,8 +4,8 @@ "version": "0.1.0", "type": "module", "scripts": { - "dev": "npm --prefix ../.. exec tauri -- dev", - "game-chat": "npm --prefix ../.. exec tauri -- dev -- -- --game-chat", + "dev": "node scripts/start-tauri-dev.mjs", + "game-chat": "node scripts/start-tauri-dev.mjs --game-chat", "dev-server": "node scripts/start-dev-server.mjs", "dev-stack": "node scripts/start-dev-stack.mjs", "build": "npm --prefix ../.. exec tauri -- build", diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs index 2f858797a..df99a183c 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -452,10 +452,7 @@ async function runConfigWizardRegressionChecks() { assert.equal(fs.existsSync(missingLinkedConfigDir), false); assert.equal( await assertSafeGameCreatorConfigDestination(missingLinkedConfigDir), - path.join( - fs.realpathSync.native(realConfigAncestor), - 'missing-appdata', - ), + path.join(fs.realpathSync.native(realConfigAncestor), 'missing-appdata'), ); const gitRoot = path.join(testRoot, 'tracked-repository'); @@ -1260,6 +1257,21 @@ if ( ); } +if (packageConfig.scripts?.dev !== 'node scripts/start-tauri-dev.mjs') { + throw new Error( + 'AI game creator shell dev must run through the managed Tauri dev launcher', + ); +} + +if ( + packageConfig.scripts?.['game-chat'] !== + 'node scripts/start-tauri-dev.mjs --game-chat' +) { + throw new Error( + 'AI game creator shell game-chat must run through the managed Tauri dev launcher', + ); +} + const gameChatInitialUrlApply = 'apply_game_chat_initial_window_url(tauri_context.config_mut(), options)'; const gameChatInitialUrlApplyIndexes = Array.from( 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 91bf6d1b2..81fae0bee 100644 --- a/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs +++ b/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs @@ -1,6 +1,7 @@ import { spawn } from 'node:child_process'; import { existsSync, readFileSync } from 'node:fs'; import http from 'node:http'; +import net from 'node:net'; import { resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -134,6 +135,21 @@ async function readExistingViteServer() { return httpGetText(viteUrl); } +function isVitePortListening() { + return new Promise((resolveRequest) => { + const socket = net.connect({ host: viteHost, port: vitePort }); + socket.once('connect', () => { + socket.destroy(); + resolveRequest(true); + }); + socket.once('error', () => resolveRequest(false)); + socket.setTimeout(1000, () => { + socket.destroy(); + resolveRequest(true); + }); + }); +} + function isAiGameCreatorServer(response) { return ( response && @@ -144,17 +160,6 @@ function isAiGameCreatorServer(response) { ); } -async function isExistingViteProxyReady() { - const response = await httpGetText(`${viteUrl}api/auth/me`, 2000); - return Boolean( - response && - response.statusCode >= 200 && - response.statusCode < 500 && - !response.body.includes('AI 游戏创作') && - !response.body.includes('/src/main.tsx'), - ); -} - async function readExistingViteMarker() { const response = await httpGetText(viteMarkerUrl, 2000); if (!response || response.statusCode !== 200) { @@ -167,24 +172,49 @@ async function readExistingViteMarker() { } } -async function isExistingVitePairedWithBackend(apiTarget) { - const marker = await readExistingViteMarker(); - return Boolean( - marker && - marker.schemaVersion === 1 && - marker.app === 'ai-game-creator-shell' && - marker.apiTarget === apiTarget, +async function preflightExistingVite({ + readServer = readExistingViteServer, + portListening = isVitePortListening, + readMarker = readExistingViteMarker, +} = {}) { + const existing = await readServer(); + if (!existing) { + if (await portListening()) { + throw new Error( + `${viteUrl} is already in use by a non-HTTP or unrecognized server. Stop it before starting Tauri dev.`, + ); + } + return { status: 'available', apiTarget: '' }; + } + + if (!isAiGameCreatorServer(existing)) { + throw new Error( + `${viteUrl} is already in use by another server. Stop it before starting Tauri dev.`, + ); + } + + const marker = await readMarker(); + const markerApiTarget = + marker?.schemaVersion === 1 && + marker?.app === 'ai-game-creator-shell' && + typeof marker?.apiTarget === 'string' + ? marker.apiTarget + : ''; + const actualTarget = markerApiTarget || 'unknown'; + throw new Error( + `${viteUrl} is already running with API target ${actualTarget}. Its owning worktree cannot be proven, so it will not be reused. Stop that Vite dev server before starting Tauri dev.`, ); } function spawnChild(command, args, options, spawnImpl = spawn) { - const useShell = process.platform === 'win32'; + const isPosix = process.platform !== 'win32'; + const useShell = options.shell ?? !isPosix; const child = spawnImpl(command, args, { ...options, shell: useShell, // POSIX 下让每个长驻服务拥有独立进程组,退出时可以连同 npm、 // Node、Cargo 及其子进程一起清理,避免残留订阅任务持续刷日志。 - detached: !useShell, + detached: isPosix, stdio: 'inherit', }); const lifecycle = { @@ -192,7 +222,7 @@ function spawnChild(command, args, options, spawnImpl = spawn) { promise: null, // detached 子进程在 POSIX 下以自身 PID 作为 PGID。leader 退出后 // child.pid 仍是清理其后代的唯一稳定句柄,必须随生命周期保留。 - processGroupId: !useShell && Number.isInteger(child.pid) ? child.pid : null, + processGroupId: isPosix && Number.isInteger(child.pid) ? child.pid : null, }; lifecycle.promise = new Promise((resolveLifecycle) => { child.once('error', (error) => { @@ -270,6 +300,142 @@ function stopChild(child, signal = 'SIGTERM') { } } +function isProcessGroupAlive(processGroupId, killImpl = process.kill) { + if (!Number.isInteger(processGroupId)) { + return false; + } + try { + killImpl(-processGroupId, 0); + return true; + } catch (error) { + return error?.code !== 'ESRCH'; + } +} + +async function waitUntil(check, timeoutMs, pollIntervalMs = 25) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (await check()) { + return true; + } + await new Promise((resolveWait) => setTimeout(resolveWait, pollIntervalMs)); + } + return check(); +} + +function runWindowsTaskkill( + processId, + { spawnImpl = spawn, timeoutMs = 5000 } = {}, +) { + return new Promise((resolveRequest) => { + const taskkill = spawnImpl( + 'taskkill.exe', + ['/PID', String(processId), '/T', '/F'], + { + shell: false, + stdio: 'ignore', + windowsHide: true, + }, + ); + let settled = false; + const timeout = setTimeout(() => { + try { + taskkill.kill('SIGKILL'); + } catch { + // ignore taskkill timeout races + } + finish({ timedOut: true, code: null, error: null }); + }, timeoutMs); + const finish = (result) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timeout); + resolveRequest(result); + }; + taskkill.once('error', (error) => + finish({ timedOut: false, code: null, error }), + ); + taskkill.once('exit', (code) => + finish({ timedOut: false, code: code ?? 0, error: null }), + ); + }); +} + +async function terminateChildTree( + child, + { + platform = process.platform, + gracefulTimeoutMs = 2500, + forceTimeoutMs = 2000, + killImpl = process.kill, + taskkillImpl = runWindowsTaskkill, + } = {}, +) { + if (!child) { + return { stopped: true, forced: false }; + } + + if (platform === 'win32') { + if (!Number.isInteger(child.pid)) { + stopChild(child, 'SIGTERM'); + return { stopped: true, forced: false }; + } + const result = await taskkillImpl(child.pid); + return { + stopped: + !result?.timedOut && + !result?.error && + [0, 128].includes(result?.code ?? 0), + forced: true, + result, + }; + } + + const processGroupId = childLifecycles.get(child)?.processGroupId; + if (!Number.isInteger(processGroupId)) { + stopChild(child, 'SIGTERM'); + const lifecycle = childLifecycles.get(child); + if (lifecycle) { + await Promise.race([ + lifecycle.promise, + new Promise((resolveWait) => + setTimeout(resolveWait, gracefulTimeoutMs), + ), + ]); + } + if (child.exitCode == null && child.signalCode == null) { + stopChild(child, 'SIGKILL'); + return { stopped: false, forced: true }; + } + return { stopped: true, forced: false }; + } + + stopChild(child, 'SIGTERM'); + if ( + await waitUntil( + () => !isProcessGroupAlive(processGroupId, killImpl), + gracefulTimeoutMs, + ) + ) { + return { stopped: true, forced: false }; + } + + try { + killImpl(-processGroupId, 'SIGKILL'); + } catch (error) { + if (error?.code !== 'ESRCH') { + return { stopped: false, forced: true, error }; + } + } + const stopped = await waitUntil( + () => !isProcessGroupAlive(processGroupId, killImpl), + forceTimeoutMs, + ); + return { stopped, forced: true }; +} + async function waitForBackendReady(backendChild, timeoutMs = 600_000) { const startedAt = Date.now(); while (Date.now() - startedAt < timeoutMs) { @@ -340,19 +506,9 @@ async function startVite(apiTarget) { const existing = await readExistingViteServer(); if (existing) { - if ( - isAiGameCreatorServer(existing) && - (await isExistingVitePairedWithBackend(apiTarget)) && - (await isExistingViteProxyReady()) - ) { - console.log( - `[ai-game-creator-shell] reuse existing Vite dev server ${viteUrl}`, - ); - return null; - } if (isAiGameCreatorServer(existing)) { throw new Error( - `${viteUrl} is already running, but its /api proxy is not connected to the paired backend. Stop it before starting Tauri dev.`, + `${viteUrl} is already running and cannot be safely reused. Stop it before starting Tauri dev.`, ); } throw new Error( @@ -384,6 +540,7 @@ async function main() { } try { + await preflightExistingVite(); const backend = await ensureBackend({ onBackendChild(child) { backendChild = child; @@ -422,6 +579,10 @@ async function main() { ); return 1; } finally { + await Promise.all([ + terminateChildTree(viteChild), + terminateChildTree(backendChild), + ]); for (const [signal, handler] of signalHandlers) { process.off(signal, handler); } @@ -439,10 +600,13 @@ export { ensureBackend, formatChildFailure, isDirectModuleExecution, + preflightExistingVite, readChildFailure, resolveBackendTargetsFromState, + runWindowsTaskkill, spawnChild, stopChild, + terminateChildTree, waitForBackendReady, waitForChildTermination, }; diff --git a/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs b/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs new file mode 100644 index 000000000..5c6bba17e --- /dev/null +++ b/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs @@ -0,0 +1,128 @@ +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { + preflightExistingVite, + spawnChild, + stopChild, + terminateChildTree, + waitForChildTermination, +} from './start-dev-stack.mjs'; + +const appRoot = fileURLToPath(new URL('..', import.meta.url)); +const repoRoot = resolve(appRoot, '../..'); +const tauriCliPath = resolve(repoRoot, 'node_modules/@tauri-apps/cli/tauri.js'); + +function parseLauncherArguments(argv) { + const args = [...argv]; + const gameChat = args[0] === '--game-chat'; + if (gameChat) { + args.shift(); + } + return { gameChat, args }; +} + +function buildTauriArguments(argv) { + const { gameChat, args } = parseLauncherArguments(argv); + if (gameChat) { + return ['dev', '--', '--', '--game-chat', ...args]; + } + return ['dev', ...args]; +} + +function spawnTauriCli(argv) { + return spawnChild(process.execPath, [tauriCliPath, ...argv], { + cwd: appRoot, + shell: false, + }); +} + +async function runTauriDev( + argv = process.argv.slice(2), + { + preflight = preflightExistingVite, + spawnCli = spawnTauriCli, + waitForCli = waitForChildTermination, + terminateTree = terminateChildTree, + } = {}, +) { + await preflight(); + + const tauriArguments = buildTauriArguments(argv); + const child = spawnCli(tauriArguments); + let resolveShutdown; + let shutdownSignal = ''; + let repeatedSignal = false; + const shutdownRequested = new Promise((resolveRequest) => { + resolveShutdown = resolveRequest; + }); + const signalHandlers = new Map(); + + for (const signal of ['SIGINT', 'SIGTERM']) { + const handler = () => { + if (!shutdownSignal) { + shutdownSignal = signal; + stopChild(child, 'SIGTERM'); + resolveShutdown(signal); + return; + } + repeatedSignal = true; + stopChild(child, 'SIGKILL'); + }; + signalHandlers.set(signal, handler); + process.on(signal, handler); + } + + try { + const childResult = waitForCli(child); + const outcome = await Promise.race([ + childResult.then((failure) => ({ type: 'exit', failure })), + shutdownRequested.then((signal) => ({ type: 'signal', signal })), + ]); + const cleanup = await terminateTree(child, { + gracefulTimeoutMs: repeatedSignal ? 0 : 2500, + }); + if (!cleanup.stopped) { + console.error( + '[ai-game-creator-shell] Tauri dev exited, but its process tree could not be fully stopped.', + ); + return 1; + } + + if (outcome.type === 'signal') { + return 1; + } + const { failure } = outcome; + return failure.type === 'error' || failure.signal ? 1 : (failure.code ?? 0); + } 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 { + buildTauriArguments, + isDirectModuleExecution, + parseLauncherArguments, + runTauriDev, + spawnTauriCli, +}; + +if (isDirectModuleExecution()) { + try { + process.exitCode = await runTauriDev(); + } catch (error) { + console.error( + `[ai-game-creator-shell] ${error instanceof Error ? error.message : String(error)}`, + ); + process.exitCode = 1; + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/autonomous_policy.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/autonomous_policy.rs index a62eee9f5..8c8c4f998 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/autonomous_policy.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/autonomous_policy.rs @@ -262,11 +262,15 @@ fn autonomous_initial_delegate_expected_artifacts( pub(in crate::agent) fn validate_agent_runtime_autonomous_initial_collaboration_contract( plan: &AgentRuntimeToolPlan, ) -> Result<(), String> { - let mut code_prototype = None; - let mut quality_review = None; + let mut design_director = None; let mut art_director = None; - let mut art_asset_plan = None; + let mut code_director = None; for action in &plan.actions { + if action.tool.trim() == "agent.spawn_isolated" { + return Err(autonomous_initial_collaboration_contract_error( + "首批只允许激活 design-director、art-director、code-director,不得启动 isolated child", + )); + } let Some(input) = autonomous_initial_delegate_input(action)? else { continue; }; @@ -284,11 +288,14 @@ pub(in crate::agent) fn validate_agent_runtime_autonomous_initial_collaboration_ )); }; let slot = match target_agent_id { - "code-prototype" => &mut code_prototype, - AGENT_RUNTIME_QUALITY_REVIEW_AGENT_ID => &mut quality_review, + "design-director" => &mut design_director, "art-director" => &mut art_director, - "art-asset-plan" => &mut art_asset_plan, - _ => continue, + "code-director" => &mut code_director, + _ => { + return Err(autonomous_initial_collaboration_contract_error(format!( + "首批只允许激活 design-director、art-director、code-director,不得委派底层 Agent:{target_agent_id}" + ))); + } }; if slot.replace(input).is_some() { return Err(autonomous_initial_collaboration_contract_error(format!( @@ -297,111 +304,80 @@ pub(in crate::agent) fn validate_agent_runtime_autonomous_initial_collaboration_ } } - let code_prototype = code_prototype.ok_or_else(|| { - autonomous_initial_collaboration_contract_error("首批缺少 code-prototype 委派") + let design_director = design_director.ok_or_else(|| { + autonomous_initial_collaboration_contract_error("首批缺少 design-director 委派") })?; - let code_task = autonomous_initial_delegate_task(code_prototype, "code-prototype")?; - let code_criteria = agent_runtime_tool_input_string_list( - &serde_json::Value::Object(code_prototype.clone()), + let design_task = autonomous_initial_delegate_task(design_director, "design-director")?; + let design_criteria = agent_runtime_tool_input_string_list( + &serde_json::Value::Object(design_director.clone()), &["acceptanceCriteria", "acceptance_criteria", "criteria"], ); - if std::iter::once(code_task.as_str()) + if !std::iter::once(design_task.as_str()) + .chain(design_criteria.iter().map(String::as_str)) + .any(agent_runtime_task_explicitly_requires_read_only_delivery) + { + return Err(autonomous_initial_collaboration_contract_error( + "首批 design-director task 或 acceptanceCriteria 必须显式声明只读且不得修改项目", + )); + } + let design_artifacts = + autonomous_initial_delegate_expected_artifacts(design_director, "design-director")?; + if !design_artifacts.is_empty() { + return Err(autonomous_initial_collaboration_contract_error( + "首批 design-director 的 expectedArtifacts 必须为 []", + )); + } + + let art_director = art_director.ok_or_else(|| { + autonomous_initial_collaboration_contract_error("首批缺少 art-director 委派") + })?; + let art_task = autonomous_initial_delegate_task(art_director, "art-director")?; + let art_criteria = agent_runtime_tool_input_string_list( + &serde_json::Value::Object(art_director.clone()), + &["acceptanceCriteria", "acceptance_criteria", "criteria"], + ); + if std::iter::once(art_task.as_str()) + .chain(art_criteria.iter().map(String::as_str)) + .any(agent_runtime_task_explicitly_requires_read_only_delivery) + { + return Err(autonomous_initial_collaboration_contract_error( + "首批 art-director 必须是非只读规范图生成任务", + )); + } + let art_artifacts = + autonomous_initial_delegate_expected_artifacts(art_director, "art-director")?; + if !art_artifacts + .iter() + .any(|path| path == "assets/art-spec.png") + { + return Err(autonomous_initial_collaboration_contract_error( + "首批 art-director 的 expectedArtifacts 必须包含 assets/art-spec.png", + )); + } + + let code_director = code_director.ok_or_else(|| { + autonomous_initial_collaboration_contract_error("首批缺少 code-director 委派") + })?; + let code_task = autonomous_initial_delegate_task(code_director, "code-director")?; + let code_criteria = agent_runtime_tool_input_string_list( + &serde_json::Value::Object(code_director.clone()), + &["acceptanceCriteria", "acceptance_criteria", "criteria"], + ); + if !std::iter::once(code_task.as_str()) .chain(code_criteria.iter().map(String::as_str)) .any(agent_runtime_task_explicitly_requires_read_only_delivery) { return Err(autonomous_initial_collaboration_contract_error( - "首批 code-prototype 必须是非只读实现任务", + "首批 code-director task 或 acceptanceCriteria 必须显式声明只读且不得修改项目", )); } let code_artifacts = - autonomous_initial_delegate_expected_artifacts(code_prototype, "code-prototype")?; - if !code_artifacts - .iter() - .any(|path| path == AGENT_RUNTIME_GAME_INDEX_PATH) - { - return Err(autonomous_initial_collaboration_contract_error(format!( - "首批 code-prototype 的 expectedArtifacts 必须包含 {AGENT_RUNTIME_GAME_INDEX_PATH}" - ))); - } - - let quality_review = quality_review.ok_or_else(|| { - autonomous_initial_collaboration_contract_error("首批缺少 quality-review 委派") - })?; - let quality_task = - autonomous_initial_delegate_task(quality_review, AGENT_RUNTIME_QUALITY_REVIEW_AGENT_ID)?; - let quality_criteria = agent_runtime_tool_input_string_list( - &serde_json::Value::Object(quality_review.clone()), - &["acceptanceCriteria", "acceptance_criteria", "criteria"], - ); - if !std::iter::once(quality_task.as_str()) - .chain(quality_criteria.iter().map(String::as_str)) - .any(agent_runtime_task_explicitly_requires_read_only_delivery) - { + autonomous_initial_delegate_expected_artifacts(code_director, "code-director")?; + if !code_artifacts.is_empty() { return Err(autonomous_initial_collaboration_contract_error( - "首批 quality-review task 或 acceptanceCriteria 必须显式声明只读且不得修改项目", + "首批 code-director 的 expectedArtifacts 必须为 []", )); } - let quality_artifacts = autonomous_initial_delegate_expected_artifacts( - quality_review, - AGENT_RUNTIME_QUALITY_REVIEW_AGENT_ID, - )?; - if !quality_artifacts.is_empty() { - return Err(autonomous_initial_collaboration_contract_error( - "首批 quality-review 的 expectedArtifacts 必须为 []", - )); - } - if let Some(art_director) = art_director { - let art_task = autonomous_initial_delegate_task(art_director, "art-director")?; - let art_criteria = agent_runtime_tool_input_string_list( - &serde_json::Value::Object(art_director.clone()), - &["acceptanceCriteria", "acceptance_criteria", "criteria"], - ); - if std::iter::once(art_task.as_str()) - .chain(art_criteria.iter().map(String::as_str)) - .any(agent_runtime_task_explicitly_requires_read_only_delivery) - { - return Err(autonomous_initial_collaboration_contract_error( - "首批 art-director 必须是非只读规范图生成任务", - )); - } - let art_artifacts = - autonomous_initial_delegate_expected_artifacts(art_director, "art-director")?; - if !art_artifacts - .iter() - .any(|path| path == "assets/art-spec.png") - { - return Err(autonomous_initial_collaboration_contract_error( - "首批 art-director 的 expectedArtifacts 必须包含 assets/art-spec.png", - )); - } - } - if let Some(art_asset_plan) = art_asset_plan { - let art_task = autonomous_initial_delegate_task(art_asset_plan, "art-asset-plan")?; - let art_criteria = agent_runtime_tool_input_string_list( - &serde_json::Value::Object(art_asset_plan.clone()), - &["acceptanceCriteria", "acceptance_criteria", "criteria"], - ); - if std::iter::once(art_task.as_str()) - .chain(art_criteria.iter().map(String::as_str)) - .any(agent_runtime_task_explicitly_requires_read_only_delivery) - { - return Err(autonomous_initial_collaboration_contract_error( - "首批 art-asset-plan 必须是非只读美术生成任务", - )); - } - let art_artifacts = - autonomous_initial_delegate_expected_artifacts(art_asset_plan, "art-asset-plan")?; - let missing_artifacts = ["assets/manifest.art.json", "assets/art-spritesheet.png"] - .into_iter() - .filter(|required| !art_artifacts.iter().any(|path| path == required)) - .collect::>(); - if !missing_artifacts.is_empty() { - return Err(autonomous_initial_collaboration_contract_error(format!( - "首批 art-asset-plan 的 expectedArtifacts 缺少:{}", - missing_artifacts.join(", ") - ))); - } - } Ok(()) } @@ -1506,6 +1482,24 @@ pub(in crate::agent) fn restrict_agent_runtime_supervisor_collaboration_repair_t Ok(()) } +pub(in crate::agent) fn restrict_agent_runtime_autonomous_initial_collaboration_repair_tools( + request: &mut LlmRunRequest, +) -> Result<(), String> { + let delegate_function = native_runtime_function_name("agent.delegate") + .ok_or_else(|| "无法生成 autonomous 首批协作修复工具名:agent.delegate".to_string())?; + request + .function_tools + .retain(|tool| tool.name == delegate_function); + if !request + .function_tools + .iter() + .any(|tool| tool.name == delegate_function) + { + return Err("autonomous 首批协作修复工具目录缺少 agent.delegate".to_string()); + } + Ok(()) +} + pub(in crate::agent) fn agent_runtime_protocol_error_requires_supervisor_collaboration_repair( error: &str, ) -> bool { @@ -1522,6 +1516,93 @@ pub(in crate::agent) fn agent_runtime_protocol_error_requires_supervisor_collabo mod tests { use super::*; + fn autonomous_initial_delegate( + agent_id: &str, + expected_artifacts: &[&str], + ) -> AgentRuntimeToolAction { + let read_only_planner = matches!(agent_id, "design-director" | "code-director"); + AgentRuntimeToolAction { + tool: "agent.delegate".to_string(), + reason: Some("建立首批 Leader 规划".to_string()), + input: serde_json::json!({ + "agentId": agent_id, + "task": if read_only_planner { + format!("由 {agent_id} 只读完成首轮专业规划,不得修改项目") + } else { + format!("由 {agent_id} 完成首轮专业交付") + }, + "acceptanceCriteria": if read_only_planner { + vec!["只读给出可供后续底层 Agent 按需执行的规划,不得修改项目"] + } else { + vec!["视觉规范可供后续底层 Agent 按需执行"] + }, + "expectedArtifacts": expected_artifacts, + "repairOfDelegationId": null, + "runId": null, + }), + } + } + + fn autonomous_initial_leader_plan() -> AgentRuntimeToolPlan { + AgentRuntimeToolPlan { + thinking_summary: "首批只激活程策美 Leader".to_string(), + plan_update: None, + plan: Vec::new(), + actions: vec![ + autonomous_initial_delegate("design-director", &[]), + autonomous_initial_delegate("art-director", &["assets/art-spec.png"]), + autonomous_initial_delegate("code-director", &[]), + ], + response: String::new(), + } + } + + #[test] + fn autonomous_initial_collaboration_accepts_only_three_leaders() { + validate_agent_runtime_autonomous_initial_collaboration_contract( + &autonomous_initial_leader_plan(), + ) + .expect("three leader initial contract"); + } + + #[test] + fn autonomous_initial_collaboration_rejects_bottom_agent() { + let mut plan = autonomous_initial_leader_plan(); + plan.actions[2] = + autonomous_initial_delegate("code-prototype", &[AGENT_RUNTIME_GAME_INDEX_PATH]); + + let error = validate_agent_runtime_autonomous_initial_collaboration_contract(&plan) + .expect_err("bottom agent must be rejected"); + + assert!(error.contains("不得委派底层 Agent:code-prototype")); + } + + #[test] + fn autonomous_initial_collaboration_rejects_isolated_child() { + let mut plan = autonomous_initial_leader_plan(); + plan.actions.push(AgentRuntimeToolAction { + tool: "agent.spawn_isolated".to_string(), + reason: None, + input: serde_json::json!({"children": [], "joinMode": "all"}), + }); + + let error = validate_agent_runtime_autonomous_initial_collaboration_contract(&plan) + .expect_err("isolated child must be rejected"); + + assert!(error.contains("不得启动 isolated child")); + } + + #[test] + fn autonomous_initial_collaboration_requires_leader_artifacts() { + let mut plan = autonomous_initial_leader_plan(); + plan.actions[1] = autonomous_initial_delegate("art-director", &[]); + + let error = validate_agent_runtime_autonomous_initial_collaboration_contract(&plan) + .expect_err("art artifact must be required"); + + assert!(error.contains("expectedArtifacts 必须包含 assets/art-spec.png")); + } + #[test] fn autonomous_manifest_dag_waits_only_after_seed_execution_starts() { let temporary = tempfile::tempdir().expect("create manifest DAG policy root"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_tool_plan.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_tool_plan.rs index 21336aba1..556316ca5 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_tool_plan.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_tool_plan.rs @@ -52,7 +52,7 @@ fn supervisor_collaboration_missing_agent_ids(error: &str) -> Vec { .collect::>() }) .unwrap_or_default(); - for agent_id in ["code-prototype", "quality-review", "art-asset-plan"] { + for agent_id in ["design-director", "art-director", "code-director"] { if error.contains(&format!("缺少 {agent_id} 委派")) && !missing.iter().any(|value| value == agent_id) { @@ -62,6 +62,31 @@ fn supervisor_collaboration_missing_agent_ids(error: &str) -> Vec { missing } +fn is_autonomous_initial_leader_delegate_action(action: &AgentRuntimeToolAction) -> bool { + if action.tool.trim() != "agent.delegate" { + return false; + } + let Some(input) = action.input.as_object() else { + return false; + }; + if input + .get("repairOfDelegationId") + .or_else(|| input.get("repair_of_delegation_id")) + .and_then(serde_json::Value::as_str) + .is_some_and(|value| !value.trim().is_empty()) + { + return false; + } + matches!( + input + .get("agentId") + .or_else(|| input.get("agent_id")) + .and_then(serde_json::Value::as_str) + .map(str::trim), + Some("design-director" | "art-director" | "code-director") + ) +} + fn restrict_supervisor_collaboration_repair_to_missing_agents( request: &mut LlmRunRequest, error: &str, @@ -843,9 +868,22 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at if force_supervisor_initial_collaboration { supervisor_collaboration_repair_active = true; if let Some(actions) = supervisor_collaboration_candidate_actions.take() { - supervisor_collaboration_repair_actions = actions; + supervisor_collaboration_repair_actions = + if run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD { + actions + .into_iter() + .filter(is_autonomous_initial_leader_delegate_action) + .collect() + } else { + actions + }; } restrict_agent_runtime_supervisor_collaboration_repair_tools(&mut request)?; + if run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD { + restrict_agent_runtime_autonomous_initial_collaboration_repair_tools( + &mut request, + )?; + } restrict_supervisor_collaboration_repair_to_missing_agents( &mut request, &protocol_error, @@ -854,7 +892,7 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD { format!( - "上一条输出不符合工具计划协议:{protocol_error}\n本次修复的原生工具目录只保留首批协作工具。必须在同一响应一次性建立完整首批合同:code-prototype 必须是非只读实现任务且 expectedArtifacts 包含 game/index.html;quality-review 的 task 必须显式声明只读、不得修改项目,且 expectedArtifacts 必须为 [];如当前 policy 的 requiredStaticAgentIds 包含 art-director,必须加入非只读规范图委派且 expectedArtifacts 包含 assets/art-spec.png;如包含 design-foundation,必须加入非只读设计委派且 expectedArtifacts 包含 memory/project.md、game/game_design.md 与 assets/ui-prototype.png;如包含 art-asset-plan,还必须加入非只读美术生成委派且 expectedArtifacts 同时包含 assets/manifest.art.json 与 assets/art-spritesheet.png。所有静态委派都使用 agent.delegate,repairOfDelegationId=null、runId=null;如当前 policy 还要求 isolated,再在同批补齐 agent.spawn_isolated。不得更新计划、读取、搜索、查询状态、修改项目或返回最终回复。不要解释,不要 markdown,不要代码围栏。" + "上一条输出不符合工具计划协议:{protocol_error}\n本次修复的原生工具目录只保留 agent.delegate。必须在同一响应一次性建立完整首批合同,且只允许以下三个非 repair 委派,各出现一次:design-director 与 code-director 的 task 或 acceptanceCriteria 必须显式声明只读且不得修改项目,expectedArtifacts 必须为 [];art-director 必须是非只读规范图生成任务,expectedArtifacts 必须包含 assets/art-spec.png。三者都必须提供非空 task、1-8 条 acceptanceCriteria,并设置 repairOfDelegationId=null、runId=null。不得委派 code-prototype、quality-review、design-foundation、art-asset-plan 或其它底层 Agent,不得调用 agent.spawn_isolated,不得更新计划、读取、搜索、查询状态、修改项目或返回最终回复。不要解释,不要 markdown,不要代码围栏。" ) } else { format!( @@ -1081,12 +1119,42 @@ mod supervisor_collaboration_repair_tests { .is_empty()); assert_eq!( supervisor_collaboration_missing_agent_ids( - "Project Supervisor 首批协作不满足项目合同:missingStaticAgents=code-prototype,quality-review · isolatedChildrenTotal=0" + "Project Supervisor 首批协作不满足项目合同:missingStaticAgents=design-director,art-director,code-director · isolatedChildrenTotal=0" ), - vec!["code-prototype".to_string(), "quality-review".to_string()] + vec![ + "design-director".to_string(), + "art-director".to_string(), + "code-director".to_string(), + ] ); } + #[test] + fn autonomous_initial_repair_keeps_only_leader_delegates() { + let actions = [ + collaboration_action( + "agent.delegate", + serde_json::json!({"agentId": "design-director", "repairOfDelegationId": null}), + ), + collaboration_action( + "agent.delegate", + serde_json::json!({"agentId": "code-prototype", "repairOfDelegationId": null}), + ), + collaboration_action( + "agent.spawn_isolated", + serde_json::json!({"children": [], "joinMode": "all"}), + ), + ]; + + let kept = actions + .iter() + .filter(|action| is_autonomous_initial_leader_delegate_action(action)) + .map(|action| action.input["agentId"].as_str().expect("leader id")) + .collect::>(); + + assert_eq!(kept, vec!["design-director"]); + } + #[test] fn isolated_repair_replaces_the_single_accumulated_slot() { let accumulated = vec![collaboration_action( diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/response_stream_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/response_stream_tests.rs index ccba17a4f..ba11e9fd4 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/response_stream_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/response_stream_tests.rs @@ -501,6 +501,90 @@ fn response_stream_finalization_commits_exactly_one_canonical_assistant() { assert_eq!(assistants, vec![response]); } +#[test] +fn non_stream_professional_final_reply_remains_queryable_after_later_project_revision() { + assert!( + !GameCreatorLlmConfig::default().stream, + "the production default exercises the non-stream final-reply path" + ); + let project = tempfile::tempdir().expect("create non-stream professional reply project"); + let root = project.path(); + init_local_game_project_at( + root, + "non-stream-professional-reply", + "非流式专业 Agent 回复", + ) + .expect("initialize non-stream professional reply project"); + let mut state = start_game_creator_agent_runtime_task_at( + root, + "art-director", + "生成 game-chat 首版统一视觉规范", + "non-stream-art-director-run", + "agent-delegate", + "整理专业 Agent 最终回复", + vec!["生成并登记统一视觉规范图".to_string()], + ) + .expect("start non-stream professional runtime"); + state.parent_agent_id = Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string()); + state.parent_run_id = Some("game-chat-parent-run".to_string()); + state.delegation_id = Some("game-chat-art-director-delegation".to_string()); + state.loop_iteration = 1; + state.status = "running".to_string(); + state.phase = "response".to_string(); + state.current_action = "直接采用非流式最终回复".to_string(); + state.waiting_on = "finalization 持久化".to_string(); + state.next_step = "提交 durable response stream 投影".to_string(); + state.updated_at = unix_timestamp(); + append_game_creator_agent_runtime_task(root, &state) + .expect("append non-stream professional runtime task"); + write_game_creator_agent_runtime_state(root, &state) + .expect("write non-stream professional runtime state"); + + let response_revision = read_game_creator_agent_runtime_project_revision(root) + .expect("read non-stream response revision") + .revision; + assert!( + read_game_creator_agent_runtime_response_stream_at(root, &state.agent_id, &state.run_id,) + .expect("read absent pre-finalization response stream") + .is_none(), + "stream=false must enter finalization without a pre-existing stream sidecar" + ); + + let response = "美术 Agent:统一视觉规范图已生成并登记。"; + let completed = finish_game_creator_agent_background_runtime_turn_at( + root, + state.clone(), + response, + response_revision, + &[], + ) + .expect("finalize non-stream professional reply"); + assert!(matches!( + completed, + AgentBackgroundFinalizationOutcome::Completed(_) + )); + + let mut later_revision = read_game_creator_agent_runtime_project_revision(root) + .expect("read project revision before later stage mutation"); + later_revision.revision = later_revision.revision.saturating_add(1); + later_revision.updated_at = unix_timestamp(); + write_game_creator_agent_runtime_project_revision(root, &later_revision) + .expect("simulate a later game-chat stage advancing project revision"); + + let queried = read_game_creator_agent_runtime_at(root, &state.agent_id) + .expect("query completed professional runtime after revision advance"); + let stream = queried + .response_stream + .expect("durable professional final reply remains queryable"); + assert_eq!( + stream.status, + AGENT_RUNTIME_RESPONSE_STREAM_STATUS_COMMITTED + ); + assert_eq!(stream.request_kind, "final-reply"); + assert_eq!(stream.response_revision, response_revision); + assert_eq!(stream.accumulated_text, response); +} + #[test] fn finalization_cleanup_closes_entire_tool_plan_repair_chain_before_removal() { let (project, state, response_revision, snapshot) = diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_tests.rs index 6bc7d08ec..98dfec582 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_tests.rs @@ -620,7 +620,12 @@ fn game_chat_single_round_converges_without_another_provider_plan_after_playtest for task in new_game_creation_app_seed_tasks() { if !matches!( task.id.as_str(), - "art-director" | "code-prototype" | "preview-readiness" | "preview-playtest" + "design-director" + | "art-director" + | "code-director" + | "code-prototype" + | "preview-readiness" + | "preview-playtest" ) { update_manifest_task_status_at(&root, &task.id, GameCreationAppTaskStatus::Pending) .unwrap_or_else(|error| panic!("leave non-fast-path task pending: {error}")); @@ -687,7 +692,12 @@ fn game_chat_single_round_cannot_converge_after_the_hard_budget() { for task in new_game_creation_app_seed_tasks() { if !matches!( task.id.as_str(), - "art-director" | "code-prototype" | "preview-readiness" | "preview-playtest" + "design-director" + | "art-director" + | "code-director" + | "code-prototype" + | "preview-readiness" + | "preview-playtest" ) { update_manifest_task_status_at(&root, &task.id, GameCreationAppTaskStatus::Pending) .unwrap_or_else(|error| panic!("leave non-fast-path task pending: {error}")); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs index 6d886e21b..cfe56d75b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs @@ -661,8 +661,14 @@ pub(in crate::agent) fn autonomous_manifest_seed_tasks_for_source( .into_iter() .filter_map(|mut task| { let dependencies = match task.id.as_str() { + "design-director" => Some(Vec::new()), "art-director" => Some(Vec::new()), - "code-prototype" => Some(vec!["art-director".to_string()]), + "code-director" => Some(Vec::new()), + "code-prototype" => Some(vec![ + "design-director".to_string(), + "art-director".to_string(), + "code-director".to_string(), + ]), "preview-readiness" => Some(vec!["code-prototype".to_string()]), "preview-playtest" => Some(vec!["preview-readiness".to_string()]), _ => None, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs index ab6595834..1afbb5d25 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs @@ -1325,13 +1325,17 @@ fn autonomous_manifest_ready_task_completion_blocker_at_locked( )); } }; - // game-chat 可能在 UI 完成项目 hydration 前启动第一个 ready child,随后初始化写回会短暂把 - // code-prototype 恢复成 Pending。child binding、owner artifact 和验证门仍能确认当前 run, - // 因此仅允许这个首任务收束,再由 terminal projection 写入权威 Completed 状态。 + // game-chat 可能在 UI 完成项目 hydration 前并行启动 source-aware lane 的首波 + // ready child,随后初始化写回会短暂把这些零依赖任务恢复成 Pending。child binding、owner + // artifact 和验证门仍能确认当前 run,因此仅允许当前 source 的零依赖首波收束, + // 再由 terminal projection 写入权威 Completed 状态。后续 preview 与中间任务继续 + // 严格要求 Running/Completed,不得借 hydration 例外越过依赖。 // GUI/CLI 以及后续 preview 任务继续严格要求 Running/Completed。 - let game_chat_hydration_pending = state.agent_id == "code-prototype" - && root_source == AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE - && task.status == GameCreationAppTaskStatus::Pending; + let game_chat_hydration_pending = root_source == AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE + && task.status == GameCreationAppTaskStatus::Pending + && crate::agent::autonomous_manifest_seed_tasks_for_source(&root_source) + .iter() + .any(|seed_task| seed_task.id == state.agent_id && seed_task.dependencies.is_empty()); if !matches!( task.status, GameCreationAppTaskStatus::Running | GameCreationAppTaskStatus::Completed diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion_contract_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion_contract_tests.rs index ccdc990c2..90c87ba3a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion_contract_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion_contract_tests.rs @@ -72,7 +72,7 @@ fn autonomous_supervisor_source_allowlist_includes_game_chat_only() { } #[test] -fn game_chat_manifest_seed_projection_is_a_serial_four_task_lane() { +fn game_chat_manifest_seed_projection_starts_three_directors_then_runs_three_task_lane() { let game_chat_tasks = autonomous_manifest_seed_tasks_for_source(AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE); assert_eq!( @@ -81,23 +81,31 @@ fn game_chat_manifest_seed_projection_is_a_serial_four_task_lane() { .map(|task| task.id.as_str()) .collect::>(), [ + "design-director", "art-director", + "code-director", "code-prototype", "preview-readiness", "preview-playtest", ] ); assert_eq!(game_chat_tasks[0].dependencies, Vec::::new()); + assert_eq!(game_chat_tasks[1].dependencies, Vec::::new()); + assert_eq!(game_chat_tasks[2].dependencies, Vec::::new()); assert_eq!( - game_chat_tasks[1].dependencies, - vec!["art-director".to_string()] + game_chat_tasks[3].dependencies, + vec![ + "design-director".to_string(), + "art-director".to_string(), + "code-director".to_string(), + ] ); assert_eq!( - game_chat_tasks[2].dependencies, + game_chat_tasks[4].dependencies, vec!["code-prototype".to_string()] ); assert_eq!( - game_chat_tasks[3].dependencies, + game_chat_tasks[5].dependencies, vec!["preview-readiness".to_string()] ); @@ -116,7 +124,12 @@ fn game_chat_manifest_seed_projection_is_a_serial_four_task_lane() { for task in &mut manifest_tasks { if matches!( task.id.as_str(), - "art-director" | "code-prototype" | "preview-readiness" | "preview-playtest" + "design-director" + | "art-director" + | "code-director" + | "code-prototype" + | "preview-readiness" + | "preview-playtest" ) { task.status = GameCreationAppTaskStatus::Pending; } @@ -126,14 +139,20 @@ fn game_chat_manifest_seed_projection_is_a_serial_four_task_lane() { &manifest_tasks, AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, ), - vec!["art-director".to_string()] + vec![ + "design-director".to_string(), + "art-director".to_string(), + "code-director".to_string(), + ] ); - manifest_tasks - .iter_mut() - .find(|task| task.id == "art-director") - .expect("art director task exists") - .status = GameCreationAppTaskStatus::Completed; + for task_id in ["design-director", "art-director", "code-director"] { + manifest_tasks + .iter_mut() + .find(|task| task.id == task_id) + .unwrap_or_else(|| panic!("{task_id} task exists")) + .status = GameCreationAppTaskStatus::Completed; + } assert_eq!( autonomous_manifest_ready_task_ids( &manifest_tasks, @@ -1080,7 +1099,12 @@ fn game_chat_parent_completion_stops_after_preview_playtest_without_publish_task for task in new_game_creation_app_seed_tasks() { if !matches!( task.id.as_str(), - "art-director" | "code-prototype" | "preview-readiness" | "preview-playtest" + "design-director" + | "art-director" + | "code-director" + | "code-prototype" + | "preview-readiness" + | "preview-playtest" ) { update_manifest_task_status_at(&root, &task.id, GameCreationAppTaskStatus::Pending) .unwrap_or_else(|error| panic!("leave non-fast-path task pending: {error}")); @@ -1367,15 +1391,52 @@ fn game_chat_art_stage_fails_closed_without_editor_configuration_or_canvas_asset } #[test] -fn game_chat_ready_child_can_converge_after_hydration_restores_manifest_to_pending() { +fn game_chat_initial_directors_can_converge_after_hydration_restores_manifest_to_pending() { let _config_guard = crate::tests::write_test_local_config("{}".to_string()); let (_temporary, root, parent_state, _contract) = autonomous_fixture_with_source( "创建一轮星空收集游戏", "game-chat-ready-child-hydration-parent", AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, ); + for task_id in ["design-director", "art-director", "code-director"] { + update_manifest_task_status_at(&root, task_id, GameCreationAppTaskStatus::Pending) + .unwrap_or_else(|error| panic!("restore initial {task_id} to pending: {error}")); + let record = queue_autonomous_manifest_child_fixture(&root, &parent_state, task_id); + let mut state = agent_runtime_state_from_task_record(&record); + + assert!( + autonomous_game_build_completion_blocker_at_locked(&root, &state).is_none(), + "bound game-chat initial child {task_id} must survive late hydration" + ); + state.status = "completed".to_string(); + state.phase = "completed".to_string(); + assert!( + project_autonomous_manifest_ready_task_terminal_at(&root, &state) + .unwrap_or_else(|error| panic!("project completed {task_id}: {error}")) + ); + } + let manifest = read_manifest_for_project(&root).expect("read projected game-chat manifest"); + for task_id in ["design-director", "art-director", "code-director"] { + assert_eq!( + manifest + .tasks + .iter() + .find(|task| task.id == task_id) + .map(|task| &task.status), + Some(&GameCreationAppTaskStatus::Completed) + ); + } +} + +#[test] +fn game_chat_later_code_child_rejects_pending_then_projects_verified_completion() { + let (_temporary, root, parent_state, _contract) = autonomous_fixture_with_source( + "创建一轮星空收集游戏", + "game-chat-code-pending-parent", + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + ); update_manifest_task_status_at(&root, "code-prototype", GameCreationAppTaskStatus::Pending) - .expect("restore code prototype to pending as late hydration can do"); + .expect("leave later code prototype pending"); let code_record = queue_autonomous_manifest_child_fixture(&root, &parent_state, "code-prototype"); let mut code_state = agent_runtime_state_from_task_record(&code_record); @@ -1385,10 +1446,15 @@ fn game_chat_ready_child_can_converge_after_hydration_restores_manifest_to_pendi "", ); - assert!( - autonomous_game_build_completion_blocker_at_locked(&root, &code_state).is_none(), - "the verified game-chat child owns the artifact and terminal projection will persist Completed" - ); + let blocker = autonomous_game_build_completion_blocker_at_locked(&root, &code_state) + .expect("later code child must keep the strict manifest status gate"); + assert!(blocker + .detail + .as_deref() + .is_some_and(|detail| detail.contains("status=pending"))); + + update_manifest_task_status_at(&root, "code-prototype", GameCreationAppTaskStatus::Running) + .expect("restore the later code child to its authoritative running state"); mark_verification_passed(&root, &code_state, "game.static_smoke"); code_state.status = "completed".to_string(); code_state.phase = "completed".to_string(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/response_stream.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/response_stream.rs index 209a344da..7352ad379 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/response_stream.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/response_stream.rs @@ -185,8 +185,6 @@ pub(in crate::agent) fn visible_game_creator_agent_runtime_response_stream_at( if stream.task_id != state.task_id || stream.session_id != state.session_id || stream.applied_steer_cursor != state.applied_steer_cursor - || stream.response_revision - != read_game_creator_agent_runtime_project_revision(root)?.revision || stream.request_slot != game_creator_agent_runtime_response_stream_request_slot( state, @@ -195,10 +193,14 @@ pub(in crate::agent) fn visible_game_creator_agent_runtime_response_stream_at( { return Ok(None); } + let response_revision_is_current = stream.response_revision + == read_game_creator_agent_runtime_project_revision(root)?.revision; let visible = match stream.status.as_str() { AGENT_RUNTIME_RESPONSE_STREAM_STATUS_STREAMING | AGENT_RUNTIME_RESPONSE_STREAM_STATUS_READY => { - state.status == "running" && matches!(state.phase.as_str(), "response" | "finalizing") + response_revision_is_current + && state.status == "running" + && matches!(state.phase.as_str(), "response" | "finalizing") } AGENT_RUNTIME_RESPONSE_STREAM_STATUS_COMMITTED => { matches!(state.status.as_str(), "idle" | "completed") && state.phase == "completed" diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/task_ops.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/task_ops.rs index ce731e574..e8769c440 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/task_ops.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/task_ops.rs @@ -157,7 +157,9 @@ mod tests { ) .expect("bind game-chat run"); for task_id in [ + "design-director", "art-director", + "code-director", "code-prototype", "preview-readiness", "preview-playtest", @@ -178,13 +180,13 @@ mod tests { assert!(!detail.contains("publish-package"), "{detail}"); assert!( detail.contains( - "seedTaskCounts: completed=4 running=0 pending=0 waiting=0 failed=0 total=4" + "seedTaskCounts: completed=6 running=0 pending=0 waiting=0 failed=0 total=6" ), "{detail}" ); assert!( detail - .contains("taskCounts: completed=4 running=0 pending=0 waiting=0 failed=0 total=4"), + .contains("taskCounts: completed=6 running=0 pending=0 waiting=0 failed=0 total=6"), "{detail}" ); diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/supervisor_planning.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/supervisor_planning.rs index f9b5ad9d3..641ce4acf 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/supervisor_planning.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/supervisor_planning.rs @@ -1432,10 +1432,11 @@ async fn supervisor_collaboration_partial_initial_wave_repairs_with_collaboratio &root, SupervisorCollaborationPolicy { required_initial_wave: SupervisorInitialCollaborationWave::Static, - min_static_delegates: 2, + min_static_delegates: 3, required_static_agent_ids: vec![ - "code-prototype".to_string(), - "quality-review".to_string(), + "design-director".to_string(), + "art-director".to_string(), + "code-director".to_string(), ], ..SupervisorCollaborationPolicy::default() }, @@ -1444,26 +1445,36 @@ async fn supervisor_collaboration_partial_initial_wave_repairs_with_collaboratio let (sender, receiver) = mpsc::channel(); let delegate_function = native_runtime_function_name("agent.delegate").expect("delegate function"); - let isolated_function = - native_runtime_function_name("agent.spawn_isolated").expect("isolated function"); - let code_arguments = serde_json::json!({ - "reason": "委派原型实现 Agent", + let design_arguments = serde_json::json!({ + "reason": "委派策划 Leader", "input": { - "agentId": "code-prototype", - "task": "实现可直接试玩的游戏原型", - "acceptanceCriteria": ["项目能够启动并完成最小玩法闭环"], - "expectedArtifacts": ["game/index.html"], + "agentId": "design-director", + "task": "只读拆解首轮玩法目标和专业分工,不得修改项目", + "acceptanceCriteria": ["只读给出可供后续底层 Agent 按需执行的策划规划,不得修改项目"], + "expectedArtifacts": [], "repairOfDelegationId": null, "runId": null } }) .to_string(); - let quality_arguments = serde_json::json!({ - "reason": "委派质量评审 Agent", + let art_arguments = serde_json::json!({ + "reason": "委派美术 Leader", "input": { - "agentId": "quality-review", - "task": "只读评审可玩性与闯关闭环,不要修改任何项目文件", - "acceptanceCriteria": ["只读指出阻塞试玩的具体问题并给出验收结论"], + "agentId": "art-director", + "task": "确定首轮原创视觉方向", + "acceptanceCriteria": ["视觉规范可供后续底层 Agent 按需执行"], + "expectedArtifacts": ["assets/art-spec.png"], + "repairOfDelegationId": null, + "runId": null + } + }) + .to_string(); + let code_arguments = serde_json::json!({ + "reason": "委派程序 Leader", + "input": { + "agentId": "code-director", + "task": "只读拆解首轮程序实现边界,不得修改项目", + "acceptanceCriteria": ["只读给出可供后续底层 Agent 按需执行的程序规划,不得修改项目"], "expectedArtifacts": [], "repairOfDelegationId": null, "runId": null @@ -1473,15 +1484,22 @@ async fn supervisor_collaboration_partial_initial_wave_repairs_with_collaboratio let base_url = spawn_mock_llm_raw_responses_with_capture( vec![ native_agent_tool_plan_chat_response( - "call-supervisor-initial-code-delegate", + "call-supervisor-initial-design-delegate", delegate_function.as_str(), - code_arguments, - ), - native_agent_tool_plan_chat_response( - "call-supervisor-read-only-quality-delegate", - delegate_function.as_str(), - quality_arguments, + design_arguments, ), + native_agent_tool_plan_chat_response_with_calls(vec![ + ( + "call-supervisor-art-director-delegate", + delegate_function.as_str(), + art_arguments, + ), + ( + "call-supervisor-code-director-delegate", + delegate_function.as_str(), + code_arguments, + ), + ]), ], Some(sender), ); @@ -1511,13 +1529,13 @@ async fn supervisor_collaboration_partial_initial_wave_repairs_with_collaboratio let runtime = start_game_creator_agent_runtime_task_at( &root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - "并行完成原型实现与质量评审", + "并行完成程策美 Leader 首轮规划", run_id, AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, "读取后建立首批协作", vec![ "读取必要上下文".to_string(), - "一次性委派两个专业 Agent".to_string(), + "一次性委派三个 Leader Agent".to_string(), ], ) .expect("start supervisor runtime"); @@ -1535,13 +1553,14 @@ async fn supervisor_collaboration_partial_initial_wave_repairs_with_collaboratio .await .expect("repair read-only initial collaboration plan") .expect("repaired collaboration plan"); - assert_eq!(plan.actions.len(), 2); + assert_eq!(plan.actions.len(), 3); assert!(plan .actions .iter() .all(|action| action.tool == "agent.delegate")); - assert_eq!(plan.actions[0].input["agentId"], "code-prototype"); - assert_eq!(plan.actions[1].input["agentId"], "quality-review"); + assert_eq!(plan.actions[0].input["agentId"], "design-director"); + assert_eq!(plan.actions[1].input["agentId"], "art-director"); + assert_eq!(plan.actions[2].input["agentId"], "code-director"); let initial_request = receiver .recv_timeout(Duration::from_secs(2)) @@ -1550,7 +1569,7 @@ async fn supervisor_collaboration_partial_initial_wave_repairs_with_collaboratio let repair_request = receiver .recv_timeout(Duration::from_secs(2)) .expect("supervisor read-only collaboration repair request"); - assert!(repair_request.contains("missingStaticAgents=quality-review")); + assert!(repair_request.contains("missingStaticAgents=art-director,code-director")); let repair_request_json = mock_http_request_json(&repair_request); let repair_function_names = repair_request_json["tools"] .as_array() @@ -1568,7 +1587,7 @@ async fn supervisor_collaboration_partial_initial_wave_repairs_with_collaboratio .collect::>(); assert_eq!( repair_function_names, - BTreeSet::from([delegate_function.as_str(), isolated_function.as_str()]) + BTreeSet::from([delegate_function.as_str()]) ); let delegate_schema = repair_request_json["tools"] .as_array() @@ -1590,7 +1609,7 @@ async fn supervisor_collaboration_partial_initial_wave_repairs_with_collaboratio .expect("delegate parameters"); assert_eq!( parameters["properties"]["input"]["properties"]["agentId"]["enum"], - serde_json::json!(["quality-review"]) + serde_json::json!(["art-director", "code-director"]) ); assert!(receiver.recv_timeout(Duration::from_millis(200)).is_err()); @@ -1613,7 +1632,7 @@ async fn supervisor_collaboration_partial_initial_wave_repairs_with_collaboratio }) .expect("repaired collaboration protocol audit"); assert_eq!(protocol["repairAttempt"], 1); - assert_eq!(protocol["functionCallCount"], 1); + assert_eq!(protocol["functionCallCount"], 2); let collaboration_state = read_supervisor_collaboration_state_at( &root, diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 386b6cc90..7d2652110 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -268,7 +268,9 @@ type GameChatPreviewValidationCandidate = GameChatPlayableRevision & { }; const GAME_CHAT_STAGE_TASK_IDS = [ + 'design-director', 'art-director', + 'code-director', 'code-prototype', 'preview-readiness', 'preview-playtest', @@ -5670,9 +5672,7 @@ export function App({ prompt, runtime: runtimeAtSubmission, runProfile: submissionRunProfile, - ...(gameChatOnly - ? { source: 'project-supervisor-game-chat' } - : {}), + ...(gameChatOnly ? { source: 'project-supervisor-game-chat' } : {}), }); const runtimeResult = submission.runtimeResult; const acceptedRunId = submission.acceptedRunId.trim(); diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/SupervisorChatOnlyView.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/SupervisorChatOnlyView.tsx index d3a67f345..7ebbbec4c 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/SupervisorChatOnlyView.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/SupervisorChatOnlyView.tsx @@ -58,7 +58,9 @@ export type GameChatRuntimeEvent = { const GAME_CHAT_RUNTIME_EVENT_MESSAGE_PREFIX = 'game-chat-runtime-event:'; const GAME_CHAT_FINAL_REPLY_MESSAGE_PREFIX = 'game-chat-final-reply:'; const GAME_CHAT_FINAL_REPLY_AGENT_IDS = new Set([ + 'design-director', 'art-director', + 'code-director', 'code-prototype', 'preview-readiness', 'preview-playtest', @@ -324,7 +326,9 @@ export function buildGameChatProgressEvidence( return null; } const fastPathTaskIds = new Set([ + 'design-director', 'art-director', + 'code-director', 'code-prototype', 'preview-readiness', 'preview-playtest', diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts index 4cd91dd7b..f204885be 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts @@ -133,7 +133,9 @@ function gameChatRuntimeState( } const GAME_CHAT_STAGE_TASK_IDS = [ + 'design-director', 'art-director', + 'code-director', 'code-prototype', 'preview-readiness', 'preview-playtest', @@ -2845,8 +2847,10 @@ export function registerProjectSupervisorSurfaceTests() { updatedAt: 200 + responseRevision, }); const messages = gameChatFinalReplyMessages([ + makeStream('design-director', 'committed', '玩法方向已完成'), makeStream('art-director', 'ready', '视觉方向已完成'), makeStream('art-asset-plan', 'committed', '平台美术图集已生成并登记'), + makeStream('code-director', 'ready', '程序方案已完成'), makeStream('code-prototype', 'ready', '代码原型已完成'), makeStream('preview-readiness', 'committed', '预览就绪检查已完成'), makeStream('preview-playtest', 'ready', '试玩验证已完成'), @@ -2855,21 +2859,27 @@ export function registerProjectSupervisorSurfaceTests() { requestKind: 'tool-plan', }, ]); - expect(messages).toHaveLength(4); + expect(messages).toHaveLength(6); expect(messages.map((message) => message.text)).toEqual([ + expect.stringContaining('玩法方向已完成'), expect.stringContaining('视觉方向已完成'), + expect.stringContaining('程序方案已完成'), expect.stringContaining('代码原型已完成'), expect.stringContaining('预览就绪检查已完成'), expect.stringContaining('试玩验证已完成'), ]); - expect(messages[0]?.messageId).toContain('art-director'); + expect(messages[0]?.messageId).toContain('design-director'); expect(messages[0]?.messageId).toContain('game-chat-final-reply:'); expect(messages.every((message) => message.agentId)).toBe(true); const hydrated = mergeGameChatFinalReplyMessagesIntoHistory( [messages[0]!], messages, ); - expect(hydrated.filter((message) => message.messageId === messages[0]?.messageId)).toHaveLength(1); + expect( + hydrated.filter( + (message) => message.messageId === messages[0]?.messageId, + ), + ).toHaveLength(1); }); it('persists professional final-reply streams with stable ids and does not duplicate them after hydration', async () => { @@ -2924,6 +2934,7 @@ export function registerProjectSupervisorSurfaceTests() { }; }; const professionalResults = [ + makeRuntimeResult('design-director', 'committed', '玩法方向已完成', 170), makeRuntimeResult('art-director', 'ready', '视觉方向已完成', 180), makeRuntimeResult( 'art-asset-plan', @@ -2931,8 +2942,14 @@ export function registerProjectSupervisorSurfaceTests() { '平台美术图集已生成并登记', 190, ), + makeRuntimeResult('code-director', 'ready', '程序方案已完成', 195), makeRuntimeResult('code-prototype', 'ready', '代码原型已完成', 200), - makeRuntimeResult('preview-readiness', 'committed', '预览就绪已完成', 210), + makeRuntimeResult( + 'preview-readiness', + 'committed', + '预览就绪已完成', + 210, + ), makeRuntimeResult('preview-playtest', 'ready', '试玩验证已完成', 220), ]; const harness = createProjectSupervisorRuntimeHarness({ @@ -2985,14 +3002,14 @@ export function registerProjectSupervisorSurfaceTests() { String(args?.messageId ?? '').startsWith('game-chat-final-reply:'), ); await waitFor(() => { - expect(finalReplyAppends()).toHaveLength(4); + expect(finalReplyAppends()).toHaveLength(6); }); rendered.unmount(); rendered = renderRelease(); await waitFor(() => { expect(screen.getByText(/代码原型已完成/)).not.toBeNull(); }); - expect(finalReplyAppends()).toHaveLength(4); + expect(finalReplyAppends()).toHaveLength(6); rendered.unmount(); }); @@ -3077,10 +3094,7 @@ export function registerProjectSupervisorSurfaceTests() { ([command, args]) => command === 'append_local_conversation_message' && args?.agentId === null && - String( - args?.messageId ?? - '', - ).startsWith('game-chat-runtime-event:'), + String(args?.messageId ?? '').startsWith('game-chat-runtime-event:'), ); await waitFor(() => { @@ -3558,7 +3572,7 @@ export function registerProjectSupervisorSurfaceTests() { expect(statusCard.textContent).toContain('生成 Agent 工具计划(本轮)'); }); - it('counts only the four first-playable fast-path tasks in game-chat progress', () => { + it('counts only the six first-playable tasks in game-chat progress', () => { const manifest = createGameCreationAppManifest( 'game-chat-progress-total', 'game-chat-progress-total', @@ -3578,7 +3592,7 @@ export function registerProjectSupervisorSurfaceTests() { renderGameChatStatus({ runtime, manifest }); const progress = screen.getByLabelText('Supervisor 进度播报'); - expect(progress.textContent).toContain('任务图 4/4'); + expect(progress.textContent).toContain('任务图 6/6'); expect(progress.textContent).not.toContain('publish-strategy'); expect(progress.textContent).not.toContain('publish-package'); }); @@ -3665,8 +3679,10 @@ export function registerProjectSupervisorSurfaceTests() { 'game-chat-progress-broadcast', 'game-chat-progress-broadcast', ); - manifest.tasks = manifest.tasks.map((task, index) => { - if (index < 3) { + manifest.tasks = manifest.tasks.map((task) => { + if ( + ['design-director', 'art-director', 'code-director'].includes(task.id) + ) { return { ...task, status: 'completed' as const }; } if (task.id === 'code-prototype') { @@ -3756,16 +3772,12 @@ export function registerProjectSupervisorSurfaceTests() { expect(within(progress).getByText('本轮生成进度')).not.toBeNull(); expect(within(progress).queryByText(/第 4 轮/u)).toBeNull(); expect( - within(progress).getByText( - '任务图 1/4 · 进行中 1 · 计划 1/3', - ), + within(progress).getByText('任务图 3/6 · 进行中 1 · 计划 1/3'), ).not.toBeNull(); expect(within(progress).getByText('核对首版试玩诊断')).not.toBeNull(); expect(within(progress).getByText('活跃专业 Agent')).not.toBeNull(); expect( - within(progress).getByText( - '程序原型 Agent · 修复角色碰撞与重开逻辑', - ), + within(progress).getByText('程序原型 Agent · 修复角色碰撞与重开逻辑'), ).not.toBeNull(); expect(within(progress).getByText('试玩未通过')).not.toBeNull(); expect( @@ -3778,10 +3790,7 @@ export function registerProjectSupervisorSurfaceTests() { ([command, args]) => command === 'append_local_conversation_message' && args?.agentId === null && - String( - args?.messageId ?? - '', - ).startsWith('game-chat-runtime-event:'), + String(args?.messageId ?? '').startsWith('game-chat-runtime-event:'), ); await waitFor(() => { expect(runtimeEventAppends()).toHaveLength(1); @@ -3829,14 +3838,10 @@ export function registerProjectSupervisorSurfaceTests() { }); await waitFor(() => { - expect( - within(progress).getByText('本轮生成进度'), - ).not.toBeNull(); + expect(within(progress).getByText('本轮生成进度')).not.toBeNull(); expect(within(progress).queryByText(/第 5 轮/u)).toBeNull(); expect( - within(progress).getByText( - '任务图 1/4 · 进行中 1 · 计划 2/3', - ), + within(progress).getByText('任务图 3/6 · 进行中 1 · 计划 2/3'), ).not.toBeNull(); expect(within(progress).getByText('安排程序 Agent 返工')).not.toBeNull(); expect(within(progress).getByText('返工决定')).not.toBeNull(); @@ -4300,7 +4305,9 @@ export function registerProjectSupervisorSurfaceTests() { task.id === 'preview-playtest' ? { ...task, status: 'failed' as const } : [ + 'design-director', 'art-director', + 'code-director', 'code-prototype', 'preview-readiness', ].includes(task.id) @@ -4380,7 +4387,8 @@ export function registerProjectSupervisorSurfaceTests() { await waitFor(() => { expect( invoke.mock.calls.some( - ([command]) => command === 'start_game_creator_supervisor_runtime_task', + ([command]) => + command === 'start_game_creator_supervisor_runtime_task', ), ).toBe(true); }); @@ -4440,7 +4448,7 @@ export function registerProjectSupervisorSurfaceTests() { (stageRecordAppends()[0]?.[1] as { message?: { content?: string } }) ?.message?.content ?? '', ); - expect(stageRecord).toContain('3/4'); + expect(stageRecord).toContain('5/6'); }); it('archives a terminal game-chat run restored during initial hydration exactly once', async () => { @@ -4542,7 +4550,9 @@ export function registerProjectSupervisorSurfaceTests() { fireEvent.change(screen.getByRole('textbox'), { target: { value: '/tasks' }, }); - fireEvent.submit((screen.getByRole('textbox') as HTMLTextAreaElement).form!); + fireEvent.submit( + (screen.getByRole('textbox') as HTMLTextAreaElement).form!, + ); await waitFor(() => { expect( invoke.mock.calls.some( @@ -5229,13 +5239,16 @@ export function registerProjectSupervisorSurfaceTests() { const previewStatusReads = driver.invoke.mock.calls.filter( ([command]) => command === 'get_local_game_preview_status', ).length; - await waitFor(() => { - expect( - driver.invoke.mock.calls.filter( - ([command]) => command === 'get_local_game_preview_status', - ).length, - ).toBeGreaterThan(previewStatusReads); - }, { timeout: 3000 }); + await waitFor( + () => { + expect( + driver.invoke.mock.calls.filter( + ([command]) => command === 'get_local_game_preview_status', + ).length, + ).toBeGreaterThan(previewStatusReads); + }, + { timeout: 3000 }, + ); expect(driver.readAuthorization()?.authorizationId).toBe( steerAuthorizationId, ); diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/assert-planning-and-status-shortcuts.ts b/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/assert-planning-and-status-shortcuts.ts index fb7760e84..d1d8fd730 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/assert-planning-and-status-shortcuts.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/assert-planning-and-status-shortcuts.ts @@ -19,7 +19,7 @@ export async function assertPlanningAndStatusShortcutFlow( screen.getByText(/最近 run 已通过,但当前本地预览未运行。 建议:\/run/), ).not.toBeNull(); expect( - screen.getByText(/还有 1 个 ready 任务等待处理。 建议:\/tasks/), + screen.getByText(/还有 3 个 ready 任务等待处理。 建议:\/tasks/), ).not.toBeNull(); fireEvent.click(screen.getByRole('button', { name: '处理首个风险' })); expect(screen.getByLabelText('创作想法')).toHaveProperty('value', '/run'); @@ -382,13 +382,13 @@ export async function assertPlanningAndStatusShortcutFlow( 'Run:run-main-shortcut-trace · passed / done · 1/3 轮 · evaluator-passed', ); expect(dependencyMessage.textContent).toContain( - '状态:active 0 / carry 0 / ready 1 / 等待依赖 15', + '状态:active 0 / carry 0 / ready 1 / 等待依赖 13', ); expect(dependencyMessage.textContent).toContain( '美术组 / Asset 生成首版美术素材(art-asset-plan) · 等待:美术组 / Director 确定视觉方向与规范图(art-director);策划组 / Gameplay 确定玩法规格与界面原型(design-foundation)', ); expect(dependencyMessage.textContent).toContain( - '美术组 / Director 确定视觉方向与规范图(art-director) · 等待:策划组 / Director 拆解创作方向(design-director)', + '策划组 / Gameplay 确定玩法规格与界面原型(design-foundation) · 等待:策划组 / Director 拆解创作方向(design-director);美术组 / Director 确定视觉方向与规范图(art-director)', ); expect(dependencyMessage.textContent).toContain( '边界:只整理任务依赖;不读取任务文件;不启动 run;不修改项目', @@ -928,7 +928,7 @@ export async function assertPlanningAndStatusShortcutFlow( 'Run:run-main-shortcut-trace · passed / done · 1/3 轮 · evaluator-passed', ); expect(qaMessage.textContent).toContain('Evaluator:通过 · 质量评审通过'); - expect(qaMessage.textContent).toContain('任务:完成 0/16 · ready 1 · 失败 0'); + expect(qaMessage.textContent).toContain('任务:完成 0/16 · ready 3 · 失败 0'); expect(qaMessage.textContent).toContain('静态自检:通过'); expect(qaMessage.textContent).toContain('试玩:待启动预览'); expect(qaMessage.textContent).toContain('产物:3 个'); diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/assert-project-and-design-shortcuts.ts b/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/assert-project-and-design-shortcuts.ts index a57bb7a96..e8ff42b86 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/assert-project-and-design-shortcuts.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/assert-project-and-design-shortcuts.ts @@ -107,7 +107,7 @@ export async function assertProjectAndDesignShortcutFlow( ).length; submitChat('/brief'); expect(await screen.findByText(/项目简报:/)).not.toBeNull(); - expect(screen.getByText(/任务:完成 0\/16 · ready 1/)).not.toBeNull(); + expect(screen.getByText(/任务:完成 0\/16 · ready 3/)).not.toBeNull(); expect(screen.getByText(/最近 Run:run-main-shortcut-trace/)).not.toBeNull(); fireEvent.click(screen.getByRole('button', { name: '查看下一步' })); expect(screen.getByLabelText('创作想法')).toHaveProperty('value', '/next'); @@ -297,7 +297,7 @@ export async function assertProjectAndDesignShortcutFlow( '当前状态:最近 run run-main-shortcut-trace · passed / done · 1/3 轮 · evaluator-passed', ); expect(mvpMessage.textContent).toContain( - '任务:完成 0/16 · ready 1 · 失败 0', + '任务:完成 0/16 · ready 3 · 失败 0', ); expect(mvpMessage.textContent).toContain('预览:未启动'); expect(mvpMessage.textContent).toContain('资产:2 个'); 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 index 0a6b36918..50ea09925 100644 --- a/apps/ai-game-creator-shell/tests/start-dev-stack.test.ts +++ b/apps/ai-game-creator-shell/tests/start-dev-stack.test.ts @@ -7,9 +7,12 @@ import { describe, expect, test, vi } from 'vitest'; import { ensureBackend, + preflightExistingVite, resolveBackendTargetsFromState, + runWindowsTaskkill, spawnChild, stopChild, + terminateChildTree, waitForChildTermination, } from '../scripts/start-dev-stack.mjs'; @@ -175,4 +178,100 @@ describe('AI 游戏创作启动子进程生命周期', () => { expect(child.kill).toHaveBeenCalledWith('SIGTERM'); }); + + test('Windows 通过 taskkill 收束 Tauri CLI 进程树', async () => { + const child = Object.assign(new EventEmitter(), { + pid: 4821, + exitCode: 1, + signalCode: null, + kill: vi.fn(), + }); + const taskkillImpl = vi.fn(async () => ({ + timedOut: false, + code: 0, + error: null, + })); + + const result = await terminateChildTree(child, { + platform: 'win32', + taskkillImpl, + }); + + expect(taskkillImpl).toHaveBeenCalledWith(4821); + expect(result).toMatchObject({ stopped: true, forced: true }); + }); + + test('Windows taskkill 固定携带 PID、整树和强制参数', async () => { + const taskkill = Object.assign(new EventEmitter(), { + kill: vi.fn(), + }); + const spawnImpl = vi.fn(() => { + queueMicrotask(() => taskkill.emit('exit', 0)); + return taskkill; + }); + + await expect( + runWindowsTaskkill(4821, { spawnImpl, timeoutMs: 100 }), + ).resolves.toMatchObject({ timedOut: false, code: 0, error: null }); + expect(spawnImpl).toHaveBeenCalledWith( + 'taskkill.exe', + ['/PID', '4821', '/T', '/F'], + expect.objectContaining({ shell: false, windowsHide: true }), + ); + }); +}); + +describe('AI 游戏创作 3080 启动前预检', () => { + const agcHtml = { + statusCode: 200, + body: 'AI 游戏创作', + }; + + test('旧 Vite marker 指向其它 API 时在启动后端前失败', async () => { + await expect( + preflightExistingVite({ + readServer: async () => agcHtml, + portListening: async () => true, + readMarker: async () => ({ + schemaVersion: 1, + app: 'ai-game-creator-shell', + apiTarget: 'http://127.0.0.1:10001', + }), + }), + ).rejects.toThrow( + 'API target http://127.0.0.1:10001. Its owning worktree cannot be proven', + ); + }); + + test('marker target 看似匹配时仍拒绝复用无法证明归属的 Vite', async () => { + await expect( + preflightExistingVite({ + readServer: async () => agcHtml, + portListening: async () => true, + readMarker: async () => ({ + schemaVersion: 1, + app: 'ai-game-creator-shell', + apiTarget: 'http://127.0.0.1:10004', + }), + }), + ).rejects.toThrow('Its owning worktree cannot be proven'); + }); + + test('HTTP 探测无响应但端口已监听时失败关闭', async () => { + await expect( + preflightExistingVite({ + readServer: async () => null, + portListening: async () => true, + }), + ).rejects.toThrow('non-HTTP or unrecognized server'); + }); + + test('3080 未监听时允许继续启动', async () => { + await expect( + preflightExistingVite({ + readServer: async () => null, + portListening: async () => false, + }), + ).resolves.toEqual({ status: 'available', apiTarget: '' }); + }); }); diff --git a/apps/ai-game-creator-shell/tests/start-tauri-dev.test.ts b/apps/ai-game-creator-shell/tests/start-tauri-dev.test.ts new file mode 100644 index 000000000..d5e73f03b --- /dev/null +++ b/apps/ai-game-creator-shell/tests/start-tauri-dev.test.ts @@ -0,0 +1,215 @@ +import { EventEmitter } from 'node:events'; +import { existsSync, mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { describe, expect, test, vi } from 'vitest'; + +import { spawnChild, terminateChildTree } from '../scripts/start-dev-stack.mjs'; +import { + buildTauriArguments, + runTauriDev, +} from '../scripts/start-tauri-dev.mjs'; + +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 游戏创作 Tauri dev 启动参数', () => { + test('普通 dev 参数原样交给 Tauri CLI', () => { + expect(buildTauriArguments(['--no-watch'])).toEqual(['dev', '--no-watch']); + }); + + test('game-chat 参数进入应用参数区且保留项目参数', () => { + expect( + buildTauriArguments([ + '--game-chat', + '--project-path', + '/tmp/example-game', + ]), + ).toEqual([ + 'dev', + '--', + '--', + '--game-chat', + '--project-path', + '/tmp/example-game', + ]); + }); +}); + +describe('AI 游戏创作 Tauri dev 生命周期', () => { + test('3080 预检失败时不启动 Tauri CLI', async () => { + const spawnCli = vi.fn(); + + await expect( + runTauriDev([], { + preflight: async () => { + throw new Error('stale 3080'); + }, + spawnCli, + }), + ).rejects.toThrow('stale 3080'); + + expect(spawnCli).not.toHaveBeenCalled(); + }); + + test('预检先于 CLI 启动且 CLI 退出后始终清理进程树', async () => { + const order: string[] = []; + const child = Object.assign(new EventEmitter(), { + pid: 1234, + exitCode: 1, + signalCode: null, + kill: vi.fn(), + }); + const result = await runTauriDev([], { + preflight: async () => { + order.push('preflight'); + }, + spawnCli: () => { + order.push('spawn'); + return child; + }, + waitForCli: async () => { + order.push('exit'); + return { type: 'exit', code: 1, signal: null }; + }, + terminateTree: async (receivedChild) => { + expect(receivedChild).toBe(child); + order.push('cleanup'); + return { stopped: true, forced: false }; + }, + }); + + expect(result).toBe(1); + expect(order).toEqual(['preflight', 'spawn', 'exit', 'cleanup']); + }); + + const posixTest = process.platform === 'win32' ? test.skip : test; + + posixTest('Tauri CLI leader 先退出后仍收束同 PGID 的客户端后代', async () => { + const tempDir = mkdtempSync(join(tmpdir(), 'agc-tauri-tree-')); + const readyPath = join(tempDir, 'client-ready'); + const stoppedPath = join(tempDir, 'client-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 { existsSync } = require('node:fs'); + 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(); + const timer = setInterval(() => { + if (existsSync(readyPath)) { + clearInterval(timer); + process.exit(42); + } + }, 10); + `; + let cliChild; + try { + const result = await runTauriDev([], { + preflight: async () => {}, + spawnCli: () => { + cliChild = spawnChild( + process.execPath, + ['-e', leaderSource, readyPath, stoppedPath, descendantSource], + { cwd: process.cwd() }, + ); + return cliChild; + }, + }); + + expect(result).toBe(42); + await waitForFile(stoppedPath); + } finally { + if (Number.isInteger(cliChild?.pid)) { + try { + process.kill(-cliChild.pid, 'SIGKILL'); + } catch { + // 进程组已经由启动器收束。 + } + } + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + posixTest('客户端后代忽略 TERM 时在有界宽限后升级 KILL', async () => { + const tempDir = mkdtempSync(join(tmpdir(), 'agc-tauri-force-tree-')); + const readyPath = join(tempDir, 'client-ready'); + const descendantSource = ` + const { writeFileSync } = require('node:fs'); + const [readyPath] = process.argv.slice(1); + process.on('SIGTERM', () => {}); + writeFileSync(readyPath, 'ready'); + setInterval(() => {}, 1000); + `; + const leaderSource = ` + const { existsSync } = require('node:fs'); + const { spawn } = require('node:child_process'); + const [readyPath, descendantSource] = process.argv.slice(1); + const descendant = spawn( + process.execPath, + ['-e', descendantSource, readyPath], + { stdio: 'ignore' }, + ); + descendant.unref(); + const timer = setInterval(() => { + if (existsSync(readyPath)) { + clearInterval(timer); + process.exit(42); + } + }, 10); + `; + let cliChild; + try { + const result = await runTauriDev([], { + preflight: async () => {}, + spawnCli: () => { + cliChild = spawnChild( + process.execPath, + ['-e', leaderSource, readyPath, descendantSource], + { cwd: process.cwd() }, + ); + return cliChild; + }, + terminateTree: (child) => + terminateChildTree(child, { + gracefulTimeoutMs: 50, + forceTimeoutMs: 2000, + }), + }); + + expect(result).toBe(42); + expect(() => process.kill(-cliChild.pid, 0)).toThrow(); + } finally { + if (Number.isInteger(cliChild?.pid)) { + try { + process.kill(-cliChild.pid, 'SIGKILL'); + } catch { + // 进程组已经由启动器强制收束。 + } + } + rmSync(tempDir, { recursive: true, force: true }); + } + }); +}); diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index d23cf419a..0975b8153 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -5899,3 +5899,12 @@ - Runtime 恢复确认:GUI 自动扫描 `agent.resume` 前必须先用只读方式判断是否存在可恢复任务或 durable recovery artifact;全新项目与已完全终态、无任何恢复工作的项目直接返回空结果,不弹出“恢复未完成 Runtime 任务”;一旦存在 task、retry、handoff、finalization、pending action 或 reconciliation 等可恢复工作,仍必须经过原 `agent.resume` policy 门禁,不得通过吞掉 policy error 绕过确认。 - 每条输出入聊天:事件文件中的原始 `summary / detail` 仍是私有 Runtime 证据,不可由前端直接持久化。Rust 只对白名单用户进度生成 `publicText`,同时为每次真实追加生成 `eventId`;action 重放沿用 action 身份,普通事件使用进程、毫秒与单调序列组成唯一身份。前端把 `eventId + publicText` 和四阶段专业 Agent 的 durable final reply 作为独立 assistant 消息,按顶层 `messageId` 幂等写入项目 conversation;重载恢复、轮询与实时事件并发不得重复或漏掉当前已观察输出。 - 关联文档:`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`、`docs/project-memory/shared-memory/development-workflow.md`。 + +## 2026-08-03 game-chat 开发态前后端同源与快车道恢复 + +- 启动决策:`npm run agc` 与 `npm run agc:game-chat` 统一先经外层 Node 启动器预检 `3080`。在 marker 尚不能证明 worktree 归属时,任何已占用的 3080 都不得复用,并必须在原生窗口创建前失败关闭;Tauri CLI 退出后必须收束已启动的客户端进程树,不允许终端已退出但窗口与 Runner 仍假在线。 +- 首波决策:普通 GUI / CLI 的正式 16 节点 DAG 与 game-chat 首版 lane 都只把 `design-director / art-director / code-director` 作为首波 ready Agent。正式 DAG 的 `design-foundation` 等待策划与美术 Director,`code-prototype` 等待程序 Director 及数值、美术、音频三条底层产物链;game-chat 则在三个 Director 全部完成后才启动 `code-prototype`,再串行执行静态检查和试玩。首波以外的非 repair 底层 Agent 只能由依赖就绪调度或上层明确返工合同按需激活。 +- Runtime 决策:game-chat 六任务 lane、平台美术、单轮收束和自动预览只由持久 `project-supervisor-game-chat` root source 启用。hydration 对 `Pending` 的容忍只适用于当前 source-aware lane 的三个零依赖首波任务,不再硬编码单个历史任务。页面进度、阶段记录和 final-reply 白名单统一使用六项任务与 `x/6`。 +- 显式协作合同:autonomous 的旧 `code-prototype + quality-review` 首批合同退出。显式 project collaboration policy 或持久 batch 恢复若进入首批 `agent.delegate` 路径,只允许且要求三个 Director 各一次;策划与程序 Director 是只读规划且 `expectedArtifacts=[]`,美术 Director 是非只读规范图任务且必须交付 `assets/art-spec.png`。任何非 repair 底层委派与 isolated child 都在首批失败关闭;默认 manifest DAG 仍是唯一自动首轮执行链,不额外复制三个 Director 委派。 +- 输出决策:保留未提交 `streaming / ready` 的当前 revision 门;已提交的专业 Agent final reply 继续使用既有 durable response-stream 身份,后续项目 revision 变化不再隐藏早期阶段回复。 +- 关联:`apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs`、`start-dev-stack.mjs`、`src-tauri/src/agent/runtime_protocol/autonomous_completion.rs`、`response_stream.rs`、`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index fe9792898..aa8b12b47 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -3998,3 +3998,19 @@ - 处理:改外部 v1 响应前先确认 `external_api_key` 是否已有非内部账号的活跃密钥。仍无调用方时可按现行豁免直接改,但必须同步更新接入方案的「版本与兼容策略」;已有调用方时按该节规则择一处理(兼容值 / 弃用期 / 升 v2),只改 JSON 不构成合规变更。 - 验证:`external_editor_api.rs` 的 openapi 断言只校验 schema 形状,不校验兼容性,通过不等于契约安全;判定 breaking 与否以「删字段、移出 required、收窄类型、改语义、新增必填」为准。 - 关联:`docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md`、`docs/openapi/genarrative-external-v1.openapi.json`、`server-rs/crates/api-server/src/external_editor_api.rs`、`server-rs/crates/api-server/src/modules/external_api.rs`。 + +## Tauri beforeDevCommand 失败不等于已启动客户端会自动退出(2026-08-03) + +- 现象:旧 worktree 的 AGC Vite 长期占用 `127.0.0.1:3080`,marker 仍指向旧 API;新 worktree 启动 game-chat 后,配套后端在新端口 ready,随后 `beforeDevCommand` 因代理 target 不匹配返回非零,终端已经回到提示符,但原生客户端和它启动的 Runner 仍存活。客户端 WebView 实际加载旧 Vite,因此当前 master 的界面优化看起来全部缺失。 +- 原因:Tauri 的字符串 `beforeDevCommand` 默认 `wait=false`。只要固定 `devUrl` 上已有可访问页面,Tauri CLI 可以在配套启动脚本完成前创建原生窗口;旧实现又直接从 npm 启动 Tauri CLI,没有在 CLI leader 退出后继续持有其 PGID / Windows 进程树。`start-dev-stack.mjs` 虽会在后端 ready 后识别 marker/API 错配,但检查时机已经晚于窗口创建,且只清理自己登记的后端和 Vite。 +- 处理:`dev` 与 `game-chat` 统一先进入 `start-tauri-dev.mjs`,在启动 Tauri CLI 前无副作用检查 3080。现有 marker 只有 API target,不能证明监听器属于当前 worktree,因此任何已存在的 3080 都失败关闭,不主动杀不能证明归属的旧服务,也不因 target 看似匹配而复用。Tauri CLI 使用独立 POSIX 进程组,任意退出后按负 PGID 先 TERM、有界等待、再 KILL;Windows 固定调用 `taskkill /PID /T /F`。`start-dev-stack.mjs` 自己的后端 / Vite 独立组也在返回前有界收束。 +- 验证:定向测试必须覆盖旧 marker target 在 CLI spawn 前被拒绝、target 看似匹配仍拒绝无归属 Vite、非 HTTP 3080 失败、预检调用顺序、CLI leader 先退出后同 PGID 客户端仍收到 TERM、忽略 TERM 时升级 KILL,以及 Windows taskkill 的 `/PID /T /F` 参数。人工复验旧 worktree 占用 3080 时,新命令不得启动后端或弹出新窗口;正常启动后退出,确认 Tauri 客户端、Runner 和本轮自有后端 / Vite 均按生命周期收束。 +- 关联:`apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs`、`apps/ai-game-creator-shell/scripts/start-dev-stack.mjs`、`apps/ai-game-creator-shell/tests/start-tauri-dev.test.ts`、`apps/ai-game-creator-shell/tests/start-dev-stack.test.ts`。 + +## game-chat 快车道首波与已提交回复不能被后续 revision 破坏(2026-08-03) + +- 现象:首波从单个美术任务扩展为三个 Director 后,hydration 若仍只容忍 seed lane 的第一个任务在 manifest 短暂恢复 `Pending` 时收束,另外两个已启动 Director 会被卡住。另外默认 `llm.stream=false` 下的专业 final reply 虽已由 finalization 提交,但后续阶段推进项目 revision 后,早期回复会从 Runtime 查询中消失。 +- 原因:hydration 例外把“首波”错误收窄成了单个固定或数组第一项任务;`visible_game_creator_agent_runtime_response_stream_at` 又把未提交流的 revision 新鲜度门误用到了已终态提交的 durable final reply。 +- 处理:从当前 root source 的 seed lane 动态解析全部零依赖首波任务,只对这些 child 容忍 hydration `Pending`,后续 code prototype / preview 仍严格要求 Running/Completed。`streaming / ready` 仍要求当前 revision,`committed` 回复改为依据 finalization 的稳定身份查询,不随后续项目 revision 失效。 +- 验证:覆盖 `design-director / art-director / code-director` 三个 Pending 首波 child 均可投影 Completed、`code-prototype` Pending 仍被拒绝;非流式专业 Agent 在 finalization 前无 stream,提交后形成 committed stream,再推进项目 revision 后仍可查询且正文不变。 +- 关联:`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/response_stream.rs`。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 3abf6cb6c..3fd742e30 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -34,7 +34,7 @@ - 对话与事件:窗口固定使用 `project-supervisor + autonomous-game-build`,继续复用 active Session、External Runner、持久 conversation、流式回复、same-run steer、工具确认与用户追问。以 `/` 开头的输入必须继续走现有内置命令解析,例如 `/preview` 只能生成 `preview.start` 确认卡,不得作为自主构建任务投递给 Supervisor。界面聚合当前 Supervisor 父 run 及其直接委派专业 Agent 的最新原始事件,按时间倒序稳定去重并标注 Agent;默认显示 4 条,可展开至最新 20 条。原始 `summary / detail` 仍只作 Runtime 状态投影,不直接写入 conversation。需要进入聊天的事件必须由 Rust 同步生成唯一 `eventId` 与安全 `publicText`;前端只按这两个字段形成独立 assistant 消息,无 `eventId`、空 `publicText`、legacy 事件和内部 tool / Provider / Runner 协议一律忽略。 - Supervisor 进度播报:聊天消息流内保留且只保留一条当前 run 的 Runtime-owned 播报卡,由客户端从 manifest 任务图、Supervisor 结构化计划、`loopIteration`、当前动作、直接委派专业 Agent 及其持久事件确定性整理;显示当前轮次、任务 / 计划进度、活跃 Agent、最近试玩与静态检查、返工决定、代码修改和截图检查证据。同一 run 原位更新,切换 run 时替换,不调用额外模型、不追加持久 conversation,也不改变最终 assistant 回复的唯一性;任意详情必须有界且不展示绝对路径、Provider 元数据或内部指纹。 - Provider 故障展示:Provider retry 的“是否可重试”继续使用 `upstream-5xx` 等稳定类别判断,但 durable retry record 保留安全的精确 `upstream-` 身份。等待态必须从真实 record 显示 HTTP 状态、`nextAttempt/maxRetries` 与当前持久退避剩余秒数,例如“Provider 上游返回 HTTP 503,准备自动重试 1/3;预计 8 秒后重试”;不得以动画或前端自增计时伪造 attempt。重试耗尽的 Runtime 私有错误只保存 `kind/httpStatus/fingerprint/chars/retryAttempt/maxRetries/retryState`,前端和持久 conversation 仅在字段顺序、范围、状态一致且无尾随正文时派生“上游服务返回 HTTP 503;自动重试已耗尽(3/3)”;其它错误使用固定安全摘要。Provider 响应正文、URL/query、凭据、本地绝对路径、fingerprint、字符数和 `[redacted ...]` 占位符均不得进入用户可见消息。 -- 跨轮阶段记录:game-chat 父 run 进入真实 completed / failed / cancelled 终态后,客户端等待 `art-director / code-prototype / preview-readiness / preview-playtest` 四项快车道任务也全部投影到 completed / failed 终态,再把本轮、任务 / 计划完成度、最新试玩 / 静态检查、最近返工决定和已登记成果图片路径整理成一条 `【Supervisor 阶段记录】` 项目 assistant 消息。`art-asset-plan` 不属于五分钟首版阶段,不阻塞阶段记录。父 run 先终态而 manifest 仍在 hydration 时不得用陈旧 `0/4` 提前归档,要暂存终态 Runtime 并在 manifest 刷新后重试。页面初始 hydration 若直接读到缺少阶段记录的真实终态 run,也必须补写,但 `idle` 不是可归档终态。每个“项目 + 父 run”最多追加一次,进入现有 `conversation.write` 权限与项目 conversation 持久化链路,下一轮及重载后继续保留。阶段记录不是 Supervisor Runtime 正式回复,不写入 Agent Session、不增加 final assistant 数量,也不逐条复制原始事件或内部正文。 +- 跨轮阶段记录:game-chat 父 run 进入真实 completed / failed / cancelled 终态后,客户端等待 `design-director / art-director / code-director / code-prototype / preview-readiness / preview-playtest` 六项首版任务也全部投影到 completed / failed 终态,再把本轮、任务 / 计划完成度、最新试玩 / 静态检查、最近返工决定和已登记成果图片路径整理成一条 `【Supervisor 阶段记录】` 项目 assistant 消息。`art-asset-plan` 不属于五分钟首版阶段,不阻塞阶段记录。父 run 先终态而 manifest 仍在 hydration 时不得用陈旧 `0/6` 提前归档,要暂存终态 Runtime 并在 manifest 刷新后重试。页面初始 hydration 若直接读到缺少阶段记录的真实终态 run,也必须补写,但 `idle` 不是可归档终态。每个“项目 + 父 run”最多追加一次,进入现有 `conversation.write` 权限与项目 conversation 持久化链路,下一轮及重载后继续保留。阶段记录不是 Supervisor Runtime 正式回复,不写入 Agent Session、不增加 final assistant 数量,也不逐条复制原始事件或内部正文。 - 图片成果:当前 manifest 新增或恢复已登记的 PNG / JPEG / WebP 资源时,聊天消息流同步显示 Runtime-owned “Supervisor 成果图片”卡,最多展示最新 4 张并随 manifest 原位更新。图片必须通过现有 `read_local_project_image_preview` 读取,只允许当前授权项目中 `assets/` 下的已登记资源,继续执行 `file.read` auto 权限、真实格式、大小、尺寸、普通文件、祖先目录和项目根边界校验;前端只接受返回路径、媒体类型和 `data:` 前缀与请求完全一致的结果。缩略图点击后使用独立模态查看器,支持按钮与滚轮缩放、指针拖拽、双击 / 按钮复位、Esc / 按钮 / 遮罩关闭,移动端占满视口;不得在聊天卡下方追加展开区。图片卡不写入 conversation,不解析 assistant 文本中的任意 Markdown / 绝对路径,也不开放 `.agent` 验收截图读取。 - Run 接管:External Runner 模式下首次提交可能返回“旧 canonical state + 新 `acceptedRunId`”;页面必须以 `acceptedRunId` 作为本轮权威身份,在 state 尚未切换时显示“已投递,正在同步 Agent Runner”,并允许该 run 的 Tauri event 或轮询结果接管。不得把旧 idle state 当作本轮结果、过滤新 run 事件,自动预览授权也必须绑定 `acceptedRunId`。 - 运行容器:当前项目没有由 Tauri 客户端 `PreviewRegistry` 返回的有效 `running` 预览时,页面只渲染聊天,不显示游戏区域或占位文案,顶部运行状态必须明确显示“预览未启动”,不得再使用含义不明的“未启动”;预览运行后自动显示 iframe,桌面端按“游戏 2 / 聊天 1”分栏,移动端改为上下布局。预览停止、失败或切换项目后立即移除 iframe。运行容器继续只接受当前授权项目的 `http://127.0.0.1:*`,复用现有 CSP、iframe sandbox、autoplay、fullscreen 和 gamepad 约束;远程 URL、`file://`、手填地址或陈旧 manifest 状态均不得显示。 @@ -52,20 +52,27 @@ ## 2026-07-31 game-chat 输出、单轮预览与平台美术资源 -- 对话输出:game-chat 的 Supervisor `ready` response stream 继续以稳定身份显示;`art-director / code-prototype / preview-readiness / preview-playtest` 的 `requestKind=final-reply` 且 `status=ready|committed` 的非空安全回复也分别以 Agent、Session、run、request slot 和 response revision 形成 durable message ID,并带 Agent 标签逐条追加到项目聊天;`art-asset-plan` 的回复不进入 game-chat 项目聊天。每条 Rust `eventId + publicText` 公开输出同样形成独立 durable 消息。所有这些消息通过 `append_local_conversation_message` 的顶层 `messageId` 幂等写入,事件、轮询、React StrictMode 和 hydration 重放不重复;tool-plan、半成品 stream、原始事件 detail、命令正文、绝对路径、Provider / Runner 元数据、哈希和凭据不得进入聊天。普通 `supervisor-chat` 保持原有 transient response 行为。 +- 对话输出:game-chat 的 Supervisor `ready` response stream 继续以稳定身份显示;`design-director / art-director / code-director / code-prototype / preview-readiness / preview-playtest` 的 `requestKind=final-reply` 且 `status=ready|committed` 的非空安全回复也分别以 Agent、Session、run、request slot 和 response revision 形成 durable message ID,并带 Agent 标签逐条追加到项目聊天;`art-asset-plan` 的回复不进入 game-chat 项目聊天。每条 Rust `eventId + publicText` 公开输出同样形成独立 durable 消息。所有这些消息通过 `append_local_conversation_message` 的顶层 `messageId` 幂等写入,事件、轮询、React StrictMode 和 hydration 重放不重复;tool-plan、半成品 stream、原始事件 detail、命令正文、绝对路径、Provider / Runner 元数据、哈希和凭据不得进入聊天。普通 `supervisor-chat` 保持原有 transient response 行为。 - 单轮收束:game-chat source 只生成至 `preview-playtest` 的 manifest seed task,试玩完成后父 Run 直接进入完成门,不再调度 `publish-strategy` / `publish-package`;`agent.schedule_ready` 必须按当前 Supervisor Run 的持久 source/profile 选择同一 source-aware scheduler,不能绕过该边界。`task.list` 对同一 root source 必须从任务行、readyTaskIds 和统计中排除两个发布节点,`agent.delegate` 也必须按 root binding 拒绝直接委派这两个节点,不能让 Provider 用“读取完整 DAG 后手工委派”恢复已裁掉的发布阶段。完成门满足且 collaboration、Provider batch、进程会话、视觉资源等非验证屏障全部清零后,Runtime 必须用确定性回复直接收束结构化计划并结束父 Run,不再请求下一次 Provider 工具计划。普通 GUI / CLI 仍执行完整发布 DAG。 -- 轮次展示:`loopIteration` 只是同一父 Run 内的 Provider / 工具规划循环,用于委派、回执、返工和验收,不是用户发起的游戏生成轮次。game-chat 的进度卡、当前工作和“最新状态”事件统一显示“本轮”,整个页面不向用户显示“第 N 轮”;完整 GUI / CLI pre-publish 任务图仍可显示 `x/14`,但首版快车道只按四阶段显示 `x/4`(详见 2026-08-01 小节),不得把两个发布节点计入任一分母。父 Run 终态后移除运行中进度卡,只保留终态阶段记录与预览。 +- 轮次展示:`loopIteration` 只是同一父 Run 内的 Provider / 工具规划循环,用于委派、回执、返工和验收,不是用户发起的游戏生成轮次。game-chat 的进度卡、当前工作和“最新状态”事件统一显示“本轮”,整个页面不向用户显示“第 N 轮”;完整 GUI / CLI pre-publish 任务图仍可显示 `x/14`,但首版只按六项任务显示 `x/6`(详见 2026-08-03 小节),不得把两个发布节点计入任一分母。父 Run 终态后移除运行中进度卡,只保留终态阶段记录与预览。 - 平台美术资源:live10 实测正式透明 `icon-spritesheet` 的生成与后处理超过 `300` 秒,因此 game-chat 五分钟首版不运行 `art-asset-plan` 图集链路。它必须配置可用的 External Editor API,由 `art-director` 通过一次平台 `images/generations` 生成并登记 `assets/art-spec.png`,再由 `code-prototype` 在 `game/index.html` 的用户可见画面中把该图片显著用于主要背景、玩家和目标;未配置 API,或缺少生成、登记、文件、引用、可见使用任一证据时均 fail-closed。普通 GUI / CLI autonomous 继续正式透明 `assets/art-spritesheet.png` 的完整 DAG,不采用该快车道。 - 验证:前端运行时模型定向测试、Rust completion/source/asset 合同测试、`cargo fmt --check`、`npm run check:encoding` 与 `git diff --check` 必须全部执行;Windows 文件锁竞态只可作为既有测试失败单独记录,不得将其改写为本次改动的通过证据。 -## 2026-08-01 game-chat 首版四阶段快车道与美术硬门 +## 2026-08-01 game-chat 首版六任务快车道与美术硬门 -- 首版任务边界:game-chat 首版只展示 `art-director`、`code-prototype`、`preview-readiness`、`preview-playtest` 四个阶段,进度统一显示为 `x/4`;`art-asset-plan` 不进入进度、阶段记录或 final-reply 投影。不把完整 GUI / CLI 任务图的其它节点投影到该页面,也不显示内部 Provider / child loop 轮次。 +- 首版任务边界:game-chat 首版只展示 `design-director`、`art-director`、`code-director`、`code-prototype`、`preview-readiness`、`preview-playtest` 六项任务,进度统一显示为 `x/6`;首波仅并行激活三个 Director,`code-prototype` 必须等待三者完成,后续验证再串行推进。`art-asset-plan` 不进入进度、阶段记录或 final-reply 投影。不把完整 GUI / CLI 任务图的其它节点投影到该页面,也不显示内部 Provider / child loop 轮次。 - 时间预算:从 game-chat 父 Run 接受用户请求开始,首版可玩版本使用 `240` 秒软预算;父 Run 与其全部 child Run、等待和回收阶段共享从 root `bound_at` 计算的 `300` 秒绝对硬上限,不能把硬上限拆成每个 Agent 独立计时。整个 Runtime pass 必须受同一 `timeout_at` 约束,覆盖 Provider、图片生成、文件写入、静态检查、浏览器试玩和 final-reply 的在途等待;超时先强制持久化 `failed`,再清理恢复 sidecar 与进程会话。达到软预算后只允许进入确定性的本地兜底、静态 smoke 和浏览器试玩;达到硬上限仍未通过完成门必须失败关闭。即使完成证据恰好在上限后到齐,单轮确定性收束也必须再次检查累计预算并拒绝写入 `single_round_converged`,不得把超时伪装成 completed。 -- Provider 次数:首版快车道最多执行一次 Provider 首版规划 / 写入请求;后续不再请求第二次 Provider tool-plan、自动传输重试或无限 repair。Provider 成功返回后由 Runtime 依次执行确定性的 `game.static_smoke` 与 `preview.validate`,以当前 revision 和真实浏览器证据决定是否可交付。 +- Provider 次数:首波 `design-director / code-director` 的规划请求与 `art-director` 的确定性平台生图并行;各 Director 只处理本组规划,不得提前激活底层 Agent。三者收束后,`code-prototype` 最多执行一次 Provider 首版写入请求;后续不再请求第二次代码 tool-plan、自动传输重试或无限 repair。Provider 成功返回后由 Runtime 依次执行确定性的 `game.static_smoke` 与 `preview.validate`,以当前 revision 和真实浏览器证据决定是否可交付。 - 可玩兜底:软预算或首版 Provider 无法及时完成时,可以生成完整、自包含、无远程运行依赖的中文 HTML 模板。模板必须从 `ready` 开始,包含真实 Canvas 绘制、`requestAnimationFrame`、键盘 / 触控主要操作、唯一可见且启用的 start / primary-action / restart 控件,状态 JSON 持续推进,并能在 primary-action 后保持 `playing`、在 restart 后稳定回到 `ready | playing`;不得在开始前固定进入 `lost`,也不得通过固定失败冒充试玩通过。模板只有在 `assets/art-spec.png` 已经完成有效登记并真实存在时才允许生成,且必须把该平台图片显著绘制为主要背景、玩家和目标;未配置 External Editor API、图片生成失败或缺少有效登记时直接失败关闭,不得生成纯几何首版。 - 平台图片:live10 已证明透明 `icon-spritesheet` 后处理无法稳定收进 `300` 秒。game-chat 由 `art-director` 在首版预算内发起一次平台 `images/generations`,生成并登记 `assets/art-spec.png`;图片必须完整解码,`game/index.html` 的引用必须正确解析到该登记路径,且同一资源必须在非隐藏活动 Canvas 的可达执行路径中至少完成一次背景级和两次实体级 `drawImage`。`code-prototype` 必须在用户可见游戏画面中把它作为主要背景、玩家和目标真实加载和绘制。未配置 API,或生成、登记、文件存在、HTML 引用、可见使用任一缺失时,都必须在父 Run 的 `300` 秒累计硬上限内失败关闭,绝不写入 completed、`single_round_converged` 或其它成功结论。首版仍只在 `preview-playtest` 后单轮收束,不进入发布节点。普通 GUI / CLI 继续正式透明 `assets/art-spritesheet.png` 的完整 DAG。 -- 关联验收:快车道必须分别验证 `x/4` 投影、四个专业 Agent 安全 final-reply 逐条入聊天且排除 `art-asset-plan`、240 / 300 秒累计预算、单次 `images/generations`、External Editor API 缺失时失败关闭、`assets/art-spec.png` 生成 / 登记 / 文件 / 引用 / 主要背景与玩家及目标可见使用硬门,以及当前 revision 的静态 smoke 与浏览器试玩;这些规则不改变普通 GUI / CLI 的完整任务图和透明图集硬门。 +- 关联验收:快车道必须分别验证三 Director 首波并行、`code-prototype` 依赖三者、`x/6` 投影、六个专业 Agent 安全 final-reply 逐条入聊天且排除 `art-asset-plan`、240 / 300 秒累计预算、单次 `images/generations`、External Editor API 缺失时失败关闭、`assets/art-spec.png` 生成 / 登记 / 文件 / 引用 / 主要背景与玩家及目标可见使用硬门,以及当前 revision 的静态 smoke 与浏览器试玩;这些规则不改变普通 GUI / CLI 的完整任务图和透明图集硬门。 + +## 2026-08-03 game-chat 开发态同源与持久输出修复 + +- 开发态启动必须在 Tauri CLI 之前预检固定 `3080`。现有 marker 只包含 API target,不能证明监听器属于当前 worktree;因此只有端口空闲时才允许继续,任何已存在的 AGC Vite、非 HTTP 监听器或其它服务都必须在原生窗口创建前失败关闭。启动器不擅自终止无法证明归属的旧服务,也不得把当前 Rust 壳 / Runner 与其它 worktree 的旧 Vite 前端混用。Tauri CLI 任意退出后,外层启动器必须有界收束已启动的客户端进程树,避免 `beforeDevCommand` 失败后留下假在线窗口。 +- game-chat root binding 的 `source` 必须精确为 `project-supervisor-game-chat`。只有该持久 source 才能选择首波并行 `design-director + art-director + code-director`,随后 `code-prototype → preview-readiness → preview-playtest` 的六任务 lane、平台 `art-spec.png` 美术门、单轮确定性收束和自动预览;若绑定为 `project-supervisor-gui`,必须视为启动链路错误,不能用完整 16 节点 DAG 的运行状态伪装 game-chat 进度。 +- source-aware lane 的首波 ready child 可能在 UI hydration 写回时短暂恢复为 `Pending`。该例外必须从当前 root source 的种子 lane 解析全部零依赖任务,不得硬编码某个 Agent;当前 game-chat 首波是 `design-director / art-director / code-director`,后续 code prototype / preview child 仍严格拒绝 `Pending` 收束。 +- 专业 Agent 的非流式 final reply 继续由既有 finalization journal 重建并提交 `responseStream`。`streaming / ready` 投影仍必须匹配当前项目 revision;已经 finalization 提交的 `committed` 回复以 Agent / Session / run / request slot / response revision 稳定身份为准,不得因后续阶段推进项目 revision 而从 game-chat 查询中消失。 ## Runtime 边界 @@ -129,6 +136,8 @@ V1.11 的受保护仓库控制目录同时包含 `.git / .agent / .agents / .cod 2026-07-22 V1.47 收紧自主构建首批职责与专业交付:`project-supervisor` 的 initial wave 必须同时且各一次委派 `code-prototype` 与 `quality-review`。程序委派必须是非只读实现任务,`expectedArtifacts` 包含 `game/index.html`;质量委派必须显式只读、不得修改项目且 `expectedArtifacts=[]`。合同在 Provider 计划解析、batch prepare 和 durable batch 恢复三处重验;新批次使用 `game-creator-provider-action-batch.v3`,只有 v3 按新职责失败关闭,升级前已持久化的 v2 collaboration batch 与 v1 contractless batch 继续按原 fingerprint 和合同恢复。只读专业 Agent 的 Provider 计划只允许纯读取与状态观察动作,任何文件、revision、命令、任务、记忆、黑板、资产或委派副作用都在执行前拒绝,并把格式修复目录收窄为仅 `respond_to_user`。非只读专业 Agent 只有在本人 run 产生 project mutation 且当前 mutation revision 已验证通过后才能回复;ready 未认领或 claim 尚未 observed 时,父 Supervisor 必须先只调用 `agent.run_status` 收束回执,再决定 repair。只读判定只接受明确的只读审查/验收或不得修改指令,`非只读 / 不要只读 / not read-only` 等否定式标签不得因子串命中而误判。 +2026-08-03 覆盖说明:上段 `code-prototype + quality-review` 首批身份已退出当前合同,现行首批为 `design-director + art-director + code-director`;策划与程序 Director 只读且 `expectedArtifacts=[]`,美术 Director 非只读并要求 `assets/art-spec.png`。V1.47 的 batch v3 恢复、只读工具限制、mutation / verification 与 claim 收束边界继续保留。 + 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` 和存在时的 `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 的完整生命周期、隐私和残留门禁。 @@ -814,7 +823,7 @@ game-project/ ## 2026-07-31 长耗时与恢复收口 -- `autonomous-game-build` 的固定 manifest DAG 是唯一首轮专业执行链。缺省 collaboration policy 不再额外强制 `code-prototype / quality-review / art-*` 静态首波;显式项目 policy 仍原样生效,但 Runtime 不再按 Editor Key 或已有图片偷偷追加 Agent。Supervisor prompt 同步禁止在 manifest 前复制同职责委派。 +- `autonomous-game-build` 的固定 manifest DAG 是唯一缺省首轮专业执行链。缺省 collaboration policy 不再额外强制 `code-prototype / quality-review / art-*` 静态首波,Runtime 也不再按 Editor Key 或已有图片偷偷追加 Agent。2026-08-03 起,显式项目 policy 或旧 batch 恢复若进入首批 `agent.delegate` 兜底,同样只能激活 `design-director / art-director / code-director`,不得提前激活底层 Agent;这条兜底不能在默认 manifest 前复制同职责委派。 - `preview-readiness` 只有在自己的 child run 持有当前 project revision 的 `game.static_smoke=passed` 凭证后才能完成;`preview-playtest` 作为根 Supervisor 的直接 manifest child,必须解析并写入根完成合同的当前 revision browser receipt,报告、desktop/mobile 截图及摘要复核通过后才能投影 manifest completed。 - 浏览器未发现、临时环境不可建、启动超时或在 WebSocket URL 解析前退出统一分类为 `preview-infrastructure-unavailable`。首个持久 observation 后收束当前 action batch并失败结束 child/root run,禁止继续用 Provider 逐轮规划同一 revision 的重复启动;普通页面/玩法验收失败仍保留为业务失败,不混入基础设施分类。 - game-chat release 在 `CloseRequested / ExitRequested` 前复用 Runner durable idle probe;只要存在 process session、pending/finalization/provider/tool-plan handoff 或非终态 Agent queue/phase,就阻止关闭并提示先完成、暂停或取消。不可撤销的最终 `Exit` 不再作为唯一保护点,Windows Job Object 的 child-owned 安全边界保持不变。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index b5d95a433..943e1857f 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -1,6 +1,6 @@ # 本地开发验证与生产运维 -更新时间:`2026-07-23` +更新时间:`2026-08-03` ## 标准开发流程 @@ -58,6 +58,10 @@ Linux 本机多用户并发开发时,`npm run dev` 和 `npm run dev:*` 单模 后端日志默认写入 `logs/api-server/`,独立 BgFilter worker 日志默认写入 `logs/bgfilter-worker/`。后端 API smoke 使用 `npm run dev:api-server`,先检查 BgFilter worker `/readyz`,再检查 API `/healthz`;需要确认 API 实例可接生产流量时检查 API `/readyz`。不要使用旧 `api-server:maincloud` 或任何 `GENARRATIVE_SPACETIME_MAINCLOUD_*` 口径。 +AI 游戏创作客户端使用 `npm run agc`,开发态 game-chat 使用 `npm run agc:game-chat`。两个入口都先由 `apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs` 在 Tauri CLI 启动前检查固定地址 `http://127.0.0.1:3080/`:只有端口空闲时才继续启动。现有 marker 只包含 API target,不能证明监听器属于当前 worktree;即使页面和 target 看似匹配,也不得复用已经存在的 3080。旧 worktree Vite、无响应监听器或非 AGC 服务一律在创建原生窗口前失败关闭,并提示先停止旧服务;启动器不擅自终止无法证明归属的进程。 + +Tauri `beforeDevCommand` 默认与客户端构建并行,不能把上述检查只放在 `beforeDevCommand` 内:旧 3080 已就绪时,Tauri 可能先创建加载旧前端的窗口,随后配套后端才因代理不匹配退出。外层启动器会把 Tauri CLI 放入受控进程树;CLI 正常退出、启动失败或收到终止信号后,POSIX 先向保留的 PGID 发送 `SIGTERM`、有界等待后升级 `SIGKILL`,Windows 使用 `taskkill /PID /T /F`。配套后端和 Vite 仍由 `start-dev-stack.mjs` 各自持有,退出时同样有界收束,避免只剩客户端、Runner、Cargo 或旧订阅进程。排障时同时核对 3080 marker、`.app/dev-stack.json` 的实际 API URL 和进程 cwd;不要把“终端已返回”当成客户端及其 Runner 已退出的证据。 + Windows 本地 `npm run dev` / `npm run dev:api-server` / `npm run dev:bgfilter-worker` 会用空的 `RUSTC_WRAPPER` / `CARGO_BUILD_RUSTC_WRAPPER` 覆盖 `server-rs/.cargo/config.toml` 里的 `sccache`,从而直连真实 `rustc`。完整栈和 `dev:api-server` 把 API 与 BgFilter worker 作为一个 Rust 重启单元:源码变化时先停两个进程,再先启动并验活 worker、最后启动并验活 API,避免两个 `cargo run` 并发链接同一个 Windows 可执行文件。不要把 wrapper 绕过值写成 `rustc`;Cargo 会按 wrapper 协议调用 `rustc <真实rustc路径> - ...`,最终报 `multiple input filenames provided` 并导致 api-server 无法启动。排查本地启动失败时,先看 dev 日志是否出现该错误,再确认脚本注入的 wrapper 为空。 Windows 本地如果已在 `%LOCALAPPDATA%\Genarrative\ffmpeg\bin` 安装 FFmpeg,`npm run dev` / `npm run dev:api-server` 会自动把该目录加入本次 `api-server` 子进程 `Path`,并注入 `CHARACTER_ANIMATION_FFMPEG_PATH` / `CHARACTER_ANIMATION_FFPROBE_PATH` 的绝对路径。这样即使外层终端或长期运行的 dev 进程是在安装 FFmpeg 之前启动,角色动画抽帧也不会继续因为 `ffmpeg: program not found` 失败;若手动配置了上述环境变量或 `GENARRATIVE_CHARACTER_ANIMATION_*` 前缀变量,显式配置优先。 diff --git a/packages/shared/src/contracts/gameCreationApp.test.ts b/packages/shared/src/contracts/gameCreationApp.test.ts index 2512dd437..655329af0 100644 --- a/packages/shared/src/contracts/gameCreationApp.test.ts +++ b/packages/shared/src/contracts/gameCreationApp.test.ts @@ -487,6 +487,15 @@ describe('AI 游戏创作 App 共享契约', () => { 'publish-strategy', 'publish-package', ]); + for (const directorId of [ + 'design-director', + 'art-director', + 'code-director', + ]) { + expect( + manifest.tasks.find((task) => task.id === directorId)?.dependencies, + ).toEqual([]); + } expect( manifest.tasks.find((task) => task.id === 'design-foundation'), ).toEqual({ @@ -495,7 +504,7 @@ describe('AI 游戏创作 App 共享契约', () => { group: 'design', role: 'Gameplay', status: 'pending', - dependencies: ['art-director'], + dependencies: ['design-director', 'art-director'], artifacts: [ 'memory/project.md', 'game/game_design.md', @@ -519,6 +528,14 @@ describe('AI 游戏创作 App 共享契约', () => { ], }, ); + expect( + manifest.tasks.find((task) => task.id === 'code-prototype')?.dependencies, + ).toEqual([ + 'code-director', + 'balance-seed', + 'art-polish', + 'audio-asset-plan', + ]); }); it('selects ready tasks from dependency status', () => { @@ -526,17 +543,16 @@ describe('AI 游戏创作 App 共享契约', () => { expect( selectGameCreationAppReadyTasks(manifest).map((task) => task.id), - ).toEqual(['design-director']); + ).toEqual(['design-director', 'art-director', 'code-director']); - manifest.tasks.find((task) => task.id === 'design-director')!.status = - 'completed'; - - expect( - selectGameCreationAppReadyTasks(manifest).map((task) => task.id), - ).toEqual(['art-director']); - - manifest.tasks.find((task) => task.id === 'art-director')!.status = - 'completed'; + for (const directorId of [ + 'design-director', + 'art-director', + 'code-director', + ]) { + manifest.tasks.find((task) => task.id === directorId)!.status = + 'completed'; + } expect( selectGameCreationAppReadyTasks(manifest).map((task) => task.id), @@ -548,6 +564,61 @@ describe('AI 游戏创作 App 共享契约', () => { expect( selectGameCreationAppReadyTasks(manifest).map((task) => task.id), ).toEqual(['balance-director', 'art-asset-plan', 'audio-director']); + + for (const taskId of [ + 'balance-director', + 'art-asset-plan', + 'audio-director', + ]) { + manifest.tasks.find((task) => task.id === taskId)!.status = 'completed'; + } + expect( + selectGameCreationAppReadyTasks(manifest).map((task) => task.id), + ).toEqual(['balance-seed', 'art-polish', 'audio-asset-plan']); + + manifest.tasks.find((task) => task.id === 'balance-seed')!.status = + 'completed'; + expect( + selectGameCreationAppReadyTasks(manifest).map((task) => task.id), + ).toEqual(['art-polish', 'audio-asset-plan']); + + for (const taskId of ['art-polish', 'audio-asset-plan']) { + manifest.tasks.find((task) => task.id === taskId)!.status = 'completed'; + } + expect( + selectGameCreationAppReadyTasks(manifest).map((task) => task.id), + ).toEqual(['code-prototype']); + }); + + it('keeps the formal seed task graph acyclic with known dependencies', () => { + const tasks = createGameCreationAppSeedTasks(); + const remaining = new Map( + tasks.map((task) => [task.id, new Set(task.dependencies)]), + ); + const knownIds = new Set(remaining.keys()); + expect( + tasks + .flatMap((task) => task.dependencies) + .every((id) => knownIds.has(id)), + ).toBe(true); + + let visited = 0; + while (remaining.size > 0) { + const readyIds = Array.from(remaining) + .filter(([, dependencies]) => dependencies.size === 0) + .map(([id]) => id); + expect(readyIds.length).toBeGreaterThan(0); + for (const id of readyIds) { + remaining.delete(id); + visited += 1; + } + for (const dependencies of remaining.values()) { + for (const id of readyIds) { + dependencies.delete(id); + } + } + } + expect(visited).toBe(tasks.length); }); it('keeps canvas asset source fields camelCase', () => { diff --git a/packages/shared/src/contracts/gameCreationApp.ts b/packages/shared/src/contracts/gameCreationApp.ts index eddefabf9..adcba62ba 100644 --- a/packages/shared/src/contracts/gameCreationApp.ts +++ b/packages/shared/src/contracts/gameCreationApp.ts @@ -266,7 +266,7 @@ export const GAME_CREATION_APP_SEED_TASKS = [ group: 'art', role: 'Director', status: 'pending', - dependencies: ['design-director'], + dependencies: [], artifacts: [ '.agent/passes/pass-*/groups/art/director.md', 'assets/art-spec.png', @@ -281,7 +281,7 @@ export const GAME_CREATION_APP_SEED_TASKS = [ group: 'design', role: 'Gameplay', status: 'pending', - dependencies: ['art-director'], + dependencies: ['design-director', 'art-director'], artifacts: [ 'memory/project.md', 'game/game_design.md', @@ -359,12 +359,7 @@ export const GAME_CREATION_APP_SEED_TASKS = [ group: 'code', role: 'Director', status: 'pending', - dependencies: [ - 'design-foundation', - 'balance-seed', - 'art-polish', - 'audio-asset-plan', - ], + dependencies: [], artifacts: ['.agent/passes/pass-*/groups/code/director.md'], acceptanceCriteria: ['渲染、输入、状态和数据读取边界明确'], }, @@ -374,7 +369,12 @@ export const GAME_CREATION_APP_SEED_TASKS = [ group: 'code', role: 'Code', status: 'pending', - dependencies: ['code-director'], + dependencies: [ + 'code-director', + 'balance-seed', + 'art-polish', + 'audio-asset-plan', + ], artifacts: ['game/'], acceptanceCriteria: ['本地 Web 游戏项目可以通过 HTTP server 打开'], }, diff --git a/server-rs/crates/shared-contracts/src/game_creation_app.rs b/server-rs/crates/shared-contracts/src/game_creation_app.rs index ad0cf6db0..0d2225abe 100644 --- a/server-rs/crates/shared-contracts/src/game_creation_app.rs +++ b/server-rs/crates/shared-contracts/src/game_creation_app.rs @@ -275,7 +275,7 @@ pub fn new_game_creation_app_seed_tasks() -> Vec { "确定视觉方向与规范图", GameCreationAppAgentGroup::Art, "Director", - ["design-director"], + [], [ ".agent/passes/pass-*/groups/art/director.md", "assets/art-spec.png", @@ -287,7 +287,7 @@ pub fn new_game_creation_app_seed_tasks() -> Vec { "确定玩法规格与界面原型", GameCreationAppAgentGroup::Design, "Gameplay", - ["art-director"], + ["design-director", "art-director"], [ "memory/project.md", "game/game_design.md", @@ -358,12 +358,7 @@ pub fn new_game_creation_app_seed_tasks() -> Vec { "拆解程序实现", GameCreationAppAgentGroup::Code, "Director", - [ - "design-foundation", - "balance-seed", - "art-polish", - "audio-asset-plan", - ], + [], [".agent/passes/pass-*/groups/code/director.md"], ["渲染、输入、状态和数据读取边界明确"], ), @@ -372,7 +367,12 @@ pub fn new_game_creation_app_seed_tasks() -> Vec { "生成可运行原型", GameCreationAppAgentGroup::Code, "Code", - ["code-director"], + [ + "code-director", + "balance-seed", + "art-polish", + "audio-asset-plan", + ], ["game/"], ["本地 Web 游戏项目可以通过 HTTP server 打开"], ), @@ -1375,6 +1375,14 @@ mod tests { ] ); assert_eq!(manifest.tasks[0].group, GameCreationAppAgentGroup::Design); + for director_id in ["design-director", "art-director", "code-director"] { + let director = manifest + .tasks + .iter() + .find(|task| task.id == director_id) + .expect("director task"); + assert!(director.dependencies.is_empty()); + } let design = manifest .tasks .iter() @@ -1384,7 +1392,7 @@ mod tests { assert_eq!(design.group, GameCreationAppAgentGroup::Design); assert_eq!(design.role, "Gameplay"); assert_eq!(design.status, GameCreationAppTaskStatus::Pending); - assert_eq!(design.dependencies, ["art-director"]); + assert_eq!(design.dependencies, ["design-director", "art-director"]); assert_eq!( design.artifacts, [ @@ -1420,6 +1428,20 @@ mod tests { "角色、场景、UI 和动画需求已映射到画板或本地资产,且至少一张首版核心素材图已生成并登记" ] ); + let code_prototype = manifest + .tasks + .iter() + .find(|task| task.id == "code-prototype") + .expect("code prototype task"); + assert_eq!( + code_prototype.dependencies, + [ + "code-director", + "balance-seed", + "art-polish", + "audio-asset-plan" + ] + ); } #[test] @@ -1431,30 +1453,17 @@ mod tests { .iter() .map(|task| task.id.as_str()) .collect::>(), - vec!["design-director"] + vec!["design-director", "art-director", "code-director"] ); - manifest - .tasks - .iter_mut() - .find(|task| task.id == "design-director") - .unwrap() - .status = GameCreationAppTaskStatus::Completed; - - assert_eq!( - select_game_creation_app_ready_tasks(&manifest) - .iter() - .map(|task| task.id.as_str()) - .collect::>(), - vec!["art-director"] - ); - - manifest - .tasks - .iter_mut() - .find(|task| task.id == "art-director") - .unwrap() - .status = GameCreationAppTaskStatus::Completed; + for director_id in ["design-director", "art-director", "code-director"] { + manifest + .tasks + .iter_mut() + .find(|task| task.id == director_id) + .unwrap() + .status = GameCreationAppTaskStatus::Completed; + } assert_eq!( select_game_creation_app_ready_tasks(&manifest) @@ -1478,6 +1487,96 @@ mod tests { .collect::>(), vec!["balance-director", "art-asset-plan", "audio-director"] ); + + for task_id in ["balance-director", "art-asset-plan", "audio-director"] { + manifest + .tasks + .iter_mut() + .find(|task| task.id == task_id) + .unwrap() + .status = GameCreationAppTaskStatus::Completed; + } + assert_eq!( + select_game_creation_app_ready_tasks(&manifest) + .iter() + .map(|task| task.id.as_str()) + .collect::>(), + vec!["balance-seed", "art-polish", "audio-asset-plan"] + ); + + manifest + .tasks + .iter_mut() + .find(|task| task.id == "balance-seed") + .unwrap() + .status = GameCreationAppTaskStatus::Completed; + assert_eq!( + select_game_creation_app_ready_tasks(&manifest) + .iter() + .map(|task| task.id.as_str()) + .collect::>(), + vec!["art-polish", "audio-asset-plan"] + ); + + for task_id in ["art-polish", "audio-asset-plan"] { + manifest + .tasks + .iter_mut() + .find(|task| task.id == task_id) + .unwrap() + .status = GameCreationAppTaskStatus::Completed; + } + assert_eq!( + select_game_creation_app_ready_tasks(&manifest) + .iter() + .map(|task| task.id.as_str()) + .collect::>(), + vec!["code-prototype"] + ); + } + + #[test] + fn seed_task_graph_is_acyclic_and_references_known_dependencies() { + let tasks = new_game_creation_app_seed_tasks(); + let known_ids = tasks + .iter() + .map(|task| task.id.as_str()) + .collect::>(); + assert!(tasks.iter().all(|task| { + task.dependencies + .iter() + .all(|dependency| known_ids.contains(dependency.as_str())) + })); + + let mut remaining = tasks + .iter() + .map(|task| { + ( + task.id.as_str(), + task.dependencies + .iter() + .map(String::as_str) + .collect::>(), + ) + }) + .collect::>(); + let mut visited = 0; + while !remaining.is_empty() { + let ready_ids = remaining + .iter() + .filter(|(_, dependencies)| dependencies.is_empty()) + .map(|(id, _)| *id) + .collect::>(); + assert!(!ready_ids.is_empty(), "seed task graph contains a cycle"); + remaining.retain(|(id, _)| !ready_ids.contains(id)); + visited += ready_ids.len(); + for (_, dependencies) in &mut remaining { + for ready_id in &ready_ids { + dependencies.remove(ready_id); + } + } + } + assert_eq!(visited, tasks.len()); } #[test]