Files
Genarrative/apps/ai-game-creator-shell/scripts/agent-swarm-test-chat.mjs
T
lhk229 2fa967a93a
Project CI / Repository checks (pull_request) Failing after 12s
Project CI / Backend tests (pull_request) Failing after 9s
Project CI / Frontend tests (pull_request) Successful in 3m15s
Project CI / Native shell tests (pull_request) Failing after 15m21s
删除策划 V1 的测试脚本入口并同步退役文档
删除 agent-swarm-test-chat.mjs 的 --plan 模式、自动 GDD 审批回路与 planning 产物检查
删除根与应用 package.json 的 test:plan / test:plan:manual / agc:test:plan* 四条脚本
同步删除 agentSwarmTestEntry.test.ts 中只覆盖 V1 审批回路与 --plan 参数的用例
GddApprovalCard.tsx 注释改指 planning_gdd_model.rs 的现行路径权威定义
立项策划Agent(Fast GDD)方案文档头部标注已退役,仅作历史推导记录
策划会话 Runtime V2 方案文档状态更新为 P5 已完成并记录源码删除执行清单
Provider 兼容性缺陷文档的缺陷 4 标注相关代码已随 V1 退役删除
decision-log 新增 2026-09-08 策划 V1 链路源码整体退役决策记录
2026-09-08 10:43:38 +00:00

