From 5263efdfc1e36ffc7f042e9d54bdaf0e417aa207 Mon Sep 17 00:00:00 2001 From: AIGameCreator App Date: Fri, 17 Jul 2026 11:48:07 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=E8=A1=A5=E9=BD=90=E8=87=AA=E4=B8=BBSwarm?= =?UTF-8?q?=E7=BB=88=E7=AB=AF=E9=AA=8C=E6=94=B6=E4=B8=8E=E6=81=A2=E5=A4=8D?= =?UTF-8?q?=E9=97=A8=E7=A6=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增Project Supervisor自主终端协作真实E2E与隔离AppData安全检查 为Swarm Chat输出脱敏单行turn report并补齐终端收束断言 强化Supervisor多专业并行委派与语义验收提示 修复静态委派和隔离all-join混合等待及认领恢复 为agent.message增加语义幂等持久化并避免重复消息伪造进展 补充消息幂等、混合恢复和turn report定向回归测试 增加根级与应用级自主验收命令并更新Runtime技术文档 --- apps/ai-game-creator-shell/package.json | 1 + .../scripts/agent-runtime-real-e2e.mjs | 425 ++++++++++++- .../src-tauri/src/agent.rs | 223 +++++-- .../src-tauri/src/project.rs | 118 +++- .../src-tauri/src/swarm_cli.rs | 446 ++++++++++++-- .../src-tauri/src/tests.rs | 559 ++++++++++++++++++ ...案】AI游戏创作Agent Runtime V1.1-2026-07-12.md | 14 + package.json | 1 + 8 files changed, 1677 insertions(+), 110 deletions(-) diff --git a/apps/ai-game-creator-shell/package.json b/apps/ai-game-creator-shell/package.json index 578876e64..08b398a11 100644 --- a/apps/ai-game-creator-shell/package.json +++ b/apps/ai-game-creator-shell/package.json @@ -15,6 +15,7 @@ "agent-run": "node scripts/run-cli-with-config.mjs --agent-run", "agent-run:smoke": "node scripts/smoke-agent-run-local-provider.mjs", "agent-runtime:real-e2e": "node scripts/agent-runtime-real-e2e.mjs", + "agent-runtime:supervisor-swarm-autonomous-chat-real-e2e": "node scripts/agent-runtime-real-e2e.mjs --suite supervisor-swarm-autonomous-chat", "agent-runtime:supervisor-swarm-transient-retry-real-e2e": "node scripts/agent-runtime-real-e2e.mjs --suite supervisor-swarm-transient-retry", "agent-runtime:steer-real-e2e": "node scripts/agent-runtime-steer-real-e2e.mjs", "agent-runtime:steer-runner-kill-real-e2e": "node scripts/agent-runtime-real-e2e.mjs --suite steer-runner-kill", diff --git a/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e.mjs b/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e.mjs index 1a9e3cf72..0237f9fc1 100644 --- a/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e.mjs +++ b/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e.mjs @@ -71,6 +71,10 @@ const supervisorSwarmTransientRetryAppDataSentinelFileName = '.agent-runtime-real-e2e-supervisor-swarm-transient-retry-appdata.json'; const supervisorSwarmTransientRetryAppDataSentinelSchema = 'genarrative-agent-runtime-real-e2e-supervisor-swarm-transient-retry-appdata.v1'; +const supervisorSwarmAutonomousChatAppDataSentinelFileName = + '.agent-runtime-real-e2e-supervisor-swarm-autonomous-chat-appdata.json'; +const supervisorSwarmAutonomousChatAppDataSentinelSchema = + 'genarrative-agent-runtime-real-e2e-supervisor-swarm-autonomous-chat-appdata.v1'; const mainAgentId = 'code-prototype'; const projectSupervisorAgentId = 'project-supervisor'; const requestedRunId = `real-e2e-${Date.now()}-${randomUUID().slice(0, 8)}`; @@ -101,6 +105,7 @@ const steerRunnerKillSuite = 'steer-runner-kill'; const parallelReadSuite = 'parallel-read'; const supervisorSwarmSuite = 'supervisor-swarm'; const supervisorSwarmTransientRetrySuite = 'supervisor-swarm-transient-retry'; +const supervisorSwarmAutonomousChatSuite = 'supervisor-swarm-autonomous-chat'; const runtimeContextBundleSchemaVersion = 'game-creator-runtime-context-bundle.v5'; const providerRequestLifecycleSchemaVersion = @@ -230,6 +235,26 @@ const supervisorSwarmConfirmedTools = [ 'project.patchset', 'project.verify', ]; +const supervisorSwarmAutonomousTask = + '请把这个试玩项目推进到可以交给首批玩家体验的状态。以仓库现有要求和实际验证结果为准,完成后简短说明交付内容、验证结论和仍需关注的问题。'; +const supervisorSwarmAutonomousRoutingTerms = [ + supervisorSwarmDesignAgentId, + supervisorSwarmQualityAgentId, + 'agent.delegate', + 'agent.run_status', + '同一个 planning 轮次', + '同轮', + '并行', + '返工', + 'repair', + '一次', + '两个 Agent', + '两个专业', + 'Runner', + 'pidfd', + 'actionId', + 'delegationId', +]; const webSearchBaselineApiUrl = 'https://api.github.com/repos/nodejs/node/releases/latest'; const goalSessionId = `agent-session-${mainAgentId}`; @@ -350,6 +375,7 @@ let shutdownSignal = null; let linuxPidfdPythonPath = null; let cleanupInProgress = false; let userInputCliSession = null; +let supervisorSwarmCliSession = null; class StreamingSecretScanner { constructor(secrets) { @@ -616,6 +642,10 @@ const state = { newRunnerBootId: null, transientFaultProxy: null, transientFaultCheckpoint: null, + autonomousTaskRecipeFree: false, + autonomousRepositoryRecipeFree: false, + interactiveCliUsed: false, + turnReport: null, }, confirmedActionIds: new Set(), cleanupPerformed: false, @@ -765,6 +795,15 @@ try { } userInputCliSession = null; } + if (isSupervisorSwarmAutonomousChatSuite() && supervisorSwarmCliSession) { + try { + await closeInteractiveCli(supervisorSwarmCliSession); + } catch (error) { + state.status = 'FAIL'; + recordError('supervisor-swarm-autonomous-chat-cli-cleanup-failed', error); + } + supervisorSwarmCliSession = null; + } if (isMcpRuntimeSuite() && state.mcp.httpFixture) { try { await stopMcpHttpFixture(); @@ -5723,6 +5762,23 @@ async function collectPartialParallelReadEvidence() { } function supervisorSwarmRepositoryInstructions() { + if (isSupervisorSwarmAutonomousChatSuite()) { + return `# Player-ready repository contract + +- SUPERVISOR_SWARM_REPOSITORY_CONTEXT: a player-ready handoff requires both deliverables below to satisfy their business meaning and repository verification. +- The experience specification is ${supervisorSwarmDesignPath}; its complete content must be exactly: + DESIGN_STATUS=ready + DESIGN_MARKER=${supervisorSwarmDesignMarker} +- The release quality record is ${supervisorSwarmQualityPath}; its required final content must be exactly: + QUALITY_STATUS=ready + QUALITY_MARKER=${supervisorSwarmQualityMarker} +- A status label is not semantic proof. Before the release quality record is changed, its current content must be independently audited against this contract; any correction remains bound to the same acceptance criteria and artifact. +- Any work that changes a deliverable must read package.json, run its declared verification, and return evidence before the player-ready handoff is accepted. +- The user receives one concise handoff only after all required evidence is accepted. Intermediate professional work stays internal. +- Do not use Git, MCP, external generation, or dynamic child instances for this repository contract. +- Never read or expose .env, ${configFileName}, .agent/private-secret.txt, credentials, private runtime payloads, or absolute paths. +`; + } return `# Supervisor swarm real E2E - SUPERVISOR_SWARM_REPOSITORY_CONTEXT: complete both professional deliverables through the Project Supervisor and keep all professional replies internal. @@ -5741,6 +5797,27 @@ function supervisorSwarmRepositoryInstructions() { `; } +function assertSupervisorSwarmAutonomousRepositoryInstructions( + repositoryInstructions, +) { + if (!isSupervisorSwarmAutonomousChatSuite()) return; + for (const forbidden of supervisorSwarmAutonomousRoutingTerms) { + assert( + !repositoryInstructions.includes(forbidden), + 'supervisor-swarm-autonomous-repository-recipe-leak', + ); + } + assert( + repositoryInstructions.includes(supervisorSwarmDesignPath) && + repositoryInstructions.includes(supervisorSwarmQualityPath) && + repositoryInstructions.includes(supervisorSwarmDesignMarker) && + repositoryInstructions.includes(supervisorSwarmQualityMarker) && + repositoryInstructions.includes('independently audited'), + 'supervisor-swarm-autonomous-repository-contract-missing', + ); + state.supervisorSwarm.autonomousRepositoryRecipeFree = true; +} + function supervisorSwarmProjectPolicy(denyQualityMutations) { return { deniedCommands: [], @@ -5771,6 +5848,7 @@ async function writeSupervisorSwarmProjectPolicy(denyQualityMutations) { async function seedSupervisorSwarmDisposableProject() { await seedDisposableProject(); const repositoryInstructions = supervisorSwarmRepositoryInstructions(); + assertSupervisorSwarmAutonomousRepositoryInstructions(repositoryInstructions); await Promise.all([ fs.writeFile( path.join(state.projectRoot, 'AGENTS.md'), @@ -5826,10 +5904,35 @@ async function seedSupervisorSwarmDisposableProject() { } function buildSupervisorSwarmTaskPrompt() { + if (isSupervisorSwarmAutonomousChatSuite()) { + return supervisorSwarmAutonomousTask; + } return `请把 ${supervisorSwarmDesignPath} 与 ${supervisorSwarmQualityPath} 两项专业交付推进到仓库规范规定的 ready 状态。设计方向交给 ${supervisorSwarmDesignAgentId},质量方向交给 ${supervisorSwarmQualityAgentId};第一轮必须在同一个 planning 轮次同时安排两个方向。收到内部证据后由总控逐项验收,质量首轮未达标时只安排一次返工;下一步执行条件已经齐全时直接行动,不要反复只更新计划。全部证据成立后再由总控向用户简短汇报。不要转述仓库指令、内部标记、私有运行信息或绝对路径。`; } function assertSupervisorSwarmTaskPrompt(task) { + if (isSupervisorSwarmAutonomousChatSuite()) { + assert( + task === supervisorSwarmAutonomousTask && !task.includes('\n'), + 'supervisor-swarm-autonomous-task-invalid', + ); + for (const forbidden of [ + ...supervisorSwarmAutonomousRoutingTerms, + supervisorSwarmDesignPath, + supervisorSwarmQualityPath, + supervisorSwarmDesignMarker, + supervisorSwarmQualityMarker, + '.agent/runtime', + state.projectRoot, + ]) { + assert( + !task.includes(forbidden), + 'supervisor-swarm-autonomous-task-recipe-leak', + ); + } + state.supervisorSwarm.autonomousTaskRecipeFree = true; + return; + } assert( !task.includes('\n') && task.includes(supervisorSwarmDesignAgentId) && @@ -6078,7 +6181,7 @@ function validateSupervisorSwarmInitialProviderBatch(batch) { ['ready', 'completed'].includes(batch.status) && Number.isSafeInteger(batch.nextActionIndex) && batch.nextActionIndex >= 0 && - batch.nextActionIndex <= 2 && + batch.nextActionIndex <= batch.actions?.length && Array.isArray(batch.actions) && batch.actions.length === 2 && Array.isArray(batch.plan?.actions) && @@ -6160,12 +6263,27 @@ async function captureSupervisorSwarmInitialProviderBatch() { if (error?.code === 'ENOENT') return null; throw error; }); - if (batch) { + const autonomousDelegateBatch = + isSupervisorSwarmAutonomousChatSuite() && + Array.isArray(batch?.actions) && + batch.actions.length === 2 && + batch.actions.every( + (pending) => pending?.action?.tool === 'agent.delegate', + ); + if ( + batch && + (!isSupervisorSwarmAutonomousChatSuite() || autonomousDelegateBatch) + ) { validateSupervisorSwarmInitialProviderBatch(batch); return; } pollCount += 1; if (pollCount % 20 === 0) { + if (isSupervisorSwarmAutonomousChatSuite()) { + await confirmSupervisorSwarmPendingActions( + new Set([`${projectSupervisorAgentId}\0${state.initialRunId}`]), + ); + } const agentDb = await readOptionalJsonl( path.join(state.projectRoot, '.agent/agent.db'), ); @@ -7011,13 +7129,18 @@ async function driveSupervisorSwarmToRepairKillBoundary() { initial, ); if (state.supervisorSwarm.initialProviderOverlapObserved) { - await confirmSupervisorSwarmPendingActions( - new Set( - initial.map( - (delivery) => - `${delivery.targetAgentId}\0${delivery.targetRunId}`, - ), + const confirmRunKeys = new Set( + initial.map( + (delivery) => `${delivery.targetAgentId}\0${delivery.targetRunId}`, ), + ); + if (isSupervisorSwarmAutonomousChatSuite()) { + confirmRunKeys.add( + `${projectSupervisorAgentId}\0${state.initialRunId}`, + ); + } + await confirmSupervisorSwarmPendingActions( + confirmRunKeys, new Set( parentDeliveries .filter((delivery) => delivery.repairOfDelegationId != null) @@ -7523,20 +7646,41 @@ async function runSupervisorSwarmE2e() { chars: [...task].length, sha256: hashValue(task), }; - state.initialRunId = requestedRunId; - state.initialSessionId = supervisorSwarmSessionId; state.isolatedRunner.launchAttempted = true; - await runCli( - [ - '--agent-enqueue', + if (isSupervisorSwarmAutonomousChatSuite()) { + supervisorSwarmCliSession = startInteractiveCli([ + '--swarm-chat', '--init', state.projectRoot, - projectSupervisorAgentId, - state.initialRunId, - task, - ], - { timeoutMs: 120_000 }, - ); + ]); + await waitForInteractiveCliOutput( + supervisorSwarmCliSession, + (output) => + output.includes('Agent Swarm Chat') && + output.includes(`父 Agent:${projectSupervisorAgentId}`), + 'supervisor-swarm-autonomous-chat-banner-timeout', + 30_000, + ); + state.supervisorSwarm.interactiveCliUsed = true; + writeInteractiveCliLine(supervisorSwarmCliSession, task); + const started = await waitForSupervisorSwarmAutonomousParentRuntime(task); + state.initialRunId = started.runId; + state.initialSessionId = started.sessionId; + } else { + state.initialRunId = requestedRunId; + state.initialSessionId = supervisorSwarmSessionId; + await runCli( + [ + '--agent-enqueue', + '--init', + state.projectRoot, + projectSupervisorAgentId, + state.initialRunId, + task, + ], + { timeoutMs: 120_000 }, + ); + } await claimOwnedRunner(); const runtime = await readRuntime(projectSupervisorAgentId); assert( @@ -7550,10 +7694,81 @@ async function runSupervisorSwarmE2e() { await captureSupervisorSwarmTransientRetryCheckpoint(); await driveSupervisorSwarmToRepairKillBoundary(); await driveSupervisorSwarmRuntimeToCompletion(); + if (isSupervisorSwarmAutonomousChatSuite()) { + await captureSupervisorSwarmAutonomousTurnReport(); + writeInteractiveCliLine(supervisorSwarmCliSession, '/quit'); + await waitForInteractiveCliExit(supervisorSwarmCliSession, 30_000); + supervisorSwarmCliSession = null; + } state.evidence = await validateSupervisorSwarmEvidence(); assert(state.evidence.secretLeakCount === 0, 'loaded-key-leak-detected'); } +async function waitForSupervisorSwarmAutonomousParentRuntime(task) { + const deadline = Date.now() + 120_000; + while (Date.now() < deadline) { + const runtime = await readRuntime(projectSupervisorAgentId).catch( + () => null, + ); + if ( + runtime?.agentId === projectSupervisorAgentId && + runtime.sessionId === supervisorSwarmSessionId && + isNonEmptyString(runtime.runId) && + runtime.currentTask === task + ) { + return runtime; + } + if (supervisorSwarmCliSession?.closed) { + throw codedError('supervisor-swarm-autonomous-chat-closed-before-run'); + } + await sleep(50); + } + throw codedError('supervisor-swarm-autonomous-parent-runtime-timeout'); +} + +async function captureSupervisorSwarmAutonomousTurnReport() { + const session = supervisorSwarmCliSession; + assert(session && !session.closed, 'supervisor-swarm-chat-session-missing'); + const output = await waitForInteractiveCliOutput( + session, + (value) => value.includes('[turn.report] '), + 'supervisor-swarm-autonomous-turn-report-timeout', + 60_000, + ); + const reportLines = output + .split(/\r?\n/u) + .filter((line) => line.startsWith('[turn.report] ')); + assert( + reportLines.length === 1, + 'supervisor-swarm-autonomous-turn-report-count-invalid', + ); + const report = JSON.parse(reportLines[0].slice('[turn.report] '.length)); + assert( + isPlainObject(report) && + JSON.stringify(Object.keys(report).sort()) === + JSON.stringify( + [ + 'schemaVersion', + 'outcome', + 'parentAgentId', + 'sessionId', + 'parentRunId', + 'runtimeCount', + 'busyRuntimeCount', + 'pendingTaskCount', + 'runningTaskCount', + 'waitingForConfirmationCount', + 'waitingForUserInputCount', + 'newAssistantMessageCount', + 'finalReplyChars', + 'reconciliationAgentCount', + ].sort(), + ), + 'supervisor-swarm-autonomous-turn-report-shape-invalid', + ); + state.supervisorSwarm.turnReport = report; +} + function validateSupervisorSwarmProviderLifecycle(agentDb, deliveries) { const relevantRuns = supervisorSwarmRelevantRunKeys(deliveries); const lifecycle = agentDb.filter( @@ -8769,12 +8984,74 @@ async function validateSupervisorSwarmEvidence() { const secretLeakCount = (state.transcriptScanner?.count ?? 0) + projectSecretLeakCount; assert(secretLeakCount === 0, 'loaded-key-leak-detected'); + const autonomousModeEnabled = isSupervisorSwarmAutonomousChatSuite(); + const turnReport = state.supervisorSwarm.turnReport; + const turnReportPrivateLeakCount = autonomousModeEnabled + ? countExactSecrets(Buffer.from(JSON.stringify(turnReport ?? {})), [ + ...state.supervisorSwarm.privateValues, + ...state.secrets, + ...absolutePathVariants(state.projectRoot), + ...formalConfigPathVariants(), + ]) + : 0; + if (autonomousModeEnabled) { + assert( + state.supervisorSwarm.autonomousTaskRecipeFree && + state.supervisorSwarm.autonomousRepositoryRecipeFree && + state.supervisorSwarm.interactiveCliUsed && + turnReport?.schemaVersion === 'game-creator-swarm-turn-report.v1' && + turnReport.outcome === 'settled' && + turnReport.parentAgentId === projectSupervisorAgentId && + turnReport.sessionId === supervisorSwarmSessionId && + turnReport.parentRunId === state.initialRunId && + Number.isSafeInteger(turnReport.runtimeCount) && + turnReport.runtimeCount >= 3 && + turnReport.busyRuntimeCount === 0 && + turnReport.pendingTaskCount === 0 && + turnReport.runningTaskCount === 0 && + turnReport.waitingForConfirmationCount === 0 && + turnReport.waitingForUserInputCount === 0 && + turnReport.newAssistantMessageCount === 1 && + turnReport.finalReplyChars === + [...supervisorAssistants[0].content].length && + turnReport.reconciliationAgentCount === 0 && + turnReportPrivateLeakCount === 0, + 'supervisor-swarm-autonomous-turn-report-invalid', + ); + } return buildSupervisorSwarmEvidence( { - scenario: - 'project-supervisor-dual-delegate-single-repair-runner-recovery', + scenario: autonomousModeEnabled + ? 'project-supervisor-autonomous-chat-dual-delegate-single-repair-runner-recovery' + : 'project-supervisor-dual-delegate-single-repair-runner-recovery', targetAgentId: projectSupervisorAgentId, + autonomousModeEnabled, + autonomousTaskRecipeFree: state.supervisorSwarm.autonomousTaskRecipeFree, + autonomousRepositoryRecipeFree: + state.supervisorSwarm.autonomousRepositoryRecipeFree, + interactiveCliUsed: state.supervisorSwarm.interactiveCliUsed, + turnReportCaptured: turnReport != null, + turnReportOutcome: turnReport?.outcome ?? 'not-requested', + turnReportParentIdentityStable: autonomousModeEnabled + ? turnReport?.parentAgentId === projectSupervisorAgentId && + turnReport?.sessionId === supervisorSwarmSessionId && + turnReport?.parentRunId === state.initialRunId + : false, + turnReportRuntimeCount: turnReport?.runtimeCount ?? 0, + turnReportBusyRuntimeCount: turnReport?.busyRuntimeCount ?? 0, + turnReportPendingTaskCount: turnReport?.pendingTaskCount ?? 0, + turnReportRunningTaskCount: turnReport?.runningTaskCount ?? 0, + turnReportWaitingForConfirmationCount: + turnReport?.waitingForConfirmationCount ?? 0, + turnReportWaitingForUserInputCount: + turnReport?.waitingForUserInputCount ?? 0, + turnReportNewAssistantMessageCount: + turnReport?.newAssistantMessageCount ?? 0, + turnReportFinalReplyChars: turnReport?.finalReplyChars ?? 0, + turnReportReconciliationAgentCount: + turnReport?.reconciliationAgentCount ?? 0, + turnReportPrivateLeakCount, providerModel: state.supervisorSwarm.effectiveModel, providerApiKind: state.supervisorSwarm.effectiveApiKind, providerReasoningEffort: state.supervisorSwarm.effectiveReasoningEffort, @@ -9184,6 +9461,7 @@ function parseArguments(args) { suite === parallelReadSuite || suite === supervisorSwarmSuite || suite === supervisorSwarmTransientRetrySuite || + suite === supervisorSwarmAutonomousChatSuite || suite === steerRunnerKillSuite || processSessionSuites.has(suite), 'unsupported-suite', @@ -9313,8 +9591,15 @@ function sameEffectiveAgentLlm(left, right) { ); } +function isolatedSuiteProtectsSourceAppData() { + return ( + isSupervisorSwarmTransientRetrySuite() || + isSupervisorSwarmAutonomousChatSuite() + ); +} + function isolatedSuiteUsesSiblingAppData() { - return isWebSearchSuite() || isSupervisorSwarmTransientRetrySuite(); + return isWebSearchSuite() || isolatedSuiteProtectsSourceAppData(); } function isolatedSuiteAppDataProfile() { @@ -9398,6 +9683,14 @@ function isolatedSuiteAppDataProfile() { codePrefix: 'supervisor-swarm-transient-retry-appdata', }; } + if (isSupervisorSwarmAutonomousChatSuite()) { + return { + prefix: '.agent-runtime-real-e2e-supervisor-swarm-autonomous-chat-', + sentinelName: supervisorSwarmAutonomousChatAppDataSentinelFileName, + sentinelSchema: supervisorSwarmAutonomousChatAppDataSentinelSchema, + codePrefix: 'supervisor-swarm-autonomous-chat-appdata', + }; + } if (isSupervisorSwarmSuite()) { return { prefix: '.agent-runtime-real-e2e-supervisor-swarm-', @@ -9485,7 +9778,7 @@ function closeSourceAppDataDirectoryGuard() { } function startSourceAppDataDirectoryGuard(sourceConfigDir, profile) { - if (!isSupervisorSwarmTransientRetrySuite()) return; + if (!isolatedSuiteProtectsSourceAppData()) return; assert( !state.isolatedRunner.sourceAppDataDirectoryWatcher, 'source-appdata-directory-guard-already-started', @@ -9509,7 +9802,7 @@ function startSourceAppDataDirectoryGuard(sourceConfigDir, profile) { } async function verifySourceAppDataDirectoryUntouched() { - if (!isSupervisorSwarmTransientRetrySuite()) return; + if (!isolatedSuiteProtectsSourceAppData()) return; closeSourceAppDataDirectoryGuard(); const sourceConfigDir = await fs.realpath(state.options.configDir); const profile = isolatedSuiteAppDataProfile(); @@ -9569,7 +9862,7 @@ async function prepareIsolatedSuiteAppData({ state.isolatedRunner.appDataDir = appDataDir; state.isolatedRunner.ownerToken = ownerToken; state.isolatedRunner.createdAt = createdAt; - if (isSupervisorSwarmTransientRetrySuite()) { + if (isolatedSuiteProtectsSourceAppData()) { const realAppDataDir = await fs.realpath(appDataDir); assert( !isPathInside(sourceConfigDir, realAppDataDir) && @@ -9985,8 +10278,9 @@ async function prepareIsolatedSuiteAppData({ effective.requestTimeoutMs > 0 && Number.isSafeInteger(effective.maxRetries) && effective.maxRetries >= + (isSupervisorSwarmAutonomousChatSuite() || (isSupervisorSwarmTransientRetrySuite() && - agentId !== supervisorSwarmDesignAgentId + agentId !== supervisorSwarmDesignAgentId) ? 0 : 1) && effective.maxRetries <= 3 && @@ -15394,6 +15688,13 @@ async function confirmPendingActions( allowedTools = null, shouldConfirm = () => true, ) { + if (isSupervisorSwarmAutonomousChatSuite()) { + await confirmSupervisorSwarmPendingActionsInChat( + allowedTools, + shouldConfirm, + ); + return; + } for (const pending of await findPendingActions()) { if (state.confirmedActionIds.has(pending.actionId)) continue; if (!shouldConfirm(pending)) continue; @@ -15487,6 +15788,54 @@ async function confirmPendingActions( } } +async function confirmSupervisorSwarmPendingActionsInChat( + allowedTools, + shouldConfirm, +) { + const session = supervisorSwarmCliSession; + assert(session && !session.closed, 'supervisor-swarm-chat-session-missing'); + const allowed = new Set( + allowedTools ? [...allowedTools] : supervisorSwarmConfirmedTools, + ); + const candidates = (await findPendingActions()).filter((pending) => { + if (state.confirmedActionIds.has(pending.actionId)) return false; + if (!shouldConfirm(pending)) return false; + assert( + allowed.has(pending.tool), + `pending-tool-not-allowed-in-scenario:${pending.tool}`, + ); + return true; + }); + if (candidates.length === 0) return; + const output = await waitForInteractiveCliOutput( + session, + (value) => + candidates.some((pending) => + value.includes( + `[待确认] agent=${pending.agentId} run=${pending.runId} action=${pending.actionId} tool=${pending.tool}`, + ), + ), + 'supervisor-swarm-chat-confirmation-prompt-timeout', + 120_000, + ); + const pending = candidates.find((candidate) => + output.includes( + `[待确认] agent=${candidate.agentId} run=${candidate.runId} action=${candidate.actionId} tool=${candidate.tool}`, + ), + ); + assert(pending, 'supervisor-swarm-chat-prompted-action-missing'); + const runtime = await readRuntime(pending.agentId); + assert(runtime.runId === pending.runId, 'pending-run-mismatch'); + writeInteractiveCliLine(session, 'approve'); + await waitForInteractiveCliOutput( + session, + (output) => output.includes(`[已批准] ${pending.actionId}`), + 'supervisor-swarm-chat-confirmation-result-timeout', + 120_000, + ); + state.confirmedActionIds.add(pending.actionId); +} + async function monitorGoalWriteActionUntilSettled(pending) { state.goal.monitoredWriteActionIds.add(pending.actionId); const deadline = Date.now() + 120_000; @@ -21065,6 +21414,23 @@ function supervisorSwarmEvidenceFieldTemplate() { return { scenario: 'project-supervisor-dual-delegate-single-repair-runner-recovery', targetAgentId: projectSupervisorAgentId, + autonomousModeEnabled: false, + autonomousTaskRecipeFree: false, + autonomousRepositoryRecipeFree: false, + interactiveCliUsed: false, + turnReportCaptured: false, + turnReportOutcome: 'not-requested', + turnReportParentIdentityStable: false, + turnReportRuntimeCount: 0, + turnReportBusyRuntimeCount: 0, + turnReportPendingTaskCount: 0, + turnReportRunningTaskCount: 0, + turnReportWaitingForConfirmationCount: 0, + turnReportWaitingForUserInputCount: 0, + turnReportNewAssistantMessageCount: 0, + turnReportFinalReplyChars: 0, + turnReportReconciliationAgentCount: 0, + turnReportPrivateLeakCount: 0, providerModel: null, providerApiKind: null, providerReasoningEffort: null, @@ -22203,7 +22569,8 @@ function isParallelReadSuite() { function isSupervisorSwarmSuite() { return ( state.suite === supervisorSwarmSuite || - isSupervisorSwarmTransientRetrySuite() + isSupervisorSwarmTransientRetrySuite() || + isSupervisorSwarmAutonomousChatSuite() ); } @@ -22211,6 +22578,10 @@ function isSupervisorSwarmTransientRetrySuite() { return state.suite === supervisorSwarmTransientRetrySuite; } +function isSupervisorSwarmAutonomousChatSuite() { + return state.suite === supervisorSwarmAutonomousChatSuite; +} + function isSteerRunnerKillSuite() { return state.suite === steerRunnerKillSuite; } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent.rs b/apps/ai-game-creator-shell/src-tauri/src/agent.rs index 2a75b6d0e..8c0891a34 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -1596,12 +1596,23 @@ fn supervisor_delivery_pending_action_has_durable_side_effect_at( pending: &AgentRuntimePendingToolAction, ) -> Result { match pending.action.tool.as_str() { - "agent.run_status" => static_delegate_claim_exists_at( - root, - &pending.agent_id, - &pending.run_id, - &pending.action_id, - ), + "agent.run_status" => { + if static_delegate_claim_exists_at( + root, + &pending.agent_id, + &pending.run_id, + &pending.action_id, + )? { + Ok(true) + } else { + isolated_join_claim_exists_for_parent_action_at( + root, + &pending.agent_id, + &pending.run_id, + &pending.action_id, + ) + } + } "agent.delegate" => { let target_agent_id = normalize_game_creator_runtime_agent_id(&agent_runtime_tool_input_text( @@ -6240,6 +6251,63 @@ fn persist_waiting_static_delegate_parent_context_at( Ok(()) } +fn persist_waiting_isolated_parent_context_at( + root: &Path, + runtime: &mut AgentRuntimeState, + task: &str, + plan: &AgentRuntimeToolPlan, + observations: &mut Vec, + next_loop_index: usize, + context_tracker: &mut AgentRuntimeContextWindowTracker, + blocker: AgentRuntimeToolObservation, +) -> Result<(), String> { + let blocker_summary = blocker.summary(); + runtime.status = "running".to_string(); + runtime.phase = "waiting-for-isolated-join".to_string(); + runtime.current_action = "等待动态隔离 Agent 的 all-join".to_string(); + runtime.waiting_on = "隔离子 Agent 完成并由父 run 认领 all-join".to_string(); + runtime.next_step = "all-join ready 后自动唤醒当前父 run".to_string(); + runtime.observations.push(blocker_summary.clone()); + runtime.updated_at = unix_timestamp(); + context_tracker.record(&blocker); + observations.push(blocker); + persist_game_creator_agent_runtime_context( + root, + runtime, + task, + plan, + observations, + next_loop_index, + context_tracker, + )?; + append_game_creator_agent_runtime_task(root, runtime)?; + refresh_game_creator_agent_runtime_task_queue(root, runtime)?; + write_game_creator_agent_runtime_state(root, runtime)?; + let _ = append_game_creator_agent_runtime_event( + root, + runtime, + "observation", + "running", + "waiting-for-isolated-join", + &blocker_summary, + None, + ); + let _ = append_agent_db_record( + root, + serde_json::json!({ + "recordType": "agent.runtime.agent.isolated_join.waiting", + "agentId": runtime.agent_id, + "taskId": runtime.task_id, + "sessionId": runtime.session_id, + "runId": runtime.run_id, + "status": "waiting-for-isolated-join", + "nextLoopIndex": next_loop_index, + }), + ); + emit_game_creator_agent_runtime_update(root, &runtime.agent_id); + Ok(()) +} + async fn run_game_creator_agent_background_task_pass_with_context( root: PathBuf, agent_id: String, @@ -6333,6 +6401,35 @@ async fn run_game_creator_agent_background_task_pass_with_context( } }; if !consumed_steer { + if let Some(blocker) = + isolated_join_completion_blocker_at(&root, &agent_id, &runtime.run_id) + { + let waits_for_join = blocker + .detail + .as_deref() + .is_some_and(isolated_join_barrier_has_waiting_groups); + if waits_for_join { + if let Err(error) = persist_waiting_isolated_parent_context_at( + &root, + &mut runtime, + &task, + &plan, + &mut observations, + loop_index, + &mut context_tracker, + blocker, + ) { + return fail_game_creator_agent_background_context_at( + &root, + &agent_id, + &session_id, + runtime, + &format!("持久化动态隔离 Agent all-join 等待状态失败:{error}"), + ); + } + return AgentBackgroundTaskOutcome::WaitingForIsolatedJoin; + } + } if let Some(blocker) = static_delegate_completion_blocker_at(&root, &agent_id, &runtime.run_id) { @@ -11236,7 +11333,10 @@ impl AgentRuntimeContextWindowTracker { } pub(crate) fn record(&mut self, observation: &AgentRuntimeToolObservation) { - if observation.tool == "runtime.context" { + if observation.tool == "runtime.context" + || (observation.tool == "agent.message" + && observation.detail.as_deref() == Some("messageAppended=false")) + { return; } self.observation_signatures @@ -19997,7 +20097,9 @@ pub(crate) async fn execute_game_creator_agent_runtime_tool_action_with_pending_ .await } "blackboard.write" => observe_agent_runtime_blackboard_write(root, agent_id, &action.input), - "agent.message" => observe_agent_runtime_agent_message(root, agent_id, &action.input), + "agent.message" => { + observe_agent_runtime_agent_message(root, agent_id, run_id, &action.input) + } "agent.delegate" => observe_agent_runtime_project_snapshot_with_lock( root, agent_id, @@ -26274,9 +26376,10 @@ fn observe_agent_runtime_blackboard_write( } } -fn observe_agent_runtime_agent_message( +pub(crate) fn observe_agent_runtime_agent_message( root: &Path, agent_id: &str, + run_id: &str, input: &serde_json::Value, ) -> AgentRuntimeToolObservation { let target_agent_id = agent_runtime_tool_input_text(input, &["agentId", "targetAgentId"]); @@ -26313,36 +26416,63 @@ fn observe_agent_runtime_agent_message( }; let content = truncate_agent_runtime_text(sanitize_prompt_context(&content).as_str(), 1_200); let message = format!("来自 {agent_id} 的定向消息:{content}"); - let result = append_local_conversation_message_at( - root, - Some(&target_agent_id), - LocalConversationMessage { - role: "tool".to_string(), - content: message, - agent_id: None, - }, - ) - .and_then(|conversation| { - let relative_path = - agent_runtime_relative_project_path(root, Path::new(&conversation.path))?; - append_agent_db_record( - root, - serde_json::json!({ - "recordType": "agent.runtime.agent.message", - "agentId": agent_id, - "targetAgentId": target_agent_id.clone(), - "targetSessionId": conversation.session_id, - "path": relative_path, - }), - ) - }); + let result = resolve_agent_conversation_session_id_at(root, &target_agent_id, None, true) + .and_then(|target_session_id| { + let content_sha256 = format!("{:x}", Sha256::digest(content.as_bytes())); + let semantic_identity = format!( + "{agent_id}\n{run_id}\n{target_agent_id}\n{target_session_id}\n{content_sha256}" + ); + let semantic_sha256 = format!("{:x}", Sha256::digest(semantic_identity.as_bytes())); + let message_id = format!("agent-message-{semantic_sha256}"); + let audit_action_id = format!("action-{}", &semantic_sha256[..24]); + append_local_conversation_message_for_session_idempotent_with_status_at( + root, + Some(&target_agent_id), + Some(&target_session_id), + LocalConversationMessage { + role: "tool".to_string(), + content: message, + agent_id: None, + }, + &message_id, + ) + .and_then(|(conversation, appended)| { + let relative_path = + agent_runtime_relative_project_path(root, Path::new(&conversation.path))?; + append_agent_db_agent_message_if_missing( + root, + agent_id, + run_id, + &audit_action_id, + serde_json::json!({ + "recordType": "agent.runtime.agent.message", + "agentId": agent_id, + "runId": run_id, + "actionId": audit_action_id, + "messageId": message_id, + "targetAgentId": target_agent_id, + "targetSessionId": conversation.session_id, + "path": relative_path, + "contentSha256": content_sha256, + "contentChars": content.chars().count(), + }), + )?; + Ok(appended) + }) + }); match result { - Ok(()) => AgentRuntimeToolObservation { + Ok(true) => AgentRuntimeToolObservation { tool: "agent.message".to_string(), status: "ok".to_string(), summary: format!("已给 {target_agent_id} 留消息"), detail: Some(content), }, + Ok(false) => AgentRuntimeToolObservation { + tool: "agent.message".to_string(), + status: "ok".to_string(), + summary: format!("给 {target_agent_id} 的相同定向消息已存在,未重复追加"), + detail: Some("messageAppended=false".to_string()), + }, Err(error) => AgentRuntimeToolObservation { tool: "agent.message".to_string(), status: "failed".to_string(), @@ -29287,6 +29417,28 @@ fn claimed_isolated_join_count_for_parent_at( Ok(count) } +fn isolated_join_claim_exists_for_parent_action_at( + root: &Path, + parent_agent_id: &str, + parent_run_id: &str, + action_id: &str, +) -> Result { + for join in reconcile_all_isolated_groups_at(root)? + .into_iter() + .filter(|join| { + join.parent_agent_id == parent_agent_id && join.parent_run_id == parent_run_id + }) + { + if read_isolated_join_delivery_at(root, &join)?.is_some_and(|delivery| { + delivery.status == IsolatedAgentJoinDeliveryStatus::ClaimedByParent + && delivery.claimed_by_action_id.as_deref() == Some(action_id) + }) { + return Ok(true); + } + } + Ok(false) +} + fn ready_isolated_join_status_for_parent_at( root: &Path, parent_agent_id: &str, @@ -33552,7 +33704,7 @@ pub(crate) fn game_creator_agent_runtime_tool_plan_system_prompt_for_agent( return prompt; } let prompt = format!( - "{prompt}\n\n你当前是项目唯一面向用户的 Project Supervisor,并拥有最终回复权。每一轮都必须把用户原始目标视为最高层业务目标,专业 Agent 回执只能补充证据,不能把回执内容改写成新目标。优先把边界清晰的专业工作通过带 acceptanceCriteria 和 expectedArtifacts 的 agent.delegate 分给静态专业 Agent,把互不重叠的临时并行检查通过 agent.spawn_isolated 分派;已有委派未收束时不要重复委派。需要等待专业 Agent 时返回空 response,让 Runtime 的 delegate/all-join 完成屏障保持同一父 run;取得 readyDelegateReceipts 或 readyIsolatedJoins 后直接整合结果。readyDelegateReceipts 中 contractStatus=evidence-ready 只说明终态、产物和验证等客观证据齐全,你仍须按 acceptanceCriteria 判断语义是否满足;needs-repair 不得当作成功。客观或语义不满足时可以发起一次新 agent.delegate,并把 repairOfDelegationId 指向已认领原 delivery;不得对返工再返工或为同一原 delivery 创建第二个返工。专业结果冲突且无法依据用户目标裁决时,合并问题后用一次 user.input_request 询问用户。只有实现路径、产品取舍或缺失事实会实质改变结果时才调用 user.input_request;项目内可读取事实、权限确认和工具失败不得伪装成用户问题。只在所有必要回执已认领、所有必要返工也已认领、项目副作用已验证且没有待确认动作或待回答请求时给用户最终回复。不要向用户暴露内部 task/event、工具计划、动态 child ID 或调试状态。" + "{prompt}\n\n你当前是项目唯一面向用户的 Project Supervisor,并拥有最终回复权。每一轮都必须把用户原始目标视为最高层业务目标,专业 Agent 回执只能补充证据,不能把回执内容改写成新目标。总控不能替代已有专业角色完成其领域交付:只要仓库目标同时包含两个以上互不依赖的专业方向,就必须自行查看静态角色目录,选择最匹配的不同专业 Agent,并在同一个 native planning 批次用带 acceptanceCriteria 和 expectedArtifacts 的 agent.delegate 发起委派,让这些方向并行;用户不需要点名 Agent、指定数量或提醒并行。只有没有匹配专业角色、纯协调工作或一两步轻量读取时才由总控直接处理。互不重叠的临时并行检查通过 agent.spawn_isolated 分派;已有委派未收束时不要重复委派。需要等待专业 Agent 时返回空 response,让 Runtime 的 delegate/all-join 完成屏障保持同一父 run;取得 readyDelegateReceipts 或 readyIsolatedJoins 后直接整合结果。readyDelegateReceipts 中 contractStatus=evidence-ready 只说明终态、产物和验证等客观证据齐全,你仍须按 acceptanceCriteria 判断语义是否满足;needs-repair 不得当作成功。客观或语义不满足时可以发起一次新 agent.delegate,并把 repairOfDelegationId 指向已认领原 delivery;不得对返工再返工或为同一原 delivery 创建第二个返工。专业结果冲突且无法依据用户目标裁决时,合并问题后用一次 user.input_request 询问用户。只有实现路径、产品取舍或缺失事实会实质改变结果时才调用 user.input_request;项目内可读取事实、权限确认和工具失败不得伪装成用户问题。只在所有必要回执已认领、所有必要返工也已认领、项目副作用已验证且没有待确认动作或待回答请求时给用户最终回复。不要向用户暴露内部 task/event、工具计划、动态 child ID 或调试状态。" ); format!( "{prompt}\n\n普通 agent.run_status 的 claimedDelegateContracts 只提供已认领合同目录。语义复核或返工前必须用原 delegationId 再调用 agent.run_status,读取 claimedDelegateContract 中未截断的 acceptanceCriteria 和 expectedArtifacts,并在 repair agent.delegate 中逐项原样提交。若返工因合同未完整继承而失败,失败 observation 中的 claimedDelegateContract 是同一 durable delivery 的权威快照,必须逐项据此修正;只有该字段缺失或身份不确定时才按同一 delegationId 重读,不得无目标地重复 run_status 或从 action_history 摘要猜测。" @@ -33620,6 +33772,9 @@ pub(crate) fn game_creator_agent_runtime_tool_plan_system_prompt() -> String { let prompt = format!( "{prompt} git.inspect 会返回 commitSnapshotFingerprint;只有当前非零 revision 已由本 run 验证通过,且已完整审阅变更时,才能用 project.git_commit 的 message、显式 paths、expectedHead 和 expectedSnapshotFingerprint 创建本地提交。project.git_commit 不允许访问 remote、切换分支或执行 merge、rebase、reset、stash、tag、submodule、worktree。" ); + let prompt = format!( + "{prompt} 作为被委派的专业 Agent 时,agent.message 只用于确有必要的中途协调,不能替代自身终态交付;验收、产物和验证已完成后,必须把全部必要计划步骤更新为 completed,并用一次最终 response 收束为父 Agent 可认领的回执,不得反复给同一 Agent 留消息或重复读取同一证据来维持 run。" + ); let isolated_template_ids = GAME_CREATOR_AGENT_GROUP_DEFINITIONS .iter() .flat_map(|group| group.roles.iter().map(|role| role.task_id)) diff --git a/apps/ai-game-creator-shell/src-tauri/src/project.rs b/apps/ai-game-creator-shell/src-tauri/src/project.rs index becc8557f..1e6ac0ac8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project.rs @@ -1608,6 +1608,86 @@ pub(crate) fn append_agent_db_record_if_missing_for_action( ) } +pub(crate) fn append_agent_db_agent_message_if_missing( + root: &Path, + agent_id: &str, + run_id: &str, + action_id: &str, + record: serde_json::Value, +) -> Result { + const RECORD_TYPE: &str = "agent.runtime.agent.message"; + let object = record + .as_object() + .ok_or_else(|| "Agent DB 定向消息审计必须是对象".to_string())?; + let expected_fields = [ + "actionId", + "agentId", + "contentChars", + "contentSha256", + "messageId", + "path", + "recordType", + "runId", + "targetAgentId", + "targetSessionId", + ]; + let mut actual_fields = object.keys().map(String::as_str).collect::>(); + actual_fields.sort_unstable(); + if actual_fields != expected_fields + || record.get("recordType").and_then(serde_json::Value::as_str) != Some(RECORD_TYPE) + || record.get("agentId").and_then(serde_json::Value::as_str) != Some(agent_id) + || record.get("runId").and_then(serde_json::Value::as_str) != Some(run_id) + || record.get("actionId").and_then(serde_json::Value::as_str) != Some(action_id) + { + return Err("Agent DB 定向消息审计身份或字段无效".to_string()); + } + if !action_id.strip_prefix("action-").is_some_and(|suffix| { + suffix.len() == 24 && suffix.bytes().all(|byte| byte.is_ascii_hexdigit()) + }) { + return Err("Agent DB 定向消息审计 actionId 无效".to_string()); + } + let message_id = record + .get("messageId") + .and_then(serde_json::Value::as_str) + .unwrap_or_default(); + let content_sha256 = record + .get("contentSha256") + .and_then(serde_json::Value::as_str) + .unwrap_or_default(); + let path = record + .get("path") + .and_then(serde_json::Value::as_str) + .unwrap_or_default(); + if !is_valid_agent_db_prefixed_hex(message_id, "agent-message-", 64) + || !is_valid_agent_db_sha256(content_sha256) + || record + .get("contentChars") + .and_then(serde_json::Value::as_u64) + .is_none_or(|count| count == 0 || count > 1_200) + || ["targetAgentId", "targetSessionId"].iter().any(|field| { + record + .get(*field) + .and_then(serde_json::Value::as_str) + .is_none_or(|value| value.trim().is_empty() || value.chars().any(char::is_control)) + }) + || Path::new(path).is_absolute() + || !path.starts_with(".agent/conversations/agents/") + || !path.ends_with(".jsonl") + || path.split('/').any(|segment| segment == "..") + { + return Err("Agent DB 定向消息审计元数据无效".to_string()); + } + append_agent_db_record_if_missing_for_action_internal( + root, + RECORD_TYPE, + agent_id, + run_id, + action_id, + record, + || {}, + ) +} + pub(crate) fn append_agent_db_process_reconciliation_if_missing_for_action( root: &Path, agent_id: &str, @@ -4878,7 +4958,7 @@ fn append_local_conversation_message_for_session_internal_at( message: LocalConversationMessage, message_id: Option<&str>, finalization_id: Option<&str>, -) -> Result { +) -> Result<(LocalConversationResult, bool), String> { let LocalConversationMessage { role, content, @@ -4930,7 +5010,8 @@ fn append_local_conversation_message_for_session_internal_at( if message_id.is_some() { return Err("带 messageId 的对话内容不能为空".to_string()); } - return read_local_conversation_for_session_at(root, agent_id, session_id); + return read_local_conversation_for_session_at(root, agent_id, session_id) + .map(|conversation| (conversation, false)); } let message_id = message_id .map(normalize_local_conversation_message_id) @@ -5040,11 +5121,14 @@ fn append_local_conversation_message_for_session_internal_at( appended, )?; } - Ok(local_conversation_result_from_persisted_records( - &path, - normalized_agent_id, - normalized_session_id, - &records, + Ok(( + local_conversation_result_from_persisted_records( + &path, + normalized_agent_id, + normalized_session_id, + &records, + ), + appended, )) } @@ -5057,6 +5141,7 @@ pub(crate) fn append_local_conversation_message_for_session_at( append_local_conversation_message_for_session_internal_at( root, agent_id, session_id, message, None, None, ) + .map(|(conversation, _)| conversation) } pub(crate) fn append_local_conversation_message_for_session_idempotent_at( @@ -5074,6 +5159,24 @@ pub(crate) fn append_local_conversation_message_for_session_idempotent_at( Some(message_id), None, ) + .map(|(conversation, _)| conversation) +} + +pub(crate) fn append_local_conversation_message_for_session_idempotent_with_status_at( + root: &Path, + agent_id: Option<&str>, + session_id: Option<&str>, + message: LocalConversationMessage, + message_id: &str, +) -> Result<(LocalConversationResult, bool), String> { + append_local_conversation_message_for_session_internal_at( + root, + agent_id, + session_id, + message, + Some(message_id), + None, + ) } pub(crate) fn append_local_conversation_message_for_session_idempotent_with_finalization_at( @@ -5092,6 +5195,7 @@ pub(crate) fn append_local_conversation_message_for_session_idempotent_with_fina Some(message_id), Some(finalization_id), ) + .map(|(conversation, _)| conversation) } pub(crate) fn append_local_conversation_message_at( 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 eac71bc16..014b375fb 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 @@ -8,6 +8,8 @@ const SWARM_CHAT_POLL_INTERVAL: Duration = Duration::from_millis(250); const SWARM_CHAT_SETTLE_WINDOW: Duration = Duration::from_millis(1_500); const SWARM_CHAT_HISTORY_LIMIT: usize = 50; const SWARM_CHAT_PLAN_STEP_LIMIT: usize = 8; +const SWARM_TURN_REPORT_PREFIX: &str = "[turn.report] "; +const SWARM_TURN_REPORT_SCHEMA_VERSION: &str = "game-creator-swarm-turn-report.v1"; #[derive(Debug, Eq, PartialEq)] enum SwarmChatInput { @@ -88,11 +90,46 @@ struct SwarmRejectedResponseStreamSnapshot { #[derive(Debug, Eq, PartialEq)] enum SwarmTurnOutcome { - Settled, - NeedsReconciliation(Vec), + Settled(SwarmTurnReport), + NeedsReconciliation { + agent_ids: Vec, + report: SwarmTurnReport, + }, Quit, } +#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Serialize)] +#[serde(rename_all = "kebab-case")] +enum SwarmTurnReportOutcome { + Settled, + NeedsReconciliation, +} + +#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize)] +#[serde(rename_all = "camelCase")] +struct SwarmTurnReport { + schema_version: &'static str, + outcome: SwarmTurnReportOutcome, + parent_agent_id: String, + session_id: String, + parent_run_id: Option, + runtime_count: usize, + busy_runtime_count: usize, + pending_task_count: u64, + running_task_count: u64, + waiting_for_confirmation_count: u64, + waiting_for_user_input_count: u64, + new_assistant_message_count: usize, + final_reply_chars: usize, + reconciliation_agent_count: usize, +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +struct SwarmTurnConversationMetrics { + new_assistant_message_count: usize, + final_reply_chars: usize, +} + enum SwarmInputEvent { Line(String), Eof, @@ -910,7 +947,14 @@ fn wait_for_swarm_turn( let mut reconciliation = swarm_reconciliation_agents(&runtimes); if !reconciliation.is_empty() { observer.close_response_line(output)?; - return Ok(SwarmTurnOutcome::NeedsReconciliation(reconciliation)); + return build_reconciliation_turn_outcome( + root, + parent_agent_id, + session_id, + previous_message_count, + &runtimes, + reconciliation, + ); } match observer.resolve_confirmations(root, parent_agent_id, &runtimes, input, output)? { SwarmConfirmationResolution::Handled => { @@ -942,7 +986,14 @@ fn wait_for_swarm_turn( if runtimes_are_busy(&runtimes) && (!runner.enabled || !runner.running) { reconciliation.push("external-runner".to_string()); observer.close_response_line(output)?; - return Ok(SwarmTurnOutcome::NeedsReconciliation(reconciliation)); + return build_reconciliation_turn_outcome( + root, + parent_agent_id, + session_id, + previous_message_count, + &runtimes, + reconciliation, + ); } } if runtimes_are_busy(&runtimes) { @@ -966,7 +1017,7 @@ fn wait_for_swarm_turn( stable_since = Some(Instant::now()); continue; } - print_new_parent_reply( + let conversation_metrics = print_new_parent_reply( root, parent_agent_id, session_id, @@ -974,7 +1025,15 @@ fn wait_for_swarm_turn( output, observer, )?; - return Ok(SwarmTurnOutcome::Settled); + let report = build_swarm_turn_report( + SwarmTurnReportOutcome::Settled, + parent_agent_id, + session_id, + &runtimes, + conversation_metrics, + 0, + ); + return Ok(SwarmTurnOutcome::Settled(report)); } } match input.recv_timeout(poll_interval) { @@ -1044,20 +1103,22 @@ fn wait_for_swarm_turn( } fn runtimes_are_busy(runtimes: &[AgentRuntimeResult]) -> bool { - runtimes.iter().any(|runtime| { - matches!( - runtime.state.status.as_str(), - "pending" - | "running" - | "waiting-for-confirmation" - | "waiting-for-user-input" - | "cancelling" - ) || runtime.state.phase == "needs-reconciliation" - || runtime.task_queue.pending > 0 - || runtime.task_queue.running > 0 - || runtime.task_queue.waiting_for_confirmation > 0 - || runtime.task_queue.waiting_for_user_input > 0 - }) + runtimes.iter().any(runtime_is_busy) +} + +fn runtime_is_busy(runtime: &AgentRuntimeResult) -> bool { + matches!( + runtime.state.status.as_str(), + "pending" + | "running" + | "waiting-for-confirmation" + | "waiting-for-user-input" + | "cancelling" + ) || runtime.state.phase == "needs-reconciliation" + || runtime.task_queue.pending > 0 + || runtime.task_queue.running > 0 + || runtime.task_queue.waiting_for_confirmation > 0 + || runtime.task_queue.waiting_for_user_input > 0 } fn swarm_reconciliation_agents(runtimes: &[AgentRuntimeResult]) -> Vec { @@ -1074,6 +1135,114 @@ fn swarm_reconciliation_agents(runtimes: &[AgentRuntimeResult]) -> Vec { .collect() } +fn build_reconciliation_turn_outcome( + root: &Path, + parent_agent_id: &str, + session_id: &str, + previous_message_count: usize, + runtimes: &[AgentRuntimeResult], + agent_ids: Vec, +) -> Result { + let conversation_metrics = + read_turn_conversation_metrics(root, parent_agent_id, session_id, previous_message_count)?; + let report = build_swarm_turn_report( + SwarmTurnReportOutcome::NeedsReconciliation, + parent_agent_id, + session_id, + runtimes, + conversation_metrics, + agent_ids.len(), + ); + Ok(SwarmTurnOutcome::NeedsReconciliation { agent_ids, report }) +} + +fn read_turn_conversation_metrics( + root: &Path, + parent_agent_id: &str, + session_id: &str, + previous_message_count: usize, +) -> Result { + let conversation = + read_local_conversation_for_session_at(root, Some(parent_agent_id), Some(session_id))?; + let (metrics, _) = summarize_new_assistant_messages( + conversation + .messages + .iter() + .skip(previous_message_count) + .map(|message| (message.role.as_str(), message.content.as_str())), + ); + Ok(metrics) +} + +fn summarize_new_assistant_messages<'a>( + messages: impl IntoIterator, +) -> (SwarmTurnConversationMetrics, Option<&'a str>) { + let mut new_assistant_message_count = 0; + let mut final_reply = None; + for (role, content) in messages { + if role == "assistant" { + new_assistant_message_count += 1; + final_reply = Some(content); + } + } + ( + SwarmTurnConversationMetrics { + new_assistant_message_count, + final_reply_chars: final_reply.map_or(0, |reply| reply.chars().count()), + }, + final_reply, + ) +} + +fn build_swarm_turn_report( + outcome: SwarmTurnReportOutcome, + parent_agent_id: &str, + session_id: &str, + runtimes: &[AgentRuntimeResult], + conversation_metrics: SwarmTurnConversationMetrics, + reconciliation_agent_count: usize, +) -> SwarmTurnReport { + let parent_run_id = runtimes + .iter() + .find(|runtime| { + runtime.state.agent_id == parent_agent_id && runtime.state.session_id == session_id + }) + .map(|runtime| runtime.state.run_id.trim()) + .filter(|run_id| !run_id.is_empty()) + .map(str::to_string); + SwarmTurnReport { + schema_version: SWARM_TURN_REPORT_SCHEMA_VERSION, + outcome, + parent_agent_id: parent_agent_id.to_string(), + session_id: session_id.to_string(), + parent_run_id, + runtime_count: runtimes.len(), + busy_runtime_count: runtimes + .iter() + .filter(|runtime| runtime_is_busy(runtime)) + .count(), + pending_task_count: runtimes + .iter() + .map(|runtime| u64::from(runtime.task_queue.pending)) + .sum(), + running_task_count: runtimes + .iter() + .map(|runtime| u64::from(runtime.task_queue.running)) + .sum(), + waiting_for_confirmation_count: runtimes + .iter() + .map(|runtime| u64::from(runtime.task_queue.waiting_for_confirmation)) + .sum(), + waiting_for_user_input_count: runtimes + .iter() + .map(|runtime| u64::from(runtime.task_queue.waiting_for_user_input)) + .sum(), + new_assistant_message_count: conversation_metrics.new_assistant_message_count, + final_reply_chars: conversation_metrics.final_reply_chars, + reconciliation_agent_count, + } +} + fn print_new_parent_reply( root: &Path, parent_agent_id: &str, @@ -1081,23 +1250,19 @@ fn print_new_parent_reply( previous_message_count: usize, output: &mut W, observer: &mut SwarmRuntimeObserver, -) -> Result<(), String> { +) -> Result { let conversation = read_local_conversation_for_session_at(root, Some(parent_agent_id), Some(session_id))?; - let reply = conversation - .messages - .iter() - .skip(previous_message_count) - .filter(|message| message.role == "assistant") - .next_back(); + let (metrics, reply) = summarize_new_assistant_messages( + conversation + .messages + .iter() + .skip(previous_message_count) + .map(|message| (message.role.as_str(), message.content.as_str())), + ); observer.close_response_line(output)?; - print_settled_parent_reply( - parent_agent_id, - session_id, - reply.map(|message| message.content.as_str()), - observer, - output, - ) + print_settled_parent_reply(parent_agent_id, session_id, reply, observer, output)?; + Ok(metrics) } fn print_settled_parent_reply( @@ -1120,15 +1285,29 @@ fn print_settled_parent_reply( } fn print_turn_outcome(outcome: SwarmTurnOutcome, output: &mut W) -> Result<(), String> { - if let SwarmTurnOutcome::NeedsReconciliation(agent_ids) = outcome { - writeln!( - output, - "[已阻断] 以下 Agent 需要人工 reconciliation:{}", - agent_ids.join(", ") - ) - .map_err(|error| format!("写入终端失败:{error}"))?; + match outcome { + SwarmTurnOutcome::Settled(report) => print_swarm_turn_report(&report, output), + SwarmTurnOutcome::NeedsReconciliation { agent_ids, report } => { + writeln!( + output, + "[已阻断] 以下 Agent 需要人工 reconciliation:{}", + agent_ids.join(", ") + ) + .map_err(|error| format!("写入终端失败:{error}"))?; + print_swarm_turn_report(&report, output) + } + SwarmTurnOutcome::Quit => Ok(()), } - Ok(()) +} + +fn print_swarm_turn_report( + report: &SwarmTurnReport, + output: &mut W, +) -> Result<(), String> { + let json = serde_json::to_string(report) + .map_err(|error| format!("序列化 turn report 失败:{error}"))?; + writeln!(output, "{SWARM_TURN_REPORT_PREFIX}{json}") + .map_err(|error| format!("写入终端失败:{error}")) } impl SwarmResponseStreamIdentity { @@ -2175,6 +2354,176 @@ mod tests { ); } + #[test] + fn turn_report_counts_runtime_and_conversation_snapshots() { + let mut parent = runtime("running", "response", 2); + parent.state.agent_id = "project-supervisor".to_string(); + parent.state.session_id = "session-report".to_string(); + parent.state.run_id = "run-parent".to_string(); + parent.task_queue.running = 1; + parent.task_queue.waiting_for_confirmation = 2; + + let mut child = runtime("idle", "completed", 3); + child.state.agent_id = "child-code".to_string(); + child.task_queue.waiting_for_user_input = 1; + + let mut idle = runtime("idle", "completed", 0); + idle.state.agent_id = "design-review".to_string(); + let runtimes = vec![parent, child, idle]; + let (conversation_metrics, final_reply) = summarize_new_assistant_messages([ + ("user", "请继续"), + ("assistant", "阶段回复"), + ("tool", "PRIVATE_OBSERVATION"), + ("assistant", "最终🙂"), + ]); + assert_eq!(final_reply, Some("最终🙂")); + + let report = build_swarm_turn_report( + SwarmTurnReportOutcome::NeedsReconciliation, + "project-supervisor", + "session-report", + &runtimes, + conversation_metrics, + 1, + ); + + assert_eq!(report.schema_version, SWARM_TURN_REPORT_SCHEMA_VERSION); + assert_eq!(report.outcome, SwarmTurnReportOutcome::NeedsReconciliation); + assert_eq!(report.parent_agent_id, "project-supervisor"); + assert_eq!(report.session_id, "session-report"); + assert_eq!(report.parent_run_id.as_deref(), Some("run-parent")); + assert_eq!(report.runtime_count, 3); + assert_eq!(report.busy_runtime_count, 2); + assert_eq!(report.pending_task_count, 5); + assert_eq!(report.running_task_count, 1); + assert_eq!(report.waiting_for_confirmation_count, 2); + assert_eq!(report.waiting_for_user_input_count, 1); + assert_eq!(report.new_assistant_message_count, 2); + assert_eq!(report.final_reply_chars, "最终🙂".chars().count()); + assert_eq!(report.reconciliation_agent_count, 1); + } + + #[test] + fn turn_report_json_is_single_line_and_omits_sensitive_bodies_and_paths() { + let sensitive_reply = concat!( + "PRIVATE_REPLY_BODY\n", + "/private/project/root ", + "prompt=DO_NOT_LEAK observation=DO_NOT_LEAK CREDENTIAL_SENTINEL" + ); + let (conversation_metrics, _) = + summarize_new_assistant_messages([("assistant", sensitive_reply)]); + let report = build_swarm_turn_report( + SwarmTurnReportOutcome::Settled, + "project-supervisor", + "session-safe", + &[], + conversation_metrics, + 0, + ); + let json = serde_json::to_string(&report).expect("serialize turn report"); + let value = serde_json::from_str::(&json).expect("parse turn report"); + let object = value.as_object().expect("turn report is an object"); + + assert_eq!(json.lines().count(), 1); + assert_eq!(object.len(), 14); + for key in [ + "schemaVersion", + "outcome", + "parentAgentId", + "sessionId", + "parentRunId", + "runtimeCount", + "busyRuntimeCount", + "pendingTaskCount", + "runningTaskCount", + "waitingForConfirmationCount", + "waitingForUserInputCount", + "newAssistantMessageCount", + "finalReplyChars", + "reconciliationAgentCount", + ] { + assert!(object.contains_key(key), "turn report omitted {key}"); + } + assert_eq!( + value["schemaVersion"], + serde_json::json!(SWARM_TURN_REPORT_SCHEMA_VERSION) + ); + assert_eq!(value["outcome"], serde_json::json!("settled")); + assert_eq!(value["parentRunId"], serde_json::Value::Null); + assert_eq!(value["newAssistantMessageCount"], serde_json::json!(1)); + assert_eq!( + value["finalReplyChars"], + serde_json::json!(sensitive_reply.chars().count()) + ); + for forbidden in [ + "PRIVATE_REPLY_BODY", + "/private/project/root", + "DO_NOT_LEAK", + "CREDENTIAL_SENTINEL", + ] { + assert!(!json.contains(forbidden), "report leaked {forbidden}"); + } + } + + #[test] + fn turn_outcome_prints_settled_and_reconciliation_reports_but_not_quit() { + let metrics = SwarmTurnConversationMetrics { + new_assistant_message_count: 1, + final_reply_chars: 4, + }; + let settled_report = build_swarm_turn_report( + SwarmTurnReportOutcome::Settled, + "project-supervisor", + "session-settled", + &[], + metrics, + 0, + ); + let mut settled_output = Vec::new(); + print_turn_outcome( + SwarmTurnOutcome::Settled(settled_report), + &mut settled_output, + ) + .expect("print settled report"); + let settled_output = String::from_utf8(settled_output).expect("settled output is utf-8"); + assert_eq!(settled_output.lines().count(), 1); + assert!(settled_output.starts_with(SWARM_TURN_REPORT_PREFIX)); + assert!(settled_output.contains("\"outcome\":\"settled\"")); + + let reconciliation_report = build_swarm_turn_report( + SwarmTurnReportOutcome::NeedsReconciliation, + "project-supervisor", + "session-reconciliation", + &[], + metrics, + 2, + ); + let mut reconciliation_output = Vec::new(); + print_turn_outcome( + SwarmTurnOutcome::NeedsReconciliation { + agent_ids: vec!["code-prototype".to_string(), "external-runner".to_string()], + report: reconciliation_report, + }, + &mut reconciliation_output, + ) + .expect("print reconciliation report"); + let reconciliation_output = + String::from_utf8(reconciliation_output).expect("reconciliation output is utf-8"); + let lines = reconciliation_output.lines().collect::>(); + assert_eq!(lines.len(), 2); + assert_eq!( + lines[0], + "[已阻断] 以下 Agent 需要人工 reconciliation:code-prototype, external-runner" + ); + assert!(lines[1].starts_with(SWARM_TURN_REPORT_PREFIX)); + assert!(lines[1].contains("\"outcome\":\"needs-reconciliation\"")); + assert!(lines[1].contains("\"reconciliationAgentCount\":2")); + + let mut quit_output = Vec::new(); + print_turn_outcome(SwarmTurnOutcome::Quit, &mut quit_output).expect("ignore quit"); + assert!(quit_output.is_empty()); + } + #[test] fn blank_agent_snapshots_do_not_reset_the_settle_window() { let mut blank = runtime("idle", "idle", 0); @@ -2520,10 +2869,23 @@ mod tests { &mut output, ) .expect("settle streamed reply"); + let (conversation_metrics, _) = + summarize_new_assistant_messages([("assistant", "权威最终回复")]); + let report = build_swarm_turn_report( + SwarmTurnReportOutcome::Settled, + "code-prototype", + "session-test", + &[], + conversation_metrics, + 0, + ); + print_turn_outcome(SwarmTurnOutcome::Settled(report), &mut output) + .expect("print settled report after stream"); let output = String::from_utf8(output).expect("settle output is utf-8"); assert_eq!(output.matches("权威最终回复").count(), 1); assert!(output.contains("父 Agent 回复已完整流式输出")); + assert!(output.contains(SWARM_TURN_REPORT_PREFIX)); let mut fallback = Vec::new(); print_settled_parent_reply( diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/tests.rs index 2b747a327..ed9c03aaf 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests.rs @@ -11955,6 +11955,154 @@ fn agent_runtime_context_window_ignores_dynamic_successful_verification_output() } } +#[test] +fn agent_runtime_context_window_skips_duplicate_agent_message_noops() { + let mut tracker = AgentRuntimeContextWindowTracker::default(); + for next_loop_index in 1..=AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT { + tracker.record(&AgentRuntimeToolObservation { + tool: "agent.message".to_string(), + status: "ok".to_string(), + summary: "给 project-supervisor 的相同定向消息已存在,未重复追加".to_string(), + detail: Some("messageAppended=false".to_string()), + }); + let checkpoint = tracker.complete_loop(next_loop_index); + if next_loop_index == AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT { + assert_eq!( + checkpoint, + AgentRuntimeContextCheckpoint::Stalled, + "durable duplicate messages must not keep a run alive" + ); + } else { + assert_eq!(checkpoint, AgentRuntimeContextCheckpoint::Continue); + } + } +} + +#[test] +fn agent_runtime_context_window_counts_distinct_agent_message_bodies() { + let mut tracker = AgentRuntimeContextWindowTracker::default(); + for next_loop_index in 1..=AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT { + let detail = if next_loop_index % 2 == 0 { + "第二条真实新消息" + } else { + "第一条真实新消息" + }; + tracker.record(&AgentRuntimeToolObservation { + tool: "agent.message".to_string(), + status: "ok".to_string(), + summary: "已给 project-supervisor 留消息".to_string(), + detail: Some(detail.to_string()), + }); + let checkpoint = tracker.complete_loop(next_loop_index); + if next_loop_index == AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT { + assert_eq!( + checkpoint, + AgentRuntimeContextCheckpoint::Compacted, + "two distinct durable messages must still count as independent progress" + ); + } else { + assert_eq!(checkpoint, AgentRuntimeContextCheckpoint::Continue); + } + } +} + +#[test] +fn agent_runtime_agent_message_uses_durable_semantic_idempotency() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "定向消息幂等测试").expect("project init"); + let first_input = serde_json::json!({ + "agentId": "art-asset-plan", + "content": "请核对主角剪影与月光食材。" + }); + let first = observe_agent_runtime_agent_message( + &root, + "design-director", + "agent-message-semantic-run", + &first_input, + ); + assert_eq!(first.status, "ok", "{first:?}"); + assert!(first.summary.contains("已给 art-asset-plan 留消息")); + + let duplicate = observe_agent_runtime_agent_message( + &root, + "design-director", + "agent-message-semantic-run", + &first_input, + ); + assert_eq!(duplicate.status, "ok"); + assert!(duplicate.summary.contains("相同定向消息已存在")); + assert_eq!(duplicate.detail.as_deref(), Some("messageAppended=false")); + + let distinct = observe_agent_runtime_agent_message( + &root, + "design-director", + "agent-message-semantic-run", + &serde_json::json!({ + "agentId": "art-asset-plan", + "content": "请补充暗影厨具的轮廓风险。" + }), + ); + assert_eq!(distinct.status, "ok"); + assert!(distinct.summary.contains("已给 art-asset-plan 留消息")); + let other_target = observe_agent_runtime_agent_message( + &root, + "design-director", + "agent-message-semantic-run", + &serde_json::json!({ + "agentId": "quality-review", + "content": "请核对主角剪影与月光食材。" + }), + ); + assert_eq!(other_target.status, "ok"); + assert!(other_target.summary.contains("已给 quality-review 留消息")); + + let art_conversation = + read_local_conversation_at(&root, Some("art-asset-plan")).expect("art conversation"); + assert_eq!( + art_conversation + .messages + .iter() + .filter(|message| message.role == "tool") + .count(), + 2 + ); + let quality_conversation = + read_local_conversation_at(&root, Some("quality-review")).expect("quality conversation"); + assert_eq!( + quality_conversation + .messages + .iter() + .filter(|message| message.role == "tool") + .count(), + 1 + ); + let message_records = read_agent_db_records_for_test(&root) + .into_iter() + .filter(|record| { + record.get("recordType").and_then(Value::as_str) == Some("agent.runtime.agent.message") + }) + .collect::>(); + assert_eq!(message_records.len(), 3); + assert_eq!( + message_records + .iter() + .filter_map(|record| record.get("messageId").and_then(Value::as_str)) + .collect::>() + .len(), + 3 + ); + assert!(message_records.iter().all(|record| { + record + .get("contentSha256") + .and_then(Value::as_str) + .is_some() + && record.get("contentChars").and_then(Value::as_u64).is_some() + && record.get("content").is_none() + })); + + fs::remove_dir_all(root).ok(); +} + #[test] fn agent_runtime_context_bundle_preserves_project_verification_gate_evidence() { let root = unique_project_path(); @@ -14220,6 +14368,146 @@ fn runtime_v11_waiting_isolated_join_resume_preserves_loop_and_child() { fs::remove_dir_all(root).ok(); } +#[tokio::test] +async fn project_supervisor_mixed_waiting_recovery_does_not_plan_until_all_join_ready() { + use platform_agent::game_creation::{ + GameCreationIsolatedAgentChildSpec, GameCreationIsolatedAgentJoinMode, + GameCreationIsolatedAgentSpawnRequest, + }; + + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "混合委派等待恢复测试").expect("project init"); + let (sender, receiver) = mpsc::channel(); + let base_url = spawn_mock_llm_server_responses_with_capture( + vec![final_tool_plan_response( + "不应在 all-join ready 前请求 Provider", + )], + Some(sender), + ); + let _config_guard = write_test_local_config(format!( + r#"{{ + "agentLlm": {{ + "project-supervisor": {{ + "apiKey": "mixed-waiting-key", + "baseUrl": {base_url:?}, + "model": "mixed-waiting-model", + "apiKind": "openai_responses" + }} + }} +}}"# + )); + let task = "等待静态专业回执与动态隔离 all-join"; + let parent_run_id = "project-supervisor-mixed-waiting-run"; + let mut parent_state = start_game_creator_agent_runtime_task_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + task, + parent_run_id, + "agent-chat", + "等待混合委派", + vec!["取得两类交付后统一收束".to_string()], + ) + .expect("start mixed waiting parent"); + parent_state.status = "running".to_string(); + parent_state.phase = "waiting-for-delegate-receipts".to_string(); + parent_state.current_action = "等待专业 Agent 委派回执".to_string(); + parent_state.waiting_on = "静态与隔离 Agent 完成".to_string(); + parent_state.next_step = "保持等待,不请求 Provider".to_string(); + parent_state.loop_iteration = 4; + parent_state.max_loop_iterations = 18; + + let static_delivery = new_static_delegate_delivery( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &parent_state.session_id, + parent_run_id, + "project-supervisor-mixed-waiting-static-action", + "project-supervisor-mixed-waiting-static-delivery", + "design-director", + "project-supervisor-mixed-waiting-static-session", + "project-supervisor-mixed-waiting-static-run", + ); + create_or_read_static_delegate_delivery_at(&root, &static_delivery) + .expect("create ready static delivery"); + mark_static_delegate_delivery_ready_at( + &root, + &static_delivery.target_agent_id, + &static_delivery.target_session_id, + &static_delivery.target_run_id, + &static_delivery.delegation_id, + "completed", + "静态专业交付已完成", + ) + .expect("mark static delivery ready"); + let isolated_request = GameCreationIsolatedAgentSpawnRequest { + children: vec![GameCreationIsolatedAgentChildSpec { + template_agent_id: "code-prototype".to_string(), + task: "继续执行尚未完成的隔离检查".to_string(), + acceptance_criteria: vec!["隔离检查完成".to_string()], + expected_artifacts: vec!["game/mixed/output.txt".to_string()], + write_scopes: vec!["game/mixed/**".to_string()], + }], + join_mode: GameCreationIsolatedAgentJoinMode::All, + }; + create_or_read_isolated_group_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + &parent_state.session_id, + "project-supervisor-mixed-waiting-isolated-action", + &isolated_request, + ) + .expect("create waiting isolated group"); + append_game_creator_agent_runtime_task(&root, &parent_state) + .expect("append mixed waiting parent task"); + write_game_creator_agent_runtime_state(&root, &parent_state) + .expect("persist mixed waiting parent state"); + let context = build_game_creator_agent_runtime_context_bundle( + &root, + &parent_state, + task, + &AgentRuntimeToolPlan::default(), + &[], + 4, + &AgentRuntimeContextWindowTracker::default(), + ) + .expect("build mixed waiting context"); + write_game_creator_agent_runtime_context_bundle(&root, &context) + .expect("persist mixed waiting context"); + + resume_game_creator_agent_background_tasks_at(&root).expect("resume mixed waiting parent"); + let deadline = std::time::Instant::now() + Duration::from_secs(2); + loop { + let runtime = + read_game_creator_agent_runtime_at(&root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID) + .expect("read mixed waiting parent") + .state; + if runtime.phase == "waiting-for-isolated-join" { + assert_eq!(runtime.run_id, parent_run_id); + assert_eq!(runtime.loop_iteration, 4); + assert_eq!(runtime.max_loop_iterations, 18); + break; + } + assert!( + std::time::Instant::now() < deadline, + "mixed parent did not switch to isolated waiting" + ); + tokio::time::sleep(Duration::from_millis(10)).await; + } + assert!(receiver.recv_timeout(Duration::from_millis(200)).is_err()); + resume_game_creator_agent_background_tasks_at(&root) + .expect("repeat resume mixed waiting parent"); + assert!(receiver.recv_timeout(Duration::from_millis(200)).is_err()); + let stable = + read_game_creator_agent_runtime_at(&root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID) + .expect("read stable mixed waiting parent") + .state; + assert_eq!(stable.run_id, parent_run_id); + assert_eq!(stable.phase, "waiting-for-isolated-join"); + assert_eq!(stable.loop_iteration, 4); + + fs::remove_dir_all(root).ok(); +} + #[test] fn repository_context_v1_pending_fingerprint_blocks_project_mutation() { let root = unique_project_path(); @@ -44632,6 +44920,9 @@ async fn project_supervisor_prompts_are_total_control_and_reject_isolated_templa "唯一面向用户的 Project Supervisor", "用户原始目标", "专业 Agent 回执只能补充证据", + "总控不能替代已有专业角色", + "同一个 native planning 批次", + "用户不需要点名 Agent", "agent.delegate", "readyDelegateReceipts", "acceptanceCriteria", @@ -44639,6 +44930,7 @@ async fn project_supervisor_prompts_are_total_control_and_reject_isolated_templa "evidence-ready", "所有必要回执已认领", "不能反复只改 explanation", + "不能替代自身终态交付", ] { assert!( planning_prompt.contains(expected), @@ -47776,6 +48068,273 @@ async fn project_supervisor_resume_replays_executing_run_status_observation() { fs::remove_dir_all(root).ok(); } +#[tokio::test] +async fn project_supervisor_mixed_run_status_recovery_reuses_partial_isolated_claim_after_revision_drift( +) { + use platform_agent::game_creation::{ + GameCreationIsolatedAgentArtifact, GameCreationIsolatedAgentChildResult, + GameCreationIsolatedAgentChildSpec, GameCreationIsolatedAgentEvidence, + GameCreationIsolatedAgentJoinMode, GameCreationIsolatedAgentResultStatus, + GameCreationIsolatedAgentSpawnRequest, + }; + + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "混合委派部分认领恢复测试") + .expect("project init"); + let base_url = spawn_mock_llm_server_responses(vec![final_tool_plan_response( + "已恢复同一 mixed run_status 并完成收束。", + )]); + let _config_guard = write_test_local_config(format!( + r#"{{ + "agentLlm": {{ + "project-supervisor": {{ + "apiKey": "mixed-resume-key", + "baseUrl": {base_url:?}, + "model": "mixed-resume-model", + "apiKind": "openai_responses" + }} + }} +}}"# + )); + let parent_run_id = "project-supervisor-mixed-resume-parent-run"; + let mut parent_state = start_game_creator_agent_runtime_task_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "恢复部分完成的混合交付认领动作", + parent_run_id, + "agent-chat", + "认领 mixed 交付", + vec!["恢复同一个 agent.run_status observation".to_string()], + ) + .expect("start mixed recovery parent"); + parent_state.loop_iteration = 1; + + let static_delivery = new_static_delegate_delivery( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &parent_state.session_id, + parent_run_id, + "project-supervisor-mixed-resume-static-action", + "project-supervisor-mixed-resume-static-delivery", + "design-director", + "project-supervisor-mixed-resume-static-session", + "project-supervisor-mixed-resume-static-run", + ); + create_or_read_static_delegate_delivery_at(&root, &static_delivery) + .expect("create mixed static delivery"); + mark_static_delegate_delivery_ready_at( + &root, + &static_delivery.target_agent_id, + &static_delivery.target_session_id, + &static_delivery.target_run_id, + &static_delivery.delegation_id, + "completed", + "静态交付已 ready", + ) + .expect("mark mixed static delivery ready"); + + let isolated_request = GameCreationIsolatedAgentSpawnRequest { + children: vec![GameCreationIsolatedAgentChildSpec { + template_agent_id: "code-prototype".to_string(), + task: "完成独立 mixed 检查".to_string(), + acceptance_criteria: vec!["mixed 检查已完成".to_string()], + expected_artifacts: vec!["game/mixed/result.txt".to_string()], + write_scopes: vec!["game/mixed/**".to_string()], + }], + join_mode: GameCreationIsolatedAgentJoinMode::All, + }; + let group = create_or_read_isolated_group_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + &parent_state.session_id, + "project-supervisor-mixed-resume-isolated-action", + &isolated_request, + ) + .expect("create mixed isolated group"); + let instance = resolve_isolated_agent_instance_at(&root, &group.instance_ids[0]) + .expect("resolve mixed isolated instance"); + let join = record_isolated_child_result_at( + &root, + &GameCreationIsolatedAgentChildResult { + delegation_id: instance.delegation_id.clone(), + instance_id: instance.instance_id.clone(), + template_agent_id: instance.template_agent_id.clone(), + run_id: instance.run_id.clone(), + status: GameCreationIsolatedAgentResultStatus::Completed, + summary: "mixed 隔离检查已完成".to_string(), + artifacts: vec![GameCreationIsolatedAgentArtifact { + path: "game/mixed/result.txt".to_string(), + sha256: "b".repeat(64), + }], + evidence: vec![GameCreationIsolatedAgentEvidence { + kind: "project.verify".to_string(), + summary: "mixed 隔离验证通过".to_string(), + path: None, + sha256: None, + }], + verified_revision: Some(1), + error: None, + }, + ) + .expect("record mixed isolated result") + .expect("mixed isolated join ready"); + + let mut pending = pending_tool_action_for_test( + &root, + &parent_state, + AgentRuntimeToolAction { + tool: "agent.run_status".to_string(), + reason: Some("同时认领 static receipt 与 isolated all-join".to_string()), + input: serde_json::json!({ "scope": "self" }), + }, + AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING, + None, + ); + pending.execution_mode = AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO.to_string(); + write_game_creator_agent_runtime_pending_tool_action(&root, &pending) + .expect("persist mixed executing run_status"); + append_agent_db_record( + &root, + serde_json::json!({ + "recordType": "agent.runtime.tool_action.executing", + "agentId": pending.agent_id, + "taskId": pending.task_id, + "runId": pending.run_id, + "actionId": pending.action_id, + "actionFingerprint": pending.action_fingerprint, + "tool": pending.action.tool, + "executionMode": AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO, + "inputSummary": pending.input_summary, + }), + ) + .expect("append mixed executing audit"); + parent_state.status = "running".to_string(); + parent_state.phase = "action".to_string(); + parent_state.current_action = "恢复执行 mixed agent.run_status".to_string(); + parent_state.pending_tool_action = Some(pending.summary()); + append_game_creator_agent_runtime_task(&root, &parent_state) + .expect("append mixed executing parent task"); + write_game_creator_agent_runtime_state(&root, &parent_state) + .expect("persist mixed executing parent state"); + + let partial_claim = write_isolated_join_delivery_at( + &root, + &join, + IsolatedAgentJoinDeliveryStatus::ClaimedByParent, + None, + Some(&pending.action_id), + ) + .expect("persist partial isolated claim before crash"); + assert_eq!( + partial_claim.claimed_by_action_id.as_deref(), + Some(pending.action_id.as_str()) + ); + let before_static = static_delegate_completion_barrier_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + ) + .expect("read static barrier before mixed recovery"); + assert_eq!(before_static.ready_unclaimed_count, 1); + advance_project_revision_for_test( + &root, + "code-prototype", + "mixed-external-revision-run", + "file.write", + ); + + let resumed = resume_game_creator_agent_pending_action_for_agent_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + &pending.action_id, + ) + .expect("resume partially claimed mixed run_status"); + assert_eq!(resumed.state.run_id, parent_run_id); + assert_eq!(resumed.state.phase, "observation"); + let stable_isolated = read_isolated_join_delivery_at(&root, &join) + .expect("read isolated claim after mixed recovery") + .expect("isolated claim remains present"); + assert_eq!(stable_isolated, partial_claim); + let claimed_static = read_static_delegate_delivery_at(&root, &static_delivery.delegation_id) + .expect("read claimed static delivery") + .expect("claimed static delivery exists"); + assert_eq!( + claimed_static.status, + StaticDelegateDeliveryStatus::ClaimedByParent + ); + assert_eq!( + claimed_static.claimed_by_action_id.as_deref(), + Some(pending.action_id.as_str()) + ); + assert!(static_delegate_completion_barrier_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + ) + .expect("read observed mixed static barrier") + .is_clear()); + + let completed = wait_for_agent_runtime_idle(&root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID); + assert_eq!(completed.phase, "completed"); + assert_eq!( + completed.last_response.as_deref(), + Some("已恢复同一 mixed run_status 并完成收束。") + ); + let replayed = observe_agent_runtime_run_status( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + Some(&pending.action_id), + &serde_json::json!({ "scope": "self" }), + ); + assert_eq!(replayed.status, "ok"); + let replayed_detail = replayed.detail.unwrap_or_default(); + assert!(replayed_detail.contains("readyIsolatedJoins")); + assert!(replayed_detail.contains("readyDelegateReceipts")); + let records = read_agent_db_records_for_test(&root); + assert_eq!( + records + .iter() + .filter(|record| { + record.get("recordType").and_then(Value::as_str) + == Some("agent.runtime.agent.isolated_join.claimed_by_parent") + && record.get("actionId").and_then(Value::as_str) + == Some(pending.action_id.as_str()) + }) + .count(), + 1 + ); + assert_eq!( + records + .iter() + .filter(|record| { + record.get("recordType").and_then(Value::as_str) + == Some("agent.runtime.agent.delegate_receipts.claimed_by_parent") + && record.get("actionId").and_then(Value::as_str) + == Some(pending.action_id.as_str()) + }) + .count(), + 1 + ); + let conversation = read_local_conversation_for_session_at( + &root, + Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID), + Some(&parent_state.session_id), + ) + .expect("read mixed recovery supervisor conversation"); + assert_eq!( + conversation + .messages + .iter() + .filter(|message| message.role == "assistant") + .count(), + 1 + ); + + fs::remove_dir_all(root).ok(); +} + #[test] fn project_supervisor_resume_rechecks_delegate_policy_after_delivery_reservation() { let root = unique_project_path(); diff --git a/docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md b/docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md index 148a9ba52..19f059dcb 100644 --- a/docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md +++ b/docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md @@ -662,6 +662,7 @@ V1.15 新增不依赖 Tauri WebView 或正常客户端 GUI 的终端聊天入口 - 终端只提供聊天所需的轻量控制命令:`/help`、`/agents`、`/status`、`/history`、`/quit`。空闲时普通文本创建父 Agent 新 run;父 Agent run 仍处于 pending / running 时普通文本追加为同一 run steer,只有 child 忙而父 Agent 已终态时拒绝吞掉输入并要求稍后重发。确认动作在终端显示 Agent、run、action、tool 和安全摘要,并接受 `approve / reject`,分别调用现有 confirm / reject Runtime 路径,不能要求回到开发窗口。stdin 由独立读取线程投递,因此活跃 run 中 `/quit`、EOF、状态命令和 steer 仍可响应;退出只结束观察客户端。 - 每轮轮询 `read_game_creator_agent_runtimes_at`,按 Agent / run / event 去重输出状态、phase、委派来源、父 Agent、delegationId、动态 child 和 join / receipt 事件。Provider token delta 当前没有经过 Runner RPC 暴露,首版只承诺 Runtime 状态与事件的持续输出以及持久化后的最终父 Agent 回复,禁止用拆字或延时打印伪装 token streaming。 - 一轮只有在所有已发现 Runtime 都不处于 `pending / running / waiting-for-confirmation / cancelling / needs-reconciliation`,全部任务队列为空,并持续经过稳定观察窗口后才能收束。父 run 暂时 idle 但 delegated child 尚未终态、receipt 尚未入队或 all-join 尚未认领时不得提前返回。失败、取消和 reconciliation 要明确显示并保留项目现场,不自动重试副作用。 +- 每轮进入 `settled` 或 `needs-reconciliation` 终态后,终端必须额外输出且只输出一条 `[turn.report] <单行 JSON>`,schema 固定为 `game-creator-swarm-turn-report.v1`。报告只从本轮 authoritative conversation 与 Runtime snapshot 计算,白名单字段至少包含 outcome、父 Agent/Session/run 身份、Runtime 忙闲数量、四类任务队列计数、新增 assistant 数量、最终回复字符数和 reconciliation Agent 数量;不得包含项目路径、对话/任务/回复正文、observation、prompt、event detail、隐藏 thinking、Provider payload、凭据或本地存储路径。该行用于开发终端和真实 E2E 定位一轮边界,不替代 task/event/delivery/claim/receipt/conversation 等持久事实;`/quit` 不伪造 turn report。 - 终端退出只结束观察客户端,不终止 External Runner、已投递 run 或 Runner-owned process session;下次启动先调用现有 resume,再从 conversation 与 Runtime journal 恢复。首版只允许一个前台输入流,不承诺多个终端并发编辑同一 active Session。 确定性验收必须覆盖 CLI parse、项目绝对路径与 `--config-dir` 门禁、`--init`、空输入和 EOF、命令分流、历史恢复、连续两轮写入同一 Session、状态与事件去重、两个静态 Agent 并行委派、多个隔离 child 并行与唯一 all-join、confirm / reject、父 Agent receipt 汇总、稳定窗口不早退、Runner / 终端重启恢复以及失败与 reconciliation 显示。真实 Provider 验收必须保存一份脱敏 transcript,并以 task / event / Agent DB / receipt / conversation 的结构化事实证明并行、最终父回复唯一、副作用无重放和密钥零泄漏;未实际运行时只能标记未验收,不能凭确定性测试宣称 swarm 可用。 @@ -1128,6 +1129,18 @@ Supervisor 认领回执后必须能够再次从 durable delivery 取回权威返 该受控 suite 是 V1.28 协议与恢复的故障注入门禁,不替代后续自主 Swarm 验收。现有 fixture 明确给出两个专业方向、同轮要求和一次 repair 上限;“Supervisor 在不提供 Agent ID、并行配方或 repair 次数时自主选择编排”仍需独立 `supervisor-swarm-autonomous` 真实 suite 证明。真实 `--swarm-chat`、同一 run 的 static delivery + isolated all-join 组合以及 Tauri/WebView 宿主级 Supervisor GUI 也仍是单独完成项。 +## V1.30 Project Supervisor 自主终端协作验收 + +V1.30 新增独立 `supervisor-swarm-autonomous-chat` 真实 Provider suite,同时证明 Project Supervisor 的自主专业编排和正式 `agc:chat / --swarm-chat` 入口。它复用 V1.28 的 static delivery/claim/repair、External Runner、隔离 AppData、确认、恢复、finalization 和唯一回复事实源,不新增 Agent、调度器、Provider 客户端或第二套对话持久化。现有 `supervisor-swarm` 与 `supervisor-swarm-transient-retry` 继续分别承担固定协议链和受控瞬态故障门禁,不能被本 suite 替代。 + +- 唯一用户任务只能表达业务结果,例如把试玩项目推进到可交给首批玩家体验并汇报交付、验证和风险;任务不得出现静态 Agent ID、Agent 数量、同轮/并行要求、planning 轮次、返工/repair 次数、原生工具名、run/action/delegation 身份或 Runner 操作。一次性仓库规则只描述玩家体验规格、发布质量记录、语义验收、修改后验证和安全边界;不得指定由哪个 Agent 承担、必须同批委派、必须返工几次或调用什么工具。 +- fixture 提供一项缺失的体验规格和一项“客观文件/验证存在但语义仍不满足”的质量记录。质量记录在初始阶段必须先独立审阅再允许修改;harness 只通过项目 policy 暂时拒绝质量角色写入,弱回执被父 run 认领后解除 policy,不发送 steer、不改业务文件、不补充新任务。初始弱回执必须是 `completed + evidence-ready` 且无缺失产物,确保后续 repair 来自 Supervisor 对 acceptance criteria 的语义判断,而不是 Runtime 自动把客观失败标成 `needs-repair`。 +- `--swarm-chat --init ` 必须由真实发布二进制启动,省略 parentAgentId 后进入 `project-supervisor`;用户任务通过 stdin 发送,所有确认也经同一终端 `approve` 入口完成。允许 Supervisor 先做必要读取,但首个包含专业委派的 native Provider 批次必须自主选择至少两个不同规范专业 Agent,并在同批形成两个初始合同;两个 child 的真实 Provider lifecycle 必须重叠,不能用同一 Agent 的 retry/format repair 或仅凭 delegate action 时间冒充并行。 +- 弱质量 claim 进入 `Observed` 后,Supervisor 必须在同一父 Session/run 自主创建引用原 delivery 的唯一 repair;目标 Agent、acceptanceCriteria 和 expectedArtifacts 必须完整继承,repair action 必须晚于弱 claim、早于唯一最终回复。repair 待确认边界继续执行 pidfd Runner 强杀与 boot 恢复,任务、delivery、claim、pending action 和 Provider started 身份不得漂移或重放。 +- 终局必须同时满足:严格 host oracle 判定两项产物语义正确,最后修改后的验证凭证有效;`[turn.report]` 为 v1/settled、父 Agent/Session/run 与 journal 一致、新增 assistant 恰好 1、队列和 reconciliation 计数为 0;正式用户会话只有 1 条 user 和 1 条 Supervisor assistant,专业 assistant 仅留在内部 Session;无 steer、重复 delivery/action/message/receipt/Provider lifecycle、残留 sidecar、Provider payload、私有正文、API Key、诱饵、项目/正式配置路径或报告泄漏,隔离 Runner/AppData/项目全部清理。任何一次带更明确提示的重跑都只能算新的失败后尝试,不能与原 run 拼接成 PASS。 + +该 suite 完成后只能证明自主 static 专业编排与真实终端聊天可组合;同一父 run 的 static delivery + isolated all-join 组合恢复,以及 Tauri/WebView 宿主级 Supervisor E2E 仍需各自独立门禁。 + ## 验收命令 - `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml structured_plan_ -- --nocapture` @@ -1157,6 +1170,7 @@ Supervisor 认领回执后必须能够再次从 durable delivery 取回权威返 - `npm run ai-game-creator-shell:agent-runtime:real-e2e -- --config-dir --suite parallel-read` - `npm run ai-game-creator-shell:agent-runtime:real-e2e -- --config-dir --suite supervisor-swarm` - `npm run ai-game-creator-shell:agent-runtime:supervisor-swarm-transient-retry-real-e2e -- --config-dir ` +- `npm run ai-game-creator-shell:agent-runtime:supervisor-swarm-autonomous-chat-real-e2e -- --config-dir ` - `npm run ai-game-creator-shell:agent-runtime:real-e2e -- --config-dir --suite full` - `npm run check:encoding` - `git diff --check` diff --git a/package.json b/package.json index 22e9cd545..366520bbc 100644 --- a/package.json +++ b/package.json @@ -146,6 +146,7 @@ "ai-game-creator-shell:agent-run": "npm --prefix apps/ai-game-creator-shell run agent-run --", "ai-game-creator-shell:agent-run:smoke": "npm --prefix apps/ai-game-creator-shell run agent-run:smoke", "ai-game-creator-shell:agent-runtime:real-e2e": "npm --prefix apps/ai-game-creator-shell run agent-runtime:real-e2e --", + "ai-game-creator-shell:agent-runtime:supervisor-swarm-autonomous-chat-real-e2e": "npm --prefix apps/ai-game-creator-shell run agent-runtime:supervisor-swarm-autonomous-chat-real-e2e --", "ai-game-creator-shell:agent-runtime:supervisor-swarm-transient-retry-real-e2e": "npm --prefix apps/ai-game-creator-shell run agent-runtime:supervisor-swarm-transient-retry-real-e2e --", "ai-game-creator-shell:agent-runtime:steer-real-e2e": "npm --prefix apps/ai-game-creator-shell run agent-runtime:steer-real-e2e --", "ai-game-creator-shell:agent-runtime:steer-runner-kill-real-e2e": "npm --prefix apps/ai-game-creator-shell run agent-runtime:steer-runner-kill-real-e2e --", From 4474fb9eab60afc45aab967e09bff4c21ed2c136 Mon Sep 17 00:00:00 2001 From: AIGameCreator App Date: Fri, 17 Jul 2026 12:21:34 +0800 Subject: [PATCH 2/2] =?UTF-8?q?=E8=A1=A5=E9=BD=90=E6=99=BA=E8=83=BD?= =?UTF-8?q?=E4=BD=93=E6=B6=88=E6=81=AF=E6=B4=BB=E9=94=81=E5=9B=9E=E5=BD=92?= =?UTF-8?q?=E4=B8=8E=E9=AA=8C=E6=94=B6=E8=AE=B0=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增重复 agent.message 的有界活锁回归并验证持久化幂等 收紧 isolated all-join 与 PTY 测试的异步竞态断言 同步自主 Swarm 真实验收结果、工作流和长期踩坑记录 --- .../src-tauri/src/process_session.rs | 8 +- .../src-tauri/src/tests.rs | 262 +++++++++++++++++- .../shared-memory/decision-log.md | 10 + .../shared-memory/development-workflow.md | 16 ++ docs/project-memory/shared-memory/pitfalls.md | 40 +++ ...案】AI游戏创作Agent Runtime V1.1-2026-07-12.md | 9 +- ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 4 + 7 files changed, 346 insertions(+), 3 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/process_session.rs b/apps/ai-game-creator-shell/src-tauri/src/process_session.rs index 527a28b51..acd670b58 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/process_session.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/process_session.rs @@ -3623,7 +3623,13 @@ setInterval(() => {}, 1000); "{:?}", transcript.output ); - assert!(transcript_lines.contains(&"BRIDGE_ENV:")); + assert!( + transcript_lines + .iter() + .any(|line| line.ends_with("BRIDGE_ENV:")), + "{:?}", + transcript.output + ); let record_json = fs::read_to_string(root.join(process_session_record_relative_path(&record.process_id))) .expect("read process record json"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/tests.rs index ed9c03aaf..ecd9994a9 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests.rs @@ -10622,6 +10622,259 @@ async fn background_agent_runtime_marks_unconverged_loop_budget_exhausted() { fs::remove_dir_all(root).ok(); } +#[tokio::test] +async fn background_agent_runtime_bounds_duplicate_agent_message_livelock() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "定向消息停滞项目").expect("project init"); + write_project_permission_policy_at( + &root, + ProjectPermissionPolicy { + denied_commands: Vec::new(), + confirm_commands: Vec::new(), + agent_policies: BTreeMap::new(), + }, + ) + .expect("allow runtime collaboration writes"); + + let (sender, receiver) = mpsc::channel(); + let responses = (1..=AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT) + .map(|iteration| { + serde_json::json!({ + "thinkingSummary": format!("第 {iteration} 轮重复发送同一条进展消息"), + "planUpdate": { + "explanation": "等待自身专业交付完成", + "steps": [{ + "step": "完成专业交付并返回最终回执", + "status": "in_progress" + }] + }, + "plan": [], + "actions": [{ + "tool": "agent.message", + "reason": "重复同一消息以验证 durable no-op 不伪造进展", + "input": { + "agentId": "project-supervisor", + "content": "专业交付仍在处理中,请等待。" + } + }], + "response": "" + }) + .to_string() + }) + .collect::>(); + let base_url = spawn_mock_llm_server_responses_with_capture(responses, Some(sender)); + let _config_guard = write_test_local_config(format!( + r#"{{ + "agentLlm": {{ + "design-director": {{ + "apiKey": "design-key", + "baseUrl": {base_url:?}, + "model": "design-runtime-model", + "apiKind": "openai_responses" + }} + }} +}}"# + )); + + let run_id = "design-duplicate-message-livelock-run"; + start_game_creator_agent_background_task_at( + &root, + "design-director", + "验证重复定向消息不会制造无限进展", + run_id, + ) + .expect("start background task"); + for iteration in 1..=AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT { + let request = receiver + .recv_timeout(Duration::from_secs(2)) + .expect("planning request"); + assert!(request.contains(&format!("第 {iteration} 轮"))); + if iteration == 2 { + assert!(request.contains("已给 project-supervisor 留消息")); + } else if iteration > 2 { + assert!(request.contains("messageAppended=false")); + assert!(request.contains("相同定向消息已存在")); + } + } + assert!( + receiver.recv_timeout(Duration::from_millis(200)).is_err(), + "duplicate no-op must not request a seventh Provider plan" + ); + + let mut result = + read_game_creator_agent_runtime_at(&root, "design-director").expect("read runtime"); + for _ in 0..50 { + if result.state.status == "failed" { + break; + } + std::thread::sleep(Duration::from_millis(20)); + result = + read_game_creator_agent_runtime_at(&root, "design-director").expect("read runtime"); + } + assert_eq!(result.state.status, "failed"); + assert_eq!(result.state.phase, "budget-exhausted"); + assert_eq!(result.state.plan_revision, 1); + assert_eq!(result.state.plan_steps.len(), 1); + assert_eq!(result.state.plan_steps[0].status, "in_progress"); + assert!(result + .state + .error + .as_deref() + .is_some_and(|error| error.contains("loop-budget-exhausted"))); + assert!(!result + .recent_events + .iter() + .any(|event| event.event_type == "turn.completed")); + assert!(!result + .recent_events + .iter() + .any(|event| event.event_type == "context.window_checkpoint")); + let message_tool_calls = result + .state + .recent_tool_calls + .iter() + .filter(|call| call.tool == "agent.message") + .collect::>(); + assert_eq!( + message_tool_calls.len(), + AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT, + "all six duplicate attempts must reach the durable action ledger" + ); + assert_eq!( + message_tool_calls + .iter() + .filter_map(|call| call.action_id.as_deref()) + .collect::>() + .len(), + AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT + ); + assert_eq!( + message_tool_calls + .iter() + .filter_map(|call| call.action_fingerprint.as_deref()) + .collect::>() + .len(), + 1, + "semantic retries must share one action fingerprint" + ); + assert_eq!( + message_tool_calls + .iter() + .filter(|call| call.detail.as_deref() == Some("messageAppended=false")) + .count(), + AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT - 1 + ); + + let supervisor_conversation = read_local_conversation_at(&root, Some("project-supervisor")) + .expect("read supervisor conversation"); + assert_eq!( + supervisor_conversation + .messages + .iter() + .filter(|message| message.role == "tool") + .count(), + 1, + "semantic retries must append the target message exactly once" + ); + let agent_db = read_agent_db_records_for_test(&root); + assert_eq!( + agent_db + .iter() + .filter(|record| { + record.get("recordType").and_then(Value::as_str) + == Some("agent.runtime.agent.message") + && record.get("agentId").and_then(Value::as_str) == Some("design-director") + && record.get("runId").and_then(Value::as_str) == Some(run_id) + }) + .count(), + 1, + "semantic retries must write one message audit" + ); + let conversation_message_records = agent_db + .iter() + .filter(|record| { + record.get("recordType").and_then(Value::as_str) == Some("conversation.message") + && record.get("agentId").and_then(Value::as_str) == Some("project-supervisor") + && record.get("role").and_then(Value::as_str) == Some("tool") + }) + .collect::>(); + assert_eq!(conversation_message_records.len(), 1); + let executing_records = agent_db + .iter() + .filter(|record| { + record.get("recordType").and_then(Value::as_str) + == Some("agent.runtime.tool_action.executing") + && record.get("runId").and_then(Value::as_str) == Some(run_id) + && record.get("tool").and_then(Value::as_str) == Some("agent.message") + }) + .collect::>(); + assert_eq!(executing_records.len(), AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT); + assert_eq!( + executing_records + .iter() + .filter_map(|record| record.get("actionId").and_then(Value::as_str)) + .collect::>() + .len(), + AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT + ); + assert_eq!( + executing_records + .iter() + .filter_map(|record| record.get("actionFingerprint").and_then(Value::as_str)) + .collect::>() + .len(), + 1 + ); + for executing in &executing_records { + assert_auto_tool_action_audit_pair( + &agent_db, + run_id, + executing + .get("actionId") + .and_then(Value::as_str) + .expect("action id"), + executing + .get("actionFingerprint") + .and_then(Value::as_str) + .expect("action fingerprint"), + "agent.message", + "ok", + ); + } + let receipts = agent_db + .iter() + .filter(|record| { + record.get("recordType").and_then(Value::as_str) + == Some(AGENT_RUNTIME_ACTION_RECEIPT_RECORD_TYPE) + && record.get("runId").and_then(Value::as_str) == Some(run_id) + && record.get("tool").and_then(Value::as_str) == Some("agent.message") + }) + .collect::>(); + assert_eq!(receipts.len(), AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT); + assert!(receipts.iter().all(|record| { + record.get("status").and_then(Value::as_str) == Some("ok") + && record.get("detailUnavailable").and_then(Value::as_bool) == Some(true) + && record.get("safeDetail").is_some_and(Value::is_null) + })); + assert!(agent_db.iter().any(|record| { + record.get("recordType").and_then(Value::as_str) + == Some("agent.runtime.background_task.failed") + && record.get("runId").and_then(Value::as_str) == Some(run_id) + && record.get("failureKind").and_then(Value::as_str) == Some("loop-budget-exhausted") + })); + assert!(!agent_db.iter().any(|record| { + record.get("recordType").and_then(Value::as_str) + == Some("agent.runtime.background_task.completed") + && record.get("runId").and_then(Value::as_str) == Some(run_id) + })); + assert!(!agent_db.iter().any(|record| { + record.get("recordType").and_then(Value::as_str) == Some("agent.runtime.context.compacted") + && record.get("runId").and_then(Value::as_str) == Some(run_id) + })); + + fs::remove_dir_all(root).ok(); +} + #[tokio::test] async fn background_agent_runtime_checkpoints_full_context_across_multiple_windows() { let root = unique_project_path(); @@ -13901,7 +14154,14 @@ async fn isolated_agents_with_same_template_run_independently_and_join_once() { write_game_creator_agent_runtime_state(&root, &parent_state) .expect("persist waiting parent state"); drop(parent_lock); - let joins = reconcile_all_isolated_groups_at(&root).expect("reconcile ready all-join"); + let mut joins = Vec::new(); + for _ in 0..100 { + joins = reconcile_all_isolated_groups_at(&root).expect("reconcile ready all-join"); + if !joins.is_empty() { + break; + } + std::thread::sleep(Duration::from_millis(20)); + } assert_eq!(joins.len(), 1); let join = joins[0].clone(); dispatch_isolated_agent_join_at(&root, join.clone()).expect("wake waiting parent run"); diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 4cb046e0d..0b8fc4752 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -16,6 +16,16 @@ --- +## 2026-07-17 AI 游戏创作 V1.30 使用自主 Supervisor 终端门禁和语义消息收敛 + +- 背景:V1.28 已证明预置双专业方向下的合同委派、repair、Runner 恢复和唯一回复,V1.29 已证明受控瞬态重试;但二者都没有证明 Supervisor 在用户不提供 Agent ID、数量、并行或 repair 配方时会自主编排,也没有把重复 `agent.message` 的持久幂等与后台 loop 有界收敛串成完整证据。 +- 自主编排:保留 `project-supervisor` 作为正式用户唯一对话与最终回复 Agent。`supervisor-swarm-autonomous-chat` 必须通过真实发布二进制的 `--swarm-chat` 接收纯业务任务,由 Supervisor 在同一 native planning 批次自主选择至少两个不同规范专业 Agent;真实 child Provider 生命周期必须重叠。弱交付只能由 Supervisor 按 acceptance criteria 做语义裁决,并在同一父 Session/run 创建唯一、完整继承原合同的单层 repair。终局以 durable delivery/claim/receipt/finalization、唯一 Supervisor assistant 和单行脱敏 `turn.report` 为事实源。 +- 消息收敛:`agent.message` 的语义身份固定为来源 Agent/run、目标 Agent/已解析 Session 与清洗截断后正文 SHA-256。相同语义重放必须复用唯一 conversation message 与 `agent.runtime.agent.message` 审计,冲突失败关闭;不同来源、run、目标、Session 或正文仍是新消息。重复调用返回 `messageAppended=false`,不算上下文窗口的新进展,也不能替代专业 Agent 自身最终回执。持续重复时最多在当前 6 轮停滞窗口结束后进入 `failed / budget-exhausted / loop-budget-exhausted`,原 `in_progress` 计划保持原样,不能写 completed 或成功回复;每次 Runtime action/observation/receipt 仍完整留痕且公共 receipt 不保存正文。 +- E2E 隔离:自主 suite 的 sentinel AppData 必须创建在正式 AppData 同级,不能嵌套在源目录;正式目录只读,配置副本、source-dir guard、endpoint 身份、CLI 调用计数和自动清理均进入硬门禁。父 Supervisor 在 repair 前执行的 `project.verify` 属于合法宿主验证,harness 只能拒绝其它意外父 pending action,不能把父验证和专业 Agent 修改确认一刀切。 +- 验证:最终正式 `openai_chat / gpt-5.5` 诊断轮为 PASS:无编排配方任务下完成双专业 Provider 真重叠、2 个初始 delivery、1 个 repair、2 个 Observed claim、pidfd Runner 强杀/boot 恢复、同一父 Session/run、严格宿主验证、唯一正式 assistant 和 3 条内部专业 assistant。51 个 Provider request identity 全部 `started -> completed`,28/28 成功计划和 19/19 格式修复全为 `native_runtime_tools`;重复、残留 sidecar、Provider payload、私有正文、API Key、诱饵、项目/配置路径和报告泄漏均为 0。确定性完整 loop 回归另证明 6 次重复消息 action 全部落账、目标消息/两类消息审计各 1 条、第 6 轮预算失败且第 7 次 Provider 请求、compaction 和 completed 均为 0。 +- 范围:V1.30 证明自主 static 专业编排与真实终端聊天可组合;同一父 run 的 static delivery + isolated all-join 真实组合,以及 Tauri/WebView 宿主级 Supervisor E2E 仍是独立后续门禁。 +- 关联文档:`docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md`、`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`。 + ## 2026-07-17 AI 游戏创作 Swarm 显式重试必须使用受控真实故障门禁 - 背景:V1.28 `supervisor-swarm` 正式报告的 46 个 Provider request 全部 completed;确定性测试和条件式 E2E validator 虽覆盖 retry 契约,但 `failed=0 / retry=0` 仍可 PASS,不能证明真实 Provider Swarm 进入过显式重试链。 diff --git a/docs/project-memory/shared-memory/development-workflow.md b/docs/project-memory/shared-memory/development-workflow.md index fb8074fe2..d0ef17a9b 100644 --- a/docs/project-memory/shared-memory/development-workflow.md +++ b/docs/project-memory/shared-memory/development-workflow.md @@ -104,6 +104,22 @@ npm run ai-game-creator-shell:agent-runtime:supervisor-swarm-transient-retry-rea 真实 PASS 必须恰好包含 1 个 failed lifecycle、1 条 retry audit 和 1 个 `-transient-1` 后继 identity;forwarding gate 放行前 action、receipt、目标 Agent 子委派、claim、assistant、pending、project revision 与 upstream forwarding 全为 0。放行后仍须完成双专业 Agent 重叠、唯一 repair、Runner 强杀恢复、唯一 Supervisor assistant 和零重复/残留/泄漏。suite 只能读正式 AppData,在其同级目录写入 sentinel 管理的 `0600` 私有副本和 overlay;启动 CLI/Runner 时须把 loopback 合并进大小写两套 no-proxy 环境,防止系统 HTTP 代理绕过本地故障门禁;source-dir guard 必须证明本 suite 前缀未进入源目录,源配置和 endpoint 身份保持不变,报告不得保存 Provider URL、headers、正文、凭据或绝对配置路径。若后续 repair/恢复/终局失败,partial report 仍应保留已经取得的 retry checkpoint。 +### AI 游戏创作自主 Swarm 终端复验 + +修改 Supervisor 自主编排、`agent.message`、static delivery/claim/repair、Swarm CLI `turn.report`、Runner 恢复或 autonomous harness 后,先跑确定性收敛门禁,再运行真实终端 suite: + +```bash +cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml background_agent_runtime_bounds_duplicate_agent_message_livelock -- --nocapture +cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml agent_runtime_context_window_ -- --nocapture +cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml project_supervisor_ -- --nocapture +cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml swarm_cli::tests:: -- --nocapture +npm run ai-game-creator-shell:agent-runtime:supervisor-swarm-autonomous-chat-real-e2e -- --config-dir +``` + +业务任务和一次性仓库规则不得出现 Agent ID、数量、并行/同轮、工具、repair 次数、run/action/delegation 或 Runner 配方。PASS 必须由真实发布二进制的 `--swarm-chat` 自主形成至少两个不同专业 Agent 的同批委派和真实 Provider 重叠,在 acceptance criteria 不满足时只形成一个继承原合同的 repair;repair 确认边界执行 Runner 强杀后仍保持父 Session/run、delivery、claim、pending action 和 Provider started 身份。父 run 的 `project.verify` 是允许的宿主验证,其它意外父 pending action继续失败关闭。 + +终局必须同时得到 `turn.report=settled`、新增 Supervisor assistant 恰好 1、专业 assistant 只在内部 Session、队列/确认/用户输入/reconciliation/sidecar 全 0,以及重复 action/delivery/message/receipt/Provider lifecycle 和正文/凭据/绝对路径泄漏全 0。`agent.message` 完整回归还要证明同语义消息只写一次、后续 no-op 不刷新进展、6 轮后保持未完成计划并诚实 `budget-exhausted`。隔离 AppData 必须位于正式 AppData 同级并自动清理;失败尝试与后续 PASS 不能拼接,`maxRetries=0` 下的真实外部 Provider 失败应单独保留为失败证据。 + ### AI 游戏创作 Runtime V1.10 持久进程定向复验 V1.10 的 PTY 只通过四个 Runner-owned 工具开放;不要把 V1.2 `command.exec` 改成长驻入口。最小工具输入保持结构化: diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index e3641f5f3..bd387f4ca 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -14,6 +14,46 @@ - 关联:相关文件、文档、提交或 Issue ``` +## 重复成功的 agent.message 不能被当成新的 Runtime 进展 + +- 现象:专业 Agent 已把一条定向消息写入目标 Session,却在后续 planning 中反复发送相同正文;目标会话看起来没有重复消息,但 Provider 请求持续增长,run 可能长期不返回自身终态回执。 +- 原因:conversation 层的 messageId 幂等只能阻止重复落盘。若每个新 Runtime action 的 `status=ok` 都进入上下文进展指纹,相同 durable no-op 会不断刷新 6 轮停滞窗口;只检查目标会话条数无法证明 action loop 已有界收束。 +- 处理:消息语义键必须包含来源 Agent/run、目标 Agent/已解析 Session 和清洗截断后正文 SHA-256;conversation message、`conversation.message` 和 `agent.runtime.agent.message` 各自 exactly-once。重复调用继续完整记录自己的 action/observation/receipt,但私有 observation 固定返回 `messageAppended=false`,ContextWindowTracker 只忽略这一精确 no-op,不能忽略不同正文的新消息。专业 Agent prompt 同时明确中途消息不能替代自身 final response。 +- 验证:`background_agent_runtime_bounds_duplicate_agent_message_livelock` 必须真实驱动 6 个相同指纹、不同 actionId 的消息动作,证明 action/observation/receipt 各 6 条,目标消息和两类消息审计各 1 条,后 5 次不算进展,第 6 轮保留 `in_progress` 计划并进入 `budget-exhausted`,没有第 7 次 Provider 请求、context compaction 或 completed。另保留 `agent_runtime_context_window_counts_distinct_agent_message_bodies`,防止把真正不同的新消息误压成 no-op。 +- 关联:`apps/ai-game-creator-shell/src-tauri/src/agent.rs`、`apps/ai-game-creator-shell/src-tauri/src/project.rs`、`apps/ai-game-creator-shell/src-tauri/src/tests.rs`、`docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md`。 + +## Swarm E2E 的隔离 AppData 不能建在正式 AppData 里面 + +- 现象:真实 suite 自称使用隔离配置,但一次性 AppData 出现在正式 AppData 子目录;源目录 watcher、配置副本计数和清理归属变得含糊,Runner 还可能把临时 endpoint 或运行态写进正式目录树。 +- 原因:把 `mkdtemp` 前缀拼在 source config dir 内,只隔离了文件名,没有隔离目录所有权;source-dir guard 无法区分 suite 自己的合法子目录写入与污染,失败清理也可能触碰正式目录边界。 +- 处理:需要保护正式配置的 suite 一律在 `dirname(realConfigDir)` 下创建 sentinel 管理的 sibling AppData,并要求 realpath 后与源目录同父、互不包含。配置只使用私有副本或受控 hardlink/overlay,启动 CLI/Runner 全部指向 sibling;清理前核对 sentinel、源配置 inode/hash/link count、source-dir 前缀事件、正式 endpoint 身份和正式 CLI 调用计数,随后只删除拥有明确 token 的临时目录。 +- 验证:真实报告必须同时满足 `isolatedAppDataUsed=true`、`sourceAppDataDirectoryUntouched=true`、`sourceRunnerEndpointUnchanged=true`、`formalConfigCliCallCount=0`、配置副本校验和 `AppDataCleanupPerformed=true`;项目选择 `--keep-project` 时也不能改变 AppData 自动清理。 +- 关联:`apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e.mjs`、`docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md`。 + +## 异步 Runtime 测试不能把 child idle 当成终态结果已发布 + +- 现象:isolated child 已显示 idle,单次 all-join reconcile 却偶发返回空列表,完整 Rust suite 里出现低概率失败,单独重跑通常通过。 +- 原因:child Runtime 释放执行 lane 与持久化终态 result、发布 join readiness 不是同一个原子观测点;测试只等待 idle,会在终态 result 发布前抢先 reconcile。 +- 处理:产品协议仍以 durable terminal result 和 join readiness 为准。测试在有界时限内重复调用幂等 reconcile,直到取得唯一 join 或超时;不得靠固定长 sleep,也不能因为第一次为空就把协议改成吞掉未完成 child。 +- 验证:`isolated_agents_with_same_template_run_independently_and_join_once` 最多执行 100 次、每次间隔 20ms 的 reconcile,并继续断言只有一个 all-join 和一次父唤醒。 +- 关联:`apps/ai-game-creator-shell/src-tauri/src/tests.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent.rs`。 + +## PTY 测试不能假设输入回显与后续输出必然分行 + +- 现象:PTY 环境隔离用例偶发得到 `你好BRIDGE_ENV:`,而不是独立的 `你好` 与 `BRIDGE_ENV:` 两行;真实私有环境变量并未泄漏,但整行相等断言失败。 +- 原因:canonical PTY 的输入回显和目标进程后续输出存在合法调度竞争,读取边界不等于逻辑行边界,回显可能与紧随其后的固定标记合并。 +- 处理:对不含秘密的固定标记按语义边界断言,例如要求某行以标记结尾;敏感值仍必须在完整 transcript 和公共持久面执行严格零命中扫描,不能借此放宽泄漏门禁。 +- 验证:`process_session_pty_uses_private_environment_and_redacts_public_records` 对 `BRIDGE_ENV:` 使用行尾匹配,并保留真实私有环境变量、stdin 正文与公共记录泄漏扫描。 +- 关联:`apps/ai-game-creator-shell/src-tauri/src/process_session.rs`、`apps/ai-game-creator-shell/src-tauri/src/command_output.rs`。 + +## 自主 Swarm 验收不能把父 project.verify 当成意外确认动作 + +- 现象:两个专业 Agent 已完成初始交付,Supervisor 在语义 repair 前合法执行项目宿主验证,但 E2E harness 把所有父 run pending action 一律拒绝,导致真实协作链在业务逻辑正常时提前失败。 +- 原因:验收器把“repair 前不允许父 Agent 绕过专业工作”错误实现成“父 run 不能出现任何确认动作”,混淆了 Supervisor 自己的 `project.verify` 与会改变专业交付/文件的意外动作。 +- 处理:确认过滤器必须按 owning run 和 tool 精确判断。repair 前允许当前父 run 的 `project.verify`,仍拒绝其它未列入场景合同的父 pending action;专业 Agent 的修改和验证继续按各自 run、policy 和预期确认集合处理。允许确认不等于通过验收,最终仍由 host oracle、最新 revision verification、delivery/claim/repair 和唯一回复共同裁决。 +- 验证:自主 suite 必须出现有效 `hostVerificationPassed=true`,同时保持恰好 2 个初始 + 1 个 repair delivery、父计划完成、意外 pending 为 0、Runner 强杀恢复和唯一 Supervisor assistant;若放宽后出现额外父写动作,场景必须失败而不是吞掉。 +- 关联:`apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e.mjs`、`docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md`。 + ## Provider 全成功的真实报告不能证明显式重试可用 - 现象:真实 Swarm 报告显示全部 Provider lifecycle completed,E2E 的 retry validator 也没有报错,于是文档把“支持瞬态重试”一并写成已真实验收。 diff --git a/docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md b/docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md index 19f059dcb..9315078c7 100644 --- a/docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md +++ b/docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md @@ -1138,8 +1138,15 @@ V1.30 新增独立 `supervisor-swarm-autonomous-chat` 真实 Provider suite, - `--swarm-chat --init ` 必须由真实发布二进制启动,省略 parentAgentId 后进入 `project-supervisor`;用户任务通过 stdin 发送,所有确认也经同一终端 `approve` 入口完成。允许 Supervisor 先做必要读取,但首个包含专业委派的 native Provider 批次必须自主选择至少两个不同规范专业 Agent,并在同批形成两个初始合同;两个 child 的真实 Provider lifecycle 必须重叠,不能用同一 Agent 的 retry/format repair 或仅凭 delegate action 时间冒充并行。 - 弱质量 claim 进入 `Observed` 后,Supervisor 必须在同一父 Session/run 自主创建引用原 delivery 的唯一 repair;目标 Agent、acceptanceCriteria 和 expectedArtifacts 必须完整继承,repair action 必须晚于弱 claim、早于唯一最终回复。repair 待确认边界继续执行 pidfd Runner 强杀与 boot 恢复,任务、delivery、claim、pending action 和 Provider started 身份不得漂移或重放。 - 终局必须同时满足:严格 host oracle 判定两项产物语义正确,最后修改后的验证凭证有效;`[turn.report]` 为 v1/settled、父 Agent/Session/run 与 journal 一致、新增 assistant 恰好 1、队列和 reconciliation 计数为 0;正式用户会话只有 1 条 user 和 1 条 Supervisor assistant,专业 assistant 仅留在内部 Session;无 steer、重复 delivery/action/message/receipt/Provider lifecycle、残留 sidecar、Provider payload、私有正文、API Key、诱饵、项目/正式配置路径或报告泄漏,隔离 Runner/AppData/项目全部清理。任何一次带更明确提示的重跑都只能算新的失败后尝试,不能与原 run 拼接成 PASS。 +- `agent.message` 的语义身份固定绑定来源 Agent/run、目标 Agent/已解析 Session 和清洗截断后正文 SHA-256。同一语义消息重放只能复用唯一 conversation message 与 `agent.runtime.agent.message` 审计,并以 `messageAppended=false` 返回 durable no-op;不同正文、目标、Session、来源 Agent 或来源 run 仍是新消息。该 no-op 不得计入上下文窗口的新进展,也不能替代专业 Agent 自身最终回执。若模型持续重复同一消息,Runtime 最迟在当前完整 6 轮停滞窗口结束时写 `failed / budget-exhausted / loop-budget-exhausted`,保留原 `in_progress` 计划,不写 completed 或伪造成功回复;每个尝试的 action/observation/receipt 仍须完整落账且公共 receipt 不保存消息正文。 -该 suite 完成后只能证明自主 static 专业编排与真实终端聊天可组合;同一父 run 的 static delivery + isolated all-join 组合恢复,以及 Tauri/WebView 宿主级 Supervisor E2E 仍需各自独立门禁。 +2026-07-17 最终正式 `openai_chat / gpt-5.5` 诊断轮 **PASS**。唯一业务任务未提供 Agent ID、Agent 数量、并行、工具、repair 或 Runner 配方;Supervisor 在 1 个 native planning 批次自主选择 2 个不同专业 Agent,真实 Provider 区间重叠,并在同一父 Session/run 完成 `2` 份初始 delivery、`1` 次语义 repair、`2` 个 Observed claim、严格宿主验证、pidfd Runner 强杀、boot 切换和身份稳定恢复。最终 `[turn.report]` 为 `settled`,正式会话新增 Supervisor assistant 恰好 `1`,内部专业 assistant 为 `3`;父计划 `4/4` completed,pending/running/confirmation/user-input/reconciliation 均为 `0`。 + +该轮共形成 110 条 task、197 条 event、330 条 Agent DB 和 9 条会话消息;51 个 Provider request identity 全部唯一闭合为 `51 started / 51 terminal / 51 completed / 0 failed`,28/28 个成功工具计划和 19/19 个格式修复均为 `native_runtime_tools`,wrapper/text fallback 为 `0`。delivery、message、action lifecycle、executing action、receipt、Provider lifecycle 的重复计数均为 `0`,所有 batch/finalization/confirmation/user-input sidecar 为 `0`,Provider payload、私有正文、API Key、诱饵、项目/正式配置绝对路径和报告泄漏均为 `0`。隔离 AppData 自动清理;保留的 disposable 项目经 sentinel/进程核对后手动删除。此前两次独立尝试在 `maxRetries=0` 下各遇到 1 次外部 Provider 终态失败并在恢复边界前停止,均只作失败证据,未与本轮拼接。 + +确定性 `background_agent_runtime_bounds_duplicate_agent_message_livelock` 同时证明:6 次同指纹 Runtime action/observation/receipt 全部实际落账,目标 conversation、`conversation.message` 和 `agent.runtime.agent.message` 各仅 1 条,后 5 次为 durable no-op,第 6 轮保留 `in_progress` 计划并进入 `budget-exhausted`,不存在第 7 次 Provider 请求、context compaction 或 completed 投影。 + +V1.30 至此只证明自主 static 专业编排与真实终端聊天可组合;同一父 run 的 static delivery + isolated all-join 真实组合恢复,以及 Tauri/WebView 宿主级 Supervisor E2E 仍需各自独立门禁。 ## 验收命令 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 6e204ec40..725d57207 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -54,6 +54,8 @@ V1.11 的受保护仓库控制目录同时包含 `.git / .agent / .agents / .cod 当前委派协议已把 delivery 与 `Prepared -> Committed -> Observed` claim journal 作为事实源,认领前按 delegationId 排序并取得全部 delivery 锁,`.agent/agent.db` 只作 best-effort 诊断投影。恢复中的 executing 动作只允许 Supervisor `agent.delegate / agent.run_status` 经项目锁、pending 全身份和 policy 重验后补交;parent-wake 使用 singleflight、有界错误分类和稳定 Runner requestId。子终态只有在 parent/child/delivery 完整身份一致后才能 ready 或 suppression;错配不得改写 delivery。最终回复继续由原父 run 的 finalization journal 幂等写入。 +2026-07-17 起,Runtime V1.30 已把真实 `agc:chat / --swarm-chat` 与自主专业编排纳入同一门禁。用户只描述业务交付,不提供 Agent ID、数量、并行、工具、repair 或 Runner 配方;`project-supervisor` 必须在单个 native planning 批次自主选择至少两个不同专业 Agent 并形成真实 Provider 重叠,语义判定不满足时在同一父 Session/run 发起唯一 repair,最终以单条 `[turn.report] game-creator-swarm-turn-report.v1` 和唯一 Supervisor assistant 收束。正式 `openai_chat / gpt-5.5` 最终诊断轮已完成 `51/51` Provider lifecycle、双专业并行、2+1 delivery、2 个 Observed claim、pidfd Runner 强杀/boot 恢复、唯一正式回复和零重复/残留/泄漏,当前门禁状态为 PASS;static delivery + isolated all-join 的真实组合和 Tauri/WebView 宿主级 E2E 仍是独立后续项。 + 2026-07-15 起,Runtime V1.1 文档的“V1.17 单 Agent 持久计划”作为后台工具规划进度的新事实源。`submit_agent_tool_plan` 新增 nullable `planUpdate={explanation,steps[{step,status}]}`;步骤只接受 `pending / in_progress / completed`,最多 8 步且至多一个 `in_progress`。结构化计划一旦建立,legacy `plan` 只作旧协议 fallback;终态步骤必须保留,`planRevision` 只在真实变化时单调递增,工具 action 下标不得自动完成结构化步骤,存在未完成步骤时不得写最终回复或 completed。 V1.17 计划快照随 `game-creator-runtime-context-bundle.v3` 持久化,v2 在通过原身份、revision 和 verification gate 校验后从当前 Runtime state 补齐计划字段继续恢复;计划元数据本身不推进项目 revision、不改变 verification gate,也不触发项目权限确认。开发 UI 和 CLI 有界展示 revision、说明与完整 8 步;正式用户的 Supervisor 只展示完成数、当前步骤、等待对象、下一步和协作数量的紧凑摘要。恢复、same-run steer 和真实 Provider 的完整验收矩阵以 Runtime V1.17 章节为准;2026-07-16 已在当前 v5 context 上完成正式 `openai_chat / gpt-5.5` 的同 run steer + Runner 强杀恢复专项,门禁状态为 PASS。 @@ -586,4 +588,6 @@ game-project/ - V1.28 对 Provider 瞬态失败采用 Runtime 显式重试:`agentLlm..maxRetries / retryBackoffMs` 表示独立物理尝试及其有界指数退避,不得恢复为 `LlmClient` 在单 lifecycle 内隐式重放。每次尝试都重建禁用自动重试的 client,并写独立单次 lifecycle;首次 request slot 不变,第 `N` 次重试稳定使用 `-transient-N` 后缀。只有 `timeout / connectivity / transport` 可重试;工具协议无效仍进入独立 `repair-N` 格式修复,其他错误与重试耗尽按原失败路径收束。重试前必须重新检查 Goal、steer、cancel、task/run 与 orphan 门禁;控制请求可阻止下一次尝试,Runner 强杀后无可信终态的 `started` 仍进入 reconciliation,不能自动补发。重试发生在解析和副作用之前,不创建 action、pending、receipt、delivery、assistant 或 revision;既有 Runner、orphan、finalization 和隐私边界均不放宽,公共审计不得保存请求/响应正文、arguments、凭据或绝对路径。 - V1.28 已于 2026-07-17 完成正式 `openai_chat / gpt-5.5` `supervisor-swarm` PASS:同一 native 批次双专业委派、真实 Provider 重叠、2 份初始 delivery、1 次 targeted contract read、1 份唯一 repair、pidfd Runner 强杀/boot 恢复、同一父 Session/run、唯一 Supervisor assistant 和 3 条内部专业 assistant 全部成立。报告包含 46/46 闭合且 completed 的 Provider lifecycle,成功计划 24/24、格式修复 20/20 全为原生工具协议;重复、残留 sidecar、Provider payload、私有正文、API Key、项目/正式配置绝对路径、报告、secret 与 lure 泄漏均为 0。正式 AppData 零 CLI 调用且源 Runner endpoint 未变化;规范复验命令为 `npm run ai-game-creator-shell:agent-runtime:real-e2e -- --config-dir --suite supervisor-swarm`。 - 2026-07-17 追加 `supervisor-swarm-transient-retry` 受控故障门禁:一次性本地回环代理只让 `design-director` 首个请求在正文转发前断线,并暂停后继请求,直到验收器确认唯一 failed lifecycle、唯一 retry audit、新 `-transient-1` identity,以及 action/receipt/子委派/claim/assistant/pending/revision/upstream forwarding 全为 0。E2E 启动 CLI/Runner 时会把 loopback 合并进 `NO_PROXY / no_proxy`,避免继承的系统 HTTP 代理接触故障门禁请求中的凭据和正文。最终加强版正式 `gpt-5.5` 报告为 46/46 lifecycle 闭合、45 completed/1 failed/1 retry;代理观察到的 10 个目标 Agent 请求与该 Agent lifecycle 数量一致,放行后完整双 Agent、唯一 repair、Runner 强杀恢复、唯一 Supervisor assistant、零重复/残留/泄漏继续 PASS。隔离 AppData 创建在正式目录同级,source-dir guard 与 `sourceAppDataDirectoryUntouched` 证明正式 AppData 未被写入,源配置与 endpoint 身份保持只读,失败 partial report 保留已取得的 retry checkpoint,代理与隔离现场全部清理。该 suite 只证明显式重试和既有协作链可组合,不把预置双 Agent fixture 扩大解释为自主编排;无 Agent ID/并行/repair 配方的自主 suite、真实 `agc:chat`、static+isolated 组合和 Tauri 宿主 E2E 仍待单独验收。 +- 2026-07-17 V1.30 `supervisor-swarm-autonomous-chat` 最终真实 PASS:唯一业务任务和仓库规则均不包含编排配方;Supervisor 在 1 个 native 批次自主选择两个不同专业 Agent,真实 Provider 重叠后形成 2 个初始 delivery,并基于 acceptance criteria 自主创建 1 个继承原合同的 repair。最终报告包含 110 条 task、197 条 event、330 条 Agent DB、9 条会话消息和 `51 started / 51 terminal / 51 completed / 0 failed` Provider lifecycle;28/28 成功计划与 19/19 格式修复均为原生工具协议,父计划 4/4 completed,Runner pidfd 强杀恢复后身份稳定,`turn.report` settled、正式 assistant 1、内部专业 assistant 3,重复、sidecar、正文、密钥、诱饵、项目/配置路径和报告泄漏均为 0。两次较早的独立尝试在 `maxRetries=0` 下各遇到 1 次外部 Provider 终态失败并中止,未与最终 PASS 拼接。 +- `agent.message` 使用来源 Agent/run、目标 Agent/Session 和清洗后正文 SHA-256 形成稳定语义身份。同一语义消息只允许写 1 条目标 tool conversation、1 条 `conversation.message` 和 1 条 `agent.runtime.agent.message`;后续 Runtime action 仍完整落账,但返回 `messageAppended=false` 且不算新的 loop 进展。专业 Agent 不得用重复消息替代最终回执;持续重复时最多经过当前 6 轮停滞窗口即以 `loop-budget-exhausted` 失败,保留 `in_progress` 计划且不写 completed。完整后台回归同时断言 6 个 actionId、同一 action fingerprint、6 组 action/observation/receipt、消息持久化唯一、receipt 零正文、第 7 次 Provider 请求为 0。 - 开发模式可通过本地项目文件面板执行 `file.list/read/write/delete`,普通用户界面不暴露文件面板。