From 02fff8f3084fabcdf5f74e2ff2726358ab7d85e0 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Fri, 14 Aug 2026 07:14:25 +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=E6=B8=B8=E6=88=8F=E7=94=9F=E6=88=90=E9=98=BB?= =?UTF-8?q?=E6=96=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 普通Web工作台默认使用单Supervisor自主生成链路 补齐安全默认返工、结构化诊断与同Run恢复 绑定当前revision、入口摘要和双视口试玩完成凭证 增强Runner跨boot单飞恢复与game-chat来源隔离 新增真实Provider验收入口及确定性回归 同步更新技术方案、开发流程和共享决策 --- apps/ai-game-creator-shell/package.json | 1 + .../agent-runtime-real-e2e/harness/config.mjs | 2 + .../harness/project.mjs | 37 +- .../agent-runtime-real-e2e/runtime-state.mjs | 3 + .../suites/self-test.mjs | 72 ++ .../suites/supervisor-autonomous-playable.mjs | 124 ++- .../src/agent/generation/draft_validation.rs | 3 +- .../src/agent/runtime_actions/action_audit.rs | 73 ++ .../runtime_actions/autonomous_policy.rs | 3 + .../agent/runtime_actions/project_gates.rs | 59 +- .../runtime_driver/game_chat_fast_path.rs | 13 +- .../src/agent/runtime_driver/main_loop.rs | 28 +- .../agent/runtime_driver/main_loop_tests.rs | 839 +++++++++++++++++- .../agent/runtime_driver/provider_recovery.rs | 18 + .../runtime_protocol/autonomous_completion.rs | 124 ++- .../autonomous_completion_contract_tests.rs | 208 ++++- .../src/agent/runtime_protocol/models.rs | 5 + .../agent/runtime_protocol/verification.rs | 43 +- .../src-tauri/src/agent/runtime_tools.rs | 8 +- .../src/agent/runtime_tools/delegation.rs | 130 ++- .../src/agent/runtime_tools/delivery.rs | 466 +++++++++- .../src-tauri/src/cli.rs | 92 +- .../src-tauri/src/delegation.rs | 270 ++++++ .../src-tauri/src/runner.rs | 34 + .../src-tauri/src/runner/dispatch.rs | 121 ++- .../src-tauri/src/runner/state.rs | 179 +++- .../src-tauri/src/runner/tests.rs | 579 +++++++++++- .../src-tauri/src/swarm_cli.rs | 2 + .../src-tauri/src/swarm_cli/input.rs | 40 +- .../src/swarm_cli/terminal_classification.rs | 50 +- .../src-tauri/src/swarm_cli/tests.rs | 148 ++- .../src-tauri/src/swarm_cli/turn_dispatch.rs | 57 +- .../src-tauri/src/swarm_cli/turn_wait.rs | 8 + .../tests/collaboration/static_deliveries.rs | 389 ++++++++ .../src-tauri/src/tests/command_runtime.rs | 89 ++ .../src-tauri/src/tests/response_stream.rs | 173 ++++ .../tests/runtime_actions/action_execution.rs | 141 +++ .../src/tests/runtime_actions/support.rs | 16 +- apps/ai-game-creator-shell/src/App.tsx | 37 +- .../src/features/agent-runtime/model.ts | 59 +- .../features/app-shell/WorkspaceLauncher.tsx | 2 + .../src/features/app-shell/model.ts | 1 + .../app-shell/useHomeProjectCreation.ts | 1 + .../ProjectSupervisorView.tsx | 74 +- .../src/view/project-development/index.tsx | 156 ++-- .../tests/agentRuntimeModel.test.ts | 126 ++- .../tests/appSurface/home.suite.ts | 1 + .../appSurface/project-development.suite.ts | 47 +- .../shared-memory/decision-log.md | 10 + .../shared-memory/development-workflow.md | 8 + ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 10 + package.json | 1 + 52 files changed, 4823 insertions(+), 357 deletions(-) diff --git a/apps/ai-game-creator-shell/package.json b/apps/ai-game-creator-shell/package.json index 5ad694034..69bd74590 100644 --- a/apps/ai-game-creator-shell/package.json +++ b/apps/ai-game-creator-shell/package.json @@ -24,6 +24,7 @@ "agent-runtime:mixed-swarm-real-e2e": "node scripts/agent-runtime-real-e2e.mjs --suite supervisor-swarm-static-isolated-autonomous-chat", "agent-runtime:supervisor-swarm-autonomous-chat-real-e2e": "node scripts/agent-runtime-real-e2e.mjs --suite supervisor-swarm-autonomous-chat", "agent-runtime:supervisor-autonomous-playable-lane-defense-real-e2e": "node scripts/agent-runtime-real-e2e.mjs --suite supervisor-autonomous-playable-lane-defense", + "agent-runtime:supervisor-game-chat-single-main-playable-real-e2e": "node scripts/agent-runtime-real-e2e.mjs --suite supervisor-game-chat-single-main-playable", "agent-runtime:supervisor-autonomous-playable-lane-defense-deterministic-e2e": "node scripts/agent-runtime-deterministic-playable-e2e.mjs", "agent-runtime:supervisor-autonomous-playable-lane-defense-deterministic-self-test": "node scripts/agent-runtime-deterministic-playable-e2e.mjs --self-test", "agent-runtime:supervisor-swarm-transient-retry-real-e2e": "node scripts/agent-runtime-real-e2e.mjs --suite supervisor-swarm-transient-retry", diff --git a/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/harness/config.mjs b/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/harness/config.mjs index 3f9b19d8f..44d9da6b5 100644 --- a/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/harness/config.mjs +++ b/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/harness/config.mjs @@ -15,6 +15,7 @@ import { scopedAgentsSuite, steerRunnerKillSuite, supervisorAutonomousPlayableLaneDefenseSuite, + supervisorGameChatSingleMainPlayableSuite, supervisorSwarmAutonomousChatSuite, supervisorSwarmCollaborationPolicyMixedRecoverySuite, supervisorSwarmFinalReplyTransientRetrySuite, @@ -70,6 +71,7 @@ export function parseArguments(args) { suite === supervisorSwarmToolPlanHandoffRunnerKillSuite || suite === supervisorSwarmAutonomousChatSuite || suite === supervisorAutonomousPlayableLaneDefenseSuite || + suite === supervisorGameChatSingleMainPlayableSuite || suite === supervisorSwarmStaticIsolatedAutonomousChatSuite || suite === supervisorSwarmCollaborationPolicyMixedRecoverySuite || suite === steerRunnerKillSuite || 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 9fcd0cee4..445d66175 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 @@ -28,7 +28,10 @@ import { isGoalRuntimeSuite, } from '../suites/goal.mjs'; import { isResponseStreamSuite } from '../suites/response-stream.mjs'; -import { isSupervisorAutonomousPlayableLaneDefenseSuite } from '../suites/supervisor-autonomous-playable.mjs'; +import { + isSupervisorAutonomousPlayableLaneDefenseSuite, + isSupervisorGameChatSingleMainPlayableSuite, +} from '../suites/supervisor-autonomous-playable.mjs'; import { isSupervisorSwarmSuite, supervisorSwarmVerificationFixtureSource, @@ -40,20 +43,26 @@ import { effectiveAgentLlmConfig } from './config.mjs'; import { runProcess } from './process.mjs'; import { isIsolatedRunnerSuite } from './reporting.mjs'; -export async function checkPrerequisites(config) { - const requiredAgents = isUserInputRuntimeSuite() +export function requiredAgentIdsForSuite() { + return isUserInputRuntimeSuite() ? [projectSupervisorAgentId] - : isSupervisorAutonomousPlayableLaneDefenseSuite() - ? [projectSupervisorAgentId] - : isSupervisorSwarmSuite() - ? [ - projectSupervisorAgentId, - supervisorSwarmDesignAgentId, - supervisorSwarmQualityAgentId, - ] - : isIsolatedRunnerSuite() - ? [mainAgentId] - : [mainAgentId, 'quality-review']; + : isSupervisorGameChatSingleMainPlayableSuite() + ? [projectSupervisorAgentId, mainAgentId] + : isSupervisorAutonomousPlayableLaneDefenseSuite() + ? [projectSupervisorAgentId] + : isSupervisorSwarmSuite() + ? [ + projectSupervisorAgentId, + supervisorSwarmDesignAgentId, + supervisorSwarmQualityAgentId, + ] + : isIsolatedRunnerSuite() + ? [mainAgentId] + : [mainAgentId, 'quality-review']; +} + +export async function checkPrerequisites(config) { + const requiredAgents = requiredAgentIdsForSuite(); const llmConfigured = requiredAgents.every((agentId) => { const effective = effectiveAgentLlmConfig(config, agentId); return ['apiKey', 'baseUrl', 'model'].every( diff --git a/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/runtime-state.mjs b/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/runtime-state.mjs index 9937fffd4..ff9b12ffe 100644 --- a/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/runtime-state.mjs +++ b/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/runtime-state.mjs @@ -227,6 +227,9 @@ export const supervisorSwarmAutonomousChatSuite = export const supervisorAutonomousPlayableLaneDefenseSuite = 'supervisor-autonomous-playable-lane-defense'; +export const supervisorGameChatSingleMainPlayableSuite = + 'supervisor-game-chat-single-main-playable'; + export const supervisorSwarmStaticIsolatedAutonomousChatSuite = 'supervisor-swarm-static-isolated-autonomous-chat'; diff --git a/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/suites/self-test.mjs b/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/suites/self-test.mjs index 4a43c159f..1700a085b 100644 --- a/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/suites/self-test.mjs +++ b/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/suites/self-test.mjs @@ -22,6 +22,7 @@ import { } from '../harness/app-data.mjs'; import { parseArguments } from '../harness/config.mjs'; import { safeProcessFailureDiagnostic } from '../harness/process.mjs'; +import { requiredAgentIdsForSuite } from '../harness/project.mjs'; import { isIsolatedRunnerSuite } from '../harness/reporting.mjs'; import { agentConversationPath } from '../harness/runtime.mjs'; import { @@ -29,6 +30,7 @@ import { commandFailureMarker, commandPassedMarker, isolatedAgentJoinClaimSchemaVersion, + mainAgentId, projectSupervisorAgentId, providerActionBatchSchemaVersion, repoRoot, @@ -43,6 +45,7 @@ import { supervisorCollaborationPolicySchemaVersion, supervisorCollaborationPolicySnapshotInitialBatchBinding, supervisorCollaborationPolicySnapshotSchemaVersion, + supervisorGameChatSingleMainPlayableSuite, supervisorSwarmAutonomousChatSuite, supervisorSwarmCollaborationPolicyMixedRecoverySuite, supervisorSwarmFollowupIsolatedReviews, @@ -63,6 +66,8 @@ import { buildSupervisorAutonomousPlayableStdin, collectPartialSupervisorAutonomousPlayableEvidence, isSupervisorAutonomousPlayableLaneDefenseSuite, + isSupervisorGameChatSingleMainPlayableSuite, + supervisorAutonomousPlayableMode, } from './supervisor-autonomous-playable.mjs'; import { buildSupervisorSwarmEvidence, @@ -150,6 +155,69 @@ export async function runAgentRuntimeRealE2eSelfTests() { const shellPackage = JSON.parse( readFileSync(path.join(appRoot, 'package.json'), 'utf8'), ); + const previousSuiteForGameChatPlayable = state.suite; + state.suite = supervisorAutonomousPlayableLaneDefenseSuite; + const professionalPlayableMode = supervisorAutonomousPlayableMode(); + const professionalPlayableRequiredAgents = requiredAgentIdsForSuite(); + state.suite = supervisorGameChatSingleMainPlayableSuite; + const gameChatPlayableProfile = isolatedSuiteAppDataProfile(); + const gameChatPlayableParsedArguments = parseArguments([ + '--config-dir', + path.resolve('synthetic-game-chat-playable-config'), + '--suite', + supervisorGameChatSingleMainPlayableSuite, + ]); + const gameChatPlayableMode = supervisorAutonomousPlayableMode(); + const gameChatPlayableRequiredAgents = requiredAgentIdsForSuite(); + const gameChatPlayablePackageCommandsRegistered = + shellPackage.scripts?.[ + 'agent-runtime:supervisor-game-chat-single-main-playable-real-e2e' + ] === + 'node scripts/agent-runtime-real-e2e.mjs --suite supervisor-game-chat-single-main-playable' && + rootPackage.scripts?.[ + 'ai-game-creator-shell:agent-runtime:supervisor-game-chat-single-main-playable-real-e2e' + ] === + 'npm --prefix apps/ai-game-creator-shell run agent-runtime:supervisor-game-chat-single-main-playable-real-e2e --'; + const gameChatPlayableSuiteRegistered = + gameChatPlayableParsedArguments.suite === + supervisorGameChatSingleMainPlayableSuite && + isSupervisorGameChatSingleMainPlayableSuite() && + isSupervisorAutonomousPlayableLaneDefenseSuite() && + !isSupervisorSwarmSuite() && + isIsolatedRunnerSuite() && + isolatedSuiteProtectsSourceAppData() && + isolatedSuiteUsesSiblingAppData() && + gameChatPlayableProfile.sentinelName === + supervisorAutonomousPlayableAppDataSentinelFileName && + gameChatPlayableProfile.sentinelSchema === + supervisorAutonomousPlayableAppDataSentinelSchema; + const gameChatPlayableModeValidated = + professionalPlayableMode.gameChatSingleMain === false && + professionalPlayableMode.rootSource === 'project-supervisor-cli' && + professionalPlayableMode.evidenceAgentId === projectSupervisorAgentId && + professionalPlayableMode.scenario === + 'project-supervisor-autonomous-playable-lane-defense' && + professionalPlayableMode.cliFlags.length === 0 && + gameChatPlayableMode.gameChatSingleMain === true && + gameChatPlayableMode.rootSource === 'project-supervisor-game-chat' && + gameChatPlayableMode.evidenceAgentId === mainAgentId && + gameChatPlayableMode.scenario === + 'project-supervisor-game-chat-single-main-playable' && + JSON.stringify(gameChatPlayableMode.cliFlags) === + JSON.stringify(['--game-chat-smoke']); + const gameChatPlayableRequiredAgentsValidated = + JSON.stringify(professionalPlayableRequiredAgents) === + JSON.stringify([projectSupervisorAgentId]) && + JSON.stringify(gameChatPlayableRequiredAgents) === + JSON.stringify([projectSupervisorAgentId, mainAgentId]); + state.suite = previousSuiteForGameChatPlayable; + assert( + gameChatPlayableSuiteRegistered && + gameChatPlayablePackageCommandsRegistered && + gameChatPlayableModeValidated && + gameChatPlayableRequiredAgentsValidated, + 'agent-runtime-real-e2e-self-test-game-chat-playable-suite-invalid', + ); const previousSuiteForAutonomousPlayable = state.suite; state.suite = supervisorAutonomousPlayableLaneDefenseSuite; const autonomousPlayableProfile = isolatedSuiteAppDataProfile(); @@ -1593,6 +1661,10 @@ export async function runAgentRuntimeRealE2eSelfTests() { collaborationPolicySnapshotBindingStable: true, durableSnapshotEligibilityAndContractBindingValidated: true, sourceEndpointAbsentLifecycleGuardValidated, + gameChatPlayableSuiteRegistered, + gameChatPlayablePackageCommandsRegistered, + gameChatPlayableModeValidated, + gameChatPlayableRequiredAgentsValidated, autonomousPlayableSuiteRegistered, autonomousPlayablePackageCommandsRegistered, autonomousPlayableDedicatedPathValidated: true, diff --git a/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/suites/supervisor-autonomous-playable.mjs b/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/suites/supervisor-autonomous-playable.mjs index 7d0acb8f7..3fbdfb34a 100644 --- a/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/suites/supervisor-autonomous-playable.mjs +++ b/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/suites/supervisor-autonomous-playable.mjs @@ -52,6 +52,7 @@ import { supervisorAutonomousPlayableSafeTurnOutcomes, supervisorAutonomousPlayableSourceFieldMaxChars, supervisorAutonomousPlayableSourceTotalMaxChars, + supervisorGameChatSingleMainPlayableSuite, supervisorSwarmSessionId, supervisorSwarmTerminalSidecarCleanupTimeoutMs, } from '../runtime-state.mjs'; @@ -65,6 +66,29 @@ import { validateSupervisorSwarmFinalization, } from './supervisor-swarm.mjs'; +const projectSupervisorCliSource = 'project-supervisor-cli'; +const projectSupervisorGameChatSource = 'project-supervisor-game-chat'; +const gameChatMainAgentId = 'code-prototype'; + +export function supervisorAutonomousPlayableMode() { + if (state.suite === supervisorGameChatSingleMainPlayableSuite) { + return { + gameChatSingleMain: true, + rootSource: projectSupervisorGameChatSource, + evidenceAgentId: gameChatMainAgentId, + scenario: 'project-supervisor-game-chat-single-main-playable', + cliFlags: ['--game-chat-smoke'], + }; + } + return { + gameChatSingleMain: false, + rootSource: projectSupervisorCliSource, + evidenceAgentId: projectSupervisorAgentId, + scenario: 'project-supervisor-autonomous-playable-lane-defense', + cliFlags: [], + }; +} + export function buildSupervisorAutonomousPlayableStdin() { const task = supervisorAutonomousPlayableLaneDefenseTask; assert( @@ -776,6 +800,7 @@ export async function validateSupervisorAutonomousPlayableEvidence( residualSidecars, ) { const task = supervisorAutonomousPlayableLaneDefenseTask; + const mode = supervisorAutonomousPlayableMode(); const report = state.supervisorAutonomousPlayable.turnReport; assert( report?.schemaVersion === 'game-creator-swarm-turn-report.v1' && @@ -814,7 +839,7 @@ export async function validateSupervisorAutonomousPlayableEvidence( ); assert( parentTask?.task === task && - parentTask.source === 'project-supervisor-cli' && + parentTask.source === mode.rootSource && parentTask.status === 'completed' && parentTask.phase === 'completed' && parentTask.runProfile === autonomousGameBuildRunProfile && @@ -996,7 +1021,7 @@ export async function validateSupervisorAutonomousPlayableEvidence( binding.rootRunId === state.initialRunId && binding.parentAgentId == null && binding.parentRunId == null && - binding.source === 'project-supervisor-cli' && + binding.source === mode.rootSource && binding.profile === autonomousGameBuildRunProfile && binding.profileFingerprint === hashJsonValue({ @@ -1019,6 +1044,67 @@ export async function validateSupervisorAutonomousPlayableEvidence( ); const contract = contracts[0]; const receipt = receipts[0]; + let evidenceBinding = binding; + let evidenceRunId = state.initialRunId; + if (mode.gameChatSingleMain) { + const manifest = JSON.parse( + decodeUtf8Fatal( + await fs.readFile(path.join(state.projectRoot, '.agent/manifest.json')), + 'supervisor-autonomous-playable-manifest-invalid-utf8', + ), + ); + const tasks = Array.isArray(manifest.tasks) ? manifest.tasks : []; + assert( + tasks.length === 1 && + tasks[0]?.id === gameChatMainAgentId && + tasks[0]?.status === 'completed', + 'supervisor-game-chat-single-main-manifest-invalid', + ); + const rootChildTasks = persistence.taskSnapshot.latest.filter( + (candidate) => + candidate.parentAgentId === projectSupervisorAgentId && + candidate.parentRunId === state.initialRunId, + ); + const rootChildRuntimes = persistence.runtimeStates.filter( + (candidate) => + candidate.parentAgentId === projectSupervisorAgentId && + candidate.parentRunId === state.initialRunId, + ); + const codeTask = rootChildTasks[0]; + const codeRuntime = rootChildRuntimes[0]; + assert( + rootChildTasks.length === 1 && + codeTask.agentId === gameChatMainAgentId && + codeTask.status === 'completed' && + codeTask.phase === 'completed' && + codeTask.source === 'agent-ready-task-scheduler' && + rootChildRuntimes.length === 1 && + codeRuntime.agentId === gameChatMainAgentId && + codeRuntime.runId === codeTask.runId && + codeRuntime.phase === 'completed' && + codeRuntime.pendingToolAction == null && + codeRuntime.pendingAction == null, + 'supervisor-game-chat-single-main-runtime-invalid', + ); + evidenceRunId = codeTask.runId; + const codeBindings = runProfileBindings.filter( + (candidate) => + candidate.agentId === gameChatMainAgentId && + candidate.runId === evidenceRunId && + candidate.rootAgentId === projectSupervisorAgentId && + candidate.rootRunId === state.initialRunId && + candidate.parentAgentId === projectSupervisorAgentId && + candidate.parentRunId === state.initialRunId && + candidate.profile === autonomousGameBuildRunProfile && + candidate.source === 'agent-ready-task-scheduler' && + candidate.parentBindingFingerprint === binding.bindingFingerprint, + ); + assert( + codeBindings.length === 1, + 'supervisor-game-chat-single-main-binding-invalid', + ); + evidenceBinding = codeBindings[0]; + } const contractIdentity = { schemaVersion: contract.schemaVersion, projectId: contract.projectId, @@ -1072,23 +1158,22 @@ export async function validateSupervisorAutonomousPlayableEvidence( await readSupervisorSwarmJsonDirectory('.agent/runtime/verification') ).filter( (gate) => - gate.agentId === projectSupervisorAgentId && - gate.runId === state.initialRunId, + gate.agentId === mode.evidenceAgentId && gate.runId === evidenceRunId, ); const gate = gates[0]; const staticSmokeAudits = persistence.agentDb.filter( (record) => record.recordType === 'agent.runtime.command.run_limited' && - record.agentId === projectSupervisorAgentId && - record.runId === state.initialRunId && + record.agentId === mode.evidenceAgentId && + record.runId === evidenceRunId && record.commandId === 'game.static_smoke' && record.status === 'completed', ); assert( gates.length === 1 && - gate.lastVerificationTool === 'game.static_smoke' && gate.lastVerificationStatus === 'passed' && gate.verifiedRevision === revision.revision && + gate.staticSmokeVerifiedRevision === revision.revision && staticSmokeAudits.length >= 1, 'supervisor-autonomous-playable-static-smoke-invalid', ); @@ -1112,9 +1197,10 @@ export async function validateSupervisorAutonomousPlayableEvidence( assert( receipt.schemaVersion === autonomousPlaytestReceiptSchemaVersion && receipt.projectId === contract.projectId && - receipt.agentId === projectSupervisorAgentId && - receipt.runId === state.initialRunId && - receipt.runProfileBindingFingerprint === binding.bindingFingerprint && + receipt.agentId === mode.evidenceAgentId && + receipt.runId === evidenceRunId && + receipt.runProfileBindingFingerprint === + evidenceBinding.bindingFingerprint && receipt.revision === revision.revision && receipt.gameIndex.path === 'game/index.html' && receipt.gameIndex.sha256 === finalGameIndexSha256 && @@ -1123,8 +1209,8 @@ export async function validateSupervisorAutonomousPlayableEvidence( receipt.receiptFingerprint === hashJsonValue(receiptIdentity) && actionReceipts.filter( (record) => - record.agentId === projectSupervisorAgentId && - record.runId === state.initialRunId && + record.agentId === mode.evidenceAgentId && + record.runId === evidenceRunId && record.actionId === receipt.actionId && record.actionFingerprint === receipt.actionFingerprint && record.tool === 'preview.validate' && @@ -1511,10 +1597,12 @@ export async function runSupervisorAutonomousPlayableLaneDefenseE2e() { state.supervisorAutonomousPlayable.privateValues = [task]; state.supervisorSwarm.userTask = task; state.supervisorSwarm.privateValues = [task]; + const mode = supervisorAutonomousPlayableMode(); state.supervisorAutonomousPlayableCliSession = startInteractiveCli([ '--swarm-chat', '--init', '--autonomous-game-build', + ...mode.cliFlags, state.projectRoot, ]); state.supervisorAutonomousPlayable.stdinWriteCount = 1; @@ -1546,8 +1634,9 @@ export async function runSupervisorAutonomousPlayableLaneDefenseE2e() { } export function emptySupervisorAutonomousPlayableEvidence() { + const mode = supervisorAutonomousPlayableMode(); return { - scenario: 'project-supervisor-autonomous-playable-lane-defense', + scenario: mode.scenario, targetAgentId: projectSupervisorAgentId, runProfile: autonomousGameBuildRunProfile, dedicatedZeroInterventionPath: true, @@ -1688,5 +1777,12 @@ export function emptySupervisorAutonomousPlayableEvidence() { } export function isSupervisorAutonomousPlayableLaneDefenseSuite() { - return state.suite === supervisorAutonomousPlayableLaneDefenseSuite; + return ( + state.suite === supervisorAutonomousPlayableLaneDefenseSuite || + state.suite === supervisorGameChatSingleMainPlayableSuite + ); +} + +export function isSupervisorGameChatSingleMainPlayableSuite() { + return state.suite === supervisorGameChatSingleMainPlayableSuite; } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/draft_validation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/draft_validation.rs index e2d726e00..42c63705f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/draft_validation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/draft_validation.rs @@ -259,10 +259,11 @@ pub(crate) fn validate_safe_game_html_runtime(html: &str, label: &str) -> Result pub(crate) fn validate_game_html_smoke(html: &str) -> Result<(), String> { let lower_html = html.to_ascii_lowercase(); + validate_closed_game_script_blocks(&lower_html)?; + validate_executable_inline_javascript_syntax(html)?; if !lower_html.contains(" "static-smoke-failed", + "game.static_smoke 无法取得项目验证锁" => "verification-lock-unavailable", + "game.static_smoke 无法清除旧验证凭证" => "verification-reset-failed", + "game.static_smoke 结果无法形成有效验证凭证" => { + "verification-receipt-invalid" + } + _ => return None, + }) + }; + let diagnostic_check = observation.detail.as_deref().map_or("none", |detail| { + if detail.contains("JavaScript") { + "javascript-syntax" + } else { + "game-static-contract" + } + }); + let persisted = observation + .detail + .as_deref() + .and_then(|value| serde_json::from_str::(value).ok()) + .filter(|value| { + value.get("commandId").and_then(serde_json::Value::as_str) + == Some("game.static_smoke") + }); + let diagnostic = if let Some(value) = persisted.as_ref() { + if value.get("passed").and_then(serde_json::Value::as_bool) != Some(passed) + || value.get("failureCode").and_then(serde_json::Value::as_str) != failure_code + { + return None; + } + value + .get("diagnostic") + .and_then(serde_json::Value::as_str) + .map(|value| redact_agent_runtime_error(root, value, 500)) + } else { + observation + .detail + .as_deref() + .map(|value| redact_agent_runtime_error(root, value, 500)) + }; + if !passed && diagnostic.is_none() { + return None; + } + if receipt_owner.is_none() { + return serde_json::to_string(&serde_json::json!({ + "commandId": "game.static_smoke", + "passed": passed, + "failureCode": failure_code, + "check": diagnostic_check, + "path": AGENT_RUNTIME_GAME_INDEX_PATH, + })) + .ok(); + } + return serde_json::to_string(&serde_json::json!({ + "commandId": "game.static_smoke", + "passed": passed, + "failureCode": failure_code, + "check": diagnostic_check, + "path": AGENT_RUNTIME_GAME_INDEX_PATH, + "diagnostic": diagnostic, + })) + .ok(); + } if observation.tool == "image.inspect" { let detail = serde_json::from_str::( observation.detail.as_deref().unwrap_or_default(), diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/autonomous_policy.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/autonomous_policy.rs index 731d40ef0..25a208d6c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/autonomous_policy.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/autonomous_policy.rs @@ -1960,6 +1960,7 @@ mod tests { last_verification_tool: None, last_verification_status: None, static_smoke_verified_revision: None, + static_smoke_verified_game_index_sha256: None, failed_playtest_revision: None, updated_at: 0, }; @@ -2001,6 +2002,7 @@ mod tests { last_verification_tool: None, last_verification_status: None, static_smoke_verified_revision: None, + static_smoke_verified_game_index_sha256: None, failed_playtest_revision: None, updated_at: 0, }; @@ -2036,6 +2038,7 @@ mod tests { last_verification_tool: None, last_verification_status: None, static_smoke_verified_revision: None, + static_smoke_verified_game_index_sha256: None, failed_playtest_revision: None, updated_at: 0, }; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs index 45c8f1111..94616c992 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs @@ -157,6 +157,7 @@ pub(crate) fn prepare_agent_runtime_project_mutation_locked( gate.last_verification_tool = None; gate.last_verification_status = None; gate.static_smoke_verified_revision = None; + gate.static_smoke_verified_game_index_sha256 = None; gate.failed_playtest_revision = None; gate.updated_at = now; if let Err(error) = write_game_creator_agent_runtime_verification_gate(root, &gate) { @@ -359,8 +360,59 @@ pub(crate) fn finish_agent_runtime_project_verification_locked( ) -> Result<(), String> { let current_revision = read_game_creator_agent_runtime_project_revision(root)?; let revision_unchanged = current_revision.revision == expected_revision.revision; - let passed = passed && revision_unchanged; + let mut passed = passed && revision_unchanged; let verification_tool = gate.last_verification_tool.clone(); + let mut static_smoke_game_index_sha256 = None; + let mut static_smoke_credential_error = None; + if passed && verification_tool.as_deref() == Some("game.static_smoke") { + match read_autonomous_evidence_file_at( + root, + AGENT_RUNTIME_GAME_INDEX_PATH, + "game.static_smoke 游戏入口", + AGENT_RUNTIME_AUTONOMOUS_GAME_INDEX_MAX_BYTES, + ) { + Ok(Some((digest, bytes))) => { + let html = match std::str::from_utf8(&bytes) { + Ok(html) => html, + Err(error) => { + passed = false; + static_smoke_credential_error = Some(format!( + "game.static_smoke 通过后 game/index.html 不是有效 UTF-8:{error}" + )); + "" + } + }; + if passed && !html.contains(" static_smoke_game_index_sha256 = Some(digest.sha256), + Err(error) => { + passed = false; + static_smoke_credential_error = Some(format!( + "game.static_smoke 通过后 game/index.html 内容已变化且复核失败:{error}" + )); + } + } + } + } + Ok(None) => { + passed = false; + static_smoke_credential_error = + Some("game.static_smoke 通过后 game/index.html 缺失".to_string()); + } + Err(error) => { + passed = false; + static_smoke_credential_error = Some(format!( + "game.static_smoke 通过后无法绑定 game/index.html 摘要:{error}" + )); + } + } + } gate.verified_revision = passed .then_some(current_revision.revision) .filter(|value| *value > 0); @@ -376,6 +428,8 @@ pub(crate) fn finish_agent_runtime_project_verification_locked( } if verification_tool.as_deref() == Some("game.static_smoke") { gate.static_smoke_verified_revision = passed.then_some(current_revision.revision); + gate.static_smoke_verified_game_index_sha256 = + passed.then_some(static_smoke_game_index_sha256).flatten(); } gate.last_verification_status = Some( if passed { @@ -393,6 +447,9 @@ pub(crate) fn finish_agent_runtime_project_verification_locked( expected_revision.revision, current_revision.revision )); } + if let Some(error) = static_smoke_credential_error { + return Err(error); + } Ok(()) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/game_chat_fast_path.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/game_chat_fast_path.rs index 021ff399c..03750a4dc 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/game_chat_fast_path.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/game_chat_fast_path.rs @@ -2010,8 +2010,7 @@ fn game_chat_fast_path_current_revision_is_verified( &runtime.agent_id, &runtime.run_id, )?; - Ok(revision.revision > 0 - && agent_runtime_static_smoke_passed_for_revision(&gate, revision.revision)) + agent_runtime_static_smoke_passed_for_current_entry_at(root, &gate, revision.revision) } fn game_chat_fast_path_is_main_runtime( @@ -2256,7 +2255,11 @@ pub(crate) fn game_chat_fast_path_plan_at( revision.revision, )?; let current_revision_static_smoke_passed = owns_current_mutation - && agent_runtime_static_smoke_passed_for_revision(&gate, revision.revision); + && agent_runtime_static_smoke_passed_for_current_entry_at( + root, + &gate, + revision.revision, + )?; let current_revision_failed = owns_current_mutation && gate.last_verification_status.as_deref() == Some(AGENT_RUNTIME_VERIFICATION_STATUS_FAILED); @@ -3663,6 +3666,10 @@ mod tests { gate.last_verification_tool = Some("game.static_smoke".to_string()); gate.static_smoke_verified_revision = Some(1); + gate.static_smoke_verified_game_index_sha256 = + fs::read(root.join(AGENT_RUNTIME_GAME_INDEX_PATH)) + .ok() + .map(|bytes| format!("{:x}", Sha256::digest(bytes))); write_game_creator_agent_runtime_verification_gate(&root, &gate) .expect("write static smoke gate"); assert!( diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs index fa6ecd3fc..e6389adbd 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs @@ -1923,20 +1923,30 @@ async fn run_game_creator_agent_background_task_pass_without_deadline( ); } }; - if let Err(error) = ensure_static_delegate_user_input_wait_at( + let waiting_for_user = match ensure_static_delegate_user_input_wait_at( &root, &mut runtime, &deliveries, ) { - return fail_game_creator_agent_background_context_at( - &root, - &agent_id, - &session_id, - runtime, - &format!("Supervisor 用户澄清请求无法安全进入等待态:{error}"), - ); + Ok(waiting) => waiting, + Err(error) => { + return fail_game_creator_agent_background_context_at( + &root, + &agent_id, + &session_id, + runtime, + &format!("Supervisor 用户澄清请求无法安全进入等待态:{error}"), + ); + } + }; + if waiting_for_user { + return AgentBackgroundTaskOutcome::WaitingForUserInput; } - return AgentBackgroundTaskOutcome::WaitingForUserInput; + runtime.phase = "planning".to_string(); + runtime.current_action = + "等待 Project Supervisor 采用安全默认值发起唯一返工".to_string(); + runtime.waiting_on = "已转换为 needs-repair 的自主构建专业回执".to_string(); + runtime.next_step = "调用 agent.delegate,并把 repairOfDelegationId 指向原 delivery;返工任务必须采用安全默认值继续,不能再询问用户".to_string(); } else if repair_required { runtime.phase = "planning".to_string(); runtime.current_action = "等待 Project Supervisor 发起唯一返工".to_string(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_tests.rs index e02eacbd6..67856863f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_tests.rs @@ -843,6 +843,182 @@ async fn game_chat_art_receipt_wakes_the_same_code_prototype_run() { ); } +#[test] +fn game_chat_main_agent_allows_only_one_same_contract_safe_default_repair() { + let temporary = tempfile::tempdir().expect("create game-chat safe-default repair root"); + let root = temporary.path().join("project"); + let (main, mut child, delegation_id, child_lane) = game_chat_main_art_child_fixture_with_lane( + &root, + "art-director", + &["art-spec", "core-spritesheet"], + true, + ); + let child_lane = child_lane.expect("delegated art child lane is held"); + let needs_input = format!( + "AGC_NEEDS_USER_INPUT_V1\n{}", + serde_json::json!({ + "questions": [{ + "id": "visual_preference", + "header": "视觉偏好", + "question": "请选择首版视觉偏好", + "options": [ + {"label": "明亮", "description": "使用明亮、安全的默认配色。"}, + {"label": "柔和", "description": "使用柔和配色。"} + ] + }] + }) + ); + child.status = "completed".to_string(); + child.phase = "completed".to_string(); + child.current_action = "请求非必要视觉偏好".to_string(); + append_game_creator_agent_runtime_task(&root, &child) + .expect("persist completed art child with clarification"); + let child_task = read_latest_game_creator_agent_runtime_task_by_run_id( + &root, + &child.agent_id, + &child.run_id, + ) + .expect("read completed clarification child") + .expect("completed clarification child exists"); + publish_game_creator_agent_delegate_result(&root, &child_task, Some(&needs_input)); + claim_ready_static_delegate_receipts_at( + &root, + "code-prototype", + &main.run_id, + "game-chat-safe-default-claim", + ) + .expect("claim converted safe-default delivery"); + let original = read_static_delegate_delivery_at(&root, &delegation_id) + .expect("read original safe-default delivery") + .expect("original safe-default delivery exists"); + assert_eq!( + original.status, + StaticDelegateDeliveryStatus::ClaimedByParent + ); + assert_eq!( + original + .structured_result + .as_ref() + .map(|result| result.contract_status), + Some(StaticDelegateContractStatus::NeedsRepair) + ); + assert!(original + .structured_result + .as_ref() + .and_then(|result| result.error.as_deref()) + .is_some_and(|error| error.contains("game-chat-safe-default-repair.v1"))); + + let repair_action_id = "game-chat-safe-default-repair-action"; + let repair = observe_agent_runtime_agent_delegate( + &root, + "code-prototype", + &main.run_id, + Some(repair_action_id), + &serde_json::json!({ + "agentId": "art-director", + "task": "不要追问用户,采用安全默认视觉偏好继续交付", + "acceptanceCriteria": original.acceptance_criteria, + "expectedArtifacts": original.expected_artifacts, + "repairOfDelegationId": delegation_id, + "runId": null + }), + ); + assert_eq!(repair.status, "ok", "{repair:?}"); + let repair_delegation_id = agent_runtime_delegation_id( + "code-prototype", + &main.run_id, + "art-director", + repair_action_id, + ); + let repaired = read_static_delegate_delivery_at(&root, &repair_delegation_id) + .expect("read safe-default repair delivery") + .expect("safe-default repair delivery exists"); + assert_eq!( + repaired.repair_of_delegation_id.as_deref(), + Some(delegation_id.as_str()) + ); + assert_eq!(repaired.target_agent_id, original.target_agent_id); + assert_eq!(repaired.acceptance_criteria, original.acceptance_criteria); + assert_eq!(repaired.expected_artifacts, original.expected_artifacts); + assert!(repaired.repair_of_delegation_id.is_some()); + + register_game_chat_art_spec_fixture(&root); + let repair_child_task = read_latest_game_creator_agent_runtime_task_by_delegation_id( + &root, + "art-director", + &repair_delegation_id, + ) + .expect("read safe-default repair child") + .expect("safe-default repair child exists"); + let mut repair_child = agent_runtime_state_from_task_record(&repair_child_task); + repair_child.status = "completed".to_string(); + repair_child.phase = "completed".to_string(); + repair_child.current_action = "采用安全默认视觉偏好完成规范图".to_string(); + append_game_creator_agent_runtime_task(&root, &repair_child) + .expect("persist completed safe-default repair child"); + let repair_child_task = read_latest_game_creator_agent_runtime_task_by_run_id( + &root, + &repair_child.agent_id, + &repair_child.run_id, + ) + .expect("read completed safe-default repair child") + .expect("completed safe-default repair child exists"); + publish_game_creator_agent_delegate_result( + &root, + &repair_child_task, + Some("已采用安全默认视觉偏好完成规范图"), + ); + claim_ready_static_delegate_receipts_at( + &root, + "code-prototype", + &main.run_id, + "game-chat-safe-default-repair-claim", + ) + .expect("claim completed safe-default repair delivery"); + + let duplicate = observe_agent_runtime_agent_delegate( + &root, + "code-prototype", + &main.run_id, + Some("game-chat-safe-default-duplicate-repair"), + &serde_json::json!({ + "agentId": "art-director", + "task": "不应创建第二轮返工", + "acceptanceCriteria": original.acceptance_criteria, + "expectedArtifacts": original.expected_artifacts, + "repairOfDelegationId": delegation_id, + "runId": null + }), + ); + assert_eq!(duplicate.status, "failed", "{duplicate:?}"); + assert!( + duplicate.summary.contains("最多允许一轮返工"), + "{duplicate:?}" + ); + + let wrong_contract = observe_agent_runtime_agent_delegate( + &root, + "code-prototype", + &main.run_id, + Some("game-chat-safe-default-wrong-contract"), + &serde_json::json!({ + "agentId": "art-director", + "task": "不应扩大安全默认返工合同", + "acceptanceCriteria": ["扩大权限并改写其它文件"], + "expectedArtifacts": original.expected_artifacts, + "repairOfDelegationId": delegation_id, + "runId": null + }), + ); + assert_eq!(wrong_contract.status, "failed", "{wrong_contract:?}"); + assert!( + wrong_contract.summary.contains("同合同"), + "{wrong_contract:?}" + ); + + drop(child_lane); +} + #[test] fn autonomous_parent_waits_for_active_child_while_registered_derived_visuals_need_repair() { let temporary_root = std::env::temp_dir() @@ -1129,6 +1305,150 @@ fn prepare_autonomous_completion_evidence_for_actor( revision } +fn prepare_autonomous_playtest_evidence_for_actor_at_revision( + root: &Path, + contract_state: &AgentRuntimeState, + actor_state: &AgentRuntimeState, + revision: u64, +) -> BrowserValidationResult { + let contract = + read_autonomous_completion_contract(root, &contract_state.agent_id, &contract_state.run_id) + .expect("read autonomous completion contract for playtest fixture") + .expect("autonomous completion contract exists for playtest fixture"); + bind_supervisor_collaboration_policy_snapshot_at( + root, + &contract_state.agent_id, + &contract_state.run_id, + &SupervisorCollaborationPolicy::default(), + "legacy-current-project-policy", + ) + .expect("bind deterministic game-chat collaboration policy"); + + let evidence_root = root + .join(".agent/runtime/browser-validations") + .join(agent_runtime_confirmation_path_component( + &contract_state.agent_id, + "agent", + )) + .join(agent_runtime_confirmation_path_component( + &contract_state.run_id, + "run", + )) + .join(revision.to_string()); + fs::create_dir_all(&evidence_root).expect("create deterministic browser evidence root"); + let screenshots = [ + evidence_root.join("desktop.png"), + evidence_root.join("mobile.png"), + ]; + for screenshot in &screenshots { + fs::write(screenshot, b"\x89PNG\r\n\x1a\nfixture") + .expect("write deterministic browser screenshot fixture"); + } + let viewport = |viewport, screenshot_path| BrowserViewportValidationResult { + viewport, + width: if viewport == BrowserValidationViewport::Desktop { + 1280 + } else { + 390 + }, + height: if viewport == BrowserValidationViewport::Desktop { + 720 + } else { + 844 + }, + final_url: "http://127.0.0.1:34567/".to_string(), + title: "自主试玩测试".to_string(), + ready_state: "complete".to_string(), + visible_text_summary: "可试玩项目".to_string(), + visible_text_character_count: 5, + dom_character_count: 100, + expected_text: Vec::new(), + console_errors: Vec::new(), + console_warnings: Vec::new(), + exceptions: Vec::new(), + failed_requests: Vec::new(), + canvases: Vec::new(), + blocked_popup_count: 0, + blocked_dialog_count: 0, + blocked_download_count: 0, + blocked_permission_count: 0, + blocked_service_worker_count: 0, + screenshot_path, + passed: true, + diagnostics: Vec::new(), + }; + let scenario = contract.playtest_scenario; + let result = BrowserValidationResult { + schema_version: "browser-validation.v1".to_string(), + url: "http://127.0.0.1:34567/".to_string(), + browser: BrowserIdentity { + kind: DiscoveredBrowserKind::Chrome, + product: "test-browser".to_string(), + protocol_version: "1".to_string(), + }, + passed: true, + viewport_results: vec![ + viewport(BrowserValidationViewport::Desktop, screenshots[0].clone()), + viewport(BrowserValidationViewport::Mobile, screenshots[1].clone()), + ], + playtest: Some(BrowserPlaytestResult { + scenario, + scenario_fingerprint: browser_playtest_scenario_fingerprint(scenario), + passed: true, + initial_sequence: Some(1), + initial_phase: Some(BrowserPlaytestPhase::Ready), + initial_level: Some(1), + final_sequence: Some(8), + final_phase: Some(BrowserPlaytestPhase::Playing), + final_level: Some(2), + assertions: scenario + .assertion_names() + .iter() + .map(|name| BrowserPlaytestAssertion { + name: (*name).to_string(), + passed: true, + }) + .collect(), + diagnostics: Vec::new(), + }), + diagnostics: Vec::new(), + evidence: BrowserValidationEvidencePaths { + root: evidence_root.clone(), + report_path: evidence_root.join("validation.json"), + }, + completed_at_unix_ms: 1, + }; + fs::write( + &result.evidence.report_path, + serde_json::to_vec_pretty(&result).expect("serialize deterministic browser evidence"), + ) + .expect("write deterministic browser evidence report"); + let action = AgentRuntimeToolAction { + tool: "preview.validate".to_string(), + reason: Some("验证真实可玩闭环".to_string()), + input: serde_json::json!({}), + }; + let action_fingerprint = + agent_runtime_tool_action_fingerprint(&action, &actor_state.current_task); + let action_id = agent_runtime_tool_action_id( + &actor_state.run_id, + actor_state.loop_iteration.max(1), + 3, + 1, + &action_fingerprint, + ); + write_autonomous_playtest_receipt_at( + root, + &contract, + &action_id, + &action_fingerprint, + revision, + &result, + ) + .expect("write deterministic autonomous playtest receipt"); + result +} + #[test] fn game_chat_preview_playtest_migrates_legacy_generic_tetris_receipt_before_delivery() { let temporary = tempfile::tempdir().expect("create legacy game-chat Tetris root"); @@ -1355,6 +1675,10 @@ fn game_chat_code_completion_blocker_reopens_active_repair_before_delivery() { gate.last_verification_tool = Some("game.static_smoke".to_string()); gate.last_verification_status = Some(AGENT_RUNTIME_VERIFICATION_STATUS_PASSED.to_string()); gate.static_smoke_verified_revision = Some(revision.revision); + gate.static_smoke_verified_game_index_sha256 = + fs::read(root.join(AGENT_RUNTIME_GAME_INDEX_PATH)) + .ok() + .map(|bytes| format!("{:x}", Sha256::digest(bytes))); write_game_creator_agent_runtime_verification_gate(&root, &gate) .expect("write code verification gate"); child_state.applied_steer_cursor = 2; @@ -1472,7 +1796,11 @@ fn game_chat_code_completion_blocker_reopens_active_repair_before_delivery() { .expect("evaluate repaired code delivery") .expect("fully repaired code may use deterministic delivery"); assert_eq!(playtest.actions.len(), 1); - assert_eq!(playtest.actions[0].tool, "preview.validate"); + assert_eq!(playtest.actions[0].tool, "command.run_limited"); + assert_eq!( + playtest.actions[0].input.get("commandId"), + Some(&serde_json::json!("game.static_smoke")) + ); assert!(playtest.response.is_empty()); } @@ -1509,6 +1837,10 @@ fn game_chat_code_failed_patch_revision_does_not_count_as_owned_mutation() { gate.last_verification_tool = Some("game.static_smoke".to_string()); gate.last_verification_status = Some(AGENT_RUNTIME_VERIFICATION_STATUS_PASSED.to_string()); gate.static_smoke_verified_revision = Some(revision.revision); + gate.static_smoke_verified_game_index_sha256 = + fs::read(root.join(AGENT_RUNTIME_GAME_INDEX_PATH)) + .ok() + .map(|bytes| format!("{:x}", Sha256::digest(bytes))); write_game_creator_agent_runtime_verification_gate(&root, &gate) .expect("write failed patch verification gate"); let prior_action = AgentRuntimeToolAction { @@ -1639,6 +1971,10 @@ fn game_chat_code_cannot_borrow_successful_mutation_receipt_from_another_run() { gate.last_verification_tool = Some("game.static_smoke".to_string()); gate.last_verification_status = Some(AGENT_RUNTIME_VERIFICATION_STATUS_PASSED.to_string()); gate.static_smoke_verified_revision = Some(revision.revision); + gate.static_smoke_verified_game_index_sha256 = + fs::read(root.join(AGENT_RUNTIME_GAME_INDEX_PATH)) + .ok() + .map(|bytes| format!("{:x}", Sha256::digest(bytes))); write_game_creator_agent_runtime_verification_gate(&root, &gate) .expect("write current verification gate"); let action = AgentRuntimeToolAction { @@ -2174,8 +2510,7 @@ fn game_chat_existing_game_requires_code_mutation_and_full_completion_before_del let root = temporary.path().join("project"); init_local_game_project_at(&root, "game-chat-existing-code", "水晶俄罗斯方块") .expect("init existing game project"); - let existing = - ""; + let existing = "

目标:旋转方块消除整行获得胜利;堆满后失败,按 R 重新开始。

"; fs::write(root.join(AGENT_RUNTIME_GAME_INDEX_PATH), existing) .expect("write existing non-placeholder game"); let (root_state, mut code_state) = queue_game_chat_fast_path_child( @@ -2263,6 +2598,504 @@ fn game_chat_existing_game_requires_code_mutation_and_full_completion_before_del assert_eq!(code_gate.verified_revision, Some(1)); } +#[test] +fn game_chat_trusted_single_main_repairs_javascript_smoke_and_completes_without_intervention() { + const ROOT_RUN_ID: &str = "game-chat-deterministic-repair-root"; + const TASK: &str = "生成一版完整可玩的彩虹餐厅经营小游戏,全程自主修复并完成"; + let _config_guard = crate::tests::write_test_local_config("{}".to_string()); + let temporary = tempfile::tempdir().expect("create deterministic game-chat repair root"); + let root = temporary.path().join("project"); + init_local_game_project_at(&root, "game-chat-deterministic-repair", TASK) + .expect("init deterministic game-chat repair project"); + + let seed_tasks = + autonomous_manifest_seed_tasks_for_source(AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE); + assert_eq!( + seed_tasks + .iter() + .map(|task| task.id.as_str()) + .collect::>(), + vec!["code-prototype"], + "trusted game-chat must persist one single-main manifest lane" + ); + let (mut root_state, mut code_state) = + queue_game_chat_fast_path_child(&root, ROOT_RUN_ID, TASK, "code-prototype"); + crate::tests::freeze_test_root_goal_contract_at(&root, ROOT_RUN_ID); + assert_eq!(root_state.source, AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE); + assert_eq!( + root_state.run_profile, + AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + ); + assert_eq!(code_state.agent_id, "code-prototype"); + assert_eq!(code_state.source, "agent-ready-task-scheduler"); + assert_eq!( + code_state.parent_agent_id.as_deref(), + Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID) + ); + assert_eq!(code_state.parent_run_id.as_deref(), Some(ROOT_RUN_ID)); + assert_eq!( + code_state.run_id, + autonomous_manifest_ready_task_run_id(ROOT_RUN_ID, "code-prototype") + ); + register_game_chat_art_spec_fixture(&root); + register_game_chat_art_spritesheet_fixture(&root); + let art_manifest = read_manifest_for_project(&root).expect("read registered art manifest"); + validate_manifest_required_visual_asset(&root, &art_manifest, "art-director") + .expect("deterministic art spec is reusable"); + validate_manifest_required_visual_asset(&root, &art_manifest, "art-asset-plan") + .expect("deterministic spritesheet is reusable"); + write_local_project_file_at( + &root, + "assets/manifest.art.json", + &game_chat_fast_path_art_manifest_content(), + ) + .expect("write reusable deterministic art manifest before audit"); + code_state.status = "running".to_string(); + code_state.phase = "planning".to_string(); + code_state.loop_iteration = 1; + append_game_creator_agent_runtime_task(&root, &code_state) + .expect("persist running deterministic code child"); + write_game_creator_agent_runtime_state(&root, &code_state) + .expect("persist deterministic code runtime"); + persist_game_chat_main_asset_audit_and_route(&root, &root_state, &mut code_state); + + let playable_html = render_game_chat_fast_path_html("彩虹餐厅经营挑战"); + let invalid_html = playable_html.replacen( + " stateNode.textContent = JSON.stringify(state);\n requestAnimationFrame(draw);", + " function terminalRepairProbe() {} terminalRepairProbe()const terminalPhases = ['won', 'lost'];\n stateNode.textContent = JSON.stringify(state);\n requestAnimationFrame(draw);", + 1, + ); + assert_ne!( + invalid_html, playable_html, + "invalid JavaScript fixture marker" + ); + assert!(invalid_html.contains("terminalRepairProbe()const terminalPhases")); + let initial_write = AgentRuntimeToolAction { + tool: "file.write".to_string(), + reason: Some("生成首版可试玩游戏入口".to_string()), + input: serde_json::json!({ + "path": AGENT_RUNTIME_GAME_INDEX_PATH, + "content": invalid_html, + }), + }; + let initial_write_fingerprint = + agent_runtime_tool_action_fingerprint(&initial_write, &code_state.current_task); + let initial_write_id = agent_runtime_tool_action_id( + &code_state.run_id, + code_state.loop_iteration, + 0, + 1, + &initial_write_fingerprint, + ); + let initial_write_observation = observe_agent_runtime_file_write( + &root, + &code_state.agent_id, + &code_state.run_id, + &initial_write, + &initial_write_fingerprint, + None, + ); + assert_eq!(initial_write_observation.status, "ok"); + let initial_write_task = code_state.current_task.clone(); + append_agent_runtime_tool_call_record( + &root, + &mut code_state, + &initial_write_task, + &initial_write, + &initial_write_observation, + Some(&initial_write_id), + Some(&initial_write_fingerprint), + ); + append_agent_runtime_action_receipt( + &root, + &code_state, + &initial_write_id, + &initial_write_fingerprint, + "file.write", + AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO, + None, + &initial_write_observation, + ) + .expect("persist initial game write receipt"); + + let failed_smoke = AgentRuntimeToolAction { + tool: "command.run_limited".to_string(), + reason: Some("检查首版游戏入口".to_string()), + input: serde_json::json!({ "commandId": "game.static_smoke" }), + }; + let failed_smoke_fingerprint = + agent_runtime_tool_action_fingerprint(&failed_smoke, &code_state.current_task); + let failed_smoke_id = agent_runtime_tool_action_id( + &code_state.run_id, + code_state.loop_iteration, + 1, + 1, + &failed_smoke_fingerprint, + ); + let failed_smoke_observation = observe_agent_runtime_limited_command( + &root, + &code_state.agent_id, + &code_state.run_id, + &failed_smoke.input, + ); + assert_eq!(failed_smoke_observation.status, "failed"); + let failed_smoke_detail = failed_smoke_observation + .detail + .as_deref() + .expect("failed smoke diagnostic"); + assert!( + failed_smoke_detail.contains("不是有效 JavaScript"), + "{failed_smoke_detail}" + ); + assert!( + !failed_smoke_detail.contains("可渲染画布"), + "JavaScript syntax must fail before surface checks: {failed_smoke_detail}" + ); + let failed_smoke_task = code_state.current_task.clone(); + append_agent_runtime_tool_call_record( + &root, + &mut code_state, + &failed_smoke_task, + &failed_smoke, + &failed_smoke_observation, + Some(&failed_smoke_id), + Some(&failed_smoke_fingerprint), + ); + append_agent_runtime_action_receipt( + &root, + &code_state, + &failed_smoke_id, + &failed_smoke_fingerprint, + "command.run_limited", + AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO, + None, + &failed_smoke_observation, + ) + .expect("persist failed static smoke receipt"); + + let (history, count, truncated, output_truncated) = read_agent_runtime_action_history( + &root, + &code_state.agent_id, + &code_state.run_id, + &serde_json::json!({ + "runId": code_state.run_id, + "actionId": failed_smoke_id, + "tool": "command.run_limited", + "status": "failed", + "limit": 1, + }), + ) + .expect("read owner-visible failed smoke action history"); + assert_eq!(count, 1); + assert!(!truncated); + assert!(!output_truncated); + let history: serde_json::Value = + serde_json::from_str(&history).expect("parse failed smoke history"); + let history_item = &history["actions"][0]; + assert_eq!(history_item["actionId"], failed_smoke_id); + let diagnostic: serde_json::Value = serde_json::from_str( + history_item["safeDetail"] + .as_str() + .expect("owner-visible structured smoke detail"), + ) + .expect("parse owner-visible structured smoke detail"); + assert_eq!(diagnostic["failureCode"], "static-smoke-failed"); + assert_eq!(diagnostic["check"], "javascript-syntax"); + assert_eq!(diagnostic["path"], AGENT_RUNTIME_GAME_INDEX_PATH); + assert!(diagnostic["diagnostic"] + .as_str() + .is_some_and(|value| value.contains("不是有效 JavaScript"))); + + code_state.loop_iteration = 2; + let repair = AgentRuntimeToolAction { + tool: "file.patch".to_string(), + reason: Some("根据结构化 JavaScript 语法诊断修复当前入口".to_string()), + input: serde_json::json!({ + "path": AGENT_RUNTIME_GAME_INDEX_PATH, + "oldText": "terminalRepairProbe()const terminalPhases", + "newText": "terminalRepairProbe();const terminalPhases", + "expectedReplacements": 1, + }), + }; + let repair_fingerprint = + agent_runtime_tool_action_fingerprint(&repair, &code_state.current_task); + let repair_id = agent_runtime_tool_action_id( + &code_state.run_id, + code_state.loop_iteration, + 0, + 1, + &repair_fingerprint, + ); + let repair_observation = observe_agent_runtime_file_patch( + &root, + &code_state.agent_id, + &code_state.run_id, + &repair, + &repair_fingerprint, + None, + ); + assert_eq!(repair_observation.status, "ok", "{repair_observation:?}"); + let repair_task = code_state.current_task.clone(); + append_agent_runtime_tool_call_record( + &root, + &mut code_state, + &repair_task, + &repair, + &repair_observation, + Some(&repair_id), + Some(&repair_fingerprint), + ); + append_agent_runtime_action_receipt( + &root, + &code_state, + &repair_id, + &repair_fingerprint, + "file.patch", + AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO, + None, + &repair_observation, + ) + .expect("persist same-run repair receipt"); + + let repaired_smoke = AgentRuntimeToolAction { + tool: "command.run_limited".to_string(), + reason: Some("重新验证已修复的当前 revision".to_string()), + input: serde_json::json!({ "commandId": "game.static_smoke" }), + }; + let repaired_smoke_fingerprint = + agent_runtime_tool_action_fingerprint(&repaired_smoke, &code_state.current_task); + let repaired_smoke_id = agent_runtime_tool_action_id( + &code_state.run_id, + code_state.loop_iteration, + 1, + 1, + &repaired_smoke_fingerprint, + ); + let repaired_smoke_observation = observe_agent_runtime_limited_command( + &root, + &code_state.agent_id, + &code_state.run_id, + &repaired_smoke.input, + ); + assert_eq!( + repaired_smoke_observation.status, "ok", + "{repaired_smoke_observation:?}" + ); + let repaired_smoke_task = code_state.current_task.clone(); + append_agent_runtime_tool_call_record( + &root, + &mut code_state, + &repaired_smoke_task, + &repaired_smoke, + &repaired_smoke_observation, + Some(&repaired_smoke_id), + Some(&repaired_smoke_fingerprint), + ); + append_agent_runtime_action_receipt( + &root, + &code_state, + &repaired_smoke_id, + &repaired_smoke_fingerprint, + "command.run_limited", + AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO, + None, + &repaired_smoke_observation, + ) + .expect("persist repaired static smoke receipt"); + let revision = read_game_creator_agent_runtime_project_revision(&root) + .expect("read repaired project revision") + .revision; + let code_gate = read_game_creator_agent_runtime_verification_gate( + &root, + &code_state.agent_id, + &code_state.run_id, + ) + .expect("read repaired code verification gate"); + assert_eq!(code_gate.mutation_revision, Some(revision)); + assert_eq!(code_gate.verified_revision, Some(revision)); + assert_eq!(code_gate.static_smoke_verified_revision, Some(revision)); + assert_eq!(code_gate.last_mutation_tool.as_deref(), Some("file.patch")); + + for (path, content) in [ + ("memory/project.md", "# 项目记忆\n\n正式约束。\n"), + ("game/game_design.md", "# 游戏设计\n\n核心循环。\n"), + ("game/balance.json", r#"{"lives":3,"speed":1}"#), + ("assets/manifest.audio.json", r#"{"bgm":[],"sfx":[]}"#), + ("exports/README.md", "# 发布说明\n\n可试玩。\n"), + ] { + write_local_project_file_at(&root, path, content) + .unwrap_or_else(|error| panic!("write deterministic artifact {path}: {error}")); + } + let browser_result = prepare_autonomous_playtest_evidence_for_actor_at_revision( + &root, + &root_state, + &code_state, + revision, + ); + assert!(browser_result.passed); + assert_eq!(browser_result.viewport_results.len(), 2); + assert!(browser_result + .viewport_results + .iter() + .any( + |viewport| viewport.viewport == BrowserValidationViewport::Desktop && viewport.passed + )); + assert!(browser_result + .viewport_results + .iter() + .any(|viewport| viewport.viewport == BrowserValidationViewport::Mobile && viewport.passed)); + let code_completion_blocker = + autonomous_game_build_completion_blocker_at_locked(&root, &code_state); + assert!( + code_completion_blocker.is_none(), + "deterministic code completion blocker: {code_completion_blocker:?}" + ); + + let validated_game_index = + fs::read(root.join(AGENT_RUNTIME_GAME_INDEX_PATH)).expect("snapshot validated game entry"); + fs::write( + root.join(AGENT_RUNTIME_GAME_INDEX_PATH), + "", + ) + .expect("mutate game entry without advancing durable revision"); + let stale_receipt_blocker = + autonomous_game_build_completion_blocker_at_locked(&root, &code_state) + .expect("same-revision stale playtest receipt must block completion"); + assert!( + stale_receipt_blocker.summary.contains("game.static_smoke"), + "unexpected stale receipt blocker: {stale_receipt_blocker:?}" + ); + fs::write( + root.join(AGENT_RUNTIME_GAME_INDEX_PATH), + validated_game_index, + ) + .expect("restore validated game entry"); + assert!( + autonomous_game_build_completion_blocker_at_locked(&root, &code_state).is_none(), + "restored validated entry must satisfy completion gate" + ); + + let completed_code = finish_game_creator_agent_runtime_turn_at( + &root, + code_state, + &format!("首版已完成,并通过 revision {revision} 的静态检查与双视口试玩。"), + ) + .expect("finish deterministic code-prototype run"); + update_manifest_task_status_at(&root, "code-prototype", GameCreationAppTaskStatus::Running) + .expect("restore running manifest projection before deterministic terminal projection"); + assert!( + project_autonomous_manifest_ready_task_terminal_at(&root, &completed_code) + .expect("project deterministic code-prototype completion") + ); + let root_stale_receipt_blocker = + autonomous_game_build_completion_blocker_at_locked(&root, &root_state); + assert!( + root_stale_receipt_blocker.is_none(), + "root must accept the restored current entry: {root_stale_receipt_blocker:?}" + ); + crate::tests::pass_test_root_acceptance_graph_at(&root, &root_state); + let mut provider_plan = AgentRuntimeToolPlan { + actions: vec![AgentRuntimeToolAction { + tool: "agent.run_status".to_string(), + reason: Some("模型原本仍想继续轮询".to_string()), + input: serde_json::json!({}), + }], + ..AgentRuntimeToolPlan::default() + }; + let convergence = prepare_game_chat_single_round_convergence_at( + &root, + &mut root_state, + &mut provider_plan, + unix_timestamp(), + ) + .expect("prepare deterministic root convergence") + .expect("fully verified game-chat root must converge without another Provider plan"); + assert_eq!(convergence.1, revision); + assert!(provider_plan.actions.is_empty()); + let completed_root = + finish_game_creator_agent_runtime_turn_at(&root, root_state, &convergence.0) + .expect("finish deterministic game-chat root once"); + assert_eq!(completed_root.phase, "completed"); + + let logical_code_runs = latest_game_creator_agent_runtime_tasks( + read_all_game_creator_agent_runtime_tasks(&game_creator_agent_runtime_task_path( + &root, + "code-prototype", + )) + .expect("read deterministic code journal"), + ) + .into_iter() + .filter(|record| { + record.source == "agent-ready-task-scheduler" + && record.parent_run_id.as_deref() == Some(ROOT_RUN_ID) + }) + .collect::>(); + assert_eq!(logical_code_runs.len(), 1); + assert_eq!(logical_code_runs[0].run_id, completed_code.run_id); + assert_eq!(logical_code_runs[0].phase, "completed"); + let root_terminal_records = read_all_game_creator_agent_runtime_tasks( + &game_creator_agent_runtime_task_path(&root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID), + ) + .expect("read deterministic root journal") + .into_iter() + .filter(|record| record.run_id == ROOT_RUN_ID && record.phase == "completed") + .count(); + assert_eq!( + root_terminal_records, 1, + "root must have one completed terminal" + ); + let (db_records, _) = + read_agent_db_records_bounded(&root, AGENT_RUNTIME_ACTION_HISTORY_MAX_DB_BYTES) + .expect("read deterministic Agent DB records"); + assert_eq!( + db_records + .iter() + .filter(|record| { + record.get("recordType").and_then(serde_json::Value::as_str) + == Some("agent.runtime.completed") + && record.get("agentId").and_then(serde_json::Value::as_str) + == Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID) + && record.get("runId").and_then(serde_json::Value::as_str) == Some(ROOT_RUN_ID) + }) + .count(), + 1, + "root completion audit must be unique" + ); + for agent_id in [GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, "code-prototype"] { + let records = read_all_game_creator_agent_runtime_tasks( + &game_creator_agent_runtime_task_path(&root, agent_id), + ) + .expect("read no-intervention journal"); + assert!(records.iter().all(|record| { + record.status != "waiting-for-confirmation" + && record.status != "waiting-for-user-input" + && record.phase != "waiting-for-confirmation" + && record.phase != "waiting-for-user-input" + })); + } + assert!(!game_creator_agent_runtime_pending_tool_action_path( + &root, + &completed_code.agent_id, + &completed_code.run_id, + ) + .exists()); + assert!(!game_creator_agent_runtime_pending_tool_action_path( + &root, + &completed_root.agent_id, + &completed_root.run_id, + ) + .exists()); + let confirmations_root = root.join(".agent/runtime/confirmations"); + assert!( + !confirmations_root.exists() + || fs::read_dir(&confirmations_root) + .expect("read confirmation root") + .next() + .is_none(), + "deterministic game-chat completion must not require confirmation" + ); +} + #[test] fn autonomous_supervisor_empty_plan_uses_deterministic_final_reply_fallback() { assert_eq!( diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/provider_recovery.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/provider_recovery.rs index 3b6af0f9d..6b0e60d62 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/provider_recovery.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/provider_recovery.rs @@ -329,6 +329,24 @@ pub(crate) fn ensure_static_delegate_user_input_wait_at( runtime: &mut AgentRuntimeState, deliveries: &[StaticDelegateDeliveryRecord], ) -> Result { + let parent_task = read_latest_game_creator_agent_runtime_task_by_run_id( + root, + &runtime.agent_id, + &runtime.run_id, + )? + .ok_or_else(|| "needs-user-input 转换缺少当前 parent task".to_string())?; + if parent_task.session_id != runtime.session_id + || parent_task.source != runtime.source + || parent_task.run_profile != runtime.run_profile + || parent_task.run_profile_binding_fingerprint != runtime.run_profile_binding_fingerprint + { + return Err("needs-user-input 转换的 parent task 与 runtime 身份不一致".to_string()); + } + if convert_claimed_game_chat_user_input_deliveries_to_repair_at(root, &parent_task, deliveries)? + > 0 + { + return Ok(false); + } let mut pending_deliveries = deliveries.iter().filter(|delivery| { delivery.structured_result.as_ref().is_some_and(|result| { result.contract_status == StaticDelegateContractStatus::NeedsUserInput diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs index 8d2d866d8..75ea76559 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs @@ -6145,7 +6145,21 @@ fn autonomous_manifest_ready_task_completion_blocker_at_locked( )); } }; - if !agent_runtime_static_smoke_passed_for_revision(&gate, revision.revision) { + let static_smoke_matches_current_entry = + match agent_runtime_static_smoke_passed_for_current_entry_at( + root, + &gate, + revision.revision, + ) { + Ok(matches) => matches, + Err(error) => { + return Some(autonomous_completion_blocker( + "preview-readiness 无法复核当前 game.static_smoke 入口摘要", + error, + )); + } + }; + if !static_smoke_matches_current_entry { return Some(autonomous_completion_blocker( "preview-readiness 尚未通过当前 revision 的 game.static_smoke", format!( @@ -6198,9 +6212,29 @@ fn autonomous_manifest_ready_task_completion_blocker_at_locked( )); } }; - if receipt.revision != revision.revision { + let current_index = match read_autonomous_evidence_file_at( + root, + AGENT_RUNTIME_GAME_INDEX_PATH, + "preview-playtest 当前游戏入口", + AGENT_RUNTIME_AUTONOMOUS_GAME_INDEX_MAX_BYTES, + ) { + Ok(Some((digest, _))) => digest, + Ok(None) => { + return Some(autonomous_completion_blocker( + "preview-playtest 当前游戏入口缺失", + "必须重新生成 game/index.html 并在当前 revision 执行 preview.validate。", + )); + } + Err(error) => { + return Some(autonomous_completion_blocker( + "preview-playtest 当前游戏入口无法安全读取", + error, + )); + } + }; + if receipt.revision != revision.revision || receipt.game_index != current_index { return Some(autonomous_completion_blocker( - "preview-playtest 浏览器试玩回执不属于当前 revision", + "preview-playtest 浏览器试玩回执不属于当前 revision 或入口产物", format!( "receiptRevision={}, currentRevision={}", receipt.revision, revision.revision @@ -6374,13 +6408,13 @@ fn game_chat_main_completion_blocker_at_locked( ), )); } - let current_index_bytes = match read_autonomous_evidence_file_at( + let (current_index, current_index_bytes) = match read_autonomous_evidence_file_at( root, AGENT_RUNTIME_GAME_INDEX_PATH, "game-chat 主 Agent 游戏入口", AGENT_RUNTIME_AUTONOMOUS_GAME_INDEX_MAX_BYTES, ) { - Ok(Some((_, bytes))) => bytes, + Ok(Some(value)) => value, Ok(None) => { return Some(autonomous_completion_blocker( "game-chat code-prototype 尚未生成 game/index.html", @@ -6422,11 +6456,12 @@ fn game_chat_main_completion_blocker_at_locked( )); } }; - if !agent_runtime_static_smoke_passed_for_revision(&gate, revision.revision) { + if !agent_runtime_static_smoke_passed_for_entry(&gate, revision.revision, ¤t_index.sha256) + { return Some(autonomous_completion_blocker( - "game-chat code-prototype 尚未通过当前 revision 的 game.static_smoke", + "game-chat code-prototype 尚未通过当前 game/index.html 的 game.static_smoke", format!( - "currentRevision={}, staticSmokeVerifiedRevision={}", + "path=game/index.html, currentRevision={}, staticSmokeVerifiedRevision={}", revision.revision, gate.static_smoke_verified_revision .map(|value| value.to_string()) @@ -6449,9 +6484,9 @@ fn game_chat_main_completion_blocker_at_locked( )); } }; - if receipt.revision != revision.revision { + if receipt.revision != revision.revision || receipt.game_index != current_index { return Some(autonomous_completion_blocker( - "game-chat code-prototype 试玩回执不属于当前 revision", + "game-chat code-prototype 试玩回执不属于当前 revision 或入口产物", format!( "receiptRevision={}, currentRevision={}", receipt.revision, revision.revision @@ -7309,6 +7344,52 @@ pub(in crate::agent) fn javascript_is_syntactically_valid(content: &str, is_modu semantic.diagnostics.is_empty() } +pub(in crate::agent) fn validate_executable_inline_javascript_syntax( + html: &str, +) -> Result<(), String> { + let lower = html.to_ascii_lowercase(); + let mut cursor = 0usize; + let mut executable_index = 0usize; + while let Some(offset) = lower[cursor..].find('<') { + let tag_start = cursor + offset; + if lower[tag_start..].starts_with("") + .map(|end| tag_start + 4 + end + 3) + .unwrap_or(lower.len()); + continue; + } + let Some(tag_after) = html_tag_end(&lower, tag_start) else { + break; + }; + let tag = &lower[tag_start..tag_after]; + if let Some(end) = html_non_executable_container_end(&lower, tag, tag_after - 1) { + cursor = end; + continue; + } + if !html_tag_starts_element(tag, "script") { + cursor = tag_after; + continue; + } + let Some((close_start, close_end)) = + raw_text_html_element_close(&lower, tag_after, "script") + else { + return Err("游戏入口的 " + "

目标:移动角色收集全部目标,避开障碍并获得胜利;失败后可按 R 重新开始。

" +} + +fn with_static_smoke_contract(html: &str) -> String { + let contract = "

目标:移动角色收集全部目标并获得胜利;碰到危险即失败,按 R 重新开始。

"; + if let Some(body_end) = html.rfind("") { + format!("{}{}{}", &html[..body_end], contract, &html[body_end..]) + } else { + format!("{html}{contract}") + } } fn executable_tetris_game_html() -> String { @@ -5378,11 +5387,10 @@ fn autonomous_preview_manifest_tasks_accept_bound_current_revision_receipts() { let readiness_child = queue_autonomous_manifest_child_fixture(&root, &parent_state, "preview-readiness"); let readiness_state = agent_runtime_state_from_task_record(&readiness_child); - advance_game_index_revision( - &root, - &parent_state, - "静态检查通过", + let readiness_html = with_static_smoke_contract( + "静态检查通过", ); + advance_game_index_revision(&root, &parent_state, &readiness_html); mark_verification_passed(&root, &readiness_state, "game.static_smoke"); assert!(autonomous_game_build_completion_blocker_at_locked(&root, &readiness_state).is_none()); @@ -5655,6 +5663,50 @@ fn autonomous_playtest_receipt_rejects_previous_scenario_fingerprint() { assert!(error.contains("场景指纹"), "unexpected error: {error}"); } +#[test] +fn autonomous_playtest_receipt_requires_passed_desktop_and_mobile_viewports() { + let (_temporary, root, state, contract) = + autonomous_fixture("做一个完整小游戏", "autonomous-required-viewports-run"); + let revision = advance_game_index_revision( + &root, + &state, + "双视口试玩", + ); + let result = + browser_result_fixture(&root, &state, revision, BrowserPlaytestScenario::GenericV1); + let action = AgentRuntimeToolAction { + tool: "preview.validate".to_string(), + reason: Some("验证 desktop/mobile 试玩合同".to_string()), + input: serde_json::json!({}), + }; + let action_fingerprint = agent_runtime_tool_action_fingerprint(&action, &state.current_task); + let action_id = agent_runtime_tool_action_id(&state.run_id, 1, 0, 1, &action_fingerprint); + + let mut duplicate_desktop = result.clone(); + duplicate_desktop.viewport_results[1].viewport = BrowserValidationViewport::Desktop; + write_autonomous_playtest_receipt_at( + &root, + &contract, + &action_id, + &action_fingerprint, + revision, + &duplicate_desktop, + ) + .expect_err("duplicate desktop must not satisfy the mobile viewport gate"); + + let mut failed_mobile = result; + failed_mobile.viewport_results[1].passed = false; + write_autonomous_playtest_receipt_at( + &root, + &contract, + &action_id, + &action_fingerprint, + revision, + &failed_mobile, + ) + .expect_err("failed mobile must not satisfy the dual viewport gate"); +} + #[test] fn stale_scenario_receipt_reads_as_missing_and_can_be_replaced() { let (_temporary, root, state, contract) = autonomous_fixture( @@ -5849,6 +5901,107 @@ fn game_chat_parent_completion_stops_after_main_agent_smoke_and_dual_viewport_pl assert!(autonomous_game_build_completion_blocker_at_locked(&root, &state).is_none()); } +#[test] +fn game_chat_completion_rejects_same_revision_entry_rewrite_after_static_smoke() { + let _config_guard = crate::tests::write_test_local_config("{}".to_string()); + let (_temporary, root, parent_state, _contract) = autonomous_fixture_with_source( + "创建一轮植物塔防游戏", + "game-chat-static-smoke-entry-rewrite", + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + ); + install_game_chat_existing_art_manifest(&root); + update_manifest_task_status_at(&root, "code-prototype", GameCreationAppTaskStatus::Running) + .expect("start the game-chat main agent"); + let mut main_state = start_game_chat_main_agent(&root, &parent_state); + persist_game_chat_main_asset_audit_and_route(&root, &parent_state, &mut main_state); + let revision = advance_game_index_revision(&root, &main_state, cropped_spritesheet_game_html()); + mark_verification_passed(&root, &main_state, "game.static_smoke"); + + let rewritten = format!( + "{}\n", + cropped_spritesheet_game_html() + ); + fs::write(root.join(AGENT_RUNTIME_GAME_INDEX_PATH), rewritten) + .expect("rewrite game entry without advancing project revision"); + persist_game_chat_main_playtest_receipt(&root, &parent_state, &main_state, revision); + + let blocker = autonomous_game_build_completion_blocker_at_locked(&root, &main_state) + .expect("a static-smoke credential for different entry bytes must not complete"); + assert_eq!(blocker.tool, "runtime.autonomous_completion"); + assert!(blocker.summary.contains("game.static_smoke")); + assert!(blocker + .detail + .as_deref() + .is_some_and(|detail| detail.contains("game/index.html"))); +} + +#[test] +fn game_chat_prepared_finalization_rejects_same_revision_entry_rewrite() { + let _config_guard = crate::tests::write_test_local_config("{}".to_string()); + let (_temporary, root, parent_state, _contract) = autonomous_fixture_with_source( + "创建一轮植物塔防游戏", + "game-chat-static-smoke-prepared-rewrite", + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + ); + install_game_chat_existing_art_manifest(&root); + update_manifest_task_status_at(&root, "code-prototype", GameCreationAppTaskStatus::Running) + .expect("start the game-chat main agent"); + let mut main_state = start_game_chat_main_agent(&root, &parent_state); + persist_game_chat_main_asset_audit_and_route(&root, &parent_state, &mut main_state); + let revision = advance_game_index_revision(&root, &main_state, cropped_spritesheet_game_html()); + mark_verification_passed(&root, &main_state, "game.static_smoke"); + persist_game_chat_main_playtest_receipt(&root, &parent_state, &main_state, revision); + main_state.status = "running".to_string(); + main_state.phase = "finalizing".to_string(); + main_state.current_action = "测试恢复 prepared finalization".to_string(); + append_game_creator_agent_runtime_task(&root, &main_state) + .expect("append finalizing game-chat main task"); + write_game_creator_agent_runtime_state(&root, &main_state) + .expect("persist finalizing game-chat main state"); + + let response = "不应复用旧静态检查凭证的完成回复"; + let journal = build_game_creator_agent_runtime_finalization_journal( + &root, + &main_state, + response, + revision, + ) + .expect("build prepared game-chat finalization"); + write_game_creator_agent_runtime_finalization_journal(&root, &journal) + .expect("write prepared game-chat finalization"); + append_game_creator_agent_runtime_finalization_lifecycle_stage( + &root, + &journal, + "prepared", + journal.prepared_at, + ) + .expect("write prepared game-chat lifecycle"); + fs::write( + root.join(AGENT_RUNTIME_GAME_INDEX_PATH), + format!( + "{}\n", + cropped_spritesheet_game_html() + ), + ) + .expect("rewrite game entry after prepared finalization"); + + assert_eq!( + resume_game_creator_agent_finalization_for_test_at(&root, &main_state.agent_id) + .expect("resume game-chat prepared finalization"), + "not-found" + ); + let conversation = read_local_conversation_for_session_at( + &root, + Some(&main_state.agent_id), + Some(&main_state.session_id), + ) + .expect("read game-chat main conversation"); + assert!(!conversation + .messages + .iter() + .any(|message| message.role == "assistant" && message.content == response)); +} + #[test] fn game_chat_schedule_ready_tool_cannot_bypass_the_single_round_publish_boundary() { let (_temporary, root, state, _contract) = autonomous_fixture_with_source( @@ -6111,11 +6264,10 @@ fn game_chat_code_prototype_requires_cropped_spritesheet_use() { .expect("mark code prototype running"); let mut code_state = start_game_chat_main_agent(&root, &parent_state); persist_game_chat_main_asset_audit_and_route(&root, &parent_state, &mut code_state); - let revision = advance_game_index_revision( - &root, - &code_state, + let pure_code_html = with_static_smoke_contract( "", ); + let revision = advance_game_index_revision(&root, &code_state, &pure_code_html); mark_verification_passed(&root, &code_state, "game.static_smoke"); persist_game_chat_main_playtest_receipt(&root, &parent_state, &code_state, revision); let blocker = autonomous_game_build_completion_blocker_at_locked(&root, &code_state) @@ -6131,7 +6283,8 @@ fn game_chat_code_prototype_requires_cropped_spritesheet_use() { "", "", ] { - let revision = advance_game_index_revision(&root, &code_state, html); + let html = with_static_smoke_contract(html); + let revision = advance_game_index_revision(&root, &code_state, &html); mark_verification_passed(&root, &code_state, "game.static_smoke"); persist_game_chat_main_playtest_receipt(&root, &parent_state, &code_state, revision); assert!( @@ -6140,11 +6293,10 @@ fn game_chat_code_prototype_requires_cropped_spritesheet_use() { ); } - let overwritten_revision = advance_game_index_revision( - &root, - &code_state, + let overwritten_html = with_static_smoke_contract( "", ); + let overwritten_revision = advance_game_index_revision(&root, &code_state, &overwritten_html); mark_verification_passed(&root, &code_state, "game.static_smoke"); persist_game_chat_main_playtest_receipt( &root, @@ -6262,11 +6414,10 @@ fn cli_code_prototype_keeps_registered_canvas_spritesheet_gate_when_editor_is_co let code_record = queue_autonomous_manifest_child_fixture(&root, &parent_state, "code-prototype"); let code_state = agent_runtime_state_from_task_record(&code_record); - advance_game_index_revision( - &root, - &code_state, + let art_spec_only_html = with_static_smoke_contract( "", ); + advance_game_index_revision(&root, &code_state, &art_spec_only_html); mark_verification_passed(&root, &code_state, "game.static_smoke"); let blocker = autonomous_game_build_completion_blocker_at_locked(&root, &code_state) .expect("CLI must still require the art spritesheet"); @@ -6370,11 +6521,10 @@ fn game_chat_code_prototype_fails_closed_without_generated_spritesheet() { persist_game_chat_main_asset_audit_and_route(&root, &parent_state, &mut code_state); fs::remove_file(root.join("assets/art-spritesheet.png")) .expect("remove required art-spritesheet file"); - let revision = advance_game_index_revision( - &root, - &code_state, + let pure_code_html = with_static_smoke_contract( "", ); + let revision = advance_game_index_revision(&root, &code_state, &pure_code_html); mark_verification_passed(&root, &code_state, "game.static_smoke"); persist_game_chat_main_playtest_receipt(&root, &parent_state, &code_state, revision); @@ -7768,11 +7918,8 @@ fn autonomous_completion_requires_changed_index_static_smoke_and_bound_playtest( .as_deref() .is_some_and(|detail| detail.contains("game/index.html(initial-placeholder)"))); - let revision = advance_game_index_revision( - &root, - &state, - "可玩塔防", - ); + let valid_game = cropped_spritesheet_game_html().to_string(); + let revision = advance_game_index_revision(&root, &state, &valid_game); mark_verification_passed(&root, &state, "project.verify"); let blocker = autonomous_game_build_completion_blocker_at_locked(&root, &state) .expect("project.verify cannot replace static smoke"); @@ -7839,11 +7986,8 @@ fn autonomous_completion_requires_changed_index_static_smoke_and_bound_playtest( .expect("persist autonomous playtest receipt"); assert!(autonomous_game_build_completion_blocker_at_locked(&root, &state).is_none()); - advance_game_index_revision( - &root, - &state, - "试玩后又修改", - ); + let changed_game = format!("{valid_game}\n"); + advance_game_index_revision(&root, &state, &changed_game); mark_verification_passed(&root, &state, "game.static_smoke"); let blocker = autonomous_game_build_completion_blocker_at_locked(&root, &state) .expect("stale playtest must block completion"); @@ -7856,11 +8000,10 @@ fn autonomous_prepared_finalization_without_playtest_is_discarded_before_assista "做一个植物大战僵尸式塔防游戏", "autonomous-prepared-finalization-run", ); - let revision = advance_game_index_revision( - &root, - &state, - "尚未试玩的塔防", + let unplayed_html = with_static_smoke_contract( + "尚未试玩的塔防", ); + let revision = advance_game_index_revision(&root, &state, &unplayed_html); mark_verification_passed(&root, &state, "game.static_smoke"); state.status = "running".to_string(); state.phase = "finalizing".to_string(); @@ -7920,6 +8063,7 @@ fn autonomous_playtest_liveness_only_enforces_the_latest_preview_result() { last_verification_tool: Some("game.static_smoke".to_string()), last_verification_status: Some(AGENT_RUNTIME_VERIFICATION_STATUS_PASSED.to_string()), static_smoke_verified_revision: Some(2), + static_smoke_verified_game_index_sha256: Some("a".repeat(64)), failed_playtest_revision: None, updated_at: 0, }; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/models.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/models.rs index 5f5b26ccf..3607bb842 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/models.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/models.rs @@ -286,6 +286,11 @@ pub(crate) struct AgentRuntimeVerificationGate { /// proof that the exact same revision passed `game.static_smoke`. #[serde(default)] pub(crate) static_smoke_verified_revision: Option, + /// Bind the durable static-smoke credential to the exact entry bytes. + /// A project revision alone is insufficient because files can be changed + /// outside the Runtime mutation path without advancing that revision. + #[serde(default)] + pub(crate) static_smoke_verified_game_index_sha256: Option, #[serde(default)] pub(crate) failed_playtest_revision: Option, pub(crate) updated_at: u64, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/verification.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/verification.rs index 2ee35f3bf..30592a7d7 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/verification.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/verification.rs @@ -49,6 +49,7 @@ pub(in crate::agent) fn default_agent_runtime_verification_gate( last_verification_tool: None, last_verification_status: None, static_smoke_verified_revision: None, + static_smoke_verified_game_index_sha256: None, failed_playtest_revision: None, updated_at: 0, }) @@ -125,6 +126,20 @@ pub(in crate::agent) fn validate_agent_runtime_verification_gate( "Agent Runtime verification gate 的 failedPlaytestRevision 必须大于 0".to_string(), ); } + if gate + .static_smoke_verified_game_index_sha256 + .as_deref() + .is_some_and(|sha256| !is_lowercase_sha256(sha256)) + { + return Err("Agent Runtime verification gate 的 static smoke 入口摘要无效".to_string()); + } + if gate.static_smoke_verified_revision.is_none() + && gate.static_smoke_verified_game_index_sha256.is_some() + { + return Err( + "Agent Runtime verification gate 的 static smoke 入口摘要缺少 revision".to_string(), + ); + } if gate .failed_playtest_revision .zip(gate.mutation_revision) @@ -195,11 +210,35 @@ pub(in crate::agent) fn validate_agent_runtime_verification_gate( Ok(()) } -pub(in crate::agent) fn agent_runtime_static_smoke_passed_for_revision( +pub(in crate::agent) fn agent_runtime_static_smoke_passed_for_entry( gate: &AgentRuntimeVerificationGate, revision: u64, + game_index_sha256: &str, ) -> bool { - revision > 0 && gate.static_smoke_verified_revision == Some(revision) + revision > 0 + && gate.static_smoke_verified_revision == Some(revision) + && gate.static_smoke_verified_game_index_sha256.as_deref() == Some(game_index_sha256) +} + +pub(in crate::agent) fn agent_runtime_static_smoke_passed_for_current_entry_at( + root: &Path, + gate: &AgentRuntimeVerificationGate, + revision: u64, +) -> Result { + let Some((game_index, _)) = read_autonomous_evidence_file_at( + root, + AGENT_RUNTIME_GAME_INDEX_PATH, + "game.static_smoke 当前游戏入口", + AGENT_RUNTIME_AUTONOMOUS_GAME_INDEX_MAX_BYTES, + )? + else { + return Ok(false); + }; + Ok(agent_runtime_static_smoke_passed_for_entry( + gate, + revision, + &game_index.sha256, + )) } pub(crate) fn read_game_creator_agent_runtime_project_revision( diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools.rs index c8327c490..80db1ffa4 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools.rs @@ -35,7 +35,11 @@ pub(in crate::agent) use run_status::*; pub(in crate::agent) use task_ops::*; #[cfg(test)] -pub(crate) use delivery::build_static_delegate_result_for_child_at; +pub(crate) use delivery::{ + build_static_delegate_result_for_child_at, + convert_game_chat_child_user_input_to_safe_default_repair, + trusted_game_chat_autonomous_root_parent_at, +}; #[cfg(test)] pub(crate) use media::validate_agent_runtime_canvas_replacement_authorization_at; @@ -51,7 +55,9 @@ pub(crate) use delegation::{ }; pub(crate) use delivery::{ agent_runtime_delegation_id, dispatch_isolated_agent_join_at, + game_chat_safe_default_repair_replacement, game_chat_safe_default_repair_task_instruction, game_creator_agent_runtime_terminal_status, publish_game_creator_agent_delegate_result, + reconcile_claimed_game_chat_safe_default_half_states_at, reconcile_game_creator_agent_delegate_receipts_at, }; #[allow(unused_imports)] diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delegation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delegation.rs index 165830cec..af44bb3e1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delegation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delegation.rs @@ -286,18 +286,18 @@ fn validate_game_chat_main_art_delegation_at( acceptance_criteria: &[String], expected_artifacts: &[String], repair_of_delegation_id: Option<&str>, -) -> Result<(), String> { +) -> Result { let Some(binding) = read_game_creator_agent_runtime_run_profile_binding(root, parent_agent_id, parent_run_id)? else { - return Ok(()); + return Ok(false); }; let may_be_game_chat = binding.source == AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE || (binding.source == "agent-ready-task-scheduler" && binding.profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD && binding.root_agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID); if !may_be_game_chat { - return Ok(()); + return Ok(false); } let root_binding = read_game_creator_agent_runtime_run_profile_binding( root, @@ -320,13 +320,32 @@ fn validate_game_chat_main_art_delegation_at( ); } if !is_game_chat_main { - return Ok(()); - } - if repair_of_delegation_id.is_some() { - return Err( - "game-chat 主 Agent 美术委派不允许发起返工链;请认领当前回执后重新审计".to_string(), - ); + return Ok(false); } + let safe_default_repair = if let Some(original_delegation_id) = repair_of_delegation_id { + let parent_task = read_latest_game_creator_agent_runtime_task_by_run_id( + root, + parent_agent_id, + parent_run_id, + )? + .ok_or_else(|| "game-chat 安全默认返工缺少 code-prototype 父任务".to_string())?; + let original = read_static_delegate_delivery_at(root, original_delegation_id)? + .ok_or_else(|| "game-chat 安全默认返工引用的原 delivery 不存在".to_string())?; + if original.repair_of_delegation_id.is_some() + || original.target_agent_id != target_agent_id + || original.acceptance_criteria != acceptance_criteria + || original.expected_artifacts != expected_artifacts + || !trusted_game_chat_safe_default_repair_delivery_at(root, &parent_task, &original)? + { + return Err( + "game-chat code-prototype 只能对同一安全默认 delivery 发起唯一、同合同的一层返工" + .to_string(), + ); + } + true + } else { + false + }; if !matches!(target_agent_id, "art-director" | "art-asset-plan") { return Err( "game-chat code-prototype 只能按审计结果委派 art-director 或 art-asset-plan" @@ -387,13 +406,14 @@ fn validate_game_chat_main_art_delegation_at( && delivery.parent_run_id == parent_run_id && delivery.parent_action_id == action_identity && delivery.target_agent_id == target_agent_id - && delivery.repair_of_delegation_id.is_none() + && delivery.repair_of_delegation_id.as_deref() == repair_of_delegation_id && delivery.status != StaticDelegateDeliveryStatus::Suppressed }); if static_delegate_target_agent_ids_at(root, parent_agent_id, parent_run_id)? .iter() .any(|existing_target| existing_target == target_agent_id) && !same_action_replay + && !safe_default_repair { return Err(format!( "game-chat 每个审计缺口最多委派一次:target={target_agent_id}" @@ -404,7 +424,7 @@ fn validate_game_chat_main_art_delegation_at( { return Err("game-chat code-prototype 同一时刻最多保留一个活跃美术委派".to_string()); } - Ok(()) + Ok(safe_default_repair) } pub(crate) fn observe_agent_runtime_agent_delegate( @@ -528,7 +548,68 @@ pub(crate) fn observe_agent_runtime_agent_delegate( detail: None, }; } - if repair_of_delegation_id.is_some() && agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { + let mut safe_default_repair_instruction = None; + let game_chat_safe_default_repair = if let Some(original_delegation_id) = + repair_of_delegation_id.as_deref() + { + let parent_task = match read_latest_game_creator_agent_runtime_task_by_run_id( + root, + agent_id, + parent_run_id, + ) { + Ok(parent_task) => parent_task, + Err(error) => { + return AgentRuntimeToolObservation { + tool: "agent.delegate".to_string(), + status: "failed".to_string(), + summary: redact_agent_runtime_project_paths(root, &error, 240), + detail: None, + }; + } + }; + let original = match read_static_delegate_delivery_at(root, original_delegation_id) { + Ok(original) => original, + Err(error) => { + return AgentRuntimeToolObservation { + tool: "agent.delegate".to_string(), + status: "failed".to_string(), + summary: redact_agent_runtime_project_paths(root, &error, 240), + detail: None, + }; + } + }; + match (parent_task.as_ref(), original.as_ref()) { + (Some(parent_task), Some(original)) => { + match trusted_game_chat_safe_default_repair_delivery_at(root, parent_task, original) + { + Ok(authorized) => { + if authorized { + safe_default_repair_instruction = original + .structured_result + .as_ref() + .and_then(game_chat_safe_default_repair_task_instruction); + } + authorized + } + Err(error) => { + return AgentRuntimeToolObservation { + tool: "agent.delegate".to_string(), + status: "failed".to_string(), + summary: redact_agent_runtime_project_paths(root, &error, 240), + detail: None, + }; + } + } + } + _ => false, + } + } else { + false + }; + if repair_of_delegation_id.is_some() + && agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID + && !game_chat_safe_default_repair + { return AgentRuntimeToolObservation { tool: "agent.delegate".to_string(), status: "failed".to_string(), @@ -573,7 +654,7 @@ pub(crate) fn observe_agent_runtime_agent_delegate( detail: None, }; } - if let Err(error) = validate_game_chat_main_art_delegation_at( + let game_chat_safe_default_repair = match validate_game_chat_main_art_delegation_at( root, agent_id, parent_run_id, @@ -583,13 +664,16 @@ pub(crate) fn observe_agent_runtime_agent_delegate( &expected_artifacts, repair_of_delegation_id.as_deref(), ) { - return AgentRuntimeToolObservation { - tool: "agent.delegate".to_string(), - status: "failed".to_string(), - summary: redact_agent_runtime_project_paths(root, &error, 240), - detail: None, - }; - } + Ok(safe_default_repair) => safe_default_repair, + Err(error) => { + return AgentRuntimeToolObservation { + tool: "agent.delegate".to_string(), + status: "failed".to_string(), + summary: redact_agent_runtime_project_paths(root, &error, 240), + detail: None, + }; + } + }; if let Err(error) = ensure_current_autonomous_ready_child_mutation_at_locked(root, agent_id, parent_run_id) { @@ -609,8 +693,11 @@ pub(crate) fn observe_agent_runtime_agent_delegate( &target_agent_id, delegation_action_identity, ); + let delegated_task_text = safe_default_repair_instruction + .map(|instruction| format!("{instruction}\n\n{task}")) + .unwrap_or(task); let delegated_task = match render_static_delegate_task_contract( - &task, + &delegated_task_text, agent_id, parent_run_id, &delegation_id, @@ -638,6 +725,7 @@ pub(crate) fn observe_agent_runtime_agent_delegate( && binding.profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD && binding.root_agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID }); + debug_assert!(!game_chat_safe_default_repair || main_art_delegation); let run_id = if run_id_input.trim().is_empty() { if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID || main_art_delegation { format!("delegated-{delegation_id}") diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delivery.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delivery.rs index a4bbf91db..4f0152312 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delivery.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delivery.rs @@ -1,5 +1,432 @@ use super::*; +const GAME_CHAT_SAFE_DEFAULT_REPAIR_SCHEMA_VERSION: &str = "game-chat-safe-default-repair.v1"; +const GAME_CHAT_SAFE_DEFAULT_REPAIR_CODE: &str = "child-needs-user-input"; +const GAME_CHAT_SAFE_DEFAULT_REPAIR_STRATEGY: &str = "continue-with-safe-defaults"; +const GAME_CHAT_SAFE_DEFAULT_REASON_PREFERENCE: &str = "preference-clarification"; +const GAME_CHAT_SAFE_DEFAULT_REASON_SENSITIVE: &str = "sensitive-or-permission-request"; +const GAME_CHAT_SAFE_DEFAULT_DECISION_USE_DEFAULT: &str = "use-safe-default"; +const GAME_CHAT_SAFE_DEFAULT_DECISION_SKIP_DENIED: &str = "skip-denied"; +const GAME_CHAT_SAFE_DEFAULT_REPAIR_SUMMARY: &str = + "专业 Agent 请求了用户补充信息;自主构建必须采用安全默认值继续,并由 Supervisor 发起唯一返工。"; +const GAME_CHAT_SAFE_DEFAULT_RESULT_SUMMARY: &str = + "自主构建已将专业 Agent 的补充信息请求转换为安全默认返工。"; + +fn game_chat_safe_default_request_allows_preference_default( + result: &StaticDelegateStructuredResult, +) -> bool { + let serialized = serde_json::to_string(&result.user_input_questions).unwrap_or_default(); + let lower = serialized.to_ascii_lowercase(); + if redact_secret_tokens(&serialized) != serialized + || redact_absolute_path_tokens(&serialized) != serialized + || [ + ".env", + "api key", + "api_key", + "apikey", + "token", + "secret", + "password", + "credential", + "authorization", + "cookie", + "bearer ", + "权限", + "授权", + "删除", + "发布", + "支付", + "外部副作用", + "permission", + "authorize", + "delete", + "publish", + "payment", + ] + .iter() + .any(|marker| lower.contains(marker)) + { + return false; + } + [ + "偏好", + "视觉", + "配色", + "色彩", + "颜色", + "风格", + "主题", + "布局", + "难度", + "节奏", + "preference", + "visual", + "palette", + "color", + "style", + "theme", + "layout", + "difficulty", + "pace", + ] + .iter() + .any(|marker| lower.contains(marker)) +} + +fn game_chat_safe_default_repair_error(result: &StaticDelegateStructuredResult) -> String { + let use_preference_default = game_chat_safe_default_request_allows_preference_default(result); + serde_json::json!({ + "schemaVersion": GAME_CHAT_SAFE_DEFAULT_REPAIR_SCHEMA_VERSION, + "code": GAME_CHAT_SAFE_DEFAULT_REPAIR_CODE, + "strategy": GAME_CHAT_SAFE_DEFAULT_REPAIR_STRATEGY, + "reasonCode": if use_preference_default { + GAME_CHAT_SAFE_DEFAULT_REASON_PREFERENCE + } else { + GAME_CHAT_SAFE_DEFAULT_REASON_SENSITIVE + }, + "defaultDecision": if use_preference_default { + GAME_CHAT_SAFE_DEFAULT_DECISION_USE_DEFAULT + } else { + GAME_CHAT_SAFE_DEFAULT_DECISION_SKIP_DENIED + }, + "requestSha256": result.user_input_questions_sha256, + "summary": GAME_CHAT_SAFE_DEFAULT_REPAIR_SUMMARY, + }) + .to_string() +} + +fn game_chat_safe_default_repair_marker( + result: &StaticDelegateStructuredResult, +) -> Option { + if result.contract_status != StaticDelegateContractStatus::NeedsRepair + || !result.user_input_questions.is_empty() + || result.user_input_questions_sha256.is_some() + { + return None; + } + let marker = serde_json::from_str::(result.error.as_deref()?).ok()?; + let reason = marker.get("reasonCode")?.as_str()?; + let decision = marker.get("defaultDecision")?.as_str()?; + let request_sha256 = marker.get("requestSha256")?.as_str()?; + (marker.get("schemaVersion")?.as_str()? == GAME_CHAT_SAFE_DEFAULT_REPAIR_SCHEMA_VERSION + && marker.get("code")?.as_str()? == GAME_CHAT_SAFE_DEFAULT_REPAIR_CODE + && marker.get("strategy")?.as_str()? == GAME_CHAT_SAFE_DEFAULT_REPAIR_STRATEGY + && marker.get("summary")?.as_str()? == GAME_CHAT_SAFE_DEFAULT_REPAIR_SUMMARY + && matches!( + (reason, decision), + ( + GAME_CHAT_SAFE_DEFAULT_REASON_PREFERENCE, + GAME_CHAT_SAFE_DEFAULT_DECISION_USE_DEFAULT + ) | ( + GAME_CHAT_SAFE_DEFAULT_REASON_SENSITIVE, + GAME_CHAT_SAFE_DEFAULT_DECISION_SKIP_DENIED + ) + ) + && request_sha256.len() == 64 + && request_sha256.bytes().all(|byte| byte.is_ascii_hexdigit())) + .then_some(marker) +} + +pub(crate) fn game_chat_safe_default_repair_result_is_valid( + result: &StaticDelegateStructuredResult, +) -> bool { + game_chat_safe_default_repair_marker(result).is_some() +} + +pub(crate) fn game_chat_safe_default_repair_task_instruction( + result: &StaticDelegateStructuredResult, +) -> Option<&'static str> { + let marker = game_chat_safe_default_repair_marker(result)?; + match marker.get("defaultDecision")?.as_str()? { + GAME_CHAT_SAFE_DEFAULT_DECISION_USE_DEFAULT => Some( + "Runtime 安全默认决策:use-safe-default。仅采用无需用户补充、无需新增权限的普通偏好默认值继续;不得再次询问用户。", + ), + GAME_CHAT_SAFE_DEFAULT_DECISION_SKIP_DENIED => Some( + "Runtime 安全默认决策:skip-denied。跳过被拒绝、敏感、越权或无法证明安全的输入与动作,仅在原合同和现有权限内继续;不得再次询问用户。", + ), + _ => None, + } +} + +pub(crate) fn game_chat_safe_default_repair_replacement( + result: &StaticDelegateStructuredResult, +) -> Option<(String, StaticDelegateStructuredResult)> { + let mut replacement = result.clone(); + if replacement.contract_status == StaticDelegateContractStatus::NeedsUserInput { + convert_game_chat_child_user_input_to_safe_default_repair(&mut replacement); + } else if !game_chat_safe_default_repair_result_is_valid(&replacement) { + return None; + } + Some(( + GAME_CHAT_SAFE_DEFAULT_RESULT_SUMMARY.to_string(), + replacement, + )) +} + +fn trusted_game_chat_autonomous_root_binding( + parent_task: &AgentRuntimeTaskRecord, + binding: &AgentRuntimeRunProfileBinding, +) -> bool { + parent_task.agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID + && parent_task.run_id == binding.run_id + && parent_task.source == AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE + && parent_task.run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + && parent_task.parent_agent_id.is_none() + && parent_task.parent_run_id.is_none() + && binding.agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID + && binding.root_agent_id == binding.agent_id + && binding.root_run_id == binding.run_id + && binding.parent_agent_id.is_none() + && binding.parent_run_id.is_none() + && binding.source == AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE + && binding.profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + && parent_task.run_profile_binding_fingerprint == binding.binding_fingerprint +} + +fn trusted_game_chat_autonomous_parent_chain_at( + root: &Path, + parent_task: &AgentRuntimeTaskRecord, +) -> Result, String> { + let Some(parent_binding) = read_game_creator_agent_runtime_run_profile_binding( + root, + &parent_task.agent_id, + &parent_task.run_id, + )? + else { + return Ok(None); + }; + if parent_task.agent_id != parent_binding.agent_id + || parent_task.run_id != parent_binding.run_id + || parent_task.source != parent_binding.source + || parent_task.run_profile != parent_binding.profile + || parent_task.parent_agent_id != parent_binding.parent_agent_id + || parent_task.parent_run_id != parent_binding.parent_run_id + || parent_task.run_profile_binding_fingerprint != parent_binding.binding_fingerprint + { + return Ok(None); + } + if trusted_game_chat_autonomous_root_binding(parent_task, &parent_binding) { + return Ok(Some(parent_binding)); + } + if parent_task.agent_id != "code-prototype" + || parent_task.source != "agent-ready-task-scheduler" + || parent_task.run_profile != AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + || parent_task.delegation_id.is_some() + || parent_binding.parent_agent_id.as_deref() + != Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID) + || parent_binding.parent_run_id.as_deref() != Some(parent_binding.root_run_id.as_str()) + || parent_binding.root_agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID + || parent_binding.parent_binding_fingerprint.is_none() + { + return Ok(None); + } + let Some(root_binding) = read_game_creator_agent_runtime_run_profile_binding( + root, + &parent_binding.root_agent_id, + &parent_binding.root_run_id, + )? + else { + return Ok(None); + }; + let Some(root_task) = read_latest_game_creator_agent_runtime_task_by_run_id( + root, + &root_binding.agent_id, + &root_binding.run_id, + )? + else { + return Ok(None); + }; + if !trusted_game_chat_autonomous_root_binding(&root_task, &root_binding) + || parent_binding.parent_binding_fingerprint.as_deref() + != Some(root_binding.binding_fingerprint.as_str()) + { + return Ok(None); + } + Ok(Some(parent_binding)) +} + +pub(crate) fn trusted_game_chat_autonomous_root_parent_at( + root: &Path, + parent_task: &AgentRuntimeTaskRecord, +) -> Result { + if parent_task.agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID + || parent_task.source != AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE + || parent_task.run_profile != AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + || parent_task.parent_agent_id.is_some() + || parent_task.parent_run_id.is_some() + { + return Ok(false); + } + let Some(binding) = read_game_creator_agent_runtime_run_profile_binding( + root, + &parent_task.agent_id, + &parent_task.run_id, + )? + else { + return Ok(false); + }; + Ok(trusted_game_chat_autonomous_root_binding( + parent_task, + &binding, + )) +} + +fn trusted_game_chat_autonomous_child_delivery_at( + root: &Path, + parent_task: &AgentRuntimeTaskRecord, + delivery: &StaticDelegateDeliveryRecord, + supplied_child_task: Option<&AgentRuntimeTaskRecord>, +) -> Result { + let Some(parent_binding) = trusted_game_chat_autonomous_parent_chain_at(root, parent_task)? + else { + return Ok(false); + }; + if delivery.parent_agent_id != parent_task.agent_id + || delivery.parent_session_id != parent_task.session_id + || delivery.parent_run_id != parent_task.run_id + || agent_runtime_delegation_id( + &delivery.parent_agent_id, + &delivery.parent_run_id, + &delivery.target_agent_id, + &delivery.parent_action_id, + ) != delivery.delegation_id + || !matches!( + delivery.target_agent_id.as_str(), + "code-prototype" | "art-director" | "art-asset-plan" + ) + || (parent_task.agent_id == "code-prototype" + && !matches!( + delivery.target_agent_id.as_str(), + "art-director" | "art-asset-plan" + )) + { + return Err("game-chat 安全默认返工 delivery 与可信父责任链不一致".to_string()); + } + let child_task = match supplied_child_task { + Some(task) => task.clone(), + None => read_latest_game_creator_agent_runtime_task_by_run_id( + root, + &delivery.target_agent_id, + &delivery.target_run_id, + )? + .ok_or_else(|| "game-chat 安全默认返工缺少专业 child task".to_string())?, + }; + validate_static_delegate_delivery_for_child_result(delivery, parent_task, &child_task)?; + let child_binding = read_game_creator_agent_runtime_run_profile_binding( + root, + &child_task.agent_id, + &child_task.run_id, + )? + .ok_or_else(|| "game-chat 安全默认返工缺少专业 child binding".to_string())?; + if child_task.source != "agent-delegate" + || child_task.run_profile != AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + || child_task.run_profile_binding_fingerprint != child_binding.binding_fingerprint + || child_binding.agent_id != delivery.target_agent_id + || child_binding.run_id != delivery.target_run_id + || child_binding.source != "agent-delegate" + || child_binding.profile != AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + || child_binding.root_agent_id != parent_binding.root_agent_id + || child_binding.root_run_id != parent_binding.root_run_id + || child_binding.parent_agent_id.as_deref() != Some(parent_task.agent_id.as_str()) + || child_binding.parent_run_id.as_deref() != Some(parent_task.run_id.as_str()) + || child_binding.parent_binding_fingerprint.as_deref() + != Some(parent_binding.binding_fingerprint.as_str()) + { + return Err("game-chat 安全默认返工专业 child binding/link 身份不一致".to_string()); + } + Ok(true) +} + +pub(crate) fn convert_game_chat_child_user_input_to_safe_default_repair( + result: &mut StaticDelegateStructuredResult, +) -> bool { + if result.contract_status != StaticDelegateContractStatus::NeedsUserInput { + return false; + } + let error = game_chat_safe_default_repair_error(result); + result.contract_status = StaticDelegateContractStatus::NeedsRepair; + result.user_input_questions.clear(); + result.user_input_questions_sha256 = None; + result.error = Some(error); + true +} + +pub(in crate::agent) fn convert_claimed_game_chat_user_input_deliveries_to_repair_at( + root: &Path, + parent_task: &AgentRuntimeTaskRecord, + deliveries: &[StaticDelegateDeliveryRecord], +) -> Result { + if trusted_game_chat_autonomous_parent_chain_at(root, parent_task)?.is_none() { + return Ok(0); + } + let mut converted = 0_usize; + for expected in deliveries.iter().filter(|delivery| { + delivery.parent_agent_id == parent_task.agent_id + && delivery.parent_session_id == parent_task.session_id + && delivery.parent_run_id == parent_task.run_id + && delivery.status == StaticDelegateDeliveryStatus::ClaimedByParent + && delivery.clarification_request_id.is_none() + && delivery.clarification_answers_sha256.is_none() + && delivery.structured_result.as_ref().is_some_and(|result| { + result.contract_status == StaticDelegateContractStatus::NeedsUserInput + }) + }) { + if !trusted_game_chat_autonomous_child_delivery_at(root, parent_task, expected, None)? { + continue; + } + replace_claimed_static_delegate_result_for_game_chat_safe_default_at(root, expected)?; + converted = converted.saturating_add(1); + } + Ok(converted) +} + +pub(crate) fn reconcile_claimed_game_chat_safe_default_half_states_at( + root: &Path, + parent_agent_id: &str, + parent_run_id: &str, +) -> Result<(), String> { + let Some(parent_task) = read_latest_game_creator_agent_runtime_task_by_run_id( + root, + parent_agent_id, + parent_run_id, + )? + else { + return Ok(()); + }; + if trusted_game_chat_autonomous_parent_chain_at(root, &parent_task)?.is_none() { + return Ok(()); + } + for delivery in claimed_static_delegate_deliveries_at(root, parent_agent_id, parent_run_id)? { + if delivery.structured_result.as_ref().is_some_and(|result| { + result.contract_status == StaticDelegateContractStatus::NeedsUserInput + || game_chat_safe_default_repair_result_is_valid(result) + }) { + if !trusted_game_chat_autonomous_child_delivery_at(root, &parent_task, &delivery, None)? + { + continue; + } + replace_claimed_static_delegate_result_for_game_chat_safe_default_at(root, &delivery)?; + } + } + Ok(()) +} + +pub(crate) fn trusted_game_chat_safe_default_repair_delivery_at( + root: &Path, + parent_task: &AgentRuntimeTaskRecord, + delivery: &StaticDelegateDeliveryRecord, +) -> Result { + Ok( + delivery.status == StaticDelegateDeliveryStatus::ClaimedByParent + && delivery.result_summary.as_deref() == Some(GAME_CHAT_SAFE_DEFAULT_RESULT_SUMMARY) + && delivery + .structured_result + .as_ref() + .is_some_and(game_chat_safe_default_repair_result_is_valid) + && trusted_game_chat_autonomous_child_delivery_at(root, parent_task, delivery, None)?, + ) +} + pub(crate) fn agent_runtime_delegation_id( parent_agent_id: &str, parent_run_id: &str, @@ -334,7 +761,22 @@ pub(in crate::agent) fn wake_waiting_static_delegate_parent_run_at( ¤t_task.run_id, )?; let mut state = state; - ensure_static_delegate_user_input_wait_at(root, &mut state, &deliveries)?; + if !ensure_static_delegate_user_input_wait_at(root, &mut state, &deliveries)? { + let state = advance_game_creator_agent_runtime_turn_at( + root, + state, + "planning", + "自主构建澄清已转换为安全默认返工", + "专业 Agent 回执已按安全默认策略进入 needs-repair,恢复同一父 run。", + )?; + let root = root.to_path_buf(); + let agent_id = current_task.agent_id.clone(); + let task = current_task.task.clone(); + tauri::async_runtime::spawn(async move { + let _runtime_lock = runtime_lock; + drain_game_creator_agent_background_tasks(root, agent_id, task, state).await; + }); + } return Ok(true); } let state = advance_game_creator_agent_runtime_turn_at( @@ -889,8 +1331,8 @@ pub(crate) fn publish_game_creator_agent_delegate_result( return; } let was_dispatched = existing_delivery.status == StaticDelegateDeliveryStatus::Dispatched; - let safe_result_summary = truncate_agent_runtime_text(&result_detail, 140); - let structured_result = match build_static_delegate_result_for_child_at( + let mut safe_result_summary = truncate_agent_runtime_text(&result_detail, 140); + let mut structured_result = match build_static_delegate_result_for_child_at( root, &existing_delivery, child_task, @@ -918,6 +1360,24 @@ pub(crate) fn publish_game_creator_agent_delegate_result( return; } }; + match trusted_game_chat_autonomous_child_delivery_at( + root, + &parent_task, + &existing_delivery, + Some(child_task), + ) { + Ok(true) => { + if convert_game_chat_child_user_input_to_safe_default_repair(&mut structured_result) + { + safe_result_summary = GAME_CHAT_SAFE_DEFAULT_RESULT_SUMMARY.to_string(); + } + } + Ok(false) => {} + Err(error) => { + record_game_creator_agent_delegate_result_failure(root, child_task, &error); + return; + } + } let delivery = match mark_static_delegate_delivery_ready_with_result_at( root, &child_task.agent_id, diff --git a/apps/ai-game-creator-shell/src-tauri/src/cli.rs b/apps/ai-game-creator-shell/src-tauri/src/cli.rs index 076a4f0aa..4cc1439b4 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/cli.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/cli.rs @@ -24,6 +24,7 @@ pub(crate) enum CliCommand { parent_agent_id: String, initialize: bool, run_profile: String, + supervisor_source: &'static str, }, AgentEnqueue { project_path: PathBuf, @@ -687,7 +688,7 @@ pub(crate) fn parse_cli_command(args: &[String]) -> Result, S })); } if args.first().map(String::as_str) == Some("--swarm-chat") { - const USAGE: &str = "用法:--swarm-chat [--init] [--autonomous-game-build] <本地项目绝对路径> [parentAgentId]"; + const USAGE: &str = "用法:--swarm-chat [--init] [--autonomous-game-build] [--game-chat-smoke] <本地项目绝对路径> [parentAgentId]"; let mut rest = args[1..].to_vec(); let initialize = if let Some(index) = rest.iter().position(|arg| arg == "--init") { rest.remove(index); @@ -711,9 +712,36 @@ pub(crate) fn parse_cli_command(args: &[String]) -> Result, S } _ => return Err(USAGE.to_string()), }; + let game_chat_smoke = match rest + .iter() + .filter(|arg| arg.as_str() == "--game-chat-smoke") + .count() + { + 0 => false, + 1 => { + let index = rest + .iter() + .position(|arg| arg == "--game-chat-smoke") + .expect("counted game-chat smoke flag"); + rest.remove(index); + true + } + _ => return Err(USAGE.to_string()), + }; if !(1..=2).contains(&rest.len()) || rest.iter().any(|value| value.trim().is_empty()) { return Err(USAGE.to_string()); } + if game_chat_smoke + && (!autonomous_game_build + || rest.get(1).is_some_and(|parent| { + parent.trim() != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID + })) + { + return Err( + "--game-chat-smoke 仅允许 project-supervisor 的 --autonomous-game-build 受限验收入口" + .to_string(), + ); + } return Ok(Some(CliCommand::SwarmChat { project_path: PathBuf::from(&rest[0]), parent_agent_id: rest @@ -726,6 +754,11 @@ pub(crate) fn parse_cli_command(args: &[String]) -> Result, S } else { AGENT_RUNTIME_RUN_PROFILE_STANDARD.to_string() }, + supervisor_source: if game_chat_smoke { + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE + } else { + AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE + }, })); } if args.first().map(String::as_str) == Some("--agent-task") { @@ -984,11 +1017,17 @@ pub(crate) fn run_cli_command(command: CliCommand) -> Result<(), String> { parent_agent_id, initialize, run_profile, + supervisor_source, } => { let project_path = canonicalize_cli_path(&project_path, "本地项目路径", initialize)?; require_external_agent_runner_for_cli_runtime_write(&project_path)?; initialize_cli_agent_project(&project_path, initialize)?; - run_game_creator_swarm_chat_at(&project_path, &parent_agent_id, &run_profile) + run_game_creator_swarm_chat_at( + &project_path, + &parent_agent_id, + &run_profile, + supervisor_source, + ) } CliCommand::AgentEnqueue { project_path, @@ -1869,6 +1908,7 @@ mod tests { parent_agent_id: "code-prototype".to_string(), initialize: true, run_profile: AGENT_RUNTIME_RUN_PROFILE_STANDARD.to_string(), + supervisor_source: AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, } ); assert!(command.requires_external_agent_runner()); @@ -1948,6 +1988,7 @@ mod tests { parent_agent_id: GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(), initialize: false, run_profile: AGENT_RUNTIME_RUN_PROFILE_STANDARD.to_string(), + supervisor_source: AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, } ); } @@ -1970,10 +2011,57 @@ mod tests { parent_agent_id: GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(), initialize: false, run_profile: AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD.to_string(), + supervisor_source: AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, } ); } + #[test] + fn swarm_chat_game_chat_smoke_flag_is_restricted_and_selects_trusted_source() { + let project_path = std::env::current_dir().expect("current directory"); + let command = parse_cli_command(&[ + "--swarm-chat".to_string(), + "--init".to_string(), + "--autonomous-game-build".to_string(), + "--game-chat-smoke".to_string(), + project_path.display().to_string(), + ]) + .expect("parse game-chat smoke") + .expect("game-chat smoke command"); + assert_eq!( + command, + CliCommand::SwarmChat { + project_path, + parent_agent_id: GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(), + initialize: true, + run_profile: AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD.to_string(), + supervisor_source: AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + } + ); + assert!(parse_cli_command(&[ + "--swarm-chat".to_string(), + "--game-chat-smoke".to_string(), + "/tmp/game-project".to_string(), + ]) + .is_err()); + assert!(parse_cli_command(&[ + "--swarm-chat".to_string(), + "--autonomous-game-build".to_string(), + "--game-chat-smoke".to_string(), + "/tmp/game-project".to_string(), + "code-prototype".to_string(), + ]) + .is_err()); + assert!(parse_cli_command(&[ + "--swarm-chat".to_string(), + "--autonomous-game-build".to_string(), + "--game-chat-smoke".to_string(), + "--game-chat-smoke".to_string(), + "/tmp/game-project".to_string(), + ]) + .is_err()); + } + #[test] fn swarm_chat_rejects_missing_or_extra_arguments() { assert!(parse_cli_command(&["--swarm-chat".to_string()]).is_err()); diff --git a/apps/ai-game-creator-shell/src-tauri/src/delegation.rs b/apps/ai-game-creator-shell/src-tauri/src/delegation.rs index d726b825b..573cccd63 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/delegation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/delegation.rs @@ -419,6 +419,7 @@ pub(crate) fn static_delegate_completion_barrier_at( ) -> Result { validate_static_delegate_id(parent_agent_id, "parentAgentId", 96)?; validate_static_delegate_id(parent_run_id, "parentRunId", 160)?; + reconcile_claimed_game_chat_safe_default_half_states_at(root, parent_agent_id, parent_run_id)?; let claims = list_static_delegate_claims_at(root)? .into_iter() .filter(|claim| { @@ -1677,6 +1678,141 @@ fn write_static_delegate_delivery_at( ) } +pub(crate) fn replace_claimed_static_delegate_result_for_game_chat_safe_default_at( + root: &Path, + expected: &StaticDelegateDeliveryRecord, +) -> Result { + validate_static_delegate_delivery_record(expected)?; + let expected_result = expected + .structured_result + .as_ref() + .ok_or_else(|| "game-chat 安全默认返工 delivery 缺少 structuredResult".to_string())?; + let (result_summary, structured_result) = + game_chat_safe_default_repair_replacement(expected_result) + .ok_or_else(|| "game-chat 安全默认返工转换状态无效".to_string())?; + let expected_is_original = + expected_result.contract_status == StaticDelegateContractStatus::NeedsUserInput; + let expected_is_converted = expected.result_summary.as_deref() == Some(result_summary.as_str()) + && expected_result == &structured_result; + if expected.status != StaticDelegateDeliveryStatus::ClaimedByParent + || expected.clarification_request_id.is_some() + || expected.clarification_answers_sha256.is_some() + || (!expected_is_original && !expected_is_converted) + { + return Err("game-chat 安全默认返工转换状态无效".to_string()); + } + validate_static_delegate_structured_result( + &structured_result, + expected.terminal_status.as_deref().unwrap_or_default(), + &expected.expected_artifacts, + )?; + let claim_action_id = expected + .claimed_by_action_id + .as_deref() + .ok_or_else(|| "game-chat 安全默认返工 delivery 缺少 claim actionId".to_string())?; + let _claim_lock = acquire_static_delegate_claim_lock_at( + root, + &expected.parent_agent_id, + &expected.parent_run_id, + claim_action_id, + )?; + let _delivery_locks = + acquire_static_delegate_delivery_locks_at(root, vec![expected.delegation_id.clone()])?; + let mut delivery = read_static_delegate_delivery_at(root, &expected.delegation_id)? + .ok_or_else(|| { + format!( + "game-chat 安全默认返工 delivery 不存在:{}", + expected.delegation_id + ) + })?; + validate_static_delegate_delivery_identity(&delivery, expected)?; + if delivery.status != StaticDelegateDeliveryStatus::ClaimedByParent + || delivery.claimed_by_action_id.as_deref() != Some(claim_action_id) + || delivery.clarification_request_id.is_some() + || delivery.clarification_answers_sha256.is_some() + { + return Err("game-chat 安全默认返工 delivery 当前状态无效".to_string()); + } + let mut claim = read_static_delegate_claim_at( + root, + &expected.parent_agent_id, + &expected.parent_run_id, + claim_action_id, + )? + .ok_or_else(|| "game-chat 安全默认返工缺少原 claim".to_string())?; + let receipt_index = claim + .receipts + .iter() + .position(|receipt| receipt.delegation_id == expected.delegation_id) + .ok_or_else(|| "game-chat 安全默认返工 claim 缺少原 delivery".to_string())?; + let receipt = &claim.receipts[receipt_index]; + if receipt.target_agent_id != delivery.target_agent_id + || receipt.status != delivery.terminal_status.as_deref().unwrap_or_default() + || receipt.acceptance_criteria != delivery.acceptance_criteria + || receipt.expected_artifacts != delivery.expected_artifacts + || receipt.repair_of_delegation_id != delivery.repair_of_delegation_id + { + return Err("game-chat 安全默认返工 claim 与 delivery 身份冲突".to_string()); + } + + let delivery_is_original = delivery.structured_result.as_ref().is_some_and(|result| { + result.contract_status == StaticDelegateContractStatus::NeedsUserInput + }); + let delivery_is_converted = delivery.result_summary.as_deref() == Some(result_summary.as_str()) + && delivery.structured_result.as_ref() == Some(&structured_result); + let receipt_is_original = receipt.structured_result.as_ref().is_some_and(|result| { + result.contract_status == StaticDelegateContractStatus::NeedsUserInput + }); + let receipt_is_converted = receipt.summary == result_summary + && receipt.structured_result.as_ref() == Some(&structured_result); + if (!delivery_is_original && !delivery_is_converted) + || (!receipt_is_original && !receipt_is_converted) + { + return Err("game-chat 安全默认返工 delivery/claim 含非唯一转换差异".to_string()); + } + + if delivery_is_original { + let original = delivery + .structured_result + .as_ref() + .expect("original delivery result exists"); + let (_, converted) = game_chat_safe_default_repair_replacement(original) + .ok_or_else(|| "game-chat 安全默认返工原 delivery 无法转换".to_string())?; + if converted != structured_result + || (receipt_is_original + && (receipt.summary != delivery.result_summary.as_deref().unwrap_or_default() + || receipt.structured_result.as_ref() != Some(original))) + { + return Err("game-chat 安全默认返工 claim 与原 delivery 结果冲突".to_string()); + } + } else if receipt_is_original { + let original = receipt + .structured_result + .as_ref() + .expect("original receipt result exists"); + let (_, converted) = game_chat_safe_default_repair_replacement(original) + .ok_or_else(|| "game-chat 安全默认返工原 claim 无法转换".to_string())?; + if converted != structured_result { + return Err("game-chat 安全默认返工 converted delivery 与原 claim 冲突".to_string()); + } + } + + if !delivery_is_converted { + delivery.result_summary = Some(result_summary.clone()); + delivery.structured_result = Some(structured_result.clone()); + delivery.updated_at = unix_timestamp(); + write_static_delegate_delivery_at(root, &delivery)?; + } + if !receipt_is_converted { + let receipt = &mut claim.receipts[receipt_index]; + receipt.summary = result_summary; + receipt.structured_result = Some(structured_result); + claim.updated_at = unix_timestamp(); + write_static_delegate_claim_at(root, &claim)?; + } + Ok(delivery) +} + fn read_static_delegate_claim_at( root: &Path, parent_agent_id: &str, @@ -2271,6 +2407,140 @@ mod tests { assert!(error.contains("user.input_request")); } + #[test] + fn game_chat_safe_default_replacement_recovers_both_persisted_half_states() { + let root = std::env::temp_dir().join(format!( + "genarrative-safe-default-half-state-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system time after unix epoch") + .as_nanos() + )); + init_local_game_project_at(&root, "project-1", "安全默认半状态恢复测试") + .expect("project init"); + let parent_agent_id = GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID; + let parent_run_id = "safe-default-half-state-parent-run"; + bind_supervisor_collaboration_policy_snapshot_at( + &root, + parent_agent_id, + parent_run_id, + &SupervisorCollaborationPolicy::default(), + "legacy-current-project-policy", + ) + .expect("bind collaboration snapshot"); + + for (suffix, convert_delivery_first) in [("delivery", true), ("claim", false)] { + let action_id = format!("safe-default-half-state-action-{suffix}"); + let delegation_id = format!("safe-default-half-state-delegation-{suffix}"); + let mut delivery = new_static_delegate_delivery_with_contract( + parent_agent_id, + "safe-default-half-state-session", + parent_run_id, + &format!("safe-default-half-state-parent-action-{suffix}"), + &delegation_id, + "code-prototype", + &format!("safe-default-half-state-child-session-{suffix}"), + &format!("safe-default-half-state-child-run-{suffix}"), + &["采用安全默认值继续".to_string()], + &[], + None, + ); + create_or_read_static_delegate_delivery_at(&root, &delivery).expect("create delivery"); + let original = build_static_delegate_structured_result_at( + &root, + "completed", + &[], + false, + None, + None, + None, + Some(&format!( + "{STATIC_DELEGATE_USER_INPUT_PREFIX}{}", + serde_json::json!({ + "questions": [{ + "id": "visual_style", + "header": "风格", + "question": "请选择视觉风格", + "options": [ + {"label": "明亮", "description": "使用明亮配色"}, + {"label": "柔和", "description": "使用柔和配色"} + ] + }] + }) + )), + ) + .expect("build needs-user-input result"); + mark_static_delegate_delivery_ready_with_result_at( + &root, + &delivery.target_agent_id, + &delivery.target_session_id, + &delivery.target_run_id, + &delivery.delegation_id, + "completed", + "需要用户选择", + original.clone(), + ) + .expect("mark delivery ready"); + claim_ready_static_delegate_receipts_at( + &root, + parent_agent_id, + parent_run_id, + &action_id, + ) + .expect("claim delivery"); + delivery = read_static_delegate_delivery_at(&root, &delegation_id) + .expect("read delivery") + .expect("delivery exists"); + let (summary, converted) = game_chat_safe_default_repair_replacement(&original) + .expect("convert safe default result"); + if convert_delivery_first { + delivery.result_summary = Some(summary.clone()); + delivery.structured_result = Some(converted.clone()); + delivery.updated_at = unix_timestamp(); + write_static_delegate_delivery_at(&root, &delivery).expect("write delivery half"); + } else { + let mut claim = read_static_delegate_claim_at( + &root, + parent_agent_id, + parent_run_id, + &action_id, + ) + .expect("read claim") + .expect("claim exists"); + let receipt = claim + .receipts + .iter_mut() + .find(|receipt| receipt.delegation_id == delegation_id) + .expect("claim receipt"); + receipt.summary = summary.clone(); + receipt.structured_result = Some(converted.clone()); + claim.updated_at = unix_timestamp(); + write_static_delegate_claim_at(&root, &claim).expect("write claim half"); + } + + let reconciled = replace_claimed_static_delegate_result_for_game_chat_safe_default_at( + &root, &delivery, + ) + .expect("reconcile half state"); + assert_eq!(reconciled.result_summary.as_deref(), Some(summary.as_str())); + assert_eq!(reconciled.structured_result.as_ref(), Some(&converted)); + let claim = + read_static_delegate_claim_at(&root, parent_agent_id, parent_run_id, &action_id) + .expect("read reconciled claim") + .expect("reconciled claim exists"); + let receipt = claim + .receipts + .iter() + .find(|receipt| receipt.delegation_id == delegation_id) + .expect("reconciled receipt"); + assert_eq!(receipt.summary, summary); + assert_eq!(receipt.structured_result.as_ref(), Some(&converted)); + } + + fs::remove_dir_all(root).ok(); + } + #[test] fn stale_prepared_claim_snapshot_cannot_downgrade_observed_claim() { let root = std::env::temp_dir().join(format!( diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner.rs b/apps/ai-game-creator-shell/src-tauri/src/runner.rs index 202a774b8..f6e1099b2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner.rs @@ -33,5 +33,39 @@ pub(crate) use server::{ bind_loopback_listener_with_linux_fallback, run_external_agent_runner_server, }; +#[cfg(test)] +pub(crate) fn simulate_external_agent_runner_cross_boot_owner_claim_for_test( + root: &std::path::Path, + config_dir: &std::path::Path, + previous_boot_id: &str, + current_boot_id: &str, +) -> Result<(std::path::PathBuf, Option), String> { + let previous = project_owner::acquire_external_agent_runner_project_execution_owner( + root, + previous_boot_id, + protocol::EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION, + )?; + drop(previous); + let state = state::ExternalAgentRunnerServerState::new( + config_dir.join(protocol::EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME), + protocol::ExternalAgentRunnerEndpoint { + protocol_version: protocol::EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION, + pid: std::process::id(), + boot_id: current_boot_id.to_string(), + port: 41009, + token: "owner-provider-token-owner-provider-token".to_string(), + heartbeat_at: 1_725_000_000_000, + executable_fingerprint: Some("a".repeat(64)), + process_start_identity: None, + }, + ); + let claim = state.claim_project_execution_owner(root)?; + let result = (claim.root, claim.recovered_from_boot_id); + let second = state.claim_project_execution_owner(root)?; + if second.recovered_from_boot_id.is_some() { + return Err("同 boot execution owner 重入重复触发恢复".to_string()); + } + Ok(result) +} #[cfg(test)] mod tests; diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/dispatch.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/dispatch.rs index 7657aeb2b..bbf6a918d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/dispatch.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/dispatch.rs @@ -368,10 +368,12 @@ pub(super) fn dispatch_external_agent_runner_wake_pending_request( pub(super) fn external_agent_runner_response_is_cacheable( response: &ExternalAgentRunnerResponse, ) -> bool { - response - .error - .as_ref() - .is_none_or(|error| error.code != EXTERNAL_AGENT_RUNNER_RETRYABLE_WAKE_ERROR_CODE) + response.error.as_ref().is_none_or(|error| { + !matches!( + error.code.as_str(), + EXTERNAL_AGENT_RUNNER_RETRYABLE_WAKE_ERROR_CODE | "project-execution-owned" + ) + }) } pub(super) fn cache_external_agent_runner_response_if_cacheable( @@ -392,8 +394,77 @@ pub(super) fn cache_external_agent_runner_response_if_cacheable( pub(super) fn dispatch_external_agent_runner_runtime_request( request: &ExternalAgentRunnerRequest, state: &ExternalAgentRunnerServerState, +) -> ExternalAgentRunnerResponse { + dispatch_external_agent_runner_runtime_request_with_owner_claim(request, state, |root| { + state.claim_project_execution_owner(root) + }) +} + +pub(super) fn dispatch_external_agent_runner_runtime_request_with_owner_claim( + request: &ExternalAgentRunnerRequest, + state: &ExternalAgentRunnerServerState, + claim_owner: impl Fn(&Path) -> Result, ) -> ExternalAgentRunnerResponse { let fingerprint = external_agent_runner_request_fingerprint(request); + { + let cache = lock_unpoisoned(&state.write_request_cache); + if let Some(cached) = cache.find(&request.request_id) { + if cached.fingerprint == fingerprint { + return cached.response.clone(); + } + return ExternalAgentRunnerResponse::failure( + &request.request_id, + "request-id-conflict", + "同一 requestId 不能用于不同请求", + ); + } + } + + let requires_project_execution_owner = matches!( + request.method.as_str(), + "runtime.wake_pending" + | "runtime.resume" + | "runtime.continue_action" + | "runtime.steer" + | "runtime.interrupt_for_steer_decision" + | "runtime.pause" + | "runtime.cancel" + | "runtime.compact" + ); + if requires_project_execution_owner && state.draining.load(Ordering::Acquire) { + return ExternalAgentRunnerResponse::failure( + &request.request_id, + "runner-draining", + "Agent Runner 正在排空并准备退出,拒绝新的写请求", + ); + } + + let token = state.endpoint_snapshot().token; + let claimed_project_root = if requires_project_execution_owner { + let root = match external_agent_runner_request_root(request) { + Ok(root) => root, + Err(error) => { + return ExternalAgentRunnerResponse::failure( + &request.request_id, + "invalid-params", + error, + ); + } + }; + match claim_owner(&root) { + Ok(claim) => Some(claim.root), + Err(error) => { + return ExternalAgentRunnerResponse::failure( + &request.request_id, + "project-execution-owned", + error, + ); + } + } + } else { + None + }; + let mut cache = lock_unpoisoned(&state.write_request_cache); if let Some(cached) = cache.find(&request.request_id) { if cached.fingerprint == fingerprint { @@ -406,26 +477,6 @@ pub(super) fn dispatch_external_agent_runner_runtime_request( ); } - if matches!( - request.method.as_str(), - "runtime.wake_pending" - | "runtime.resume" - | "runtime.continue_action" - | "runtime.steer" - | "runtime.interrupt_for_steer_decision" - | "runtime.pause" - | "runtime.cancel" - | "runtime.compact" - ) && state.draining.load(Ordering::Acquire) - { - return ExternalAgentRunnerResponse::failure( - &request.request_id, - "runner-draining", - "Agent Runner 正在排空并准备退出,拒绝新的写请求", - ); - } - - let token = state.endpoint_snapshot().token; let response = match request.method.as_str() { "runtime.wake_pending" | "runtime.resume" @@ -435,26 +486,8 @@ pub(super) fn dispatch_external_agent_runner_runtime_request( | "runtime.pause" | "runtime.cancel" | "runtime.compact" => { - let root = match external_agent_runner_request_root(request) { - Ok(root) => root, - Err(error) => { - return ExternalAgentRunnerResponse::failure( - &request.request_id, - "invalid-params", - error, - ); - } - }; - let root = match state.claim_project_execution_owner(&root) { - Ok(root) => root, - Err(error) => { - return ExternalAgentRunnerResponse::failure( - &request.request_id, - "project-execution-owned", - error, - ); - } - }; + let root = claimed_project_root + .expect("runtime write request must claim its project execution owner"); if request.method == "runtime.wake_pending" { dispatch_external_agent_runner_wake_pending_request(request, &root, &token) } else { diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/state.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/state.rs index a59bc1af2..74b0a5402 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/state.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/state.rs @@ -1,9 +1,10 @@ use super::{endpoint::*, project_owner::*, protocol::*}; use std::collections::{BTreeMap, BTreeSet}; use std::fs::{self, File}; +use std::panic::{catch_unwind, AssertUnwindSafe}; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; -use std::sync::{Mutex, MutexGuard}; +use std::sync::{Condvar, Mutex, MutexGuard}; pub(super) struct ExternalAgentRunnerServerState { pub(super) endpoint_path: PathBuf, @@ -16,10 +17,66 @@ pub(super) struct ExternalAgentRunnerServerState { pub(super) known_roots: Mutex>, pub(super) gui_owner_lock_path: PathBuf, pub(super) project_execution_owners: - Mutex>, + Mutex>, + project_execution_owner_recovery_changed: Condvar, pub(super) write_request_cache: Mutex, } +#[derive(Clone, Debug, Eq, PartialEq)] +pub(super) struct ExternalAgentRunnerProjectExecutionOwnerClaim { + pub(super) root: PathBuf, + pub(super) recovered_from_boot_id: Option, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum ExternalAgentRunnerProjectExecutionOwnerRecoveryStatus { + Pending, + Running, + Completed, +} + +pub(super) struct ExternalAgentRunnerProjectExecutionOwnerEntry { + _owner: ExternalAgentRunnerProjectExecutionOwner, + recovered_from_boot_id: Option, + recovery_status: ExternalAgentRunnerProjectExecutionOwnerRecoveryStatus, +} + +struct ExternalAgentRunnerProjectExecutionOwnerRecoveryGuard<'a> { + state: &'a ExternalAgentRunnerServerState, + root: PathBuf, + running: bool, +} + +impl ExternalAgentRunnerProjectExecutionOwnerRecoveryGuard<'_> { + fn complete(mut self) { + self.update_status(ExternalAgentRunnerProjectExecutionOwnerRecoveryStatus::Completed); + self.running = false; + } + + fn update_status(&self, status: ExternalAgentRunnerProjectExecutionOwnerRecoveryStatus) { + let mut owners = lock_unpoisoned(&self.state.project_execution_owners); + if let Some(entry) = owners.get_mut(&self.root) { + if entry.recovery_status + == ExternalAgentRunnerProjectExecutionOwnerRecoveryStatus::Running + { + entry.recovery_status = status; + } + } + drop(owners); + self.state + .project_execution_owner_recovery_changed + .notify_all(); + } +} + +impl Drop for ExternalAgentRunnerProjectExecutionOwnerRecoveryGuard<'_> { + fn drop(&mut self) { + if self.running { + self.update_status(ExternalAgentRunnerProjectExecutionOwnerRecoveryStatus::Pending); + } + } +} + impl ExternalAgentRunnerServerState { pub(super) fn new(endpoint_path: PathBuf, endpoint: ExternalAgentRunnerEndpoint) -> Self { let gui_owner_lock_path = endpoint_path @@ -37,6 +94,7 @@ impl ExternalAgentRunnerServerState { known_roots: Mutex::new(BTreeSet::new()), gui_owner_lock_path, project_execution_owners: Mutex::new(BTreeMap::new()), + project_execution_owner_recovery_changed: Condvar::new(), write_request_cache: Mutex::new(ExternalAgentRunnerRequestCache::default()), } } @@ -57,28 +115,103 @@ impl ExternalAgentRunnerServerState { lock_unpoisoned(&self.known_roots).iter().cloned().collect() } - pub(super) fn claim_project_execution_owner(&self, root: &Path) -> Result { + pub(super) fn claim_project_execution_owner( + &self, + root: &Path, + ) -> Result { + self.claim_project_execution_owner_with_recovery(root, |root| { + crate::resume_game_creator_agent_background_tasks_at(root).map(|_| ()) + }) + } + + pub(super) fn claim_project_execution_owner_with_recovery( + &self, + root: &Path, + recover: impl Fn(&Path) -> Result<(), String>, + ) -> Result { let root = canonicalize_external_agent_runner_project_root(root)?; let config_dir = self .endpoint_path .parent() .ok_or_else(|| "Agent Runner endpoint 缺少 AppData 父目录".to_string())?; crate::validate_game_creator_runtime_config_dir_outside_project(config_dir, &root)?; - - let mut owners = lock_unpoisoned(&self.project_execution_owners); - if owners.contains_key(&root) { - self.remember_root(&root); - return Ok(root); - } let endpoint = self.endpoint_snapshot(); - let owner = acquire_external_agent_runner_project_execution_owner( - &root, - &endpoint.boot_id, - endpoint.protocol_version, - )?; - owners.insert(root.clone(), owner); - self.remember_root(&root); - Ok(root) + + loop { + let mut owners = lock_unpoisoned(&self.project_execution_owners); + if !owners.contains_key(&root) { + let owner = acquire_external_agent_runner_project_execution_owner( + &root, + &endpoint.boot_id, + endpoint.protocol_version, + )?; + let recovered_from_boot_id = owner.recovered_from_boot_id().map(str::to_string); + let recovery_status = if recovered_from_boot_id.is_some() { + ExternalAgentRunnerProjectExecutionOwnerRecoveryStatus::Pending + } else { + ExternalAgentRunnerProjectExecutionOwnerRecoveryStatus::Completed + }; + owners.insert( + root.clone(), + ExternalAgentRunnerProjectExecutionOwnerEntry { + _owner: owner, + recovered_from_boot_id, + recovery_status, + }, + ); + } + + let entry = owners + .get_mut(&root) + .expect("project execution owner entry must exist after acquisition"); + match entry.recovery_status { + ExternalAgentRunnerProjectExecutionOwnerRecoveryStatus::Completed => { + drop(owners); + self.remember_root(&root); + return Ok(ExternalAgentRunnerProjectExecutionOwnerClaim { + root, + recovered_from_boot_id: None, + }); + } + ExternalAgentRunnerProjectExecutionOwnerRecoveryStatus::Running => { + owners = + wait_unpoisoned(&self.project_execution_owner_recovery_changed, owners); + drop(owners); + } + ExternalAgentRunnerProjectExecutionOwnerRecoveryStatus::Pending => { + entry.recovery_status = + ExternalAgentRunnerProjectExecutionOwnerRecoveryStatus::Running; + let recovered_from_boot_id = entry + .recovered_from_boot_id + .clone() + .expect("pending project owner recovery must name the previous boot"); + drop(owners); + self.remember_root(&root); + + let recovery_guard = ExternalAgentRunnerProjectExecutionOwnerRecoveryGuard { + state: self, + root: root.clone(), + running: true, + }; + match catch_unwind(AssertUnwindSafe(|| recover(&root))) { + Ok(Ok(())) => { + recovery_guard.complete(); + return Ok(ExternalAgentRunnerProjectExecutionOwnerClaim { + root, + recovered_from_boot_id: Some(recovered_from_boot_id), + }); + } + Ok(Err(error)) => return Err(error), + Err(_) => { + return Err( + "Agent Runner 项目恢复回调异常终止,可复用同一 requestId 重试" + .to_string(), + ) + } + } + } + } + } } } @@ -122,6 +255,12 @@ pub(super) struct ExternalAgentRunnerProjectExecutionOwner { pub(super) _record: ExternalAgentRunnerProjectExecutionOwnerRecord, } +impl ExternalAgentRunnerProjectExecutionOwner { + pub(super) fn recovered_from_boot_id(&self) -> Option<&str> { + self._record.recovered_from_boot_id.as_deref() + } +} + pub(super) struct ExternalAgentRunnerEndpointGuard { pub(super) path: PathBuf, pub(super) boot_id: String, @@ -160,3 +299,9 @@ pub(super) fn lock_unpoisoned(mutex: &Mutex) -> MutexGuard<'_, T> { .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()) } + +fn wait_unpoisoned<'a, T>(condvar: &Condvar, guard: MutexGuard<'a, T>) -> MutexGuard<'a, T> { + condvar + .wait(guard) + .unwrap_or_else(|poisoned| poisoned.into_inner()) +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs index c09ee7e49..719895e28 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs @@ -9,10 +9,15 @@ use std::fs; use std::io::{self, Cursor}; use std::net::{Ipv4Addr, SocketAddrV4, TcpListener}; use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::Mutex; +use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; +use std::sync::{mpsc, Arc, Barrier, Mutex}; use std::time::{Duration, Instant}; +use crate::{ + default_agent_runtime_run_profile, unix_timestamp, AgentRuntimeTaskRecord, + ProcessSessionRecord, AGENT_RUNTIME_SCHEMA_VERSION, +}; + static TEST_DIRECTORY_COUNTER: AtomicU64 = AtomicU64::new(0); struct TestDirectoryGuard(PathBuf); @@ -2590,9 +2595,10 @@ fn project_execution_owner_is_unique_across_appdata_and_records_recovery() { ), ); - state_a + let first_claim = state_a .claim_project_execution_owner(&root) .expect("first appdata owns project"); + assert!(first_claim.recovered_from_boot_id.is_none()); let conflict = state_b .claim_project_execution_owner(&root) .expect_err("second appdata must not own the same project"); @@ -2602,7 +2608,11 @@ fn project_execution_owner_is_unique_across_appdata_and_records_recovery() { let mut recovered = false; for attempt in 0..100 { match state_b.claim_project_execution_owner(&root) { - Ok(_) => { + Ok(claim) => { + assert_eq!( + claim.recovered_from_boot_id.as_deref(), + Some("owner-boot-a") + ); recovered = true; break; } @@ -2636,6 +2646,567 @@ fn project_execution_owner_is_unique_across_appdata_and_records_recovery() { .expect("project owner diagnostic must match TokenUser SID"); } +#[test] +fn project_execution_owner_cross_boot_claim_recovers_durable_runtime_once() { + let directory = unique_test_directory(); + let root = directory.0.join("project"); + crate::init_local_game_project_at( + &root, + "project-owner-auto-recovery", + "Runner owner 自动恢复测试", + ) + .expect("initialize owner auto recovery project"); + let appdata = crate::prepare_game_creator_runtime_config_dir(&directory.0.join("appdata")) + .expect("prepare owner auto recovery appdata"); + let previous = acquire_external_agent_runner_project_execution_owner( + &root, + "owner-auto-recovery-boot-a", + EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION, + ) + .expect("acquire previous owner"); + drop(previous); + + let state = ExternalAgentRunnerServerState::new( + appdata.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME), + test_endpoint( + "owner-auto-recovery-token-owner-auto-recovery-token", + "owner-auto-recovery-boot-b", + 41003, + ), + ); + let task = AgentRuntimeTaskRecord { + goal_id: None, + goal_revision: 0, + goal_status: None, + schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(), + agent_id: "design-director".to_string(), + task_id: "design-director".to_string(), + session_id: "agent-session-design-director".to_string(), + run_id: "owner-auto-recovery-run".to_string(), + source: "agent-background-task".to_string(), + run_profile: default_agent_runtime_run_profile(), + run_profile_binding_fingerprint: String::new(), + parent_agent_id: None, + parent_run_id: None, + delegation_id: None, + task: "跨 boot 恢复 durable task".to_string(), + status: "running".to_string(), + phase: "planning".to_string(), + current_action: "上一 boot 正在规划".to_string(), + terminal_detail: None, + error: None, + updated_at: unix_timestamp().saturating_sub(60), + }; + let task_path = root.join(".agent/runtime/tasks/design-director.jsonl"); + fs::create_dir_all(task_path.parent().expect("task parent")).expect("runtime task dir"); + fs::write( + &task_path, + format!( + "{}\n", + serde_json::to_string(&task).expect("serialize durable task") + ), + ) + .expect("write durable task"); + let mut runtime = crate::agent_runtime_state_from_task_record(&task); + crate::write_game_creator_agent_runtime_state(&root, &runtime) + .expect("write stale runtime state"); + let project_id = crate::game_creator_agent_runtime_context_project_id(&root) + .expect("read runtime project id"); + let process_id = "proc-0123456789abcdef0123456789abcdef"; + let now = unix_timestamp(); + let process_record = ProcessSessionRecord { + schema_version: "3".to_string(), + project_id, + agent_id: task.agent_id.clone(), + task_id: task.task_id.clone(), + conversation_session_id: task.session_id.clone(), + run_id: task.run_id.clone(), + start_action_id: "action-0123456789abcdef01234567".to_string(), + start_action_fingerprint: "a".repeat(64), + process_id: process_id.to_string(), + owner_boot_id: "owner-auto-recovery-boot-a".to_string(), + command_id: "command-owner-auto-recovery".to_string(), + program: "node".to_string(), + cwd: ".".to_string(), + sandbox_backend: "test-unknown".to_string(), + sandbox_mode: "unknown".to_string(), + network_access: "unknown".to_string(), + sandbox_profile_version: "test-v1".to_string(), + sandbox_establishment: "established".to_string(), + target_exec: "established".to_string(), + launch_failure_kind: None, + sandbox_ready_at: Some(now), + exec_established_at: Some(now), + status: "running".to_string(), + exit_code: None, + signal: None, + stdin_open: true, + output_bytes: 0, + output_sha256: format!("{:x}", Sha256::digest([])), + output_ref: None, + source_fingerprint_before: "b".repeat(64), + source_fingerprint_after: None, + source_changed: None, + needs_reconciliation: false, + started_at: now, + terminal_at: None, + updated_at: now, + }; + let process_path = root.join(format!(".agent/runtime/process-sessions/{process_id}.json")); + fs::create_dir_all(process_path.parent().expect("process session parent")) + .expect("process session directory"); + fs::write( + &process_path, + serde_json::to_vec_pretty(&process_record).expect("serialize process session"), + ) + .expect("write previous boot process session"); + + let first = state + .claim_project_execution_owner(&root) + .expect("cross boot claim automatically scans recovery"); + assert_eq!( + first.recovered_from_boot_id.as_deref(), + Some("owner-auto-recovery-boot-a") + ); + runtime = crate::read_game_creator_agent_runtime_at(&root, "design-director") + .expect("read reconciled runtime") + .state; + assert_eq!(runtime.run_id, "owner-auto-recovery-run"); + assert_eq!(runtime.phase, "needs-reconciliation"); + assert!(runtime + .error + .as_deref() + .is_some_and(|error| error.contains("禁止按 PID 重连或重放启动动作"))); + let process = serde_json::from_slice::( + &fs::read(&process_path).expect("read reconciled process session"), + ) + .expect("parse reconciled process session"); + assert_eq!(process.status, "needs-reconciliation"); + assert!(process.needs_reconciliation); + let records = fs::read_to_string(root.join(".agent/agent.db")).expect("read recovery audit"); + assert_eq!( + records + .matches("agent.runtime.process_session.reconciled_after_runner_restart") + .count(), + 1 + ); + assert!(!records.contains("agent.runtime.tool_action.executing")); + assert!(!records.contains("agent.runtime.agent.delegate")); + let task_records_before = fs::read_to_string(&task_path).expect("read reconciled task journal"); + let task_record_count_before = task_records_before + .lines() + .filter(|line| !line.trim().is_empty()) + .count(); + let conversation_directory = root.join(".agent/conversations"); + let conversation_snapshot_before = if conversation_directory.exists() { + let mut files = fs::read_dir(&conversation_directory) + .expect("read conversation directory") + .filter_map(Result::ok) + .map(|entry| entry.path()) + .filter(|path| path.is_file()) + .map(|path| { + ( + path.clone(), + fs::read(path).expect("read conversation file"), + ) + }) + .collect::>(); + files.sort_by(|left, right| left.0.cmp(&right.0)); + files + } else { + Vec::new() + }; + + let second = state + .claim_project_execution_owner(&root) + .expect("same boot claim is idempotent"); + assert!(second.recovered_from_boot_id.is_none()); + let records_after = + fs::read_to_string(root.join(".agent/agent.db")).expect("read idempotent recovery audit"); + assert_eq!(records_after, records); + let task_records_after = + fs::read_to_string(&task_path).expect("reread reconciled task journal"); + assert_eq!( + task_records_after + .lines() + .filter(|line| !line.trim().is_empty()) + .count(), + task_record_count_before, + "same-boot reentry must not duplicate durable task/child projection" + ); + let conversation_snapshot_after = if conversation_directory.exists() { + let mut files = fs::read_dir(&conversation_directory) + .expect("reread conversation directory") + .filter_map(Result::ok) + .map(|entry| entry.path()) + .filter(|path| path.is_file()) + .map(|path| { + ( + path.clone(), + fs::read(path).expect("reread conversation file"), + ) + }) + .collect::>(); + files.sort_by(|left, right| left.0.cmp(&right.0)); + files + } else { + Vec::new() + }; + assert_eq!(conversation_snapshot_after, conversation_snapshot_before); +} + +#[test] +fn project_execution_owner_recovery_runs_after_owner_map_lock_release() { + let directory = unique_test_directory(); + let root = directory.0.join("project"); + crate::init_local_game_project_at( + &root, + "project-owner-lock-order", + "Runner owner 恢复锁顺序测试", + ) + .expect("initialize owner lock order project"); + let previous = acquire_external_agent_runner_project_execution_owner( + &root, + "owner-lock-order-boot-a", + EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION, + ) + .expect("acquire previous owner"); + drop(previous); + let appdata = crate::prepare_game_creator_runtime_config_dir(&directory.0.join("appdata")) + .expect("prepare owner lock order appdata"); + let state = ExternalAgentRunnerServerState::new( + appdata.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME), + test_endpoint( + "owner-lock-order-token-owner-lock-order-token", + "owner-lock-order-boot-b", + 41004, + ), + ); + + let claim = state + .claim_project_execution_owner_with_recovery(&root, |claimed_root| { + let owners = state + .project_execution_owners + .try_lock() + .expect("recovery callback must run after owner-map lock release"); + assert!(owners.contains_key(claimed_root)); + Ok(()) + }) + .expect("claim with lock-order probe"); + assert_eq!( + claim.recovered_from_boot_id.as_deref(), + Some("owner-lock-order-boot-a") + ); +} + +#[test] +fn project_execution_owner_recovery_failure_retries_in_same_boot() { + let directory = unique_test_directory(); + let root = directory.0.join("project"); + crate::init_local_game_project_at( + &root, + "project-owner-recovery-retry", + "Runner owner 恢复重试测试", + ) + .expect("initialize owner recovery retry project"); + let previous = acquire_external_agent_runner_project_execution_owner( + &root, + "owner-recovery-retry-boot-a", + EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION, + ) + .expect("acquire previous owner"); + drop(previous); + let appdata = crate::prepare_game_creator_runtime_config_dir(&directory.0.join("appdata")) + .expect("prepare owner recovery retry appdata"); + let state = ExternalAgentRunnerServerState::new( + appdata.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME), + test_endpoint( + "owner-recovery-retry-token-owner-retry-token", + "owner-recovery-retry-boot-b", + 41005, + ), + ); + let recoveries = AtomicUsize::new(0); + + let first = state + .claim_project_execution_owner_with_recovery(&root, |_| { + recoveries.fetch_add(1, Ordering::SeqCst); + Err("injected recovery failure".to_string()) + }) + .expect_err("first recovery attempt must surface its failure"); + assert!(first.contains("injected recovery failure")); + assert_eq!(recoveries.load(Ordering::SeqCst), 1); + + let second = state + .claim_project_execution_owner_with_recovery(&root, |_| { + recoveries.fetch_add(1, Ordering::SeqCst); + Ok(()) + }) + .expect("same boot must retry failed recovery"); + assert_eq!( + second.recovered_from_boot_id.as_deref(), + Some("owner-recovery-retry-boot-a") + ); + assert_eq!(recoveries.load(Ordering::SeqCst), 2); + + let third = state + .claim_project_execution_owner_with_recovery(&root, |_| { + recoveries.fetch_add(1, Ordering::SeqCst); + Ok(()) + }) + .expect("completed recovery remains idempotent"); + assert!(third.recovered_from_boot_id.is_none()); + assert_eq!(recoveries.load(Ordering::SeqCst), 2); +} + +#[test] +fn project_execution_owner_concurrent_claims_single_flight_recovery() { + let directory = unique_test_directory(); + let root = directory.0.join("project"); + crate::init_local_game_project_at( + &root, + "project-owner-recovery-single-flight", + "Runner owner 单飞恢复测试", + ) + .expect("initialize owner recovery single-flight project"); + let previous = acquire_external_agent_runner_project_execution_owner( + &root, + "owner-recovery-single-flight-boot-a", + EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION, + ) + .expect("acquire previous owner"); + drop(previous); + let appdata = crate::prepare_game_creator_runtime_config_dir(&directory.0.join("appdata")) + .expect("prepare owner recovery single-flight appdata"); + let state = Arc::new(ExternalAgentRunnerServerState::new( + appdata.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME), + test_endpoint( + "owner-recovery-single-flight-token-single-flight", + "owner-recovery-single-flight-boot-b", + 41006, + ), + )); + let recoveries = Arc::new(AtomicUsize::new(0)); + let recovery_entered = Arc::new(Barrier::new(2)); + let (release_recovery_tx, release_recovery_rx) = mpsc::channel::<()>(); + + let first_state = Arc::clone(&state); + let first_root = root.clone(); + let first_recoveries = Arc::clone(&recoveries); + let first_recovery_entered = Arc::clone(&recovery_entered); + let first = std::thread::spawn(move || { + first_state.claim_project_execution_owner_with_recovery(&first_root, |_| { + first_recoveries.fetch_add(1, Ordering::SeqCst); + first_recovery_entered.wait(); + release_recovery_rx + .recv_timeout(Duration::from_secs(5)) + .expect("test must release the active recovery"); + Ok(()) + }) + }); + recovery_entered.wait(); + + let second_state = Arc::clone(&state); + let second_root = root.clone(); + let second_recoveries = Arc::clone(&recoveries); + let (second_started_tx, second_started_rx) = mpsc::channel(); + let second = std::thread::spawn(move || { + second_started_tx.send(()).expect("signal second claim"); + second_state.claim_project_execution_owner_with_recovery(&second_root, |_| { + second_recoveries.fetch_add(1, Ordering::SeqCst); + Ok(()) + }) + }); + second_started_rx + .recv_timeout(Duration::from_secs(5)) + .expect("second claim must start"); + std::thread::sleep(Duration::from_millis(50)); + assert_eq!( + recoveries.load(Ordering::SeqCst), + 1, + "concurrent claim must wait instead of duplicating recovery" + ); + + release_recovery_tx + .send(()) + .expect("release active recovery"); + let first_claim = first + .join() + .expect("first claim thread") + .expect("first claim"); + let second_claim = second + .join() + .expect("second claim thread") + .expect("second claim"); + assert_eq!( + first_claim.recovered_from_boot_id.as_deref(), + Some("owner-recovery-single-flight-boot-a") + ); + assert!(second_claim.recovered_from_boot_id.is_none()); + assert_eq!(recoveries.load(Ordering::SeqCst), 1); +} + +#[test] +fn project_execution_owner_recovery_panic_resets_running_and_wakes_waiter() { + let directory = unique_test_directory(); + let root = directory.0.join("project"); + crate::init_local_game_project_at( + &root, + "project-owner-recovery-panic", + "Runner owner 恢复 panic 测试", + ) + .expect("initialize owner recovery panic project"); + let previous = acquire_external_agent_runner_project_execution_owner( + &root, + "owner-recovery-panic-boot-a", + EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION, + ) + .expect("acquire previous owner"); + drop(previous); + let appdata = crate::prepare_game_creator_runtime_config_dir(&directory.0.join("appdata")) + .expect("prepare owner recovery panic appdata"); + let state = Arc::new(ExternalAgentRunnerServerState::new( + appdata.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME), + test_endpoint( + "owner-recovery-panic-token-owner-panic-token", + "owner-recovery-panic-boot-b", + 41007, + ), + )); + let recoveries = Arc::new(AtomicUsize::new(0)); + let (first_entered_tx, first_entered_rx) = mpsc::channel(); + let (release_first_tx, release_first_rx) = mpsc::channel(); + + let first_state = Arc::clone(&state); + let first_root = root.clone(); + let first_recoveries = Arc::clone(&recoveries); + let first = std::thread::spawn(move || { + first_state.claim_project_execution_owner_with_recovery(&first_root, |_| { + first_recoveries.fetch_add(1, Ordering::SeqCst); + first_entered_tx.send(()).expect("signal panic recovery"); + release_first_rx + .recv_timeout(Duration::from_secs(5)) + .expect("release panic recovery"); + panic!("injected recovery callback panic"); + }) + }); + first_entered_rx + .recv_timeout(Duration::from_secs(5)) + .expect("first recovery must enter"); + + let second_state = Arc::clone(&state); + let second_root = root.clone(); + let second_recoveries = Arc::clone(&recoveries); + let (second_started_tx, second_started_rx) = mpsc::channel(); + let second = std::thread::spawn(move || { + second_started_tx.send(()).expect("signal waiting claim"); + second_state.claim_project_execution_owner_with_recovery(&second_root, |_| { + second_recoveries.fetch_add(1, Ordering::SeqCst); + Ok(()) + }) + }); + second_started_rx + .recv_timeout(Duration::from_secs(5)) + .expect("waiting claim must start"); + std::thread::sleep(Duration::from_millis(50)); + assert_eq!( + recoveries.load(Ordering::SeqCst), + 1, + "waiter must not run while the first recovery is Running" + ); + + release_first_tx + .send(()) + .expect("release the panicking recovery"); + let first_error = first + .join() + .expect("panic must be contained inside owner recovery") + .expect_err("panicking recovery must fail its own claim"); + assert!(first_error.contains("恢复回调异常终止")); + let second_claim = second + .join() + .expect("waiting claim thread must be woken") + .expect("waiting claim must retry recovery"); + assert_eq!( + second_claim.recovered_from_boot_id.as_deref(), + Some("owner-recovery-panic-boot-a") + ); + assert_eq!(recoveries.load(Ordering::SeqCst), 2); +} + +#[test] +fn project_execution_owner_recovery_runs_without_request_cache_and_failure_is_retryable() { + let directory = unique_test_directory(); + let root = directory.0.join("project"); + crate::init_local_game_project_at( + &root, + "project-owner-request-cache", + "Runner owner 请求缓存锁测试", + ) + .expect("initialize owner request cache project"); + let appdata = crate::prepare_game_creator_runtime_config_dir(&directory.0.join("appdata")) + .expect("prepare owner request cache appdata"); + let token = "owner-request-cache-token-owner-request-cache-token"; + let state = ExternalAgentRunnerServerState::new( + appdata.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME), + test_endpoint(token, "owner-request-cache-boot", 41008), + ); + let request = ExternalAgentRunnerRequest { + protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION, + request_id: "owner-request-cache-request".to_string(), + token: token.to_string(), + method: "runtime.resume".to_string(), + params: ExternalAgentRunnerRequestParams { + root: Some(root.to_string_lossy().into_owned()), + ..ExternalAgentRunnerRequestParams::default() + }, + }; + let attempts = AtomicUsize::new(0); + + let first = + dispatch_external_agent_runner_runtime_request_with_owner_claim(&request, &state, |_| { + attempts.fetch_add(1, Ordering::SeqCst); + let cache = state + .write_request_cache + .try_lock() + .expect("owner recovery must not run under the global request-cache mutex"); + assert!(cache.find(&request.request_id).is_none()); + Err("injected owner recovery failure".to_string()) + }); + assert!(!first.ok); + assert_eq!( + first.error.as_ref().map(|error| error.code.as_str()), + Some("project-execution-owned") + ); + assert!(lock_unpoisoned(&state.write_request_cache) + .find(&request.request_id) + .is_none()); + + let second = dispatch_external_agent_runner_runtime_request_with_owner_claim( + &request, + &state, + |requested_root| { + attempts.fetch_add(1, Ordering::SeqCst); + Ok(ExternalAgentRunnerProjectExecutionOwnerClaim { + root: canonicalize_external_agent_runner_project_root(requested_root)?, + recovered_from_boot_id: Some("previous-boot".to_string()), + }) + }, + ); + assert!(second.ok, "same requestId retry failed: {:?}", second.error); + assert_eq!(attempts.load(Ordering::SeqCst), 2); + + let replay = dispatch_external_agent_runner_runtime_request_with_owner_claim( + &request, + &state, + |_| -> Result { + panic!("cached replay must not claim the owner again") + }, + ); + assert_eq!(replay, second); + assert_eq!(attempts.load(Ordering::SeqCst), 2); +} + #[test] fn corrupt_legacy_owner_diagnostic_does_not_block_lock_recovery() { let directory = unique_test_directory(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/swarm_cli.rs b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli.rs index 6e80fdbba..9bbdae1bb 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/swarm_cli.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli.rs @@ -31,6 +31,7 @@ pub(crate) fn run_game_creator_swarm_chat_at( root: &Path, parent_agent_id: &str, run_profile: &str, + supervisor_source: &'static str, ) -> Result<(), String> { let (input_tx, input_rx) = mpsc::channel(); std::thread::spawn(move || { @@ -64,6 +65,7 @@ pub(crate) fn run_game_creator_swarm_chat_at( root, parent_agent_id, run_profile, + supervisor_source, &input_rx, &mut output, ) diff --git a/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/input.rs b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/input.rs index cc4bb3c7a..dd4c51ee4 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/input.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/input.rs @@ -44,9 +44,23 @@ pub(super) enum SwarmNewRunLaunch<'a> { ExplicitParentDebug, } +impl SwarmNewRunLaunch<'_> { + pub(super) fn expected_parent_source(self) -> Option<&'static str> { + match self { + Self::ProjectSupervisor { source, .. } + if source == AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE => + { + Some(source) + } + Self::ProjectSupervisor { .. } | Self::ExplicitParentDebug => None, + } + } +} + pub(super) fn resolve_swarm_new_run_launch<'a>( parent_agent_id: &str, run_profile: &'a str, + supervisor_source: &'static str, ) -> Result, String> { if !matches!( run_profile, @@ -55,11 +69,27 @@ pub(super) fn resolve_swarm_new_run_launch<'a>( return Err(format!("不支持的 Agent Runtime Run Profile:{run_profile}")); } if parent_agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { + if !matches!( + supervisor_source, + AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE | AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE + ) { + return Err(format!( + "不支持的 Project Supervisor source:{supervisor_source}" + )); + } + if supervisor_source == AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE + && run_profile != AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + { + return Err("game-chat smoke source 仅支持 autonomous-game-build".to_string()); + } return Ok(SwarmNewRunLaunch::ProjectSupervisor { - source: AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, + source: supervisor_source, run_profile, }); } + if supervisor_source != AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE { + return Err("game-chat smoke source 仅支持 project-supervisor 总控入口".to_string()); + } if run_profile != AGENT_RUNTIME_RUN_PROFILE_STANDARD { return Err("--autonomous-game-build 仅支持 project-supervisor 总控入口".to_string()); } @@ -70,6 +100,7 @@ pub(super) fn run_game_creator_swarm_chat_with_input( root: &Path, parent_agent_id: &str, run_profile: &str, + supervisor_source: &'static str, input: &Receiver, output: &mut W, ) -> Result<(), String> { @@ -77,7 +108,9 @@ pub(super) fn run_game_creator_swarm_chat_with_input( if parent_agent_id.is_empty() { return Err("parentAgentId 不能为空".to_string()); } - let new_run_launch = resolve_swarm_new_run_launch(parent_agent_id, run_profile)?; + let new_run_launch = + resolve_swarm_new_run_launch(parent_agent_id, run_profile, supervisor_source)?; + let expected_parent_source = new_run_launch.expected_parent_source(); enforce_project_permission_policy(root, "conversation.read")?; enforce_project_permission_policy(root, "conversation.write")?; enforce_project_permission_policy(root, "agent.run_status")?; @@ -103,6 +136,7 @@ pub(super) fn run_game_creator_swarm_chat_with_input( parent_agent_id, active_session_id, run_profile, + expected_parent_source, &existing_runtimes, ) .is_some_and(runtime_is_busy); @@ -152,6 +186,7 @@ pub(super) fn run_game_creator_swarm_chat_with_input( parent_agent_id, &observation.session_id, run_profile, + expected_parent_source, conversation_baseline, input, output, @@ -177,6 +212,7 @@ pub(super) fn run_game_creator_swarm_chat_with_input( parent_agent_id, session_id, run_profile, + expected_parent_source, before.messages.len(), input, output, diff --git a/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/terminal_classification.rs b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/terminal_classification.rs index 155ac9ac3..129d9a255 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/terminal_classification.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/terminal_classification.rs @@ -25,12 +25,14 @@ pub(super) fn swarm_parent_runtime<'a>( parent_agent_id: &str, session_id: &str, run_profile: &str, + expected_source: Option<&str>, runtimes: &'a [AgentRuntimeResult], ) -> Option<&'a AgentRuntimeResult> { runtimes.iter().find(|runtime| { runtime.state.agent_id == parent_agent_id && runtime.state.session_id == session_id && runtime.state.run_profile == run_profile + && expected_source.is_none_or(|source| runtime.state.source == source) }) } @@ -38,21 +40,36 @@ pub(super) fn swarm_parent_runtime_for_run<'a>( parent_agent_id: &str, session_id: &str, run_profile: &str, + expected_source: Option<&str>, expected_run_id: &str, runtimes: &'a [AgentRuntimeResult], ) -> Option<&'a AgentRuntimeResult> { - swarm_parent_runtime(parent_agent_id, session_id, run_profile, runtimes) - .filter(|runtime| runtime.state.run_id == expected_run_id) + swarm_parent_runtime( + parent_agent_id, + session_id, + run_profile, + expected_source, + runtimes, + ) + .filter(|runtime| runtime.state.run_id == expected_run_id) } pub(super) fn swarm_parent_steer_target<'a>( parent_agent_id: &str, session_id: &str, run_profile: &str, + expected_source: Option<&str>, expected_run_id: Option<&str>, runtimes: &'a [AgentRuntimeResult], ) -> Option<&'a AgentRuntimeResult> { - swarm_parent_runtime(parent_agent_id, session_id, run_profile, runtimes).filter(|runtime| { + swarm_parent_runtime( + parent_agent_id, + session_id, + run_profile, + expected_source, + runtimes, + ) + .filter(|runtime| { expected_run_id.is_none_or(|run_id| runtime.state.run_id == run_id) && game_creator_agent_runtime_accepts_steer(&runtime.state) }) @@ -62,6 +79,7 @@ pub(super) fn matching_pending_swarm_run_id<'a>( runtime: &'a AgentRuntimeResult, session_id: &str, run_profile: &str, + expected_source: Option<&str>, message: &str, ) -> Option<&'a str> { let message = message.trim(); @@ -72,6 +90,7 @@ pub(super) fn matching_pending_swarm_run_id<'a>( .find(|task| { task.session_id == session_id && task.run_profile == run_profile + && expected_source.is_none_or(|source| task.source == source) && task.status == "pending" && task.phase == "queued" && task.task.trim() == message @@ -83,10 +102,12 @@ pub(super) fn next_pending_swarm_task<'a>( runtime: &'a AgentRuntimeResult, session_id: &str, run_profile: &str, + expected_source: Option<&str>, ) -> Option<&'a AgentRuntimeTaskRecord> { runtime.recent_tasks.iter().find(|task| { task.session_id == session_id && task.run_profile == run_profile + && expected_source.is_none_or(|source| task.source == source) && task.status == "pending" && task.phase == "queued" }) @@ -96,14 +117,23 @@ pub(super) fn swarm_parent_task_for_run<'a>( parent_agent_id: &str, session_id: &str, run_profile: &str, + expected_source: Option<&str>, expected_run_id: &str, runtimes: &'a [AgentRuntimeResult], ) -> Option<&'a AgentRuntimeTaskRecord> { - swarm_parent_runtime(parent_agent_id, session_id, run_profile, runtimes).and_then(|runtime| { + swarm_parent_runtime( + parent_agent_id, + session_id, + run_profile, + expected_source, + runtimes, + ) + .and_then(|runtime| { runtime.recent_tasks.iter().find(|task| { task.agent_id == parent_agent_id && task.session_id == session_id && task.run_profile == run_profile + && expected_source.is_none_or(|source| task.source == source) && task.run_id == expected_run_id }) }) @@ -147,6 +177,7 @@ pub(super) fn swarm_parent_runtime_snapshot_for_run( parent_agent_id: &str, session_id: &str, run_profile: &str, + expected_source: Option<&str>, expected_run_id: &str, runtimes: &[AgentRuntimeResult], ) -> Result, String> { @@ -154,6 +185,7 @@ pub(super) fn swarm_parent_runtime_snapshot_for_run( parent_agent_id, session_id, run_profile, + expected_source, expected_run_id, runtimes, ) { @@ -167,6 +199,7 @@ pub(super) fn swarm_parent_runtime_snapshot_for_run( if task.agent_id != parent_agent_id || task.session_id != session_id || task.run_profile != run_profile + || expected_source.is_some_and(|source| task.source != source) || task.run_id != expected_run_id { return Err("Swarm parent task journal 与目标 Session/run 不匹配".to_string()); @@ -177,6 +210,7 @@ pub(super) fn swarm_parent_runtime_snapshot_for_run( parent_agent_id, session_id, run_profile, + expected_source, expected_run_id, runtimes, ) @@ -188,6 +222,7 @@ pub(super) fn swarm_turn_is_busy( parent_agent_id: &str, session_id: &str, run_profile: &str, + expected_source: Option<&str>, expected_run_id: &str, runtimes: &[AgentRuntimeResult], ) -> Result { @@ -196,6 +231,7 @@ pub(super) fn swarm_turn_is_busy( parent_agent_id, session_id, run_profile, + expected_source, expected_run_id, runtimes, )? @@ -216,6 +252,7 @@ pub(super) fn swarm_current_runtimes_for_run( parent_agent_id: &str, session_id: &str, run_profile: &str, + expected_source: Option<&str>, expected_run_id: &str, runtimes: &[AgentRuntimeResult], ) -> Vec { @@ -225,6 +262,7 @@ pub(super) fn swarm_current_runtimes_for_run( (runtime.state.agent_id == parent_agent_id && runtime.state.session_id == session_id && runtime.state.run_profile == run_profile + && expected_source.is_none_or(|source| runtime.state.source == source) && runtime.state.run_id == expected_run_id) || (runtime.state.parent_agent_id.as_deref() == Some(parent_agent_id) && runtime.state.parent_run_id.as_deref() == Some(expected_run_id)) @@ -336,6 +374,7 @@ pub(super) fn scan_swarm_terminal_failures_at( parent_agent_id: &str, session_id: &str, run_profile: &str, + expected_source: Option<&str>, expected_parent_run_id: &str, runtimes: &[AgentRuntimeResult], ) -> SwarmTerminalFailureScan { @@ -344,6 +383,7 @@ pub(super) fn scan_swarm_terminal_failures_at( parent_agent_id, session_id, run_profile, + expected_source, expected_parent_run_id, runtimes, ); @@ -356,6 +396,7 @@ pub(super) fn scan_swarm_terminal_failures_at( if task.agent_id == parent_agent_id && task.session_id == session_id && task.run_profile == run_profile + && expected_source.is_none_or(|source| task.source == source) && task.run_id == expected_parent_run_id => { Some(task) @@ -375,6 +416,7 @@ pub(super) fn scan_swarm_terminal_failures_at( parent_agent_id, session_id, run_profile, + expected_source, expected_parent_run_id, runtimes, ) diff --git a/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/tests.rs index 05e6e3049..ba88f0e55 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/tests.rs @@ -64,8 +64,12 @@ fn new_supervisor_runs_fix_cli_source_and_select_requested_profile() { AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD, ] { assert_eq!( - resolve_swarm_new_run_launch(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, run_profile,) - .expect("resolve supervisor launch"), + resolve_swarm_new_run_launch( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_profile, + AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, + ) + .expect("resolve supervisor launch"), SwarmNewRunLaunch::ProjectSupervisor { source: AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, run_profile, @@ -77,20 +81,61 @@ fn new_supervisor_runs_fix_cli_source_and_select_requested_profile() { #[test] fn explicit_parent_debug_keeps_standard_profile_only() { assert_eq!( - resolve_swarm_new_run_launch("code-prototype", AGENT_RUNTIME_RUN_PROFILE_STANDARD,) - .expect("resolve explicit parent debug launch"), + resolve_swarm_new_run_launch( + "code-prototype", + AGENT_RUNTIME_RUN_PROFILE_STANDARD, + AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, + ) + .expect("resolve explicit parent debug launch"), SwarmNewRunLaunch::ExplicitParentDebug, ); assert!(resolve_swarm_new_run_launch( "code-prototype", AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD, + AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, ) .expect_err("autonomous profile must stay on the supervisor root run") .contains(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID)); - assert!( - resolve_swarm_new_run_launch(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, "unsupported",) - .is_err() + assert!(resolve_swarm_new_run_launch( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "unsupported", + AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, + ) + .is_err()); +} + +#[test] +fn restricted_game_chat_smoke_launch_selects_trusted_single_main_source() { + assert_eq!( + resolve_swarm_new_run_launch( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD, + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + ) + .expect("resolve restricted game-chat smoke launch"), + SwarmNewRunLaunch::ProjectSupervisor { + source: AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + run_profile: AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD, + } ); + assert!(resolve_swarm_new_run_launch( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + AGENT_RUNTIME_RUN_PROFILE_STANDARD, + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + ) + .is_err()); + assert!(resolve_swarm_new_run_launch( + "code-prototype", + AGENT_RUNTIME_RUN_PROFILE_STANDARD, + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + ) + .is_err()); + assert!(resolve_swarm_new_run_launch( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD, + "untrusted-supervisor-source", + ) + .is_err()); } #[test] @@ -270,6 +315,7 @@ fn parent_runtime_matching_is_scoped_to_requested_profile() { GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, "session-profile-match", AGENT_RUNTIME_RUN_PROFILE_STANDARD, + None, &runtimes, ) .map(|runtime| runtime.state.run_id.as_str()), @@ -280,6 +326,7 @@ fn parent_runtime_matching_is_scoped_to_requested_profile() { GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, "session-profile-match", AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD, + None, &runtimes, ) .map(|runtime| runtime.state.run_id.as_str()), @@ -287,6 +334,72 @@ fn parent_runtime_matching_is_scoped_to_requested_profile() { ); } +#[test] +fn game_chat_smoke_does_not_steer_a_cli_source_runtime() { + let session_id = "session-source-isolation"; + let run_profile = AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD; + let mut old_cli = runtime("running", "planning", 0); + old_cli.state.agent_id = GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(); + old_cli.state.session_id = session_id.to_string(); + old_cli.state.run_id = "run-old-cli".to_string(); + old_cli.state.run_profile = run_profile.to_string(); + old_cli.state.source = AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE.to_string(); + old_cli.recent_tasks.push( + serde_json::from_value(serde_json::json!({ + "agentId": GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "taskId": GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "sessionId": session_id, + "runId": "run-old-cli-pending", + "source": AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, + "runProfile": run_profile, + "task": "生成完整可玩游戏", + "status": "pending", + "phase": "queued" + })) + .expect("deserialize old CLI pending task"), + ); + let runtimes = vec![old_cli]; + + assert!(swarm_parent_steer_target( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + session_id, + run_profile, + Some(AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE), + None, + &runtimes, + ) + .is_none()); + assert!(swarm_parent_runtime( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + session_id, + run_profile, + Some(AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE), + &runtimes, + ) + .is_none()); + assert!(matching_pending_swarm_run_id( + &runtimes[0], + session_id, + run_profile, + Some(AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE), + "生成完整可玩游戏", + ) + .is_none()); + assert_eq!( + swarm_parent_steer_target( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + session_id, + run_profile, + None, + None, + &runtimes, + ) + .map(|runtime| runtime.state.run_id.as_str()), + Some("run-old-cli"), + "ordinary CLI lookup must preserve its existing source-agnostic behavior" + ); +} + #[test] fn completed_parent_with_pending_task_is_busy_but_not_a_steer_target() { let session_id = "session-pending-after-completed"; @@ -319,13 +432,20 @@ fn completed_parent_with_pending_task_is_busy_but_not_a_steer_target() { session_id, run_profile, None, + None, &runtimes, ) .is_none(), "queued work must not make the completed canonical run steerable" ); assert_eq!( - matching_pending_swarm_run_id(&runtimes[0], session_id, run_profile, "继续补齐游戏功能",), + matching_pending_swarm_run_id( + &runtimes[0], + session_id, + run_profile, + None, + "继续补齐游戏功能", + ), Some("run-pending-after-completed") ); } @@ -465,6 +585,7 @@ fn completed_target_turn_settles_after_canonical_advances_to_next_run() { GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, session_id, run_profile, + None, "run-target-completed", &runtimes, ) @@ -474,6 +595,7 @@ fn completed_target_turn_settles_after_canonical_advances_to_next_run() { GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, session_id, run_profile, + None, "run-next-running", &runtimes, ) @@ -483,6 +605,7 @@ fn completed_target_turn_settles_after_canonical_advances_to_next_run() { GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, session_id, run_profile, + None, "run-target-completed", &runtimes, ) @@ -509,6 +632,7 @@ fn running_parent_remains_the_only_valid_swarm_steer_target() { GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, session_id, run_profile, + None, Some("run-running-steer"), &runtimes, ) @@ -519,6 +643,7 @@ fn running_parent_remains_the_only_valid_swarm_steer_target() { GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, session_id, run_profile, + None, Some("another-run"), &runtimes, ) @@ -807,6 +932,7 @@ fn active_turn_eof_keeps_observing_until_parent_completes() { parent_agent_id, &session_id, AGENT_RUNTIME_RUN_PROFILE_STANDARD, + None, conversation_baseline, &rx, &mut output, @@ -1083,6 +1209,7 @@ fn wait_for_turn_keeps_consecutive_run_reply_and_report_scoped() { parent_agent_id, &session_id, AGENT_RUNTIME_RUN_PROFILE_STANDARD, + None, baseline, &rx, &mut output, @@ -1338,6 +1465,7 @@ fn observer_failure_scan_waits_for_original_repair_and_fails_repair_child() { &parent.state.agent_id, &parent.state.session_id, AGENT_RUNTIME_RUN_PROFILE_STANDARD, + None, &parent.state.run_id, &[parent.clone(), child.clone()], ); @@ -1366,6 +1494,7 @@ fn observer_failure_scan_waits_for_original_repair_and_fails_repair_child() { &parent.state.agent_id, &parent.state.session_id, AGENT_RUNTIME_RUN_PROFILE_STANDARD, + None, &parent.state.run_id, &[parent.clone(), child], ); @@ -1528,6 +1657,7 @@ fn failure_scan_reads_all_historical_runs_for_the_same_specialist() { &parent.state.agent_id, &parent.state.session_id, AGENT_RUNTIME_RUN_PROFILE_STANDARD, + None, &parent.state.run_id, &[parent.clone(), current_specialist, current_quality], ); @@ -1575,6 +1705,7 @@ fn failure_scan_ignores_cancelled_parent_and_children_from_another_run() { &old_parent.state.agent_id, session_id, run_profile, + None, "run-new-pending", &runtimes, ); @@ -1587,6 +1718,7 @@ fn failure_scan_ignores_cancelled_parent_and_children_from_another_run() { &old_parent.state.agent_id, session_id, run_profile, + None, &old_parent.state.run_id, &runtimes, ); diff --git a/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/turn_dispatch.rs b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/turn_dispatch.rs index 531b0b4c6..7d98a5cd6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/turn_dispatch.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/turn_dispatch.rs @@ -10,6 +10,7 @@ pub(super) fn handle_swarm_user_turn( input: &Receiver, output: &mut W, ) -> Result { + let expected_parent_source = new_run_launch.expected_parent_source(); let before = read_local_conversation_for_session_at(root, Some(parent_agent_id), Some(session_id))?; let active_goal_run_id = @@ -49,6 +50,7 @@ pub(super) fn handle_swarm_user_turn( parent_agent_id, session_id, run_profile, + expected_parent_source, active_goal_run_id.as_deref(), &runtimes, ) { @@ -57,6 +59,7 @@ pub(super) fn handle_swarm_user_turn( parent_agent_id, session_id, run_profile, + expected_parent_source, &runtime.state.run_id, message, before.messages.len(), @@ -75,15 +78,34 @@ pub(super) fn handle_swarm_user_turn( .last() .is_some_and(|item| item.role == "user" && item.content.trim() == message.trim()); let matching_pending_run_id = pending_message_is_latest - .then(|| swarm_parent_runtime(parent_agent_id, session_id, run_profile, &runtimes)) + .then(|| { + swarm_parent_runtime( + parent_agent_id, + session_id, + run_profile, + expected_parent_source, + &runtimes, + ) + }) .flatten() .and_then(|runtime| { - matching_pending_swarm_run_id(runtime, session_id, run_profile, message) + matching_pending_swarm_run_id( + runtime, + session_id, + run_profile, + expected_parent_source, + message, + ) }) .map(str::to_string); - let parent_has_queued_work = - swarm_parent_runtime(parent_agent_id, session_id, run_profile, &runtimes) - .is_some_and(runtime_is_busy); + let parent_has_queued_work = swarm_parent_runtime( + parent_agent_id, + session_id, + run_profile, + expected_parent_source, + &runtimes, + ) + .is_some_and(runtime_is_busy); if parent_has_queued_work { require_external_agent_runner_for_cli_runtime_write(root)?; resume_game_creator_agent_background_tasks_at(root)?; @@ -104,6 +126,7 @@ pub(super) fn handle_swarm_user_turn( parent_agent_id, session_id, run_profile, + expected_parent_source, conversation_baseline, input, output, @@ -114,6 +137,7 @@ pub(super) fn handle_swarm_user_turn( parent_agent_id, session_id, run_profile, + expected_parent_source, active_goal_run_id.as_deref(), &runtimes, ) { @@ -122,6 +146,7 @@ pub(super) fn handle_swarm_user_turn( parent_agent_id, session_id, run_profile, + expected_parent_source, &runtime.state.run_id, message, before.messages.len(), @@ -155,6 +180,7 @@ pub(super) fn handle_swarm_user_turn( parent_agent_id, session_id, run_profile, + expected_parent_source, None, ¤t_runtimes, ) { @@ -163,6 +189,7 @@ pub(super) fn handle_swarm_user_turn( parent_agent_id, session_id, run_profile, + expected_parent_source, &runtime.state.run_id, message, before.messages.len(), @@ -226,6 +253,7 @@ pub(super) fn handle_swarm_user_turn( parent_agent_id, session_id, run_profile, + expected_parent_source, before.messages.len(), input, output, @@ -339,6 +367,7 @@ fn steer_and_wait_for_swarm_turn( parent_agent_id: &str, session_id: &str, run_profile: &str, + expected_parent_source: Option<&str>, run_id: &str, message: &str, previous_message_count: usize, @@ -371,6 +400,7 @@ fn steer_and_wait_for_swarm_turn( parent_agent_id, session_id, run_profile, + expected_parent_source, conversation_baseline, input, output, @@ -424,6 +454,7 @@ fn start_and_wait_for_swarm_turn( parent_agent_id, session_id, run_profile, + new_run_launch.expected_parent_source(), conversation_baseline, input, output, @@ -448,6 +479,7 @@ pub(super) fn handle_swarm_resume_turn( parent_agent_id: &str, session_id: &str, run_profile: &str, + expected_parent_source: Option<&str>, previous_message_count: usize, input: &Receiver, output: &mut W, @@ -459,14 +491,18 @@ pub(super) fn handle_swarm_resume_turn( .map_err(|error| format!("写入终端失败:{error}"))?; } let current_runtimes = read_game_creator_agent_runtimes_at(root)?; - let Some(parent) = - swarm_parent_runtime(parent_agent_id, session_id, run_profile, ¤t_runtimes) - else { + let Some(parent) = swarm_parent_runtime( + parent_agent_id, + session_id, + run_profile, + expected_parent_source, + ¤t_runtimes, + ) else { writeln!(output, "[恢复] 当前 Session 没有可恢复的运行任务。") .map_err(|error| format!("写入终端失败:{error}"))?; return Ok(SwarmChatFlow::Continue); }; - let pending = next_pending_swarm_task(parent, session_id, run_profile); + let pending = next_pending_swarm_task(parent, session_id, run_profile, expected_parent_source); let (target_run_id, target_status, target_phase) = if game_creator_agent_runtime_accepts_steer(&parent.state) || parent.state.status == "pending" @@ -506,6 +542,7 @@ pub(super) fn handle_swarm_resume_turn( parent_agent_id, session_id, run_profile, + expected_parent_source, conversation_baseline, input, output, @@ -517,6 +554,7 @@ fn wait_and_print_swarm_turn( parent_agent_id: &str, session_id: &str, run_profile: &str, + expected_parent_source: Option<&str>, conversation_baseline: SwarmTurnConversationBaseline, input: &Receiver, output: &mut W, @@ -527,6 +565,7 @@ fn wait_and_print_swarm_turn( parent_agent_id, session_id, run_profile, + expected_parent_source, conversation_baseline, input, output, diff --git a/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/turn_wait.rs b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/turn_wait.rs index 994acd3e2..59d03b910 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/turn_wait.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/turn_wait.rs @@ -14,6 +14,7 @@ pub(super) fn wait_for_swarm_turn( parent_agent_id: &str, session_id: &str, run_profile: &str, + expected_parent_source: Option<&str>, conversation_baseline: SwarmTurnConversationBaseline, input: &Receiver, output: &mut W, @@ -31,6 +32,7 @@ pub(super) fn wait_for_swarm_turn( parent_agent_id, session_id, run_profile, + expected_parent_source, &conversation_baseline.parent_run_id, &runtimes, ); @@ -110,6 +112,7 @@ pub(super) fn wait_for_swarm_turn( parent_agent_id, session_id, run_profile, + expected_parent_source, &conversation_baseline.parent_run_id, &runtimes, ); @@ -154,6 +157,7 @@ pub(super) fn wait_for_swarm_turn( parent_agent_id, session_id, run_profile, + expected_parent_source, &conversation_baseline.parent_run_id, &runtimes, )? && (!runner.enabled || !runner.running) @@ -175,6 +179,7 @@ pub(super) fn wait_for_swarm_turn( parent_agent_id, session_id, run_profile, + expected_parent_source, &conversation_baseline.parent_run_id, &runtimes, )? { @@ -208,6 +213,7 @@ pub(super) fn wait_for_swarm_turn( parent_agent_id, session_id, run_profile, + expected_parent_source, &conversation_baseline.parent_run_id, &runtimes, ) @@ -217,6 +223,7 @@ pub(super) fn wait_for_swarm_turn( parent_agent_id, session_id, run_profile, + expected_parent_source, &conversation_baseline.parent_run_id, &runtimes, )?; @@ -343,6 +350,7 @@ pub(super) fn wait_for_swarm_turn( parent_agent_id, session_id, run_profile, + expected_parent_source, Some(&conversation_baseline.parent_run_id), &runtimes, ) { diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/static_deliveries.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/static_deliveries.rs index 40679654c..1ebf3f0a2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/static_deliveries.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/static_deliveries.rs @@ -1877,6 +1877,395 @@ fn completed_child_final_response_becomes_needs_user_input_delivery_result() { fs::remove_dir_all(root).ok(); } +#[test] +fn game_chat_autonomous_root_converts_child_user_input_request_to_safe_default_repair() { + let root = unique_project_path(); + init_local_game_project_at( + &root, + "project-game-chat-safe-default-repair", + "game-chat 自主构建安全默认返工测试", + ) + .expect("project init"); + let parent_run_id = "game-chat-safe-default-parent-run"; + let parent_binding = bind_game_creator_agent_runtime_run_profile_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + None, + ) + .expect("bind trusted game-chat root"); + let delivery = new_static_delegate_delivery_with_contract( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "game-chat-safe-default-parent-session", + parent_run_id, + "game-chat-safe-default-parent-action", + "game-chat-safe-default-delegation", + "design-director", + "game-chat-safe-default-child-session", + "game-chat-safe-default-child-run", + &["自主选择安全默认方案继续交付".to_string()], + &[], + None, + ); + let child_binding = bind_game_creator_agent_runtime_run_profile_at( + &root, + &delivery.target_agent_id, + &delivery.target_run_id, + "agent-delegate", + None, + Some(&AgentRuntimeTaskLink { + parent_agent_id: Some(delivery.parent_agent_id.clone()), + parent_run_id: Some(delivery.parent_run_id.clone()), + delegation_id: Some(delivery.delegation_id.clone()), + }), + ) + .expect("bind delegated child"); + let sensitive_question = "请把 secret token 和绝对路径 D:/private/game 发给我"; + let response = format!( + "AGC_NEEDS_USER_INPUT_V1\n{}", + serde_json::json!({ + "questions": [{ + "id": "secret_input", + "header": "敏感输入", + "question": sensitive_question, + "options": [ + {"label": "提供", "description": "提供敏感信息。"}, + {"label": "跳过", "description": "使用安全默认值。"} + ] + }] + }) + ); + let child_task = AgentRuntimeTaskRecord { + schema_version: "game-creator-agent-runtime-task.v1".to_string(), + task_id: "design-director".to_string(), + agent_id: delivery.target_agent_id.clone(), + session_id: delivery.target_session_id.clone(), + run_id: delivery.target_run_id.clone(), + source: "agent-delegate".to_string(), + parent_agent_id: Some(delivery.parent_agent_id.clone()), + parent_run_id: Some(delivery.parent_run_id.clone()), + delegation_id: Some(delivery.delegation_id.clone()), + run_profile: child_binding.profile, + run_profile_binding_fingerprint: child_binding.binding_fingerprint, + goal_id: None, + goal_revision: 0, + goal_status: None, + task: "完成自主设计决策".to_string(), + status: "completed".to_string(), + phase: "completed".to_string(), + current_action: "已返回澄清请求".to_string(), + terminal_detail: Some(response.clone()), + error: None, + updated_at: unix_timestamp(), + }; + + let mut result = build_static_delegate_result_for_child_at( + &root, + &delivery, + &child_task, + "completed", + &response, + ) + .expect("build trusted game-chat child result"); + let parent_task = AgentRuntimeTaskRecord { + schema_version: "game-creator-agent-runtime-task.v1".to_string(), + task_id: GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(), + agent_id: delivery.parent_agent_id.clone(), + session_id: delivery.parent_session_id.clone(), + run_id: delivery.parent_run_id.clone(), + source: AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE.to_string(), + parent_agent_id: None, + parent_run_id: None, + delegation_id: None, + run_profile: AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD.to_string(), + run_profile_binding_fingerprint: parent_binding.binding_fingerprint, + goal_id: None, + goal_revision: 0, + goal_status: None, + task: "自主生成游戏".to_string(), + status: "running".to_string(), + phase: "planning".to_string(), + current_action: "等待专业 Agent".to_string(), + terminal_detail: None, + error: None, + updated_at: unix_timestamp(), + }; + assert!( + trusted_game_chat_autonomous_root_parent_at(&root, &parent_task) + .expect("validate trusted game-chat parent") + ); + assert!(convert_game_chat_child_user_input_to_safe_default_repair( + &mut result + )); + + assert_eq!( + result.contract_status, + StaticDelegateContractStatus::NeedsRepair + ); + assert!(result.user_input_questions.is_empty()); + assert!(result.user_input_questions_sha256.is_none()); + let error = result.error.expect("safe-default repair summary"); + let summary: Value = serde_json::from_str(&error).expect("structured safe-default summary"); + assert_eq!(summary["schemaVersion"], "game-chat-safe-default-repair.v1"); + assert_eq!(summary["code"], "child-needs-user-input"); + assert_eq!(summary["strategy"], "continue-with-safe-defaults"); + assert!(error.chars().count() <= 500); + assert!(!error.contains(sensitive_question)); + assert!(!error.contains("D:/private/game")); + assert!(!error.contains("API Key")); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn game_chat_safe_default_decision_is_allowlisted_and_fail_closed() { + let question = |id: &str, header: &str, text: &str| AgentRuntimeUserInputQuestion { + id: id.to_string(), + header: header.to_string(), + question: text.to_string(), + options: vec![ + AgentRuntimeUserInputOption { + label: "默认".to_string(), + description: "使用平台已有的默认值。".to_string(), + }, + AgentRuntimeUserInputOption { + label: "跳过".to_string(), + description: "跳过当前不确定项。".to_string(), + }, + ], + }; + let convert = |question: AgentRuntimeUserInputQuestion| { + let questions = vec![question]; + let sha256 = format!( + "{:x}", + Sha256::digest(serde_json::to_vec(&questions).expect("serialize question")) + ); + let mut result = StaticDelegateStructuredResult { + contract_status: StaticDelegateContractStatus::NeedsUserInput, + user_input_questions: questions, + user_input_questions_sha256: Some(sha256), + ..StaticDelegateStructuredResult::default() + }; + assert!(convert_game_chat_child_user_input_to_safe_default_repair( + &mut result + )); + let marker = + serde_json::from_str::(result.error.as_deref().expect("safe-default marker")) + .expect("parse safe-default marker"); + let instruction = game_chat_safe_default_repair_task_instruction(&result) + .expect("safe-default task instruction"); + (marker, instruction) + }; + + let (preference, preference_instruction) = convert(question( + "visual_preference", + "视觉偏好", + "首版采用明亮还是柔和配色?", + )); + assert_eq!(preference["reasonCode"], "preference-clarification"); + assert_eq!(preference["defaultDecision"], "use-safe-default"); + assert!(preference_instruction.contains("use-safe-default")); + + for question in [ + question("unknown_fact", "补充信息", "请告诉我还缺什么再继续。"), + question("secret", "接入信息", "请提供 API Key 后继续。"), + question("permission", "外部操作", "是否允许将结果发布到外部平台?"), + ] { + let (marker, instruction) = convert(question); + assert_eq!(marker["reasonCode"], "sensitive-or-permission-request"); + assert_eq!(marker["defaultDecision"], "skip-denied"); + assert!(instruction.contains("skip-denied")); + assert!(instruction.contains("不得再次询问用户")); + } +} + +#[test] +fn legacy_claimed_game_chat_user_input_delivery_recovers_as_repair_without_pending_question() { + let root = unique_project_path(); + init_local_game_project_at( + &root, + "project-game-chat-legacy-safe-default-repair", + "game-chat legacy 澄清恢复测试", + ) + .expect("project init"); + let parent_run_id = "game-chat-legacy-safe-default-parent-run"; + let parent_binding = bind_game_creator_agent_runtime_run_profile_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + None, + ) + .expect("bind trusted game-chat root"); + let mut state = start_game_creator_agent_runtime_task_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "自主完成可玩游戏", + parent_run_id, + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + "等待专业 Agent", + vec!["收齐专业回执".to_string()], + ) + .expect("start trusted game-chat parent"); + state.run_profile = parent_binding.profile.clone(); + state.run_profile_binding_fingerprint = parent_binding.binding_fingerprint.clone(); + write_game_creator_agent_runtime_state(&root, &state).expect("persist bound game-chat state"); + append_game_creator_agent_runtime_task(&root, &state).expect("persist bound game-chat task"); + let delivery = new_static_delegate_delivery_with_contract( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &state.session_id, + parent_run_id, + "game-chat-legacy-parent-action", + &agent_runtime_delegation_id( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + "code-prototype", + "game-chat-legacy-parent-action", + ), + "code-prototype", + "game-chat-legacy-child-session", + "game-chat-legacy-child-run", + &["采用安全默认值继续完成设计".to_string()], + &[], + None, + ); + create_or_read_static_delegate_delivery_at(&root, &delivery).expect("create legacy delivery"); + let child_binding = bind_game_creator_agent_runtime_run_profile_at( + &root, + &delivery.target_agent_id, + &delivery.target_run_id, + "agent-delegate", + None, + Some(&AgentRuntimeTaskLink { + parent_agent_id: Some(delivery.parent_agent_id.clone()), + parent_run_id: Some(delivery.parent_run_id.clone()), + delegation_id: Some(delivery.delegation_id.clone()), + }), + ) + .expect("bind legacy delegated child"); + let child_task = AgentRuntimeTaskRecord { + schema_version: "game-creator-agent-runtime-task.v1".to_string(), + task_id: delivery.target_agent_id.clone(), + agent_id: delivery.target_agent_id.clone(), + session_id: delivery.target_session_id.clone(), + run_id: delivery.target_run_id.clone(), + source: "agent-delegate".to_string(), + parent_agent_id: Some(delivery.parent_agent_id.clone()), + parent_run_id: Some(delivery.parent_run_id.clone()), + delegation_id: Some(delivery.delegation_id.clone()), + run_profile: child_binding.profile, + run_profile_binding_fingerprint: child_binding.binding_fingerprint, + goal_id: None, + goal_revision: 0, + goal_status: None, + task: "完成自主代码原型".to_string(), + status: "completed".to_string(), + phase: "completed".to_string(), + current_action: "已返回澄清请求".to_string(), + terminal_detail: None, + error: None, + updated_at: unix_timestamp(), + }; + append_game_creator_agent_runtime_task( + &root, + &agent_runtime_state_from_task_record(&child_task), + ) + .expect("persist legacy delegated child task"); + let sensitive_question = "请提供 C:/secret/token.txt 内的 token"; + let result = build_static_delegate_structured_result_at( + &root, + "completed", + &[], + false, + None, + None, + None, + Some(&format!( + "AGC_NEEDS_USER_INPUT_V1\n{}", + serde_json::json!({ + "questions": [{ + "id": "secret_token", + "header": "密钥", + "question": sensitive_question, + "options": [ + {"label": "提供", "description": "读取秘密文件。"}, + {"label": "跳过", "description": "使用安全默认值。"} + ] + }] + }) + )), + ) + .expect("build legacy needs-user-input result"); + mark_static_delegate_delivery_ready_with_result_at( + &root, + &delivery.target_agent_id, + &delivery.target_session_id, + &delivery.target_run_id, + &delivery.delegation_id, + "completed", + "需要敏感用户输入", + result, + ) + .expect("mark legacy delivery ready"); + bind_supervisor_collaboration_policy_snapshot_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + &SupervisorCollaborationPolicy::default(), + "legacy-current-project-policy", + ) + .expect("bind collaboration policy"); + claim_ready_static_delegate_receipts_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + "game-chat-legacy-claim-action", + ) + .expect("claim legacy delivery"); + let deliveries = claimed_static_delegate_deliveries_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + ) + .expect("read legacy claimed delivery"); + + assert!( + !ensure_static_delegate_user_input_wait_at(&root, &mut state, &deliveries,) + .expect("convert legacy clarification") + ); + let converted = read_static_delegate_delivery_at(&root, &delivery.delegation_id) + .expect("read converted delivery") + .expect("converted delivery exists"); + let converted_result = converted.structured_result.expect("converted result"); + assert_eq!( + converted_result.contract_status, + StaticDelegateContractStatus::NeedsRepair + ); + assert!(converted_result.user_input_questions.is_empty()); + assert!(converted_result.user_input_questions_sha256.is_none()); + let error = converted_result.error.expect("safe-default repair summary"); + assert!(!error.contains(sensitive_question)); + assert!(read_game_creator_agent_runtime_pending_tool_action( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + ) + .is_err()); + let barrier = static_delegate_completion_barrier_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + ) + .expect("read converted barrier"); + assert_eq!(barrier.user_input_required_count, 0); + assert_eq!(barrier.repair_required_count, 1); + + fs::remove_dir_all(root).ok(); +} + #[test] fn project_supervisor_run_status_replays_receipts_for_same_action() { let root = unique_project_path(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/command_runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/command_runtime.rs index b06cfa55b..ad5d30974 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/command_runtime.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/command_runtime.rs @@ -1,5 +1,65 @@ use super::*; +#[test] +fn static_smoke_finish_rejects_same_revision_entry_rewrite_before_credential_binding() { + let root = unique_project_path(); + init_local_game_project_at( + &root, + "project-static-smoke-toctou", + "静态验证入口摘要时序测试", + ) + .expect("project init"); + fs::write( + root.join("game/index.html"), + fake_llm_game_draft().game_html, + ) + .expect("write initially valid game entry"); + let run_id = "static-smoke-toctou-run"; + let _lock = acquire_project_write_lock(&root, "test.static-smoke.toctou") + .expect("acquire project write lock"); + prepare_agent_runtime_project_mutation_locked( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + "file.write", + ) + .expect("prepare project mutation"); + let (expected_revision, gate) = begin_agent_runtime_project_verification_locked( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + "game.static_smoke", + ) + .expect("begin static smoke"); + + fs::write( + root.join("game/index.html"), + "unverified replacement", + ) + .expect("replace entry without advancing revision"); + let error = + finish_agent_runtime_project_verification_locked(&root, &expected_revision, gate, true) + .expect_err("replacement bytes must be smoke-validated before credential binding"); + assert!(error.contains("game.static_smoke"), "{error}"); + + let gate = read_game_creator_agent_runtime_verification_gate( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + ) + .expect("read failed static smoke gate"); + assert_eq!(gate.verified_revision, None); + assert_eq!(gate.static_smoke_verified_revision, None); + assert_eq!(gate.static_smoke_verified_game_index_sha256, None); + assert_eq!( + gate.last_verification_status.as_deref(), + Some(AGENT_RUNTIME_VERIFICATION_STATUS_FAILED) + ); + + drop(_lock); + fs::remove_dir_all(root).ok(); +} + #[test] fn failed_autonomous_preview_invalidates_static_smoke_verification_gate() { let root = unique_project_path(); @@ -8,6 +68,11 @@ fn failed_autonomous_preview_invalidates_static_smoke_verification_gate() { write_supervisor_collaboration_policy_at(&root, SupervisorCollaborationPolicy::default()) .expect("write default collaboration policy"); let run_id = "preview-gate-failure-run"; + fs::write( + root.join("game/index.html"), + fake_llm_game_draft().game_html, + ) + .expect("write smoke-valid game entry"); let revision = prepare_agent_runtime_project_mutation_locked( &root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, @@ -2809,6 +2874,30 @@ fn limited_local_command_rejects_blank_canvas_game_smoke() { fs::remove_dir_all(root).ok(); } +#[test] +fn limited_local_command_rejects_invalid_javascript_before_surface_checks() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "脚本语法优先验证测试").expect("project init"); + let html = r#" + + +

目标:完成订单。胜利或失败后可以重开。

+ + +"#; + fs::write(root.join("game/index.html"), html).expect("write invalid JavaScript game html"); + + let error = run_limited_local_command_at(&root, "game.static_smoke") + .expect_err("invalid JavaScript must fail before canvas keywords"); + + assert!(error.contains("不是有效 JavaScript"), "{error}"); + assert!(!error.contains("可渲染画布"), "{error}"); + fs::remove_dir_all(root).ok(); +} + #[test] fn limited_local_command_rejects_token_rich_truncated_script() { let root = unique_project_path(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/response_stream.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/response_stream.rs index b0dadfb3f..1eae8af9a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/response_stream.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/response_stream.rs @@ -4033,6 +4033,179 @@ async fn provider_handoff_final_reply_restart_replays_success_without_network_re fs::remove_dir_all(config_dir).ok(); } +#[tokio::test] +async fn project_execution_owner_cross_boot_replays_same_run_provider_handoff_to_completion_once() { + let root = unique_project_path(); + init_local_game_project_at( + &root, + "project-owner-provider-handoff", + "execution owner 跨 boot Provider 恢复测试", + ) + .expect("project init"); + let config_dir = unique_project_path(); + fs::create_dir_all(&config_dir).expect("create cross-boot Provider config dir"); + let config_guard = use_test_runtime_config_dir(config_dir.clone()); + let config_path = config_dir.join(GAME_CREATOR_CONFIG_FILE_NAME); + let injection = root.join(".agent/runtime/test-stop-after-provider-handoff"); + fs::create_dir_all(injection.parent().expect("handoff injection parent")) + .expect("create handoff injection parent"); + fs::write(&injection, b"stop-after-final-reply-provider-handoff") + .expect("write final reply handoff injection"); + let agent_id = "design-director"; + let run_id = "owner-cross-boot-provider-handoff-run"; + let final_response = "新 boot 从原 Provider handoff 恢复并唯一完成。"; + let (request_notice_sender, request_notice_receiver) = mpsc::channel(); + let (request_capture_sender, request_capture_receiver) = mpsc::channel(); + let (base_url, server_handle) = spawn_mock_llm_tool_plan_then_transient_final_reply( + final_tool_plan_response("不得提交 planning fallback。"), + final_response.to_string(), + request_notice_sender, + request_capture_sender, + ); + replace_test_local_config( + &config_path, + format!( + r#"{{ + "agentMode": "provider", + "agentLlm": {{ + "design-director": {{ + "apiKey": "owner-cross-boot-provider-key", + "baseUrl": {base_url:?}, + "model": "owner-cross-boot-provider-model", + "apiKind": "openai_responses", + "stream": true, + "maxRetries": 1, + "retryBackoffMs": 1000 + }} + }} +}}"# + ), + ); + let started = start_game_creator_agent_background_task_at( + &root, + agent_id, + "验证新 execution owner 恢复纯 Provider durable run", + run_id, + ) + .expect("start cross-boot Provider task"); + + for _ in 0..3 { + request_notice_receiver + .recv_timeout(Duration::from_secs(5)) + .expect("Provider request before simulated old Runner exit"); + } + let handoff = wait_for_provider_handoff_test_stop(&root, agent_id, run_id); + server_handle.join().expect("join cross-boot Provider mock"); + let captured_requests = (0..3) + .map(|_| { + request_capture_receiver + .recv_timeout(Duration::from_secs(5)) + .expect("captured Provider request before ownership transfer") + }) + .collect::>(); + assert_eq!(captured_requests.len(), 3); + assert!(request_capture_receiver.try_recv().is_err()); + + let (claimed_root, recovered_from_boot_id) = + crate::runner::simulate_external_agent_runner_cross_boot_owner_claim_for_test( + &root, + &config_dir, + "owner-provider-boot-a", + "owner-provider-boot-b", + ) + .expect("new boot claims and resumes Provider handoff"); + assert_eq!(claimed_root, root); + assert_eq!( + recovered_from_boot_id.as_deref(), + Some("owner-provider-boot-a") + ); + + let completed = wait_for_agent_runtime_terminal_and_lane_release( + &root, + agent_id, + run_id, + "idle", + "completed", + ); + assert_eq!(completed.state.session_id, started.state.session_id); + assert_eq!( + completed.state.last_response.as_deref(), + Some(final_response) + ); + assert!(request_capture_receiver.try_recv().is_err()); + wait_for_provider_handoff_terminal_cleanup(&root, agent_id, run_id); + + let records = read_agent_db_records_for_test(&root); + assert_eq!( + records + .iter() + .filter(|record| { + record["recordType"] == "agent.runtime.provider_request.lifecycle" + && record["runId"] == run_id + && record["requestId"] == handoff.provider_request_id + && record["status"] == "started" + }) + .count(), + 1 + ); + assert_eq!( + records + .iter() + .filter(|record| { + record["recordType"] == "agent.runtime.provider_request.lifecycle" + && record["runId"] == run_id + && record["requestId"] == handoff.provider_request_id + && record["status"] == "completed" + }) + .count(), + 1 + ); + assert_eq!( + records + .iter() + .filter(|record| { + record["runId"] == run_id + && matches!( + record["recordType"].as_str(), + Some("agent.runtime.agent.delegate") + | Some("agent.runtime.tool_action.executing") + ) + }) + .count(), + 0 + ); + let conversation = read_local_conversation_for_session_at( + &root, + Some(agent_id), + Some(&started.state.session_id), + ) + .expect("read recovered conversation"); + assert_eq!( + conversation + .messages + .iter() + .filter(|message| message.role == "assistant" && message.content == final_response) + .count(), + 1 + ); + assert_eq!( + completed + .recent_tasks + .iter() + .filter(|task| task.run_id == run_id && task.status == "completed") + .count(), + 1 + ); + + assert!(request_capture_receiver.try_recv().is_err()); + let records_after = read_agent_db_records_for_test(&root); + assert_eq!(records_after, records); + + fs::remove_dir_all(root).ok(); + drop(config_guard); + fs::remove_dir_all(config_dir).ok(); +} + #[tokio::test] async fn provider_handoff_final_reply_compaction_restart_only_requests_final_reply() { let root = unique_project_path(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/action_execution.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/action_execution.rs index 51ed3113a..8fd82c4b6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/action_execution.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/action_execution.rs @@ -2041,6 +2041,147 @@ fn preview_validate_public_event_detail_stays_structured_below_event_limit() { fs::remove_dir_all(root).ok(); } +#[test] +fn static_smoke_failure_receipt_keeps_owner_diagnostic_and_redacts_public_detail() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "静态验证诊断回执测试").expect("project init"); + let observation = AgentRuntimeToolObservation { + tool: "command.run_limited".to_string(), + status: "failed".to_string(), + summary: "game.static_smoke 执行失败".to_string(), + detail: Some(format!( + "游戏入口第 1 个可执行内联脚本不是有效 JavaScript:{}", + root.join("game/index.html").display() + )), + }; + + let public = agent_runtime_action_receipt_public_safe_detail_for_test(&root, &observation) + .expect("public static smoke metadata"); + let public = serde_json::from_str::(&public).expect("parse public metadata"); + assert_eq!(public["commandId"], "game.static_smoke"); + assert_eq!(public["passed"], false); + assert_eq!(public["failureCode"], "static-smoke-failed"); + assert_eq!(public["check"], "javascript-syntax"); + assert_eq!(public["path"], "game/index.html"); + assert!(public.get("diagnostic").is_none()); + + let owner = agent_runtime_action_receipt_safe_detail_for_owner_for_test( + &root, + "code-prototype", + "static-smoke-owner-run", + &observation, + ) + .expect("owner static smoke metadata"); + let owner = serde_json::from_str::(&owner).expect("parse owner metadata"); + assert_eq!(owner["failureCode"], "static-smoke-failed"); + assert_eq!(owner["check"], "javascript-syntax"); + assert_eq!(owner["path"], "game/index.html"); + let diagnostic = owner["diagnostic"].as_str().expect("owner diagnostic"); + assert!(diagnostic.contains("不是有效 JavaScript"), "{diagnostic}"); + assert!(!diagnostic.contains(root.to_string_lossy().as_ref())); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn static_smoke_failure_receipt_round_trips_owner_diagnostic_from_agent_db() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "静态验证诊断持久化测试").expect("project init"); + let run_id = "static-smoke-owner-round-trip-run"; + let runtime = start_game_creator_agent_runtime_task_at( + &root, + "code-prototype", + "修复游戏入口语法并重新验证", + run_id, + "agent-background-task", + "执行静态验证", + vec!["读取失败诊断并修复".to_string()], + ) + .expect("start static smoke owner runtime"); + let action = AgentRuntimeToolAction { + tool: "command.run_limited".to_string(), + reason: Some("验证当前游戏入口".to_string()), + input: serde_json::json!({ "commandId": "game.static_smoke" }), + }; + let action_fingerprint = agent_runtime_tool_action_fingerprint(&action, &runtime.current_task); + let action_id = agent_runtime_tool_action_id(run_id, 1, 0, 0, &action_fingerprint); + let observation = AgentRuntimeToolObservation { + tool: action.tool.clone(), + status: "failed".to_string(), + summary: "game.static_smoke 执行失败".to_string(), + detail: Some(format!( + "游戏入口第 1 个可执行内联脚本不是有效 JavaScript:{}", + root.join("game/index.html").display() + )), + }; + + append_agent_runtime_action_receipt( + &root, + &runtime, + &action_id, + &action_fingerprint, + &action.tool, + AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO, + Some("game.static_smoke"), + &observation, + ) + .expect("persist static smoke failure receipt"); + + let records = read_agent_db_records_for_test(&root); + let receipt = records + .iter() + .find(|record| { + record["recordType"] == AGENT_RUNTIME_ACTION_RECEIPT_RECORD_TYPE + && record["actionId"] == action_id + }) + .expect("persisted static smoke receipt"); + let persisted = serde_json::from_str::( + receipt["safeDetail"] + .as_str() + .expect("persisted owner safe detail"), + ) + .expect("parse persisted owner safe detail"); + assert_eq!(persisted["failureCode"], "static-smoke-failed"); + assert!(persisted["diagnostic"] + .as_str() + .is_some_and(|value| value.contains("不是有效 JavaScript"))); + assert!(!receipt + .to_string() + .contains(root.to_string_lossy().as_ref())); + + let history = observe_agent_runtime_action_history( + &root, + "code-prototype", + run_id, + &serde_json::json!({ "actionId": action_id, "limit": 1 }), + ); + assert_eq!(history.status, "ok", "{history:?}"); + let history = serde_json::from_str::( + history + .detail + .as_deref() + .expect("round-tripped action history detail"), + ) + .expect("parse round-tripped action history detail"); + let round_tripped = history["actions"][0]["safeDetail"] + .as_str() + .expect("round-tripped owner safe detail"); + let round_tripped = + serde_json::from_str::(round_tripped).expect("parse round-tripped safe detail"); + assert_eq!(round_tripped["commandId"], "game.static_smoke"); + assert_eq!(round_tripped["passed"], false); + assert_eq!(round_tripped["failureCode"], "static-smoke-failed"); + assert_eq!(round_tripped["check"], "javascript-syntax"); + assert_eq!(round_tripped["path"], "game/index.html"); + let diagnostic = round_tripped["diagnostic"] + .as_str() + .expect("round-tripped diagnostic"); + assert!(diagnostic.contains("不是有效 JavaScript"), "{diagnostic}"); + assert!(!diagnostic.contains(root.to_string_lossy().as_ref())); + + fs::remove_dir_all(root).ok(); +} + #[test] fn seed_refresh_downgrades_completed_visual_tasks_when_registered_file_is_missing() { let _config_guard = crate::tests::write_test_local_config( diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/support.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/support.rs index 4cc6d88c5..0a9ca06bf 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/support.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/support.rs @@ -42,11 +42,12 @@ pub(super) use crate::{ agent_runtime_tool_requires_repository_context_fingerprint_gate, agent_runtime_verified_delivery_completion_plan_update, append_agent_db_record, append_agent_db_terminal_observation_if_missing_for_action, - append_agent_runtime_tool_call_record, append_game_creator_agent_runtime_action_event, - append_game_creator_agent_runtime_task, append_game_creator_agent_runtime_task_projection_once, - append_local_conversation_message_at, append_local_conversation_message_for_session_at, - append_local_permission_log_at, append_or_read_exact_game_creator_agent_runtime_pending_task, - apply_agent_runtime_plan_update, await_game_creator_agent_runtime_provider_request, + append_agent_runtime_action_receipt, append_agent_runtime_tool_call_record, + append_game_creator_agent_runtime_action_event, append_game_creator_agent_runtime_task, + append_game_creator_agent_runtime_task_projection_once, append_local_conversation_message_at, + append_local_conversation_message_for_session_at, append_local_permission_log_at, + append_or_read_exact_game_creator_agent_runtime_pending_task, apply_agent_runtime_plan_update, + await_game_creator_agent_runtime_provider_request, begin_agent_runtime_project_verification_locked, bind_game_creator_agent_runtime_run_profile_at, build_game_creation_seed_task_graph, build_repository_startup_context_at, cancel_game_creator_agent_runtime_task_at, @@ -64,8 +65,9 @@ pub(super) use crate::{ game_creator_agent_runtime_task_lock_is_available, game_creator_agent_runtime_task_path, game_creator_agent_runtime_tool_policy_rule_for_run, init_local_game_project_at, invalidate_agent_runtime_project_verification_after_preview_failure_at, - native_runtime_function_name, observe_agent_runtime_agent_message, - plan_game_creation_agent_pass, prepare_agent_runtime_project_mutation_locked, + native_runtime_function_name, observe_agent_runtime_action_history, + observe_agent_runtime_agent_message, plan_game_creation_agent_pass, + prepare_agent_runtime_project_mutation_locked, prepare_game_creator_agent_runtime_provider_action_batch, project_verification_completion_blocker_at, read_all_game_creator_agent_runtime_tasks, read_game_creator_agent_runtime_at, read_game_creator_agent_runtime_context_bundle, diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 2dc8e129b..a37e3f958 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -119,6 +119,7 @@ import { projectSupervisorPendingRepairMatchesProfessional, projectSupervisorResponseStreamIdentity, readProjectSupervisorActiveSessionId, + resolveProjectSupervisorRuntimeSubmission, sameAgentRuntimeRun, submitProjectSupervisorRuntimeTask, taskRowsFromManifest, @@ -568,6 +569,7 @@ type AppProps = { initialProjectPath?: string; initialProjectManifest?: GameCreationAppManifest; initialProjectKind?: LocalProjectKind; + orchestrationMode?: 'single-supervisor' | 'professional-dag'; projectSupervisorOnly?: boolean; supervisorChatOnly?: boolean; gameChatOnly?: boolean; @@ -588,6 +590,7 @@ export function App({ initialProjectPath: initialProjectPathOverride = '', initialProjectManifest, initialProjectKind = 'web', + orchestrationMode = 'professional-dag', projectSupervisorOnly = false, supervisorChatOnly = false, gameChatOnly = false, @@ -5781,21 +5784,20 @@ export function App({ if (!sessionId || localProjectPathRef.current !== nextProjectPath) { return; } - const submissionRunProfile = - workspaceProjectKind === 'godot' || - (supervisorChatOnly && !gameChatOnly) - ? 'standard' - : 'autonomous-game-build'; + const submissionRoute = resolveProjectSupervisorRuntimeSubmission({ + workspaceProjectKind, + orchestrationMode, + supervisorChatOnly, + gameChatOnly, + }); const runtimeAtSubmission = projectSupervisorRuntimeRef.current; - const steerRuntime = gameChatOnly - ? matchingAgentRuntimeForSteer( - [runtimeAtSubmission], - PROJECT_SUPERVISOR_AGENT_ID, - sessionId, - submissionRunProfile, - 'project-supervisor-game-chat', - ) - : null; + const steerRuntime = matchingAgentRuntimeForSteer( + [runtimeAtSubmission], + PROJECT_SUPERVISOR_AGENT_ID, + sessionId, + submissionRoute.runProfile, + submissionRoute.source, + ); let autoPreviewAfterRevision = 0; let autoPreviewAfterValidatedAt = 0; if (steerRuntime) { @@ -5838,10 +5840,8 @@ export function App({ sessionId, prompt, runtime: runtimeAtSubmission, - runProfile: submissionRunProfile, - source: gameChatOnly - ? 'project-supervisor-game-chat' - : 'project-supervisor-gui', + runProfile: submissionRoute.runProfile, + source: submissionRoute.source, }); const runtimeResult = submission.runtimeResult; const acceptedRunId = submission.acceptedRunId.trim(); @@ -11072,6 +11072,7 @@ export function App({ transientReply={projectSupervisorTransientReply} visibleMessages={visibleMessages} visibleProfessionalAgentCards={visibleProfessionalAgentCards} + showProfessionalCollaboration={orchestrationMode === 'professional-dag'} workspaceStatus={workspaceStatus} runtime={projectSupervisorRuntime} error={projectSupervisorRuntimeError} diff --git a/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts b/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts index 8cfb64d71..bac015459 100644 --- a/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts +++ b/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts @@ -26,6 +26,43 @@ const AGENT_RUNTIME_TASK_MESSAGE_ID_PREFIX = 'runtime-task-'; const AGENT_RUNTIME_STEER_MESSAGE_ID_PREFIX = 'agent-steer-'; const AGENT_RUNTIME_MESSAGE_CORRELATION_PATTERN = /^[0-9a-f]{32}$/; +export type ProjectSupervisorRuntimeSubmission = { + runProfile: 'standard' | 'autonomous-game-build'; + source: 'project-supervisor-gui' | 'project-supervisor-game-chat'; +}; + +export function resolveProjectSupervisorRuntimeSubmission({ + workspaceProjectKind, + orchestrationMode, + supervisorChatOnly, + gameChatOnly, +}: { + workspaceProjectKind: 'web' | 'godot'; + orchestrationMode: 'single-supervisor' | 'professional-dag'; + supervisorChatOnly: boolean; + gameChatOnly: boolean; +}): ProjectSupervisorRuntimeSubmission { + if ( + workspaceProjectKind === 'godot' || + (supervisorChatOnly && !gameChatOnly) + ) { + return { + runProfile: 'standard', + source: 'project-supervisor-gui', + }; + } + if (!gameChatOnly && orchestrationMode !== 'single-supervisor') { + return { + runProfile: 'autonomous-game-build', + source: 'project-supervisor-gui', + }; + } + return { + runProfile: 'autonomous-game-build', + source: 'project-supervisor-game-chat', + }; +} + function agentRuntimeMessageCorrelationId( messageId: string | null | undefined, ) { @@ -912,7 +949,7 @@ export async function submitProjectSupervisorRuntimeTask({ prompt: string; runtime: AgentRuntimeState | null; runProfile: 'standard' | 'autonomous-game-build'; - source?: string; + source: ProjectSupervisorRuntimeSubmission['source']; }) { const steerRuntime = matchingAgentRuntimeForSteer( [runtime], @@ -932,7 +969,7 @@ export async function submitProjectSupervisorRuntimeTask({ steerId: createAgentChatRunId('project-supervisor-steer'), instruction: prompt, runProfile, - ...(source ? { source } : {}), + source, }, ); return { @@ -950,7 +987,7 @@ export async function submitProjectSupervisorRuntimeTask({ task: prompt, runId: requestedRunId, runProfile, - ...(source ? { source } : {}), + source, }, ); return { @@ -1465,7 +1502,9 @@ export function projectSupervisorRuntimeStatusLabel( return '等待专业 Agent'; } if (runtime.phase === 'waiting-for-manifest-tasks') { - return '等待项目任务'; + return runtime.source === 'project-supervisor-game-chat' + ? '生成中' + : '等待项目任务'; } if ( ['action', 'observation', 'executing'].includes(runtime.phase) || @@ -1603,7 +1642,13 @@ export function projectRuntimeStatusPresentation(runtime: AgentRuntimeState) { return { label: '协作中', tone: 'running' }; } if (runtime.phase === 'waiting-for-manifest-tasks') { - return { label: '项目任务中', tone: 'running' }; + return { + label: + runtime.source === 'project-supervisor-game-chat' + ? '生成中' + : '项目任务中', + tone: 'running', + }; } return { label: '执行中', tone: 'running' }; } @@ -1655,7 +1700,9 @@ export function projectRuntimeVisibleCurrentWork(runtime: AgentRuntimeState) { return '正在等待专业 Agent 回执'; } if (runtime.phase === 'waiting-for-manifest-tasks') { - return '正在等待项目专业任务完成'; + return runtime.source === 'project-supervisor-game-chat' + ? '正在推进游戏生成' + : '正在等待项目专业任务完成'; } if (runtime.status === 'failed' || runtime.phase === 'failed') { return '本轮工作执行失败'; diff --git a/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx b/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx index 2fab75181..3ed8b76eb 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx +++ b/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx @@ -279,6 +279,7 @@ export function WorkspaceLauncherShell({ /> ) : launcherView === 'project-development' && currentProjectContext ? ( void; onSubmit: FormEventHandler; pendingConfirmation: PendingUiConfirmation | null; + showProfessionalCollaboration?: boolean; transientReply: string; visibleMessages: ChatMessage[]; visibleProfessionalAgentCards: AgentStatusCard[]; @@ -52,6 +53,7 @@ export function ProjectSupervisorView({ onShowEarlierMessages, onSubmit, pendingConfirmation, + showProfessionalCollaboration = true, transientReply, visibleMessages, visibleProfessionalAgentCards, @@ -149,40 +151,44 @@ export function ProjectSupervisorView({ {projectWorkspaceStatusForDisplay(workspaceStatus)} - + {showProfessionalCollaboration ? ( + + ) : null} ); } diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index c74750bc6..94a4d405b 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -117,6 +117,7 @@ type ResourceCategory = ProjectResourceCategory; type ResourceSortMode = ProjectResourceCanvasLayoutMode; type WorkbenchMode = 'resources' | 'run'; type ApprovalMode = 'strict' | 'risk' | 'none'; +type OrchestrationMode = 'single-supervisor' | 'professional-dag'; type WebKitGestureEvent = Event & { clientX?: number; @@ -234,6 +235,7 @@ const RESOURCE_DEPENDENCY_VISUAL_GUTTER = 64; type AgentSummary = ProjectAgentRuntimeSummary; export type ProjectDevelopmentViewProps = { + orchestrationMode?: OrchestrationMode; projectName: string; projectPath: string; manifest: GameCreationAppManifest; @@ -670,6 +672,7 @@ const ResourceCard = memo(function ResourceCard({ }); export default function ProjectDevelopmentView({ + orchestrationMode = 'professional-dag', projectName, projectPath, manifest, @@ -680,6 +683,7 @@ export default function ProjectDevelopmentView({ supervisor, onManifestChange, }: ProjectDevelopmentViewProps) { + const professionalDagVisible = orchestrationMode === 'professional-dag'; const [mode, setMode] = useState('resources'); const [sortMode, setSortMode] = useState('dependency'); const [searchText, setSearchText] = useState(''); @@ -1395,7 +1399,8 @@ export default function ProjectDevelopmentView({ } const viewport = resolveViewport(event); const category = viewport?.dataset.resourceSectionScroll as - ResourceCategory | undefined; + | ResourceCategory + | undefined; if (!viewport || !category) { return; } @@ -1414,7 +1419,8 @@ export default function ProjectDevelopmentView({ const event = rawEvent as WebKitGestureEvent; const viewport = resolveViewport(event); const category = viewport?.dataset.resourceSectionScroll as - ResourceCategory | undefined; + | ResourceCategory + | undefined; if (!viewport || !category) { return; } @@ -1502,7 +1508,8 @@ export default function ProjectDevelopmentView({ .querySelectorAll('[data-resource-section-scroll]') .forEach((viewport) => { const category = viewport.dataset.resourceSectionScroll as - ResourceCategory | undefined; + | ResourceCategory + | undefined; if (!category) { return; } @@ -1522,7 +1529,8 @@ export default function ProjectDevelopmentView({ ?.querySelectorAll('[data-resource-section-scroll]') .forEach((viewport) => { const category = viewport.dataset.resourceSectionScroll as - ResourceCategory | undefined; + | ResourceCategory + | undefined; if (!category) { return; } @@ -2816,16 +2824,16 @@ export default function ProjectDevelopmentView({ dependencyLayoutSettled: dependencyLayout.settled, dependencyPositioned: Boolean( intent.resourceId && - dependencyLayout.layout.positions.some( - (position) => position.resourceId === intent.resourceId, - ), + dependencyLayout.layout.positions.some( + (position) => position.resourceId === intent.resourceId, + ), ), typeLayoutSettled: typeLayout.settled, typePositioned: Boolean( intent.resourceId && - typeLayout.layout.positions.some( - (position) => position.resourceId === intent.resourceId, - ), + typeLayout.layout.positions.some( + (position) => position.resourceId === intent.resourceId, + ), ), visible: targetVisible, domRendered: Boolean(card), @@ -3777,69 +3785,81 @@ export default function ProjectDevelopmentView({ 与陶泥儿的对话 项目总控 Agent - + {professionalDagVisible ? ( + + ) : ( + + 自动执行 + + )} {supervisor} -
- {agentSummaries.map((agent) => ( -
- - { - { - design: '策', - art: '美', - code: '程', - balance: '数', - audio: '音', - publishing: '发', - }[agent.group] - } - - - {agent.label} - {agent.failureSummary ?? agent.statusLabel} - - + {agentSummaries.map((agent) => ( +
- {agent.currentTask} - {agent.currentAction ? ( - {agent.currentAction} - ) : null} - {agent.waitingOn ? ( - {`等待:${agent.waitingOn}`} - ) : null} - {`任务完成 ${agent.completedCount}/${agent.totalCount}`} - -
- ))} - -
+ + { + { + design: '策', + art: '美', + code: '程', + balance: '数', + audio: '音', + publishing: '发', + }[agent.group] + } + + + {agent.label} + {agent.failureSummary ?? agent.statusLabel} + + + {agent.currentTask} + {agent.currentAction ? ( + {agent.currentAction} + ) : null} + {agent.waitingOn ? ( + {`等待:${agent.waitingOn}`} + ) : null} + {`任务完成 ${agent.completedCount}/${agent.totalCount}`} + + + ))} + + + ) : null} {resourceRecoveryPanelOpen ? (
) : null} - {approvalDialogOpen ? ( + {professionalDagVisible && approvalDialogOpen ? (
{ '正在读取项目', ); }); + + test.each([ + { + name: '普通 Web 工作台', + input: { + workspaceProjectKind: 'web' as const, + orchestrationMode: 'single-supervisor' as const, + supervisorChatOnly: false, + gameChatOnly: false, + }, + expected: { + runProfile: 'autonomous-game-build', + source: 'project-supervisor-game-chat', + }, + }, + { + name: '游戏生成聊天', + input: { + workspaceProjectKind: 'web' as const, + orchestrationMode: 'professional-dag' as const, + supervisorChatOnly: false, + gameChatOnly: true, + }, + expected: { + runProfile: 'autonomous-game-build', + source: 'project-supervisor-game-chat', + }, + }, + { + name: '显式项目总控调试', + input: { + workspaceProjectKind: 'web' as const, + orchestrationMode: 'professional-dag' as const, + supervisorChatOnly: true, + gameChatOnly: false, + }, + expected: { + runProfile: 'standard', + source: 'project-supervisor-gui', + }, + }, + { + name: 'Godot 项目调试', + input: { + workspaceProjectKind: 'godot' as const, + orchestrationMode: 'single-supervisor' as const, + supervisorChatOnly: false, + gameChatOnly: false, + }, + expected: { + runProfile: 'standard', + source: 'project-supervisor-gui', + }, + }, + { + name: '显式专业 DAG Web 工作台', + input: { + workspaceProjectKind: 'web' as const, + orchestrationMode: 'professional-dag' as const, + supervisorChatOnly: false, + gameChatOnly: false, + }, + expected: { + runProfile: 'autonomous-game-build', + source: 'project-supervisor-gui', + }, + }, + ])('$name 使用稳定的 Runtime 提交路由', ({ input, expected }) => { + expect(resolveProjectSupervisorRuntimeSubmission(input)).toEqual(expected); + }); }); describe('Agent 最近任务失败摘要', () => { @@ -484,7 +557,7 @@ describe('Game Chat stream identity and source', () => { ).not.toBe(projectSupervisorResponseStreamIdentity(base)); }); - test('game-chat source is forwarded for start and steer without changing default source behavior', async () => { + test('Project Supervisor source is explicit for every start and steer', async () => { const runtimeResult = { state: { schemaVersion: 'game-creator-agent-runtime.v1', @@ -538,8 +611,12 @@ describe('Game Chat stream identity and source', () => { prompt: 'chat', runtime: null, runProfile: 'standard', + source: 'project-supervisor-gui', }); - expect(invoke.mock.calls[0]?.[1]).not.toHaveProperty('source'); + expect(invoke).toHaveBeenCalledWith( + 'start_game_creator_supervisor_runtime_task', + expect.objectContaining({ source: 'project-supervisor-gui' }), + ); invoke.mockClear(); const steerRuntime = { @@ -572,13 +649,7 @@ describe('Game Chat stream identity and source', () => { ); invoke.mockClear(); - invoke.mockResolvedValueOnce({ - runtime: runtimeResult, - steerId: 'steer-2', - sequence: 2, - status: 'applied', - providerInterrupted: false, - }); + invoke.mockResolvedValueOnce(runtimeResult); await submitProjectSupervisorRuntimeTask({ invoke, projectPath: '/tmp/game-chat', @@ -586,8 +657,12 @@ describe('Game Chat stream identity and source', () => { prompt: 'continue this run', runtime: steerRuntime, runProfile: 'autonomous-game-build', + source: 'project-supervisor-gui', }); - expect(invoke.mock.calls[0]?.[1]).not.toHaveProperty('source'); + expect(invoke).toHaveBeenCalledWith( + 'start_game_creator_supervisor_runtime_task', + expect.objectContaining({ source: 'project-supervisor-gui' }), + ); invoke.mockClear(); await submitProjectSupervisorRuntimeTask({ @@ -696,6 +771,37 @@ function providerRetryRuntime(): AgentRuntimeState { } describe('Agent Runtime Provider 状态投影', () => { + test('单主管来源使用中性的游戏生成状态,GUI 调试来源保留专业任务状态', () => { + const gameChatRuntime = { + ...providerRetryRuntime(), + source: 'project-supervisor-game-chat', + phase: 'waiting-for-manifest-tasks', + currentAction: null, + waitingOn: null, + planSteps: [], + }; + const guiRuntime = { + ...gameChatRuntime, + source: 'project-supervisor-gui', + }; + + expect(projectSupervisorRuntimeStatusLabel(gameChatRuntime, '')).toBe( + '生成中', + ); + expect(projectRuntimeStatusPresentation(gameChatRuntime).label).toBe( + '生成中', + ); + expect(projectRuntimeVisibleCurrentWork(gameChatRuntime)).toBe( + '正在推进游戏生成', + ); + expect(projectSupervisorRuntimeStatusLabel(guiRuntime, '')).toBe( + '等待项目任务', + ); + expect(projectRuntimeVisibleCurrentWork(guiRuntime)).toBe( + '正在等待项目专业任务完成', + ); + }); + test('等待 503 重试时优先显示 Runtime 的真实动作和等待时间', () => { const runtime = providerRetryRuntime(); const expected = diff --git a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts index 58e090dfd..abcb3d347 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts @@ -1351,6 +1351,7 @@ export function registerHomeProjectCreationTests() { task: expect.stringContaining('初始意图:art / 做素材'), runId: expect.stringMatching(/^project-supervisor-task-/), runProfile: 'autonomous-game-build', + source: 'project-supervisor-game-chat', }); expect(startCalls[0]?.[1]).toMatchObject({ task: expect.stringContaining( diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts index 0c67e2014..3fc36ddf5 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts @@ -22,6 +22,7 @@ import { mergeGameChatFinalReplyMessagesIntoHistory, SupervisorChatOnlyView, } from '../../src/features/project-workspace/SupervisorChatOnlyView'; +import type { ProjectAgentRuntimeSummary } from '../../src/view/project-development'; import { RESOURCE_CANVAS_CARD_WIDTH, RESOURCE_CANVAS_COLUMN_GAP, @@ -9131,7 +9132,11 @@ export function registerProjectSupervisorSurfaceTests() { agentId: 'project-supervisor', sessionId: supervisorHarness.sessionId, }); - expect(screen.getByLabelText('专业 Agent 协作状态')).not.toBeNull(); + expect(screen.queryByLabelText('专业 Agent 协作状态')).toBeNull(); + expect(screen.getByRole('status', { name: '自动执行' })).not.toBeNull(); + expect(screen.queryByLabelText('子 Agent 状态栏')).toBeNull(); + expect(screen.queryByText('严格审批')).toBeNull(); + expect(screen.queryByRole('button', { name: /审批配置/ })).toBeNull(); expect(screen.queryByLabelText('选择 Agent')).toBeNull(); expect(screen.queryByRole('dialog', { name: 'Agent 对话' })).toBeNull(); expect(supervisorHarness.listen).not.toHaveBeenCalledWith( @@ -9157,7 +9162,7 @@ export function registerProjectSupervisorSurfaceTests() { task: '先完成正式客户端玩法拆解', runId: expect.stringMatching(/^project-supervisor-task-/), runProfile: 'autonomous-game-build', - source: 'project-supervisor-gui', + source: 'project-supervisor-game-chat', }, ); }); @@ -9192,7 +9197,7 @@ export function registerProjectSupervisorSurfaceTests() { steerId: expect.stringMatching(/^project-supervisor-steer-/), instruction: '补充:优先复用现有素材', runProfile: 'autonomous-game-build', - source: 'project-supervisor-gui', + source: 'project-supervisor-game-chat', }, ); }); @@ -9541,12 +9546,36 @@ export function registerProjectSupervisorSurfaceTests() { }, ); window.__TAURI__ = { core: { invoke } }; - renderLauncherProjectsAt('/?launcher'); - - fireEvent.change(screen.getByLabelText('项目目录'), { - target: { value: projectPath }, - }); - fireEvent.click(screen.getByRole('button', { name: '打开' })); + function ProfessionalWorkbenchFixture() { + const [agentRuntimeSummaries, setAgentRuntimeSummaries] = React.useState< + ProjectAgentRuntimeSummary[] + >([]); + const [agentResults, setAgentResults] = React.useState< + ProjectAgentResultSummary[] + >([]); + return React.createElement(ProjectDevelopmentView, { + orchestrationMode: 'professional-dag', + projectName: manifest.name, + projectPath, + manifest, + attachments: [], + recentRunStatus: null, + recentRunStopReason: null, + agentRuntimeSummaries, + agentResults, + supervisor: React.createElement(App, { + initialProjectPath: projectPath, + initialProjectManifest: manifest, + orchestrationMode: 'professional-dag', + projectSupervisorOnly: true, + onAgentRuntimeSummariesChange: setAgentRuntimeSummaries, + onAgentResultsChange: setAgentResults, + }), + onHomeOpen: vi.fn(), + onProjectsOpen: vi.fn(), + }); + } + render(React.createElement(ProfessionalWorkbenchFixture)); expect(await screen.findByLabelText('项目总控对话')).not.toBeNull(); await waitFor(() => { diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 1c69eb86c..648c30f4c 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -7166,6 +7166,16 @@ - HTTP 传输边界:根 H5 包不得依赖 Tauri guest 插件;AGC 独立包保留 `@tauri-apps/plugin-http`。Rust 插件显式关闭默认特性,只启用 `charset`、`cookies`、`http2` 和 `rustls-tls`,避免 `reqwest/system-proxy` 通过 Cargo feature union 把画布、Provider、Runtime 与本地回环夹具统一接入 OS 自动系统代理;如未来产品要求正式客户端继承系统代理,必须按各客户端明确设计并单独完成跨平台验证。 - 运行决策:Godot 项目提交给 Project Supervisor 时使用 `standard` Run Profile,避免触发 Web 专用 `game/index.html`、HTTP preview 与自主 Web 完成门。Godot 编辑器启动和内嵌运行预览不在本切片范围。 +## 2026-08-13 AGC 普通 Web 工作台默认单主无人值守生成 + +- 决策:项目首页进入的普通 Web 工作台默认 `single-supervisor`,提交固定映射为 `project-supervisor-game-chat + autonomous-game-build`。该决策覆盖此前“普通 GUI autonomous 继续固定完整 DAG”的现行入口规则;固定专业 autonomous DAG 只保留给显式 `professional-dag` 和专业 CLI 验收,Supervisor 调试与 Godot 继续 `standard + project-supervisor-gui`。 +- 路由:game-chat 根先由 Supervisor 持久化意图,随后只启动唯一 `code-prototype`;主 Run 完成资产审计后,只有真实缺口才可一次委派一个受限美术 child。普通用户界面不再展示固定专业 DAG、子 Agent Dock 或“严格审批”,统一显示自动执行与中性生成状态。 +- 澄清:仅可信 game-chat autonomous 根链及其绑定 child 将 `NeedsUserInput` 转为安全默认返工,并保持同一主 Run 继续;问题正文与公开问题指纹清除,内部只保留不公开的 SHA-256 幂等 marker。standard、GUI/CLI 调试、非可信或身份不完整链路仍保留原人工澄清,自动化不能扩大项目外写入、任意命令、发布、凭据或未知外部副作用权限。 +- 诊断:`game.static_smoke` 必须先解析可执行内联 JavaScript。公共失败回执只暴露稳定结构化字段;同一 owner 额外取得脱敏有界诊断。可修复失败按同 Run 的“诊断、修改、当前 revision 静态复验、双视口试玩”循环收口,不能以继续堆关键词或要求用户发送“继续”代替修复。 +- 恢复:跨 boot 取得 `execution-owner` 即幂等触发既有 durable recovery scan;安全动作续跑,未知外部副作用保留证据并进入 reconciliation,同 boot 重入不得重复恢复副作用。 +- 完成:根 Run 只有在正式 artifact、manifest、当前 revision static smoke 和 desktop/mobile `preview.validate` 一致时唯一 completed。确定性 loopback E2E 是提交门,真实 Provider 空项目单输入、零人工介入 smoke 是现场验收门,两者不得混称。 +- 关联:`docs/project-memory/plans/【实施计划】AGC无人值守游戏生成可靠性收口-2026-08-13.md`、`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`。 + ## 2026-08-12 AGC Agent 设置分层界面 - `RuntimeConfigDialog` 保持现有配置字段、持久化格式、Runner 读取和 MCP 测试语义,只重构界面信息架构。 diff --git a/docs/project-memory/shared-memory/development-workflow.md b/docs/project-memory/shared-memory/development-workflow.md index 1a64e8c5f..42f7732f4 100644 --- a/docs/project-memory/shared-memory/development-workflow.md +++ b/docs/project-memory/shared-memory/development-workflow.md @@ -680,3 +680,11 @@ npm run ai-game-creator-shell:agent-runtime:supervisor-swarm-tool-plan-handoff-r - 修改预览完成门时,分别覆盖 child `preview-readiness` 当前 revision smoke、child `preview-playtest` 到根 Supervisor 合同的 browser receipt,以及 WebSocket 启动前退出的稳定基础设施分类。基础设施错误必须在一次浏览器调用后让 run 失败,不能只做到后续调用快速失败而继续消耗 Provider 轮次。 - 修改 game-chat Runner 生命周期时,至少覆盖 busy durable sidecar 拒绝 client-exit、拒绝后 `draining=false` 可继续执行、清空后 idle shutdown、重复 shutdown 幂等;Windows target check 继续保留,不能以删除 Job Object 或放任后台继续来规避 reconciliation。 - LLM 配置回归必须穷举全部规范 Agent,校验无遗漏/重复、显式 patch 覆盖默认,并锁定 GUI 展示映射和 Rust resolver 一致;模板与 GUI 初始草稿不得把规范默认持久化成 `agentLlm` 显式覆盖。`--llm-status` 要输出逐 Agent 实际 reasoning/timing/retry 值。运行日志与当前配置冲突时,先区分 durable run snapshot 和后来修改的文件,不能按当前文件反推历史请求。 + +## AI 游戏创作普通 Web 工作台无人值守验收 + +- 普通项目首页进入的 Web 工作台使用 `single-supervisor`,提交必须是 `project-supervisor-game-chat + autonomous-game-build`;持久 Supervisor 决策前零 child,之后只启动 `code-prototype`。显式 `professional-dag` 与 CLI 专业验收继续完整 autonomous DAG,Supervisor 调试与 Godot 继续 standard;这些显式入口不受普通默认值影响。 +- 修改入口或路由后,至少运行 AGC typecheck、`agentRuntimeModel.test.ts` 和挂载后的 `appSurface.test.ts`,同时断言普通工作台不展示专业 Agent 栏、子 Agent Dock 或审批按钮;显式专业入口仍可访问这些开发能力。steer 必须精确匹配 source、profile、Session 和 run。 +- 修改 smoke、delivery 或 recovery 后,必须用确定性路径证明:首次 `game.static_smoke` 精确报告 JavaScript/合同失败;公共回执不含诊断和绝对路径;同 owner 取得脱敏诊断;同一 `code-prototype` Run 有真实 mutation;最新 revision 的 static smoke 与 desktop/mobile `preview.validate` 均通过;根 Run 只有一个 completed;confirmation 与 user-input 为零。 +- 修改 `execution-owner` 后必须覆盖跨 boot 首次 claim 自动触发一次 recovery scan、同 boot/重复 hydration 幂等不重复,以及未知外部副作用仍进入 reconciliation。不能只证明 OS 锁可重新取得,也不能依赖用户显式 `/resume` 或点击继续。 +- 最终交付把“确定性 mock/loopback E2E”和“真实 Provider 空项目 smoke”分开报告。未完成后一项时可以说明实现和回归已完成,但不得宣称已经证明真实场景全程无人介入。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 300e30c82..47fe26bce 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -1074,3 +1074,13 @@ game-project/ - 所有静态 delegate、ready child 和 isolated child 都只读继承根合同与当前 Acceptance Graph;isolated child 还必须实际收到自己的 `acceptanceCriteria / expectedArtifacts / writeScopes`。继承上下文不扩大工具、目录、写入权限或 expectedArtifacts。专业 Agent 只能报告局部结果、证据和剩余风险,不能修改根合同、根验收图或宣布用户总目标完成。 - Acceptance Graph 节点由 Supervisor 针对当前任务动态生成,不来自玩法模板。节点记录 required/optional、依赖、状态、证据引用与摘要;只有同一可信根 Supervisor 能调用 `agent.acceptance_update`,且该动作必须独占一轮。failed 与 not-observed 节点形成下一轮定向返工集合,未提交的 passed 节点保持不变。 - 完成门在普通收束、finalization 和 prepared 恢复中统一消费 Acceptance Graph。任一 required 节点为 failed、not-observed 或缺失都会阻止最终回复;工具成功、计划 completed、文件存在、smoke 或 preview 单独通过均不代表用户目标完成。项目 revision 改变后,整张图先进入待 Supervisor 确认状态;Supervisor 只更新它判断为受影响的节点并确认 current revision,未提交的 passed 节点及其证据保持不变。requiredEvidence 必须命中 Runtime 明确允许的持久证据工具集合,拼错、控制面、纯协调工具和依赖动态 catalog 的 MCP 调用不能形成不可完成合同。动作回执记录执行边界的 `projectRevisionBefore / projectRevisionAfter`:非 mutation evidence 的 before、after 都必须等于验收时 current revision;mutation evidence 的 after 必须等于 current revision。旧 revision、跨 revision 只读结果或执行后延迟落账的回执都不能重放为 passed。 + +## 2026-08-13 普通 Web 工作台无人值守生成可靠性收口 + +- **入口覆盖**:本节取代本文更早“普通 GUI autonomous 继续固定完整 DAG”的现行含义。项目首页进入的普通 Web 工作台必须显式使用 `single-supervisor` 编排,提交映射为 `project-supervisor-game-chat + autonomous-game-build`;持久化 Supervisor 决策前零 child,决策后只启动唯一 `code-prototype` 主 Run,美术仅在该 Run 完成 `asset.list` 并形成可证实缺口后一次委派一个受限 child。独立 game-chat 入口沿用相同规则。显式 `professional-dag` 与 CLI 专业 autonomous 验收继续使用 `project-supervisor-gui|cli + autonomous-game-build` 的原有完整 DAG;Supervisor 调试与 Godot 继续 `project-supervisor-gui + standard`,不得被普通入口默认值误改。 +- **普通界面**:`single-supervisor` 工作台不展示固定专业 DAG、子 Agent Dock 或“严格审批”,只显示总控对话、生成状态与“自动执行”。这只是产品投影收敛,不删除开发诊断入口,也不放宽权限;项目外写入、任意 shell、发布、凭据、系统设置和未知外部副作用继续失败关闭。 +- **无人介入语义**:可信 game-chat autonomous 根链路及其绑定 child 不得停在普通 `waiting-for-confirmation` 或 `waiting-for-user-input`。child 的信息不足交付在严格身份校验后转为同一父链可执行的安全默认返工,问题正文与公开问题指纹清除,内部只保留不公开的 SHA-256 幂等 marker;standard、GUI/CLI 调试和非可信绑定继续保留人工澄清。可恢复失败必须回到同一 `code-prototype` Run,不能要求用户发送“继续”。 +- **验证反馈与完成门**:`game.static_smoke` 先校验 `