From dedff81475e8b44b7942708872a6663e6ff5d3cd Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Sat, 15 Aug 2026 15:12:05 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8DAGC=E6=97=A0=E4=BA=BA?= =?UTF-8?q?=E5=80=BC=E5=AE=88=E7=94=9F=E6=88=90=E9=98=BB=E6=96=AD=E7=9A=84?= =?UTF-8?q?=E4=BA=A4=E4=BB=98=E6=94=B6=E5=8F=A3=E4=B8=8E=E9=AA=8C=E6=94=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 可信 code-prototype 父 Run 认领并观察直属美术 delivery,普通失败立即收束,合法安全默认 marker 由 Runtime 确定性执行唯一同合同返工 - 补齐 suppressed 无 child 与父身份链丢失时的 completion 失败关闭边界 - 修复 Windows project.lock delete-pending 竞争并收紧 HTML 内联 JS 语法 fail-open - 同步技术方案、决策记录与实施计划,并完成确定性及真实 Provider 分层验证 --- ...ent-runtime-deterministic-playable-e2e.mjs | 1282 +++++++++++---- .../assertions/runtime.mjs | 36 +- .../scripts/agent-runtime-real-e2e/entry.mjs | 173 +- .../harness/app-data.mjs | 637 +++++++- .../harness/process.mjs | 317 +++- .../harness/project.mjs | 173 +- .../harness/reporting.mjs | 33 + .../harness/runtime.mjs | 12 + .../agent-runtime-real-e2e/runtime-state.mjs | 88 +- .../suites/self-test.mjs | 1444 ++++++++++++++++- .../suites/supervisor-autonomous-playable.mjs | 675 +++++++- .../suites/supervisor-swarm/persistence.mjs | 55 +- .../scripts/check-config.mjs | 1 + .../deterministic-lane-defense-provider.mjs | 1368 ++++++++-------- .../src-tauri/src/agent/codex_app_server.rs | 22 +- .../src-tauri/src/agent/codex_cli.rs | 11 + .../src-tauri/src/agent/interaction.rs | 70 +- .../agent/runtime_actions/action_execution.rs | 174 ++ .../runtime_actions/autonomous_policy.rs | 512 +++++- .../agent/runtime_actions/project_gates.rs | 13 +- .../runtime_actions/provider_action_batch.rs | 10 +- .../provider_request_builders.rs | 253 ++- .../runtime_actions/provider_tool_plan.rs | 53 +- .../runtime_actions/run_status_observation.rs | 2 +- .../agent/runtime_actions/structured_plan.rs | 29 +- .../runtime_driver/game_chat_fast_path.rs | 67 +- .../src/agent/runtime_driver/main_loop.rs | 97 +- .../agent/runtime_driver/main_loop_tests.rs | 1061 +++++++++++- .../src/agent/runtime_driver/task_start.rs | 5 + .../runtime_protocol/autonomous_completion.rs | 46 +- .../autonomous_completion_contract_tests.rs | 21 +- .../agent/runtime_protocol/provider_retry.rs | 20 + .../src/agent/runtime_tools/delivery.rs | 167 ++ .../src/agent/runtime_tools/run_status.rs | 34 +- .../src-tauri/src/agent_native_tools.rs | 2 +- .../src-tauri/src/assets.rs | 53 +- .../src-tauri/src/config.rs | 3 +- .../src-tauri/src/delegation.rs | 19 +- .../src-tauri/src/project/filesystem.rs | 28 +- .../src-tauri/src/project/manifest.rs | 88 +- .../src/project/manifest/recovery_tests.rs | 110 ++ .../tests/collaboration/static_deliveries.rs | 6 +- .../collaboration/supervisor_planning.rs | 5 + .../src-tauri/src/tests/configuration.rs | 14 +- .../src-tauri/src/tests/mod.rs | 9 + .../src-tauri/src/tests/project.rs | 8 +- .../src-tauri/src/tests/project_tools.rs | 67 + .../src-tauri/src/tests/provider.rs | 4 + .../planning_strategy/autonomous_build.rs | 11 +- .../src/tests/runtime_actions/support.rs | 2 +- .../src-tauri/src/tests/runtime_state.rs | 98 ++ .../src-tauri/src/tests/sessions.rs | 4 +- apps/ai-game-creator-shell/src/app/types.ts | 1 + .../appSurface/runtime-settings.suite.ts | 25 +- ...】AGC无人值守游戏生成可靠性收口-2026-08-13.md | 13 +- .../shared-memory/decision-log.md | 29 + .../shared-memory/development-workflow.md | 2 + ...案】AI游戏创作Agent Runtime V1.1-2026-07-12.md | 10 +- ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 21 +- .../crates/agent-runtime-core/src/provider.rs | 1 + .../tests/provider_registry.rs | 18 + server-rs/crates/platform-llm/src/lib.rs | 6 +- .../platform-llm/src/provider_adapter.rs | 21 +- 63 files changed, 8237 insertions(+), 1402 deletions(-) diff --git a/apps/ai-game-creator-shell/scripts/agent-runtime-deterministic-playable-e2e.mjs b/apps/ai-game-creator-shell/scripts/agent-runtime-deterministic-playable-e2e.mjs index 0bf11ca70..89f552dc7 100644 --- a/apps/ai-game-creator-shell/scripts/agent-runtime-deterministic-playable-e2e.mjs +++ b/apps/ai-game-creator-shell/scripts/agent-runtime-deterministic-playable-e2e.mjs @@ -7,6 +7,7 @@ import { fileURLToPath } from 'node:url'; import { createDeterministicLaneDefenseRouter, + deterministicLaneDefenseCanonicalHtml, deterministicLaneDefenseInitialHtml, deterministicLaneDefenseModel, deterministicManifestReadyAgentIds, @@ -50,6 +51,32 @@ function parseArguments(args) { return { keepProject, selfTest }; } +function deterministicRuntimeConfig(apiKey, provider) { + return { + agentMode: 'provider', + editorApi: { + apiKey, + baseUrl: provider.editorBaseUrl, + }, + llm: { + apiKey, + baseUrl: provider.baseUrl, + model: deterministicLaneDefenseModel, + apiKind: 'openai_chat', + reasoningEffort: 'max', + stream: false, + requestTimeoutMs: 30_000, + maxRetries: 0, + retryBackoffMs: 100, + }, + agentLlm: { + 'project-supervisor': { + reasoningEffort: 'max', + }, + }, + }; +} + function appendBounded(current, chunk) { const combined = Buffer.concat([current, chunk]); if (combined.length > outputLimit) throw new Error('child-output-too-large'); @@ -117,71 +144,14 @@ function parseChildReport(result) { } } -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, - artAssetPlanDelegationCount: stats?.artAssetPlanDelegationCount ?? null, - artAssetPlanExpectedArtifactCount: - stats?.artAssetPlanExpectedArtifactCount ?? null, - artAssetPlanArtifactContractCount: - stats?.artAssetPlanArtifactContractCount ?? null, - qualityReviewProjectMutationCount: - stats?.byAgent?.['quality-review']?.projectMutation ?? null, - qualityPlanningCount, - qualityRevisionReplanCount: stats?.qualityRevisionReplanCount ?? null, - }; - return { - ...evidence, - codeOwnsGameIndex: - Number.isInteger(evidence.codePrototypeDelegationCount) && - evidence.codePrototypeDelegationCount >= 1 && - evidence.codePrototypeExpectedArtifactCount === - evidence.codePrototypeDelegationCount && - evidence.codePrototypeGameIndexArtifactDelegationCount === - evidence.codePrototypeDelegationCount && - Number.isInteger(evidence.codePrototypeProjectMutationCount) && - evidence.codePrototypeProjectMutationCount >= 2, - qualityIsReadOnly: - evidence.qualityReviewDelegationCount === 1 && - evidence.qualityReviewReadOnlyDelegationCount === 1 && - evidence.qualityReviewExpectedArtifactCount === 0 && - evidence.qualityReviewProjectMutationCount === 0, - qualityIndependentOfCodeTiming: - Number.isInteger(qualityPlanningCount) && - qualityPlanningCount >= 1 && - Number.isInteger(evidence.qualityRevisionReplanCount) && - evidence.qualityRevisionReplanCount >= 0 && - evidence.qualityRevisionReplanCount <= 1, - artOwnsGeneratedAsset: - evidence.artAssetPlanDelegationCount === 1 && - evidence.artAssetPlanExpectedArtifactCount === 2 && - evidence.artAssetPlanArtifactContractCount === 1, - contractViolationFree: - Number.isInteger(evidence.delegationContractCount) && - evidence.delegationContractCount >= 3 && - evidence.delegationContractViolationCount === 0, - }; -} - function expectedProviderStats(stats) { - const responsibility = responsibilityContractEvidence(stats); const manifestReadyTasks = manifestReadyTaskEvidence(stats); + const projectSupervisorRuntimePlanningCount = + Number.isInteger(stats.byAgent?.['project-supervisor']?.planning) && + Number.isInteger(stats.interactionExecuteCount) + ? stats.byAgent['project-supervisor'].planning - + stats.interactionExecuteCount + : null; return ( Number.isInteger(stats.requestCount) && stats.requestCount >= 40 && @@ -191,38 +161,40 @@ function expectedProviderStats(stats) { (stats.imageInspectionRequestCount ?? 0) === stats.requestCount && stats.finalReplyRequestCount === 0 && - stats.initialDelegationCount === 3 && - stats.followupDelegationCount === 0 && + stats.interactionExecuteCount === 1 && + stats.goalContractCount === 1 && + stats.actionHistoryCount === 1 && + stats.acceptanceUpdateCount === 1 && + stats.delegateActionCount === 0 && stats.runStatusCount >= 1 && stats.sourceWriteCount >= 7 && stats.staticSmokeCount >= 4 && stats.previewValidationCount >= 2 && - stats.supervisorDirectMutationAttemptCount === 0 && manifestReadyTasks.exactlyOnce && stats.manifestReadyTaskFileWriteCount >= 7 && stats.manifestReadyTaskPreviewValidationCount >= 1 && - stats.manifestReadyTaskCanvasGenerationCount >= 1 && - stats.manifestReadyTaskCanvasGenerationCount <= 2 && + stats.manifestReadyTaskCanvasGenerationCount >= + stats.canvasGenerationRequestCount && + stats.manifestReadyTaskCanvasGenerationCount <= + stats.canvasGenerationRequestCount + 16 && stats.imageInspectionRequestCount === 1 && - stats.canvasGenerationRequestCount === 2 && - stats.canvasDownloadRequestCount === 2 && + stats.canvasGenerationRequestCount === 3 && + stats.canvasDownloadRequestCount === 3 && + stats.canvasSliceDownloadRequestCount === 4 && stats.canvasGeneratedAspectRatios?.['16:9'] === 1 && - stats.canvasGeneratedAspectRatios?.['1:1'] === 1 && + stats.canvasGeneratedAspectRatios?.['1:1'] === 2 && stats.canonicalCodeRunCount === 1 && + stats.canonicalCodeAssetListCount === 1 && stats.unexpectedRequestCount === 0 && Object.keys(stats.rejectionCodes ?? {}).length === 0 && - stats.byAgent?.['project-supervisor']?.planning >= 5 && + projectSupervisorRuntimePlanningCount >= 5 && stats.byAgent?.['project-supervisor']?.finalReply === 0 && stats.byAgent?.['project-supervisor']?.projectMutation === 0 && - stats.byAgent?.['code-prototype']?.planning >= 6 && + stats.byAgent?.['code-prototype']?.planning >= 5 && stats.byAgent?.['code-prototype']?.finalReply === 0 && - stats.byAgent?.['code-prototype']?.projectMutation >= 2 && + stats.byAgent?.['code-prototype']?.projectMutation === 1 && stats.byAgent?.['quality-review']?.finalReply === 0 && - responsibility.codeOwnsGameIndex && - responsibility.qualityIsReadOnly && - responsibility.qualityIndependentOfCodeTiming && - responsibility.artOwnsGeneratedAsset && - responsibility.contractViolationFree + stats.byAgent?.['quality-review']?.projectMutation === 0 ); } @@ -362,6 +334,53 @@ function syntheticPayload(agentId, runId, tools, extraContext = '') { }; } +function syntheticInteractionExecutePayload() { + return { + model: deterministicLaneDefenseModel, + stream: false, + reasoning_effort: 'max', + messages: [ + { + role: 'system', + content: '你现在位于统一的 Agent interaction loop。', + }, + { + role: 'user', + content: '用户这轮输入:\n制作一个三消经营游戏', + }, + ], + tools: ['runtime_execute', 'runtime_resume', 'project_location'].map( + (name) => ({ type: 'function', function: { name } }), + ), + }; +} + +function syntheticUiPrototypeInspectionPayload() { + return { + model: deterministicLaneDefenseModel, + stream: false, + messages: [ + { + role: 'system', + content: '你是游戏界面视觉检查 Agent。只分析画面。', + }, + { + role: 'user', + content: [ + { + type: 'text', + text: '请逐项检查 informationHud、gameplaySurface、objectiveEntities、primaryControls、failureRestartFlow、responsiveLayout、implementationClarity、originalTheme,并只返回严格 JSON。', + }, + { + type: 'image_url', + image_url: { url: 'data:image/png;base64,AA==' }, + }, + ], + }, + ], + }; +} + function responseFunctionNames(response) { return response.choices[0].message.tool_calls.map( (call) => call.function.name, @@ -387,12 +406,49 @@ async function runSelfTest() { html.includes('requestAnimationFrame'), 'self-test-html-contract-invalid', ); + const canonicalHtml = deterministicLaneDefenseCanonicalHtml(); + assert( + canonicalHtml.includes(visibleCanvasCss) && + canonicalHtml.includes("atlasArt.src='../assets/art-spritesheet.png'") && + [ + 'player.png', + 'blocks-and-targets.png', + 'obstacles-and-scene.png', + 'feedback-effects.png', + ].every((fileName) => + canonicalHtml.includes(`../assets/art-spritesheet-slices/${fileName}`), + ) && + (canonicalHtml.match(/ctx\.drawImage\(/g) ?? []).length >= 5, + 'self-test-canonical-visible-art-contract-invalid', + ); const apiKey = `deterministic-self-test-${randomUUID()}`; - const router = createDeterministicLaneDefenseRouter({ apiKey }); + const runtimeConfig = deterministicRuntimeConfig(apiKey, { + baseUrl: 'http://127.0.0.1:41001/v1', + editorBaseUrl: 'http://127.0.0.1:41002', + }); + assert( + runtimeConfig.agentMode === 'provider' && + runtimeConfig.llm.apiKind === 'openai_chat' && + runtimeConfig.llm.reasoningEffort === 'max' && + runtimeConfig.agentLlm?.['project-supervisor']?.reasoningEffort === + 'max' && + runtimeConfig.llm.apiKey === apiKey && + runtimeConfig.editorApi.apiKey === apiKey, + 'self-test-provider-runtime-config-invalid', + ); + const captureProviderErrorCode = (operation) => { + try { + operation(); + return null; + } catch (error) { + return error?.code ?? null; + } + }; const allTools = [ 'update_agent_plan', 'respond_to_user', - 'runtime_tool_agent_delegate', + 'runtime_tool_agent_action_history', + 'runtime_tool_agent_acceptance_update', 'runtime_tool_agent_run_status', 'runtime_tool_command_run_limited', 'runtime_tool_file_patch', @@ -405,141 +461,155 @@ async function runSelfTest() { 'runtime_tool_asset_list', 'runtime_tool_file_read', ]; + const rootRunId = 'parent-run'; + const contractFingerprint = 'a'.repeat(64); + const contractContext = `[Root Goal Contract]\n${JSON.stringify({ contractFingerprint })}`; + const observationContext = (observations) => + `${contractContext}\n已有工具观察:\n${JSON.stringify(observations)}`; + const goalContractTools = ['runtime_tool_agent_goal_contract']; + const goalContractRouter = createDeterministicLaneDefenseRouter({ apiKey }); + goalContractRouter.route({ + authorization: `Bearer ${apiKey}`, + payload: syntheticPayload( + 'project-supervisor', + 'goal-contract-order-run', + goalContractTools, + ), + }); + assert( + captureProviderErrorCode(() => + goalContractRouter.route({ + authorization: `Bearer ${apiKey}`, + payload: syntheticPayload( + 'project-supervisor', + 'goal-contract-order-run', + goalContractTools, + ), + }), + ) === 'provider-goal-contract-out-of-order', + 'self-test-goal-contract-duplicate-not-rejected', + ); + + const router = createDeterministicLaneDefenseRouter({ apiKey }); 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, + const interactionExecuteResponse = router.route({ + authorization: `Bearer ${apiKey}`, + payload: syntheticInteractionExecutePayload(), + }); + const interactionExecuteCalls = responseFunctionCalls( + interactionExecuteResponse, + ); + const interactionExecuteStats = router.getStats(); + assert( + interactionExecuteCalls.length === 1 && + interactionExecuteCalls[0]?.name === 'runtime_execute' && + Object.keys(interactionExecuteCalls[0]?.arguments ?? {}).length === 0 && + interactionExecuteStats.requestCount === 1 && + interactionExecuteStats.planningRequestCount === 1 && + interactionExecuteStats.interactionExecuteCount === 1 && + interactionExecuteStats.manifestReadyTaskRunCount === 0 && + interactionExecuteStats.manifestReadyTaskCompletionCount === 0 && + Object.keys(interactionExecuteStats.readyTaskCountsByAgent).length === 0, + 'self-test-interaction-max-execute-sequence-invalid', + ); + const visualInspectionResponse = router.route({ + authorization: `Bearer ${apiKey}`, + payload: syntheticUiPrototypeInspectionPayload(), + }); + const visualInspection = JSON.parse( + visualInspectionResponse.choices[0].message.content, ); assert( - initialDelegationCalls.map((call) => call.name).join(',') === - 'runtime_tool_agent_delegate,runtime_tool_agent_delegate,runtime_tool_agent_delegate', - 'self-test-initial-delegation-invalid', + Object.keys(visualInspection?.checks ?? {}).join(',') === + 'informationHud,gameplaySurface,objectiveEntities,primaryControls,failureRestartFlow,responsiveLayout,implementationClarity,originalTheme' && + Object.values(visualInspection.checks).every((value) => value === true) && + Array.isArray(visualInspection.issues) && + visualInspection.issues.length === 0 && + router.getStats().imageInspectionRequestCount === 1 && + router.getStats().unexpectedRequestCount === 0, + 'self-test-ui-prototype-inspection-request-not-routed', ); - const initialCodeDelegation = initialDelegationCalls[0]?.arguments?.input; - const initialQualityDelegation = initialDelegationCalls[1]?.arguments?.input; - const initialArtDelegation = initialDelegationCalls[2]?.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 && - initialArtDelegation?.agentId === 'art-asset-plan' && - initialArtDelegation.task.includes('canvas.asset_generate') && - JSON.stringify(initialArtDelegation.expectedArtifacts) === - JSON.stringify([ - 'assets/manifest.art.json', - 'assets/art-spritesheet.png', - ]), - '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}\n已有工具观察:\n${JSON.stringify([ - { - tool: 'runtime.verification', - status: 'blocked', - summary: '最终回复基于的项目 revision 已过期', - detail: 'responseRevision=0, currentRevision=1', - }, - ])}`, - ), - ).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( + const goalContractResponse = route( 'project-supervisor', - 'parent-run', - ['runtime_tool_agent_delegate'], - '当前父 run 已进入只编排模式,本次修复的原生工具目录只保留 agent.delegate', + rootRunId, + goalContractTools, ); - const followupDelegationCall = responseFunctionCalls( - followupDelegationResponse, - )[0]; + const goalContractCalls = responseFunctionCalls(goalContractResponse); + const acceptanceNodes = + goalContractCalls[0]?.arguments?.input?.acceptanceNodes ?? []; 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_task_list,runtime_tool_agent_run_status', - 'self-test-manifest-wait-invalid', + goalContractCalls.length === 1 && + goalContractCalls[0]?.name === 'runtime_tool_agent_goal_contract' && + goalContractCalls[0]?.arguments?.input?.outcome?.includes( + '可完整游玩的原创植物塔防网页游戏', + ) && + acceptanceNodes.length === 2 && + acceptanceNodes[0]?.criterionId === 'static-current-revision' && + JSON.stringify(acceptanceNodes[0]?.requiredEvidence) === + JSON.stringify(['tool:command.run_limited']) && + acceptanceNodes[1]?.criterionId === 'playable-current-revision' && + JSON.stringify(acceptanceNodes[1]?.requiredEvidence) === + JSON.stringify(['tool:preview.validate']) && + JSON.stringify(acceptanceNodes[1]?.dependsOn) === + JSON.stringify(['static-current-revision']), + 'self-test-goal-contract-invalid', ); assert( responseFunctionNames( route( 'project-supervisor', - 'parent-run', - allTools, - '已有工具观察:\nseedTaskCounts: completed=16 running=0 pending=0 waiting=0 failed=0 total=16', + rootRunId, + ['runtime_tool_task_list', 'runtime_tool_agent_run_status'], + `${contractContext}\n当前正式 manifest DAG 仍有专业 task 在运行`, ), - ).join(',') === - 'runtime_tool_command_run_limited,runtime_tool_preview_validate', - 'self-test-repair-verification-batch-invalid', + ).join(',') === 'runtime_tool_task_list,runtime_tool_agent_run_status', + 'self-test-manifest-wait-invalid', ); - route('project-supervisor', 'parent-run'); + const visualReadyAgentIds = new Set([ + 'art-director', + 'design-foundation', + 'art-asset-plan', + ]); + const canvasCallsByAgent = new Map(); + const callBatchesByAgent = new Map(); for (const agentId of deterministicManifestReadyAgentIds) { const runId = `manifest-ready-${agentId}`; const extraContext = `处理 manifest ready 任务:${agentId}\n` + `任务 ID:${agentId}`; + const readyTools = visualReadyAgentIds.has(agentId) + ? [ + ...manifestReadyTools, + 'runtime_tool_canvas_asset_generate', + 'runtime_tool_image_inspect', + ] + : manifestReadyTools; let terminalCompletionCount = 0; for (let attempt = 0; attempt < 8; attempt += 1) { - const response = route(agentId, runId, manifestReadyTools, extraContext); - if (responseFunctionNames(response).includes('respond_to_user')) { + const attemptTools = + agentId === 'art-director' && attempt >= 2 + ? ['respond_to_user'] + : readyTools; + const response = route(agentId, runId, attemptTools, extraContext); + const calls = responseFunctionCalls(response); + const batches = callBatchesByAgent.get(agentId) ?? []; + batches.push(calls.map((call) => call.name)); + callBatchesByAgent.set(agentId, batches); + const canvasCall = calls.find( + (call) => call.name === 'runtime_tool_canvas_asset_generate', + ); + if (canvasCall) { + assert( + !canvasCallsByAgent.has(agentId), + `self-test-canvas-generation-duplicate:${agentId}`, + ); + canvasCallsByAgent.set(agentId, canvasCall); + } + if (calls.some((call) => call.name === 'respond_to_user')) { terminalCompletionCount += 1; break; } @@ -549,10 +619,429 @@ async function runSelfTest() { `self-test-manifest-ready-terminal-invalid:${agentId}`, ); } + const artDirectorAssetCall = canvasCallsByAgent.get('art-director'); + const designFoundationAssetCall = canvasCallsByAgent.get('design-foundation'); + const artAssetPlanAssetCall = canvasCallsByAgent.get('art-asset-plan'); + assert( + JSON.stringify(callBatchesByAgent.get('art-asset-plan')) === + JSON.stringify([ + ['runtime_tool_asset_list'], + ['runtime_tool_file_write'], + ['runtime_tool_canvas_asset_generate'], + ['runtime_tool_file_read', 'runtime_tool_asset_list'], + ['update_agent_plan', 'respond_to_user'], + ]), + 'self-test-art-asset-plan-revision-stages-invalid', + ); + assert( + JSON.stringify(callBatchesByAgent.get('code-prototype')) === + JSON.stringify([ + [ + 'runtime_tool_file_read', + 'runtime_tool_file_read', + 'runtime_tool_file_read', + ], + [ + 'runtime_tool_file_read', + 'runtime_tool_file_read', + 'runtime_tool_file_read', + 'runtime_tool_asset_list', + ], + ['runtime_tool_file_write'], + ['runtime_tool_command_run_limited'], + ['update_agent_plan', 'respond_to_user'], + ]), + 'self-test-code-prototype-asset-audit-sequence-invalid', + ); + assert( + canvasCallsByAgent.size === 3 && + artDirectorAssetCall?.arguments?.input?.outputPath === + 'assets/art-spec.png' && + artDirectorAssetCall.arguments.input.aspectRatio === '1:1' && + artDirectorAssetCall.arguments.input.assetKind === 'icon-spec' && + designFoundationAssetCall?.arguments?.input?.outputPath === + 'assets/ui-prototype.png' && + designFoundationAssetCall.arguments.input.aspectRatio === '16:9' && + designFoundationAssetCall.arguments.input.assetKind === 'ui-prototype' && + artAssetPlanAssetCall?.arguments?.input?.outputPath === + 'assets/art-spritesheet.png' && + artAssetPlanAssetCall.arguments.input.aspectRatio === '1:1' && + artAssetPlanAssetCall.arguments.input.assetKind === 'art-spritesheet', + 'self-test-visual-assets-invalid', + ); + + const visualRecoveryRouter = createDeterministicLaneDefenseRouter({ apiKey }); + const visualRecoveryRunId = 'visual-recovery-design-foundation'; + const visualRecoveryTools = [ + ...manifestReadyTools, + 'runtime_tool_canvas_asset_generate', + 'runtime_tool_image_inspect', + ]; + const visualRecoveryRoute = ( + tools = visualRecoveryTools, + extraContext = '', + ) => + visualRecoveryRouter.route({ + authorization: `Bearer ${apiKey}`, + payload: syntheticPayload( + 'design-foundation', + visualRecoveryRunId, + tools, + `处理 manifest ready 任务:design-foundation\n任务 ID:design-foundation${extraContext}`, + ), + }); + visualRecoveryRoute(); + visualRecoveryRoute(); + const visualRecoveryCanvasResponse = visualRecoveryRoute(); + const visualRecoveryInitialInspectResponse = visualRecoveryRoute(); + assert( + responseFunctionNames(visualRecoveryCanvasResponse).join(',') === + 'runtime_tool_canvas_asset_generate' && + responseFunctionNames(visualRecoveryInitialInspectResponse).includes( + 'runtime_tool_image_inspect', + ), + 'self-test-ui-prototype-initial-inspection-sequence-invalid', + ); + const failedInspectObservation = { + tool: 'image.inspect', + status: 'failed', + summary: '视觉模型调用失败', + detail: 'upstream-422', + }; + const prematureCompletionResponse = visualRecoveryRoute( + ['update_agent_plan', 'respond_to_user'], + `\n已有工具观察:\n${JSON.stringify([failedInspectObservation])}`, + ); + assert( + responseFunctionNames(prematureCompletionResponse).includes( + 'respond_to_user', + ), + 'self-test-ui-prototype-premature-completion-fixture-invalid', + ); + const visualBlockedObservation = { + tool: 'runtime.visual_asset', + status: 'blocked', + summary: 'UI 原型视觉检查尚未通过', + detail: + 'expectedPath=assets/ui-prototype.png · requiredInspection=image.inspect', + }; + const blockedRetryResponse = visualRecoveryRoute( + visualRecoveryTools, + `\n已有工具观察:\n${JSON.stringify([visualBlockedObservation])}`, + ); + assert( + responseFunctionNames(blockedRetryResponse).join(',') === + 'runtime_tool_image_inspect', + 'self-test-ui-prototype-visual-block-did-not-retry-inspection', + ); + const failedRetryResponse = visualRecoveryRoute( + visualRecoveryTools, + `\n已有工具观察:\n${JSON.stringify([failedInspectObservation])}`, + ); + assert( + responseFunctionNames(failedRetryResponse).join(',') === + 'runtime_tool_image_inspect', + 'self-test-ui-prototype-failed-inspection-did-not-retry', + ); + const successfulInspectObservation = { + tool: 'image.inspect', + status: 'ok', + summary: '视觉检查已完成,共分析 1 张图片', + detail: null, + }; + const visualRecoveryFinalResponse = visualRecoveryRoute( + ['update_agent_plan', 'respond_to_user'], + `\n已有工具观察:\n${JSON.stringify([successfulInspectObservation])}`, + ); + const visualRecoveryStats = visualRecoveryRouter.getStats(); + assert( + responseFunctionNames(visualRecoveryFinalResponse).includes( + 'respond_to_user', + ) && + visualRecoveryStats.manifestReadyTaskRunCount === 1 && + visualRecoveryStats.manifestReadyTaskCompletionCount === 1 && + visualRecoveryStats.manifestReadyTaskCanvasGenerationCount === 1 && + visualRecoveryStats.byAgent?.['design-foundation']?.projectMutation === + 3 && + visualRecoveryStats.unexpectedRequestCount === 0, + 'self-test-ui-prototype-visual-recovery-not-exactly-once', + ); + + const finalStaticResponse = route( + 'project-supervisor', + rootRunId, + ['runtime_tool_command_run_limited'], + `${contractContext}\n已有工具观察:\nseedTaskCounts: completed=16 running=0 pending=0 waiting=0 failed=0 total=16`, + ); + assert( + responseFunctionNames(finalStaticResponse).join(',') === + 'runtime_tool_command_run_limited', + 'self-test-final-static-not-exclusive', + ); + const successfulFinalStaticObservation = { + tool: 'command.run_limited', + status: 'ok', + summary: 'game.static_smoke 已完成', + detail: null, + }; + const finalPreviewResponse = route( + 'project-supervisor', + rootRunId, + ['runtime_tool_preview_validate'], + observationContext([successfulFinalStaticObservation]), + ); + assert( + responseFunctionNames(finalPreviewResponse).join(',') === + 'runtime_tool_preview_validate', + 'self-test-final-preview-not-exclusive', + ); + const successfulFinalPreviewObservation = { + tool: 'preview.validate', + status: 'ok', + summary: '桌面与移动视口真实试玩已通过', + detail: null, + }; + const finalHistoryResponse = route( + 'project-supervisor', + rootRunId, + ['runtime_tool_agent_action_history'], + observationContext([successfulFinalPreviewObservation]), + ); + const finalHistoryCalls = responseFunctionCalls(finalHistoryResponse); + assert( + finalHistoryCalls.length === 1 && + finalHistoryCalls[0]?.name === 'runtime_tool_agent_action_history' && + JSON.stringify(finalHistoryCalls[0]?.arguments?.input) === + JSON.stringify({ + runId: null, + actionId: null, + tool: null, + status: 'ok', + limit: 10, + }), + 'self-test-final-action-history-invalid', + ); + const staticActionId = `action-${'1'.repeat(24)}`; + const previewActionId = `action-${'2'.repeat(24)}`; + const actionHistoryDetail = JSON.stringify({ + runId: rootRunId, + count: 2, + truncated: false, + outputTruncated: false, + actions: [ + { + agentId: 'project-supervisor', + runId: rootRunId, + actionId: staticActionId, + tool: 'command.run_limited', + status: 'ok', + }, + { + agentId: 'project-supervisor', + runId: rootRunId, + actionId: previewActionId, + tool: 'preview.validate', + status: 'ok', + }, + ], + }); + const finalAcceptanceResponse = route( + 'project-supervisor', + rootRunId, + ['runtime_tool_agent_acceptance_update'], + observationContext([ + { + tool: 'agent.action_history', + status: 'ok', + summary: '已读取当前 Agent 的 2 条终态动作', + detail: actionHistoryDetail, + }, + ]), + ); + const finalAcceptanceCalls = responseFunctionCalls(finalAcceptanceResponse); + const acceptanceInput = finalAcceptanceCalls[0]?.arguments?.input; + assert( + finalAcceptanceCalls.length === 1 && + finalAcceptanceCalls[0]?.name === + 'runtime_tool_agent_acceptance_update' && + acceptanceInput?.contractFingerprint === contractFingerprint && + acceptanceInput?.evaluations?.length === 2 && + acceptanceInput.evaluations[0]?.criterionId === + 'static-current-revision' && + acceptanceInput.evaluations[0]?.evidence?.[0]?.actionId === + staticActionId && + acceptanceInput.evaluations[1]?.criterionId === + 'playable-current-revision' && + acceptanceInput.evaluations[1]?.evidence?.[0]?.actionId === + previewActionId, + 'self-test-final-acceptance-update-invalid', + ); + const finalRespondResponse = route( + 'project-supervisor', + rootRunId, + ['respond_to_user'], + observationContext([ + { + tool: 'agent.acceptance_update', + status: 'ok', + summary: 'Acceptance Graph 已更新', + detail: null, + }, + ]), + ); + assert( + responseFunctionNames(finalRespondResponse).join(',') === 'respond_to_user', + 'self-test-final-respond-not-exclusive', + ); + + const planRejectionRouter = createDeterministicLaneDefenseRouter({ apiKey }); + planRejectionRouter.route({ + authorization: `Bearer ${apiKey}`, + payload: syntheticPayload( + 'project-supervisor', + 'plan-rejection-run', + goalContractTools, + ), + }); + const planRejectionResponse = planRejectionRouter.route({ + authorization: `Bearer ${apiKey}`, + payload: syntheticPayload( + 'project-supervisor', + 'plan-rejection-run', + ['respond_to_user', 'runtime_tool_file_patch', 'runtime_tool_file_write'], + '当前父 run 已进入只编排模式;本轮只允许收束或安全编排。', + ), + }); + const planRejectionWaitResponse = planRejectionRouter.route({ + authorization: `Bearer ${apiKey}`, + payload: syntheticPayload( + 'project-supervisor', + 'plan-rejection-run', + ['runtime_tool_task_list', 'runtime_tool_agent_run_status'], + '当前正式 manifest DAG 仍有专业 task 在运行', + ), + }); + const planRejectionStats = planRejectionRouter.getStats(); + assert( + responseFunctionNames(planRejectionResponse).join(',') === + 'respond_to_user' && + responseFunctionNames(planRejectionWaitResponse).join(',') === + 'runtime_tool_task_list,runtime_tool_agent_run_status' && + planRejectionStats.byAgent?.['project-supervisor']?.projectMutation === + 0 && + planRejectionStats.delegateActionCount === 0 && + planRejectionStats.runStatusCount === 1 && + planRejectionStats.unexpectedRequestCount === 0, + 'self-test-supervisor-plan-rejection-did-not-converge-read-only', + ); + + const earlyFinalReplyRouter = createDeterministicLaneDefenseRouter({ + apiKey, + }); + assert( + captureProviderErrorCode(() => + earlyFinalReplyRouter.route({ + authorization: `Bearer ${apiKey}`, + payload: syntheticPayload( + 'project-supervisor', + 'early-final-reply-run', + [], + '给用户一个正常中文回复', + ), + }), + ) === 'provider-parent-final-reply-before-acceptance', + 'self-test-parent-final-reply-bypassed-acceptance', + ); + + const acceptanceGuardRunId = 'acceptance-guard-run'; + const acceptanceGuardRouter = createDeterministicLaneDefenseRouter({ + apiKey, + }); + const acceptanceGuardRoute = (tools, extraContext = '') => + acceptanceGuardRouter.route({ + authorization: `Bearer ${apiKey}`, + payload: syntheticPayload( + 'project-supervisor', + acceptanceGuardRunId, + tools, + extraContext, + ), + }); + acceptanceGuardRoute(goalContractTools); + acceptanceGuardRoute( + ['runtime_tool_command_run_limited'], + `${contractContext}\n已有工具观察:\nseedTaskCounts: completed=16 running=0 pending=0 waiting=0 failed=0 total=16`, + ); + acceptanceGuardRoute( + ['runtime_tool_preview_validate'], + observationContext([successfulFinalStaticObservation]), + ); + acceptanceGuardRoute( + ['runtime_tool_agent_action_history'], + observationContext([successfulFinalPreviewObservation]), + ); + const acceptanceGuardDetail = JSON.parse(actionHistoryDetail); + acceptanceGuardDetail.runId = acceptanceGuardRunId; + for (const action of acceptanceGuardDetail.actions) { + action.runId = acceptanceGuardRunId; + } + const actionHistoryObservationFor = (detail) => ({ + tool: 'agent.action_history', + status: 'ok', + summary: '已读取当前 Agent 的 2 条终态动作', + detail: JSON.stringify(detail), + }); + const ambiguousContractContext = `[Root Goal Contract]\n{"contractFingerprint":"${contractFingerprint}","nested":{"contractFingerprint":"${'b'.repeat(64)}"}}`; + assert( + captureProviderErrorCode(() => + acceptanceGuardRoute( + ['runtime_tool_agent_acceptance_update'], + `${ambiguousContractContext}\n已有工具观察:\n${JSON.stringify([ + actionHistoryObservationFor(acceptanceGuardDetail), + ])}`, + ), + ) === 'provider-goal-contract-fingerprint-missing', + 'self-test-ambiguous-goal-contract-fingerprint-accepted', + ); + const invalidActionHistoryDetail = structuredClone(acceptanceGuardDetail); + invalidActionHistoryDetail.actions[0].actionId = 'action-invalid'; + assert( + captureProviderErrorCode(() => + acceptanceGuardRoute( + ['runtime_tool_agent_acceptance_update'], + `${contractContext}\n已有工具观察:\n${JSON.stringify([ + actionHistoryObservationFor(invalidActionHistoryDetail), + ])}`, + ), + ) === 'provider-final-acceptance-evidence-missing', + 'self-test-invalid-action-history-id-accepted', + ); + assert( + responseFunctionNames( + acceptanceGuardRoute( + ['runtime_tool_agent_acceptance_update'], + `${contractContext}\n已有工具观察:\n${JSON.stringify([ + actionHistoryObservationFor(acceptanceGuardDetail), + ])}`, + ), + ).join(',') === 'runtime_tool_agent_acceptance_update', + 'self-test-acceptance-stage-advanced-after-rejected-history', + ); + const stats = router.getStats(); const manifestReadyTasks = manifestReadyTaskEvidence(stats); assert( - manifestReadyTasks.exactlyOnce, + manifestReadyTasks.exactlyOnce && + stats.goalContractCount === 1 && + stats.actionHistoryCount === 1 && + stats.acceptanceUpdateCount === 1 && + stats.interactionExecuteCount === 1 && + stats.delegateActionCount === 0 && + stats.imageInspectionRequestCount === 1 && + stats.manifestReadyTaskCanvasGenerationCount === 3 && + stats.byAgent?.['project-supervisor']?.projectMutation === 0 && + stats.byAgent?.['code-prototype']?.projectMutation === 1 && + stats.byAgent?.['quality-review']?.projectMutation === 0, 'self-test-manifest-ready-exactly-once-invalid', ); @@ -597,6 +1086,20 @@ async function runSelfTest() { detail: 'repositoryContextDrift=true · 请在同一 run 下一轮 planning 重新确认适用规范', }; + const projectLockSmokeObservations = [ + { + tool: 'command.run_limited', + status: 'failed', + summary: 'game.static_smoke 无法取得项目验证锁', + detail: '项目正在被其他写操作占用:$PROJECT_ROOT/.agent/project.lock', + }, + { + tool: 'command.run_limited', + status: 'failed', + summary: 'game.static_smoke 无法取得项目验证锁', + detail: '项目正在被其他写操作占用:$PROJECT_ROOT\\.agent\\project.lock', + }, + ]; const projectRevisionDriftSmokeObservation = { tool: 'command.run_limited', status: 'blocked', @@ -604,43 +1107,179 @@ async function runSelfTest() { detail: 'projectRevisionDrift=true · expectedRevision=4 · currentRevision=5 · replanRequired=true', }; + const projectRevisionDriftCanvasObservation = { + tool: 'canvas.asset_generate', + status: 'blocked', + summary: '并行项目变更使旧动作过期,旧动作未执行', + detail: + 'projectRevisionDrift=true · expectedRevision=5 · currentRevision=6 · replanRequired=true', + }; const successfulProjectMutationObservation = { tool: 'project.patchset', status: 'ok', summary: 'project.patchset 已原子应用 2 项变更', detail: 'checkpointId=checkpoint-concurrent-ready-batch', }; - const captureProviderErrorCode = (operation) => { - try { - operation(); - return null; - } catch (error) { - return error?.code ?? null; - } + const missingArtAssetObservation = { + tool: 'runtime.visual_asset', + status: 'blocked', + summary: '首版美术素材图尚未按正式视觉流程生成并登记,不能完成任务', + detail: '[redacted sensitive context]', + }; + const successfulCanvasMutationObservation = { + tool: 'canvas.asset_generate', + status: 'ok', + summary: '素材画布图片已生成并登记', + detail: null, }; - const oldSmokeRouter = createDeterministicLaneDefenseRouter({ apiKey }); - const oldSmokeRoute = (extraContext = '') => - oldSmokeRouter.route({ + const artAssetRecoveryAgentId = 'art-asset-plan'; + const artAssetRecoveryRunId = 'art-asset-visual-recovery-ready-run'; + const artAssetRecoveryTools = [ + ...manifestReadyTools, + 'runtime_tool_canvas_asset_generate', + ]; + const artAssetRecoveryRouter = createDeterministicLaneDefenseRouter({ + apiKey, + }); + const artAssetRecoveryRoute = (extraContext = '') => + artAssetRecoveryRouter.route({ authorization: `Bearer ${apiKey}`, payload: syntheticPayload( - 'code-prototype', - 'old-smoke-initial-code-run', - allTools, - extraContext, + artAssetRecoveryAgentId, + artAssetRecoveryRunId, + artAssetRecoveryTools, + `处理 manifest ready 任务:${artAssetRecoveryAgentId}\n任务 ID:${artAssetRecoveryAgentId}${extraContext}`, ), }); - oldSmokeRoute(); - oldSmokeRoute(); - oldSmokeRoute(); + for (let attempt = 0; attempt < 8; attempt += 1) { + if ( + responseFunctionNames(artAssetRecoveryRoute()).includes('respond_to_user') + ) { + break; + } + } + const artAssetRegenerationResponse = artAssetRecoveryRoute( + retryObservationContext([missingArtAssetObservation]), + ); + const artAssetReverificationResponse = artAssetRecoveryRoute( + retryObservationContext([successfulCanvasMutationObservation]), + ); + const artAssetRefinalizationResponse = artAssetRecoveryRoute( + retryObservationContext([successfulSmokeObservation]), + ); + const artAssetRecoveryStats = artAssetRecoveryRouter.getStats(); assert( - responseFunctionNames( - oldSmokeRoute(retryObservationContext([successfulSmokeObservation])), - ).includes('respond_to_user') && - captureProviderErrorCode(() => - oldSmokeRoute(retryObservationContext([successfulSmokeObservation])), - ) === 'provider-terminal-replay-unarmed:code-prototype', - 'self-test-initial-terminal-old-smoke-replayed', + responseFunctionNames(artAssetRegenerationResponse).join(',') === + 'runtime_tool_canvas_asset_generate' && + responseFunctionNames(artAssetReverificationResponse).join(',') === + 'runtime_tool_command_run_limited' && + responseFunctionNames(artAssetRefinalizationResponse).includes( + 'respond_to_user', + ) && + artAssetRecoveryStats.readyTaskCountsByAgent[artAssetRecoveryAgentId] + ?.completion === 1 && + artAssetRecoveryStats.manifestReadyTaskCanvasGenerationCount === 2 && + artAssetRecoveryStats.unexpectedRequestCount === 0, + 'self-test-art-asset-visual-recovery-not-exactly-once', + ); + + const artAssetRevisionDriftRunId = 'art-asset-revision-drift-ready-run'; + const artAssetRevisionDriftRouter = createDeterministicLaneDefenseRouter({ + apiKey, + }); + const artAssetRevisionDriftRoute = (extraContext = '') => + artAssetRevisionDriftRouter.route({ + authorization: `Bearer ${apiKey}`, + payload: syntheticPayload( + artAssetRecoveryAgentId, + artAssetRevisionDriftRunId, + artAssetRecoveryTools, + `处理 manifest ready 任务:${artAssetRecoveryAgentId}\n任务 ID:${artAssetRecoveryAgentId}${extraContext}`, + ), + }); + const artAssetInitialListResponse = artAssetRevisionDriftRoute(); + const artAssetManifestWriteResponse = artAssetRevisionDriftRoute(); + const artAssetInitialCanvasResponse = artAssetRevisionDriftRoute(); + const artAssetRetryCanvasResponse = artAssetRevisionDriftRoute( + retryObservationContext([projectRevisionDriftCanvasObservation]), + ); + const artAssetPostRetryReadResponse = artAssetRevisionDriftRoute( + retryObservationContext([successfulCanvasMutationObservation]), + ); + const artAssetRevisionDriftFinalizationResponse = + artAssetRevisionDriftRoute(); + const artAssetRevisionDriftStats = artAssetRevisionDriftRouter.getStats(); + assert( + responseFunctionNames(artAssetInitialListResponse).join(',') === + 'runtime_tool_asset_list' && + responseFunctionNames(artAssetManifestWriteResponse).join(',') === + 'runtime_tool_file_write' && + responseFunctionNames(artAssetInitialCanvasResponse).join(',') === + 'runtime_tool_canvas_asset_generate' && + responseFunctionNames(artAssetRetryCanvasResponse).join(',') === + 'runtime_tool_canvas_asset_generate' && + responseFunctionNames(artAssetPostRetryReadResponse).join(',') === + 'runtime_tool_file_read,runtime_tool_asset_list' && + responseFunctionNames(artAssetRevisionDriftFinalizationResponse).includes( + 'respond_to_user', + ) && + artAssetRevisionDriftStats.readyTaskCountsByAgent[artAssetRecoveryAgentId] + ?.run === 1 && + artAssetRevisionDriftStats.readyTaskCountsByAgent[artAssetRecoveryAgentId] + ?.completion === 1 && + artAssetRevisionDriftStats.manifestReadyTaskCanvasGenerationCount === 2 && + artAssetRevisionDriftStats.unexpectedRequestCount === 0, + 'self-test-art-asset-revision-drift-recovery-not-exactly-once', + ); + + const artAssetRevisionDriftLimitRunId = + 'art-asset-revision-drift-limit-ready-run'; + const artAssetRevisionDriftLimitRouter = createDeterministicLaneDefenseRouter( + { apiKey }, + ); + const artAssetRevisionDriftLimitRoute = (extraContext = '') => + artAssetRevisionDriftLimitRouter.route({ + authorization: `Bearer ${apiKey}`, + payload: syntheticPayload( + artAssetRecoveryAgentId, + artAssetRevisionDriftLimitRunId, + artAssetRecoveryTools, + `处理 manifest ready 任务:${artAssetRecoveryAgentId}\n任务 ID:${artAssetRecoveryAgentId}${extraContext}`, + ), + }); + artAssetRevisionDriftLimitRoute(); + artAssetRevisionDriftLimitRoute(); + artAssetRevisionDriftLimitRoute(); + for (let retry = 0; retry < 16; retry += 1) { + assert( + responseFunctionNames( + artAssetRevisionDriftLimitRoute( + retryObservationContext([projectRevisionDriftCanvasObservation]), + ), + ).join(',') === 'runtime_tool_canvas_asset_generate', + `self-test-art-asset-revision-drift-retry-invalid:${retry}`, + ); + } + const artAssetRevisionDriftLimitCode = captureProviderErrorCode(() => + artAssetRevisionDriftLimitRoute( + retryObservationContext([projectRevisionDriftCanvasObservation]), + ), + ); + const artAssetRevisionDriftLimitStats = + artAssetRevisionDriftLimitRouter.getStats(); + assert( + artAssetRevisionDriftLimitCode === + 'provider-ready-art-canvas-retry-exhausted' && + artAssetRevisionDriftLimitStats.readyTaskCountsByAgent[ + artAssetRecoveryAgentId + ]?.run === 1 && + artAssetRevisionDriftLimitStats.readyTaskCountsByAgent[ + artAssetRecoveryAgentId + ]?.completion === 0 && + artAssetRevisionDriftLimitStats.manifestReadyTaskCanvasGenerationCount === + 17, + 'self-test-art-asset-revision-drift-limit-invalid', ); const readOnlyCommandRouter = createDeterministicLaneDefenseRouter({ @@ -713,58 +1352,30 @@ async function runSelfTest() { 'self-test-ready-precompletion-limit-invalid', ); - const exerciseInitialTerminalRetryLimit = ({ - agentId, - runId, - tools, - initialRequestCount, - }) => { - const terminalRouter = createDeterministicLaneDefenseRouter({ apiKey }); - const terminalRoute = (extraContext = '') => - terminalRouter.route({ - authorization: `Bearer ${apiKey}`, - payload: syntheticPayload(agentId, runId, tools, extraContext), - }); - for (let request = 0; request < initialRequestCount; request += 1) { - terminalRoute(); - } - let deliveryCount = 0; - for (let retry = 0; retry < 16; retry += 1) { - assert( - responseFunctionNames( - terminalRoute(retryObservationContext([staleObservation])), - ).join(',') === 'runtime_tool_command_run_limited', - `self-test-initial-terminal-verification-invalid:${agentId}:${retry}`, - ); - if ( - responseFunctionNames( - terminalRoute(retryObservationContext([successfulSmokeObservation])), - ).includes('respond_to_user') - ) { - deliveryCount += 1; - } - } - const limitCode = captureProviderErrorCode(() => - terminalRoute(retryObservationContext([staleObservation])), - ); + const projectLockRouter = createDeterministicLaneDefenseRouter({ apiKey }); + const projectLockRunId = 'project-lock-transient-ready-run'; + const projectLockRoute = (tools, extraContext = '') => + projectLockRouter.route({ + authorization: `Bearer ${apiKey}`, + payload: syntheticPayload( + preCompletionAgentId, + projectLockRunId, + tools, + `处理 manifest ready 任务:${preCompletionAgentId}\n任务 ID:${preCompletionAgentId}${extraContext}`, + ), + }); + projectLockRoute(manifestReadyTools); + for (const observation of projectLockSmokeObservations) { assert( - deliveryCount === 16 && - limitCode === `provider-terminal-retry-exhausted:${agentId}`, - `self-test-initial-terminal-retry-limit-invalid:${agentId}`, + responseFunctionNames( + projectLockRoute( + ['runtime_tool_command_run_limited'], + retryObservationContext([observation]), + ), + ).join(',') === 'runtime_tool_command_run_limited', + 'self-test-ready-project-lock-path-separator-not-retried', ); - }; - exerciseInitialTerminalRetryLimit({ - agentId: 'code-prototype', - runId: 'initial-code-terminal-retry-run', - tools: allTools, - initialRequestCount: 3, - }); - exerciseInitialTerminalRetryLimit({ - agentId: 'art-asset-plan', - runId: 'initial-art-terminal-retry-run', - tools: [...manifestReadyTools, 'runtime_tool_canvas_asset_generate'], - initialRequestCount: 5, - }); + } const duplicateRouter = createDeterministicLaneDefenseRouter({ apiKey }); const duplicateRoute = (runId, extraContext = '') => @@ -857,6 +1468,49 @@ async function runSelfTest() { 'self-test-ready-retry-limit-or-exactly-once-invalid', ); + const verifiedDeliveryRepairRouter = createDeterministicLaneDefenseRouter({ + apiKey, + }); + const verifiedDeliveryRepairRunId = 'verified-delivery-repair-ready-run'; + const verifiedDeliveryRepairRoute = (tools, extraContext = '') => + verifiedDeliveryRepairRouter.route({ + authorization: `Bearer ${apiKey}`, + payload: syntheticPayload( + retryAgentId, + verifiedDeliveryRepairRunId, + tools, + `处理 manifest ready 任务:${retryAgentId}\n任务 ID:${retryAgentId}${extraContext}`, + ), + }); + for (let attempt = 0; attempt < 8; attempt += 1) { + if ( + responseFunctionNames( + verifiedDeliveryRepairRoute(manifestReadyTools), + ).includes('respond_to_user') + ) { + break; + } + } + const rejectedVerificationPlan = verifiedDeliveryRepairRoute( + manifestReadyTools, + retryObservationContext([staleObservation]), + ); + const verifiedDeliveryRepairResponse = verifiedDeliveryRepairRoute( + ['respond_to_user'], + `${retryObservationContext([staleObservation])}\n上一条输出不符合工具计划协议:当前 revision 已通过验证。本次修复的原生工具目录只保留 respond_to_user;必须立即调用它交付专业合同结论。`, + ); + const verifiedDeliveryRepairStats = verifiedDeliveryRepairRouter.getStats(); + assert( + responseFunctionNames(rejectedVerificationPlan).join(',') === + 'runtime_tool_command_run_limited' && + responseFunctionNames(verifiedDeliveryRepairResponse).join(',') === + 'respond_to_user' && + verifiedDeliveryRepairStats.readyTaskCountsByAgent[retryAgentId] + ?.completion === 1 && + verifiedDeliveryRepairStats.unexpectedRequestCount === 0, + 'self-test-ready-verified-delivery-repair-invalid', + ); + const transientRetryRouter = createDeterministicLaneDefenseRouter({ apiKey }); const transientRetryRunId = 'transient-retry-ready-run'; const transientRetryRoute = (extraContext = '') => @@ -1094,23 +1748,24 @@ async function runSelfTest() { readOnlyRetryRouter.getStats().manifestReadyTaskStaticSmokeCount === 0, 'self-test-read-only-retry-used-forbidden-verification', ); - const responsibilityContract = responsibilityContractEvidence(stats); assert( - responsibilityContract.codeOwnsGameIndex && - responsibilityContract.qualityIsReadOnly && - responsibilityContract.qualityIndependentOfCodeTiming && - responsibilityContract.artOwnsGeneratedAsset && - responsibilityContract.contractViolationFree, - 'self-test-responsibility-contract-invalid', - ); - assert( - stats.initialDelegationCount === 3 && - stats.followupDelegationCount === 1 && - stats.supervisorDirectMutationAttemptCount === 1 && + stats.goalContractCount === 1 && + stats.actionHistoryCount === 1 && + stats.acceptanceUpdateCount === 1 && + stats.delegateActionCount === 0 && + stats.manifestReadyTaskCanvasGenerationCount === 3 && + stats.byAgent?.['project-supervisor']?.projectMutation === 0 && + stats.byAgent?.['code-prototype']?.projectMutation === 1 && + stats.byAgent?.['quality-review']?.projectMutation === 0 && stats.unexpectedRequestCount === 0, 'self-test-provider-core-stats-invalid', ); + const syntheticRuntimeProviderRequestCount = + stats.requestCount - + stats.interactionExecuteCount - + stats.imageInspectionRequestCount; + const syntheticChildReport = { status: 'PASS', suite, @@ -1138,10 +1793,10 @@ async function runSelfTest() { projectRevisionDelta: 2, finalSupervisorAssistantCount: 1, professionalAssistantCount: 3, - providerRequestIdentityCount: stats.requestCount, - providerLifecycleStartedCount: stats.requestCount, - providerLifecycleTerminalCount: stats.requestCount, - providerLifecycleCompletedCount: stats.requestCount, + providerRequestIdentityCount: syntheticRuntimeProviderRequestCount, + providerLifecycleStartedCount: syntheticRuntimeProviderRequestCount, + providerLifecycleTerminalCount: syntheticRuntimeProviderRequestCount, + providerLifecycleCompletedCount: syntheticRuntimeProviderRequestCount, }, }; const incompletePlaytestReport = structuredClone(syntheticChildReport); @@ -1195,14 +1850,39 @@ async function runSelfTest() { run: duplicateRunCode, }, terminalRetryContractsValidated: { - oldSmokeObservationSingleUse: true, readOnlyCommandOnlyRejected: true, preCompletionTransientRetryLimit: 16, - initialCodeRetryLimit: 16, - initialArtRetryLimit: 16, + projectLockPathSeparatorsRetried: ['/', '\\'], }, - responsibilityContract, - qualityTimingOrdersValidated: ['before-code', 'after-code'], + providerRuntimeModeValidated: true, + interactionExecutionValidated: { + reasoningEffort: 'max', + requestCount: stats.interactionExecuteCount, + responseTools: interactionExecuteCalls.map((call) => call.name), + readyTaskRunCountBeforeRuntime: + interactionExecuteStats.manifestReadyTaskRunCount, + readyTaskCompletionCountBeforeRuntime: + interactionExecuteStats.manifestReadyTaskCompletionCount, + }, + goalContractValidated: true, + finalizationSequenceValidated: [ + 'command.run_limited', + 'preview.validate', + 'agent.action_history', + 'agent.acceptance_update', + 'respond_to_user', + ], + canvasArtifactsValidated: [ + 'assets/art-spec.png', + 'assets/ui-prototype.png', + 'assets/art-spritesheet.png', + ], + uiPrototypeInspectionValidated: true, + uiPrototypeVisualRecoveryExactlyOnceValidated: true, + artAssetVisualRecoveryExactlyOnceValidated: true, + artAssetRevisionDriftRecoveryExactlyOnceValidated: true, + artAssetRevisionDriftRetryLimitValidated: 16, + supervisorPlanRejectionMode: 'respond-only-then-read-only-wait', childHardGatesValidated: true, packageCommandsRegistered: true, }; @@ -1228,23 +1908,7 @@ async function runE2e(options) { { flag: 'wx', mode: 0o600 }, ); provider = await startDeterministicLaneDefenseProvider({ apiKey }); - const config = { - editorApi: { - apiKey, - baseUrl: provider.editorBaseUrl, - }, - llm: { - apiKey, - baseUrl: provider.baseUrl, - model: deterministicLaneDefenseModel, - apiKind: 'openai_chat', - reasoningEffort: 'low', - stream: false, - requestTimeoutMs: 30_000, - maxRetries: 0, - retryBackoffMs: 100, - }, - }; + const config = deterministicRuntimeConfig(apiKey, provider); await fs.writeFile( path.join(configDir, configFileName), `${JSON.stringify(config)}\n`, @@ -1298,7 +1962,6 @@ async function runE2e(options) { const providerPassed = providerStats?.stopped === true && expectedProviderStats(providerStats); const manifestReadyTasks = manifestReadyTaskEvidence(providerStats); - const responsibilityContract = responsibilityContractEvidence(providerStats); const status = !failureCode && childPassed && providerPassed && configRemoved ? 'PASS' @@ -1318,7 +1981,14 @@ async function runE2e(options) { child: childReport, provider: providerStats, manifestReadyTasks, - responsibilityContract, + finalizationContract: { + goalContractCount: providerStats?.goalContractCount ?? null, + actionHistoryCount: providerStats?.actionHistoryCount ?? null, + acceptanceUpdateCount: providerStats?.acceptanceUpdateCount ?? null, + delegateActionCount: providerStats?.delegateActionCount ?? null, + supervisorProjectMutationCount: + providerStats?.byAgent?.['project-supervisor']?.projectMutation ?? null, + }, cleanup: { providerStopped: providerStats?.stopped === true, configRemoved, diff --git a/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/assertions/runtime.mjs b/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/assertions/runtime.mjs index fb509b580..72448f213 100644 --- a/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/assertions/runtime.mjs +++ b/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/assertions/runtime.mjs @@ -2406,11 +2406,36 @@ export function isolatedJoinDeliveryTarget(delivery) { return target; } -export function finalMessageId(agentId, sessionId, runId) { - const fingerprint = createHash('sha256') +function runtimeMessageCorrelationId(agentId, sessionId, runId) { + return createHash('sha256') .update(`${agentId}\n${sessionId}\n${runId}`) .digest('hex'); - return `agent-finalization-${fingerprint.slice(0, 32)}`; +} + +export function finalMessageId(agentId, sessionId, runId) { + return `agent-finalization-${runtimeMessageCorrelationId( + agentId, + sessionId, + runId, + ).slice(0, 32)}`; +} + +export function runtimePublicStatusMessageId( + agentId, + sessionId, + runId, + status, +) { + const correlationId = runtimeMessageCorrelationId( + agentId, + sessionId, + runId, + ).slice(0, 32); + const statusFingerprint = createHash('sha256') + .update(status) + .digest('hex') + .slice(0, 16); + return `runtime-public-status-${correlationId}-${statusFingerprint}`; } export function backgroundTaskMessageId(agentId, sessionId, runId, source) { @@ -2444,7 +2469,10 @@ export function disposableProjectPathVariants() { } export function formalConfigPathVariants() { - return absolutePathVariants(state.options?.configDir); + return absolutePathVariants( + state.options?.configDir, + state.isolatedRunner?.appDataDir, + ); } export function absolutePathVariants(...values) { diff --git a/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/entry.mjs b/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/entry.mjs index ca6b520fb..93d668d4d 100644 --- a/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/entry.mjs +++ b/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/entry.mjs @@ -20,7 +20,15 @@ import { stopOwnedIsolatedRunner, } from './harness/app-data.mjs'; import { loadConfig, parseArguments } from './harness/config.mjs'; -import { closeInteractiveCli } from './harness/process.mjs'; +import { + activeInteractiveCliSessions, + captureOwnedProcessCleanupSnapshot, + closeInteractiveCli, + destroyInteractiveCliOutputStreams, + interactiveCliOutput, + verifyOwnedProcessCleanupSnapshot, + waitForInteractiveCliStdioClose, +} from './harness/process.mjs'; import { checkPrerequisites } from './harness/project.mjs'; import { buildSummary, @@ -266,6 +274,21 @@ if (selfTestRequested) { recordError(error?.code ?? 'unexpected-error', error); } finally { state.cleanupInProgress = true; + const stateTrackedInteractiveCliSessions = new Set( + [ + state.userInputCliSession, + state.supervisorAutonomousPlayableCliSession, + state.supervisorSwarmCliSession, + ].filter(Boolean), + ); + const interactiveCliSessions = [ + ...new Set([ + ...stateTrackedInteractiveCliSessions, + ...activeInteractiveCliSessions, + ]), + ]; + const supervisorAutonomousPlayableCliSession = + state.supervisorAutonomousPlayableCliSession; if (isUserInputRuntimeSuite() && state.userInputCliSession) { try { await closeInteractiveCli(state.userInputCliSession); @@ -311,6 +334,15 @@ if (selfTestRequested) { } state.supervisorSwarmCliSession = null; } + for (const session of interactiveCliSessions) { + if (stateTrackedInteractiveCliSessions.has(session)) continue; + try { + await closeInteractiveCli(session); + } catch (error) { + state.status = 'FAIL'; + recordError('interactive-cli-cleanup-failed', error); + } + } if (isMcpRuntimeSuite() && state.mcp.httpFixture) { try { await stopMcpHttpFixture(); @@ -320,16 +352,42 @@ if (selfTestRequested) { recordError('mcp-http-fixture-cleanup-failed', error); } } + if ( + isSupervisorAutonomousPlayableLaneDefenseSuite() && + state.isolatedRunner.appDataDir + ) { + try { + const runnerPid = state.isolatedRunner.current?.pid ?? null; + const helperPid = + state.isolatedRunner.current?.killHandle?.child?.pid ?? null; + state.supervisorAutonomousPlayable.ownedProcessCleanupSnapshot = + await captureOwnedProcessCleanupSnapshot({ + runnerPid, + helperPids: Number.isSafeInteger(helperPid) ? [helperPid] : [], + rootPids: [ + ...interactiveCliSessions.map((session) => session.child?.pid), + ...[...activeCommandChildren].map((child) => child.pid), + ].filter((pid) => Number.isSafeInteger(pid) && pid > 0), + }); + const observed = + state.supervisorAutonomousPlayable.ownedProcessCleanupSnapshot + .observedCounts; + assert( + observed.runner === 1 && observed.helper === 1, + 'supervisor-autonomous-playable-owned-process-snapshot-incomplete', + ); + } catch (error) { + state.status = 'FAIL'; + recordError( + 'supervisor-autonomous-playable-owned-process-snapshot-failed', + error, + ); + } + } if (isIsolatedRunnerSuite() && state.isolatedRunner.appDataDir) { try { await stopOwnedIsolatedRunner(); state.isolatedRunner.stopped = true; - state.isolatedRunner.cleanupPerformed = - await removeIsolatedSuiteAppData(); - if (!state.isolatedRunner.cleanupPerformed) { - state.status = 'FAIL'; - recordError('isolated-appdata-cleanup-sentinel-missing'); - } } catch (error) { state.status = 'FAIL'; const safeCleanupErrorCode = @@ -342,8 +400,51 @@ if (selfTestRequested) { state.isolatedRunner.current?.killHandle, ).catch(() => {}); } + } + for (const session of interactiveCliSessions) { + try { + await waitForInteractiveCliStdioClose(session, 10_000); + } catch (error) { + destroyInteractiveCliOutputStreams(session); + state.status = 'FAIL'; + recordError( + error?.code === 'interactive-cli-stdio-close-timeout' + ? error.code + : 'interactive-cli-stdio-cleanup-failed', + error, + ); + } + } + if (supervisorAutonomousPlayableCliSession) { + state.supervisorAutonomousPlayable.cliOutput = interactiveCliOutput( + supervisorAutonomousPlayableCliSession, + ); + } + if (isIsolatedRunnerSuite() && state.isolatedRunner.appDataDir) { + if (state.isolatedRunner.stopped) { + try { + state.isolatedRunner.cleanupPerformed = + await removeIsolatedSuiteAppData(); + if (!state.isolatedRunner.cleanupPerformed) { + state.status = 'FAIL'; + recordError('isolated-appdata-cleanup-sentinel-missing'); + } + } catch (error) { + state.status = 'FAIL'; + const safeCleanupErrorCode = + isNonEmptyString(error?.code) && + /^(?:isolated|source)-[a-z0-9-]+$/u.test(error.code) + ? error.code + : 'isolated-appdata-cleanup-failed'; + recordError(safeCleanupErrorCode, error); + } + } const killMethod = - state.isolatedRunner.pidfdClaimCount > 0 ? 'linux-pidfd' : null; + state.isolatedRunner.pidfdClaimCount > 0 + ? process.platform === 'win32' + ? 'windows-process-handle' + : 'linux-pidfd' + : null; if (isSteerRunnerKillSuite()) { state.evidence.steerRunnerStopped = state.isolatedRunner.stopped; state.evidence.steerAppDataCleanupPerformed = @@ -661,6 +762,61 @@ if (selfTestRequested) { ); } } + if ( + isSupervisorAutonomousPlayableLaneDefenseSuite() && + state.isolatedRunner.appDataDir + ) { + const snapshot = + state.supervisorAutonomousPlayable.ownedProcessCleanupSnapshot; + if (snapshot) { + try { + const cleanup = await verifyOwnedProcessCleanupSnapshot(snapshot); + state.evidence.ownedProcessIdentityCaptured = true; + state.evidence.ownedRunnerObservedCount = + snapshot.observedCounts.runner; + state.evidence.ownedHelperObservedCount = + snapshot.observedCounts.helper; + state.evidence.ownedNodeDescendantObservedCount = + snapshot.observedCounts.node; + state.evidence.ownedBrowserDescendantObservedCount = + snapshot.observedCounts.browser; + state.evidence.ownedCommandDescendantObservedCount = + snapshot.observedCounts.command; + state.evidence.ownedRunnerResidualCount = + cleanup.residualCounts.runner; + state.evidence.ownedHelperResidualCount = + cleanup.residualCounts.helper; + state.evidence.ownedNodeDescendantResidualCount = + cleanup.residualCounts.node; + state.evidence.ownedBrowserDescendantResidualCount = + cleanup.residualCounts.browser; + state.evidence.ownedCommandDescendantResidualCount = + cleanup.residualCounts.command; + state.evidence.activeCommandChildrenAfterCleanup = + cleanup.activeCommandChildCount; + state.evidence.activeInteractiveCliSessionsAfterCleanup = + cleanup.activeInteractiveCliSessionCount; + state.evidence.ownedProcessCleanupPassed = cleanup.clean; + if (!cleanup.clean) { + state.status = 'FAIL'; + recordError( + 'supervisor-autonomous-playable-owned-process-residual-detected', + ); + } + } catch (error) { + state.status = 'FAIL'; + recordError( + 'supervisor-autonomous-playable-owned-process-verification-failed', + error, + ); + } + } else { + state.status = 'FAIL'; + recordError( + 'supervisor-autonomous-playable-owned-process-snapshot-missing', + ); + } + } if ( isSteerRunnerKillSuite() && state.projectRoot && @@ -1210,6 +1366,7 @@ if (selfTestRequested) { const safeSummary = { status: state.status, suite: state.suite, + providerUsed: false, blocked: state.blocked, cleanup: { performed: state.cleanupPerformed, diff --git a/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/harness/app-data.mjs b/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/harness/app-data.mjs index becaa44d4..251fe1321 100644 --- a/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/harness/app-data.mjs +++ b/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/harness/app-data.mjs @@ -1,5 +1,8 @@ import { assert, codedError, hashValue, sleep } from '../assertions/core.mjs'; -import { isNonEmptyString } from '../assertions/runtime.mjs'; +import { + absolutePathVariants, + isNonEmptyString, +} from '../assertions/runtime.mjs'; import { createHash, fs, @@ -10,6 +13,7 @@ import { watchFileSystem, } from '../dependencies.mjs'; import { + activeCommandChildren, appRoot, BlockedError, configFileName, @@ -57,6 +61,7 @@ import { userInputAppDataSentinelSchema, webSearchAppDataSentinelFileName, webSearchAppDataSentinelSchema, + windowsProcessHandleHelperSource, } from '../runtime-state.mjs'; import { isContextCompactionSuite } from '../suites/context-compaction.mjs'; import { isGoalRuntimeSuite } from '../suites/goal.mjs'; @@ -66,7 +71,10 @@ import { isProjectSkillSuite } from '../suites/project-skill.mjs'; import { isResponseStreamSuite } from '../suites/response-stream.mjs'; import { isScopedAgentsSuite } from '../suites/scoped-agents.mjs'; import { isSteerRunnerKillSuite } from '../suites/steer-runner-kill.mjs'; -import { isSupervisorAutonomousPlayableLaneDefenseSuite } from '../suites/supervisor-autonomous-playable.mjs'; +import { + isSupervisorAutonomousPlayableLaneDefenseSuite, + isSupervisorGameChatSingleMainPlayableSuite, +} from '../suites/supervisor-autonomous-playable.mjs'; import { isSupervisorSwarmAutonomousChatSuite, isSupervisorSwarmCollaborationPolicyMixedRecoverySuite, @@ -112,6 +120,27 @@ export function isolatedSuiteUsesSiblingAppData() { return isWebSearchSuite() || isolatedSuiteProtectsSourceAppData(); } +export function sameSupervisorPlayableProviderBinding(left, right) { + const scalarFields = [ + 'providerAgentMode', + 'providerModel', + 'providerApiKind', + 'providerReasoningEffort', + 'providerBaseUrlSha256', + ]; + return ( + left != null && + right != null && + scalarFields.every((field) => left[field] === right[field]) && + Array.isArray(left.boundAgentIds) && + Array.isArray(right.boundAgentIds) && + left.boundAgentIds.length === right.boundAgentIds.length && + left.boundAgentIds.every( + (agentId, index) => agentId === right.boundAgentIds[index], + ) + ); +} + export function isolatedSuiteAppDataProfile() { if (isSteerRunnerKillSuite()) { return { @@ -278,12 +307,19 @@ export async function createSentinelOwnedTempDirectory({ }) { const directory = await fs.mkdtemp(prefix); try { - if (process.platform !== 'win32') await fs.chmod(directory, 0o700); - await fs.writeFile( - path.join(directory, sentinelName), - `${JSON.stringify(sentinel)}\n`, - { flag: 'wx', mode: 0o600 }, - ); + if (process.platform === 'win32') { + await secureWindowsOwnedTempPath(directory, true); + } else { + await fs.chmod(directory, 0o700); + } + const sentinelPath = path.join(directory, sentinelName); + await fs.writeFile(sentinelPath, `${JSON.stringify(sentinel)}\n`, { + flag: 'wx', + mode: 0o600, + }); + if (process.platform === 'win32') { + await secureWindowsOwnedTempPath(sentinelPath, false); + } return directory; } catch (error) { try { @@ -298,6 +334,91 @@ export async function createSentinelOwnedTempDirectory({ } } +export async function secureWindowsOwnedTempPath(targetPath, isDirectory) { + assert( + process.platform === 'win32' && path.isAbsolute(targetPath), + 'isolated-windows-owned-path-precondition-invalid', + ); + const powershell = await findControlledWindowsPowerShell(); + const script = String.raw` +$ErrorActionPreference = 'Stop' +$targetPath = [Environment]::GetEnvironmentVariable( + 'AGC_OWNED_TEMP_PATH', + [EnvironmentVariableTarget]::Process) +$isDirectory = [Environment]::GetEnvironmentVariable( + 'AGC_OWNED_TEMP_PATH_IS_DIRECTORY', + [EnvironmentVariableTarget]::Process) -eq '1' +if ([String]::IsNullOrWhiteSpace($targetPath)) { exit 70 } +$sid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User +$inheritance = [System.Security.AccessControl.InheritanceFlags]::None +if ($isDirectory) { + if (-not (Test-Path -LiteralPath $targetPath -PathType Container)) { exit 71 } + $acl = [System.Security.AccessControl.DirectorySecurity]::new() + $acl.SetAccessRuleProtection($true, $false) + $acl.SetOwner($sid) + $inheritance = [System.Security.AccessControl.InheritanceFlags]::ContainerInherit -bor [System.Security.AccessControl.InheritanceFlags]::ObjectInherit + $rule = [System.Security.AccessControl.FileSystemAccessRule]::new( + $sid, + [System.Security.AccessControl.FileSystemRights]::FullControl, + $inheritance, + [System.Security.AccessControl.PropagationFlags]::None, + [System.Security.AccessControl.AccessControlType]::Allow) +} else { + if (-not (Test-Path -LiteralPath $targetPath -PathType Leaf)) { exit 72 } + $acl = [System.Security.AccessControl.FileSecurity]::new() + $acl.SetAccessRuleProtection($true, $false) + $acl.SetOwner($sid) + $rule = [System.Security.AccessControl.FileSystemAccessRule]::new( + $sid, + [System.Security.AccessControl.FileSystemRights]::FullControl, + [System.Security.AccessControl.AccessControlType]::Allow) +} +[void]$acl.AddAccessRule($rule) +Set-Acl -LiteralPath $targetPath -AclObject $acl +$targetItem = Get-Item -LiteralPath $targetPath -Force +$verified = $targetItem.GetAccessControl() +$verifiedOwner = $verified.GetOwner( + [System.Security.Principal.SecurityIdentifier]) +$verifiedRules = @($verified.GetAccessRules( + $true, + $true, + [System.Security.Principal.SecurityIdentifier])) +if ( + -not $verifiedOwner.Equals($sid) -or + -not $verified.AreAccessRulesProtected -or + $verifiedRules.Count -ne 1 +) { exit 73 } +$verifiedRule = $verifiedRules[0] +if ( + -not $verifiedRule.IdentityReference.Equals($sid) -or + $verifiedRule.AccessControlType -ne + [System.Security.AccessControl.AccessControlType]::Allow -or + [int]$verifiedRule.FileSystemRights -ne + [int][System.Security.AccessControl.FileSystemRights]::FullControl -or + $verifiedRule.InheritanceFlags -ne $inheritance -or + $verifiedRule.PropagationFlags -ne + [System.Security.AccessControl.PropagationFlags]::None -or + $verifiedRule.IsInherited +) { exit 74 } +`; + try { + await runProcess( + powershell, + ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', script], + { + cwd: appRoot, + timeoutMs: 30_000, + env: controlledWindowsPowerShellEnvironment({ + AGC_OWNED_TEMP_PATH: targetPath, + AGC_OWNED_TEMP_PATH_IS_DIRECTORY: isDirectory ? '1' : '0', + }), + }, + ); + } catch (error) { + throw codedError('isolated-windows-owned-path-secure-failed', error); + } +} + export async function captureSourceRunnerEndpointSnapshot(sourceConfigDir) { const endpointPath = path.join(sourceConfigDir, runnerEndpointFileName); const metadata = await fs.lstat(endpointPath).catch((error) => { @@ -439,8 +560,11 @@ export async function prepareIsolatedSuiteAppData({ state.isolatedRunner.appDataDir = appDataDir; state.isolatedRunner.ownerToken = ownerToken; state.isolatedRunner.createdAt = createdAt; + const realAppDataDir = await fs.realpath(appDataDir); + state.formalConfigPathTranscriptScanner?.addSecrets( + absolutePathVariants(appDataDir, realAppDataDir), + ); if (isolatedSuiteProtectsSourceAppData()) { - const realAppDataDir = await fs.realpath(appDataDir); assert( !isPathInside(sourceConfigDir, realAppDataDir) && path.dirname(realAppDataDir) === path.dirname(sourceConfigDir), @@ -708,6 +832,56 @@ export async function prepareIsolatedSuiteAppData({ }); assert(!unexpectedEndpoint, `${profile.codePrefix}-endpoint-preexisted`); state.runtimeConfigDir = appDataDir; + if (isSupervisorAutonomousPlayableLaneDefenseSuite()) { + const isolatedConfig = await loadConfig(appDataDir); + const expectedBinding = state.config.providerBinding; + const agentIds = isSupervisorGameChatSingleMainPlayableSuite() + ? [ + projectSupervisorAgentId, + mainAgentId, + 'art-director', + 'art-asset-plan', + ] + : [projectSupervisorAgentId]; + const bindings = agentIds.map((agentId) => { + const effective = effectiveAgentLlmConfig(isolatedConfig.config, agentId); + assert( + ['apiKey', 'baseUrl', 'model', 'apiKind', 'reasoningEffort'].every( + (key) => isNonEmptyString(effective[key]), + ), + 'supervisor-autonomous-playable-effective-provider-incomplete', + ); + return { + providerModel: effective.model.trim(), + providerApiKind: effective.apiKind.trim(), + providerReasoningEffort: effective.reasoningEffort.trim(), + providerBaseUrlSha256: hashValue(effective.baseUrl.trim()), + }; + }); + const effectiveBinding = { + ...bindings[0], + ...(isSupervisorGameChatSingleMainPlayableSuite() + ? { providerAgentMode: isolatedConfig.config.agentMode?.trim() ?? null } + : {}), + boundAgentIds: [...agentIds].sort(), + }; + assert( + expectedBinding && + bindings.every( + (binding) => JSON.stringify(binding) === JSON.stringify(bindings[0]), + ) && + sameSupervisorPlayableProviderBinding( + effectiveBinding, + expectedBinding, + ), + 'supervisor-autonomous-playable-effective-provider-binding-mismatch', + ); + state.supervisorAutonomousPlayable.expectedProviderBinding = { + ...expectedBinding, + }; + state.supervisorAutonomousPlayable.effectiveProviderBinding = + effectiveBinding; + } if (streamAgentId) { const isolatedConfig = await loadConfig(appDataDir); const isolatedEffective = effectiveAgentLlmConfig( @@ -1066,22 +1240,27 @@ export async function verifyOwnedRunnerForKill() { } export async function ensureOwnedRunnerStableKillSupport() { - assert( - process.platform === 'linux', - 'isolated-runner-stable-kill-handle-platform-unsupported', - ); - const python = await findControlledLinuxPython(); - const probe = - 'import os, signal; assert hasattr(os, "pidfd_open") and hasattr(signal, "pidfd_send_signal"); fd = os.pidfd_open(os.getpid(), 0); os.close(fd)'; - try { - await runProcess(python, ['-I', '-S', '-c', probe], { - cwd: appRoot, - timeoutMs: 30_000, - env: { LANG: 'C', LC_ALL: 'C', PATH: '/usr/bin:/bin' }, - }); - } catch (error) { - throw codedError('isolated-runner-pidfd-support-unavailable', error); + if (process.platform === 'linux') { + const python = await findControlledLinuxPython(); + const probe = + 'import os, signal; assert hasattr(os, "pidfd_open") and hasattr(signal, "pidfd_send_signal"); fd = os.pidfd_open(os.getpid(), 0); os.close(fd)'; + try { + await runProcess(python, ['-I', '-S', '-c', probe], { + cwd: appRoot, + timeoutMs: 30_000, + env: { LANG: 'C', LC_ALL: 'C', PATH: '/usr/bin:/bin' }, + }); + } catch (error) { + throw codedError('isolated-runner-pidfd-support-unavailable', error); + } + return; } + if (process.platform === 'win32') { + const handle = await openOwnedRunnerKillHandle(process.pid); + await closeOwnedRunnerKillHandle(handle); + return; + } + throw codedError('isolated-runner-stable-kill-handle-platform-unsupported'); } export async function findControlledLinuxPython() { @@ -1099,28 +1278,140 @@ export async function findControlledLinuxPython() { throw codedError('isolated-runner-controlled-python-unavailable'); } +export async function findControlledWindowsPowerShell() { + if (state.windowsProcessHandlePowerShellPath) + return state.windowsProcessHandlePowerShellPath; + const systemRoot = process.env.SystemRoot ?? process.env.WINDIR; + assert( + isNonEmptyString(systemRoot) && path.isAbsolute(systemRoot), + 'isolated-runner-windows-system-root-invalid', + ); + const candidate = path.join( + systemRoot, + 'System32', + 'WindowsPowerShell', + 'v1.0', + 'powershell.exe', + ); + const resolved = await fs.realpath(candidate).catch(() => null); + const metadata = resolved ? await fs.stat(resolved).catch(() => null) : null; + assert( + metadata?.isFile(), + 'isolated-runner-controlled-powershell-unavailable', + ); + state.windowsProcessHandlePowerShellPath = resolved; + return resolved; +} + +export function controlledWindowsPowerShellEnvironment(extra = {}) { + const systemRoot = process.env.SystemRoot ?? process.env.windir; + assert( + isNonEmptyString(systemRoot) && path.isAbsolute(systemRoot), + 'isolated-runner-windows-system-root-invalid', + ); + const powershellRoot = path.join( + systemRoot, + 'System32', + 'WindowsPowerShell', + 'v1.0', + ); + const programFiles = process.env.ProgramW6432 ?? process.env.ProgramFiles; + const moduleRoots = [path.join(powershellRoot, 'Modules')]; + if (isNonEmptyString(programFiles) && path.isAbsolute(programFiles)) { + moduleRoots.push(path.join(programFiles, 'WindowsPowerShell', 'Modules')); + } + const environment = { + SystemRoot: systemRoot, + windir: systemRoot, + ComSpec: path.join(systemRoot, 'System32', 'cmd.exe'), + PATH: [powershellRoot, path.join(systemRoot, 'System32'), systemRoot].join( + path.delimiter, + ), + PATHEXT: '.COM;.EXE;.BAT;.CMD', + PSModulePath: moduleRoots.join(path.delimiter), + }; + for (const name of ['TEMP', 'TMP', 'LOCALAPPDATA', 'USERPROFILE']) { + if (isNonEmptyString(process.env[name])) + environment[name] = process.env[name]; + } + if (isNonEmptyString(programFiles)) environment.ProgramFiles = programFiles; + return { ...environment, ...extra }; +} + export async function openOwnedRunnerKillHandle(pid) { assert( - process.platform === 'linux' && Number.isSafeInteger(pid) && pid > 1, - 'isolated-runner-pidfd-open-precondition-invalid', - ); - const python = await findControlledLinuxPython(); - const child = spawn( - python, - ['-I', '-S', '-c', linuxPidfdHelperSource, String(pid)], - { - cwd: appRoot, - env: { LANG: 'C', LC_ALL: 'C', PATH: '/usr/bin:/bin' }, - stdio: ['pipe', 'pipe', 'pipe'], - }, + Number.isSafeInteger(pid) && pid > 1, + 'isolated-runner-stable-kill-handle-open-precondition-invalid', ); + let child; + let readyMarker; + let exitedMarker; + let kind; + if (process.platform === 'linux') { + const python = await findControlledLinuxPython(); + child = spawn( + python, + ['-I', '-S', '-c', linuxPidfdHelperSource, String(pid)], + { + cwd: appRoot, + env: { LANG: 'C', LC_ALL: 'C', PATH: '/usr/bin:/bin' }, + stdio: ['pipe', 'pipe', 'pipe'], + }, + ); + readyMarker = Buffer.from('PIDFD_READY\n'); + exitedMarker = Buffer.from('PIDFD_EXITED\n'); + kind = 'linux-pidfd'; + } else if (process.platform === 'win32') { + const powershell = await findControlledWindowsPowerShell(); + child = spawn( + powershell, + [ + '-NoLogo', + '-NoProfile', + '-NonInteractive', + '-EncodedCommand', + Buffer.from(windowsProcessHandleHelperSource, 'utf16le').toString( + 'base64', + ), + ], + { + cwd: appRoot, + env: controlledWindowsPowerShellEnvironment({ + AGC_OWNED_RUNNER_PID: String(pid), + }), + stdio: ['pipe', 'pipe', 'pipe'], + windowsHide: true, + }, + ); + readyMarker = Buffer.from('HANDLE_READY'); + exitedMarker = Buffer.from('HANDLE_EXITED'); + kind = 'windows-process-handle'; + } else { + throw codedError('isolated-runner-stable-kill-handle-platform-unsupported'); + } const handle = { + kind, + readyMarker, + exitedMarker, pid, child, stdout: Buffer.alloc(0), stderr: Buffer.alloc(0), closed: false, + command: null, + stdinError: null, }; + activeCommandChildren.add(child); + child.once('error', () => { + if (!Number.isSafeInteger(child.pid)) activeCommandChildren.delete(child); + }); + child.once('close', () => { + activeCommandChildren.delete(child); + handle.closed = true; + }); + child.stdin.on('error', (error) => { + handle.stdinError ??= error; + }); child.stdout.on('data', (chunk) => { handle.stdout = appendBounded(handle.stdout, chunk, 4_096); }); @@ -1134,8 +1425,15 @@ export async function openOwnedRunnerKillHandle(pid) { export async function waitForOwnedRunnerKillHandleReady(handle) { await new Promise((resolve, reject) => { const timer = setTimeout(() => { - handle.child.kill('SIGKILL'); - reject(codedError('isolated-runner-pidfd-open-timeout')); + settle(() => { + void terminateOwnedRunnerKillHandleHelper(handle).then( + () => + reject( + codedError('isolated-runner-stable-kill-handle-open-timeout'), + ), + reject, + ); + }); }, 10_000); const settle = (callback) => { clearTimeout(timer); @@ -1145,16 +1443,23 @@ export async function waitForOwnedRunnerKillHandleReady(handle) { callback(); }; const onData = () => { - if (handle.stdout.includes(Buffer.from('PIDFD_READY\n'))) { + if (handle.stdout.includes(handle.readyMarker)) { settle(resolve); } }; const onError = (error) => settle(() => - reject(codedError('isolated-runner-pidfd-helper-spawn-failed', error)), + reject( + codedError( + 'isolated-runner-stable-kill-handle-helper-spawn-failed', + error, + ), + ), ); const onClose = () => - settle(() => reject(codedError('isolated-runner-pidfd-open-failed'))); + settle(() => + reject(codedError('isolated-runner-stable-kill-handle-open-failed')), + ); handle.child.stdout.on('data', onData); handle.child.on('error', onError); handle.child.on('close', onClose); @@ -1164,55 +1469,250 @@ export async function waitForOwnedRunnerKillHandleReady(handle) { export async function closeOwnedRunnerKillHandle(handle) { if (!handle || handle.closed) return; - handle.closed = true; - if (handle.child.exitCode !== null || handle.child.signalCode !== null) + if (handle.child.exitCode !== null || handle.child.signalCode !== null) { + await waitForChildClose(handle.child, 2_000, 5_000); + handle.closed = true; return; - handle.child.stdin.end('CLOSE\n'); - const result = await waitForChildClose(handle.child, 10_000); - assert(result.code === 0, 'isolated-runner-pidfd-close-failed'); + } + if (handle.command !== null) { + await terminateOwnedRunnerKillHandleHelper(handle); + return; + } + handle.command = 'CLOSE'; + try { + await writeOwnedRunnerKillHandleCommand(handle, 'CLOSE'); + const result = await waitForChildClose(handle.child, 10_000); + assert( + result.code === 0, + 'isolated-runner-stable-kill-handle-close-failed', + ); + handle.closed = true; + } catch (error) { + await rethrowOwnedRunnerKillHandleFailureAfterCleanup(handle, error); + } } export async function signalOwnedRunnerKillHandle(handle) { assert( handle && handle.closed === false && + handle.command === null && handle.child.exitCode === null && handle.child.signalCode === null, - 'isolated-runner-pidfd-handle-not-live', - ); - handle.closed = true; - handle.child.stdin.end('KILL\n'); - const result = await waitForChildClose(handle.child, 15_000); - assert( - result.code === 0 && handle.stdout.includes(Buffer.from('PIDFD_EXITED\n')), - 'isolated-runner-pidfd-sigkill-failed', + 'isolated-runner-stable-kill-handle-not-live', ); + handle.command = 'KILL'; + try { + await writeOwnedRunnerKillHandleCommand(handle, 'KILL'); + const result = await waitForChildClose(handle.child, 15_000); + assert( + result.code === 0 && handle.stdout.includes(handle.exitedMarker), + 'isolated-runner-stable-kill-handle-signal-failed', + ); + handle.closed = true; + } catch (error) { + await rethrowOwnedRunnerKillHandleFailureAfterCleanup(handle, error); + } } -export async function waitForChildClose(child, timeoutMs) { +export async function writeOwnedRunnerKillHandleCommand( + handle, + command, + timeoutMs = 5_000, +) { + assert( + handle?.child?.stdin && + !handle.child.stdin.destroyed && + !handle.child.stdin.writableEnded && + ['CLOSE', 'KILL'].includes(command) && + Number.isSafeInteger(timeoutMs) && + timeoutMs > 0, + 'isolated-runner-stable-kill-handle-stdin-unavailable', + ); + await new Promise((resolve, reject) => { + let settled = false; + let timer = null; + const finish = (error = null) => { + if (settled) return; + settled = true; + clearTimeout(timer); + handle.child.stdin.off('error', onError); + if (error) reject(error); + else resolve(); + }; + const onError = (error) => { + finish( + codedError('isolated-runner-stable-kill-handle-stdin-failed', error), + ); + }; + timer = setTimeout(() => { + finish(codedError('isolated-runner-stable-kill-handle-stdin-timeout')); + }, timeoutMs); + handle.child.stdin.once('error', onError); + try { + handle.child.stdin.end(`${command}\n`, () => finish()); + } catch (error) { + onError(error); + } + }); +} + +async function rethrowOwnedRunnerKillHandleFailureAfterCleanup(handle, error) { + try { + await terminateOwnedRunnerKillHandleHelper(handle); + } catch (cleanupError) { + throw codedError( + isNonEmptyString(error?.code) + ? error.code + : 'isolated-runner-stable-kill-handle-operation-failed', + new AggregateError( + [error, cleanupError], + 'stable kill handle operation and helper cleanup both failed', + ), + ); + } + throw error; +} + +async function terminateOwnedRunnerKillHandleHelper(handle) { + if (!handle || handle.closed) return; + const child = handle.child; if (child.exitCode !== null || child.signalCode !== null) { + await waitForChildClose(child, 2_000, 5_000); + handle.closed = true; + return; + } + let initialKillError = null; + try { + if (!child.kill('SIGKILL')) { + initialKillError = codedError( + 'isolated-runner-stable-kill-handle-helper-kill-rejected', + ); + } + } catch (error) { + initialKillError = codedError( + 'isolated-runner-stable-kill-handle-helper-kill-failed', + error, + ); + } + try { + await waitForChildClose(child, 2_000, 5_000); + handle.closed = true; + } catch (error) { + if (child.exitCode !== null || child.signalCode !== null) { + handle.closed = true; + return; + } + if (initialKillError) { + throw codedError( + 'isolated-runner-stable-kill-handle-helper-cleanup-failed', + new AggregateError( + [initialKillError, error], + 'stable kill handle helper could not be terminated and reaped', + ), + ); + } + throw error; + } +} + +export async function waitForChildClose( + child, + timeoutMs, + reapTimeoutMs = 5_000, +) { + assert( + child && + Number.isSafeInteger(timeoutMs) && + timeoutMs > 0 && + Number.isSafeInteger(reapTimeoutMs) && + reapTimeoutMs > 0, + 'isolated-runner-stable-kill-handle-wait-precondition-invalid', + ); + if ( + (child.exitCode !== null || child.signalCode !== null) && + childProcessStdioClosed(child) + ) { return { code: child.exitCode, signal: child.signalCode }; } return new Promise((resolve, reject) => { + let settled = false; + let timedOut = false; + let killError = null; + let reapTimer = null; + const finish = (error = null, result = null) => { + if (settled) return; + settled = true; + clearTimeout(timer); + clearTimeout(reapTimer); + child.off('error', onError); + child.off('close', onClose); + if (error) reject(error); + else resolve(result); + }; const timer = setTimeout(() => { - child.kill('SIGKILL'); - reject(codedError('isolated-runner-pidfd-helper-timeout')); + timedOut = true; + try { + if (!child.kill('SIGKILL')) { + killError = codedError( + 'isolated-runner-stable-kill-handle-helper-kill-rejected', + ); + } + } catch (error) { + killError = codedError( + 'isolated-runner-stable-kill-handle-helper-kill-failed', + error, + ); + } + reapTimer = setTimeout(() => { + finish( + codedError( + 'isolated-runner-stable-kill-handle-helper-reap-timeout', + killError, + ), + ); + }, reapTimeoutMs); + if ( + (child.exitCode !== null || child.signalCode !== null) && + childProcessStdioClosed(child) + ) { + onClose(child.exitCode, child.signalCode); + } }, timeoutMs); const onError = (error) => { - clearTimeout(timer); - child.off('close', onClose); - reject(codedError('isolated-runner-pidfd-helper-failed', error)); + finish( + codedError('isolated-runner-stable-kill-handle-helper-failed', error), + ); }; const onClose = (code, signal) => { - clearTimeout(timer); - child.off('error', onError); - resolve({ code, signal }); + if (timedOut) { + finish( + codedError( + 'isolated-runner-stable-kill-handle-helper-timeout', + killError, + ), + ); + } else { + finish(null, { code, signal }); + } }; child.once('error', onError); child.once('close', onClose); + if ( + (child.exitCode !== null || child.signalCode !== null) && + childProcessStdioClosed(child) + ) { + onClose(child.exitCode, child.signalCode); + } }); } +function childProcessStdioClosed(child) { + return [child.stdin, child.stdout, child.stderr] + .filter(Boolean) + .every((stream) => stream.destroyed || stream.closed === true); +} + export async function captureOwnedRunnerProcessIdentity(pid) { const expectedAppData = state.isolatedRunner.appDataDir; assert( @@ -1269,11 +1769,16 @@ export async function captureOwnedRunnerProcessIdentity(pid) { } if (process.platform === 'win32') { + const powershell = await findControlledWindowsPowerShell(); const script = `$process = Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}"; if ($null -eq $process) { exit 3 }; $process | Select-Object ProcessId,CreationDate,ExecutablePath,CommandLine | ConvertTo-Json -Compress`; const result = await runProcess( - 'powershell.exe', + powershell, ['-NoProfile', '-NonInteractive', '-Command', script], - { cwd: appRoot, timeoutMs: 30_000 }, + { + cwd: appRoot, + timeoutMs: 30_000, + env: controlledWindowsPowerShellEnvironment(), + }, ); const value = JSON.parse(result.stdout); const executable = path.resolve(String(value.ExecutablePath ?? '')); @@ -1322,7 +1827,7 @@ export async function killRunnerPidOnce(pid, ownedRunner) { const claimed = state.isolatedRunner.current; assert( claimed?.pid === pid && claimed.killHandle?.pid === pid, - 'isolated-runner-pidfd-identity-missing', + 'isolated-runner-stable-kill-handle-identity-missing', ); await signalOwnedRunnerKillHandle(claimed.killHandle); state.isolatedRunner.pidfdSignalCount += 1; diff --git a/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/harness/process.mjs b/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/harness/process.mjs index 139e9f677..d9c44b0d0 100644 --- a/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/harness/process.mjs +++ b/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/harness/process.mjs @@ -19,6 +19,8 @@ import { } from '../suites/supervisor-swarm.mjs'; import { isIsolatedRunnerSuite } from './reporting.mjs'; +export const activeInteractiveCliSessions = new Set(); + export async function prepareCliBinary() { const cargo = process.platform === 'win32' ? 'cargo.exe' : 'cargo'; await runProcess( @@ -154,36 +156,70 @@ export function startInteractiveCli(args) { stdio: ['pipe', 'pipe', 'pipe'], }, ); + return createInteractiveCliSession(child); +} + +export function createInteractiveCliSession(child) { activeCommandChildren.add(child); const session = { child, stdout: Buffer.alloc(0), stderr: Buffer.alloc(0), + exited: false, + exitInfo: null, + exitPromise: null, closed: false, closeInfo: null, closePromise: null, + stdioClosed: false, + stdioCloseInfo: null, + spawnError: null, + stdinError: null, }; + activeInteractiveCliSessions.add(session); + session.exitPromise = new Promise((resolve) => { + const settle = (result) => { + if (session.exited) return; + activeCommandChildren.delete(child); + session.exited = true; + session.closed = true; + session.exitInfo = result; + session.closeInfo = result; + resolve(result); + }; + child.once('error', (error) => { + session.spawnError = error; + settle({ code: null, signal: null, error }); + }); + child.once('exit', (code, signal) => { + settle({ code, signal, error: null }); + }); + }); session.closePromise = new Promise((resolve) => { - child.on('error', (error) => { + child.once('close', (code, signal) => { activeCommandChildren.delete(child); - session.closed = true; - session.closeInfo = { code: null, signal: null, error }; - resolve(session.closeInfo); - }); - child.on('close', (code, signal) => { - activeCommandChildren.delete(child); - session.closed = true; - session.closeInfo = { code, signal, error: null }; - resolve(session.closeInfo); + activeInteractiveCliSessions.delete(session); + session.stdioClosed = true; + session.stdioCloseInfo = { + code, + signal, + error: session.spawnError, + }; + resolve(session.stdioCloseInfo); }); }); + child.stdin?.on('error', (error) => { + session.stdinError ??= error; + }); child.stdout.on('data', (chunk) => { state.transcriptScanner?.scan('interactive-stdout', chunk); + state.projectPathTranscriptScanner?.scan('interactive-stdout', chunk); state.formalConfigPathTranscriptScanner?.scan('interactive-stdout', chunk); session.stdout = appendBounded(session.stdout, chunk, commandOutputLimit); }); child.stderr.on('data', (chunk) => { state.transcriptScanner?.scan('interactive-stderr', chunk); + state.projectPathTranscriptScanner?.scan('interactive-stderr', chunk); state.formalConfigPathTranscriptScanner?.scan('interactive-stderr', chunk); session.stderr = appendBounded(session.stderr, chunk, commandOutputLimit); }); @@ -205,16 +241,23 @@ export async function waitForInteractiveCliOutput( predicate, code, timeoutMs, + { allowAfterProcessExit = false } = {}, ) { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { const output = interactiveCliOutput(session); if (predicate(output)) return output; - if (session.closed) { + if (session.exited && !allowAfterProcessExit) { if (session === state.supervisorSwarmCliSession) { recordSupervisorSwarmChatSessionFailureDiagnostic(session); } - throw codedError(`${code}-cli-closed`); + throw codedError(`${code}-cli-exited`); + } + if (session.stdioClosed) { + if (session === state.supervisorSwarmCliSession) { + recordSupervisorSwarmChatSessionFailureDiagnostic(session); + } + throw codedError(`${code}-cli-stdio-closed`); } await sleep(50); } @@ -223,7 +266,7 @@ export async function waitForInteractiveCliOutput( export async function waitForInteractiveCliExit(session, timeoutMs) { const result = await Promise.race([ - session.closePromise, + session.exitPromise, sleep(timeoutMs).then(() => null), ]); if (!result) throw codedError('interactive-cli-exit-timeout'); @@ -236,26 +279,52 @@ export async function waitForInteractiveCliExit(session, timeoutMs) { } export async function closeInteractiveCli(session) { - if (!session || session.closed) return; - if (session.child.stdin.writable) { + if (!session) return null; + if (session.exited) return session.exitInfo; + if ( + session.child.stdin.writable && + !session.child.stdin.writableEnded && + !session.child.stdin.destroyed + ) { session.child.stdin.write('/quit\n'); } let result = await Promise.race([ - session.closePromise, + session.exitPromise, sleep(3_000).then(() => null), ]); - if (!result && !session.closed) { + if (!result && !session.exited) { session.child.kill('SIGTERM'); result = await Promise.race([ - session.closePromise, + session.exitPromise, sleep(2_000).then(() => null), ]); } - if (!result && !session.closed) { + if (!result && !session.exited) { session.child.kill('SIGKILL'); - result = await session.closePromise; + result = await Promise.race([ + session.exitPromise, + sleep(5_000).then(() => null), + ]); } assert(Boolean(result), 'interactive-cli-cleanup-timeout'); + return result; +} + +export async function waitForInteractiveCliStdioClose(session, timeoutMs) { + if (!session || session.stdioClosed) return session?.stdioCloseInfo ?? null; + const result = await Promise.race([ + session.closePromise, + sleep(timeoutMs).then(() => null), + ]); + if (!result) throw codedError('interactive-cli-stdio-close-timeout'); + return result; +} + +export function destroyInteractiveCliOutputStreams(session) { + if (!session) return; + for (const stream of [session.child.stdout, session.child.stderr]) { + if (stream && !stream.destroyed) stream.destroy(); + } } export async function runProcess( @@ -329,6 +398,214 @@ export async function runProcess( }); } +export async function listSystemProcessIdentities() { + if (process.platform === 'win32') { + const systemRoot = process.env.SystemRoot ?? process.env.SYSTEMROOT; + assert( + typeof systemRoot === 'string' && path.isAbsolute(systemRoot), + 'owned-process-snapshot-system-root-invalid', + ); + const powershell = path.join( + systemRoot, + 'System32/WindowsPowerShell/v1.0/powershell.exe', + ); + const metadata = await fs.lstat(powershell); + assert( + metadata.isFile() && !metadata.isSymbolicLink(), + 'owned-process-snapshot-powershell-invalid', + ); + const result = await runProcess( + powershell, + [ + '-NoProfile', + '-NonInteractive', + '-Command', + '$processes = @(Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,CreationDate,Name); $processes | ConvertTo-Json -Compress', + ], + { + cwd: appRoot, + timeoutMs: 30_000, + env: { ...process.env, NO_COLOR: '1', RUST_BACKTRACE: '0' }, + }, + ); + const parsed = JSON.parse(result.stdout); + return (Array.isArray(parsed) ? parsed : [parsed]) + .map((record) => ({ + pid: Number(record?.ProcessId), + parentPid: Number(record?.ParentProcessId), + startedAt: String(record?.CreationDate ?? ''), + name: String(record?.Name ?? ''), + })) + .filter(validSystemProcessIdentity); + } + assert( + process.platform === 'linux' || process.platform === 'darwin', + 'owned-process-snapshot-platform-unsupported', + ); + const result = await runProcess( + 'ps', + ['-A', '-o', 'pid=', '-o', 'ppid=', '-o', 'lstart=', '-o', 'comm='], + { cwd: appRoot, timeoutMs: 30_000 }, + ); + return result.stdout + .split(/\r?\n/u) + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => { + const fields = line.split(/\s+/u); + return { + pid: Number(fields[0]), + parentPid: Number(fields[1]), + startedAt: fields.slice(2, 7).join(' '), + name: fields.slice(7).join(' '), + }; + }) + .filter(validSystemProcessIdentity); +} + +function validSystemProcessIdentity(record) { + return ( + Number.isSafeInteger(record?.pid) && + record.pid > 0 && + Number.isSafeInteger(record.parentPid) && + record.parentPid >= 0 && + typeof record.startedAt === 'string' && + record.startedAt.length > 0 && + typeof record.name === 'string' && + record.name.length > 0 + ); +} + +export function buildOwnedProcessCleanupSnapshot( + processRecords, + { rootPids = [], runnerPid = null, helperPids = [] } = {}, +) { + assert( + Array.isArray(processRecords) && + Array.isArray(rootPids) && + Array.isArray(helperPids), + 'owned-process-snapshot-input-invalid', + ); + const records = processRecords.filter(validSystemProcessIdentity); + const byPid = new Map(records.map((record) => [record.pid, record])); + const childrenByParent = new Map(); + for (const record of records) { + const children = childrenByParent.get(record.parentPid) ?? []; + children.push(record.pid); + childrenByParent.set(record.parentPid, children); + } + const normalizedRunnerPid = Number.isSafeInteger(runnerPid) + ? runnerPid + : null; + const helperPidSet = new Set( + helperPids.filter((pid) => Number.isSafeInteger(pid) && pid > 0), + ); + const roots = [ + ...new Set( + [...rootPids, normalizedRunnerPid, ...helperPidSet].filter( + (pid) => Number.isSafeInteger(pid) && pid > 0, + ), + ), + ]; + assert(roots.length > 0, 'owned-process-snapshot-root-missing'); + const ownedPids = new Set(); + const queue = [...roots]; + while (queue.length > 0) { + const pid = queue.shift(); + if (ownedPids.has(pid)) continue; + ownedPids.add(pid); + queue.push(...(childrenByParent.get(pid) ?? [])); + } + const identities = [...ownedPids] + .map((pid) => byPid.get(pid)) + .filter(Boolean) + .map((record) => ({ + pid: record.pid, + startedAt: record.startedAt, + name: record.name, + kind: ownedProcessKind(record, normalizedRunnerPid, helperPidSet), + })) + .sort((left, right) => left.pid - right.pid); + return { + identities, + observedCounts: countOwnedProcessKinds(identities), + }; +} + +function ownedProcessKind(record, runnerPid, helperPids) { + if (record.pid === runnerPid) return 'runner'; + if (helperPids.has(record.pid)) return 'helper'; + const name = path.basename(record.name).toLowerCase(); + if (/^node(?:\.exe)?$/u.test(name)) return 'node'; + if (/^(?:chrome|chromium|msedge|google-chrome)(?:\.exe)?$/u.test(name)) { + return 'browser'; + } + return 'command'; +} + +function countOwnedProcessKinds(identities) { + const counts = { + runner: 0, + helper: 0, + node: 0, + browser: 0, + command: 0, + total: identities.length, + }; + for (const identity of identities) counts[identity.kind] += 1; + return counts; +} + +export function inspectOwnedProcessCleanupResiduals( + snapshot, + processRecords, + { activeCommandChildCount = 0, activeInteractiveCliSessionCount = 0 } = {}, +) { + assert( + Array.isArray(snapshot?.identities) && Array.isArray(processRecords), + 'owned-process-residual-input-invalid', + ); + const currentByPid = new Map( + processRecords + .filter(validSystemProcessIdentity) + .map((record) => [record.pid, record]), + ); + const residualIdentities = snapshot.identities.filter((identity) => { + const current = currentByPid.get(identity.pid); + return ( + current?.startedAt === identity.startedAt && + current?.name === identity.name + ); + }); + return { + residualCounts: countOwnedProcessKinds(residualIdentities), + activeCommandChildCount, + activeInteractiveCliSessionCount, + clean: + residualIdentities.length === 0 && + activeCommandChildCount === 0 && + activeInteractiveCliSessionCount === 0, + }; +} + +export async function captureOwnedProcessCleanupSnapshot(options) { + return buildOwnedProcessCleanupSnapshot( + await listSystemProcessIdentities(), + options, + ); +} + +export async function verifyOwnedProcessCleanupSnapshot(snapshot) { + return inspectOwnedProcessCleanupResiduals( + snapshot, + await listSystemProcessIdentities(), + { + activeCommandChildCount: activeCommandChildren.size, + activeInteractiveCliSessionCount: activeInteractiveCliSessions.size, + }, + ); +} + export function appendBounded(current, chunk, limit) { const combined = Buffer.concat([current, chunk]); return combined.length <= limit diff --git a/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/harness/project.mjs b/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/harness/project.mjs index 445d66175..68c0b6fce 100644 --- a/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/harness/project.mjs +++ b/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/harness/project.mjs @@ -1,4 +1,4 @@ -import { assert } from '../assertions/core.mjs'; +import { assert, hashValue } from '../assertions/core.mjs'; import { disposableProjectPathVariants } from '../assertions/runtime.mjs'; import { fs, os, path, randomUUID } from '../dependencies.mjs'; import { @@ -47,7 +47,12 @@ export function requiredAgentIdsForSuite() { return isUserInputRuntimeSuite() ? [projectSupervisorAgentId] : isSupervisorGameChatSingleMainPlayableSuite() - ? [projectSupervisorAgentId, mainAgentId] + ? [ + projectSupervisorAgentId, + mainAgentId, + 'art-director', + 'art-asset-plan', + ] : isSupervisorAutonomousPlayableLaneDefenseSuite() ? [projectSupervisorAgentId] : isSupervisorSwarmSuite() @@ -61,15 +66,74 @@ export function requiredAgentIdsForSuite() { : [mainAgentId, 'quality-review']; } +export function expectedProviderBindingForSuite(config) { + if ( + isSupervisorGameChatSingleMainPlayableSuite() && + config.agentMode !== 'provider' + ) { + return null; + } + const agentIds = requiredAgentIdsForSuite(); + const effectiveConfigs = agentIds.map((agentId) => + effectiveAgentLlmConfig(config, agentId), + ); + if ( + effectiveConfigs.length === 0 || + effectiveConfigs.some((effective) => + ['apiKey', 'baseUrl', 'model', 'apiKind', 'reasoningEffort'].some( + (key) => + typeof effective[key] !== 'string' || + effective[key].trim().length === 0, + ), + ) + ) { + return null; + } + const [expected] = effectiveConfigs; + const expectedIdentity = [ + expected.model.trim(), + expected.apiKind.trim(), + expected.reasoningEffort.trim(), + expected.baseUrl.trim(), + ]; + if ( + effectiveConfigs.some( + (effective) => + JSON.stringify([ + effective.model.trim(), + effective.apiKind.trim(), + effective.reasoningEffort.trim(), + effective.baseUrl.trim(), + ]) !== JSON.stringify(expectedIdentity), + ) + ) { + return null; + } + return { + ...(isSupervisorGameChatSingleMainPlayableSuite() + ? { providerAgentMode: 'provider' } + : {}), + providerModel: expectedIdentity[0], + providerApiKind: expectedIdentity[1], + providerReasoningEffort: expectedIdentity[2], + providerBaseUrlSha256: hashValue(expectedIdentity[3]), + boundAgentIds: [...agentIds].sort(), + }; +} + export async function checkPrerequisites(config) { const requiredAgents = requiredAgentIdsForSuite(); - const llmConfigured = requiredAgents.every((agentId) => { + let llmConfigured = requiredAgents.every((agentId) => { const effective = effectiveAgentLlmConfig(config, agentId); return ['apiKey', 'baseUrl', 'model'].every( (key) => typeof effective[key] === 'string' && effective[key].trim().length > 0, ); }); + const providerBinding = expectedProviderBindingForSuite(config); + if (isSupervisorGameChatSingleMainPlayableSuite()) { + llmConfigured = llmConfigured && providerBinding !== null; + } const editorApiConfigured = ['apiKey', 'baseUrl'].every( (key) => typeof config.editorApi?.[key] === 'string' && @@ -77,6 +141,7 @@ export async function checkPrerequisites(config) { ); return { llmConfigured, + providerBinding, chromeAvailable: !isIsolatedRunnerSuite() || isSupervisorAutonomousPlayableLaneDefenseSuite() @@ -159,7 +224,9 @@ export function supportedBrowserCandidates(platform, environment) { return candidates; } -export async function seedDisposableProject() { +export async function seedDisposableProject({ + preserveProductionInitBaseline = false, +} = {}) { const prefix = path.join(os.tmpdir(), 'genarrative-agent-runtime-real-e2e-'); const sentinelToken = randomUUID(); state.projectRoot = await createSentinelOwnedTempDirectory({ @@ -198,37 +265,42 @@ export async function seedDisposableProject() { ...(isResponseStreamSuite() ? [responseStreamThinkingCanary] : []), ]; + const generatedBaselineWrites = preserveProductionInitBaseline + ? [] + : [ + fs.writeFile( + path.join(state.projectRoot, 'package.json'), + `${JSON.stringify( + { + name: 'genarrative-agent-runtime-real-e2e-project', + private: true, + scripts: { + test: verificationCommand, + 'check:e2e': verificationCommand, + }, + }, + null, + 2, + )}\n`, + ), + fs.writeFile( + path.join(state.projectRoot, 'verify-e2e.mjs'), + isSupervisorSwarmSuite() + ? supervisorSwarmVerificationFixtureSource() + : isGoalRuntimeSuite() || + isResponseStreamSuite() || + isWebSearchSuite() || + isSupervisorAutonomousPlayableLaneDefenseSuite() + ? goalRevisionOneVerificationFixtureSource() + : goalRevisionTwoVerificationFixtureSource(), + ), + fs.writeFile( + path.join(state.projectRoot, 'game/index.html'), + seededGameHtml(), + ), + ]; await Promise.all([ - fs.writeFile( - path.join(state.projectRoot, 'package.json'), - `${JSON.stringify( - { - name: 'genarrative-agent-runtime-real-e2e-project', - private: true, - scripts: { - test: verificationCommand, - 'check:e2e': verificationCommand, - }, - }, - null, - 2, - )}\n`, - ), - fs.writeFile( - path.join(state.projectRoot, 'verify-e2e.mjs'), - isSupervisorSwarmSuite() - ? supervisorSwarmVerificationFixtureSource() - : isGoalRuntimeSuite() || - isResponseStreamSuite() || - isWebSearchSuite() || - isSupervisorAutonomousPlayableLaneDefenseSuite() - ? goalRevisionOneVerificationFixtureSource() - : goalRevisionTwoVerificationFixtureSource(), - ), - fs.writeFile( - path.join(state.projectRoot, 'game/index.html'), - seededGameHtml(), - ), + ...generatedBaselineWrites, fs.writeFile( path.join(state.projectRoot, 'AGENTS.md'), '# Real E2E Repository Context\n\n- REPOSITORY_CONTEXT_MARKER: preserve the visible text and canvas.\n- Read package.json before verification.\n- Never read or expose .env, game-creator.config.json, or .agent/private-secret.txt.\n', @@ -273,7 +345,16 @@ export async function seedDisposableProject() { mode: 0o600, }, ); - await initializeDisposableGitRepository(); + await initializeDisposableGitRepository( + preserveProductionInitBaseline + ? [ + 'AGENTS.md', + 'e2e/isolated-a/evidence.txt', + 'e2e/isolated-b/evidence.txt', + 'e2e/isolated-c/evidence.txt', + ] + : undefined, + ); } export async function initializeDisposableGitRepository( @@ -319,12 +400,17 @@ export function seededGameHtml() { ${visibleText} REAL_E2E_TARGET:before + Objective: survive until victory. On defeat, use Restart. ') && + lowerCandidate.includes('
REAL_E2E_TARGET:before
Objective: survive until victory. On defeat, use Restart.