Merge remote-tracking branch 'web/master' into feat/pixel_art2
# Conflicts: # docs/project-memory/shared-memory/decision-log.md
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
+180
-99
@@ -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("不得委派底层 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");
|
||||
|
||||
+73
-5
@@ -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.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::<Vec<_>>();
|
||||
|
||||
assert_eq!(kept, vec!["design-director"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn isolated_repair_replaces_the_single_accumulated_slot() {
|
||||
let accumulated = vec![collaboration_action(
|
||||
|
||||
+84
@@ -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,
|
||||
|
||||
+10
-6
@@ -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
|
||||
|
||||
+85
-19
@@ -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}"
|
||||
);
|
||||
|
||||
|
||||
+51
-32
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
+4
-4
@@ -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 个');
|
||||
|
||||
+2
-2
@@ -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 个');
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -5996,3 +5996,12 @@
|
||||
- 已知残留:剥离是本地的,不主动回写。`applyProjectSnapshot` 会置 `skipNextProjectLayoutSaveRef`,加载后的第一次 effect 被消费掉,所以清理要等用户下一次布局变更才随防抖落库;在此之前重复打开会重复提示。刻意不强制回写:那会加剧多标签页问题——B 标签加载时会误判 A 标签正在跑的占位为孤儿,只在本地剥离时 A 的回填仍能成功,一旦立即回写就会让 A 撞上 `409` 占位不存在。多标签页同时编辑同一画布另有 CAS `expected_revision` 兜底,不由本次修复承担。
|
||||
- 验证:`dropDeadInlineGenerationPlaceholders` 四条单测覆盖「剥离已死 inline 占位」「保留队列型占位(缺字段与显式 false 两种)」「保留已终态的 inline 占位与普通图层」「标记经 hydrate 与序列化往返不丢失」——最后一条钉住白名单式 hydrate 漏字段会让标记在一次「加载→保存」后消失。工作流测试新增 `live-session-dialogs` 探针,正向断言完美像素占位置位、反向断言去除背景占位不置位。`vitest src/components/image-editor` 893 通过 / 72 文件,typecheck、eslint、check:encoding 通过。
|
||||
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.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`。
|
||||
|
||||
@@ -4014,3 +4014,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 <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
Reference in New Issue
Block a user