Merge branch 'master' into codex/dependency-graph
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',
|
||||
|
||||
@@ -167,7 +167,9 @@ function gameChatRuntimeState(
|
||||
}
|
||||
|
||||
const GAME_CHAT_STAGE_TASK_IDS = [
|
||||
'design-director',
|
||||
'art-director',
|
||||
'code-director',
|
||||
'code-prototype',
|
||||
'preview-readiness',
|
||||
'preview-playtest',
|
||||
@@ -3383,8 +3385,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', '试玩验证已完成'),
|
||||
@@ -3393,21 +3397,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 () => {
|
||||
@@ -3462,6 +3472,7 @@ export function registerProjectSupervisorSurfaceTests() {
|
||||
};
|
||||
};
|
||||
const professionalResults = [
|
||||
makeRuntimeResult('design-director', 'committed', '玩法方向已完成', 170),
|
||||
makeRuntimeResult('art-director', 'ready', '视觉方向已完成', 180),
|
||||
makeRuntimeResult(
|
||||
'art-asset-plan',
|
||||
@@ -3469,8 +3480,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({
|
||||
@@ -3523,14 +3540,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();
|
||||
});
|
||||
|
||||
@@ -3615,10 +3632,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(() => {
|
||||
@@ -4096,7 +4110,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',
|
||||
@@ -4116,7 +4130,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');
|
||||
});
|
||||
@@ -4203,8 +4217,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') {
|
||||
@@ -4294,16 +4310,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(
|
||||
@@ -4316,10 +4328,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);
|
||||
@@ -4367,14 +4376,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();
|
||||
@@ -4838,7 +4843,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)
|
||||
@@ -4918,7 +4925,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);
|
||||
});
|
||||
@@ -4978,7 +4986,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 () => {
|
||||
@@ -5080,7 +5088,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(
|
||||
@@ -5767,13 +5777,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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user