2131 lines
66 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { spawn } from 'node:child_process';
import { randomUUID } from 'node:crypto';
import { constants as fsConstants } from 'node:fs';
import {
chmod,
copyFile,
lstat,
mkdir,
mkdtemp,
open,
readdir,
readFile,
realpath,
rm,
writeFile,
} from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { createInterface } from 'node:readline/promises';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { inflateSync } from 'node:zlib';
export const appIdentifier = 'world.genarrative.ai-game-creator';
export const configFileName = 'game-creator.config.json';
export const localConfigFileName = 'game-creator.config.local.json';
export const runnerEndpointFileName = 'agent-runner.endpoint.json';
export const testProjectPrefix = 'genarrative-agc-swarm-test-';
export const testProjectSentinelName = '.agc-swarm-test.json';
export const testProjectSentinelSchema =
'genarrative-agc-swarm-test-project.v1';
export const testRuntimeConfigPrefix = 'genarrative-agc-swarm-config-';
export const testRuntimeConfigSentinelName = '.agc-swarm-config.json';
export const testRuntimeConfigSentinelSchema =
'genarrative-agc-swarm-test-config.v1';
export const ungeneratedGameEntryMarker =
'还没有生成游戏。回到聊天输入创意并确认生成后';
export const defaultRealSwarmTestTask =
'制作一个可直接试玩的原创植物塔防小游戏:玩家选择并放置原创守卫阻挡敌人,完成波次后可以进入下一关并重新开始。主题、单位名称与视觉语言必须原创,不使用任何现有游戏角色、单位名、Logo 或受保护视觉语言。请自主完成正式产物、静态检查和双视口试玩验证。';
export const swarmTurnReportPrefix = '[turn.report] ';
export const swarmTurnReportSchema = 'game-creator-swarm-turn-report.v1';
const swarmTurnReportKeys = [
'schemaVersion',
'outcome',
'parentAgentId',
'sessionId',
'parentRunId',
'runtimeCount',
'busyRuntimeCount',
'pendingTaskCount',
'runningTaskCount',
'waitingForConfirmationCount',
'waitingForUserInputCount',
'newAssistantMessageCount',
'finalReplyChars',
'reconciliationAgentCount',
].sort();
const settledZeroCountFields = [
'busyRuntimeCount',
'pendingTaskCount',
'runningTaskCount',
'waitingForConfirmationCount',
'waitingForUserInputCount',
'reconciliationAgentCount',
];
const requiredFormalArtifactSpecs = [
{ path: 'memory/project.md', kind: 'file' },
{ path: 'game/game_design.md', kind: 'file' },
{ path: 'game/balance.json', kind: 'json' },
{ path: 'assets/manifest.art.json', kind: 'json' },
{ path: 'assets/manifest.audio.json', kind: 'json' },
{ path: 'game/index.html', kind: 'game-entry' },
{ path: 'exports/README.md', kind: 'file' },
];
const editorImageArtifactSpecs = [
{ path: 'assets/ui-prototype.png', kind: 'image', aspectRatio: 16 / 9 },
{ path: 'assets/art-spritesheet.png', kind: 'image', aspectRatio: 1 },
];
export const requiredSwarmManifestTaskIds = Object.freeze([
'design-director',
'design-foundation',
'balance-director',
'balance-seed',
'art-director',
'art-asset-plan',
'art-polish',
'audio-director',
'audio-asset-plan',
'code-director',
'code-prototype',
'quality-review',
'preview-readiness',
'preview-playtest',
'publish-strategy',
'publish-package',
]);
const appRoot = path.resolve(fileURLToPath(new URL('..', import.meta.url)));
const cargoManifestPath = path.join(appRoot, 'src-tauri', 'Cargo.toml');
const configWizardPath = path.join(
appRoot,
'scripts',
'game-creator-config-wizard.mjs',
);
const cargoCommand = process.platform === 'win32' ? 'cargo.exe' : 'cargo';
const childTerminationGraceMs = 10_000;
const childForceTerminationWaitMs = 5_000;
const runnerShutdownTimeoutMs = 20_000;
const cleanupDirectoryTimeoutMs = 10_000;
const maximumValidatedPngBytes = 64 * 1024 * 1024;
const maximumValidatedPngPixels = 100_000_000;
const maximumInflatedPngBytes = 256 * 1024 * 1024;
const minimumMarkdownBodyCharacters = 24;
const minimumHtmlCharacters = 120;
const incompleteArtifactTextPattern =
/\b(?:todo|tbd|placeholder|coming[\t ]+soon|lorem[\t ]+ipsum)\b|待补充|待完善|占位|尚未完成|稍后补充|待填写|待验证|待复核|待确认|待定/iu;
const uncheckedMarkdownChecklistPattern =
/^[\t ]*(?:>[\t ]*)*(?:[-+*]|\d+[.)])[\t ]+\[[\t ]\](?:[\t ]|$)/mu;
export function hasIncompleteArtifactMarker(content) {
return (
incompleteArtifactTextPattern.test(content) ||
uncheckedMarkdownChecklistPattern.test(content)
);
}
export const usage = `用法:
npm run agc:test:chat
npm run agc:test:chat:manual -- [选项]
自动读取客户端 AppData 配置并复制到隔离目录,创建一次性项目后进入 Project Supervisor 自主测试。
带 --task 时完成正式产物验收后自动退出;手工聊天模式完成后启动持续预览。
选项:
--config-dir <绝对路径> 显式指定客户端 AppData 配置来源目录
--project-dir <绝对路径> 使用已有项目或空目录,不自动删除
--keep-project 保留自动创建的一次性项目
--no-open 手工模式启动预览但不自动打开浏览器
--task <需求> 通过 manual 入口非交互提交自定义需求
--timeout-minutes <分钟> 设置本次执行期限;自动任务默认 50 分钟,手工模式默认不限时
--dry-run 只检查目录发现和项目准备,不启动 LLM
-h, --help 显示帮助
`;
function readOptionValue(args, index, option) {
const value = args[index + 1]?.trim();
if (!value || value.startsWith('--')) {
throw new Error(`${option} 缺少路径`);
}
return value;
}
function readTimeoutMinutes(args, index, option) {
const value = readOptionValue(args, index, option);
if (!/^[1-9]\d*$/u.test(value)) {
throw new Error(`${option} 必须是 1-1440 的整数分钟`);
}
const minutes = Number(value);
if (!Number.isSafeInteger(minutes) || minutes > 1_440) {
throw new Error(`${option} 必须是 1-1440 的整数分钟`);
}
return minutes;
}
export function parseSwarmTestArguments(args) {
const options = {
configDir: null,
projectDir: null,
keepProject: false,
openBrowser: true,
task: null,
timeoutMinutes: null,
dryRun: false,
help: false,
};
for (let index = 0; index < args.length; index += 1) {
const argument = args[index];
if (argument === '--config-dir') {
if (options.configDir) throw new Error('--config-dir 只能指定一次');
options.configDir = readOptionValue(args, index, argument);
index += 1;
} else if (argument === '--project-dir') {
if (options.projectDir) throw new Error('--project-dir 只能指定一次');
options.projectDir = readOptionValue(args, index, argument);
index += 1;
} else if (argument === '--keep-project') {
options.keepProject = true;
} else if (argument === '--no-open') {
options.openBrowser = false;
} else if (argument === '--task') {
if (options.task) throw new Error('--task 只能指定一次');
const task = readOptionValue(args, index, argument);
if (task.length > 4_000) throw new Error('--task 不能超过 4000 字符');
options.task = task;
index += 1;
} else if (argument === '--timeout-minutes') {
if (options.timeoutMinutes !== null) {
throw new Error('--timeout-minutes 只能指定一次');
}
options.timeoutMinutes = readTimeoutMinutes(args, index, argument);
index += 1;
} else if (argument === '--dry-run') {
options.dryRun = true;
} else if (argument === '--help' || argument === '-h') {
options.help = true;
} else {
throw new Error(`未知选项:${argument}`);
}
}
return options;
}
export function shouldStartPersistentPreview(options) {
return !options.task;
}
export function resolveSwarmTestTimeoutMs(options) {
const minutes = options.timeoutMinutes ?? (options.task ? 50 : null);
return minutes === null ? null : minutes * 60_000;
}
function pushUnique(values, value) {
if (value && !values.includes(value)) values.push(value);
}
export function defaultRuntimeConfigDirCandidates({
platform = process.platform,
environment = process.env,
homeDirectory = os.homedir(),
} = {}) {
const candidates = [];
if (platform === 'win32') {
pushUnique(
candidates,
environment.APPDATA
? path.win32.join(environment.APPDATA, appIdentifier)
: path.win32.join(homeDirectory, 'AppData', 'Roaming', appIdentifier),
);
if (environment.LOCALAPPDATA) {
pushUnique(
candidates,
path.win32.join(environment.LOCALAPPDATA, appIdentifier),
);
}
} else if (platform === 'darwin') {
pushUnique(
candidates,
path.posix.join(
homeDirectory,
'Library',
'Application Support',
appIdentifier,
),
);
} else {
const configuredRoot = environment.XDG_CONFIG_HOME;
const posixAbsoluteConfiguredRoot =
configuredRoot && path.posix.isAbsolute(configuredRoot);
const hostAbsoluteConfiguredRoot =
configuredRoot &&
!posixAbsoluteConfiguredRoot &&
path.isAbsolute(configuredRoot);
const configRoot =
configuredRoot &&
(posixAbsoluteConfiguredRoot || hostAbsoluteConfiguredRoot)
? configuredRoot
: path.posix.join(homeDirectory, '.config');
pushUnique(
candidates,
hostAbsoluteConfiguredRoot
? path.join(configRoot, appIdentifier)
: path.posix.join(configRoot, appIdentifier),
);
}
return candidates;
}
async function isRegularFileWithoutSymlink(filePath) {
const metadata = await lstat(filePath).catch((error) => {
if (error?.code === 'ENOENT') return null;
throw error;
});
return Boolean(metadata?.isFile() && !metadata.isSymbolicLink());
}
export async function discoverRuntimeConfigDir(
explicitConfigDir,
platformContext,
) {
if (explicitConfigDir && !path.isAbsolute(explicitConfigDir)) {
throw new Error('--config-dir 必须是绝对路径');
}
const candidates = explicitConfigDir
? [explicitConfigDir]
: defaultRuntimeConfigDirCandidates(platformContext);
for (const candidate of candidates) {
const resolved = path.resolve(candidate);
const metadata = await lstat(resolved).catch((error) => {
if (error?.code === 'ENOENT') return null;
throw error;
});
if (!metadata?.isDirectory() || metadata.isSymbolicLink()) continue;
if (
!(await isRegularFileWithoutSymlink(path.join(resolved, configFileName)))
) {
continue;
}
return realpath(resolved);
}
if (explicitConfigDir) {
throw new Error(
`指定目录中没有可用的 ${configFileName}${explicitConfigDir}`,
);
}
throw new Error(
`未找到客户端 AppData 配置。请运行 npm run agc:config,或启动 npm run agc 后在“运行时配置”中保存 LLM Provider。`,
);
}
export function canPromptForMissingRuntimeConfig(
stdinIsTty = process.stdin.isTTY,
stdoutIsTty = process.stdout.isTTY,
) {
return Boolean(stdinIsTty && stdoutIsTty);
}
async function askToConfigureMissingRuntime() {
if (!canPromptForMissingRuntimeConfig()) return false;
const readline = createInterface({
input: process.stdin,
output: process.stdout,
});
try {
const answer = (
await readline.question(
'未找到客户端 AppData 配置,是否现在进入安全配置向导? [Y/n]: ',
)
)
.trim()
.toLowerCase();
return !answer || ['y', 'yes', '是'].includes(answer);
} finally {
readline.close();
}
}
export function buildMissingConfigWizardArguments(explicitConfigDir) {
return [
configWizardPath,
'--configure-only',
...(explicitConfigDir ? ['--config-dir', explicitConfigDir] : []),
];
}
async function runMissingConfigWizard(setActiveChild, explicitConfigDir) {
const child = spawnChild(
process.execPath,
buildMissingConfigWizardArguments(explicitConfigDir),
{
stdio: 'inherit',
},
);
setActiveChild(child);
const result = await childExit(child);
setActiveChild(null);
if (result.code !== 0 || result.signal) {
throw new Error(
`配置向导未正常完成:code=${result.code ?? ''} signal=${result.signal ?? ''}`,
);
}
}
async function secureWindowsPrivateRuntimePath(targetPath, options) {
const { secureWindowsGameCreatorPathForCurrentUser } = await import(
'./game-creator-config-wizard.mjs'
);
await secureWindowsGameCreatorPathForCurrentUser(targetPath, options);
}
async function copyPrivateRuntimeConfigEntry(
sourceConfigDir,
runtimeConfigDir,
fileName,
required,
) {
const sourcePath = path.join(sourceConfigDir, fileName);
const sourceMetadata = await lstat(sourcePath).catch((error) => {
if (error?.code === 'ENOENT') return null;
throw error;
});
if (!sourceMetadata) {
if (required) throw new Error(`配置来源缺少 ${fileName}`);
return false;
}
if (!sourceMetadata.isFile() || sourceMetadata.isSymbolicLink()) {
throw new Error(`配置来源必须是无符号链接普通文件:${fileName}`);
}
const destinationPath = path.join(runtimeConfigDir, fileName);
if (process.platform === 'win32') {
const sourceBytes = await readFile(sourcePath);
const destinationFile = await open(destinationPath, 'wx', 0o600);
try {
await secureWindowsPrivateRuntimePath(destinationPath, {
isDirectory: false,
});
await destinationFile.writeFile(sourceBytes);
await destinationFile.sync();
} finally {
await destinationFile.close();
}
} else {
await copyFile(sourcePath, destinationPath, fsConstants.COPYFILE_EXCL);
await chmod(destinationPath, 0o600);
}
const [sourceMetadataAfterCopy, destinationMetadata] = await Promise.all([
lstat(sourcePath),
lstat(destinationPath),
]);
if (
!sourceMetadataAfterCopy.isFile() ||
sourceMetadataAfterCopy.isSymbolicLink() ||
sourceMetadataAfterCopy.dev !== sourceMetadata.dev ||
sourceMetadataAfterCopy.ino !== sourceMetadata.ino ||
sourceMetadataAfterCopy.size !== sourceMetadata.size ||
sourceMetadataAfterCopy.mtimeMs !== sourceMetadata.mtimeMs ||
!destinationMetadata.isFile() ||
destinationMetadata.isSymbolicLink() ||
(process.platform !== 'win32' &&
((destinationMetadata.mode & 0o077) !== 0 ||
(destinationMetadata.dev === sourceMetadata.dev &&
destinationMetadata.ino === sourceMetadata.ino)))
) {
throw new Error(`隔离配置副本身份或权限无效:${fileName}`);
}
return true;
}
export async function prepareSwarmTestRuntimeConfig(sourceConfigDir, tempRoot) {
if (!path.isAbsolute(sourceConfigDir)) {
throw new Error('配置来源目录必须是绝对路径');
}
const sourceMetadata = await lstat(sourceConfigDir).catch((error) => {
if (error?.code === 'ENOENT') return null;
throw error;
});
if (!sourceMetadata?.isDirectory() || sourceMetadata.isSymbolicLink()) {
throw new Error(`配置来源必须是无符号链接普通目录:${sourceConfigDir}`);
}
const canonicalSourceConfigDir = await realpath(sourceConfigDir);
const canonicalTempRoot = await realpath(
path.resolve(tempRoot ?? os.tmpdir()),
);
const runtimeConfigDir = await mkdtemp(
path.join(canonicalTempRoot, testRuntimeConfigPrefix),
);
try {
if (process.platform === 'win32') {
await secureWindowsPrivateRuntimePath(runtimeConfigDir, {
isDirectory: true,
});
} else {
await chmod(runtimeConfigDir, 0o700);
}
const sentinelToken = randomUUID();
await writeFile(
path.join(runtimeConfigDir, testRuntimeConfigSentinelName),
`${JSON.stringify({
schemaVersion: testRuntimeConfigSentinelSchema,
token: sentinelToken,
})}\n`,
{ flag: 'wx', mode: 0o600 },
);
await copyPrivateRuntimeConfigEntry(
canonicalSourceConfigDir,
runtimeConfigDir,
configFileName,
true,
);
await copyPrivateRuntimeConfigEntry(
canonicalSourceConfigDir,
runtimeConfigDir,
localConfigFileName,
false,
);
return {
path: await realpath(runtimeConfigDir),
sourcePath: canonicalSourceConfigDir,
owned: true,
sentinelToken,
};
} catch (error) {
await rm(runtimeConfigDir, { recursive: true, force: true });
throw error;
}
}
const cleanupDirectoryChildProgram = String.raw`
const { rm } = require('node:fs/promises');
const target = process.argv[1];
rm(target, { recursive: true, force: false }).catch((error) => {
process.stderr.write(String(error && error.message || error));
process.exitCode = 1;
});
`;
export async function removeDirectoryWithTimeout(
directoryPath,
{
timeoutMs = cleanupDirectoryTimeoutMs,
childProgram = cleanupDirectoryChildProgram,
} = {},
) {
const child = spawnChild(
process.execPath,
['-e', childProgram, directoryPath],
{
stdio: ['ignore', 'ignore', 'ignore'],
},
);
const result = await childExitWithTimeout(
child,
timeoutMs,
`清理目录 ${path.basename(directoryPath)}`,
{ graceMs: 1_000, forceWaitMs: 2_000 },
);
if (result.code !== 0 || result.signal) {
throw new Error(
`清理目录失败:${path.basename(directoryPath)} code=${result.code ?? ''} signal=${result.signal ?? ''}`,
);
}
}
export async function cleanupSwarmTestRuntimeConfig(runtimeConfig) {
if (!runtimeConfig?.owned) return false;
const sentinelPath = path.join(
runtimeConfig.path,
testRuntimeConfigSentinelName,
);
if (!(await isRegularFileWithoutSymlink(sentinelPath))) {
throw new Error('拒绝清理:隔离配置哨兵缺失或类型无效');
}
let sentinel;
try {
sentinel = JSON.parse(await readFile(sentinelPath, 'utf8'));
} catch (error) {
throw new Error(`拒绝清理:隔离配置哨兵无效:${error.message}`);
}
if (
sentinel.schemaVersion !== testRuntimeConfigSentinelSchema ||
sentinel.token !== runtimeConfig.sentinelToken
) {
throw new Error('拒绝清理:隔离配置哨兵身份不匹配');
}
const canonical = await realpath(runtimeConfig.path);
if (
canonical !== runtimeConfig.path ||
canonical === runtimeConfig.sourcePath ||
!path.basename(canonical).startsWith(testRuntimeConfigPrefix)
) {
throw new Error('拒绝清理:隔离配置目录身份不匹配');
}
const endpointMetadata = await lstat(
path.join(canonical, runnerEndpointFileName),
).catch((error) => {
if (error?.code === 'ENOENT') return null;
throw error;
});
if (endpointMetadata) {
throw new Error('拒绝清理:隔离 Agent Runner 尚未退出');
}
await removeDirectoryWithTimeout(canonical);
return true;
}
async function ensureExplicitProject(projectDir) {
if (!path.isAbsolute(projectDir)) {
throw new Error('--project-dir 必须是绝对路径');
}
const resolved = path.resolve(projectDir);
const metadata = await lstat(resolved).catch((error) => {
if (error?.code === 'ENOENT') return null;
throw error;
});
if (!metadata) {
await mkdir(resolved, { recursive: true, mode: 0o700 });
} else if (!metadata.isDirectory() || metadata.isSymbolicLink()) {
throw new Error(`测试项目路径必须是普通目录:${resolved}`);
}
const canonical = await realpath(resolved);
const entries = await readdir(canonical);
const initialized = await isRegularFileWithoutSymlink(
path.join(canonical, '.agent', 'manifest.json'),
);
if (entries.length > 0 && !initialized) {
throw new Error(`--project-dir 只能指向空目录或已初始化项目:${canonical}`);
}
return {
path: canonical,
owned: false,
sentinelToken: null,
};
}
export async function prepareSwarmTestProject(explicitProjectDir, tempRoot) {
if (explicitProjectDir) return ensureExplicitProject(explicitProjectDir);
const root = path.resolve(tempRoot ?? os.tmpdir());
const projectPath = await mkdtemp(path.join(root, testProjectPrefix));
try {
if (process.platform !== 'win32') await chmod(projectPath, 0o700);
const sentinelToken = randomUUID();
await writeFile(
path.join(projectPath, testProjectSentinelName),
`${JSON.stringify({
schemaVersion: testProjectSentinelSchema,
token: sentinelToken,
})}\n`,
{ flag: 'wx', mode: 0o600 },
);
return {
path: await realpath(projectPath),
owned: true,
sentinelToken,
};
} catch (error) {
await rm(projectPath, { recursive: true, force: true });
throw error;
}
}
export async function cleanupSwarmTestProject(project) {
if (!project?.owned) return false;
const sentinelPath = path.join(project.path, testProjectSentinelName);
if (!(await isRegularFileWithoutSymlink(sentinelPath))) {
throw new Error('拒绝清理:一次性项目哨兵缺失或类型无效');
}
let sentinel;
try {
sentinel = JSON.parse(await readFile(sentinelPath, 'utf8'));
} catch (error) {
throw new Error(`拒绝清理:一次性项目哨兵无效:${error.message}`);
}
if (
sentinel.schemaVersion !== testProjectSentinelSchema ||
sentinel.token !== project.sentinelToken
) {
throw new Error('拒绝清理:一次性项目哨兵身份不匹配');
}
const canonical = await realpath(project.path);
if (
canonical !== project.path ||
!path.basename(canonical).startsWith(testProjectPrefix)
) {
throw new Error('拒绝清理:一次性项目目录身份不匹配');
}
await removeDirectoryWithTimeout(canonical);
return true;
}
export function buildCargoCliArguments(cliArguments) {
// `--quiet` only silences cargo's own build chatter; compiler errors and the
// CLI's stdout still come through. Without it the crate's several hundred
// dead-code warnings are reprinted on every spawn and bury the run output
// this script exists to show.
return [
'run',
'--quiet',
'--manifest-path',
cargoManifestPath,
'--',
...cliArguments,
];
}
function spawnChild(command, args, options = {}) {
return spawn(command, args, {
cwd: appRoot,
env: process.env,
detached: process.platform !== 'win32',
...options,
});
}
const childExitPromises = new WeakMap();
const closedChildren = new WeakSet();
function childExit(child) {
const existing = childExitPromises.get(child);
if (existing) return existing;
const exitPromise = new Promise((resolve, reject) => {
child.once('error', reject);
child.once('close', (code, signal) => {
closedChildren.add(child);
resolve({ code, signal });
});
});
childExitPromises.set(child, exitPromise);
return exitPromise;
}
async function childExitWithin(exitPromise, timeoutMs) {
let timeoutHandle;
const timeoutPromise = new Promise((resolve) => {
timeoutHandle = setTimeout(() => resolve(null), timeoutMs);
});
try {
return await Promise.race([exitPromise, timeoutPromise]);
} finally {
clearTimeout(timeoutHandle);
}
}
export async function terminateChildTree(
child,
signal = 'SIGTERM',
force = false,
) {
if (!child || closedChildren.has(child) || !Number.isInteger(child.pid)) {
return;
}
if (process.platform === 'win32') {
const taskkill = spawn(
'taskkill.exe',
['/PID', String(child.pid), '/T', ...(force ? ['/F'] : [])],
{
stdio: 'ignore',
windowsHide: true,
},
);
const taskkillExit = childExit(taskkill).catch(() => null);
if (!(await childExitWithin(taskkillExit, childForceTerminationWaitMs))) {
try {
taskkill.kill('SIGKILL');
} catch {
// The taskkill helper may have exited at the timeout boundary.
}
await childExitWithin(taskkillExit, childForceTerminationWaitMs);
}
return;
}
try {
process.kill(-child.pid, force ? 'SIGKILL' : signal);
} catch (error) {
try {
child.kill(force ? 'SIGKILL' : signal);
} catch {
if (error?.code !== 'ESRCH') throw error;
}
}
}
async function terminateChildTreeAndWait(
child,
exitPromise,
signal,
label,
{
graceMs = childTerminationGraceMs,
forceWaitMs = childForceTerminationWaitMs,
} = {},
) {
await terminateChildTree(child, signal, false);
const gracefulResult = await childExitWithin(exitPromise, graceMs);
if (gracefulResult) return gracefulResult;
await terminateChildTree(child, 'SIGKILL', true);
const forcedResult = await childExitWithin(exitPromise, forceWaitMs);
if (forcedResult) return forcedResult;
throw new Error(`${label} 无法在强制终止进程树后关闭 stdio`);
}
export async function childExitWithTimeout(
child,
timeoutMs,
label,
terminationOptions,
) {
const exitPromise = childExit(child);
if (timeoutMs === null) return exitPromise;
let timeoutHandle;
const timeoutPromise = new Promise((resolve) => {
timeoutHandle = setTimeout(() => resolve({ kind: 'timeout' }), timeoutMs);
});
try {
const first = await Promise.race([
exitPromise.then((result) => ({ kind: 'exit', result })),
timeoutPromise,
]);
if (first.kind === 'exit') return first.result;
try {
await terminateChildTreeAndWait(
child,
exitPromise,
'SIGTERM',
label,
terminationOptions,
);
} catch (error) {
error.code = 'AGC_CHILD_TIMEOUT';
throw error;
}
throw Object.assign(
new Error(`${label} 超过 ${Math.ceil(timeoutMs / 1000)} 秒期限`),
{ code: 'AGC_CHILD_TIMEOUT' },
);
} finally {
clearTimeout(timeoutHandle);
}
}
function isPlainObject(value) {
return (
value !== null &&
typeof value === 'object' &&
!Array.isArray(value) &&
Object.getPrototypeOf(value) === Object.prototype
);
}
function isNonNegativeSafeInteger(value) {
return Number.isSafeInteger(value) && value >= 0;
}
export function parseSettledSwarmTurnReport(output) {
const reportLines = String(output)
.split(/\r?\n/u)
.filter((line) => line.startsWith(swarmTurnReportPrefix));
if (reportLines.length !== 1) {
throw new Error(
`Agent Swarm 终态报告数量无效:expected=1 actual=${reportLines.length}`,
);
}
let report;
try {
report = JSON.parse(reportLines[0].slice(swarmTurnReportPrefix.length));
} catch {
throw new Error('Agent Swarm 终态报告不是有效 JSON');
}
if (
!isPlainObject(report) ||
JSON.stringify(Object.keys(report).sort()) !==
JSON.stringify(swarmTurnReportKeys)
) {
throw new Error('Agent Swarm 终态报告结构无效');
}
if (report.schemaVersion !== swarmTurnReportSchema) {
throw new Error('Agent Swarm 终态报告 schema 无效');
}
if (
typeof report.parentAgentId !== 'string' ||
!report.parentAgentId.trim() ||
typeof report.sessionId !== 'string' ||
!report.sessionId.trim() ||
typeof report.parentRunId !== 'string' ||
!report.parentRunId.trim()
) {
throw new Error('Agent Swarm 终态报告运行身份无效');
}
const countFields = [
'runtimeCount',
...settledZeroCountFields,
'newAssistantMessageCount',
'finalReplyChars',
];
if (countFields.some((field) => !isNonNegativeSafeInteger(report[field]))) {
throw new Error('Agent Swarm 终态报告计数无效');
}
if (
!['settled', 'failed', 'incomplete', 'needs-reconciliation'].includes(
report.outcome,
)
) {
throw new Error('Agent Swarm 终态报告 outcome 无效');
}
if (report.outcome !== 'settled') {
throw new Error(`Agent Swarm 本轮未收束:outcome=${report.outcome}`);
}
const unsettledField = settledZeroCountFields.find(
(field) => report[field] !== 0,
);
if (unsettledField) {
throw new Error(
`Agent Swarm 本轮仍有未收束工作:${unsettledField}=${report[unsettledField]}`,
);
}
if (report.runtimeCount < 1) {
throw new Error('Agent Swarm 终态报告没有 Runtime');
}
if (
report.newAssistantMessageCount !== 1 ||
report.finalReplyChars < 1 ||
report.finalReplyChars > 1_000_000
) {
throw new Error('Agent Swarm 最终回复无效');
}
return report;
}
async function runCapturedCargo(
cliArguments,
setActiveChild,
{ timeoutMs = null, label = 'Cargo 子命令', stdin = null } = {},
) {
const child = spawnChild(cargoCommand, buildCargoCliArguments(cliArguments), {
stdio: [stdin === null ? 'ignore' : 'pipe', 'pipe', 'pipe'],
});
setActiveChild(child);
if (stdin !== null) {
child.stdin.end(stdin);
}
let stdout = '';
let stderr = '';
child.stdout.setEncoding('utf8');
child.stderr.setEncoding('utf8');
child.stdout.on('data', (chunk) => {
stdout += chunk;
});
child.stderr.on('data', (chunk) => {
stderr += chunk;
});
try {
const result = await childExitWithTimeout(child, timeoutMs, label);
return { ...result, stdout, stderr };
} finally {
setActiveChild(null);
}
}
async function runInteractiveCargo(cliArguments, setActiveChild) {
const child = spawnChild(cargoCommand, buildCargoCliArguments(cliArguments), {
stdio: 'inherit',
});
setActiveChild(child);
const result = await childExit(child);
setActiveChild(null);
return result;
}
// 立项策划跑 standard 档,`agent.delegate` 这类动作按项目权限策略必须逐个确认,
// 而确认和问询都只从 CLI 的 stdin 读。自主构建档没有这一步,所以只有 --plan 需要
// 一个把「人坐在终端前敲 approve」自动化掉的应答器;判据本身仍然走后端确认命令。
const swarmConfirmationPromptPattern = /输入 approve 或 reject$/u;
const swarmUserInputPromptPattern = /请选择 1-\d+,或直接输入其他答案:$/u;
export function nextSwarmAutoPilotReply(output) {
if (swarmConfirmationPromptPattern.test(output)) return 'approve';
if (swarmUserInputPromptPattern.test(output)) return '1';
return null;
}
// CLI 的 REPL 是「先打印提示符再读行」,所以第一个「你>」出现时本轮还没开始跑:
// 它就是用来读我们这条任务的。收 stdin 必须等到投递之后的下一个提示符——那才是
// 本轮结束、CLI 回到待输入状态。绝大多数情况下此前已经打印过 turn 回执,但总控也
// 可能判定直接回复而不起持久 Run,那条路径没有回执,只等回执会一直干等到超时。
const swarmChatPromptPattern = /(^|\n)你> $/u;
export function swarmAutoPilotSitsAtPrompt(output) {
return swarmChatPromptPattern.test(output);
}
export function swarmAutoPilotShouldCloseInput(output, promptsAfterSubmit) {
return swarmAutoPilotSitsAtPrompt(output) && promptsAfterSubmit >= 1;
}
async function runTaskCargo(
cliArguments,
task,
setActiveChild,
timeoutMs,
autoPilot = false,
) {
const child = spawnChild(cargoCommand, buildCargoCliArguments(cliArguments), {
stdio: ['pipe', 'pipe', 'inherit'],
});
setActiveChild(child);
const reportLines = [];
let pendingLine = '';
let settled = false;
let taskSubmitted = false;
let promptsSeen = 0;
let sittingAtPrompt = false;
child.stdout.setEncoding('utf8');
child.stdout.on('data', (chunk) => {
process.stdout.write(chunk);
pendingLine += chunk;
const lines = pendingLine.split('\n');
pendingLine = lines.pop() ?? '';
for (const line of lines) {
const normalizedLine = line.endsWith('\r') ? line.slice(0, -1) : line;
if (normalizedLine.startsWith(swarmTurnReportPrefix)) {
reportLines.push(normalizedLine);
settled = true;
}
}
if (!autoPilot || child.stdin.writableEnded) return;
const atPrompt = swarmAutoPilotSitsAtPrompt(pendingLine);
if (atPrompt && !sittingAtPrompt) promptsSeen += 1;
sittingAtPrompt = atPrompt;
// turn 已给出回执、或 CLI 回到了投递之后的下一个提示符,都说明本轮结束。
if (
settled ||
(taskSubmitted &&
swarmAutoPilotShouldCloseInput(pendingLine, promptsSeen - 1))
) {
child.stdin.end();
return;
}
const reply = nextSwarmAutoPilotReply(pendingLine);
if (reply === null) return;
console.log(`[自动应答] ${reply}`);
pendingLine = '';
child.stdin.write(`${reply}\n`);
});
if (autoPilot) {
child.stdin.write(`${task}\n`);
taskSubmitted = true;
} else {
child.stdin.end(`${task}\n`);
}
try {
const result = await childExitWithTimeout(
child,
timeoutMs,
'Agent Swarm 自动任务',
);
const normalizedPendingLine = pendingLine.endsWith('\r')
? pendingLine.slice(0, -1)
: pendingLine;
if (normalizedPendingLine.startsWith(swarmTurnReportPrefix)) {
reportLines.push(normalizedPendingLine);
}
return {
...result,
turnReportOutput: reportLines.join('\n'),
};
} finally {
setActiveChild(null);
}
}
export function parseRunnerShutdownOutput(output) {
const match = output.match(/^runner\.stopped=(true|false)$/m);
if (!match) throw new Error('Runner 收束命令缺少 stopped 状态');
return match[1] === 'true';
}
async function shutdownSwarmTestRunner(runtimeConfig, setActiveChild) {
const result = await runCapturedCargo(
['--config-dir', runtimeConfig.path, '--runner-shutdown-if-idle'],
setActiveChild,
{
timeoutMs: runnerShutdownTimeoutMs,
label: '隔离 Agent Runner 收束命令',
},
);
if (result.code !== 0 || result.signal) {
throw new Error(
`隔离 Agent Runner 收束失败:${result.stderr.trim() || result.stdout.trim() || `code=${result.code ?? ''} signal=${result.signal ?? ''}`}`,
);
}
return parseRunnerShutdownOutput(result.stdout);
}
export function validatePreviewUrl(value) {
const url = new URL(value);
if (
url.protocol !== 'http:' ||
url.hostname !== '127.0.0.1' ||
!url.port ||
url.username ||
url.password ||
url.pathname !== '/' ||
url.search ||
url.hash
) {
throw new Error('预览命令返回了非 loopback URL');
}
return url.toString();
}
export async function hasGeneratedGameEntry(projectPath) {
const gameEntryPath = path.join(projectPath, 'game', 'index.html');
if (!(await isRegularFileWithoutSymlink(gameEntryPath))) return false;
const html = await readFile(gameEntryPath, 'utf8');
return html.trim().length > 0 && !html.includes(ungeneratedGameEntryMarker);
}
const pngSignature = Buffer.from([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
]);
const pngCrcTable = Uint32Array.from({ length: 256 }, (_unused, index) => {
let value = index;
for (let bit = 0; bit < 8; bit += 1) {
value = value & 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1;
}
return value >>> 0;
});
function pngCrc32(bytes) {
let value = 0xffffffff;
for (const byte of bytes) {
value = pngCrcTable[(value ^ byte) & 0xff] ^ (value >>> 8);
}
return (value ^ 0xffffffff) >>> 0;
}
export function validatePngBytes(bytes) {
if (
!Buffer.isBuffer(bytes) ||
bytes.length < 57 ||
bytes.length > maximumValidatedPngBytes ||
!bytes.subarray(0, pngSignature.length).equals(pngSignature)
) {
throw new Error('PNG 签名或文件大小无效');
}
let offset = pngSignature.length;
let ihdr = null;
let ihdrCount = 0;
let idatSeen = false;
let idatEnded = false;
let iendSeen = false;
let plteCount = 0;
let plteEntries = 0;
const idatChunks = [];
while (offset < bytes.length) {
if (bytes.length - offset < 12) throw new Error('PNG chunk 被截断');
const length = bytes.readUInt32BE(offset);
const typeOffset = offset + 4;
const dataOffset = typeOffset + 4;
const dataEnd = dataOffset + length;
const chunkEnd = dataEnd + 4;
if (dataEnd > bytes.length - 4 || chunkEnd > bytes.length) {
throw new Error('PNG chunk 长度越界');
}
const type = bytes.subarray(typeOffset, dataOffset).toString('ascii');
if (!/^[A-Za-z]{4}$/u.test(type)) throw new Error('PNG chunk 类型无效');
const expectedCrc = bytes.readUInt32BE(dataEnd);
const actualCrc = pngCrc32(bytes.subarray(typeOffset, dataEnd));
if (expectedCrc !== actualCrc) throw new Error(`${type} CRC 无效`);
const data = bytes.subarray(dataOffset, dataEnd);
if (offset === pngSignature.length && type !== 'IHDR') {
throw new Error('IHDR 必须是第一个 chunk');
}
if (type === 'IHDR') {
ihdrCount += 1;
if (ihdrCount !== 1 || length !== 13) {
throw new Error('IHDR 数量或长度无效');
}
ihdr = {
width: data.readUInt32BE(0),
height: data.readUInt32BE(4),
bitDepth: data[8],
colorType: data[9],
compression: data[10],
filter: data[11],
interlace: data[12],
};
} else if (type === 'PLTE') {
plteCount += 1;
if (
!ihdr ||
idatSeen ||
iendSeen ||
plteCount !== 1 ||
length < 3 ||
length > 768 ||
length % 3 !== 0
) {
throw new Error('PLTE 数量、长度或顺序无效');
}
plteEntries = length / 3;
} else if (type === 'IDAT') {
if (!ihdr || idatEnded || iendSeen) {
throw new Error('IDAT 顺序无效');
}
idatSeen = true;
idatChunks.push(data);
} else if (type === 'IEND') {
if (!ihdr || !idatSeen || iendSeen || length !== 0) {
throw new Error('IEND 数量、长度或顺序无效');
}
iendSeen = true;
if (chunkEnd !== bytes.length) throw new Error('IEND 后存在额外数据');
} else {
if ((type.charCodeAt(0) & 0x20) === 0) {
throw new Error(`不支持的 PNG critical chunk${type}`);
}
if (idatSeen) idatEnded = true;
}
offset = chunkEnd;
}
if (!ihdr || ihdrCount !== 1 || !idatSeen || !iendSeen) {
throw new Error('PNG 缺少唯一 IHDR、IDAT 或 IEND');
}
const validBitDepths = {
0: [1, 2, 4, 8, 16],
2: [8, 16],
3: [1, 2, 4, 8],
4: [8, 16],
6: [8, 16],
};
if (
ihdr.width < 1 ||
ihdr.height < 1 ||
ihdr.width * ihdr.height > maximumValidatedPngPixels ||
!validBitDepths[ihdr.colorType]?.includes(ihdr.bitDepth) ||
ihdr.compression !== 0 ||
ihdr.filter !== 0 ||
ihdr.interlace !== 0
) {
throw new Error('IHDR 参数无效或不支持交错 PNG');
}
if (
(ihdr.colorType === 3 &&
(plteCount !== 1 || plteEntries > 2 ** ihdr.bitDepth)) ||
([0, 4].includes(ihdr.colorType) && plteCount !== 0)
) {
throw new Error('PLTE 与 PNG color type 或 bit depth 不匹配');
}
const channels = { 0: 1, 2: 3, 3: 1, 4: 2, 6: 4 }[ihdr.colorType];
const rowBytes = Math.ceil((ihdr.width * channels * ihdr.bitDepth) / 8);
const expectedInflatedBytes = ihdr.height * (rowBytes + 1);
if (
!Number.isSafeInteger(expectedInflatedBytes) ||
expectedInflatedBytes > maximumInflatedPngBytes
) {
throw new Error('PNG scanline 大小无效');
}
let inflated;
try {
const compressed = Buffer.concat(idatChunks);
const result = inflateSync(compressed, {
maxOutputLength: expectedInflatedBytes + 1,
info: true,
});
if (result.engine.bytesWritten !== compressed.length) {
throw new Error('trailing compressed bytes');
}
inflated = result.buffer;
} catch {
throw new Error('IDAT zlib 数据无法完整解压');
}
if (inflated.length !== expectedInflatedBytes) {
throw new Error('非交错 PNG scanline 长度无效');
}
for (let row = 0; row < ihdr.height; row += 1) {
if (inflated[row * (rowBytes + 1)] > 4) {
throw new Error(`第 ${row + 1} 行 filter byte 无效`);
}
}
return { width: ihdr.width, height: ihdr.height };
}
async function validatePngFile(filePath) {
const metadata = await lstat(filePath);
if (
!metadata.isFile() ||
metadata.isSymbolicLink() ||
metadata.size < 1_024 ||
metadata.size > maximumValidatedPngBytes
) {
throw new Error('PNG 文件缺失、类型无效或大小超限');
}
const noFollowFlag =
process.platform === 'win32' ? 0 : (fsConstants.O_NOFOLLOW ?? 0);
const file = await open(filePath, fsConstants.O_RDONLY | noFollowFlag);
try {
return validatePngBytes(await file.readFile());
} finally {
await file.close();
}
}
async function inspectFormalArtifact(projectPath, spec) {
const artifactPath = path.join(projectPath, ...spec.path.split('/'));
let metadata;
try {
metadata = await lstat(artifactPath);
} catch (error) {
return {
path: spec.path,
reason:
error?.code === 'ENOENT' || error?.code === 'ENOTDIR'
? '缺失'
: '无法安全读取',
};
}
if (!metadata.isFile() || metadata.isSymbolicLink()) {
return { path: spec.path, reason: '不是无符号链接普通文件' };
}
if (metadata.size === 0) return { path: spec.path, reason: '空文件' };
const noFollowFlag =
process.platform === 'win32' ? 0 : (fsConstants.O_NOFOLLOW ?? 0);
let file;
try {
file = await open(artifactPath, fsConstants.O_RDONLY | noFollowFlag);
} catch {
return { path: spec.path, reason: '无法安全读取' };
}
try {
const openedMetadata = await file.stat();
if (!openedMetadata.isFile()) {
return { path: spec.path, reason: '不是无符号链接普通文件' };
}
if (openedMetadata.size === 0) {
return { path: spec.path, reason: '空文件' };
}
if (spec.kind === 'json') {
let parsed;
try {
parsed = JSON.parse(await file.readFile('utf8'));
} catch {
return { path: spec.path, reason: 'JSON 无法解析' };
}
if (!isPlainObject(parsed) || Object.keys(parsed).length === 0) {
return { path: spec.path, reason: 'JSON 必须是非空对象' };
}
} else if (spec.kind === 'file' || spec.kind === 'game-entry') {
const content = await file.readFile('utf8');
if (content.trim().length === 0) {
return { path: spec.path, reason: '空文件' };
}
if (hasIncompleteArtifactMarker(content)) {
return { path: spec.path, reason: '仍包含占位标记' };
}
const compactContent = content.replace(/\s/gu, '');
if (
spec.kind === 'file' &&
compactContent.length < minimumMarkdownBodyCharacters
) {
return { path: spec.path, reason: 'Markdown 正文过短' };
}
if (
spec.kind === 'game-entry' &&
content.includes(ungeneratedGameEntryMarker)
) {
return { path: spec.path, reason: '仍是初始化占位页' };
}
if (
spec.kind === 'game-entry' &&
(compactContent.length < minimumHtmlCharacters ||
!/<html\b[^>]*>[\s\S]*<\/html>/iu.test(content) ||
!/<script\b/iu.test(content) ||
!/<(?:canvas|button)\b/iu.test(content))
) {
return { path: spec.path, reason: 'HTML 正文或交互结构不完整' };
}
} else if (spec.kind === 'image') {
if (openedMetadata.size < 1_024) {
return { path: spec.path, reason: '图片文件小于 1 KiB' };
}
if (openedMetadata.size > maximumValidatedPngBytes) {
return { path: spec.path, reason: '图片文件大小超限' };
}
let dimensions;
try {
dimensions = validatePngBytes(await file.readFile());
} catch (error) {
return {
path: spec.path,
reason: `PNG 文件无效:${error.message}`,
};
}
const ratio = dimensions.width / dimensions.height;
if (Math.abs(ratio - spec.aspectRatio) > 0.03) {
return {
path: spec.path,
reason: `图片比例无效:${dimensions.width}x${dimensions.height}`,
};
}
}
} catch {
return { path: spec.path, reason: '无法安全读取' };
} finally {
await file.close();
}
return null;
}
function resolveProjectEvidencePath(projectPath, relativePath) {
if (
typeof relativePath !== 'string' ||
!relativePath ||
path.isAbsolute(relativePath) ||
relativePath.includes('\\')
) {
throw new Error('证据路径无效');
}
const root = path.resolve(projectPath);
const targetPath = path.resolve(root, ...relativePath.split('/'));
if (!targetPath.startsWith(`${root}${path.sep}`)) {
throw new Error('证据路径越界');
}
return targetPath;
}
async function readSafeJson(projectPath, relativePath, maximumBytes) {
const targetPath = resolveProjectEvidencePath(projectPath, relativePath);
const metadata = await lstat(targetPath).catch(() => null);
if (
!metadata?.isFile() ||
metadata.isSymbolicLink() ||
metadata.size < 2 ||
metadata.size > maximumBytes
) {
throw new Error('文件缺失、类型无效或大小超限');
}
const noFollowFlag =
process.platform === 'win32' ? 0 : (fsConstants.O_NOFOLLOW ?? 0);
const file = await open(targetPath, fsConstants.O_RDONLY | noFollowFlag);
try {
return JSON.parse(await file.readFile('utf8'));
} finally {
await file.close();
}
}
async function readSafeJsonLines(projectPath, relativePath, maximumBytes) {
const targetPath = resolveProjectEvidencePath(projectPath, relativePath);
const metadata = await lstat(targetPath);
if (
!metadata.isFile() ||
metadata.isSymbolicLink() ||
metadata.size < 2 ||
metadata.size > maximumBytes
) {
throw new Error('JSONL 文件缺失、类型无效或大小超限');
}
const noFollowFlag =
process.platform === 'win32' ? 0 : (fsConstants.O_NOFOLLOW ?? 0);
const file = await open(targetPath, fsConstants.O_RDONLY | noFollowFlag);
try {
const content = await file.readFile('utf8');
if (!content.endsWith('\n')) throw new Error('JSONL 尾记录不完整');
return content
.split(/\r?\n/u)
.filter(Boolean)
.map((line) => JSON.parse(line));
} finally {
await file.close();
}
}
async function inspectReadyTaskExactlyOnce(
projectPath,
parentRunId,
agentDbRecords,
) {
const issues = [];
for (const taskId of requiredSwarmManifestTaskIds) {
const journalPath = `.agent/runtime/tasks/${taskId}.jsonl`;
try {
const journal = await readSafeJsonLines(
projectPath,
journalPath,
8 * 1024 * 1024,
);
const currentRunRecords = journal.filter(
(record) =>
record?.agentId === taskId &&
record?.taskId === taskId &&
record?.source === 'agent-ready-task-scheduler' &&
record?.parentAgentId === 'project-supervisor' &&
record?.parentRunId === parentRunId,
);
const runIds = [
...new Set(currentRunRecords.map((record) => record?.runId)),
].filter((runId) => typeof runId === 'string' && runId.length > 0);
if (runIds.length !== 1) throw new Error('logical-run-count');
const runId = runIds[0];
const latest = currentRunRecords
.filter((record) => record?.runId === runId)
.at(-1);
if (
latest?.status !== 'completed' ||
latest?.phase !== 'completed' ||
latest?.runProfile !== 'autonomous-game-build'
) {
throw new Error('logical-run-terminal');
}
const count = (recordType) =>
agentDbRecords.filter(
(record) =>
record?.recordType === recordType &&
record?.agentId === taskId &&
record?.taskId === taskId &&
record?.runId === runId &&
record?.source === 'agent-ready-task-scheduler',
).length;
const projections = agentDbRecords.filter(
(record) =>
record?.recordType ===
'agent.runtime.autonomous_ready_task.manifest_projected' &&
record?.agentId === taskId &&
record?.taskId === taskId &&
record?.runId === runId &&
record?.source === 'agent-ready-task-scheduler' &&
record?.parentAgentId === 'project-supervisor' &&
record?.parentRunId === parentRunId &&
record?.terminalPhase === 'completed' &&
record?.manifestStatus === 'completed',
);
if (
count('agent.runtime.background_task') !== 1 ||
count('agent.runtime.background_task.completed') !== 1 ||
count('agent.runtime.background_task.failed') !== 0 ||
count('agent.runtime.background_task.cancelled') !== 0 ||
projections.length !== 1
) {
throw new Error('lifecycle-count');
}
} catch {
issues.push({
path: journalPath,
reason: `正式任务 ${taskId} 未在当前父 Run 中恰好启动并完成一次`,
});
}
}
return issues;
}
async function inspectSwarmRuntimeAcceptance(projectPath, parentRunId = null) {
const issues = [];
let manifest;
try {
manifest = await readSafeJson(
projectPath,
'.agent/manifest.json',
2 * 1024 * 1024,
);
} catch {
issues.push({
path: '.agent/manifest.json',
reason: '无法读取正式任务图',
});
}
if (manifest) {
const tasks = Array.isArray(manifest.tasks) ? manifest.tasks : [];
const taskIds = tasks.map((task) => task?.id);
const expectedIds = [...requiredSwarmManifestTaskIds].sort();
const actualIds = [...taskIds].sort();
if (
tasks.length !== requiredSwarmManifestTaskIds.length ||
new Set(taskIds).size !== taskIds.length ||
JSON.stringify(actualIds) !== JSON.stringify(expectedIds) ||
tasks.some((task) => task?.status !== 'completed')
) {
issues.push({
path: '.agent/manifest.json',
reason: '固定 16 个正式任务未全部且仅完成一次',
});
}
}
let revision;
try {
const revisionRecord = await readSafeJson(
projectPath,
'.agent/runtime/project-revision.json',
64 * 1024,
);
revision = revisionRecord?.revision;
if (!Number.isSafeInteger(revision) || revision < 1) throw new Error();
} catch {
issues.push({
path: '.agent/runtime/project-revision.json',
reason: '当前项目 revision 无效',
});
}
let records = [];
let recordsValid = true;
try {
const databasePath = path.join(projectPath, '.agent', 'agent.db');
const metadata = await lstat(databasePath);
if (
!metadata.isFile() ||
metadata.isSymbolicLink() ||
metadata.size < 2 ||
metadata.size > 64 * 1024 * 1024
) {
throw new Error();
}
records = (await readFile(databasePath, 'utf8'))
.split(/\r?\n/u)
.filter(Boolean)
.map((line) => JSON.parse(line));
} catch {
recordsValid = false;
issues.push({ path: '.agent/agent.db', reason: 'Runtime 证据库无效' });
}
if (!recordsValid) return issues;
if (parentRunId) {
issues.push(
...(await inspectReadyTaskExactlyOnce(projectPath, parentRunId, records)),
);
}
const staticSmoke = records.some(
(record) =>
record?.recordType === 'agent.runtime.command.run_limited' &&
record?.commandId === 'game.static_smoke' &&
record?.status === 'completed' &&
record?.revision === revision,
);
if (!staticSmoke) {
issues.push({
path: '.agent/agent.db',
reason: '缺少当前 revision 的静态检查通过凭证',
});
}
const browserEvidence = records
.filter(
(record) =>
record?.recordType === 'agent.runtime.preview.validation' &&
record?.passed === true &&
record?.playtestPassed === true &&
record?.revision === revision,
)
.sort((left, right) => (right.updatedAt ?? 0) - (left.updatedAt ?? 0))[0];
if (!browserEvidence) {
issues.push({
path: '.agent/agent.db',
reason: '缺少当前 revision 的桌面与移动试玩通过凭证',
});
return issues;
}
const screenshots = Array.isArray(browserEvidence.screenshots)
? browserEvidence.screenshots
: [];
for (const viewport of ['desktop', 'mobile']) {
const screenshot = screenshots.find((item) =>
String(item).endsWith(`/${viewport}.png`),
);
let screenshotValid = false;
try {
if (screenshot) {
await validatePngFile(
resolveProjectEvidencePath(projectPath, screenshot),
);
screenshotValid = true;
}
} catch {
screenshotValid = false;
}
if (!screenshotValid) {
issues.push({
path: '.agent/agent.db',
reason: `缺少 ${viewport} 试玩截图`,
});
}
}
try {
const report = await readSafeJson(
projectPath,
browserEvidence.reportPath,
2 * 1024 * 1024,
);
const viewportResults = Array.isArray(report?.viewportResults)
? report.viewportResults
: [];
if (
report?.passed !== true ||
report?.playtest?.passed !== true ||
!['desktop', 'mobile'].every((viewport) =>
viewportResults.some(
(result) => result?.viewport === viewport && result?.passed === true,
),
)
) {
throw new Error();
}
} catch {
issues.push({
path: String(
browserEvidence.reportPath ?? '.agent/runtime/browser-validations',
),
reason: '双视口试玩报告无效或已过期',
});
}
return issues;
}
export async function inspectSwarmProjectArtifacts(
projectPath,
{ requireEditorImages = false, parentRunId = null } = {},
) {
const specs = requireEditorImages
? [...requiredFormalArtifactSpecs, ...editorImageArtifactSpecs]
: requiredFormalArtifactSpecs;
const issues = (
await Promise.all(
specs.map((spec) => inspectFormalArtifact(projectPath, spec)),
)
).filter(Boolean);
issues.push(
...(await inspectSwarmRuntimeAcceptance(projectPath, parentRunId)),
);
return {
valid: issues.length === 0,
requireEditorImages,
invalidPaths: issues.map((issue) => issue.path),
issues,
};
}
export async function validateSwarmProjectArtifacts(projectPath, options) {
const inspection = await inspectSwarmProjectArtifacts(projectPath, options);
if (!inspection.valid) {
throw new Error(
`Agent Swarm 已退出,但最小正式产物检查失败:\n${inspection.issues
.map((issue) => `- ${issue.path}${issue.reason}`)
.join('\n')}`,
);
}
return inspection;
}
export async function hasConfiguredEditorApiKey(configDir) {
let configured = false;
for (const fileName of [configFileName, localConfigFileName]) {
const configPath = path.join(configDir, fileName);
const metadata = await lstat(configPath).catch((error) => {
if (error?.code === 'ENOENT') return null;
throw error;
});
if (!metadata) continue;
if (!metadata.isFile() || metadata.isSymbolicLink()) {
throw new Error(`运行配置必须是无符号链接普通文件:${fileName}`);
}
let config;
try {
config = JSON.parse(await readFile(configPath, 'utf8'));
} catch (error) {
throw new Error(`解析运行配置失败:${fileName}${error.message}`);
}
if (
config?.editorApi &&
Object.prototype.hasOwnProperty.call(config.editorApi, 'apiKey') &&
typeof config.editorApi.apiKey === 'string'
) {
configured = config.editorApi.apiKey.trim().length > 0;
}
}
return configured;
}
export async function openPreviewUrl(url, platform = process.platform) {
const validated = validatePreviewUrl(url);
const command =
platform === 'win32'
? 'cmd.exe'
: platform === 'darwin'
? 'open'
: 'xdg-open';
const args =
platform === 'win32'
? ['/d', '/s', '/c', 'start', '', validated]
: [validated];
await new Promise((resolve, reject) => {
const child = spawn(command, args, {
detached: true,
stdio: 'ignore',
windowsHide: true,
});
child.once('error', reject);
child.once('spawn', () => {
child.unref();
resolve();
});
});
}
async function runPreview(
projectPath,
openBrowser,
setActiveChild,
stopRequested,
) {
const child = spawnChild(
cargoCommand,
buildCargoCliArguments(['--preview-serve', projectPath]),
{ stdio: ['ignore', 'pipe', 'pipe'] },
);
setActiveChild(child);
child.stdout.setEncoding('utf8');
child.stderr.setEncoding('utf8');
child.stdout.pipe(process.stdout);
child.stderr.pipe(process.stderr);
let pending = '';
let previewUrl = null;
let resolvePreviewUrl;
let rejectPreviewUrl;
const previewUrlReady = new Promise((resolve, reject) => {
resolvePreviewUrl = resolve;
rejectPreviewUrl = reject;
});
child.stdout.on('data', (chunk) => {
pending += chunk;
const lines = pending.split(/\r?\n/);
pending = lines.pop() ?? '';
for (const line of lines) {
if (!line.startsWith('previewUrl=')) continue;
try {
previewUrl = validatePreviewUrl(
line.slice('previewUrl='.length).trim(),
);
resolvePreviewUrl(previewUrl);
} catch (error) {
rejectPreviewUrl(error);
}
}
});
const exitPromise = childExit(child);
try {
exitPromise.then(({ code, signal }) => {
if (!previewUrl) {
rejectPreviewUrl(
new Error(
`预览进程提前退出:code=${code ?? ''} signal=${signal ?? ''}`,
),
);
}
});
const url = await previewUrlReady;
console.log(`\n试玩地址:${url}`);
console.log('按 Ctrl+C 结束预览。');
if (openBrowser) {
await openPreviewUrl(url).catch((error) => {
console.warn(
`无法自动打开浏览器,请手动访问上面的地址:${error.message}`,
);
});
}
const result = await exitPromise;
if (!stopRequested() && (result.code !== 0 || result.signal)) {
throw new Error(
`预览进程异常退出:code=${result.code ?? ''} signal=${result.signal ?? ''}`,
);
}
} finally {
if (!closedChildren.has(child)) {
await terminateChildTreeAndWait(
child,
exitPromise,
'SIGINT',
'持续预览进程',
);
}
setActiveChild(null);
}
}
export async function runSwarmTestChat(options) {
let sourceConfigDir = null;
let runtimeConfig = null;
let project = null;
let activeChild = null;
let receivedSignal = null;
let timedOut = false;
let phase = 'setup';
let runnerMayHaveStarted = false;
let turnReport = null;
let primaryError = null;
let forceTerminationHandle = null;
const setActiveChild = (child) => {
activeChild = child;
};
const concurrentChildren = new Set();
const stopRequested = () => receivedSignal !== null;
const handleSignal = (signal) => {
const repeatedSignal = receivedSignal !== null;
receivedSignal ??= signal;
const targets = [activeChild, ...concurrentChildren].filter(Boolean);
if (targets.length === 0) return;
for (const target of targets) {
void terminateChildTree(target, signal, repeatedSignal).catch(() => {});
}
if (repeatedSignal) return;
forceTerminationHandle = setTimeout(() => {
for (const target of [activeChild, ...concurrentChildren].filter(
Boolean,
)) {
void terminateChildTree(target, 'SIGKILL', true).catch(() => {});
}
}, childTerminationGraceMs);
forceTerminationHandle.unref();
};
const clearForceTermination = () => {
if (forceTerminationHandle) {
clearTimeout(forceTerminationHandle);
forceTerminationHandle = null;
}
};
const timeoutMs = resolveSwarmTestTimeoutMs(options);
const timeoutDeadline = timeoutMs === null ? null : Date.now() + timeoutMs;
const timeoutHandle =
timeoutMs === null
? null
: setTimeout(() => {
timedOut = true;
console.error(
`agc:test:chat 已达到 ${options.timeoutMinutes ?? 50} 分钟执行期限,正在安全收束。`,
);
handleSignal('SIGTERM');
}, timeoutMs);
timeoutHandle?.unref();
process.on('SIGINT', handleSignal);
process.on('SIGTERM', handleSignal);
try {
session: {
try {
sourceConfigDir = await discoverRuntimeConfigDir(options.configDir);
} catch (error) {
if (!(await askToConfigureMissingRuntime())) throw error;
await runMissingConfigWizard(setActiveChild, options.configDir);
sourceConfigDir = await discoverRuntimeConfigDir(options.configDir);
}
runtimeConfig = await prepareSwarmTestRuntimeConfig(sourceConfigDir);
if (receivedSignal) break session;
project = await prepareSwarmTestProject(options.projectDir);
if (receivedSignal) break session;
console.log(`配置来源:${path.join(sourceConfigDir, configFileName)}`);
console.log(
`隔离运行配置:${path.join(runtimeConfig.path, configFileName)}`,
);
console.log(`测试项目:${project.path}`);
if (options.dryRun) {
console.log('测试环境检查通过;未启动 LLM。');
break session;
}
console.log('\n正在检查 LLM 配置...');
const llmStatus = await runCapturedCargo(
['--config-dir', runtimeConfig.path, '--llm-status'],
setActiveChild,
);
if (receivedSignal) break session;
if (llmStatus.code !== 0 || llmStatus.signal) {
throw new Error(
`LLM 配置未就绪:${llmStatus.stderr.trim() || llmStatus.stdout.trim()}`,
);
}
console.log('LLM 配置已就绪。');
console.log(
options.task
? '已提交一条非交互游戏需求,正在等待 Swarm 自主完成。\n'
: '输入一条游戏需求并回车;提交后按 Ctrl+D,让 Swarm 自主完成。\n',
);
phase = 'chat';
runnerMayHaveStarted = true;
const chatArguments = [
'--config-dir',
runtimeConfig.path,
'--swarm-chat',
'--init',
'--autonomous-game-build',
project.path,
];
let chat;
try {
chat = options.task
? await runTaskCargo(
chatArguments,
options.task,
setActiveChild,
timeoutDeadline === null
? null
: Math.max(1, timeoutDeadline - Date.now()),
)
: await runInteractiveCargo(chatArguments, setActiveChild);
} catch (error) {
if (error?.code === 'AGC_CHILD_TIMEOUT') timedOut = true;
throw error;
}
if (receivedSignal) break session;
if (chat.code !== 0 || chat.signal) {
throw new Error(
`Agent Swarm 未正常收束:code=${chat.code ?? ''} signal=${chat.signal ?? ''}`,
);
}
if (options.task) {
turnReport = parseSettledSwarmTurnReport(chat.turnReportOutput);
}
const requireEditorImages = await hasConfiguredEditorApiKey(
runtimeConfig.path,
);
await validateSwarmProjectArtifacts(project.path, {
requireEditorImages,
parentRunId: turnReport?.parentRunId ?? null,
});
if (!shouldStartPersistentPreview(options)) {
phase = 'complete';
console.log(
`\n真实 Swarm 测试通过:正式产物${
requireEditorImages ? '及画布图片' : ''
}已验收,Runtime 已完成静态检查和双视口试玩。`,
);
break session;
}
phase = 'preview';
console.log('\nAgent Swarm 已收束,正在启动试玩...');
await runPreview(
project.path,
options.openBrowser,
setActiveChild,
stopRequested,
);
phase = 'complete';
}
} catch (error) {
primaryError = error;
} finally {
if (timeoutHandle) clearTimeout(timeoutHandle);
clearForceTermination();
let cleanupError = null;
let runnerStopped = true;
if (runtimeConfig && runnerMayHaveStarted) {
try {
runnerStopped = await shutdownSwarmTestRunner(
runtimeConfig,
setActiveChild,
);
if (!runnerStopped) {
cleanupError = new Error(
'隔离 Agent Runner 仍有任务,已保留测试项目和隔离运行配置',
);
}
} catch (error) {
runnerStopped = false;
cleanupError = error;
}
}
const preserveFailedRun =
project?.owned &&
(phase === 'chat' ||
phase === 'artifact-validation' ||
(phase === 'preview' && !receivedSignal));
const preserveForRunner = project?.owned && !runnerStopped;
if (
project?.owned &&
(options.keepProject || preserveFailedRun || preserveForRunner)
) {
if (preserveFailedRun && !options.keepProject) {
console.warn(
'测试尚未正常结束,为避免删除后台任务或失败证据,测试项目不会自动清理。',
);
}
console.log(`已保留测试项目:${project.path}`);
} else if (project?.owned) {
try {
await cleanupSwarmTestProject(project);
console.log('已清理一次性测试项目。');
} catch (error) {
cleanupError ??= error;
}
}
if (runtimeConfig) {
if (runnerStopped) {
try {
await cleanupSwarmTestRuntimeConfig(runtimeConfig);
console.log('已清理隔离运行配置。');
} catch (error) {
cleanupError ??= error;
}
} else {
console.warn(`已保留隔离运行配置:${runtimeConfig.path}`);
}
}
if (cleanupError) {
if (primaryError) {
console.warn(`测试现场清理未完成:${cleanupError.message}`);
} else {
primaryError = cleanupError;
}
}
process.off('SIGINT', handleSignal);
process.off('SIGTERM', handleSignal);
}
if (timedOut) {
if (primaryError) {
console.warn(`超时收束附带错误:${primaryError.message}`);
}
primaryError = Object.assign(new Error('真实 Swarm 测试超过内部执行期限'), {
exitCode: 124,
});
} else if (receivedSignal) {
if (primaryError) {
console.warn(`信号收束附带错误:${primaryError.message}`);
}
primaryError = Object.assign(
new Error(`真实 Swarm 测试收到 ${receivedSignal}`),
{ exitCode: receivedSignal === 'SIGINT' ? 130 : 143 },
);
}
if (primaryError) throw primaryError;
}
async function main() {
const options = parseSwarmTestArguments(process.argv.slice(2));
if (options.help) {
console.log(usage);
return;
}
await runSwarmTestChat(options);
}
const entryPath = process.argv[1]
? pathToFileURL(path.resolve(process.argv[1])).href
: '';
if (entryPath === import.meta.url) {
main().catch((error) => {
console.error(`agc:test:chat 失败:${error.message}`);
process.exitCode = Number.isInteger(error.exitCode) ? error.exitCode : 1;
});
}