Files
Genarrative/apps/ai-game-creator-shell/scripts/agent-runtime-deterministic-playable-e2e.mjs
T
AIGameCreator App 167b11d52e 完善自主 Swarm 无干预可玩交付
固定首批程序实现与质量只读职责,并在计划执行前阻断质量 Agent 项目写入
要求非只读专业 Agent 完成本人修改和对应 revision 验证后再交付
补齐 Provider 瞬态重试、最终回复和 Swarm CLI 终态收敛
升级 Provider action batch v3 并兼容 v2/v1 持久恢复
扩展确定性与真实外部 Provider 塔防验收、前端状态和项目文档
2026-07-23 01:06:54 +08:00

621 lines
22 KiB
JavaScript

import { spawn } from 'node:child_process';
import { createHash, randomUUID } from 'node:crypto';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
createDeterministicLaneDefenseRouter,
deterministicLaneDefenseInitialHtml,
deterministicLaneDefenseModel,
hiddenCanvasCss,
startDeterministicLaneDefenseProvider,
visibleCanvasCss,
} from './deterministic-lane-defense-provider.mjs';
import { withLoopbackNoProxy } from './llm-transient-fault-proxy.mjs';
const appRoot = path.resolve(fileURLToPath(new URL('..', import.meta.url)));
const repoRoot = path.resolve(appRoot, '../..');
const realE2eScript = path.join(appRoot, 'scripts/agent-runtime-real-e2e.mjs');
const suite = 'supervisor-autonomous-playable-lane-defense';
const wrapperSuite =
'supervisor-autonomous-playable-lane-defense-deterministic';
const configFileName = 'game-creator.config.json';
const configSentinelName = '.deterministic-provider-e2e.json';
const configSentinelSchema = 'genarrative-deterministic-provider-e2e-config.v1';
const outputLimit = 32 * 1024 * 1024;
function hashValue(value) {
return createHash('sha256').update(value).digest('hex');
}
function assert(condition, code) {
if (condition) return;
const error = new Error(code);
error.code = code;
throw error;
}
function parseArguments(args) {
let keepProject = false;
let selfTest = false;
for (const arg of args) {
if (arg === '--keep-project') keepProject = true;
else if (arg === '--self-test') selfTest = true;
else throw new Error('unknown-argument');
}
assert(!(keepProject && selfTest), 'self-test-keep-project-conflict');
return { keepProject, selfTest };
}
function appendBounded(current, chunk) {
const combined = Buffer.concat([current, chunk]);
if (combined.length > outputLimit) throw new Error('child-output-too-large');
return combined;
}
function runChild(args, environment) {
return new Promise((resolve) => {
const child = spawn(process.execPath, args, {
cwd: appRoot,
env: environment,
stdio: ['ignore', 'pipe', 'pipe'],
});
let stdout = Buffer.alloc(0);
let stderr = Buffer.alloc(0);
let outputError = null;
child.stdout.on('data', (chunk) => {
try {
stdout = appendBounded(stdout, chunk);
} catch (error) {
outputError = error;
child.kill('SIGTERM');
}
});
child.stderr.on('data', (chunk) => {
try {
stderr = appendBounded(stderr, chunk);
} catch (error) {
outputError = error;
child.kill('SIGTERM');
}
});
child.once('error', (error) =>
resolve({ code: null, signal: null, error, stdout, stderr }),
);
child.once('close', (code, signal) =>
resolve({ code, signal, error: outputError, stdout, stderr }),
);
});
}
function safeChildDiagnostic(result) {
return {
exitCode: Number.isInteger(result.code) ? result.code : null,
signal: typeof result.signal === 'string' ? result.signal : null,
errorCode:
typeof result.error?.code === 'string' ? result.error.code : null,
stdoutBytes: result.stdout.length,
stdoutSha256: hashValue(result.stdout),
stderrBytes: result.stderr.length,
stderrSha256: hashValue(result.stderr),
};
}
function parseChildReport(result) {
try {
const report = JSON.parse(result.stdout.toString('utf8'));
assert(report && typeof report === 'object', 'child-report-root-invalid');
return report;
} catch (error) {
if (error?.code) throw error;
const wrapped = new Error('child-report-json-invalid');
wrapped.code = 'child-report-json-invalid';
throw wrapped;
}
}
function responsibilityContractEvidence(stats) {
const qualityPlanningCount =
stats?.byAgent?.['quality-review']?.planning ?? null;
const evidence = {
delegationContractCount: stats?.delegationContractCount ?? null,
delegationContractViolationCount:
stats?.delegationContractViolationCount ?? null,
codePrototypeDelegationCount: stats?.codePrototypeDelegationCount ?? null,
codePrototypeExpectedArtifactCount:
stats?.codePrototypeExpectedArtifactCount ?? null,
codePrototypeGameIndexArtifactDelegationCount:
stats?.codePrototypeGameIndexArtifactDelegationCount ?? null,
codePrototypeProjectMutationCount:
stats?.byAgent?.['code-prototype']?.projectMutation ?? null,
qualityReviewDelegationCount: stats?.qualityReviewDelegationCount ?? null,
qualityReviewReadOnlyDelegationCount:
stats?.qualityReviewReadOnlyDelegationCount ?? null,
qualityReviewExpectedArtifactCount:
stats?.qualityReviewExpectedArtifactCount ?? null,
qualityReviewProjectMutationCount:
stats?.byAgent?.['quality-review']?.projectMutation ?? null,
qualityPlanningCount,
qualityRevisionReplanCount: stats?.qualityRevisionReplanCount ?? null,
};
return {
...evidence,
codeOwnsGameIndex:
evidence.codePrototypeDelegationCount === 2 &&
evidence.codePrototypeExpectedArtifactCount === 2 &&
evidence.codePrototypeGameIndexArtifactDelegationCount === 2,
qualityIsReadOnly:
evidence.qualityReviewDelegationCount === 1 &&
evidence.qualityReviewReadOnlyDelegationCount === 1 &&
evidence.qualityReviewExpectedArtifactCount === 0 &&
evidence.qualityReviewProjectMutationCount === 0,
qualityIndependentOfCodeTiming:
(qualityPlanningCount === 1 || qualityPlanningCount === 2) &&
evidence.qualityRevisionReplanCount === qualityPlanningCount - 1,
contractViolationFree:
evidence.delegationContractCount === 3 &&
evidence.delegationContractViolationCount === 0,
};
}
function expectedProviderStats(stats) {
const responsibility = responsibilityContractEvidence(stats);
const qualityPlanningCount = responsibility.qualityPlanningCount;
return (
(qualityPlanningCount === 1 || qualityPlanningCount === 2) &&
stats.requestCount === 15 + qualityPlanningCount &&
stats.planningRequestCount === 15 + qualityPlanningCount &&
stats.finalReplyRequestCount === 0 &&
stats.initialDelegationCount === 2 &&
stats.followupDelegationCount === 1 &&
stats.runStatusCount === 2 &&
stats.sourceWriteCount === 1 &&
stats.sourcePatchCount === 1 &&
stats.staticSmokeCount === 4 &&
stats.previewValidationCount === 2 &&
stats.supervisorDirectMutationAttemptCount === 1 &&
stats.unexpectedRequestCount === 0 &&
Object.keys(stats.rejectionCodes ?? {}).length === 0 &&
stats.byAgent?.['project-supervisor']?.planning === 9 &&
stats.byAgent?.['project-supervisor']?.finalReply === 0 &&
stats.byAgent?.['project-supervisor']?.projectMutation === 1 &&
stats.byAgent?.['code-prototype']?.planning === 6 &&
stats.byAgent?.['code-prototype']?.finalReply === 0 &&
stats.byAgent?.['code-prototype']?.projectMutation === 2 &&
stats.byAgent?.['quality-review']?.finalReply === 0 &&
responsibility.codeOwnsGameIndex &&
responsibility.qualityIsReadOnly &&
responsibility.qualityIndependentOfCodeTiming &&
responsibility.contractViolationFree
);
}
const requiredZeroChildEvidenceFields = Object.freeze([
'activeRunnerKillCount',
'approveInputCount',
'answerInputCount',
'steerInputCount',
'turnReportWaitingForConfirmationCount',
'turnReportWaitingForUserInputCount',
'turnReportReconciliationAgentCount',
'providerLifecycleFailedCount',
'openProviderLifecycleCount',
'pendingActionCount',
'confirmationSidecarCount',
'userInputSidecarCount',
'providerActionBatchSidecarCount',
'providerRetrySidecarCount',
'providerHandoffSidecarCount',
'toolPlanHandoffSidecarCount',
'finalizationJournalCount',
'reconciliationResidueCount',
]);
function expectedChildReport(report, options, providerStats) {
const evidence = report?.evidence;
const providerRequestCount = providerStats?.requestCount;
return (
report?.status === 'PASS' &&
report?.suite === suite &&
report?.errorCount === 0 &&
Array.isArray(report?.blocked) &&
report.blocked.length === 0 &&
report?.cleanup?.performed === !options.keepProject &&
report?.cleanup?.kept === options.keepProject &&
evidence?.evidenceCompleteness === 'complete' &&
evidence?.dedicatedZeroInterventionPath === true &&
evidence?.stdinTaskCount === 1 &&
evidence?.stdinEndedAfterTask === true &&
evidence?.turnReportOutcome === 'settled' &&
evidence?.parentTaskStatus === 'completed' &&
evidence?.parentRuntimeStatus === 'idle' &&
evidence?.parentRuntimePhase === 'completed' &&
evidence?.laneDefensePlaytestPassed === true &&
evidence?.laneDefenseAssertionCount === 37 &&
evidence?.laneDefensePassedAssertionCount === 37 &&
evidence?.browserValidationPassed === true &&
evidence?.staticSmokePassed === true &&
evidence?.gameIndexChanged === true &&
Number.isInteger(evidence?.projectRevisionDelta) &&
evidence.projectRevisionDelta > 0 &&
evidence?.finalSupervisorAssistantCount === 1 &&
evidence?.professionalAssistantCount === 3 &&
evidence?.providerRequestIdentityCount === providerRequestCount &&
evidence?.providerLifecycleStartedCount === providerRequestCount &&
evidence?.providerLifecycleTerminalCount === providerRequestCount &&
evidence?.providerLifecycleCompletedCount === providerRequestCount &&
requiredZeroChildEvidenceFields.every((field) => evidence?.[field] === 0)
);
}
function syntheticPayload(agentId, runId, tools, extraContext = '') {
return {
model: deterministicLaneDefenseModel,
stream: false,
messages: [
{
role: 'user',
content: `- templateAgentId: ${agentId}\n- runId: ${runId}\n${extraContext}`,
},
],
tools: tools.map((name) => ({ type: 'function', function: { name } })),
};
}
function responseFunctionNames(response) {
return response.choices[0].message.tool_calls.map(
(call) => call.function.name,
);
}
function responseFunctionCalls(response) {
return response.choices[0].message.tool_calls.map((call) => ({
name: call.function.name,
arguments: JSON.parse(call.function.arguments),
}));
}
async function runSelfTest() {
const html = deterministicLaneDefenseInitialHtml();
assert([...html].length <= 8_000, 'self-test-html-source-budget-invalid');
assert(
html.includes(hiddenCanvasCss) &&
!html.includes(visibleCanvasCss) &&
html.includes('playable-web-game-state.v1') &&
html.includes('data-playtest-id="next-level"') &&
html.includes('Goal: defend the garden and win every wave.') &&
html.includes('requestAnimationFrame'),
'self-test-html-contract-invalid',
);
const apiKey = `deterministic-self-test-${randomUUID()}`;
const router = createDeterministicLaneDefenseRouter({ apiKey });
const allTools = [
'update_agent_plan',
'respond_to_user',
'runtime_tool_agent_delegate',
'runtime_tool_agent_run_status',
'runtime_tool_command_run_limited',
'runtime_tool_file_patch',
'runtime_tool_file_write',
'runtime_tool_preview_validate',
];
const route = (agentId, runId, tools = allTools, extraContext = '') =>
router.route({
authorization: `Bearer ${apiKey}`,
payload: syntheticPayload(agentId, runId, tools, extraContext),
});
const initialDelegationResponse = route('project-supervisor', 'parent-run');
const initialDelegationCalls = responseFunctionCalls(
initialDelegationResponse,
);
assert(
initialDelegationCalls.map((call) => call.name).join(',') ===
'runtime_tool_agent_delegate,runtime_tool_agent_delegate',
'self-test-initial-delegation-invalid',
);
const initialCodeDelegation = initialDelegationCalls[0]?.arguments?.input;
const initialQualityDelegation = initialDelegationCalls[1]?.arguments?.input;
assert(
initialCodeDelegation?.agentId === 'code-prototype' &&
JSON.stringify(initialCodeDelegation.expectedArtifacts) ===
JSON.stringify(['game/index.html']) &&
initialQualityDelegation?.agentId === 'quality-review' &&
initialQualityDelegation.task.includes('只读验收') &&
initialQualityDelegation.task.includes('不要修改任何文件') &&
initialQualityDelegation.task.includes(
'不要读取或依赖并行 code-prototype',
) &&
Array.isArray(initialQualityDelegation.expectedArtifacts) &&
initialQualityDelegation.expectedArtifacts.length === 0,
'self-test-initial-delegation-contract-invalid',
);
const qualityPlan =
'计划进度:\n- #1 [in_progress] 核对完整玩法\n- #2 [pending] 回传结论\n工具策略:auto=无';
const builderPlan =
'计划进度:\n- #1 [completed] 生成入口\n- #2 [completed] 静态验证\n- #3 [in_progress] 回传结论\n工具策略:auto=无';
assert(
responseFunctionNames(
route('quality-review', 'quality-run', allTools, qualityPlan),
).join(',') === 'update_agent_plan,respond_to_user',
'self-test-quality-read-only-invalid',
);
route('code-prototype', 'initial-code-run');
route('code-prototype', 'initial-code-run');
assert(
responseFunctionNames(
route('code-prototype', 'initial-code-run', allTools, builderPlan),
).join(',') === 'update_agent_plan,respond_to_user',
'self-test-builder-plan-completion-invalid',
);
assert(
responseFunctionNames(
route('quality-review', 'quality-run', allTools, qualityPlan),
).join(',') === 'update_agent_plan,respond_to_user',
'self-test-quality-stale-replan-invalid',
);
route('project-supervisor', 'parent-run');
route('project-supervisor', 'parent-run');
route('project-supervisor', 'parent-run');
assert(
responseFunctionNames(route('project-supervisor', 'parent-run'))[0] ===
'runtime_tool_file_patch',
'self-test-forbidden-parent-mutation-invalid',
);
const followupDelegationResponse = route(
'project-supervisor',
'parent-run',
['runtime_tool_agent_delegate'],
'当前父 run 已进入只编排模式,本次修复的原生工具目录只保留 agent.delegate',
);
const followupDelegationCall = responseFunctionCalls(
followupDelegationResponse,
)[0];
assert(
followupDelegationCall?.name === 'runtime_tool_agent_delegate' &&
followupDelegationCall.arguments?.input?.agentId === 'code-prototype' &&
JSON.stringify(
followupDelegationCall.arguments.input.expectedArtifacts,
) === JSON.stringify(['game/index.html']),
'self-test-followup-delegation-invalid',
);
route('code-prototype', 'repair-code-run');
route('code-prototype', 'repair-code-run');
route('code-prototype', 'repair-code-run', allTools, builderPlan);
assert(
responseFunctionNames(route('project-supervisor', 'parent-run'))[0] ===
'runtime_tool_agent_run_status',
'self-test-repair-claim-before-verification-invalid',
);
assert(
responseFunctionNames(route('project-supervisor', 'parent-run')).join(
',',
) === 'runtime_tool_command_run_limited,runtime_tool_preview_validate',
'self-test-repair-verification-batch-invalid',
);
route('project-supervisor', 'parent-run');
const stats = router.getStats();
assert(expectedProviderStats(stats), 'self-test-provider-stats-invalid');
const responsibilityContract = responsibilityContractEvidence(stats);
assert(
responsibilityContract.codeOwnsGameIndex &&
responsibilityContract.qualityIsReadOnly &&
responsibilityContract.qualityIndependentOfCodeTiming &&
responsibilityContract.contractViolationFree,
'self-test-responsibility-contract-invalid',
);
const singlePassQualityStats = structuredClone(stats);
singlePassQualityStats.requestCount -= 1;
singlePassQualityStats.planningRequestCount -= 1;
singlePassQualityStats.qualityRevisionReplanCount = 0;
singlePassQualityStats.byAgent['quality-review'].planning = 1;
assert(
expectedProviderStats(singlePassQualityStats),
'self-test-quality-single-pass-timing-invalid',
);
const syntheticChildReport = {
status: 'PASS',
suite,
errorCount: 0,
blocked: [],
cleanup: { performed: true, kept: false },
evidence: {
...Object.fromEntries(
requiredZeroChildEvidenceFields.map((field) => [field, 0]),
),
evidenceCompleteness: 'complete',
dedicatedZeroInterventionPath: true,
stdinTaskCount: 1,
stdinEndedAfterTask: true,
turnReportOutcome: 'settled',
parentTaskStatus: 'completed',
parentRuntimeStatus: 'idle',
parentRuntimePhase: 'completed',
laneDefensePlaytestPassed: true,
laneDefenseAssertionCount: 37,
laneDefensePassedAssertionCount: 37,
browserValidationPassed: true,
staticSmokePassed: true,
gameIndexChanged: true,
projectRevisionDelta: 2,
finalSupervisorAssistantCount: 1,
professionalAssistantCount: 3,
providerRequestIdentityCount: stats.requestCount,
providerLifecycleStartedCount: stats.requestCount,
providerLifecycleTerminalCount: stats.requestCount,
providerLifecycleCompletedCount: stats.requestCount,
},
};
const incompletePlaytestReport = structuredClone(syntheticChildReport);
incompletePlaytestReport.evidence.laneDefensePassedAssertionCount = 36;
const manualInputReport = structuredClone(syntheticChildReport);
manualInputReport.evidence.approveInputCount = 1;
const residualSidecarReport = structuredClone(syntheticChildReport);
residualSidecarReport.evidence.providerHandoffSidecarCount = 1;
assert(
expectedChildReport(syntheticChildReport, { keepProject: false }, stats) &&
!expectedChildReport(
incompletePlaytestReport,
{ keepProject: false },
stats,
) &&
!expectedChildReport(manualInputReport, { keepProject: false }, stats) &&
!expectedChildReport(
residualSidecarReport,
{ keepProject: false },
stats,
),
'self-test-child-hard-gates-invalid',
);
const rootPackage = JSON.parse(
await fs.readFile(path.join(repoRoot, 'package.json'), 'utf8'),
);
const shellPackage = JSON.parse(
await fs.readFile(path.join(appRoot, 'package.json'), 'utf8'),
);
assert(
shellPackage.scripts?.[
'agent-runtime:supervisor-autonomous-playable-lane-defense-deterministic-e2e'
] === 'node scripts/agent-runtime-deterministic-playable-e2e.mjs' &&
rootPackage.scripts?.[
'ai-game-creator-shell:agent-runtime:supervisor-autonomous-playable-lane-defense-deterministic-e2e'
] ===
'npm --prefix apps/ai-game-creator-shell run agent-runtime:supervisor-autonomous-playable-lane-defense-deterministic-e2e --',
'self-test-package-command-invalid',
);
return {
status: 'PASS',
suite: `${wrapperSuite}-self-test`,
providerUsed: false,
htmlChars: [...html].length,
providerStats: stats,
responsibilityContract,
qualityTimingOrdersValidated: ['before-code', 'after-code'],
childHardGatesValidated: true,
packageCommandsRegistered: true,
};
}
async function runE2e(options) {
const token = randomUUID();
const apiKey = `deterministic-runtime-${randomUUID()}`;
const configDir = await fs.mkdtemp(
path.join(os.tmpdir(), 'genarrative-deterministic-provider-config-'),
);
let provider = null;
let childResult = null;
let childReport = null;
let configRemoved = false;
let providerStats = null;
let failureCode = null;
try {
if (process.platform !== 'win32') await fs.chmod(configDir, 0o700);
await fs.writeFile(
path.join(configDir, configSentinelName),
`${JSON.stringify({ schemaVersion: configSentinelSchema, token })}\n`,
{ flag: 'wx', mode: 0o600 },
);
provider = await startDeterministicLaneDefenseProvider({ apiKey });
const config = {
llm: {
apiKey,
baseUrl: provider.baseUrl,
model: deterministicLaneDefenseModel,
apiKind: 'openai_chat',
reasoningEffort: 'low',
stream: false,
requestTimeoutMs: 30_000,
maxRetries: 0,
retryBackoffMs: 100,
},
};
await fs.writeFile(
path.join(configDir, configFileName),
`${JSON.stringify(config)}\n`,
{ flag: 'wx', mode: 0o600 },
);
const childArgs = [
realE2eScript,
'--suite',
suite,
'--config-dir',
configDir,
];
if (options.keepProject) childArgs.push('--keep-project');
childResult = await runChild(
childArgs,
withLoopbackNoProxy({ ...process.env, NO_COLOR: '1' }),
);
childReport = parseChildReport(childResult);
} catch (error) {
failureCode = error?.code ?? 'deterministic-e2e-unexpected-error';
} finally {
if (provider) {
try {
await provider.stop();
providerStats = provider.getStats();
} catch {
failureCode ??= 'deterministic-provider-stop-failed';
}
}
try {
const sentinel = JSON.parse(
await fs.readFile(path.join(configDir, configSentinelName), 'utf8'),
);
assert(
sentinel.schemaVersion === configSentinelSchema &&
sentinel.token === token,
'deterministic-config-sentinel-invalid',
);
await fs.rm(configDir, { recursive: true, force: false });
configRemoved = true;
} catch (error) {
failureCode ??= error?.code ?? 'deterministic-config-cleanup-failed';
}
}
const childPassed =
childResult?.code === 0 &&
childResult?.signal === null &&
!childResult?.error &&
expectedChildReport(childReport, options, providerStats);
const providerPassed =
providerStats?.stopped === true && expectedProviderStats(providerStats);
const responsibilityContract = responsibilityContractEvidence(providerStats);
const status =
!failureCode && childPassed && providerPassed && configRemoved
? 'PASS'
: 'FAIL';
if (status !== 'PASS' && !failureCode) {
failureCode = !childPassed
? 'deterministic-child-e2e-failed'
: !providerPassed
? 'deterministic-provider-contract-failed'
: 'deterministic-config-not-cleaned';
}
return {
status,
suite: wrapperSuite,
providerMode: 'deterministic-loopback-openai-chat',
delegatedSuite: suite,
child: childReport,
provider: providerStats,
responsibilityContract,
cleanup: {
providerStopped: providerStats?.stopped === true,
configRemoved,
projectKept: options.keepProject,
},
childDiagnostic: childResult ? safeChildDiagnostic(childResult) : null,
failureCode,
};
}
const options = parseArguments(process.argv.slice(2));
const report = options.selfTest ? await runSelfTest() : await runE2e(options);
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
process.exitCode = report.status === 'PASS' ? 0 : 1;