优化游戏聊天生命周期与首轮调度
Project CI / Repository checks (push) Successful in 1m8s
Project CI / Frontend tests (push) Failing after 2m8s
Project CI / Native shell tests (push) Failing after 3m28s
Project CI / Backend tests (push) Successful in 3m42s

新增受控 Tauri 开发启动器并在启动失败或终端退出时收束客户端进程树
修复 game-chat source hydration 与专业 Agent 已提交回复的持久可见性
统一正式任务图和 game-chat 首轮只激活策划美术程序三个 Director
限制显式首批协作合同提前激活底层 Agent 或 isolated child
同步六任务进度投影、共享契约、回归测试与项目文档
This commit is contained in:
2026-08-03 11:41:59 +08:00
parent 7309adb4f0
commit 38c43b708a
28 changed files with 1502 additions and 317 deletions
+2 -2
View File
@@ -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",
@@ -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(
@@ -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('<title>AI 游戏创作</title>') &&
!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,
};
@@ -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;
}
}
@@ -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::<Vec<_>>();
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("不得委派底层 Agentcode-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");
@@ -52,7 +52,7 @@ fn supervisor_collaboration_missing_agent_ids(error: &str) -> Vec<String> {
.collect::<Vec<_>>()
})
.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<String> {
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.htmlquality-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.delegaterepairOfDelegationId=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::<Vec<_>>();
assert_eq!(kept, vec!["design-director"]);
}
#[test]
fn isolated_repair_replaces_the_single_accumulated_slot() {
let accumulated = vec![collaboration_action(
@@ -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) =
@@ -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}"));
@@ -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,
@@ -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
@@ -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::<Vec<_>>(),
[
"design-director",
"art-director",
"code-director",
"code-prototype",
"preview-readiness",
"preview-playtest",
]
);
assert_eq!(game_chat_tasks[0].dependencies, Vec::<String>::new());
assert_eq!(game_chat_tasks[1].dependencies, Vec::<String>::new());
assert_eq!(game_chat_tasks[2].dependencies, Vec::<String>::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
"<!doctype html><html><body><img id=\"art\" hidden src=\"../assets/art-spec.png\"><canvas></canvas><script>const image=document.getElementById('art');context.drawImage(image,0,0,canvas.width,canvas.height);context.drawImage(image,0,0,96,96);context.drawImage(image,120,80,112,112);requestAnimationFrame(()=>{});</script></body></html>",
);
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();
@@ -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"
@@ -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}"
);
@@ -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::<BTreeSet<_>>();
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,
+3 -3
View File
@@ -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();
@@ -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',
@@ -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,
);
@@ -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(
'Runrun-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(
'Runrun-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 个');
@@ -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(/最近 Runrun-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 个');
@@ -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: '<html><title>AI 游戏创作</title><script src="/src/main.tsx"></script></html>',
};
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: '' });
});
});
@@ -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 });
}
});
});
@@ -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`
@@ -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、有界等待、再 KILLWindows 固定调用 `taskkill /PID <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`
@@ -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-<HTTP status>` 身份。等待态必须从真实 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 安全边界保持不变。
@@ -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 <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_*` 前缀变量,显式配置优先。

Some files were not shown because too many files have changed in this diff Show More