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 21a8e6fcb..8aa3baef1 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 @@ -5,6 +5,7 @@ import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { TextDecoder } from 'node:util'; import { buildProcessSessionFixtureSource } from './process-session-real-e2e-fixture.mjs'; @@ -12,8 +13,13 @@ const appRoot = path.resolve(fileURLToPath(new URL('..', import.meta.url))); const repoRoot = path.resolve(appRoot, '../..'); const manifestPath = path.join(appRoot, 'src-tauri/Cargo.toml'); const configFileName = 'game-creator.config.json'; +const localConfigFileName = 'game-creator.config.local.json'; +const runnerEndpointFileName = 'agent-runner.endpoint.json'; const sentinelFileName = '.agent-runtime-real-e2e-disposable.json'; const sentinelSchema = 'genarrative-agent-runtime-real-e2e-disposable.v1'; +const goalAppDataSentinelFileName = '.agent-runtime-real-e2e-goal-appdata.json'; +const goalAppDataSentinelSchema = + 'genarrative-agent-runtime-real-e2e-goal-appdata.v1'; const mainAgentId = 'code-prototype'; const requestedRunId = `real-e2e-${Date.now()}-${randomUUID().slice(0, 8)}`; const visibleText = 'GENARRATIVE_REAL_E2E_VISIBLE'; @@ -21,6 +27,7 @@ const patchedText = 'REAL_E2E_PATCHED'; const patchsetCreatedPath = 'game/e2e-patchset.txt'; const patchsetCreatedMarker = 'GENARRATIVE_REAL_E2E_PATCHSET_CREATED'; const patchsetCreatedContent = `${patchsetCreatedMarker}\n`; +const goalDeliveryPath = 'game/goal-delivery.txt'; const gitSensitivePath = 'data/local.sqlite'; const gitCommitReflogMessage = 'project.git_commit: controlled local commit'; const editorAssetPrompt = 'real e2e amber arcade token, transparent background'; @@ -30,6 +37,38 @@ const commandPassedMarker = 'real-e2e-command=passed'; const commandRootErrorMarker = `real-e2e-root-${randomUUID().replaceAll('-', '')}`; const commandRootErrorLine = 170; const commandDiagnosticLineCount = 240; +const goalRuntimeSuite = 'goal-runtime'; +const goalSessionId = `agent-session-${mainAgentId}`; +const goalInitialMarker = `GENARRATIVE_GOAL_REVISION_ONE_${randomUUID() + .replaceAll('-', '') + .slice(0, 16)}`; +const goalFinalMarker = `GENARRATIVE_GOAL_REVISION_TWO_${randomUUID() + .replaceAll('-', '') + .slice(0, 16)}`; +const goalInitialPayload = { + outcome: `在当前 disposable 项目的 ${goalDeliveryPath} 中交付一份新的变更证据,文件完整内容必须是唯一标记“${goalInitialMarker}”和一个结尾换行。基于仓库真实状态维护计划、完成变更并审阅实际结果,证据不足时不要结束。`, + constraints: [ + '保留全部既有受跟踪内容,只新增完成本目标所需的一份安全证据。', + '不得读取、提交或转述敏感诱饵、配置密钥、Runtime 私有正文或项目绝对路径。', + ], + verification: [ + `项目交付内容包含且只包含一次标记“${goalInitialMarker}”。`, + '实际变更已经过仓库事实和内容差异审阅。', + ], +}; +const goalEditedPayload = { + outcome: `前一版目标已经废止,项目交付文件不得保留标记“${goalInitialMarker}”。改为修复当前 disposable 项目唯一的真实验收失败,让项目声明的验收通过,并让 ${goalDeliveryPath} 的完整内容只包含唯一标记“${goalFinalMarker}”和一个结尾换行。基于真实运行反馈维护计划并审阅完整结果,证据不足时不要结束。`, + constraints: [ + '保留既有可见内容、非空动画画布和仓库安全边界,只落地完成当前目标所需的原子变更。', + '不得读取、提交或转述敏感诱饵、配置密钥、Runtime 私有正文或项目绝对路径。', + `任何项目交付文件都不得出现已废止标记“${goalInitialMarker}”。`, + ], + verification: [ + '项目清单声明的原始验收真实通过。', + `新增交付证据精确包含唯一标记“${goalFinalMarker}”。`, + `Runtime 控制面之外不存在已废止标记“${goalInitialMarker}”。`, + ], +}; const steerInstruction = '继续完成原任务,并依据恢复后的真实进展重审、重排尚未完成的安排,确保最终交付完整。'; const processFixtureScriptPath = 'fixtures/process-session-service.mjs'; @@ -45,6 +84,15 @@ const processSessionSuites = new Set([ 'process-session', 'process-session-runner-kill', ]); +const goalProjectWriteTools = new Set([ + 'command.exec', + 'command.run_limited', + 'file.delete', + 'file.patch', + 'file.write', + 'project.git_commit', + 'project.patchset', +]); const idempotentObservationTools = new Set([ 'project.index', 'project.search', @@ -60,6 +108,39 @@ const idempotentObservationTools = new Set([ const pngSignature = Buffer.from([ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, ]); +const linuxPidfdHelperSource = String.raw` +import os +import select +import signal +import sys + +if not hasattr(os, "pidfd_open") or not hasattr(signal, "pidfd_send_signal"): + raise SystemExit(70) + +pidfd = os.pidfd_open(int(sys.argv[1]), 0) +try: + sys.stdout.write("PIDFD_READY\n") + sys.stdout.flush() + command = sys.stdin.buffer.readline() + if command == b"CLOSE\n": + raise SystemExit(0) + if command != b"KILL\n": + raise SystemExit(71) + signal.pidfd_send_signal(pidfd, signal.SIGKILL, None, 0) + poller = select.poll() + poller.register(pidfd, select.POLLIN) + if not poller.poll(10000): + raise SystemExit(72) + sys.stdout.write("PIDFD_EXITED\n") + sys.stdout.flush() +finally: + os.close(pidfd) +`; +const activeCommandChildren = new Set(); +const shutdownWaiters = new Set(); +let shutdownSignal = null; +let linuxPidfdPythonPath = null; +let cleanupInProgress = false; class StreamingSecretScanner { constructor(secrets) { @@ -119,6 +200,7 @@ const state = { commandOutputContextPages: new Set(), commandMarkerReportLeakCount: 0, steerInstructionReportLeakCount: 0, + goalBodyReportLeakCount: 0, projectPathTranscriptLeakCount: 0, projectPathReportLeakCount: 0, transcriptScanner: null, @@ -126,6 +208,7 @@ const state = { projectRoot: null, sentinelToken: null, cliBinary: null, + runtimeConfigDir: null, runnerKilled: false, resumed: false, identityStable: false, @@ -134,6 +217,44 @@ const state = { initialTask: null, planRecovery: null, steer: null, + goal: { + goalId: null, + initialRevision: 0, + editedRevision: 0, + editProviderInterrupted: false, + pauseProviderInterrupted: false, + initialPending: null, + editedPending: null, + initialCompletedStepHashes: [], + editedEvidenceAbsentBeforeEdit: false, + pauseSnapshot: null, + oldRunnerBootId: null, + newRunnerBootId: null, + revisionOneFixtureIsolated: false, + revisionTwoFixtureInjected: false, + revisionTwoHostFailureObserved: false, + revisionTwoFailureObserved: false, + revisionTwoFailureExitCode: null, + revisionTwoFailureFingerprint: null, + revisionTwoEditAgentDbBoundary: 0, + revisionTwoAgentDbBoundary: 0, + initialGoalSnapshotFingerprint: null, + editedGoalSnapshotFingerprint: null, + initialMarkerAbsenceCheckCount: 0, + monitoredWriteActionIds: new Set(), + runner: { + appDataDir: null, + ownerToken: null, + createdAt: 0, + current: null, + configLinks: [], + launchAttempted: false, + pidfdClaimCount: 0, + pidfdSignalCount: 0, + stopped: false, + cleanupPerformed: false, + }, + }, confirmedActionIds: new Set(), cleanupPerformed: false, process: { @@ -155,18 +276,44 @@ const state = { evidence: emptyEvidence(), }; +function requestShutdown(signal) { + if (shutdownSignal) return; + shutdownSignal = signal; + state.status = 'FAIL'; + recordError(`interrupted-${signal.toLowerCase()}`); + if (cleanupInProgress) return; + for (const waiter of shutdownWaiters) waiter(); + for (const child of activeCommandChildren) { + if (child.exitCode !== null || child.signalCode !== null) continue; + child.kill('SIGTERM'); + const forceTimer = setTimeout(() => { + if (child.exitCode === null && child.signalCode === null) { + child.kill('SIGKILL'); + } + }, 1_000); + forceTimer.unref(); + } +} + +for (const signal of ['SIGINT', 'SIGTERM']) { + process.on(signal, () => requestShutdown(signal)); +} + try { state.options = parseArguments(process.argv.slice(2)); state.suite = state.options.suite; + state.runtimeConfigDir = state.options.configDir; if (isProcessSessionSuite()) state.evidence = emptyProcessEvidence(); + if (isGoalRuntimeSuite()) state.evidence = emptyGoalEvidence(); const loaded = await loadConfig(state.options.configDir); state.secrets = loaded.secrets; state.transcriptScanner = new StreamingSecretScanner(state.secrets); state.config = await checkPrerequisites(loaded.config); - const required = isProcessSessionSuite() - ? ['llmConfigured'] - : ['llmConfigured', 'chromeAvailable']; + const required = + isProcessSessionSuite() || isGoalRuntimeSuite() + ? ['llmConfigured'] + : ['llmConfigured', 'chromeAvailable']; if (state.suite === 'full') { required.push('editorApiConfigured'); } @@ -176,13 +323,16 @@ try { if (state.blocked.length > 0) { state.status = 'BLOCKED'; } else { - if (isProcessSessionSuite()) { + if (isGoalRuntimeSuite()) { + await runGoalRuntimeE2e(); + } else if (isProcessSessionSuite()) { await runProcessSessionE2e(); } else { await runRealE2e(); } state.status = 'PASS'; } + throwIfShutdownRequested(); } catch (error) { if (error instanceof BlockedError) { state.status = 'BLOCKED'; @@ -192,6 +342,43 @@ try { } recordError(error?.code ?? 'unexpected-error', error); } finally { + cleanupInProgress = true; + if (isGoalRuntimeSuite() && state.goal.runner.appDataDir) { + try { + await stopOwnedGoalRunner(); + state.goal.runner.stopped = true; + state.goal.runner.cleanupPerformed = await removeGoalSuiteAppData(); + if (!state.goal.runner.cleanupPerformed) { + state.status = 'FAIL'; + recordError('goal-appdata-cleanup-sentinel-missing'); + } + } catch (error) { + state.status = 'FAIL'; + recordError('goal-owned-runner-cleanup-failed', error); + await closeGoalRunnerKillHandle( + state.goal.runner.current?.killHandle, + ).catch(() => {}); + } + state.evidence.goalRunnerStopped = state.goal.runner.stopped; + state.evidence.goalAppDataCleanupPerformed = + state.goal.runner.cleanupPerformed; + state.evidence.goalRunnerKillMethod = + state.goal.runner.pidfdClaimCount > 0 ? 'linux-pidfd' : null; + state.evidence.goalRunnerPidfdClaimCount = + state.goal.runner.pidfdClaimCount; + state.evidence.goalRunnerPidfdSignalCount = + state.goal.runner.pidfdSignalCount; + } + if (isGoalRuntimeSuite() && state.projectRoot && state.status !== 'PASS') { + try { + state.evidence = { + ...state.evidence, + ...(await collectPartialGoalEvidence()), + }; + } catch (error) { + recordError('goal-partial-evidence-read-failed', error); + } + } if (state.projectRoot && state.secrets.length > 0) { try { state.projectLeakCount = await countSecretsInProject( @@ -214,7 +401,15 @@ try { state.status = 'FAIL'; recordError('disposable-project-path-transcript-leak-detected'); } - if (state.projectRoot && !state.options?.keepProject) { + const goalRunnerAllowsProjectCleanup = + !isGoalRuntimeSuite() || + !state.goal.runner.appDataDir || + state.goal.runner.stopped; + if ( + state.projectRoot && + !state.options?.keepProject && + goalRunnerAllowsProjectCleanup + ) { try { state.cleanupPerformed = await removeDisposableProject(); if (!state.cleanupPerformed) { @@ -268,6 +463,19 @@ try { summary = buildSummary(); report = JSON.stringify(summary, null, 2); } + if (isGoalRuntimeSuite()) { + state.goalBodyReportLeakCount = countExactSecrets( + Buffer.from(report), + goalPrivateBodyValues(), + ); + state.evidence.goalBodyReportLeakCount = state.goalBodyReportLeakCount; + if (state.goalBodyReportLeakCount > 0) { + state.status = 'FAIL'; + recordError('goal-body-report-leak-detected'); + summary = buildSummary(); + report = JSON.stringify(summary, null, 2); + } + } state.projectPathReportLeakCount = countExactSecrets( Buffer.from(report), disposableProjectPathVariants(), @@ -314,8 +522,15 @@ try { report = JSON.stringify(safeSummary, null, 2); } process.stdout.write(`${report}\n`); - process.exitCode = - state.status === 'PASS' ? 0 : state.status === 'BLOCKED' ? 2 : 1; + process.exitCode = shutdownSignal + ? shutdownSignal === 'SIGINT' + ? 130 + : 143 + : state.status === 'PASS' + ? 0 + : state.status === 'BLOCKED' + ? 2 + : 1; } async function runRealE2e() { @@ -377,10 +592,202 @@ async function runRealE2e() { assert(state.evidence.secretLeakCount === 0, 'loaded-key-leak-detected'); } +async function runGoalRuntimeE2e() { + await ensureGoalRunnerStableKillSupport(); + await seedDisposableProject(); + await assertGoalInitialMarkerAbsent('goal-project-seeded'); + state.cliBinary = await prepareCliBinary(); + await prepareGoalSuiteAppData(); + assertGoalPayloadUnscripted(goalInitialPayload, 'goal-initial'); + assertGoalPayloadUnscripted(goalEditedPayload, 'goal-edited'); + state.initialTask = { + chars: [...goalInitialPayload.outcome].length, + sha256: hashValue(goalInitialPayload.outcome), + }; + + state.goal.runner.launchAttempted = true; + const started = parseGoalMutation( + await runCli( + [ + '--agent-goal-start', + '--init', + state.projectRoot, + mainAgentId, + goalSessionId, + requestedRunId, + '--stdin', + ], + { + timeoutMs: 120_000, + stdin: `${JSON.stringify(goalInitialPayload)}\n`, + }, + ), + ); + assertGoalMutationIdentity(started, 1, 'goal-start'); + state.goal.goalId = started.goal.goalId; + state.goal.initialRevision = started.goal.revision; + state.initialRunId = started.goal.runId; + state.initialSessionId = started.goal.sessionId; + await claimGoalRunnerOwnership(); + const canonicalRuntime = await waitForCanonicalRuntime(); + assert( + canonicalRuntime.agentId === mainAgentId && + canonicalRuntime.sessionId === state.initialSessionId && + canonicalRuntime.runId === state.initialRunId && + canonicalRuntime.goalId === state.goal.goalId && + canonicalRuntime.goalRevision === state.goal.initialRevision && + canonicalRuntime.goalStatus === 'active', + 'goal-start-canonical-runtime-invalid', + ); + + const initialPending = await waitForGoalRevisionPendingAction({ + revision: state.goal.initialRevision, + marker: goalInitialMarker, + codePrefix: 'goal-initial', + }); + state.goal.initialCompletedStepHashes = [ + ...initialPending.plan.completedStepHashes, + ]; + state.goal.initialPending = summarizeGoalPending(initialPending.pending); + await assertGoalInitialMarkerAbsent( + 'goal-initial-pending-before-edit', + initialPending.pending.actionId, + ); + const [deliveryBeforeEdit, finalMarkerBeforeEdit] = await Promise.all([ + fs.lstat(path.join(state.projectRoot, goalDeliveryPath)).catch((error) => { + if (error?.code === 'ENOENT') return null; + throw error; + }), + countMarkerOutsideRuntimeControl(goalFinalMarker), + ]); + assert( + deliveryBeforeEdit === null && finalMarkerBeforeEdit === 0, + 'goal-edited-evidence-present-before-edit', + ); + state.goal.editedEvidenceAbsentBeforeEdit = true; + await assertGoalRevisionOneFixtureIsolation(); + await injectGoalRevisionTwoFixtureAndProveFailure(); + state.goal.revisionTwoEditAgentDbBoundary = ( + await readOptionalJsonl(path.join(state.projectRoot, '.agent/agent.db')) + ).length; + + const edited = parseGoalMutation( + await runCli( + [ + '--agent-goal-edit', + state.projectRoot, + mainAgentId, + state.initialSessionId, + state.goal.goalId, + String(state.goal.initialRevision), + '--stdin', + ], + { + timeoutMs: 120_000, + stdin: `${JSON.stringify(goalEditedPayload)}\n`, + }, + ), + ); + assertGoalMutationIdentity(edited, 2, 'goal-edit'); + state.goal.editedRevision = edited.goal.revision; + state.goal.editProviderInterrupted = edited.providerInterrupted === true; + await waitForGoalOldActionBlocked(initialPending.pending); + await waitForGoalRevisionTwoAgentVerificationFailure(); + + const editedPending = await waitForGoalRevisionPendingAction({ + revision: state.goal.editedRevision, + marker: goalFinalMarker, + codePrefix: 'goal-edited', + minimumPlanRevision: initialPending.plan.revision + 1, + requiredCompletedStepHashes: state.goal.initialCompletedStepHashes, + }); + state.goal.editedPending = summarizeGoalPending(editedPending.pending); + + const paused = parseGoalMutation( + await runCli( + [ + '--agent-goal-pause', + state.projectRoot, + mainAgentId, + state.initialSessionId, + state.goal.goalId, + String(state.goal.editedRevision), + ], + { timeoutMs: 120_000 }, + ), + ); + assertGoalMutationIdentity(paused, state.goal.editedRevision, 'goal-pause'); + assert( + paused.goal.status === 'paused' && + paused.runtime?.state?.status === 'paused' && + paused.runtime?.state?.phase === 'paused', + 'goal-pause-not-durable', + ); + state.goal.pauseProviderInterrupted = paused.providerInterrupted === true; + + const beforeKillRunner = await readRunnerStatus(); + state.goal.oldRunnerBootId = runnerBootId(beforeKillRunner); + assert( + isNonEmptyString(state.goal.oldRunnerBootId), + 'goal-runner-boot-before-kill-missing', + ); + state.goal.pauseSnapshot = await captureGoalPausedSnapshot( + editedPending.pending, + 'goal-paused-before-kill', + ); + await killRunnerOnce(); + await runCli(['--agent-resume', state.projectRoot], { timeoutMs: 120_000 }); + state.resumed = true; + const restartedRunner = await waitForRunnerBootChange( + state.goal.oldRunnerBootId, + ); + state.goal.newRunnerBootId = runnerBootId(restartedRunner); + assert( + isNonEmptyString(state.goal.newRunnerBootId) && + state.goal.newRunnerBootId !== state.goal.oldRunnerBootId, + 'goal-runner-boot-did-not-change', + ); + await claimGoalRunnerOwnership(restartedRunner); + state.goal.executionOwnerRecovered = + await waitForGoalExecutionOwnerTakeover(); + await assertGoalRemainsPausedAfterRestart( + state.goal.pauseSnapshot, + editedPending.pending, + ); + + const resumed = parseGoalMutation( + await runCli( + [ + '--agent-goal-resume', + state.projectRoot, + mainAgentId, + state.initialSessionId, + state.goal.goalId, + String(state.goal.editedRevision), + ], + { timeoutMs: 120_000 }, + ), + ); + assertGoalMutationIdentity(resumed, state.goal.editedRevision, 'goal-resume'); + assert( + resumed.goal.status === 'active' && + resumed.goal.runId === state.initialRunId && + ['pending', 'waiting-for-confirmation', 'running'].includes( + resumed.runtime?.state?.status, + ), + 'goal-explicit-resume-invalid', + ); + state.identityStable = true; + + await driveGoalRuntimeToQuiescence(); + state.evidence = await validateGoalRuntimeEvidence(); + assert(state.evidence.secretLeakCount === 0, 'loaded-key-leak-detected'); +} + async function runProcessSessionE2e() { await seedProcessSessionDisposableProject(); state.cliBinary = await prepareCliBinary(); - await stopExistingRunnerBeforeProcessSuite(); + await stopExistingRunnerBeforeRuntimeSuite(); const task = buildProcessSessionTaskPrompt(); assertProcessSessionTaskPrompt(task); @@ -436,6 +843,7 @@ function parseArguments(args) { assert( suite === 'full' || suite === 'llm-runtime' || + suite === goalRuntimeSuite || processSessionSuites.has(suite), 'unsupported-suite', ); @@ -455,22 +863,682 @@ async function loadConfig(configDir) { !isPathInside(realRepoRoot, realConfigDir), 'config-dir-inside-repository', ); - const configPath = path.join(realConfigDir, configFileName); - const metadata = await fs.lstat(configPath).catch(() => null); - if (!metadata || !metadata.isFile() || metadata.isSymbolicLink()) { - throw new BlockedError(['config']); + const effectiveConfig = {}; + const secrets = new Set(); + for (const name of [configFileName, localConfigFileName]) { + const configPath = path.join(realConfigDir, name); + const metadata = await fs.lstat(configPath).catch((error) => { + if (error?.code === 'ENOENT') return null; + throw error; + }); + if (!metadata) { + if (name === configFileName) throw new BlockedError(['config']); + continue; + } + if (!metadata.isFile() || metadata.isSymbolicLink()) { + throw codedError('config-file-not-regular'); + } + let fileConfig; + try { + fileConfig = JSON.parse( + decodeUtf8Fatal(await fs.readFile(configPath), 'config-invalid-utf8'), + ); + } catch (error) { + throw codedError('config-json-invalid', error); + } + assert(isPlainObject(fileConfig), 'config-root-invalid'); + for (const secret of collectApiKeys(fileConfig)) secrets.add(secret); + mergeConfigPatch(effectiveConfig, fileConfig); } - let config; + return { config: effectiveConfig, secrets: [...secrets] }; +} + +function mergeConfigPatch(target, patch) { + for (const [key, value] of Object.entries(patch)) { + if (['__proto__', 'constructor', 'prototype'].includes(key)) continue; + if (value == null) continue; + if (isPlainObject(value)) { + const current = isPlainObject(target[key]) ? target[key] : {}; + target[key] = current; + mergeConfigPatch(current, value); + } else { + target[key] = value; + } + } + return target; +} + +function isPlainObject(value) { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +async function createSentinelOwnedTempDirectory({ + prefix, + sentinelName, + sentinel, + codePrefix, +}) { + const directory = await fs.mkdtemp(prefix); try { - config = JSON.parse(await fs.readFile(configPath, 'utf8')); + if (process.platform !== 'win32') await fs.chmod(directory, 0o700); + await fs.writeFile( + path.join(directory, sentinelName), + `${JSON.stringify(sentinel)}\n`, + { flag: 'wx', mode: 0o600 }, + ); + return directory; } catch (error) { - throw codedError('config-json-invalid', error); + try { + await fs.rm(directory, { recursive: true, force: true }); + } catch (cleanupError) { + throw codedError( + `${codePrefix}-sentinel-create-cleanup-failed`, + cleanupError, + ); + } + throw codedError(`${codePrefix}-sentinel-create-failed`, error); } - return { config, secrets: collectApiKeys(config) }; +} + +async function prepareGoalSuiteAppData() { + assert(isGoalRuntimeSuite(), 'goal-appdata-used-outside-goal-suite'); + const suiteSecrets = new Set(state.secrets); + const sourceConfigDir = await fs.realpath(state.options.configDir); + const ownerToken = randomUUID(); + const createdAt = Date.now(); + const appDataDir = await createSentinelOwnedTempDirectory({ + prefix: path.join(sourceConfigDir, '.agent-runtime-real-e2e-goal-'), + sentinelName: goalAppDataSentinelFileName, + sentinel: { + schemaVersion: goalAppDataSentinelSchema, + token: ownerToken, + ownerPid: process.pid, + createdAt, + }, + codePrefix: 'goal-appdata', + }); + state.goal.runner.appDataDir = appDataDir; + state.goal.runner.ownerToken = ownerToken; + state.goal.runner.createdAt = createdAt; + + for (const name of [configFileName, localConfigFileName]) { + const sourcePath = path.join(sourceConfigDir, name); + const metadata = await fs.lstat(sourcePath).catch((error) => { + if (error?.code === 'ENOENT') return null; + throw error; + }); + if (!metadata) { + assert(name !== configFileName, 'goal-source-config-missing'); + continue; + } + assert( + metadata.isFile() && !metadata.isSymbolicLink(), + 'goal-source-config-not-regular-file', + ); + const linkedPath = path.join(appDataDir, name); + try { + // Share the private config inode without serializing credentials into a copy. + await fs.link(sourcePath, linkedPath); + } catch (error) { + throw codedError('goal-appdata-config-hardlink-failed', error); + } + const linkedMetadata = await fs.lstat(linkedPath); + const sourceContent = await fs.readFile(sourcePath); + let sourceConfig; + try { + sourceConfig = JSON.parse( + decodeUtf8Fatal(sourceContent, 'goal-linked-config-invalid-utf8'), + ); + } catch (error) { + throw codedError('goal-linked-config-json-invalid', error); + } + for (const secret of collectApiKeys(sourceConfig)) suiteSecrets.add(secret); + assert( + linkedMetadata.isFile() && + !linkedMetadata.isSymbolicLink() && + linkedMetadata.dev === metadata.dev && + linkedMetadata.ino === metadata.ino, + 'goal-appdata-config-hardlink-identity-invalid', + ); + state.goal.runner.configLinks.push({ + name, + sourcePath, + linkedPath, + dev: metadata.dev, + ino: metadata.ino, + sha256: createHash('sha256').update(sourceContent).digest('hex'), + }); + } + assert( + state.goal.runner.configLinks.some((link) => link.name === configFileName), + 'goal-appdata-primary-config-link-missing', + ); + const previousLeakCount = state.transcriptScanner?.count ?? 0; + state.secrets = [...suiteSecrets]; + state.transcriptScanner = new StreamingSecretScanner(state.secrets); + state.transcriptScanner.count = previousLeakCount; + const unexpectedEndpoint = await fs + .lstat(path.join(appDataDir, runnerEndpointFileName)) + .catch((error) => { + if (error?.code === 'ENOENT') return null; + throw error; + }); + assert(!unexpectedEndpoint, 'goal-appdata-endpoint-preexisted'); + state.runtimeConfigDir = appDataDir; +} + +async function readGoalAppDataSentinel() { + const runner = state.goal.runner; + assert( + isNonEmptyString(runner.appDataDir) && isNonEmptyString(runner.ownerToken), + 'goal-appdata-ownership-missing', + ); + const sentinelPath = path.join( + runner.appDataDir, + goalAppDataSentinelFileName, + ); + const metadata = await fs.lstat(sentinelPath); + const sentinel = await readJson(sentinelPath); + assert( + metadata.isFile() && + !metadata.isSymbolicLink() && + sentinel.schemaVersion === goalAppDataSentinelSchema && + sentinel.token === runner.ownerToken && + sentinel.ownerPid === process.pid && + sentinel.createdAt === runner.createdAt, + 'goal-appdata-ownership-invalid', + ); + return sentinel; +} + +async function inspectGoalRunnerIdentity(status) { + await readGoalAppDataSentinel(); + const runner = state.goal.runner; + const pid = Number(status?.pid ?? status?.status?.pid); + const bootId = runnerBootId(status); + assert( + status?.running === true && + Number.isSafeInteger(pid) && + pid > 1 && + pid !== process.pid && + isNonEmptyString(bootId), + 'goal-owned-runner-status-invalid', + ); + const endpointPath = path.join(runner.appDataDir, runnerEndpointFileName); + const endpointMetadata = await fs.lstat(endpointPath); + const endpoint = await readJson(endpointPath); + assert( + endpointMetadata.isFile() && + !endpointMetadata.isSymbolicLink() && + endpoint.pid === pid && + endpoint.bootId === bootId && + endpoint.protocolVersion === status.protocolVersion && + endpoint.port === status.port && + Number.isSafeInteger(endpoint.heartbeatAt) && + endpoint.heartbeatAt >= runner.createdAt && + isNonEmptyString(endpoint.token) && + endpoint.token.length >= 32, + 'goal-owned-runner-endpoint-identity-invalid', + ); + return { + pid, + bootId, + protocolVersion: endpoint.protocolVersion, + port: endpoint.port, + processIdentity: await captureGoalRunnerProcessIdentity(pid), + }; +} + +async function claimGoalRunnerOwnership(status = null) { + const liveStatus = status ?? (await readRunnerStatus()); + const identity = await inspectGoalRunnerIdentity(liveStatus); + const current = state.goal.runner.current; + if (current) { + assert( + current.pid === identity.pid && + current.bootId === identity.bootId && + current.processIdentity.fingerprint === + identity.processIdentity.fingerprint && + current.killHandle?.closed === false, + 'goal-owned-runner-identity-changed-after-claim', + ); + return current; + } + + const killHandle = await openGoalRunnerKillHandle(identity.pid); + try { + const rechecked = await inspectGoalRunnerIdentity(await readRunnerStatus()); + assert( + rechecked.pid === identity.pid && + rechecked.bootId === identity.bootId && + rechecked.protocolVersion === identity.protocolVersion && + rechecked.port === identity.port && + rechecked.processIdentity.fingerprint === + identity.processIdentity.fingerprint, + 'goal-owned-runner-identity-changed-during-pidfd-claim', + ); + } catch (error) { + await closeGoalRunnerKillHandle(killHandle).catch(() => {}); + throw error; + } + state.goal.runner.current = { ...identity, killHandle }; + state.goal.runner.pidfdClaimCount += 1; + return state.goal.runner.current; +} + +async function verifyOwnedGoalRunnerForKill() { + const claimed = state.goal.runner.current; + assert(claimed, 'goal-owned-runner-not-claimed'); + const current = await inspectGoalRunnerIdentity(await readRunnerStatus()); + assert( + current.pid === claimed.pid && + current.bootId === claimed.bootId && + current.protocolVersion === claimed.protocolVersion && + current.port === claimed.port && + current.processIdentity.fingerprint === + claimed.processIdentity.fingerprint && + claimed.killHandle?.pid === claimed.pid && + claimed.killHandle.closed === false, + 'goal-owned-runner-identity-changed-before-kill', + ); + return claimed; +} + +async function ensureGoalRunnerStableKillSupport() { + assert( + process.platform === 'linux', + 'goal-runner-stable-kill-handle-platform-unsupported', + ); + const python = await findControlledLinuxPython(); + const probe = + 'import os, signal; assert hasattr(os, "pidfd_open") and hasattr(signal, "pidfd_send_signal"); fd = os.pidfd_open(os.getpid(), 0); os.close(fd)'; + try { + await runProcess(python, ['-I', '-S', '-c', probe], { + cwd: appRoot, + timeoutMs: 30_000, + env: { LANG: 'C', LC_ALL: 'C', PATH: '/usr/bin:/bin' }, + }); + } catch (error) { + throw codedError('goal-runner-pidfd-support-unavailable', error); + } +} + +async function findControlledLinuxPython() { + if (linuxPidfdPythonPath) return linuxPidfdPythonPath; + for (const candidate of ['/usr/bin/python3', '/usr/local/bin/python3']) { + const resolved = await fs.realpath(candidate).catch(() => null); + const metadata = resolved + ? await fs.stat(resolved).catch(() => null) + : null; + if (metadata?.isFile() && (metadata.mode & 0o111) !== 0) { + linuxPidfdPythonPath = resolved; + return resolved; + } + } + throw codedError('goal-runner-controlled-python-unavailable'); +} + +async function openGoalRunnerKillHandle(pid) { + assert( + process.platform === 'linux' && Number.isSafeInteger(pid) && pid > 1, + 'goal-runner-pidfd-open-precondition-invalid', + ); + const python = await findControlledLinuxPython(); + const child = spawn( + python, + ['-I', '-S', '-c', linuxPidfdHelperSource, String(pid)], + { + cwd: appRoot, + env: { LANG: 'C', LC_ALL: 'C', PATH: '/usr/bin:/bin' }, + stdio: ['pipe', 'pipe', 'pipe'], + }, + ); + const handle = { + pid, + child, + stdout: Buffer.alloc(0), + stderr: Buffer.alloc(0), + closed: false, + }; + child.stdout.on('data', (chunk) => { + handle.stdout = appendBounded(handle.stdout, chunk, 4_096); + }); + child.stderr.on('data', (chunk) => { + handle.stderr = appendBounded(handle.stderr, chunk, 4_096); + }); + await waitForGoalRunnerKillHandleReady(handle); + return handle; +} + +async function waitForGoalRunnerKillHandleReady(handle) { + await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + handle.child.kill('SIGKILL'); + reject(codedError('goal-runner-pidfd-open-timeout')); + }, 10_000); + const settle = (callback) => { + clearTimeout(timer); + handle.child.stdout.off('data', onData); + handle.child.off('error', onError); + handle.child.off('close', onClose); + callback(); + }; + const onData = () => { + if (handle.stdout.includes(Buffer.from('PIDFD_READY\n'))) { + settle(resolve); + } + }; + const onError = (error) => + settle(() => + reject(codedError('goal-runner-pidfd-helper-spawn-failed', error)), + ); + const onClose = () => + settle(() => reject(codedError('goal-runner-pidfd-open-failed'))); + handle.child.stdout.on('data', onData); + handle.child.on('error', onError); + handle.child.on('close', onClose); + onData(); + }); +} + +async function closeGoalRunnerKillHandle(handle) { + if (!handle || handle.closed) return; + handle.closed = true; + if (handle.child.exitCode !== null || handle.child.signalCode !== null) + return; + handle.child.stdin.end('CLOSE\n'); + const result = await waitForChildClose(handle.child, 10_000); + assert(result.code === 0, 'goal-runner-pidfd-close-failed'); +} + +async function signalGoalRunnerKillHandle(handle) { + assert( + handle && + handle.closed === false && + handle.child.exitCode === null && + handle.child.signalCode === null, + 'goal-runner-pidfd-handle-not-live', + ); + handle.closed = true; + handle.child.stdin.end('KILL\n'); + const result = await waitForChildClose(handle.child, 15_000); + assert( + result.code === 0 && handle.stdout.includes(Buffer.from('PIDFD_EXITED\n')), + 'goal-runner-pidfd-sigkill-failed', + ); +} + +async function waitForChildClose(child, timeoutMs) { + if (child.exitCode !== null || child.signalCode !== null) { + return { code: child.exitCode, signal: child.signalCode }; + } + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + child.kill('SIGKILL'); + reject(codedError('goal-runner-pidfd-helper-timeout')); + }, timeoutMs); + const onError = (error) => { + clearTimeout(timer); + child.off('close', onClose); + reject(codedError('goal-runner-pidfd-helper-failed', error)); + }; + const onClose = (code, signal) => { + clearTimeout(timer); + child.off('error', onError); + resolve({ code, signal }); + }; + child.once('error', onError); + child.once('close', onClose); + }); +} + +async function captureGoalRunnerProcessIdentity(pid) { + const expectedAppData = state.goal.runner.appDataDir; + assert( + isNonEmptyString(expectedAppData) && Boolean(state.cliBinary), + 'goal-runner-process-identity-context-missing', + ); + if (process.platform === 'linux') { + const [executable, expectedExecutable, stat, commandLine] = + await Promise.all([ + fs.realpath(`/proc/${pid}/exe`), + fs.realpath(state.cliBinary), + fs.readFile(`/proc/${pid}/stat`, 'utf8'), + fs.readFile(`/proc/${pid}/cmdline`), + ]); + const closeParenthesis = stat.lastIndexOf(')'); + const fields = stat + .slice(closeParenthesis + 1) + .trim() + .split(/\s+/u); + const startTime = fields[19]; + const argv = commandLine.toString('utf8').split('\0').filter(Boolean); + const configIndex = argv.indexOf('--config-dir'); + assert( + closeParenthesis > 0 && + isNonEmptyString(startTime) && + executable === expectedExecutable && + argv.includes('--agent-runner') && + configIndex >= 0 && + argv[configIndex + 1] === expectedAppData, + 'goal-runner-linux-process-identity-invalid', + ); + return { + kind: 'linux-proc', + fingerprint: hashValue(JSON.stringify({ executable, startTime, argv })), + }; + } + + if (process.platform === 'darwin') { + const result = await runProcess( + '/bin/ps', + ['-p', String(pid), '-o', 'lstart=', '-o', 'command='], + { cwd: appRoot, timeoutMs: 30_000 }, + ); + assert( + result.stdout.includes(path.basename(state.cliBinary)) && + result.stdout.includes('--agent-runner') && + result.stdout.includes(expectedAppData), + 'goal-runner-darwin-process-identity-invalid', + ); + return { + kind: 'darwin-ps', + fingerprint: hashValue(result.stdout.trim()), + }; + } + + if (process.platform === 'win32') { + const script = `$process = Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}"; if ($null -eq $process) { exit 3 }; $process | Select-Object ProcessId,CreationDate,ExecutablePath,CommandLine | ConvertTo-Json -Compress`; + const result = await runProcess( + 'powershell.exe', + ['-NoProfile', '-NonInteractive', '-Command', script], + { cwd: appRoot, timeoutMs: 30_000 }, + ); + const value = JSON.parse(result.stdout); + const executable = path.resolve(String(value.ExecutablePath ?? '')); + const expectedExecutable = path.resolve(state.cliBinary); + const commandLine = String(value.CommandLine ?? ''); + assert( + Number(value.ProcessId) === pid && + executable.toLowerCase() === expectedExecutable.toLowerCase() && + isNonEmptyString(value.CreationDate) && + commandLine.includes('--agent-runner') && + commandLine.includes(expectedAppData), + 'goal-runner-windows-process-identity-invalid', + ); + return { + kind: 'windows-cim', + fingerprint: hashValue( + JSON.stringify({ + pid, + creationDate: value.CreationDate, + executable: executable.toLowerCase(), + commandLine, + }), + ), + }; + } + + throw codedError('goal-runner-process-identity-platform-unsupported'); +} + +function isProcessAlive(pid) { + if (!Number.isSafeInteger(pid) || pid <= 1 || pid === process.pid) + return false; + try { + process.kill(pid, 0); + return true; + } catch (error) { + return error?.code === 'EPERM'; + } +} + +async function killRunnerPidOnce(pid, ownedRunner) { + if (isGoalRuntimeSuite()) { + assert(ownedRunner, 'goal-runner-pid-kill-fallback-forbidden'); + } + if (ownedRunner) { + const claimed = state.goal.runner.current; + assert( + claimed?.pid === pid && claimed.killHandle?.pid === pid, + 'goal-runner-pidfd-identity-missing', + ); + await signalGoalRunnerKillHandle(claimed.killHandle); + state.goal.runner.pidfdSignalCount += 1; + state.runnerKilled = true; + state.goal.runner.current = null; + return; + } + try { + process.kill(pid, 'SIGKILL'); + } catch (error) { + throw codedError('runner-sigkill-failed', error); + } + state.runnerKilled = true; + const deadline = Date.now() + 10_000; + while (Date.now() < deadline) { + if (!isProcessAlive(pid)) { + if (ownedRunner) state.goal.runner.current = null; + return; + } + await sleep(50); + } + throw codedError('runner-still-alive-after-sigkill'); +} + +async function stopClaimedGoalRunnerWithoutEndpoint() { + const claimed = state.goal.runner.current; + if (!claimed) return; + if (!isProcessAlive(claimed.pid)) { + await closeGoalRunnerKillHandle(claimed.killHandle); + state.goal.runner.current = null; + return; + } + let currentIdentity; + try { + currentIdentity = await captureGoalRunnerProcessIdentity(claimed.pid); + } catch (error) { + if (!isProcessAlive(claimed.pid)) { + await closeGoalRunnerKillHandle(claimed.killHandle); + state.goal.runner.current = null; + return; + } + throw error; + } + assert( + currentIdentity.fingerprint === claimed.processIdentity.fingerprint, + 'goal-owned-runner-identity-changed-without-endpoint', + ); + await killRunnerPidOnce(claimed.pid, true); +} + +async function stopOwnedGoalRunner() { + await readGoalAppDataSentinel(); + const endpointPath = path.join( + state.goal.runner.appDataDir, + runnerEndpointFileName, + ); + const endpointMetadata = await fs.lstat(endpointPath).catch((error) => { + if (error?.code === 'ENOENT') return null; + throw error; + }); + if (!endpointMetadata) { + assert( + state.goal.runner.current || !state.goal.runner.launchAttempted, + 'goal-owned-runner-endpoint-missing-before-stable-claim', + ); + await stopClaimedGoalRunnerWithoutEndpoint(); + return; + } + assert( + endpointMetadata.isFile() && !endpointMetadata.isSymbolicLink(), + 'goal-owned-runner-endpoint-not-regular-file', + ); + const endpoint = await readJson(endpointPath); + const status = await readRunnerStatus(); + if (status?.running !== true) { + assert( + !isProcessAlive(Number(endpoint.pid)), + 'goal-owned-runner-live-pid-without-identity', + ); + await closeGoalRunnerKillHandle(state.goal.runner.current?.killHandle); + state.goal.runner.current = null; + return; + } + await claimGoalRunnerOwnership(status); + await killRunnerOnce(); +} + +async function verifyGoalSuiteConfigLinksUnchanged() { + for (const link of state.goal.runner.configLinks) { + const [sourceMetadata, linkedMetadata, sourceContent] = await Promise.all([ + fs.lstat(link.sourcePath), + fs.lstat(link.linkedPath), + fs.readFile(link.sourcePath), + ]); + assert( + sourceMetadata.isFile() && + !sourceMetadata.isSymbolicLink() && + linkedMetadata.isFile() && + !linkedMetadata.isSymbolicLink() && + sourceMetadata.dev === link.dev && + sourceMetadata.ino === link.ino && + linkedMetadata.dev === link.dev && + linkedMetadata.ino === link.ino && + createHash('sha256').update(sourceContent).digest('hex') === + link.sha256, + 'goal-source-config-changed-during-suite', + ); + } +} + +async function removeGoalSuiteAppData() { + await readGoalAppDataSentinel(); + const [sourceConfigDir, appDataDir] = await Promise.all([ + fs.realpath(state.options.configDir), + fs.realpath(state.goal.runner.appDataDir), + ]); + assert( + isPathInside(sourceConfigDir, appDataDir) && + path.basename(appDataDir).startsWith('.agent-runtime-real-e2e-goal-'), + 'goal-appdata-cleanup-path-invalid', + ); + let linkError = null; + try { + await verifyGoalSuiteConfigLinksUnchanged(); + } catch (error) { + linkError = error; + } + await fs.rm(appDataDir, { recursive: true, force: false }); + state.runtimeConfigDir = state.options.configDir; + if (linkError) throw linkError; + return true; } async function checkPrerequisites(config) { - const requiredAgents = [mainAgentId, 'code-prototype', 'quality-review']; + const requiredAgents = isGoalRuntimeSuite() + ? [mainAgentId] + : [mainAgentId, 'quality-review']; const llmConfigured = requiredAgents.every((agentId) => { const effective = { apiKey: config.llm?.apiKey, @@ -570,16 +1638,17 @@ function supportedBrowserCandidates(platform, environment) { async function seedDisposableProject() { const prefix = path.join(os.tmpdir(), 'genarrative-agent-runtime-real-e2e-'); - state.projectRoot = await fs.mkdtemp(prefix); + const sentinelToken = randomUUID(); + state.projectRoot = await createSentinelOwnedTempDirectory({ + prefix, + sentinelName: sentinelFileName, + sentinel: { schemaVersion: sentinelSchema, token: sentinelToken }, + codePrefix: 'project', + }); state.projectPathTranscriptScanner = new StreamingSecretScanner( disposableProjectPathVariants(), ); - state.sentinelToken = randomUUID(); - await fs.writeFile( - path.join(state.projectRoot, sentinelFileName), - `${JSON.stringify({ schemaVersion: sentinelSchema, token: state.sentinelToken })}\n`, - { flag: 'wx', mode: 0o600 }, - ); + state.sentinelToken = sentinelToken; await Promise.all([ fs.mkdir(path.join(state.projectRoot, 'game'), { recursive: true }), fs.mkdir(path.join(state.projectRoot, 'e2e/isolated-a'), { @@ -618,7 +1687,9 @@ async function seedDisposableProject() { ), fs.writeFile( path.join(state.projectRoot, 'verify-e2e.mjs'), - `import fs from 'node:fs';\nconst html = fs.readFileSync('game/index.html', 'utf8');\nconst agents = fs.readFileSync('AGENTS.md', 'utf8');\nconst requiredCreatedContent = ${JSON.stringify(patchsetCreatedContent)};\nconst patchsetFile = fs.existsSync('${patchsetCreatedPath}') ? fs.readFileSync('${patchsetCreatedPath}', 'utf8') : '';\nconst passed = html.includes('${patchedText}') && html.includes(' { + if (error?.code === 'ENOENT') return null; + throw error; + }); + assert( + gameHtml.includes('REAL_E2E_TARGET:before') && + !gameHtml.includes(patchedText) && + patchsetMetadata === null, + 'goal-revision-two-failure-precondition-missing', + ); + await assertGoalInitialMarkerAbsent('goal-revision-two-fixture-before-write'); + await fs.writeFile( + path.join(state.projectRoot, 'verify-e2e.mjs'), + goalRevisionTwoVerificationFixtureSource(), + ); + await assertGoalInitialMarkerAbsent('goal-revision-two-fixture-after-write'); + state.goal.revisionTwoFixtureInjected = true; + const verification = await runProcess(process.execPath, ['verify-e2e.mjs'], { + cwd: state.projectRoot, + timeoutMs: 120_000, + allowNonZero: true, + }); + const exactRootCause = `ROOT_CAUSE marker=${commandRootErrorMarker} repairPath=game/index.html old=REAL_E2E_TARGET:before new=${patchedText} createPath=${patchsetCreatedPath} createContentJson=${JSON.stringify(patchsetCreatedContent)}`; + assert( + verification.code === 1 && + verification.signal === null && + verification.stderr.includes(commandFailureMarker) && + verification.stderr.includes(exactRootCause), + 'goal-revision-two-real-failure-not-observed', + ); + state.goal.revisionTwoHostFailureObserved = true; +} + async function seedProcessSessionDisposableProject() { const prefix = path.join( os.tmpdir(), 'genarrative-agent-runtime-process-real-e2e-', ); - state.projectRoot = await fs.mkdtemp(prefix); + const sentinelToken = randomUUID(); + state.projectRoot = await createSentinelOwnedTempDirectory({ + prefix, + sentinelName: sentinelFileName, + sentinel: { schemaVersion: sentinelSchema, token: sentinelToken }, + codePrefix: 'process-project', + }); state.projectPathTranscriptScanner = new StreamingSecretScanner( disposableProjectPathVariants(), ); - state.sentinelToken = randomUUID(); - await fs.writeFile( - path.join(state.projectRoot, sentinelFileName), - `${JSON.stringify({ schemaVersion: sentinelSchema, token: state.sentinelToken })}\n`, - { flag: 'wx', mode: 0o600 }, - ); + state.sentinelToken = sentinelToken; await Promise.all([ fs.mkdir(path.join(state.projectRoot, 'fixtures'), { recursive: true }), fs.mkdir(path.join(state.projectRoot, '.agent/runtime'), { @@ -922,6 +2072,50 @@ function assertResultOrientedDisposableTask(task, codePrefix) { } } +function assertGoalPayloadUnscripted(payload, codePrefix) { + assert( + payload && + isNonEmptyString(payload.outcome) && + Array.isArray(payload.constraints) && + payload.constraints.length > 0 && + Array.isArray(payload.verification) && + payload.verification.length > 0, + `${codePrefix}-payload-invalid`, + ); + assertResultOrientedDisposableTask( + [payload.outcome, ...payload.constraints, ...payload.verification].join( + '\n', + ), + codePrefix, + ); +} + +function goalPrivateBodyValues() { + return [ + goalInitialPayload.outcome, + ...goalInitialPayload.constraints, + ...goalInitialPayload.verification, + goalEditedPayload.outcome, + ...goalEditedPayload.constraints, + ...goalEditedPayload.verification, + goalInitialMarker, + goalFinalMarker, + ]; +} + +function goalPublicBodyValues() { + return [ + goalInitialPayload.outcome, + ...goalInitialPayload.constraints, + ...goalInitialPayload.verification, + goalEditedPayload.outcome, + ...goalEditedPayload.constraints, + ...goalEditedPayload.verification, + goalInitialMarker, + goalFinalMarker, + ]; +} + function assertUnscriptedSteerInstruction(instruction) { for (const forbidden of [ '/', @@ -977,9 +2171,10 @@ async function prepareCliBinary() { async function runCli(args, options = {}) { assert(Boolean(state.cliBinary), 'cli-binary-not-ready'); + assert(Boolean(state.runtimeConfigDir), 'runtime-config-dir-not-ready'); return runProcess( state.cliBinary, - [...args, '--config-dir', state.options.configDir], + [...args, '--config-dir', state.runtimeConfigDir], { cwd: appRoot, timeoutMs: options.timeoutMs ?? 60_000, @@ -988,13 +2183,19 @@ async function runCli(args, options = {}) { ); } -async function runProcess(program, args, { cwd, timeoutMs, stdin }) { +async function runProcess( + program, + args, + { cwd, timeoutMs, stdin, allowNonZero = false, env = null }, +) { + throwIfShutdownRequested(); return new Promise((resolve, reject) => { const child = spawn(program, args, { cwd, - env: { ...process.env, NO_COLOR: '1', RUST_BACKTRACE: '0' }, + env: env ?? { ...process.env, NO_COLOR: '1', RUST_BACKTRACE: '0' }, stdio: [stdin === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'], }); + activeCommandChildren.add(child); let stdout = Buffer.alloc(0); let stderr = Buffer.alloc(0); let timedOut = false; @@ -1014,10 +2215,12 @@ async function runProcess(program, args, { cwd, timeoutMs, stdin }) { }); child.on('error', (error) => { clearTimeout(timer); + activeCommandChildren.delete(child); reject(codedError('process-spawn-failed', error)); }); child.on('close', (code, signal) => { clearTimeout(timer); + activeCommandChildren.delete(child); const result = { stdout: stdout.toString('utf8'), stderr: stderr.toString('utf8'), @@ -1026,7 +2229,9 @@ async function runProcess(program, args, { cwd, timeoutMs, stdin }) { }; if (timedOut) { reject(codedError('process-timeout')); - } else if (code !== 0) { + } else if (shutdownSignal && !cleanupInProgress) { + reject(codedError(`interrupted-${shutdownSignal.toLowerCase()}`)); + } else if (code !== 0 && !allowNonZero) { reject(codedError('cli-command-failed')); } else { resolve(result); @@ -1042,7 +2247,7 @@ async function readRuntime(agentId) { { timeoutMs: 60_000 }, ); const value = parseAssignedJson(result.stdout, ['runtimeJson']); - const runtime = value?.state ?? value?.runtime?.state ?? value; + const runtime = value?.state; assert(runtime && typeof runtime === 'object', 'runtime-json-invalid'); return runtime; } @@ -1082,11 +2287,7 @@ function agentConversationPath(agentId, sessionId) { async function readRunnerStatus() { const result = await runCli(['--runner-status'], { timeoutMs: 60_000 }); - return parseAssignedJson(result.stdout, [ - 'runnerJson', - 'runnerStatusJson', - 'statusJson', - ]); + return parseAssignedJson(result.stdout, ['runnerJson']); } async function waitForCanonicalRuntime() { @@ -1109,31 +2310,23 @@ async function waitForCanonicalRuntime() { } async function killRunnerOnce() { - const runner = await readRunnerStatus(); - const pid = Number(runner?.pid ?? runner?.status?.pid); - assert( - Number.isSafeInteger(pid) && pid > 1 && pid !== process.pid, - 'runner-pid-invalid', - ); - try { - process.kill(pid, 'SIGKILL'); - } catch (error) { - throw codedError('runner-sigkill-failed', error); + const ownedRunner = isGoalRuntimeSuite() + ? await verifyOwnedGoalRunnerForKill() + : null; + const runner = ownedRunner ? null : await readRunnerStatus(); + const pid = ownedRunner + ? ownedRunner.pid + : Number(runner?.pid ?? runner?.status?.pid); + if (!ownedRunner) { + assert( + Number.isSafeInteger(pid) && pid > 1 && pid !== process.pid, + 'runner-pid-invalid', + ); } - state.runnerKilled = true; - const deadline = Date.now() + 10_000; - while (Date.now() < deadline) { - try { - process.kill(pid, 0); - } catch { - return; - } - await sleep(50); - } - throw codedError('runner-still-alive-after-sigkill'); + await killRunnerPidOnce(pid, Boolean(ownedRunner)); } -async function stopExistingRunnerBeforeProcessSuite() { +async function stopExistingRunnerBeforeRuntimeSuite() { const runner = await readRunnerStatus().catch(() => null); const pid = Number(runner?.pid ?? runner?.status?.pid); if (!Number.isSafeInteger(pid) || pid <= 1 || pid === process.pid) return; @@ -1371,6 +2564,543 @@ function assertStructuredPlanAuditSnapshot(runtime, records, code) { ); } +function parseGoalMutation(result) { + const mutation = parseAssignedJson(result.stdout, ['goalMutationJson']); + assert( + mutation?.goal && + mutation?.runtime?.state && + typeof mutation.providerInterrupted === 'boolean', + 'goal-mutation-json-invalid', + ); + return mutation; +} + +function assertGoalMutationIdentity(mutation, expectedRevision, codePrefix) { + const goal = mutation.goal; + const runtime = mutation.runtime.state; + const payload = + expectedRevision === state.goal.initialRevision || expectedRevision === 1 + ? goalInitialPayload + : goalEditedPayload; + const runtimeIdentityMatches = + runtime.runId === goal.runId && + runtime.agentId === goal.agentId && + runtime.sessionId === goal.sessionId && + runtime.goalId === goal.goalId && + runtime.goalRevision === goal.revision && + runtime.goalStatus === goal.status; + const queuedStartMatches = + codePrefix === 'goal-start' && + mutation.runtime.recentTasks?.some( + (task) => + task.agentId === goal.agentId && + task.sessionId === goal.sessionId && + task.runId === goal.runId && + task.goalId === goal.goalId && + task.goalRevision === goal.revision && + task.goalStatus === goal.status && + task.status === 'pending', + ); + assert( + goal.schemaVersion === 'game-creator-agent-goal.v1' && + isNonEmptyString(goal.projectId) && + goal.agentId === mainAgentId && + goal.sessionId === goalSessionId && + goal.runId === requestedRunId && + goal.revision === expectedRevision && + goal.outcome === payload.outcome && + JSON.stringify(goal.constraints) === + JSON.stringify(payload.constraints) && + JSON.stringify(goal.verification) === + JSON.stringify(payload.verification) && + (runtimeIdentityMatches || queuedStartMatches), + `${codePrefix}-identity-invalid`, + ); +} + +function goalSnapshotFingerprint(goal) { + return hashValue( + JSON.stringify({ + projectId: goal.projectId, + goalId: goal.goalId, + agentId: goal.agentId, + sessionId: goal.sessionId, + runId: goal.runId, + revision: goal.revision, + outcome: goal.outcome, + constraints: goal.constraints, + verification: goal.verification, + }), + ); +} + +async function readGoalStatus() { + const result = await runCli( + [ + '--agent-goal-status', + state.projectRoot, + mainAgentId, + state.initialSessionId, + ], + { timeoutMs: 60_000 }, + ); + const goal = parseAssignedJson(result.stdout, ['goalJson']); + assert(goal && typeof goal === 'object', 'goal-status-json-invalid'); + return goal; +} + +function assertGoalContextSnapshot(runtime, contextBundle, goal, codePrefix) { + const expectedGoalSnapshotFingerprint = goalSnapshotFingerprint(goal); + assert( + contextBundle.schemaVersion === 'game-creator-runtime-context-bundle.v4' && + contextBundle.projectId === goal.projectId && + contextBundle.agentId === runtime.agentId && + contextBundle.taskId === runtime.taskId && + contextBundle.sessionId === runtime.sessionId && + contextBundle.runId === runtime.runId && + contextBundle.goalId === goal.goalId && + contextBundle.goalRevision === goal.revision && + contextBundle.goalStatus === 'active' && + contextBundle.goalSnapshotFingerprint === + expectedGoalSnapshotFingerprint && + runtime.goalId === goal.goalId && + runtime.goalRevision === goal.revision && + runtime.goalStatus === goal.status && + runtime.goalOutcome === goal.outcome && + JSON.stringify(runtime.goalConstraints) === + JSON.stringify(goal.constraints) && + JSON.stringify(runtime.goalVerification) === + JSON.stringify(goal.verification) && + contextBundle.planRevision === runtime.planRevision && + contextBundle.planExplanation === runtime.planExplanation && + JSON.stringify(contextBundle.planSteps) === + JSON.stringify(runtime.planSteps) && + contextBundle.activePlanStepIndex === runtime.activePlanStepIndex, + `${codePrefix}-context-snapshot-mismatch`, + ); +} + +async function readVerifiedGoalPlanSnapshot(codePrefix) { + const [runtime, contextBundle, records, goal] = await Promise.all([ + readJson(mainRuntimeStatePath()), + readJson(mainContextBundlePath()), + readOptionalJsonl(path.join(state.projectRoot, '.agent/agent.db')), + readGoalStatus(), + ]); + assert(goal.status === 'active', `${codePrefix}-goal-not-active`); + const snapshot = inspectStructuredPlanSnapshot(runtime, codePrefix); + assertGoalContextSnapshot(runtime, contextBundle, goal, codePrefix); + assertStructuredPlanAuditSnapshot(runtime, records, `${codePrefix}-audit`); + return { ...snapshot, runtime, contextBundle, goal, records }; +} + +function goalPendingDeliveryMutation(pending) { + const action = pending?.record?.action ?? pending?.action; + const input = action?.input; + if (!action || !input || typeof input !== 'object') return null; + if (action.tool === 'file.write') { + return { + tool: action.tool, + path: input.path, + content: input.content, + }; + } + if (action.tool !== 'project.patchset' || !Array.isArray(input.changes)) { + return null; + } + const matchingChanges = input.changes.filter( + (change) => + change?.operation === 'create' && change?.path === goalDeliveryPath, + ); + if (matchingChanges.length !== 1) return null; + return { + tool: action.tool, + path: matchingChanges[0].path, + content: matchingChanges[0].content, + }; +} + +function goalPendingMatchesDelivery(pending, marker) { + const mutation = goalPendingDeliveryMutation(pending); + return ( + mutation?.path === goalDeliveryPath && mutation.content === `${marker}\n` + ); +} + +function validateGoalPendingAction( + pending, + plan, + revision, + marker, + codePrefix, +) { + const record = pending.record; + const expectedGoalSnapshotFingerprint = goalSnapshotFingerprint(plan.goal); + assert( + record?.schemaVersion === 'game-creator-pending-action.v5' && + record.agentId === mainAgentId && + record.taskId === plan.runtime.taskId && + record.sessionId === state.initialSessionId && + record.runId === state.initialRunId && + record.goalId === state.goal.goalId && + record.goalRevision === revision && + record.goalSnapshotFingerprint === expectedGoalSnapshotFingerprint && + plan.contextBundle.goalSnapshotFingerprint === + expectedGoalSnapshotFingerprint && + isNonEmptyString(record.actionId) && + isNonEmptyString(record.actionFingerprint) && + record.actionId === pending.actionId && + record.action?.tool === pending.tool && + ['pending', 'pending-confirmation'].includes(record.status) && + Number.isSafeInteger(record.plannedSteerCursor) && + goalPendingMatchesDelivery(pending, marker), + `${codePrefix}-pending-action-invalid`, + ); + if (revision === state.goal.initialRevision) { + assert( + state.goal.initialGoalSnapshotFingerprint == null || + state.goal.initialGoalSnapshotFingerprint === + expectedGoalSnapshotFingerprint, + `${codePrefix}-initial-goal-fingerprint-changed`, + ); + state.goal.initialGoalSnapshotFingerprint = expectedGoalSnapshotFingerprint; + } else if (revision === state.goal.editedRevision) { + assert( + isNonEmptyString(state.goal.initialGoalSnapshotFingerprint) && + expectedGoalSnapshotFingerprint !== + state.goal.initialGoalSnapshotFingerprint, + `${codePrefix}-goal-fingerprint-did-not-change`, + ); + state.goal.editedGoalSnapshotFingerprint = expectedGoalSnapshotFingerprint; + } +} + +async function waitForGoalRevisionPendingAction({ + revision, + marker, + codePrefix, + minimumPlanRevision = 1, + requiredCompletedStepHashes = [], +}) { + const deadline = Date.now() + runTimeoutMs; + let lastError = null; + while (Date.now() < deadline) { + await assertGoalInitialMarkerAbsent(`${codePrefix}-poll`); + const taskSnapshot = await readTaskSnapshot(); + const initial = taskSnapshot.latest.find( + (task) => + task.agentId === mainAgentId && task.runId === state.initialRunId, + ); + if (initial && isFailedTask(initial)) { + throw codedError(`${codePrefix}-runtime-failed-before-pending`); + } + if (initial && !isLiveTask(initial)) { + throw codedError(`${codePrefix}-runtime-terminal-before-pending`); + } + + let targetPending = null; + try { + const plan = await readVerifiedGoalPlanSnapshot(`${codePrefix}-plan`); + const pendingActions = (await findPendingActions()).filter( + (pending) => + pending.agentId === mainAgentId && + pending.runId === state.initialRunId, + ); + targetPending = pendingActions.find((pending) => + goalPendingMatchesDelivery(pending, marker), + ); + if (targetPending) { + validateGoalPendingAction( + targetPending, + plan, + revision, + marker, + codePrefix, + ); + assert( + plan.revision >= minimumPlanRevision && + plan.completedStepHashes.length > 0 && + plan.incompleteStepCount > 0 && + requiredCompletedStepHashes.every((stepHash) => + plan.completedStepHashes.includes(stepHash), + ), + `${codePrefix}-partial-plan-missing`, + ); + const messages = await readOptionalJsonl( + agentConversationPath(mainAgentId, state.initialSessionId), + ); + assert( + messages.filter((message) => message.role === 'assistant').length === + 0, + `${codePrefix}-assistant-before-control-point`, + ); + return { pending: targetPending, plan }; + } + } catch (error) { + lastError = error; + } + + await confirmPendingActions( + null, + (pending) => !goalPendingMatchesDelivery(pending, marker), + ); + await sleep(pollIntervalMs); + } + throw codedError(`${codePrefix}-pending-action-timeout`, lastError); +} + +function summarizeGoalPending(pending) { + return { + actionId: pending.actionId, + actionFingerprint: pending.record.actionFingerprint, + goalRevision: pending.record.goalRevision, + schemaVersion: pending.record.schemaVersion, + tool: pending.tool, + }; +} + +function goalOldActionExecutionEvidence(records, actionId) { + const executing = records.filter( + (record) => + record.actionId === actionId && + [ + 'agent.runtime.tool_action.executing', + 'agent.runtime.tool_confirmation.approved', + ].includes(record.recordType), + ); + const successfulReceipts = records.filter( + (record) => + record.recordType === 'agent.runtime.action_receipt' && + record.actionId === actionId && + ['ok', 'command-failed'].includes(record.status), + ); + return { + executionCount: executing.length, + successfulReceiptCount: successfulReceipts.length, + }; +} + +async function waitForGoalOldActionBlocked(initialPending) { + const deadline = Date.now() + 120_000; + let lastError = null; + while (Date.now() < deadline) { + await assertGoalInitialMarkerAbsent( + 'goal-old-action-block-poll', + initialPending.actionId, + ); + try { + const [runtime, contextBundle, records, goal, oldMarkerCount] = + await Promise.all([ + readJson(mainRuntimeStatePath()), + readJson(mainContextBundlePath()), + readOptionalJsonl(path.join(state.projectRoot, '.agent/agent.db')), + readGoalStatus(), + countMarkerOutsideRuntimeControl(goalInitialMarker), + ]); + const execution = goalOldActionExecutionEvidence( + records, + initialPending.actionId, + ); + assert( + execution.executionCount === 0 && + execution.successfulReceiptCount === 0 && + oldMarkerCount === 0, + 'goal-old-action-executed', + ); + const blocked = (contextBundle.observations ?? []).filter( + (observation) => + observation?.tool === 'runtime.goal' && + observation?.status === 'blocked', + ); + const blockedReceipts = records.filter( + (record) => + record.recordType === 'agent.runtime.action_receipt' && + record.agentId === mainAgentId && + record.runId === state.initialRunId && + record.actionId === initialPending.actionId && + record.tool === 'runtime.goal' && + record.status === 'blocked', + ); + if ( + goal.revision === state.goal.editedRevision && + goal.status === 'active' && + runtime.runId === state.initialRunId && + runtime.sessionId === state.initialSessionId && + runtime.goalId === state.goal.goalId && + runtime.goalRevision === state.goal.editedRevision && + contextBundle.schemaVersion === + 'game-creator-runtime-context-bundle.v4' && + contextBundle.goalId === state.goal.goalId && + contextBundle.goalRevision === state.goal.editedRevision && + contextBundle.goalSnapshotFingerprint === + goalSnapshotFingerprint(goal) && + contextBundle.goalSnapshotFingerprint !== + state.goal.initialGoalSnapshotFingerprint && + blocked.length >= 1 && + blockedReceipts.length === 1 + ) { + state.goal.initialPending.blockedObservationCount = blocked.length; + state.goal.initialPending.blockedReceiptCount = blockedReceipts.length; + return; + } + lastError = codedError('goal-old-action-block-not-yet-observed'); + } catch (error) { + lastError = error; + } + await sleep(pollIntervalMs); + } + throw codedError('goal-old-action-block-timeout', lastError); +} + +async function waitForGoalRevisionTwoAgentVerificationFailure() { + assert( + state.goal.revisionTwoFixtureInjected === true && + state.goal.revisionTwoHostFailureObserved === true && + Number.isSafeInteger(state.goal.revisionTwoEditAgentDbBoundary), + 'goal-revision-two-agent-failure-precondition-invalid', + ); + const deadline = Date.now() + runTimeoutMs; + let lastError = null; + while (Date.now() < deadline) { + await assertGoalInitialMarkerAbsent('goal-revision-two-verify-poll'); + const records = await readOptionalJsonl( + path.join(state.projectRoot, '.agent/agent.db'), + ); + const failure = findGoalRevisionTwoAgentVerificationFailure(records); + if (failure) { + const contextBundle = await readJson(mainContextBundlePath()); + const failedObservations = (contextBundle.observations ?? []).filter( + (observation) => + observation?.tool === 'project.verify' && + observation?.status === 'failed', + ); + if (failedObservations.length === 0) { + lastError = codedError( + 'goal-revision-two-failed-observation-not-checkpointed', + ); + await sleep(pollIntervalMs); + continue; + } + const logPath = resolveProjectRelative(failure.audit.logPath); + assert( + relativeProjectPath(logPath).startsWith('.agent/') && + failure.audit.exitCode === 1 && + failure.audit.timedOut === false, + 'goal-revision-two-failed-verification-audit-invalid', + ); + const log = await fs.readFile(logPath); + assert( + countExactSecrets(log, [commandRootErrorMarker]) === 1 && + log.includes(Buffer.from(commandFailureMarker)), + 'goal-revision-two-failed-verification-log-invalid', + ); + state.goal.revisionTwoFailureObserved = true; + state.goal.revisionTwoFailureExitCode = failure.audit.exitCode; + state.goal.revisionTwoFailureFingerprint = hashValue( + Buffer.concat([ + Buffer.from( + `${failure.audit.actionId}\0${failure.audit.actionFingerprint}\0`, + ), + log, + ]), + ); + state.goal.revisionTwoAgentDbBoundary = failure.observationIndex + 1; + return; + } + + const pendingActions = (await findPendingActions()).filter( + (pending) => + pending.agentId === mainAgentId && pending.runId === state.initialRunId, + ); + const earlyWrite = pendingActions.find((pending) => + goalProjectWriteTools.has(pending.tool), + ); + assert(!earlyWrite, 'goal-revision-two-write-before-failed-verification'); + await confirmPendingActions( + new Set(['project.checkpoint', 'project.verify']), + (pending) => + pending.tool === 'project.checkpoint' || + pending.tool === 'project.verify', + ); + lastError = codedError('goal-revision-two-agent-failure-not-yet-observed'); + await sleep(pollIntervalMs); + } + throw codedError('goal-revision-two-agent-failure-timeout', lastError); +} + +function findGoalRevisionTwoAgentVerificationFailure(records) { + for ( + let auditIndex = state.goal.revisionTwoEditAgentDbBoundary; + auditIndex < records.length; + auditIndex += 1 + ) { + const audit = records[auditIndex]; + if ( + audit.recordType !== 'agent.runtime.project.verify' || + audit.agentId !== mainAgentId || + audit.runId !== state.initialRunId || + audit.status !== 'failed' || + audit.exitCode !== 1 || + audit.timedOut !== false || + !['test', 'check:e2e'].includes(audit.script) || + audit.expectedCommand !== verificationCommand || + !isNonEmptyString(audit.actionId) || + !isNonEmptyString(audit.actionFingerprint) || + !isNonEmptyString(audit.logPath) || + !hasExpectedWorkspaceSandboxMetadata(audit) + ) { + continue; + } + let startIndex = -1; + for (let index = auditIndex - 1; index >= 0; index -= 1) { + const record = records[index]; + if ( + record.agentId === mainAgentId && + record.runId === state.initialRunId && + record.tool === 'project.verify' && + record.actionId === audit.actionId && + record.actionFingerprint === audit.actionFingerprint && + [ + 'agent.runtime.tool_action.executing', + 'agent.runtime.tool_confirmation.approved', + ].includes(record.recordType) + ) { + startIndex = index; + break; + } + } + const observationIndex = records.findIndex( + (record, index) => + index > auditIndex && + record.recordType === 'agent.runtime.tool_observation' && + record.agentId === mainAgentId && + record.runId === state.initialRunId && + record.tool === 'project.verify' && + record.actionId === audit.actionId && + record.status === 'failed', + ); + if ( + startIndex < state.goal.revisionTwoEditAgentDbBoundary || + observationIndex < 0 + ) { + continue; + } + const earlyWrite = records + .slice(state.goal.revisionTwoEditAgentDbBoundary, observationIndex + 1) + .some( + (record) => + goalProjectWriteTools.has(record.tool) && + [ + 'agent.runtime.tool_action.executing', + 'agent.runtime.tool_confirmation.approved', + ].includes(record.recordType), + ); + assert(!earlyWrite, 'goal-revision-two-write-executed-before-failure'); + return { audit, auditIndex, observationIndex, startIndex }; + } + return null; +} + async function injectSameRunSteer() { assertUnscriptedSteerInstruction(steerInstruction); const steerTargetPlan = await waitForProviderPlanningSteerTarget(); @@ -1775,6 +3505,245 @@ async function driveRuntimeToQuiescence() { throw codedError('runtime-e2e-timeout'); } +async function readGoalRuntimePersistence() { + const taskSnapshot = await readTaskSnapshot(); + const eventFiles = await listFiles( + path.join(state.projectRoot, '.agent/runtime/events'), + ); + const events = []; + for (const file of eventFiles.filter((entry) => entry.endsWith('.jsonl'))) { + events.push(...(await readJsonl(file))); + } + const [ + agentDb, + conversations, + activity, + output, + runtimeState, + contextBundle, + goal, + ] = await Promise.all([ + readOptionalJsonl(path.join(state.projectRoot, '.agent/agent.db')), + readOptionalJsonl( + agentConversationPath(mainAgentId, state.initialSessionId), + ), + readOptionalJsonl(path.join(state.projectRoot, '.agent/activity.jsonl')), + readOptionalJsonl(path.join(state.projectRoot, '.agent/output.jsonl')), + readJson(mainRuntimeStatePath()), + readJson(mainContextBundlePath()), + readGoalStatus(), + ]); + const pendingActions = (await findPendingActions()).filter( + (pending) => + pending.agentId === mainAgentId && pending.runId === state.initialRunId, + ); + return { + taskSnapshot, + events, + agentDb, + conversations, + activity, + output, + runtimeState, + contextBundle, + goal, + pendingActions, + }; +} + +async function captureGoalPausedSnapshot(expectedPending, codePrefix) { + await assertGoalInitialMarkerAbsent(`${codePrefix}-marker-check`); + const persistence = await readGoalRuntimePersistence(); + const { + taskSnapshot, + events, + agentDb, + conversations, + runtimeState, + contextBundle, + goal, + pendingActions, + } = persistence; + const latest = taskSnapshot.latest.find( + (task) => task.agentId === mainAgentId && task.runId === state.initialRunId, + ); + const targetRuns = [ + ...new Set( + taskSnapshot.all + .filter((task) => task.agentId === mainAgentId) + .map((task) => task.runId), + ), + ].sort(); + const pending = pendingActions.find( + (candidate) => candidate.actionId === expectedPending.actionId, + ); + assert( + goal.goalId === state.goal.goalId && + goal.revision === state.goal.editedRevision && + goal.status === 'paused' && + runtimeState.agentId === mainAgentId && + runtimeState.sessionId === state.initialSessionId && + runtimeState.runId === state.initialRunId && + runtimeState.goalId === state.goal.goalId && + runtimeState.goalRevision === state.goal.editedRevision && + runtimeState.goalStatus === 'paused' && + runtimeState.status === 'paused' && + runtimeState.phase === 'paused' && + latest?.status === 'paused' && + latest?.phase === 'paused' && + JSON.stringify(targetRuns) === JSON.stringify([state.initialRunId]) && + contextBundle.schemaVersion === + 'game-creator-runtime-context-bundle.v4' && + contextBundle.goalId === state.goal.goalId && + contextBundle.goalRevision === state.goal.editedRevision && + contextBundle.goalStatus === 'active' && + pending?.record?.schemaVersion === 'game-creator-pending-action.v5' && + pending.record.goalId === state.goal.goalId && + pending.record.goalRevision === state.goal.editedRevision && + pending.record.goalSnapshotFingerprint === + contextBundle.goalSnapshotFingerprint && + contextBundle.goalSnapshotFingerprint === goalSnapshotFingerprint(goal) && + contextBundle.goalSnapshotFingerprint === + state.goal.editedGoalSnapshotFingerprint && + contextBundle.goalSnapshotFingerprint !== + state.goal.initialGoalSnapshotFingerprint && + conversations.filter((message) => message.role === 'assistant').length === + 0, + `${codePrefix}-state-invalid`, + ); + const planProtocolCount = agentDb.filter( + (record) => + record.recordType === 'agent.runtime.tool_plan.protocol' && + record.agentId === mainAgentId && + record.runId === state.initialRunId, + ).length; + const actionProgressCount = agentDb.filter( + (record) => + record.agentId === mainAgentId && + record.runId === state.initialRunId && + [ + 'agent.runtime.tool_action.executing', + 'agent.runtime.tool_action.observed', + 'agent.runtime.tool_confirmation.approved', + 'agent.runtime.action_receipt', + ].includes(record.recordType), + ).length; + const providerRequestStartedCount = agentDb.filter( + (record) => + record.recordType === 'agent.runtime.provider_request.lifecycle' && + record.agentId === mainAgentId && + record.runId === state.initialRunId && + record.status === 'started', + ).length; + const signaturePayload = { + goalSha256: hashValue(JSON.stringify(goal)), + runtimeSha256: hashValue(JSON.stringify(runtimeState)), + contextSha256: hashValue(JSON.stringify(contextBundle)), + pendingSha256: hashValue(JSON.stringify(pending.record)), + taskCount: taskSnapshot.all.filter( + (task) => + task.agentId === mainAgentId && task.runId === state.initialRunId, + ).length, + eventCount: events.filter( + (event) => + event.agentId === mainAgentId && event.runId === state.initialRunId, + ).length, + agentDbCount: agentDb.filter( + (record) => + record.agentId === mainAgentId && record.runId === state.initialRunId, + ).length, + conversationCount: conversations.length, + assistantCount: conversations.filter( + (message) => message.role === 'assistant', + ).length, + planProtocolCount, + providerRequestStartedCount, + actionProgressCount, + planRevision: runtimeState.planRevision, + pendingActionIdHash: hashValue(pending.actionId), + }; + return { + ...signaturePayload, + signature: hashValue(JSON.stringify(signaturePayload)), + }; +} + +async function assertGoalRemainsPausedAfterRestart(baseline, expectedPending) { + let latest = null; + for (let poll = 0; poll < 4; poll += 1) { + latest = await captureGoalPausedSnapshot( + expectedPending, + 'goal-paused-after-restart', + ); + assert( + latest.signature === baseline.signature, + 'goal-paused-evidence-progressed-after-restart', + ); + await sleep(1_000); + } + state.goal.pausedAfterRestart = latest; +} + +async function waitForGoalExecutionOwnerTakeover() { + const ownerPath = path.join( + state.projectRoot, + '.agent/runtime/execution-owner.json', + ); + const deadline = Date.now() + 30_000; + while (Date.now() < deadline) { + const owner = await readJson(ownerPath).catch(() => null); + if ( + Number.isSafeInteger(owner?.protocolVersion) && + owner.protocolVersion > 0 && + Number.isSafeInteger(owner.pid) && + owner.pid > 1 && + owner.bootId === state.goal.newRunnerBootId && + owner.recoveredFromBootId === state.goal.oldRunnerBootId + ) { + return true; + } + await sleep(100); + } + throw codedError('goal-execution-owner-takeover-timeout'); +} + +async function driveGoalRuntimeToQuiescence() { + const deadline = Date.now() + runTimeoutMs; + let quietPolls = 0; + while (Date.now() < deadline) { + await assertGoalInitialMarkerAbsent('goal-quiescence-poll'); + await confirmPendingActions(); + const [snapshot, goal] = await Promise.all([ + readTaskSnapshot(), + readGoalStatus(), + ]); + const initial = snapshot.latest.find( + (task) => + task.agentId === mainAgentId && task.runId === state.initialRunId, + ); + if (initial && isFailedTask(initial)) { + throw codedError('goal-runtime-failed'); + } + if (goal.status === 'needs-reconciliation') { + throw codedError('goal-runtime-needs-reconciliation'); + } + const pending = await findPendingActions(); + const completed = + initial?.status === 'completed' && + initial?.phase === 'completed' && + goal.status === 'completed'; + const hasLive = snapshot.latest.some(isLiveTask); + if (completed && !hasLive && pending.length === 0) { + quietPolls += 1; + if (quietPolls >= 3) return; + } else { + quietPolls = 0; + } + await sleep(pollIntervalMs); + } + throw codedError('goal-runtime-e2e-timeout'); +} + async function driveProcessRuntimeToQuiescence() { const deadline = Date.now() + runTimeoutMs; let quietPolls = 0; @@ -1961,14 +3930,27 @@ async function isIsolatedJoinSettledForQuiescence(joinTasks) { ); } -async function confirmPendingActions(allowedTools = null) { +async function confirmPendingActions( + allowedTools = null, + shouldConfirm = () => true, +) { for (const pending of await findPendingActions()) { if (state.confirmedActionIds.has(pending.actionId)) continue; + if (!shouldConfirm(pending)) continue; const whitelist = new Set([ 'project.patchset', 'project.git_commit', 'command.exec', ...(state.suite === 'llm-runtime' ? ['command.run_limited'] : []), + ...(isGoalRuntimeSuite() + ? [ + 'command.run_limited', + 'project.checkpoint', + 'file.write', + 'file.patch', + 'file.delete', + ] + : []), 'project.verify', 'preview.start', 'preview.validate', @@ -2001,6 +3983,19 @@ async function confirmPendingActions(allowedTools = null) { ); assert(runtimePending.tool === pending.tool, 'pending-tool-mismatch'); } + const monitorsGoalWrite = + isGoalRuntimeSuite() && goalProjectWriteTools.has(pending.tool); + if (monitorsGoalWrite) { + assert( + state.goal.editedRevision === 0 || + state.goal.revisionTwoFailureObserved === true, + 'goal-write-confirmed-before-revision-two-failure', + ); + await assertGoalInitialMarkerAbsent( + 'goal-write-before-confirm', + pending.actionId, + ); + } await runCli( [ '--agent-confirm', @@ -2012,9 +4007,47 @@ async function confirmPendingActions(allowedTools = null) { { timeoutMs: 120_000 }, ); state.confirmedActionIds.add(pending.actionId); + if (monitorsGoalWrite) { + await monitorGoalWriteActionUntilSettled(pending); + } } } +async function monitorGoalWriteActionUntilSettled(pending) { + state.goal.monitoredWriteActionIds.add(pending.actionId); + const deadline = Date.now() + 120_000; + while (Date.now() < deadline) { + await assertGoalInitialMarkerAbsent( + 'goal-write-after-confirm', + pending.actionId, + ); + const records = await readOptionalJsonl( + path.join(state.projectRoot, '.agent/agent.db'), + ); + const settled = records.some( + (record) => + record.agentId === pending.agentId && + record.runId === pending.runId && + record.actionId === pending.actionId && + record.tool === pending.tool && + [ + 'agent.runtime.action_receipt', + 'agent.runtime.tool_action.observed', + 'agent.runtime.tool_observation', + ].includes(record.recordType), + ); + if (settled) { + await assertGoalInitialMarkerAbsent( + 'goal-write-settled', + pending.actionId, + ); + return; + } + await sleep(100); + } + throw codedError('goal-write-action-settle-timeout'); +} + async function findPendingActions() { const root = path.join(state.projectRoot, '.agent/runtime/pending-actions'); const files = await listFiles(root); @@ -2044,7 +4077,7 @@ async function findPendingActions() { const actionId = action.actionId ?? value.actionId; const tool = action.tool ?? value.tool; if (agentId && runId && actionId && tool) { - pending.push({ agentId, runId, actionId, tool }); + pending.push({ agentId, runId, actionId, tool, action, record: value }); } } return pending; @@ -2058,6 +4091,10 @@ async function readTaskSnapshot() { for (const file of taskFiles.filter((entry) => entry.endsWith('.jsonl'))) { all.push(...(await readJsonl(file))); } + return buildTaskSnapshot(all); +} + +function buildTaskSnapshot(all) { const latestByIdentity = new Map(); for (const record of all) { latestByIdentity.set(`${record.agentId}\0${record.runId}`, record); @@ -4885,6 +6922,747 @@ async function validateLandedEvidence() { }; } +async function validateGoalRuntimeEvidence() { + const persistence = await readGoalRuntimePersistence(); + const { + taskSnapshot, + events, + agentDb, + conversations, + activity, + output, + runtimeState, + contextBundle, + goal, + pendingActions, + } = persistence; + assert(taskSnapshot.all.length > 0, 'goal-task-evidence-missing'); + assert(events.length > 0, 'goal-event-evidence-missing'); + assert(agentDb.length > 0, 'goal-agent-db-evidence-missing'); + assertNoPersistedImagePayload('goal-task', taskSnapshot.all); + assertNoPersistedImagePayload('goal-event', events); + assertNoPersistedImagePayload('goal-agent-db', agentDb); + + const plan = inspectStructuredPlanSnapshot(runtimeState, 'goal-final-plan'); + const verificationExecution = + findFinalSuccessfulGoalVerificationExecution(agentDb); + const projectRevision = await readJson( + path.join(state.projectRoot, '.agent/runtime/project-revision.json'), + ); + const verificationGate = await readGoalVerificationGate( + projectRevision, + verificationExecution, + goal, + ); + const expectedCompletionEvidence = [ + `goalRevision=${state.goal.editedRevision}`, + `planRevision=${runtimeState.planRevision} completedSteps=${plan.completedStepHashes.length}`, + `verificationRequired=true verifiedRevision=${projectRevision.revision}`, + `runId=${state.initialRunId} sessionId=${state.initialSessionId}`, + ]; + assert( + plan.incompleteStepCount === 0 && + plan.completedStepHashes.length === runtimeState.planSteps.length && + state.goal.initialCompletedStepHashes.every((stepHash) => + plan.completedStepHashes.includes(stepHash), + ), + 'goal-final-plan-incomplete', + ); + assertGoalContextSnapshot(runtimeState, contextBundle, goal, 'goal-final'); + assertStructuredPlanAuditSnapshot( + runtimeState, + agentDb, + 'goal-final-plan-audit', + ); + assert( + goal.schemaVersion === 'game-creator-agent-goal.v1' && + goal.goalId === state.goal.goalId && + goal.agentId === mainAgentId && + goal.sessionId === state.initialSessionId && + goal.runId === state.initialRunId && + goal.revision === state.goal.editedRevision && + goalSnapshotFingerprint(goal) === + state.goal.editedGoalSnapshotFingerprint && + state.goal.editedGoalSnapshotFingerprint !== + state.goal.initialGoalSnapshotFingerprint && + goal.status === 'completed' && + Number.isSafeInteger(goal.completedAt) && + goal.completedAt > 0 && + projectRevision.updatedAt <= goal.completedAt && + verificationGate.updatedAt <= goal.completedAt && + /^[0-9a-f]{64}$/u.test(goal.responseFingerprint) && + Array.isArray(goal.completionEvidence) && + JSON.stringify(goal.completionEvidence) === + JSON.stringify(expectedCompletionEvidence) && + runtimeState.status === 'idle' && + runtimeState.phase === 'completed' && + runtimeState.goalId === goal.goalId && + runtimeState.goalRevision === goal.revision && + runtimeState.goalStatus === 'completed', + 'goal-final-state-invalid', + ); + + const targetTasks = taskSnapshot.all.filter( + (task) => task.agentId === mainAgentId && task.runId === state.initialRunId, + ); + const targetRunIds = [ + ...new Set( + taskSnapshot.all + .filter((task) => task.agentId === mainAgentId) + .map((task) => task.runId), + ), + ].sort(); + const completedTasks = targetTasks.filter( + (task) => task.status === 'completed' && task.phase === 'completed', + ); + const latest = taskSnapshot.latest.find( + (task) => task.agentId === mainAgentId && task.runId === state.initialRunId, + ); + assert( + JSON.stringify(targetRunIds) === JSON.stringify([state.initialRunId]) && + completedTasks.length === 2 && + completedTasks.filter((task) => task.goalStatus === 'active').length === + 1 && + completedTasks.filter((task) => task.goalStatus === 'completed') + .length === 1 && + latest?.goalStatus === 'completed', + 'goal-finalization-projection-invalid', + ); + + const finalMessage = finalMessageId( + mainAgentId, + state.initialSessionId, + state.initialRunId, + ); + const userMessages = conversations.filter( + (message) => message.role === 'user', + ); + const assistantMessages = conversations.filter( + (message) => message.role === 'assistant', + ); + const finalAssistant = assistantMessages.find( + (message) => message.messageId === finalMessage, + ); + const assistantAudits = agentDb.filter( + (record) => + record.recordType === 'conversation.message' && + record.role === 'assistant' && + record.agentId === mainAgentId && + record.sessionId === state.initialSessionId && + record.messageId === finalMessage, + ); + const responseEvents = events.filter( + (event) => + event.agentId === mainAgentId && + event.runId === state.initialRunId && + event.eventType === 'response' && + event.phase === 'completed', + ); + const completedEvents = events.filter( + (event) => + event.agentId === mainAgentId && + event.runId === state.initialRunId && + event.eventType === 'turn.completed' && + event.phase === 'completed', + ); + assert( + userMessages.length === 2 && + assistantMessages.length === 1 && + finalAssistant?.agentId === mainAgentId && + hashValue(finalAssistant.content.trim()) === goal.responseFingerprint && + assistantAudits.length === 1 && + responseEvents.length === 1 && + completedEvents.length === 1, + 'goal-final-assistant-invalid', + ); + const providerLifecycle = validateGoalProviderRequestLifecycle(agentDb); + const finalizationLifecycle = validateGoalFinalizationLifecycle( + agentDb, + goal, + runtimeState, + ); + assert( + verificationExecution.completionIndex < + finalizationLifecycle.firstStageIndex, + 'goal-completed-before-verification-gate-passed', + ); + + const oldExecution = goalOldActionExecutionEvidence( + agentDb, + state.goal.initialPending.actionId, + ); + const editedActionRecords = agentDb.filter( + (record) => record.actionId === state.goal.editedPending.actionId, + ); + const editedExecutionCount = editedActionRecords.filter((record) => + [ + 'agent.runtime.tool_action.executing', + 'agent.runtime.tool_confirmation.approved', + ].includes(record.recordType), + ).length; + const editedReceipts = editedActionRecords.filter( + (record) => + record.recordType === 'agent.runtime.action_receipt' && + record.status === 'ok', + ); + assert( + state.goal.initialPending.blockedObservationCount >= 1 && + state.goal.initialPending.blockedReceiptCount === 1 && + oldExecution.executionCount === 0 && + oldExecution.successfulReceiptCount === 0 && + editedExecutionCount >= 1 && + editedReceipts.length === 1 && + pendingActions.length === 0, + 'goal-action-transition-invalid', + ); + const executedGoalWriteActionIds = new Set( + agentDb + .filter( + (record) => + record.agentId === mainAgentId && + record.runId === state.initialRunId && + goalProjectWriteTools.has(record.tool) && + [ + 'agent.runtime.tool_action.executing', + 'agent.runtime.tool_confirmation.approved', + ].includes(record.recordType), + ) + .map((record) => record.actionId) + .filter(isNonEmptyString), + ); + assert( + [...executedGoalWriteActionIds].every((actionId) => + state.goal.monitoredWriteActionIds.has(actionId), + ) && state.goal.initialMarkerAbsenceCheckCount > 0, + 'goal-write-action-marker-monitoring-incomplete', + ); + assert( + state.goal.runner.pidfdClaimCount >= 2 && + state.goal.runner.pidfdSignalCount >= 1, + 'goal-runner-pidfd-evidence-invalid', + ); + + const repairEvidence = validateGoalRevisionTwoRepairEvidence( + agentDb, + verificationExecution, + ); + const verification = await runProcess(process.execPath, ['verify-e2e.mjs'], { + cwd: state.projectRoot, + timeoutMs: 120_000, + }); + assert( + verification.stdout.includes(commandPassedMarker), + 'goal-final-project-verification-failed', + ); + const goalDelivery = await fs.readFile( + path.join(state.projectRoot, goalDeliveryPath), + 'utf8', + ); + const oldMarkerProjectCount = + await countMarkerOutsideRuntimeControl(goalInitialMarker); + assert( + goalDelivery === `${goalFinalMarker}\n` && oldMarkerProjectCount === 0, + 'goal-final-delivery-invalid', + ); + + const finalizationFiles = await listFiles( + path.join(state.projectRoot, '.agent/runtime/finalizations'), + ); + const finalizationJournalCount = finalizationFiles.filter((file) => + file.endsWith('.json'), + ).length; + assert(finalizationJournalCount === 0, 'goal-finalization-journal-present'); + + const replayEvidence = validateToolActionReplays(agentDb); + const duplicateActionCount = duplicateCount( + agentDb.filter((record) => record.actionId).map(actionAuditIdentity), + ); + const duplicateMessageCount = duplicateCount( + conversations.map((message) => message.messageId).filter(Boolean), + ); + const receipts = agentDb.filter( + (record) => record.recordType === 'agent.runtime.action_receipt', + ); + const duplicateReceiptCount = duplicateCount( + receipts.map(receiptAuditIdentity), + ); + assert( + duplicateActionCount === 0 && + duplicateMessageCount === 0 && + duplicateReceiptCount === 0 && + replayEvidence.sideEffectReplayCount === 0, + 'goal-duplicate-or-replay-detected', + ); + + const publicSurfaces = { + event: events, + agentDb, + receipt: receipts, + activity, + output, + }; + const goalPublicBodyLeakCounts = {}; + for (const [surface, records] of Object.entries(publicSurfaces)) { + goalPublicBodyLeakCounts[surface] = countExactSecrets( + Buffer.from(JSON.stringify(records)), + goalPublicBodyValues(), + ); + } + const goalPublicBodyLeakCount = sumObjectValues(goalPublicBodyLeakCounts); + assert(goalPublicBodyLeakCount === 0, 'goal-body-public-leak-detected'); + const projectPathPublicLeakCounts = validateProjectRootPublicLeakBoundary( + publicSurfaces, + 'goal-public', + ); + const projectPathPublicLeakCount = sumObjectValues( + projectPathPublicLeakCounts, + ); + state.lureLeakCount = await countLureLeaks(); + assert(state.lureLeakCount === 0, 'sensitive-lure-leak-detected'); + + const protocolCount = validateMainRunToolPlanProtocols(agentDb); + const successfulToolExecutionCount = receipts.filter( + (record) => + record.agentId === mainAgentId && + record.runId === state.initialRunId && + record.status === 'ok', + ).length; + return { + scenario: 'goal-edit-pause-runner-restart-resume', + taskCount: taskSnapshot.all.length, + eventCount: events.length, + agentDbRecordCount: agentDb.length, + conversationMessageCount: conversations.length, + successfulToolExecutionCount, + toolPlanProtocolCount: protocolCount, + structuredPlanRevision: plan.revision, + structuredPlanCompletedStepCount: plan.completedStepHashes.length, + goalInitialCompletedStepCount: state.goal.initialCompletedStepHashes.length, + goalRetainedInitialCompletedStepCount: + state.goal.initialCompletedStepHashes.filter((stepHash) => + plan.completedStepHashes.includes(stepHash), + ).length, + goalEditedEvidenceAbsentBeforeEdit: + state.goal.editedEvidenceAbsentBeforeEdit, + goalRevisionOneFixtureIsolated: state.goal.revisionOneFixtureIsolated, + goalRevisionTwoFixtureInjected: state.goal.revisionTwoFixtureInjected, + goalRevisionTwoHostFailureObserved: + state.goal.revisionTwoHostFailureObserved, + goalRevisionTwoFailureObserved: state.goal.revisionTwoFailureObserved, + goalRevisionTwoFailureExitCode: state.goal.revisionTwoFailureExitCode, + goalRevisionTwoFailureFingerprint: state.goal.revisionTwoFailureFingerprint, + goalRevisionTwoRepairActionCount: repairEvidence.actionCount, + goalRevisionTwoRepairFileWriteCount: repairEvidence.fileWriteCount, + goalRevisionTwoRepairPatchsetCount: repairEvidence.patchsetCount, + goalIdHash: hashValue(goal.goalId), + goalInitialRevision: state.goal.initialRevision, + goalEditedRevision: state.goal.editedRevision, + goalSnapshotFingerprintChanged: + state.goal.initialGoalSnapshotFingerprint !== + state.goal.editedGoalSnapshotFingerprint, + goalInitialMarkerAbsenceCheckCount: + state.goal.initialMarkerAbsenceCheckCount, + goalMonitoredWriteActionCount: state.goal.monitoredWriteActionIds.size, + goalFinalStatus: goal.status, + goalEditProviderInterrupted: state.goal.editProviderInterrupted, + goalPauseProviderInterrupted: state.goal.pauseProviderInterrupted, + goalPausedBeforeKill: true, + goalPausedAfterRestart: true, + goalRunnerBootChanged: + state.goal.oldRunnerBootId !== state.goal.newRunnerBootId, + goalRunnerKillMethod: 'linux-pidfd', + goalRunnerPidfdClaimCount: state.goal.runner.pidfdClaimCount, + goalRunnerPidfdSignalCount: state.goal.runner.pidfdSignalCount, + goalExecutionOwnerRecovered: state.goal.executionOwnerRecovered === true, + goalExplicitResumeSameRun: true, + goalTargetRunCount: targetRunIds.length, + goalUnexpectedRunCount: targetRunIds.length - 1, + goalOldActionCount: 1, + goalOldActionBlockedReceiptCount: + state.goal.initialPending.blockedReceiptCount, + goalOldActionExecutionCount: oldExecution.executionCount, + goalOldActionReplayCount: oldExecution.successfulReceiptCount, + goalEditedActionExecutionCount: editedExecutionCount, + goalPausedTaskDelta: + state.goal.pausedAfterRestart.taskCount - + state.goal.pauseSnapshot.taskCount, + goalPausedPlanDelta: + state.goal.pausedAfterRestart.planRevision - + state.goal.pauseSnapshot.planRevision, + goalPausedConversationDelta: + state.goal.pausedAfterRestart.conversationCount - + state.goal.pauseSnapshot.conversationCount, + goalPausedProviderPlanDelta: + state.goal.pausedAfterRestart.planProtocolCount - + state.goal.pauseSnapshot.planProtocolCount, + goalPausedProviderRequestStartedDelta: + state.goal.pausedAfterRestart.providerRequestStartedCount - + state.goal.pauseSnapshot.providerRequestStartedCount, + goalPausedActionProgressDelta: + state.goal.pausedAfterRestart.actionProgressCount - + state.goal.pauseSnapshot.actionProgressCount, + goalContextSchemaVersion: contextBundle.schemaVersion, + goalPendingSchemaVersion: state.goal.editedPending.schemaVersion, + projectRevision: projectRevision.revision, + verificationPassed: true, + verificationActionIdentityBound: true, + verificationActionIdHash: hashValue(verificationExecution.actionId), + goalProviderRequestStartedCount: providerLifecycle.startedCount, + goalProviderRequestTerminalCount: providerLifecycle.terminalCount, + goalFinalizationSchemaVersion: finalizationLifecycle.schemaVersion, + goalFinalizationObserved: true, + goalFinalizationStageCount: finalizationLifecycle.stageCount, + goalFinalizationIdHash: finalizationLifecycle.finalizationIdHash, + goalFinalizationJournalCount: finalizationJournalCount, + goalCompletedProjectionCount: completedTasks.length, + goalAssistantCount: assistantMessages.length, + goalPublicBodyLeakCount, + goalBodyReportLeakCount: state.goalBodyReportLeakCount, + sideEffectActionCount: replayEvidence.sideEffectActionCount, + sideEffectReplayCount: replayEvidence.sideEffectReplayCount, + idempotentReplayActionCount: replayEvidence.idempotentReplayActionCount, + actionReceiptReplayRecordCount: + replayEvidence.actionReceiptReplayRecordCount, + finalAssistantCount: assistantMessages.length, + finalAssistantAuditCount: assistantAudits.length, + duplicateActionCount, + duplicateMessageCount, + duplicateReceiptCount, + confirmedActionCount: state.confirmedActionIds.size, + projectPathPublicLeakCount, + projectPathPublicSurfaceCount: Object.keys(projectPathPublicLeakCounts) + .length, + secretLeakCount: state.transcriptLeakCount + state.projectLeakCount, + lureLeakCount: state.lureLeakCount, + paths: [ + '.agent/runtime/tasks', + '.agent/runtime/events', + '.agent/agent.db', + relativeProjectPath(mainContextBundlePath()), + relativeProjectPath(mainRuntimeStatePath()), + '.agent/runtime/goals/current', + '.agent/conversations', + goalDeliveryPath, + ], + }; +} + +async function readGoalVerificationGate( + projectRevision, + verificationExecution, + goal, +) { + const manifest = await readJson( + path.join(state.projectRoot, '.agent/manifest.json'), + ); + assert( + projectRevision?.schemaVersion === 'game-creator-project-revision.v1' && + isNonEmptyString(projectRevision.projectId) && + manifest?.projectId === projectRevision.projectId && + goal.projectId === manifest.projectId && + Number.isSafeInteger(projectRevision.revision) && + projectRevision.revision > 0 && + Number.isSafeInteger(projectRevision.updatedAt) && + projectRevision.updatedAt > 0, + 'goal-project-revision-invalid', + ); + const verificationFiles = await listFiles( + path.join(state.projectRoot, '.agent/runtime/verification'), + ); + const matching = []; + for (const file of verificationFiles.filter((entry) => + entry.endsWith('.json'), + )) { + const value = await readJson(file); + if (value.agentId === mainAgentId && value.runId === state.initialRunId) { + matching.push(value); + } + } + assert(matching.length === 1, 'goal-verification-gate-count-invalid'); + const gate = matching[0]; + assert( + gate.schemaVersion === 'game-creator-verification-gate.v1' && + gate.projectId === projectRevision.projectId && + gate.agentId === mainAgentId && + gate.runId === state.initialRunId && + gate.requiresVerification === true && + gate.mutationRevision === projectRevision.revision && + gate.verifiedRevision === projectRevision.revision && + ['file.write', 'project.patchset'].includes(gate.lastMutationTool) && + gate.lastVerificationTool === verificationExecution.tool && + gate.lastVerificationStatus === 'passed' && + Number.isSafeInteger(gate.updatedAt) && + gate.updatedAt >= projectRevision.updatedAt && + gate.updatedAt >= verificationExecution.verificationAudit.updatedAt && + verificationExecution.verificationAudit.actionId === + verificationExecution.actionId && + verificationExecution.verificationAudit.actionFingerprint === + verificationExecution.actionFingerprint, + 'goal-verification-credential-invalid', + ); + return gate; +} + +function findFinalSuccessfulGoalVerificationExecution(agentDb) { + let auditIndex = -1; + for (let index = agentDb.length - 1; index >= 0; index -= 1) { + const record = agentDb[index]; + if ( + record.recordType === 'agent.runtime.project.verify' && + record.agentId === mainAgentId && + record.runId === state.initialRunId + ) { + auditIndex = index; + break; + } + } + assert(auditIndex >= 0, 'goal-project-verification-missing'); + const verificationAudit = agentDb[auditIndex]; + assert( + verificationAudit.status === 'completed' && + verificationAudit.exitCode === 0 && + verificationAudit.timedOut === false && + ['test', 'check:e2e'].includes(verificationAudit.script) && + verificationAudit.expectedCommand === verificationCommand && + isNonEmptyString(verificationAudit.actionId) && + isNonEmptyString(verificationAudit.actionFingerprint) && + Number.isSafeInteger(verificationAudit.updatedAt) && + hasExpectedWorkspaceSandboxMetadata(verificationAudit), + 'goal-final-project-verification-audit-invalid', + ); + const execution = findSuccessfulToolExecution( + agentDb, + 'project.verify', + state.initialRunId, + (candidate) => + candidate.actionId === verificationAudit.actionId && + candidate.actionFingerprint === verificationAudit.actionFingerprint && + ['test', 'check:e2e'].includes( + auditInputValue(candidate.inputSummary, 'script'), + ), + ); + assert( + execution && + execution.startIndex < auditIndex && + auditIndex < execution.resultIndex, + 'goal-final-project-verification-action-identity-invalid', + ); + return { ...execution, auditIndex, verificationAudit }; +} + +function validateGoalRevisionTwoRepairEvidence(agentDb, verificationExecution) { + assert( + state.goal.revisionTwoFixtureInjected === true && + state.goal.revisionTwoHostFailureObserved === true && + state.goal.revisionTwoFailureObserved === true && + Number.isSafeInteger(state.goal.revisionTwoAgentDbBoundary) && + state.goal.revisionTwoAgentDbBoundary > 0, + 'goal-revision-two-failure-boundary-invalid', + ); + const afterFailureBoundary = (execution) => + execution.startIndex >= state.goal.revisionTwoAgentDbBoundary && + execution.completionIndex < verificationExecution.startIndex; + const gamePatchset = findSuccessfulToolExecution( + agentDb, + 'project.patchset', + state.initialRunId, + (execution) => + afterFailureBoundary(execution) && + auditPatchsetPathsInclude(execution.inputSummary, [ + 'update:game/index.html', + ]), + ); + const createdPatchset = findSuccessfulToolExecution( + agentDb, + 'project.patchset', + state.initialRunId, + (execution) => + afterFailureBoundary(execution) && + auditPatchsetPathsInclude(execution.inputSummary, [ + `create:${patchsetCreatedPath}`, + ]), + ); + const gameWrite = findSuccessfulToolExecution( + agentDb, + 'file.write', + state.initialRunId, + (execution) => + afterFailureBoundary(execution) && + auditPathEquals(execution.inputSummary, 'game/index.html'), + ); + const createdWrite = findSuccessfulToolExecution( + agentDb, + 'file.write', + state.initialRunId, + (execution) => + afterFailureBoundary(execution) && + auditPathEquals(execution.inputSummary, patchsetCreatedPath), + ); + assert( + Boolean(gamePatchset || gameWrite) && + Boolean(createdPatchset || createdWrite), + 'goal-revision-two-agent-repair-evidence-missing', + ); + const executions = [ + gamePatchset ?? gameWrite, + createdPatchset ?? createdWrite, + ]; + const byAction = new Map( + executions.map((execution) => [execution.actionId, execution]), + ); + return { + actionCount: byAction.size, + fileWriteCount: [...byAction.values()].filter( + (execution) => execution.tool === 'file.write', + ).length, + patchsetCount: [...byAction.values()].filter( + (execution) => execution.tool === 'project.patchset', + ).length, + }; +} + +function validateGoalProviderRequestLifecycle(agentDb) { + const records = agentDb.filter( + (record) => + record.recordType === 'agent.runtime.provider_request.lifecycle' && + record.agentId === mainAgentId && + record.runId === state.initialRunId, + ); + const byRequest = new Map(); + for (const record of records) { + assert( + record.auditSchemaVersion === + 'game-creator-provider-request-lifecycle.v1' && + record.taskId && + record.sessionId === state.initialSessionId && + ['tool-plan', 'final-reply'].includes(record.requestKind) && + typeof record.requestSlot === 'string' && + !['prompt', 'response', 'error', 'baseUrl', 'model'].some((key) => + Object.hasOwn(record, key), + ), + 'goal-provider-request-lifecycle-invalid', + ); + const group = byRequest.get(record.requestId) ?? []; + group.push(record); + byRequest.set(record.requestId, group); + } + assert(byRequest.size > 0, 'goal-provider-request-lifecycle-missing'); + for (const group of byRequest.values()) { + assert( + group.length === 2 && + group[0].status === 'started' && + ['completed', 'failed', 'interrupted'].includes(group[1].status) && + group[0].requestKind === group[1].requestKind && + group[0].requestSlot === group[1].requestSlot, + 'goal-provider-request-lifecycle-incomplete', + ); + } + return { + startedCount: byRequest.size, + terminalCount: byRequest.size, + }; +} + +function validateGoalFinalizationLifecycle(agentDb, goal, runtimeState) { + const records = agentDb.filter( + (record) => + record.recordType === 'agent.runtime.finalization.lifecycle' && + record.agentId === mainAgentId && + record.runId === state.initialRunId, + ); + const expectedStages = [ + 'prepared', + 'assistant-persisted', + 'runtime-completed', + 'goal-completed', + ]; + assert( + records.length === expectedStages.length, + 'goal-finalization-audit-count-invalid', + ); + const finalizationId = records[0]?.finalizationId; + const messageId = records[0]?.messageId; + const expectedGoalSnapshotFingerprint = goalSnapshotFingerprint(goal); + for (const [index, record] of records.entries()) { + assert( + record.auditSchemaVersion === 'game-creator-finalization-lifecycle.v1' && + record.journalSchemaVersion === + 'game-creator-runtime-finalization.v3' && + record.finalizationId === finalizationId && + record.messageId === messageId && + record.taskId === runtimeState.taskId && + record.sessionId === state.initialSessionId && + record.stage === expectedStages[index] && + record.stageOrdinal === index + 1 && + record.previousStage === + (index === 0 ? null : expectedStages[index - 1]) && + record.goalId === goal.goalId && + record.goalRevision === goal.revision && + record.goalSnapshotFingerprint === expectedGoalSnapshotFingerprint && + record.planRevision === runtimeState.planRevision && + record.responseFingerprint === goal.responseFingerprint && + Number.isSafeInteger(record.stageAt) && + record.stageAt > 0 && + !['task', 'response', 'prompt', 'observation'].some((key) => + Object.hasOwn(record, key), + ), + 'goal-finalization-audit-invalid', + ); + if (index > 0) { + assert( + record.stageAt >= records[index - 1].stageAt, + 'goal-finalization-audit-time-regressed', + ); + } + } + const assistantAuditIndex = agentDb.findIndex( + (record) => + record.recordType === 'conversation.message' && + record.messageId === messageId && + record.role === 'assistant', + ); + const assistantStageIndex = agentDb.findIndex( + (record) => + record.recordType === 'agent.runtime.finalization.lifecycle' && + record.finalizationId === finalizationId && + record.stage === 'assistant-persisted', + ); + assert( + assistantAuditIndex >= 0 && assistantAuditIndex < assistantStageIndex, + 'goal-finalization-assistant-order-invalid', + ); + return { + schemaVersion: records[0].journalSchemaVersion, + stageCount: records.length, + finalizationIdHash: hashValue(finalizationId), + firstStageIndex: agentDb.findIndex((record) => record === records[0]), + }; +} + +async function countMarkerOutsideRuntimeControl(marker) { + let count = 0; + for (const file of await listFiles(state.projectRoot)) { + const relative = relativeProjectPath(file); + if (relative === '.agent' || relative.startsWith('.agent/')) { + continue; + } + const metadata = await fs.lstat(file); + if (!metadata.isFile() || metadata.isSymbolicLink()) continue; + count += countExactSecrets(await fs.readFile(file), [marker]); + } + return count; +} + +async function assertGoalInitialMarkerAbsent(code, actionId = null) { + if (!isGoalRuntimeSuite() || !state.projectRoot) return; + const count = await countMarkerOutsideRuntimeControl(goalInitialMarker); + state.goal.initialMarkerAbsenceCheckCount += 1; + if (isNonEmptyString(actionId)) { + state.goal.monitoredWriteActionIds.add(actionId); + } + assert(count === 0, `${code}-revision-one-marker-landed`); +} + async function countLureLeaks() { const excluded = new Set([ '.env', @@ -5131,6 +7909,372 @@ function emptyEvidence() { }; } +function emptyGoalEvidence() { + return { + scenario: 'goal-edit-pause-runner-restart-resume', + taskCount: 0, + eventCount: 0, + agentDbRecordCount: 0, + conversationMessageCount: 0, + successfulToolExecutionCount: 0, + toolPlanProtocolCount: 0, + structuredPlanRevision: 0, + structuredPlanCompletedStepCount: 0, + goalInitialCompletedStepCount: 0, + goalRetainedInitialCompletedStepCount: 0, + goalEditedEvidenceAbsentBeforeEdit: false, + goalRevisionOneFixtureIsolated: false, + goalRevisionTwoFixtureInjected: false, + goalRevisionTwoHostFailureObserved: false, + goalRevisionTwoFailureObserved: false, + goalRevisionTwoFailureExitCode: null, + goalRevisionTwoFailureFingerprint: null, + goalRevisionTwoRepairActionCount: 0, + goalRevisionTwoRepairFileWriteCount: 0, + goalRevisionTwoRepairPatchsetCount: 0, + goalIdHash: null, + goalInitialRevision: 0, + goalEditedRevision: 0, + goalSnapshotFingerprintChanged: false, + goalInitialMarkerAbsenceCheckCount: 0, + goalMonitoredWriteActionCount: 0, + goalFinalStatus: null, + goalEditProviderInterrupted: false, + goalPauseProviderInterrupted: false, + goalPausedBeforeKill: false, + goalPausedAfterRestart: false, + goalRunnerBootChanged: false, + goalRunnerKillMethod: null, + goalRunnerPidfdClaimCount: 0, + goalRunnerPidfdSignalCount: 0, + goalRunnerStopped: false, + goalAppDataCleanupPerformed: false, + goalExecutionOwnerRecovered: false, + goalExplicitResumeSameRun: false, + goalTargetRunCount: 0, + goalUnexpectedRunCount: 0, + goalOldActionCount: 0, + goalOldActionBlockedReceiptCount: 0, + goalOldActionExecutionCount: 0, + goalOldActionReplayCount: 0, + goalEditedActionExecutionCount: 0, + goalPausedTaskDelta: 0, + goalPausedPlanDelta: 0, + goalPausedConversationDelta: 0, + goalPausedProviderPlanDelta: 0, + goalPausedProviderRequestStartedDelta: 0, + goalPausedActionProgressDelta: 0, + goalContextSchemaVersion: null, + goalPendingSchemaVersion: null, + projectRevision: 0, + verificationPassed: false, + verificationActionIdentityBound: false, + verificationActionIdHash: null, + goalProviderRequestStartedCount: 0, + goalProviderRequestTerminalCount: 0, + goalFinalizationSchemaVersion: null, + goalFinalizationObserved: false, + goalFinalizationStageCount: 0, + goalFinalizationIdHash: null, + goalFinalizationJournalCount: 0, + goalCompletedProjectionCount: 0, + goalAssistantCount: 0, + goalPublicBodyLeakCount: 0, + goalBodyReportLeakCount: 0, + sideEffectActionCount: 0, + sideEffectReplayCount: 0, + idempotentReplayActionCount: 0, + actionReceiptReplayRecordCount: 0, + finalAssistantCount: 0, + finalAssistantAuditCount: 0, + duplicateActionCount: 0, + duplicateMessageCount: 0, + duplicateReceiptCount: 0, + confirmedActionCount: 0, + projectPathPublicLeakCount: 0, + projectPathPublicSurfaceCount: 0, + projectPathTranscriptLeakCount: 0, + projectPathReportLeakCount: 0, + secretLeakCount: 0, + lureLeakCount: 0, + failureEvidenceErrors: { + task: [], + event: [], + agentDb: [], + conversation: [], + }, + paths: [], + }; +} + +async function collectPartialGoalEvidence() { + const [taskSurface, eventSurface, agentDbSurface, conversationSurface] = + await Promise.all([ + collectPartialGoalJsonlSurface('task', async () => + ( + await listFiles(path.join(state.projectRoot, '.agent/runtime/tasks')) + ).filter((file) => file.endsWith('.jsonl')), + ), + collectPartialGoalJsonlSurface('event', async () => + ( + await listFiles(path.join(state.projectRoot, '.agent/runtime/events')) + ).filter((file) => file.endsWith('.jsonl')), + ), + collectPartialGoalJsonlSurface('agent-db', async () => [ + path.join(state.projectRoot, '.agent/agent.db'), + ]), + collectPartialGoalJsonlSurface('conversation', async () => + ( + await listFiles(path.join(state.projectRoot, '.agent/conversations')) + ).filter((file) => file.endsWith('.jsonl')), + ), + ]); + const taskSnapshot = buildTaskSnapshot(taskSurface.records); + const events = eventSurface.records; + const agentDb = agentDbSurface.records; + const conversations = conversationSurface.records; + const runtime = await readJson(mainRuntimeStatePath()).catch(() => null); + const contextFiles = await listFiles( + path.join(state.projectRoot, '.agent/runtime/context-bundles', mainAgentId), + ).catch(() => []); + const context = await readLatestJsonFile(contextFiles); + const goalFiles = await listFiles( + path.join(state.projectRoot, '.agent/runtime/goals/current'), + ).catch(() => []); + const goals = await Promise.all( + goalFiles + .filter((file) => file.endsWith('.json')) + .map((file) => readJson(file).catch(() => null)), + ); + const goal = goals.find( + (candidate) => + candidate?.agentId === mainAgentId && + (!state.initialRunId || candidate.runId === state.initialRunId), + ); + const targetRunIds = [ + ...new Set( + taskSnapshot.all + .filter((task) => task.agentId === mainAgentId) + .map((task) => task.runId), + ), + ]; + const receipts = agentDb.filter( + (record) => record.recordType === 'agent.runtime.action_receipt', + ); + const providerLifecycle = agentDb.filter( + (record) => + record.recordType === 'agent.runtime.provider_request.lifecycle' && + record.agentId === mainAgentId, + ); + const finalizationLifecycle = agentDb.filter( + (record) => + record.recordType === 'agent.runtime.finalization.lifecycle' && + record.agentId === mainAgentId, + ); + const completedStepCount = Array.isArray(runtime?.planSteps) + ? runtime.planSteps.filter((step) => step.status === 'completed').length + : 0; + return { + taskCount: taskSnapshot.all.length, + eventCount: events.length, + agentDbRecordCount: agentDb.length, + conversationMessageCount: conversations.length, + successfulToolExecutionCount: receipts.filter( + (record) => record.status === 'ok', + ).length, + toolPlanProtocolCount: agentDb.filter( + (record) => record.recordType === 'agent.runtime.tool_plan.protocol', + ).length, + structuredPlanRevision: runtime?.planRevision ?? 0, + structuredPlanCompletedStepCount: completedStepCount, + goalEditedEvidenceAbsentBeforeEdit: + state.goal.editedEvidenceAbsentBeforeEdit, + goalRevisionOneFixtureIsolated: state.goal.revisionOneFixtureIsolated, + goalRevisionTwoFixtureInjected: state.goal.revisionTwoFixtureInjected, + goalRevisionTwoHostFailureObserved: + state.goal.revisionTwoHostFailureObserved, + goalRevisionTwoFailureObserved: state.goal.revisionTwoFailureObserved, + goalRevisionTwoFailureExitCode: state.goal.revisionTwoFailureExitCode, + goalRevisionTwoFailureFingerprint: state.goal.revisionTwoFailureFingerprint, + goalIdHash: isNonEmptyString(goal?.goalId) ? hashValue(goal.goalId) : null, + goalInitialRevision: state.goal.initialRevision || goal?.revision || 0, + goalEditedRevision: state.goal.editedRevision, + goalSnapshotFingerprintChanged: + isNonEmptyString(state.goal.initialGoalSnapshotFingerprint) && + isNonEmptyString(state.goal.editedGoalSnapshotFingerprint) && + state.goal.initialGoalSnapshotFingerprint !== + state.goal.editedGoalSnapshotFingerprint, + goalInitialMarkerAbsenceCheckCount: + state.goal.initialMarkerAbsenceCheckCount, + goalMonitoredWriteActionCount: state.goal.monitoredWriteActionIds.size, + goalFinalStatus: goal?.status ?? runtime?.goalStatus ?? null, + goalRunnerKillMethod: + state.goal.runner.pidfdClaimCount > 0 ? 'linux-pidfd' : null, + goalRunnerPidfdClaimCount: state.goal.runner.pidfdClaimCount, + goalRunnerPidfdSignalCount: state.goal.runner.pidfdSignalCount, + goalTargetRunCount: targetRunIds.length, + goalUnexpectedRunCount: Math.max(0, targetRunIds.length - 1), + goalContextSchemaVersion: context?.schemaVersion ?? null, + goalProviderRequestStartedCount: providerLifecycle.filter( + (record) => record.status === 'started', + ).length, + goalProviderRequestTerminalCount: providerLifecycle.filter((record) => + ['completed', 'failed', 'interrupted'].includes(record.status), + ).length, + goalFinalizationSchemaVersion: + finalizationLifecycle.at(-1)?.journalSchemaVersion ?? null, + goalFinalizationObserved: finalizationLifecycle.length > 0, + goalFinalizationStageCount: finalizationLifecycle.length, + goalFinalizationIdHash: isNonEmptyString( + finalizationLifecycle.at(-1)?.finalizationId, + ) + ? hashValue(finalizationLifecycle.at(-1).finalizationId) + : null, + goalAssistantCount: conversations.filter( + (message) => message.role === 'assistant', + ).length, + finalAssistantCount: + goal?.status === 'completed' + ? conversations.filter((message) => message.role === 'assistant').length + : 0, + finalAssistantAuditCount: + goal?.status === 'completed' + ? agentDb.filter( + (record) => + record.recordType === 'conversation.message' && + record.role === 'assistant', + ).length + : 0, + duplicateMessageCount: duplicateCount( + conversations.map((message) => message.messageId).filter(Boolean), + ), + duplicateReceiptCount: duplicateCount(receipts.map(receiptAuditIdentity)), + failureEvidenceErrors: { + task: taskSurface.errors, + event: eventSurface.errors, + agentDb: agentDbSurface.errors, + conversation: conversationSurface.errors, + }, + paths: [ + '.agent/runtime/tasks', + '.agent/runtime/events', + '.agent/agent.db', + '.agent/runtime/context-bundles', + '.agent/runtime/goals/current', + '.agent/conversations', + ], + }; +} + +async function collectPartialGoalJsonlSurface(surface, resolveFiles) { + let files; + try { + files = await resolveFiles(); + } catch { + const errors = ['surface-list-failed']; + recordError(`goal-partial-${surface}-read-failed`, codedError(errors[0])); + return { records: [], errors }; + } + const result = await readJsonlFilesPreservingValidRecords(files); + if (result.errors.length > 0) { + recordError( + `goal-partial-${surface}-read-failed`, + codedError([...new Set(result.errors)].join(',')), + ); + } + return { + records: result.records, + errors: [...new Set(result.errors)], + }; +} + +async function readJsonlFilesPreservingValidRecords(files) { + const records = []; + const errors = []; + for (const file of files) { + const result = await readJsonlPreservingValidRecords(file); + records.push(...result.records); + errors.push(...result.errors); + } + return { records, errors }; +} + +async function readJsonlPreservingValidRecords(file) { + let content; + try { + content = await fs.readFile(file); + } catch (error) { + return { + records: [], + errors: [ + error?.code === 'ENOENT' ? 'file-disappeared' : 'file-read-failed', + ], + }; + } + const records = []; + const errors = []; + const lines = splitJsonlBufferLines(content); + for (const lineRecord of lines) { + let line; + try { + line = decodeUtf8Fatal(lineRecord.bytes, 'jsonl-invalid-utf8'); + } catch { + errors.push('invalid-utf8'); + continue; + } + if (line.trim().length === 0) continue; + try { + const record = JSON.parse(line); + if (!record || typeof record !== 'object' || Array.isArray(record)) { + errors.push('invalid-record'); + continue; + } + records.push(record); + } catch { + errors.push(lineRecord.terminated ? 'invalid-record' : 'truncated-tail'); + } + } + return { records, errors }; +} + +function splitJsonlBufferLines(content) { + assert(Buffer.isBuffer(content), 'jsonl-content-not-buffer'); + const lines = []; + let start = 0; + for (let index = 0; index < content.length; index += 1) { + if (content[index] !== 0x0a) continue; + let end = index; + if (end > start && content[end - 1] === 0x0d) end -= 1; + lines.push({ bytes: content.subarray(start, end), terminated: true }); + start = index + 1; + } + if (start < content.length) { + lines.push({ bytes: content.subarray(start), terminated: false }); + } + return lines; +} + +function decodeUtf8Fatal(content, code = 'invalid-utf8') { + try { + return new TextDecoder('utf-8', { fatal: true }).decode(content); + } catch (error) { + throw codedError(code, error); + } +} + +async function readLatestJsonFile(files) { + const candidates = []; + for (const file of files.filter((entry) => entry.endsWith('.json'))) { + const metadata = await fs.stat(file).catch(() => null); + if (metadata?.isFile()) + candidates.push({ file, mtimeMs: metadata.mtimeMs }); + } + candidates.sort((left, right) => right.mtimeMs - left.mtimeMs); + return candidates.length > 0 + ? readJson(candidates[0].file).catch(() => null) + : null; +} + function emptyProcessEvidence() { return { scenario: @@ -5184,6 +8328,10 @@ function isProcessSessionSuite() { return processSessionSuites.has(state.suite); } +function isGoalRuntimeSuite() { + return state.suite === goalRuntimeSuite; +} + function collectApiKeys(value, keys = []) { if (!value || typeof value !== 'object') return keys; if (Array.isArray(value)) { @@ -5205,19 +8353,16 @@ function collectApiKeys(value, keys = []) { } function parseAssignedJson(output, names) { + const matches = []; for (const line of output.split(/\r?\n/u)) { for (const name of names) { if (line.startsWith(`${name}=`)) { - return JSON.parse(line.slice(name.length + 1)); + matches.push({ name, payload: line.slice(name.length + 1) }); } } } - const jsonLine = output - .split(/\r?\n/u) - .map((line) => line.trim()) - .find((line) => line.startsWith('{') && line.endsWith('}')); - assert(Boolean(jsonLine), 'cli-json-output-missing'); - return JSON.parse(jsonLine); + assert(matches.length === 1, 'cli-assigned-json-output-invalid'); + return JSON.parse(matches[0].payload); } async function listFiles(root) { @@ -5273,11 +8418,13 @@ function validateCommandOutputSidecar(sidecar, audit, file) { } async function readJsonl(file) { - const content = await fs.readFile(file, 'utf8'); - return content - .split(/\r?\n/u) - .filter((line) => line.trim().length > 0) - .map((line) => JSON.parse(line)); + const content = await fs.readFile(file); + const records = []; + for (const lineRecord of splitJsonlBufferLines(content)) { + const line = decodeUtf8Fatal(lineRecord.bytes, 'jsonl-invalid-utf8'); + if (line.trim().length > 0) records.push(JSON.parse(line)); + } + return records; } async function readOptionalJsonl(file) { @@ -7394,6 +10541,18 @@ function auditPatchsetPathsMatch(summary, expectedPaths) { ); } +function auditPatchsetPathsInclude(summary, expectedPaths) { + const value = auditInputValue(summary, 'paths'); + if (!isNonEmptyString(value)) return false; + const actual = new Set( + value + .split(',') + .map((entry) => entry.trim()) + .filter(Boolean), + ); + return expectedPaths.every((entry) => actual.has(entry)); +} + function auditPathListMatches(summary, key, expectedPaths) { const value = auditInputValue(summary, key); if (!isNonEmptyString(value)) return false; @@ -7571,7 +10730,9 @@ function redactSecrets(value) { function hashValue(value) { if (!value) return null; - return createHash('sha256').update(String(value)).digest('hex'); + return createHash('sha256') + .update(Buffer.isBuffer(value) ? value : String(value)) + .digest('hex'); } function codedError(code, cause) { @@ -7584,6 +10745,25 @@ function assert(condition, code) { if (!condition) throw codedError(code); } -function sleep(milliseconds) { - return new Promise((resolve) => setTimeout(resolve, milliseconds)); +function throwIfShutdownRequested() { + if (shutdownSignal && !cleanupInProgress) { + throw codedError(`interrupted-${shutdownSignal.toLowerCase()}`); + } +} + +function sleep(milliseconds) { + throwIfShutdownRequested(); + return new Promise((resolve, reject) => { + const finish = () => { + shutdownWaiters.delete(interrupt); + resolve(); + }; + const timer = setTimeout(finish, milliseconds); + const interrupt = () => { + clearTimeout(timer); + shutdownWaiters.delete(interrupt); + reject(codedError(`interrupted-${shutdownSignal.toLowerCase()}`)); + }; + shutdownWaiters.add(interrupt); + }); } 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 22bf765e1..dc6eb21d1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -243,7 +243,7 @@ pub(crate) async fn chat_with_game_creator_role_agent_for_session_at( Some(&session_id), prompt, )?; - let client = build_game_creator_llm_client_from_llm_config(&llm, &config_path)?; + let client = build_game_creator_agent_runtime_llm_client(&llm, &config_path)?; let response = client .run(request) .await @@ -340,7 +340,7 @@ where Some(&session_id), prompt, )?; - let client = build_game_creator_llm_client_from_llm_config(&llm, &config_path)?; + let client = build_game_creator_agent_runtime_llm_client(&llm, &config_path)?; let fallback_request = request.clone(); let mut streamed_reply_text = String::new(); let mut streamed_finish_reason = None; @@ -567,22 +567,6 @@ pub(crate) fn resume_game_creator_agent_background_tasks_at( else { continue; }; - if let Some(result) = - reconcile_game_creator_agent_control_before_resume_at(root, &agent_id)? - { - if result.state.phase != "cancelled" { - resumed.push(result); - } - drop(runtime_lock); - continue; - } - if let Some(result) = - reconcile_game_creator_agent_process_sessions_after_restart_at(root, &agent_id)? - { - resumed.push(result); - drop(runtime_lock); - continue; - } let runtime_lock = match resume_game_creator_agent_finalization_at(root, &agent_id, runtime_lock)? { AgentRuntimeFinalizationResume::Recovered(result, runtime_lock) => { @@ -602,6 +586,22 @@ pub(crate) fn resume_game_creator_agent_background_tasks_at( } AgentRuntimeFinalizationResume::NotFound(runtime_lock) => runtime_lock, }; + if let Some(result) = + reconcile_game_creator_agent_control_before_resume_at(root, &agent_id)? + { + if result.state.phase != "cancelled" { + resumed.push(result); + } + drop(runtime_lock); + continue; + } + if let Some(result) = + reconcile_game_creator_agent_process_sessions_after_restart_at(root, &agent_id)? + { + resumed.push(result); + drop(runtime_lock); + continue; + } let runtime_lock = match resume_game_creator_agent_pending_tool_action_at( root, &agent_id, @@ -677,6 +677,13 @@ pub(crate) fn resume_game_creator_agent_background_tasks_at( continue; } }; + let recovered_task_sha256 = format!("{:x}", Sha256::digest(state.current_task.as_bytes())); + let recovered_task_chars = state.current_task.chars().count(); + let recovered_private_task = agent_runtime_task_requires_private_audit( + state.goal_id.as_deref(), + state.parent_agent_id.as_deref(), + ); + let recovered_task = (!recovered_private_task).then(|| state.current_task.clone()); let _ = append_agent_db_record( root, serde_json::json!({ @@ -687,7 +694,11 @@ pub(crate) fn resume_game_creator_agent_background_tasks_at( "runId": state.run_id, "source": state.source, "recoveredFromStatus": task.status, - "task": state.current_task, + "task": recovered_task, + "taskSha256": recovered_task_sha256, + "taskChars": recovered_task_chars, + "goalBound": state.goal_id.is_some(), + "delegated": state.parent_agent_id.is_some(), }), ); let _ = append_game_creator_agent_background_task_started_record(root, &state); @@ -1033,9 +1044,6 @@ fn resume_game_creator_agent_finalization_at( agent_id: &str, runtime_lock: AgentRuntimeTaskLock, ) -> Result { - if let Some(result) = reconcile_game_creator_agent_control_before_resume_at(root, agent_id)? { - return Ok(AgentRuntimeFinalizationResume::Blocked(result)); - } let (mut state, state_reconstructed_from_task) = read_game_creator_agent_runtime_state_for_finalization_resume(root, agent_id)?; if state.run_id.trim().is_empty() { @@ -1175,6 +1183,21 @@ fn resume_game_creator_agent_finalization_at( read_latest_game_creator_agent_runtime_task_by_run_id(root, agent_id, &state.run_id)? .is_some_and(|task| task.status == "cancelled"); let cancellation_requested = game_creator_agent_runtime_cancel_requested(root, &state); + if assistant_exists && (task_cancelled || cancellation_requested) { + remove_game_creator_agent_runtime_cancel_request(root, &state.agent_id, &state.run_id); + let _ = append_agent_db_record( + root, + serde_json::json!({ + "recordType": "agent.runtime.background_task.finalization_cancel_ignored", + "agentId": state.agent_id, + "taskId": state.task_id, + "sessionId": state.session_id, + "runId": state.run_id, + "source": state.source, + "reason": "assistant-already-persisted-on-resume", + }), + ); + } if journal.status == AGENT_RUNTIME_FINALIZATION_STATUS_PREPARED && !assistant_exists && (state.status == "cancelled" @@ -2138,6 +2161,13 @@ fn start_game_creator_agent_background_task_with_link_in_session_lane_at( return read_game_creator_agent_runtime_for_session_at(root, &agent_id, Some(&session_id)) .map(|result| (result, run_id)); } + let queued_task_sha256 = format!("{:x}", Sha256::digest(pending_task.task.as_bytes())); + let queued_task_chars = pending_task.task.chars().count(); + let queued_private_task = agent_runtime_task_requires_private_audit( + pending_task.goal_id.as_deref(), + pending_task.parent_agent_id.as_deref(), + ); + let queued_task = (!queued_private_task).then(|| pending_task.task.clone()); let _ = append_agent_db_record( root, serde_json::json!({ @@ -2152,7 +2182,11 @@ fn start_game_creator_agent_background_task_with_link_in_session_lane_at( "delegationId": pending_task.delegation_id, "status": pending_task.status, "phase": pending_task.phase, - "task": pending_task.task, + "task": queued_task, + "taskSha256": queued_task_sha256, + "taskChars": queued_task_chars, + "goalBound": pending_task.goal_id.is_some(), + "delegated": pending_task.parent_agent_id.is_some(), }), ); if external_agent_runner_owns_background_execution() { @@ -2431,12 +2465,18 @@ pub(crate) fn cancel_game_creator_agent_runtime_task_at( "Agent Runtime 任务已结束,不能取消:{target_run_id}" )); } - write_game_creator_agent_runtime_cancel_request( - root, - &agent_id, - &target_run_id, - "开发者取消后台任务", - )?; + { + let _control_lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "runtime.cancel.request", + )?; + write_game_creator_agent_runtime_cancel_request( + root, + &agent_id, + &target_run_id, + "开发者取消后台任务", + )?; + } for child in list_non_terminal_isolated_children_for_parent_cancel_at(root, &agent_id, &target_run_id)? { @@ -2778,7 +2818,7 @@ pub(crate) fn resume_game_creator_agent_runtime_for_goal_at( append_game_creator_agent_runtime_task(root, &state)?; refresh_game_creator_agent_runtime_task_queue(root, &mut state)?; write_game_creator_agent_runtime_state(root, &state)?; - append_game_creator_agent_runtime_event( + let _ = append_game_creator_agent_runtime_event( root, &state, "goal.resumed", @@ -2886,6 +2926,22 @@ pub(crate) fn append_game_creator_agent_runtime_queued_cancellation( let cancelled_task = append_game_creator_agent_runtime_cancelled_task_record(root, task, summary)?; let event_state = agent_runtime_state_from_task_record(&cancelled_task); + let task_sha256 = format!("{:x}", Sha256::digest(cancelled_task.task.as_bytes())); + let task_chars = cancelled_task.task.chars().count(); + let goal_bound = cancelled_task.goal_id.is_some(); + let delegated = cancelled_task.parent_agent_id.is_some(); + let private_task = agent_runtime_task_requires_private_audit( + cancelled_task.goal_id.as_deref(), + cancelled_task.parent_agent_id.as_deref(), + ); + let public_task = (!private_task).then(|| cancelled_task.task.clone()); + let event_detail = if private_task { + format!( + "taskChars={task_chars} · taskSha256={task_sha256} · goalBound={goal_bound} · delegated={delegated}" + ) + } else { + cancelled_task.task.clone() + }; let _ = append_game_creator_agent_runtime_event( root, &event_state, @@ -2893,7 +2949,7 @@ pub(crate) fn append_game_creator_agent_runtime_queued_cancellation( "cancelled", "cancelled", summary, - Some(&cancelled_task.task), + Some(&event_detail), ); append_agent_db_record( root, @@ -2904,7 +2960,11 @@ pub(crate) fn append_game_creator_agent_runtime_queued_cancellation( "sessionId": task.session_id.clone(), "runId": task.run_id.clone(), "source": task.source.clone(), - "task": task.task.clone(), + "task": public_task, + "taskSha256": task_sha256, + "taskChars": task_chars, + "goalBound": goal_bound, + "delegated": delegated, "summary": summary, }), )?; @@ -2930,6 +2990,9 @@ pub(crate) fn retry_game_creator_agent_runtime_task_at( let task = read_latest_game_creator_agent_runtime_task_by_run_id(root, &agent_id, &target_run_id)? .ok_or_else(|| format!("未找到 Agent Runtime 任务:{target_run_id}"))?; + if task.goal_id.is_some() { + return Err("持久 Goal 任务不能使用普通 retry;请清理后创建新 Goal".to_string()); + } if task.phase == "needs-reconciliation" || game_creator_agent_runtime_pending_tool_action_exists(root, &agent_id, &target_run_id) { @@ -2984,6 +3047,15 @@ pub(crate) fn retry_game_creator_agent_runtime_task_at( }, retry_link.as_ref(), )?; + let retry_task_sha256 = format!("{:x}", Sha256::digest(task.task.as_bytes())); + let retry_task_chars = task.task.chars().count(); + let retry_goal_bound = task.goal_id.is_some(); + let retry_delegated = task.parent_agent_id.is_some(); + let retry_private_task = agent_runtime_task_requires_private_audit( + task.goal_id.as_deref(), + task.parent_agent_id.as_deref(), + ); + let retry_public_task = (!retry_private_task).then(|| task.task.clone()); append_agent_db_record( root, serde_json::json!({ @@ -2994,7 +3066,11 @@ pub(crate) fn retry_game_creator_agent_runtime_task_at( "runId": task.run_id, "retryRunId": actual_retry_run_id, "source": task.source, - "task": task.task, + "task": retry_public_task, + "taskSha256": retry_task_sha256, + "taskChars": retry_task_chars, + "goalBound": retry_goal_bound, + "delegated": retry_delegated, }), )?; Ok(result) @@ -3032,6 +3108,11 @@ pub(crate) fn confirm_game_creator_agent_runtime_task_at( runtime.waiting_on = "已确认工具执行结果".to_string(); runtime.next_step = "执行原工具动作并把观察交回 Agent".to_string(); runtime.updated_at = unix_timestamp(); + let public_input_summary = agent_runtime_public_action_input_summary( + root, + &pending_action.action.tool, + pending_action.input_summary.as_deref(), + ); append_game_creator_agent_runtime_task(root, &runtime) .and_then(|_| refresh_game_creator_agent_runtime_task_queue(root, &mut runtime)) .and_then(|_| write_game_creator_agent_runtime_state(root, &runtime)) @@ -3043,7 +3124,7 @@ pub(crate) fn confirm_game_creator_agent_runtime_task_at( "running", "action", "开发者已批准精确工具动作,Runtime 将直接执行原 action。", - pending_action.input_summary.as_deref(), + public_input_summary.as_deref(), ) }) .and_then(|_| { @@ -3060,7 +3141,7 @@ pub(crate) fn confirm_game_creator_agent_runtime_task_at( "tool": pending_action.action.tool, "commandId": command_id, "actionFingerprint": pending_action.action_fingerprint, - "inputSummary": pending_action.input_summary, + "inputSummary": public_input_summary, "note": note, }), ) @@ -3190,6 +3271,11 @@ pub(crate) fn reject_game_creator_agent_runtime_task_at( runtime.waiting_on = "Agent 根据拒绝结果修正计划".to_string(); runtime.next_step = "把拒绝结果交给 Agent 修正计划".to_string(); runtime.updated_at = unix_timestamp(); + let public_input_summary = agent_runtime_public_action_input_summary( + root, + &pending_action.action.tool, + pending_action.input_summary.as_deref(), + ); append_game_creator_agent_runtime_task(root, &runtime) .and_then(|_| refresh_game_creator_agent_runtime_task_queue(root, &mut runtime)) .and_then(|_| write_game_creator_agent_runtime_state(root, &runtime)) @@ -3201,7 +3287,7 @@ pub(crate) fn reject_game_creator_agent_runtime_task_at( "running", "observation", "开发者已拒绝待确认工具动作,Runtime 将把拒绝结果交回 Agent。", - pending_action.input_summary.as_deref(), + public_input_summary.as_deref(), ) }) .and_then(|_| { @@ -3216,7 +3302,7 @@ pub(crate) fn reject_game_creator_agent_runtime_task_at( "actionId": pending_action.action_id, "tool": pending_action.action.tool, "actionFingerprint": pending_action.action_fingerprint, - "inputSummary": pending_action.input_summary, + "inputSummary": public_input_summary, "note": note, }), ) @@ -3958,6 +4044,13 @@ fn agent_runtime_background_task_message_id( ) } +fn agent_runtime_task_requires_private_audit( + goal_id: Option<&str>, + parent_agent_id: Option<&str>, +) -> bool { + goal_id.is_some() || parent_agent_id.is_some() +} + #[cfg(test)] fn take_agent_runtime_background_conversation_failure_injection(root: &Path) -> Option { let path = root.join(".agent/runtime/test-fail-next-background-conversation"); @@ -3972,6 +4065,13 @@ fn append_game_creator_agent_background_task_started_record( root: &Path, state: &AgentRuntimeState, ) -> Result<(), String> { + let task_sha256 = format!("{:x}", Sha256::digest(state.current_task.as_bytes())); + let task_chars = state.current_task.chars().count(); + let private_task = agent_runtime_task_requires_private_audit( + state.goal_id.as_deref(), + state.parent_agent_id.as_deref(), + ); + let task = (!private_task).then(|| state.current_task.clone()); append_agent_db_record( root, serde_json::json!({ @@ -3983,7 +4083,11 @@ fn append_game_creator_agent_background_task_started_record( "source": state.source, "status": state.status, "phase": state.phase, - "task": state.current_task, + "task": task, + "taskSha256": task_sha256, + "taskChars": task_chars, + "goalBound": state.goal_id.is_some(), + "delegated": state.parent_agent_id.is_some(), }), ) } @@ -4110,7 +4214,18 @@ fn fail_game_creator_agent_background_context_at( runtime: AgentRuntimeState, error: &str, ) -> AgentBackgroundTaskOutcome { - let failed_runtime = fail_game_creator_agent_runtime_turn_at(root, runtime, error); + if fs::read_to_string(game_creator_agent_runtime_session_path(root, agent_id)) + .ok() + .and_then(|content| serde_json::from_str::(&content).ok()) + .is_some_and(|current| { + current.run_id == runtime.run_id && current.phase == "needs-reconciliation" + }) + { + emit_game_creator_agent_runtime_update(root, agent_id); + return AgentBackgroundTaskOutcome::Finished; + } + let error = redact_agent_runtime_error(root, error, 500); + let failed_runtime = fail_game_creator_agent_runtime_turn_at(root, runtime, &error); let _ = append_local_conversation_message_for_session_at( root, Some(agent_id), @@ -4718,6 +4833,10 @@ async fn run_game_creator_agent_background_task_pass_with_context( if stop_game_creator_agent_runtime_if_cancel_requested(&root, &mut runtime) { return AgentBackgroundTaskOutcome::Finished; } + if error.starts_with(AGENT_RUNTIME_PROVIDER_REQUEST_RECONCILIATION_PREFIX) { + return AgentBackgroundTaskOutcome::NeedsReconciliation; + } + let error = redact_agent_runtime_error(&root, &error, 500); let failed_runtime = fail_game_creator_agent_runtime_turn_at(&root, runtime, &error); let _ = append_local_conversation_message_for_session_at( @@ -5319,6 +5438,7 @@ async fn run_game_creator_agent_background_task_pass_with_context( ) { Ok(pending) => Some(pending), Err(error) => { + let error = redact_agent_runtime_error(&root, &error, 500); let _ = fail_game_creator_agent_runtime_turn_at(&root, runtime, &error); let _ = append_local_conversation_message_for_session_at( &root, @@ -5367,6 +5487,7 @@ async fn run_game_creator_agent_background_task_pass_with_context( if let Err(error) = write_game_creator_agent_runtime_pending_tool_action(&root, &pending_action) { + let error = redact_agent_runtime_error(&root, &error, 500); let _ = fail_game_creator_agent_runtime_turn_at(&root, runtime, &error); let _ = append_local_conversation_message_for_session_at( &root, @@ -5634,6 +5755,7 @@ async fn run_game_creator_agent_background_task_pass_with_context( if let Err(error) = write_game_creator_agent_runtime_pending_tool_action(&root, &pending_action) { + let error = redact_agent_runtime_error(&root, &error, 500); let _ = fail_game_creator_agent_runtime_turn_at(&root, runtime, &error); let _ = append_local_conversation_message_for_session_at( &root, @@ -5828,6 +5950,13 @@ async fn run_game_creator_agent_background_task_pass_with_context( ); } if observation.is_waiting_for_confirmation() { + let public_input_summary = runtime.pending_tool_action.as_ref().and_then(|item| { + agent_runtime_public_action_input_summary( + &root, + &observation.tool, + item.input_summary.as_deref(), + ) + }); if let Err(error) = append_agent_db_record( &root, serde_json::json!({ @@ -5838,7 +5967,7 @@ async fn run_game_creator_agent_background_task_pass_with_context( "tool": observation.tool, "actionId": runtime.pending_tool_action.as_ref().map(|item| item.action_id.clone()), "actionFingerprint": runtime.pending_tool_action.as_ref().map(|item| item.action_fingerprint.clone()), - "inputSummary": runtime.pending_tool_action.as_ref().and_then(|item| item.input_summary.clone()), + "inputSummary": public_input_summary, "summary": observation.summary, }), ) { @@ -5954,6 +6083,7 @@ async fn run_game_creator_agent_background_task_pass_with_context( "loop-budget-exhausted: 已执行 {AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT} 轮仍有未收束动作" ), }; + let error = redact_agent_runtime_error(&root, &error, 500); let failed_runtime = fail_game_creator_agent_runtime_budget_at(&root, runtime, &error); let _ = append_local_conversation_message_for_session_at( &root, @@ -6028,6 +6158,10 @@ async fn run_game_creator_agent_background_task_pass_with_context( ); } }; + let final_reply_request_slot = format!( + "final-reply-loop-{}-revision-{response_revision}", + runtime.loop_iteration + ); let final_reply_result = request_game_creator_agent_background_final_reply_at( &root, &agent_id, @@ -6037,6 +6171,7 @@ async fn run_game_creator_agent_background_task_pass_with_context( &plan, &observations, runtime.applied_steer_cursor, + &final_reply_request_slot, ) .await; if stop_game_creator_agent_runtime_if_cancel_requested(&root, &mut runtime) { @@ -6092,8 +6227,14 @@ async fn run_game_creator_agent_background_task_pass_with_context( continuation, }; } + Err(error) + if error.starts_with(AGENT_RUNTIME_PROVIDER_REQUEST_RECONCILIATION_PREFIX) => + { + return AgentBackgroundTaskOutcome::NeedsReconciliation; + } Err(_) if !plan.response.trim().is_empty() => plan.response.clone(), Err(error) => { + let error = redact_agent_runtime_error(&root, &error, 500); let failed_runtime = fail_game_creator_agent_runtime_turn_at(&root, runtime, &error); let _ = append_local_conversation_message_for_session_at( @@ -6142,6 +6283,11 @@ async fn run_game_creator_agent_background_task_pass_with_context( debug_assert_eq!(completed.session_id, runtime.session_id); AgentBackgroundTaskOutcome::Finished } + Ok(AgentBackgroundFinalizationOutcome::Cancelled(cancelled)) => { + debug_assert_eq!(cancelled.run_id, runtime.run_id); + debug_assert_eq!(cancelled.session_id, runtime.session_id); + AgentBackgroundTaskOutcome::Finished + } Ok(AgentBackgroundFinalizationOutcome::Stale(blocker)) => { let continuation = match prepare_game_creator_agent_background_stale_continuation_at( &root, @@ -6203,6 +6349,16 @@ const AGENT_RUNTIME_FINALIZATION_OLDER_SCHEMA_VERSION: &str = pub(crate) const AGENT_RUNTIME_FINALIZATION_STATUS_PREPARED: &str = "prepared"; const AGENT_RUNTIME_FINALIZATION_STATUS_ASSISTANT_PERSISTED: &str = "assistant-persisted"; const AGENT_RUNTIME_FINALIZATION_STATUS_RUNTIME_COMPLETED: &str = "runtime-completed"; +const AGENT_RUNTIME_PROVIDER_REQUEST_LIFECYCLE_SCHEMA_VERSION: &str = + "game-creator-provider-request-lifecycle.v1"; +const AGENT_RUNTIME_PROVIDER_REQUEST_LIFECYCLE_RECORD_TYPE: &str = + "agent.runtime.provider_request.lifecycle"; +const AGENT_RUNTIME_PROVIDER_REQUEST_RECONCILIATION_PREFIX: &str = + "provider-request-needs-reconciliation"; +const AGENT_RUNTIME_FINALIZATION_LIFECYCLE_RECORD_TYPE: &str = + "agent.runtime.finalization.lifecycle"; +const AGENT_RUNTIME_FINALIZATION_LIFECYCLE_SCHEMA_VERSION: &str = + "game-creator-finalization-lifecycle.v1"; const AGENT_RUNTIME_FINALIZATION_RESPONSE_MAX_CHARS: usize = 32_000; pub(crate) const AGENT_RUNTIME_TOOL_PLAN_FUNCTION_NAME: &str = "submit_agent_tool_plan"; const AGENT_RUNTIME_RECENT_TOOL_CALL_LIMIT: usize = 20; @@ -6258,6 +6414,7 @@ pub(crate) enum AgentBackgroundTaskOutcome { #[derive(Debug)] pub(crate) enum AgentBackgroundFinalizationOutcome { Completed(AgentRuntimeState), + Cancelled(AgentRuntimeState), Stale(AgentRuntimeToolObservation), Pending(String), } @@ -6384,6 +6541,30 @@ pub(crate) struct AgentRuntimeProjectRevision { pub(crate) updated_at: u64, } +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct AgentRuntimeProviderRequestSnapshot { + project_id: String, + agent_id: String, + task_id: String, + session_id: String, + run_id: String, + source: String, + goal_id: Option, + goal_revision: u64, + goal_snapshot_fingerprint: String, + applied_steer_cursor: u64, + request_kind: String, + request_slot: String, +} + +impl AgentRuntimeProviderRequestSnapshot { + fn with_request_slot(&self, request_slot: impl Into) -> Self { + let mut snapshot = self.clone(); + snapshot.request_slot = request_slot.into(); + snapshot + } +} + #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(deny_unknown_fields, rename_all = "camelCase")] pub(crate) struct AgentRuntimeVerificationGate { @@ -7583,27 +7764,516 @@ fn close_game_creator_agent_runtime_steer_ledger_at_locked( ) } +fn capture_game_creator_agent_runtime_provider_request_snapshot_at_locked( + root: &Path, + agent_id: &str, + session_id: &str, + run_id: &str, + request_kind: &str, + request_slot: &str, + applied_steer_cursor: u64, +) -> Result { + if request_kind.trim().is_empty() || request_slot.trim().is_empty() { + return Err("Provider 请求 kind/slot 不能为空".to_string()); + } + let task = read_latest_game_creator_agent_runtime_task_by_run_id(root, agent_id, run_id)? + .ok_or_else(|| "Provider 请求缺少当前 Agent run 的持久任务".to_string())?; + let runtime = read_game_creator_agent_runtime_at(root, agent_id)?.state; + if task.agent_id != agent_id + || task.session_id != session_id + || task.run_id != run_id + || runtime.agent_id != task.agent_id + || runtime.task_id != task.task_id + || runtime.session_id != task.session_id + || runtime.run_id != task.run_id + || runtime.source != task.source + || runtime.current_task != task.task + || runtime.goal_id != task.goal_id + || runtime.goal_revision != task.goal_revision + { + return Err("Provider 请求构建快照与当前 Runtime/task 身份不匹配".to_string()); + } + if runtime.applied_steer_cursor != applied_steer_cursor { + return Err("Provider 请求构建快照与当前 steer cursor 不匹配".to_string()); + } + let goal_snapshot_fingerprint = if let Some(goal_id) = task.goal_id.as_deref() { + if task.goal_revision == 0 { + return Err("Provider 请求绑定 Goal 但 revision 无效".to_string()); + } + let goal = read_game_creator_agent_goal_at(root, agent_id, session_id)? + .ok_or_else(|| "Provider 请求绑定的规范 Agent Goal sidecar 缺失".to_string())?; + if goal.project_id != game_creator_agent_runtime_context_project_id(root)? + || goal.goal_id != goal_id + || goal.agent_id != agent_id + || goal.session_id != session_id + || goal.run_id != run_id + || goal.revision != task.goal_revision + { + return Err("Provider 请求构建快照与规范 Agent Goal 身份不匹配".to_string()); + } + agent_goal_snapshot_fingerprint(&goal) + } else { + if task.goal_revision != 0 { + return Err("Provider 请求未绑定 Goal 但 task goalRevision 非零".to_string()); + } + String::new() + }; + Ok(AgentRuntimeProviderRequestSnapshot { + project_id: game_creator_agent_runtime_context_project_id(root)?, + agent_id: task.agent_id, + task_id: task.task_id, + session_id: task.session_id, + run_id: task.run_id, + source: task.source, + goal_id: task.goal_id, + goal_revision: task.goal_revision, + goal_snapshot_fingerprint, + applied_steer_cursor, + request_kind: request_kind.to_string(), + request_slot: request_slot.to_string(), + }) +} + +#[cfg(test)] +pub(crate) fn capture_game_creator_agent_runtime_provider_request_snapshot( + root: &Path, + agent_id: &str, + session_id: &str, + run_id: &str, + request_kind: &str, + request_slot: &str, + applied_steer_cursor: u64, +) -> Result { + let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "runtime.provider_request.build", + )?; + capture_game_creator_agent_runtime_provider_request_snapshot_at_locked( + root, + agent_id, + session_id, + run_id, + request_kind, + request_slot, + applied_steer_cursor, + ) +} + +fn game_creator_agent_runtime_provider_snapshot_has_durable_control_at_locked( + root: &Path, + snapshot: &AgentRuntimeProviderRequestSnapshot, +) -> Result { + if game_creator_agent_runtime_cancel_requested_for(root, &snapshot.agent_id, &snapshot.run_id) { + return Ok(true); + } + let task = read_latest_game_creator_agent_runtime_task_by_run_id( + root, + &snapshot.agent_id, + &snapshot.run_id, + )? + .ok_or_else(|| "Provider 请求缺少当前 Agent run 的持久任务".to_string())?; + if task.agent_id != snapshot.agent_id + || task.task_id != snapshot.task_id + || task.session_id != snapshot.session_id + || task.run_id != snapshot.run_id + || task.source != snapshot.source + { + return Err("Provider 请求构建快照与当前 task 身份冲突".to_string()); + } + if task.goal_id != snapshot.goal_id { + return Err("Provider 请求构建快照与当前 task Goal 身份冲突".to_string()); + } + if task.goal_revision < snapshot.goal_revision { + return Err("Provider 请求检测到 task Goal revision 回退".to_string()); + } + if task.goal_revision > snapshot.goal_revision { + return Ok(true); + } + if !matches!(task.status.as_str(), "pending" | "running") { + return Ok(true); + } + + let runtime = read_game_creator_agent_runtime_at(root, &snapshot.agent_id)?.state; + if runtime.agent_id != snapshot.agent_id + || runtime.task_id != snapshot.task_id + || runtime.session_id != snapshot.session_id + || runtime.run_id != snapshot.run_id + || runtime.source != snapshot.source + || runtime.goal_id != snapshot.goal_id + { + return Err("Provider 请求构建快照与当前 Runtime 身份冲突".to_string()); + } + if runtime.goal_revision < snapshot.goal_revision + || runtime.applied_steer_cursor < snapshot.applied_steer_cursor + { + return Err("Provider 请求检测到 Runtime Goal/steer 快照回退".to_string()); + } + if runtime.goal_revision > snapshot.goal_revision + || runtime.applied_steer_cursor > snapshot.applied_steer_cursor + { + return Ok(true); + } + + if let Some(goal_id) = snapshot.goal_id.as_deref() { + let goal = read_game_creator_agent_goal_at(root, &snapshot.agent_id, &snapshot.session_id)? + .ok_or_else(|| "Provider 请求绑定的规范 Agent Goal sidecar 缺失".to_string())?; + if goal.project_id != snapshot.project_id + || goal.goal_id != goal_id + || goal.agent_id != snapshot.agent_id + || goal.session_id != snapshot.session_id + || goal.run_id != snapshot.run_id + { + return Err("Provider 请求构建快照与规范 Agent Goal 身份冲突".to_string()); + } + if goal.revision < snapshot.goal_revision { + return Err("Provider 请求检测到规范 Agent Goal revision 回退".to_string()); + } + if goal.revision > snapshot.goal_revision { + return Ok(true); + } + if agent_goal_snapshot_fingerprint(&goal) != snapshot.goal_snapshot_fingerprint { + return Err("Provider 请求检测到同 revision Agent Goal 快照冲突".to_string()); + } + if goal.status != AGENT_GOAL_STATUS_ACTIVE { + return Ok(true); + } + } else if read_game_creator_agent_goal_at(root, &snapshot.agent_id, &snapshot.session_id)? + .is_some_and(|goal| goal.run_id == snapshot.run_id) + { + return Err("未绑定 Goal 的 Provider 请求命中同 run Goal sidecar".to_string()); + } + + game_creator_agent_runtime_has_queued_steer_after_cursor( + root, + &snapshot.agent_id, + &snapshot.run_id, + snapshot.applied_steer_cursor, + ) +} + +fn append_game_creator_agent_runtime_provider_request_lifecycle( + root: &Path, + snapshot: &AgentRuntimeProviderRequestSnapshot, + request_id: &str, + status: &str, +) -> Result { + append_agent_db_lifecycle_record_idempotent( + root, + "requestId", + request_id, + "status", + status, + serde_json::json!({ + "recordType": AGENT_RUNTIME_PROVIDER_REQUEST_LIFECYCLE_RECORD_TYPE, + "auditSchemaVersion": AGENT_RUNTIME_PROVIDER_REQUEST_LIFECYCLE_SCHEMA_VERSION, + "agentId": snapshot.agent_id, + "taskId": snapshot.task_id, + "sessionId": snapshot.session_id, + "runId": snapshot.run_id, + "source": snapshot.source, + "requestId": request_id, + "requestKind": snapshot.request_kind, + "requestSlot": snapshot.request_slot, + "status": status, + }), + ) + .map_err(|error| redact_agent_runtime_error(root, &error, 500)) +} + +fn mark_game_creator_agent_runtime_provider_request_needs_reconciliation_at_locked( + root: &Path, + snapshot: &AgentRuntimeProviderRequestSnapshot, + request_id: &str, +) -> Result<(), String> { + let state_path = game_creator_agent_runtime_session_path(root, &snapshot.agent_id); + let mut state = serde_json::from_str::( + &fs::read_to_string(&state_path).map_err(|error| { + format!( + "读取 Provider reconciliation Runtime 状态失败:{}: {error}", + state_path.display() + ) + })?, + ) + .map_err(|error| { + format!( + "解析 Provider reconciliation Runtime 状态失败:{}: {error}", + state_path.display() + ) + })?; + normalize_game_creator_agent_runtime_state(&mut state, &snapshot.agent_id); + if state.task_id != snapshot.task_id + || state.session_id != snapshot.session_id + || state.run_id != snapshot.run_id + || state.source != snapshot.source + { + return Err("孤立 Provider 请求与当前 Runtime 身份冲突".to_string()); + } + state.status = "running".to_string(); + state.phase = "needs-reconciliation".to_string(); + state.current_action = "Provider 请求终态无法确认".to_string(); + state.waiting_on = "开发者核对 Provider 请求与计费状态".to_string(); + state.next_step = "确认孤立 Provider 请求后再恢复当前 run".to_string(); + state.error = Some(format!( + "{AGENT_RUNTIME_PROVIDER_REQUEST_RECONCILIATION_PREFIX}: requestId={request_id}" + )); + state.updated_at = unix_timestamp(); + append_game_creator_agent_runtime_task(root, &state)?; + refresh_game_creator_agent_runtime_task_queue(root, &mut state)?; + write_game_creator_agent_runtime_state(root, &state)?; + let _ = append_game_creator_agent_runtime_event( + root, + &state, + "provider_request.needs_reconciliation", + "running", + "needs-reconciliation", + "检测到只有 started、没有可信终态的 Provider 请求,Runtime 已停止自动重放。", + Some(request_id), + ); + let _ = append_agent_db_record( + root, + serde_json::json!({ + "recordType": "agent.runtime.provider_request.needs_reconciliation", + "agentId": state.agent_id, + "taskId": state.task_id, + "sessionId": state.session_id, + "runId": state.run_id, + "source": state.source, + "requestId": request_id, + "requestKind": snapshot.request_kind, + "requestSlot": snapshot.request_slot, + }), + ); + emit_game_creator_agent_runtime_update(root, &snapshot.agent_id); + Ok(()) +} + +#[cfg(test)] pub(crate) async fn await_game_creator_agent_runtime_provider_request( root: &Path, agent_id: &str, + session_id: &str, run_id: &str, + request_kind: &str, + request_slot: &str, applied_steer_cursor: u64, request: F, ) -> Result, String> where F: std::future::Future>, { - let (key, active) = - register_game_creator_agent_runtime_provider_request(root, agent_id, run_id)?; - if game_creator_agent_runtime_has_queued_steer_after_cursor( + let snapshot = capture_game_creator_agent_runtime_provider_request_snapshot( root, agent_id, + session_id, run_id, + request_kind, + request_slot, applied_steer_cursor, - )? { + )?; + await_game_creator_agent_runtime_provider_request_with_snapshot(root, snapshot, request).await +} + +async fn await_game_creator_agent_runtime_provider_request_with_snapshot( + root: &Path, + snapshot: AgentRuntimeProviderRequestSnapshot, + request: F, +) -> Result, String> +where + F: std::future::Future>, +{ + await_game_creator_agent_runtime_provider_request_with_snapshot_and_control_recheck( + root, + snapshot, + request, + || {}, + ) + .await +} + +#[cfg(test)] +pub(crate) async fn await_game_creator_agent_runtime_provider_request_with_control_hook_for_test< + T, + F, + H, +>( + root: &Path, + agent_id: &str, + session_id: &str, + run_id: &str, + request_kind: &str, + request_slot: &str, + applied_steer_cursor: u64, + request: F, + before_control_recheck: H, +) -> Result, String> +where + F: std::future::Future>, + H: FnOnce(), +{ + let snapshot = capture_game_creator_agent_runtime_provider_request_snapshot( + root, + agent_id, + session_id, + run_id, + request_kind, + request_slot, + applied_steer_cursor, + )?; + await_game_creator_agent_runtime_provider_request_with_snapshot_and_control_recheck( + root, + snapshot, + request, + before_control_recheck, + ) + .await +} + +#[cfg(test)] +pub(crate) async fn await_game_creator_agent_runtime_provider_request_with_snapshot_for_test< + T, + F, + H, +>( + root: &Path, + snapshot: AgentRuntimeProviderRequestSnapshot, + request: F, + before_control_recheck: H, +) -> Result, String> +where + F: std::future::Future>, + H: FnOnce(), +{ + await_game_creator_agent_runtime_provider_request_with_snapshot_and_control_recheck( + root, + snapshot, + request, + before_control_recheck, + ) + .await +} + +async fn await_game_creator_agent_runtime_provider_request_with_snapshot_and_control_recheck< + T, + F, + H, +>( + root: &Path, + snapshot: AgentRuntimeProviderRequestSnapshot, + request: F, + before_control_recheck: H, +) -> Result, String> +where + F: std::future::Future>, + H: FnOnce(), +{ + let base_request_id = game_creator_agent_runtime_provider_request_id(&snapshot); + let (key, active) = register_game_creator_agent_runtime_provider_request( + root, + &snapshot.agent_id, + &snapshot.run_id, + ) + .map_err(|error| redact_agent_runtime_error(root, &error, 500))?; + before_control_recheck(); + let control_lock = match acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "runtime.provider_request.start", + ) { + Ok(lock) => lock, + Err(error) => { + unregister_game_creator_agent_runtime_provider_request(&key, &active); + return Err(redact_agent_runtime_error(root, &error, 500)); + } + }; + let incomplete_request_ids = match read_agent_db_incomplete_provider_request_ids_at( + root, + &snapshot.agent_id, + &snapshot.run_id, + ) { + Ok(request_ids) => request_ids, + Err(error) => { + unregister_game_creator_agent_runtime_provider_request(&key, &active); + return Err(redact_agent_runtime_error(root, &error, 500)); + } + }; + if let Some(orphan_request_id) = incomplete_request_ids.first() { + let reconciliation = + mark_game_creator_agent_runtime_provider_request_needs_reconciliation_at_locked( + root, + &snapshot, + orphan_request_id, + ); + unregister_game_creator_agent_runtime_provider_request(&key, &active); + reconciliation?; + return Err(format!( + "{AGENT_RUNTIME_PROVIDER_REQUEST_RECONCILIATION_PREFIX}: requestId={orphan_request_id}" + )); + } + let has_durable_control = + match game_creator_agent_runtime_provider_snapshot_has_durable_control_at_locked( + root, &snapshot, + ) { + Ok(value) => value, + Err(error) => { + unregister_game_creator_agent_runtime_provider_request(&key, &active); + return Err(redact_agent_runtime_error(root, &error, 500)); + } + }; + if has_durable_control { active.interrupted.store(true, Ordering::Release); active.notify.notify_one(); + unregister_game_creator_agent_runtime_provider_request(&key, &active); + return Ok(None); } + let (request_id, needs_reconciliation) = + match resolve_game_creator_agent_runtime_provider_request_attempt_at_locked( + root, + &base_request_id, + ) { + Ok(resolution) => resolution, + Err(error) => { + unregister_game_creator_agent_runtime_provider_request(&key, &active); + return Err(redact_agent_runtime_error(root, &error, 500)); + } + }; + if needs_reconciliation { + let reconciliation = + mark_game_creator_agent_runtime_provider_request_needs_reconciliation_at_locked( + root, + &snapshot, + &request_id, + ); + unregister_game_creator_agent_runtime_provider_request(&key, &active); + reconciliation?; + return Err(format!( + "{AGENT_RUNTIME_PROVIDER_REQUEST_RECONCILIATION_PREFIX}: requestId={request_id}" + )); + } + match append_game_creator_agent_runtime_provider_request_lifecycle( + root, + &snapshot, + &request_id, + "started", + ) { + Ok(true) => {} + Ok(false) => { + let reconciliation = + mark_game_creator_agent_runtime_provider_request_needs_reconciliation_at_locked( + root, + &snapshot, + &request_id, + ); + unregister_game_creator_agent_runtime_provider_request(&key, &active); + reconciliation?; + return Err(format!( + "{AGENT_RUNTIME_PROVIDER_REQUEST_RECONCILIATION_PREFIX}: requestId={request_id}" + )); + } + Err(error) => { + unregister_game_creator_agent_runtime_provider_request(&key, &active); + return Err(error); + } + } + drop(control_lock); let notified = active.notify.notified(); tokio::pin!(notified); tokio::pin!(request); @@ -7617,9 +8287,115 @@ where } }; unregister_game_creator_agent_runtime_provider_request(&key, &active); + let result = result.map_err(|error| redact_agent_runtime_error(root, &error, 500)); + let status = match &result { + Ok(Some(_)) => "completed", + Ok(None) => "interrupted", + Err(_) => "failed", + }; + if append_game_creator_agent_runtime_provider_request_lifecycle( + root, + &snapshot, + &request_id, + status, + ) + .is_err() + { + let _control_lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "runtime.provider_request.terminal_reconciliation", + )?; + mark_game_creator_agent_runtime_provider_request_needs_reconciliation_at_locked( + root, + &snapshot, + &request_id, + )?; + return Err(format!( + "{AGENT_RUNTIME_PROVIDER_REQUEST_RECONCILIATION_PREFIX}: requestId={request_id}" + )); + } result } +pub(crate) fn game_creator_agent_runtime_provider_request_id( + snapshot: &AgentRuntimeProviderRequestSnapshot, +) -> String { + format!( + "provider-request-{:x}", + Sha256::digest( + format!( + "{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}", + snapshot.project_id, + snapshot.agent_id, + snapshot.task_id, + snapshot.session_id, + snapshot.run_id, + snapshot.goal_id.as_deref().unwrap_or_default(), + snapshot.goal_revision, + snapshot.goal_snapshot_fingerprint, + snapshot.applied_steer_cursor, + snapshot.request_kind, + snapshot.request_slot + ) + .as_bytes() + ) + ) +} + +fn game_creator_agent_runtime_provider_request_attempt_id( + base_request_id: &str, + attempt: usize, +) -> String { + if attempt == 0 { + return base_request_id.to_string(); + } + format!( + "provider-request-{:x}", + Sha256::digest(format!("{base_request_id}\nattempt\n{attempt}").as_bytes()) + ) +} + +fn resolve_game_creator_agent_runtime_provider_request_attempt_at_locked( + root: &Path, + base_request_id: &str, +) -> Result<(String, bool), String> { + const MAX_INTERRUPTED_ATTEMPTS: usize = 64; + for attempt in 0..=MAX_INTERRUPTED_ATTEMPTS { + let request_id = + game_creator_agent_runtime_provider_request_attempt_id(base_request_id, attempt); + let transitions = read_agent_db_lifecycle_transitions_at( + root, + AGENT_RUNTIME_PROVIDER_REQUEST_LIFECYCLE_RECORD_TYPE, + "requestId", + &request_id, + )?; + if transitions.is_empty() { + return Ok((request_id, false)); + } + if transitions == ["started", "interrupted"] && attempt < MAX_INTERRUPTED_ATTEMPTS { + continue; + } + return Ok((request_id, true)); + } + unreachable!("Provider interrupted attempt loop always returns") +} + +#[cfg(test)] +pub(crate) fn append_game_creator_agent_runtime_provider_lifecycle_for_test( + root: &Path, + snapshot: &AgentRuntimeProviderRequestSnapshot, + status: &str, +) -> Result { + let request_id = game_creator_agent_runtime_provider_request_id(snapshot); + append_game_creator_agent_runtime_provider_request_lifecycle( + root, + snapshot, + &request_id, + status, + )?; + Ok(request_id) +} + impl AgentRuntimeContextWindowTracker { pub(crate) fn from_continuation(continuation: &AgentRuntimeContinuationContext) -> Self { Self { @@ -8995,6 +9771,65 @@ fn write_game_creator_agent_runtime_finalization_journal( ) } +fn append_game_creator_agent_runtime_finalization_lifecycle_stage( + root: &Path, + journal: &AgentRuntimeFinalizationJournal, + stage: &str, + stage_at: u64, +) -> Result<(), String> { + let (stage_ordinal, previous_stage) = match stage { + "prepared" => (1_u8, None), + "assistant-persisted" => (2, Some("prepared")), + "runtime-completed" => (3, Some("assistant-persisted")), + "goal-completed" => (4, Some("runtime-completed")), + _ => { + return Err(format!( + "Agent Runtime finalization lifecycle 阶段无效:{stage}" + )) + } + }; + let (conversation_path, _, _) = conversation_file_path_for_session( + root, + Some(&journal.agent_id), + Some(&journal.session_id), + )?; + let conversation_path = relative_project_path(root, &conversation_path)?; + let expected = serde_json::json!({ + "recordType": AGENT_RUNTIME_FINALIZATION_LIFECYCLE_RECORD_TYPE, + "auditSchemaVersion": AGENT_RUNTIME_FINALIZATION_LIFECYCLE_SCHEMA_VERSION, + "journalSchemaVersion": journal.schema_version, + "agentId": journal.agent_id, + "taskId": journal.task_id, + "sessionId": journal.session_id, + "runId": journal.run_id, + "source": journal.source, + "finalizationId": journal.finalization_id, + "messageId": journal.message_id, + "stage": stage, + "stageOrdinal": stage_ordinal, + "previousStage": previous_stage, + "goalId": journal.goal_id, + "goalRevision": journal.goal_revision, + "goalSnapshotFingerprint": journal.goal_snapshot_fingerprint, + "planRevision": journal.plan_revision, + "planSnapshotFingerprint": journal.plan_snapshot_fingerprint, + "responseFingerprint": journal.response_fingerprint, + "responseChars": journal.response.chars().count(), + "conversationPath": conversation_path, + "stageAt": stage_at, + }); + append_agent_db_lifecycle_record_idempotent( + root, + "finalizationId", + &journal.finalization_id, + "stage", + stage, + expected, + ) + .map(|_| ()) + .map_err(|error| redact_agent_runtime_error(root, &error, 500)) +} + pub(crate) fn remove_game_creator_agent_runtime_finalization_journal( root: &Path, agent_id: &str, @@ -9884,6 +10719,11 @@ fn append_game_creator_agent_runtime_auto_tool_action_executing_record( root: &Path, pending: &AgentRuntimePendingToolAction, ) -> Result<(), String> { + let public_input_summary = agent_runtime_public_action_input_summary( + root, + &pending.action.tool, + pending.input_summary.as_deref(), + ); append_agent_db_record( root, serde_json::json!({ @@ -9895,7 +10735,7 @@ fn append_game_creator_agent_runtime_auto_tool_action_executing_record( "actionFingerprint": pending.action_fingerprint, "tool": pending.action.tool, "executionMode": AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO, - "inputSummary": pending.input_summary, + "inputSummary": public_input_summary, }), ) } @@ -10864,8 +11704,7 @@ pub(crate) fn append_agent_runtime_action_receipt( } let safe_detail = agent_runtime_action_receipt_safe_detail(root, observation); let detail_unavailable = observation.detail.is_some() && safe_detail.is_none(); - let input_summary = input_summary - .and_then(|value| agent_runtime_action_receipt_safe_text(root, value, 320, None)); + let input_summary = agent_runtime_public_action_input_summary(root, &tool, input_summary); let summary = agent_runtime_action_receipt_safe_text( root, &observation.summary, @@ -11328,6 +12167,56 @@ fn agent_runtime_action_receipt_safe_text( Some(sanitized) } +fn agent_runtime_public_action_input_summary( + root: &Path, + tool: &str, + input_summary: Option<&str>, +) -> Option { + let input_summary = input_summary?.trim(); + if input_summary.is_empty() { + return None; + } + let public_shape_only = matches!( + tool, + "memory.read" + | "project.restore" + | "project.diff" + | "git.inspect" + | "project.patchset" + | "project.search" + | "project.verify" + | "file.list" + | "file.read" + | "file.write" + | "file.patch" + | "file.delete" + | "task.update" + | "command.exec" + | "command.start" + | "command.poll" + | "command.stdin" + | "command.terminate" + | "command.output_read" + | "command.run_limited" + | "preview.validate" + | "image.inspect" + | "canvas.asset_generate" + | "agent.message" + | "agent.delegate" + | "agent.schedule_ready" + | "agent.action_history" + | "agent.run_status" + ); + if public_shape_only { + return agent_runtime_action_receipt_safe_text(root, input_summary, 320, None); + } + Some(format!( + "inputSummarySha256={:x} · inputSummaryChars={}", + Sha256::digest(input_summary.as_bytes()), + input_summary.chars().count() + )) +} + fn agent_runtime_action_receipt_identity_text( root: &Path, value: &str, @@ -11571,16 +12460,11 @@ pub(crate) fn agent_runtime_tool_action_input_summary( "project.verify" => { let expected_command = text(&["expectedCommand", "expected_command"]); let command_chars = expected_command.chars().count(); - let command_head = expected_command.chars().take(48).collect::(); - let mut command_tail = expected_command.chars().rev().take(96).collect::>(); - command_tail.reverse(); format!( - "script={} · expectedCommandSha256={:x} · expectedCommandChars={} · head={} · tail={} · timeoutSeconds={}", + "script={} · expectedCommandSha256={:x} · expectedCommandChars={} · timeoutSeconds={}", text(&["script"]), Sha256::digest(expected_command.as_bytes()), command_chars, - command_head, - command_tail.into_iter().collect::(), input .get("timeoutSeconds") .or_else(|| input.get("timeout_seconds")) @@ -12250,8 +13134,22 @@ async fn request_game_creator_agent_background_tool_plan_at( loop_index: usize, applied_steer_cursor: u64, ) -> Result, String> { - let (llm, config_path, mut request, repository_context_fingerprint) = - build_game_creator_agent_background_tool_plan_request( + let initial_request_slot = format!("loop-{loop_index}-repair-0"); + let (provider_snapshot, (llm, config_path, mut request, repository_context_fingerprint)) = { + let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "runtime.provider_request.build.tool_plan", + )?; + let snapshot = capture_game_creator_agent_runtime_provider_request_snapshot_at_locked( + root, + agent_id, + session_id, + run_id, + "tool-plan", + &initial_request_slot, + applied_steer_cursor, + )?; + let request = build_game_creator_agent_background_tool_plan_request( root, agent_id, session_id, @@ -12260,6 +13158,8 @@ async fn request_game_creator_agent_background_tool_plan_at( observations, loop_index, )?; + (snapshot, request) + }; let client = build_game_creator_llm_client_from_llm_config(&llm, &config_path)?; for repair_attempt in 0..=AGENT_RUNTIME_TOOL_PLAN_FORMAT_REPAIR_ATTEMPTS { if game_creator_agent_runtime_cancel_requested_for(root, agent_id, run_id) { @@ -12273,22 +13173,19 @@ async fn request_game_creator_agent_background_tool_plan_at( AGENT_RUNTIME_TOOL_PLAN_FORMAT_REPAIR_ATTEMPTS ) }; + let request_slot = format!("loop-{loop_index}-repair-{repair_attempt}"); let provider_request = async { - request_game_creator_agent_llm_text_retrying_recoverable( - &client, - &llm, - request.clone(), - operation.as_str(), - false, - ) - .await - .map_err(|error| format!("{config_path} {operation}调用 LLM 失败:{error}")) + client.run(request.clone()).await.map_err(|error| { + format!( + "{config_path} {operation}调用 LLM 失败:{}", + game_creator_agent_llm_error_public_summary(&error) + ) + }) }; - let Some(response) = await_game_creator_agent_runtime_provider_request( + let request_snapshot = provider_snapshot.with_request_slot(&request_slot); + let Some(response) = await_game_creator_agent_runtime_provider_request_with_snapshot( root, - agent_id, - run_id, - applied_steer_cursor, + request_snapshot, provider_request, ) .await? @@ -12389,33 +13286,47 @@ async fn request_game_creator_agent_background_final_reply_at( plan: &AgentRuntimeToolPlan, observations: &[AgentRuntimeToolObservation], applied_steer_cursor: u64, + request_slot: &str, ) -> Result, String> { - let (llm, config_path, request) = build_game_creator_agent_background_final_reply_request( - root, - agent_id, - session_id, - run_id, - task, - plan, - observations, - )?; + let (provider_snapshot, (llm, config_path, request)) = { + let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "runtime.provider_request.build.final_reply", + )?; + let snapshot = capture_game_creator_agent_runtime_provider_request_snapshot_at_locked( + root, + agent_id, + session_id, + run_id, + "final-reply", + request_slot, + applied_steer_cursor, + )?; + let request = build_game_creator_agent_background_final_reply_request( + root, + agent_id, + session_id, + run_id, + task, + plan, + observations, + )?; + (snapshot, request) + }; let client = build_game_creator_llm_client_from_llm_config(&llm, &config_path)?; let provider_request = async { - request_game_creator_agent_llm_text_retrying_recoverable( - &client, - &llm, - request, - "后台 Agent 最终回复", - true, - ) - .await - .map_err(|error| format!("{config_path} 后台 Agent 最终回复调用 LLM 失败:{error}")) + request_game_creator_llm_text(&client, &llm, request) + .await + .map_err(|error| { + format!( + "{config_path} 后台 Agent 最终回复调用 LLM 失败:{}", + game_creator_agent_llm_error_public_summary(&error) + ) + }) }; - let Some(response) = await_game_creator_agent_runtime_provider_request( + let Some(response) = await_game_creator_agent_runtime_provider_request_with_snapshot( root, - agent_id, - run_id, - applied_steer_cursor, + provider_snapshot, provider_request, ) .await? @@ -18516,7 +19427,7 @@ async fn observe_agent_runtime_image_inspect( }; let llm = resolve_game_creator_llm_config_for_agent(&app_config, &template_agent_id); let config_path = format!("agentLlm.{template_agent_id}"); - let client = match build_game_creator_llm_client_from_llm_config(&llm, &config_path) { + let client = match build_game_creator_agent_runtime_llm_client(&llm, &config_path) { Ok(client) => client, Err(error) => { return AgentRuntimeToolObservation { @@ -18575,25 +19486,14 @@ async fn observe_agent_runtime_image_inspect( }; } }; - let response = match request_game_creator_agent_llm_text_retrying_recoverable( - &client, - &llm, - request, - "image.inspect 视觉检查", - false, - ) - .await - { + let response = match client.run(request).await { Ok(response) => response, Err(error) => { - let error = redact_agent_runtime_image_data_urls(&error.to_string()); + let error = game_creator_agent_llm_error_public_summary(&error); return AgentRuntimeToolObservation { tool: "image.inspect".to_string(), status: "failed".to_string(), - summary: sanitize_agent_runtime_text( - &format!("{config_path} 视觉模型调用失败:{error}"), - 240, - ), + summary: format!("{config_path} 视觉模型调用失败:{error}"), detail: None, }; } @@ -19280,6 +20180,8 @@ pub(crate) fn observe_agent_runtime_agent_delegate( } else { "queued" }; + let delegated_task_sha256 = format!("{:x}", Sha256::digest(task.as_bytes())); + let delegated_task_chars = task.chars().count(); let _ = append_agent_db_record( root, serde_json::json!({ @@ -19294,7 +20196,8 @@ pub(crate) fn observe_agent_runtime_agent_delegate( "parentRunId": parent_run_id, "delegationId": delegation_id, "status": status, - "task": sanitize_agent_runtime_text(&task, 180), + "taskSha256": delegated_task_sha256.clone(), + "taskChars": delegated_task_chars, }), ); AgentRuntimeToolObservation { @@ -19302,14 +20205,15 @@ pub(crate) fn observe_agent_runtime_agent_delegate( status: "ok".to_string(), summary: format!("已委派 {target_agent_id} 后台任务"), detail: Some(format!( - "targetAgentId={}, runId={}, delegationId={}, delegateStatus={}, targetStatus={}, targetPhase={}, task={}", + "targetAgentId={}, runId={}, delegationId={}, delegateStatus={}, targetStatus={}, targetPhase={}, taskChars={}, taskSha256={}", target_agent_id, delegated_run_id, delegation_id, status, target_state.status, target_state.phase, - sanitize_agent_runtime_text(&task, 180) + delegated_task_chars, + delegated_task_sha256 )), } } @@ -21989,6 +22893,23 @@ fn start_game_creator_agent_runtime_task_for_session_in_session_lane_at( append_game_creator_agent_runtime_task(root, &state)?; refresh_game_creator_agent_runtime_task_queue(root, &mut state)?; write_game_creator_agent_runtime_state(root, &state)?; + let runtime_task_sha256 = format!("{:x}", Sha256::digest(runtime_task.as_bytes())); + let runtime_task_chars = runtime_task.chars().count(); + let private_runtime_task = agent_runtime_task_requires_private_audit( + state.goal_id.as_deref(), + state.parent_agent_id.as_deref(), + ); + let public_runtime_task = (!private_runtime_task).then(|| runtime_task.clone()); + let runtime_event_detail = if private_runtime_task { + format!( + "goalRevision={} · taskChars={runtime_task_chars} · taskSha256={runtime_task_sha256} · goalBound={} · delegated={}", + state.goal_revision, + state.goal_id.is_some(), + state.parent_agent_id.is_some(), + ) + } else { + runtime_task.clone() + }; append_game_creator_agent_runtime_event( root, &state, @@ -21996,7 +22917,7 @@ fn start_game_creator_agent_runtime_task_for_session_in_session_lane_at( "running", "planning", "Agent Runtime 开始处理本轮输入。", - Some(&runtime_task), + Some(&runtime_event_detail), )?; append_agent_db_record( root, @@ -22009,7 +22930,11 @@ fn start_game_creator_agent_runtime_task_for_session_in_session_lane_at( "source": state.source, "status": state.status, "phase": state.phase, - "task": state.current_task, + "task": public_runtime_task, + "taskSha256": runtime_task_sha256, + "taskChars": runtime_task_chars, + "goalBound": state.goal_id.is_some(), + "delegated": state.parent_agent_id.is_some(), }), )?; Ok(state) @@ -22166,12 +23091,22 @@ fn game_creator_agent_runtime_event_exists( Ok(false) } -fn agent_db_record_exists_for_run( +pub(crate) fn agent_db_record_exists_for_finalization( root: &Path, - record_type: &str, - agent_id: &str, - run_id: &str, + expected_record: &serde_json::Value, ) -> Result { + let record_type = expected_record + .get("recordType") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| "finalization 审计缺少 recordType".to_string())?; + let finalization_id = expected_record + .get("finalizationId") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| "finalization 审计缺少 finalizationId".to_string())?; + let message_id = expected_record + .get("messageId") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| "finalization 审计缺少 messageId".to_string())?; let path = root.join(".agent/agent.db"); let file = match File::open(&path) { Ok(file) => file, @@ -22180,9 +23115,10 @@ fn agent_db_record_exists_for_run( return Err(format!( "读取 Agent 本地索引失败:{}: {error}", path.display() - )) + )); } }; + let mut exact_matches = 0usize; for line in BufReader::new(file).lines() { let line = line.map_err(|error| format!("读取 Agent 本地索引失败:{}: {error}", path.display()))?; @@ -22191,14 +23127,28 @@ fn agent_db_record_exists_for_run( } let record = serde_json::from_str::(&line) .map_err(|error| format!("解析 Agent 本地索引失败:{}: {error}", path.display()))?; - if 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) + if record.get("recordType").and_then(serde_json::Value::as_str) != Some(record_type) + || record + .get("finalizationId") + .and_then(serde_json::Value::as_str) + != Some(finalization_id) + || record.get("messageId").and_then(serde_json::Value::as_str) != Some(message_id) { - return Ok(true); + continue; + } + if !agent_db_stored_record_matches_expected_payload(&record, expected_record) { + return Err(format!( + "Agent finalization 审计内容冲突:recordType={record_type} finalizationId={finalization_id}" + )); + } + exact_matches = exact_matches.saturating_add(1); + if exact_matches > 1 { + return Err(format!( + "Agent finalization 审计重复:recordType={record_type} finalizationId={finalization_id}" + )); } } - Ok(false) + Ok(exact_matches == 1) } fn agent_db_record_exists_for_action( @@ -22241,6 +23191,7 @@ fn agent_db_record_exists_for_action( fn finish_game_creator_agent_background_runtime_turn_idempotently_at( root: &Path, state: AgentRuntimeState, + journal: &AgentRuntimeFinalizationJournal, response: &str, ) -> Result { let mut completed = prepare_game_creator_agent_runtime_completed_state(root, state, response)?; @@ -22270,6 +23221,12 @@ fn finish_game_creator_agent_background_runtime_turn_idempotently_at( } refresh_game_creator_agent_runtime_task_queue(root, &mut completed)?; write_game_creator_agent_runtime_state(root, &completed)?; + append_game_creator_agent_runtime_finalization_lifecycle_stage( + root, + journal, + "runtime-completed", + unix_timestamp(), + )?; complete_game_creator_agent_goal_for_runtime_at_locked(root, &mut completed, response)?; let goal_projection_matches = read_latest_game_creator_agent_runtime_task_by_run_id( root, @@ -22287,6 +23244,12 @@ fn finish_game_creator_agent_background_runtime_turn_idempotently_at( } refresh_game_creator_agent_runtime_task_queue(root, &mut completed)?; write_game_creator_agent_runtime_state(root, &completed)?; + append_game_creator_agent_runtime_finalization_lifecycle_stage( + root, + journal, + "goal-completed", + unix_timestamp(), + )?; if !game_creator_agent_runtime_event_exists( root, &completed.agent_id, @@ -22303,24 +23266,20 @@ fn finish_game_creator_agent_background_runtime_turn_idempotently_at( completed.last_response.as_deref(), )?; } - if !agent_db_record_exists_for_run( - root, - "agent.runtime.completed", - &completed.agent_id, - &completed.run_id, - )? { - append_agent_db_record( - root, - serde_json::json!({ - "recordType": "agent.runtime.completed", - "agentId": completed.agent_id, - "taskId": completed.task_id, - "sessionId": completed.session_id, - "runId": completed.run_id, - "source": completed.source, - "responsePreview": completed.last_response, - }), - )?; + let completed_audit = serde_json::json!({ + "recordType": "agent.runtime.completed", + "agentId": completed.agent_id, + "taskId": completed.task_id, + "sessionId": completed.session_id, + "runId": completed.run_id, + "source": completed.source, + "finalizationId": journal.finalization_id, + "messageId": journal.message_id, + "responseFingerprint": journal.response_fingerprint, + "responseChars": journal.response.chars().count(), + }); + if !agent_db_record_exists_for_finalization(root, &completed_audit)? { + append_agent_db_record(root, completed_audit)?; } remove_game_creator_agent_runtime_pending_tool_action( root, @@ -22349,24 +23308,20 @@ fn finish_game_creator_agent_background_runtime_turn_idempotently_at( completed.last_response.as_deref(), )?; } - if !agent_db_record_exists_for_run( - root, - "agent.runtime.background_task.completed", - &completed.agent_id, - &completed.run_id, - )? { - append_agent_db_record( - root, - serde_json::json!({ - "recordType": "agent.runtime.background_task.completed", - "agentId": completed.agent_id, - "taskId": completed.task_id, - "sessionId": completed.session_id, - "runId": completed.run_id, - "source": completed.source, - "responsePreview": completed.last_response, - }), - )?; + let background_completed_audit = serde_json::json!({ + "recordType": "agent.runtime.background_task.completed", + "agentId": completed.agent_id, + "taskId": completed.task_id, + "sessionId": completed.session_id, + "runId": completed.run_id, + "source": completed.source, + "finalizationId": journal.finalization_id, + "messageId": journal.message_id, + "responseFingerprint": journal.response_fingerprint, + "responseChars": journal.response.chars().count(), + }); + if !agent_db_record_exists_for_finalization(root, &background_completed_audit)? { + append_agent_db_record(root, background_completed_audit)?; } Ok(completed) } @@ -22484,9 +23439,15 @@ where F: FnMut(AgentRuntimeFinalizationCheckpoint) -> Result<(), String>, { let assistant_exists = game_creator_agent_runtime_finalization_assistant_exists(root, journal)?; + append_game_creator_agent_runtime_finalization_lifecycle_stage( + root, + journal, + "prepared", + journal.prepared_at, + )?; match journal.status.as_str() { AGENT_RUNTIME_FINALIZATION_STATUS_PREPARED => { - append_local_conversation_message_for_session_idempotent_at( + append_local_conversation_message_for_session_idempotent_with_finalization_at( root, Some(&journal.agent_id), Some(&journal.session_id), @@ -22496,6 +23457,7 @@ where agent_id: None, }, &journal.message_id, + &journal.finalization_id, )?; checkpoint(AgentRuntimeFinalizationCheckpoint::AssistantAppended)?; let now = unix_timestamp(); @@ -22515,9 +23477,17 @@ where _ => return Err("Agent Runtime finalization 状态无效".to_string()), } + append_game_creator_agent_runtime_finalization_lifecycle_stage( + root, + journal, + "assistant-persisted", + journal.assistant_persisted_at.unwrap_or(journal.updated_at), + )?; + let completed = finish_game_creator_agent_background_runtime_turn_idempotently_at( root, state, + journal, &journal.response, )?; if journal.status != AGENT_RUNTIME_FINALIZATION_STATUS_RUNTIME_COMPLETED { @@ -22528,6 +23498,12 @@ where journal.updated_at = now; write_game_creator_agent_runtime_finalization_journal(root, journal)?; } + append_game_creator_agent_runtime_finalization_lifecycle_stage( + root, + journal, + "goal-completed", + journal.runtime_completed_at.unwrap_or(journal.updated_at), + )?; remove_game_creator_agent_runtime_finalization_journal( root, &journal.agent_id, @@ -22541,6 +23517,7 @@ fn record_game_creator_agent_runtime_finalization_pending( state: &AgentRuntimeState, error: &str, ) { + let error = redact_agent_runtime_error(root, error, 500); let mut visible_state = read_game_creator_agent_runtime_at(root, &state.agent_id) .map(|result| result.state) .unwrap_or_else(|_| state.clone()); @@ -22555,7 +23532,7 @@ fn record_game_creator_agent_runtime_finalization_pending( visible_state.current_action = "正在恢复最终回复持久化".to_string(); visible_state.waiting_on = "Agent Runtime finalization 恢复".to_string(); visible_state.next_step = "修复持久化错误后自动完成当前 run".to_string(); - visible_state.error = Some(sanitize_agent_runtime_text(error, 500)); + visible_state.error = Some(error.clone()); visible_state.updated_at = unix_timestamp(); let _ = append_game_creator_agent_runtime_task(root, &visible_state); let _ = refresh_game_creator_agent_runtime_task_queue(root, &mut visible_state); @@ -22568,7 +23545,7 @@ fn record_game_creator_agent_runtime_finalization_pending( visible_state.status.as_str(), visible_state.phase.as_str(), "最终回复已进入可恢复持久化,等待恢复完成。", - Some(error), + Some(&error), ); let _ = append_agent_db_record( root, @@ -22579,7 +23556,7 @@ fn record_game_creator_agent_runtime_finalization_pending( "sessionId": visible_state.session_id.clone(), "runId": visible_state.run_id.clone(), "source": visible_state.source.clone(), - "error": sanitize_agent_runtime_text(error, 500), + "error": error, }), ); emit_game_creator_agent_runtime_update(root, &visible_state.agent_id); @@ -22587,7 +23564,7 @@ fn record_game_creator_agent_runtime_finalization_pending( pub(crate) fn finish_game_creator_agent_background_runtime_turn_with_checkpoint_at( root: &Path, - state: AgentRuntimeState, + mut state: AgentRuntimeState, response: &str, response_revision: u64, observations: &[AgentRuntimeToolObservation], @@ -22600,6 +23577,15 @@ where root, "runtime.background.complete", )?; + if game_creator_agent_runtime_cancel_requested(root, &state) { + mark_game_creator_agent_runtime_cancelled_at_locked( + root, + &mut state, + "Agent 后台任务已按开发者请求取消", + Some("取消请求在最终回复持久化前生效。"), + )?; + return Ok(AgentBackgroundFinalizationOutcome::Cancelled(state)); + } let steer_snapshot = read_game_creator_agent_runtime_steer_ledger(root, &state.agent_id, &state.run_id)?; if steer_snapshot.entries.values().any(|entry| { @@ -22676,9 +23662,22 @@ where &state, &response, response_revision, - )?; - write_game_creator_agent_runtime_finalization_journal(root, &journal)?; + ) + .map_err(|error| redact_agent_runtime_error(root, &error, 500))?; + write_game_creator_agent_runtime_finalization_journal(root, &journal) + .map_err(|error| redact_agent_runtime_error(root, &error, 500))?; + if let Err(error) = append_game_creator_agent_runtime_finalization_lifecycle_stage( + root, + &journal, + "prepared", + journal.prepared_at, + ) { + let error = redact_agent_runtime_error(root, &error, 500); + record_game_creator_agent_runtime_finalization_pending(root, &state, &error); + return Ok(AgentBackgroundFinalizationOutcome::Pending(error)); + } if let Err(error) = checkpoint(AgentRuntimeFinalizationCheckpoint::Prepared) { + let error = redact_agent_runtime_error(root, &error, 500); record_game_creator_agent_runtime_finalization_pending(root, &state, &error); return Ok(AgentBackgroundFinalizationOutcome::Pending(error)); } @@ -22709,6 +23708,7 @@ where Ok(AgentBackgroundFinalizationOutcome::Completed(completed)) } Err(error) => { + let error = redact_agent_runtime_error(root, &error, 500); record_game_creator_agent_runtime_finalization_pending(root, &state, &error); Ok(AgentBackgroundFinalizationOutcome::Pending(error)) } @@ -22749,8 +23749,9 @@ pub(crate) fn fail_game_creator_agent_runtime_turn_at( state.current_action = "等待开发者处理失败".to_string(); state.waiting_on = "开发者处理失败".to_string(); state.next_step = "等待开发者处理失败".to_string(); - state.error = Some(sanitize_agent_runtime_text(error, 500)); - fail_agent_runtime_remaining_plan_steps(&mut state, error); + let public_error = redact_agent_runtime_error(root, error, 500); + state.error = Some(public_error.clone()); + fail_agent_runtime_remaining_plan_steps(&mut state, &public_error); let _ = refresh_game_creator_agent_runtime_tool_policy(root, &mut state); state.updated_at = unix_timestamp(); append_game_creator_agent_runtime_task(root, &state)?; @@ -22797,8 +23798,9 @@ pub(crate) fn fail_game_creator_agent_runtime_budget_at( state.current_action = "Agent loop 预算耗尽".to_string(); state.waiting_on = "开发者调整目标或重试".to_string(); state.next_step = "调整任务范围后重试".to_string(); - state.error = Some(sanitize_agent_runtime_text(error, 500)); - fail_agent_runtime_remaining_plan_steps(&mut state, error); + let public_error = redact_agent_runtime_error(root, error, 500); + state.error = Some(public_error.clone()); + fail_agent_runtime_remaining_plan_steps(&mut state, &public_error); let _ = refresh_game_creator_agent_runtime_tool_policy(root, &mut state); state.updated_at = unix_timestamp(); append_game_creator_agent_runtime_task(root, &state)?; @@ -23341,7 +24343,10 @@ pub(crate) fn remove_game_creator_agent_runtime_cancel_request( )); } -fn game_creator_agent_runtime_cancel_requested(root: &Path, state: &AgentRuntimeState) -> bool { +pub(crate) fn game_creator_agent_runtime_cancel_requested( + root: &Path, + state: &AgentRuntimeState, +) -> bool { game_creator_agent_runtime_cancel_requested_for(root, &state.agent_id, &state.run_id) } @@ -24126,6 +25131,22 @@ fn mark_game_creator_agent_runtime_cancelled_at_locked( append_game_creator_agent_runtime_task(root, state)?; refresh_game_creator_agent_runtime_task_queue(root, state)?; write_game_creator_agent_runtime_state(root, state)?; + let task_sha256 = format!("{:x}", Sha256::digest(state.current_task.as_bytes())); + let task_chars = state.current_task.chars().count(); + let goal_bound = state.goal_id.is_some(); + let delegated = state.parent_agent_id.is_some(); + let private_task = agent_runtime_task_requires_private_audit( + state.goal_id.as_deref(), + state.parent_agent_id.as_deref(), + ); + let public_task = (!private_task).then(|| state.current_task.clone()); + let public_detail = if private_task { + Some(format!( + "taskChars={task_chars} · taskSha256={task_sha256} · goalBound={goal_bound} · delegated={delegated}" + )) + } else { + detail.map(ToString::to_string) + }; append_game_creator_agent_runtime_event( root, state, @@ -24133,7 +25154,7 @@ fn mark_game_creator_agent_runtime_cancelled_at_locked( "cancelled", "cancelled", summary, - detail, + public_detail.as_deref(), )?; append_agent_db_record( root, @@ -24144,7 +25165,11 @@ fn mark_game_creator_agent_runtime_cancelled_at_locked( "sessionId": state.session_id.clone(), "runId": state.run_id.clone(), "source": state.source.clone(), - "task": state.current_task.clone(), + "task": public_task, + "taskSha256": task_sha256, + "taskChars": task_chars, + "goalBound": goal_bound, + "delegated": delegated, "summary": summary, }), )?; @@ -24869,11 +25894,81 @@ fn sanitize_agent_runtime_text(value: &str, max_chars: usize) -> String { truncate_agent_runtime_text(sanitize_prompt_context(value).trim(), max_chars) } +fn redact_agent_runtime_url_tokens(value: &str) -> String { + let mut output = String::with_capacity(value.len()); + let mut cursor = 0usize; + while cursor < value.len() { + let remaining = &value[cursor..]; + let url_prefix_bytes = if remaining + .get(..8) + .is_some_and(|prefix| prefix.eq_ignore_ascii_case("https://")) + { + Some(8) + } else if remaining + .get(..7) + .is_some_and(|prefix| prefix.eq_ignore_ascii_case("http://")) + { + Some(7) + } else { + None + }; + let Some(url_prefix_bytes) = url_prefix_bytes else { + let character = remaining.chars().next().unwrap_or_default(); + output.push(character); + cursor += character.len_utf8(); + continue; + }; + output.push_str(""); + cursor += url_prefix_bytes; + while cursor < value.len() { + let character = value[cursor..].chars().next().unwrap_or_default(); + if character.is_whitespace() + || matches!( + character, + '\'' | '"' | '`' | '<' | '>' | '[' | ']' | '{' | '}' + ) + { + break; + } + cursor += character.len_utf8(); + } + } + output +} + +fn redact_agent_runtime_error(root: &Path, value: &str, max_chars: usize) -> String { + let redacted = redact_agent_runtime_url_tokens(value); + let redacted = redact_agent_runtime_project_paths_raw(root, &redacted); + let redacted = redact_absolute_path_tokens(&redacted); + let redacted = redact_secret_tokens(&redacted); + let mut sanitized = sanitize_prompt_context(&redacted); + let preserved_markers = [ + "", + "$PROJECT_ROOT", + "", + "[redacted-secret]", + ] + .into_iter() + .filter(|marker| redacted.contains(marker) && !sanitized.contains(marker)) + .collect::>(); + if !preserved_markers.is_empty() { + sanitized = format!("{} {}", preserved_markers.join(" "), sanitized.trim()); + } + truncate_agent_runtime_text(sanitized.trim(), max_chars) +} + pub(crate) fn redact_agent_runtime_project_paths( root: &Path, value: &str, max_chars: usize, ) -> String { + sanitize_agent_runtime_text( + &redact_agent_runtime_project_paths_raw(root, value), + max_chars, + ) +} + +fn redact_agent_runtime_project_paths_raw(root: &Path, value: &str) -> String { let mut redacted = value.to_string(); let root_display = root.to_string_lossy(); if !root_display.is_empty() { @@ -24885,7 +25980,7 @@ pub(crate) fn redact_agent_runtime_project_paths( redacted = redacted.replace(canonical_display.as_ref(), "$PROJECT_ROOT"); } } - sanitize_agent_runtime_text(&redacted, max_chars) + redacted } fn redact_agent_runtime_project_paths_preserving_tail( @@ -26134,64 +27229,37 @@ pub(crate) async fn request_game_creator_llm_text( } } -pub(crate) fn is_game_creator_agent_llm_transient_error(error: &platform_llm::LlmError) -> bool { - match error { - platform_llm::LlmError::Timeout { .. } - | platform_llm::LlmError::Connectivity { .. } - | platform_llm::LlmError::Transport(_) => true, - platform_llm::LlmError::Upstream { status_code, .. } => { - matches!(*status_code, 408 | 429 | 500..=599) - } - platform_llm::LlmError::InvalidConfig(_) - | platform_llm::LlmError::InvalidRequest(_) - | platform_llm::LlmError::StreamUnavailable - | platform_llm::LlmError::EmptyResponse - | platform_llm::LlmError::Deserialize(_) => false, - } +fn build_game_creator_agent_runtime_llm_client( + llm: &GameCreatorLlmConfig, + config_path: &str, +) -> Result { + let mut single_attempt = llm.clone(); + single_attempt.max_retries = 0; + build_game_creator_llm_client_from_llm_config(&single_attempt, config_path) } -async fn request_game_creator_agent_llm_text_retrying_recoverable( - client: &LlmClient, - llm: &GameCreatorLlmConfig, - request: LlmRunRequest, - operation: &str, - allow_stream: bool, -) -> Result { - const MAX_EMPTY_RETRIES: u32 = 3; - const MAX_TRANSIENT_RETRIES: u32 = 5; - const TRANSIENT_RETRY_BACKOFF_MS: u64 = 500; - let mut empty_retries = 0u32; - let mut transient_retries = 0u32; - loop { - let result = if allow_stream { - request_game_creator_llm_text(client, llm, request.clone()).await - } else { - client.run(request.clone()).await - }; - match result { - Ok(response) => return Ok(response), - Err(platform_llm::LlmError::EmptyResponse) if empty_retries < MAX_EMPTY_RETRIES => { - empty_retries += 1; - eprintln!( - "agent.runtime.llm.empty-response: {operation} 自动重试 {empty_retries}/{MAX_EMPTY_RETRIES}" - ); - } - Err(error) - if is_game_creator_agent_llm_transient_error(&error) - && transient_retries < MAX_TRANSIENT_RETRIES => - { - transient_retries += 1; - eprintln!( - "agent.runtime.llm.transient-error: {operation} 自动重试 {transient_retries}/{MAX_TRANSIENT_RETRIES}: {error}" - ); - tokio::time::sleep(Duration::from_millis( - TRANSIENT_RETRY_BACKOFF_MS.saturating_mul(u64::from(transient_retries)), - )) - .await; - } - Err(error) => return Err(error), +pub(crate) fn game_creator_agent_llm_error_public_summary( + error: &platform_llm::LlmError, +) -> String { + let kind = match error { + platform_llm::LlmError::Timeout { .. } => "timeout".to_string(), + platform_llm::LlmError::Connectivity { .. } => "connectivity".to_string(), + platform_llm::LlmError::Transport(_) => "transport".to_string(), + platform_llm::LlmError::Upstream { status_code, .. } => { + format!("upstream-{status_code}") } - } + platform_llm::LlmError::InvalidConfig(_) => "invalid-config".to_string(), + platform_llm::LlmError::InvalidRequest(_) => "invalid-request".to_string(), + platform_llm::LlmError::StreamUnavailable => "stream-unavailable".to_string(), + platform_llm::LlmError::EmptyResponse => "empty-response".to_string(), + platform_llm::LlmError::Deserialize(_) => "deserialize".to_string(), + }; + let raw = error.to_string(); + format!( + "kind={kind} fingerprint={:x} chars={}", + Sha256::digest(raw.as_bytes()), + raw.chars().count() + ) } pub(crate) async fn request_agent_group_briefs_with_client( @@ -29644,6 +30712,11 @@ pub(crate) fn sanitize_prompt_context(value: &str) -> String { || lower.contains("password=") || lower.contains("password:") || lower.contains("\"password\"") + || lower.contains("--password") + || lower.contains("--api-key") + || lower.contains("--apikey") + || lower.contains("--token") + || lower.contains("--secret") || lower.contains("secret=") || lower.contains("token=") || lower.contains("\"token\"") diff --git a/apps/ai-game-creator-shell/src-tauri/src/cli.rs b/apps/ai-game-creator-shell/src-tauri/src/cli.rs index f2304ad92..e6e40356b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/cli.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/cli.rs @@ -40,6 +40,7 @@ pub(crate) enum CliCommand { agent_id: String, session_id: String, run_id: String, + initialize: bool, }, AgentGoalEdit { project_path: PathBuf, @@ -142,11 +143,15 @@ impl CliCommand { project_path, initialize, .. + } + | Self::AgentGoalStart { + project_path, + initialize, + .. } => Some((project_path, *initialize)), Self::AgentChat { project_path, .. } | Self::AgentRuntimeStatus { project_path, .. } | Self::AgentGoalStatus { project_path, .. } - | Self::AgentGoalStart { project_path, .. } | Self::AgentGoalEdit { project_path, .. } | Self::AgentGoalPause { project_path, .. } | Self::AgentGoalResume { project_path, .. } @@ -385,19 +390,26 @@ pub(crate) fn parse_cli_command(args: &[String]) -> Result, S })); } if args.first().map(String::as_str) == Some("--agent-goal-start") { - const USAGE: &str = - "用法:--agent-goal-start <本地项目绝对路径> --stdin"; - if args.len() != 6 - || args.last().map(String::as_str) != Some("--stdin") - || args[1..5].iter().any(|value| value.trim().is_empty()) + const USAGE: &str = "用法:--agent-goal-start [--init] <本地项目绝对路径> --stdin"; + let mut rest = args[1..].to_vec(); + let initialize = if let Some(index) = rest.iter().position(|arg| arg == "--init") { + rest.remove(index); + true + } else { + false + }; + if rest.len() != 5 + || rest.last().map(String::as_str) != Some("--stdin") + || rest[..4].iter().any(|value| value.trim().is_empty()) { return Err(USAGE.to_string()); } return Ok(Some(CliCommand::AgentGoalStart { - project_path: PathBuf::from(&args[1]), - agent_id: args[2].trim().to_string(), - session_id: args[3].trim().to_string(), - run_id: args[4].trim().to_string(), + project_path: PathBuf::from(&rest[0]), + agent_id: rest[1].trim().to_string(), + session_id: rest[2].trim().to_string(), + run_id: rest[3].trim().to_string(), + initialize, })); } if args.first().map(String::as_str) == Some("--agent-goal-edit") { @@ -834,9 +846,26 @@ pub(crate) fn run_cli_command(command: CliCommand) -> Result<(), String> { agent_id, session_id, run_id, + initialize, } => { - let project_path = canonicalize_cli_path(&project_path, "本地项目路径", false)?; + let project_path = canonicalize_cli_path(&project_path, "本地项目路径", initialize)?; require_external_agent_runner_for_cli_runtime_write(&project_path)?; + if initialize && !project_path.join(".agent/manifest.json").is_file() { + let project_name = project_path + .file_name() + .and_then(|value| value.to_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("CLI Goal 项目"); + init_local_game_project_at( + &project_path, + &format!("cli-goal-{}", unix_millis()), + project_name, + )?; + } + if !project_path.join(".agent/manifest.json").is_file() { + return Err("项目尚未初始化;请先在 App 中创建项目,或显式传入 --init".to_string()); + } let payload = read_cli_agent_goal_payload(&mut std::io::stdin().lock())?; let result = start_game_creator_agent_goal( project_path.display().to_string(), @@ -1251,9 +1280,36 @@ mod tests { agent_id: "code-prototype".to_string(), session_id: "session-7".to_string(), run_id: "goal-run-9".to_string(), + initialize: false, } ); + let mut initialized_start = parse_cli_command(&[ + "--agent-goal-start".to_string(), + "--init".to_string(), + project_path.display().to_string(), + "code-prototype".to_string(), + "session-8".to_string(), + "goal-run-10".to_string(), + "--stdin".to_string(), + ]) + .expect("parse initialized goal start") + .expect("initialized goal start command"); + assert_eq!( + initialized_start, + CliCommand::AgentGoalStart { + project_path: project_path.clone(), + agent_id: "code-prototype".to_string(), + session_id: "session-8".to_string(), + run_id: "goal-run-10".to_string(), + initialize: true, + } + ); + let (_, initialize) = initialized_start + .project_path_mut() + .expect("initialized Goal start project path"); + assert!(initialize); + let edit = parse_cli_command(&[ "--agent-goal-edit".to_string(), project_path.display().to_string(), 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 86975d0ae..744b3df43 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project.rs @@ -79,16 +79,193 @@ pub(crate) fn init_local_game_project_at( const AGENT_DB_MAX_RECORD_BYTES: usize = 1024 * 1024; const AGENT_DB_ACTION_RECEIPT_RECORD_TYPE: &str = "agent.runtime.action_receipt"; +const AGENT_DB_PROVIDER_REQUEST_LIFECYCLE_RECORD_TYPE: &str = + "agent.runtime.provider_request.lifecycle"; +const AGENT_DB_FINALIZATION_LIFECYCLE_RECORD_TYPE: &str = "agent.runtime.finalization.lifecycle"; +const AGENT_DB_PROVIDER_REQUEST_LIFECYCLE_SCHEMA_VERSION: &str = + "game-creator-provider-request-lifecycle.v1"; +const AGENT_DB_FINALIZATION_LIFECYCLE_SCHEMA_VERSION: &str = + "game-creator-finalization-lifecycle.v1"; +const AGENT_DB_FINALIZATION_JOURNAL_SCHEMA_V1: &str = "game-creator-runtime-finalization.v1"; +const AGENT_DB_FINALIZATION_JOURNAL_SCHEMA_V2: &str = "game-creator-runtime-finalization.v2"; +const AGENT_DB_FINALIZATION_JOURNAL_SCHEMA_V3: &str = "game-creator-runtime-finalization.v3"; const AGENT_DB_MAX_ACTION_RECEIPT_SCAN_BYTES: u64 = 256 * 1024 * 1024; const AGENT_DB_MAX_SCAN_RECORDS: usize = 1_000_000; const AGENT_DB_TERMINAL_RESERVE_RECORDS: u64 = 64; const AGENT_DB_TERMINAL_RESERVE_BYTES: u64 = (AGENT_DB_MAX_RECORD_BYTES as u64 + 1) * AGENT_DB_TERMINAL_RESERVE_RECORDS; -const AGENT_DB_MAX_ORDINARY_APPEND_BYTES: u64 = - AGENT_DB_MAX_ACTION_RECEIPT_SCAN_BYTES - AGENT_DB_TERMINAL_RESERVE_BYTES; +const AGENT_DB_LIFECYCLE_TERMINAL_MAX_RECORD_BYTES: usize = 16 * 1024; +const AGENT_DB_FINALIZATION_CRITICAL_MAX_RECORD_BYTES: usize = 256 * 1024; +const AGENT_DB_LIFECYCLE_TERMINAL_RESERVE_RECORDS: u64 = 128; +const AGENT_DB_LIFECYCLE_TERMINAL_RESERVE_BYTES: u64 = + (AGENT_DB_FINALIZATION_CRITICAL_MAX_RECORD_BYTES as u64 + 1) + * AGENT_DB_LIFECYCLE_TERMINAL_RESERVE_RECORDS; +const AGENT_DB_MAX_ORDINARY_APPEND_BYTES: u64 = AGENT_DB_MAX_ACTION_RECEIPT_SCAN_BYTES + - AGENT_DB_TERMINAL_RESERVE_BYTES + - AGENT_DB_LIFECYCLE_TERMINAL_RESERVE_BYTES; +const AGENT_DB_MAX_ORDINARY_APPEND_RECORDS: usize = AGENT_DB_MAX_SCAN_RECORDS + - AGENT_DB_TERMINAL_RESERVE_RECORDS as usize + - AGENT_DB_LIFECYCLE_TERMINAL_RESERVE_RECORDS as usize; +const AGENT_DB_FINALIZATION_CRITICAL_RECORDS_PER_SEQUENCE: usize = 7; const AGENT_DB_MAX_BOUNDED_READ_BYTES: u64 = 32 * 1024 * 1024; const AGENT_DB_MAX_BOUNDED_RECORDS: usize = 16_384; +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum AgentDbRecordAppendClass { + Ordinary, + ActionTerminal, + LifecycleTerminal, + FinalizationCritical, +} + +fn agent_db_record_append_class(record: &serde_json::Value) -> AgentDbRecordAppendClass { + let record_type = record.get("recordType").and_then(serde_json::Value::as_str); + if record_type == Some(AGENT_DB_ACTION_RECEIPT_RECORD_TYPE) + || agent_db_record_uses_terminal_reserve(record) + { + return AgentDbRecordAppendClass::ActionTerminal; + } + if record_type == Some(AGENT_DB_FINALIZATION_LIFECYCLE_RECORD_TYPE) + || agent_db_record_is_finalization_assistant_audit(record) + || agent_db_record_is_finalization_completed_audit(record) + { + return AgentDbRecordAppendClass::FinalizationCritical; + } + if record_type == Some(AGENT_DB_PROVIDER_REQUEST_LIFECYCLE_RECORD_TYPE) + && record + .get("status") + .and_then(serde_json::Value::as_str) + .is_some_and(|status| matches!(status, "completed" | "failed" | "interrupted")) + { + return AgentDbRecordAppendClass::LifecycleTerminal; + } + AgentDbRecordAppendClass::Ordinary +} + +fn agent_db_record_has_finalization_audit_identity(record: &serde_json::Value) -> bool { + record + .get("finalizationId") + .and_then(serde_json::Value::as_str) + .is_some_and(is_valid_agent_db_finalization_id) + && record + .get("messageId") + .and_then(serde_json::Value::as_str) + .is_some_and(is_valid_agent_db_finalization_id) +} + +fn agent_db_record_has_exact_payload_fields( + record: &serde_json::Value, + expected_fields: &[&str], +) -> bool { + let Some(object) = record.as_object() else { + return false; + }; + let has_envelope = object.contains_key("schemaVersion") || object.contains_key("updatedAt"); + let expected_len = expected_fields.len() + usize::from(has_envelope) * 2; + if object.len() != expected_len + || !expected_fields + .iter() + .all(|field| object.contains_key(*field)) + { + return false; + } + !has_envelope + || (record + .get("schemaVersion") + .and_then(serde_json::Value::as_str) + == Some(GAME_CREATOR_AGENT_DB_SCHEMA_VERSION) + && record + .get("updatedAt") + .and_then(serde_json::Value::as_u64) + .is_some_and(|value| value > 0)) +} + +fn agent_db_record_is_finalization_assistant_audit(record: &serde_json::Value) -> bool { + const FIELDS: &[&str] = &[ + "recordType", + "agentId", + "sessionId", + "role", + "path", + "messageId", + "finalizationId", + ]; + let path = record + .get("path") + .and_then(serde_json::Value::as_str) + .unwrap_or_default(); + record.get("recordType").and_then(serde_json::Value::as_str) == Some("conversation.message") + && record.get("role").and_then(serde_json::Value::as_str) == Some("assistant") + && ["agentId", "sessionId"].into_iter().all(|field| { + record + .get(field) + .and_then(serde_json::Value::as_str) + .is_some_and(is_safe_agent_db_lifecycle_identity) + }) + && normalize_relative_path(path).is_ok_and(|normalized| { + normalized == path && path.starts_with(".agent/conversations/agents/") + }) + && agent_db_record_has_finalization_audit_identity(record) + && agent_db_record_has_exact_payload_fields(record, FIELDS) +} + +fn agent_db_record_is_finalization_completed_audit(record: &serde_json::Value) -> bool { + const FIELDS: &[&str] = &[ + "recordType", + "agentId", + "taskId", + "sessionId", + "runId", + "source", + "finalizationId", + "messageId", + "responseFingerprint", + "responseChars", + ]; + matches!( + record.get("recordType").and_then(serde_json::Value::as_str), + Some("agent.runtime.completed" | "agent.runtime.background_task.completed") + ) && ["agentId", "taskId", "sessionId", "runId", "source"] + .into_iter() + .all(|field| { + record + .get(field) + .and_then(serde_json::Value::as_str) + .is_some_and(is_safe_agent_db_lifecycle_identity) + }) + && agent_db_record_has_finalization_audit_identity(record) + && record + .get("responseFingerprint") + .and_then(serde_json::Value::as_str) + .is_some_and(is_valid_agent_db_sha256) + && record + .get("responseChars") + .and_then(serde_json::Value::as_u64) + .is_some_and(|value| value > 0) + && agent_db_record_has_exact_payload_fields(record, FIELDS) +} + +pub(crate) fn agent_db_stored_record_matches_expected_payload( + stored: &serde_json::Value, + expected: &serde_json::Value, +) -> bool { + let (Some(stored), Some(expected)) = (stored.as_object(), expected.as_object()) else { + return false; + }; + stored.len() == expected.len() + 2 + && stored + .get("schemaVersion") + .and_then(serde_json::Value::as_str) + == Some(GAME_CREATOR_AGENT_DB_SCHEMA_VERSION) + && stored + .get("updatedAt") + .and_then(serde_json::Value::as_u64) + .is_some_and(|value| value > 0) + && expected + .iter() + .all(|(field, value)| stored.get(field) == Some(value)) +} + fn serialize_agent_db_record(mut record: serde_json::Value) -> Result { let object = record .as_object_mut() @@ -847,10 +1024,15 @@ fn verify_agent_db_storage_current(_storage: &AgentDbStorage) -> Result<(), Stri } pub(crate) fn append_agent_db_record(root: &Path, record: serde_json::Value) -> Result<(), String> { - if record.get("recordType").and_then(serde_json::Value::as_str) - == Some(AGENT_DB_ACTION_RECEIPT_RECORD_TYPE) - { - return Err("Agent 持久动作回执必须使用幂等终态 receipt 追加入口".to_string()); + match record.get("recordType").and_then(serde_json::Value::as_str) { + Some(AGENT_DB_ACTION_RECEIPT_RECORD_TYPE) => { + return Err("Agent 持久动作回执必须使用幂等终态 receipt 追加入口".to_string()) + } + Some( + AGENT_DB_PROVIDER_REQUEST_LIFECYCLE_RECORD_TYPE + | AGENT_DB_FINALIZATION_LIFECYCLE_RECORD_TYPE, + ) => return Err("Agent DB lifecycle 记录必须使用专用幂等追加入口".to_string()), + _ => {} } append_agent_db_record_internal(root, record) } @@ -882,7 +1064,7 @@ fn append_agent_db_record_internal(root: &Path, record: serde_json::Value) -> Re root, record.get("recordType").and_then(serde_json::Value::as_str), )?; - let uses_terminal_reserve = agent_db_record_uses_terminal_reserve(&record); + let append_class = agent_db_record_append_class(&record); let path = root.join(".agent/agent.db"); let directory = open_agent_db_directory(root, true)? .ok_or_else(|| "创建项目 .agent 目录失败".to_string())?; @@ -895,11 +1077,8 @@ fn append_agent_db_record_internal(root: &Path, record: serde_json::Value) -> Re repair_truncated_jsonl_tail_unlocked(&mut storage.file, &storage.path, "Agent 本地索引")?; verify_agent_db_storage_current(&storage)?; let line = serialize_agent_db_record(record)?; - if uses_terminal_reserve { - append_agent_db_terminal_line_unlocked(&mut storage, &line) - } else { - append_agent_db_line_unlocked(&mut storage, &line) - } + validate_agent_db_append_class_record_size(append_class, &line)?; + append_agent_db_classified_line_unlocked(&mut storage, &line, append_class) } #[cfg(test)] @@ -915,26 +1094,450 @@ fn agent_db_record_uses_terminal_reserve(record: &serde_json::Value) -> bool { .get("actionId") .and_then(serde_json::Value::as_str) .is_some_and(|action_id| !action_id.trim().is_empty()); - if !has_action_id { - return false; - } match record.get("recordType").and_then(serde_json::Value::as_str) { - Some("agent.runtime.tool_observation") => record + Some("agent.runtime.tool_observation") if has_action_id => record .get("status") .and_then(serde_json::Value::as_str) .is_some_and(is_terminal_agent_db_action_status), - Some("agent.runtime.tool_action.observed") => record + Some("agent.runtime.tool_action.observed") if has_action_id => record .get("observationStatus") .and_then(serde_json::Value::as_str) .is_some_and(is_terminal_agent_db_action_status), Some( "agent.runtime.tool_action.needs_reconciliation" | "agent.runtime.tool_confirmation.needs_reconciliation", - ) => true, + ) if has_action_id => true, _ => false, } } +fn validate_agent_db_append_class_record_size( + append_class: AgentDbRecordAppendClass, + line: &str, +) -> Result<(), String> { + let record_type = serde_json::from_str::(line) + .ok() + .and_then(|record| { + record + .get("recordType") + .and_then(serde_json::Value::as_str) + .map(str::to_string) + }); + if matches!( + record_type.as_deref(), + Some( + AGENT_DB_PROVIDER_REQUEST_LIFECYCLE_RECORD_TYPE + | AGENT_DB_FINALIZATION_LIFECYCLE_RECORD_TYPE + ) + ) && line.len() > AGENT_DB_LIFECYCLE_TERMINAL_MAX_RECORD_BYTES + { + return Err(format!( + "Agent DB lifecycle 单条记录超过 {} 字节上限", + AGENT_DB_LIFECYCLE_TERMINAL_MAX_RECORD_BYTES + )); + } + if append_class == AgentDbRecordAppendClass::FinalizationCritical + && line.len() > AGENT_DB_FINALIZATION_CRITICAL_MAX_RECORD_BYTES + { + return Err(format!( + "Agent DB finalization-critical 单条记录超过 {} 字节上限", + AGENT_DB_FINALIZATION_CRITICAL_MAX_RECORD_BYTES + )); + } + Ok(()) +} + +pub(crate) fn append_agent_db_lifecycle_record_idempotent( + root: &Path, + identity_field: &str, + identity_value: &str, + transition_field: &str, + transition_value: &str, + record: serde_json::Value, +) -> Result { + let record_type = validate_agent_db_lifecycle_record_input( + identity_field, + identity_value, + transition_field, + transition_value, + &record, + )?; + #[cfg(test)] + take_agent_db_record_failure_injection(root, Some(record_type))?; + + let append_class = agent_db_record_append_class(&record); + let path = root.join(".agent/agent.db"); + let directory = open_agent_db_directory(root, true)? + .ok_or_else(|| "创建项目 .agent 目录失败".to_string())?; + let append_lock = project_append_lock_for(&path)?; + let _append_guard = append_lock.lock_process("Agent 本地索引")?; + verify_agent_db_directory_current(&directory)?; + let mut storage = open_agent_db_storage(directory, true, true)? + .ok_or_else(|| "创建 Agent 本地索引失败".to_string())?; + verify_agent_db_storage_current(&storage)?; + repair_truncated_jsonl_tail_unlocked(&mut storage.file, &storage.path, "Agent 本地索引")?; + verify_agent_db_storage_current(&storage)?; + let found_existing = validate_agent_db_lifecycle_records_unlocked( + &mut storage.file, + &storage.path, + record_type, + identity_field, + identity_value, + transition_field, + transition_value, + &record, + )?; + if found_existing { + return Ok(false); + } + let line = serialize_agent_db_record(record)?; + validate_agent_db_append_class_record_size(append_class, &line)?; + append_agent_db_classified_line_unlocked(&mut storage, &line, append_class)?; + Ok(true) +} + +fn validate_agent_db_lifecycle_record_input<'a>( + identity_field: &str, + identity_value: &str, + transition_field: &str, + transition_value: &str, + record: &'a serde_json::Value, +) -> Result<&'a str, String> { + let record_type = record + .get("recordType") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| "Agent DB lifecycle 记录缺少 recordType".to_string())?; + validate_agent_db_lifecycle_record_semantics(record_type, record, false)?; + let valid_shape = match record_type { + AGENT_DB_PROVIDER_REQUEST_LIFECYCLE_RECORD_TYPE => { + identity_field == "requestId" + && transition_field == "status" + && matches!( + transition_value, + "started" | "completed" | "failed" | "interrupted" + ) + } + AGENT_DB_FINALIZATION_LIFECYCLE_RECORD_TYPE => { + identity_field == "finalizationId" + && transition_field == "stage" + && matches!( + transition_value, + "prepared" | "assistant-persisted" | "runtime-completed" | "goal-completed" + ) + } + _ => false, + }; + if !valid_shape + || identity_value.trim().is_empty() + || record + .get(identity_field) + .and_then(serde_json::Value::as_str) + != Some(identity_value) + || record + .get(transition_field) + .and_then(serde_json::Value::as_str) + != Some(transition_value) + { + return Err("Agent DB lifecycle 幂等记录身份或阶段不匹配".to_string()); + } + Ok(record_type) +} + +fn validate_agent_db_lifecycle_record_semantics( + record_type: &str, + record: &serde_json::Value, + stored: bool, +) -> Result<(), String> { + validate_agent_db_lifecycle_record_fields(record_type, record, stored)?; + for field in ["agentId", "taskId", "sessionId", "runId", "source"] { + let value = record + .get(field) + .and_then(serde_json::Value::as_str) + .ok_or_else(|| format!("Agent DB lifecycle 记录缺少合法字段:{field}"))?; + if !is_safe_agent_db_lifecycle_identity(value) { + return Err(format!("Agent DB lifecycle 记录字段安全形状无效:{field}")); + } + } + if stored + && (record + .get("schemaVersion") + .and_then(serde_json::Value::as_str) + != Some(GAME_CREATOR_AGENT_DB_SCHEMA_VERSION) + || record + .get("updatedAt") + .and_then(serde_json::Value::as_u64) + .is_none_or(|value| value == 0)) + { + return Err("Agent DB lifecycle 记录持久化 envelope 无效".to_string()); + } + + match record_type { + AGENT_DB_PROVIDER_REQUEST_LIFECYCLE_RECORD_TYPE => { + if record + .get("auditSchemaVersion") + .and_then(serde_json::Value::as_str) + != Some(AGENT_DB_PROVIDER_REQUEST_LIFECYCLE_SCHEMA_VERSION) + { + return Err("Agent DB Provider lifecycle audit schema 无效".to_string()); + } + let request_id = record + .get("requestId") + .and_then(serde_json::Value::as_str) + .unwrap_or_default(); + if !is_valid_agent_db_provider_request_id(request_id) { + return Err("Agent DB Provider lifecycle requestId 安全形状无效".to_string()); + } + if !matches!( + record + .get("requestKind") + .and_then(serde_json::Value::as_str), + Some("tool-plan" | "final-reply") + ) { + return Err("Agent DB Provider lifecycle requestKind 无效".to_string()); + } + let request_slot = record + .get("requestSlot") + .and_then(serde_json::Value::as_str) + .unwrap_or_default(); + if !is_safe_agent_db_lifecycle_identity(request_slot) { + return Err("Agent DB Provider lifecycle requestSlot 安全形状无效".to_string()); + } + let status = record + .get("status") + .and_then(serde_json::Value::as_str) + .unwrap_or_default(); + if !matches!(status, "started" | "completed" | "failed" | "interrupted") { + return Err(format!("Agent DB Provider lifecycle 阶段无效:{status}")); + } + } + AGENT_DB_FINALIZATION_LIFECYCLE_RECORD_TYPE => { + validate_agent_db_finalization_lifecycle_semantics(record)?; + } + _ => return Err("Agent DB lifecycle recordType 不受支持".to_string()), + } + Ok(()) +} + +fn validate_agent_db_lifecycle_record_fields( + record_type: &str, + record: &serde_json::Value, + stored: bool, +) -> Result<(), String> { + const PROVIDER_FIELDS: &[&str] = &[ + "recordType", + "auditSchemaVersion", + "agentId", + "taskId", + "sessionId", + "runId", + "source", + "requestId", + "requestKind", + "requestSlot", + "status", + ]; + const FINALIZATION_FIELDS: &[&str] = &[ + "recordType", + "auditSchemaVersion", + "journalSchemaVersion", + "agentId", + "taskId", + "sessionId", + "runId", + "source", + "finalizationId", + "messageId", + "stage", + "stageOrdinal", + "previousStage", + "goalId", + "goalRevision", + "goalSnapshotFingerprint", + "planRevision", + "planSnapshotFingerprint", + "responseFingerprint", + "responseChars", + "conversationPath", + "stageAt", + ]; + let expected = match record_type { + AGENT_DB_PROVIDER_REQUEST_LIFECYCLE_RECORD_TYPE => PROVIDER_FIELDS, + AGENT_DB_FINALIZATION_LIFECYCLE_RECORD_TYPE => FINALIZATION_FIELDS, + _ => return Err("Agent DB lifecycle recordType 不受支持".to_string()), + }; + let object = record + .as_object() + .ok_or_else(|| "Agent DB lifecycle 记录必须是 object".to_string())?; + let expected_len = expected.len().saturating_add(if stored { 2 } else { 0 }); + let has_expected_fields = expected.iter().all(|field| object.contains_key(*field)); + let has_envelope = + !stored || (object.contains_key("schemaVersion") && object.contains_key("updatedAt")); + if object.len() != expected_len || !has_expected_fields || !has_envelope { + return Err("Agent DB lifecycle 记录字段集合无效".to_string()); + } + Ok(()) +} + +fn validate_agent_db_finalization_lifecycle_semantics( + record: &serde_json::Value, +) -> Result<(), String> { + if record + .get("auditSchemaVersion") + .and_then(serde_json::Value::as_str) + != Some(AGENT_DB_FINALIZATION_LIFECYCLE_SCHEMA_VERSION) + { + return Err("Agent DB finalization lifecycle audit schema 无效".to_string()); + } + let journal_schema = record + .get("journalSchemaVersion") + .and_then(serde_json::Value::as_str) + .unwrap_or_default(); + if !matches!( + journal_schema, + AGENT_DB_FINALIZATION_JOURNAL_SCHEMA_V1 + | AGENT_DB_FINALIZATION_JOURNAL_SCHEMA_V2 + | AGENT_DB_FINALIZATION_JOURNAL_SCHEMA_V3 + ) { + return Err("Agent DB finalization lifecycle journal schema 无效".to_string()); + } + for field in ["finalizationId", "messageId"] { + if !record + .get(field) + .and_then(serde_json::Value::as_str) + .is_some_and(is_valid_agent_db_finalization_id) + { + return Err(format!( + "Agent DB finalization lifecycle {field} 安全形状无效" + )); + } + } + if !record + .get("responseFingerprint") + .and_then(serde_json::Value::as_str) + .is_some_and(is_valid_agent_db_sha256) + { + return Err("Agent DB finalization lifecycle responseFingerprint 无效".to_string()); + } + if record + .get("responseChars") + .and_then(serde_json::Value::as_u64) + .is_none_or(|value| value == 0) + { + return Err("Agent DB finalization lifecycle responseChars 无效".to_string()); + } + let conversation_path = record + .get("conversationPath") + .and_then(serde_json::Value::as_str) + .unwrap_or_default(); + if !normalize_relative_path(conversation_path).is_ok_and(|normalized| { + normalized == conversation_path + && conversation_path.starts_with(".agent/conversations/agents/") + }) { + return Err("Agent DB finalization lifecycle conversationPath 无效".to_string()); + } + let plan_revision = record + .get("planRevision") + .and_then(serde_json::Value::as_u64) + .ok_or_else(|| "Agent DB finalization lifecycle planRevision 无效".to_string())?; + let plan_fingerprint = record + .get("planSnapshotFingerprint") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| { + "Agent DB finalization lifecycle planSnapshotFingerprint 无效".to_string() + })?; + if journal_schema == AGENT_DB_FINALIZATION_JOURNAL_SCHEMA_V1 { + if plan_revision != 0 || !plan_fingerprint.is_empty() { + return Err("Agent DB v1 finalization lifecycle 不能携带计划快照".to_string()); + } + } else if !is_valid_agent_db_sha256(plan_fingerprint) { + return Err("Agent DB finalization lifecycle planSnapshotFingerprint 无效".to_string()); + } + + let goal_id = record.get("goalId").and_then(serde_json::Value::as_str); + let goal_revision = record + .get("goalRevision") + .and_then(serde_json::Value::as_u64) + .ok_or_else(|| "Agent DB finalization lifecycle goalRevision 无效".to_string())?; + let goal_fingerprint = record + .get("goalSnapshotFingerprint") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| { + "Agent DB finalization lifecycle goalSnapshotFingerprint 无效".to_string() + })?; + if journal_schema != AGENT_DB_FINALIZATION_JOURNAL_SCHEMA_V3 { + if !record.get("goalId").is_some_and(serde_json::Value::is_null) + || goal_revision != 0 + || !goal_fingerprint.is_empty() + { + return Err("Agent DB 旧版 finalization lifecycle 不能携带 Goal 快照".to_string()); + } + } else if let Some(goal_id) = goal_id { + if !is_safe_agent_db_lifecycle_identity(goal_id) + || goal_revision == 0 + || !is_valid_agent_db_sha256(goal_fingerprint) + { + return Err("Agent DB finalization lifecycle Goal 快照无效".to_string()); + } + } else if !record.get("goalId").is_some_and(serde_json::Value::is_null) + || goal_revision != 0 + || !goal_fingerprint.is_empty() + { + return Err("Agent DB 无 Goal finalization lifecycle 快照无效".to_string()); + } + + let stage = record + .get("stage") + .and_then(serde_json::Value::as_str) + .unwrap_or_default(); + validate_agent_db_lifecycle_transition_metadata( + AGENT_DB_FINALIZATION_LIFECYCLE_RECORD_TYPE, + stage, + record, + )?; + if record + .get("stageAt") + .and_then(serde_json::Value::as_u64) + .is_none_or(|value| value == 0) + { + return Err("Agent DB finalization lifecycle stageAt 无效".to_string()); + } + Ok(()) +} + +fn is_safe_agent_db_lifecycle_identity(value: &str) -> bool { + !value.is_empty() + && value.len() <= 256 + && value.trim() == value + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b':')) +} + +fn is_valid_agent_db_lower_hex(value: &str, length: usize) -> bool { + value.len() == length + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + +fn is_valid_agent_db_prefixed_hex(value: &str, prefix: &str, length: usize) -> bool { + value + .strip_prefix(prefix) + .is_some_and(|suffix| is_valid_agent_db_lower_hex(suffix, length)) +} + +fn is_valid_agent_db_provider_request_id(value: &str) -> bool { + is_valid_agent_db_prefixed_hex(value, "provider-request-", 64) +} + +fn is_valid_agent_db_finalization_id(value: &str) -> bool { + is_valid_agent_db_prefixed_hex(value, "agent-finalization-", 32) +} + +fn is_valid_agent_db_sha256(value: &str) -> bool { + is_valid_agent_db_lower_hex(value, 64) +} + fn is_terminal_agent_db_action_status(status: &str) -> bool { matches!( status, @@ -1119,8 +1722,9 @@ where if found_existing { return Ok(false); } + let append_class = agent_db_record_append_class(&record); let line = serialize_agent_db_record(record)?; - append_agent_db_terminal_line_unlocked(&mut storage, &line)?; + append_agent_db_classified_line_unlocked(&mut storage, &line, append_class)?; Ok(true) } @@ -1428,6 +2032,426 @@ fn validate_agent_db_action_records_unlocked( Ok(found) } +fn validate_agent_db_lifecycle_records_unlocked( + file: &mut File, + path: &Path, + record_type: &str, + identity_field: &str, + identity_value: &str, + transition_field: &str, + transition_value: &str, + expected: &serde_json::Value, +) -> Result { + let (expected_identity_field, expected_transition_field) = + agent_db_lifecycle_key_fields(record_type)?; + if identity_field != expected_identity_field || transition_field != expected_transition_field { + return Err("Agent DB lifecycle 幂等记录身份或阶段字段不匹配".to_string()); + } + let scan = scan_agent_db_lifecycle_records_unlocked(file, path, record_type)?; + let sequence = scan.sequences.get(identity_value); + if let Some(sequence) = sequence { + validate_agent_db_lifecycle_record_identity( + &sequence.identity_record, + expected, + record_type, + )?; + } + let target_exists = sequence.is_some_and(|sequence| { + sequence + .transition_counts + .get(transition_value) + .copied() + .unwrap_or(0) + == 1 + }); + validate_agent_db_lifecycle_append_transition( + record_type, + transition_value, + sequence.map(|sequence| sequence.transitions_in_physical_order.as_slice()), + target_exists, + )?; + if !target_exists && scan.record_count >= AGENT_DB_MAX_SCAN_RECORDS { + return Err(format!( + "Agent 本地索引已达到 {} 条记录扫描上限,无法追加 lifecycle 记录:{}", + AGENT_DB_MAX_SCAN_RECORDS, + path.display() + )); + } + Ok(target_exists) +} + +struct AgentDbLifecycleSequence { + identity_record: serde_json::Value, + transition_counts: BTreeMap, + transitions_in_physical_order: Vec, +} + +struct AgentDbLifecycleScan { + record_count: usize, + sequences: BTreeMap, +} + +fn scan_agent_db_lifecycle_records_unlocked( + file: &mut File, + path: &Path, + record_type: &str, +) -> Result { + let (identity_field, transition_field) = agent_db_lifecycle_key_fields(record_type)?; + let length = file + .metadata() + .map_err(|error| format!("读取 Agent 本地索引元数据失败:{}: {error}", path.display()))? + .len(); + if length > AGENT_DB_MAX_ACTION_RECEIPT_SCAN_BYTES { + return Err(format!( + "Agent 本地索引超过 {} 字节扫描上限:{}", + AGENT_DB_MAX_ACTION_RECEIPT_SCAN_BYTES, + path.display() + )); + } + file.seek(SeekFrom::Start(0)) + .map_err(|error| format!("定位 Agent 本地索引失败:{}: {error}", path.display()))?; + let mut reader = BufReader::new(file); + let mut record_count = 0usize; + let mut sequences = BTreeMap::::new(); + while let Some(line) = read_agent_db_jsonl_line_bounded(&mut reader, path)? { + if !line.complete { + return Err(format!( + "Agent DB lifecycle 全量扫描发现不完整 JSONL 尾记录:{}", + path.display() + )); + } + if line.content.iter().all(|byte| byte.is_ascii_whitespace()) { + continue; + } + record_count = record_count.saturating_add(1); + if record_count > AGENT_DB_MAX_SCAN_RECORDS { + return Err(format!( + "Agent 本地索引超过 {} 条记录扫描上限:{}", + AGENT_DB_MAX_SCAN_RECORDS, + path.display() + )); + } + let record = serde_json::from_slice::(&line.content) + .map_err(|error| format!("解析 Agent 本地索引失败:{}: {error}", path.display()))?; + if record.get("recordType").and_then(serde_json::Value::as_str) == Some(record_type) { + validate_agent_db_lifecycle_record_semantics(record_type, &record, true)?; + let identity_value = record + .get(identity_field) + .and_then(serde_json::Value::as_str) + .ok_or_else(|| "Agent DB lifecycle 既有记录缺少身份".to_string())?; + let transition = record + .get(transition_field) + .and_then(serde_json::Value::as_str) + .ok_or_else(|| "Agent DB lifecycle 既有记录缺少阶段".to_string())?; + let sequence = sequences + .entry(identity_value.to_string()) + .or_insert_with(|| AgentDbLifecycleSequence { + identity_record: record.clone(), + transition_counts: BTreeMap::new(), + transitions_in_physical_order: Vec::new(), + }); + validate_agent_db_lifecycle_record_identity( + &sequence.identity_record, + &record, + record_type, + )?; + let count = sequence + .transition_counts + .entry(transition.to_string()) + .or_default(); + *count = count.saturating_add(1); + sequence + .transitions_in_physical_order + .push(transition.to_string()); + } + } + for (identity, sequence) in &sequences { + if let Some((transition, _)) = sequence + .transition_counts + .iter() + .find(|(_, count)| **count > 1) + { + return Err(format!( + "Agent DB lifecycle 同一身份阶段记录重复:{record_type}/{identity}/{transition}" + )); + } + validate_agent_db_lifecycle_existing_sequence( + record_type, + &sequence.transitions_in_physical_order, + )?; + } + Ok(AgentDbLifecycleScan { + record_count, + sequences, + }) +} + +fn agent_db_lifecycle_key_fields( + record_type: &str, +) -> Result<(&'static str, &'static str), String> { + match record_type { + AGENT_DB_PROVIDER_REQUEST_LIFECYCLE_RECORD_TYPE => Ok(("requestId", "status")), + AGENT_DB_FINALIZATION_LIFECYCLE_RECORD_TYPE => Ok(("finalizationId", "stage")), + _ => Err("Agent DB lifecycle recordType 不受支持".to_string()), + } +} + +pub(crate) fn read_agent_db_lifecycle_transitions_at( + root: &Path, + record_type: &str, + identity_field: &str, + identity_value: &str, +) -> Result, String> { + let (expected_identity_field, _) = agent_db_lifecycle_key_fields(record_type)?; + if identity_field != expected_identity_field + || (record_type == AGENT_DB_PROVIDER_REQUEST_LIFECYCLE_RECORD_TYPE + && !is_valid_agent_db_provider_request_id(identity_value)) + || (record_type == AGENT_DB_FINALIZATION_LIFECYCLE_RECORD_TYPE + && !is_valid_agent_db_finalization_id(identity_value)) + { + return Err("Agent DB lifecycle 查询身份或 recordType 不受支持".to_string()); + } + let path = root.join(".agent/agent.db"); + let Some(directory) = open_agent_db_directory(root, false)? else { + return Ok(Vec::new()); + }; + let append_lock = project_append_lock_for(&path)?; + let _append_guard = append_lock.lock_process("Agent 本地索引 lifecycle 查询")?; + verify_agent_db_directory_current(&directory)?; + let Some(mut storage) = open_agent_db_storage(directory, false, false)? else { + return Ok(Vec::new()); + }; + verify_agent_db_storage_current(&storage)?; + let scan = + scan_agent_db_lifecycle_records_unlocked(&mut storage.file, &storage.path, record_type)?; + verify_agent_db_storage_current(&storage)?; + Ok(scan + .sequences + .get(identity_value) + .map(|sequence| sequence.transitions_in_physical_order.clone()) + .unwrap_or_default()) +} + +pub(crate) fn read_agent_db_incomplete_provider_request_ids_at( + root: &Path, + agent_id: &str, + run_id: &str, +) -> Result, String> { + if !is_safe_agent_db_lifecycle_identity(agent_id) + || !is_safe_agent_db_lifecycle_identity(run_id) + { + return Err("Agent DB Provider lifecycle 查询 Agent/run 身份无效".to_string()); + } + let path = root.join(".agent/agent.db"); + let Some(directory) = open_agent_db_directory(root, false)? else { + return Ok(Vec::new()); + }; + let append_lock = project_append_lock_for(&path)?; + let _append_guard = append_lock.lock_process("Agent 本地索引 Provider lifecycle 查询")?; + verify_agent_db_directory_current(&directory)?; + let Some(mut storage) = open_agent_db_storage(directory, false, false)? else { + return Ok(Vec::new()); + }; + verify_agent_db_storage_current(&storage)?; + let scan = scan_agent_db_lifecycle_records_unlocked( + &mut storage.file, + &storage.path, + AGENT_DB_PROVIDER_REQUEST_LIFECYCLE_RECORD_TYPE, + )?; + verify_agent_db_storage_current(&storage)?; + Ok(scan + .sequences + .into_iter() + .filter(|(_, sequence)| { + sequence.transitions_in_physical_order.len() == 1 + && sequence.transitions_in_physical_order[0] == "started" + && agent_db_record_string_equals(&sequence.identity_record, "agentId", agent_id) + && agent_db_record_string_equals(&sequence.identity_record, "runId", run_id) + }) + .map(|(request_id, _)| request_id) + .collect()) +} + +fn validate_agent_db_lifecycle_record_identity( + existing: &serde_json::Value, + expected: &serde_json::Value, + record_type: &str, +) -> Result<(), String> { + let identity_fields: &[&str] = match record_type { + AGENT_DB_PROVIDER_REQUEST_LIFECYCLE_RECORD_TYPE => &[ + "recordType", + "auditSchemaVersion", + "agentId", + "taskId", + "sessionId", + "runId", + "source", + "requestId", + "requestKind", + "requestSlot", + ], + AGENT_DB_FINALIZATION_LIFECYCLE_RECORD_TYPE => &[ + "recordType", + "auditSchemaVersion", + "journalSchemaVersion", + "agentId", + "taskId", + "sessionId", + "runId", + "source", + "finalizationId", + "messageId", + "goalId", + "goalRevision", + "goalSnapshotFingerprint", + "planRevision", + "planSnapshotFingerprint", + "responseFingerprint", + ], + _ => return Err("Agent DB lifecycle recordType 不受支持".to_string()), + }; + if identity_fields + .iter() + .all(|field| existing.get(*field) == expected.get(*field)) + { + Ok(()) + } else { + Err("Agent DB lifecycle 同一身份阶段记录内容冲突".to_string()) + } +} + +fn validate_agent_db_lifecycle_transition_metadata( + record_type: &str, + transition: &str, + record: &serde_json::Value, +) -> Result<(), String> { + if record_type == AGENT_DB_PROVIDER_REQUEST_LIFECYCLE_RECORD_TYPE { + if matches!( + transition, + "started" | "completed" | "failed" | "interrupted" + ) { + return Ok(()); + } + return Err(format!( + "Agent DB Provider lifecycle 阶段无效:{transition}" + )); + } + let (ordinal, previous) = match transition { + "prepared" => (1_u64, None), + "assistant-persisted" => (2, Some("prepared")), + "runtime-completed" => (3, Some("assistant-persisted")), + "goal-completed" => (4, Some("runtime-completed")), + _ => { + return Err(format!( + "Agent DB finalization lifecycle 阶段无效:{transition}" + )) + } + }; + let actual_previous = record + .get("previousStage") + .and_then(serde_json::Value::as_str); + if record + .get("stageOrdinal") + .and_then(serde_json::Value::as_u64) + != Some(ordinal) + || actual_previous != previous + || (previous.is_none() + && !record + .get("previousStage") + .is_some_and(serde_json::Value::is_null)) + { + return Err(format!( + "Agent DB finalization lifecycle 阶段元数据冲突:{transition}" + )); + } + Ok(()) +} + +fn validate_agent_db_lifecycle_existing_sequence( + record_type: &str, + transitions_in_physical_order: &[String], +) -> Result<(), String> { + if record_type == AGENT_DB_PROVIDER_REQUEST_LIFECYCLE_RECORD_TYPE { + if transitions_in_physical_order.first().map(String::as_str) != Some("started") { + return Err("Agent DB Provider lifecycle 缺少 started 前序阶段".to_string()); + } + if transitions_in_physical_order.len() > 2 { + return Err("Agent DB Provider lifecycle 存在多个终态".to_string()); + } + if transitions_in_physical_order.get(1).is_some_and(|status| { + !matches!(status.as_str(), "completed" | "failed" | "interrupted") + }) { + return Err("Agent DB Provider lifecycle JSONL 物理顺序倒置".to_string()); + } + return Ok(()); + } + + let stages = [ + "prepared", + "assistant-persisted", + "runtime-completed", + "goal-completed", + ]; + if transitions_in_physical_order.is_empty() + || transitions_in_physical_order.len() > stages.len() + || transitions_in_physical_order + .iter() + .enumerate() + .any(|(index, stage)| stages.get(index).copied() != Some(stage.as_str())) + { + return Err("Agent DB finalization lifecycle JSONL 物理顺序倒置".to_string()); + } + Ok(()) +} + +fn validate_agent_db_lifecycle_append_transition( + record_type: &str, + transition_value: &str, + existing: Option<&[String]>, + target_exists: bool, +) -> Result<(), String> { + if target_exists { + return Ok(()); + } + let existing = existing.unwrap_or_default(); + if record_type == AGENT_DB_PROVIDER_REQUEST_LIFECYCLE_RECORD_TYPE { + return match existing { + [] if transition_value == "started" => Ok(()), + [] => Err("Agent DB Provider lifecycle 缺少 started 前序阶段".to_string()), + [started] + if started == "started" + && matches!(transition_value, "completed" | "failed" | "interrupted") => + { + Ok(()) + } + [_started, _terminal] => { + Err("Agent DB Provider lifecycle 终态冲突或缺少 started".to_string()) + } + _ => Err("Agent DB Provider lifecycle JSONL 物理顺序倒置".to_string()), + }; + } + + let stages = [ + "prepared", + "assistant-persisted", + "runtime-completed", + "goal-completed", + ]; + let expected = stages.get(existing.len()).copied(); + if expected == Some(transition_value) { + return Ok(()); + } + let target_index = stages + .iter() + .position(|stage| *stage == transition_value) + .ok_or_else(|| "Agent DB finalization lifecycle 目标阶段无效".to_string())?; + if target_index > existing.len() { + Err("Agent DB finalization lifecycle 缺少前序阶段".to_string()) + } else { + Err("Agent DB finalization lifecycle 阶段顺序倒置".to_string()) + } +} + fn validate_agent_db_action_record_identity( existing: &serde_json::Value, expected: &serde_json::Value, @@ -1655,92 +2679,575 @@ fn append_jsonl_line_unlocked(path: &Path, line: &str, error_label: &str) -> Res .map_err(|error| format!("写入{error_label}失败:{}: {error}", path.display())) } -fn append_agent_db_line_unlocked(storage: &mut AgentDbStorage, line: &str) -> Result<(), String> { - ensure_agent_db_record_capacity_unlocked( - &mut storage.file, - &storage.path, - AGENT_DB_MAX_SCAN_RECORDS.saturating_sub( - usize::try_from(AGENT_DB_TERMINAL_RESERVE_RECORDS).unwrap_or(usize::MAX), - ), - "普通审计记录软上限", - )?; - append_agent_db_line_with_capacity_unlocked( - storage, - line, - AGENT_DB_MAX_ORDINARY_APPEND_BYTES, - "普通审计追加软上限", - ) +const AGENT_DB_FINALIZATION_SLOT_PREPARED: u8 = 0; +const AGENT_DB_FINALIZATION_SLOT_ASSISTANT_AUDIT: u8 = 1; +const AGENT_DB_FINALIZATION_SLOT_ASSISTANT_PERSISTED: u8 = 2; +const AGENT_DB_FINALIZATION_SLOT_RUNTIME_COMPLETED: u8 = 3; +const AGENT_DB_FINALIZATION_SLOT_GOAL_COMPLETED: u8 = 4; +const AGENT_DB_FINALIZATION_SLOT_COMPLETED_AUDIT: u8 = 5; +const AGENT_DB_FINALIZATION_SLOT_BACKGROUND_COMPLETED_AUDIT: u8 = 6; + +#[derive(Debug)] +struct AgentDbFinalizationCapacityReservation { + finalization_id: String, + agent_id: String, + task_id: String, + session_id: String, + run_id: String, + source: String, + message_id: String, + journal_schema_version: String, + goal_id: Option, + goal_revision: u64, + goal_snapshot_fingerprint: String, + plan_revision: u64, + plan_snapshot_fingerprint: String, + response_fingerprint: String, + response_chars: u64, + conversation_path: String, + seen_slots: u8, + tail_records: usize, + tail_bytes: u64, } -fn append_agent_db_terminal_line_unlocked( - storage: &mut AgentDbStorage, - line: &str, -) -> Result<(), String> { - ensure_agent_db_record_capacity_unlocked( - &mut storage.file, - &storage.path, - AGENT_DB_MAX_SCAN_RECORDS, - "terminal 记录硬上限", - )?; - append_agent_db_line_with_capacity_unlocked( - storage, - line, - AGENT_DB_MAX_ACTION_RECEIPT_SCAN_BYTES, - "terminal receipt/reconciliation 追加硬上限", - ) +impl AgentDbFinalizationCapacityReservation { + fn from_prepared(record: &serde_json::Value) -> Option { + if record.get("stage").and_then(serde_json::Value::as_str) != Some("prepared") { + return None; + } + validate_agent_db_lifecycle_record_semantics( + AGENT_DB_FINALIZATION_LIFECYCLE_RECORD_TYPE, + record, + true, + ) + .ok()?; + Some(Self { + finalization_id: agent_db_nonempty_record_string(record, "finalizationId")?.to_string(), + agent_id: agent_db_nonempty_record_string(record, "agentId")?.to_string(), + task_id: agent_db_nonempty_record_string(record, "taskId")?.to_string(), + session_id: agent_db_nonempty_record_string(record, "sessionId")?.to_string(), + run_id: agent_db_nonempty_record_string(record, "runId")?.to_string(), + source: agent_db_nonempty_record_string(record, "source")?.to_string(), + message_id: agent_db_nonempty_record_string(record, "messageId")?.to_string(), + journal_schema_version: agent_db_nonempty_record_string( + record, + "journalSchemaVersion", + )? + .to_string(), + goal_id: record + .get("goalId") + .and_then(serde_json::Value::as_str) + .map(str::to_string), + goal_revision: record.get("goalRevision")?.as_u64()?, + goal_snapshot_fingerprint: record.get("goalSnapshotFingerprint")?.as_str()?.to_string(), + plan_revision: record.get("planRevision")?.as_u64()?, + plan_snapshot_fingerprint: record.get("planSnapshotFingerprint")?.as_str()?.to_string(), + response_fingerprint: agent_db_nonempty_record_string(record, "responseFingerprint")? + .to_string(), + response_chars: record.get("responseChars")?.as_u64()?, + conversation_path: agent_db_nonempty_record_string(record, "conversationPath")? + .to_string(), + seen_slots: 1 << AGENT_DB_FINALIZATION_SLOT_PREPARED, + tail_records: 0, + tail_bytes: 0, + }) + } + + fn matches_lifecycle(&self, record: &serde_json::Value) -> bool { + agent_db_record_string_equals(record, "finalizationId", &self.finalization_id) + && agent_db_record_string_equals(record, "messageId", &self.message_id) + && agent_db_record_string_equals(record, "agentId", &self.agent_id) + && agent_db_record_string_equals(record, "taskId", &self.task_id) + && agent_db_record_string_equals(record, "sessionId", &self.session_id) + && agent_db_record_string_equals(record, "runId", &self.run_id) + && agent_db_record_string_equals(record, "source", &self.source) + && agent_db_record_string_equals( + record, + "journalSchemaVersion", + &self.journal_schema_version, + ) + && record.get("goalId").and_then(serde_json::Value::as_str) == self.goal_id.as_deref() + && record + .get("goalRevision") + .and_then(serde_json::Value::as_u64) + == Some(self.goal_revision) + && agent_db_record_string_equals( + record, + "goalSnapshotFingerprint", + &self.goal_snapshot_fingerprint, + ) + && record + .get("planRevision") + .and_then(serde_json::Value::as_u64) + == Some(self.plan_revision) + && agent_db_record_string_equals( + record, + "planSnapshotFingerprint", + &self.plan_snapshot_fingerprint, + ) + && agent_db_record_string_equals( + record, + "responseFingerprint", + &self.response_fingerprint, + ) + && record + .get("responseChars") + .and_then(serde_json::Value::as_u64) + == Some(self.response_chars) + && agent_db_record_string_equals(record, "conversationPath", &self.conversation_path) + } + + fn matches_assistant_audit(&self, record: &serde_json::Value) -> bool { + agent_db_record_is_finalization_assistant_audit(record) + && agent_db_record_string_equals(record, "finalizationId", &self.finalization_id) + && agent_db_record_string_equals(record, "messageId", &self.message_id) + && agent_db_record_string_equals(record, "agentId", &self.agent_id) + && agent_db_record_string_equals(record, "sessionId", &self.session_id) + && agent_db_record_string_equals(record, "path", &self.conversation_path) + } + + fn matches_completed_audit(&self, record: &serde_json::Value) -> bool { + agent_db_record_is_finalization_completed_audit(record) + && agent_db_record_string_equals(record, "finalizationId", &self.finalization_id) + && agent_db_record_string_equals(record, "messageId", &self.message_id) + && agent_db_record_string_equals(record, "agentId", &self.agent_id) + && agent_db_record_string_equals(record, "taskId", &self.task_id) + && agent_db_record_string_equals(record, "sessionId", &self.session_id) + && agent_db_record_string_equals(record, "runId", &self.run_id) + && agent_db_record_string_equals(record, "source", &self.source) + && agent_db_record_string_equals( + record, + "responseFingerprint", + &self.response_fingerprint, + ) + && record + .get("responseChars") + .and_then(serde_json::Value::as_u64) + == Some(self.response_chars) + } + + fn observe_slot(&mut self, slot: u8, in_record_tail: bool, tail_bytes: u64) -> bool { + let mask = 1_u8 << slot; + let predecessor_mask = mask.saturating_sub(1); + if self.seen_slots & mask != 0 || self.seen_slots & predecessor_mask != predecessor_mask { + return false; + } + self.seen_slots |= mask; + self.tail_records = self + .tail_records + .saturating_add(usize::from(in_record_tail)); + self.tail_bytes = self.tail_bytes.saturating_add(tail_bytes); + true + } + + fn missing_slots(&self) -> usize { + AGENT_DB_FINALIZATION_CRITICAL_RECORDS_PER_SEQUENCE + .saturating_sub(self.seen_slots.count_ones() as usize) + } } -fn ensure_agent_db_record_capacity_unlocked( +#[derive(Default)] +struct AgentDbReservedTailCapacity { + record_count: usize, + file_length: u64, + action_tail_records: usize, + action_tail_bytes: u64, + lifecycle_unlinked_tail_records: usize, + lifecycle_unlinked_tail_bytes: u64, + finalizations: BTreeMap, +} + +impl AgentDbReservedTailCapacity { + fn observe_record( + &mut self, + record: &serde_json::Value, + append_class: AgentDbRecordAppendClass, + in_record_tail: bool, + tail_bytes: u64, + ) { + match append_class { + AgentDbRecordAppendClass::Ordinary => {} + AgentDbRecordAppendClass::ActionTerminal => { + self.action_tail_records = self + .action_tail_records + .saturating_add(usize::from(in_record_tail)); + self.action_tail_bytes = self.action_tail_bytes.saturating_add(tail_bytes); + } + AgentDbRecordAppendClass::LifecycleTerminal => { + self.observe_unlinked_lifecycle(in_record_tail, tail_bytes); + } + AgentDbRecordAppendClass::FinalizationCritical => { + if !self.observe_finalization_record(record, in_record_tail, tail_bytes) { + self.observe_unlinked_lifecycle(in_record_tail, tail_bytes); + } + } + } + } + + fn observe_unlinked_lifecycle(&mut self, in_record_tail: bool, tail_bytes: u64) { + self.lifecycle_unlinked_tail_records = self + .lifecycle_unlinked_tail_records + .saturating_add(usize::from(in_record_tail)); + self.lifecycle_unlinked_tail_bytes = self + .lifecycle_unlinked_tail_bytes + .saturating_add(tail_bytes); + } + + fn observe_finalization_record( + &mut self, + record: &serde_json::Value, + in_record_tail: bool, + tail_bytes: u64, + ) -> bool { + match record.get("recordType").and_then(serde_json::Value::as_str) { + Some(AGENT_DB_FINALIZATION_LIFECYCLE_RECORD_TYPE) => { + if validate_agent_db_lifecycle_record_semantics( + AGENT_DB_FINALIZATION_LIFECYCLE_RECORD_TYPE, + record, + true, + ) + .is_err() + { + return false; + } + let Some(finalization_id) = + agent_db_nonempty_record_string(record, "finalizationId") + else { + return false; + }; + let stage = record + .get("stage") + .and_then(serde_json::Value::as_str) + .unwrap_or_default(); + if stage == "prepared" { + let Some(mut reservation) = + AgentDbFinalizationCapacityReservation::from_prepared(record) + else { + return false; + }; + if self.finalizations.contains_key(finalization_id) { + return false; + } + reservation.tail_records = usize::from(in_record_tail); + reservation.tail_bytes = tail_bytes; + self.finalizations + .insert(finalization_id.to_string(), reservation); + return true; + } + let slot = match stage { + "assistant-persisted" => AGENT_DB_FINALIZATION_SLOT_ASSISTANT_PERSISTED, + "runtime-completed" => AGENT_DB_FINALIZATION_SLOT_RUNTIME_COMPLETED, + "goal-completed" => AGENT_DB_FINALIZATION_SLOT_GOAL_COMPLETED, + _ => return false, + }; + self.finalizations + .get_mut(finalization_id) + .filter(|reservation| reservation.matches_lifecycle(record)) + .is_some_and(|reservation| { + reservation.observe_slot(slot, in_record_tail, tail_bytes) + }) + } + Some("conversation.message") => self.observe_unique_matching_finalization( + record, + AGENT_DB_FINALIZATION_SLOT_ASSISTANT_AUDIT, + in_record_tail, + tail_bytes, + AgentDbFinalizationCapacityReservation::matches_assistant_audit, + ), + Some("agent.runtime.completed") => self.observe_unique_matching_finalization( + record, + AGENT_DB_FINALIZATION_SLOT_COMPLETED_AUDIT, + in_record_tail, + tail_bytes, + AgentDbFinalizationCapacityReservation::matches_completed_audit, + ), + Some("agent.runtime.background_task.completed") => self + .observe_unique_matching_finalization( + record, + AGENT_DB_FINALIZATION_SLOT_BACKGROUND_COMPLETED_AUDIT, + in_record_tail, + tail_bytes, + AgentDbFinalizationCapacityReservation::matches_completed_audit, + ), + _ => false, + } + } + + fn observe_unique_matching_finalization( + &mut self, + record: &serde_json::Value, + slot: u8, + in_record_tail: bool, + tail_bytes: u64, + matches: fn(&AgentDbFinalizationCapacityReservation, &serde_json::Value) -> bool, + ) -> bool { + let Some(finalization_id) = record + .get("finalizationId") + .and_then(serde_json::Value::as_str) + else { + return false; + }; + self.finalizations + .get_mut(finalization_id) + .filter(|reservation| matches(reservation, record)) + .is_some_and(|reservation| reservation.observe_slot(slot, in_record_tail, tail_bytes)) + } + + fn lifecycle_tail_records_with_reservations(&self) -> usize { + self.finalizations.values().fold( + self.lifecycle_unlinked_tail_records, + |count, reservation| { + count + .saturating_add(reservation.tail_records) + .saturating_add(reservation.missing_slots()) + }, + ) + } + + fn lifecycle_tail_bytes_with_reservations(&self) -> u64 { + let reserved_frame_bytes = AGENT_DB_FINALIZATION_CRITICAL_MAX_RECORD_BYTES as u64 + 1; + self.finalizations.values().fold( + self.lifecycle_unlinked_tail_bytes, + |bytes, reservation| { + bytes.saturating_add(reservation.tail_bytes).saturating_add( + reserved_frame_bytes.saturating_mul(reservation.missing_slots() as u64), + ) + }, + ) + } + + fn missing_finalization_records(&self) -> usize { + self.finalizations + .values() + .fold(0usize, |count, reservation| { + count.saturating_add(reservation.missing_slots()) + }) + } + + fn missing_finalization_bytes(&self) -> u64 { + let reserved_frame_bytes = AGENT_DB_FINALIZATION_CRITICAL_MAX_RECORD_BYTES as u64 + 1; + self.finalizations + .values() + .fold(0_u64, |bytes, reservation| { + bytes.saturating_add( + reserved_frame_bytes.saturating_mul(reservation.missing_slots() as u64), + ) + }) + } +} + +fn agent_db_nonempty_record_string<'a>( + record: &'a serde_json::Value, + field: &str, +) -> Option<&'a str> { + record + .get(field) + .and_then(serde_json::Value::as_str) + .filter(|value| !value.trim().is_empty()) +} + +fn agent_db_record_string_equals(record: &serde_json::Value, field: &str, expected: &str) -> bool { + record.get(field).and_then(serde_json::Value::as_str) == Some(expected) +} + +fn scan_agent_db_reserved_tail_capacity_unlocked( file: &mut File, path: &Path, - capacity: usize, - capacity_label: &str, -) -> Result<(), String> { +) -> Result { + let length = file + .metadata() + .map_err(|error| format!("读取 Agent 本地索引元数据失败:{}: {error}", path.display()))? + .len(); + if length > AGENT_DB_MAX_ACTION_RECEIPT_SCAN_BYTES { + return Err(format!( + "Agent 本地索引超过 {} 字节扫描上限:{}", + AGENT_DB_MAX_ACTION_RECEIPT_SCAN_BYTES, + path.display() + )); + } file.seek(SeekFrom::Start(0)) .map_err(|error| format!("定位 Agent 本地索引失败:{}: {error}", path.display()))?; let mut reader = BufReader::new(file); - let mut record_count = 0usize; + let mut capacity = AgentDbReservedTailCapacity { + file_length: length, + ..AgentDbReservedTailCapacity::default() + }; + let mut byte_offset = 0_u64; while let Some(line) = read_agent_db_jsonl_line_bounded(&mut reader, path)? { if !line.complete { break; } + let framed_length = u64::try_from(line.content.len()) + .unwrap_or(u64::MAX) + .saturating_add(1); + let line_start = byte_offset; + byte_offset = byte_offset.saturating_add(framed_length); if line.content.iter().all(|byte| byte.is_ascii_whitespace()) { continue; } - serde_json::from_slice::(&line.content) - .map_err(|error| format!("解析 Agent 本地索引失败:{}: {error}", path.display()))?; - record_count = record_count.saturating_add(1); - if record_count >= capacity { + if capacity.record_count >= AGENT_DB_MAX_SCAN_RECORDS { return Err(format!( - "Agent 本地索引已达到 {capacity} 条{capacity_label},无法继续追加:{}", + "Agent 本地索引超过 {} 条记录扫描上限:{}", + AGENT_DB_MAX_SCAN_RECORDS, path.display() )); } + let record = serde_json::from_slice::(&line.content) + .map_err(|error| format!("解析 Agent 本地索引失败:{}: {error}", path.display()))?; + let in_record_tail = capacity.record_count >= AGENT_DB_MAX_ORDINARY_APPEND_RECORDS; + let tail_bytes = + byte_offset.saturating_sub(line_start.max(AGENT_DB_MAX_ORDINARY_APPEND_BYTES)); + capacity.observe_record( + &record, + agent_db_record_append_class(&record), + in_record_tail, + tail_bytes, + ); + capacity.record_count = capacity.record_count.saturating_add(1); + } + Ok(capacity) +} + +fn ensure_agent_db_classified_capacity_unlocked( + file: &mut File, + path: &Path, + line: &str, + append_class: AgentDbRecordAppendClass, +) -> Result<(), String> { + let mut capacity = scan_agent_db_reserved_tail_capacity_unlocked(file, path)?; + let framed_length = u64::try_from(line.len()) + .unwrap_or(u64::MAX) + .saturating_add(1); + if capacity.record_count >= AGENT_DB_MAX_SCAN_RECORDS { + return Err(format!( + "Agent 本地索引已达到 {} 条 terminal 记录硬上限,无法继续追加:{}", + AGENT_DB_MAX_SCAN_RECORDS, + path.display() + )); + } + ensure_agent_db_append_capacity( + path, + capacity.file_length, + framed_length, + AGENT_DB_MAX_ACTION_RECEIPT_SCAN_BYTES, + "terminal receipt/reconciliation 追加硬上限", + )?; + let record = serde_json::from_str::(line) + .map_err(|error| format!("解析待追加 Agent 本地索引记录失败:{error}"))?; + let in_record_tail = capacity.record_count >= AGENT_DB_MAX_ORDINARY_APPEND_RECORDS; + let next_length = capacity.file_length.saturating_add(framed_length); + let tail_bytes = + next_length.saturating_sub(capacity.file_length.max(AGENT_DB_MAX_ORDINARY_APPEND_BYTES)); + capacity.observe_record(&record, append_class, in_record_tail, tail_bytes); + let next_record_count = capacity.record_count.saturating_add(1); + ensure_agent_db_physical_capacity_with_reservations( + path, + next_record_count, + next_length, + capacity.missing_finalization_records(), + capacity.missing_finalization_bytes(), + )?; + if append_class == AgentDbRecordAppendClass::Ordinary { + if capacity.record_count >= AGENT_DB_MAX_ORDINARY_APPEND_RECORDS { + return Err(format!( + "Agent 本地索引已达到 {} 条普通审计记录软上限,无法继续追加:{}", + AGENT_DB_MAX_ORDINARY_APPEND_RECORDS, + path.display() + )); + } + return ensure_agent_db_append_capacity( + path, + capacity.file_length, + framed_length, + AGENT_DB_MAX_ORDINARY_APPEND_BYTES, + "普通审计追加软上限", + ); + } + + match append_class { + AgentDbRecordAppendClass::ActionTerminal => { + if capacity.action_tail_records > AGENT_DB_TERMINAL_RESERVE_RECORDS as usize { + return Err(format!( + "Agent 本地索引已达到 {} 条 action terminal 尾部配额,无法继续追加:{}", + AGENT_DB_TERMINAL_RESERVE_RECORDS, + path.display() + )); + } + if capacity.action_tail_bytes > AGENT_DB_TERMINAL_RESERVE_BYTES { + return Err(format!( + "Agent 本地索引将超过 {} 字节 action terminal 尾部配额:{}", + AGENT_DB_TERMINAL_RESERVE_BYTES, + path.display() + )); + } + } + AgentDbRecordAppendClass::LifecycleTerminal + | AgentDbRecordAppendClass::FinalizationCritical => { + if capacity.lifecycle_tail_records_with_reservations() + > AGENT_DB_LIFECYCLE_TERMINAL_RESERVE_RECORDS as usize + { + return Err(format!( + "Agent 本地索引已达到 {} 条 lifecycle/finalization terminal 尾部配额,无法继续追加:{}", + AGENT_DB_LIFECYCLE_TERMINAL_RESERVE_RECORDS, + path.display() + )); + } + if capacity.lifecycle_tail_bytes_with_reservations() + > AGENT_DB_LIFECYCLE_TERMINAL_RESERVE_BYTES + { + return Err(format!( + "Agent 本地索引将超过 {} 字节 lifecycle/finalization terminal 尾部配额:{}", + AGENT_DB_LIFECYCLE_TERMINAL_RESERVE_BYTES, + path.display() + )); + } + } + AgentDbRecordAppendClass::Ordinary => unreachable!(), } Ok(()) } -fn append_agent_db_line_with_capacity_unlocked( +fn ensure_agent_db_physical_capacity_with_reservations( + path: &Path, + physical_record_count: usize, + physical_bytes: u64, + reserved_record_count: usize, + reserved_bytes: u64, +) -> Result<(), String> { + if physical_record_count.saturating_add(reserved_record_count) > AGENT_DB_MAX_SCAN_RECORDS { + return Err(format!( + "Agent 本地索引物理记录硬容量不足,无法兑现已激活 finalization reservation:{}", + path.display() + )); + } + if physical_bytes.saturating_add(reserved_bytes) > AGENT_DB_MAX_ACTION_RECEIPT_SCAN_BYTES { + return Err(format!( + "Agent 本地索引物理字节硬容量不足,无法兑现已激活 finalization reservation:{}", + path.display() + )); + } + Ok(()) +} + +fn append_agent_db_classified_line_unlocked( storage: &mut AgentDbStorage, line: &str, - capacity: u64, - capacity_label: &str, + append_class: AgentDbRecordAppendClass, ) -> Result<(), String> { verify_agent_db_storage_current(storage)?; - let append_start = storage.file.seek(SeekFrom::End(0)).map_err(|error| { + ensure_agent_db_classified_capacity_unlocked( + &mut storage.file, + &storage.path, + line, + append_class, + )?; + storage.file.seek(SeekFrom::End(0)).map_err(|error| { format!( "定位 Agent 本地索引尾部失败:{}: {error}", storage.path.display() ) })?; let framed = format!("{line}\n"); - ensure_agent_db_append_capacity( - &storage.path, - append_start, - u64::try_from(framed.len()).unwrap_or(u64::MAX), - capacity, - capacity_label, - )?; storage .file .write_all(framed.as_bytes()) @@ -1763,6 +3270,67 @@ fn append_agent_db_line_with_capacity_unlocked( verify_agent_db_storage_current(storage) } +#[cfg(test)] +pub(crate) fn fill_agent_db_to_ordinary_record_capacity_for_test( + root: &Path, +) -> Result { + let path = root.join(".agent/agent.db"); + let directory = open_agent_db_directory(root, true)? + .ok_or_else(|| "创建项目 .agent 目录失败".to_string())?; + let append_lock = project_append_lock_for(&path)?; + let _append_guard = append_lock.lock_process("Agent 本地索引测试容量填充")?; + verify_agent_db_directory_current(&directory)?; + let mut storage = open_agent_db_storage(directory, true, true)? + .ok_or_else(|| "创建 Agent 本地索引失败".to_string())?; + repair_truncated_jsonl_tail_unlocked(&mut storage.file, &storage.path, "Agent 本地索引")?; + let capacity = scan_agent_db_reserved_tail_capacity_unlocked(&mut storage.file, &storage.path)?; + if capacity.record_count > AGENT_DB_MAX_ORDINARY_APPEND_RECORDS { + return Err(format!( + "Agent 本地索引已有 {} 条记录,超过测试普通容量 {}", + capacity.record_count, AGENT_DB_MAX_ORDINARY_APPEND_RECORDS + )); + } + let filler_records = AGENT_DB_MAX_ORDINARY_APPEND_RECORDS - capacity.record_count; + let filler_bytes = u64::try_from(filler_records) + .unwrap_or(u64::MAX) + .saturating_mul(3); + ensure_agent_db_append_capacity( + &storage.path, + capacity.file_length, + filler_bytes, + AGENT_DB_MAX_ORDINARY_APPEND_BYTES, + "测试普通审计追加软上限", + )?; + storage.file.seek(SeekFrom::End(0)).map_err(|error| { + format!( + "定位 Agent 本地索引测试容量尾部失败:{}: {error}", + storage.path.display() + ) + })?; + let chunk = "{}\n".repeat(8_192); + let mut remaining = filler_records; + while remaining >= 8_192 { + storage + .file + .write_all(chunk.as_bytes()) + .map_err(|error| format!("写入 Agent 本地索引测试容量失败:{error}"))?; + remaining -= 8_192; + } + if remaining > 0 { + storage + .file + .write_all("{}\n".repeat(remaining).as_bytes()) + .map_err(|error| format!("写入 Agent 本地索引测试容量失败:{error}"))?; + } + storage + .file + .flush() + .and_then(|_| storage.file.sync_data()) + .map_err(|error| format!("同步 Agent 本地索引测试容量失败:{error}"))?; + verify_agent_db_storage_current(&storage)?; + Ok(filler_records) +} + fn ensure_agent_db_append_capacity( path: &Path, current_length: u64, @@ -1861,11 +3429,14 @@ fn agent_db_has_conversation_message_audit_unlocked( agent_id: Option<&str>, session_id: Option<&str>, message_id: &str, + finalization_id: Option<&str>, + expected_record: &serde_json::Value, ) -> Result { file.seek(SeekFrom::Start(0)) .map_err(|error| format!("定位 Agent 本地索引失败:{}: {error}", path.display()))?; let mut reader = BufReader::new(file); let mut line_index = 0usize; + let mut exact_matches = 0usize; while let Some(line) = read_agent_db_jsonl_line_bounded(&mut reader, path)? { line_index = line_index.saturating_add(1); if !line.complete { @@ -1882,16 +3453,38 @@ fn agent_db_has_conversation_message_audit_unlocked( line_index ) })?; - if record.get("recordType").and_then(serde_json::Value::as_str) + let same_identity = record.get("recordType").and_then(serde_json::Value::as_str) == Some("conversation.message") && record.get("messageId").and_then(serde_json::Value::as_str) == Some(message_id) - && record.get("agentId").and_then(serde_json::Value::as_str) == agent_id - && record.get("sessionId").and_then(serde_json::Value::as_str) == session_id - { - return Ok(true); + && match finalization_id { + Some(finalization_id) => { + record + .get("finalizationId") + .and_then(serde_json::Value::as_str) + == Some(finalization_id) + } + None => { + record.get("agentId").and_then(serde_json::Value::as_str) == agent_id + && record.get("sessionId").and_then(serde_json::Value::as_str) == session_id + && record.get("finalizationId").is_none() + } + }; + if !same_identity { + continue; + } + if !agent_db_stored_record_matches_expected_payload(&record, expected_record) { + return Err(format!( + "conversation.message 审计内容冲突:messageId={message_id}" + )); + } + exact_matches = exact_matches.saturating_add(1); + if exact_matches > 1 { + return Err(format!( + "conversation.message 审计重复:messageId={message_id}" + )); } } - Ok(false) + Ok(exact_matches == 1) } fn ensure_conversation_message_audit_at( @@ -1901,6 +3494,14 @@ fn ensure_conversation_message_audit_at( message_id: &str, audit_record: serde_json::Value, ) -> Result<(), String> { + let finalization_id = audit_record + .get("finalizationId") + .and_then(serde_json::Value::as_str); + if finalization_id.is_some_and(|value| !is_valid_agent_db_finalization_id(value)) + || (finalization_id.is_some() && !is_valid_agent_db_finalization_id(message_id)) + { + return Err("finalization conversation 审计身份安全形状无效".to_string()); + } let path = root.join(".agent/agent.db"); let directory = open_agent_db_directory(root, true)? .ok_or_else(|| "创建项目 .agent 目录失败".to_string())?; @@ -1918,11 +3519,15 @@ fn ensure_conversation_message_audit_at( agent_id, session_id, message_id, + finalization_id, + &audit_record, )? { return Ok(()); } + let append_class = agent_db_record_append_class(&audit_record); let line = serialize_agent_db_record(audit_record)?; - append_agent_db_line_unlocked(&mut storage, &line) + validate_agent_db_append_class_record_size(append_class, &line)?; + append_agent_db_classified_line_unlocked(&mut storage, &line, append_class) } #[derive(Debug)] @@ -3234,6 +4839,7 @@ fn append_local_conversation_message_for_session_internal_at( session_id: Option<&str>, message: LocalConversationMessage, message_id: Option<&str>, + finalization_id: Option<&str>, ) -> Result { let LocalConversationMessage { role, @@ -3291,6 +4897,19 @@ fn append_local_conversation_message_for_session_internal_at( let message_id = message_id .map(normalize_local_conversation_message_id) .transpose()?; + let finalization_id = finalization_id.map(str::trim); + if let Some(finalization_id) = finalization_id { + if role != "assistant" + || normalized_agent_id.is_none() + || normalized_session_id.is_none() + || !is_valid_agent_db_finalization_id(finalization_id) + || !message_id + .as_deref() + .is_some_and(is_valid_agent_db_finalization_id) + { + return Err("finalization conversation 审计身份或角色无效".to_string()); + } + } let record = PersistedLocalConversationMessageRecord { schema_version: LOCAL_CONVERSATION_SCHEMA_VERSION.to_string(), role: role.to_string(), @@ -3347,6 +4966,12 @@ fn append_local_conversation_message_for_session_internal_at( "messageId".to_string(), serde_json::Value::String(message_id.to_string()), ); + if let Some(finalization_id) = finalization_id { + object.insert( + "finalizationId".to_string(), + serde_json::Value::String(finalization_id.to_string()), + ); + } } if let Some(message_id) = message_id.as_deref() { ensure_conversation_message_audit_at( @@ -3392,7 +5017,7 @@ pub(crate) fn append_local_conversation_message_for_session_at( message: LocalConversationMessage, ) -> Result { append_local_conversation_message_for_session_internal_at( - root, agent_id, session_id, message, None, + root, agent_id, session_id, message, None, None, ) } @@ -3409,6 +5034,25 @@ pub(crate) fn append_local_conversation_message_for_session_idempotent_at( session_id, message, Some(message_id), + None, + ) +} + +pub(crate) fn append_local_conversation_message_for_session_idempotent_with_finalization_at( + root: &Path, + agent_id: Option<&str>, + session_id: Option<&str>, + message: LocalConversationMessage, + message_id: &str, + finalization_id: &str, +) -> Result { + append_local_conversation_message_for_session_internal_at( + root, + agent_id, + session_id, + message, + Some(message_id), + Some(finalization_id), ) } @@ -6531,6 +8175,9 @@ mod agent_db_security_tests { static TEST_ROOT_NONCE: AtomicU64 = AtomicU64::new(1); const TEST_ACTION_ID: &str = "action-000000000000000000000001"; + const TEST_FINALIZATION_ID: &str = "agent-finalization-11111111111111111111111111111111"; + const TEST_FINALIZATION_MESSAGE_ID: &str = + "agent-finalization-22222222222222222222222222222222"; fn unique_agent_db_test_root(test_name: &str) -> PathBuf { std::env::temp_dir().join(format!( @@ -6559,6 +8206,112 @@ mod agent_db_security_tests { }) } + fn provider_request_id(hex: char) -> String { + format!("provider-request-{}", hex.to_string().repeat(64)) + } + + fn finalization_id(hex: char) -> String { + format!("agent-finalization-{}", hex.to_string().repeat(32)) + } + + fn provider_lifecycle_record(request_id: &str, status: &str) -> serde_json::Value { + serde_json::json!({ + "recordType": AGENT_DB_PROVIDER_REQUEST_LIFECYCLE_RECORD_TYPE, + "auditSchemaVersion": AGENT_DB_PROVIDER_REQUEST_LIFECYCLE_SCHEMA_VERSION, + "agentId": "code-prototype", + "taskId": "task-lifecycle-1", + "sessionId": "session-lifecycle-1", + "runId": "run-lifecycle-1", + "source": "test", + "requestId": request_id, + "requestKind": "tool-plan", + "requestSlot": "loop-0-repair-0", + "status": status, + }) + } + + fn finalization_lifecycle_record(finalization_id: &str, stage: &str) -> serde_json::Value { + finalization_lifecycle_record_with_schema( + finalization_id, + stage, + AGENT_DB_FINALIZATION_JOURNAL_SCHEMA_V3, + ) + } + + fn finalization_lifecycle_record_with_schema( + finalization_id: &str, + stage: &str, + journal_schema: &str, + ) -> serde_json::Value { + let (stage_ordinal, previous_stage) = match stage { + "prepared" => (1_u64, None), + "assistant-persisted" => (2, Some("prepared")), + "runtime-completed" => (3, Some("assistant-persisted")), + "goal-completed" => (4, Some("runtime-completed")), + _ => panic!("unsupported finalization test stage: {stage}"), + }; + let legacy_v1 = journal_schema == AGENT_DB_FINALIZATION_JOURNAL_SCHEMA_V1; + serde_json::json!({ + "recordType": AGENT_DB_FINALIZATION_LIFECYCLE_RECORD_TYPE, + "auditSchemaVersion": AGENT_DB_FINALIZATION_LIFECYCLE_SCHEMA_VERSION, + "journalSchemaVersion": journal_schema, + "agentId": "code-prototype", + "taskId": "task-finalization-1", + "sessionId": "session-finalization-1", + "runId": "run-finalization-1", + "source": "test", + "finalizationId": finalization_id, + "messageId": TEST_FINALIZATION_MESSAGE_ID, + "stage": stage, + "stageOrdinal": stage_ordinal, + "previousStage": previous_stage, + "goalId": null, + "goalRevision": 0, + "goalSnapshotFingerprint": "", + "planRevision": if legacy_v1 { 0 } else { 1 }, + "planSnapshotFingerprint": if legacy_v1 { String::new() } else { "3".repeat(64) }, + "responseFingerprint": "4".repeat(64), + "responseChars": 2, + "conversationPath": ".agent/conversations/agents/code-prototype/sessions/session-finalization-1.jsonl", + "stageAt": stage_ordinal, + }) + } + + fn with_agent_db_envelope(mut record: serde_json::Value) -> serde_json::Value { + record["schemaVersion"] = + serde_json::Value::String(GAME_CREATOR_AGENT_DB_SCHEMA_VERSION.to_string()); + record["updatedAt"] = serde_json::Value::Number(1_u64.into()); + record + } + + fn write_agent_db_reserved_tail_fixture( + root: &Path, + tail_record: &serde_json::Value, + tail_count: usize, + ) { + let agent_dir = root.join(".agent"); + fs::create_dir_all(&agent_dir).expect("create reserved tail fixture directory"); + let mut file = fs::OpenOptions::new() + .create(true) + .truncate(true) + .write(true) + .open(agent_dir.join("agent.db")) + .expect("open reserved tail fixture"); + file.write_all( + "{}\n" + .repeat(AGENT_DB_MAX_ORDINARY_APPEND_RECORDS) + .as_bytes(), + ) + .expect("write ordinary Agent DB prefix"); + let framed = format!( + "{}\n", + serde_json::to_string(tail_record).expect("serialize reserved tail record") + ); + file.write_all(framed.repeat(tail_count).as_bytes()) + .expect("write classified Agent DB tail"); + file.flush().expect("flush reserved tail fixture"); + } + fn write_agent_db_records(root: &Path, records: &[serde_json::Value]) { let agent_dir = root.join(".agent"); fs::create_dir_all(&agent_dir).expect("create agent db fixture directory"); @@ -6571,6 +8324,13 @@ mod agent_db_security_tests { .expect("write agent db fixture"); } + fn write_agent_db_empty_record_fixture(root: &Path, record_count: usize) { + let agent_dir = root.join(".agent"); + fs::create_dir_all(&agent_dir).expect("create Agent DB count fixture directory"); + fs::write(agent_dir.join("agent.db"), b"{}\n".repeat(record_count)) + .expect("write Agent DB count fixture"); + } + fn write_sparse_agent_db_with_complete_tail(root: &Path, length: u64) { let agent_dir = root.join(".agent"); fs::create_dir_all(&agent_dir).expect("create sparse agent db fixture directory"); @@ -6885,8 +8645,11 @@ mod agent_db_security_tests { root.join(".agent/agent.db"), b"{}\n".repeat( AGENT_DB_MAX_SCAN_RECORDS - - usize::try_from(AGENT_DB_TERMINAL_RESERVE_RECORDS) - .expect("reserve count fits usize"), + - usize::try_from( + AGENT_DB_TERMINAL_RESERVE_RECORDS + + AGENT_DB_LIFECYCLE_TERMINAL_RESERVE_RECORDS, + ) + .expect("reserve count fits usize"), ), ) .expect("write ordinary record-capacity fixture"); @@ -6921,6 +8684,664 @@ mod agent_db_security_tests { }))); } + #[test] + fn lifecycle_and_finalization_records_are_classified_from_their_audit_shape() { + assert_eq!( + agent_db_record_append_class(&serde_json::json!({ + "recordType": AGENT_DB_PROVIDER_REQUEST_LIFECYCLE_RECORD_TYPE, + "status": "started", + })), + AgentDbRecordAppendClass::Ordinary + ); + for status in ["completed", "failed", "interrupted"] { + let record = serde_json::json!({ + "recordType": AGENT_DB_PROVIDER_REQUEST_LIFECYCLE_RECORD_TYPE, + "status": status, + }); + assert!(!agent_db_record_uses_terminal_reserve(&record)); + assert_eq!( + agent_db_record_append_class(&record), + AgentDbRecordAppendClass::LifecycleTerminal + ); + } + for stage in [ + "prepared", + "assistant-persisted", + "runtime-completed", + "goal-completed", + ] { + let record = serde_json::json!({ + "recordType": AGENT_DB_FINALIZATION_LIFECYCLE_RECORD_TYPE, + "stage": stage, + }); + assert!(!agent_db_record_uses_terminal_reserve(&record)); + assert_eq!( + agent_db_record_append_class(&record), + AgentDbRecordAppendClass::FinalizationCritical + ); + } + for record in [ + serde_json::json!({ + "recordType": "conversation.message", + "agentId": "code-prototype", + "sessionId": "session-finalization-1", + "role": "assistant", + "path": ".agent/conversations/agents/code-prototype/sessions/session-finalization-1.jsonl", + "finalizationId": TEST_FINALIZATION_ID, + "messageId": TEST_FINALIZATION_MESSAGE_ID, + }), + serde_json::json!({ + "recordType": "agent.runtime.completed", + "agentId": "code-prototype", + "taskId": "task-finalization-1", + "sessionId": "session-finalization-1", + "runId": "run-finalization-1", + "source": "test", + "finalizationId": TEST_FINALIZATION_ID, + "messageId": TEST_FINALIZATION_MESSAGE_ID, + "responseFingerprint": "4".repeat(64), + "responseChars": 2, + }), + serde_json::json!({ + "recordType": "agent.runtime.background_task.completed", + "agentId": "code-prototype", + "taskId": "task-finalization-1", + "sessionId": "session-finalization-1", + "runId": "run-finalization-1", + "source": "test", + "finalizationId": TEST_FINALIZATION_ID, + "messageId": TEST_FINALIZATION_MESSAGE_ID, + "responseFingerprint": "4".repeat(64), + "responseChars": 2, + }), + ] { + assert_eq!( + agent_db_record_append_class(&record), + AgentDbRecordAppendClass::FinalizationCritical + ); + } + for record in [ + serde_json::json!({ + "recordType": "conversation.message", + "role": "assistant", + "messageId": TEST_FINALIZATION_MESSAGE_ID, + }), + serde_json::json!({"recordType": "agent.runtime.completed"}), + serde_json::json!({ + "recordType": "agent.runtime.background_task.completed", + "finalizationId": TEST_FINALIZATION_ID, + }), + ] { + assert_eq!( + agent_db_record_append_class(&record), + AgentDbRecordAppendClass::Ordinary + ); + } + } + + #[test] + fn generic_append_rejects_both_lifecycle_record_types() { + for (index, record_type) in [ + AGENT_DB_PROVIDER_REQUEST_LIFECYCLE_RECORD_TYPE, + AGENT_DB_FINALIZATION_LIFECYCLE_RECORD_TYPE, + ] + .into_iter() + .enumerate() + { + let root = unique_agent_db_test_root(&format!("generic-lifecycle-{index}")); + let error = + append_agent_db_record(&root, serde_json::json!({"recordType": record_type})) + .expect_err("generic append must reject lifecycle records"); + assert!(error.contains("专用幂等追加入口"), "{error}"); + assert!(!root.join(".agent/agent.db").exists()); + fs::remove_dir_all(root).ok(); + } + } + + #[test] + fn lifecycle_dedicated_append_validates_the_complete_shape_before_first_write() { + let request_id = provider_request_id('3'); + let valid = provider_lifecycle_record(&request_id, "started"); + let mut missing_field = valid.clone(); + missing_field + .as_object_mut() + .expect("provider lifecycle object") + .remove("requestSlot"); + let mut extra_field = valid.clone(); + extra_field["unexpected"] = serde_json::Value::Bool(true); + let mut wrong_schema = valid.clone(); + wrong_schema["auditSchemaVersion"] = serde_json::Value::String("invalid.v1".to_string()); + let mut unsafe_identity = valid.clone(); + unsafe_identity["taskId"] = serde_json::Value::String("../task".to_string()); + let mut unsafe_request_id = valid.clone(); + unsafe_request_id["requestId"] = + serde_json::Value::String("provider-request-bad".to_string()); + + for (index, (identity, record)) in [ + (request_id.as_str(), missing_field), + (request_id.as_str(), extra_field), + (request_id.as_str(), wrong_schema), + (request_id.as_str(), unsafe_identity), + ("provider-request-bad", unsafe_request_id), + ] + .into_iter() + .enumerate() + { + let root = unique_agent_db_test_root(&format!("invalid-lifecycle-input-{index}")); + append_agent_db_lifecycle_record_idempotent( + &root, + "requestId", + identity, + "status", + "started", + record, + ) + .expect_err("invalid lifecycle input must fail before opening Agent DB"); + assert!(!root.join(".agent/agent.db").exists()); + fs::remove_dir_all(root).ok(); + } + + let root = unique_agent_db_test_root("invalid-finalization-input"); + let mut bad_stage = finalization_lifecycle_record(TEST_FINALIZATION_ID, "prepared"); + bad_stage["stageOrdinal"] = serde_json::Value::Number(2_u64.into()); + let error = append_agent_db_lifecycle_record_idempotent( + &root, + "finalizationId", + TEST_FINALIZATION_ID, + "stage", + "prepared", + bad_stage, + ) + .expect_err("invalid finalization stage metadata must fail before first write"); + assert!(error.contains("阶段元数据"), "{error}"); + assert!(!root.join(".agent/agent.db").exists()); + + let mut bad_fingerprint = finalization_lifecycle_record(TEST_FINALIZATION_ID, "prepared"); + bad_fingerprint["responseFingerprint"] = serde_json::Value::String("ABC".to_string()); + append_agent_db_lifecycle_record_idempotent( + &root, + "finalizationId", + TEST_FINALIZATION_ID, + "stage", + "prepared", + bad_fingerprint, + ) + .expect_err("invalid finalization fingerprint must fail before first write"); + assert!(!root.join(".agent/agent.db").exists()); + fs::remove_dir_all(root).ok(); + } + + #[test] + fn lifecycle_reads_validate_every_same_type_record_before_identity_filtering() { + let target_id = provider_request_id('4'); + let unrelated_id = provider_request_id('5'); + let target = with_agent_db_envelope(provider_lifecycle_record(&target_id, "started")); + let mut unrelated = provider_lifecycle_record(&unrelated_id, "started"); + unrelated["auditSchemaVersion"] = serde_json::Value::String("invalid.v1".to_string()); + let root = unique_agent_db_test_root("lifecycle-unrelated-invalid-record"); + write_agent_db_records(&root, &[target.clone(), with_agent_db_envelope(unrelated)]); + let error = read_agent_db_lifecycle_transitions_at( + &root, + AGENT_DB_PROVIDER_REQUEST_LIFECYCLE_RECORD_TYPE, + "requestId", + &target_id, + ) + .expect_err("an invalid non-target lifecycle record must fail the whole read"); + assert!(error.contains("audit schema"), "{error}"); + fs::remove_dir_all(&root).ok(); + + let root = unique_agent_db_test_root("lifecycle-missing-identity-record"); + let mut missing_identity = provider_lifecycle_record(&unrelated_id, "started"); + missing_identity + .as_object_mut() + .expect("provider lifecycle object") + .remove("requestId"); + write_agent_db_records(&root, &[target, with_agent_db_envelope(missing_identity)]); + let error = read_agent_db_lifecycle_transitions_at( + &root, + AGENT_DB_PROVIDER_REQUEST_LIFECYCLE_RECORD_TYPE, + "requestId", + &target_id, + ) + .expect_err("a same-type record missing identity must fail before target lookup"); + assert!(error.contains("字段集合"), "{error}"); + fs::remove_dir_all(root).ok(); + } + + #[test] + fn finalization_lifecycle_accepts_supported_v1_through_v3_journals() { + for (index, (schema, hex)) in [ + (AGENT_DB_FINALIZATION_JOURNAL_SCHEMA_V1, '6'), + (AGENT_DB_FINALIZATION_JOURNAL_SCHEMA_V2, '7'), + (AGENT_DB_FINALIZATION_JOURNAL_SCHEMA_V3, '8'), + ] + .into_iter() + .enumerate() + { + let root = unique_agent_db_test_root(&format!("finalization-schema-{index}")); + let finalization_id = finalization_id(hex); + for stage in [ + "prepared", + "assistant-persisted", + "runtime-completed", + "goal-completed", + ] { + assert!(append_agent_db_lifecycle_record_idempotent( + &root, + "finalizationId", + &finalization_id, + "stage", + stage, + finalization_lifecycle_record_with_schema(&finalization_id, stage, schema), + ) + .unwrap_or_else(|error| panic!("append {schema}/{stage}: {error}"))); + } + assert_eq!( + read_agent_db_lifecycle_transitions_at( + &root, + AGENT_DB_FINALIZATION_LIFECYCLE_RECORD_TYPE, + "finalizationId", + &finalization_id, + ) + .expect("read supported finalization lifecycle"), + vec![ + "prepared", + "assistant-persisted", + "runtime-completed", + "goal-completed", + ] + ); + fs::remove_dir_all(root).ok(); + } + + let root = unique_agent_db_test_root("finalization-v3-goal-snapshot"); + let finalization_id = finalization_id('9'); + for stage in [ + "prepared", + "assistant-persisted", + "runtime-completed", + "goal-completed", + ] { + let mut record = finalization_lifecycle_record(&finalization_id, stage); + record["goalId"] = serde_json::Value::String("agent-goal-1".to_string()); + record["goalRevision"] = serde_json::Value::Number(2_u64.into()); + record["goalSnapshotFingerprint"] = serde_json::Value::String("5".repeat(64)); + append_agent_db_lifecycle_record_idempotent( + &root, + "finalizationId", + &finalization_id, + "stage", + stage, + record, + ) + .unwrap_or_else(|error| panic!("append v3 Goal {stage}: {error}")); + } + fs::remove_dir_all(root).ok(); + } + + #[test] + fn lifecycle_idempotency_scans_beyond_the_bounded_read_tail_and_rejects_conflicts() { + let root = unique_agent_db_test_root("lifecycle-full-scan"); + fs::create_dir_all(root.join(".agent")).expect("create agent directory"); + let request_id = provider_request_id('1'); + let record = provider_lifecycle_record(&request_id, "started"); + assert!(append_agent_db_lifecycle_record_idempotent( + &root, + "requestId", + &request_id, + "status", + "started", + record.clone(), + ) + .expect("append first lifecycle record")); + assert_eq!( + read_agent_db_lifecycle_transitions_at( + &root, + AGENT_DB_PROVIDER_REQUEST_LIFECYCLE_RECORD_TYPE, + "requestId", + &request_id, + ) + .expect("read durable started lifecycle"), + vec!["started"] + ); + + let filler = (0..=AGENT_DB_MAX_BOUNDED_RECORDS) + .map(|index| format!("{{\"recordType\":\"test.filler\",\"index\":{index}}}\n")) + .collect::(); + fs::OpenOptions::new() + .append(true) + .open(root.join(".agent/agent.db")) + .expect("open lifecycle fixture") + .write_all(filler.as_bytes()) + .expect("append lifecycle filler records"); + + assert!(!append_agent_db_lifecycle_record_idempotent( + &root, + "requestId", + &request_id, + "status", + "started", + record.clone(), + ) + .expect("find lifecycle record outside bounded tail")); + let mut completed = record.clone(); + completed["status"] = serde_json::Value::String("completed".to_string()); + assert!(append_agent_db_lifecycle_record_idempotent( + &root, + "requestId", + &request_id, + "status", + "completed", + completed, + ) + .expect("append one Provider terminal lifecycle")); + assert_eq!( + read_agent_db_lifecycle_transitions_at( + &root, + AGENT_DB_PROVIDER_REQUEST_LIFECYCLE_RECORD_TYPE, + "requestId", + &request_id, + ) + .expect("read lifecycle transitions through the full-file helper"), + vec!["started", "completed"] + ); + let mut conflicting_terminal = record.clone(); + conflicting_terminal["status"] = serde_json::Value::String("failed".to_string()); + let terminal_error = append_agent_db_lifecycle_record_idempotent( + &root, + "requestId", + &request_id, + "status", + "failed", + conflicting_terminal, + ) + .expect_err("Provider request must have only one terminal lifecycle"); + assert!(terminal_error.contains("终态冲突"), "{terminal_error}"); + + let mut conflicting = record; + conflicting["taskId"] = serde_json::Value::String("task-lifecycle-conflict".to_string()); + let error = append_agent_db_lifecycle_record_idempotent( + &root, + "requestId", + &request_id, + "status", + "started", + conflicting, + ) + .expect_err("conflicting lifecycle identity must fail closed"); + assert!(error.contains("内容冲突"), "{error}"); + + fs::remove_dir_all(root).ok(); + } + + #[test] + fn incomplete_provider_request_query_scans_the_full_db_and_filters_after_validation() { + let root = unique_agent_db_test_root("provider-orphan-full-scan"); + let first_incomplete = provider_request_id('a'); + append_agent_db_lifecycle_record_idempotent( + &root, + "requestId", + &first_incomplete, + "status", + "started", + provider_lifecycle_record(&first_incomplete, "started"), + ) + .expect("append first incomplete Provider request"); + + let filler = (0..=AGENT_DB_MAX_BOUNDED_RECORDS) + .map(|index| format!("{{\"recordType\":\"test.query-filler\",\"index\":{index}}}\n")) + .collect::(); + fs::OpenOptions::new() + .append(true) + .open(root.join(".agent/agent.db")) + .expect("open Provider query fixture") + .write_all(filler.as_bytes()) + .expect("append Provider query filler"); + + let second_incomplete = provider_request_id('b'); + append_agent_db_lifecycle_record_idempotent( + &root, + "requestId", + &second_incomplete, + "status", + "started", + provider_lifecycle_record(&second_incomplete, "started"), + ) + .expect("append second incomplete Provider request"); + let completed = provider_request_id('c'); + for status in ["started", "completed"] { + append_agent_db_lifecycle_record_idempotent( + &root, + "requestId", + &completed, + "status", + status, + provider_lifecycle_record(&completed, status), + ) + .unwrap_or_else(|error| panic!("append completed Provider {status}: {error}")); + } + let other_run = provider_request_id('d'); + let mut other_run_record = provider_lifecycle_record(&other_run, "started"); + other_run_record["runId"] = serde_json::Value::String("run-lifecycle-other".to_string()); + append_agent_db_lifecycle_record_idempotent( + &root, + "requestId", + &other_run, + "status", + "started", + other_run_record, + ) + .expect("append another run's incomplete Provider request"); + + assert_eq!( + read_agent_db_incomplete_provider_request_ids_at( + &root, + "code-prototype", + "run-lifecycle-1", + ) + .expect("query incomplete Provider requests through the full DB"), + vec![first_incomplete, second_incomplete] + ); + fs::remove_dir_all(root).ok(); + } + + #[test] + fn incomplete_provider_request_query_uses_the_agent_db_append_lock() { + let root = unique_agent_db_test_root("provider-query-append-lock"); + let request_id = provider_request_id('e'); + append_agent_db_lifecycle_record_idempotent( + &root, + "requestId", + &request_id, + "status", + "started", + provider_lifecycle_record(&request_id, "started"), + ) + .expect("append Provider request before lock test"); + + let append_lock = project_append_lock_for(&root.join(".agent/agent.db")) + .expect("resolve Agent DB append lock"); + let guard = append_lock + .lock_process("Agent DB query lock test") + .expect("hold Agent DB append lock"); + let (sender, receiver) = std::sync::mpsc::channel(); + let query_root = root.clone(); + let handle = std::thread::spawn(move || { + let result = read_agent_db_incomplete_provider_request_ids_at( + &query_root, + "code-prototype", + "run-lifecycle-1", + ); + sender.send(result).expect("send Provider query result"); + }); + assert!(matches!( + receiver.recv_timeout(std::time::Duration::from_millis(50)), + Err(std::sync::mpsc::RecvTimeoutError::Timeout) + )); + drop(guard); + assert_eq!( + receiver + .recv_timeout(std::time::Duration::from_secs(2)) + .expect("query resumes after append lock release") + .expect("locked Provider query succeeds"), + vec![request_id] + ); + handle.join().expect("join Provider query thread"); + fs::remove_dir_all(root).ok(); + } + + #[test] + fn incomplete_provider_request_query_rejects_non_target_duplicate_reversed_and_multi_terminal_sequences( + ) { + let fixtures = [ + ("duplicate", vec!["started", "started"]), + ("reversed", vec!["completed", "started"]), + ("multi-terminal", vec!["started", "completed", "failed"]), + ]; + for (index, (name, statuses)) in fixtures.into_iter().enumerate() { + let root = unique_agent_db_test_root(&format!("provider-query-{name}")); + let request_id = provider_request_id(char::from(b'1' + index as u8)); + let records = statuses + .into_iter() + .map(|status| { + let mut record = provider_lifecycle_record(&request_id, status); + record["agentId"] = serde_json::Value::String("unrelated-agent".to_string()); + record["runId"] = serde_json::Value::String("unrelated-run".to_string()); + with_agent_db_envelope(record) + }) + .collect::>(); + write_agent_db_records(&root, &records); + let error = read_agent_db_incomplete_provider_request_ids_at( + &root, + "code-prototype", + "run-lifecycle-1", + ) + .expect_err("invalid non-target Provider sequence must fail the whole query"); + assert!(!error.is_empty(), "{name}"); + fs::remove_dir_all(root).ok(); + } + } + + #[test] + fn lifecycle_idempotency_rejects_existing_records_with_extra_fields() { + let root = unique_agent_db_test_root("lifecycle-extra-field"); + fs::create_dir_all(root.join(".agent")).expect("create Agent DB directory"); + let request_id = provider_request_id('2'); + let clean = provider_lifecycle_record(&request_id, "started"); + let mut tampered = clean.clone(); + tampered["schemaVersion"] = + serde_json::Value::String(GAME_CREATOR_AGENT_DB_SCHEMA_VERSION.to_string()); + tampered["updatedAt"] = serde_json::Value::Number(1_u64.into()); + tampered["unexpectedPayload"] = + serde_json::Value::String("must-not-be-accepted".to_string()); + fs::write( + root.join(".agent/agent.db"), + format!( + "{}\n", + serde_json::to_string(&tampered).expect("serialize tampered record") + ), + ) + .expect("write tampered lifecycle record"); + + let error = append_agent_db_lifecycle_record_idempotent( + &root, + "requestId", + &request_id, + "status", + "started", + clean, + ) + .expect_err("extra lifecycle fields must fail closed"); + assert!(error.contains("字段集合"), "{error}"); + + fs::remove_dir_all(root).ok(); + } + + #[test] + fn finalization_lifecycle_rejects_a_stage_without_its_predecessor() { + let root = unique_agent_db_test_root("finalization-stage-order"); + fs::create_dir_all(root.join(".agent")).expect("create agent directory"); + let assistant_stage = + finalization_lifecycle_record(TEST_FINALIZATION_ID, "assistant-persisted"); + let error = append_agent_db_lifecycle_record_idempotent( + &root, + "finalizationId", + TEST_FINALIZATION_ID, + "stage", + "assistant-persisted", + assistant_stage, + ) + .expect_err("assistant stage without prepared must fail closed"); + assert!(error.contains("缺少前序阶段"), "{error}"); + + fs::remove_dir_all(root).ok(); + } + + #[test] + fn finalization_lifecycle_requires_one_exact_common_identity() { + let root = unique_agent_db_test_root("finalization-common-identity"); + append_agent_db_lifecycle_record_idempotent( + &root, + "finalizationId", + TEST_FINALIZATION_ID, + "stage", + "prepared", + finalization_lifecycle_record(TEST_FINALIZATION_ID, "prepared"), + ) + .expect("append prepared finalization lifecycle"); + let mut mismatched = + finalization_lifecycle_record(TEST_FINALIZATION_ID, "assistant-persisted"); + mismatched["messageId"] = serde_json::Value::String(finalization_id('c')); + let error = append_agent_db_lifecycle_record_idempotent( + &root, + "finalizationId", + TEST_FINALIZATION_ID, + "stage", + "assistant-persisted", + mismatched, + ) + .expect_err("same finalizationId with another messageId must fail closed"); + assert!(error.contains("内容冲突"), "{error}"); + fs::remove_dir_all(root).ok(); + } + + #[test] + fn finalization_lifecycle_rejects_physically_reversed_jsonl_stages() { + let root = unique_agent_db_test_root("finalization-physical-stage-order"); + let finalization_id = TEST_FINALIZATION_ID; + write_agent_db_records( + &root, + &[ + with_agent_db_envelope(finalization_lifecycle_record( + finalization_id, + "assistant-persisted", + )), + with_agent_db_envelope(finalization_lifecycle_record(finalization_id, "prepared")), + ], + ); + + let error = append_agent_db_lifecycle_record_idempotent( + &root, + "finalizationId", + finalization_id, + "stage", + "assistant-persisted", + finalization_lifecycle_record(finalization_id, "assistant-persisted"), + ) + .expect_err("stage set with reversed JSONL order must fail closed"); + assert!(error.contains("物理顺序倒置"), "{error}"); + let read_error = read_agent_db_lifecycle_transitions_at( + &root, + AGENT_DB_FINALIZATION_LIFECYCLE_RECORD_TYPE, + "finalizationId", + finalization_id, + ) + .expect_err("read helper must reject the same reversed physical order"); + assert!(read_error.contains("物理顺序倒置"), "{read_error}"); + + fs::remove_dir_all(root).ok(); + } + #[test] fn agent_db_capacity_reserves_terminal_receipt_space_without_rotation() { let path = Path::new("agent.db"); @@ -6946,6 +9367,24 @@ mod agent_db_security_tests { ensure_agent_db_append_capacity( path, AGENT_DB_MAX_ORDINARY_APPEND_BYTES, + AGENT_DB_LIFECYCLE_TERMINAL_RESERVE_BYTES, + AGENT_DB_MAX_ORDINARY_APPEND_BYTES + AGENT_DB_LIFECYCLE_TERMINAL_RESERVE_BYTES, + "lifecycle/finalization terminal 尾部配额", + ) + .expect("lifecycle terminal may consume only its dedicated reserve"); + let lifecycle_error = ensure_agent_db_append_capacity( + path, + AGENT_DB_MAX_ORDINARY_APPEND_BYTES + AGENT_DB_LIFECYCLE_TERMINAL_RESERVE_BYTES, + 1, + AGENT_DB_MAX_ORDINARY_APPEND_BYTES + AGENT_DB_LIFECYCLE_TERMINAL_RESERVE_BYTES, + "lifecycle/finalization terminal 尾部配额", + ) + .expect_err("lifecycle terminal must preserve the action receipt reserve"); + assert!(lifecycle_error.contains("尾部配额"), "{lifecycle_error}"); + + ensure_agent_db_append_capacity( + path, + AGENT_DB_MAX_ORDINARY_APPEND_BYTES + AGENT_DB_LIFECYCLE_TERMINAL_RESERVE_BYTES, AGENT_DB_TERMINAL_RESERVE_BYTES, AGENT_DB_MAX_ACTION_RECEIPT_SCAN_BYTES, "terminal receipt 追加硬上限", @@ -6965,17 +9404,415 @@ mod agent_db_security_tests { #[test] fn ordinary_append_soft_limit_preserves_action_receipt_record_slots() { assert_eq!( - AGENT_DB_MAX_ORDINARY_APPEND_BYTES + AGENT_DB_TERMINAL_RESERVE_BYTES, + AGENT_DB_MAX_ORDINARY_APPEND_BYTES + + AGENT_DB_LIFECYCLE_TERMINAL_RESERVE_BYTES + + AGENT_DB_TERMINAL_RESERVE_BYTES, AGENT_DB_MAX_ACTION_RECEIPT_SCAN_BYTES ); + assert_eq!(AGENT_DB_MAX_ORDINARY_APPEND_RECORDS, 999_808); assert_eq!( AGENT_DB_MAX_SCAN_RECORDS - usize::try_from(AGENT_DB_TERMINAL_RESERVE_RECORDS) - .expect("reserve count fits usize"), + .expect("action reserve count fits usize"), 999_936 ); } + #[test] + fn finalization_reservation_requires_exact_path_chars_and_lifecycle_snapshot() { + let prepared = with_agent_db_envelope(finalization_lifecycle_record( + TEST_FINALIZATION_ID, + "prepared", + )); + let reservation = AgentDbFinalizationCapacityReservation::from_prepared(&prepared) + .expect("build finalization reservation"); + let assistant = serde_json::json!({ + "recordType": "conversation.message", + "agentId": "code-prototype", + "sessionId": "session-finalization-1", + "role": "assistant", + "path": ".agent/conversations/agents/code-prototype/sessions/session-finalization-1.jsonl", + "finalizationId": TEST_FINALIZATION_ID, + "messageId": TEST_FINALIZATION_MESSAGE_ID, + }); + assert!(reservation.matches_assistant_audit(&assistant)); + let mut wrong_path = assistant; + wrong_path["path"] = serde_json::Value::String( + ".agent/conversations/agents/code-prototype/sessions/other.jsonl".to_string(), + ); + assert!(!reservation.matches_assistant_audit(&wrong_path)); + + let completed = serde_json::json!({ + "recordType": "agent.runtime.completed", + "agentId": "code-prototype", + "taskId": "task-finalization-1", + "sessionId": "session-finalization-1", + "runId": "run-finalization-1", + "source": "test", + "finalizationId": TEST_FINALIZATION_ID, + "messageId": TEST_FINALIZATION_MESSAGE_ID, + "responseFingerprint": "4".repeat(64), + "responseChars": 2, + }); + assert!(reservation.matches_completed_audit(&completed)); + let mut wrong_chars = completed; + wrong_chars["responseChars"] = serde_json::Value::Number(3_u64.into()); + assert!(!reservation.matches_completed_audit(&wrong_chars)); + + let matching_stage = with_agent_db_envelope(finalization_lifecycle_record( + TEST_FINALIZATION_ID, + "assistant-persisted", + )); + assert!(reservation.matches_lifecycle(&matching_stage)); + let mut wrong_snapshot = matching_stage; + wrong_snapshot["planRevision"] = serde_json::Value::Number(2_u64.into()); + assert!(!reservation.matches_lifecycle(&wrong_snapshot)); + } + + #[test] + fn physical_hard_capacity_counts_active_finalization_reservations() { + let path = Path::new("agent.db"); + let reserved_records = AGENT_DB_FINALIZATION_CRITICAL_RECORDS_PER_SEQUENCE - 1; + let reserved_bytes = + (AGENT_DB_FINALIZATION_CRITICAL_MAX_RECORD_BYTES as u64 + 1) * reserved_records as u64; + ensure_agent_db_physical_capacity_with_reservations( + path, + AGENT_DB_MAX_SCAN_RECORDS - reserved_records, + AGENT_DB_MAX_ACTION_RECEIPT_SCAN_BYTES - reserved_bytes, + reserved_records, + reserved_bytes, + ) + .expect("physical hard capacity may be reserved exactly"); + let record_error = ensure_agent_db_physical_capacity_with_reservations( + path, + AGENT_DB_MAX_SCAN_RECORDS - reserved_records + 1, + 0, + reserved_records, + 0, + ) + .expect_err("reserved records must count against the global hard capacity"); + assert!(record_error.contains("物理记录硬容量"), "{record_error}"); + let byte_error = ensure_agent_db_physical_capacity_with_reservations( + path, + 0, + AGENT_DB_MAX_ACTION_RECEIPT_SCAN_BYTES - reserved_bytes + 1, + 0, + reserved_bytes, + ) + .expect_err("reserved bytes must count against the global hard capacity"); + assert!(byte_error.contains("物理字节硬容量"), "{byte_error}"); + } + + #[test] + fn prepared_finalization_reserves_six_global_slots_from_legacy_overfull_ordinary_db() { + let root = unique_agent_db_test_root("prepared-global-reservation"); + write_agent_db_empty_record_fixture( + &root, + AGENT_DB_MAX_SCAN_RECORDS - AGENT_DB_FINALIZATION_CRITICAL_RECORDS_PER_SEQUENCE, + ); + append_agent_db_lifecycle_record_idempotent( + &root, + "finalizationId", + TEST_FINALIZATION_ID, + "stage", + "prepared", + finalization_lifecycle_record(TEST_FINALIZATION_ID, "prepared"), + ) + .expect("legacy ordinary rows may coexist when all seven physical slots remain"); + + for (label, record) in [ + ( + "ordinary", + serde_json::json!({"recordType": "test.after-prepared"}), + ), + ( + "action", + serde_json::json!({ + "recordType": "agent.runtime.tool_observation", + "agentId": "code-prototype", + "runId": "run-finalization-1", + "actionId": TEST_ACTION_ID, + "status": "ok", + }), + ), + ] { + let error = append_agent_db_record(&root, record) + .err() + .unwrap_or_else(|| panic!("{label} append must preserve the reservation")); + assert!( + error.contains("finalization reservation"), + "{label}: {error}" + ); + } + let lifecycle_error = append_agent_db_record_fixture( + &root, + serde_json::json!({ + "recordType": AGENT_DB_PROVIDER_REQUEST_LIFECYCLE_RECORD_TYPE, + "status": "completed", + }), + ) + .expect_err("unrelated lifecycle append must preserve the reservation"); + assert!( + lifecycle_error.contains("finalization reservation"), + "{lifecycle_error}" + ); + + let other_finalization_id = finalization_id('a'); + let other_message_id = finalization_id('b'); + for (label, finalization_id, message_id) in [ + ( + "wrong-finalization", + other_finalization_id.as_str(), + TEST_FINALIZATION_MESSAGE_ID, + ), + ( + "wrong-message", + TEST_FINALIZATION_ID, + other_message_id.as_str(), + ), + ] { + let error = append_agent_db_record( + &root, + serde_json::json!({ + "recordType": "conversation.message", + "role": "assistant", + "agentId": "code-prototype", + "sessionId": "session-finalization-1", + "path": ".agent/conversations/agents/code-prototype/sessions/session-finalization-1.jsonl", + "finalizationId": finalization_id, + "messageId": message_id, + }), + ) + .err() + .unwrap_or_else(|| panic!("{label} must not consume a reserved slot")); + assert!( + error.contains("finalization reservation"), + "{label}: {error}" + ); + } + + append_agent_db_record( + &root, + serde_json::json!({ + "recordType": "conversation.message", + "role": "assistant", + "agentId": "code-prototype", + "sessionId": "session-finalization-1", + "path": ".agent/conversations/agents/code-prototype/sessions/session-finalization-1.jsonl", + "finalizationId": TEST_FINALIZATION_ID, + "messageId": TEST_FINALIZATION_MESSAGE_ID, + }), + ) + .expect("the exact finalizationId/messageId audit consumes one reserved slot"); + + let rejected_root = unique_agent_db_test_root("prepared-global-overcommit"); + write_agent_db_empty_record_fixture( + &rejected_root, + AGENT_DB_MAX_SCAN_RECORDS - AGENT_DB_FINALIZATION_CRITICAL_RECORDS_PER_SEQUENCE, + ); + append_agent_db_record( + &rejected_root, + serde_json::json!({ + "recordType": "agent.runtime.tool_observation", + "agentId": "code-prototype", + "runId": "run-finalization-1", + "actionId": TEST_ACTION_ID, + "status": "ok", + }), + ) + .expect("legacy action may occupy the last unreserved slot before prepared"); + let error = append_agent_db_lifecycle_record_idempotent( + &rejected_root, + "finalizationId", + TEST_FINALIZATION_ID, + "stage", + "prepared", + finalization_lifecycle_record(TEST_FINALIZATION_ID, "prepared"), + ) + .expect_err("prepared must not report success when only six physical slots remain"); + assert!(error.contains("物理记录硬容量"), "{error}"); + + fs::remove_dir_all(root).ok(); + fs::remove_dir_all(rejected_root).ok(); + } + + #[test] + fn lifecycle_terminal_capacity_cannot_consume_action_receipt_record_slots() { + let root = unique_agent_db_test_root("lifecycle-preserves-action-receipts"); + write_agent_db_reserved_tail_fixture( + &root, + &serde_json::json!({ + "recordType": AGENT_DB_PROVIDER_REQUEST_LIFECYCLE_RECORD_TYPE, + "status": "completed", + }), + AGENT_DB_LIFECYCLE_TERMINAL_RESERVE_RECORDS as usize, + ); + + let lifecycle_error = append_agent_db_record_fixture( + &root, + serde_json::json!({ + "recordType": AGENT_DB_PROVIDER_REQUEST_LIFECYCLE_RECORD_TYPE, + "status": "completed", + }), + ) + .expect_err("lifecycle terminal must stop before action receipt slots"); + assert!( + lifecycle_error.contains("lifecycle/finalization terminal 尾部配额"), + "{lifecycle_error}" + ); + append_agent_db_record( + &root, + serde_json::json!({ + "recordType": "agent.runtime.tool_observation", + "agentId": "implementation-engineer", + "runId": "run-after-lifecycle-capacity", + "actionId": TEST_ACTION_ID, + "status": "ok", + }), + ) + .expect("action terminal still uses its dedicated hard reserve"); + + fs::remove_dir_all(root).ok(); + } + + #[test] + fn action_terminal_capacity_cannot_consume_lifecycle_finalization_slots() { + let root = unique_agent_db_test_root("action-preserves-lifecycle-finalization"); + let action_terminal = serde_json::json!({ + "recordType": "agent.runtime.tool_observation", + "agentId": "implementation-engineer", + "runId": "run-action-tail", + "actionId": TEST_ACTION_ID, + "status": "ok", + }); + write_agent_db_reserved_tail_fixture( + &root, + &action_terminal, + AGENT_DB_TERMINAL_RESERVE_RECORDS as usize, + ); + + let action_error = append_agent_db_record(&root, action_terminal) + .expect_err("action terminal must stop at its own tail quota"); + assert!( + action_error.contains("action terminal 尾部配额"), + "{action_error}" + ); + append_agent_db_record_fixture( + &root, + serde_json::json!({ + "recordType": AGENT_DB_PROVIDER_REQUEST_LIFECYCLE_RECORD_TYPE, + "status": "completed", + }), + ) + .expect("lifecycle terminal still uses its dedicated tail quota"); + + fs::remove_dir_all(root).ok(); + } + + #[test] + fn finalization_prepared_reserves_its_complete_critical_tail_sequence() { + let provider_terminal = serde_json::json!({ + "recordType": AGENT_DB_PROVIDER_REQUEST_LIFECYCLE_RECORD_TYPE, + "status": "completed", + }); + let root = unique_agent_db_test_root("finalization-critical-exact-boundary"); + write_agent_db_reserved_tail_fixture( + &root, + &provider_terminal, + AGENT_DB_LIFECYCLE_TERMINAL_RESERVE_RECORDS as usize + - AGENT_DB_FINALIZATION_CRITICAL_RECORDS_PER_SEQUENCE, + ); + let finalization_id = TEST_FINALIZATION_ID; + append_agent_db_lifecycle_record_idempotent( + &root, + "finalizationId", + finalization_id, + "stage", + "prepared", + finalization_lifecycle_record(finalization_id, "prepared"), + ) + .expect("prepared reserves every remaining finalization-critical slot"); + ensure_conversation_message_audit_at( + &root, + Some("code-prototype"), + Some("session-finalization-1"), + TEST_FINALIZATION_MESSAGE_ID, + serde_json::json!({ + "recordType": "conversation.message", + "agentId": "code-prototype", + "sessionId": "session-finalization-1", + "role": "assistant", + "path": ".agent/conversations/agents/code-prototype/sessions/session-finalization-1.jsonl", + "finalizationId": finalization_id, + "messageId": TEST_FINALIZATION_MESSAGE_ID, + }), + ) + .expect("assistant audit consumes its prepared reservation"); + for stage in ["assistant-persisted", "runtime-completed", "goal-completed"] { + append_agent_db_lifecycle_record_idempotent( + &root, + "finalizationId", + finalization_id, + "stage", + stage, + finalization_lifecycle_record(finalization_id, stage), + ) + .unwrap_or_else(|error| panic!("append reserved {stage} stage: {error}")); + } + for record_type in [ + "agent.runtime.completed", + "agent.runtime.background_task.completed", + ] { + append_agent_db_record( + &root, + serde_json::json!({ + "recordType": record_type, + "agentId": "code-prototype", + "taskId": "task-finalization-1", + "sessionId": "session-finalization-1", + "runId": "run-finalization-1", + "source": "test", + "finalizationId": finalization_id, + "messageId": TEST_FINALIZATION_MESSAGE_ID, + "responseFingerprint": "4".repeat(64), + "responseChars": 2, + }), + ) + .unwrap_or_else(|error| panic!("append reserved {record_type}: {error}")); + } + let terminal_error = append_agent_db_record_fixture(&root, provider_terminal.clone()) + .expect_err("the exact critical tail boundary leaves no unreserved lifecycle slot"); + assert!( + terminal_error.contains("lifecycle/finalization terminal 尾部配额"), + "{terminal_error}" + ); + + let rejected_root = unique_agent_db_test_root("finalization-critical-overcommit"); + write_agent_db_reserved_tail_fixture( + &rejected_root, + &provider_terminal, + AGENT_DB_LIFECYCLE_TERMINAL_RESERVE_RECORDS as usize + - AGENT_DB_FINALIZATION_CRITICAL_RECORDS_PER_SEQUENCE + + 1, + ); + let error = append_agent_db_lifecycle_record_idempotent( + &rejected_root, + "finalizationId", + TEST_FINALIZATION_ID, + "stage", + "prepared", + finalization_lifecycle_record(TEST_FINALIZATION_ID, "prepared"), + ) + .expect_err("prepared must fail before assistant visibility when six slots remain"); + assert!( + error.contains("lifecycle/finalization terminal 尾部配额"), + "{error}" + ); + + fs::remove_dir_all(root).ok(); + fs::remove_dir_all(rejected_root).ok(); + } + #[test] fn generic_terminal_append_rejects_the_full_scan_record_count_boundary() { let root = unique_agent_db_test_root("terminal-record-count-limit"); @@ -7176,8 +10013,12 @@ mod agent_db_security_tests { let line = serialize_agent_db_record(serde_json::json!({"recordType": "test"})) .expect("serialize append record"); - let error = append_agent_db_line_unlocked(&mut storage, &line) - .expect_err("post-write path identity check must fail"); + let error = append_agent_db_classified_line_unlocked( + &mut storage, + &line, + AgentDbRecordAppendClass::Ordinary, + ) + .expect_err("post-write path identity check must fail"); assert!(error.contains("替换"), "{error}"); assert_eq!( fs::read(root.join(".agent/agent.db")).expect("read current agent db"), @@ -8254,4 +11095,112 @@ mod idempotent_conversation_tests { std::fs::remove_dir_all(root).ok(); } + + #[test] + fn finalization_conversation_retry_requires_exact_finalization_and_message_identity() { + const FINALIZATION_ID: &str = "agent-finalization-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const MESSAGE_ID: &str = "agent-finalization-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + let root = unique_conversation_test_root(); + init_local_game_project_at(&root, "project-1", "finalization 对话审计测试") + .expect("init project"); + let session_id = legacy_agent_conversation_session_id("design-director"); + let content = "同一条 finalization assistant 回复"; + + append_idempotent_test_message(&root, &session_id, MESSAGE_ID, content) + .expect("append legacy message-only audit"); + for _ in 0..2 { + append_local_conversation_message_for_session_idempotent_with_finalization_at( + &root, + Some("design-director"), + Some(&session_id), + LocalConversationMessage { + role: "assistant".to_string(), + content: content.to_string(), + agent_id: None, + }, + MESSAGE_ID, + FINALIZATION_ID, + ) + .expect("append or replay exact finalization conversation audit"); + } + + let audits = std::fs::read_to_string(root.join(".agent/agent.db")) + .expect("read finalization conversation audits") + .lines() + .filter_map(|line| serde_json::from_str::(line).ok()) + .filter(|record| { + record.get("recordType").and_then(serde_json::Value::as_str) + == Some("conversation.message") + && record.get("messageId").and_then(serde_json::Value::as_str) + == Some(MESSAGE_ID) + }) + .collect::>(); + assert_eq!(audits.len(), 2); + assert_eq!( + audits + .iter() + .filter(|record| { + record + .get("finalizationId") + .and_then(serde_json::Value::as_str) + == Some(FINALIZATION_ID) + && agent_db_record_append_class(record) + == AgentDbRecordAppendClass::FinalizationCritical + }) + .count(), + 1 + ); + assert_eq!( + audits + .iter() + .filter(|record| record.get("finalizationId").is_none()) + .filter(|record| { + agent_db_record_append_class(record) == AgentDbRecordAppendClass::Ordinary + }) + .count(), + 1 + ); + + std::fs::remove_dir_all(root).ok(); + } + + #[test] + fn finalization_conversation_retry_rejects_conflicting_audit_payload() { + const FINALIZATION_ID: &str = "agent-finalization-cccccccccccccccccccccccccccccccc"; + const MESSAGE_ID: &str = "agent-finalization-dddddddddddddddddddddddddddddddd"; + let root = unique_conversation_test_root(); + init_local_game_project_at(&root, "project-1", "finalization 冲突审计测试") + .expect("init project"); + let session_id = legacy_agent_conversation_session_id("design-director"); + append_agent_db_record( + &root, + serde_json::json!({ + "recordType": "conversation.message", + "agentId": "design-director", + "sessionId": session_id, + "role": "tool", + "path": ".agent/conversations/agents/design-director.jsonl", + "messageId": MESSAGE_ID, + "finalizationId": FINALIZATION_ID, + }), + ) + .expect("append conflicting generic audit fixture"); + + let error = append_local_conversation_message_for_session_idempotent_with_finalization_at( + &root, + Some("design-director"), + Some(&session_id), + LocalConversationMessage { + role: "assistant".to_string(), + content: "不能被伪造审计跳过的最终回复".to_string(), + agent_id: None, + }, + MESSAGE_ID, + FINALIZATION_ID, + ) + .expect_err("conflicting finalization audit must fail closed"); + assert!(error.contains("审计内容冲突"), "{error}"); + + std::fs::remove_dir_all(root).ok(); + } } 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 da386baa2..6ada8905d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests.rs @@ -3,7 +3,7 @@ use serde_json::Value; use sha2::{Digest as _, Sha256}; use std::collections::BTreeSet; use std::io::{Read, Write}; -use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, Barrier, Condvar, Mutex as StdMutex, MutexGuard as StdMutexGuard}; use std::time::{SystemTime, UNIX_EPOCH}; use zip::write::SimpleFileOptions; @@ -197,6 +197,101 @@ async fn agent_goal_edit_pause_resume_keeps_one_session_and_run_until_completion terminal_task.goal_status.as_deref(), Some(AGENT_GOAL_STATUS_COMPLETED) ); + let agent_db = read_agent_db_records_for_test(&root); + let public_task_records = agent_db + .iter() + .filter(|record| { + matches!( + record["recordType"].as_str(), + Some( + "agent.runtime.background_task.queued" + | "agent.runtime.background_task.recovered" + | "agent.runtime.background_task" + | "agent.runtime.turn" + ) + ) && record["runId"] == run_id + }) + .collect::>(); + assert!(!public_task_records.is_empty()); + assert!(public_task_records.iter().all(|record| { + record["goalBound"] == true + && record["task"].is_null() + && record["taskChars"].as_u64().is_some_and(|value| value > 0) + && record["taskSha256"] + .as_str() + .is_some_and(|value| value.len() == 64) + })); + let public_agent_db = serde_json::to_string(&agent_db).expect("serialize Goal Agent DB"); + assert!(!public_agent_db.contains("实现并验证一个可运行的最小原型")); + assert!(!public_agent_db.contains("实现、测试并说明一个可运行的最小原型")); + let events = fs::read_to_string(root.join(".agent/runtime/events/code-prototype.jsonl")) + .expect("read Goal runtime events"); + assert!(!events.contains("实现并验证一个可运行的最小原型")); + assert!(!events.contains("实现、测试并说明一个可运行的最小原型")); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn agent_goal_bound_cancel_audits_hash_task_instead_of_copying_outcome() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-goal-cancel-redaction", "Goal 取消审计项目") + .expect("project init"); + let run_id = "goal-cancel-redaction-run"; + let goal_outcome = "GOAL_CANCEL_PRIVATE_OUTCOME 交付一个可验证的私有目标"; + let mut state = start_game_creator_agent_runtime_task_at( + &root, + "code-prototype", + goal_outcome, + run_id, + "agent-background-task", + "准备执行 Goal", + vec!["取消前保持私有".to_string()], + ) + .expect("start Goal cancellation fixture"); + seed_game_creator_agent_goal_for_runtime_test_at( + &root, + &mut state, + goal_outcome, + AGENT_GOAL_STATUS_ACTIVE, + ) + .expect("bind active Goal to cancellation fixture"); + fs::remove_file(root.join(".agent/agent.db")).expect("reset pre-Goal public audit fixture"); + fs::remove_file(root.join(".agent/runtime/events/code-prototype.jsonl")) + .expect("reset pre-Goal event fixture"); + + let cancelled = cancel_game_creator_agent_runtime_task_at(&root, "code-prototype", run_id) + .expect("cancel Goal-bound Runtime"); + assert_eq!(cancelled.state.status, "cancelled"); + assert_eq!(cancelled.state.phase, "cancelled"); + let records = read_agent_db_records_for_test(&root); + let cancelled_record = records + .iter() + .find(|record| { + record["recordType"] == "agent.runtime.background_task.cancelled" + && record["runId"] == run_id + }) + .expect("Goal-bound cancellation audit"); + assert_eq!(cancelled_record["goalBound"], true); + assert!(cancelled_record["task"].is_null()); + assert_eq!(cancelled_record["taskChars"], goal_outcome.chars().count()); + assert_eq!( + cancelled_record["taskSha256"], + format!("{:x}", Sha256::digest(goal_outcome.as_bytes())) + ); + let public_records = serde_json::to_string(&records).expect("serialize cancellation audit"); + let public_events = fs::read_to_string(root.join(".agent/runtime/events/code-prototype.jsonl")) + .expect("read Goal cancellation events"); + assert!(!public_records.contains(goal_outcome)); + assert!(!public_events.contains(goal_outcome)); + let retry_error = retry_game_creator_agent_runtime_task_at( + &root, + "code-prototype", + run_id, + "goal-cancel-redaction-retry", + ) + .expect_err("Goal-bound task must not downgrade into an ordinary retry run"); + assert!(retry_error.contains("不能使用普通 retry")); fs::remove_dir_all(root).ok(); } @@ -5189,7 +5284,8 @@ async fn background_agent_runtime_marks_response_plan_step_failed_when_final_rep "apiKey": "design-key", "baseUrl": {base_url:?}, "model": "design-runtime-model", - "apiKind": "openai_responses" + "apiKind": "openai_responses", + "retryBackoffMs": 1 }} }} }}"# @@ -12707,7 +12803,6 @@ async fn background_agent_runtime_command_exec_repairs_failure_and_finishes_once .count(), 1 ); - fs::remove_dir_all(root).ok(); } @@ -14803,6 +14898,9 @@ fn background_finalization_rechecks_stale_credential_before_assistant_persistenc AgentBackgroundFinalizationOutcome::Pending(error) => { panic!("stale credential must not enter finalization: {error}") } + AgentBackgroundFinalizationOutcome::Cancelled(_) => { + panic!("stale credential recheck must not cancel the run") + } }; assert!(blocker .detail @@ -14825,6 +14923,84 @@ fn background_finalization_rechecks_stale_credential_before_assistant_persistenc fs::remove_dir_all(root).ok(); } +#[test] +fn background_finalization_honors_existing_cancel_before_any_completion_write() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "最终回复锁内取消项目").expect("project init"); + let run_id = "design-finalization-existing-cancel-run"; + let state = start_game_creator_agent_runtime_task_at( + &root, + "design-director", + "取消必须先于最终回复持久化", + run_id, + "agent-background-task", + "准备最终回复", + vec!["取消最终回复".to_string()], + ) + .expect("start finalization cancellation runtime"); + write_game_creator_agent_runtime_cancel_request( + &root, + "design-director", + run_id, + "测试最终回复锁内取消", + ) + .expect("write finalization cancel tombstone"); + + let outcome = finish_game_creator_agent_background_runtime_turn_at( + &root, + state.clone(), + "取消后绝不能落盘的回复", + 0, + &[], + ) + .expect("existing cancel must be a terminal finalization outcome"); + let cancelled = match outcome { + AgentBackgroundFinalizationOutcome::Cancelled(cancelled) => cancelled, + AgentBackgroundFinalizationOutcome::Completed(_) => { + panic!("existing cancel must win over finalization") + } + AgentBackgroundFinalizationOutcome::Stale(blocker) => { + panic!( + "existing cancel must not be treated as stale: {}", + blocker.summary + ) + } + AgentBackgroundFinalizationOutcome::Pending(error) => { + panic!("existing cancel must not leave finalization pending: {error}") + } + }; + assert_eq!(cancelled.status, "cancelled"); + assert_eq!(cancelled.phase, "cancelled"); + assert!( + read_game_creator_agent_runtime_finalization_journal(&root, "design-director", run_id,) + .expect("read absent finalization journal") + .is_none() + ); + let conversation = read_local_conversation_for_session_at( + &root, + Some("design-director"), + Some(&state.session_id), + ) + .expect("read cancelled finalization conversation"); + assert!(!conversation.messages.iter().any(|message| { + message.role == "assistant" && message.content == "取消后绝不能落盘的回复" + })); + let records = read_agent_db_records_for_test(&root); + assert!(!records.iter().any(|record| { + record["runId"] == run_id + && matches!( + record["recordType"].as_str(), + Some( + "agent.runtime.finalization.lifecycle" + | "agent.runtime.completed" + | "agent.runtime.background_task.completed" + ) + ) + })); + + fs::remove_dir_all(root).ok(); +} + #[test] fn finalization_resume_completes_persisted_assistant_without_llm_replay() { let root = unique_project_path(); @@ -14987,6 +15163,245 @@ fn finalization_resume_completes_persisted_assistant_without_llm_replay() { assert!(!finalization_still_exists); } +#[test] +fn finalization_prepared_lifecycle_failure_remains_recoverable() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "最终回复审计恢复项目").expect("project init"); + let run_id = "design-finalization-prepared-lifecycle-run"; + let state = start_game_creator_agent_runtime_task_at( + &root, + "design-director", + "在 prepared 审计失败后恢复最终回复", + run_id, + "agent-background-task", + "准备最终回复", + vec!["保存最终回复".to_string()], + ) + .expect("start finalization lifecycle runtime"); + fs::write( + root.join(".agent/runtime/test-fail-next-agent-db-record"), + "agent.runtime.finalization.lifecycle", + ) + .expect("inject prepared lifecycle failure"); + + let response = "prepared lifecycle 修复后只保存一次的回复"; + let outcome = finish_game_creator_agent_background_runtime_turn_at( + &root, + state.clone(), + response, + 0, + &[], + ) + .expect("prepared lifecycle failure must be recoverable"); + assert!(matches!( + outcome, + AgentBackgroundFinalizationOutcome::Pending(ref error) + if error.contains("agent.runtime.finalization.lifecycle") + )); + let journal = + read_game_creator_agent_runtime_finalization_journal(&root, "design-director", run_id) + .expect("read prepared lifecycle journal") + .expect("prepared lifecycle journal exists"); + assert_eq!(journal.status, AGENT_RUNTIME_FINALIZATION_STATUS_PREPARED); + let pending = read_game_creator_agent_runtime_at(&root, "design-director") + .expect("read pending finalization runtime") + .state; + assert_eq!(pending.status, "running"); + assert_eq!(pending.phase, "finalizing"); + let before_resume = read_local_conversation_for_session_at( + &root, + Some("design-director"), + Some(&state.session_id), + ) + .expect("read conversation before finalization recovery"); + assert!(!before_resume + .messages + .iter() + .any(|message| message.role == "assistant" && message.content == response)); + + let resumed = resume_game_creator_agent_background_tasks_at(&root) + .expect("resume prepared lifecycle finalization"); + assert_eq!(resumed.len(), 1); + let completed = wait_for_agent_runtime_idle(&root, "design-director"); + assert_eq!(completed.phase, "completed"); + assert_eq!(completed.run_id, run_id); + let conversation = read_local_conversation_for_session_at( + &root, + Some("design-director"), + Some(&state.session_id), + ) + .expect("read recovered finalization conversation"); + assert_eq!( + conversation + .messages + .iter() + .filter(|message| message.role == "assistant" && message.content == response) + .count(), + 1 + ); + assert!( + read_game_creator_agent_runtime_finalization_journal(&root, "design-director", run_id,) + .expect("read cleaned finalization journal") + .is_none() + ); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn finalization_completed_audit_requires_exact_payload() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "最终回复精确审计项目").expect("project init"); + let expected = serde_json::json!({ + "recordType": "agent.runtime.completed", + "agentId": "design-director", + "taskId": "design-director", + "sessionId": "agent-session-design-director", + "runId": "finalization-exact-audit-run", + "source": "agent-background-task", + "finalizationId": "agent-finalization-eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + "messageId": "agent-finalization-ffffffffffffffffffffffffffffffff", + "responseFingerprint": "a".repeat(64), + "responseChars": 8, + }); + let mut conflicting = expected.clone(); + conflicting["responseChars"] = serde_json::Value::Number(7_u64.into()); + append_agent_db_record(&root, conflicting).expect("append conflicting completed audit"); + + let error = agent_db_record_exists_for_finalization(&root, &expected) + .expect_err("same finalization identity with a different payload must fail closed"); + assert!(error.contains("审计内容冲突"), "{error}"); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn finalization_critical_audits_complete_at_the_ordinary_capacity_boundary() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "最终回复保留容量项目").expect("project init"); + let run_id = "design-finalization-critical-capacity-run"; + let state = start_game_creator_agent_runtime_task_at( + &root, + "design-director", + "在普通审计容量耗尽后完成最终回复", + run_id, + "agent-background-task", + "验证最终回复保留容量", + vec!["保存最终回复".to_string()], + ) + .expect("start finalization capacity runtime"); + let filler_records = fill_agent_db_to_ordinary_record_capacity_for_test(&root) + .expect("fill Agent DB to the ordinary record boundary"); + assert!(filler_records > 0); + + let response = "普通审计已满时仍完整持久化的最终回复"; + let completed = match finish_game_creator_agent_background_runtime_turn_at( + &root, + state.clone(), + response, + 0, + &[], + ) + .expect("finalization must use its critical reserve") + { + AgentBackgroundFinalizationOutcome::Completed(completed) => completed, + AgentBackgroundFinalizationOutcome::Stale(blocker) => { + panic!( + "capacity boundary must not stale finalization: {}", + blocker.summary + ) + } + AgentBackgroundFinalizationOutcome::Pending(error) => { + panic!("capacity boundary must not leave finalization pending: {error}") + } + AgentBackgroundFinalizationOutcome::Cancelled(_) => { + panic!("capacity boundary must not cancel finalization") + } + }; + assert_eq!(completed.phase, "completed"); + let conversation = read_local_conversation_for_session_at( + &root, + Some("design-director"), + Some(&state.session_id), + ) + .expect("read capacity-boundary conversation"); + assert_eq!( + conversation + .messages + .iter() + .filter(|message| message.role == "assistant" && message.content == response) + .count(), + 1 + ); + + let agent_db = + fs::read_to_string(root.join(".agent/agent.db")).expect("read capacity-boundary Agent DB"); + let critical_records = agent_db + .lines() + .filter(|line| { + line.contains("agent.runtime.finalization.lifecycle") + || line.contains("conversation.message") + || line.contains("agent.runtime.completed") + || line.contains("agent.runtime.background_task.completed") + }) + .map(|line| serde_json::from_str::(line).expect("parse finalization critical audit")) + .collect::>(); + assert_eq!( + critical_records + .iter() + .filter(|record| { + record["recordType"] == "conversation.message" + && record["role"] == "assistant" + && record["agentId"] == "design-director" + && record["sessionId"] == state.session_id + }) + .count(), + 1 + ); + assert_eq!( + critical_records + .iter() + .filter(|record| { + record["recordType"] == "agent.runtime.completed" && record["runId"] == run_id + }) + .count(), + 1 + ); + assert_eq!( + critical_records + .iter() + .filter(|record| { + record["recordType"] == "agent.runtime.background_task.completed" + && record["runId"] == run_id + }) + .count(), + 1 + ); + assert_eq!( + critical_records + .iter() + .filter(|record| { + record["recordType"] == "agent.runtime.finalization.lifecycle" + && record["runId"] == run_id + }) + .map(|record| record["stage"].as_str().unwrap_or_default()) + .collect::>(), + vec![ + "prepared", + "assistant-persisted", + "runtime-completed", + "goal-completed" + ] + ); + assert!( + read_game_creator_agent_runtime_finalization_journal(&root, "design-director", run_id) + .expect("read finalization journal after capacity-boundary completion") + .is_none() + ); + + fs::remove_dir_all(root).ok(); +} + #[test] fn finalization_resume_recovers_real_assistant_append_checkpoint_once() { let root = unique_project_path(); @@ -15093,6 +15508,37 @@ fn finalization_resume_recovers_real_assistant_append_checkpoint_once() { .count(), 1 ); + let lifecycle = read_agent_db_records_for_test(&root) + .into_iter() + .filter(|record| { + record["recordType"] == "agent.runtime.finalization.lifecycle" + && record["runId"] == run_id + }) + .collect::>(); + assert_eq!(lifecycle.len(), 4); + assert_eq!( + lifecycle + .iter() + .map(|record| record["stage"].as_str().unwrap_or_default()) + .collect::>(), + vec![ + "prepared", + "assistant-persisted", + "runtime-completed", + "goal-completed" + ] + ); + assert!(lifecycle.iter().enumerate().all(|(index, record)| { + record["auditSchemaVersion"] == "game-creator-finalization-lifecycle.v1" + && record["journalSchemaVersion"] == "game-creator-runtime-finalization.v3" + && record["finalizationId"] == journal.finalization_id + && record["messageId"] == journal.message_id + && record["stageOrdinal"] == u64::try_from(index + 1).unwrap_or_default() + && record["stageAt"].as_u64().is_some_and(|value| value > 0) + && record.get("task").is_none() + && record.get("response").is_none() + && record.get("prompt").is_none() + })); assert!(resume_game_creator_agent_background_tasks_at(&root) .expect("repeat assistant checkpoint recovery") .is_empty()); @@ -15312,7 +15758,6 @@ fn finalization_resume_recovers_interrupted_sidecar_replace_backup() { .count(), 1 ); - fs::remove_dir_all(root).ok(); } @@ -15692,6 +16137,148 @@ fn finalization_cancel_completes_reply_already_persisted_to_conversation() { fs::remove_dir_all(root).ok(); } +#[test] +fn finalization_restart_applies_cancel_before_uncommitted_assistant() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "finalization 重启取消项目") + .expect("project init"); + let run_id = "design-finalization-restart-prepared-cancel-run"; + let state = start_game_creator_agent_runtime_task_at( + &root, + "design-director", + "重启后取消未提交 assistant", + run_id, + "agent-background-task", + "准备最终回复", + vec!["等待提交".to_string()], + ) + .expect("start restart prepared cancellation runtime"); + let response = "重启取消后不得出现的回复"; + let outcome = finish_game_creator_agent_background_runtime_turn_with_checkpoint_at( + &root, + state.clone(), + response, + 0, + &[], + |checkpoint| { + (checkpoint != AgentRuntimeFinalizationCheckpoint::Prepared) + .then_some(()) + .ok_or_else(|| "injected-prepared-restart-cancel-crash".to_string()) + }, + ) + .expect("prepared restart cancellation checkpoint"); + assert!(matches!( + outcome, + AgentBackgroundFinalizationOutcome::Pending(_) + )); + write_game_creator_agent_runtime_cancel_request( + &root, + "design-director", + run_id, + "模拟重启前 durable cancel", + ) + .expect("write restart cancel tombstone"); + + resume_game_creator_agent_background_tasks_at(&root) + .expect("resume prepared finalization with cancel tombstone"); + let runtime = read_game_creator_agent_runtime_at(&root, "design-director") + .expect("read restart-cancelled finalization runtime") + .state; + assert_eq!(runtime.phase, "cancelled"); + let conversation = read_local_conversation_for_session_at( + &root, + Some("design-director"), + Some(&state.session_id), + ) + .expect("read restart-cancelled conversation"); + assert!(!conversation + .messages + .iter() + .any(|message| message.role == "assistant" && message.content == response)); + assert!( + read_game_creator_agent_runtime_finalization_journal(&root, "design-director", run_id) + .expect("read dropped prepared journal") + .is_none() + ); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn finalization_restart_finishes_committed_assistant_before_late_cancel() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "finalization 重启提交点项目") + .expect("project init"); + let run_id = "design-finalization-restart-assistant-cancel-run"; + let state = start_game_creator_agent_runtime_task_at( + &root, + "design-director", + "重启后 assistant 提交点优先", + run_id, + "agent-background-task", + "准备最终回复", + vec!["保存最终回复".to_string()], + ) + .expect("start restart assistant cancellation runtime"); + let response = "重启后必须补齐完成的已提交回复"; + let outcome = finish_game_creator_agent_background_runtime_turn_with_checkpoint_at( + &root, + state.clone(), + response, + 0, + &[], + |checkpoint| { + (checkpoint != AgentRuntimeFinalizationCheckpoint::AssistantAppended) + .then_some(()) + .ok_or_else(|| "injected-assistant-restart-cancel-crash".to_string()) + }, + ) + .expect("assistant restart cancellation checkpoint"); + assert!(matches!( + outcome, + AgentBackgroundFinalizationOutcome::Pending(_) + )); + write_game_creator_agent_runtime_cancel_request( + &root, + "design-director", + run_id, + "模拟 assistant 后重启前 durable cancel", + ) + .expect("write late restart cancel tombstone"); + + resume_game_creator_agent_background_tasks_at(&root) + .expect("resume assistant-persisted finalization before cancel"); + let runtime = read_game_creator_agent_runtime_at(&root, "design-director") + .expect("read committed restart finalization runtime") + .state; + assert_eq!(runtime.phase, "completed"); + assert_eq!(runtime.last_response.as_deref(), Some(response)); + assert!(!game_creator_agent_runtime_cancel_requested( + &root, &runtime + )); + let conversation = read_local_conversation_for_session_at( + &root, + Some("design-director"), + Some(&state.session_id), + ) + .expect("read committed restart conversation"); + assert_eq!( + conversation + .messages + .iter() + .filter(|message| message.role == "assistant" && message.content == response) + .count(), + 1 + ); + assert!( + read_game_creator_agent_runtime_finalization_journal(&root, "design-director", run_id) + .expect("read completed restart journal") + .is_none() + ); + + fs::remove_dir_all(root).ok(); +} + #[test] fn finalization_resume_blocks_corrupt_journal_without_llm_replay() { let root = unique_project_path(); @@ -16486,6 +17073,9 @@ async fn stale_finalization_context_survives_restart_before_same_run_replanning( AgentBackgroundFinalizationOutcome::Pending(error) => { panic!("stale final reply must not enter finalization: {error}") } + AgentBackgroundFinalizationOutcome::Cancelled(_) => { + panic!("stale final reply must not cancel before restart") + } }; prepare_game_creator_agent_background_stale_continuation_at( &root, @@ -17554,6 +18144,29 @@ async fn background_agent_runtime_resumes_approved_auto_action_once_without_llm_ "memory.write", "ok", ); + let public_action_records = records + .iter() + .filter(|record| { + record["actionId"] == pending.action_id + && matches!( + record["recordType"].as_str(), + Some( + "agent.runtime.tool_action.executing" + | AGENT_RUNTIME_ACTION_RECEIPT_RECORD_TYPE + ) + ) + }) + .collect::>(); + assert_eq!(public_action_records.len(), 2); + assert!(public_action_records.iter().all(|record| { + record["inputSummary"] + .as_str() + .is_some_and(|summary| summary.starts_with("inputSummarySha256=")) + })); + let public_action_records_json = serde_json::to_string(&public_action_records) + .expect("serialize memory.write audit records"); + assert!(!public_action_records_json.contains("自动恢复唯一标记")); + assert!(!public_action_records_json.contains("这条自动动作只能写入一次")); assert!(!root .join( ".agent/runtime/pending-actions/design-director/design-auto-approved-recovery-run.json" @@ -21465,23 +22078,14 @@ async fn background_agent_runtime_deletes_file_then_verifies_before_completion() } #[tokio::test] -async fn background_agent_runtime_retries_empty_plan_and_final_responses() { +async fn background_agent_runtime_does_not_replay_empty_provider_response() { let root = unique_project_path(); init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init"); let (sender, receiver) = mpsc::channel(); - let converged_plan = serde_json::json!({ - "thinkingSummary": "已有信息足够,可以整理回复", - "plan": ["整理最终回复"], - "actions": [], - "response": "" - }) - .to_string(); let base_url = spawn_mock_llm_server_responses_with_capture( vec![ String::new(), - converged_plan, - String::new(), - "空响应重试后已恢复。EMPTY_RETRY_OK".to_string(), + final_tool_plan_response("不应发起第二次请求"), ], Some(sender), ); @@ -21501,45 +22105,56 @@ async fn background_agent_runtime_retries_empty_plan_and_final_responses() { start_game_creator_agent_background_task_at( &root, "design-director", - "验证后台 Agent 空响应恢复", - "design-empty-response-retry-run", + "验证后台 Agent 空响应不会自动重放", + "design-empty-response-no-replay-run", ) .expect("start background task"); - for request_index in 0..4 { - let request = receiver - .recv_timeout(Duration::from_secs(2)) - .unwrap_or_else(|_| panic!("captured llm request {}", request_index + 1)); - assert!(request.contains("POST /chat/completions HTTP/1.1")); - if request_index < 2 { - assert!(request.contains("\"max_tokens\":4000")); - } else { - assert!(request.contains("\"max_tokens\":2400")); + let request = receiver + .recv_timeout(Duration::from_secs(2)) + .expect("capture only empty-response Provider request"); + assert!(request.contains("POST /chat/completions HTTP/1.1")); + assert!(receiver.recv_timeout(Duration::from_millis(250)).is_err()); + let mut runtime = read_game_creator_agent_runtime_at(&root, "design-director") + .expect("read empty-response runtime") + .state; + for _ in 0..250 { + if runtime.phase == "failed" { + break; } - assert!(request.contains("\"reasoning_effort\":\"high\"")); + std::thread::sleep(Duration::from_millis(20)); + runtime = read_game_creator_agent_runtime_at(&root, "design-director") + .expect("poll empty-response runtime") + .state; } - assert!(receiver.recv_timeout(Duration::from_millis(200)).is_err()); - - let runtime = wait_for_agent_runtime_idle(&root, "design-director"); - assert_eq!(runtime.status, "idle"); - assert_eq!(runtime.phase, "completed"); - assert_eq!( - runtime.last_response.as_deref(), - Some("空响应重试后已恢复。EMPTY_RETRY_OK") - ); - assert!(runtime.error.is_none()); + assert_eq!(runtime.phase, "failed"); + assert!(runtime + .error + .as_deref() + .is_some_and(|error| error.contains("kind=empty-response"))); + let lifecycle = read_agent_db_records_for_test(&root) + .into_iter() + .filter(|record| { + record["recordType"] == "agent.runtime.provider_request.lifecycle" + && record["runId"] == "design-empty-response-no-replay-run" + }) + .collect::>(); + assert_eq!(lifecycle.len(), 2); + assert_eq!(lifecycle[0]["status"], "started"); + assert_eq!(lifecycle[1]["status"], "failed"); + assert_eq!(lifecycle[0]["requestId"], lifecycle[1]["requestId"]); fs::remove_dir_all(root).ok(); } #[tokio::test] -async fn background_agent_runtime_retries_repeated_transport_failures() { +async fn background_agent_runtime_does_not_replay_ambiguous_transport_failure() { 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_transport_failures_then_response( - 4, - final_tool_plan_response("连续传输失败后已恢复。TRANSIENT_RETRY_OK"), + 1, + final_tool_plan_response("不应自动重放模糊传输失败"), Some(sender), ); let _config_guard = write_test_local_config(format!( @@ -21550,7 +22165,9 @@ async fn background_agent_runtime_retries_repeated_transport_failures() { "baseUrl": {base_url:?}, "model": "design-runtime-model", "apiKind": "openai_chat", - "stream": false + "stream": false, + "maxRetries": 5, + "retryBackoffMs": 1 }} }} }}"# @@ -21559,82 +22176,215 @@ async fn background_agent_runtime_retries_repeated_transport_failures() { start_game_creator_agent_background_task_at( &root, "design-director", - "验证连续 TLS/transport 故障自动恢复", - "design-transient-transport-retry-run", + "验证 TLS/transport 故障不会在无幂等键时自动重放", + "design-transport-no-replay-run", ) - .expect("start transient retry task"); + .expect("start transport no-replay task"); - for request_index in 0..5 { - let request = receiver - .recv_timeout(Duration::from_secs(5)) - .unwrap_or_else(|_| panic!("captured transport retry request {}", request_index + 1)); - assert!(request.contains("POST /chat/completions HTTP/1.1")); + let request = receiver + .recv_timeout(Duration::from_secs(5)) + .expect("capture only transport-failed Provider request"); + assert!(request.contains("POST /chat/completions HTTP/1.1")); + assert!(receiver.recv_timeout(Duration::from_millis(250)).is_err()); + let mut runtime = read_game_creator_agent_runtime_at(&root, "design-director") + .expect("read transport failure runtime") + .state; + for _ in 0..250 { + if runtime.phase == "failed" { + break; + } + std::thread::sleep(Duration::from_millis(20)); + runtime = read_game_creator_agent_runtime_at(&root, "design-director") + .expect("poll transport failure runtime") + .state; } - assert!(receiver.recv_timeout(Duration::from_millis(200)).is_err()); - let runtime = wait_for_agent_runtime_idle(&root, "design-director"); - assert_eq!(runtime.status, "idle"); - assert_eq!(runtime.phase, "completed"); - assert_eq!( - runtime.last_response.as_deref(), - Some("连续传输失败后已恢复。TRANSIENT_RETRY_OK") - ); - assert!(runtime.error.is_none()); + assert_eq!(runtime.phase, "failed"); + assert!(runtime.error.as_deref().is_some_and(|error| { + error.contains("kind=transport") || error.contains("kind=connectivity") + })); fs::remove_dir_all(root).ok(); } #[test] -fn agent_llm_transient_error_classification_matches_retry_contract() { - let transient_errors = [ - platform_llm::LlmError::Timeout { attempts: 1 }, - platform_llm::LlmError::Connectivity { - attempts: 1, - message: "connection reset".to_string(), - }, - platform_llm::LlmError::Transport("TLS bad record mac".to_string()), - ]; - for error in &transient_errors { +fn agent_llm_public_error_summary_never_copies_provider_error_text() { + let provider_secret = ["sk", "provider-error-secret"].join("-"); + let raw = format!( + "provider failed at https://provider-error.example/v1 for /tmp/provider-private/project and task PROVIDER_TASK_SENTINEL with secret {provider_secret}" + ); + let error = platform_llm::LlmError::Transport(raw); + let summary = game_creator_agent_llm_error_public_summary(&error); + assert!(summary.starts_with("kind=transport fingerprint=")); + assert!(summary.contains(" chars=")); + for forbidden in [ + "provider-error.example", + "/tmp/provider-private/project", + "PROVIDER_TASK_SENTINEL", + provider_secret.as_str(), + ] { assert!( - is_game_creator_agent_llm_transient_error(error), - "expected transient error: {error:?}" + !summary.contains(forbidden), + "public summary leaked {forbidden}" + ); + } +} + +#[tokio::test] +async fn background_provider_failure_redacts_all_persisted_runtime_surfaces() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "Provider 错误脱敏项目").expect("project init"); + let provider_url_sentinel = "https://provider-error.example/private/v1"; + let provider_task_sentinel = "PROVIDER_ERROR_TASK_SENTINEL"; + let provider_secret_sentinel = ["sk", "provider-error-secret-value"].join("-"); + let provider_path_sentinel = root.join("private-provider-error.txt"); + let raw_provider_error = format!( + "request failed url={provider_url_sentinel} path={} task={provider_task_sentinel} secret={provider_secret_sentinel}", + provider_path_sentinel.display() + ); + let listener = TcpListener::bind(("127.0.0.1", 0)).expect("provider error mock bind"); + let base_url = format!( + "http://{}", + listener.local_addr().expect("provider error mock addr") + ); + let server = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("provider error mock accept"); + let request = read_mock_http_request(&mut stream); + assert!(request.contains("POST /responses HTTP/1.1")); + let body = serde_json::json!({ + "error": { + "message": raw_provider_error, + "type": "invalid_request_error" + } + }) + .to_string(); + let response = format!( + "HTTP/1.1 400 Bad Request\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ); + stream + .write_all(response.as_bytes()) + .expect("provider error mock response"); + }); + let _config_guard = write_test_local_config(format!( + r#"{{ + "agentLlm": {{ + "design-director": {{ + "apiKey": "provider-config-secret-key", + "baseUrl": {base_url:?}, + "model": "provider-error-model", + "apiKind": "openai_responses", + "stream": false, + "maxRetries": 0 + }} + }} +}}"# + )); + let run_id = "design-provider-error-redaction-run"; + start_game_creator_agent_background_task_at( + &root, + "design-director", + "验证 Provider 错误只公开类型和指纹", + run_id, + ) + .expect("start Provider error redaction task"); + server.join().expect("join provider error mock"); + + let mut runtime = read_game_creator_agent_runtime_at(&root, "design-director") + .expect("read Provider error runtime") + .state; + for _ in 0..250 { + if runtime.run_id == run_id && runtime.phase == "failed" { + break; + } + std::thread::sleep(Duration::from_millis(20)); + runtime = read_game_creator_agent_runtime_at(&root, "design-director") + .expect("poll Provider error runtime") + .state; + } + assert_eq!(runtime.phase, "failed"); + assert!(runtime + .error + .as_deref() + .is_some_and(|error| error.contains("kind=upstream-400"))); + let conversation = read_local_conversation_for_session_at( + &root, + Some("design-director"), + Some(&runtime.session_id), + ) + .expect("read Provider error conversation"); + let persisted_surfaces = format!( + "{}\n{}\n{}\n{}", + serde_json::to_string(&runtime).expect("serialize Provider error runtime"), + fs::read_to_string(root.join(".agent/runtime/events/design-director.jsonl")) + .expect("read Provider error events"), + fs::read_to_string(root.join(".agent/agent.db")).expect("read Provider error Agent DB"), + serde_json::to_string(&conversation).expect("serialize Provider error conversation") + ); + let provider_path = provider_path_sentinel.to_string_lossy().into_owned(); + for forbidden in [ + provider_url_sentinel, + provider_task_sentinel, + provider_secret_sentinel.as_str(), + provider_path.as_str(), + "provider-config-secret-key", + ] { + assert!( + !persisted_surfaces.contains(forbidden), + "Provider error leaked into persisted Runtime surface: {forbidden}" ); } - for status_code in [408_u16, 429].into_iter().chain(500..=599) { - let error = platform_llm::LlmError::Upstream { - status_code, - message: "retryable upstream status".to_string(), - }; - assert!( - is_game_creator_agent_llm_transient_error(&error), - "expected transient upstream status: {status_code}" - ); - } + fs::remove_dir_all(root).ok(); +} - let permanent_errors = [ - platform_llm::LlmError::InvalidConfig("bad config".to_string()), - platform_llm::LlmError::InvalidRequest("bad request".to_string()), - platform_llm::LlmError::Deserialize("bad response json".to_string()), - platform_llm::LlmError::StreamUnavailable, - platform_llm::LlmError::EmptyResponse, - ]; - for error in &permanent_errors { +#[test] +fn agent_runtime_failure_redacts_legacy_plan_detail_and_all_error_projections() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "Runtime 失败投影脱敏项目") + .expect("project init"); + let state = start_game_creator_agent_runtime_task_at( + &root, + "design-director", + "执行不会包含错误诱饵的普通任务", + "runtime-error-redaction-run", + "agent-background-task", + "准备执行", + vec!["等待执行".to_string()], + ) + .expect("start error redaction runtime"); + let private_path = root.join("private/error-detail.txt"); + let runtime_secret = ["sk", "runtime-error-secret"].join("-"); + let raw_error = format!( + "HTTPS://ERROR.EXAMPLE/private {} {runtime_secret} --password RAW_PASSWORD ERROR_DETAIL_CANARY", + private_path.display(), + ); + let failed = fail_game_creator_agent_runtime_turn_at(&root, state, &raw_error) + .expect("persist redacted Runtime failure"); + assert_eq!(failed.phase, "failed"); + let surfaces = format!( + "{}\n{}\n{}", + serde_json::to_string(&failed).expect("serialize failed runtime"), + fs::read_to_string(root.join(".agent/runtime/events/design-director.jsonl")) + .expect("read failed runtime events"), + fs::read_to_string(root.join(".agent/runtime/tasks/design-director.jsonl")) + .expect("read failed runtime tasks") + ); + for forbidden in [ + "ERROR.EXAMPLE", + private_path.to_string_lossy().as_ref(), + runtime_secret.as_str(), + "RAW_PASSWORD", + "ERROR_DETAIL_CANARY", + ] { assert!( - !is_game_creator_agent_llm_transient_error(error), - "expected permanent error: {error:?}" + !surfaces.contains(forbidden), + "Runtime failure projection leaked {forbidden}" ); } + assert!(surfaces.contains("")); - for status_code in (400_u16..=499).filter(|status| !matches!(*status, 408 | 429)) { - let error = platform_llm::LlmError::Upstream { - status_code, - message: "non-retryable upstream status".to_string(), - }; - assert!( - !is_game_creator_agent_llm_transient_error(&error), - "expected permanent upstream status: {status_code}" - ); - } + fs::remove_dir_all(root).ok(); } #[tokio::test] @@ -22442,6 +23192,46 @@ fn agent_runtime_git_commit_prompt_and_input_summary_keep_the_safe_contract() { fs::remove_dir_all(root).ok(); } +#[test] +fn agent_runtime_project_verify_summary_hashes_command_without_head_or_tail() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "验证命令公开摘要项目").expect("project init"); + let command = format!( + "curl https://user:password@verify.example/private --password SUPER_SECRET --data GOAL_VERIFY_CANARY {}", + root.join("private/verify-target").display() + ); + let action = AgentRuntimeToolAction { + tool: "project.verify".to_string(), + reason: Some("执行项目验证".to_string()), + input: serde_json::json!({ + "script": "test", + "expectedCommand": command, + "timeoutSeconds": 120 + }), + }; + let summary = + agent_runtime_tool_action_input_summary(&root, &action).expect("verify input summary"); + assert!(summary.contains("script=test")); + assert!(summary.contains("expectedCommandSha256=")); + assert!(summary.contains("expectedCommandChars=")); + assert!(!summary.contains("head=")); + assert!(!summary.contains("tail=")); + for forbidden in [ + "verify.example", + "password", + "SUPER_SECRET", + "GOAL_VERIFY_CANARY", + root.to_string_lossy().as_ref(), + ] { + assert!( + !summary.contains(forbidden), + "verify summary leaked {forbidden}" + ); + } + + fs::remove_dir_all(root).ok(); +} + fn run_agent_runtime_git_fixture(root: &Path, arguments: &[&str]) -> String { let output = std::process::Command::new("git") .current_dir(root) @@ -35470,6 +36260,9 @@ fn structured_plan_incomplete_normal_and_resumed_finalization_audits_are_redacte AgentBackgroundFinalizationOutcome::Pending(error) => { panic!("ordinary incomplete plan must block before journal: {error}") } + AgentBackgroundFinalizationOutcome::Cancelled(_) => { + panic!("ordinary incomplete plan must not cancel finalization") + } }; assert_eq!(ordinary_blocker.tool, "runtime.plan_update"); assert!(!ordinary_blocker.summary.contains(&ordinary_step_title)); @@ -36059,12 +36852,16 @@ async fn agent_runtime_steer_interrupts_only_the_active_provider_wait() { let (provider_started_tx, provider_started_rx) = tokio::sync::oneshot::channel(); let wait_root = root.clone(); let wait_agent = state.agent_id.clone(); + let wait_session = state.session_id.clone(); let wait_run = state.run_id.clone(); let provider_wait = tokio::spawn(async move { await_game_creator_agent_runtime_provider_request( &wait_root, &wait_agent, + &wait_session, &wait_run, + "tool-plan", + "test-interrupt", 0, async move { let _ = provider_started_tx.send(()); @@ -36091,6 +36888,417 @@ async fn agent_runtime_steer_interrupts_only_the_active_provider_wait() { .expect("provider wait task joined") .expect("provider wait result"); assert!(outcome.is_none()); + let lifecycle = read_agent_db_records_for_test(&root) + .into_iter() + .filter(|record| { + record["recordType"] == "agent.runtime.provider_request.lifecycle" + && record["runId"] == state.run_id + }) + .collect::>(); + assert_eq!(lifecycle.len(), 2); + assert_eq!(lifecycle[0]["status"], "started"); + assert_eq!(lifecycle[1]["status"], "interrupted"); + assert_eq!(lifecycle[0]["requestId"], lifecycle[1]["requestId"]); + assert!(lifecycle.iter().all(|record| { + record["auditSchemaVersion"] == "game-creator-provider-request-lifecycle.v1" + && record["requestKind"] == "tool-plan" + && record["requestSlot"] == "test-interrupt" + && record.get("prompt").is_none() + && record.get("response").is_none() + && record.get("error").is_none() + && record.get("baseUrl").is_none() + })); +} + +#[tokio::test] +async fn agent_runtime_durable_cancel_after_provider_registration_starts_no_request() { + let root = unique_project_path(); + let state = start_agent_runtime_steer_fixture(&root, "provider-control-race-run"); + let provider_polled = Arc::new(AtomicBool::new(false)); + let provider_polled_for_request = provider_polled.clone(); + let outcome = await_game_creator_agent_runtime_provider_request_with_control_hook_for_test( + &root, + &state.agent_id, + &state.session_id, + &state.run_id, + "tool-plan", + "test-durable-cancel-race", + 0, + async move { + provider_polled_for_request.store(true, Ordering::Release); + Ok::<(), String>(()) + }, + || { + write_game_creator_agent_runtime_cancel_request( + &root, + &state.agent_id, + &state.run_id, + "测试注册后持久取消", + ) + .expect("write durable cancel after provider registration"); + }, + ) + .await + .expect("durable cancel must stop provider before request start"); + assert!(outcome.is_none()); + assert!(!provider_polled.load(Ordering::Acquire)); + assert!(!read_agent_db_records_for_test(&root).iter().any(|record| { + record["recordType"] == "agent.runtime.provider_request.lifecycle" + && record["runId"] == state.run_id + })); + assert!(!interrupt_game_creator_agent_runtime_provider_request_at( + &root, + &state.agent_id, + &state.run_id, + ) + .expect("provider registry must be released")); + + fs::remove_dir_all(root).ok(); +} + +#[tokio::test] +async fn agent_runtime_goal_edit_after_provider_registration_starts_no_stale_request() { + let root = unique_project_path(); + let mut state = start_agent_runtime_steer_fixture(&root, "provider-goal-edit-race-run"); + seed_game_creator_agent_goal_for_runtime_test_at( + &root, + &mut state, + "完成第一版持久目标", + AGENT_GOAL_STATUS_ACTIVE, + ) + .expect("seed active Goal before Provider registration"); + let original_revision = state.goal_revision; + let agent_id = state.agent_id.clone(); + let session_id = state.session_id.clone(); + let run_id = state.run_id.clone(); + let mut revised_state = state.clone(); + let provider_polled = Arc::new(AtomicBool::new(false)); + let provider_polled_for_request = provider_polled.clone(); + + let outcome = await_game_creator_agent_runtime_provider_request_with_control_hook_for_test( + &root, + &agent_id, + &session_id, + &run_id, + "tool-plan", + "test-goal-edit-race", + 0, + async move { + provider_polled_for_request.store(true, Ordering::Release); + Ok::<(), String>(()) + }, + || { + let revised = revise_game_creator_agent_goal_for_runtime_test_at( + &root, + &mut revised_state, + "完成第二版持久目标", + ) + .expect("commit Goal revision after Provider registration"); + assert_eq!(revised.revision, original_revision + 1); + }, + ) + .await + .expect("new Goal revision must stop the stale Provider request"); + assert!(outcome.is_none()); + assert!(!provider_polled.load(Ordering::Acquire)); + assert!(!read_agent_db_records_for_test(&root).iter().any(|record| { + record["recordType"] == "agent.runtime.provider_request.lifecycle" + && record["runId"] == run_id + })); + assert!( + !interrupt_game_creator_agent_runtime_provider_request_at(&root, &agent_id, &run_id) + .expect("provider registry must be released") + ); + + fs::remove_dir_all(root).ok(); +} + +#[tokio::test] +async fn agent_runtime_prebuilt_goal_snapshot_rejects_edit_before_provider_registration() { + let root = unique_project_path(); + let mut state = start_agent_runtime_steer_fixture(&root, "provider-prebuilt-goal-edit-run"); + seed_game_creator_agent_goal_for_runtime_test_at( + &root, + &mut state, + "完成第一版预构建请求目标", + AGENT_GOAL_STATUS_ACTIVE, + ) + .expect("seed active Goal before request build"); + let snapshot = capture_game_creator_agent_runtime_provider_request_snapshot( + &root, + &state.agent_id, + &state.session_id, + &state.run_id, + "tool-plan", + "test-prebuilt-goal-edit", + state.applied_steer_cursor, + ) + .expect("capture Provider request build snapshot"); + let same_snapshot = capture_game_creator_agent_runtime_provider_request_snapshot( + &root, + &state.agent_id, + &state.session_id, + &state.run_id, + "tool-plan", + "test-prebuilt-goal-edit", + state.applied_steer_cursor, + ) + .expect("capture identical Provider request build snapshot"); + let request_id = game_creator_agent_runtime_provider_request_id(&snapshot); + assert_eq!( + request_id, + game_creator_agent_runtime_provider_request_id(&same_snapshot), + "the same logical Provider request must keep a stable requestId" + ); + + revise_game_creator_agent_goal_for_runtime_test_at( + &root, + &mut state, + "完成第二版预构建请求目标", + ) + .expect("edit Goal after prompt snapshot construction"); + let provider_polled = Arc::new(AtomicBool::new(false)); + let provider_polled_for_request = provider_polled.clone(); + let outcome = await_game_creator_agent_runtime_provider_request_with_snapshot_for_test( + &root, + snapshot, + async move { + provider_polled_for_request.store(true, Ordering::Release); + Ok::<(), String>(()) + }, + || {}, + ) + .await + .expect("new Goal revision must invalidate the prebuilt Provider request"); + assert!(outcome.is_none()); + assert!(!provider_polled.load(Ordering::Acquire)); + assert!(!read_agent_db_records_for_test(&root).iter().any(|record| { + record["recordType"] == "agent.runtime.provider_request.lifecycle" + && record["requestId"] == request_id + })); + + fs::remove_dir_all(root).ok(); +} + +#[tokio::test] +async fn agent_runtime_orphan_provider_started_requires_reconciliation_without_replay() { + let root = unique_project_path(); + let state = start_agent_runtime_steer_fixture(&root, "provider-orphan-started-run"); + let snapshot = capture_game_creator_agent_runtime_provider_request_snapshot( + &root, + &state.agent_id, + &state.session_id, + &state.run_id, + "tool-plan", + "test-orphan-started", + state.applied_steer_cursor, + ) + .expect("capture orphan Provider request snapshot"); + let request_id = + append_game_creator_agent_runtime_provider_lifecycle_for_test(&root, &snapshot, "started") + .expect("seed orphan Provider started lifecycle"); + let provider_polled = Arc::new(AtomicBool::new(false)); + let provider_polled_for_request = provider_polled.clone(); + let error = await_game_creator_agent_runtime_provider_request_with_snapshot_for_test( + &root, + snapshot, + async move { + provider_polled_for_request.store(true, Ordering::Release); + Ok::<(), String>(()) + }, + || {}, + ) + .await + .expect_err("orphan Provider started lifecycle must stop automatic replay"); + assert!(error.contains("provider-request-needs-reconciliation")); + assert!(!provider_polled.load(Ordering::Acquire)); + let runtime = read_game_creator_agent_runtime_at(&root, &state.agent_id) + .expect("read Provider reconciliation runtime") + .state; + assert_eq!(runtime.phase, "needs-reconciliation"); + assert!(runtime + .error + .as_deref() + .is_some_and(|value| value.contains(&request_id))); + let records = read_agent_db_records_for_test(&root); + assert_eq!( + records + .iter() + .filter(|record| { + record["recordType"] == "agent.runtime.provider_request.lifecycle" + && record["requestId"] == request_id + }) + .count(), + 1 + ); + assert_eq!( + records + .iter() + .filter(|record| { + record["recordType"] == "agent.runtime.provider_request.needs_reconciliation" + && record["requestId"] == request_id + }) + .count(), + 1 + ); + + fs::remove_dir_all(root).ok(); +} + +#[tokio::test] +async fn agent_runtime_old_orphan_survives_goal_revision_change_and_blocks_new_request() { + let root = unique_project_path(); + let mut state = start_agent_runtime_steer_fixture(&root, "provider-old-orphan-goal-edit-run"); + seed_game_creator_agent_goal_for_runtime_test_at( + &root, + &mut state, + "完成旧 revision 目标", + AGENT_GOAL_STATUS_ACTIVE, + ) + .expect("seed old Goal revision"); + let old_snapshot = capture_game_creator_agent_runtime_provider_request_snapshot( + &root, + &state.agent_id, + &state.session_id, + &state.run_id, + "tool-plan", + "test-old-orphan-goal-edit", + state.applied_steer_cursor, + ) + .expect("capture old Goal Provider snapshot"); + let old_request_id = append_game_creator_agent_runtime_provider_lifecycle_for_test( + &root, + &old_snapshot, + "started", + ) + .expect("seed old orphan Provider started lifecycle"); + revise_game_creator_agent_goal_for_runtime_test_at(&root, &mut state, "完成新 revision 目标") + .expect("advance Goal revision after orphan request"); + let provider_polled = Arc::new(AtomicBool::new(false)); + let provider_polled_for_request = provider_polled.clone(); + let error = await_game_creator_agent_runtime_provider_request( + &root, + &state.agent_id, + &state.session_id, + &state.run_id, + "tool-plan", + "test-new-goal-request-after-orphan", + state.applied_steer_cursor, + async move { + provider_polled_for_request.store(true, Ordering::Release); + Ok::<(), String>(()) + }, + ) + .await + .expect_err("old orphan must block a new Goal revision Provider request"); + assert!(error.contains(&old_request_id)); + assert!(!provider_polled.load(Ordering::Acquire)); + let runtime = read_game_creator_agent_runtime_at(&root, &state.agent_id) + .expect("read old orphan reconciliation runtime") + .state; + assert_eq!(runtime.phase, "needs-reconciliation"); + let lifecycle = read_agent_db_records_for_test(&root) + .into_iter() + .filter(|record| { + record["recordType"] == "agent.runtime.provider_request.lifecycle" + && record["runId"] == state.run_id + }) + .collect::>(); + assert_eq!(lifecycle.len(), 1); + assert_eq!(lifecycle[0]["requestId"], old_request_id); + assert_eq!(lifecycle[0]["status"], "started"); + + fs::remove_dir_all(root).ok(); +} + +#[tokio::test] +async fn agent_runtime_provider_lifecycle_failure_redacts_project_path_and_starts_no_request() { + let root = unique_project_path(); + let state = start_agent_runtime_steer_fixture(&root, "provider-lifecycle-redaction-run"); + let agent_db_path = root.join(".agent/agent.db"); + fs::remove_file(&agent_db_path).expect("remove Agent DB fixture"); + fs::create_dir(&agent_db_path).expect("replace Agent DB with invalid directory"); + let provider_polled = Arc::new(AtomicBool::new(false)); + let provider_polled_for_request = provider_polled.clone(); + let error = await_game_creator_agent_runtime_provider_request( + &root, + &state.agent_id, + &state.session_id, + &state.run_id, + "tool-plan", + "test-redacted-start-failure", + 0, + async move { + provider_polled_for_request.store(true, Ordering::Release); + Ok::<(), String>(()) + }, + ) + .await + .expect_err("invalid Agent DB must fail before Provider polling"); + assert!(!provider_polled.load(Ordering::Acquire)); + assert!(!error.contains(root.to_string_lossy().as_ref()), "{error}"); + assert!( + !error.contains(agent_db_path.to_string_lossy().as_ref()), + "{error}" + ); + + fs::remove_dir_all(root).ok(); +} + +#[tokio::test] +async fn agent_runtime_terminal_lifecycle_failure_keeps_reconciliation_barrier() { + let root = unique_project_path(); + let state = start_agent_runtime_steer_fixture(&root, "provider-terminal-lifecycle-failure-run"); + let event_path = game_creator_agent_runtime_event_path(&root, &state.agent_id); + let request_root = root.clone(); + let request_event_path = event_path.clone(); + let error = await_game_creator_agent_runtime_provider_request( + &root, + &state.agent_id, + &state.session_id, + &state.run_id, + "tool-plan", + "test-terminal-lifecycle-failure", + 0, + async move { + fs::write( + request_root.join(".agent/runtime/test-fail-next-agent-db-record"), + "agent.runtime.provider_request.lifecycle", + ) + .expect("inject Provider terminal lifecycle failure"); + if request_event_path + .try_exists() + .expect("inspect Runtime event fixture") + { + fs::remove_file(&request_event_path).expect("remove Runtime event file"); + } + fs::create_dir(&request_event_path).expect("block reconciliation event append"); + Ok::<(), String>(()) + }, + ) + .await + .expect_err("terminal lifecycle failure must stop normal completion"); + assert!(error.contains("provider-request-needs-reconciliation")); + let runtime: AgentRuntimeState = serde_json::from_str( + &fs::read_to_string( + root.join(".agent/runtime/agents") + .join(format!("{}.json", state.agent_id)), + ) + .expect("read terminal lifecycle reconciliation state"), + ) + .expect("parse terminal lifecycle reconciliation state"); + assert_eq!(runtime.status, "running"); + assert_eq!(runtime.phase, "needs-reconciliation"); + assert!(runtime + .error + .as_deref() + .is_some_and(|value| value.contains("provider-request-needs-reconciliation"))); + let tasks = fs::read_to_string(game_creator_agent_runtime_task_path(&root, &state.agent_id)) + .expect("read terminal lifecycle reconciliation tasks"); + assert!(tasks.contains("needs-reconciliation")); + assert!(!tasks.contains("\"phase\":\"failed\"")); + + fs::remove_dir_all(root).ok(); } #[tokio::test] @@ -36714,6 +37922,7 @@ fn project_supervisor_delegate_reuses_reserved_identity_after_interrupted_dispat let _target_lock = try_acquire_game_creator_agent_runtime_task_lock(&root, target_agent_id) .expect("acquire target lane") .expect("target lane available"); + let delegated_task = "GOAL_DERIVED_DELEGATE_CANARY 输出角色玩法约束"; let observation = observe_agent_runtime_agent_delegate( &root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, @@ -36721,7 +37930,7 @@ fn project_supervisor_delegate_reuses_reserved_identity_after_interrupted_dispat Some(action_id), &serde_json::json!({ "agentId": target_agent_id, - "task": "输出角色玩法约束", + "task": delegated_task, "runId": "new-run-id-must-not-replace-reservation" }), ); @@ -36740,6 +37949,33 @@ fn project_supervisor_delegate_reuses_reserved_identity_after_interrupted_dispat .expect("reused delivery exists"); assert_eq!(delivery.status, StaticDelegateDeliveryStatus::Dispatched); assert_eq!(delivery.target_run_id, reserved_run_id); + assert!(!observation + .detail + .as_deref() + .unwrap_or_default() + .contains(delegated_task)); + let all_records = read_agent_db_records_for_test(&root); + let delegate_records = all_records + .iter() + .into_iter() + .filter(|record| { + record["recordType"] == "agent.runtime.agent.delegate" + && record["parentRunId"] == parent_run_id + && record["delegationId"] == delegation_id + }) + .collect::>(); + assert_eq!(delegate_records.len(), 1); + assert!(delegate_records[0].get("task").is_none()); + assert_eq!( + delegate_records[0]["taskChars"], + delegated_task.chars().count() + ); + assert_eq!( + delegate_records[0]["taskSha256"], + format!("{:x}", Sha256::digest(delegated_task.as_bytes())) + ); + let public_agent_db = serde_json::to_string(&all_records).expect("serialize delegate audits"); + assert!(!public_agent_db.contains(delegated_task)); fs::remove_dir_all(root).ok(); } diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 6505e2cd0..621fd5cad 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -133,11 +133,11 @@ - 2026-07-12 加固:`file.delete` 取得项目写锁后必须重新读取当前项目和 Agent 权限策略;锁竞争期间从 allow 改为 deny 时立即阻断,从 allow 改为 confirm 时自动动作退回待确认,只有已确认动作可继续。Agent 私有记忆以及客户端开发面板的文件、记忆、资产、草案、导出和 checkpoint 恢复写入都在同一项目锁内保守推进全局 revision,确保等待中的旧删除动作不会作用于客户端刚改写的内容。manifest 持久化使用同目录临时文件;平台不能覆盖既有文件时先移动到 `.manifest.json.previous` 恢复副本,主文件缺失时从副本读取,安装新文件失败时恢复旧文件。 - 2026-07-11 补充,2026-07-12 更新:单 Agent 代码闭环新增开发专用 `project.verify`,用于在修改后执行项目根 `package.json` 已定义的固定脚本 `check / typecheck / test / lint / build`,或以 `check: / test: / lint: / typecheck: / build: / verify: / validate:` 开头、后缀由安全非空段组成的命名脚本;当前只支持 npm,不接受自由命令、参数或工作目录。Agent 必须先读取普通文件 `package.json`,再把真实存在的脚本名、完整原始脚本文本 `expectedCommand` 和 1-300 秒超时一起提交;Runtime 在真正执行前重新解析 JSON,要求脚本仍存在且正文精确一致,脚本漂移时拒绝执行。非 npm `packageManager` 或 pnpm / yarn / bun 锁文件必须失败关闭;`pre* / post*` 生命周期脚本名不在允许范围,npm 执行再附加 `--ignore-scripts`,阻止所选脚本关联的 pre/post lifecycle。该工具使用独立、默认 `confirm` 的 `project.verify` 权限,不再与 `command.run_limited` / `game.static_smoke` 共用授权;确认指纹覆盖脚本正文和超时。执行时不经过 App 自行拼接的 `bash -c`;继承环境被清理到 PATH 与必要平台变量,HOME/TMP/npm cache 隔离,stdin 关闭,输出保留有界头尾。Unix 下验证根进程正常结束或超时都会清理同进程组残留后代;项目写锁会按持有 PID 回收崩溃遗留锁,并拒绝 `.agent` 符号链接逃逸。进入进程执行后的成功、非零退出、启动失败和超时会写 `.agent/logs/command.log`、manifest command run 与 `agent.runtime.project.verify` 审计;输入预检拒绝则只进入 Runtime observation / error 事件。输出先过滤敏感内容再进入 observation。只要最新 `project.verify` 未通过,或通过后又发生 `file.write / file.patch / file.delete / project.restore`,Runtime 就拒绝模型用空 actions 假完成,继续要求修复和重新验证;多窗口重复无进展而以 `loop-budget-exhausted` 终止时,仍未形成新通过结果则保持失败。该能力会执行用户项目自身脚本,环境隔离不等同于 OS 沙箱,不能把不可信项目脚本视为安全代码;它不是自由 shell 代理,也不进入普通用户命令入口。 - 2026-07-11 补充:开发侧新增 headless 单 Agent Runtime 入口 `npm run ai-game-creator-shell:agent-task -- [--init] `。该入口不实现第二套 Agent,只复用 Tauri App 的持久任务队列、per-agent 锁、LLM 路由、权限策略、工具 action / observation loop、对话和审计文件,并轮询到 `completed / failed / waiting-for-confirmation` 后用稳定键值行退出;`--init` 只在显式传入且 manifest 不存在时初始化项目。遇到待确认动作时 CLI 返回非零并打印 actionId、tool 和脱敏摘要,后续仍由开发窗口完成确认,不提供静默 `--yes` 绕过。 -- 2026-07-11 补充:后台 Agent 的工具规划和最终回复请求对 `LlmError::EmptyResponse` 最多自动重试 3 次(含首次共 4 次请求),与既有 Generator 对上游 HTTP 成功但空 content 的恢复策略一致;对 `Timeout / Connectivity / Transport` 及上游 `408 / 429 / 5xx` 最多额外自动重试 2 次并做线性退避,配置、请求、流能力、反序列化错误及其他 `4xx` 仍立即失败。重试发生在工具计划被解析和执行前,或最终回复尚未落盘时,不会重复执行已经落盘的工具动作。 +- 2026-07-15 收口:废止后台 Agent 工具规划和最终回复对 `EmptyResponse / Timeout / Connectivity / Transport / 408 / 429 / 5xx` 的原样自动重试。每个 Provider request lifecycle 只允许一次物理请求,专用客户端强制 `max_retries=0`,不继承全局或 per-Agent 的 `maxRetries`;可观察错误写唯一 `failed` 终态,`started` 后没有可信终态则进入 `needs-reconciliation` orphan barrier。只有显式 steer、Goal resume 或人工 reconciliation 后的新 request slot 才能建立新 lifecycle;工具协议格式修复使用新的 repair slot/lifecycle,不属于传输重试。 - 2026-07-11 调整,2026-07-12 更新:后台单 Agent 的 planning loop 每 6 轮形成一个上下文压缩窗口,每轮工具动作上限仍为 3;6 轮不再是整个 run 的固定上限。`loopIteration` 在同一 run 内连续递增,`maxLoopIterations` 指向当前窗口结束轮次,跨重启待确认动作按 context bundle 的 `nextLoopIndex` 继续。每个窗口结束时压缩已有 observation;窗口产生新的独立观察时继续同一 run,最近 6 轮没有独立进展或相邻窗口指纹重复时才进入 `failed / budget-exhausted`,并记录 `loop-budget-exhausted`,不会伪装完成。该调整只作用于后台单 Agent Runtime,不改变游戏草案 Generator/Evaluator 的 3 轮上限;旧实施摘要中“后台 3 轮后整理最终回复”或“整个 run 最多 6 轮”的描述由本条取代。 - 2026-07-12 补充:后台 Agent 每个 run 的可恢复 planning 上下文使用 `.agent/runtime/context-bundles//.json`。Runtime 通过临时文件替换原子写入,绑定 Agent、Task、Session、Run 和任务正文,保存 `nextLoopIndex`、当前窗口、计划、fallback response、压缩后的 observation 与上一窗口指纹;单文件最多 64 KiB、最多 12 条 observation。写入前统一截断并过滤敏感内容和项目绝对路径,安全校验失败时拒绝落盘;读取时要求普通文件,并校验 schema、Agent、Session、Run、任务正文和 observation 数量,身份不一致时拒绝续跑。该路径属于 Runtime 私有控制面,与根级 `.agent/context.bundle.json` 的旧 run-control 辅助文件不是同一契约,通用文件工具不得暴露。 -- 2026-07-11 调整:开发者投递的后台任务从队列记录、Runtime `currentTask/currentGoal` 到待确认动作私有账本统一保留最多 4,000 字符,不再在入队时截成 180 字符。180 字符只用于 UI、事件和审计预览;LLM planning、失败重试、确认续跑和重启恢复必须使用完整任务字段,避免位于长需求末尾的验收条件、禁止项或输出格式在真正执行前丢失。 -- 2026-07-11 调整:后台结构化 planning 使用独立的 4,000 输出 token 上限,最终回复使用 2,400;两者显式请求 low reasoning effort 和 low text verbosity。`platform-llm` 会把 reasoning effort 同时映射到 OpenAI Responses 的 `reasoning.effort` 与 Chat Completions 的 `reasoning_effort`,未设置时不新增字段。真实 gpt-5.5 Chat 响应曾连续消耗约 1,000-1,400 completion tokens 却不返回 message content,低推理强度、较大的可见输出余量和 EmptyResponse 重试共同构成恢复策略。 +- 2026-07-11 调整,2026-07-15 收口:开发者投递的后台任务从队列记录、Runtime `currentTask/currentGoal` 到待确认动作私有账本统一保留最多 4,000 字符,不再在入队时截成 180 字符。必要的 180 字符可见摘要只用于私有执行界面;公共 event、Agent DB、receipt、activity、output 和报告不再保存任务预览或正文,只保存 `taskSha256 / taskChars` 等身份、哈希和计数。LLM planning、显式恢复、确认续跑和重启恢复继续使用私有完整任务字段,避免丢失长需求末尾的验收条件、禁止项或输出格式。 +- 2026-07-11 调整,2026-07-15 收口:后台结构化 planning 使用独立的 4,000 输出 token 上限,最终回复使用 2,400;两者显式请求 low reasoning effort 和 low text verbosity。`platform-llm` 会把 reasoning effort 同时映射到 OpenAI Responses 的 `reasoning.effort` 与 Chat Completions 的 `reasoning_effort`,未设置时不新增字段。低推理强度和较大的可见输出余量只用于降低空响应概率;`EmptyResponse` 仍按单次 lifecycle 的歧义失败处理,不再自动原样重放。 - 2026-07-11 补充:后台单 Agent 的工具计划响应只接受可反序列化为计划 schema 的 JSON object。解析器提取模型输出中的首个完整对象并允许对象后带普通说明;未找到完整 JSON 对象,或提取对象无法反序列化为工具计划时,Runtime 最多追加 2 次自动格式修复请求。每次修复只携带限长、脱敏后的上一次无效输出,并写入 `agent.runtime.tool_plan.repair` 审计。两次修复后仍无有效对象则按工具规划失败处理;工具规划阶段的普通文本不得转换为默认的空 actions + response,也不得据此把任务标记为完成。 - 2026-07-11 调整:工具计划顶层 `thinkingSummary / plan / actions / response` 四个字段必须同时存在,未知顶层字段、空 thinkingSummary 和空 tool 均属于协议错误并进入同一格式修复预算,`{}` 或前置无关 JSON 对象不能再触发空计划收束。空 actions 表示 planning 收束;response 非空时直接采用,response 为空时进入独立的最终回复生成。`agent.runtime.project.verify` 审计同时保存 `runId / actionId / actionFingerprint`,使并行 Agent 的失败与通过记录能够精确归属到发起动作。 - 2026-07-12 补充:OpenAI Chat / Responses 的后台 Agent 工具 planning 改用唯一 `submit_agent_tool_plan` 原生 function tool,字符串 `tool_choice=required` 和 strict schema;只接受恰好一次同名调用,arguments 继续经过本地计划 schema、工具白名单和权限策略校验,错误函数、多调用或非法 arguments 进入原有两次格式修复预算且不产生副作用。Anthropic 保留文本 JSON 回退;planning 非流式,最终回复仍可流式。每轮成功协议写 `agent.runtime.tool_plan.protocol`,修复审计记录 protocol、callId 和 functionName。 @@ -4433,7 +4433,7 @@ - 决策:父 run 在 `waitingGroups > 0` 时必须持久进入 `waiting-for-isolated-join`、保存原 context cursor 并释放 Agent lane;重复 resume 只返回等待状态,不请求 LLM、不推进 loop,也不取消仍在工作的 child。最后一个 child 就绪后写 `deliveryTarget=parent-wake` 并唤醒同一 parent run / session,由模型通过持久 `agent.run_status` actionId 认领;不得创建 join continuation。活跃 planning / running 父 run 直接保留 ready join 等待认领,重复 dispatch 不创建任务。旧 delivery 缺少 target 时按 continuation 单向兼容。 - 决策:action task / event 投影的幂等阶段键为 `runId + actionId + phase`,允许同一 action 从 waiting-for-confirmation 合法推进到终态 observation,同阶段冲突仍失败关闭。Agent DB 终态 observation 扫描跳过同 action 的非终态前置记录,只对既有终态做全字段一致性检查;`recentToolCalls` 按 actionId 原位更新,避免 waiting 投影遮住最终结果。 - 决策:动作历史结构化 detail 上限 7,200 字符;超预算时只能先删除可选字段,再按最旧优先删除完整记录,不得字符截断 JSON,也不得清空 `runId / actionFingerprint` 破坏身份。运行时文本清洗必须覆盖 Unix、Windows 盘符、正反斜杠 UNC、`file:/...`、`file:///...` 及百分号编码绝对路径,统一替换为 ``,并保留普通相对文本与 HTTP(S) URL。 -- 决策:后台 planning 和最终回复的瞬时 LLM 错误重试上限提高为额外 5 次,覆盖 `Timeout / Connectivity / Transport` 与上游 `408 / 429 / 5xx`,按 500ms 线性递增退避;不可重试错误继续直接失败,任何重试都不得跨越工具执行或回复落盘提交点。 +- 2026-07-15 修正:撤销后台 planning 和最终回复“瞬时错误额外自动重试”的旧契约。真实长 planning 的 TLS 失败证明“尚未形成 plan/action”不能证明 Provider 未接收或未计费;`Timeout / Connectivity / Transport / EmptyResponse / 408 / 429 / 5xx` 均不得在同一 lifecycle 内原样重放,底层 `LlmClient` 强制 `max_retries=0`。错误只保存 kind、SHA-256、字符数和脱敏摘要;是否再次请求必须经过显式恢复并使用新的 request slot/lifecycle。按错误指纹切换 TLS 栈、HTTP 版本或协议版本的实验没有真实收益且扩大共享依赖,继续不作为全局传输分支。 - 决策:本轮只交付模型工具,不新增前端动作历史弹窗;UI 继续显示最近动作投影,后续历史查看必须使用独立弹窗。 - 验证:Rust 全量 507 项中 504 通过、3 项真实浏览器 opt-in 用例按设计忽略;覆盖 receipt 折叠、组合过滤、默认值与上限、敏感清洗、旧记录、尾部修复、中间损坏失败关闭、身份冲突、句柄安全、目录同步、确认阶段到终态投影、`parent-wake` 和恢复补齐。最终真实 `gpt-5.5` V1.6 `llm-runtime` 套件中,模型实际调用 1 次 `agent.action_history` 并返回 1 条与 `.agent/agent.db` 全身份对齐的当前 run 记录;94 条 task、158 条 event、164 条 Agent DB、11 条合法工具协议、13 次成功工具执行和 24 条 terminal receipt 中,主 run receipt 为 18,递归历史、重复 receipt / action / message、receipt identity 冲突、密钥和诱饵泄漏均为 0。Runner 强杀后恢复原 run / session 且身份稳定,3 个隔离实例形成唯一 all-join 认领,本次真实竞态未创建 continuation task;动作历史只在父 run 认领 join 后执行,项目、桌面和移动验证通过。 @@ -4584,8 +4584,14 @@ - 动作门禁:pending action 升级为 `game-creator-pending-action.v5`,在 project revision、verification gate、repository context fingerprint 和 steer cursor 之外绑定 `goalId / goalRevision / goalSnapshotFingerprint`;旧 v1-v4 全部失败关闭。Goal edit 提交新 revision 后,旧自动动作和旧待确认动作统一转成 `blocked` observation,在原 run 重规划,禁止执行旧副作用、从当前 Goal 猜回绑定或创建 retry run。 - 暂停恢复:Runner 重启先处理 cancel / Goal control,再进入 process reconciliation、finalization、pending action 和 runnable task;`pause-requested` 必须先收束成 `paused`,`paused` 直接保持休眠。resume 的有效迁移只接受 `paused -> active`,先清理同一 run 遗留 cancel tombstone,再唤醒原 Agent/Session/run,不创建新 run;若 sidecar 已 `active` 但 Runtime 投影或 Runner 唤醒未提交,重复 resume 继续补齐同一 run,不能假成功。当前 Agent/Session/run 的 Goal sidecar 损坏或冲突时,即使 Runtime 缺少 legacy `goalId` 投影也失败关闭到 reconciliation。 - Finalization:journal 升级为 `game-creator-runtime-finalization.v3` 并绑定 Goal revision/快照。assistant 按稳定 messageId 落盘后,先可靠写入 Runtime completed task/state,再提交 Goal completed,并补写携带 Goal 终态的 task/state projection;全部可靠后 journal 才进入 `runtime-completed` 并删除。assistant 尚未落盘且 Goal revision 漂移时丢弃旧 prepared journal 并 same-run 重规划,assistant 已落盘后只补投影,不再请求 Provider。 +- 持久请求证据:background planning / final reply 的 request snapshot 固定绑定 project、Agent、task、Session、run、source、Goal ID/revision/snapshot fingerprint、applied steer cursor、request kind 和 request slot,requestId 从该闭集稳定派生。Provider future 真正开始前可靠追加 `agent.runtime.provider_request.lifecycle / started`,且该 lifecycle 只能发起一次物理请求;专用客户端强制 `max_retries=0`。返回、可观察失败或控制中断后以同一 requestId 追加唯一 `completed / failed / interrupted`,任何歧义错误不得原样自动重放;显式恢复必须创建新的 slot/lifecycle。记录不含 prompt、工具输入、URL、模型、回复或错误正文。 +- Provider orphan barrier:注册后在项目写锁内复核 queued steer、cancel tombstone、规范 Goal、task/Runtime 身份和 steer cursor,再提交 `started`;已生效控制不写伪 `started`。启动新请求前全量扫描同 Agent/run 的 lifecycle;发现 `started` 没有可信唯一终态,或同 request 多终态、字段冲突、阶段重复/倒置时,立即把原 run 投影为 `needs-reconciliation`,阻断 Provider、工具和 finalization,禁止自动补发。paused 重启窗口必须以 started 数量零增长证明没有暗中请求。 +- Finalization 顺序与容量:同一 `finalizationId / messageId` 的物理七槽严格固定为 `lifecycle/prepared -> conversation.message assistant 审计 -> lifecycle/assistant-persisted -> lifecycle/runtime-completed -> lifecycle/goal-completed -> agent.runtime.completed -> agent.runtime.background_task.completed`。prepared 成功即在独立的 128 条 lifecycle/finalization reserve 中同时预留后六条的记录数和最大字节容量;七条都不能占用 64 条 action receipt/reconciliation reserve。缺前序、倒序、重复、跨身份匹配失败或容量无法兑现时失败关闭;prepared journal 后首条审计失败保持 `finalizing` 并恢复补齐,不得改判普通 failed 或重放 Provider/assistant。 +- Finalization 生产闭集:四条 lifecycle 只允许固定 lifecycle 字段和统一 `schemaVersion / updatedAt` envelope,并绑定 `responseChars / conversationPath`;assistant 审计只允许 `recordType / agentId / sessionId / role / path / messageId / finalizationId`,两条 completed 审计只允许 `recordType / agentId / taskId / sessionId / runId / source / finalizationId / messageId / responseFingerprint / responseChars`,再加同一 envelope。匹配必须逐字核对 finalization/message、Agent/task/Session/run/source、Goal/plan 快照、response fingerprint/chars 和 conversation path,四阶段还必须核对 ordinal/previousStage 与 JSONL 物理顺序;任何额外生产字段都不能获得 finalization reservation。 +- 公共投影边界:task、Goal/steer、委派任务、`project.verify` 命令和 Provider/Runtime error 正文只保留在对应私有执行事实中。event、Agent DB、receipt、activity、output 与报告统一只存身份、状态、SHA-256、字符/字节/条目计数和经 URL、项目根、其它绝对路径及凭据清洗的有界摘要;公共 task 固定不存正文,委派只存 `taskSha256 / taskChars`,verify 只存脚本安全标识、`expectedCommandSha256 / expectedCommandChars`、timeout 和结果计数,error 只存 kind/fingerprint/chars 或脱敏摘要。禁止保留 task/Goal/委派/命令/error 的正文、preview、head 或 tail;普通非 Goal 任务也不例外。 +- 真实验收器:revision 2 marker/path/content 不再预埋首轮项目 fixture;两个 revision 都必须命中同一交付路径的真实 `file.write` 或 `project.patchset create` 待确认动作,edit 前最终 marker/文件必须不存在,revision 1 已完成步骤在 revision 2 和终态不可回退。Goal suite 使用带 sentinel 的专用 AppData,配置只以 hardlink 复用并在清理前核对 inode/hash;全部 CLI 固定指向专用 config dir,Runner 强杀绑定 endpoint、boot、实际二进制/argv 和 OS 启动指纹,endpoint 丢失只允许回收已认领的同指纹进程。CLI JSON 只接受精确 assigned 前缀,Goal completion evidence 按四项生产契约逐字核对,公共扫描同时包含完整正文和两个 marker,失败报告从现存 task/event/Agent DB/conversation 分面容错回收部分证据而不再全报 0。 - 展示边界:开发 Agent UI 使用 `执行 / 聊天 / 目标` 三段模式,Goal 创建/编辑通过独立弹层完成,并展示状态、revision、完成标准和暂停/恢复/清理;纯聊天 CLI 提供对应 `/goal` 命令。正式用户 Project Supervisor 页面不暴露 Goal 管理控件。 -- 验收现状:确定性回归与 UI 覆盖不能替代真实 Provider 长链路。截至 2026-07-15 尚未记录 V1.18 真实 Provider PASS;恢复后必须用一次性项目完成 Goal edit、pause、Runner 强杀、重启保持 paused、显式同 run resume、唯一 assistant 和零旧动作重放的交叉取证。 +- 验收现状:确定性回归与 UI 覆盖不能替代真实 Provider 长链路。截至 2026-07-15 尚未记录 V1.18 真实 Provider PASS;最新现场仍在首轮 planning、零 plan/action 时由对端关闭长连接,Rust 25.2 秒短请求成功只能证明基础通道。恢复后必须用一次性项目完成 Goal edit、pause、Runner 强杀、重启保持 paused、显式同 run resume、唯一 assistant 和零旧动作重放的交叉取证。 ## 2026-07-15 Project Supervisor 纯聊天短入口 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 374159a56..e4697c4cd 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 @@ -376,7 +376,7 @@ npm run ai-game-creator-shell:agent-runtime:real-e2e -- --config-dir - - policy denied 和未知工具也要先落 durable observed pending,再追加 terminal receipt。terminal observation 一旦持久化,就不能在 receipt 前响应取消;receipt 成功后再结束取消流程。receipt 写入失败统一进入 `needs-reconciliation`,恢复仅补回执,不重放工具。 - action 关联的 task / event 投影按 `runId + actionId + phase` 幂等,同一动作允许从 `waiting-for-confirmation` 合法推进到终态 observation,但同阶段身份冲突继续失败关闭。Agent DB 终态 observation 扫描跳过同 action 的非终态前置记录,只对既有终态做全字段复核;`recentToolCalls` 按 actionId 原位更新,不能让 waiting 投影遮住后续 `ok / failed`。 - `agent.action_history` 结构化 detail 上限 7,200 字符;超预算时先移除可选字段,再删除完整最旧项,禁止用字符截断破坏 JSON,禁止清空 `runId / actionFingerprint`。上下文清洗覆盖 Unix、Windows 盘符、正反斜杠 UNC、`file:/...`、`file:///...` 和百分号编码的 `file:` URI,绝对路径统一替换为 ``,同时保留普通相对文本与 HTTP(S) URL。 -- 后台 planning 与最终回复遇到 `Timeout / Connectivity / Transport` 或上游 `408 / 429 / 5xx` 时最多额外重试 5 次,按 `500 / 1000 / 1500 / 2000 / 2500ms` 退避;配置、请求、流协议、反序列化错误及其他 `4xx` 不重试。重试只发生在工具计划执行前或最终回复落盘前,不能重放已完成副作用。 +- 2026-07-15 收口:后台 planning 与最终回复的每个 Provider request lifecycle 只允许一次物理请求,专用 `LlmClient` 无条件强制 `max_retries=0`,不继承全局或 per-Agent `maxRetries`。`Timeout / Connectivity / Transport / EmptyResponse / 408 / 429 / 5xx` 等歧义错误只收束当前 lifecycle,禁止自动原样重放;只有显式 steer、Goal resume 或人工 reconciliation 后的新 request slot 才能建立新 lifecycle。工具协议解析失败后的格式修复使用独立 repair slot/lifecycle,不属于传输重试。 - 本轮只提供模型工具,不新增前端动作历史弹窗。UI 继续显示最近动作投影;后续若增加历史查看能力,必须使用点击后打开的独立弹窗,不得在当前面板下方追加内容。 ### 2026-07-13 真实验收结果 @@ -769,13 +769,13 @@ V1.18 对标 Codex CLI `/goal` 的长任务语义:目标文本既是首轮任 ### 启动、编辑与运行隔离 -- `/goal <文本>` 或 Tauri start command 创建 Goal 并用同一 outcome 启动首个 background run;未显式提供 verification 时,outcome 自身作为完成标准。Goal 活跃或暂停期间,当前 Session 禁止另起不相关 run;后续普通输入默认继续走同 run steer,独立任务应使用另一 Session。 +- `/goal <文本>`、Tauri start command 或 `--agent-goal-start [--init] --stdin` 创建 Goal 并用同一 outcome 启动首个 background run;CLI 的 `--init` 只在 manifest 缺失时初始化一次性/新项目,不借道其它 Agent task。未显式提供 verification 时,outcome 自身作为完成标准。Goal 活跃或暂停期间,当前 Session 禁止另起不相关 run;后续普通输入默认继续走同 run steer,独立任务应使用另一 Session。 - 编辑 Goal 先在项目写锁内提交 revision,再把规范化的新目标作为同 run steer 持久化。Provider 正在 planning 时允许中断;确认中或工具执行中只排队,旧动作在下一安全边界前必须校验 Goal revision,不能在目标已变更后继续执行。旧自动动作或待确认动作若绑定旧 Goal 快照,统一转成 `blocked` observation 并在同一 run 重规划,不执行旧副作用,也不创建 retry run。编辑失败不得回退已提交 revision,Runtime 会以 sidecar 为事实源重规划并拒绝旧 finalization。 - Goal 内容进入每轮 planning/final reply 的显式“持久目标”上下文。模型仍通过 V1.17 `planUpdate` 维护可观察步骤;Goal revision 不推进 project revision、不改变权限或 verification gate,也不能放宽 sandbox/approval。 ### 暂停、恢复与清理 -- pause 写 durable request,并通过 typed Runner `runtime.pause` 中断当前 planning/final Provider;已进入工具的动作允许返回后再停。Provider 中断或返回边界先把可恢复 continuation 写入 v4 context bundle,其中恢复快照按 `active` Goal 语义保存,随后 Runtime 才在 LLM/工具/observation/finalization 安全边界把同一 run 收束为 `paused`。Runner 重启时先处理 cancel / Goal control,再进入 process reconciliation、finalization 和 pending action;`pause-requested` 会先收束成 `paused`,`paused` 直接保持休眠,不生成 assistant、不创建新 run、不调用 Provider。 +- pause 写 durable request,并通过 typed Runner `runtime.pause` 中断当前 planning/final Provider;已进入工具的动作允许返回后再停。Provider 注册、durable Goal control/cancel 二次复核与 `started` 审计使用同一项目写锁形成线性化边界:锁内同时核对 cancel tombstone、queued steer、Goal 状态和 task 绑定的 Goal revision,edit 已提交新 revision 但 steer 尚未落盘时也不得轮询旧 Provider 或写伪 `started`;请求先提交时才属于可中断或等待安全边界的在途调用。Provider 中断或返回边界先把可恢复 continuation 写入 v4 context bundle,其中恢复快照按 `active` Goal 语义保存,随后 Runtime 才在 LLM/工具/observation/finalization 安全边界把同一 run 收束为 `paused`。Runner 重启时先处理 cancel / Goal control,再进入 process reconciliation、finalization 和 pending action;`pause-requested` 会先收束成 `paused`,`paused` 直接保持休眠,不生成 assistant、不创建新 run、不调用 Provider。 - 暂停父 Agent 不撤销已经 durable 投递的专业 child;child 可以把结果写成 ready,但父 run 在显式 resume 前不能认领或继续 Provider。Runner-owned process session 在暂停提交前终止,恢复后由 Agent 根据 observation 重规划,禁止按 PID 重连或重放未知 start。 - resume 的有效状态迁移只接受 `paused -> active`,先清理同一 run 遗留的 cancel tombstone,再把原 Agent/Session/run 重新投影为 pending 并唤醒 External Runner;不创建 retry run。若进程在 Goal sidecar 已写成 `active`、Runtime 投影或 Runner 唤醒尚未完成时失败,重复 resume 必须识别同一 run 的半提交并继续补齐,不能把 `active` 单独当成恢复成功或直接返回。pending confirmation 仍回到确认态,普通 planning 从 v4 context bundle 继续。clear 对活跃 Goal 复用取消 tombstone,待安全取消后把 Goal 写为 cleared;paused/completed Goal 可直接清理。clear 不删除历史 conversation、task、event 或 Goal history。 @@ -785,13 +785,22 @@ V1.18 对标 Codex CLI `/goal` 的长任务语义:目标文本既是首轮任 - pending action 升级为 `game-creator-pending-action.v5`,在既有 project revision、verification gate、repository context fingerprint 和 steer cursor 外,固定绑定 `goalId / goalRevision / goalSnapshotFingerprint`。旧 v1-v4 一律失败关闭,不能从当前 Goal 猜回缺失绑定;Goal edit 后,无论动作原为自动还是待确认,都把旧记录收束成稳定 `blocked` observation 并在同一 run 重规划。 - finalization journal 升级为 `game-creator-runtime-finalization.v3`,把 Goal 快照指纹纳入 finalizationId。prepared 回复必须绑定当前 active Goal、同一 run/revision、全部完成的结构化计划、清空的 process/join/delegate 屏障和现有 verification gate。Goal 编辑、暂停、清理或 revision 漂移会让未提交 assistant 的旧 finalization 失效并回到同 run;assistant 已提交后只允许按 journal 原快照补齐 Runtime 与 Goal completed,不能重新请求 Provider。 - assistant 持久化后,finalization 先写 Runtime completed task/state,再把规范 Goal 写为 completed,并补写携带 completed Goal 状态的 task/state projection;两层投影均可靠后,journal 才推进 `runtime-completed` 并删除。完成证据由系统从最终结构化计划、verification gate、run/session 身份和 response fingerprint 生成,不保存模型 thinking 或原始私有 observation。Goal 未完成、paused、clearing、sidecar 缺失或 revision 不匹配时,response 不能绕过完成门禁。 +- finalization 以同一 `finalizationId / messageId` 在 Agent DB 追加四条 `agent.runtime.finalization.lifecycle`:`prepared` 在 prepared journal 后,`assistant-persisted` 在会话及其审计和 journal 状态后,`runtime-completed` 在首个 Runtime completed task/state 后且 Goal 完成前,`goal-completed` 在规范 Goal 与第二个 completed task/state 均可靠后。四阶段字段闭集固定为 `recordType / auditSchemaVersion / journalSchemaVersion / agentId / taskId / sessionId / runId / source / finalizationId / messageId / stage / stageOrdinal / previousStage / goalId / goalRevision / goalSnapshotFingerprint / planRevision / planSnapshotFingerprint / responseFingerprint / responseChars / conversationPath / stageAt`,持久层只可再添加统一 `schemaVersion / updatedAt` envelope;不得携带 task、response、Goal、observation、error 或其它额外字段。 +- finalization-critical 物理顺序严格固定为七槽:`lifecycle/prepared -> conversation.message assistant 审计 -> lifecycle/assistant-persisted -> lifecycle/runtime-completed -> lifecycle/goal-completed -> agent.runtime.completed -> agent.runtime.background_task.completed`。assistant 审计字段闭集只允许 `recordType / agentId / sessionId / role / path / messageId / finalizationId`,两条 completed 审计只允许 `recordType / agentId / taskId / sessionId / runId / source / finalizationId / messageId / responseFingerprint / responseChars`,并只允许同一持久 envelope。prepared 成功时必须在独立的 128 条 lifecycle/finalization reserve 中同时预留后六条的记录数和最大字节容量,七条都不得占用 64 条 action receipt/reconciliation reserve;容量不足时 prepared 自身失败关闭。容量扫描和幂等检查都在 Agent DB 追加锁内读取完整受支持文件,不依赖 32 MiB recent tail,并对 `finalizationId / messageId / Agent / task / Session / run / source / Goal/plan 快照 / responseFingerprint / responseChars / conversationPath`、stage ordinal/previousStage 和 JSONL 物理顺序做精确匹配。缺前序、倒序、重复、身份冲突、额外生产字段或 reservation 无法兑现都失败关闭;严格七槽未全部齐全时不得删除 journal。prepared journal 已写入但首条 lifecycle 暂时失败时,run 保持可恢复 `finalizing`,不得被外层改判普通 failed;恢复只补同一七槽,不重放 Provider 或 assistant。 ### 控制面与验收 - 纯聊天入口支持 `/goal <文本>`、`/goal status`、`/goal pause`、`/goal resume`、`/goal edit <文本>`、`/goal clear`;开发 Agent UI 使用 `执行 / 聊天 / 目标` 三段模式,Goal 创建/编辑通过独立弹层提交,并在状态行显示 outcome、revision、状态与完成标准,提供暂停/恢复/清理。普通用户 Supervisor 首页不暴露开发 Goal 管理控件。 +- background planning / final reply 的专用 Provider 客户端强制 `max_retries=0`;每个 `agent.runtime.provider_request.lifecycle` 从 `started` 到唯一 `completed / failed / interrupted` 最多对应一次物理请求。`Timeout / Connectivity / Transport / EmptyResponse / 408 / 429 / 5xx` 以及无法证明请求未被上游接收的其它错误,不得在同一 lifecycle 内自动原样重放;只记录 error kind、SHA-256、字符数或脱敏摘要。显式 steer、Goal resume 或人工 reconciliation 决定再次调用时,必须使用新的 request slot/lifecycle;格式修复同样使用 `loop--repair-` 新 slot,不能伪装成底层 retry。 +- 每次 request snapshot 固定绑定 `projectId / agentId / taskId / sessionId / runId / source / goalId / goalRevision / goalSnapshotFingerprint / appliedSteerCursor / requestKind / requestSlot`,requestId 从该闭集稳定派生。真正进入 Provider future 前,Runtime 在同一项目写锁内重读 task/Runtime 身份、queued steer、cancel tombstone、规范 Goal 状态与快照;已生效控制只返回未启动,不得写伪 `started`。Provider lifecycle 的生产字段闭集只允许 `recordType / auditSchemaVersion / agentId / taskId / sessionId / runId / source / requestId / requestKind / requestSlot / status`,持久层只可再添加统一 `schemaVersion / updatedAt` envelope;不包含 prompt、工具输入、URL、模型、回复或错误正文。 +- 启动新请求前必须在 Agent DB 锁内全量扫描同 Agent/run 的 Provider lifecycle,不依赖 recent tail。只要发现 `started` 后没有可信唯一终态,就把原 run/task/state 收束到 `needs-reconciliation` orphan barrier,阻断后续 Provider、工具和 finalization;同 request 多终态、缺 started、物理顺序倒置、重复阶段、身份/字段冲突或额外生产字段同样失败关闭,禁止自动补发。paused Runner 重启窗口必须以 started 数量零增长证明没有暗中请求,不能只看 plan/action 是否落盘。 - 确定性验收覆盖:单 Session 单 Goal、跨 Agent/Session 隔离、expectedRevision 幂等/冲突、活跃 Goal 阻止新 run、Provider in-flight pause、工具返回后 pause、paused 重启不自启、同 run resume、pending confirmation 恢复、编辑触发 steer 与旧动作失效、clear/cancel 竞态、v4/v3/v2 恢复、v3 finalization、assistant 后崩溃补齐 completed,以及 Goal 元数据零 project revision/policy 变化。 -- 真实 Provider 使用一次性项目证明:Goal 自行建立并多次更新计划,运行中编辑一次,暂停并强杀 Runner,重启后保持 paused,显式恢复同 run,最终全部步骤和 verification 收束后只写一个 assistant;task/event/context/finalization/goal/conversation 交叉证明无新主 run、无动作重放、无 paused Provider 调用,并扫描密钥、Goal 正文和项目绝对路径的公共泄漏。 -- 截至 2026-07-15,V1.18 真实 Provider 门禁尚未通过,不能把 Rust/Runner/UI 确定性回归或短鉴权请求外推为 V1.18 PASS;Provider 链路恢复后仍需完整执行上一条一次性项目验收。 +- 真实 Provider 使用现有 `agent-runtime-real-e2e.mjs` 的独立 `goal-runtime` suite 和一次性项目证明,不新增平行验收器。suite 只要求 AppData 中 `code-prototype` 的真实 LLM 配置,不要求 Chrome 或 External Editor API。revision 1 必须先形成至少三步、已有 completed 且仍有未完成步骤的计划,并停在一个绑定 Goal revision 1 的 `game-creator-pending-action.v5` 写动作;编辑到 revision 2 后,该旧动作必须形成 `runtime.goal / blocked` observation 且旧标记从未落盘,同一 run 再形成绑定 revision 2 的 v5 写动作。 +- `goal-runtime` 不复用正在运行的正式 AppData Runner。验收器在用户提供的 AppData 下创建 `0700` 专用子目录和带随机 owner token/PID/时间的 sentinel;主配置与可选 local 配置只以普通文件 hardlink 复用,结束时复核 source/link 的 device、inode 与 SHA-256,全程不复制或输出 API Key。所有 Goal/Runner CLI 统一附加该专用 `runtimeConfigDir`。SIGKILL 前必须同时核对 sentinel、endpoint、Runner boot/PID/port、实际 CLI 路径、`--agent-runner --config-dir` argv 和 OS 启动指纹;endpoint 在已认领后异常消失时,只允许按先前同一启动指纹回收。成功或失败都先停止自有 Runner,再按 sentinel 和受限目录前缀删除专用 AppData;身份不一致时保留现场并失败,禁止猜测或清理其它 Runner。 +- revision 2 验证 fixture 只在 revision 1 待确认动作与隔离证明完成后注入,并先由宿主真实执行一次失败命令形成不可伪造的失败边界。失败报告对 task、event、Agent DB 和 conversation 分面容错读取,截断尾行保留已完成 JSONL 记录并单独报告读取错误;不得把某一分面损坏折算成其它分面全为零。 +- revision 2 写动作待确认时执行 pause;命令返回、Goal status 与 Runtime state 都必须是 durable `paused`。随后记录 Goal、`game-creator-runtime-context-bundle.v4`、pending v5、计划、task/event/Agent DB、conversation 和副作用计数,SIGKILL Runner 并使用全局 `--agent-resume` 启动新 boot;至少两个稳定采样窗口内上述运行证据不得推进,不得新增 Provider plan、工具执行、assistant 或主 run。只有显式 `--agent-goal-resume` 后才允许确认 revision 2 动作并继续。 +- 最终 Goal、Runtime 和最新 task 必须在原 Agent/Session/run 上 completed,结构化计划全部完成,Goal completion evidence 必须精确匹配当前 Goal/plan/verification/run/session;同一 finalizationId 必须按严格七槽物理顺序形成完整记录,finalization sidecar 最终不残留,目标 Session 只允许一个 assistant。Goal sidecar、task JSONL、Runtime state、v4 context 和 conversation 属于本地私有执行事实,可包含完成目标所需正文;event、Agent DB、receipt、activity、output 与最终报告不得保存 task、Goal/steer、委派任务、verify 命令或 error 正文,只允许身份/状态、SHA-256、字符/字节/条目计数和经 URL、项目根、其它绝对路径及凭据清洗的有界摘要。公共 task 统一不保留正文;委派只保留 `taskSha256 / taskChars`,verify 只保留脚本安全标识、`expectedCommandSha256 / expectedCommandChars`、timeout 和结果计数,error 只保留 kind/fingerprint/chars 或脱敏摘要,禁止任何正文、preview、head 或 tail。验收必须扫描完整 Goal/编辑/委派/verify/error canary、已加载密钥和一次性项目绝对路径在全部公共持久面泄漏为 0,并确认动作、消息、receipt 和 Provider lifecycle 均无重复。 +- 截至 2026-07-15,V1.18 真实 Provider 门禁尚未通过。最新保留现场在首轮 planning、`planRevision=0`、零 pending action/observation 时由对端关闭长 TLS 连接;同一发布配置、模型和 Rust native-tls 客户端的最小单 Agent 请求在 25.2 秒成功,证明基础鉴权与短请求通道可用,但不能外推为工具 planning 或 Goal 长链路 PASS。Provider 长请求恢复后仍需完整执行上一条一次性项目验收。 ## 验收命令 @@ -806,6 +815,7 @@ V1.18 对标 Codex CLI `/goal` 的长任务语义:目标文本既是首轮任 - `cargo test -p shared-contracts --manifest-path server-rs/Cargo.toml game_creation_app` - `npm run ai-game-creator-shell:agent-run:smoke` - `npm run ai-game-creator-shell:agent-runtime:real-e2e -- --config-dir --suite llm-runtime` +- `npm run ai-game-creator-shell:agent-runtime:real-e2e -- --config-dir --suite goal-runtime` - `npm run ai-game-creator-shell:agent-runtime:real-e2e -- --config-dir --suite full` - `npm run check:encoding` - `git diff --check`