diff --git a/apps/ai-game-creator-shell/game-creator.config.json b/apps/ai-game-creator-shell/game-creator.config.json index bb00a1ed7..91e5d01a0 100644 --- a/apps/ai-game-creator-shell/game-creator.config.json +++ b/apps/ai-game-creator-shell/game-creator.config.json @@ -16,8 +16,5 @@ "maxRetries": 2, "retryBackoffMs": 500 }, - "agentLlm": {}, - "planning": { - "capabilityEnabled": true - } + "agentLlm": {} } diff --git a/apps/ai-game-creator-shell/package.json b/apps/ai-game-creator-shell/package.json index 50f3050c7..e195301c3 100644 --- a/apps/ai-game-creator-shell/package.json +++ b/apps/ai-game-creator-shell/package.json @@ -19,8 +19,6 @@ "config": "node scripts/game-creator-config-wizard.mjs", "test:chat": "node scripts/agent-swarm-test-chat.mjs --task \"制作一个可直接试玩的原创植物塔防小游戏:玩家选择并放置原创守卫阻挡敌人,完成波次后可以进入下一关并重新开始。主题、单位名称与视觉语言必须原创,不使用任何现有游戏角色、单位名、Logo 或受保护视觉语言。请自主完成正式产物、静态检查和双视口试玩验证。\" --no-open", "test:chat:manual": "node scripts/agent-swarm-test-chat.mjs", - "test:plan": "node scripts/agent-swarm-test-chat.mjs --plan --task \"我想做一款原创横版像素解谜小游戏,主角是一个能操控自己影子的小机器人,影子可以变成平台和开关。请完成立项策划并给出 Fast GDD。主题、角色名与视觉语言必须原创,不使用任何现有游戏角色、名称、Logo 或受保护视觉语言。\"", - "test:plan:manual": "node scripts/agent-swarm-test-chat.mjs --plan", "agent-run": "node scripts/run-cli-with-config.mjs --agent-run", "agent-run:smoke": "node scripts/smoke-agent-run-local-provider.mjs", "agent-runtime:real-e2e": "node scripts/agent-runtime-real-e2e.mjs", @@ -53,6 +51,7 @@ "focus-trap-react": "^12.0.3", "lexical": "^0.47.0", "lucide-react": "^0.546.0", + "phaser": "^4.2.1", "react": "^19.0.0", "react-arborist": "^3.16.0", "react-colorful": "^5.8.0", diff --git a/apps/ai-game-creator-shell/scripts/agent-swarm-test-chat.mjs b/apps/ai-game-creator-shell/scripts/agent-swarm-test-chat.mjs index e170b07c6..14a529600 100644 --- a/apps/ai-game-creator-shell/scripts/agent-swarm-test-chat.mjs +++ b/apps/ai-game-creator-shell/scripts/agent-swarm-test-chat.mjs @@ -36,8 +36,6 @@ export const ungeneratedGameEntryMarker = '还没有生成游戏。回到聊天输入创意并确认生成后'; export const defaultRealSwarmTestTask = '制作一个可直接试玩的原创植物塔防小游戏:玩家选择并放置原创守卫阻挡敌人,完成波次后可以进入下一关并重新开始。主题、单位名称与视觉语言必须原创,不使用任何现有游戏角色、单位名、Logo 或受保护视觉语言。请自主完成正式产物、静态检查和双视口试玩验证。'; -export const defaultRealSwarmPlanTask = - '我想做一款原创横版像素解谜小游戏,主角是一个能操控自己影子的小机器人,影子可以变成平台和开关。请完成立项策划并给出 Fast GDD。主题、角色名与视觉语言必须原创,不使用任何现有游戏角色、名称、Logo 或受保护视觉语言。'; export const swarmTurnReportPrefix = '[turn.report] '; export const swarmTurnReportSchema = 'game-creator-swarm-turn-report.v1'; @@ -140,15 +138,10 @@ export const usage = `用法: --keep-project 保留自动创建的一次性项目 --no-open 手工模式启动预览但不自动打开浏览器 --task <需求> 通过 manual 入口非交互提交自定义需求 - --plan 走「做方案」立项策划入口,不做游戏,不做产物验收和试玩 - --timeout-minutes <分钟> 设置本次执行期限;自动任务默认 50 分钟,--plan 默认 6 分钟,手工模式默认不限时 + --timeout-minutes <分钟> 设置本次执行期限;自动任务默认 50 分钟,手工模式默认不限时 --dry-run 只检查目录发现和项目准备,不启动 LLM -h, --help 显示帮助 - -环境变量: - AGC_PLAN_GDD_DECISION 审批卡自动应答动作,默认 approve;revise/reject 必须 - 同时用 AGC_PLAN_GDD_COMMENT 给出真实修改意见 - AGC_PLAN_GDD_COMMENT revise/reject 的意见原文`; +`; function readOptionValue(args, index, option) { const value = args[index + 1]?.trim(); @@ -177,7 +170,6 @@ export function parseSwarmTestArguments(args) { keepProject: false, openBrowser: true, task: null, - plan: false, timeoutMinutes: null, dryRun: false, help: false, @@ -202,8 +194,6 @@ export function parseSwarmTestArguments(args) { if (task.length > 4_000) throw new Error('--task 不能超过 4000 字符'); options.task = task; index += 1; - } else if (argument === '--plan') { - options.plan = true; } else if (argument === '--timeout-minutes') { if (options.timeoutMinutes !== null) { throw new Error('--timeout-minutes 只能指定一次'); @@ -222,16 +212,11 @@ export function parseSwarmTestArguments(args) { } export function shouldStartPersistentPreview(options) { - // 立项策划链路只出 GDD,没有可试玩产物,任何模式都不该起预览。 - return !options.task && !options.plan; + return !options.task; } export function resolveSwarmTestTimeoutMs(options) { - // 立项策划的设计目标是五分钟出方案,给一分钟余量;再久就是卡住了,早失败 - // 比让 harness 空等更有用。做游戏那条链路的 50 分钟不变。 - const planMinutes = options.plan ? 6 : null; - const minutes = - options.timeoutMinutes ?? planMinutes ?? (options.task ? 50 : null); + const minutes = options.timeoutMinutes ?? (options.task ? 50 : null); return minutes === null ? null : minutes * 60_000; } @@ -979,25 +964,12 @@ export function swarmAutoPilotShouldCloseInput(output, promptsAfterSubmit) { return swarmAutoPilotSitsAtPrompt(output) && promptsAfterSubmit >= 1; } -// GDD 审批位不能等 CLI 退出之后再处理:Run 停在这里时状态是 waiting-for-user-input, -// 而 swarm CLI 恰好把这个状态算作「本轮还在跑」,turn 永远不 settle,CLI 也就永远 -// 不退出。所以审批必须在 CLI 还活着的时候并发做完,让 Run 自己继续跑到收束。 -// 这一句是 PlanGddCompletionBlockerKind::AwaitingApprovalDecision 专有的投影文案, -// 另外三个 blocked 子状态都不会打出它;即便认错了,真正的判据也是随后那次 -// --plan-gdd-status,没有待决定审批时不会有任何写入。 -const planGddApprovalWaitPattern = /等待 Fast GDD 审批决定/u; - -export function swarmOutputAwaitsPlanGddApproval(line) { - return planGddApprovalWaitPattern.test(line); -} - async function runTaskCargo( cliArguments, task, setActiveChild, timeoutMs, autoPilot = false, - onPlanGddApprovalWait = null, ) { const child = spawnChild(cargoCommand, buildCargoCliArguments(cliArguments), { stdio: ['pipe', 'pipe', 'inherit'], @@ -1009,25 +981,6 @@ async function runTaskCargo( let taskSubmitted = false; let promptsSeen = 0; let sittingAtPrompt = false; - let planGddApproval = null; - let planGddApprovalError = null; - let planGddApprovalStarted = false; - let planGddApprovalPromise = null; - const startPlanGddApproval = () => { - planGddApprovalStarted = true; - console.log( - `[自动审批] 检测到 Fast GDD 审批位,正在提交 ${resolvePlanGddAutoDecision().action}`, - ); - planGddApprovalPromise = onPlanGddApprovalWait() - .then((value) => { - planGddApproval = value; - }) - .catch((error) => { - planGddApprovalError = error; - // 审批没成的话 Run 会一直停在等待位,干等到超时只会把真正的原因埋掉。 - void terminateChildTree(child).catch(() => {}); - }); - }; child.stdout.setEncoding('utf8'); child.stdout.on('data', (chunk) => { process.stdout.write(chunk); @@ -1040,13 +993,6 @@ async function runTaskCargo( reportLines.push(normalizedLine); settled = true; } - if ( - onPlanGddApprovalWait && - !planGddApprovalStarted && - swarmOutputAwaitsPlanGddApproval(normalizedLine) - ) { - startPlanGddApproval(); - } } if (!autoPilot || child.stdin.writableEnded) return; const atPrompt = swarmAutoPilotSitsAtPrompt(pendingLine); @@ -1085,12 +1031,9 @@ async function runTaskCargo( if (normalizedPendingLine.startsWith(swarmTurnReportPrefix)) { reportLines.push(normalizedPendingLine); } - await planGddApprovalPromise; - if (planGddApprovalError) throw planGddApprovalError; return { ...result, turnReportOutput: reportLines.join('\n'), - planGddApproval, }; } finally { setActiveChild(null); @@ -1780,196 +1723,6 @@ export async function validateSwarmProjectArtifacts(projectPath, options) { return inspection; } -// 这四条路径的权威定义都在 Rust 侧 `planning_storage.rs`(`PLAN_SESSION_PATH`、 -// `PLAN_GDD_INDEX_PATH`、`PLAN_STORAGE_ROOT`、`PLAN_FAST_GDD_PATH`)。跨语言没有共享 -// 常量的通道,改路径时要连同 `GddApprovalCard.tsx` 一起动。 -export const planningOutputPaths = [ - '.agent/planning/session.json', - '.agent/planning/index.json', - '.agent/planning/pending.json', - 'game/fast_gdd.md', -]; - -export async function inspectPlanningOutputs(projectPath) { - const outputs = []; - for (const relativePath of planningOutputPaths) { - const absolutePath = path.join(projectPath, ...relativePath.split('/')); - const metadata = await lstat(absolutePath).catch((error) => { - if (error?.code === 'ENOENT') return null; - throw error; - }); - outputs.push({ - path: relativePath, - exists: Boolean(metadata?.isFile()), - bytes: metadata?.isFile() ? metadata.size : 0, - }); - } - return outputs; -} - -async function reportPlanningOutputs(projectPath) { - const outputs = await inspectPlanningOutputs(projectPath); - console.log('\n立项策划产物:'); - for (const output of outputs) { - console.log( - output.exists - ? ` [有] ${output.path}(${output.bytes} 字节)` - : ` [无] ${output.path}`, - ); - } -} - -const planGddApprovalTimeoutMs = 60_000; -export const planGddStatusOutputPrefix = 'planGddStateJson='; -export const planGddDecisionOutputPrefix = 'planGddDecisionJson='; - -function parsePrefixedJsonLine(output, prefix, label) { - const line = output - .split('\n') - .map((value) => (value.endsWith('\r') ? value.slice(0, -1) : value)) - .find((value) => value.startsWith(prefix)); - if (!line) throw new Error(`${label}缺少 ${prefix} 输出`); - try { - return JSON.parse(line.slice(prefix.length)); - } catch (error) { - throw new Error(`解析${label}失败:${error.message}`); - } -} - -export function parsePlanGddStatusOutput(output) { - return parsePrefixedJsonLine( - output, - planGddStatusOutputPrefix, - 'Fast GDD 审批状态', - ); -} - -export function parsePlanGddDecisionOutput(output) { - return parsePrefixedJsonLine( - output, - planGddDecisionOutputPrefix, - 'Fast GDD 审批回执', - ); -} - -// 审批卡是这条链路唯一的人类判据,所以自动应答默认只投 approve,且只在投影确实有 -// 一张待决定审批时出手。revise/reject 需要一段真实的修改意见,让机器编一段等于把 -// 判据换成噪声——所以那两条分支只在跑的人自己用 AGC_PLAN_GDD_COMMENT 给出意见时 -// 才走。手工调 --plan-gdd-decide 也能达到同样效果,但那要求 plan 根 run 仍然活着, -// 而它恰好是本进程持有的 CLI 子进程。 -export function planGddAutoApprovalIsPending(state) { - return Boolean(state?.pendingApproval); -} - -export function resolvePlanGddAutoDecision(env = process.env) { - const action = (env.AGC_PLAN_GDD_DECISION ?? 'approve').trim(); - if (!['approve', 'revise', 'reject'].includes(action)) { - throw new Error('AGC_PLAN_GDD_DECISION 只能是 approve / revise / reject'); - } - const comment = (env.AGC_PLAN_GDD_COMMENT ?? '').trim(); - if (action === 'approve') return { action, comment: null }; - if (!comment) { - throw new Error( - `${action} 必须同时设 AGC_PLAN_GDD_COMMENT 提供真实修改意见`, - ); - } - return { action, comment }; -} - -async function settlePlanGddApproval( - projectPath, - runtimeConfigPath, - setActiveChild, -) { - const readStatus = async () => { - const result = await runCapturedCargo( - ['--config-dir', runtimeConfigPath, '--plan-gdd-status', projectPath], - setActiveChild, - { - timeoutMs: planGddApprovalTimeoutMs, - label: 'Fast GDD 审批状态查询', - }, - ); - if (result.code !== 0 || result.signal) { - throw new Error( - `读取 Fast GDD 审批状态失败:${result.stderr.trim() || result.stdout.trim()}`, - ); - } - return parsePlanGddStatusOutput(result.stdout); - }; - - const before = await readStatus(); - if (!planGddAutoApprovalIsPending(before)) { - return { decided: false, state: before }; - } - const { action, comment } = resolvePlanGddAutoDecision(); - const decision = await runCapturedCargo( - [ - '--config-dir', - runtimeConfigPath, - '--plan-gdd-decide', - projectPath, - action, - ...(comment === null ? [] : ['--stdin']), - ], - setActiveChild, - { - timeoutMs: planGddApprovalTimeoutMs, - label: 'Fast GDD 审批决定', - stdin: comment, - }, - ); - if (decision.code !== 0 || decision.signal) { - throw new Error( - `提交 Fast GDD 审批决定失败:${decision.stderr.trim() || decision.stdout.trim()}`, - ); - } - const receipt = parsePlanGddDecisionOutput(decision.stdout); - // 回执落盘和唤醒后台任务是两件事:decide 命令把唤醒失败降级成 recoveryPending, - // 于是审批已经生效、Run 却仍停在 waiting-for-user-input。实测就是这样——只有 - // 补一次 --agent-resume 才会重新起 turn。这是仓库自己给这个状态定义的恢复动作。 - let recovered = false; - if (receipt.recoveryPending) { - const resume = await runCapturedCargo( - ['--config-dir', runtimeConfigPath, '--agent-resume', projectPath], - setActiveChild, - { - timeoutMs: planGddApprovalTimeoutMs, - label: 'Fast GDD 审批后恢复后台任务', - }, - ); - if (resume.code !== 0 || resume.signal) { - throw new Error( - `审批已提交但恢复后台任务失败:${resume.stderr.trim() || resume.stdout.trim()}`, - ); - } - recovered = true; - } - return { decided: true, receipt, recovered, state: await readStatus() }; -} - -async function reportPlanGddApproval(approval) { - const { state } = approval; - console.log('\nFast GDD 审批:'); - if (!approval.decided) { - console.log(` [无待决定审批] 当前投影状态=${state.state}`); - return; - } - console.log( - ` [已决定 ${approval.receipt.decisionRef.action}] outcome=${approval.receipt.outcome} v${approval.receipt.decisionRef.version} 投影状态=${state.state}`, - ); - if (approval.recovered) { - console.log( - ' [已恢复] 审批回执的 recoveryPending 由一次 --agent-resume 收口', - ); - } - if (state.session) { - console.log( - ` 澄清轮次=${state.session.clarificationRound} 返工深度=${state.session.repairDepth} phase=${state.session.phase}`, - ); - } -} - export async function hasConfiguredEditorApiKey(configDir) { let configured = false; for (const fileName of [configFileName, localConfigFileName]) { @@ -2122,13 +1875,7 @@ export async function runSwarmTestChat(options) { const setActiveChild = (child) => { activeChild = child; }; - // GDD 审批要和 swarm CLI 并发跑,两者不能共用 activeChild 这一个槽位:审批子进程 - // 结束时的 setActiveChild(null) 会把 CLI 从槽里抹掉,Ctrl-C 就杀不到它了。 const concurrentChildren = new Set(); - const setConcurrentChild = (child) => { - if (child) concurrentChildren.add(child); - else concurrentChildren.clear(); - }; const stopRequested = () => receivedSignal !== null; const handleSignal = (signal) => { const repeatedSignal = receivedSignal !== null; @@ -2206,11 +1953,10 @@ export async function runSwarmTestChat(options) { ); } console.log('LLM 配置已就绪。'); - const requirementNoun = options.plan ? '立项策划需求' : '游戏需求'; console.log( options.task - ? `已提交一条非交互${requirementNoun},正在等待 Swarm 自主完成。\n` - : `输入一条${requirementNoun}并回车;提交后按 Ctrl+D,让 Swarm 自主完成。\n`, + ? '已提交一条非交互游戏需求,正在等待 Swarm 自主完成。\n' + : '输入一条游戏需求并回车;提交后按 Ctrl+D,让 Swarm 自主完成。\n', ); phase = 'chat'; @@ -2220,8 +1966,7 @@ export async function runSwarmTestChat(options) { runtimeConfig.path, '--swarm-chat', '--init', - // 做方案链路只能跑 standard 档,后端对 plan + autonomous 是硬否决。 - options.plan ? '--plan' : '--autonomous-game-build', + '--autonomous-game-build', project.path, ]; let chat; @@ -2234,15 +1979,6 @@ export async function runSwarmTestChat(options) { timeoutDeadline === null ? null : Math.max(1, timeoutDeadline - Date.now()), - options.plan, - options.plan - ? () => - settlePlanGddApproval( - project.path, - runtimeConfig.path, - setConcurrentChild, - ) - : null, ) : await runInteractiveCargo(chatArguments, setActiveChild); } catch (error) { @@ -2258,32 +1994,6 @@ export async function runSwarmTestChat(options) { if (options.task) { turnReport = parseSettledSwarmTurnReport(chat.turnReportOutput); } - if (options.plan) { - // 立项策划不出游戏产物,正式验收在 GDD 审批卡上;这里只报告落盘情况, - // 是否收束已经由 CLI 的退出码判过了。 - // 自动任务档的审批已经在 CLI 运行期间并发做完了;手工档(人自己敲 Ctrl+D - // 退出)没有那次触发,退出后补一次,没有待决定审批时它是只读的。 - phase = 'plan-approval'; - const approval = - chat.planGddApproval ?? - (await settlePlanGddApproval( - project.path, - runtimeConfig.path, - setConcurrentChild, - )); - if (receivedSignal) break session; - phase = 'plan-report'; - await reportPlanGddApproval(approval); - await reportPlanningOutputs(project.path); - phase = 'complete'; - console.log( - approval.decided - ? '\n立项策划链路已收束:Fast GDD 已批准,策划产物见上方清单。' - : '\n立项策划链路已收束:Run 正常结束但没有待决定审批,策划产物见上方清单。', - ); - break session; - } - phase = 'artifact-validation'; const requireEditorImages = await hasConfiguredEditorApiKey( runtimeConfig.path, ); diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs index c5e202e03..f2c19ac58 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -107,12 +107,16 @@ const rustSharedContractSource = fs.readFileSync( 'utf8', ); const allowedUncalledTauriCommands = [ + 'append_direct_project_conversation_message', 'chat_with_game_creator_agent', 'check_ui_editor_font_glyph_coverage', 'create_ui_design_resource', 'open_game_creator_launcher_window', 'open_game_creator_workspace_window', + 'read_direct_project_conversation', 'stop_local_game_preview_if_matches', + 'start_game_creator_external_mcp', + 'stop_game_creator_external_mcp', ]; const sourceExtensions = new Set([ '.json', @@ -1701,10 +1705,16 @@ if ( runtimeConfigSetupStart === -1 || runtimeConfigSetupEnd === -1 || !runtimeConfigSetupSource.includes('sanitize_diagnostic_message(') || - !runtimeConfigSetupSource.includes('append_bounded_diagnostic_line(') || + !runtimeConfigSetupSource.includes('setup_log.fail(') || !runtimeConfigSetupSource.includes( 'startup.appdata.configure.failed details={details}', - ) + ) || + !tauriHandlerSource.includes('impl StartupLogSlot {') || + !tauriHandlerSource.includes('append_bounded_diagnostic_line(&path, line)') || + !tauriHandlerSource.includes( + 'self.append(line);\n show_startup_error_dialog(self.path().as_deref());', + ) || + !tauriHandlerSource.includes('early_startup_log_path(') ) { throw new Error( 'AI game creator setup must configure the runtime AppData directory and log sanitized setup failures', diff --git a/apps/ai-game-creator-shell/scripts/smoke-agent-run-local-provider.mjs b/apps/ai-game-creator-shell/scripts/smoke-agent-run-local-provider.mjs index b2ddbac7b..e8a78f9f1 100644 --- a/apps/ai-game-creator-shell/scripts/smoke-agent-run-local-provider.mjs +++ b/apps/ai-game-creator-shell/scripts/smoke-agent-run-local-provider.mjs @@ -722,7 +722,7 @@ function runAgent() { stderr += chunk.toString(); }); child.on('error', reject); - child.on('close', (code) => { + child.on('close', (code, signal) => { const output = `${stdout}${stderr}`; if (previewReadError) { reject(previewReadError); @@ -740,13 +740,24 @@ function runAgent() { previewDom, }); } else { - reject(new Error(output || `agent run exited with ${code}`)); + reject( + new Error( + `agent run failed: exitCode=${code}, signal=${signal ?? 'none'}\n` + + `stderr tail (last 8000 characters):\n${stderr.slice(-8000)}\n` + + `stdout tail (last 4000 characters):\n${stdout.slice(-4000)}`, + ), + ); } }); }); } async function seedLocalAsset() { + await fs.mkdir(path.join(projectRoot, 'game'), { recursive: true }); + await fs.writeFile( + path.join(projectRoot, 'game/index.html'), + '还没有生成游戏', + ); await fs.mkdir(path.join(projectRoot, 'assets/uploads'), { recursive: true }); await fs.mkdir(path.join(projectRoot, '.agent'), { recursive: true }); await seedConversationContext(); diff --git a/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs b/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs index 397d88dcf..8435bbfa9 100644 --- a/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs +++ b/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs @@ -1,10 +1,16 @@ -import { spawn } from 'node:child_process'; +import { spawn, spawnSync } from 'node:child_process'; import { existsSync, readdirSync, readFileSync } from 'node:fs'; import http from 'node:http'; import net from 'node:net'; import { resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { + normalizeWindowsPath, + parseWindowsProcessSnapshot, + stopWindowsProcessTree, + stopWindowsWorktreeProcesses, +} from '../../../scripts/dev-windows-process.mjs'; import { agcVitePortEnvKey, readAgcDevEndpoint, @@ -15,6 +21,10 @@ import { const appRoot = fileURLToPath(new URL('..', import.meta.url)); const repoRoot = resolve(appRoot, '../..'); const devStackStatePath = resolve(repoRoot, '.app/dev-stack.json'); +const apiServerExePath = resolve( + repoRoot, + 'server-rs/target/debug/api-server.exe', +); const defaultApiTarget = process.env.RUST_SERVER_TARGET || 'http://127.0.0.1:8082'; const backendDatabase = 'genarrative-game-creator-dev'; @@ -127,19 +137,217 @@ function readBackendTargets({ requireAgcBackend = false } = {}) { }); } +function readBackendServiceFailure( + state, + { + expectedDatabase = backendDatabase, + expectedSpacetimeDataDir = backendSpacetimeDataDir, + } = {}, +) { + const targets = resolveBackendTargetsFromState(state, { + requireAgcBackend: true, + expectedDatabase, + expectedSpacetimeDataDir, + }); + if (!targets.hasMatchingBackend) { + return null; + } + + for (const serviceName of ['spacetime', 'api-server', 'bgfilter-worker']) { + const service = state?.services?.[serviceName]; + if (service?.status !== 'failed') { + continue; + } + + return { + serviceName, + failure: service.signal + ? `signal=${service.signal}` + : `code=${service.exitCode ?? 1}`, + }; + } + + return null; +} + +function urlPort(url) { + try { + const port = Number(new URL(url).port); + return Number.isInteger(port) && port > 0 ? port : 0; + } catch { + return 0; + } +} + +// 读取端口当前真正的监听进程身份。返回 null 表示探测本身不可用(例如缺少 +// Get-NetTCPConnection),此时调用方必须退化为旧行为,不能让本地启动直接失败。 +function readWindowsPortOwnerIdentities( + ports, + { spawnImpl = spawnSync, env = process.env } = {}, +) { + const uniquePorts = [...new Set(ports.filter((port) => port > 0))]; + if (uniquePorts.length === 0) { + return null; + } + + const command = [ + '$ErrorActionPreference = "SilentlyContinue"', + '$ports = ($env:GENARRATIVE_QUERY_PORTS -split ",") | Where-Object { $_ }', + '$result = @()', + 'foreach ($port in $ports) {', + ' $connection = Get-NetTCPConnection -State Listen -LocalPort ([int]$port) -ErrorAction SilentlyContinue | Select-Object -First 1', + ' if (-not $connection) { continue }', + ' $owner = Get-CimInstance Win32_Process -Filter ("ProcessId=" + $connection.OwningProcess) -ErrorAction SilentlyContinue', + ' $result += [pscustomobject]@{ port = [int]$port; processId = [int]$connection.OwningProcess; name = $owner.Name; executablePath = $owner.ExecutablePath; commandLine = $owner.CommandLine }', + '}', + 'ConvertTo-Json -InputObject @($result) -Compress', + ].join('\n'); + + const result = spawnImpl( + 'powershell.exe', + ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', command], + { + encoding: 'utf8', + env: { ...env, GENARRATIVE_QUERY_PORTS: uniquePorts.join(',') }, + maxBuffer: 8 * 1024 * 1024, + }, + ); + if (result?.error || result?.status !== 0) { + return null; + } + + const owners = new Map(); + for (const entry of parseWindowsProcessSnapshot(result.stdout)) { + const port = Number(entry?.port); + if (Number.isInteger(port) && port > 0) { + owners.set(port, entry); + } + } + return owners; +} + +function isWorktreeApiServerOwner( + owner, + { expectedExePath = apiServerExePath } = {}, +) { + if (!owner) { + return false; + } + const expected = normalizeWindowsPath(expectedExePath); + const actual = normalizeWindowsPath(owner.executablePath); + return Boolean(expected) && actual === expected; +} + +function isWorktreeSpacetimeOwner( + owner, + { expectedDataDir = backendSpacetimeDataDir } = {}, +) { + if (!owner) { + return false; + } + const expected = normalizeWindowsPath(expectedDataDir); + if (!expected) { + return false; + } + const name = String(owner.name ?? '').toLowerCase(); + if (!name.startsWith('spacetime')) { + return false; + } + return normalizeWindowsPath(owner.commandLine).includes(expected); +} + +// 端口健康不代表后端属于当前工作树:上个工作树 Ctrl+C 残留的 api-server 仍会 +// 应答 /healthz。复用前必须证明端口上的进程就是本工作树的可执行文件与数据目录。 +function verifyAgcBackendOwnership({ + apiUrl, + spacetimeUrl, + bgfilterWorkerUrl, + platform = process.platform, + expectedExePath = apiServerExePath, + expectedDataDir = backendSpacetimeDataDir, + readPortOwners = readWindowsPortOwnerIdentities, +} = {}) { + if (platform !== 'win32') { + return { ok: true, reason: 'platform-unsupported', owners: new Map() }; + } + + const ports = [ + urlPort(apiUrl), + urlPort(bgfilterWorkerUrl), + urlPort(spacetimeUrl), + ]; + const owners = readPortOwners(ports); + if (!owners) { + return { ok: true, reason: 'owner-probe-unavailable', owners: new Map() }; + } + + const apiOwner = owners.get(urlPort(apiUrl)); + if (!isWorktreeApiServerOwner(apiOwner, { expectedExePath })) { + return { ok: false, reason: 'api-server-owner-mismatch', owners, apiOwner }; + } + + const workerOwner = owners.get(urlPort(bgfilterWorkerUrl)); + if (!isWorktreeApiServerOwner(workerOwner, { expectedExePath })) { + return { + ok: false, + reason: 'bgfilter-worker-owner-mismatch', + owners, + workerOwner, + }; + } + + const spacetimeOwner = owners.get(urlPort(spacetimeUrl)); + if (!isWorktreeSpacetimeOwner(spacetimeOwner, { expectedDataDir })) { + return { + ok: false, + reason: 'spacetime-owner-mismatch', + owners, + spacetimeOwner, + }; + } + + return { ok: true, reason: 'owned', owners }; +} + +function formatOwnerLabel(owner) { + if (!owner) { + return '未知进程'; + } + const pid = Number(owner.processId); + const label = owner.executablePath || owner.commandLine || owner.name || ''; + return `${Number.isInteger(pid) ? `pid=${pid} ` : ''}${String(label).trim()}`.trim(); +} + async function isBackendReady({ state = readJson(devStackStatePath), isReady = isHttpReady, + verifyOwnership = verifyAgcBackendOwnership, + onOwnershipRejected = null, } = {}) { const { apiUrl, spacetimeUrl, bgfilterWorkerUrl, hasMatchingBackend } = resolveBackendTargetsFromState(state, { requireAgcBackend: true, }); + if (!hasMatchingBackend || !apiUrl || !spacetimeUrl || !bgfilterWorkerUrl) { + return false; + } + + const ownership = await verifyOwnership({ + apiUrl, + spacetimeUrl, + bgfilterWorkerUrl, + }); + if (!ownership?.ok) { + onOwnershipRejected?.(ownership); + return false; + } + if (ownership.reason === 'owner-probe-unavailable') { + console.warn( + '[ai-game-creator-shell] 无法读取端口监听进程归属,本次按旧行为复用配套后端。', + ); + } + return ( - hasMatchingBackend && - Boolean(apiUrl) && - Boolean(spacetimeUrl) && - Boolean(bgfilterWorkerUrl) && (await isReady(`${apiUrl}/healthz`)) && (await isReady(`${spacetimeUrl}/v1/ping`)) && (await isReady(`${bgfilterWorkerUrl}/readyz`)) @@ -452,14 +660,18 @@ async function terminateChildTree( return { stopped: true, forced: false }; } const result = await taskkillImpl(child.pid); - return { - stopped: - !result?.timedOut && - !result?.error && - [0, 128].includes(result?.code ?? 0), - forced: true, - result, - }; + const taskkillStopped = + !result?.timedOut && + !result?.error && + [0, 128].includes(result?.code ?? 0); + if (taskkillStopped) { + return { stopped: true, forced: true, result }; + } + + // 包装层(cmd.exe / npm.cmd)先被 Ctrl+C 杀掉时 taskkill 拿不到活着的 PID, + // 这里继续按记录下来的根 PID 遍历,尽量收掉更深的后端进程。 + const treeStopped = stopWindowsProcessTree(child.pid); + return { stopped: treeStopped.length > 0, forced: true, result }; } const processGroupId = childLifecycles.get(child)?.processGroupId; @@ -505,11 +717,43 @@ async function terminateChildTree( return { stopped, forced: true }; } -async function waitForBackendReady(backendChild, timeoutMs = 600_000) { +async function waitForBackendReady( + backendChild, + timeoutMs = 600_000, + { + checkBackendReady = (onOwnershipRejected) => + isBackendReady({ onOwnershipRejected }), + readState = () => readJson(devStackStatePath), + resolveTargets = readBackendTargets, + } = {}, +) { + const initialStateUpdatedAt = readState()?.updatedAt ?? ''; const startedAt = Date.now(); + let lastOwnershipReason = ''; while (Date.now() - startedAt < timeoutMs) { - if (await isBackendReady()) { - return readBackendTargets(); + if ( + await checkBackendReady((ownership) => { + if (ownership.reason === lastOwnershipReason) { + return; + } + lastOwnershipReason = ownership.reason; + // 本次自己拉起的后端如果归属校验一直不通过,必须把原因打出来, + // 否则只会表现为等待 600 秒后超时。 + console.warn( + `[ai-game-creator-shell] 等待配套后端就绪时归属校验未通过(${ownership.reason}: ${formatOwnerLabel(ownership.apiOwner ?? ownership.spacetimeOwner ?? ownership.workerOwner)})。`, + ); + }) + ) { + return resolveTargets(); + } + const state = readState(); + if ((state?.updatedAt ?? '') !== initialStateUpdatedAt) { + const serviceFailure = readBackendServiceFailure(state); + if (serviceFailure) { + throw new Error( + `配套后端启动失败: ${serviceFailure.serviceName} ${serviceFailure.failure}`, + ); + } } const failure = readChildFailure(backendChild); if (failure) { @@ -525,7 +769,14 @@ async function waitForBackendReady(backendChild, timeoutMs = 600_000) { async function ensureBackend({ onBackendChild = () => {}, - checkBackendReady = isBackendReady, + checkBackendReady = () => + isBackendReady({ + onOwnershipRejected(ownership) { + console.warn( + `[ai-game-creator-shell] 端口上的配套后端不属于当前工作树(${ownership.reason}: ${formatOwnerLabel(ownership.apiOwner ?? ownership.spacetimeOwner ?? ownership.workerOwner)}),改为启动本工作树自己的后端。`, + ); + }, + }), resolveTargets = readBackendTargets, spawnBackend = () => spawnChild( @@ -605,15 +856,33 @@ async function startVite(apiTarget, endpoint = readAgcDevEndpoint()) { async function main() { let backendChild = null; + let startedBackend = false; let viteChild = null; let shutdownSignal = ''; const signalHandlers = new Map(); + // 只有本次会话真正拉起过配套后端时才做兜底清扫:复用别人后端时不能连带 + // 杀掉对方的进程。dev.mjs 的清理依赖它的 shell 包装层仍然活着,而 Ctrl+C + // 往往先杀掉包装层,所以这里必须按本工作树 api-server.exe 的身份再收一次。 + const sweepStartedBackend = () => { + if (!startedBackend || process.platform !== 'win32') { + return; + } + const stopped = stopWindowsWorktreeProcesses({ apiServerExePath }); + if (stopped.length > 0) { + console.log( + `[ai-game-creator-shell] 已清理残留后端进程: ${stopped.join(', ')}`, + ); + } + }; + for (const signal of ['SIGINT', 'SIGTERM']) { const handler = () => { shutdownSignal = signal; stopChild(viteChild, signal); stopChild(backendChild, signal); + // 立刻清扫,避免外层 taskkill /F 抢在 finally 之前把本进程杀掉。 + sweepStartedBackend(); }; signalHandlers.set(signal, handler); process.on(signal, handler); @@ -632,6 +901,7 @@ async function main() { }, }); backendChild = backend.backendChild; + startedBackend = Boolean(backendChild); if (shutdownSignal) { throw new Error(`启动期收到 ${shutdownSignal},已停止配套后端`); } @@ -665,6 +935,7 @@ async function main() { terminateChildTree(viteChild), terminateChildTree(backendChild), ]); + sweepStartedBackend(); for (const [signal, handler] of signalHandlers) { process.off(signal, handler); } @@ -681,19 +952,25 @@ function isDirectModuleExecution() { export { ensureBackend, formatChildFailure, + formatOwnerLabel, isAiGameCreatorServer, isBackendReady, isDirectModuleExecution, isProcessGroupAlive, + isWorktreeApiServerOwner, + isWorktreeSpacetimeOwner, preflightExistingVite, + readBackendServiceFailure, readChildFailure, readExistingViteServer, readLinuxProcessGroupAlive, + readWindowsPortOwnerIdentities, resolveBackendTargetsFromState, runWindowsTaskkill, spawnChild, stopChild, terminateChildTree, + verifyAgcBackendOwnership, waitForBackendReady, waitForChildTermination, }; diff --git a/apps/ai-game-creator-shell/src-tauri/build_support/runtime_prompt_bundle.rs b/apps/ai-game-creator-shell/src-tauri/build_support/runtime_prompt_bundle.rs index d6bb2369b..9321fb7b6 100644 --- a/apps/ai-game-creator-shell/src-tauri/build_support/runtime_prompt_bundle.rs +++ b/apps/ai-game-creator-shell/src-tauri/build_support/runtime_prompt_bundle.rs @@ -43,7 +43,6 @@ struct PromptCompositions { /// 而是一份独立的完整清单:plan 根的工具面只有 7 个原生工具,专业组、 /// isolated child、任务图与视觉产物合同在这条链路上全部不可执行,逐段 /// 减法会把「plan 根到底看到什么」摊在两个函数的四个否定分支里。 - supervisor_plan: Vec, supervisor_chat: SupervisorChatComposition, } @@ -99,10 +98,6 @@ struct ProviderFragments { #[serde(rename_all = "camelCase", deny_unknown_fields)] struct AgentCatalog { supervisor: AgentGroup, - /// 立项策划子 Agent。与 `supervisor` 平级、**不进 `groups`**:`specialist_nodes` - /// 只从 `groups[].roles[]` 派生,因此它不参与 `build.rs` 与种子 DAG 的一致性 - /// 校验,「做游戏」的 16 任务 DAG 一行不动。详见技术方案第 3.1 节。 - planning: AgentGroup, groups: Vec, } @@ -231,12 +226,6 @@ pub fn compile_manifest(manifest_path: &Path) -> Result Result Result>(); @@ -421,14 +408,6 @@ fn validate_section_ownership(manifest: &PromptBundleManifest) -> Result<(), Str { register("composition supervisor", section); } - for section in manifest - .compositions - .supervisor_plan - .iter() - .filter(|section| !section.starts_with('$')) - { - register("composition supervisorPlan", section); - } register("composition supervisorChat.identity", identity); register( "composition supervisorChat.finalReply", @@ -457,19 +436,9 @@ fn validate_section_ownership(manifest: &PromptBundleManifest) -> Result<(), Str "composition supervisor", "composition supervisorChat.identity", ]); - // plan 根 composition 是 Supervisor system prompt 的第二条 lane,不是另一种 - // 语义面。它按设计复用 runtime lane 的 `isolatedAgentContract`(`agent.delegate` - // 的 expectedArtifacts/writeScopes 合同)和 supervisor lane 的 `supervisorRepair` - // (返工必须逐字继承原合同)。除这两个方向外,跨所有者复用仍然是错误。 - let allowed_plan_runtime_owners = - BTreeSet::from(["composition runtime", "composition supervisorPlan"]); - let allowed_plan_supervisor_owners = - BTreeSet::from(["composition supervisor", "composition supervisorPlan"]); for (section, section_owners) in owners { if section_owners.len() > 1 && !(section == identity && section_owners == allowed_identity_owners) - && section_owners != allowed_plan_runtime_owners - && section_owners != allowed_plan_supervisor_owners { return Err(format!( "Prompt section 跨语义所有者复用:{section} -> {section_owners:?}" @@ -706,17 +675,11 @@ fn validate_agent_catalog(catalog: &AgentCatalog) -> Result<(), String> { if catalog.supervisor.roles.len() != 1 { return Err("agentCatalog.supervisor 必须且只能包含一个 role".to_string()); } - if catalog.planning.roles.len() != 1 { - return Err("agentCatalog.planning 必须且只能包含一个 role".to_string()); - } if catalog.groups.is_empty() { return Err("agentCatalog.groups 不能为空".to_string()); } let mut group_brief_names = BTreeSet::new(); - for group in std::iter::once(&catalog.supervisor) - .chain(std::iter::once(&catalog.planning)) - .chain(catalog.groups.iter()) - { + for group in std::iter::once(&catalog.supervisor).chain(catalog.groups.iter()) { if !group_brief_names.insert(group.brief_path_name.as_str()) { return Err(format!( "agent group briefPathName 重复:{}", @@ -724,10 +687,7 @@ fn validate_agent_catalog(catalog: &AgentCatalog) -> Result<(), String> { )); } } - let mut generated_names = BTreeSet::from([ - "PROJECT_SUPERVISOR".to_string(), - "PROJECT_PLANNING".to_string(), - ]); + let mut generated_names = BTreeSet::from(["PROJECT_SUPERVISOR".to_string()]); for group in &catalog.groups { let generated = rust_identifier(&group.id); if !generated @@ -753,12 +713,6 @@ fn validate_agent_catalog(catalog: &AgentCatalog) -> Result<(), String> { &mut task_ids, &mut tool_ids, )?; - validate_agent_group( - &catalog.planning, - &mut group_ids, - &mut task_ids, - &mut tool_ids, - )?; for group in &catalog.groups { validate_agent_group(group, &mut group_ids, &mut task_ids, &mut tool_ids)?; } @@ -920,10 +874,6 @@ fn render_rust(manifest: &PromptBundleManifest, sections: &BTreeMap String { "static PROJECT_SUPERVISOR_AGENT_DEFINITION: AgentGroupDefinition = {};\n", render_group_value(&catalog.supervisor, "&PROJECT_SUPERVISOR_AGENT_ROLES") )); - let planning_role = &catalog.planning.roles[0]; - output.push_str(&format!( - "pub(crate) const GAME_CREATOR_PROJECT_PLANNING_AGENT_ID: &str = {};\n", - rust_literal(&planning_role.task_id) - )); - output.push_str(&format!( - "pub(crate) const GAME_CREATOR_PROJECT_PLANNING_MEMORY_PATH: &str = {};\n", - rust_literal(&format!( - "memory/agents/{}", - catalog.planning.brief_path_name - )) - )); - output.push_str(&render_role_array( - "PROJECT_PLANNING_AGENT_ROLES", - &catalog.planning.roles, - )); - output.push_str(&format!( - "static PROJECT_PLANNING_AGENT_DEFINITION: AgentGroupDefinition = {};\n", - render_group_value(&catalog.planning, "&PROJECT_PLANNING_AGENT_ROLES") - )); for group in &catalog.groups { let roles_name = format!("{}_AGENT_ROLES", rust_identifier(&group.id)); output.push_str(&render_role_array(&roles_name, &group.roles)); diff --git a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/manifest.json b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/manifest.json index a72b12706..ef7380bb9 100644 --- a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/manifest.json +++ b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/manifest.json @@ -23,11 +23,7 @@ "supervisorVisualWithEditor": "supervisor/visual-contract-with-editor.md", "supervisorPlaybook": "supervisor/playbook.md", "supervisorClaimGate": "supervisor/claim-gate.md", - "supervisorRepair": "supervisor/repair.md", - "projectPlanningRoleBrief": "roles/project-planning.md", - "planCommon": "plan/common.md", - "planSupervisorIdentity": "plan/supervisor-identity.md", - "planSupervisorPlaybook": "plan/supervisor-playbook.md" + "supervisorRepair": "supervisor/repair.md" }, "compositions": { "runtime": [ @@ -47,14 +43,6 @@ "supervisorClaimGate", "supervisorRepair" ], - "supervisorPlan": [ - "$header", - "planCommon", - "isolatedAgentContract", - "planSupervisorIdentity", - "planSupervisorPlaybook", - "supervisorRepair" - ], "supervisorChat": { "identity": "supervisorIdentityContract", "finalReply": "supervisorFinalReplyContract" @@ -70,12 +58,7 @@ "editorUnavailable": "supervisorVisualWithoutEditor" } }, - "roleOverlays": [ - { - "agentId": "project-planning", - "sections": ["projectPlanningRoleBrief"] - } - ], + "roleOverlays": [], "providerFragments": { "isolatedToolContract": "providerIsolatedToolContract", "autonomousRunProfile": "providerAutonomousRunProfile", @@ -102,21 +85,6 @@ } ] }, - "planning": { - "id": "planning", - "label": "立项策划", - "role": "Project Planning", - "briefPathName": "project-planning.md", - "roles": [ - { - "id": "project-planning", - "role": "Project Planning", - "taskId": "project-planning", - "toolId": "agent.runtime.project-planning", - "briefPathName": "project-planning.md" - } - ] - }, "groups": [ { "id": "design", diff --git a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/plan/common.md b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/plan/common.md deleted file mode 100644 index cfcd33c7b..000000000 --- a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/plan/common.md +++ /dev/null @@ -1,7 +0,0 @@ -用户只描述玩法类型、机制或相似体验时,不代表授权复刻现有游戏。所有专业 Agent 必须创建原创标题、阵营、资源、单位名称、角色造型、界面术语和视觉语言;禁止沿用、翻译或近似改写现有游戏的专有角色、单位名、Logo、贴图、标志性布局与受保护视觉语言。除非用户明确提供有权使用的项目内素材,否则不得把 Sunflower、Peashooter、向日葵、豌豆射手、僵尸等知名塔防元素写入策划、记忆、代码、图片提示或正式产物。 - -静态委派协议:新 agent.delegate 必须提交 1-8 条 acceptanceCriteria、0-16 个精确项目内非私有 expectedArtifacts,以及 nullable repairOfDelegationId/runId/continuationOfDelegationId/questionsSha256/answersSha256,普通委派后三项传 null。专业 Agent 收到的 task 会携带完整合同。Supervisor 认领回执后必须区分 evidence-ready、needs-user-input 与 needs-repair;前者仍需语义验收,needs-repair 不能作为成功。专业 Agent 若缺少会实质改变结果的用户事实,不能调用 user.input_request,必须以最终回复首行 `AGC_NEEDS_USER_INPUT_V1`,下一行短 JSON `{"questions":[...]}` 返回 1-3 个结构化问题;Runtime 会把它作为内部回执交给 Supervisor。Supervisor 对每个原 delivery 逐一用现有 user.input_request 提问,收齐对应答案后最多创建一次 continuation 委派,并同时提交 continuationOfDelegationId、questionsSha256、answersSha256;Runtime 会自动派生稳定 continuation identity,不得把多个 delivery 的问题或答案混入同一 continuation。 - -每 6 轮只是一次进度 checkpoint 与停滞检测,不是上下文压缩或 run 的终止上限;只要 observation 出现新的独立进展,就在同一 run 继续下一窗口,只有窗口没有新进展时才按停滞处理。真正的上下文压缩仅由 token 阈值或显式 compact 触发。 - -必须直接调用与当前请求广告的工具一一对应的动作函数,或在本阶段确实无事可做时调用 respond_to_user。本 run 不维护结构化计划,也没有 update_agent_plan 可调;工具结果会由 Runtime 作为 observation 返回,不要假装工具已执行,不要把动作或回复放进普通文本,不要 markdown,不要泄露密钥。 diff --git a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/plan/supervisor-identity.md b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/plan/supervisor-identity.md deleted file mode 100644 index d15e589f8..000000000 --- a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/plan/supervisor-identity.md +++ /dev/null @@ -1,7 +0,0 @@ -你是 Genarrative AI 游戏创作桌面 App 的 Project Supervisor。当前 run 是立项策划根 run(`source=project-supervisor-plan`),你是用户在本条链路里唯一的对话对象。 - -你不生产策划内容。本链路的全部策划工作——提问、取舍、撰写 GDD——都由 `project-planning` 子 Agent 完成。你只有四件事:冻结目标合同;发起与续跑对 `project-planning` 的委派;代子 Agent 向用户提问并把答案原样转达回去;在子 Agent 提交 GDD 后完成取证,把审批交给用户。 - -你不做的事:不自己提策划问题(`user.input_request` 只能用于转达子 Agent 的问题信封);不自己撰写、补写或改写 GDD 正文、决定台账与原型验证项;不替用户做产品决定;不写文件、不跑命令、不做预览、不生成素材、不查询任务图、不调度 ready 任务;不委派 `project-planning` 以外的任何 Agent,也不创建 isolated child 或启动构建。 - -`project-planning` 的消息和回执只是原目标的证据,不能替换原目标。contractStatus=evidence-ready 只代表客观证据齐全,你仍须按 acceptanceCriteria 逐条完成语义验收;needs-repair 不得忽略,同一原委派最多发起一轮显式返工。GDD 最终是否通过由用户在审批卡上决定,不由你代答。 diff --git a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/plan/supervisor-playbook.md b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/plan/supervisor-playbook.md deleted file mode 100644 index 9901e42d7..000000000 --- a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/plan/supervisor-playbook.md +++ /dev/null @@ -1,22 +0,0 @@ -【固定动作顺序,不得跳步】 - -1. 本 run 第一轮只调用一次 `agent.goal_contract` 冻结目标合同:outcome 概括用户原话意图,`preferences` 必须传空数组,`acceptanceNodes` 提交 Runtime 指定的固定单节点。这一轮不做任何其它调用。 -2. 冻结后立即用一次 `agent.delegate` 把任务委派给 `project-planning`,`expectedArtifacts` 写 `game/fast_gdd.md`,`repairOfDelegationId`、`runId`、`continuationOfDelegationId`、`questionsSha256`、`answersSha256` 全传 null。已有委派尚未收束时不要重复委派。 -3. 等待子 Agent 期间不得调用 `respond_to_user`。Runtime 会通过 delegate 完成屏障保持同一父 run,回执到达后再继续。 -4. 子 Agent 以问询信封退出时,决策卡由 Runtime 直接按信封原文呈现给用户,**不需要你调用任何工具**——你根本不会在那一刻被恢复。用户答完之后你才会拿到答案,届时为该原 delivery 创建且仅创建一次 continuation 委派,`continuationOfDelegationId` 与 `repairOfDelegationId` 都指向该原 delivery。`questionsSha256`、`answersSha256`、`acceptanceCriteria`、`expectedArtifacts` 四个全传 null——Runtime 会从该原 delivery 补齐权威指纹和原委派合同,你不要自己抄。子 Agent 在 continuation 里**再次**以信封退出时,对那条新 delivery 重复同一动作:「仅创建一次」约束的是单条 delivery,不是整条链,澄清预算未用尽时这个循环继续。Runtime 会在委派 task 末尾写明已用轮次与上限,不需要你自己数,也不要替它宣布预算已尽。 -5. 回执 contractStatus=evidence-ready 且 GDD 已提交时,用 `file.read` 从第 1 行读到 `game/fast_gdd.md` 末尾取证,每次都传 `maxLines: 240`(上限),尽量一页读完;确实需要第二页时从上一页的下一行开始,不要重复读同一段。每次 `file.read` 的 observation 末尾都带着 `sourceAgentId` / `sourceRunId` / `sourceActionId` 三个字段,把它们原样抄成 evidence 的 `{agentId, runId, actionId}`,用一次 `agent.acceptance_update` 一并提交即可——evidence 是按这三个字段整体查回执的,回忆错任何一个都会被判成"缺少持久动作回执"。不要为了取这些字段再去查动作历史。取证完成前审批卡不会出现。 -6. 用户在审批卡上选择修改或退回时,直接创建返工委派:`repairOfDelegationId` 指向原 delegationId,`runId`、`acceptanceCriteria`、`expectedArtifacts` 都传 null——Runtime 会从原 delivery 继承权威合同,不需要先 `agent.run_status` 去取再手抄。把用户原话完整附在 task 里。「同一原委派只能返工一次」约束的是单条 delivery,不是整条链:用户看过新稿再点一次修改,就对那条新 delivery 重复同一动作,这个循环没有次数上限——`repair_depth` 防的是 runaway agent,而每一轮修订都由用户亲手触发,人本身就是循环边界。不要替 Runtime 宣布「这是最后一次修改机会」,也不要因此把多条意见攒到一轮里改完。用户通过后只做一句简短收尾。 - -【转达的规则】 - -- 把用户答案回灌给 `project-planning` 时,逐条列出全部已确认决定,每条格式为 `[已确认] 第N轮问的是:{question 原文} | 候选项:{option1.label} / {option2.label} / {option3.label} → 用户答:{原文}`。**问题原文和三个选项标签必须带上**:`{header}` 只写到「第N轮·当前要决定:{主题}」这一层,答案落在选项上;子 Agent 每轮都是全新 run,除了这段正文什么都看不到,只给它主题和答案,「类似B」「B · 沙盒里程碑成长」这类答案就无从解读,它只能把同一件事再问一遍。用户答案原文一字不改、不归纳、不拆分、不搬轮次;任务长度接近上限时压缩你自己的说明文字和选项描述,绝不压缩用户答案、问题原文和选项标签。 -- 策划链路的澄清信封**恰好一题**,不是通用静态委派协议里的 1-3 题:`project-planning` 每轮只提一个主要决定,Runtime 也只接受一题,多于一题会在出卡时被拒。委派 task 里不要写“1-3 个结构化问题”。 -- 上一条格式里的三个选项标签就是决策卡上的 A、B 和“需要原型验证”,必须原样转述、一个都不能省;B 是用户确认的 `confirmed/user_option`,不能转成默认建议。用户后续自由填写推翻了更早的决定时,你只负责把两轮答案的原文都原样带到,并说明后者更晚;怎么记进决定台账由 `project-planning` 判断,不要替它裁定哪条作废。 - -【委派合同的边界】 - -委派 `project-planning` 时,acceptanceCriteria 只写产物形状、覆盖范围与红线(例如必须交付 `game/fast_gdd.md`、必须原创、必须只定义一个 MVP 闭环),**不得替用户预先裁定产品取舍**。用户没有指定的玩法规则、数值、关卡量级、美术方向和目标人群,一律留给策划子 Agent 按其 3 轮问询预算决定是提问还是按默认建议填写;不要写“未指定的标注为立项假设”“自行假设后继续”这类指令,那会把问询预算作废。平台事实(自包含 Web、desktop/mobile 双视口、keyboard/touch 双输入、本地 HTTP 预览)由 Runtime 固定注入,属于已定事实,不得要求标为待定、建议或开放项。 - -**本轮指令三选一。** 委派任务正文里,除了用户原始意图和已确认答案原文,你只能再写一句“本轮该做什么”,且必须是下面三个之一:**继续澄清**(默认,不附加任何前置条件)、**直接出稿**(仅当用户明确要求跳过问询)、**按意见修订**(仅审批返回修改或退回时)。不要自己描述“什么情况下才该提问”“若缺少会实质改变结果的事实则……”“否则直接提交完整 GDD”——那不在这三项里。提问预算怎么花,由 `project-planning` 按 Runtime 注入的判据决定。 - -不要向用户暴露内部 task/event、工具计划、动态 child ID 或调试状态。 diff --git a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/roles/project-planning.md b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/roles/project-planning.md deleted file mode 100644 index 347c83ae8..000000000 --- a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/roles/project-planning.md +++ /dev/null @@ -1,33 +0,0 @@ -你是“立项策划 Agent”(`agentId=project-planning`),由 Project Supervisor 通过静态 `agent.delegate` 委派。你的工作是把一句用户需求收敛成可审批的 MVP Fast GDD;你只负责玩法澄清、原型验证建议和最小 GDD,不负责完整游戏构建。 - -## 身份与边界 - -- 当前 run 固定为 `source=agent-delegate`、`profile=standard`,父 Agent 是 `project-supervisor`。不得伪造、改写或猜测这些 Runtime 身份。 -- 你不能委派或调度其他 Agent,不能创建 isolated child,不能调用 MCP、命令、进程、预览、画布、素材生成、写入/补丁/删除工具,也不能改变项目版本或审批事实。 -- 你的原生工具目录只应包含 `file.read`、`file.list` 以及 Runtime 协议控制函数 `update_agent_plan`、`respond_to_user`;`user.input_request` 不属于你的工具目录。若需要用户决定,必须以终态信封首行 `AGC_NEEDS_USER_INPUT_V1` 退出本轮,下一行给出严格 JSON 信封 `{"questions":[{ ... }]}`,交由 Supervisor 转发。`questions` 恰好一个元素;元素字段只能是 `id`、`header`、`question`、`options` 四个,多写任何字段(例如 `answerFormat`)或省掉 `questions` 外壳都会被 Runtime 拒收,整条委派随即作废。`id` 是唯一 snake_case(小写字母开头,只含小写字母、数字、下划线);`header` 是决策卡标题,写成 `第N轮·当前要决定:<主题>`,单行且不超过 60 字符;`question` 是决策卡正文,单行且不超过 400 字符;`options` 恰好 3 个 `{"label": ..., "description": ...}`,依次是 A、B、逐字“需要原型验证”(详见下文决策卡一段),label 单行不超过 60 字符、description 单行不超过 240 字符。不要另起一行写答题说明或把选项复述进 `question`,作答方式由 Runtime 自己呈现。 -- 只有 Runtime 广告并允许 `plan.submit_gdd` 时才可提交 GDD;不要假设未广告的工具存在,也不要把 GDD、审批或下游构建写进普通文本。 - -## 目标与轮次 - -- 最多进行 3 轮关键澄清;每轮是新 run、同一 session。你看得到自己的历史,但用户答案以 Supervisor 委派任务中的转述为准,缺失信息不能臆造。 -- **默认先澄清。** 出稿只有四个触发器,除此之外每轮都先做下面的字段差距检测再决定问不问:①任务正文出现“直接出稿”这四个字;②已完成第 3 轮澄清(任务正文写明的已用轮次已达上限);③剩余空白都能由默认建议覆盖,且不影响首个可玩闭环;④收到 Runtime 的活跃预算或超时提示。任务正文能改变流程的只有第 ① 条——它写的其它说明属于内容,不是出稿触发器。既定事实(用户答案、已确认决定)仍以任务正文为准。 -- 每轮提问前逐项对照 `plan-submit-gdd-input.v1` 的 `game` 字段做差距检测:用户明确提供的 = `confirmed`;有依据可推断的 = 按下面的默认建议填写并标 `default_pending`;无从判断的 = 空白。提问名额只花在**空白或存疑、且影响首个可玩闭环**的决定上;有默认建议兜底的字段优先用默认建议而不是提问——「有默认」不等于「不能问」,那条默认明显可能是错的、且选错就做不出首个可玩闭环时,它就是一个该问的存疑项。`title`、`oneLiner`、`mvpSystems`、`creatorTips` 由你生成并标 `default_pending`,不作为提问对象;`platformFacts` 禁问。 -- **默认建议**(一律 `answerSource=default`、`round=0`;只用于缩短对话,不覆盖用户明确输入):`targetUsers.sessionLength` 缺 → 10~20 分钟一局;`artStyle` 缺 → `visualType` 风格化、轮廓清楚,`keywords` 取自已确认的核心行为,`mvpArtBoundary` 写明 MVP 用占位资产、资产可复用;缺成长时 → 1 条成长线和 2~3 个选择;缺探索时 → 1 条主路线加 1 个有意义的岔路;缺构建时 → 高风险输出和稳健防御两种方向。清单之外的字段没有默认值兜底——`genre.fusion`、`targetUsers.coreUsers` / `preferences` / `referenceGames`、`outOfScope` 缺失时都算空白,该不该花一轮问它们由上面的判据决定,不要自己拍一个值填掉就当它已经定了。**`pillars` 与 `coreLoop` 没有默认建议**:它们就是首个可玩闭环本身,空白时属于该问的空白,不得用默认值填掉。 -- 优先顺序:核心行为与本局目标 → 重玩动力 → 制作边界与 MVP。每轮最多问一个主要决定。**已确认决定关掉的那条轴不得重问。** 任务正文里每条 `[已确认]` 都带着当轮的问题原文和三个选项标签,先照它判断哪些轴已经关闭,本轮的问题必须落在另一条还没关闭的轴上。把已确认答案换个说法再问一遍——例如用户已经选定“自由经营、靠成就和攒钱升级推进”,你又拿“短周期经营目标 vs 沙盒里程碑成长”去问——是白烧一轮预算。所有轴都已关闭时按出稿触发器③直接出稿。 -- 决策卡的 header 写成“第N轮·当前要决定:<主题>”,最多 60 字符。N 是 Runtime 从委派谱系派生的当前轮号,写错会被 Runtime 拒收:首轮恒为 1;之后每次续跑的任务正文都会写明已用轮次与上限,本轮该用的 N 就是“已用轮次 + 1”。`<主题>` 是这一轮真正要定的那件事本身(例如“塔的构筑方式”“每局变化来源”),一句话说完、不带状态标记——它会原样落进决定台账的 `topic`,也是你下一轮辨认哪些轴已经关掉的唯一线索,写成“关键决定”这类空话等于把它作废。正文只问尚未由平台事实或 MVP 规则排除的真实产品取舍,并说明为什么现在问;每张卡固定提供三个选项:A 是你的推荐方案(label 以 `A ·`、`A:`、`A:` 或 `A-` 开头并写明推荐、好处和代价),B 是形状不同且真实可行的平行备选(label 以 `B ·`、`B:`、`B:` 或 `B-` 开头并写明后果和代价),第三项逐字为“需要原型验证”,description 必须给出 30~90 分钟微型原型、试玩对象、观察信号和通过标准。自由输入按用户原话处理。 - -## 低幻觉与 GDD 约束 - -- 用户描述玩法类型、机制或“像某款游戏”时,不代表授权复刻该游戏。游戏名称、世界观、角色与单位名、阵营、资源、界面术语和视觉语言必须原创;不得沿用、翻译或近似改写现有游戏的专有名称、Logo、标志性布局与受保护视觉语言,也不得把它们写进 GDD 正文、决定台账或原型验证项。用户提到的相似作品只能作为抽象品类参考,`targetUsers.referenceGames` 同样不得填入受保护名称。你的工具面窄,但内容红线不因此放宽——GDD 是整条产线的上游。 -- 决定台账记录当前 GDD 的决定快照。澄清阶段的 A、B 或自由填写得到的用户决定标 `confirmed`,选择“需要原型验证”标 `prototype_pending`;未提问、由你按默认建议填写的字段标 `default_pending`、`answerSource=default`、`round=0`。审批阶段的用户修改意见是本轮最高优先级:由该意见新增或改写的决定使用 `answerSource=user_revision`、`round=0`,并按当前意见重新填写 `topic`、`state` 和 `answerSummary`。 -- 以当前 GDD 为基线,仅修改用户审批意见明确涉及的内容,以及为保持内部一致性所必需同步调整的派生内容。未被意见涉及的内容保持不变;如果意见与过去决定冲突,以最新意见为准。不要把用户未要求的其它方向自行扩展进本轮修订。提交时仍须提供完整 GDD 快照,但完整快照不代表可以任意重写未涉及内容。 -- `prototypeValidationItems` 是必填字段(没有就传空数组),与 `prototype_pending` 决定**一一对应**:每条 `prototype_pending` 决定必须有一个同 id 的验证项,每个验证项也必须对应一条 `prototype_pending` 决定,最多 3 项。除了用户亲选“需要原型验证”之外,你自己也可以主动标:手感、节奏、可读性、难度曲线这类你没问过、但选错就做不出首个可玩闭环的判断,标 `prototype_pending`(`answerSource=default`、`round=0`)比标 `default_pending` 诚实——那不是一个默认值,是一个没人验证过的假设。每项写清 30~90 分钟微型原型做什么、让谁试玩、观察什么信号、什么算通过。 -- 不得编造具体游戏的机制、数值、销量、人群规模、团队规模或来源。写 `targetUsers` 时按已确认的类型与核心行为描述典型玩家即可。 -- 只定义一个完整可玩闭环。MVP 不含多人、商城、服务器、开放世界、赛季、复杂社交、完整剧情或全量内容,除非用户明确改变范围。 -- GDD 至少覆盖:游戏名称与类型、一句话描述、2~4 条游戏支柱、核心循环、目标用户、美术方向、3~6 个最小 MVP 系统、先做/暂缓/验证/扩展条件、决定状态和审批请求。不要把 Runtime 注入的身份、时间、指纹、审批 receipt 或平台事实当作 Provider 输入字段。 -- 平台事实由 Runtime 固定注入为自包含 Web、desktop/mobile 双视口、keyboard/touch 双输入、本地 HTTP 预览;不得修改、删减或向用户询问。 - -## 输出纪律 - -- 澄清模式只返回 `AGC_NEEDS_USER_INPUT_V1` 终态信封,不再调用其他函数;成稿模式只在 `plan.submit_gdd` 被广告时调用它并等待 Runtime 校验;收到 revise/reject observation 后按同一 GDD 谱系修订,收到 approve 后只做简短收尾。 -- 必须直接调用当前请求广告的原生函数;不要输出 JSON、代码围栏或内部思考过程,不要假装已经写入文件、完成审批或启动构建。 diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-game-package-lock.json b/apps/ai-game-creator-shell/src-tauri/resources/agc-game-package-lock.json new file mode 100644 index 000000000..45ece2543 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-game-package-lock.json @@ -0,0 +1,1138 @@ +{ + "name": "agc-game", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "agc-game", + "dependencies": { + "phaser": "4.2.1" + }, + "devDependencies": { + "vite": "^6.2.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.1.tgz", + "integrity": "sha512-UZ8sUxPTiHWYX9QNdJedb1kDZSpS1t/VPWBWGSgqHNi9w3Cu6IXvu2mzbhiTiPvtrqgTQJ+zqiAq2iPIPilpaQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.1.tgz", + "integrity": "sha512-cQ4nFQABN5cDvDpbvJ7bMStCpnaVxynZrRMfUJYgxcIk9Sh54FIO1vtfkg0B69REjER77ioZ/ov+eAApx/KmLQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.1.tgz", + "integrity": "sha512-FQNqd1lRy/0QhDk3xeRIkSBiCpXCiDnZO3YLVdcDKN1UBiKToNftCzcXYNLshmPDUMlu2TdeS8tGcsU6f3YF1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.1.tgz", + "integrity": "sha512-pvD16V939D3CloK0+qikpGaxiPrDUXTe7Y5cWOMkMSy7m1cawa8EGy/kXYi/G/cKAC4HDAbSnzCIk1WmsoOKXg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.1.tgz", + "integrity": "sha512-pcFGeL2345VwdTnJhA6zLbew+YgWB0qBG2+dMtXjCicf6+rm6kO6cOoh5VnTe0ZMrMRgRyuHmCJxZWrIdzYuOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.1.tgz", + "integrity": "sha512-mRJlqSRulVzcKq/LKA6ICSIc3K/l4fzlVn/gePn2nXIHy8seRi5z/eeRE0d/XMBxcMldiXtQTSpRj0tkkC3g8Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.1.tgz", + "integrity": "sha512-YDUNvVM85TI3g/1OpnqKP1h4NeW/j64DfWMf+G3M809xNk1bJSnpFp4sh83NpmVE5DXnkh8ULor4LTVZKoYLHw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.1.tgz", + "integrity": "sha512-7Mcn71p9ZuQFAj+h+dhQXy/yeLePRS2yKRnmW1DijA9thKO5qap0GNOIQK4yQ6iP3SU0Mrb/yWo8h8vgRba8lw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.1.tgz", + "integrity": "sha512-4YiLQTX6U4CSl0L9cluep9A9W6UmTfqBDc2/CH6wlu54pl4E7Jn3cOD8oxzvBDEGk/JMKgJ47C8g+radF7mwvg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.1.tgz", + "integrity": "sha512-2ra8F7w8OquwZN9z2/fKFnli69wa8PLwaVzRMIPGb13ByMJwC28Fbp8YcVGoUhlYMTt7j5j9bNgpysrN2UM+vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.1.tgz", + "integrity": "sha512-Sy20ncyhjmBP0Ml+UvQbimjlk6VFgjW5uNP+qqwHB00mTE8Bl2C1TuHTlRwK2YoXeZbee5lP2XevBWVkAQAtSQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.1.tgz", + "integrity": "sha512-noITLp8oNjYliPnGWmLyelIHwULGqbHloQHGw1rtxbWhTuWooRpnZarZQJ1y9EUC4szuCusCc+HEpUtxpIwYvA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.1.tgz", + "integrity": "sha512-hlxxXd+F1mWiAcaFR7Sv9ZQT6m6UfI8+Vy/kFJzztq2pDMU/0wZ9sish0iszNZvsQDo8Gc0i5yuFEOz5dDf6fA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.1.tgz", + "integrity": "sha512-EF7OpqQTQ/BvGqLzUi4rEHuagCV9MugAUXSHemwPW5vxZ75RR+jxO/2j95Ph2dalMpFHSVECjRoioHZgA9zOYA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.1.tgz", + "integrity": "sha512-wQO3JesW9PRkwlabQ27y7sPfVOOTLRG73I4F2UYHG5PXun3J9U3y+b7ezVKSYbsvSKGQ1k1cq8Qlun4C9kLt3w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.1.tgz", + "integrity": "sha512-ouAGwhO6wHRXdnOVCOsB0tRFkA7nhNB2Nwax6oECXN0YiN8EYUTBAOudADOB1PI+yDL61TeNx/u7MVCzksNbkQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.1.tgz", + "integrity": "sha512-q2R38Sn+1J8RxhfJ+T54wSWmyKXWec+9jgDfqO2AtArEqHO5R2aeayp5H5OYLr5UYDVGsVaZPEFUooMhYCdz5A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.1.tgz", + "integrity": "sha512-gfI5T24WLLuFfSKw7Go/zDXjAAV0fny0swTaDv+WjK7vqcw4cRhFfdsyKL1n+ukI+ooBxn3bVQnyrn06WpI50w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.1.tgz", + "integrity": "sha512-4h6XqthmB4Hspji84wvgk+ElodTsGj+dbZqHJHHtKxj4mYq0ANSEEPX9ys3moJueqsRjwpaJYH7874Itwnj2ow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.1.tgz", + "integrity": "sha512-dlfCOa87o1VAYegLQ9EKilx2JCeRofiyPGhTCmqnuXZ6bMPiycO1rq1+sKoulAp7pGLIsTIw+1x5R+zgh5LhhA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.1.tgz", + "integrity": "sha512-cjkLbOlfcm3QGhMM1J5zaZjsw1GggbN6rw9UTSSRrPrR1KkcXnN7Uq9rPw34xImQ9VOY9GN+6u2Zj80B9ptkcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.1.tgz", + "integrity": "sha512-Li1KdUnWGE4N3e1F/B4RTB1ms+nG4WBgjByO46pkeBVX/2UBsY53xf5vK9WygVmnH3RwncIST7lkSdLSY6P9lg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.1.tgz", + "integrity": "sha512-t4ZYOSoLTgwhuFMrmTMLx/+i1DQVK7HYqMc6kY46EApwi8X0nIVphzdNoThU3xt6n+N5urG1/gxBdCaKDLavfg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.1.tgz", + "integrity": "sha512-RgroPfMmKlD1RzSDxvwgcPiy2HNQKoYV7OmwIXDsk73uKW5t6B/V8KIy27SMv/FNXFo/oSBtWc9J0X7t91ezZg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.1.tgz", + "integrity": "sha512-at8QVep6S3h5Y6gSbdGU06bRY5WJkf6WUduM9YtvYMbYhB1MOFfUgc6kehitQXzOtMSaT70q7f9ydPhpqu821w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/phaser": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/phaser/-/phaser-4.2.1.tgz", + "integrity": "sha512-WUNwCPJpdjvZiuT6SgCfYVW8Qw/3j0jJ4ws7P2QkhFLFu74sbGuyHJcbFueGkY/AYO4Pi47bNQXn1OCJeLX//w==", + "license": "MIT", + "dependencies": { + "eventemitter3": "^5.0.4" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.1.tgz", + "integrity": "sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.1", + "@rollup/rollup-android-arm64": "4.63.1", + "@rollup/rollup-darwin-arm64": "4.63.1", + "@rollup/rollup-darwin-x64": "4.63.1", + "@rollup/rollup-freebsd-arm64": "4.63.1", + "@rollup/rollup-freebsd-x64": "4.63.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.1", + "@rollup/rollup-linux-arm-musleabihf": "4.63.1", + "@rollup/rollup-linux-arm64-gnu": "4.63.1", + "@rollup/rollup-linux-arm64-musl": "4.63.1", + "@rollup/rollup-linux-loong64-gnu": "4.63.1", + "@rollup/rollup-linux-loong64-musl": "4.63.1", + "@rollup/rollup-linux-ppc64-gnu": "4.63.1", + "@rollup/rollup-linux-ppc64-musl": "4.63.1", + "@rollup/rollup-linux-riscv64-gnu": "4.63.1", + "@rollup/rollup-linux-riscv64-musl": "4.63.1", + "@rollup/rollup-linux-s390x-gnu": "4.63.1", + "@rollup/rollup-linux-x64-gnu": "4.63.1", + "@rollup/rollup-linux-x64-musl": "4.63.1", + "@rollup/rollup-openbsd-x64": "4.63.1", + "@rollup/rollup-openharmony-arm64": "4.63.1", + "@rollup/rollup-win32-arm64-msvc": "4.63.1", + "@rollup/rollup-win32-ia32-msvc": "4.63.1", + "@rollup/rollup-win32-x64-gnu": "4.63.1", + "@rollup/rollup-win32-x64-msvc": "4.63.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + } + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-web-game-development/SKILL.md b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-web-game-development/SKILL.md index 24ad9799a..1cd480a1e 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-web-game-development/SKILL.md +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-web-game-development/SKILL.md @@ -1,21 +1,22 @@ --- name: agc-web-game-development -description: Build or modify a playable web game in the current AGC project. Use for gameplay creation, bug fixes, UI or layout changes, responsive behavior, asset integration, controls, scoring, reset flows, and other HTML, CSS, JavaScript, DOM, Canvas, or WebGL work. +description: Build or modify a playable npm-managed Phaser 4 web game in the current AGC project. Use for gameplay creation, bug fixes, UI or layout changes, responsive behavior, asset integration, controls, scoring, reset flows, and other HTML, CSS, JavaScript, DOM, Canvas, or WebGL work. --- # AGC Web Game Development -Implement the user's actual game request in the current project. Choose DOM, Canvas, WebGL, or a combination based on the game rather than a fixed code template. +Implement the user's actual game request in the current project as an npm-managed Phaser 4.2.1 game. Use Phaser scenes for gameplay and DOM only for deliberately external UI. ## Workflow -1. Read the existing `index.html`, `style.css`, and `game.js` before modifying an existing game. -2. Keep the entry self-contained and runnable from the AGC loopback preview. Avoid CDN-only dependencies and network-required runtime assets. -3. Build a complete playable loop: visible objective, responsive input, meaningful state changes, success or failure feedback, and a reliable restart path where the game needs one. -4. Fit the active game scene to desktop and mobile viewports without accidental page scrollbars. Reserve deliberate safe space for HUD elements instead of covering interactive content. -5. Reuse registered Taonier art when available through `agc_tools`. Load media defensively and keep gameplay usable when an optional derivative is absent; never relabel a local placeholder as platform art. -6. Avoid undefined animation callbacks, duplicate loops, stale event listeners, and state that survives restart unintentionally. -7. After a meaningful game change, use the browser playtest Skill and fix issues shown by real evidence before reporting completion. +1. Read the existing package and source files before editing. New projects place `package.json`, `index.html`, `style.css`, and `game.js` under `game/`; existing root packages retain their layout. Run npm in that package directory (for example `npm --prefix game ci` and `npm --prefix game run build`). +2. Keep `package.json` and `package-lock.json` authoritative. Import Phaser with `import Phaser from 'phaser'`; do not copy a bundle, add an import map, or use a CDN. Other npm dependencies are allowed when the game needs them. +3. Build with the project's npm script before previewing. The playable entry is the package directory's `dist/index.html`; never report an unbuilt bare-module page as playable. Import assets or configure public assets so all runtime media is included in dist; preview and exports cannot read outside it. +4. Build a complete playable loop: visible objective, responsive input, meaningful state changes, success or failure feedback, and a reliable restart path where the game needs one. +5. Fit the active game scene to desktop and mobile viewports without accidental page scrollbars. Reserve deliberate safe space for HUD elements instead of covering interactive content. +6. Reuse registered Taonier art when available through `agc_tools`. Load media defensively and keep gameplay usable when an optional derivative is absent; never relabel a local placeholder as platform art. +7. Let Phaser own the render loop and input dispatch. Avoid duplicate scenes, stale event listeners, and state that survives restart unintentionally. +8. After a meaningful game change, use the browser playtest Skill and fix issues shown by real evidence before reporting completion. When implementing a new game loop or a broad gameplay revision, read `references/game-quality-checklist.md`. diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-web-game-development/agents/openai.yaml b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-web-game-development/agents/openai.yaml index b99cbcfe1..3bb189c87 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-web-game-development/agents/openai.yaml +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-web-game-development/agents/openai.yaml @@ -1,4 +1,4 @@ interface: display_name: "Web 游戏实现" - short_description: "在当前项目内设计、实现并验证可玩的 HTML、CSS 与 JavaScript 游戏" + short_description: "在当前项目内设计、实现并验证 npm 管理的 Phaser 4 游戏" default_prompt: "Use $agc-web-game-development to build or modify the current playable web game." diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json index 1fd0458a6..a4c42d7b5 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json @@ -1,6 +1,6 @@ { "schemaVersion": "agc-skill-pack.v1", - "version": "2026-08-26.10", + "version": "2026-08-26.12", "skills": [ { "name": "agc-project-structure", @@ -57,7 +57,7 @@ "agents/openai.yaml", "references/game-quality-checklist.md" ], - "sha256": "d7748d9ebf4324add0541daf16a2bbec09c4862b85af55bfb369c7f3b99aedff" + "sha256": "0649c72dd53e05ad7c87b28def1397c2badf61b0c308091196c40f7c48a8b36a" }, { "name": "agc-browser-playtest", 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 bb0f70aee..cc37945ca 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -14,6 +14,8 @@ mod codex_cli; mod codex_provider_proxy; mod direct_codex_attachments; mod direct_codex_audit; +mod direct_project_history; +mod direct_project_turn_history; mod direct_runtime; mod direct_tool_bridge; mod direct_tools_mcp; @@ -38,6 +40,8 @@ pub(crate) use codex_cli::{ pub(crate) use codex_provider_proxy::*; pub(crate) use direct_codex_attachments::*; pub(crate) use direct_codex_audit::*; +pub(crate) use direct_project_history::*; +pub(crate) use direct_project_turn_history::*; pub(crate) use direct_runtime::*; pub(crate) use direct_tool_bridge::*; pub(crate) use direct_tools_mcp::*; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs index 8c98b5009..dab131406 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs @@ -8,6 +8,7 @@ use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, OnceLock, Weak}; use tokio::io::{AsyncBufRead, AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; use tokio::sync::{mpsc, oneshot, Mutex, Notify}; +use uuid::Uuid; const GAME_CREATOR_CODEX_APP_SERVER_PROVIDER_ID: &str = "genarrative_agc"; const GAME_CREATOR_CODEX_APP_SERVER_API_KEY_ENV: &str = "GENARRATIVE_AGC_CODEX_API_KEY"; @@ -443,13 +444,17 @@ impl From<&AgentRuntimeProviderRequestSnapshot> for CodexNodeThreadKey { #[derive(Clone, Debug)] enum CodexTurnEvent { - AgentMessageDelta(String), + AgentMessageDelta { + item_id: String, + delta: String, + }, IntermediateText(String), Activity(&'static str), Item { completed: bool, params: serde_json::Value, }, + RawItem(serde_json::Value), Terminal(serde_json::Value), TransportClosed(String), } @@ -869,11 +874,28 @@ fn direct_codex_notification_event( .get("delta") .and_then(serde_json::Value::as_str) .filter(|value| !value.trim().is_empty()) - .map(|delta| CodexTurnEvent::AgentMessageDelta(delta.to_string())), + .map(|delta| { + let item_id = params + .get("itemId") + .and_then(serde_json::Value::as_str) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| "direct-missing-item".to_string()); + CodexTurnEvent::AgentMessageDelta { + item_id, + delta: delta.to_string(), + } + }), "item/started" | "item/completed" => Some(CodexTurnEvent::Item { completed: method == "item/completed", params: params.clone(), }), + "rawResponseItem/completed" => Some(CodexTurnEvent::RawItem( + params + .get("item") + .cloned() + .unwrap_or(serde_json::Value::Null), + )), _ => Some(CodexTurnEvent::Terminal(params.clone())), } } @@ -1312,6 +1334,9 @@ fn codex_app_server_thread_start_params( params["modelProvider"] = serde_json::Value::String(GAME_CREATOR_CODEX_APP_SERVER_PROVIDER_ID.to_string()); } + if workspace_mode == CodexAppServerWorkspaceMode::DirectProject { + params["experimentalRawEvents"] = serde_json::Value::Bool(true); + } params } @@ -1321,6 +1346,7 @@ fn codex_app_server_turn_start_params( model: &str, workspace_path: &std::path::Path, workspace_mode: CodexAppServerWorkspaceMode, + client_user_message_id: Option<&str>, ) -> serde_json::Value { let approval_policy = "never"; let mut params = serde_json::json!({ @@ -1330,14 +1356,22 @@ fn codex_app_server_turn_start_params( "approvalPolicy": approval_policy, }); if workspace_mode.allows_workspace_writes() { - // Native project commands stay offline and remain bounded to the - // real game workspace. + // npm install/build must resolve project dependencies. Network access + // is enabled only for DirectProject; writableRoots keeps the file-write + // boundary at the real game workspace. params["sandboxPolicy"] = serde_json::json!({ "type": "workspaceWrite", "writableRoots": [workspace_path], - "networkAccess": false + "networkAccess": true }); } + if let Some(client_user_message_id) = client_user_message_id + .map(str::trim) + .filter(|value| !value.is_empty()) + { + params["clientUserMessageId"] = + serde_json::Value::String(client_user_message_id.to_string()); + } params } @@ -2119,6 +2153,9 @@ impl CodexAppServerConnection { if workspace_mode == CodexAppServerWorkspaceMode::DirectProject && llm.web_search_enabled { command.env(DIRECT_TOOLS_MCP_CONTROLLED_WEB_SEARCH_ENV, "1"); } + if workspace_mode == CodexAppServerWorkspaceMode::DirectProject { + command.env("npm_config_cache", workspace_path.join(".npm-cache")); + } command .env("CODEX_HOME", &isolated_codex_home) .env("HOME", &isolated_os_home) @@ -2547,32 +2584,48 @@ impl CodexAppServerConnection { mut audit: Option<&mut DirectCodexTurnAudit>, ) -> Result { let _turn_guard = self.inner.turn_gate.lock().await; + let mut request = request; + let history_root = direct_history_root.unwrap_or(&self.inner.workspace_path); + if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject { + let current_prompt = direct_codex_current_user_prompt(&request).trim(); + if current_prompt.is_empty() { + return Err(platform_llm::LlmError::InvalidRequest( + "DirectProject 用户消息不能为空".to_string(), + )); + } + if let Some(client_turn_id) = direct_client_turn_id { + let user_item = direct_project_local_message_item( + "user", + current_prompt, + Some(&format!("direct-codex:{client_turn_id}:user")), + ) + .map_err(platform_llm::LlmError::InvalidRequest)?; + append_direct_project_user_message_at(history_root, &user_item) + .map_err(platform_llm::LlmError::InvalidRequest)?; + } + } let (thread_lease, thread_created) = self.thread_for(snapshot, &request, llm).await?; self.wait_for_initial_client_mcp_startup().await; let thread_id = thread_lease.thread_id.clone(); - let mut request = request; - if thread_created && self.inner.workspace_mode.uses_direct_conversation() { - // DirectProject owns the append-only project history in AGC. The - // replay builder derives a bounded prompt without mutating that - // durable fact source, so a new ephemeral thread can recover the - // newest contiguous context within the model budget. - let current_prompt = direct_codex_current_user_prompt(&request).to_string(); - let history_root = direct_history_root.unwrap_or(&self.inner.workspace_path); - let history_prompt = build_direct_codex_history_prompt( - history_root, - direct_client_turn_id.unwrap_or("__none__"), - ¤t_prompt, - &request, - llm, - ) - .map_err(platform_llm::LlmError::InvalidRequest)?; - if let Some(message) = request - .messages - .iter_mut() - .rev() - .find(|message| message.role == LlmMessageRole::User) - { - message.content = history_prompt; + if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject { + if thread_created { + let items = match read_direct_project_history_items_at(history_root) { + Ok(items) => items, + Err(error) => { + self.release_thread(snapshot, &thread_id).await; + return Err(platform_llm::LlmError::InvalidRequest(error)); + } + }; + if let Err(error) = self + .request( + "thread/inject_items", + serde_json::json!({"threadId": thread_id, "items": items}), + ) + .await + { + self.release_thread(snapshot, &thread_id).await; + return Err(platform_llm::LlmError::Transport(error)); + } } } let prompt = if self.inner.workspace_mode.uses_direct_conversation() { @@ -2594,7 +2647,7 @@ impl CodexAppServerConnection { "AGC 直连项目缺少客户端受控工具桥".to_string(), ) })? - .begin_user_turn(direct_codex_current_user_prompt(&request)) + .begin_user_turn() .map_err(platform_llm::LlmError::InvalidRequest)?, ) } else { @@ -2611,6 +2664,7 @@ impl CodexAppServerConnection { model, &self.inner.workspace_path, self.inner.workspace_mode, + direct_client_turn_id, ); apply_game_creator_codex_app_server_reasoning_effort(&mut params, &request); if let Some(schema) = game_creator_codex_cli_tool_output_schema(&request) { @@ -2655,6 +2709,7 @@ impl CodexAppServerConnection { }; turn_start_guard.armed = false; let mut receiver = self.register_turn(&turn_id).await; + let mut direct_project_history = DirectProjectHistoryAccumulator::default(); let mut guard = CodexTurnGuard { connection: self.clone(), thread_id: thread_id.clone(), @@ -2701,7 +2756,10 @@ impl CodexAppServerConnection { } }; match event { - Some(CodexTurnEvent::AgentMessageDelta(delta)) => { + Some(CodexTurnEvent::AgentMessageDelta { item_id, delta }) => { + if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject { + direct_project_history.observe_delta(&item_id, &delta); + } streamed_text.push_str(&delta); if let Some(observer) = direct_observer.as_deref_mut() { observer(DirectCodexTurnObservation::AccumulatedText( @@ -2721,6 +2779,28 @@ impl CodexAppServerConnection { observer(DirectCodexTurnObservation::IntermediateText(text)); } } + Some(CodexTurnEvent::RawItem(item)) => { + if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject { + if item.is_null() { + return Err(platform_llm::LlmError::Deserialize( + "rawResponseItem/completed 缺少 item".to_string(), + )); + } + let history_root = history_root.to_path_buf(); + let history_item = item.clone(); + tokio::task::spawn_blocking(move || { + append_direct_project_history_item_at(&history_root, &history_item) + }) + .await + .map_err(|error| { + platform_llm::LlmError::Transport(format!( + "DirectProject 历史落盘任务失败:{error}" + )) + })? + .map_err(platform_llm::LlmError::InvalidRequest)?; + direct_project_history.complete_item(&item); + } + } Some(CodexTurnEvent::Activity(activity)) => { if let Some(observer) = direct_observer.as_deref_mut() { observer(DirectCodexTurnObservation::Activity(activity)); @@ -2839,7 +2919,22 @@ impl CodexAppServerConnection { } } }; - let text = collect.await?; + let text = match collect.await { + Ok(text) => text, + Err(error) => { + if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject { + if let Err(persist_error) = persist_direct_project_partial_items_at( + history_root, + &mut direct_project_history, + ) { + return Err(platform_llm::LlmError::Transport(format!( + "DirectProject 异常收尾历史失败:{persist_error};原始回合错误:{error}" + ))); + } + } + return Err(error); + } + }; guard.armed = false; self.inner.turns.lock().await.remove(&turn_id); let response = parse_game_creator_codex_app_server_text(&text, &thread_id, &request)?; @@ -3216,7 +3311,11 @@ async fn read_game_creator_codex_app_server_stdout( ); if !matches!( method, - "item/agentMessage/delta" | "item/started" | "item/completed" | "turn/completed" + "item/agentMessage/delta" + | "item/started" + | "item/completed" + | "rawResponseItem/completed" + | "turn/completed" ) && safe_activity.is_none() && intermediate_text.is_none() { @@ -3254,14 +3353,57 @@ async fn read_game_creator_codex_app_server_stdout( continue; } } - let event = match direct_codex_notification_event( - method, - ¶ms, - intermediate_text, - safe_activity, - ) { - Some(event) => event, - None => continue, + let event = if let Some(activity) = safe_activity { + // Preparing notifications may carry private plan/reasoning text; + // expose only the safe activity category. Other categories may + // retain their bounded, redacted intermediate text below. + if activity == "preparing" { + CodexTurnEvent::Activity(activity) + } else if let Some(text) = intermediate_text { + CodexTurnEvent::IntermediateText(text) + } else { + CodexTurnEvent::Activity(activity) + } + } else if let Some(text) = intermediate_text { + CodexTurnEvent::IntermediateText(text) + } else { + match method { + "item/agentMessage/delta" => { + let Some(delta) = params + .get("delta") + .and_then(serde_json::Value::as_str) + .filter(|value| !value.is_empty()) + else { + continue; + }; + let item_id = params + .get("itemId") + .and_then(serde_json::Value::as_str) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| { + eprintln!( + "agent.direct_codex.protocol_warning event=item/agentMessage/delta missing_item_id" + ); + format!("direct-missing-item:{turn_id}") + }); + CodexTurnEvent::AgentMessageDelta { + item_id, + delta: delta.to_string(), + } + } + "item/started" | "item/completed" => CodexTurnEvent::Item { + completed: method == "item/completed", + params, + }, + "rawResponseItem/completed" => CodexTurnEvent::RawItem( + params + .get("item") + .cloned() + .unwrap_or(serde_json::Value::Null), + ), + _ => CodexTurnEvent::Terminal(params), + } }; let sender = if method == "turn/completed" { last_direct_activity_by_turn.remove(&turn_id); @@ -3635,8 +3777,6 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer( request_slot: "direct-chat".to_string(), web_search_enabled: config.llm.web_search_enabled, allow_idle_context_compaction: false, - // direct-codex 不是立项策划链路,没有 planning session 可绑定。 - planning_session_binding: None, }; let api_kind = parse_game_creator_llm_api_kind(&config.llm.api_kind).map_err(|error| error.to_string())?; @@ -3653,13 +3793,21 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer( .with_model(config.llm.model.clone()) .with_request_timeout_ms(config.llm.request_timeout_ms) .with_max_output_tokens(16_000); + let generated_client_turn_id; + let effective_client_turn_id = match client_turn_id { + Some(client_turn_id) => Some(client_turn_id), + None => { + generated_client_turn_id = format!("direct-cli-{}", Uuid::new_v4()); + Some(generated_client_turn_id.as_str()) + } + }; connection .run_turn_with_direct_observer_and_history( &snapshot, &config.llm, request, Some(&codex_root), - client_turn_id, + effective_client_turn_id, None, observer, audit, @@ -3669,155 +3817,6 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer( .map_err(|error| error.to_string()) } -/// Builds the prompt used when a new DirectProject thread needs replay. -/// -/// The JSONL history remains immutable; only the derived prompt is bounded. -/// When the full request exceeds the replay target, the oldest contiguous -/// records are omitted and a plain `system:` marker is prepended. -pub(crate) fn build_direct_codex_history_prompt( - root: &std::path::Path, - client_turn_id: &str, - current_prompt: &str, - base_request: &LlmRunRequest, - llm: &GameCreatorLlmConfig, -) -> Result { - let conversation = read_local_conversation_for_session_at(root, None, None)?; - let current_message_id = format!("direct-codex:{client_turn_id}:user"); - // The deliberately simple role-prefix format is part of the Direct - // replay contract. Do not introduce an envelope or implicit escaping - // here without updating the persisted-history compatibility decision. - let lines = conversation - .messages - .iter() - .filter(|message| message.message_id.as_deref() != Some(current_message_id.as_str())) - .map(|message| format!("{}: {}", message.role, message.content)) - .collect::>(); - let current_line = format!("user: {}", current_prompt.trim()); - let full_prompt = format_direct_codex_replay_prompt(&lines, ¤t_line, None); - let target_budget = direct_codex_replay_target_budget(llm, base_request)?; - if direct_codex_replay_prompt_fits(base_request, llm, &full_prompt, target_budget)? { - return Ok(full_prompt); - } - - // The persisted project conversation is immutable. We only derive a - // bounded prompt for this replay, keeping the newest contiguous records. - let omission_marker = DIRECT_CODEX_REPLAY_OMISSION_MARKER; - let mut selected_reversed = Vec::new(); - for line in lines.iter().rev() { - let mut candidate_reversed = selected_reversed.clone(); - candidate_reversed.push(line.as_str()); - let candidate_lines = candidate_reversed.iter().rev().copied().collect::>(); - let candidate_prompt = format_direct_codex_replay_prompt( - &candidate_lines, - ¤t_line, - Some(omission_marker), - ); - if direct_codex_replay_prompt_fits(base_request, llm, &candidate_prompt, target_budget)? { - selected_reversed.push(line.as_str()); - } else { - break; - } - } - - let selected_lines = selected_reversed.iter().rev().copied().collect::>(); - let marked_prompt = - format_direct_codex_replay_prompt(&selected_lines, ¤t_line, Some(omission_marker)); - if direct_codex_replay_prompt_fits(base_request, llm, &marked_prompt, target_budget)? { - return Ok(marked_prompt); - } - - // If the marker itself would push the request over the target, preserve - // the current user request and omit only the marker. - let current_only_prompt = - format_direct_codex_replay_prompt(&[] as &[&str], ¤t_line, None); - if direct_codex_replay_prompt_fits(base_request, llm, ¤t_only_prompt, target_budget)? { - return Ok(current_only_prompt); - } - direct_codex_replay_validate_context_budget(base_request, llm, ¤t_only_prompt) -} - -const DIRECT_CODEX_REPLAY_OMISSION_MARKER: &str = - "system: Earlier conversation history was omitted due to context budget."; - -fn format_direct_codex_replay_prompt( - history_lines: &[impl AsRef], - current_line: &str, - omission_marker: Option<&str>, -) -> String { - let mut lines = Vec::with_capacity(history_lines.len() + 2); - if let Some(marker) = omission_marker { - lines.push(marker.to_string()); - } - lines.extend(history_lines.iter().map(|line| line.as_ref().to_string())); - lines.push(current_line.to_string()); - lines.join("\n") -} - -fn direct_codex_replay_target_budget( - llm: &GameCreatorLlmConfig, - request: &LlmRunRequest, -) -> Result { - const SAFETY_MARGIN_TOKENS: u64 = 4_096; - let max_output_tokens = u64::from(request.max_output_tokens.unwrap_or(0)); - let hard_budget = llm - .context_window_tokens - .checked_sub(max_output_tokens) - .and_then(|value| value.checked_sub(SAFETY_MARGIN_TOKENS)) - .ok_or_else(|| "Direct replay 没有可用的输入上下文预算".to_string())?; - Ok(llm.auto_compact_token_limit.min(hard_budget)) -} - -fn direct_codex_replay_estimate( - base_request: &LlmRunRequest, - prompt: &str, -) -> Result { - let mut request = base_request.clone(); - let user = request - .messages - .iter_mut() - .rev() - .find(|message| message.role == LlmMessageRole::User) - .ok_or_else(|| "Direct replay 请求缺少 user message".to_string())?; - user.content = prompt.to_string(); - Ok(request) -} - -fn direct_codex_replay_prompt_fits( - base_request: &LlmRunRequest, - llm: &GameCreatorLlmConfig, - prompt: &str, - target_budget: u64, -) -> Result { - let request = direct_codex_replay_estimate(base_request, prompt)?; - let estimated = estimate_game_creator_llm_request_tokens(&request)?; - if estimated > target_budget { - return Ok(false); - } - validate_game_creator_llm_request_context_budget( - llm, - &request, - estimated, - "Direct replay 请求", - )?; - Ok(true) -} - -fn direct_codex_replay_validate_context_budget( - base_request: &LlmRunRequest, - llm: &GameCreatorLlmConfig, - prompt: &str, -) -> Result { - let request = direct_codex_replay_estimate(base_request, prompt)?; - let estimated = estimate_game_creator_llm_request_tokens(&request)?; - validate_game_creator_llm_request_context_budget( - llm, - &request, - estimated, - "Direct replay 请求", - )?; - Ok(prompt.to_string()) -} - /// Direct home-page chat never binds Codex to a user project. It gets a /// fresh isolated read-only workspace and a stable in-process thread so a /// normal conversation can continue without creating a project, assets, a @@ -3844,8 +3843,6 @@ pub(crate) async fn direct_game_creator_home_codex_chat( request_slot: "direct-home-chat".to_string(), web_search_enabled: config.llm.web_search_enabled, allow_idle_context_compaction: false, - // 直连 Codex 的首页对话不属于任何立项策划 session。 - planning_session_binding: None, }; let api_kind = parse_game_creator_llm_api_kind(&config.llm.api_kind).map_err(|error| error.to_string())?; @@ -3899,123 +3896,27 @@ pub(in crate::agent) fn shutdown_game_creator_codex_app_servers_impl() -> Result }) } +#[cfg(test)] +pub(crate) fn build_direct_codex_history_prompt( + root: &Path, + _client_turn_id: &str, + current_prompt: &str, + _request: &LlmRunRequest, + _llm: &GameCreatorLlmConfig, +) -> Result { + let mut lines = read_direct_project_chat_history_at(root)? + .messages + .into_iter() + .map(|message| format!("{}: {}", message.role, message.content)) + .collect::>(); + lines.push(format!("user: {}", current_prompt.trim())); + Ok(lines.join("\n")) +} + #[cfg(test)] mod tests { use super::*; - #[test] - fn direct_history_prompt_replays_all_project_messages_in_order() { - let root = tempfile::tempdir().expect("temp dir"); - init_local_game_project_at(root.path(), "history-project", "history").expect("init"); - for (role, content, message_id) in [ - ("user", "hello", "direct-codex:old:user"), - ( - "assistant", - "partial\nunexpected interrupt happened here", - "partial-id", - ), - ("tool", "file-read result", "tool-id"), - ("user", "stored raw request", "direct-codex:new-turn:user"), - ] { - append_local_conversation_message_for_session_idempotent_at( - root.path(), - None, - None, - LocalConversationMessage { - role: role.to_string(), - content: content.to_string(), - agent_id: None, - }, - message_id, - ) - .expect("append history"); - } - let request = LlmRunRequest::single_turn("system", "new request") - .with_model("fixture-model") - .with_max_output_tokens(16_000); - let prompt = build_direct_codex_history_prompt( - root.path(), - "new-turn", - "new request", - &request, - &test_llm(), - ) - .expect("build prompt"); - assert_eq!( - prompt, - "user: hello\nassistant: partial\nunexpected interrupt happened here\ntool: file-read result\nuser: new request" - ); - } - - #[test] - fn direct_history_prompt_slides_old_prefix_when_budget_is_exceeded() { - let root = tempfile::tempdir().expect("temp dir"); - init_local_game_project_at(root.path(), "window-project", "window").expect("init"); - for (role, content, message_id) in [ - ("user", "old ".repeat(1_000), "old-user"), - ("assistant", "middle ".repeat(100), "middle-assistant"), - ("tool", "newest ".repeat(100), "newest-tool"), - ] { - append_local_conversation_message_for_session_idempotent_at( - root.path(), - None, - None, - LocalConversationMessage { - role: role.to_string(), - content, - agent_id: None, - }, - message_id, - ) - .expect("append history"); - } - let mut llm = test_llm(); - llm.auto_compact_token_limit = 800; - let request = LlmRunRequest::single_turn("system", "new request") - .with_model("fixture-model") - .with_max_output_tokens(16_000); - let before = - std::fs::read_to_string(root.path().join(".agent/conversations/project.jsonl")) - .expect("read history"); - let prompt = build_direct_codex_history_prompt( - root.path(), - "new-turn", - "new request", - &request, - &llm, - ) - .expect("build prompt"); - let after = std::fs::read_to_string(root.path().join(".agent/conversations/project.jsonl")) - .expect("read history"); - - assert!(prompt.starts_with(DIRECT_CODEX_REPLAY_OMISSION_MARKER)); - assert!(prompt.contains("tool: newest")); - assert!(!prompt.contains("user: old")); - assert!(prompt.ends_with("user: new request")); - assert_eq!(before, after); - } - - #[test] - fn direct_history_prompt_rejects_current_request_that_exceeds_context() { - let root = tempfile::tempdir().expect("temp dir"); - init_local_game_project_at(root.path(), "oversized-project", "oversized").expect("init"); - let mut llm = test_llm(); - llm.context_window_tokens = 5_000; - llm.auto_compact_token_limit = 1_000; - let request = LlmRunRequest::single_turn("system", "new request") - .with_model("fixture-model") - .with_max_output_tokens(100); - let error = build_direct_codex_history_prompt( - root.path(), - "new-turn", - &"request ".repeat(10_000), - &request, - &llm, - ) - .expect_err("oversized request should fail"); - assert!(error.contains("Direct replay 请求")); - } - #[test] fn direct_item_activities_are_closed_safe_categories() { let allowed = [ @@ -4275,7 +4176,6 @@ mod tests { request_slot: "slot-1".to_string(), web_search_enabled: false, allow_idle_context_compaction: false, - planning_session_binding: None, } } @@ -4455,6 +4355,7 @@ mod tests { "fixture-model", workspace, CodexAppServerWorkspaceMode::DirectHome, + None, ); assert_eq!(turn["threadId"], "home-thread"); assert_eq!(turn["approvalPolicy"], "never"); @@ -4515,14 +4416,16 @@ mod tests { "fixture-model", &workspace, CodexAppServerWorkspaceMode::DirectProject, + Some("direct-turn-0001"), ); + assert_eq!(turn["clientUserMessageId"], "direct-turn-0001"); assert_eq!( turn.pointer("/sandboxPolicy/writableRoots/0"), Some(&serde_json::json!(workspace)) ); assert_eq!( turn.pointer("/sandboxPolicy/networkAccess"), - Some(&serde_json::json!(false)) + Some(&serde_json::json!(true)) ); let authority_paths = [ turn.get("cwd"), diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs new file mode 100644 index 000000000..42777dcfa --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs @@ -0,0 +1,467 @@ +use crate::config::prepare_game_creator_private_path_for_read; +use crate::project::{ + append_jsonl_line_unlocked, enforce_project_permission_policy, project_append_lock_for, +}; +use crate::{LocalConversationMessageRecord, LocalConversationResult}; +use serde_json::Value; +use std::fs::File; +use std::io::{BufRead, BufReader, Read, Seek, SeekFrom}; +use std::path::{Path, PathBuf}; + +const DIRECT_PROJECT_HISTORY_RECORD_TYPE: &str = "response_item"; +const DIRECT_PROJECT_INTERNAL_CONTEXT_KINDS: &[&str] = &[ + "host_skills.instructions", + "permissions.instructions", + "environments.environment_context", +]; + +const DIRECT_PROJECT_CONTEXTUAL_USER_TEXT_MARKERS: &[(&str, &str)] = &[ + ("# AGENTS.md instructions", ""), + ("", ""), + ("", ""), + ("", ""), + ("", ""), + ("", ""), + ("", ""), + ("", ""), +]; + +fn is_known_contextual_user_text(text: &str) -> bool { + let text = text.trim(); + if DIRECT_PROJECT_CONTEXTUAL_USER_TEXT_MARKERS + .iter() + .any(|(start, end)| text.starts_with(start) && text.ends_with(end)) + { + return true; + } + if text.starts_with("") { + return true; + } + text.starts_with("') + .and_then(|(start, _)| start.strip_prefix(""))) +} + +pub(crate) fn is_direct_project_internal_context_item(item: &Value) -> bool { + if matches!( + item.get("role").and_then(Value::as_str), + Some("developer" | "system") + ) { + return true; + } + if item + .pointer("/internal_chat_message_metadata_passthrough/content_item_kinds") + .and_then(Value::as_array) + .is_some_and(|kinds| { + kinds + .iter() + .filter_map(Value::as_str) + .any(|kind| DIRECT_PROJECT_INTERNAL_CONTEXT_KINDS.contains(&kind)) + }) + { + return true; + } + item.get("content") + .and_then(Value::as_array) + .is_some_and(|parts| { + parts.iter().any(|part| { + let Some(text) = part.get("text").and_then(Value::as_str) else { + return false; + }; + is_known_contextual_user_text(text) + }) + }) +} + +fn history_path(root: &Path) -> PathBuf { + root.join(".agent/conversations/project.jsonl") +} + +fn record(item: &Value) -> Result { + serde_json::to_string(&serde_json::json!({ + "type": DIRECT_PROJECT_HISTORY_RECORD_TYPE, + "payload": item, + })) + .map_err(|error| format!("序列化 DirectProject 历史失败:{error}")) +} + +const DIRECT_PROJECT_HISTORY_REVERSE_SCAN_CHUNK_BYTES: usize = 16 * 1024; + +fn find_direct_project_history_item_by_id_at( + path: &Path, + item_id: &str, +) -> Result, String> { + let mut file = File::open(path) + .map_err(|error| format!("打开 DirectProject 历史失败:{}: {error}", path.display()))?; + let mut position = file + .metadata() + .map_err(|error| { + format!( + "读取 DirectProject 历史元数据失败:{}: {error}", + path.display() + ) + })? + .len(); + let mut pending = Vec::new(); + let mut chunk = vec![0u8; DIRECT_PROJECT_HISTORY_REVERSE_SCAN_CHUNK_BYTES]; + + loop { + if position == 0 { + break; + } + let read_len = usize::try_from(position) + .unwrap_or(usize::MAX) + .min(chunk.len()); + position -= read_len as u64; + file.seek(SeekFrom::Start(position)) + .map_err(|error| format!("定位 DirectProject 历史失败:{}: {error}", path.display()))?; + file.read_exact(&mut chunk[..read_len]) + .map_err(|error| format!("读取 DirectProject 历史失败:{}: {error}", path.display()))?; + + let mut combined = Vec::with_capacity(read_len + pending.len()); + combined.extend_from_slice(&chunk[..read_len]); + combined.extend_from_slice(&pending); + let mut line_end = combined.len(); + while let Some(newline) = combined[..line_end].iter().rposition(|byte| *byte == b'\n') { + let line = &combined[newline + 1..line_end]; + if !line.is_empty() { + if let Some(item) = direct_project_history_item_from_line(path, line)? { + if item.get("id").and_then(Value::as_str) == Some(item_id) { + return Ok(Some(item)); + } + } + } + line_end = newline; + } + pending = combined[..line_end].to_vec(); + } + + if !pending.is_empty() { + // The append path repairs an unterminated final JSONL record before + // writing. A duplicate scan must not reject that repairable tail. + if let Ok(Some(item)) = direct_project_history_item_from_line(path, &pending) { + if item.get("id").and_then(Value::as_str) == Some(item_id) { + return Ok(Some(item)); + } + } + } + Ok(None) +} + +fn direct_project_history_item_from_line( + path: &Path, + line: &[u8], +) -> Result, String> { + if line.iter().all(|byte| byte.is_ascii_whitespace()) { + return Ok(None); + } + let parsed: Value = serde_json::from_slice(line) + .map_err(|error| format!("解析 DirectProject 历史失败:{}: {error}", path.display()))?; + if parsed.get("type").and_then(Value::as_str) != Some(DIRECT_PROJECT_HISTORY_RECORD_TYPE) { + return Err(format!( + "DirectProject 历史记录类型无效:{}", + path.display() + )); + } + let item = parsed + .get("payload") + .cloned() + .ok_or_else(|| format!("DirectProject 历史记录缺少 payload:{}", path.display()))?; + if is_direct_project_internal_context_item(&item) { + return Ok(None); + } + Ok(Some(item)) +} + +pub(crate) fn append_direct_project_history_item_at( + root: &Path, + item: &Value, +) -> Result<(), String> { + append_direct_project_history_item_at_with_user_policy(root, item, false) +} + +/// Appends a user message authored by AGC itself. Codex response items use +/// the default path above, which deliberately ignores echoed user messages; +/// this explicit entry point keeps the two sources distinct. +pub(crate) fn append_direct_project_user_message_at( + root: &Path, + item: &Value, +) -> Result<(), String> { + append_direct_project_history_item_at_with_user_policy(root, item, true) +} + +fn append_direct_project_history_item_at_with_user_policy( + root: &Path, + item: &Value, + allow_user_item: bool, +) -> Result<(), String> { + enforce_project_permission_policy(root, "conversation.write")?; + if is_direct_project_internal_context_item(item) { + return Ok(()); + } + if !allow_user_item && is_direct_project_codex_user_item(item) { + return Ok(()); + } + let _project_lock = crate::project::acquire_project_write_lock(root, "conversation.write")?; + let path = history_path(root); + let history_exists = + prepare_game_creator_private_path_for_read(&path, false, "DirectProject 历史")?; + let lock = project_append_lock_for(&path)?; + let _append_guard = lock.lock("DirectProject 历史追加写")?; + if history_exists { + if let Some(item_id) = item.get("id").and_then(Value::as_str) { + if let Some(existing) = find_direct_project_history_item_by_id_at(&path, item_id)? { + if &existing == item { + return Ok(()); + } + return Err(format!("DirectProject 历史 item id 冲突:{item_id}")); + } + } + } + let line = record(item)?; + append_jsonl_line_unlocked(&path, &line, "DirectProject 历史") +} + +fn is_direct_project_codex_user_item(item: &Value) -> bool { + if item.get("type").and_then(Value::as_str) == Some("userMessage") { + return true; + } + if item.get("role").and_then(Value::as_str) != Some("user") { + return false; + } + !item + .get("id") + .and_then(Value::as_str) + .is_some_and(|id| id.starts_with("direct-codex:") && id.ends_with(":user")) +} + +pub(crate) fn direct_project_local_message_item( + role: &str, + content: &str, + message_id: Option<&str>, +) -> Result { + let role = role.trim(); + let content = content.trim(); + if content.is_empty() || !matches!(role, "user" | "assistant") { + return Err("DirectProject 只接受非空 user/assistant 历史消息".to_string()); + } + let mut item = serde_json::json!({ + "type": "message", + "role": role, + "content": [{ + "type": if role == "user" { "input_text" } else { "output_text" }, + "text": content, + }], + }); + if let Some(message_id) = message_id.map(str::trim).filter(|value| !value.is_empty()) { + item["id"] = Value::String(message_id.to_string()); + } + Ok(item) +} + +pub(crate) fn read_direct_project_history_items_at(root: &Path) -> Result, String> { + let path = history_path(root); + if !prepare_game_creator_private_path_for_read(&path, false, "DirectProject 历史")? { + return Ok(Vec::new()); + } + let file = File::open(&path) + .map_err(|error| format!("打开 DirectProject 历史失败:{}: {error}", path.display()))?; + let mut items = Vec::new(); + let mut reader = BufReader::new(file); + loop { + let mut line = String::new(); + let bytes = reader + .read_line(&mut line) + .map_err(|error| format!("读取 DirectProject 历史失败:{}: {error}", path.display()))?; + if bytes == 0 { + break; + } + let had_newline = line.ends_with('\n'); + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + let parsed: Value = match serde_json::from_str(trimmed) { + Ok(value) => value, + Err(_error) if !had_newline => break, + Err(error) => { + return Err(format!( + "解析 DirectProject 历史失败:{}: {error}", + path.display() + )); + } + }; + if parsed.get("type").and_then(Value::as_str) != Some(DIRECT_PROJECT_HISTORY_RECORD_TYPE) { + return Err(format!( + "DirectProject 历史记录类型无效:{}", + path.display() + )); + } + let item = parsed + .get("payload") + .cloned() + .ok_or_else(|| format!("DirectProject 历史记录缺少 payload:{}", path.display()))?; + if is_direct_project_internal_context_item(&item) { + continue; + } + items.push(item); + } + Ok(items) +} + +pub(crate) fn read_direct_project_chat_history_at( + root: &Path, +) -> Result { + let path = history_path(root); + let items = read_direct_project_history_items_at(root)?; + let messages = items + .into_iter() + .filter_map(|item| { + let role = item.get("role").and_then(Value::as_str)?; + if !matches!(role, "user" | "assistant") { + return None; + } + let content = item + .get("content") + .and_then(Value::as_array) + .map(|parts| { + parts + .iter() + .filter_map(|part| part.get("text").and_then(Value::as_str)) + .collect::>() + .join("") + }) + .unwrap_or_default(); + (!content.is_empty()).then(|| LocalConversationMessageRecord { + schema_version: "agc-direct-project-context.v1".to_string(), + role: role.to_string(), + content, + agent_id: None, + message_id: item.get("id").and_then(Value::as_str).map(str::to_string), + updated_at: 0, + }) + }) + .collect(); + Ok(LocalConversationResult { + path: path.to_string_lossy().into_owned(), + agent_id: None, + session_id: None, + messages, + }) +} + +#[cfg(test)] +mod tests { + use super::{ + append_direct_project_history_item_at, append_direct_project_user_message_at, history_path, + is_direct_project_internal_context_item, read_direct_project_history_items_at, + }; + use serde_json::json; + + #[test] + fn filters_host_context_but_keeps_real_user_items() { + assert!(is_direct_project_internal_context_item(&json!({ + "type": "message", + "role": "developer", + "content": [{"type": "input_text", "text": ""}] + }))); + assert!(is_direct_project_internal_context_item(&json!({ + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": ""}], + "internal_chat_message_metadata_passthrough": { + "content_item_kinds": ["environments.environment_context"] + } + }))); + assert!(!is_direct_project_internal_context_item(&json!({ + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "ls ui"}], + "internal_chat_message_metadata_passthrough": { + "content_item_kinds": ["user.text"] + } + }))); + } + + #[test] + fn filters_codex_contextual_user_item_without_passthrough_metadata() { + assert!(is_direct_project_internal_context_item(&json!({ + "type": "message", + "role": "user", + "content": [{ + "type": "input_text", + "text": "\n/tmp/project\n" + }] + }))); + assert!(!is_direct_project_internal_context_item(&json!({ + "type": "message", + "role": "user", + "content": [{ + "type": "input_text", + "text": "请读取 中的说明" + }] + }))); + } + + #[test] + fn append_repairs_truncated_tail_before_idempotency_scan() { + let root = tempfile::tempdir().expect("temp project"); + crate::init_local_game_project_at(root.path(), "tail-repair", "尾行修复") + .expect("init project"); + let path = history_path(root.path()); + std::fs::create_dir_all(path.parent().expect("history parent")).expect("history dir"); + std::fs::write( + &path, + br#"{"type":"response_item","payload":{"type":"message"}"#, + ) + .expect("write truncated history tail"); + let item = serde_json::json!({ + "type": "message", + "role": "assistant", + "id": "tail-repair-item", + "content": [{"type": "output_text", "text": "已修复"}] + }); + append_direct_project_history_item_at(root.path(), &item).expect("repair and append"); + let items = + read_direct_project_history_items_at(root.path()).expect("read repaired history"); + assert_eq!(items, vec![item]); + } + + #[test] + fn codex_user_echo_is_filtered_but_agc_user_message_is_persisted() { + let root = tempfile::tempdir().expect("temp project"); + crate::init_local_game_project_at(root.path(), "user-echo", "用户回显过滤") + .expect("init project"); + let user = serde_json::json!({ + "type": "message", + "role": "user", + "id": "direct-codex:turn-0001:user", + "content": [{"type": "input_text", "text": "请创建菜单"}] + }); + append_direct_project_user_message_at(root.path(), &user).expect("persist AGC user"); + append_direct_project_history_item_at( + root.path(), + &serde_json::json!({ + "type": "userMessage", + "id": "codex-user-item-1", + "clientId": "turn-0001", + "content": [{"type": "text", "text": "请创建菜单"}] + }), + ) + .expect("ignore Codex echo"); + append_direct_project_history_item_at( + root.path(), + &serde_json::json!({ + "type": "message", + "role": "user", + "id": "codex-raw-user-item-1", + "content": [{"type": "input_text", "text": "请创建菜单"}] + }), + ) + .expect("ignore raw Codex user echo"); + let items = read_direct_project_history_items_at(root.path()).expect("read history"); + assert_eq!(items, vec![user]); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_turn_history.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_turn_history.rs new file mode 100644 index 000000000..c2c317692 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_turn_history.rs @@ -0,0 +1,54 @@ +use super::direct_project_history::append_direct_project_history_item_at; +use serde_json::Value; +use std::collections::BTreeMap; +use std::path::Path; + +#[derive(Default)] +pub(crate) struct DirectProjectHistoryAccumulator { + text_by_item_id: BTreeMap, +} + +impl DirectProjectHistoryAccumulator { + pub(crate) fn observe_delta(&mut self, item_id: &str, delta: &str) { + self.text_by_item_id + .entry(item_id.to_string()) + .or_default() + .push_str(delta); + } + + pub(crate) fn complete_item(&mut self, item: &Value) { + if let Some(item_id) = item.get("id").and_then(Value::as_str) { + self.text_by_item_id.remove(item_id); + } + } + + fn take_partial_items(&mut self) -> impl Iterator + '_ { + std::mem::take(&mut self.text_by_item_id) + .into_iter() + .filter(|(_, text)| !text.is_empty()) + .map(|(item_id, text)| { + serde_json::json!({ + "type": "message", + "role": "assistant", + "id": item_id, + "content": [{"type": "output_text", "text": text}], + }) + }) + } +} + +pub(crate) fn persist_direct_project_partial_items_at( + root: &Path, + accumulator: &mut DirectProjectHistoryAccumulator, +) -> Result<(), String> { + let items = accumulator.take_partial_items().collect::>(); + let mut first_error = None; + for item in items { + if let Err(error) = append_direct_project_history_item_at(root, &item) { + if first_error.is_none() { + first_error = Some(error); + } + } + } + first_error.map_or(Ok(()), Err) +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs index 4309d0923..2d94613ed 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs @@ -10,7 +10,7 @@ const MAX_DIRECT_SYSTEM_PROMPT_CHARS: usize = 16 * 1024; const MIN_DIRECT_CLIENT_TURN_ID_CHARS: usize = 6; const MAX_DIRECT_CLIENT_TURN_ID_CHARS: usize = 160; const DIRECT_TAONIER_IDENTITY_GUIDANCE: &str = "对外身份合同:你是“陶泥儿”,是 Genarrative 的游戏创作助手。用户询问你是谁、你的名称或能力时,以陶泥儿的身份回答;不要把 Codex、ChatGPT、OpenAI、模型、通用 AI 助手或内部执行智能体当作自己的名称或对外身份。Codex app-server 仅是客户端内部执行技术;只有用户明确询问底层实现时才可如实说明,同时仍以陶泥儿自称。"; -const DIRECT_AGC_ENGINEERING_GUIDANCE: &str = "AGC 工程合同(仅说明项目边界,不是流程门槛):当前 Codex cwd 是用户选择的项目目录(工作区根),源码、素材、音效和其它资源按项目现有结构放置;先按需读取当前 cwd 下适用的 `AGENTS.md`、README 或项目说明,把它们当作项目规范参考。原生文件工具、patch 和命令参数使用 cwd 相对路径,例如 `index.html`、`style.css`、`game.js`、`assets/hero.png`;如果 Codex 原生文件修改不可用,可以按需用客户端 `agc_write_file` 把文本写入项目相对路径。调用 `agc_write_file` 时,content 必须是目标文件的完整原始 UTF-8 正文;不得把 command.exec 的 Exit code、Wall time、Output 包装、终端日志或解释文字一起复制进 content,命令结果只能用于判断,不能当作文件正文。`../`、绝对路径、`.agent/`、`.git/`、密钥文件和 Runtime 控制面属于客户端边界,不能请求扩权或直接改写。DirectProject 提供 Codex 原生文件、搜索、命令、图片查看、Skill、经客户端注入的 `agc_tools` MCP,以及客户端扩展列表中用户已启用的第三方 MCP。用户明确指定第三方 MCP Server 或工具时,先在当前可用工具中查找并直接调用;找不到时如实说明,不得伪造。你可以按需选择这些能力:`agc_write_file` 写入代码、配置、资源依赖清单或说明文件;`agc_generate_image` 生成普通图片、角色图、视觉规范图(icon-spec)、UI 设计图或发布宣传图;`agc_edit_image` 修改已登记图片;`taonier_prepare_game_art` 准备完整游戏美术包及可用的 canonical 切片;`agc_list_registered_assets`、`agc_list_project_files`、`agc_list_account_assets`、`agc_import_account_assets` 用于发现和接入资源依赖;`agc_create_or_derive_resource` 用于视频、角色动画、音效或背景音乐;`agc_browser_playtest` 用于需要时的本地试玩观察;Skill references 按需使用相对路径直接读取。切图、资源依赖、规范图和试玩都只是可选工具提示,不要求调用、固定顺序或特定产物,AGC 不会据此替你拆任务、编排 DAG、做强验收或阻止继续执行;不要等待 Supervisor、harness 或宿主规划器。不要读取或输出凭据、Token、Cookie、auth.json、.env 或宿主私密路径;项目锁、付费提交、幂等键、下载校验和客户端投影由客户端处理。游戏文件真实变化后客户端可登记资源和版本,Codex 不直接保存或伪造项目版本。"; +const DIRECT_AGC_ENGINEERING_GUIDANCE: &str = "AGC 工程合同:当前 cwd 是用户选择的项目目录。新 Web 游戏使用 npm + Vite,Phaser 固定为 4.2.1,在 `game.js` 或模块中使用 `import Phaser from 'phaser'`;可以按需使用其它 npm 依赖,不得复制 Phaser bundle、使用 import map 或 CDN。先读取当前 cwd 下适用的 AGENTS.md、README 或项目说明。源码使用 cwd 相对路径,例如 `index.html`、`style.css`、`game.js`、`package.json`、`package-lock.json`、`assets/hero.png`;依赖安装与构建使用项目自己的 npm scripts,完成后必须从 `dist/index.html` 试玩。 原生文件工具、patch 和命令参数使用 cwd 相对路径,例如 `index.html`、`style.css`、`game.js`、`assets/hero.png`;如果 Codex 原生文件修改不可用,可以按需用客户端 `agc_write_file` 把文本写入项目相对路径。调用 `agc_write_file` 时,content 必须是目标文件的完整原始 UTF-8 正文;不得把 command.exec 的 Exit code、Wall time、Output 包装、终端日志或解释文字一起复制进 content,命令结果只能用于判断,不能当作文件正文。`../`、绝对路径、`.agent/`、`.git/`、密钥文件和 Runtime 控制面属于客户端边界,不能请求扩权或直接改写。DirectProject 提供 Codex 原生文件、搜索、命令、图片查看、Skill、经客户端注入的 `agc_tools` MCP,以及客户端扩展列表中用户已启用的第三方 MCP。用户明确指定第三方 MCP Server 或工具时,先在当前可用工具中查找并直接调用;找不到时如实说明,不得伪造。你可以按需选择这些能力:`agc_write_file` 写入代码、配置、资源依赖清单或说明文件;`agc_generate_image` 生成普通图片、角色图、视觉规范图(icon-spec)、UI 设计图或发布宣传图;`agc_edit_image` 修改已登记图片;`taonier_prepare_game_art` 准备完整游戏美术包及可用的 canonical 切片;`agc_list_registered_assets`、`agc_list_project_files`、`agc_list_account_assets`、`agc_import_account_assets` 用于发现和接入资源依赖;`agc_create_or_derive_resource` 用于视频、角色动画、音效或背景音乐;`agc_browser_playtest` 用于需要时的本地试玩观察;Skill references 按需使用相对路径直接读取。切图、资源依赖、规范图和试玩都只是可选工具提示,不要求调用、固定顺序或特定产物,AGC 不会据此替你拆任务、编排 DAG、做强验收或阻止继续执行;不要等待 Supervisor、harness 或宿主规划器。不要读取或输出凭据、Token、Cookie、auth.json、.env 或宿主私密路径;项目锁、付费提交、幂等键、下载校验和客户端投影由客户端处理。游戏文件真实变化后客户端可登记资源和版本,Codex 不直接保存或伪造项目版本。"; const DIRECT_CODEX_ART_SPEC_ASSET_PATH: &str = "assets/art-spec.png"; const DIRECT_CODEX_BACKGROUND_ASSET_PATH: &str = "assets/direct-game-background.png"; const DIRECT_CODEX_SPRITESHEET_ASSET_PATH: &str = "assets/art-spritesheet.png"; @@ -72,6 +72,21 @@ fn direct_codex_game_outputs(root: &Path) -> Vec<(String, &'static str, &'static (entry.to_string(), "game-entry", "text/html"), (format!("{prefix}style.css"), "game-style", "text/css"), (format!("{prefix}game.js"), "game-script", "text/javascript"), + ( + format!("{prefix}package.json"), + "game-package", + "application/json", + ), + ( + format!("{prefix}package-lock.json"), + "game-lockfile", + "application/json", + ), + ( + format!("{prefix}vite.config.js"), + "game-build-config", + "text/javascript", + ), ] } @@ -1761,7 +1776,7 @@ fn direct_codex_failure_recovery_hint(stage: DirectCodexFailureStage, error: &st if normalized.contains("permission-denied") || normalized.contains("http 403") { return "当前陶泥儿账号可能没有访问该资源的权限,请检查账号后重试"; } - if error.contains("项目正在被其他写操作占用") { + if error.contains(crate::project::PROJECT_WRITE_LOCK_CONTENTION_PREFIX) { return "当前项目仍有写入正在结束,请稍后再次发送该需求"; } if error.contains("身份不唯一") @@ -2132,7 +2147,7 @@ fn direct_registered_taonier_slice_paths(root: &Path) -> Vec { let Ok(validated_slices) = validated_art_slices(root) else { return Vec::new(); }; - if validated_slices.len() != 4 { + if validated_slices.is_empty() { return Vec::new(); } let mut resource_ids = std::collections::HashSet::with_capacity(validated_slices.len()); @@ -2703,6 +2718,7 @@ async fn generate_direct_taonier_art_asset_at( asset_kind: asset_kind.to_string(), asset_label: asset_label.to_string(), replace_existing: root.join(output_path).is_file(), + slice_count: None, }; let runtime_context = direct_taonier_art_generation_runtime_context(root, output_path, asset_kind)?; @@ -2806,9 +2822,9 @@ fn direct_taonier_art_package_result( } else { Vec::new() }; - if includes_spritesheet && slice_paths.len() != 4 { + if includes_spritesheet && slice_paths.is_empty() { slice_warnings.push( - "当前核心图集没有可验证的独立切片;只能使用完整图集,不得猜测切片或伪造衍生素材" + "当前图集没有可验证的独立切片;只能使用完整图集,不得猜测切片或伪造衍生素材" .to_string(), ); } @@ -3184,7 +3200,14 @@ fn direct_codex_generated_source() -> GameCreationAppAssetSource { fn direct_codex_output_fingerprint(root: &Path) -> String { let mut hasher = Sha256::new(); - for (local_path, _, _) in direct_codex_game_outputs(root) { + let mut paths: Vec = direct_codex_game_outputs(root) + .into_iter() + .map(|(p, _, _)| p) + .collect(); + paths.extend(direct_npm_source_paths(root)); + paths.sort(); + paths.dedup(); + for local_path in paths { hasher.update(local_path.as_bytes()); hasher.update([0]); let path = root.join(local_path); @@ -3200,6 +3223,70 @@ fn direct_codex_output_fingerprint(root: &Path) -> String { format!("{:x}", hasher.finalize()) } +fn direct_npm_source_paths(root: &Path) -> Vec { + let base = if root.join("package.json").is_file() { + root.to_path_buf() + } else if root.join("game/package.json").is_file() { + root.join("game") + } else { + return Vec::new(); + }; + let mut output = Vec::new(); + let mut pending = vec![(base, 0usize)]; + let mut inspected = 0usize; + while let Some((directory, depth)) = pending.pop() { + if depth > 16 || inspected >= 4096 { + break; + } + let Ok(entries) = fs::read_dir(directory) else { + continue; + }; + for entry in entries.flatten() { + inspected += 1; + if inspected > 4096 { + break; + } + let name = entry.file_name().to_string_lossy().to_ascii_lowercase(); + if name.starts_with('.') + || matches!( + name.as_str(), + "node_modules" + | "dist" + | "target" + | "memory" + | "exports" + | "auth.json" + | "credentials.json" + | "game-creator.config.json" + | "game-creator.config.local.json" + ) + { + continue; + } + let Ok(kind) = entry.file_type() else { + continue; + }; + let path = entry.path(); + if kind.is_dir() { + pending.push((path, depth + 1)); + } else if kind.is_file() + && matches!( + path.extension().and_then(|e| e.to_str()), + Some("js" | "mjs" | "cjs" | "ts" | "tsx" | "jsx" | "css" | "html" | "json") + ) + { + if let Ok(relative) = path.strip_prefix(root) { + if let Some(value) = relative.to_str() { + output.push(value.replace('\\', "/")); + } + } + } + } + } + output.sort(); + output +} + fn direct_browser_evidence_root(root: &Path, attempt: usize) -> Result { let revision = read_game_creator_agent_runtime_project_revision(root) .map(|value| value.revision) @@ -3631,6 +3718,34 @@ fn sync_direct_codex_project_file_projection_at( )?; registered += 1; } + for local_path in direct_npm_source_paths(root) { + if direct_codex_game_outputs(root) + .iter() + .any(|(p, _, _)| p == &local_path) + { + continue; + } + if root.join(&local_path).is_file() { + let media_type = if local_path.ends_with(".css") { + "text/css" + } else if local_path.ends_with(".json") { + "application/json" + } else if local_path.ends_with(".html") { + "text/html" + } else { + "text/javascript" + }; + register_local_asset_at( + root, + &local_path, + "game-source", + media_type, + "direct-codex", + direct_codex_generated_source(), + )?; + registered += 1; + } + } if registered == 0 { return Err("Codex 返回后没有可登记的游戏文件".to_string()); } @@ -4292,48 +4407,6 @@ fn normalize_direct_client_turn_id(client_turn_id: Option<&str>) -> Result Result<(), String> { - enforce_project_permission_policy(root, "conversation.write")?; - let _lock = acquire_project_write_lock(root, "conversation.write")?; - append_local_conversation_message_for_session_idempotent_at( - root, - None, - None, - LocalConversationMessage { - role: "assistant".to_string(), - content: reply.to_string(), - agent_id: None, - }, - &format!("direct-codex:{client_turn_id}:assistant"), - ) - .map(|_| ()) -} - -fn persist_direct_codex_user_prompt_at( - root: &Path, - client_turn_id: &str, - prompt: &str, -) -> Result<(), String> { - enforce_project_permission_policy(root, "conversation.write")?; - let _lock = acquire_project_write_lock(root, "conversation.write")?; - append_local_conversation_message_for_session_idempotent_at( - root, - None, - None, - LocalConversationMessage { - role: "user".to_string(), - content: prompt.trim().to_string(), - agent_id: None, - }, - &format!("direct-codex:{client_turn_id}:user"), - ) - .map(|_| ()) -} - #[tauri::command] pub(crate) async fn chat_with_game_creator_direct_codex( project_path: String, @@ -4365,15 +4438,6 @@ pub(crate) async fn chat_with_game_creator_direct_codex( return Err(error); } }; - if let Err(error) = persist_direct_codex_user_prompt_at(root, &turn_id, &user_prompt) { - audit.finish(false); - turn_emitter.emit("failed", Some("none"), None); - return Err(redact_agent_runtime_error( - root, - &format!("Direct 用户消息持久化失败,已拒绝发起回合:{error}"), - 500, - )); - } let reply = match run_direct_game_creator_turn_at_with_creation_type_and_emitter( root, &user_prompt, @@ -4389,15 +4453,6 @@ pub(crate) async fn chat_with_game_creator_direct_codex( return Err(error); } }; - if let Err(error) = persist_direct_codex_assistant_reply_at(root, &turn_id, &reply) { - audit.finish(false); - turn_emitter.emit("failed", Some("none"), None); - return Err(redact_agent_runtime_error( - root, - &format!("Direct 成功回复持久化失败,已拒绝以未落盘状态返回:{error}"), - 500, - )); - } audit.finish(true); turn_emitter.emit("completed", Some("none"), Some(reply.clone())); Ok(reply) @@ -4411,6 +4466,34 @@ pub(crate) async fn chat_with_game_creator_home_direct_codex( run_direct_game_creator_home_turn(&prompt, attachments.as_deref().unwrap_or_default()).await } +#[cfg(test)] +fn persist_direct_codex_user_prompt_at( + root: &Path, + client_turn_id: &str, + prompt: &str, +) -> Result<(), String> { + let item = direct_project_local_message_item( + "user", + prompt, + Some(&format!("direct-codex:{client_turn_id}:user")), + )?; + append_direct_project_user_message_at(root, &item) +} + +#[cfg(test)] +fn persist_direct_codex_assistant_reply_at( + root: &Path, + client_turn_id: &str, + reply: &str, +) -> Result<(), String> { + let item = direct_project_local_message_item( + "assistant", + reply, + Some(&format!("direct-codex:{client_turn_id}:assistant")), + )?; + append_direct_project_history_item_at(root, &item) +} + #[cfg(test)] mod tests { use super::*; @@ -4495,8 +4578,8 @@ mod tests { persist_direct_codex_assistant_reply_at(root.path(), turn_id, reply) .expect("the App's same-id write converges idempotently"); - let conversation = read_local_conversation_for_session_at(root.path(), None, None) - .expect("read project conversation"); + let conversation = + read_direct_project_chat_history_at(root.path()).expect("read project conversation"); let message_id = format!("direct-codex:{turn_id}:assistant"); let persisted = conversation .messages @@ -4509,7 +4592,7 @@ mod tests { assert!( persist_direct_codex_assistant_reply_at(root.path(), turn_id, "不同回复") .expect_err("same message identity cannot be rebound") - .contains("messageId 冲突") + .contains("item id 冲突") ); } @@ -4526,8 +4609,8 @@ mod tests { persist_direct_codex_user_prompt_at(root.path(), turn_id, normalized_prompt) .expect("retry reuses the same user message identity"); - let conversation = read_local_conversation_for_session_at(root.path(), None, None) - .expect("read project conversation"); + let conversation = + read_direct_project_chat_history_at(root.path()).expect("read project conversation"); let message_id = format!("direct-codex:{turn_id}:user"); let persisted = conversation .messages @@ -4540,7 +4623,7 @@ mod tests { assert!( persist_direct_codex_user_prompt_at(root.path(), turn_id, "不同的重试请求") .expect_err("same turn identity cannot be rebound") - .contains("messageId 冲突") + .contains("item id 冲突") ); } @@ -4627,10 +4710,10 @@ mod tests { assert!(!prompt.contains("你是 Codex")); assert!(prompt.contains("不要等待 Supervisor")); assert!(prompt.contains("提示词与技能")); - assert!(prompt.contains("AGC 工程合同(仅说明项目边界,不是流程门槛)")); + assert!(prompt.contains("AGC 工程合同:当前 cwd 是用户选择的项目目录")); assert!(prompt.contains("客户端扩展列表中用户已启用的第三方 MCP")); assert!(prompt.contains("用户明确指定第三方 MCP Server 或工具时")); - assert!(prompt.contains("先按需读取当前 cwd 下适用的 `AGENTS.md`")); + assert!(prompt.contains("先读取当前 cwd 下适用的 AGENTS.md、README 或项目说明")); assert!(prompt.contains("agc_write_file")); assert!(prompt.contains("content 必须是目标文件的完整原始 UTF-8 正文")); assert!(prompt.contains("不得把 command.exec 的 Exit code、Wall time、Output 包装")); @@ -7343,7 +7426,7 @@ mod tests { assert_eq!(manifest.versions.len(), 1); assert_eq!(manifest.versions[0].version_id, "initial-1"); assert_eq!(manifest.versions[0].project_revision, 1); - assert_eq!(manifest.versions[0].resource_bindings.len(), 10); + assert_eq!(manifest.versions[0].resource_bindings.len(), 13); for expected_path in DIRECT_CODEX_ART_ASSET_PATHS.into_iter().chain( DIRECT_CODEX_SPRITESHEET_SLICE_PATHS .iter() @@ -7380,7 +7463,7 @@ mod tests { .expect("project revision"); assert_eq!(revision.revision, 2); let manifest = read_manifest(&root.path().join(".agent/manifest.json")).expect("manifest"); - assert_eq!(manifest.assets.len(), 10); + assert_eq!(manifest.assets.len(), 13); assert_eq!( manifest .assets @@ -7400,7 +7483,7 @@ mod tests { manifest.versions[1].created_reason, GameIterationVersionCreatedReason::AgentRevision ); - assert_eq!(manifest.versions[1].resource_bindings.len(), 10); + assert_eq!(manifest.versions[1].resource_bindings.len(), 13); let unchanged_fingerprint = direct_codex_output_fingerprint(root.path()); sync_direct_codex_project_outputs_at(root.path(), Some(&unchanged_fingerprint)) @@ -8284,3 +8367,36 @@ mod tests { ); } } + +#[cfg(test)] +mod npm_source_projection_tests { + use super::*; + + #[test] + fn npm_sources_track_nested_modules_but_ignore_dependencies_and_private_files() { + for nested in [false, true] { + let root = tempfile::tempdir_in(std::env::temp_dir().canonicalize().unwrap()).unwrap(); + let source = if nested { + root.path().join("game") + } else { + root.path().to_path_buf() + }; + fs::create_dir_all(source.join("src/scenes")).unwrap(); + fs::create_dir_all(source.join("node_modules/demo")).unwrap(); + fs::create_dir_all(source.join(".npm-cache")).unwrap(); + fs::write(source.join("package.json"), "{}").unwrap(); + fs::write(source.join("src/scenes/play.ts"), "export const value = 1;").unwrap(); + fs::write(source.join("node_modules/demo/index.js"), "private dep").unwrap(); + fs::write(source.join(".npm-cache/auth.json"), "private cache").unwrap(); + let paths = direct_npm_source_paths(root.path()); + let prefix = if nested { "game/" } else { "" }; + assert!(paths.contains(&format!("{prefix}src/scenes/play.ts"))); + assert!(!paths + .iter() + .any(|path| path.contains("node_modules") || path.contains(".npm-cache"))); + let before = direct_codex_output_fingerprint(root.path()); + fs::write(source.join("src/scenes/play.ts"), "export const value = 2;").unwrap(); + assert_ne!(direct_codex_output_fingerprint(root.path()), before); + } + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs index 5b9f4cfdd..d1dead65a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs @@ -63,7 +63,6 @@ struct DirectToolBridgeTurnAuthorization { struct DirectToolBridgeActiveTurnAuthorization { turn_id: String, - allows_regeneration: bool, brief_sha256: Option, completed_result: Option, resource_request_ids: BTreeMap, @@ -181,30 +180,23 @@ impl DirectToolBridge { &self.url } - /// Arm exactly one client-owned Direct turn. The raw user message is used - /// only for this synchronous decision and is never retained by the bridge. - pub(crate) fn begin_user_turn( - &self, - user_prompt: &str, - ) -> Result { - self.state.begin_user_turn(user_prompt) + /// Arm exactly one client-owned Direct turn. Codex chooses the business + /// operation through the reviewed MCP tool and arguments; the bridge only + /// binds that call to the active client turn. + pub(crate) fn begin_user_turn(&self) -> Result { + self.state.begin_user_turn() } } impl DirectToolBridgeState { - fn begin_user_turn( - self: &Arc, - user_prompt: &str, - ) -> Result { + fn begin_user_turn(self: &Arc) -> Result { let turn_id = direct_taonier_active_invocation_id_at(&self.root)?; - let allows_regeneration = direct_user_explicitly_authorizes_art_regeneration(user_prompt); let mut authorization = self .turn_authorization .lock() .map_err(|_| "AGC 工具桥回合授权状态不可用".to_string())?; authorization.active = Some(DirectToolBridgeActiveTurnAuthorization { turn_id: turn_id.clone(), - allows_regeneration, brief_sha256: None, completed_result: None, resource_request_ids: BTreeMap::new(), @@ -594,12 +586,9 @@ impl DirectToolBridgeState { .active .as_mut() .ok_or_else(|| "当前没有客户端签发的美术重生成回合授权".to_string())?; - if !active.allows_regeneration { - return Err("当前用户消息未显式授权重新生成或替换美术".to_string()); - } match active.brief_sha256.as_deref() { Some(expected) if expected != brief_sha256 => { - return Err("当前用户授权已绑定另一项稳定美术重生成请求".to_string()) + return Err("当前客户端回合已绑定另一项稳定美术重生成请求".to_string()) } None => active.brief_sha256 = Some(brief_sha256.clone()), Some(_) => {} @@ -1484,7 +1473,14 @@ fn bridge_write_file(root: &Path, arguments: &Value) -> Value { return Err("工具参数 content 不能包含 NUL".to_string()); } reject_command_output_wrapper(content)?; - let _lock = acquire_project_write_lock(root, "direct-codex.file.write")?; + // Direct 写入原本用零等待取锁:任何重叠都在 24-42ms 内直接被判成"别人正在写", + // 而 `file.write / file.patch / file.delete` 等写入口用的是约 10 秒有界等待。 + // 这是用户直接触发、失败即整轮无法落盘的项目写入通道,必须和其它写入口同语义: + // 短暂重叠排队等成功,只有预算耗尽才报出带持锁方身份的错误。 + let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "direct-codex.file.write", + )?; let written = write_local_project_file_at(root, &path, content)?; let revision = advance_agent_runtime_project_revision_locked(root)?; Ok::<_, String>(json!({ @@ -1504,6 +1500,27 @@ fn bridge_write_file(root: &Path, arguments: &Value) -> Value { } } +/// 项目写锁的有界等待是同步轮询(2_000 × 5ms,最多约 10 秒)。handler 是 async, +/// 直接在 handler 里走完整条写路径会占住一个 tokio worker:争用窗口内同一轮并行写多个 +/// 文件时会有多个 worker 被占,而这条 bridge 与只读端点、UI 命令共享同一个 runtime, +/// 正是 Issue #318 现场"只读工具全部正常"这条诊断特征会被破坏的情形。 +/// 因此整条写路径挪进阻塞线程池,等待语义与错误文案都不变。 +async fn bridge_write_file_in_blocking_pool(root: PathBuf, arguments: Value) -> Value { + let task_root = root.clone(); + match tokio::task::spawn_blocking(move || bridge_write_file(&task_root, &arguments)).await { + Ok(result) => result, + Err(error) => bridge_tool_result( + redact_agent_runtime_error( + &root, + &format!("agc_write_file 阻塞任务未返回:{error}"), + 480, + ), + Vec::new(), + true, + ), + } +} + fn bridge_safe_account_asset_projection(asset: &Value) -> Option { let asset_id = asset.get("assetId").and_then(Value::as_str)?; if asset_id.trim().is_empty() { @@ -2116,6 +2133,7 @@ async fn bridge_generate_image(state: &DirectToolBridgeState, arguments: &Value) asset_kind: kind.clone(), asset_label: asset_name.clone(), replace_existing: false, + slice_count: None, }; let _generation_guard = state.image_generation_gate.lock().await; let generated = with_direct_editor_api_credentials( @@ -2317,7 +2335,9 @@ async fn handle_direct_tool_bridge( bridge_list_registered_assets(&state.root, &request.arguments) } "agc_list_project_files" => bridge_list_project_files(&state.root, &request.arguments), - "agc_write_file" => bridge_write_file(&state.root, &request.arguments), + "agc_write_file" => { + bridge_write_file_in_blocking_pool(state.root.clone(), request.arguments).await + } "agc_list_account_assets" => bridge_list_account_assets(&state, &request.arguments).await, "agc_import_account_assets" => { bridge_import_account_assets(&state, &request.arguments).await @@ -2722,6 +2742,185 @@ mod tests { ); } + /// Issue #318 第 1 条验收:同一轮里并行的多个文件写必须排队成功, + /// 而不是互相报"项目正在被其他写操作占用"。 + #[test] + fn bridge_write_file_serializes_parallel_writes_in_one_round() { + let temporary = tempfile::tempdir().expect("create parallel direct write root"); + init_local_game_project_at(temporary.path(), "direct-parallel", "Direct 并行写入测试") + .expect("initialize parallel direct write root"); + let root = temporary.path().to_path_buf(); + let paths = (0..4) + .map(|index| format!("game/parallel-{index}.js")) + .collect::>(); + + let results = std::thread::scope(|scope| { + let handles = paths + .iter() + .map(|path| { + let root = root.clone(); + let path = path.clone(); + scope.spawn(move || { + let result = bridge_write_file( + &root, + &json!({ "path": path, "content": format!("// {path}\n") }), + ); + (path, result) + }) + }) + .collect::>(); + handles + .into_iter() + .map(|handle| handle.join().expect("parallel direct write must not panic")) + .collect::>() + }); + + for (path, result) in &results { + assert_eq!( + result.get("isError").and_then(Value::as_bool), + Some(false), + "parallel direct write of {path} must succeed: {result}" + ); + assert_eq!( + fs::read_to_string(root.join(path)).expect("read parallel direct write"), + format!("// {path}\n") + ); + } + } + + /// Issue #318 第 1 条验收:App 自己另一条写通道正在写该项目时, + /// Direct 写入必须等待后成功,而不是在 24-42ms 内被判成"别人正在写"。 + #[test] + fn bridge_write_file_waits_for_a_short_same_process_project_writer() { + let temporary = tempfile::tempdir().expect("create contended direct write root"); + init_local_game_project_at(temporary.path(), "direct-contended", "Direct 写入等待测试") + .expect("initialize contended direct write root"); + let root = temporary.path().to_path_buf(); + let barrier = std::sync::Arc::new(std::sync::Barrier::new(2)); + let holder_barrier = std::sync::Arc::clone(&barrier); + let holder_root = root.clone(); + let holder = std::thread::spawn(move || { + let lock = acquire_project_write_lock(&holder_root, "concurrent-writer") + .expect("acquire a short-lived project writer"); + holder_barrier.wait(); + std::thread::sleep(std::time::Duration::from_millis(300)); + drop(lock); + }); + barrier.wait(); + + let result = bridge_write_file( + &root, + &json!({ "path": "game/waited.js", "content": "// waited\n" }), + ); + + holder.join().expect("join the short-lived project writer"); + assert_eq!( + result.get("isError").and_then(Value::as_bool), + Some(false), + "the direct write must wait out a short same-process writer: {result}" + ); + assert_eq!( + fs::read_to_string(root.join("game/waited.js")).expect("read waited direct write"), + "// waited\n" + ); + } + + /// 有界等待是同步轮询(最多约 10 秒),而 handler 是 async:等待必须挪到阻塞线程池, + /// 否则会占住 runtime worker。本用例用默认的 current_thread runtime——handler 一旦同步 + /// 阻塞,同一 runtime 上的心跳任务就完全停摆,因此在写入等待期间检查心跳即可区分。 + #[tokio::test] + async fn bridge_write_file_waits_on_the_blocking_pool_instead_of_a_runtime_worker() { + use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::Arc; + use std::time::Duration; + + let temporary = tempfile::tempdir().expect("create blocking pool write root"); + init_local_game_project_at(temporary.path(), "direct-pool", "Direct 阻塞池测试") + .expect("initialize blocking pool write root"); + let state = direct_tool_bridge_state(temporary.path().to_path_buf()); + + // 另一条写通道由 OS 线程持有项目写锁,不受本 runtime 影响。 + let holder_root = temporary.path().to_path_buf(); + let lock = acquire_project_write_lock(&holder_root, "concurrent-writer") + .expect("acquire the concurrent project writer"); + let holder = std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(250)); + drop(lock); + }); + + // 心跳任务:只有 handler 让出 worker,它才可能在写入等待期间推进。 + let heartbeat = Arc::new(AtomicBool::new(false)); + let heartbeat_writer = Arc::clone(&heartbeat); + tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(50)).await; + heartbeat_writer.store(true, Ordering::SeqCst); + }); + + let response = handle_direct_tool_bridge( + axum::extract::State(state), + axum::Json(DirectToolBridgeRequest { + tool: "agc_write_file".to_string(), + arguments: json!({ "path": "game/blocking-pool.js", "content": "// pooled\n" }), + }), + ) + .await + .0; + + holder.join().expect("join the concurrent project writer"); + assert!( + heartbeat.load(Ordering::SeqCst), + "写入等待期间同一 runtime 的心跳任务停摆了:等待必须走阻塞线程池,不能占住 worker" + ); + assert_eq!( + response.get("isError").and_then(Value::as_bool), + Some(false), + "the pooled direct write must still wait out the holder: {response}" + ); + assert_eq!( + fs::read_to_string(temporary.path().join("game/blocking-pool.js")) + .expect("read pooled direct write"), + "// pooled\n" + ); + } + + /// Issue #318 第 3 条验收:权限拒绝不得被投影成"被其他写操作占用"。 + #[cfg(unix)] + #[test] + fn bridge_write_file_does_not_project_permission_denial_as_contention() { + use std::os::unix::fs::PermissionsExt; + + let temporary = tempfile::tempdir().expect("create acl direct write root"); + init_local_game_project_at(temporary.path(), "direct-acl", "Direct ACL 测试") + .expect("initialize acl direct write root"); + let agent_directory = temporary.path().join(".agent"); + let original = fs::metadata(&agent_directory) + .expect("read control directory metadata") + .permissions(); + fs::set_permissions(&agent_directory, fs::Permissions::from_mode(0o500)) + .expect("drop write permission on the control directory"); + + let result = bridge_write_file( + temporary.path(), + &json!({ "path": "game/acl-denied.js", "content": "// denied\n" }), + ); + fs::set_permissions(&agent_directory, original) + .expect("restore control directory permission"); + + if result.get("isError").and_then(Value::as_bool) != Some(true) { + // 以 root 运行(或文件系统忽略权限位)时 0o500 不构成拒绝,本用例不成立。 + return; + } + let text = result + .pointer("/content/0/text") + .and_then(Value::as_str) + .unwrap_or_default(); + assert!( + !text.contains("项目正在被其他写操作占用"), + "a permission denial must not be projected as lock contention: {text}" + ); + assert!(!temporary.path().join("game/acl-denied.js").exists()); + } + #[test] fn resource_request_uuid_is_stable_v4_and_domain_separated() { let operation = direct_resource_request_uuid("turn-1", "operation", "abc"); @@ -2736,106 +2935,20 @@ mod tests { } #[test] - fn regenerate_requires_current_explicit_user_authorization_and_one_stable_brief() { - for prompt in [ - "继续修复布局", - "解释一下重新生成美术是什么意思", - "不要重新生成美术,只调整代码", - "别换一套美术,继续用现在这套", - "解释一下换一套美术按钮", - "是否要改变视觉风格?", - "Do not regenerate the art; keep the current package.", - "I don't want to change the visual style.", - "What does use a new art set mean?", - "文案写着“换一套美术”", - "Yesterday I said regenerate art, but today keep it.", - "Please explain how to regenerate art.", - "重新生成美术以后再说,现在只修代码", - "重做美术先不做,先改玩法", - "Regenerate the art maybe later; for now just fix the code.", - "把按钮文案改成“请重新生成美术”,不要执行生成工具", - "把按钮文案改成‘请重新生成美术’,不要执行生成工具", - "Change the button label to 'please regenerate the art'; do not execute it.", - "用户之前说请重新生成美术,我只是在复述", - "Yesterday the user said please regenerate the art; I am just quoting it.", - "以后请重新生成美术,现在先改代码", - "你能不能帮我重新生成美术,顺便解释一下价格", - "请重新生成美术吗", - "请重新生成美术吗,还是只改代码", - "请重新生成美术或者只改代码", - "请重新生成美术以外的内容", - "请重新生成美术,但不要执行生成工具", - "不需要重新生成美术", - "界面上显示:请重新生成美术", - "界面标题是请重新生成美术", - "产品经理让我写请重新生成美术", - "下周请重新生成美术", - "他说«请重新生成美术»", - "Could you please regenerate the art", - "Please regenerate the art? Or only fix code.", - "Please regenerate the art except for the paid generation.", - "Please regenerate the art, but do not execute the tool.", - "Please regenerate the art, but don’t execute the tool.", - "Please regenerate the art, but I don't authorize this paid generation.", - "Please regenerate the art, but I don‘t authorize this paid generation.", - "Please regenerate the art, but do not execute the paid tool.", - "Please regenerate the art, but never execute the paid tool.", - "Please regenerate the art, but avoid executing the paid tool.", - "Please regenerate the art, but 'do not execute the tool", - "Please regenerate the art only if it is free.", - "Please regenerate the art only after I confirm the charge.", - "Please regenerate the art, but do “not” execute the paid tool.", - "请重新生成美术,三天后再执行。", - "请重新生成美术,得到我的许可再做。", - "请重新生成美术,地面需要无缝循环。", - "Please regenerate the art, but skip the paid generation.", - "请重新生成美术【生成操作跳过】", - "请重新生成美术【仅在零元时执行】", - "Please regenerate the art “but skip the paid generation”", - "Please regenerate the art; alternatively, just fix the code.", - "Please regenerate the art, but do n\u{200B}ot execute the paid tool.", - "Please regenerate the art with a clay style.", - "I don't need you to regenerate the art", - "The UI shows: please regenerate the art", - "Please regenerate the art next week", - "He said «please regenerate the art»", - ] { - assert!( - !direct_user_explicitly_authorizes_art_regeneration(prompt), - "prompt must fail closed: {prompt}" - ); - } - for prompt in [ - "请重新生成美术。", - "那就请重新生成美术!", - "换一套美术", - "Please regenerate the art!", - ] { - assert!( - direct_user_explicitly_authorizes_art_regeneration(prompt), - "prompt must explicitly authorize: {prompt}" - ); - } - + fn regenerate_uses_current_client_turn_and_one_stable_brief() { let root = tempfile::tempdir().expect("stable client turn root"); let state = direct_tool_bridge_state(root.path().to_path_buf()); - assert!(state.begin_user_turn("请重新生成美术").is_err()); + assert!(state.begin_user_turn().is_err()); let client_turn_id = "client-turn-stable-0001"; let _active_invocation = DirectTaonierActiveInvocationGuard::enter(root.path(), client_turn_id) .expect("client-owned stable invocation"); - let ordinary_turn = state - .begin_user_turn("继续优化交互") - .expect("ordinary turn authorization state"); - assert!(state.authorize_regeneration_call("陶泥风格").is_err()); - drop(ordinary_turn); - - let authorized_turn = state - .begin_user_turn("请重新生成美术") - .expect("authorized regeneration turn"); + let active_turn = state + .begin_user_turn() + .expect("client turn authorization state"); let (turn_id, brief_sha256) = match state .authorize_regeneration_call("陶泥风格") - .expect("first stable regeneration call") + .expect("MCP mode selects regeneration explicitly") { DirectToolBridgeRegenerationCall::Execute { turn_id, @@ -2864,7 +2977,7 @@ mod tests { panic!("completed stable retry must not execute a second paid call") } } - drop(authorized_turn); + drop(active_turn); assert!(state.authorize_regeneration_call("陶泥风格").is_err()); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs index 30e3b0bea..3dde7a64a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs @@ -1,7 +1,13 @@ use super::*; +use axum::extract::{DefaultBodyLimit, State as AxumState}; +use axum::http::{HeaderMap, StatusCode}; +use axum::routing::post; +use axum::{Json, Router}; use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; use std::io::{BufRead, BufReader, Write}; use std::path::{Path, PathBuf}; +use std::sync::{Mutex, OnceLock}; pub(crate) const DIRECT_TOOLS_MCP_MODE_FLAG: &str = "--agc-direct-tools-mcp"; pub(crate) const DIRECT_TOOLS_MCP_CONTROLLED_WEB_SEARCH_ENV: &str = @@ -14,6 +20,37 @@ const DIRECT_TOOLS_MCP_MAX_RESOURCE_PROMPT_CHARS: usize = 4_000; const DIRECT_TOOLS_MCP_MAX_RESOURCE_NAME_CHARS: usize = 120; const DIRECT_TOOLS_MCP_MAX_WRITE_CONTENT_BYTES: usize = 1_500_000; const DIRECT_TOOLS_MCP_MAX_BRIDGE_RESPONSE_BYTES: usize = 32 * 1024 * 1024; +const EXTERNAL_MCP_RESPONSE_MAX_CHARS: usize = 256 * 1024; +const EXTERNAL_MCP_RESPONSE_SUMMARY_MAX_CHARS: usize = 240; +const EXTERNAL_MCP_JOURNAL_MAX_BYTES: u64 = 8 * 1024 * 1024; +const EXTERNAL_MCP_JOURNAL_RELATIVE_PATH: &str = ".agent/conversations/codex-responses.jsonl"; +static EXTERNAL_MCP_JOURNAL_LOCK: OnceLock> = OnceLock::new(); +static EXTERNAL_MCP_SERVER: OnceLock>> = OnceLock::new(); +tokio::task_local! { + static EXTERNAL_MCP_BRIDGE_URL: String; +} + +pub(crate) struct ExternalMcpServer { + _bridge: super::direct_tool_bridge::DirectToolBridge, + pub(crate) url: String, + pub(crate) token: String, + task: tokio::task::JoinHandle<()>, +} + +impl Drop for ExternalMcpServer { + fn drop(&mut self) { + self.task.abort(); + } +} + +#[derive(Clone)] +struct ExternalMcpHttpState { + bridge_url: String, + root: PathBuf, + token: String, + session_user_id: String, + session_generation: u64, +} pub(crate) fn direct_tools_mcp_mode_requested(args: &[String]) -> bool { args == [DIRECT_TOOLS_MCP_MODE_FLAG] @@ -43,6 +80,62 @@ fn direct_tools_mcp_specs() -> Value { fn direct_tools_mcp_specs_for(controlled_web_search: bool) -> Value { let tools = vec![ + json!({ + "name": "client.session.info", + "description": "返回当前已绑定的 AGC 客户端会话和项目安全摘要;不返回宿主路径、凭据或内部地址。", + "inputSchema": { "type": "object", "additionalProperties": false } + }), + json!({ + "name": "conversation.record_codex_response", + "description": "显式记录外部 Codex 的一条最终返回。客户端只保存有界、脱敏后的正文和安全摘要,不根据正文触发业务动作。", + "inputSchema": { + "type": "object", + "properties": { + "requestId": { "type": "string", "minLength": 1, "maxLength": 160 }, + "sequence": { "type": "integer", "minimum": 0, "maximum": 1000000 }, + "content": { "type": "string", "minLength": 1, "maxLength": EXTERNAL_MCP_RESPONSE_MAX_CHARS } + }, + "required": ["requestId", "sequence", "content"], + "additionalProperties": false + } + }), + json!({ + "name": "conversation.list", + "description": "按序读取当前项目已记录的 Codex 返回摘要。", + "inputSchema": { + "type": "object", + "properties": { + "offset": { "type": "integer", "minimum": 0, "maximum": 10000 }, + "limit": { "type": "integer", "minimum": 1, "maximum": 100 } + }, + "additionalProperties": false + } + }), + json!({ + "name": "conversation.read", + "description": "读取当前项目的一条已记录 Codex 返回;只能使用 conversation.list 返回的 recordId。", + "inputSchema": { + "type": "object", + "properties": { + "recordId": { "type": "string", "minLength": 1, "maxLength": 80 } + }, + "required": ["recordId"], + "additionalProperties": false + } + }), + json!({ + "name": "agc_read_skill_resource", + "description": "读取审核通过的 AGC Skill 指导文件;仅允许清单内 skillName 和相对文件名。", + "inputSchema": { + "type": "object", + "properties": { + "skillName": { "type": "string", "minLength": 1, "maxLength": 120 }, + "relativePath": { "type": "string", "minLength": 1, "maxLength": 240 } + }, + "required": ["skillName", "relativePath"], + "additionalProperties": false + } + }), json!({ "name": "agc_write_file", "description": "把文本写入当前 AGC 项目的相对路径。Codex 可以按需使用它直接推进代码、配置、资源依赖或说明文件;客户端只负责项目路径和基本控制面边界,不要求固定文件、任务顺序、验证或完成回执。", @@ -67,7 +160,7 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool) -> Value { }), json!({ "name": "taonier_prepare_game_art", - "description": "创建或安全恢复当前 AGC 项目的陶泥儿标准游戏美术包。付费提交、幂等键、operation 恢复、来源校验、下载解码和登记均由客户端确定性执行。授权由 AGC 客户端当前登录会话和受控后端完成,用户不需要提供、配置、粘贴或创建 API Key;401/403 只能报告为客户端登录或权限状态异常,不得向用户索要凭据或暴露内部 URL。regenerate 还必须通过客户端对当前用户消息签发的单回合稳定调用授权;模型参数和 MCP 自动批准本身不构成替换授权。仅在用户意图确实需要新美术时调用。", + "description": "创建或安全恢复当前 AGC 项目的陶泥儿标准游戏美术包。付费提交、幂等键、operation 恢复、来源校验、下载解码和登记均由客户端确定性执行。授权由 AGC 客户端当前登录会话和受控后端完成,用户不需要提供、配置、粘贴或创建 API Key;401/403 只能报告为客户端登录或权限状态异常,不得向用户索要凭据或暴露内部 URL。Codex 根据当前对话决定是否调用 regenerate;客户端不解析用户文本,也不替 Codex 判断意图。", "inputSchema": { "type": "object", "properties": { @@ -81,7 +174,7 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool) -> Value { "type": "string", "enum": ["reuse-or-create", "regenerate"], "default": "reuse-or-create", - "description": "缺省安全复用有效美术包;只有用户明确要求换一套或重新生成时使用 regenerate" + "description": "缺省安全复用有效美术包;Codex 仅在当前对话需要换一套或重新生成时使用 regenerate" } }, "required": ["brief"], @@ -763,7 +856,9 @@ fn tool_search_max_results(arguments: &Value) -> Result { } fn direct_tool_bridge_url() -> Result { - let value = std::env::var(DIRECT_TOOL_BRIDGE_URL_ENV) + let value = EXTERNAL_MCP_BRIDGE_URL + .try_with(Clone::clone) + .or_else(|_| std::env::var(DIRECT_TOOL_BRIDGE_URL_ENV)) .map_err(|_| "客户端受控工具桥未配置".to_string())?; let parsed = url::Url::parse(&value).map_err(|_| "客户端受控工具桥地址无效".to_string())?; let host = parsed @@ -974,6 +1069,31 @@ async fn call_agc_web_search(arguments: &Value) -> Value { call_agc_web_search_with_enabled(arguments, controlled_web_search_enabled()).await } +fn call_agc_read_skill_resource(arguments: &Value) -> Value { + if let Err(error) = validate_tool_object_fields(arguments, &["skillName", "relativePath"]) { + return mcp_tool_result(error, Vec::new(), true); + } + let skill = match bounded_tool_string(arguments, "skillName", 120) { + Ok(value) => value, + Err(error) => return mcp_tool_result(error, Vec::new(), true), + }; + let relative = match bounded_tool_string(arguments, "relativePath", 240) { + Ok(value) => value, + Err(error) => return mcp_tool_result(error, Vec::new(), true), + }; + if Path::new(&relative).is_absolute() + || relative.contains("..") + || relative.contains(':') + || relative.contains('\\') + { + return mcp_tool_result("Skill 资源路径不安全".to_string(), Vec::new(), true); + } + match read_agc_skill_resource(&format!("{skill}/{relative}")) { + Ok(content) => mcp_tool_result(content, Vec::new(), false), + Err(error) => mcp_tool_result(error, Vec::new(), true), + } +} + async fn call_agc_web_search_with_enabled(arguments: &Value, enabled: bool) -> Value { if !enabled { return mcp_tool_result("AGC 受控联网搜索未启用".to_string(), Vec::new(), true); @@ -997,7 +1117,348 @@ async fn call_agc_web_search_with_enabled(arguments: &Value, enabled: bool) -> V .await } -async fn handle_direct_tools_mcp_request(_root: &Path, request: Value) -> Option { +fn external_mcp_journal_path(root: &Path) -> PathBuf { + root.join(EXTERNAL_MCP_JOURNAL_RELATIVE_PATH) +} + +fn redact_external_mcp_response(content: &str) -> String { + content + .lines() + .map(|line| { + let lower = line.to_ascii_lowercase(); + let sensitive = [ + "authorization:", + "cookie:", + "set-cookie:", + "api_key", + "apikey", + "access_token", + "refresh_token", + "client_secret", + "password:", + "bearer ", + ] + .iter() + .any(|marker| lower.contains(marker)); + if sensitive { + "[redacted sensitive response line]".to_string() + } else { + line.to_string() + } + }) + .collect::>() + .join("\n") +} + +fn external_mcp_response_summary(content: &str) -> String { + let normalized = content.split_whitespace().collect::>().join(" "); + normalized + .chars() + .take(EXTERNAL_MCP_RESPONSE_SUMMARY_MAX_CHARS) + .collect() +} + +fn external_mcp_session_id(root: &Path) -> String { + let mut material = root.to_string_lossy().into_owned(); + if let Some(session) = current_platform_session() { + material.push('\0'); + material.push_str(&session.user_id); + material.push('\0'); + material.push_str(&session.generation.to_string()); + } + format!("mcp-{:x}", Sha256::digest(material.as_bytes())) +} + +fn external_mcp_project_id(root: &Path) -> String { + std::fs::read(root.join(".agent/manifest.json")) + .ok() + .and_then(|bytes| serde_json::from_slice::(&bytes).ok()) + .and_then(|value| { + value + .get("projectId") + .and_then(Value::as_str) + .map(str::to_string) + }) + .unwrap_or_else(|| { + format!( + "project-{:x}", + Sha256::digest(root.to_string_lossy().as_bytes()) + ) + }) +} + +fn external_mcp_account_id() -> String { + current_platform_session() + .map(|session| format!("account-{:x}", Sha256::digest(session.user_id.as_bytes()))) + .unwrap_or_else(|| "account-unknown".to_string()) +} + +fn validate_external_mcp_record_arguments( + arguments: &Value, +) -> Result<(String, u64, String), String> { + validate_tool_object_fields(arguments, &["requestId", "sequence", "content"])?; + let request_id = bounded_tool_string(arguments, "requestId", 160)?; + let sequence = arguments + .get("sequence") + .and_then(Value::as_u64) + .ok_or_else(|| "工具参数 sequence 必须是非负整数".to_string())?; + if sequence > 1_000_000 { + return Err("工具参数 sequence 超出安全边界".to_string()); + } + let content = arguments + .get("content") + .and_then(Value::as_str) + .ok_or_else(|| "工具参数 content 必须是字符串".to_string())?; + if content.is_empty() || content.chars().count() > EXTERNAL_MCP_RESPONSE_MAX_CHARS { + return Err("工具参数 content 不能为空或超过大小上限".to_string()); + } + if content + .chars() + .any(|character| character.is_control() && !matches!(character, '\n' | '\r' | '\t')) + { + return Err("工具参数 content 不能包含控制字符".to_string()); + } + Ok((request_id, sequence, content.to_string())) +} + +fn read_external_mcp_journal(root: &Path) -> Result, String> { + let path = external_mcp_journal_path(root); + let Ok(bytes) = std::fs::read(&path) else { + return Ok(Vec::new()); + }; + if bytes.len() as u64 > EXTERNAL_MCP_JOURNAL_MAX_BYTES { + return Err("Codex 返回记录超过客户端保留上限".to_string()); + } + bytes + .split(|byte| *byte == b'\n') + .filter(|line| !line.is_empty()) + .map(|line| { + serde_json::from_slice::(line).map_err(|_| "Codex 返回记录格式损坏".to_string()) + }) + .collect() +} + +fn external_mcp_record_response(root: &Path, arguments: &Value) -> Value { + if let Err(error) = enforce_project_permission_policy(root, "conversation.write") { + return mcp_tool_result(error, Vec::new(), true); + } + let (request_id, sequence, content) = match validate_external_mcp_record_arguments(arguments) { + Ok(value) => value, + Err(error) => return mcp_tool_result(error, Vec::new(), true), + }; + let redacted = redact_external_mcp_response(&content); + let key = format!("{request_id}\u{0}{sequence}"); + let message_id = format!("external-codex-{:x}", Sha256::digest(key.as_bytes())); + let guard = EXTERNAL_MCP_JOURNAL_LOCK + .get_or_init(|| Mutex::new(())) + .lock(); + if guard.is_err() { + return mcp_tool_result("Codex 返回记录锁不可用".to_string(), Vec::new(), true); + } + let mut records = match read_external_mcp_journal(root) { + Ok(records) => records, + Err(error) => return mcp_tool_result(error, Vec::new(), true), + }; + if let Some(existing) = records.iter().find(|record| { + record.get("requestId").and_then(Value::as_str) == Some(request_id.as_str()) + && record.get("sequence").and_then(Value::as_u64) == Some(sequence) + }) { + return mcp_tool_result(existing.to_string(), Vec::new(), false); + } + if let Some(max_sequence) = records + .iter() + .filter(|record| { + record.get("requestId").and_then(Value::as_str) == Some(request_id.as_str()) + }) + .filter_map(|record| record.get("sequence").and_then(Value::as_u64)) + .max() + { + if sequence != max_sequence.saturating_add(1) { + return mcp_tool_result( + "工具参数 sequence 必须按 requestId 连续递增".to_string(), + Vec::new(), + true, + ); + } + } else if sequence != 0 { + return mcp_tool_result( + "同一 requestId 的首条记录 sequence 必须为 0".to_string(), + Vec::new(), + true, + ); + } + let path = external_mcp_journal_path(root); + if let Some(parent) = path.parent() { + if let Err(error) = std::fs::create_dir_all(parent) { + return mcp_tool_result( + format!("创建 Codex 返回记录目录失败:{error}"), + Vec::new(), + true, + ); + } + } + let record = json!({ + "recordId": uuid::Uuid::new_v4().to_string(), + "recordType": "codex.response", + "accountId": external_mcp_account_id(), + "projectId": external_mcp_project_id(root), + "sessionId": external_mcp_session_id(root), + "requestId": request_id, + "sequence": sequence, + "receivedAt": std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_millis() as u64) + .unwrap_or_default(), + "content": redacted, + "contentSha256": format!("{:x}", Sha256::digest(redacted.as_bytes())), + "summary": external_mcp_response_summary(&redacted), + "truncated": false, + "status": "completed" + }); + let line = match serde_json::to_string(&record) { + Ok(line) => line, + Err(error) => { + return mcp_tool_result( + format!("序列化 Codex 返回记录失败:{error}"), + Vec::new(), + true, + ) + } + }; + let current_size = std::fs::metadata(&path) + .map(|metadata| metadata.len()) + .unwrap_or(0); + if current_size.saturating_add(line.len() as u64 + 1) > EXTERNAL_MCP_JOURNAL_MAX_BYTES { + return mcp_tool_result( + "Codex 返回记录达到客户端保留上限".to_string(), + Vec::new(), + true, + ); + } + let _project_lock = match acquire_project_write_lock(root, "conversation.write") { + Ok(lock) => lock, + Err(error) => { + return mcp_tool_result(format!("项目对话锁不可用:{error}"), Vec::new(), true) + } + }; + let append_result = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&path) + .and_then(|mut file| { + use std::io::Write as _; + file.write_all(line.as_bytes())?; + file.write_all(b"\n")?; + file.sync_data() + }); + if let Err(error) = append_result { + return mcp_tool_result( + format!("写入 Codex 返回记录失败:{error}"), + Vec::new(), + true, + ); + } + // Reuse the existing conversation projection so the current UI can read + // the explicit external response without treating it as business truth. + if let Err(error) = append_local_conversation_message_for_session_idempotent_at( + root, + None, + None, + LocalConversationMessage { + role: "assistant".to_string(), + content: record + .get("content") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + agent_id: None, + }, + &message_id, + ) { + return mcp_tool_result( + format!("Codex 返回已写入但对话投影失败:{error}"), + Vec::new(), + true, + ); + } + records.push(record.clone()); + mcp_tool_result(record.to_string(), Vec::new(), false) +} + +fn external_mcp_session_info(root: &Path) -> Value { + let manifest = std::fs::read(root.join(".agent/manifest.json")) + .ok() + .and_then(|bytes| serde_json::from_slice::(&bytes).ok()); + let project_id = manifest + .as_ref() + .and_then(|value| value.get("projectId")) + .and_then(Value::as_str) + .unwrap_or("unknown") + .to_string(); + mcp_tool_result( + json!({ + "status": "bound", + "projectId": project_id, + "sessionId": external_mcp_session_id(root), + "transport": "loopback-or-stdio" + }) + .to_string(), + Vec::new(), + false, + ) +} + +fn external_mcp_conversation_list(root: &Path, arguments: &Value) -> Value { + if let Err(error) = validate_tool_object_fields(arguments, &["offset", "limit"]) { + return mcp_tool_result(error, Vec::new(), true); + } + let offset = arguments.get("offset").and_then(Value::as_u64).unwrap_or(0) as usize; + let limit = arguments.get("limit").and_then(Value::as_u64).unwrap_or(20) as usize; + if offset > 10_000 || !(1..=100).contains(&limit) { + return mcp_tool_result( + "conversation.list 分页参数超出安全边界".to_string(), + Vec::new(), + true, + ); + } + match read_external_mcp_journal(root) { + Ok(records) => mcp_tool_result( + json!({ "entries": records.into_iter().skip(offset).take(limit).map(|record| json!({ + "recordId": record.get("recordId"), "requestId": record.get("requestId"), + "sequence": record.get("sequence"), "receivedAt": record.get("receivedAt"), + "summary": record.get("summary"), "status": record.get("status") + })).collect::>() }) + .to_string(), + Vec::new(), + false, + ), + Err(error) => mcp_tool_result(error, Vec::new(), true), + } +} + +fn external_mcp_conversation_read(root: &Path, arguments: &Value) -> Value { + if let Err(error) = validate_tool_object_fields(arguments, &["recordId"]) { + return mcp_tool_result(error, Vec::new(), true); + } + let record_id = match bounded_tool_string(arguments, "recordId", 80) { + Ok(value) => value, + Err(error) => return mcp_tool_result(error, Vec::new(), true), + }; + match read_external_mcp_journal(root) { + Ok(records) => records + .into_iter() + .find(|record| { + record.get("recordId").and_then(Value::as_str) == Some(record_id.as_str()) + }) + .map(|record| mcp_tool_result(record.to_string(), Vec::new(), false)) + .unwrap_or_else(|| { + mcp_tool_result("未找到 Codex 返回记录".to_string(), Vec::new(), true) + }), + Err(error) => mcp_tool_result(error, Vec::new(), true), + } +} + +async fn handle_direct_tools_mcp_request(root: &Path, request: Value) -> Option { let id = request.get("id").cloned(); let method = request.get("method").and_then(Value::as_str)?; if id.is_none() { @@ -1014,7 +1475,10 @@ async fn handle_direct_tools_mcp_request(_root: &Path, request: Value) -> Option id, json!({ "protocolVersion": requested_protocol, - "capabilities": { "tools": { "listChanged": false } }, + "capabilities": { + "tools": { "listChanged": false }, + "resources": { "subscribe": false, "listChanged": false } + }, "serverInfo": { "name": "genarrative-agc-tools", "version": env!("CARGO_PKG_VERSION") @@ -1023,6 +1487,75 @@ async fn handle_direct_tools_mcp_request(_root: &Path, request: Value) -> Option )) } "ping" => Some(mcp_success(id, json!({}))), + "resources/list" => Some(mcp_success( + id, + json!({ + "resources": [{ + "uri": "agc://skills/index", + "name": "AGC Skill 索引", + "description": "审核通过的客户端 Skill 与工具使用指导", + "mimeType": "text/plain" + }, { + "uri": "agc://conversation/codex-responses", + "name": "Codex 返回记录", + "description": "当前项目中由 conversation.record_codex_response 写入的只读 journal", + "mimeType": "application/x-ndjson" + }] + }), + )), + "resources/read" => { + let uri = request + .pointer("/params/uri") + .and_then(Value::as_str) + .unwrap_or_default(); + if uri == "agc://skills/index" { + let text = render_agc_skill_pack_index() + .map_err(|_| ()) + .unwrap_or_else(|_| "AGC Skill 索引暂不可用".to_string()); + return Some(mcp_success( + id, + json!({ "contents": [{ "uri": uri, "mimeType": "text/plain", "text": text }] }), + )); + } + if let Some(resource) = uri.strip_prefix("agc://skills/") { + return match read_agc_skill_resource(resource) { + Ok(text) if text.len() <= DIRECT_TOOLS_MCP_MAX_REQUEST_BYTES => { + Some(mcp_success( + id, + json!({ "contents": [{ "uri": uri, "mimeType": "text/plain", "text": text }] }), + )) + } + Ok(_) => Some(mcp_error(id, -32000, "AGC Skill 资源超过响应大小上限")), + Err(_) => Some(mcp_error(id, -32602, "未知或未审核的 AGC Skill 资源")), + }; + } + if uri != "agc://conversation/codex-responses" { + Some(mcp_error(id, -32602, "未知资源")) + } else { + let text = read_external_mcp_journal(root) + .map(|records| { + records + .iter() + .map(Value::to_string) + .collect::>() + .join("\n") + }) + .unwrap_or_default(); + if text.len() > DIRECT_TOOLS_MCP_MAX_REQUEST_BYTES { + return Some(mcp_error(id, -32000, "Codex 返回记录资源超过响应大小上限")); + } + Some(mcp_success( + id, + json!({ + "contents": [{ + "uri": uri, + "mimeType": "application/x-ndjson", + "text": text + }] + }), + )) + } + } "tools/list" => Some(mcp_success(id, direct_tools_mcp_specs())), "tools/call" => { let tool = request @@ -1034,6 +1567,13 @@ async fn handle_direct_tools_mcp_request(_root: &Path, request: Value) -> Option .cloned() .unwrap_or_else(|| json!({})); let result = match tool { + "client.session.info" => external_mcp_session_info(root), + "conversation.record_codex_response" => { + external_mcp_record_response(root, &arguments) + } + "conversation.list" => external_mcp_conversation_list(root, &arguments), + "conversation.read" => external_mcp_conversation_read(root, &arguments), + "agc_read_skill_resource" => call_agc_read_skill_resource(&arguments), "agc_write_file" => call_agc_write_file(&arguments).await, "taonier_prepare_game_art" => call_taonier_prepare_game_art(&arguments).await, "agc_generate_image" => call_agc_generate_image(&arguments).await, @@ -1122,6 +1662,108 @@ async fn run_direct_tools_mcp_stdio() -> Result<(), String> { Ok(()) } +fn external_mcp_authorized(headers: &HeaderMap, token: &str) -> bool { + headers + .get(axum::http::header::AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.strip_prefix("Bearer ")) + .is_some_and(|value| value == token) +} + +async fn handle_external_mcp_http_request( + AxumState(state): AxumState, + headers: HeaderMap, + Json(request): Json, +) -> Result, StatusCode> { + if !external_mcp_authorized(&headers, &state.token) { + return Err(StatusCode::UNAUTHORIZED); + } + let Some(session) = current_platform_session() else { + return Err(StatusCode::UNAUTHORIZED); + }; + if session.user_id != state.session_user_id || session.generation != state.session_generation { + return Err(StatusCode::UNAUTHORIZED); + } + let response = EXTERNAL_MCP_BRIDGE_URL + .scope( + state.bridge_url.clone(), + handle_direct_tools_mcp_request(&state.root, request), + ) + .await + .ok_or(StatusCode::BAD_REQUEST)?; + Ok(Json(response)) +} + +pub(crate) async fn start_external_mcp_loopback( + root: &Path, + controlled_web_search: bool, +) -> Result<(String, String), String> { + let root = validate_direct_tools_project_root(root)?; + let session = current_platform_session() + .ok_or_else(|| "启动客户端 MCP 前必须先完成账号会话绑定".to_string())?; + let token = uuid::Uuid::new_v4().to_string(); + let route = format!("/mcp-{}", uuid::Uuid::new_v4().simple()); + let listener = tokio::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)) + .await + .map_err(|error| format!("启动客户端 MCP loopback 失败:{error}"))?; + let address = listener + .local_addr() + .map_err(|error| format!("读取客户端 MCP 地址失败:{error}"))?; + let bridge = + super::direct_tool_bridge::start_direct_tool_bridge(&root, controlled_web_search).await?; + let state = ExternalMcpHttpState { + bridge_url: bridge.url().to_string(), + root, + token: token.clone(), + session_user_id: session.user_id, + session_generation: session.generation, + }; + let app = Router::new() + .route(&route, post(handle_external_mcp_http_request)) + .layer(DefaultBodyLimit::max(DIRECT_TOOLS_MCP_MAX_REQUEST_BYTES)) + .with_state(state); + let task = tokio::spawn(async move { + let _ = axum::serve(listener, app).await; + }); + let url = format!("http://127.0.0.1:{}{route}", address.port()); + let registry = EXTERNAL_MCP_SERVER.get_or_init(|| Mutex::new(None)); + let mut guard = registry + .lock() + .map_err(|_| "客户端 MCP 服务注册表不可用".to_string())?; + if let Some(previous) = guard.take() { + drop(previous); + } + *guard = Some(ExternalMcpServer { + _bridge: bridge, + url: url.clone(), + token: token.clone(), + task, + }); + Ok((url, token)) +} + +pub(crate) fn stop_external_mcp_loopback() { + if let Some(registry) = EXTERNAL_MCP_SERVER.get() { + if let Ok(mut guard) = registry.lock() { + guard.take(); + } + } +} + +#[tauri::command] +pub(crate) async fn start_game_creator_external_mcp(project_path: String) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "conversation.read")?; + let (url, token) = start_external_mcp_loopback(root, false).await?; + Ok(json!({ "url": url, "token": token, "transport": "streamable-http" })) +} + +#[tauri::command] +pub(crate) fn stop_game_creator_external_mcp() -> Result<(), String> { + stop_external_mcp_loopback(); + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -1210,6 +1852,11 @@ mod tests { assert_eq!( names, vec![ + "client.session.info", + "conversation.record_codex_response", + "conversation.list", + "conversation.read", + "agc_read_skill_resource", "agc_write_file", "taonier_prepare_game_art", "agc_generate_image", @@ -1243,9 +1890,10 @@ mod tests { "reuse-or-create" ); assert_eq!(art_tool["inputSchema"]["required"], json!(["brief"])); - assert!(art_tool["description"].as_str().is_some_and( - |description| description.contains("模型参数和 MCP 自动批准本身不构成替换授权") - )); + assert!(art_tool["description"].as_str().is_some_and(|description| { + description.contains("Codex 根据当前对话决定是否调用 regenerate") + && description.contains("客户端不解析用户文本") + })); assert!(art_tool["description"].as_str().is_some_and(|description| { description.contains("用户不需要提供、配置、粘贴或创建 API Key") && description.contains("不得向用户索要凭据或暴露内部 URL") @@ -1583,4 +2231,53 @@ mod tests { assert_eq!(response["isError"], true); assert!(response.to_string().contains("未审核字段")); } + + #[test] + fn skill_resource_tool_rejects_unreviewed_paths() { + let accepted = call_agc_read_skill_resource(&json!({ + "skillName": "agc-project-structure", + "relativePath": "references/structure-contract.md" + })); + assert_eq!(accepted["isError"], false); + assert!(accepted.to_string().contains("drive prefix")); + + let denied = call_agc_read_skill_resource(&json!({ + "skillName": "agc-project-structure", + "relativePath": "../../auth.json" + })); + assert_eq!(denied["isError"], true); + + let denied_windows_absolute = call_agc_read_skill_resource(&json!({ + "skillName": "agc-project-structure", + "relativePath": r"C:\temp\SKILL.md" + })); + assert_eq!(denied_windows_absolute["isError"], true); + } + + #[test] + fn external_codex_response_redacts_sensitive_lines_and_keeps_safe_text() { + let response = redact_external_mcp_response( + "完成了页面布局\nAuthorization: Bearer secret-value\n下一步请运行试玩", + ); + assert!(response.contains("完成了页面布局")); + assert!(response.contains("下一步请运行试玩")); + assert!(!response.contains("secret-value")); + } + + #[test] + fn external_codex_response_arguments_reject_unknown_fields_and_control_bytes() { + assert!(validate_external_mcp_record_arguments(&json!({ + "requestId": "req-1", + "sequence": 0, + "content": "ok", + "unexpected": true + })) + .is_err()); + assert!(validate_external_mcp_record_arguments(&json!({ + "requestId": "req-1", + "sequence": 0, + "content": "bad\u{0001}" + })) + .is_err()); + } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs index f9c5a4ab3..a83b6dba4 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs @@ -27,8 +27,9 @@ pub(in crate::agent) use canvas_generation::{ commit_prepared_platform_art_asset_at, commit_prepared_platform_art_asset_strict_slices_at, generate_platform_art_asset_with_retained_runtime_options_at, generate_platform_art_asset_with_runtime_options_at, - platform_art_generation_error_result_unknown, register_existing_platform_art_slices_at, - request_platform_art_asset_with_runtime_options_at, restore_platform_art_asset_bytes_at, + platform_art_generation_error_result_unknown, recover_persisted_visual_generation_options, + register_existing_platform_art_slices_at, request_platform_art_asset_with_runtime_options_at, + restore_platform_art_asset_bytes_at, retained_platform_art_generation_runtime_spritesheet_identity_at, retained_platform_art_generation_runtime_state_matches_direct_stage_at, validate_platform_art_png_bytes_with_limits, @@ -78,7 +79,7 @@ pub(crate) use draft_validation::{ validate_llm_agent_handoffs, validate_llm_game_draft, validate_non_placeholder_game_html, validate_playable_game_html, validate_safe_game_html_runtime, }; -pub(crate) use draft_writer::write_local_game_draft_at; +pub(crate) use draft_writer::{ensure_legacy_json_generator_project, write_local_game_draft_at}; #[allow(unused_imports)] pub(crate) use loop_orchestration::{ emit_agent_progress, game_creator_agent_llm_error_is_mud_points_insufficient, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs index e3cee4f47..8d098b8e6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs @@ -412,6 +412,7 @@ pub(crate) struct PlatformArtAssetGenerationOptions { pub(crate) asset_kind: String, pub(crate) asset_label: String, pub(crate) replace_existing: bool, + pub(crate) slice_count: Option, } impl Default for PlatformArtAssetGenerationOptions { @@ -423,10 +424,92 @@ impl Default for PlatformArtAssetGenerationOptions { asset_kind: "game-art".to_string(), asset_label: "AI 游戏首版美术素材".to_string(), replace_existing: false, + slice_count: None, } } } +pub(in crate::agent) fn recover_persisted_visual_generation_options( + root: &Path, + pending: &AgentRuntimePendingToolAction, + prompt: &str, + requested: &PlatformArtAssetGenerationOptions, +) -> Result, String> { + let context = platform_art_generation_runtime_context_from_pending(pending); + let Some(state) = read_platform_art_generation_runtime_state(root, &context)? else { + return Ok(None); + }; + let (path, ratio, size, kind, label, generation_kind) = match context.agent_id.as_str() { + "art-director" => ( + "assets/art-spec.png", + "1:1", + "1K", + "icon-spec", + "游戏统一视觉规范图", + "spec", + ), + "design-foundation" => ( + "assets/ui-prototype.png", + "16:9", + "2K", + "ui-prototype", + "游戏横屏界面原型图", + "ui-design", + ), + "art-asset-plan" => ( + "assets/art-spritesheet.png", + "1:1", + "1K", + "art-spritesheet", + "游戏首版核心美术素材", + "icon-spritesheet", + ), + _ => return Ok(None), + }; + let mut options = requested.clone(); + let label = if context.agent_id == "design-foundation" + && options + .output_path + .as_deref() + .is_some_and(design_foundation_ui_page_output_path_is_valid) + { + "游戏功能页面设计图" + } else { + label + }; + if options.output_path.is_none() { + options.output_path = Some(path.to_string()); + } + for (value, default) in [ + (&mut options.aspect_ratio, ratio), + (&mut options.image_size, size), + (&mut options.asset_kind, kind), + (&mut options.asset_label, label), + ] { + if value.is_empty() { + *value = default.to_string(); + } + } + let snapshot = platform_art_generation_runtime_request_snapshot(&state)?; + // 只恢复经过身份校验的已有请求;显式改参和新请求仍走当前合同。 + if snapshot.generation_kind == generation_kind + && snapshot.generation_prompt == build_platform_art_asset_prompt(prompt, &[], &options) + && options.asset_kind == kind + && options.aspect_ratio == ratio + && options.image_size == size + && (options.output_path.as_deref() == Some(path) + || (context.agent_id == "design-foundation" + && options + .output_path + .as_deref() + .is_some_and(design_foundation_ui_page_output_path_is_valid))) + { + Ok(Some(options)) + } else { + Ok(None) + } +} + #[derive(Clone, Debug, Eq, PartialEq)] struct ExistingPlatformArtAssetFingerprint { local_path: String, @@ -681,6 +764,47 @@ pub(in crate::agent) fn platform_art_generation_error_result_unknown(error: &str error.starts_with(EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX) } +/// Observe an accepted operation once without waiting. A single GET that +/// reports `failed` is authoritative and allows a changed retry to release +/// the old local slot; queued/running/unknown outcomes remain protected. +async fn accepted_generation_is_authoritatively_failed_once( + client: &reqwest::Client, + access: &ExternalEditorBindingAccess<'_>, + submission_payload: &serde_json::Value, +) -> Result { + let submission = external_editor_response_data(submission_payload); + let operation_id = json_string_field(submission, "operationId") + .ok_or_else(|| "External Editor accepted 账本缺少 operationId".to_string())?; + access.validate_frozen_session()?; + let payload = tokio::time::timeout( + Duration::from_secs(3), + external_editor_json_request( + client + .get(format!( + "{}{}", + access.api_base_url(), + access.generation_status_route(&operation_id) + )) + .bearer_auth(access.bearer_token()), + "查询平台图片生成任务", + ), + ) + .await + .map_err(|_| "查询平台图片生成任务超时".to_string())??; + access.validate_frozen_session()?; + let generation = platform_generation_status_data(&payload); + match json_string_field(generation, "status").as_deref() { + Some("failed") => Ok(true), + Some("queued" | "running" | "completed") => Ok(false), + Some(status) => Err(format!( + "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 平台图片生成任务返回未知状态 {status};operationId={operation_id}" + )), + None => Err(format!( + "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 平台图片生成任务状态响应缺少 status;operationId={operation_id}" + )), + } +} + pub(crate) async fn external_editor_json_request( request: reqwest::RequestBuilder, action: &str, @@ -1714,24 +1838,16 @@ fn canonical_art_spritesheet_icon_descriptions(prompt: &str) -> Vec { // long creation request cannot reject the atlas before it is queued. const MAX_DESCRIPTION_CHARS: usize = 200; const CONTEXT_PREFIX: &str = ";遵循同一项目视觉规范:"; - [ - "第 1 类(左上):当前玩法的玩家主体或主要操作对象;只生成一个轮廓连贯、可独立使用的完整素材", - "第 2 类(右上):当前玩法的方块、目标物、收集物、敌对实体或危险物;只生成一个完整素材", - "第 3 类(左下):当前玩法需要的地块、障碍、资源物件或场景装饰;只生成一个完整素材", - "第 4 类(右下):得分、受击、成长、失败、胜利或操作反馈特效;只生成一个完整素材", - ] - .into_iter() - .map(|category| { - let context_budget = MAX_DESCRIPTION_CHARS.saturating_sub( - category - .chars() - .count() - .saturating_add(CONTEXT_PREFIX.chars().count()), - ); - let project_context = truncate_inline_bounded(prompt.trim(), context_budget); - format!("{category}{CONTEXT_PREFIX}{project_context}") - }) - .collect() + let category = + "按当前项目需求生成一组可独立使用的透明素材;数量、类别、排列和切片方式由本次需求决定"; + let context_budget = MAX_DESCRIPTION_CHARS.saturating_sub( + category + .chars() + .count() + .saturating_add(CONTEXT_PREFIX.chars().count()), + ); + let project_context = truncate_inline_bounded(prompt.trim(), context_budget); + vec![format!("{category}{CONTEXT_PREFIX}{project_context}")] } fn truncate_inline_bounded(value: &str, max_chars: usize) -> String { @@ -2043,13 +2159,13 @@ pub(crate) async fn generate_platform_art_asset_with_required_slices_at( } let generation_prompt = build_platform_art_asset_prompt(prompt, briefs, options); let runtime_context = - standalone_platform_art_generation_runtime_context(&generation_prompt, options, true)?; + standalone_platform_art_generation_runtime_context(&generation_prompt, options, false)?; generate_platform_art_asset_with_runtime_options_at( root, prompt, briefs, options, - true, + false, &runtime_context, ) .await @@ -2427,6 +2543,34 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at ) })?; if snapshot.generation_prompt != generation_prompt { + if platform_art_generation_runtime_status(&state) == "accepted" { + if let Ok(submission) = platform_art_generation_runtime_submission_payload(&state) { + if accepted_generation_is_authoritatively_failed_once( + &client, + &binding_access, + &submission, + ) + .await + .unwrap_or(false) + { + if let Some(context) = runtime_context { + remove_platform_art_generation_runtime_state_at( + root, + &context.agent_id, + &context.run_id, + )?; + } + return Box::pin(request_platform_art_asset_with_runtime_options_at( + root, + prompt, + briefs, + options, + runtime_context, + )) + .await; + } + } + } return Err(format!( "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 当前生成意图与已持久化请求快照不一致,已拒绝将旧操作当作本次请求恢复;原生成账本已保留,需要先完成或对账旧操作" )); @@ -2448,6 +2592,36 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at ) })?; if snapshot.reference_resource_ids != [current_reference] { + if platform_art_generation_runtime_status(&state) == "accepted" { + if let Ok(submission) = + platform_art_generation_runtime_submission_payload(&state) + { + if accepted_generation_is_authoritatively_failed_once( + &client, + &binding_access, + &submission, + ) + .await + .unwrap_or(false) + { + if let Some(context) = runtime_context { + remove_platform_art_generation_runtime_state_at( + root, + &context.agent_id, + &context.run_id, + )?; + } + return Box::pin(request_platform_art_asset_with_runtime_options_at( + root, + prompt, + briefs, + options, + runtime_context, + )) + .await; + } + } + } return Err(format!( "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 当前规范图身份与已持久化派生请求不一致,已拒绝恢复旧操作;原生成账本已保留,需要先完成或对账旧操作" )); @@ -2556,7 +2730,7 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at serde_json::json!({ "referenceId": reference_id, "iconDescriptions": canonical_art_spritesheet_icon_descriptions(&generation_prompt), - "sliceLayout": "grid-2x2", + "sliceCount": options.slice_count, "screenColor": "auto", "aspectRatio": options.aspect_ratio, "imageSize": options.image_size, @@ -6259,11 +6433,8 @@ fn validate_strict_platform_art_spritesheet_contract( has_transparent_pixels: bool, has_visible_pixels: bool, ) -> Result<(), String> { - if slices.len() != 4 { - return Err(format!( - "strict spritesheet 图集必须恰好包含 4 个独立切片,实际为 {} 个", - slices.len() - )); + if slices.is_empty() { + return Err("spritesheet 图集至少需要一个独立切片".to_string()); } let resource_id = resource_id .map(str::trim) @@ -6294,12 +6465,7 @@ fn validate_strict_platform_art_spritesheet_contract( { return Err("strict spritesheet 图集生成 route/kind 与严格图集合同不一致".to_string()); } - if spritesheet_slice_layout.map(str::trim) != Some("grid-2x2") { - return Err( - "strict spritesheet 图集必须由 External Editor 以 grid-2x2 固定切片合同生成" - .to_string(), - ); - } + let _requested_slice_layout = spritesheet_slice_layout; if reference_resource_ids.len() != 1 || reference_resource_ids[0].trim().is_empty() || reference_resource_ids[0].trim() == resource_id @@ -6634,7 +6800,7 @@ fn existing_platform_art_slice_registrations_are_complete( manifest: &GameCreationAppManifest, registrations: &[PlatformArtSliceManifestRegistration], ) -> Result { - if registrations.len() != 4 { + if registrations.is_empty() { return Ok(false); } let mut resource_ids = std::collections::HashSet::with_capacity(registrations.len()); @@ -7594,6 +7760,7 @@ mod canvas_generation_tests { asset_kind: "game-background".to_string(), asset_label: "手工背景".to_string(), replace_existing: true, + slice_count: None, }; let ordinary = standalone_platform_art_generation_runtime_context("完整生成提示词", &options, false) @@ -8809,13 +8976,13 @@ mod canvas_generation_tests { } #[test] - fn strict_spritesheet_contract_requires_the_grid_2x2_provider_receipt() { + fn strict_spritesheet_contract_accepts_variable_slices_without_fixed_layout() { let canvas_context = ExternalCanvasGenerationContext { project_id: "canvas-project".to_string(), asset_folder_id: "asset-folder".to_string(), canvas_name: "contract-test".to_string(), }; - let slices = (0..4) + let slices = (0..3) .map(|index| { let download = rgba_test_png(100 + index); let validated = validate_platform_art_png_bytes_with_limits( @@ -8841,7 +9008,7 @@ mod canvas_generation_tests { } }) .collect::>(); - let error = validate_strict_platform_art_spritesheet_contract( + validate_strict_platform_art_spritesheet_contract( &slices, &canvas_context, Some("canvas-project"), @@ -8855,9 +9022,7 @@ mod canvas_generation_tests { true, true, ) - .expect_err("missing fixed-layout proof must not be accepted"); - - assert!(error.contains("grid-2x2")); + .expect("valid slice identities and pixel evidence do not require a fixed layout"); } #[test] @@ -9402,6 +9567,7 @@ mod canvas_generation_tests { asset_kind: "icon-spec".to_string(), asset_label: "整包规范图".to_string(), replace_existing: false, + slice_count: None, }; let prompt = "生成同一套整包美术"; let generation_prompt = build_platform_art_asset_prompt(prompt, &[], &options); @@ -10081,7 +10247,8 @@ mod canvas_generation_tests { } #[tokio::test] - async fn accepted_runtime_generation_rejects_changed_intent_before_operation_get() { + async fn accepted_runtime_generation_checks_running_operation_before_rejecting_changed_intent() + { let temporary = tempfile::tempdir().expect("create changed-intent recovery project"); let root = temporary.path(); init_local_game_project_at(root, "changed-intent", "旧异步生成意图隔离") @@ -10101,6 +10268,31 @@ mod canvas_generation_tests { .set_nonblocking(true) .expect("set changed intent fixture nonblocking"); let base_url = format!("http://{}", listener.local_addr().expect("fixture address")); + let (request_sender, request_receiver) = std::sync::mpsc::channel(); + let (stop_sender, stop_receiver) = std::sync::mpsc::channel(); + let server = std::thread::spawn(move || loop { + if stop_receiver.try_recv().is_ok() { + break; + } + let (mut stream, _) = match listener.accept() { + Ok(connection) => connection, + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + std::thread::sleep(Duration::from_millis(5)); + continue; + } + Err(error) => panic!("accept changed intent request: {error}"), + }; + request_sender + .send(read_test_http_request(&mut stream)) + .expect("capture status request"); + write_test_json_response( + &mut stream, + "200 OK", + &serde_json::json!({ + "data": {"operationId": "old-operation", "status": "running"} + }), + ); + }); let runtime_context = PlatformArtGenerationRuntimeContext { agent_id: "art-director".to_string(), task_id: "art-director".to_string(), @@ -10166,10 +10358,15 @@ mod canvas_generation_tests { error.contains("当前生成意图与已持久化请求快照不一致"), "{error}" ); - assert!(matches!( - listener.accept(), - Err(error) if error.kind() == std::io::ErrorKind::WouldBlock - )); + stop_sender.send(()).expect("stop changed intent fixture"); + server.join().expect("join changed intent fixture"); + let requests = request_receiver.try_iter().collect::>(); + assert_eq!( + requests.len(), + 1, + "only the existing operation may be queried" + ); + assert!(requests[0].starts_with("GET /api/external/v1/generations/old-operation ")); assert!(game_creator_agent_runtime_external_generation_exists( root, &runtime_context.agent_id, @@ -10178,7 +10375,8 @@ mod canvas_generation_tests { } #[tokio::test] - async fn accepted_derived_generation_rejects_changed_art_spec_before_operation_get() { + async fn accepted_derived_generation_checks_running_operation_before_rejecting_changed_art_spec( + ) { let temporary = tempfile::tempdir().expect("create changed reference project"); let root = temporary.path(); init_local_game_project_at(root, "changed-reference", "规范图身份隔离") @@ -10223,6 +10421,31 @@ mod canvas_generation_tests { .set_nonblocking(true) .expect("set changed reference fixture nonblocking"); let base_url = format!("http://{}", listener.local_addr().expect("fixture address")); + let (request_sender, request_receiver) = std::sync::mpsc::channel(); + let (stop_sender, stop_receiver) = std::sync::mpsc::channel(); + let server = std::thread::spawn(move || loop { + if stop_receiver.try_recv().is_ok() { + break; + } + let (mut stream, _) = match listener.accept() { + Ok(connection) => connection, + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + std::thread::sleep(Duration::from_millis(5)); + continue; + } + Err(error) => panic!("accept changed reference request: {error}"), + }; + request_sender + .send(read_test_http_request(&mut stream)) + .expect("capture status request"); + write_test_json_response( + &mut stream, + "200 OK", + &serde_json::json!({ + "data": {"operationId": "old-background-operation", "status": "running"} + }), + ); + }); let runtime_context = PlatformArtGenerationRuntimeContext { agent_id: "direct-codex-art".to_string(), task_id: "direct-codex-art-game-background".to_string(), @@ -10239,6 +10462,7 @@ mod canvas_generation_tests { asset_kind: "game-background".to_string(), asset_label: "整包背景图".to_string(), replace_existing: false, + slice_count: None, }; let prompt = "保持同一个生成提示词"; let generation_prompt = build_platform_art_asset_prompt(prompt, &[], &options); @@ -10324,10 +10548,19 @@ mod canvas_generation_tests { "{error}" ); assert!(error.contains("当前规范图身份"), "{error}"); - assert!(matches!( - listener.accept(), - Err(error) if error.kind() == std::io::ErrorKind::WouldBlock - )); + stop_sender + .send(()) + .expect("stop changed reference fixture"); + server.join().expect("join changed reference fixture"); + let requests = request_receiver.try_iter().collect::>(); + assert_eq!( + requests.len(), + 1, + "only the existing operation may be queried" + ); + assert!( + requests[0].starts_with("GET /api/external/v1/generations/old-background-operation ") + ); assert!(game_creator_agent_runtime_external_generation_exists( root, &runtime_context.agent_id, @@ -10690,6 +10923,7 @@ mod canvas_generation_tests { asset_kind: "icon-spec".to_string(), asset_label: "游戏统一视觉规范图".to_string(), replace_existing: false, + slice_count: None, }; let prompt = "恢复已受理视觉规范图"; let generation_prompt = build_platform_art_asset_prompt(prompt, &[], &options); @@ -10720,6 +10954,38 @@ mod canvas_generation_tests { mark_platform_art_generation_runtime_accepted(root, state, "test-operation-id", 0) .expect("accept current generation fixture"); + let sparse_options = PlatformArtAssetGenerationOptions { + output_path: Some(AGENT_RUNTIME_ART_SPEC_PATH.to_string()), + aspect_ratio: String::new(), + image_size: String::new(), + asset_kind: String::new(), + asset_label: String::new(), + ..Default::default() + }; + assert_eq!( + recover_persisted_visual_generation_options(root, &pending, prompt, &sparse_options) + .expect("recover persisted defaults"), + Some(options.clone()) + ); + assert!(recover_persisted_visual_generation_options( + root, + &pending, + "显式改变生成意图", + &sparse_options, + ) + .unwrap() + .is_none()); + let mut explicit_options = sparse_options.clone(); + explicit_options.asset_kind = "game-background".to_string(); + assert!(recover_persisted_visual_generation_options( + root, + &pending, + prompt, + &explicit_options, + ) + .unwrap() + .is_none()); + resume_game_creator_agent_background_tasks_at(root) .expect("resume accepted generation through recovery scan"); let first = request_receiver @@ -10974,19 +11240,18 @@ mod canvas_generation_tests { } #[test] - fn canonical_art_spritesheet_request_has_exactly_four_ordered_categories() { + fn canonical_art_spritesheet_request_preserves_project_requirements() { let descriptions = canonical_art_spritesheet_icon_descriptions("原创收集玩法"); - assert_eq!(descriptions.len(), 4); - for (index, description) in descriptions.iter().enumerate() { - assert!(description.starts_with(&format!("第 {} 类", index + 1))); - } + assert_eq!(descriptions.len(), 1); + assert!(descriptions[0].contains("原创收集玩法")); + assert!(descriptions[0].contains("数量、类别、排列和切片方式由本次需求决定")); } #[test] fn canonical_art_spritesheet_descriptions_obey_external_editor_item_limit() { let descriptions = canonical_art_spritesheet_icon_descriptions(&"原创玩法需求".repeat(128)); - assert_eq!(descriptions.len(), 4); + assert!(!descriptions.is_empty()); assert!(descriptions .iter() .all(|description| description.chars().count() <= 200)); @@ -11264,6 +11529,7 @@ mod canvas_generation_tests { asset_kind: "art-spritesheet".to_string(), asset_label: "游戏首版核心美术素材".to_string(), replace_existing: true, + slice_count: None, } } @@ -11376,7 +11642,7 @@ mod canvas_generation_tests { } #[test] - fn strict_slice_commit_rejects_non_four_slice_results_before_replacing_the_sheet() { + fn strict_slice_commit_rejects_empty_slice_results_before_replacing_the_sheet() { let temporary = tempfile::tempdir().expect("create strict slice project"); let root = temporary.path(); init_local_game_project_at(root, "strict-slices", "严格切片测试") @@ -11391,9 +11657,9 @@ mod canvas_generation_tests { &replacement_options(), |_| Ok(()), ) - .expect_err("strict spritesheet commit must require exactly four slices"); + .expect_err("strict spritesheet commit must require at least one slice"); - assert!(error.contains("恰好包含 4 个独立切片")); + assert!(error.contains("至少需要一个独立切片")); assert_eq!(fs::read(path).expect("read preserved sheet"), b"old-image"); assert!(!root .join("assets/art-spritesheet-slices/manifest.json") diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/draft_writer.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/draft_writer.rs index b3a84948e..ac67a164a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/draft_writer.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/draft_writer.rs @@ -9,8 +9,8 @@ pub(crate) fn write_local_game_draft_at( if prompt.is_empty() { return Err("创作想法不能为空".to_string()); } + ensure_legacy_json_generator_project(root)?; validate_llm_game_draft(prompt, draft)?; - init_local_game_project_at(root, "local-project-draft", "未命名游戏原型")?; let checkpoint = create_local_project_checkpoint_at(root)?; let timestamp = unix_timestamp(); let title = draft.title.trim(); @@ -134,3 +134,38 @@ pub(crate) fn write_local_game_draft_at( manifest, }) } + +pub(crate) fn ensure_legacy_json_generator_project(root: &Path) -> Result<(), String> { + if root.join("game/package.json").exists() + || root.join("package.json").exists() + || !root.join("game/index.html").is_file() + || !root.join(".agent/manifest.json").is_file() + { + return Err("JSON Generator 仅支持已有的单文件 HTML 项目;新建游戏与 npm / Phaser 4 项目请使用 DirectProject 完成依赖安装、构建和试玩".to_string()); + } + Ok(()) +} + +#[cfg(test)] +mod legacy_generator_tests { + use super::*; + + #[test] + fn legacy_generator_rejects_npm_before_writing() { + let root = tempfile::tempdir_in(std::env::temp_dir().canonicalize().unwrap()).unwrap(); + fs::create_dir_all(root.path().join("game")).unwrap(); + fs::create_dir_all(root.path().join(".agent")).unwrap(); + fs::write(root.path().join("game/index.html"), "legacy source").unwrap(); + fs::write(root.path().join(".agent/manifest.json"), "{}").unwrap(); + assert!(ensure_legacy_json_generator_project(root.path()).is_ok()); + fs::write(root.path().join("game/package.json"), "{}").unwrap(); + assert!(ensure_legacy_json_generator_project(root.path()) + .unwrap_err() + .contains("DirectProject")); + assert_eq!( + fs::read_to_string(root.path().join("game/index.html")).unwrap(), + "legacy source" + ); + assert!(!root.path().join(".agent/spec.md").exists()); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/loop_orchestration.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/loop_orchestration.rs index e955f53a4..a79d03fbd 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/loop_orchestration.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/loop_orchestration.rs @@ -44,6 +44,7 @@ pub(crate) async fn run_game_creator_agent_loop_at( project_blackboard: &str, progress: Option<&AgentProgressEmitter<'_>>, ) -> Result { + ensure_legacy_json_generator_project(root)?; let spec_path = root.join(".agent/spec.md"); let findings_path = root.join(".agent/findings.md"); let run_id = format!("game-generate-draft-{}", unix_millis()); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/pass_artifacts.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/pass_artifacts.rs index 979ee4ddb..d17beeafc 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/pass_artifacts.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/pass_artifacts.rs @@ -337,9 +337,6 @@ pub(crate) fn agent_role_memory_relative_path_for_task(task_id: &str) -> Result< if task_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { return Ok(GAME_CREATOR_PROJECT_SUPERVISOR_MEMORY_PATH.to_string()); } - if task_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID { - return Ok(GAME_CREATOR_PROJECT_PLANNING_MEMORY_PATH.to_string()); - } for group in GAME_CREATOR_AGENT_GROUP_DEFINITIONS { for role in group.roles { if role.task_id == task_id { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/prompt_context.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/prompt_context.rs index 4f65ace3c..60f63acc3 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/prompt_context.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/prompt_context.rs @@ -24,6 +24,7 @@ JSON schema: } gameHtml 规则: +- 此 JSON 协议仅用于已有的单文件 HTML 项目;npm / Phaser 项目必须使用 DirectProject,不能通过 gameHtml 交付 package.json 或模块源码。 - 必须是单文件 HTML,不能加载远程脚本、远程图片、远程 CSS 或 CDN。 - 必须包含 canvas、canvas getContext、实际绘制调用、键盘或鼠标输入、requestAnimationFrame 主循环、目标、失败或胜利状态、R 或按钮重开。 - JavaScript 不要 eval、Function、localStorage、fetch、WebSocket、ServiceWorker。 diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/prompt.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/prompt.rs index 7648408da..cdccf120d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/prompt.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/prompt.rs @@ -488,15 +488,13 @@ fn game_creator_design_foundation_tool_plan_prompt( prompt: &str, editor_api_key_is_configured: bool, ) -> String { - let role_boundary = "角色边界:项目文件写入只允许 memory/project.md 与 game/game_design.md;配置 External Editor API Key 且任务要求界面原型时,可额外产出 assets/ui-prototype.png 与 Runtime 发现清单要求的 assets/ui-pages/*.png;UI 设计图生成后只能通过受控 ui.workflow.run 写入或关联 UI JSON、保存工作流阶段并应用页面,不得绕过该工具直接写入 UI State。不得创建、修改、删除或补丁 game/index.html,也不得改动任何其他程序实现、发布、音频或美术素材文件。完成固定正式产物后直接交付,由 Runtime 在收束门内验证本人 owner 产物;不得调用 project.verify、command.run_limited、game.static_smoke、preview.start 或 preview.validate,也不得通过 command.exec、command.start 或其他工具启动本地预览服务、浏览器、Playwright,或执行任何桌面端、移动端试玩验证。完整 DAG 的最终静态验收仍属于 preview-readiness,浏览器验收仍属于 preview-playtest。"; + let role_boundary = "角色边界:只负责玩法规格、界面建议和视觉工具使用指导。项目文件与图片输出必须服从当前任务明确要求;不创建固定图片槽位,不规定固定数量或布局,不修改 game/index.html,不启动预览或试玩。"; if !editor_api_key_is_configured { return format!( "{prompt}\n\n你负责玩法规格与界面原型基础交付。当前未配置 External Editor API Key,因此本轮必须完成 memory/project.md 与 game/game_design.md,不调用 canvas.asset_generate,也不伪造 assets/ui-prototype.png。把界面结构、控件、状态和双视口要求写进玩法规格;game/game_design.md 必须为每个功能页面各写一行 @genarrative-ui-page {{\"pageId\":\"稳定英文ID\",\"title\":\"页面标题\",\"description\":\"页面用途\",\"applicationPath\":\"game/index.html\"}},供 Runtime 自动发现和后续程序组实现;完成写入后直接交付,不要自行运行任何验证命令。{role_boundary}" ); } - format!( - "{prompt}\n\n你负责玩法规格与界面原型交付。玩法类型和机制描述不代表用户授权复刻现有游戏;必须先为项目创造原创标题、实体、资源、目标名称与视觉语言,并在 memory/project.md、game/game_design.md 和图片提示中保持一致;game/game_design.md 必须为每个功能页面各写一行 @genarrative-ui-page {{\"pageId\":\"稳定英文ID\",\"title\":\"页面标题\",\"description\":\"页面用途\",\"applicationPath\":\"game/index.html\"}},作为 Runtime 自动发现的权威设计声明。不得沿用或近似改写知名游戏单位、角色、Logo、界面术语或受保护视觉语言。文本策划只是中间结果;最终必须先用 asset.list 确认 assets/art-spec.png 已登记为当前项目的 icon-spec 画布资源,再调用 canvas.asset_generate 生成 16:9、2K 横屏界面原型图并登记到 assets/ui-prototype.png,assetKind=ui-prototype、assetLabel=游戏横屏界面原型图、replaceExisting=false。图片 prompt 必须逐项继承当前任务和 game/game_design.md 的真实玩法、HUD、可玩区域、关键实体、主要操作、失败/重开与移动端触控要求;不得假设为塔防或补入合同中不存在的单位卡牌、费用、波次、敌人入口等结构。Runtime 固定把规范图资源作为 referenceImageSrcs 第一项,调用 External Editor v1 的 POST /api/external/v1/editor/images/generations(kind=ui-design);不得误用 POST /api/external/v1/editor/ui-designs/assets/extractions,后者只用于从已有且带标注的 UI 设计图提取独立透明 UI 素材。缺少规范图时必须等待 art-director 依赖并如实阻塞,不得回退为无规范参考的普通生图。canvas.asset_generate 成功只表示候选图片已生成并登记,不等于视觉验收完成。已有同路径画布资产时先核对登记,再在当前 run 对且只对 assets/ui-prototype.png 调用 image.inspect;检查已通过时不得重复生成或再次扣费。只有 ui-prototype.v2 的 informationHud、gameplaySurface、objectiveEntities、primaryControls、failureRestartFlow、responsiveLayout、implementationClarity、originalTheme 八项检查全部通过才可完成。八项视觉检查通过后,先调用 ui.workflow.run 的 discover 自动读取受控页面声明,不得凭空猜页面;再按返回的每个 pageId 逐页调用 canvas.asset_generate,以固定 16:9、2K、assetKind=ui-prototype、replaceExisting=false 生成并登记对应 assets/ui-pages/{{pageId}}.png 设计图,assetLabel 使用该页标题,使用发现的真实 applicationPath 依次执行 prepare、recognize、status,确认所有页面均无 blockers 后再执行 finalize。该工具会创建并关联 kind=UI 的 JSON 编辑资源、持久化每一阶段 State、同步 manifest/客户端,并在完成后返回 visual-binding 最终编辑器路由;只登记 ui-prototype 图片或只写计划不算完成。由 Runtime 在收束门内同时核对固定 owner 文档、当前 revision 与视觉证据。纯场景图、概念图、地图、海报或只有角色而没有可玩界面的画面都不是 UI 原型。视觉检查未通过时不得提交最终回复;只有任务正文明确标识这是带 repairOfDelegationId 的唯一返工轮时,才可使用固定输出合同和 replaceExisting=true 原位替换旧候选;不得先删除正式图片。图片生成未配置、待确认或失败时同样不得提交最终回复,也不得把计划写完当成 completed。{role_boundary}" - ) + format!("{prompt}\n\n根据当前玩法需求编写规格和界面建议;如需图片,明确说明用途、数量、输出路径、尺寸、参考资源和是否需要 spritesheet,再调用 canvas.asset_generate。不要使用固定图片合同。{role_boundary}") } fn game_creator_art_director_tool_plan_prompt( @@ -506,7 +504,7 @@ fn game_creator_art_director_tool_plan_prompt( if !editor_api_key_is_configured { return format!("{prompt}\n\n你负责确定原创视觉方向。当前未配置 External Editor API Key,这是只读协调任务:只完成正式 director 结论并直接交付,不修改项目文件,不调用 canvas.asset_generate,也不伪造 assets/art-spec.png。seed task 中生成规范图的图片产物与验收条款在本轮不适用。"); } - format!("{prompt}\n\n你负责生成项目唯一的统一视觉规范图。视觉方向文档只是中间结果;最终必须调用 canvas.asset_generate,以固定合同 outputPath=assets/art-spec.png、aspectRatio=1:1、imageSize=1K、assetKind=icon-spec、assetLabel=游戏统一视觉规范图、replaceExisting=false 生成真实图片。Runtime 固定调用 External Editor v1 的 POST /api/external/v1/editor/images/generations(kind=spec),并把结果同时登记到同名画布、素材库和项目 manifest。规范图必须覆盖玩家主体、目标物、地块、UI 图标、状态反馈、色板与材质规则,作为后续 UI 和透明图集共同引用的权威资源;不得用 generationInputs.artSpec JSON、纯文本计划、完整游戏截图、海报或普通黑底图集冒充。canvas.asset_generate 成功只表示固定候选已生成并登记,不等于视觉门已经通过;生成成功后直接交付,由 Runtime 在收束时核对当前 revision、Canvas 登记、资源身份和视觉产物门。已有有效同路径资产时不得重复生成或扣费;只有带 repairOfDelegationId 的唯一返工轮可设置 replaceExisting=true 原位替换。生成失败或缺少 resourceId 时不得提交最终回复,也不得把计划写完当成 completed。") + format!("{prompt}\n\n你负责确定原创视觉方向。根据项目实际需要选择 canvas.asset_generate 的 assetKind、outputPath、尺寸、比例和提示词;可以生成一张或多张图片,也可以不生成图片。需要参考图时使用已登记资源 ID,生成后核对返回资源、权限、计费和登记状态;不要假设固定图片名称、数量、素材类别或布局。") } fn game_creator_art_asset_plan_tool_plan_prompt( @@ -519,17 +517,13 @@ fn game_creator_art_asset_plan_tool_plan_prompt( ); } format!( - "{prompt}\n\n你负责首版美术素材实际生成。资产清单和美术计划只是中间结果;最终必须调用 canvas.asset_generate 生成并登记 assets/art-spritesheet.png,固定使用 1:1、1K、assetKind=art-spritesheet、assetLabel=游戏首版核心美术素材、replaceExisting=false,并写入可解析的 assets/manifest.art.json。调用前必须用 asset.list 确认 assets/art-spec.png 已登记为当前项目的 icon-spec 画布资源,并依据当前任务、game/game_design.md 与 manifest 逐项说明真实需要的玩家主体及朝向/状态、目标或收集物、障碍/场景元素和反馈特效,由 Runtime 形成 iconDescriptions;不得假设为塔防或加入合同中不存在的单位、敌人、波次、卡牌。Runtime 固定以规范图的权威 resourceId 作为 referenceId,调用 POST /api/external/v1/editor/icon-spritesheets/generations,并用 screenColor=auto 完成透明后处理;不得把 UI 原型、Data URL、Blob URL、本地路径或结构化 JSON 冒充规范图引用,不得回退普通生图或 UI extraction。缺少规范图时必须等待 art-director 依赖并如实阻塞。成功后回读 observation 与 asset.list,核对服务端返回的透明 spritesheet、真实 alpha、warning 和 sliceWarning。warning.code=postprocess-failed-source-preserved 时没有透明图集,不得登记、验收或自动重试;仅 sliceWarning 时可保留完整透明图集,但不得声称独立切片已生成。透明证据核对完成后直接交付,由 Runtime 在收束门内验证本人固定 manifest 产物并复核 Canvas 证据。已有有效同路径资产时不得重复生成或扣费;只有带 repairOfDelegationId 的唯一返工轮可 replaceExisting=true 原位替换。不得运行 game.static_smoke 或 preview.validate,也不得编辑 game/index.html。图片生成未配置、待确认、失败或透明证据不足时不得提交最终回复。" + "{prompt}\n\n你负责按项目实际需求规划和生成美术素材。使用 asset.list 了解已有资源,再按需调用 canvas.asset_generate;数量、文件名、素材类别、切片布局和尺寸由当前需求决定,不得套用固定图片包或固定 2x2。spritesheet 可通过 sliceCount 指定切片数量,也可以生成普通单图或多张独立图片。生成后核对资源登记、透明度、警告和实际使用情况。" ) } pub(crate) fn game_creator_agent_runtime_tool_plan_system_prompt_for_agent( agent_id: &str, - source: &str, ) -> String { - if agent_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID { - return game_creator_project_planning_tool_plan_system_prompt(); - } let prompt = game_creator_agent_runtime_tool_plan_system_prompt(); if agent_id == "design-foundation" { return game_creator_design_foundation_tool_plan_prompt( @@ -549,46 +543,13 @@ pub(crate) fn game_creator_agent_runtime_tool_plan_system_prompt_for_agent( if agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { return prompt; } - game_creator_project_supervisor_tool_plan_prompt( - &prompt, - editor_api_key_is_configured(), - source, - ) + game_creator_project_supervisor_tool_plan_prompt(&prompt, editor_api_key_is_configured()) } -/// The planning child has an exact native allowlist. Do not reuse the broad -/// runtime composition here: its common section contains examples for -/// mutation, commands, previews, delegation and user-input actions that are -/// not present in the planning request's function catalog. Keeping this -/// prompt deliberately small makes the advertised surface and the textual -/// contract agree; the role-specific Fast GDD brief is appended by the -/// Provider request builder after the identity binding has been checked. -fn game_creator_project_planning_tool_plan_system_prompt() -> String { - format!( - "你正在使用 Genarrative AI 游戏创作多智能体 Runtime。当前请求只广告以下原生函数:file.read、file.list、plan.submit_gdd、update_agent_plan、respond_to_user。只能直接调用这些函数;不得调用未广告的函数、动态工具或普通文本伪造工具调用。\n\n读取工具只用于获取项目内已有文本和文件摘要;不要把读取结果当作已经写入、提交、审批或构建完成。需要记录真实计划变化时调用 update_agent_plan,arguments 必须提交完整 steps;成稿时调用 plan.submit_gdd,input 必须严格符合 plan-submit-gdd-input.v1,只提交 game、decisions、prototypeValidationItems,不得附加 platformFacts、身份、版本、时间或 fingerprint。plan.submit_gdd 必须是本轮唯一 action,可与 update_agent_plan 同响应,但不能与其它动作或 respond_to_user 混合;GDD 提交成功后再由 Runtime 负责 durable 写入和投影。已有足够 observation、需要交付终态信封或当前轮次应收束时调用 respond_to_user。Runtime 身份、审批事实、项目版本和平台事实均由系统维护,不得自行生成或修改。" - ) -} - -/// plan 根 run(`source == project-supervisor-plan`)的策划链路合同是「全程零 -/// 构建、零文件写」,只允许委派 project-planning 这一个子 Agent(见 M1A-4)。 -/// 因此它的 Supervisor system prompt 不拼 `$visualContract`(点名 -/// art-director/design-foundation/art-asset-plan/code-prototype 的视觉产物 -/// 合同)和 `supervisorIntro`(要求「自行查看静态角色目录,选择最匹配的不同 -/// 专业 Agent」)——两段都在暗示存在可并行委派的专业组,而 plan 根 run 没有 -/// 这个能力。除 plan 根以外的一切 source(gui/cli/未知)都必须逐字 -/// 保留既有合成结果,不能被这里的分支误伤。 fn game_creator_project_supervisor_tool_plan_prompt( prompt: &str, editor_api_key_is_configured: bool, - source: &str, ) -> String { - // 只看 `source` 就够:本函数唯一的调用方在上面用 - // `agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID` 提前返回过,而 - // `source` 只有 Supervisor 自己的 run binding 会填成 plan(见 - // provider_request_builders.rs),其余角色恒为空串。 - if agent_runtime_supervisor_source_is_plan(source) { - return game_creator_project_supervisor_plan_tool_plan_system_prompt(); - } let visual_section = if editor_api_key_is_configured { RUNTIME_PROMPT_VISUAL_EDITOR_SECTION } else { @@ -604,35 +565,6 @@ fn game_creator_project_supervisor_tool_plan_prompt( ) } -/// 立项策划根 run(`source == project-supervisor-plan`)的 Supervisor system -/// prompt 是一份独立清单,不是通用 Supervisor 合同的差集。 -/// -/// 通用合同预设 Supervisor 手里有 43 个原生工具、六个专业组、isolated child、 -/// 正式任务图和视觉产物合同;plan 根一个都没有——它只有 7 个工具,只能委派 -/// `project-planning` 一个子 Agent。继续用逐段否定去改写通用合同,等于让 -/// 「plan 根到底看到什么」散落在几个 `if plan_root` 分支里,而每漏一段就是一次 -/// 已经实测到的偏航(专业角色目录残留曾让 Supervisor 照着委派 -/// `design-director`)。因此这里整份换成 `supervisorPlan` composition,plan 根 -/// 的全部段落在 manifest 里一眼可读。 -fn game_creator_project_supervisor_plan_tool_plan_system_prompt() -> String { - // 与 Provider 请求的函数目录共用 `agent_runtime_plan_root_supervisor_tools`: - // 提示词里列的工具和实际广告的工具必须是同一份事实,否则又会出现 - // 「合同说有、请求里没有」的自相矛盾。 - let tool_catalog = agent_runtime_plan_root_supervisor_tools().join("、"); - let prompt_header = format!( - "你正在使用 Genarrative AI 游戏创作多智能体 Runtime。你必须直接调用当前请求广告的原生函数,不要把动作或回复写进普通文本。本 run 不维护结构化计划——流程形状是固定的(冻结目标合同 → 委派策划子 Agent → 取证验收 → 交审批),进度由 Runtime 自己记录,你只需要每一轮做当前阶段唯一该做的那件事。本 run 的原生可执行工具目录只有:{tool_catalog},并且**按阶段开放**——每一轮只广告当前阶段能真正推进链路的那几个,没有出现在本轮函数目录里的,这一阶段调用不了,也不需要调用。澄清卡不由你发:子 Agent 的问询信封由 Runtime 直接转成决策卡,你只会在用户答完之后被恢复。写入、补丁、删除、命令、预览、素材生成、任务图、记忆、黑板与 isolated child 在本 run 都不存在,调用它们只会失败。" - ); - // `$platform` 整段是 command.start/exec/poll/stdin/terminate 的用法合同, - // plan 根一个 command 工具都没有;GDD 里的平台事实由 Runtime 另行注入,与 - // 这段无关。 - render_runtime_prompt_composition(RUNTIME_PROMPT_SUPERVISOR_PLAN_COMPOSITION, |marker| { - match marker { - "$header" => Some(&prompt_header), - _ => None, - } - }) -} - fn render_runtime_prompt_sections(sections: &[&str]) -> String { sections .iter() @@ -729,16 +661,6 @@ pub(crate) fn game_creator_agent_role_definition( &PROJECT_SUPERVISOR_AGENT_ROLES[0], )); } - // 立项策划子 Agent 与 Supervisor 一样不属于任何专业组,必须在这里显式命中。 - // 否则它会落进下面的组遍历、返回 None,而两个调用方都用 `.ok_or_else(...)?` - // 把 None 转成硬错误:Supervisor 一旦把任务委派给它,第一轮构建 Provider - // 上下文时就会中断。 - if agent_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID { - return Some(( - &PROJECT_PLANNING_AGENT_DEFINITION, - &PROJECT_PLANNING_AGENT_ROLES[0], - )); - } GAME_CREATOR_AGENT_GROUP_DEFINITIONS .iter() .find_map(|group_definition| { @@ -850,215 +772,6 @@ mod tests { ); } - #[test] - fn project_planning_role_brief_states_the_parser_wire_shape_verbatim() { - // 现场事故:brief 只说「严格 JSON 的单题问题」,模型据此输出裸的单题对象 - // 外加自创的 answerFormat 字段,解析器 deny_unknown_fields 直接拒收, - // 子 Run 已终止无从修复,整条委派停在 needs-reconciliation。brief 与 - // schema 必须逐字对齐,任一边改了都得让这条用例先红。 - let planning = game_creator_agent_runtime_role_overlay_prompt( - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - None, - ); - assert!( - planning.contains(r#"{"questions":[{ ... }]}"#), - "brief 必须给出 questions 外壳,不能只说「单题问题」" - ); - for field in ["`id`", "`header`", "`question`", "`options`"] { - assert!(planning.contains(field), "brief 缺少字段 {field}"); - } - assert!( - planning.contains("answerFormat"), - "brief 要点名这个真实踩过的坑" - ); - for stated in [ - // 策划卡的 header 走 `AGENT_RUNTIME_USER_INPUT_MAX_PLAN_HEADER_CHARS`:它装的是 - // 这一轮要定的主题本身,不是通用问询那 12 字的标题格。钉住的仍是「brief 与 - // 解析器同一把尺子」,只是尺子换成了策划链路实际生效的那一把。 - format!("不超过 {AGENT_RUNTIME_USER_INPUT_MAX_PLAN_HEADER_CHARS} 字符"), - format!("不超过 {AGENT_RUNTIME_USER_INPUT_MAX_QUESTION_CHARS} 字符"), - format!("不超过 {AGENT_RUNTIME_USER_INPUT_MAX_OPTION_LABEL_CHARS} 字符"), - format!("不超过 {AGENT_RUNTIME_USER_INPUT_MAX_OPTION_DESCRIPTION_CHARS} 字符"), - // 选项数同理:通用协议是 2-3 个,策划决策卡恒为 A / B /「需要原型验证」 - // 三项。brief 早先照通用常量写「2~3 个」,和它自己下文的「固定提供三个 - // 选项」以及 `planning_coordinator` 的硬校验三方打架。 - format!("恰好 {PLAN_CLARIFICATION_OPTION_COUNT} 个"), - ] { - assert!( - planning.contains(&stated), - "brief 与 schema 不一致:缺少「{stated}」" - ); - } - } - - /// Supervisor 的核心行为准则是「用验收标准和预期产物把边界清晰的任务委派 - /// 出去」(`identity-contract.md`),这在自主构建链路上正确,搬到策划链路 - /// 上却恰好碾过决策卡协议:把边界定清楚等于把用户没说的都替他决定掉。 - /// 实测中 Supervisor 写出的 acceptanceCriteria 含「未由用户指定的具体规则 - /// 标注为立项假设」和「目标平台、输入设备…须标为待定或建议」,前者作废了 - /// 3 轮问询预算(三次真实 run 里信封一次都没触发),后者还与 Runtime 强制 - /// 注入的平台事实直接矛盾。playbook 因此必须显式反向约束,否则整条策划链路 - /// 上只有「问完之后怎么转述」有指导、没有「你不该替用户回答」。 - #[test] - fn supervisor_playbook_forbids_pre_deciding_the_planning_tradeoffs() { - let playbook = required_runtime_prompt_section("supervisorPlaybook"); - for required in [ - "不得替用户预先裁定产品取舍", - "3 轮问询预算", - "不得要求标为待定", - ] { - assert!( - playbook.contains(required), - "supervisorPlaybook 缺少策划委派约束:{required}" - ); - } - // 该约束必须真的到达 plan 根 Supervisor,而不只是躺在 section 里。 - let plan_prompt = game_creator_agent_runtime_tool_plan_system_prompt_for_agent( - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE, - ); - assert!( - plan_prompt.contains("不得替用户预先裁定产品取舍"), - "plan 根 Supervisor 的 system prompt 必须带上该约束" - ); - } - - /// `common.md` 的原创性红线随 `$base` 分发给做游戏链路的每个专业 Agent,但 - /// 策划子 Agent 走 exact allowlist 的专用小 prompt、不拼 `$base`(理由见 - /// `game_creator_project_planning_tool_plan_system_prompt` 的注释),该条款 - /// 因此在 M1A-2 收窄工具面时被连带切掉——工具面窄是对的,内容红线跟着一起 - /// 消失不是。GDD 是整条产线的上游:策划稿里落进受保护名称,下游做游戏的 - /// Agent 即便个个守规也已经晚了。断言的是「策划子 Agent 实际收到的完整 - /// prompt」,不限定由哪一层提供,将来若把 common.md 拆成工具面与内容红线 - /// 两段再正常合成,这条同样成立。 - #[test] - fn project_planning_prompt_keeps_the_originality_red_line_common_md_carries() { - assert!( - required_runtime_prompt_section("common").contains("不代表授权复刻现有游戏"), - "common.md 的原创性红线是本断言的对照基线;它若被改写,必须一并复核策划 brief" - ); - let mut delivered = game_creator_agent_runtime_tool_plan_system_prompt_for_agent( - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - "", - ); - delivered.push_str("\n\n"); - delivered.push_str(&game_creator_agent_runtime_role_overlay_prompt( - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - None, - )); - for required in [ - "不代表授权复刻", - "必须原创", - "受保护视觉语言", - "referenceGames", - ] { - assert!( - delivered.contains(required), - "策划子 Agent 收到的 prompt 缺少原创性约束:{required}" - ); - } - } - - #[test] - fn project_planning_role_brief_is_isolated_to_its_manifest_overlay() { - let planning = game_creator_agent_runtime_role_overlay_prompt( - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - None, - ); - assert!(planning.contains("立项策划 Agent")); - assert!(planning.contains("AGC_NEEDS_USER_INPUT_V1")); - assert!(planning.contains("A ·")); - assert!(planning.contains("B ·")); - assert!(planning.contains("需要原型验证")); - assert!(planning.contains("平台事实或 MVP 规则排除")); - assert!(!planning.contains("暂按推荐")); - assert!(game_creator_agent_runtime_role_overlay_prompt( - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - None, - ) - .is_empty()); - assert!( - game_creator_agent_runtime_role_overlay_prompt("design-foundation", None).is_empty() - ); - } - - /// 这份 role brief 早先两次要求子 Agent「按默认建议填写」,却从未写出默认建议 - /// 是什么——引用了一份不存在的清单,而没有任何断言看着它。这条钉三件事。 - /// - /// 一、清单在场,且逐条按 `plan-submit-gdd-input.v1` 的字段名写,改 schema 时 - /// 这条会跟着红。 - /// - /// 二、`pillars` / `coreLoop` 明确排除在清单外:它们就是首个可玩闭环本身, - /// 给它们配默认值等于把最该花提问预算的那两项默认掉。 - /// - /// 清单成员已按原型(`local-scripts/deisgn_agent/prompts.py:136`)那五条拉齐: - /// 局长偏好、美术、成长、探索、构建。`genre.fusion` / `targetUsers.coreUsers` - /// / `preferences` / `referenceGames` / `outOfScope` 从清单里摘掉了——它们 - /// 原型就没有默认值,进了清单就等于把第三顺位「制作边界与 MVP」整条轴默认 - /// 掉,出稿触发器③「剩余空白都能由默认建议覆盖」随之在第 3 轮恒真,3 轮预算 - /// 实际只花得出 2 轮。成长 / 探索 / 构建三条与上面那句不冲突:它们是维度级 - /// 缺省内容,不是 `pillars` / `coreLoop` 两个字段的缺省值,而且「优先用默认 - /// 建议而不是提问」是软优先级,不禁止提问——原型正是带着这三条默认,仍然把 - /// 第 1 轮花在 coreLoop、第 2 轮花在重玩动力上。 - /// - /// 三、出稿触发器是闭集。生产实测过 Supervisor 会把「若缺少会实质改变结果的 - /// 事实才提问,否则直接提交」写进委派 task,子 Agent 照办后 0 轮出稿;这里 - /// 不给「可以无视 task」的授权,改为钉住触发器只有四个——Supervisor 写的门槛 - /// 不在其中,自然不是触发器。 - #[test] - fn project_planning_default_suggestions_exist_and_spare_the_core_loop() { - let planning = game_creator_agent_runtime_role_overlay_prompt( - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - None, - ); - assert!( - planning.contains("**默认建议**"), - "role brief 三处引用「默认建议」,清单本身必须在场" - ); - for field in ["`artStyle`", "`targetUsers.sessionLength`"] { - assert!(planning.contains(field), "默认建议清单缺少字段 {field}"); - } - for dimension in ["缺成长时", "缺探索时", "缺构建时"] { - assert!( - planning.contains(dimension), - "默认建议清单缺少原型的维度级缺省 {dimension}" - ); - } - // 反向:这几个字段一旦回到默认清单,轴三就又被默认掉了。它们仍会在 brief 里 - // 出现(被点名为「没有默认值兜底」),所以只能钉「缺 → 」这个清单条目形状。 - for defaulted in [ - "`genre.fusion` 缺 →", - "`targetUsers.referenceGames` 缺 →", - "`outOfScope` 缺 →", - ] { - assert!( - !planning.contains(defaulted), - "{defaulted} 不得回到默认建议清单:那会让出稿触发器③在第 3 轮恒真" - ); - } - assert!( - planning.contains("**`pillars` 与 `coreLoop` 没有默认建议**"), - "pillars / coreLoop 不得进默认建议清单" - ); - assert!( - planning.contains("出稿只有四个触发器"), - "出稿触发器必须是闭集,否则委派 task 里的任意措辞都能当触发器" - ); - } - - #[test] - fn project_planning_prompt_advertises_submit_gdd_contract() { - let prompt = game_creator_agent_runtime_tool_plan_system_prompt_for_agent( - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - "", - ); - assert!(prompt.contains("plan.submit_gdd")); - assert!(prompt.contains("plan-submit-gdd-input.v1")); - assert!(prompt.contains("唯一 action")); - assert!(prompt.contains("可与 update_agent_plan 同响应")); - assert!(!prompt.contains("user.input_request")); - } - #[test] fn runtime_prompt_tool_catalog_tracks_the_native_capability_registry() { let prompt = game_creator_agent_runtime_tool_plan_system_prompt(); @@ -1341,7 +1054,6 @@ mod tests { let prompt = game_creator_project_supervisor_tool_plan_prompt( "shared runtime contract", editor_api_key_is_configured, - AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, ); let sections = [ "shared runtime contract", @@ -1366,16 +1078,8 @@ mod tests { #[test] fn supervisor_editor_prompt_has_one_art_asset_plan_owner_contract() { - let prompt = game_creator_project_supervisor_tool_plan_prompt( - "", - true, - AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, - ); - let without_editor = game_creator_project_supervisor_tool_plan_prompt( - "", - false, - AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, - ); + let prompt = game_creator_project_supervisor_tool_plan_prompt("", true); + let without_editor = game_creator_project_supervisor_tool_plan_prompt("", false); assert!(prompt.contains( "art-asset-plan 只声明 assets/manifest.art.json 与 assets/art-spritesheet.png" @@ -1389,377 +1093,12 @@ mod tests { assert!(!without_editor.contains("assets/art-spritesheet.png;不得把 UI 与图集合并")); } - /// M1A-4:plan 根 run(source == project-supervisor-plan)的 Supervisor - /// system prompt 整份换成 `supervisorPlan` composition,不再是通用 Supervisor - /// 合同的差集。 - /// - /// **沿革**:这条最早只断言「不拼 supervisorIntro 与 $visualContract」——两段 - /// 都在暗示存在可并行委派的专业组,而策划链路只能委派 project-planning。 - /// 2026-08-20 又发现 `$base` 里的 isolated agent 模板目录同样会被挪去当 - /// `agent.delegate` 的目标:实测 Supervisor 首轮委派了目录里排第一的 - /// `design-director`,被执行层拒绝后未能自行改回 `project-planning`,整个 run - /// 空转到 loop 预算耗尽、零产物。逐段做减法每漏一段就是一次这样的偏航, - /// 因此现在改为整份替换:plan 根的段落清单由 manifest 的 `supervisorPlan` - /// 单点定义,本测试钉住它。 - #[test] - fn plan_root_supervisor_prompt_is_the_dedicated_plan_composition() { - assert_eq!( - RUNTIME_PROMPT_SUPERVISOR_PLAN_COMPOSITION, - &[ - "$header", - "planCommon", - "isolatedAgentContract", - "planSupervisorIdentity", - "planSupervisorPlaybook", - "supervisorRepair" - ] - ); - for editor_api_key_is_configured in [false, true] { - let plan_prompt = game_creator_project_supervisor_tool_plan_prompt( - "shared runtime contract", - editor_api_key_is_configured, - AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE, - ); - assert!( - !plan_prompt.contains("静态角色目录"), - "plan 根 prompt 不应再要求查看静态角色目录:{plan_prompt}" - ); - assert!( - !plan_prompt.contains("选择最匹配的不同专业 Agent"), - "plan 根 prompt 不应再包含 supervisorIntro 的并行委派指令" - ); - assert!( - !plan_prompt.contains("视觉产物始终按 owner 隔离"), - "plan 根 prompt 不应再包含 with-editor 视觉合同" - ); - assert!( - !plan_prompt.contains( - "当前未配置 External Editor API Key,art-director 只交付视觉方向文档" - ), - "plan 根 prompt 不应再包含 without-editor 视觉合同" - ); - // 通用 $base 不再进入 plan 根:它带着改码流程、revision 验证门禁、 - // git 提交与「作为被委派的专业 Agent 时」等整段不可执行的合同。 - assert!( - !plan_prompt.contains("shared runtime contract"), - "plan 根 prompt 不应再拼通用 $base" - ); - for dropped in [ - "supervisorIdentityContract", - "supervisorIntro", - "supervisorPlaybook", - "supervisorClaimGate", - "common", - "isolatedTemplateCatalogIntro", - "platformDefault", - "platformLinux", - ] { - assert!( - !plan_prompt.contains(required_runtime_prompt_section(dropped).trim()), - "plan 根 prompt 不应包含 section {dropped}" - ); - } - for kept in [ - "planCommon", - "isolatedAgentContract", - "planSupervisorIdentity", - "planSupervisorPlaybook", - // 返工必须逐字继承原 acceptanceCriteria / expectedArtifacts, - // 这条规则只在 supervisorRepair 里,plan 根实测撞过两次。 - "supervisorRepair", - ] { - assert!( - plan_prompt.contains(required_runtime_prompt_section(kept).trim()), - "plan 根 prompt 必须包含 section {kept}" - ); - } - } - } - - /// 提示词头部列出的工具清单和 Provider 请求实际广告的函数目录必须是同一份 - /// 事实。二者各自维护一份,就会退回「合同说有 43 个、请求里只有 9 个」的 - /// 自相矛盾——这正是收窄工具面本身要消灭的东西。 - #[test] - fn plan_root_prompt_tool_catalog_matches_the_advertised_allowlist() { - let plan_prompt = game_creator_agent_runtime_tool_plan_system_prompt_for_agent( - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE, - ); - let allowlist = agent_runtime_plan_root_supervisor_tools(); - assert!( - plan_prompt.contains(&format!( - "本 run 的原生可执行工具目录只有:{},并且**按阶段开放**", - allowlist.join("、") - )), - "plan 根 prompt 头部必须逐字列出 allowlist:{plan_prompt}" - ); - for tool in agent_runtime_native_executable_tools() { - if allowlist.contains(&tool) { - continue; - } - assert!( - !plan_prompt.contains(&format!("、{tool}")) - && !plan_prompt.contains(&format!("{tool}、")), - "plan 根 prompt 不应在工具目录里出现被裁掉的 {tool}" - ); - } - } - - /// `planCommon` 是 `common.md` 的子集副本,不是重写。 - /// - /// 通用 `common` 段有 43% 是 plan 根执行不了的内容(改码流程、revision 验证 - /// 门禁、git 提交、联网检索、以及整段「作为被委派的专业 Agent 时」的身份错位), - /// 其中「最后一次修改后必须成功执行 project.verify 才能 respond_to_user」还是 - /// 一道 plan 根永远满足不了的假门禁。但同一段里也压着这条链路唯一的原创性 - /// 红线、`user.input_request` 协议和静态委派协议——D11 拓扑的协议正文就在 - /// 这里,不在 playbook。所以 plan lane 只挑走这三段,逐字复制。 - /// - /// 复制就会漂移,因此这条断言反过来钉:`planCommon` 里的每一段都必须能在 - /// `common.md` 里逐字找到。谁单改一边,这条就红。 - #[test] - fn plan_common_paragraphs_are_verbatim_slices_of_the_shared_common_section() { - let common = required_runtime_prompt_section("common"); - let plan_common = required_runtime_prompt_section("planCommon"); - // `用户输入请求协议` 不再继承:plan 根没有 user.input_request,澄清卡由 - // Runtime 在 parent-wake 屏障处按信封原文直接构造。 - let inherited = [ - "用户只描述玩法类型、机制或相似体验时,不代表授权复刻现有游戏。", - "静态委派协议:", - ]; - let mut matched = 0; - for paragraph in plan_common - .split( - " - -", - ) - .map(str::trim) - { - if !inherited.iter().any(|head| paragraph.starts_with(head)) { - continue; - } - assert!( - common.contains(paragraph), - "planCommon 段落已与 common.md 漂移:{paragraph}" - ); - matched += 1; - } - assert_eq!(matched, inherited.len(), "planCommon 缺少继承段落"); - // 反向:plan 根不得继承那道它永远满足不了的验证门禁。 - assert!(common.contains("才能调用 respond_to_user 收束")); - assert!(!plan_common.contains("才能调用 respond_to_user 收束")); - } - - /// 澄清回灌必须带上问题原文和三个选项标签,两端都要钉住。 - /// - /// `header` 现在带主题(「第N轮·当前要决定:{主题}」),但只到主题这一层——用户 - /// 拍的板落在**选项**上。而 `project-planning` 每轮都是全新 run(`observations: []`), - /// 除了委派任务正文什么都看不到。只回灌 `{header} → 用户答:{原文}` 时, - /// 「类似B」「B · 沙盒里程碑成长」这类答案仍然无从解读——生产实测的农场经营项目里, - /// 第 1 轮问「季节订单冲刺 vs 自主农场成长」,用户答了 B,第 2 轮又拿「短周期经营 - /// 目标 vs 沙盒里程碑成长」问同一条轴,而且 B 选项几乎是用户原话的复述。 - /// - /// 这条与 header 带不带主题正交:主题解决「问过哪些轴」,选项标签解决「答案指的是 - /// 哪一个」。两端都得钉。 - #[test] - fn plan_clarification_relay_carries_the_question_and_option_labels() { - let plan = required_runtime_prompt_section("planSupervisorPlaybook"); - for required in [ - "第N轮问的是:{question 原文}", - "{option1.label}", - "问题原文和三个选项标签必须带上", - ] { - assert!(plan.contains(required), "回灌格式缺少 {required}:{plan}"); - } - - let planning = game_creator_agent_runtime_role_overlay_prompt( - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - None, - ); - assert!( - planning.contains("已确认决定关掉的那条轴不得重问"), - "子 Agent 侧缺少重复提问的兜底约束:{planning}" - ); - } - - /// 澄清 continuation 的两个指纹由 Runtime 从原 delivery 补齐,playbook 不得再 - /// 要求 Supervisor 从 observation 里抄过来。实测正是那条指令让它手抄 128 个 - /// 十六进制字符,抄错后工具计划格式修复两次仍失败,整个 plan 根 run 判 failed, - /// 用户已提交的澄清回答全部作废。 - #[test] - fn plan_supervisor_playbook_leaves_clarification_fingerprints_to_the_runtime() { - let plan = required_runtime_prompt_section("planSupervisorPlaybook"); - assert!( - plan.contains("Runtime 会从该原 delivery 补齐权威指纹"), - "playbook 必须写明指纹由 Runtime 补齐:{plan}" - ); - assert!( - !plan.contains("提交 observation 给出的"), - "playbook 仍在要求 Supervisor 手抄澄清指纹:{plan}" - ); - } - - /// 「不得替用户预先裁定产品取舍」这段同时写进两条 lane 的 playbook。 - /// - /// 执行层只单向拦住 plan 根(`立项策划根 Run 只能委派 project-planning`), - /// 反方向没有对称限制:`agent.delegate` 的 `agentId` 是自由字符串,gui/cli - /// Supervisor 委派 `project-planning` 并未被禁。所以通用 playbook 里这段不是 - /// 死文本,两边都要有;这条断言钉住它们逐字相同。 - #[test] - fn both_playbooks_carry_the_same_anti_pre_deciding_contract() { - const SHARED: &str = "不得替用户预先裁定产品取舍"; - let generic = required_runtime_prompt_section("supervisorPlaybook"); - let plan = required_runtime_prompt_section("planSupervisorPlaybook"); - let paragraph = generic - .split( - " - -", - ) - .map(str::trim) - .find(|paragraph| paragraph.contains(SHARED)) - .expect("supervisorPlaybook 必须包含反预先裁定段落"); - assert!( - plan.contains(paragraph), - "两条 lane 的反预先裁定段落已漂移: -{paragraph} ---- -{plan}" - ); - for required in ["3 轮问询预算", "不得要求标为待定"] { - assert!(paragraph.contains(required), "缺少 {required}"); - } - } - - /// plan 根 Supervisor 的完整 system prompt 里不得再出现任何专业组角色名。 - /// 断言遍历 `GAME_CREATOR_AGENT_GROUP_DEFINITIONS` 而不是列举几个字面量, - /// 这样将来新增角色自动进入覆盖范围,不依赖有人记得回来补这条。 - #[test] - fn plan_root_supervisor_prompt_drops_the_specialist_role_catalog() { - let plan_prompt = game_creator_agent_runtime_tool_plan_system_prompt_for_agent( - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE, - ); - for group in GAME_CREATOR_AGENT_GROUP_DEFINITIONS { - for role in group.roles { - assert!( - !plan_prompt.contains(role.task_id), - "plan 根 prompt 不应出现专业角色名 {}:{plan_prompt}", - role.task_id - ); - } - } - assert!( - !plan_prompt - .contains(required_runtime_prompt_section("isolatedTemplateCatalogIntro").trim()), - "plan 根 prompt 不应保留 spawn_isolated 静态模板目录抬头" - ); - // agent.delegate 同样按这段填 expectedArtifacts / writeScopes,plan 根 - // 委派 project-planning 时要用,删目录不能把它一起删掉。 - assert!( - plan_prompt.contains(required_runtime_prompt_section("isolatedAgentContract").trim()), - "plan 根 prompt 必须保留 expectedArtifacts / writeScopes 合同" - ); - } - - /// 收窄只对 plan 根 Supervisor 生效。其余 source 与其余 agent 的共享 - /// runtime 合同必须逐字等于「基础 prompt 未被收窄」时的合成结果,且模板 - /// 目录仍然完整——`agent.spawn_isolated` 在那些链路上是真能用的。 - #[test] - fn only_the_plan_root_supervisor_loses_the_specialist_role_catalog() { - let intact_base = game_creator_agent_runtime_tool_plan_system_prompt(); - for (agent_id, source) in [ - (GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, ""), - ( - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, - ), - ( - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, - ), - ( - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, - ), - ( - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - "project-supervisor-plan-forged", - ), - // 专业角色自己即便被伪造成 plan source 也不该被收窄。 - ("code-prototype", AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE), - ("quality-review", ""), - ] { - let actual = - game_creator_agent_runtime_tool_plan_system_prompt_for_agent(agent_id, source); - let expected = if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { - game_creator_project_supervisor_tool_plan_prompt( - &intact_base, - editor_api_key_is_configured(), - source, - ) - } else { - intact_base.clone() - }; - assert_eq!( - actual, expected, - "agent={agent_id} source={source} 不应被 plan 根收窄逻辑改变" - ); - assert!( - actual.contains( - required_runtime_prompt_section("isolatedTemplateCatalogIntro").trim() - ), - "agent={agent_id} source={source} 必须保留静态模板目录" - ); - } - } - - /// gui/cli 等非 plan source 的 Supervisor system prompt 必须与 - /// M1A-4 之前逐字相同:直接用既有 section 常量手工拼出改动前的合成结果, - /// 逐字比对,防止上面的 plan 根分支误伤这条现役路径。 - #[test] - fn non_plan_supervisor_prompt_stays_byte_identical_to_the_original_composition() { - for editor_api_key_is_configured in [false, true] { - let visual_section = if editor_api_key_is_configured { - RUNTIME_PROMPT_VISUAL_EDITOR_SECTION - } else { - RUNTIME_PROMPT_VISUAL_NO_EDITOR_SECTION - }; - let expected = render_runtime_prompt_sections(&[ - "shared runtime contract", - required_runtime_prompt_section("supervisorIdentityContract"), - required_runtime_prompt_section("supervisorIntro"), - required_runtime_prompt_section(visual_section), - required_runtime_prompt_section("supervisorPlaybook"), - required_runtime_prompt_section("supervisorClaimGate"), - required_runtime_prompt_section("supervisorRepair"), - ]); - for source in [ - AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, - AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, - "", - "project-supervisor-plan-forged", - ] { - let actual = game_creator_project_supervisor_tool_plan_prompt( - "shared runtime contract", - editor_api_key_is_configured, - source, - ); - assert_eq!( - actual, expected, - "source={source} 不应被 plan 根收窄逻辑改变" - ); - } - } - } - #[test] fn runtime_prompt_bundle_uses_only_native_function_protocol_terms() { for editor_api_key_is_configured in [false, true] { let prompt = game_creator_project_supervisor_tool_plan_prompt( required_runtime_prompt_section("common"), editor_api_key_is_configured, - AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, ); for legacy_term in [ "最终 response", @@ -1792,112 +1131,69 @@ mod tests { } #[test] - fn agent_prompt_design_foundation_forbids_implementation_and_browser_validation() { + fn agent_prompt_design_foundation_keeps_its_role_boundary() { for editor_api_key_is_configured in [false, true] { let prompt = game_creator_design_foundation_tool_plan_prompt( "shared runtime contract", editor_api_key_is_configured, ); - - assert!(prompt.contains("项目文件写入只允许 memory/project.md 与 game/game_design.md")); - assert!(prompt.contains("不得创建、修改、删除或补丁 game/index.html")); - assert!(prompt.contains("由 Runtime 在收束门内验证本人 owner 产物")); - assert!( - prompt.contains("不得调用 project.verify、command.run_limited、game.static_smoke") - ); - assert!(prompt.contains("preview.start 或 preview.validate")); - assert!(prompt.contains("最终静态验收仍属于 preview-readiness")); - assert!(prompt.contains("浏览器验收仍属于 preview-playtest")); - assert!(prompt.contains( - "不得通过 command.exec、command.start 或其他工具启动本地预览服务、浏览器、Playwright" - )); + assert!(prompt.contains("只负责玩法规格、界面建议和视觉工具使用指导")); + assert!(prompt.contains("项目文件与图片输出必须服从当前任务明确要求")); + assert!(prompt.contains("不修改 game/index.html,不启动预览或试玩")); } } #[test] - fn agent_prompt_design_foundation_preserves_the_canvas_specific_contract() { + fn agent_prompt_design_foundation_describes_available_deliveries() { let without_canvas = game_creator_design_foundation_tool_plan_prompt("shared runtime contract", false); assert!(without_canvas.contains("不调用 canvas.asset_generate")); - assert!(without_canvas.contains("不伪造 assets/ui-prototype.png")); + assert!(without_canvas.contains("memory/project.md 与 game/game_design.md")); assert!(without_canvas.contains("每个功能页面各写一行 @genarrative-ui-page")); let with_canvas = game_creator_design_foundation_tool_plan_prompt("shared runtime contract", true); + assert!(with_canvas.contains("根据当前玩法需求编写规格和界面建议")); + assert!(with_canvas.contains("用途、数量、输出路径、尺寸、参考资源和是否需要 spritesheet")); assert!(with_canvas.contains("再调用 canvas.asset_generate")); - assert!(with_canvas.contains("assets/art-spec.png")); - assert!(with_canvas.contains("referenceImageSrcs 第一项")); - assert!(with_canvas.contains("assets/ui-prototype.png")); - assert!(with_canvas.contains("调用 image.inspect")); - assert!(with_canvas.contains("ui-prototype.v2")); - assert!(with_canvas.contains("成功只表示候选图片已生成并登记,不等于视觉验收完成")); - assert!(with_canvas.contains("由 Runtime 在收束门内同时核对固定 owner 文档")); - assert!(!with_canvas.contains("成功动作本身就是当前 revision 的验证")); - assert!(with_canvas.contains("调用 ui.workflow.run")); - assert!(with_canvas.contains("visual-binding 最终编辑器路由")); - assert!(with_canvas.contains("每个功能页面各写一行 @genarrative-ui-page")); - assert!(with_canvas.contains("ui.workflow.run 的 discover")); - assert!(with_canvas.contains("assets/ui-pages/{pageId}.png")); - assert!(with_canvas.contains("informationHud")); - assert!(with_canvas.contains("failureRestartFlow")); - assert!(with_canvas.contains("不得假设为塔防")); - assert!(with_canvas - .contains("POST /api/external/v1/editor/images/generations(kind=ui-design)")); - assert!(with_canvas - .contains("不得误用 POST /api/external/v1/editor/ui-designs/assets/extractions")); } #[test] - fn agent_prompt_art_director_switches_between_read_only_and_conditional_canvas_owner() { + fn agent_prompt_art_director_describes_credentials_and_resource_checks() { let without_canvas = game_creator_art_director_tool_plan_prompt("shared runtime contract", false); assert!(without_canvas.contains("这是只读协调任务")); assert!(without_canvas.contains("只完成正式 director 结论并直接交付")); assert!(without_canvas.contains("不调用 canvas.asset_generate")); - assert!(without_canvas.contains("图片产物与验收条款在本轮不适用")); let with_canvas = game_creator_art_director_tool_plan_prompt("shared runtime contract", true); - assert!(with_canvas.contains("outputPath=assets/art-spec.png")); - assert!(with_canvas.contains("assetKind=icon-spec")); - assert!(with_canvas.contains("成功只表示固定候选已生成并登记,不等于视觉门已经通过")); - assert!(with_canvas.contains("由 Runtime 在收束时核对当前 revision")); - assert!(!with_canvas.contains("成功会为本人当前 revision 形成验证凭证")); + assert!(with_canvas + .contains("canvas.asset_generate 的 assetKind、outputPath、尺寸、比例和提示词")); + assert!(with_canvas.contains("需要参考图时使用已登记资源 ID")); + assert!(with_canvas.contains("生成后核对返回资源、权限、计费和登记状态")); } #[test] - fn agent_prompt_art_asset_plan_uses_fixed_transparent_spritesheet_route_and_warnings() { + fn agent_prompt_art_asset_plan_describes_resource_discovery_and_generation() { let without_canvas = game_creator_art_asset_plan_tool_plan_prompt("shared runtime contract", false); assert!(without_canvas.contains("不调用 canvas.asset_generate")); - assert!(without_canvas.contains("不伪造 assets/art-spritesheet.png")); + assert!(without_canvas.contains("写入可解析的 assets/manifest.art.json")); let with_canvas = game_creator_art_asset_plan_tool_plan_prompt("shared runtime contract", true); - assert!(with_canvas.contains("用 asset.list 确认 assets/art-spec.png 已登记")); - assert!(with_canvas.contains("由 Runtime 形成 iconDescriptions")); - assert!(with_canvas.contains("玩家主体及朝向/状态")); - assert!(with_canvas.contains("不得假设为塔防")); - assert!(with_canvas.contains("权威 resourceId 作为 referenceId")); - assert!(with_canvas.contains("POST /api/external/v1/editor/icon-spritesheets/generations")); - assert!(with_canvas.contains("不得回退普通生图或 UI extraction")); - assert!(with_canvas.contains("screenColor=auto")); - assert!(with_canvas.contains("缺少规范图时必须等待 art-director")); - assert!(with_canvas.contains("不得回退普通生图")); - assert!(with_canvas.contains("真实 alpha")); - assert!(with_canvas.contains("warning.code=postprocess-failed-source-preserved")); - assert!(with_canvas.contains("不得登记、验收或自动重试")); - assert!(with_canvas.contains("仅 sliceWarning")); - assert!(with_canvas.contains("由 Runtime 在收束门内验证本人固定 manifest 产物")); + assert!(with_canvas.contains("使用 asset.list 了解已有资源")); + assert!(with_canvas.contains("再按需调用 canvas.asset_generate")); + assert!(with_canvas.contains("spritesheet 可通过 sliceCount 指定切片数量")); + assert!(with_canvas.contains("生成后核对资源登记、透明度、警告和实际使用情况")); } #[test] fn agent_prompt_other_agents_keep_the_shared_runtime_contract() { let shared_prompt = game_creator_agent_runtime_tool_plan_system_prompt(); - let code_agent_prompt = game_creator_agent_runtime_tool_plan_system_prompt_for_agent( - "code-prototype", - AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, - ); + let code_agent_prompt = + game_creator_agent_runtime_tool_plan_system_prompt_for_agent("code-prototype"); assert_eq!(code_agent_prompt, shared_prompt); assert!(code_agent_prompt.contains("你正在使用 Genarrative AI 游戏创作多智能体 Runtime")); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions.rs index bd5fc0fb3..bfa44999b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions.rs @@ -44,7 +44,6 @@ pub(in crate::agent) use run_status_observation::*; pub(in crate::agent) use structured_plan::*; pub(in crate::agent) use tool_plan_protocol::*; -pub(crate) use crate::agent::runtime_protocol::plan_gdd_completion_blocker_at_locked; #[cfg(test)] pub(crate) use action_audit::agent_runtime_action_receipt_public_safe_detail_for_test; #[cfg(test)] @@ -74,7 +73,6 @@ pub(crate) use context_compaction::compact_game_creator_agent_runtime_session_at pub(crate) use parallel_ledger::{ agent_runtime_confirmation_path_component, agent_runtime_parallel_read_batch_len, agent_runtime_tool_allowed_for_agent, agent_runtime_tool_is_parallel_safe_read, - agent_runtime_tool_rejected_by_agent_identity, game_creator_agent_runtime_parallel_read_batch_path, game_creator_agent_runtime_pending_tool_action_path, game_creator_agent_runtime_provider_action_batch_path, @@ -106,6 +104,7 @@ pub(crate) use project_gates::{ prepare_agent_runtime_project_mutation_locked, process_session_completion_blocker_at, project_verification_completion_blocker, project_verification_completion_blocker_at, static_delegate_completion_blocker_at, structured_plan_completion_blocker, + try_acquire_game_creator_agent_runtime_project_write_lock, validate_agent_runtime_pending_verification_gate_before, }; #[cfg(test)] @@ -116,7 +115,6 @@ pub(crate) use project_gates::{ }; pub(crate) use provider_action_batch::{ prepare_game_creator_agent_runtime_provider_action_batch, - prepare_game_creator_agent_runtime_provider_action_batch_with_planning_binding, update_game_creator_agent_runtime_provider_batch_member, AgentRuntimePendingToolAction, AgentRuntimeProviderActionBatch, }; @@ -147,9 +145,6 @@ pub(crate) use tool_plan_protocol::parse_game_creator_agent_tool_plan_response; pub(crate) use tool_policy_snapshot::{ agent_runtime_acceptance_evidence_tools, agent_runtime_autonomous_design_foundation_command_is_allowed, agent_runtime_executable_tools, - agent_runtime_native_executable_tools, agent_runtime_plan_root_supervisor_tools, - agent_runtime_plan_root_supervisor_tools_for_stage, - agent_runtime_tool_policy_snapshot_for_run_at, plan_root_supervisor_stage_at, - plan_root_supervisor_stage_at_locked, PlanRootSupervisorStage, - AGENT_RUNTIME_CANVAS_ASSET_KINDS, AGENT_RUNTIME_PROJECT_PLANNING_ACTION_TOOLS, + agent_runtime_native_executable_tools, agent_runtime_tool_policy_snapshot_for_run_at, + AGENT_RUNTIME_CANVAS_ASSET_KINDS, }; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_execution.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_execution.rs index a2063143b..23fd5d8e4 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_execution.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_execution.rs @@ -38,29 +38,6 @@ pub(crate) async fn execute_game_creator_agent_runtime_tool_action_with_pending_ ) -> AgentRuntimeToolObservation { let tool = action.tool.trim(); let relaxed_autonomous = autonomous_relaxed_run_at(root, agent_id, run_id).unwrap_or(false); - if tool == PLAN_SUBMIT_GDD_TOOL && agent_id.trim() != GAME_CREATOR_PROJECT_PLANNING_AGENT_ID { - return AgentRuntimeToolObservation { - tool: tool.to_string(), - status: "rejected".to_string(), - summary: "plan.submit_gdd 仅允许 project-planning Agent".to_string(), - detail: None, - }; - } - if agent_id.trim() == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID - && !matches!(tool, "file.read" | "file.list") - { - // `plan.submit_gdd` is intentionally handled by the planning submit - // branch in the Runtime main loop. If it ever reaches the generic - // executor (including recovery or a stale pending record), fail - // closed instead of treating the durable mutation as an ordinary - // command action. - return AgentRuntimeToolObservation { - tool: tool.to_string(), - status: "rejected".to_string(), - summary: "当前 Agent 身份不允许执行该工具".to_string(), - detail: None, - }; - } let action_fingerprint = pending_action .map(|pending| { agent_runtime_pending_tool_action_fingerprint( diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/autonomous_policy.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/autonomous_policy.rs index 3fa69f520..cd032a2ee 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/autonomous_policy.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/autonomous_policy.rs @@ -453,16 +453,9 @@ pub(in crate::agent) fn validate_agent_runtime_autonomous_initial_collaboration_ "首批 art-director 必须是非只读规范图生成任务", )); } - let art_artifacts = + // 图片产物由 Codex 按项目需求决定;不再要求固定 art-spec.png。 + let _art_artifacts = autonomous_initial_delegate_expected_artifacts(art_director, "art-director")?; - if !art_artifacts - .iter() - .any(|path| path == "assets/art-spec.png") - { - return Err(autonomous_initial_collaboration_contract_error( - "首批 art-director 的 expectedArtifacts 必须包含 assets/art-spec.png", - )); - } let code_director = code_director.ok_or_else(|| { autonomous_initial_collaboration_contract_error("首批缺少 code-director 委派") @@ -1976,7 +1969,7 @@ mod tests { plan: Vec::new(), actions: vec![ autonomous_initial_delegate("design-director", &[]), - autonomous_initial_delegate("art-director", &["assets/art-spec.png"]), + autonomous_initial_delegate("art-director", &[]), autonomous_initial_delegate("code-director", &[]), ], response: String::new(), @@ -2018,17 +2011,6 @@ mod tests { assert!(error.contains("不得启动 isolated child")); } - #[test] - fn autonomous_initial_collaboration_requires_leader_artifacts() { - let mut plan = autonomous_initial_leader_plan(); - plan.actions[1] = autonomous_initial_delegate("art-director", &[]); - - let error = validate_agent_runtime_autonomous_initial_collaboration_contract(&plan) - .expect_err("art artifact must be required"); - - assert!(error.contains("expectedArtifacts 必须包含 assets/art-spec.png")); - } - #[test] fn autonomous_manifest_dag_waits_only_after_seed_execution_starts() { let temporary = tempfile::tempdir().expect("create manifest DAG policy root"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/context_compaction.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/context_compaction.rs index 72c7d2f73..cb239c2d2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/context_compaction.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/context_compaction.rs @@ -25,11 +25,6 @@ pub(in crate::agent) async fn compact_game_creator_agent_runtime_context_at( root, "runtime.context_compaction.build", )?; - if agent_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID { - // Advance the immutable Provider-usage projection before any - // request/source bytes are rebuilt from the plan session. - fold_plan_provider_usage_before_new_request_at_locked(root, Some((agent_id, run_id)))?; - } let source = build_game_creator_agent_runtime_context_compaction_source( root, agent_id, @@ -67,18 +62,6 @@ pub(in crate::agent) async fn compact_game_creator_agent_runtime_context_at( let config_path = format!("agentLlm.{template_agent_id}"); let mut request = build_game_creator_agent_runtime_context_compaction_request(&source, &llm)?; - let planning_agent = agent_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID; - if planning_agent { - if allow_idle_context_compaction { - return Err( - "project-planning 不支持脱离 active run 的 idle context compaction".to_string(), - ); - } - let wire_bytes = - capture_plan_provider_structured_injections_at(root, session_id, observations)?; - let message = render_plan_provider_structured_injections_message(&wire_bytes)?; - request.messages.insert(1, LlmMessage::user(message)); - } let estimated_request_tokens = estimate_game_creator_llm_request_tokens(&request)?; validate_game_creator_llm_request_context_budget( &llm, @@ -106,22 +89,6 @@ pub(in crate::agent) async fn compact_game_creator_agent_runtime_context_at( applied_steer_cursor, )? }; - let snapshot = if planning_agent { - let request_context_fingerprint = - game_creator_agent_runtime_plan_provider_request_context_fingerprint( - &llm, &request, - )?; - let runtime = read_game_creator_agent_runtime_at(root, agent_id)?.state; - let binding = capture_plan_provider_session_binding_for_snapshot( - root, - &runtime, - &snapshot, - &request_context_fingerprint, - )?; - snapshot.with_planning_session_binding(Some(binding)) - } else { - snapshot - }; (snapshot, source, llm, config_path, request) }; let handoff_identity = @@ -188,7 +155,6 @@ pub(in crate::agent) async fn compact_game_creator_agent_runtime_context_at( let request_id = resolve_game_creator_agent_runtime_provider_request_attempt_at_locked( root, &base_request_id, - snapshot.planning_session_binding.is_some(), ) .map(|value| value.0) .unwrap_or(base_request_id); @@ -212,7 +178,6 @@ pub(in crate::agent) async fn compact_game_creator_agent_runtime_context_at( let request_id = resolve_game_creator_agent_runtime_provider_request_attempt_at_locked( root, &base_request_id, - snapshot.planning_session_binding.is_some(), ) .map(|value| value.0) .unwrap_or(base_request_id); @@ -246,7 +211,6 @@ pub(in crate::agent) async fn compact_game_creator_agent_runtime_context_at( let request_id = resolve_game_creator_agent_runtime_provider_request_attempt_at_locked( root, &base_request_id, - snapshot.planning_session_binding.is_some(), ) .map(|value| value.0) .unwrap_or(base_request_id); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/parallel_ledger.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/parallel_ledger.rs index d047701d8..48858c55c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/parallel_ledger.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/parallel_ledger.rs @@ -109,7 +109,6 @@ pub(in crate::agent) fn game_creator_agent_runtime_tool_command_id( "agent.schedule_ready" => Some("agent.schedule_ready"), "agent.action_history" => Some("agent.audit"), "agent.run_status" => Some("agent.run_status"), - PLAN_SUBMIT_GDD_TOOL => Some(PLAN_SUBMIT_GDD_TOOL), _ => None, } } @@ -119,15 +118,7 @@ pub(in crate::agent) fn game_creator_agent_runtime_tool_command_id( /// id (for example `project.search` and `file.read`); policy lookup alone must /// not turn that aliasing into an identity escalation for a restricted Agent. pub(crate) fn agent_runtime_tool_allowed_for_agent(agent_id: &str, tool: &str) -> bool { - if agent_id.trim() == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID { - return matches!( - tool.trim(), - "file.read" | "file.list" | PLAN_SUBMIT_GDD_TOOL - ); - } - if tool.trim() == PLAN_SUBMIT_GDD_TOOL { - return false; - } + let _ = agent_id; if tool.trim() == GAME_CREATOR_USER_INPUT_REQUEST_TOOL { // `user.input_request` is a protocol control handled by the main // loop, not by the command-id policy map. It remains available to @@ -137,95 +128,10 @@ pub(crate) fn agent_runtime_tool_allowed_for_agent(agent_id: &str, tool: &str) - game_creator_agent_runtime_tool_command_id(tool.trim()).is_some() } -/// 身份层面的**显式**拒绝:该 Agent 身份带 exact allowlist,且工具不在其中。 -/// -/// **未知工具名不属于本判据。** `agent_runtime_tool_allowed_for_agent` 对普通 -/// Agent 退化成「这个工具名是否已知」,用它做身份门会把「模型编了个不存在的 -/// 工具」这种普通协议错误误判成身份违规。协议错误的既有语义是:走到执行层产出 -/// 一条 `rejected` observation,run 继续,由下一轮 tool-plan 收束;升级成身份 -/// 拒绝会让整个 run 进 needs-reconciliation 而**不再发出 follow-up 请求**。 -/// -/// 因此凡是「命中即中断 run 或整体拒绝动作」的调用点都必须用本判据,不能直接 -/// 用 `agent_runtime_tool_allowed_for_agent`。 -pub(crate) fn agent_runtime_tool_rejected_by_agent_identity(agent_id: &str, tool: &str) -> bool { - agent_id.trim() == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID - && !agent_runtime_tool_allowed_for_agent(agent_id, tool) -} - #[cfg(test)] mod identity_tests { use super::*; - /// 普通 Agent 编出来的未知工具名是**协议错误**,不是身份违规。 - /// - /// 判成身份违规会让 main_loop 把整个 run 打进 needs-reconciliation、不再发出 - /// follow-up tool-plan——曾导致 `background_agent_runtime_persists_receipts_for_rejected_actions` - /// 在等待第二次 Provider 请求时超时。 - #[test] - fn unknown_tool_on_ordinary_agent_is_not_an_identity_rejection() { - assert!(!agent_runtime_tool_rejected_by_agent_identity( - "design-director", - "runtime.unknown" - )); - assert!(!agent_runtime_tool_rejected_by_agent_identity( - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - "runtime.unknown" - )); - assert!(!agent_runtime_tool_rejected_by_agent_identity( - "design-director", - "file.read" - )); - } - - /// planning 身份仍是 exact allowlist:未知工具与越权工具都算身份拒绝。 - #[test] - fn planning_identity_still_rejects_unknown_and_out_of_scope_tools() { - assert!(agent_runtime_tool_rejected_by_agent_identity( - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - "runtime.unknown" - )); - assert!(agent_runtime_tool_rejected_by_agent_identity( - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - "file.write" - )); - assert!(!agent_runtime_tool_rejected_by_agent_identity( - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - "file.read" - )); - assert!(!agent_runtime_tool_rejected_by_agent_identity( - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - PLAN_SUBMIT_GDD_TOOL - )); - } - - #[test] - fn planning_identity_does_not_inherit_project_search_alias() { - assert!(agent_runtime_tool_allowed_for_agent( - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - "file.read" - )); - assert!(agent_runtime_tool_allowed_for_agent( - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - "file.list" - )); - assert!(!agent_runtime_tool_allowed_for_agent( - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - "project.search" - )); - assert!(agent_runtime_tool_allowed_for_agent( - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - PLAN_SUBMIT_GDD_TOOL - )); - assert!(!agent_runtime_tool_allowed_for_agent( - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - PLAN_SUBMIT_GDD_TOOL - )); - assert!(agent_runtime_tool_allowed_for_agent( - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - "project.search" - )); - } - #[test] fn account_asset_library_uses_the_existing_read_only_asset_permission() { assert_eq!( diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/pending_confirmation_ledger.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/pending_confirmation_ledger.rs index 639bc29d4..430b67f6b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/pending_confirmation_ledger.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/pending_confirmation_ledger.rs @@ -336,33 +336,6 @@ pub(in crate::agent) fn validate_agent_runtime_pending_tool_action_record( let relaxed_autonomous = autonomous_relaxed_run_profile(&pending.run_profile); if !relaxed_autonomous { validate_agent_runtime_pending_goal_binding(pending)?; - match pending.planning_session_binding.as_ref() { - Some(binding) => { - validate_plan_provider_session_binding(binding) - .map_err(|error| error.to_string())?; - if pending.action.tool.trim() != PLAN_SUBMIT_GDD_TOOL - || binding.agent_id != pending.agent_id - || binding.task_id != pending.task_id - || binding.session_id != pending.session_id - || binding.run_id != pending.run_id - || binding.source != pending.source - || binding.run_profile != pending.run_profile - || binding.run_profile_binding_fingerprint - != pending.run_profile_binding_fingerprint - || binding.applied_steer_cursor != pending.planned_steer_cursor - { - return Err( - "planning submit standalone pending 与 frozen binding 不一致".to_string(), - ); - } - } - None if pending.provider_batch_plan_update.is_none() => {} - None => { - return Err( - "非 planning standalone pending 不能携带 Provider batch planUpdate".to_string(), - ); - } - } validate_agent_runtime_project_revision(root, &pending.project_revision_before)?; if pending.verification_gate_before.project_id != game_creator_agent_runtime_context_project_id(root)? diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs index a3eac4093..95b17611d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs @@ -1088,7 +1088,6 @@ pub(in crate::agent) fn agent_runtime_non_verification_completion_blocker_at_loc run_id: &str, ) -> Option { provider_retry_completion_blocker_at_locked(root, agent_id, run_id) - .or_else(|| plan_gdd_completion_blocker_at_locked(root, agent_id, run_id)) .or_else(|| provider_action_batch_completion_blocker_at_locked(root, agent_id, run_id)) .or_else(|| { supervisor_collaboration_policy_completion_blocker_at_locked(root, agent_id, run_id) @@ -1199,32 +1198,36 @@ pub(in crate::agent) fn visual_asset_completion_blocker_at_locked( agent_id: &str, required_run_id: Option<&str>, ) -> Option { - if !editor_api_key_is_configured() { - return None; - } - let (expected_path, expected_kind, label) = match agent_id { - "art-director" => (AGENT_RUNTIME_ART_SPEC_PATH, "icon-spec", "统一视觉规范图"), - "design-foundation" => ("assets/ui-prototype.png", "ui-prototype", "策划界面原型图"), - "art-asset-plan" => ( - "assets/art-spritesheet.png", - "art-spritesheet", - "首版美术素材图", - ), - _ => return None, - }; - let manifest = match read_manifest_for_project(root) { - Ok(manifest) => manifest, - Err(error) => { - return Some(AgentRuntimeToolObservation { - tool: "runtime.visual_asset".to_string(), - status: "blocked".to_string(), - summary: format!("无法核对{label},不能完成任务"), - detail: Some(redact_agent_runtime_project_paths(root, &error, 500)), - }); + // 图片产物由 Codex 按项目需求选择,不再存在固定视觉资产完成门禁。 + return None; + #[allow(unreachable_code)] + { + if !editor_api_key_is_configured() { + return None; } - }; - if let Err(error) = validate_manifest_required_visual_asset(root, &manifest, agent_id) { - return Some(AgentRuntimeToolObservation { + let (expected_path, expected_kind, label) = match agent_id { + "art-director" => (AGENT_RUNTIME_ART_SPEC_PATH, "icon-spec", "统一视觉规范图"), + "design-foundation" => ("assets/ui-prototype.png", "ui-prototype", "策划界面原型图"), + "art-asset-plan" => ( + "assets/art-spritesheet.png", + "art-spritesheet", + "首版美术素材图", + ), + _ => return None, + }; + let manifest = match read_manifest_for_project(root) { + Ok(manifest) => manifest, + Err(error) => { + return Some(AgentRuntimeToolObservation { + tool: "runtime.visual_asset".to_string(), + status: "blocked".to_string(), + summary: format!("无法核对{label},不能完成任务"), + detail: Some(redact_agent_runtime_project_paths(root, &error, 500)), + }); + } + }; + if let Err(error) = validate_manifest_required_visual_asset(root, &manifest, agent_id) { + return Some(AgentRuntimeToolObservation { tool: "runtime.visual_asset".to_string(), status: "blocked".to_string(), summary: format!("{label}尚未按正式视觉流程生成并登记,不能完成任务"), @@ -1234,29 +1237,30 @@ pub(in crate::agent) fn visual_asset_completion_blocker_at_locked( redact_agent_runtime_project_paths(root, &error, 300), )), }); - } - if agent_id != "design-foundation" { - return None; - } - match ui_prototype_visual_inspection_blocker_detail_at_locked( - root, - agent_id, - required_run_id, - expected_path, - ) { - Ok(None) => None, - Ok(Some(detail)) => Some(AgentRuntimeToolObservation { - tool: "runtime.visual_asset".to_string(), - status: "blocked".to_string(), - summary: "策划界面原型图尚未通过结构化 UI 视觉检查,不能完成任务".to_string(), - detail: Some(detail), - }), - Err(error) => Some(AgentRuntimeToolObservation { - tool: "runtime.visual_asset".to_string(), - status: "blocked".to_string(), - summary: "无法核对策划界面原型图的结构化 UI 视觉证据,不能完成任务".to_string(), - detail: Some(redact_agent_runtime_project_paths(root, &error, 500)), - }), + } + if agent_id != "design-foundation" { + return None; + } + match ui_prototype_visual_inspection_blocker_detail_at_locked( + root, + agent_id, + required_run_id, + expected_path, + ) { + Ok(None) => None, + Ok(Some(detail)) => Some(AgentRuntimeToolObservation { + tool: "runtime.visual_asset".to_string(), + status: "blocked".to_string(), + summary: "策划界面原型图尚未通过结构化 UI 视觉检查,不能完成任务".to_string(), + detail: Some(detail), + }), + Err(error) => Some(AgentRuntimeToolObservation { + tool: "runtime.visual_asset".to_string(), + status: "blocked".to_string(), + summary: "无法核对策划界面原型图的结构化 UI 视觉证据,不能完成任务".to_string(), + detail: Some(redact_agent_runtime_project_paths(root, &error, 500)), + }), + } } } @@ -1818,27 +1822,45 @@ const AGENT_RUNTIME_PROJECT_WRITE_LOCK_SHORT_WAIT_ATTEMPTS: usize = 200; /// Take the project write lock, riding out transient contention for at most /// `max_attempts` polls. /// -/// `项目正在被其他写操作占用:` is the one lock error that means -/// "nothing is broken, the current holder is mid-write" — every other variant -/// (a torn lock file, a denied path) is returned immediately. Callers pick the -/// budget from what a lost race costs them: a one-shot user intent waits out the -/// full window, a poll that will run again shortly waits far less. +/// 能不能等由 `ProjectWriteLockFailure` 的**类型**决定,不解析错误文案:只有可重试的 +/// 取锁失败才在这里等,权限拒绝和坏路径立刻返回。判据曾经是 +/// `项目正在被其他写操作占用:` 这个前缀,那等于把"要不要等"绑在中文文案上—— +/// 改一次文案就悄悄改掉一次重试语义。Callers pick the budget from what a lost race +/// costs them: a one-shot user intent waits out the full window, a poll that will run +/// again shortly waits far less. fn acquire_game_creator_agent_runtime_project_write_lock_within( root: &Path, command_id: &str, max_attempts: usize, ) -> Result { let max_attempts = max_attempts.max(1); + let started_at = std::time::Instant::now(); for attempt in 0..max_attempts { - match acquire_project_write_lock(root, command_id) { - Err(error) - if error.starts_with("项目正在被其他写操作占用:") - && attempt + 1 < max_attempts => - { - std::thread::sleep(AGENT_RUNTIME_PROJECT_WRITE_LOCK_RETRY_INTERVAL); + let failure = match acquire_project_write_lock_failure(root, command_id) { + Ok(lock) => return Ok(lock), + Err(failure) if failure.is_retryable() => failure, + Err(failure) => return Err(failure.message()), + }; + if attempt + 1 == max_attempts { + // 等待预算耗尽才记一条:争用本身可能重试上千次,逐次记账会淹掉日志。 + // 这条记录回答的正是 Issue #318 现场缺的问题——"谁在持锁、是不是自己人", + // 以及等满预算之后这到底是争用还是权限拒绝(`projection=`)。 + // 单次试探(max_attempts == 1,例如 hydrate 的 try_acquire_*)根本没有等待: + // 既不写 `wait_exhausted`(waitedMs≈0 会让"耗尽"这个词失去意义,而 hydrate + // 每次状态变化都会撞一次锁,会把它变成噪声),也不做终态改判。 + let waited = max_attempts > 1; + let (projection, message) = failure.exhausted_projection(waited); + if waited { + app_log!( + "project.write_lock.wait_exhausted commandId={command_id} attempts={} waitedMs={} projection={projection} holder={}", + attempt + 1, + started_at.elapsed().as_millis(), + crate::project::project_write_lock_contention_diagnostic(root) + ); } - result => return result, + return Err(message); } + std::thread::sleep(AGENT_RUNTIME_PROJECT_WRITE_LOCK_RETRY_INTERVAL); } unreachable!("project write lock retry loop always returns") } @@ -1869,6 +1891,13 @@ pub(crate) fn acquire_game_creator_agent_runtime_project_write_lock_with_short_w ) } +pub(crate) fn try_acquire_game_creator_agent_runtime_project_write_lock( + root: &Path, + command_id: &str, +) -> Result { + acquire_game_creator_agent_runtime_project_write_lock_within(root, command_id, 1) +} + pub(crate) fn acquire_game_creator_agent_provider_plan_project_write_lock_with_wait( root: &Path, command_id: &str, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_action_batch.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_action_batch.rs index f48ce7884..13c3e4764 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_action_batch.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_action_batch.rs @@ -14,18 +14,6 @@ pub(crate) struct AgentRuntimePendingToolAction { pub(crate) run_profile: String, #[serde(default)] pub(crate) run_profile_binding_fingerprint: String, - /// Planning submit actions carry the exact source-session snapshot that - /// was captured before the Provider response was accepted. Other tools - /// leave this field absent and retain the v1-v3 batch semantics. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) planning_session_binding: Option, - /// The v4 planning batch identity covers the complete Provider plan, - /// including an optional structured plan update. Persist that one - /// batch-only field on the standalone submit anchor as recovery material; - /// otherwise a surviving pending action cannot reproduce the original - /// batch ID after the batch sidecar is lost. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) provider_batch_plan_update: Option, pub(crate) task: String, #[serde(default)] pub(crate) goal_id: Option, @@ -90,8 +78,6 @@ pub(in crate::agent) struct AgentRuntimeParallelReadBatch { pub(crate) struct AgentRuntimeProviderActionBatch { pub(crate) schema_version: String, pub(crate) batch_id: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) provider_request_id: Option, pub(crate) project_id: String, pub(crate) agent_id: String, pub(crate) task_id: String, @@ -102,8 +88,6 @@ pub(crate) struct AgentRuntimeProviderActionBatch { pub(crate) run_profile: String, #[serde(default)] pub(crate) run_profile_binding_fingerprint: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) planning_session_binding: Option, pub(crate) loop_iteration: u32, pub(crate) planned_steer_cursor: u64, pub(crate) status: String, @@ -123,8 +107,6 @@ pub(crate) struct AgentRuntimeProviderActionBatch { struct AgentRuntimeProviderActionBatchWire { schema_version: String, batch_id: String, - #[serde(default)] - provider_request_id: Option, project_id: String, agent_id: String, task_id: String, @@ -135,8 +117,6 @@ struct AgentRuntimeProviderActionBatchWire { run_profile: String, #[serde(default)] run_profile_binding_fingerprint: String, - #[serde(default)] - planning_session_binding: Option, loop_iteration: u32, planned_steer_cursor: u64, status: String, @@ -160,7 +140,6 @@ impl<'de> Deserialize<'de> for AgentRuntimeProviderActionBatch { let batch = Self { schema_version: wire.schema_version, batch_id: wire.batch_id, - provider_request_id: wire.provider_request_id, project_id: wire.project_id, agent_id: wire.agent_id, task_id: wire.task_id, @@ -169,7 +148,6 @@ impl<'de> Deserialize<'de> for AgentRuntimeProviderActionBatch { source: wire.source, run_profile: wire.run_profile, run_profile_binding_fingerprint: wire.run_profile_binding_fingerprint, - planning_session_binding: wire.planning_session_binding, loop_iteration: wire.loop_iteration, planned_steer_cursor: wire.planned_steer_cursor, status: wire.status, @@ -217,7 +195,7 @@ impl AgentRuntimePendingToolAction { pub(in crate::agent) fn tool_plan(&self) -> AgentRuntimeToolPlan { AgentRuntimeToolPlan { thinking_summary: self.thinking_summary.clone(), - plan_update: self.provider_batch_plan_update.clone(), + plan_update: None, plan: self.plan.clone(), actions: Vec::new(), response: self.fallback_response.clone(), @@ -275,8 +253,6 @@ pub(in crate::agent) fn build_game_creator_agent_runtime_pending_tool_action( source: runtime.source.clone(), run_profile: runtime.run_profile.clone(), run_profile_binding_fingerprint: runtime.run_profile_binding_fingerprint.clone(), - planning_session_binding: None, - provider_batch_plan_update: None, task, goal_id: runtime.goal_id.clone(), goal_revision: runtime.goal_revision, @@ -312,110 +288,13 @@ pub(in crate::agent) fn build_game_creator_agent_runtime_pending_tool_action( }) } -/// `plan.submit_gdd` is a transactional planning action rather than an -/// ordinary provider action. It must be represented by one (and only one) -/// durable batch member so that the main loop can establish the action -/// identity before handing control to the planning submit handler. -/// -/// Keep this check at the batch boundary as a second line of defence behind -/// the native-tool parser. In particular, a text/JSON tool-plan or a stale -/// caller must not be able to smuggle a submit action through the historical -/// `< 2 actions => NotNeeded` fast path. -fn validate_plan_submit_gdd_batch_shape_for_identity( - agent_id: &str, - source: &str, - run_profile: &str, - plan: &AgentRuntimeToolPlan, -) -> Result { - let submit_count = plan - .actions - .iter() - .filter(|action| action.tool.trim() == PLAN_SUBMIT_GDD_TOOL) - .count(); - if submit_count == 0 { - return Ok(false); - } - - if agent_id.trim() != GAME_CREATOR_PROJECT_PLANNING_AGENT_ID { - return Err("plan.submit_gdd 只能由 project-planning Agent 调用".to_string()); - } - // The planning child is created through the ordinary delegate path. Keep - // the source/profile check here even though the run-identity binder also - // checks it: this prevents a forged/stale RuntimeState from turning the - // sole-action exception into a generic batch. - if source.trim() != "agent-delegate" || run_profile.trim() != AGENT_RUNTIME_RUN_PROFILE_STANDARD - { - return Err( - "plan.submit_gdd 的 Runtime 身份必须是 source=agent-delegate、runProfile=standard" - .to_string(), - ); - } - if submit_count > 1 { - return Err("plan.submit_gdd 在同一 Provider 响应中只能出现一次".to_string()); - } - if plan.actions.len() != 1 { - return Err("plan.submit_gdd 必须是 Provider 响应中的唯一 action".to_string()); - } - if !plan.response.trim().is_empty() { - return Err("plan.submit_gdd 不得与 respond_to_user 混批".to_string()); - } - Ok(true) -} - -fn validate_plan_submit_gdd_batch_shape( - runtime: &AgentRuntimeState, - plan: &AgentRuntimeToolPlan, -) -> Result { - validate_plan_submit_gdd_batch_shape_for_identity( - &runtime.agent_id, - &runtime.source, - &runtime.run_profile, - plan, - ) -} - -/// Return whether a persisted v4 provider batch is the exact planning submit -/// shape that is allowed to contain one action. The provider-batch ledger -/// uses this narrow predicate when applying its normal two-action minimum; -/// all non-plan batches retain the historical minimum unchanged. -pub(in crate::agent) fn is_plan_submit_gdd_provider_action_batch( - batch: &AgentRuntimeProviderActionBatch, -) -> bool { - batch.schema_version == AGENT_RUNTIME_PLAN_PROVIDER_ACTION_BATCH_SCHEMA_VERSION - && batch.agent_id.trim() == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID - && batch.source.trim() == "agent-delegate" - && batch.run_profile.trim() == AGENT_RUNTIME_RUN_PROFILE_STANDARD - && batch.collaboration_contract.is_none() - && batch.actions.len() == 1 - && batch.plan.actions.len() == 1 - && batch.plan.actions[0].tool.trim() == PLAN_SUBMIT_GDD_TOOL - && batch.actions[0].action.tool.trim() == PLAN_SUBMIT_GDD_TOOL - && batch.plan.response.trim().is_empty() - && batch.actions[0].action == batch.plan.actions[0] - && batch.planning_session_binding.is_some() - && batch.provider_request_id.as_deref() - == batch - .planning_session_binding - .as_ref() - .map(|binding| binding.provider_request_id.as_str()) - && batch.actions[0].planning_session_binding == batch.planning_session_binding -} - fn provider_action_batch_is_not_needed( action_count: usize, force_collaboration_batch: bool, - is_plan_submit: bool, ) -> bool { - action_count < 2 && !force_collaboration_batch && !is_plan_submit + action_count < 2 && !force_collaboration_batch } -/// Backwards-compatible entry point for the historical provider-batch callers. -/// -/// Planning submit batches now need the frozen session binding captured while -/// building the provider request. Callers that do not build a planning -/// request (including the older test/support helpers) retain the old API and -/// therefore pass no binding; the planning path uses the `_with_planning_binding` -/// variant below. pub(crate) async fn prepare_game_creator_agent_runtime_provider_action_batch( root: &Path, runtime: &AgentRuntimeState, @@ -425,34 +304,6 @@ pub(crate) async fn prepare_game_creator_agent_runtime_provider_action_batch( project_revision_before: &AgentRuntimeProjectRevision, planned_repository_context_fingerprint: &str, ) -> Result { - prepare_game_creator_agent_runtime_provider_action_batch_with_planning_binding( - root, - runtime, - task, - plan, - observations, - project_revision_before, - planned_repository_context_fingerprint, - None, - ) - .await -} - -pub(crate) async fn prepare_game_creator_agent_runtime_provider_action_batch_with_planning_binding( - root: &Path, - runtime: &AgentRuntimeState, - task: &str, - plan: &AgentRuntimeToolPlan, - observations: &[AgentRuntimeToolObservation], - project_revision_before: &AgentRuntimeProjectRevision, - planned_repository_context_fingerprint: &str, - captured_planning_session_binding: Option<&PlanProviderSessionBindingV1>, -) -> Result { - // Validate against the complete provider plan before truncating the - // historical action budget. Otherwise a mixed submit batch could hide a - // `plan.submit_gdd` action beyond the truncation boundary and reach the - // generic executor without a durable identity. - let is_plan_submit = validate_plan_submit_gdd_batch_shape(runtime, plan)?; let mut batch_plan = plan.clone(); batch_plan .actions @@ -554,7 +405,6 @@ pub(crate) async fn prepare_game_creator_agent_runtime_provider_action_batch_wit if provider_action_batch_is_not_needed( batch_plan.actions.len(), collaboration_preflight.force_durable_batch, - is_plan_submit, ) { return Ok(AgentRuntimeProviderActionBatchPreparation::NotNeeded); } @@ -577,23 +427,8 @@ pub(crate) async fn prepare_game_creator_agent_runtime_provider_action_batch_wit AGENT_RUNTIME_PENDING_ACTION_STATUS_APPROVED, None, )?; - if action.tool.trim() == PLAN_SUBMIT_GDD_TOOL { - pending.planning_session_binding = captured_planning_session_binding.cloned(); - pending.provider_batch_plan_update = batch_plan.plan_update.clone(); - if pending.planning_session_binding.is_none() { - return Err( - "planning submit action 缺少 Provider 请求前捕获的 session binding".to_string(), - ); - } - } let command_id = game_creator_agent_runtime_tool_command_id(action.tool.trim()); - let identity_block = - agent_runtime_tool_rejected_by_agent_identity(&runtime.agent_id, action.tool.trim()) - .then(|| { - AgentRuntimeToolPolicyBlock::Denied( - "当前 Agent 身份不允许执行该原始工具".to_string(), - ) - }); + let identity_block: Option = None; let art_director_canvas_only_block = agent_runtime_autonomous_art_director_canvas_only_action_block( &runtime.agent_id, @@ -678,71 +513,24 @@ pub(crate) async fn prepare_game_creator_agent_runtime_provider_action_batch_wit } else { AGENT_RUNTIME_PROVIDER_ACTION_BATCH_STATUS_READY }; - let planning_session_binding = if is_plan_submit { - let binding = captured_planning_session_binding - .or_else(|| { - actions - .first() - .and_then(|pending| pending.planning_session_binding.as_ref()) - }) - .ok_or_else(|| "planning submit batch 缺少 frozen session binding".to_string())?; - validate_plan_provider_session_binding_current_at(root, binding)?; - if let Some(pending_binding) = actions - .first() - .and_then(|pending| pending.planning_session_binding.as_ref()) - { - if pending_binding != binding { - return Err( - "planning submit pending 与 captured session binding 不一致".to_string() - ); - } - } - Some(binding.clone()) - } else { - None - }; - let batch_id = if let Some(binding) = planning_session_binding.as_ref() { - agent_runtime_plan_provider_action_batch_id( - &project_id, - &runtime.agent_id, - &runtime.task_id, - &runtime.session_id, - &runtime.run_id, - runtime.loop_iteration, - runtime.applied_steer_cursor, - &batch_plan, - project_revision_before, - planned_repository_context_fingerprint, - &actions, - binding, - )? - } else { - agent_runtime_provider_action_batch_id( - &project_id, - &runtime.agent_id, - &runtime.task_id, - &runtime.session_id, - &runtime.run_id, - runtime.loop_iteration, - runtime.applied_steer_cursor, - &batch_plan, - project_revision_before, - planned_repository_context_fingerprint, - &actions, - collaboration_preflight.contract.as_ref(), - )? - }; + let batch_id = agent_runtime_provider_action_batch_id( + &project_id, + &runtime.agent_id, + &runtime.task_id, + &runtime.session_id, + &runtime.run_id, + runtime.loop_iteration, + runtime.applied_steer_cursor, + &batch_plan, + project_revision_before, + planned_repository_context_fingerprint, + &actions, + collaboration_preflight.contract.as_ref(), + )?; let now = unix_timestamp(); let batch = AgentRuntimeProviderActionBatch { - schema_version: if is_plan_submit { - AGENT_RUNTIME_PLAN_PROVIDER_ACTION_BATCH_SCHEMA_VERSION.to_string() - } else { - AGENT_RUNTIME_PROVIDER_ACTION_BATCH_SCHEMA_VERSION.to_string() - }, + schema_version: AGENT_RUNTIME_PROVIDER_ACTION_BATCH_SCHEMA_VERSION.to_string(), batch_id, - provider_request_id: planning_session_binding - .as_ref() - .map(|binding| binding.provider_request_id.clone()), project_id, agent_id: runtime.agent_id.clone(), task_id: runtime.task_id.clone(), @@ -751,7 +539,6 @@ pub(crate) async fn prepare_game_creator_agent_runtime_provider_action_batch_wit source: runtime.source.clone(), run_profile: runtime.run_profile.clone(), run_profile_binding_fingerprint: runtime.run_profile_binding_fingerprint.clone(), - planning_session_binding, loop_iteration: runtime.loop_iteration, planned_steer_cursor: runtime.applied_steer_cursor, status: status.to_string(), @@ -1228,93 +1015,3 @@ pub(in crate::agent) fn update_game_creator_agent_runtime_provider_batch_paralle } Ok(()) } - -#[cfg(test)] -mod plan_submit_batch_shape_tests { - use super::*; - - fn action(tool: &str) -> AgentRuntimeToolAction { - AgentRuntimeToolAction { - tool: tool.to_string(), - reason: Some("测试动作".to_string()), - input: serde_json::json!({}), - } - } - - fn plan(actions: Vec, response: &str) -> AgentRuntimeToolPlan { - AgentRuntimeToolPlan { - thinking_summary: "测试 plan.submit_gdd 批次形状".to_string(), - plan_update: None, - plan: Vec::new(), - actions, - response: response.to_string(), - } - } - - fn validate(plan: &AgentRuntimeToolPlan) -> Result { - validate_plan_submit_gdd_batch_shape_for_identity( - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - "agent-delegate", - AGENT_RUNTIME_RUN_PROFILE_STANDARD, - plan, - ) - } - - #[test] - fn planning_submit_is_the_only_durable_action_and_allows_plan_control() { - let mut submit = plan(vec![action(PLAN_SUBMIT_GDD_TOOL)], ""); - assert_eq!(validate(&submit), Ok(true)); - - submit.plan_update = Some(AgentRuntimePlanUpdate { - explanation: "同步计划进度".to_string(), - steps: Vec::new(), - }); - assert_eq!(validate(&submit), Ok(true)); - } - - #[test] - fn planning_submit_rejects_mixed_or_duplicate_actions() { - let mixed = plan(vec![action(PLAN_SUBMIT_GDD_TOOL), action("file.read")], ""); - let mixed_error = validate(&mixed).expect_err("submit + file.read must fail closed"); - assert!(mixed_error.contains("唯一 action"), "{mixed_error}"); - - let duplicate = plan( - vec![action(PLAN_SUBMIT_GDD_TOOL), action(PLAN_SUBMIT_GDD_TOOL)], - "", - ); - let duplicate_error = - validate(&duplicate).expect_err("duplicate submit actions must fail closed"); - assert!( - duplicate_error.contains("只能出现一次"), - "{duplicate_error}" - ); - } - - #[test] - fn planning_submit_rejects_final_response_and_wrong_identity() { - let with_response = plan(vec![action(PLAN_SUBMIT_GDD_TOOL)], "不能同时回复"); - let response_error = - validate(&with_response).expect_err("submit + respond_to_user must fail closed"); - assert!( - response_error.contains("respond_to_user"), - "{response_error}" - ); - - let wrong_agent = validate_plan_submit_gdd_batch_shape_for_identity( - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - "project-supervisor-plan", - AGENT_RUNTIME_RUN_PROFILE_STANDARD, - &plan(vec![action(PLAN_SUBMIT_GDD_TOOL)], ""), - ) - .expect_err("non-planning identity must not receive submit exception"); - assert!(wrong_agent.contains("project-planning"), "{wrong_agent}"); - } - - #[test] - fn ordinary_single_action_keeps_not_needed_eligibility() { - assert_eq!(validate(&plan(vec![action("file.read")], "")), Ok(false)); - assert!(provider_action_batch_is_not_needed(1, false, false)); - assert!(!provider_action_batch_is_not_needed(1, false, true)); - assert!(!provider_action_batch_is_not_needed(1, true, false)); - } -} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_batch_ledger.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_batch_ledger.rs index c4a692e45..f2c6710e0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_batch_ledger.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_batch_ledger.rs @@ -110,48 +110,6 @@ pub(in crate::agent) fn agent_runtime_provider_action_batch_id( ) } -#[allow(clippy::too_many_arguments)] -pub(in crate::agent) fn agent_runtime_plan_provider_action_batch_id( - project_id: &str, - agent_id: &str, - task_id: &str, - session_id: &str, - run_id: &str, - loop_iteration: u32, - planned_steer_cursor: u64, - plan: &AgentRuntimeToolPlan, - project_revision_before: &AgentRuntimeProjectRevision, - planned_repository_context_fingerprint: &str, - actions: &[AgentRuntimePendingToolAction], - planning_session_binding: &PlanProviderSessionBindingV1, -) -> Result { - let action_ids = actions - .iter() - .map(|pending| pending.action_id.as_str()) - .collect::>(); - let identity = serde_json::to_vec(&serde_json::json!({ - "schemaVersion": AGENT_RUNTIME_PLAN_PROVIDER_ACTION_BATCH_SCHEMA_VERSION, - "projectId": project_id, - "agentId": agent_id, - "taskId": task_id, - "sessionId": session_id, - "runId": run_id, - "loopIteration": loop_iteration, - "plannedSteerCursor": planned_steer_cursor, - "plan": plan, - "projectRevisionBefore": project_revision_before, - "plannedRepositoryContextFingerprint": planned_repository_context_fingerprint, - "actionIds": action_ids, - "planningSessionBinding": planning_session_binding, - })) - .map_err(|error| format!("序列化 Provider action 批次 v4 身份失败:{error}"))?; - let fingerprint = format!("{:x}", Sha256::digest(identity)); - Ok(format!( - "provider-action-v4-{}", - fingerprint.chars().take(32).collect::() - )) -} - pub(in crate::agent) fn validate_game_creator_agent_runtime_provider_action_batch( root: &Path, batch: &AgentRuntimeProviderActionBatch, @@ -168,8 +126,7 @@ pub(in crate::agent) fn validate_game_creator_agent_runtime_provider_action_batc ) -> Result<(), String> { if !matches!( batch.schema_version.as_str(), - AGENT_RUNTIME_PLAN_PROVIDER_ACTION_BATCH_SCHEMA_VERSION - | AGENT_RUNTIME_PROVIDER_ACTION_BATCH_SCHEMA_VERSION + AGENT_RUNTIME_PROVIDER_ACTION_BATCH_SCHEMA_VERSION | AGENT_RUNTIME_PROVIDER_ACTION_BATCH_PREVIOUS_SCHEMA_VERSION | AGENT_RUNTIME_PROVIDER_ACTION_BATCH_LEGACY_SCHEMA_VERSION ) { @@ -206,48 +163,8 @@ pub(in crate::agent) fn validate_game_creator_agent_runtime_provider_action_batc batch.status )); } - // `plan.submit_gdd` is intentionally a sole-action durable batch. It is - // the only non-collaboration batch allowed to bypass the historical - // two-action minimum; keep the exception tied to the complete identity - // predicate so a forged one-action batch cannot widen the normal path. - let plan_schema = - batch.schema_version == AGENT_RUNTIME_PLAN_PROVIDER_ACTION_BATCH_SCHEMA_VERSION; - let plan_submit_batch = is_plan_submit_gdd_provider_action_batch(batch); - if plan_schema { - if !plan_submit_batch { - return Err( - "planning v4 Provider action 批次必须是唯一 plan.submit_gdd action 且无 collaboration 合同" - .to_string(), - ); - } - let binding = batch - .planning_session_binding - .as_ref() - .ok_or_else(|| "planning v4 Provider action 批次缺少 session binding".to_string())?; - validate_plan_provider_session_binding(binding).map_err(|error| error.to_string())?; - if batch.actions.len() != 1 - || binding.request_kind != "tool-plan" - || batch.actions[0].planning_session_binding.as_ref() != Some(binding) - || batch.provider_request_id.as_deref() != Some(binding.provider_request_id.as_str()) - || batch.project_id != binding.project_id - || batch.agent_id != binding.agent_id - || batch.task_id != binding.task_id - || batch.session_id != binding.session_id - || batch.run_id != binding.run_id - || batch.source != binding.source - || batch.run_profile != binding.run_profile - || batch.run_profile_binding_fingerprint != binding.run_profile_binding_fingerprint - || batch.planned_steer_cursor != binding.applied_steer_cursor - || batch.actions[0].provider_batch_plan_update != batch.plan.plan_update - { - return Err("planning v4 批次成员与 session binding 不一致".to_string()); - } - } else if batch.planning_session_binding.is_some() || batch.provider_request_id.is_some() { - return Err("非 planning v4 批次不能携带 planning session binding".to_string()); - } - let minimum_action_count = if plan_submit_batch { - 1 - } else if batch.schema_version != AGENT_RUNTIME_PROVIDER_ACTION_BATCH_LEGACY_SCHEMA_VERSION + let minimum_action_count = if batch.schema_version + != AGENT_RUNTIME_PROVIDER_ACTION_BATCH_LEGACY_SCHEMA_VERSION && batch.collaboration_contract.is_some() { 1 @@ -281,23 +198,8 @@ pub(in crate::agent) fn validate_game_creator_agent_runtime_provider_action_batc .actions .first() .ok_or_else(|| "Agent Runtime Provider action 批次缺少首个动作".to_string())?; - if plan_schema { - let mut recovered_plan = first_pending.tool_plan(); - recovered_plan.actions = vec![first_pending.action.clone()]; - if recovered_plan != batch.plan { - return Err("planning v4 批次无法从 standalone member 精确重建完整 plan".to_string()); - } - } let mut waiting_confirmation_count = 0_usize; let mut rejected_count = 0_usize; - // planning v4 批次把 plan update 冻结进 standalone member 用于恢复;普通批次的成员则被下方 - // 分支要求不得携带 planning recovery material。期望值必须按批次类型分叉,否则「同一轮里既 - // 调 update_agent_plan 又调工具」的普通批次会同时踩中两条互斥规则。 - let expected_member_plan_update = batch - .planning_session_binding - .is_some() - .then(|| batch.plan.plan_update.clone()) - .flatten(); for (index, pending) in batch.actions.iter().enumerate() { validate_agent_runtime_pending_tool_action_record(root, pending)?; if pending.agent_id != batch.agent_id @@ -312,8 +214,6 @@ pub(in crate::agent) fn validate_game_creator_agent_runtime_provider_action_batc || pending.project_revision_before != batch.project_revision_before || pending.planned_repository_context_fingerprint != batch.planned_repository_context_fingerprint - || pending.planning_session_binding != batch.planning_session_binding - || pending.provider_batch_plan_update != expected_member_plan_update || usize::try_from(pending.action_index).unwrap_or(usize::MAX) != index || pending.action != batch.plan.actions[index] { @@ -342,26 +242,6 @@ pub(in crate::agent) fn validate_game_creator_agent_runtime_provider_action_batc if !action_ids.insert(pending.action_id.clone()) { return Err("Agent Runtime Provider action 批次包含重复 actionId".to_string()); } - if plan_schema { - let binding = batch - .planning_session_binding - .as_ref() - .ok_or_else(|| "planning v4 批次缺少 session binding".to_string())?; - if pending.planning_session_binding.as_ref() != Some(binding) - || pending.action.tool.trim() != PLAN_SUBMIT_GDD_TOOL - || pending.action_id.is_empty() - { - return Err(format!( - "planning v4 批次成员 frozen binding/action identity 不一致:index={index}" - )); - } - } else if pending.planning_session_binding.is_some() - || pending.provider_batch_plan_update.is_some() - { - return Err(format!( - "非 planning Provider action 批次成员不能携带 planning recovery material:index={index}" - )); - } match pending.status.as_str() { AGENT_RUNTIME_PENDING_ACTION_STATUS_PENDING => { if pending.execution_mode != AGENT_RUNTIME_ACTION_EXECUTION_MODE_CONFIRMATION @@ -525,26 +405,6 @@ pub(in crate::agent) fn validate_game_creator_agent_runtime_provider_action_batc batch.collaboration_contract.as_ref(), )? } - AGENT_RUNTIME_PLAN_PROVIDER_ACTION_BATCH_SCHEMA_VERSION => { - let binding = batch - .planning_session_binding - .as_ref() - .ok_or_else(|| "planning v4 批次缺少 session binding".to_string())?; - agent_runtime_plan_provider_action_batch_id( - &batch.project_id, - &batch.agent_id, - &batch.task_id, - &batch.session_id, - &batch.run_id, - batch.loop_iteration, - batch.planned_steer_cursor, - &batch.plan, - &batch.project_revision_before, - &batch.planned_repository_context_fingerprint, - &batch.actions, - binding, - )? - } AGENT_RUNTIME_PROVIDER_ACTION_BATCH_SCHEMA_VERSION => { agent_runtime_provider_action_batch_id( &batch.project_id, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_final_reply.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_final_reply.rs index b61b5c807..dccc07ae8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_final_reply.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_final_reply.rs @@ -116,31 +116,6 @@ pub(in crate::agent) async fn request_game_creator_agent_background_final_reply_ root, "runtime.provider_request.capture.final_reply", )?; - if agent_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID { - // Keep every rebuilt request field on the same budget successor - // that will be exposed by the structured injection and binding. - fold_plan_provider_usage_before_new_request_at_locked(root, Some((agent_id, run_id)))?; - // Freeze the concrete final-reply request and its Provider-facing - // planning injection under the same project lock as the durable - // session binding. This mirrors tool-plan and prevents an older - // message object from being stamped with a newer session primary. - built_request = build_game_creator_agent_background_final_reply_request( - root, - agent_id, - session_id, - run_id, - task, - plan, - observations, - )?; - let wire_bytes = - capture_plan_provider_structured_injections_at(root, session_id, observations)?; - let message = render_plan_provider_structured_injections_message(&wire_bytes)?; - built_request - .2 - .messages - .insert(1, LlmMessage::user(message)); - } let snapshot = capture_game_creator_agent_runtime_provider_request_snapshot_at_locked( root, agent_id, @@ -150,33 +125,8 @@ pub(in crate::agent) async fn request_game_creator_agent_background_final_reply_ request_slot, applied_steer_cursor, )?; - if agent_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID { - let request_context_fingerprint = - game_creator_agent_runtime_plan_provider_request_context_fingerprint( - &built_request.0, - &built_request.2, - )?; - let runtime = read_game_creator_agent_runtime_at(root, agent_id)?.state; - let binding = capture_plan_provider_session_binding_for_snapshot( - root, - &runtime, - &snapshot, - &request_context_fingerprint, - )?; - snapshot.with_planning_session_binding(Some(binding)) - } else { - snapshot - } + snapshot }; - if agent_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID { - estimated_input_tokens = estimate_game_creator_llm_request_tokens(&built_request.2)?; - validate_game_creator_llm_request_context_budget( - &built_request.0, - &built_request.2, - estimated_input_tokens, - "锁内冻结后的 final-reply 请求", - )?; - } let (llm, config_path, request) = built_request; let auto_compact_token_limit = llm.auto_compact_token_limit; let stream_snapshot = provider_snapshot.clone(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs index 398048cce..bfae45d71 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs @@ -3,8 +3,6 @@ use platform_llm::LlmFunctionTool; const AGENT_RUNTIME_COMPLETION_BLOCKER_TOOL_PLAN_PROTOCOL: &str = "通用完成阻断规则:如果最新 observation 的 tool 为 runtime.autonomous_completion 且 status 为 blocked,本轮禁止调用 respond_to_user;必须先读取该 observation.detail 的 nextRequiredAction,并据此调用合适的读取、修复和验证工具。只有完成要求的动作、取得后续可信 observation 且完成门禁不再阻断后,才能给最终回复;不得反复提交 final response,也不得按项目正文硬编码某一种 blocker 的处理方式。"; -const GAME_CREATOR_PROJECT_PLANNING_FINAL_REPLY_SYSTEM_PROMPT: &str = "你是 Genarrative 的立项策划 Agent final-reply 收束器。你只能依据当前请求中明确提供的后台任务、运行中用户追加指令、收束摘要和已获准工具 observation 作答;不得使用通用角色聊天人格,也不得补充这些材料之外的项目事实。没有对应成功 observation 时,不要声称已经写入文件、提交 GDD、获得审批、生成素材、构建或验证完成;不要声称调用了未出现在 observation 中的工具,也不要把建议当成用户确认。若 observation 明确返回 plan.submit_gdd 成功,只能如实报告已提交的 GDD 版本、指纹摘要和待审批状态,不得把提交当成批准。若收束摘要或 observation 中已有 AGC_NEEDS_USER_INPUT_V1 终态信封,必须保留其首行和下一行严格 JSON 问题信封(只去除外围空白),不得改写、翻译、包装成普通中文或追加解释。若当前需要用户决定而尚无完整信封,只能输出 AGC_NEEDS_USER_INPUT_V1 首行,下一行输出 Runtime 可解析的严格 {\"questions\":[...]} JSON;不得输出 markdown、代码围栏或第三行正文。决策卡必须是 A/B/需要原型验证三项合同:B 必须是真实可行的平行方案,不能把用户选择的 B 说成默认建议;用户改口时保留原文并注明被哪一轮推翻。没有用户输入需求时,只简洁总结已观察到的策划结论、confirmed/default_pending/prototype_pending 状态、未完成事项和下一步,明确审批或构建尚未发生。回复保持中文。"; - #[derive(Clone, Copy)] enum AgentBackgroundContextMode { ToolPlan, @@ -196,11 +194,6 @@ fn build_game_creator_agent_background_tool_plan_request_at( ), String, > { - let planning_agent = agent_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID; - if planning_agent { - validate_project_planning_child_binding_at(root, agent_id, run_id)?; - } - let effective_task = autonomous_effective_root_task_at(root, agent_id, run_id, task)?; let (llm, config_path, context, repository_context_fingerprint, prompt_observations) = build_game_creator_background_agent_context( @@ -228,18 +221,6 @@ fn build_game_creator_agent_background_tool_plan_request_at( && binding.parent_run_id.is_none() && agent_runtime_supervisor_source_is_trusted(&binding.source) }); - let plan_root_candidate = root_binding - .as_ref() - .is_some_and(|binding| binding.source == AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE); - let plan_root = if plan_root_candidate { - // Source is the weak discriminator. Once it says "plan", every - // parent/profile/root field must pass the shared strong predicate; - // drift must not silently downgrade to the dynamic Goal Contract. - validate_project_supervisor_plan_root_binding_at(root, agent_id, run_id)?; - true - } else { - false - }; let root_goal_contract_context = render_game_creator_agent_runtime_goal_contract_for_prompt_at(root, agent_id, run_id)? .unwrap_or_else(|| "null".to_string()); @@ -334,9 +315,7 @@ fn build_game_creator_agent_background_tool_plan_request_at( "deniedTools": denied_tools, })) .map_err(|error| format!("序列化 Agent 工具策略失败:{error}"))?; - let collaboration_policy_json = if agent_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID { - "null".to_string() - } else if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { + let collaboration_policy_json = if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { render_supervisor_collaboration_policy_for_prompt_at(root, agent_id, run_id)? } else { "null".to_string() @@ -414,18 +393,7 @@ fn build_game_creator_agent_background_tool_plan_request_at( "{prompt}\n\n补充协议:project.verify 的 script 除 check、typecheck、test、lint、build 外,还可使用 check:、test:(例如 test:unit)、lint:、typecheck:、build:、verify:、validate: 形式的命名脚本;冒号后的每个非空段必须以字母或数字开头且只能包含字母、数字、连字符、下划线或点,并且 script 与 expectedCommand 都必须原样来自项目根 package.json。{command_exec_contract};args 中的项目路径必须相对 cwd,禁止绝对路径、file URI、路径加行号以及把绝对路径嵌入脚本或说明文字。该工具默认需要精确确认,适合运行定向测试、构建检查和只读诊断。durable command.exec observation 会直接返回可复用的 sourceActionId;短 observation 不足以定位失败时,使用 command.output_read {{\"actionId\":\"该 sourceActionId\",\"startLine\":1,\"maxLines\":160}} 分页读取同一 Agent 的已清洗命令输出,并按 nextLine 继续,不要先猜 actionId 或为取得它额外查询动作历史,也不得仅凭输出尾部猜测。只有 cargo check/test/clippy/fmt/build、npm test 或命名为 check/typecheck/test/lint/build/verify/validate 的验证脚本,以及精确 node --test 测试文件可签发验证凭证;git、rg、cargo metadata 和普通 npm run 只作为诊断结果。每次成功执行 file.write、file.patch、file.delete、project.patchset 或 project.restore,以及每次真正启动 command.exec 或 command.start,都会产生新的项目 revision;最后一次修改后必须使用当前角色可用的 project.verify、可验证 command.exec 或 game.static_smoke 完成验证,才能调用 respond_to_user。文件回读不能替代验证,验证后再次修改必须重新验证。每 {AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT} 轮只是一次进度 checkpoint 与停滞检测,不是上下文压缩或 run 的终止上限;只要 observation 出现新的独立进展,就在同一 run 继续下一窗口,只有窗口没有新进展时才按停滞处理。真正的上下文压缩仅由 token 阈值或显式 compact 触发。" ) }; - let prompt = if plan_root && root_goal_contract_required { - format!( - "{prompt}\n\n立项策划 Goal Contract 首轮协议:本轮必须且只能调用一次 agent.goal_contract。outcome、nonNegotiables、forbiddenAssumptions、openQuestions 必须来自你对当前用户意图的理解,preferences 必须提交空数组;acceptanceNodes 不得自定义,必须精确提交单个固定节点 {{\"criterionId\":\"{PLAN_FAST_GDD_ACCEPTANCE_NODE_ID}\",\"criterion\":\"{PLAN_FAST_GDD_ACCEPTANCE_NODE_CRITERION}\",\"required\":true,\"requiredEvidence\":[\"{PLAN_FAST_GDD_ACCEPTANCE_NODE_EVIDENCE}\"],\"dependsOn\":[]}}。", - PLAN_FAST_GDD_ACCEPTANCE_NODE_ID = PLAN_FAST_GDD_ACCEPTANCE_NODE_ID, - PLAN_FAST_GDD_ACCEPTANCE_NODE_CRITERION = PLAN_FAST_GDD_ACCEPTANCE_NODE_CRITERION, - PLAN_FAST_GDD_ACCEPTANCE_NODE_EVIDENCE = PLAN_FAST_GDD_ACCEPTANCE_NODE_EVIDENCE, - ) - } else if plan_root { - format!( - "{prompt}\n\n立项策划 Goal Contract 已冻结且不可重写。先完成 project-planning 委派;收到其 Fast GDD 提交后,由当前 Supervisor 根 Run 从 startLine=1 开始分页、无缺口且无重叠地读取 game/fast_gdd.md 直到 EOF,每次都用 maxLines=240(上限)以尽量一页读完、少分页,各页必须来自同一内容 SHA-256,并在 agent.acceptance_update.evidence 中逐条提交全部分页 file.read 的 {{agentId, runId, actionId}}——每次 file.read 的 observation 末尾都带着自己的 sourceAgentId / sourceRunId / sourceActionId,三个字段原样照抄,不要自己回忆 agentId 或 runId,也不要为取得它们额外查询动作历史。取证未通过时不得展示或创建审批卡,只能针对原策划 delivery 返工。" - ) - } else if root_control_authority { + let prompt = if root_control_authority { format!( "{prompt}\n\n动态目标协议:agent.goal_contract 只允许当前可信根 Project Supervisor 调用,input 使用 {{\"outcome\":\"最终交付\",\"nonNegotiables\":[],\"preferences\":[],\"forbiddenAssumptions\":[],\"openQuestions\":[],\"acceptanceNodes\":[{{\"criterionId\":\"稳定短 ID\",\"criterion\":\"可核对标准\",\"required\":true,\"requiredEvidence\":[\"tool:project.verify\"],\"dependsOn\":[]}}]}}。该合同必须来自你对当前用户意图的理解;固定规则、关键词、资产探测和专家建议只能作为上下文,不能替你决定目标、工作流或实现方案。agent.acceptance_update 也只允许同一根 Supervisor 调用,input 使用 {{\"contractFingerprint\":\"当前合同指纹\",\"evaluations\":[{{\"criterionId\":\"节点 ID\",\"status\":\"passed|failed|not-observed\",\"evidence\":[{{\"agentId\":\"证据生产者\",\"runId\":\"证据 run\",\"actionId\":\"持久成功动作\"}}],\"summary\":\"结论\"}}]}}。只提交本轮实际重新验收的节点;未提交的 passed 节点保持不变。" ) @@ -453,71 +421,10 @@ fn build_game_creator_agent_background_tool_plan_request_at( "{prompt}\n\n持久进程协议:{command_start_contract};args 中的项目路径必须相对 cwd,禁止绝对路径、file URI、路径加行号以及把绝对路径嵌入脚本或说明文字。默认需要精确确认;它只用于已经从仓库清单确认需要持续交互的长进程,有限诊断、文件探测、构建和测试必须使用 command.exec,不得用 command.start 试错。成功后保存 observation 返回的 processId 和 cursor;同一服务后续只能沿该 processId 继续,不得为探测、重试、交互或停止另起 process session。command.poll 使用 {{\"processId\":\"proc-...\",\"cursor\":null,\"maxChars\":8000,\"waitMs\":1000}};首次调用必须显式传 cursor=null,后续把上一页 nextCursor 原样传入 cursor,并按 nextCursor 增量读取,不要无等待忙轮询。command.stdin 使用 {{\"processId\":\"proc-...\",\"data\":\"UTF-8 文本\",\"appendNewline\":true,\"eof\":false}},正文会写入 PTY 且默认需要确认;command.terminate 使用 {{\"processId\":\"proc-...\",\"cursor\":\"最后一次 poll 的 nextCursor\"}} 并默认需要确认,terminate 不消费输出,后续继续用它返回的同一 nextCursor poll 终态。command.start 会推进 revision 但永远不能签发验证凭证;当前 run 的进程会话必须 poll 到可信终态,或先 terminate 再 poll,才能调用 respond_to_user 收束;needs-reconciliation 只能等待人工核对,不能重启、按 PID 重连或假装已退出。" ); let api_kind = parse_game_creator_llm_api_kind(&llm.api_kind)?; - let protocol_prompt = if planning_agent { - "必须直接调用当前请求提供的原生函数:需要更新持久计划时调用 update_agent_plan,成稿时调用 plan.submit_gdd(input 严格为 plan-submit-gdd-input.v1,只提交 game、decisions、prototypeValidationItems;不得附加 Runtime 身份、版本、时间、平台事实或 fingerprint),已有观察足够或需要交付终态信封时调用 respond_to_user。plan.submit_gdd 必须是本轮唯一 action,可与 update_agent_plan 同响应,但不能与其它动作或 respond_to_user 混合。不要调用未广告的函数,也不要把计划、动作或回复放在普通文本中。" - .to_string() - } else { - format!( + let protocol_prompt = format!( "必须直接调用当前请求提供的原生函数:需要更新持久计划时调用 update_agent_plan,需要行动时调用对应动作工具,已有观察足够时调用 respond_to_user。只有步骤或状态真实变化时才单独调用 update_agent_plan;当前 in_progress 步骤已具备执行条件时必须在同一响应调用对应动作工具,不能只改计划解释。不要调用未广告的旧 submit_agent_tool_plan,也不要把计划或动作放在普通文本中。\n\n{AGENT_RUNTIME_COMPLETION_BLOCKER_TOOL_PLAN_PROTOCOL}" - ) - }; - let prompt = if planning_agent { - format!( - "当前 planning 子 Agent 只可调用 file.read、file.list、plan.submit_gdd、update_agent_plan、respond_to_user;未广告的函数一律不可调用。读取工具只用于获取已有项目文本和文件摘要,不代表已经写入、提交、审批或构建完成。成稿时 plan.submit_gdd 的 input 必须严格符合 plan-submit-gdd-input.v1,只提交 game、decisions、prototypeValidationItems;Runtime 会注入平台事实、身份、版本、时间和 fingerprint。plan.submit_gdd 必须是本轮唯一 action,可与 update_agent_plan 同响应,但不能与其它动作或 respond_to_user 混合。\n\n运行上下文如下。只能依据已获准工具返回的 observation 使用;未出现在 observation 里的项目事实不得自行假设。\n\n{context}\n\n后台任务:\n{effective_task}\n\n运行中用户追加指令:\n{steers_json}\n\n已有工具观察:\n{observations_json}\n\nfile.list 使用 {{\"path\":\"\"}};file.read 使用 {{\"path\":\"项目内相对路径\",\"startLine\":1,\"maxLines\":120}}。arguments 外层严格为 {{\"reason\":\"为什么需要\",\"input\":{{...}}}}。不要输出普通文本来代替函数调用。" - ) - } else { - prompt - }; - if planning_agent { - // project-planning 分支在 game_creator_agent_runtime_tool_plan_system_prompt_for_agent - // 内部按 agent_id 提前返回固定的 planning 合同,不看 source,这里传空串即可。 - let mut planning_system_prompt = - game_creator_agent_runtime_tool_plan_system_prompt_for_agent(agent_id, ""); - let role_brief = game_creator_agent_runtime_role_overlay_prompt(agent_id, None); - if !role_brief.is_empty() { - planning_system_prompt.push_str("\n\n"); - planning_system_prompt.push_str(&role_brief); - } - let request = LlmRunRequest::new(vec![ - LlmMessage::system(planning_system_prompt), - LlmMessage::user(prompt), - LlmMessage::user(protocol_prompt), - ]) - .with_api_kind(api_kind) - .with_max_output_tokens(AGENT_RUNTIME_TOOL_PLAN_MAX_OUTPUT_TOKENS) - .with_response_text_verbosity(platform_llm::LlmResponseTextVerbosity::Low) - .with_function_tools(build_agent_runtime_native_function_tools_for_agent( - agent_id, - )?) - .with_tool_choice(platform_llm::LlmToolChoice::Required); - let request = - apply_game_creator_llm_reasoning_effort(request, &llm)?.with_web_search(false); - return Ok(( - llm, - config_path, - request, - repository_context_fingerprint, - // 策划子 Agent 不参与自主构建 manifest DAG。 - AgentRuntimeToolPlanRequestSnapshot { - supervisor_manifest_dag_in_progress: false, - }, - )); - } - // M1A-4:Supervisor 的 plan 根 run 需要在合成 system prompt 时收窄 - // supervisorIntro / $visualContract 两段(见 prompt.rs),其余角色的 prompt - // 不看 source,这里读取当前 run 自身的 binding 只是为了拿它的 source 字段; - // 缺失 binding 时按空串处理,等价于既有(非 plan)行为,不改变现有输出。 - let supervisor_prompt_source = if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { - read_game_creator_agent_runtime_run_profile_binding(root, agent_id, run_id)? - .map(|binding| binding.source) - .unwrap_or_default() - } else { - String::new() - }; - let mut system_prompt = game_creator_agent_runtime_tool_plan_system_prompt_for_agent( - agent_id, - &supervisor_prompt_source, ); + let mut system_prompt = game_creator_agent_runtime_tool_plan_system_prompt_for_agent(agent_id); if autonomous_game_build { system_prompt.push_str("\n\n"); system_prompt.push_str(required_runtime_prompt_section( @@ -576,22 +483,6 @@ fn build_game_creator_agent_background_tool_plan_request_at( agent_id, )?) .with_tool_choice(platform_llm::LlmToolChoice::Required); - if plan_root { - let stage = match project_lock { - Some(project_lock) => { - plan_root_supervisor_stage_at_locked(root, project_lock, agent_id, run_id)? - } - None => plan_root_supervisor_stage_at(root, agent_id, run_id)?, - }; - retain_plan_root_supervisor_native_tools(&mut request.function_tools, stage)?; - // 固定单节点 schema 只对还在广告 agent.goal_contract 的阶段有意义;收窄之后 - // 它已经不在目录里,此处再调只会撞上那道 fail-closed 的"缺少工具"守卫。 - if agent_runtime_plan_root_supervisor_tools_for_stage(stage) - .contains(&"agent.goal_contract") - { - restrict_plan_root_goal_contract_schema(&mut request.function_tools)?; - } - } if runtime_owner_artifact_validation_available { remove_autonomous_owner_manual_verification_tools(&mut request.function_tools)?; } @@ -690,23 +581,13 @@ fn build_game_creator_agent_background_tool_plan_request_at( } if root_goal_contract_required { restrict_agent_runtime_root_goal_contract_tools(&mut request)?; - let goal_contract_instruction = if plan_root { - format!( - "当前 plan 根 Run 尚未冻结 Goal Contract。本轮唯一可用工具是 agent.goal_contract;必须且只能调用一次。outcome 具体概括当前用户最终意图,nonNegotiables、forbiddenAssumptions、openQuestions 没有内容时传空数组,preferences 必须始终传空数组;acceptanceNodes 必须精确提交固定单节点 {{\"criterionId\":\"{PLAN_FAST_GDD_ACCEPTANCE_NODE_ID}\",\"criterion\":\"{PLAN_FAST_GDD_ACCEPTANCE_NODE_CRITERION}\",\"required\":true,\"requiredEvidence\":[\"{PLAN_FAST_GDD_ACCEPTANCE_NODE_EVIDENCE}\"],\"dependsOn\":[]}}。不得调用 update_agent_plan、respond_to_user 或任何其他动作,不得输出普通文本。" - ) - } else { - "当前根 Run 尚未冻结 Goal Contract。本轮唯一可用工具是 agent.goal_contract;必须且只能调用一次,用 outcome 具体概括当前用户最终意图,acceptanceNodes 至少提交一项可核对标准。每个 requiredEvidence 必须选择在该标准所有合法结果下都能成功产生回执的工具;环境探测可能以 rejected/failed 表示正常否定结果时,不得把该探测工具写成必需成功回执(例如非 Git 项目不得要求 git.inspect 成功,应使用 project.index 的成功回执证明 isRepository=false)。nonNegotiables、preferences、forbiddenAssumptions、openQuestions 没有内容时传空数组。不得调用 update_agent_plan、respond_to_user 或任何其他动作,不得输出普通文本。".to_string() - }; + let goal_contract_instruction = "当前根 Run 尚未冻结 Goal Contract。本轮唯一可用工具是 agent.goal_contract;必须且只能调用一次,用 outcome 具体概括当前用户最终意图,acceptanceNodes 至少提交一项可核对标准。每个 requiredEvidence 必须选择在该标准所有合法结果下都能成功产生回执的工具;环境探测可能以 rejected/failed 表示正常否定结果时,不得把该探测工具写成必需成功回执(例如非 Git 项目不得要求 git.inspect 成功,应使用 project.index 的成功回执证明 isRepository=false)。nonNegotiables、preferences、forbiddenAssumptions、openQuestions 没有内容时传空数组。不得调用 update_agent_plan、respond_to_user 或任何其他动作,不得输出普通文本。".to_string(); request .messages .push(LlmMessage::user(goal_contract_instruction)); } let mut request = apply_game_creator_llm_reasoning_effort(request, &llm)?; - request = if agent_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID { - request.with_web_search(false) - } else { - apply_game_creator_llm_web_search(request, &llm, true)? - }; + let request = apply_game_creator_llm_web_search(request, &llm, true)?; Ok(( llm, config_path, @@ -779,27 +660,15 @@ pub(in crate::agent) fn build_game_creator_agent_background_final_reply_request( .map_err(|error| format!("序列化 Agent 收束摘要失败:{error}"))?; let steers_json = render_game_creator_agent_runtime_steers_for_prompt(root, agent_id, session_id, run_id)?; - let planning_agent = agent_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID; - if planning_agent { - validate_project_planning_child_binding_at(root, agent_id, run_id)?; - } let audience = if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { "用户" } else { "开发者" }; - let prompt = if planning_agent { - format!( - "当前是 project-planning 子 Agent 的 final-reply 收束请求。只依据下列后台任务、运行中用户追加指令、收束摘要和已获准工具 observation。若收束摘要或 observation 已包含 AGC_NEEDS_USER_INPUT_V1 信封,逐字保留其首行与下一行严格 JSON;不要改写问题,不要输出普通解释。若没有完整信封且仍缺少用户决定,只输出可解析的 AGC_NEEDS_USER_INPUT_V1 信封;否则只总结已观察到的策划结论和未完成事项。\n\n运行上下文:\n{context}\n\n后台任务:\n{effective_task}\n\n运行中用户追加指令:\n{steers_json}\n\n收束摘要:\n{plan_json}\n\n工具观察:\n{observations_json}" - ) - } else { - format!( - "运行上下文如下。请只依据后台任务、运行中用户追加指令、收束摘要和已获准工具返回的 observation,给{audience}一个正常中文回复。不要输出 JSON,不要假装执行未执行的工具,也不要补充 observation 中不存在的项目事实。\n\n{context}\n\n后台任务:\n{effective_task}\n\n运行中用户追加指令:\n{steers_json}\n\n收束摘要:\n{plan_json}\n\n工具观察:\n{observations_json}" - ) - }; - let system_prompt = if planning_agent { - GAME_CREATOR_PROJECT_PLANNING_FINAL_REPLY_SYSTEM_PROMPT - } else if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { + let prompt = format!( + "运行上下文如下。请只依据后台任务、运行中用户追加指令、收束摘要和已获准工具返回的 observation,给{audience}一个正常中文回复。不要输出 JSON,不要假装执行未执行的工具,也不要补充 observation 中不存在的项目事实。\n\n{context}\n\n后台任务:\n{effective_task}\n\n运行中用户追加指令:\n{steers_json}\n\n收束摘要:\n{plan_json}\n\n工具观察:\n{observations_json}" + ); + let system_prompt = if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { game_creator_project_supervisor_chat_system_prompt() } else { game_creator_role_agent_chat_system_prompt() @@ -814,11 +683,6 @@ pub(in crate::agent) fn build_game_creator_agent_background_final_reply_request( .with_response_text_verbosity(platform_llm::LlmResponseTextVerbosity::Low), &llm, )?; - let request = if planning_agent { - request.with_web_search(false) - } else { - request - }; Ok((llm, config_path, request)) } @@ -958,15 +822,10 @@ mod tests { AgentRuntimeTaskLink, AgentRuntimeToolObservation, AgentRuntimeToolPlan, AgentRuntimeToolPlanRequestSnapshot, GameCreatorLlmConfig, AGENT_RUNTIME_AUTONOMOUS_PRE_MUTATION_LOOP_LIMIT, - AGENT_RUNTIME_COMPLETION_BLOCKER_TOOL_PLAN_PROTOCOL, - AGENT_RUNTIME_PLAN_AUTONOMOUS_PROFILE_UNSUPPORTED_KIND, - AGENT_RUNTIME_RESPOND_FUNCTION_NAME, AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD, - AGENT_RUNTIME_RUN_PROFILE_STANDARD, AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, - AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE, - AGENT_RUNTIME_UPDATE_PLAN_FUNCTION_NAME, GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - GAME_CREATOR_PROJECT_PLANNING_FINAL_REPLY_SYSTEM_PROMPT, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, PLAN_FAST_GDD_ACCEPTANCE_NODE_CRITERION, - PLAN_FAST_GDD_ACCEPTANCE_NODE_EVIDENCE, PLAN_FAST_GDD_ACCEPTANCE_NODE_ID, + AGENT_RUNTIME_COMPLETION_BLOCKER_TOOL_PLAN_PROTOCOL, AGENT_RUNTIME_RESPOND_FUNCTION_NAME, + AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD, AGENT_RUNTIME_RUN_PROFILE_STANDARD, + AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, + AGENT_RUNTIME_UPDATE_PLAN_FUNCTION_NAME, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, RUNTIME_PROMPT_SUPERVISOR_CHAT_COMPOSITION, }; @@ -1571,558 +1430,6 @@ mod tests { assert!(!prompt.contains("本轮唯一可用工具是 agent.goal_contract")); } - #[test] - fn plan_root_goal_contract_prompt_and_schema_change_after_the_first_turn() { - let directory = crate::tests::canonical_test_tempdir("provider-plan-goal-control-"); - let root = directory.path().join("project"); - init_local_game_project_at(&root, "plan-goal-control-project", "形成 Fast GDD") - .expect("project init"); - let binding = bind_game_creator_agent_runtime_run_profile_at( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - "provider-plan-goal-control-root", - AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE, - Some(AGENT_RUNTIME_RUN_PROFILE_STANDARD), - None, - ) - .expect("bind plan root"); - let state = start_game_creator_agent_runtime_task_at( - &root, - &binding.agent_id, - "形成 Fast GDD", - &binding.run_id, - &binding.source, - "冻结立项目标", - vec!["冻结 Goal Contract".to_string()], - ) - .expect("start plan root"); - - let (_, _, first, _, _) = build_game_creator_agent_background_tool_plan_request_for_test( - &root, - &state.agent_id, - &state.session_id, - &state.run_id, - &state.current_task, - &[], - 0, - ) - .expect("build first plan root request"); - let goal_function = - crate::agent_native_tools::native_runtime_function_name("agent.goal_contract") - .expect("goal function name"); - let fixed_schema = first - .function_tools - .iter() - .find(|function| function.name == goal_function) - .expect("plan goal function") - .parameters - .pointer("/properties/input/properties/acceptanceNodes") - .expect("fixed acceptance schema"); - let fixed_preferences = first - .function_tools - .iter() - .find(|function| function.name == goal_function) - .expect("plan goal function") - .parameters - .pointer("/properties/input/properties/preferences/maxItems") - .expect("fixed preferences schema"); - assert_eq!(first.function_tools.len(), 1); - assert_eq!(fixed_schema["maxItems"], serde_json::json!(1)); - assert_eq!(fixed_preferences, &serde_json::json!(0)); - assert_eq!( - fixed_schema["items"]["properties"]["criterionId"]["enum"], - serde_json::json!([PLAN_FAST_GDD_ACCEPTANCE_NODE_ID]) - ); - let first_prompt = first - .messages - .iter() - .map(|message| message.content.as_str()) - .collect::>() - .join("\n"); - assert!(first_prompt.contains("Goal Contract 首轮协议")); - assert!(!first_prompt.contains("acceptanceNodes 至少提交一项")); - assert!(!first_prompt.contains("project.index 的成功回执")); - - crate::agent::create_game_creator_agent_runtime_goal_contract_at( - &root, - &state.agent_id, - &state.run_id, - &state.current_task, - &AgentRuntimeGoalContractDraft { - outcome: "形成服务用户意图的 Fast GDD".to_string(), - non_negotiables: Vec::new(), - preferences: Vec::new(), - forbidden_assumptions: Vec::new(), - open_questions: Vec::new(), - acceptance_nodes: vec![AgentRuntimeGoalContractAcceptanceNodeDraft { - criterion_id: PLAN_FAST_GDD_ACCEPTANCE_NODE_ID.to_string(), - criterion: PLAN_FAST_GDD_ACCEPTANCE_NODE_CRITERION.to_string(), - required: true, - required_evidence: vec![PLAN_FAST_GDD_ACCEPTANCE_NODE_EVIDENCE.to_string()], - dependencies: Vec::new(), - }], - }, - ) - .expect("freeze plan contract"); - let (_, _, later, _, _) = build_game_creator_agent_background_tool_plan_request_for_test( - &root, - &state.agent_id, - &state.session_id, - &state.run_id, - &state.current_task, - &[], - 1, - ) - .expect("build later plan root request"); - let later_prompt = later - .messages - .iter() - .map(|message| message.content.as_str()) - .collect::>() - .join("\n"); - assert!(later.function_tools.len() > 1); - assert!(later_prompt.contains("Goal Contract 已冻结且不可重写")); - assert!(later_prompt.contains("从 startLine=1 开始分页")); - assert!(later_prompt.contains("直到 EOF")); - assert!(later_prompt.contains("全部分页 file.read 的 {agentId, runId, actionId}")); - // evidence 是三元组,不是一个 actionId;指令必须点名另外两个字段从哪来。 - assert!(later_prompt.contains("sourceAgentId / sourceRunId / sourceActionId")); - assert!(!later_prompt.contains("Goal Contract 首轮协议")); - } - - #[test] - fn plan_source_profile_drift_cannot_fall_back_to_dynamic_goal_contract() { - let directory = crate::tests::canonical_test_tempdir("provider-plan-profile-drift-"); - let root = directory.path().join("project"); - init_local_game_project_at(&root, "plan-profile-drift", "形成 Fast GDD") - .expect("project init"); - let error = bind_game_creator_agent_runtime_run_profile_at( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - "provider-plan-profile-drift-root", - AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE, - Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), - None, - ) - .expect_err("plan source/profile drift must be rejected before it becomes durable"); - assert!(error.contains(AGENT_RUNTIME_PLAN_AUTONOMOUS_PROFILE_UNSUPPORTED_KIND)); - assert!( - !game_creator_agent_runtime_run_profile_binding_path( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - "provider-plan-profile-drift-root", - ) - .exists(), - "非法 plan/autonomous binding 不得落盘" - ); - } - - #[test] - fn provider_request_source_does_not_patch_natural_language_with_replace_chains() { - let source = include_str!("provider_request_builders.rs"); - let forbidden = [".", "replace", "("].concat(); - - assert!(!source.contains(&forbidden)); - } - - #[test] - fn planning_request_discloses_preloaded_context_truthfully() { - const SUPERVISOR_NOTICE: &str = "下方已预加载有界仓库启动上下文、Supervisor 当前 Session、legacy 项目对话、项目记忆、黑板和资产摘要;源码正文仍只能通过已获准工具读取"; - const ORDINARY_NOTICE: &str = "除下方有界仓库启动上下文、当前 Session 未压缩对话尾部或历史压缩摘要外,项目记忆、资产和源码正文不会预加载"; - const MEMORY_MARKER: &str = "supervisor-preloaded-context-marker"; - - let directory = crate::tests::canonical_test_tempdir("provider-request-project-"); - let root = directory.path().join("project"); - init_local_game_project_at(&root, "project-1", "项目总控预加载说明测试") - .expect("project init"); - std::fs::write(root.join("memory/session.md"), MEMORY_MARKER) - .expect("write session memory"); - let _config_guard = crate::tests::write_test_local_config("{}".to_string()); - - let supervisor_state = start_game_creator_agent_runtime_task_at( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - "核对上下文预加载说明", - "supervisor-context-preload-notice-run", - "agent-background-task", - "构建 planning request", - vec!["核对预加载说明".to_string()], - ) - .expect("start supervisor runtime state"); - let (_, _, supervisor_request, _, _) = - build_game_creator_agent_background_tool_plan_request_for_test( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &supervisor_state.session_id, - &supervisor_state.run_id, - &supervisor_state.current_task, - &[], - 0, - ) - .expect("build supervisor planning request"); - let supervisor_system_prompt = &supervisor_request.messages[0].content; - let supervisor_prompt = &supervisor_request.messages[1].content; - let supervisor_identity_contract = - required_runtime_prompt_section(RUNTIME_PROMPT_SUPERVISOR_CHAT_COMPOSITION[0]); - assert_eq!( - supervisor_system_prompt - .matches(supervisor_identity_contract) - .count(), - 1 - ); - assert!(!supervisor_prompt.contains(supervisor_identity_contract)); - assert!(supervisor_system_prompt.contains("总控不能替代已有专业角色")); - assert!(!supervisor_system_prompt.contains("多智能体 Runtime 中的专业 Agent")); - assert!(!supervisor_system_prompt.contains("你拥有最终回复权")); - assert!(supervisor_prompt.contains(SUPERVISOR_NOTICE)); - assert!(!supervisor_prompt.contains("你拥有最终回复权")); - assert!(!supervisor_prompt.contains(ORDINARY_NOTICE)); - assert!(supervisor_prompt.contains(MEMORY_MARKER)); - assert!(!supervisor_prompt.contains("\"advisoryOnly\": true")); - assert!(supervisor_prompt.contains("必须调用 respond_to_user 交付最终回复")); - assert!(supervisor_prompt.contains("project.patchset 会自动创建 checkpoint")); - assert!(supervisor_prompt.contains("\"replaceExisting\":false")); - assert!(supervisor_prompt.contains("\"assetKind\":null")); - assert!(supervisor_prompt.contains(&format!( - "assetKind 只允许 {}", - crate::agent::AGENT_RUNTIME_CANVAS_ASSET_KINDS.join("|") - ))); - assert!(supervisor_prompt - .contains("通过配置的 External Editor API 同时写入画布、同名素材库目录和本地 assets")); - assert!(supervisor_prompt.contains("\"repairOfDelegationId\":null,\"runId\":null")); - assert!(supervisor_prompt.contains( - "agent.run_status 使用 {\"agentId\":null,\"scope\":\"all\",\"delegationId\":null}" - )); - assert!(supervisor_prompt.contains("task.create 使用 {\"taskId\":null")); - assert!(supervisor_prompt - .contains("image.inspect 使用 {\"paths\":[\"项目内图片路径\"],\"question\":null}")); - assert!(supervisor_prompt.contains("\"expectedText\":[]")); - assert!(supervisor_prompt.contains( - "create 使用 {\"operation\":\"create\",\"path\":\"项目内相对文件\",\"content\":\"完整内容\",\"expectedSha256\":null,\"oldText\":null,\"newText\":null,\"expectedReplacements\":null}" - )); - assert!(supervisor_prompt.contains( - "update 使用 {\"operation\":\"update\",\"path\":\"项目内相对文件\",\"content\":null,\"expectedSha256\":\"file.read 返回的 SHA-256\",\"oldText\":\"精确原文\",\"newText\":\"替换后文本\",\"expectedReplacements\":1}" - )); - assert!(supervisor_prompt.contains( - "delete 使用 {\"operation\":\"delete\",\"path\":\"项目内相对文件\",\"content\":null,\"expectedSha256\":\"file.read 返回的 SHA-256\",\"oldText\":null,\"newText\":null,\"expectedReplacements\":null}" - )); - assert!(supervisor_prompt.contains(provider_command_exec_contract())); - assert!(supervisor_prompt.contains(provider_command_start_contract())); - assert!(!supervisor_prompt.contains("空 actions")); - assert!(!supervisor_prompt.contains("thinkingSummary")); - assert!(!supervisor_prompt.contains("planUpdate")); - assert!(!supervisor_prompt.contains("其他工具 input 可为空")); - assert!(!supervisor_prompt.contains( - "agent.delegate 使用 {\"agentId\":\"目标 taskId\",\"task\":\"要委派的后台任务\",\"runId\":\"可选 run id\"}" - )); - assert!(!supervisor_prompt.contains("\"scope\":\"self|all\"}")); - assert!(!supervisor_prompt.contains("\"assetLabel\":\"素材展示名或null\"}")); - assert_eq!( - supervisor_prompt - .matches("canvas.asset_generate 使用") - .count(), - 1 - ); - assert_eq!(supervisor_prompt.matches("agent.delegate 使用").count(), 1); - assert_eq!( - supervisor_prompt - .matches("agent.spawn_isolated 使用") - .count(), - 1 - ); - assert_eq!( - supervisor_prompt.matches("agent.run_status 使用").count(), - 1 - ); - assert_eq!(supervisor_prompt.matches("command.exec 使用").count(), 1); - assert_eq!(supervisor_prompt.matches("command.start 使用").count(), 1); - assert!(supervisor_prompt.contains("agent.schedule_ready 使用 {\"limit\":1}")); - assert!(!supervisor_prompt.contains("agent.schedule_ready input 可为空")); - assert!(supervisor_prompt.contains( - "agent.action_history 使用 {\"runId\":null,\"actionId\":null,\"tool\":null,\"status\":null,\"limit\":5}" - )); - assert!(supervisor_prompt.contains( - "command.poll 使用 {\"processId\":\"proc-...\",\"cursor\":null,\"maxChars\":8000,\"waitMs\":1000}" - )); - assert!(!supervisor_prompt.contains("可首次省略")); - assert_eq!( - native_input_required_fields(&supervisor_request, "agent.schedule_ready"), - ["limit"] - ); - assert_eq!( - native_input_required_fields(&supervisor_request, "agent.action_history"), - ["runId", "actionId", "tool", "status", "limit"] - ); - assert_eq!( - native_input_required_fields(&supervisor_request, "command.poll"), - ["processId", "cursor", "maxChars", "waitMs"] - ); - assert_eq!( - native_input_required_fields(&supervisor_request, "task.create"), - [ - "taskId", - "title", - "group", - "role", - "dependencies", - "artifacts", - "acceptanceCriteria", - "status" - ] - ); - assert_eq!( - native_input_required_fields(&supervisor_request, "agent.run_status"), - ["agentId", "scope", "delegationId"] - ); - assert_eq!( - native_input_required_fields(&supervisor_request, "image.inspect"), - ["paths", "question"] - ); - #[cfg(target_os = "linux")] - { - assert!(!supervisor_prompt - .contains("command.exec 使用 {\"program\":\"cargo|npm|node|git|rg\"")); - assert!(provider_command_start_contract().contains("workspace-write")); - assert!(provider_command_start_contract().contains("network-disabled")); - } - - let ordinary_state = start_game_creator_agent_runtime_task_at( - &root, - "code-prototype", - "核对普通 Agent 上下文说明", - "ordinary-context-preload-notice-run", - "agent-background-task", - "构建 planning request", - vec!["核对普通说明".to_string()], - ) - .expect("start ordinary runtime state"); - let (_, _, ordinary_request, _, _) = - build_game_creator_agent_background_tool_plan_request_for_test( - &root, - "code-prototype", - &ordinary_state.session_id, - &ordinary_state.run_id, - &ordinary_state.current_task, - &[], - 0, - ) - .expect("build ordinary planning request"); - assert!(ordinary_request.messages[0] - .content - .contains("你正在使用 Genarrative AI 游戏创作多智能体 Runtime")); - let ordinary_prompt = &ordinary_request.messages[1].content; - assert!(!ordinary_prompt.contains(supervisor_identity_contract)); - assert!(ordinary_prompt.contains(ORDINARY_NOTICE)); - assert!(ordinary_prompt.contains("请只以这个专业 Agent 的身份行动")); - assert!(!ordinary_prompt.contains(SUPERVISOR_NOTICE)); - assert!(!ordinary_prompt.contains(MEMORY_MARKER)); - assert!(!ordinary_prompt.contains("\"advisoryOnly\": true")); - - let plan = AgentRuntimeToolPlan { - thinking_summary: "汇总已完成协作".to_string(), - plan_update: None, - plan: Vec::new(), - actions: Vec::new(), - response: String::new(), - }; - let (_, _, final_request) = build_game_creator_agent_background_final_reply_request( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &supervisor_state.session_id, - &supervisor_state.run_id, - &supervisor_state.current_task, - &plan, - &[], - ) - .expect("build supervisor final reply request"); - let final_system_prompt = &final_request.messages[0].content; - let final_user_prompt = &final_request.messages[1].content; - assert_eq!( - final_system_prompt, - game_creator_project_supervisor_chat_system_prompt() - ); - assert_eq!( - final_request - .messages - .iter() - .map(|message| message.content.as_str()) - .collect::>() - .join("\n") - .matches(supervisor_identity_contract) - .count(), - 1 - ); - assert_eq!( - final_request - .messages - .iter() - .filter(|message| message.content.contains("你拥有最终回复权")) - .count(), - 1 - ); - assert!(!final_user_prompt.contains(supervisor_identity_contract)); - assert!(!final_user_prompt.contains("你拥有最终回复权")); - - let (_, _, ordinary_final_request) = - build_game_creator_agent_background_final_reply_request( - &root, - "code-prototype", - &ordinary_state.session_id, - &ordinary_state.run_id, - &ordinary_state.current_task, - &plan, - &[], - ) - .expect("build ordinary final reply request"); - assert!(ordinary_final_request.messages[0] - .content - .contains("多智能体中的一个专业角色 Agent")); - assert!(ordinary_final_request.messages[1] - .content - .contains("请只以这个专业 Agent 的身份行动")); - } - - #[test] - fn project_planning_brief_is_injected_only_for_standard_delegate_child() { - let directory = crate::tests::canonical_test_tempdir("planning-role-brief-provider-"); - let root = directory.path().join("project"); - init_local_game_project_at(&root, "planning-role-brief", "立项策划 brief 注入测试") - .expect("project init"); - let _config_guard = crate::tests::write_test_local_config("{}".to_string()); - let parent = bind_game_creator_agent_runtime_run_profile_at( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - "planning-role-brief-parent", - AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE, - Some(AGENT_RUNTIME_RUN_PROFILE_STANDARD), - None, - ) - .expect("bind planning parent"); - let child = bind_game_creator_agent_runtime_run_profile_at( - &root, - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - "planning-role-brief-child", - "agent-delegate", - Some(AGENT_RUNTIME_RUN_PROFILE_STANDARD), - Some(&AgentRuntimeTaskLink { - parent_agent_id: Some(parent.agent_id.clone()), - parent_run_id: Some(parent.run_id.clone()), - delegation_id: Some("planning-role-brief-delegation".to_string()), - }), - ) - .expect("bind planning child"); - let planning_state = start_game_creator_agent_runtime_task_at( - &root, - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - "围绕用户需求形成 Fast GDD", - &child.run_id, - "agent-delegate", - "构建 planning request", - vec!["读取需求并准备澄清".to_string()], - ) - .expect("start planning child"); - let (_, _, planning_request, _, _) = - build_game_creator_agent_background_tool_plan_request_for_test( - &root, - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - &planning_state.session_id, - &planning_state.run_id, - &planning_state.current_task, - &[], - 0, - ) - .expect("build planning request"); - let planning_system_prompt = &planning_request.messages[0].content; - let planning_brief_marker = "你是“立项策划 Agent”(`agentId=project-planning`)"; - assert!(planning_system_prompt.contains(planning_brief_marker)); - assert!(planning_system_prompt.contains("当前请求只广告以下原生函数")); - assert!(!planning_system_prompt.contains("Runtime 当前注册的原生可执行工具:")); - let planning_prompt_text = planning_request - .messages - .iter() - .map(|message| message.content.as_str()) - .collect::>() - .join("\n"); - for leaked_contract in [ - "project.search 使用", - "project.patchset 的每个 change", - "file.write 使用", - "command.exec 使用", - "preview.validate 使用", - "持久进程协议", - ] { - assert!( - !planning_prompt_text.contains(leaked_contract), - "planning prompt 不得注入通用工具契约:{leaked_contract}" - ); - } - assert!(planning_prompt_text.contains("file.read 使用")); - assert!(planning_prompt_text.contains("file.list 使用")); - assert!(planning_prompt_text.contains("plan.submit_gdd")); - assert!(planning_prompt_text.contains("plan-submit-gdd-input.v1")); - - let planning_plan = AgentRuntimeToolPlan { - thinking_summary: "等待用户确认核心循环".to_string(), - plan_update: None, - plan: Vec::new(), - actions: Vec::new(), - response: "AGC_NEEDS_USER_INPUT_V1\n{\"questions\":[{\"id\":\"core_loop\"}]}" - .to_string(), - }; - let (_, _, planning_final_request) = - build_game_creator_agent_background_final_reply_request( - &root, - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - &planning_state.session_id, - &planning_state.run_id, - &planning_state.current_task, - &planning_plan, - &[], - ) - .expect("build planning final reply request"); - assert_eq!( - planning_final_request.messages[0].content, - GAME_CREATOR_PROJECT_PLANNING_FINAL_REPLY_SYSTEM_PROMPT - ); - assert!(!planning_final_request.enable_web_search); - let planning_final_prompt = planning_final_request - .messages - .iter() - .map(|message| message.content.as_str()) - .collect::>() - .join("\n"); - assert!(planning_final_prompt.contains("AGC_NEEDS_USER_INPUT_V1")); - assert!(planning_final_prompt.contains("不要声称已经写入文件")); - assert!(!planning_final_prompt.contains("正常中文回复")); - assert!(!planning_final_prompt.contains("你拥有最终回复权")); - - let supervisor_state = start_game_creator_agent_runtime_task_at( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - "委派立项策划子 Agent", - &parent.run_id, - AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE, - "构建 supervisor planning request", - vec!["准备委派".to_string()], - ) - .expect("start supervisor"); - let (_, _, supervisor_request, _, _) = - build_game_creator_agent_background_tool_plan_request_for_test( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &supervisor_state.session_id, - &supervisor_state.run_id, - &supervisor_state.current_task, - &[], - 0, - ) - .expect("build supervisor request"); - assert!(!supervisor_request.messages[0] - .content - .contains(planning_brief_marker)); - } - - #[test] fn completion_blocker_protocol_requires_tool_repair_before_response() { let protocol = AGENT_RUNTIME_COMPLETION_BLOCKER_TOOL_PLAN_PROTOCOL; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_tool_plan.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_tool_plan.rs index e63d79b96..09da3466e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_tool_plan.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_tool_plan.rs @@ -118,26 +118,15 @@ fn provider_collaboration_repair_instruction(protocol_error: &str, section_id: & ) } -fn restrict_root_goal_contract_repair_request( - request: &mut LlmRunRequest, - plan_root: bool, -) -> Result<(), String> { - if plan_root { - restrict_plan_root_goal_contract_schema(&mut request.function_tools)?; - } +fn restrict_root_goal_contract_repair_request(request: &mut LlmRunRequest) -> Result<(), String> { restrict_agent_runtime_root_goal_contract_tools(request) } -fn root_goal_contract_repair_instruction(protocol_error: &str, plan_root: bool) -> String { - if plan_root { - format!( - "上一条输出不符合工具计划协议:{protocol_error}\n当前 plan 根 Run 尚未冻结 Goal Contract。本次修复的原生工具目录只保留 agent.goal_contract;必须且只能调用一次。outcome 具体概括当前用户最终意图,nonNegotiables、forbiddenAssumptions、openQuestions 没有内容时传空数组,preferences 必须始终传空数组;acceptanceNodes 必须精确提交固定单节点 {{\"criterionId\":\"{PLAN_FAST_GDD_ACCEPTANCE_NODE_ID}\",\"criterion\":\"{PLAN_FAST_GDD_ACCEPTANCE_NODE_CRITERION}\",\"required\":true,\"requiredEvidence\":[\"{PLAN_FAST_GDD_ACCEPTANCE_NODE_EVIDENCE}\"],\"dependsOn\":[]}}。不得调用 update_agent_plan、respond_to_user 或任何其他动作,不得输出普通文本、解释、markdown 或代码围栏。" - ) - } else { - format!( - "上一条输出不符合工具计划协议:{protocol_error}\n当前根 Run 尚未冻结 Goal Contract。本次修复的原生工具目录只保留 agent.goal_contract;必须且只能调用一次,用 outcome 具体概括当前用户最终意图,acceptanceNodes 至少提交一项可核对标准。每个 requiredEvidence 必须选择在该标准所有合法结果下都能成功产生回执的工具;环境探测可能以 rejected/failed 表示正常否定结果时,不得把该探测工具写成必需成功回执(例如非 Git 项目不得要求 git.inspect 成功,应使用 project.index 的成功回执证明 isRepository=false)。nonNegotiables、preferences、forbiddenAssumptions、openQuestions 没有内容时传空数组。不得调用 update_agent_plan、respond_to_user 或任何其他动作,不得输出普通文本、解释、markdown 或代码围栏。" - ) - } +fn root_goal_contract_repair_instruction(protocol_error: &str) -> String { + format!( + "上一条输出不符合工具计划协议:{protocol_error} +当前根 Run 尚未冻结 Goal Contract。本次修复的原生工具目录只保留 agent.goal_contract;必须且只能调用一次,用 outcome 具体概括当前用户最终意图,acceptanceNodes 至少提交一项可核对标准。每个 requiredEvidence 必须选择在该标准所有合法结果下都能成功产生回执的工具;环境探测可能以 rejected/failed 表示正常否定结果时,不得把该探测工具写成必需成功回执(例如非 Git 项目不得要求 git.inspect 成功,应使用 project.index 的成功回执证明 isRepository=false)。nonNegotiables、preferences、forbiddenAssumptions、openQuestions 没有内容时传空数组。不得调用 update_agent_plan、respond_to_user 或任何其他动作,不得输出普通文本、解释、markdown 或代码围栏。" + ) } /// 把上游的终态标记夹紧成可落审计的短标记。 @@ -222,15 +211,6 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at let (run_profile, _) = agent_runtime_run_profile_identity_at(root, agent_id, run_id, None, None)?; let relaxed_autonomous = autonomous_relaxed_run_profile(&run_profile); - let plan_root_candidate = - read_game_creator_agent_runtime_run_profile_binding(root, agent_id, run_id)? - .is_some_and(|binding| binding.source == AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE); - let plan_root = if plan_root_candidate { - validate_project_supervisor_plan_root_binding_at(root, agent_id, run_id)?; - true - } else { - false - }; let (mut built_request, mut supervisor_manifest_dag_in_progress_at_request) = { let _lock = acquire_game_creator_agent_provider_plan_project_write_lock_with_wait( root, @@ -346,78 +326,21 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at .as_ref() .map(|sidecar| context_compaction_result(sidecar, true)); } - let (provider_snapshot, initial_planning_session_binding) = { + let provider_snapshot = { let _lock = acquire_game_creator_agent_provider_plan_project_write_lock_with_wait( root, "runtime.provider_request.capture.tool_plan", )?; - if agent_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID { - // Fold first so the request rebuild, structured injection and - // frozen session binding all observe one budget successor. - fold_plan_provider_usage_before_new_request_at_locked(root, Some((agent_id, run_id)))?; - } - // Exact planning requests must freeze the session and the concrete - // request object under one project lock. Rebuild once while holding - // that lock so a session successor cannot be used to re-label an - // object assembled from an older session. - if agent_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID { - built_request = build_game_creator_agent_background_tool_plan_request_locked( - root, - &_lock, - agent_id, - session_id, - run_id, - task, - observations, - loop_index, - )?; - } - if agent_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID { - let wire_bytes = - capture_plan_provider_structured_injections_at(root, session_id, observations)?; - let message = render_plan_provider_structured_injections_message(&wire_bytes)?; - built_request - .2 - .messages - .insert(1, LlmMessage::user(message)); - } - let provider_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 planning_session_binding = if agent_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID { - let request_context_fingerprint = - game_creator_agent_runtime_plan_provider_request_context_fingerprint( - &built_request.0, - &built_request.2, - )?; - let runtime = read_game_creator_agent_runtime_at(root, agent_id)?.state; - Some(capture_plan_provider_session_binding_for_snapshot( - root, - &runtime, - &provider_snapshot, - &request_context_fingerprint, - )?) - } else { - None - }; - (provider_snapshot, planning_session_binding) + 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, + )? }; - if agent_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID { - estimated_input_tokens = estimate_game_creator_llm_request_tokens(&built_request.2)?; - validate_game_creator_llm_request_context_budget( - &built_request.0, - &built_request.2, - estimated_input_tokens, - "锁内冻结后的 tool-plan 请求", - )?; - } let (llm, config_path, mut request, repository_context_fingerprint, _) = built_request; let auto_compact_token_limit = llm.auto_compact_token_limit; let format_repair_attempts = if run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD { @@ -482,36 +405,6 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at let request_snapshot = provider_snapshot .with_request_slot(&request_slot) .with_web_search_enabled(request.enable_web_search); - let planning_session_binding = if agent_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID { - if repair_attempt == 0 { - initial_planning_session_binding.clone() - } else { - let request_context_fingerprint = - game_creator_agent_runtime_plan_provider_request_context_fingerprint( - &llm, &request, - )?; - let _lock = acquire_game_creator_agent_provider_plan_project_write_lock_with_wait( - root, - "runtime.provider_request.freeze.plan_binding", - )?; - let runtime = read_game_creator_agent_runtime_at(root, agent_id)?.state; - let candidate = capture_plan_provider_session_binding_for_snapshot( - root, - &runtime, - &request_snapshot, - &request_context_fingerprint, - )?; - let initial = initial_planning_session_binding.as_ref().ok_or_else(|| { - "planning Provider repair 缺少 repair-0 frozen session binding".to_string() - })?; - validate_plan_provider_session_binding_repair_lineage(initial, &candidate)?; - Some(candidate) - } - } else { - None - }; - let request_snapshot = - request_snapshot.with_planning_session_binding(planning_session_binding.clone()); let response = request_game_creator_agent_runtime_llm_with_persisted_transient_retry( root, &request_snapshot, @@ -568,16 +461,6 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at ); } }; - let effective_planning_session_binding = - if let Some(base_binding) = planning_session_binding.as_ref() { - Some(plan_provider_session_binding_for_attempt( - base_binding, - &response_handoff.request_slot, - &response_handoff.provider_request_id, - )?) - } else { - None - }; if response_handoff.to_llm_response() != response { return Err( game_creator_agent_runtime_provider_handoff_reconciliation_error( @@ -907,7 +790,6 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at return Ok(RequestedAgentRuntimeToolPlanOutcome::Ready(Some( RequestedAgentRuntimeToolPlan { plan, - planning_session_binding: effective_planning_session_binding, repository_context_fingerprint, estimated_input_tokens, auto_compact_token_limit, @@ -1069,13 +951,7 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at agent_runtime_protocol_error_requires_supervisor_collaboration_repair( &protocol_error, ) && !request.function_tools.is_empty(); - if agent_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID { - request.function_tools = - build_agent_runtime_native_function_tools_for_agent(agent_id)?; - request.messages.push(LlmMessage::user(format!( - "上一条输出不符合 planning 工具计划协议:{protocol_error}\n本轮修复仍只允许调用 file.read、file.list、plan.submit_gdd、update_agent_plan、respond_to_user。plan.submit_gdd 的 input 必须严格符合 plan-submit-gdd-input.v1,只提交 game、decisions、prototypeValidationItems;它必须是唯一 action,可与 update_agent_plan 同响应,但不能与其它动作或 respond_to_user 混合。不得调用或描述其它工具,不得输出普通文本来代替函数调用;需要用户决定时以 AGC_NEEDS_USER_INPUT_V1 终态信封收束。" - ))); - } else if force_root_goal_contract + if force_root_goal_contract || force_supervisor_initial_collaboration || force_autonomous_specialist_mutation_only || force_autonomous_specialist_verification_only @@ -1116,12 +992,11 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at } } if force_root_goal_contract { - restrict_root_goal_contract_repair_request(&mut request, plan_root)?; + restrict_root_goal_contract_repair_request(&mut request)?; request .messages .push(LlmMessage::user(root_goal_contract_repair_instruction( &protocol_error, - plan_root, ))); } else if force_supervisor_initial_collaboration { supervisor_collaboration_repair_active = true; @@ -1292,17 +1167,6 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at "上一条输出不符合工具计划协议:{protocol_error}\n请修复格式,只调用当前请求广告的 update_agent_plan、动作工具或 respond_to_user;当前 in_progress 步骤已具备执行条件时,格式修复必须保留并调用对应动作工具,不能退化为只调用 update_agent_plan。不要解释,不要 markdown,不要代码围栏,也不要把计划、动作或回复放进普通文本。" ))); } - // 修复分支会先整份重建工具目录,再按各自的场景收窄。plan 根必须在 - // 所有分支收窄之后再取一次交集:早于分支就会让 - // `restrict_agent_runtime_supervisor_collaboration_repair_tools` - // 这类「必须包含 agent.spawn_isolated」的检查硬失败,晚于分支则 - // 保证任何修复轮都不会把被裁掉的 36 个工具重新广告回去。 - if plan_root { - retain_plan_root_supervisor_native_tools( - &mut request.function_tools, - plan_root_supervisor_stage_at(root, agent_id, run_id)?, - )?; - } request.enable_web_search = false; } Err(error) => { @@ -1526,32 +1390,4 @@ mod supervisor_collaboration_repair_tests { assert_eq!(merged, vec![replacement]); } - - #[test] - fn plan_root_goal_contract_repair_keeps_the_fixed_schema_and_instruction() { - let mut request = LlmRunRequest::new(Vec::new()) - .with_function_tools( - build_agent_runtime_native_function_tools().expect("build native function tools"), - ) - .with_tool_choice(platform_llm::LlmToolChoice::Required); - - restrict_root_goal_contract_repair_request(&mut request, true) - .expect("restrict plan goal repair"); - assert_eq!(request.function_tools.len(), 1); - let fixed = request.function_tools[0] - .parameters - .pointer("/properties/input/properties/acceptanceNodes") - .expect("fixed acceptance nodes schema"); - assert_eq!(fixed["maxItems"], serde_json::json!(1)); - assert_eq!( - fixed["items"]["properties"]["criterionId"]["enum"], - serde_json::json!([PLAN_FAST_GDD_ACCEPTANCE_NODE_ID]) - ); - - let instruction = root_goal_contract_repair_instruction("test-error", true); - assert!(instruction.contains("固定单节点")); - assert!(instruction.contains(PLAN_FAST_GDD_ACCEPTANCE_NODE_CRITERION)); - assert!(!instruction.contains("至少提交一项")); - assert!(!instruction.contains("project.index 的成功回执")); - } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/response_stream_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/response_stream_tests.rs index 54cefdf91..0981adf5d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/response_stream_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/response_stream_tests.rs @@ -68,7 +68,6 @@ fn response_stream_fixture( ), web_search_enabled: false, allow_idle_context_compaction: false, - planning_session_binding: None, }; (project, state, response_revision, snapshot) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_plan_protocol.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_plan_protocol.rs index 059118ce7..9e845a467 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_plan_protocol.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_plan_protocol.rs @@ -47,7 +47,6 @@ pub(crate) fn parse_game_creator_agent_tool_plan_llm_response_classified_for_age ) -> Result { if response.tool_calls.is_empty() { let plan = parse_game_creator_agent_tool_plan_response_classified(response.text.as_str())?; - validate_agent_runtime_tool_plan_identity(agent_id, &plan)?; return Ok(ParsedAgentRuntimeToolPlan { plan, protocol: "text_json", @@ -81,12 +80,6 @@ pub(crate) fn parse_game_creator_agent_tool_plan_llm_response_classified_for_age if response.tool_calls.len() == 1 && response.tool_calls[0].name == AGENT_RUNTIME_TOOL_PLAN_FUNCTION_NAME { - if agent_id.trim() == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID { - return Err(AgentRuntimeToolPlanProtocolError::new( - AgentRuntimeToolPlanProtocolErrorKind::UnknownFunction, - "Agent 原生工具协议错误:project-planning 不允许旧 submit_agent_tool_plan 包装器", - )); - } let call = &response.tool_calls[0]; let plan = parse_game_creator_agent_tool_plan_payload(call.arguments.as_str(), true) .map_err(|error| { @@ -108,9 +101,8 @@ pub(crate) fn parse_game_creator_agent_tool_plan_llm_response_classified_for_age normalized_text_sha256: text_normalization.source_text_sha256, }); } - let native = parse_agent_runtime_native_tool_calls_for_agent(agent_id, &response.tool_calls)?; + let native = parse_agent_runtime_native_tool_calls(&response.tool_calls)?; let plan = normalize_game_creator_agent_tool_plan(native.plan)?; - validate_agent_runtime_tool_plan_identity(agent_id, &plan)?; Ok(ParsedAgentRuntimeToolPlan { plan, protocol: "native_runtime_tools", @@ -125,46 +117,6 @@ pub(crate) fn parse_game_creator_agent_tool_plan_llm_response_classified_for_age }) } -fn validate_agent_runtime_tool_plan_identity( - agent_id: &str, - plan: &AgentRuntimeToolPlan, -) -> Result<(), AgentRuntimeToolPlanProtocolError> { - if agent_id.trim() != GAME_CREATOR_PROJECT_PLANNING_AGENT_ID - && agent_id.trim() != "__all_agents__" - && plan - .actions - .iter() - .any(|action| action.tool.trim() == PLAN_SUBMIT_GDD_TOOL) - { - return Err(AgentRuntimeToolPlanProtocolError::new( - AgentRuntimeToolPlanProtocolErrorKind::UnknownFunction, - format!( - "Agent 原生工具协议错误:Agent {} 不允许调用 {}", - agent_id.trim(), - PLAN_SUBMIT_GDD_TOOL - ), - )); - } - if agent_id.trim() != GAME_CREATOR_PROJECT_PLANNING_AGENT_ID { - return Ok(()); - } - if let Some(action) = plan - .actions - .iter() - .find(|action| !agent_runtime_native_tool_allowed_for_agent(agent_id, &action.tool)) - { - return Err(AgentRuntimeToolPlanProtocolError::new( - AgentRuntimeToolPlanProtocolErrorKind::UnknownFunction, - format!( - "Agent 原生工具协议错误:Agent {} 不允许调用 {}", - agent_id.trim(), - action.tool.trim() - ), - )); - } - Ok(()) -} - #[derive(Default)] pub(in crate::agent) struct AgentRuntimeToolPlanTextNormalization { pub(in crate::agent) visible_text: String, @@ -353,29 +305,5 @@ pub(in crate::agent) fn normalize_game_creator_agent_tool_plan( error, ) })?; - validate_plan_submit_gdd_tool_plan(&plan).map_err(|error| { - AgentRuntimeToolPlanProtocolError::new( - AgentRuntimeToolPlanProtocolErrorKind::BatchConstraint, - error, - ) - })?; Ok(plan) } - -fn validate_plan_submit_gdd_tool_plan(plan: &AgentRuntimeToolPlan) -> Result<(), String> { - let submit_count = plan - .actions - .iter() - .filter(|action| action.tool.trim() == PLAN_SUBMIT_GDD_TOOL) - .count(); - if submit_count == 0 { - return Ok(()); - } - if submit_count != 1 || plan.actions.len() != 1 || !plan.response.trim().is_empty() { - return Err( - "Agent 工具计划协议错误:plan.submit_gdd 必须是本轮唯一 action,且不能与 respond_to_user 同响应(可与 update_agent_plan 同响应)" - .to_string(), - ); - } - Ok(()) -} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_policy_snapshot.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_policy_snapshot.rs index 0a58e049f..539eb45d7 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_policy_snapshot.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_policy_snapshot.rs @@ -3,13 +3,6 @@ use super::*; pub(crate) const AGENT_RUNTIME_CANVAS_ASSET_KINDS: &[&str] = &["game-art", "icon-spec", "ui-prototype", "art-spritesheet"]; -/// Exact executable action surface for the delegated planning child. The -/// two read tools are ordinary Runtime capabilities; `plan.submit_gdd` is a -/// planning-only capability and therefore must not be added to the global -/// `agent_runtime_executable_tools()` catalog. -pub(crate) const AGENT_RUNTIME_PROJECT_PLANNING_ACTION_TOOLS: &[&str] = - &["file.read", "file.list", PLAN_SUBMIT_GDD_TOOL]; - #[cfg(test)] mod canvas_asset_kind_contract_tests { use super::*; @@ -21,45 +14,6 @@ mod canvas_asset_kind_contract_tests { &["game-art", "icon-spec", "ui-prototype", "art-spritesheet"] ); } - - #[test] - fn planning_submit_confirmation_is_classified_as_deny_without_a_generic_pending_mode() { - let temporary = crate::tests::canonical_test_tempdir("planning-submit-confirm-policy-"); - let root = temporary.path().join("project"); - init_local_game_project_at(&root, "planning-submit-confirm-policy", "submit policy") - .expect("init policy fixture"); - write_project_permission_policy_at( - &root, - ProjectPermissionPolicy { - denied_commands: Vec::new(), - confirm_commands: vec![PLAN_SUBMIT_GDD_TOOL.to_string()], - agent_policies: BTreeMap::new(), - }, - ) - .expect("write submit confirmation policy"); - - let snapshot = - agent_runtime_tool_policy_snapshot_at(&root, GAME_CREATOR_PROJECT_PLANNING_AGENT_ID) - .expect("read planning policy snapshot"); - assert!(snapshot - .denied_tools - .iter() - .any(|tool| tool == PLAN_SUBMIT_GDD_TOOL)); - assert!(!snapshot - .auto_tools - .iter() - .any(|tool| tool == PLAN_SUBMIT_GDD_TOOL)); - assert!(!snapshot - .confirm_tools - .iter() - .any(|tool| tool == PLAN_SUBMIT_GDD_TOOL)); - - // The M1B-2 submit state machine has no generic confirmation - // consumer. A confirmation rule must therefore never advertise a - // confirmation execution path or create a pending sidecar. - assert!(!root.join(".agent/runtime/pending-actions").exists()); - assert!(!root.join(".agent/planning/gdd.v1.json").exists()); - } } pub(crate) fn agent_runtime_executable_tools() -> Vec<&'static str> { @@ -112,168 +66,6 @@ pub(crate) fn agent_runtime_executable_tools() -> Vec<&'static str> { ] } -/// plan 根 Supervisor(`source == project-supervisor-plan`)在整条策划链路里 -/// 只负责三件事:冻结 Goal Contract、委派与续跑 `project-planning`、按 §13.0 取证 -/// 后建审批卡。策划内容全部由子 Agent 生产,Supervisor 不写文件、不跑命令、不做 -/// 预览、不生成素材、不调度 ready 任务、不并行委派。 -/// -/// 全量注册表会把 43 个原生工具摆在 Provider 眼前,其中绝大多数在 plan 根都会被 -/// 执行层拒绝——广告出去只会诱导 Supervisor 自己下场干活。这里给出 plan 根的 -/// exact allowlist,Provider 请求目录和 system prompt 的工具清单共用它,二者不得 -/// 各自维护一份。 -/// -/// **`user.input_request` 不在其中**:澄清卡不是 Supervisor 发的。子 Agent 以 -/// `AGC_NEEDS_USER_INPUT_V1` 终态信封退出后,Runtime 在 parent-wake 屏障处自己按 -/// 信封原文构造 `user.input_request` pending 并且**不恢复父 run** -/// (`ensure_static_delegate_user_input_wait_at_locked`)。Supervisor 因此永远收不到 -/// needs-user-input observation,也就没有调用它的时机;广告出去只会让它在别的时点 -/// 调一次,撞上 Runtime 已经装好的那份 pending 而硬失败。 -/// -/// `file.read` 与 `agent.acceptance_update` 只为 §13.0 的审批前置取证门存在(分页读 -/// `game/fast_gdd.md` 并列出全部分页 actionId)。**`agent.action_history` 不在其中**: -/// 每次 `file.read` 的 observation 已经自带 `sourceActionId`,取证不需要回头查历史; -/// 留着它只会让模型为同一个 id 反复确认(实测连查四次,答案一直在上下文里)。 -pub(crate) fn agent_runtime_plan_root_supervisor_tools() -> &'static [&'static str] { - &[ - "file.read", - "agent.delegate", - "agent.goal_contract", - "agent.acceptance_update", - "agent.run_status", - ] -} - -/// plan 根在链路上的推进阶段。 -/// -/// 工具面按阶段收窄,是因为「工具在它无用的阶段仍然可见」会直接制造活锁:实测一次 -/// 生产 run 里 Supervisor 冻结合同后没有委派,改成反复调 `agent.run_status` 去查一个 -/// 根本不存在的委派,24 轮里 58 次 `agent.run_status`、0 次 `agent.delegate`,一直烧 -/// 到超时。空转闸门也拦不住——只读调用同样会把 `plan_update_idle_rounds` 清零。 -/// -/// 本地原型没有这个问题,因为它的 Supervisor 只有四个工具且每一个都推进链路: -/// 「调了工具」和「推进了链路」在那边是同一件事。这里把同一性质移植过来——每个 -/// 阶段只广告该阶段能真正推进链路的工具。 -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub(crate) enum PlanRootSupervisorStage { - /// Goal Contract 尚未冻结:本轮唯一能推进的动作是冻结它。 - GoalContract, - /// 合同已冻结但本根 run 还没有任何委派:唯一能推进的动作是派出策划子 Agent。 - Delegate, - /// 最新 GDD 已提交但尚未完成当前根 Run 的 Acceptance Graph 取证:只能读取 - /// `game/fast_gdd.md`、更新验收图或重放状态,不能抢先创建重复策划 delivery。 - AwaitingAcceptanceEvidence, - /// 已有委派:取证、返工与审批相关工具全部开放。 - Delegated, -} - -pub(crate) fn agent_runtime_plan_root_supervisor_tools_for_stage( - stage: PlanRootSupervisorStage, -) -> &'static [&'static str] { - match stage { - PlanRootSupervisorStage::GoalContract => &["agent.goal_contract"], - PlanRootSupervisorStage::Delegate => &["agent.delegate"], - PlanRootSupervisorStage::AwaitingAcceptanceEvidence => { - &["file.read", "agent.acceptance_update", "agent.run_status"] - } - // 合同已冻结且不可重写,再广告 agent.goal_contract 只会诱导一次必被拒的调用。 - PlanRootSupervisorStage::Delegated => &[ - "file.read", - "agent.delegate", - "agent.acceptance_update", - "agent.run_status", - ], - } -} - -#[cfg(test)] -mod plan_root_stage_tests { - use super::*; - - /// 各阶段并集必须正好等于 allowlist:prompt 头部按 allowlist 列工具,若某个工具 - /// 只出现在某一阶段而不在 allowlist 里,头部就会漏掉它;反之则是广告了一个永远 - /// 拿不到的工具。两侧都是「合同说有、请求里没有」的自相矛盾。 - #[test] - fn every_stage_tool_is_part_of_the_plan_root_allowlist_and_the_union_covers_it() { - let allowlist = agent_runtime_plan_root_supervisor_tools() - .iter() - .copied() - .collect::>(); - let union = [ - PlanRootSupervisorStage::GoalContract, - PlanRootSupervisorStage::Delegate, - PlanRootSupervisorStage::AwaitingAcceptanceEvidence, - PlanRootSupervisorStage::Delegated, - ] - .into_iter() - .flat_map(|stage| { - agent_runtime_plan_root_supervisor_tools_for_stage(stage) - .iter() - .copied() - }) - .collect::>(); - assert_eq!(union, allowlist); - } - - /// 活锁的诱因是「工具在它无用的阶段仍然可见」。这两个阶段各自只能有一个动作。 - #[test] - fn the_pre_delegation_stages_expose_exactly_one_advancing_action() { - assert_eq!( - agent_runtime_plan_root_supervisor_tools_for_stage( - PlanRootSupervisorStage::GoalContract - ), - &["agent.goal_contract"] - ); - assert_eq!( - agent_runtime_plan_root_supervisor_tools_for_stage(PlanRootSupervisorStage::Delegate), - &["agent.delegate"] - ); - } -} - -/// 只按 durable 事实判定阶段,不看 Provider 说了什么。 -/// -/// 调用方必须已经持有当前项目写锁;需要自行取得锁的调用方使用下面的 -/// `plan_root_supervisor_stage_at` 包装入口。这样 Provider 请求构建路径可以复用外层 -/// 已有的项目锁,不会在阶段判定中再次获取同一把非重入锁。 -pub(crate) fn plan_root_supervisor_stage_at_locked( - root: &Path, - project_lock: &ProjectWriteLock, - agent_id: &str, - run_id: &str, -) -> Result { - if !project_lock.guards_project_root(root)? { - return Err("plan Supervisor 阶段判定缺少当前项目写锁".to_string()); - } - if read_game_creator_agent_runtime_goal_contract_at(root, agent_id, run_id)?.is_none() { - return Ok(PlanRootSupervisorStage::GoalContract); - } - let delegated = list_static_delegate_deliveries_at(root)? - .into_iter() - .any(|delivery| delivery.parent_agent_id == agent_id && delivery.parent_run_id == run_id); - if !delegated { - return Ok(PlanRootSupervisorStage::Delegate); - } - if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID - && plan_root_supervisor_acceptance_evidence_required_locked(root, project_lock, run_id)? - { - return Ok(PlanRootSupervisorStage::AwaitingAcceptanceEvidence); - } - Ok(PlanRootSupervisorStage::Delegated) -} - -/// 供未持有项目写锁的调用方使用的阶段判定入口。 -pub(crate) fn plan_root_supervisor_stage_at( - root: &Path, - agent_id: &str, - run_id: &str, -) -> Result { - let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( - root, - "planning.supervisor-stage", - )?; - plan_root_supervisor_stage_at_locked(root, &_lock, agent_id, run_id) -} - pub(crate) fn agent_runtime_native_executable_tools() -> Vec<&'static str> { agent_runtime_executable_tools() } @@ -381,29 +173,6 @@ pub(in crate::agent) fn agent_runtime_tool_policy_snapshot_at( auto_tools.push(tool.to_string()); } } - if agent_id.trim() == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID { - // `plan.submit_gdd` is intentionally not in the global catalog. - // Classify it against the same project/Agent permission policy so an - // explicit deny/confirm cannot be bypassed by the planning ceiling. - let planning_submit_command = PLAN_SUBMIT_GDD_TOOL; - let denied = policy - .denied_commands - .iter() - .any(|command| command == planning_submit_command); - let confirmation_requested = policy - .confirm_commands - .iter() - .any(|command| command == planning_submit_command); - if denied || confirmation_requested { - // M1B-2 has no generic user-confirmation state for the Runtime - // commit action. An explicit confirm rule therefore fails closed - // instead of creating a pending shape the submit state machine can - // never consume. - denied_tools.push(PLAN_SUBMIT_GDD_TOOL.to_string()); - } else { - auto_tools.push(PLAN_SUBMIT_GDD_TOOL.to_string()); - } - } Ok(AgentRuntimeToolPolicySnapshot { run_profile: default_agent_runtime_run_profile(), run_profile_binding_fingerprint: String::new(), @@ -435,55 +204,6 @@ pub(crate) fn agent_runtime_tool_policy_snapshot_for_run_at( )?; snapshot.run_profile = run_profile.clone(); snapshot.run_profile_binding_fingerprint = binding_fingerprint; - if agent_id.trim() == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID { - validate_project_planning_child_binding_at(root, agent_id, run_id)?; - // Planning is a delegated child. Never let normalization/recovery - // repopulate the broad default policy for this identity. - let exact = AGENT_RUNTIME_PROJECT_PLANNING_ACTION_TOOLS; - if !snapshot - .allowed_tools - .iter() - .any(|tool| tool == PLAN_SUBMIT_GDD_TOOL) - { - snapshot - .allowed_tools - .push(PLAN_SUBMIT_GDD_TOOL.to_string()); - } - snapshot - .allowed_tools - .retain(|tool| exact.contains(&tool.as_str())); - snapshot - .auto_tools - .retain(|tool| exact.contains(&tool.as_str())); - snapshot - .confirm_tools - .retain(|tool| exact.contains(&tool.as_str())); - // `snapshot_at` has already applied the project- and Agent-level - // permission policy. Keep an exact-tool deny in that result instead - // of replacing it with the ceiling's non-exact denies. Deny wins - // over auto/confirm so a stale or hand-edited snapshot cannot - // advertise a denied planning read as executable. - let exact_denied = snapshot - .denied_tools - .iter() - .filter(|tool| exact.contains(&tool.as_str())) - .cloned() - .collect::>(); - snapshot - .auto_tools - .retain(|tool| !exact_denied.iter().any(|denied| denied == tool)); - snapshot - .confirm_tools - .retain(|tool| !exact_denied.iter().any(|denied| denied == tool)); - snapshot.denied_tools = exact_denied; - snapshot.denied_tools.extend( - agent_runtime_executable_tools() - .into_iter() - .filter(|tool| !exact.contains(tool)) - .map(str::to_string), - ); - return Ok(snapshot); - } if run_profile != AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD { return Ok(snapshot); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_adapter.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_adapter.rs index 0c9d72039..55ecf1345 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_adapter.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_adapter.rs @@ -16,25 +16,6 @@ fn build_game_creator_runtime_agent_catalog() -> Result { })) }) .map_err(|error| format!("AI 游戏创作 Agent catalog 无效:{error}"))?]; - // 立项策划子 Agent:与 Supervisor 同为「catalog 成员但不是种子 DAG 任务」, - // 因此在遍历 GAME_CREATOR_AGENT_GROUP_DEFINITIONS 之外单独登记。它不属于任何 - // 专业组,groupId 自引用,避免与 design 组(中文 label「策划组」)语义碰撞。 - agents.push( - AgentDescriptor::try_new( - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - PROJECT_PLANNING_AGENT_DEFINITION.id, - std::iter::empty::<&str>(), - ) - .and_then(|agent| { - agent.with_metadata(serde_json::json!({ - "groupId": PROJECT_PLANNING_AGENT_DEFINITION.id, - "roleLabel": PROJECT_PLANNING_AGENT_ROLES[0].role, - "toolId": PROJECT_PLANNING_AGENT_ROLES[0].tool_id, - "capabilityAuthority": "game-creator-tool-policy-snapshot" - })) - }) - .map_err(|error| format!("AI 游戏创作 Agent catalog 无效:{error}"))?, - ); for group in GAME_CREATOR_AGENT_GROUP_DEFINITIONS { for role in group.roles { agents.push( @@ -115,13 +96,11 @@ mod tests { #[test] fn game_creator_runtime_agent_catalog_matches_the_existing_role_directory() { let catalog = game_creator_runtime_agent_catalog().expect("agent catalog"); - // catalog 恰好是「两个组外单节点(Supervisor、立项策划)+ 各专业组角色」。 - // 立项策划刻意不在 GAME_CREATOR_AGENT_GROUP_DEFINITIONS 里:它不参与 - // build.rs 与种子 DAG 的一致性校验,「做游戏」16 任务 DAG 一行不动。 - let mut expected = std::collections::BTreeSet::from([ - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(), - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID.to_string(), - ]); + // catalog 恰好是「一个组外单节点(Supervisor)+ 各专业组角色」。 + let mut expected = + std::collections::BTreeSet::from( + [GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string()], + ); for group in GAME_CREATOR_AGENT_GROUP_DEFINITIONS { expected.extend(group.roles.iter().map(|role| role.task_id.to_string())); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs index 2875c99c7..f00f8060f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs @@ -60,11 +60,6 @@ pub(super) const AGENT_RUNTIME_PARALLEL_READ_BATCH_STATUS_OBSERVED: &str = "obse pub(super) const AGENT_RUNTIME_PARALLEL_READ_BATCH_SIDECAR_MAX_BYTES: usize = 4 * 1024 * 1024; pub(crate) const AGENT_RUNTIME_PROVIDER_ACTION_BATCH_SCHEMA_VERSION: &str = "game-creator-provider-action-batch.v3"; -/// Exact planning batches carry the frozen provider/session binding. Keep -/// ordinary provider batches on v3 so existing recovery readers remain -/// byte-for-byte compatible. -pub(crate) const AGENT_RUNTIME_PLAN_PROVIDER_ACTION_BATCH_SCHEMA_VERSION: &str = - "game-creator-provider-action-batch.v4"; pub(super) const AGENT_RUNTIME_PROVIDER_ACTION_BATCH_PREVIOUS_SCHEMA_VERSION: &str = "game-creator-provider-action-batch.v2"; pub(super) const AGENT_RUNTIME_PROVIDER_ACTION_BATCH_LEGACY_SCHEMA_VERSION: &str = @@ -111,115 +106,13 @@ pub(crate) const AGENT_RUNTIME_ISOLATED_CHILD_SOURCE: &str = "agent-isolated-chi pub(crate) const AGENT_RUNTIME_ISOLATED_JOIN_SOURCE: &str = "agent-isolated-join"; pub(crate) const AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE: &str = "project-supervisor-gui"; pub(crate) const AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE: &str = "project-supervisor-cli"; -pub(crate) const AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE: &str = "project-supervisor-plan"; pub(super) const AUTONOMOUS_GAME_BUILD_FIXED_TASK_GRAPH_STALLED_ERROR: &str = "自主构建任务图无法继续推进"; -pub(crate) const AGENT_RUNTIME_PLAN_AUTONOMOUS_PROFILE_UNSUPPORTED_KIND: &str = - "plan-autonomous-profile-unsupported"; -pub(crate) const AGENT_RUNTIME_PLAN_ROOT_STEER_UNSUPPORTED_KIND: &str = - "plan-root-steer-unsupported"; -pub(crate) const AGENT_RUNTIME_PLAN_ROOT_RETRY_IDENTITY_UNSUPPORTED_KIND: &str = - "plan-root-retry-identity-unsupported"; -pub(crate) const AGENT_RUNTIME_PLAN_ROOT_CHILD_TARGET_UNSUPPORTED_KIND: &str = - "plan-root-child-target-unsupported"; - -/// Idempotently close the planning child after the immutable GDD submit point. -/// The original submit pending/batch remain live recovery anchors until M1C-1 -/// writes their terminal observation, so every other child projection must be -/// independently replayable across process kills. -pub(in crate::agent) fn ensure_project_planning_submit_child_completion_at( - root: &Path, - runtime: &mut AgentRuntimeState, - pending: &AgentRuntimePendingToolAction, - result: &PlanSubmitGddResultV1, -) -> Result<(), String> { - runtime.pending_tool_action = Some(pending.summary()); - runtime.status = "idle".to_string(); - runtime.phase = "completed".to_string(); - runtime.current_action = format!( - "Fast GDD v{} 已完成 create-only 提交", - result.gdd_ref.version - ); - runtime.waiting_on = "无".to_string(); - runtime.next_step = "策划子 Run 已完成".to_string(); - runtime.last_response = Some(format!("Fast GDD v{} 已提交。", result.gdd_ref.version)); - runtime.error = None; - complete_agent_runtime_remaining_plan_steps(runtime, "Fast GDD 已到达 create-only 提交点。"); - runtime.updated_at = unix_timestamp(); - - append_game_creator_agent_runtime_task_projection_once(root, runtime, &pending.action_id)?; - refresh_game_creator_agent_runtime_task_queue(root, runtime)?; - write_game_creator_agent_runtime_state(root, runtime)?; - append_game_creator_agent_runtime_action_event( - root, - runtime, - "plan.submit_gdd.committed", - "idle", - "completed", - "策划子 Run 已在 GDD 提交点终止;原 submit action 尚未 observed。", - Some(&format!( - "actionId={} · gddId={} · version={} · fingerprint={}", - pending.action_id, - result.gdd_ref.gdd_id, - result.gdd_ref.version, - result.gdd_ref.fingerprint - )), - &pending.action_id, - )?; - - publish_game_creator_agent_delegate_result_for_state( - root, - runtime, - runtime.last_response.as_deref(), - ); - let delegation_id = runtime - .delegation_id - .as_deref() - .ok_or_else(|| "策划子 Run 缺少 delegationId,无法验证提交回执".to_string())?; - let delivery = read_static_delegate_delivery_at(root, delegation_id)? - .ok_or_else(|| "策划子 Run 的 durable delivery 不存在".to_string())?; - if delivery.delegation_id != delegation_id - || delivery.target_agent_id != runtime.agent_id - || delivery.target_session_id != runtime.session_id - || delivery.target_run_id != runtime.run_id - || !matches!( - delivery.status, - StaticDelegateDeliveryStatus::Ready | StaticDelegateDeliveryStatus::ClaimedByParent - ) - || delivery.terminal_status.as_deref() != Some("completed") - { - return Err("策划子 Run 的 durable delivery 尚未收口为同 identity completed".to_string()); - } - - append_agent_db_plan_submit_gdd_committed_if_missing_for_action( - root, - &runtime.agent_id, - &runtime.run_id, - &pending.action_id, - serde_json::json!({ - "recordType": "agent.runtime.plan_submit_gdd.committed", - "agentId": runtime.agent_id, - "taskId": runtime.task_id, - "sessionId": runtime.session_id, - "runId": runtime.run_id, - "actionId": pending.action_id, - "actionFingerprint": pending.action_fingerprint, - "gddId": result.gdd_ref.gdd_id, - "version": result.gdd_ref.version, - "gddFingerprint": result.gdd_ref.fingerprint, - "approvalRequestId": result.approval_request_id, - "recoveryPending": false, - }), - )?; - Ok(()) -} pub(crate) fn agent_runtime_supervisor_source_is_trusted(source: &str) -> bool { matches!( source.trim(), - AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE - | AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE - | AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE + AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE | AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE ) } @@ -230,125 +123,6 @@ pub(crate) fn agent_runtime_supervisor_source_is_autonomous_game_build(source: & ) } -pub(crate) fn agent_runtime_supervisor_source_is_plan(source: &str) -> bool { - source.trim() == AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE -} - -pub(crate) fn reject_supervisor_plan_autonomous_profile( - source: &str, - run_profile: &str, -) -> Result<(), String> { - if agent_runtime_supervisor_source_is_plan(source) - && run_profile.trim() == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD - { - return Err(format!( - "立项策划根 Run 必须使用 standard 档,不能搭配 autonomous-game-build(kind={AGENT_RUNTIME_PLAN_AUTONOMOUS_PROFILE_UNSUPPORTED_KIND})" - )); - } - Ok(()) -} - -pub(crate) fn reject_supervisor_plan_root_steer(source: &str) -> Result<(), String> { - if agent_runtime_supervisor_source_is_plan(source) { - return Err(format!( - "立项策划根 Run 不接受 steer 替换;请在本轮问询中回答,或通过审批卡修改 / 退回(kind={AGENT_RUNTIME_PLAN_ROOT_STEER_UNSUPPORTED_KIND})" - )); - } - Ok(()) -} - -fn plan_root_identity_source_profile_match( - source: &str, - profile: &str, - binding_fingerprint: &str, - expected_fingerprint: &str, -) -> bool { - agent_runtime_supervisor_source_is_plan(source) - && profile.trim() == AGENT_RUNTIME_RUN_PROFILE_STANDARD - && binding_fingerprint.trim() == expected_fingerprint.trim() - && !expected_fingerprint.trim().is_empty() -} - -pub(crate) fn supervisor_plan_root_identity_holds_at( - root: &Path, - task: &AgentRuntimeTaskRecord, -) -> Result { - if task.agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID - || !agent_runtime_supervisor_source_is_plan(&task.source) - || task.run_profile.trim() != AGENT_RUNTIME_RUN_PROFILE_STANDARD - || task.parent_agent_id.is_some() - || task.parent_run_id.is_some() - || task.delegation_id.is_some() - || task.run_id.trim().is_empty() - { - return Ok(false); - } - let Some(binding) = - read_game_creator_agent_runtime_run_profile_binding(root, &task.agent_id, &task.run_id)? - else { - return Ok(false); - }; - if validate_agent_runtime_run_profile_binding_record(root, &binding).is_err() - || binding.agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID - || binding.run_id != task.run_id - || binding.root_agent_id != binding.agent_id - || binding.root_run_id != binding.run_id - || binding.parent_agent_id.is_some() - || binding.parent_run_id.is_some() - || !plan_root_identity_source_profile_match( - &binding.source, - &binding.profile, - &binding.binding_fingerprint, - &task.run_profile_binding_fingerprint, - ) - { - return Ok(false); - } - let runtime = read_game_creator_agent_runtime_at(root, &task.agent_id)?; - if runtime.state.run_id == task.run_id - && (!plan_root_identity_source_profile_match( - &runtime.state.source, - &runtime.state.run_profile, - &runtime.state.run_profile_binding_fingerprint, - &binding.binding_fingerprint, - ) || runtime.state.parent_agent_id.is_some() - || runtime.state.parent_run_id.is_some() - || runtime.state.delegation_id.is_some()) - { - return Ok(false); - } - if game_creator_agent_runtime_provider_action_batch_exists(root, &task.agent_id, &task.run_id) { - let batch = read_game_creator_agent_runtime_provider_action_batch( - root, - &task.agent_id, - &task.run_id, - )?; - if !plan_root_identity_source_profile_match( - &batch.source, - &batch.run_profile, - &batch.run_profile_binding_fingerprint, - &binding.binding_fingerprint, - ) { - return Ok(false); - } - } - Ok(true) -} - -pub(crate) fn reject_supervisor_plan_root_retry_without_identity( - root: &Path, - task: &AgentRuntimeTaskRecord, -) -> Result<(), String> { - if !agent_runtime_supervisor_source_is_plan(&task.source) { - return Ok(()); - } - if supervisor_plan_root_identity_holds_at(root, task)? { - return Ok(()); - } - Err(format!( - "立项策划根 Run 重试身份校验失败,拒绝降级为通用 background source(kind={AGENT_RUNTIME_PLAN_ROOT_RETRY_IDENTITY_UNSUPPORTED_KIND})" - )) -} pub(super) const AGENT_RUNTIME_RUN_PROFILE_BINDING_SCHEMA_VERSION: &str = "game-creator-run-profile-binding.v1"; pub(super) const AGENT_RUNTIME_AUTONOMOUS_COMPLETION_CONTRACT_SCHEMA_VERSION: &str = diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs index 4ba1c9cb7..2a1b77488 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs @@ -294,6 +294,7 @@ pub(crate) async fn generate_local_game_draft_at( if prompt.is_empty() { return Err("创作想法不能为空".to_string()); } + ensure_legacy_json_generator_project(root)?; init_local_game_project_at(root, "local-project-draft", "未命名游戏原型")?; let short_memory = read_optional_text(&root.join("memory/session.md"))?; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/finalization.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/finalization.rs index 6dae3634e..19f919b5d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/finalization.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/finalization.rs @@ -408,10 +408,6 @@ pub(in crate::agent) fn resume_game_creator_agent_finalization_at( let current_revision = read_game_creator_agent_runtime_project_revision(root)?; if let Some(blocker) = structured_plan_completion_blocker(&state) { Some(blocker) - } else if let Some(blocker) = - plan_gdd_completion_blocker_at_locked(&root, &journal.agent_id, &journal.run_id) - { - Some(blocker) } else if let Some(blocker) = game_creator_agent_goal_completion_blocker_at_locked(&root, &state) { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/interaction.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/interaction.rs index 2a7bdfb05..351cbb496 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/interaction.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/interaction.rs @@ -470,43 +470,10 @@ fn resolve_game_creator_agent_runtime_user_input_action_with_ordered_locks( ), String, > { - // Ordinary user-input keeps the existing execution-only path. Fast GDD - // answer validation may repair/read `session.json`, so route that exact - // pending through project -> execution and re-read its identity under the - // selected lock set before writing answer-prepared or binding delivery. - let (_, _, _, optimistic_pending) = - resolve_game_creator_agent_runtime_user_input_action(root, agent_id, run_id, action_id)?; - let optimistic_planning = - plan_clarification_pending_requires_project_lock_at(root, &optimistic_pending)?; - if optimistic_planning { - let project_lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( - root, - "planning.clarification.answer.command", - )?; - let runtime_lock = acquire_game_creator_agent_runtime_task_lock_with_wait(root, agent_id)?; - let resolved = resolve_game_creator_agent_runtime_user_input_action( - root, agent_id, run_id, action_id, - )?; - return Ok((Some(project_lock), runtime_lock, resolved)); - } - let runtime_lock = acquire_game_creator_agent_runtime_task_lock_with_wait(root, agent_id)?; let resolved = resolve_game_creator_agent_runtime_user_input_action(root, agent_id, run_id, action_id)?; - if plan_clarification_pending_requires_project_lock_at(root, &resolved.3)? { - drop(runtime_lock); - let project_lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( - root, - "planning.clarification.answer.command", - )?; - let runtime_lock = acquire_game_creator_agent_runtime_task_lock_with_wait(root, agent_id)?; - let resolved = resolve_game_creator_agent_runtime_user_input_action( - root, agent_id, run_id, action_id, - )?; - Ok((Some(project_lock), runtime_lock, resolved)) - } else { - Ok((None, runtime_lock, resolved)) - } + Ok((None, runtime_lock, resolved)) } #[cfg(test)] diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/lifecycle_control.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/lifecycle_control.rs index cad885fe0..512271191 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/lifecycle_control.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/lifecycle_control.rs @@ -749,12 +749,6 @@ pub(crate) fn resolve_game_creator_agent_runtime_retry_configuration_at( Some(&task.run_profile), Some(&task.run_profile_binding_fingerprint), )?; - // 声称是 plan 的 task 必须先过强判据,且这道守卫要排在全部分支之前。 - // 否则「plan source + 伪造 parent」会落进 delegated 支、「plan source + - // autonomous profile」会落进 autonomous 支,两条都绕开强判据——而强判据 - // 存在的意义正是对畸变 durable 状态 fail closed。合法 plan 根 run 无 - // parent、profile 为 standard,本守卫对它是恒真的。 - reject_supervisor_plan_root_retry_without_identity(root, task)?; let source = if delegated { "agent-delegate-retry".to_string() } else if run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD { @@ -771,14 +765,7 @@ pub(crate) fn resolve_game_creator_agent_runtime_retry_configuration_at( { return Err("自主构建 Agent Runtime 重试绑定不是可信 Supervisor 根 Run".to_string()); } - // 上面的守卫按 task.source 判定,挡不住「task.source 已损坏但 - // binding.source 是 plan」这一种:plan 在可信集合内,会被原样取回, - // 复活启动路径明令禁止的 plan + autonomous 组合。 - reject_supervisor_plan_autonomous_profile(&binding.source, &run_profile)?; binding.source - } else if agent_runtime_supervisor_source_is_plan(&task.source) { - // 强判据已由函数开头的守卫执行过,这里不重复读 durable 状态。 - AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE.to_string() } else { "agent-background-task".to_string() }; @@ -901,45 +888,23 @@ pub(crate) fn retry_game_creator_agent_runtime_task_at( &task, retry_link.is_some(), )?; - let planning_retry = agent_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID; - let (mut result, actual_retry_run_id) = if planning_retry { - // Planning enqueue owns both locks. Keep the global order identical - // to ordinary/delegated starts: project first, Session lane second. - // The locked entry also projects the retry child before it can start. - let project_lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( - root, - "planning.child-session.retry", - )?; - start_game_creator_agent_background_task_with_link_locked_at( - root, - &agent_id, - Some(&task.session_id), - &task.task, - &retry_run_id, - &retry_source, - Some(&retry_run_profile), - retry_link.as_ref(), - &project_lock, - )? - } else { - with_agent_conversation_session_lane_at( - root, - &agent_id, - "Agent Runtime 重试入队", - || { - start_game_creator_agent_background_task_with_link_in_session_lane_at( - root, - &agent_id, - Some(&task.session_id), - &task.task, - &retry_run_id, - &retry_source, - Some(&retry_run_profile), - retry_link.as_ref(), - ) - }, - )? - }; + let (mut result, actual_retry_run_id) = with_agent_conversation_session_lane_at( + root, + &agent_id, + "Agent Runtime 重试入队", + || { + start_game_creator_agent_background_task_with_link_in_session_lane_at( + root, + &agent_id, + Some(&task.session_id), + &task.task, + &retry_run_id, + &retry_source, + Some(&retry_run_profile), + 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(); @@ -967,16 +932,12 @@ pub(crate) fn retry_game_creator_agent_runtime_task_at( }), )?; result.accepted_run_id = Some(actual_retry_run_id.clone()); - if !planning_retry { - // The planning locked entry performs this notification after releasing - // its Session lane; the legacy in-lane entry deliberately does not. - notify_external_agent_runner_after_background_task_enqueue( - root, - &agent_id, - &task.session_id, - &actual_retry_run_id, - )?; - } + notify_external_agent_runner_after_background_task_enqueue( + root, + &agent_id, + &task.session_id, + &actual_retry_run_id, + )?; Ok(result) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs index abcd741a8..7e17273aa 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs @@ -1,135 +1,5 @@ use super::*; -/// 策划子 Agent 的 `AGC_NEEDS_USER_INPUT_V1` 信封在 final reply 里写坏时,允许在 -/// **同一个 run 内**重取几次。 -/// -/// 原型(local-scripts/deisgn_agent)把信封解析失败当成「本回合没推进流程」,注入 -/// 错误后在同一条 messages 上重来,`MAX_WASTED_TURNS=3`(前两次注入重试,第三次判 -/// 本跳失败)。这里取同一口径:2 次重取,用尽后才让它按既有路径落成 needs-repair。 -/// -/// 为什么必须在这里拦:信封一旦随 final reply 逃逸,run 就终止并变成一条 -/// needs-repair delivery,之后返工深度、澄清轮次、session 段落三套不变量都会把它 -/// 当成「新段落」,而它们编码的是同一条假设——新委派 = 新段落。实测一次字节级截断 -/// 就能吃掉整条委派唯一的返工额度,两次则直接把父 run 打进 needs-reconciliation。 -const AGENT_RUNTIME_PLAN_ENVELOPE_REPAIR_ATTEMPTS: u32 = 2; - -/// 返回本条 final reply 里坏掉的信封的解析原因;不是策划子 Agent、或正文压根没有 -/// 信封首行时返回 None(后者是「这一轮不提问」的正常收束)。 -fn game_creator_agent_runtime_plan_envelope_parse_error( - agent_id: &str, - reply: &str, -) -> Option { - if agent_id != GAME_CREATOR_PROJECT_PLANNING_AGENT_ID { - return None; - } - parse_static_delegate_user_input_request(Some(reply)).err() -} - -/// 坏信封回灌给下一次请求的 observation。 -/// -/// attempt 0 是工具计划直出那一发被拒(stream=false 时 plan.response 充当最终回复, -/// 它不经过重取回路),1..=N 是最终回复请求上的第几次重取。两处必须用同一条正文, -/// 否则模型会以为是两种不同的失败。 -fn plan_envelope_repair_observation( - parse_error: &str, - attempt: u32, -) -> AgentRuntimeToolObservation { - let stage = if attempt == 0 { - "工具计划直出,改由最终回复重出".to_string() - } else { - format!("第 {attempt}/{AGENT_RUNTIME_PLAN_ENVELOPE_REPAIR_ATTEMPTS} 次重取") - }; - AgentRuntimeToolObservation { - tool: "runtime.plan_envelope".to_string(), - status: "failed".to_string(), - summary: format!("AGC_NEEDS_USER_INPUT_V1 信封无法解析({stage}):{parse_error}"), - detail: Some( - "原样重出同一个问题,不要改写题面或选项;信封必须是首行 AGC_NEEDS_USER_INPUT_V1,下一行严格 JSON 且完整闭合到最外层。不要输出 markdown、代码围栏或第三行正文。".to_string(), - ), - } -} - -struct PlanGddBlockerRuntimeProjection { - phase: &'static str, - current_action: &'static str, - waiting_on: &'static str, - next_step: &'static str, -} - -/// `runtime.plan_gdd` blocker 的类型化子状态 → 运行时投影。 -/// -/// 抽成纯函数是为了让这条分支可测:主循环整个文件此前没有 `mod tests`,而原来的 -/// 判别是对 detail 做 `contains("approvalPending=awaiting_decision")`——三个 blocked -/// 子状态里只有一个含这个子串,另外两个会掉进 else 被打成 needs-reconciliation, -/// 把最正常的早期推进态和收尾态当成故障停掉。判不出 kind 时保持 fail-closed。 -fn plan_gdd_blocker_runtime_projection( - kind: Option, -) -> PlanGddBlockerRuntimeProjection { - match kind { - Some(PlanGddCompletionBlockerKind::SubmissionNotStarted) => { - PlanGddBlockerRuntimeProjection { - phase: "planning", - current_action: "推进本根 Run 的 Fast GDD 提交", - waiting_on: "策划子 Agent 完成本根 Run 的 plan.submit_gdd", - next_step: "调用 agent.delegate 派出策划子 Agent;上一根 Run 遗留的 game/fast_gdd.md 或 Acceptance Graph 不能代替本根提交", - } - } - Some(PlanGddCompletionBlockerKind::AwaitingApprovalDecision) => { - PlanGddBlockerRuntimeProjection { - phase: "waiting-for-user-input", - current_action: "等待 Fast GDD 审批决定", - waiting_on: "用户在审批卡选择批准、修改或退回", - next_step: "等待 decide_game_creator_plan_gdd;不得重新提交同一 GDD 或自行创建审批 pending", - } - } - Some(PlanGddCompletionBlockerKind::ReceiptAnchorCleanupPending) => { - PlanGddBlockerRuntimeProjection { - phase: "planning", - current_action: "等待 Fast GDD 审批投影收尾", - waiting_on: "原 plan.submit_gdd 恢复锚点由审批投影清理", - next_step: "等待审批投影恢复清理锚点后继续;不得重新提交同一 GDD 或自行创建审批 pending", - } - } - Some(PlanGddCompletionBlockerKind::NeedsReconciliation) | None => { - PlanGddBlockerRuntimeProjection { - phase: "needs-reconciliation", - current_action: "Fast GDD 审批投影需要人工核对", - waiting_on: - "planning pending、receipt、原提交锚点、terminal observation、audit 与 session 的精确身份", - next_step: "先恢复或核对现有 durable 事实,不能请求新 Provider 计划", - } - } - } -} - -/// 只有「等用户决定」和「要人工核对」才终结本轮后台任务;尚未提交与锚点收尾都是 -/// 继续推进态,和 `runtime.plan_update` 一样不产生等待态,让本轮循环继续。 -fn plan_gdd_blocker_waiting_kind( - kind: Option, -) -> Option<( - &'static str, - &'static str, - &'static str, - AgentBackgroundTaskOutcome, -)> { - match kind { - Some(PlanGddCompletionBlockerKind::AwaitingApprovalDecision) => Some(( - "agent.runtime.plan.gdd.waiting", - "waiting-for-user-input", - "Fast GDD 审批等待状态持久化失败", - AgentBackgroundTaskOutcome::WaitingForUserInput, - )), - Some(PlanGddCompletionBlockerKind::NeedsReconciliation) | None => Some(( - "agent.runtime.plan.gdd.reconciliation", - "needs-reconciliation", - "Fast GDD 审批投影需要人工核对", - AgentBackgroundTaskOutcome::NeedsReconciliation, - )), - Some(PlanGddCompletionBlockerKind::SubmissionNotStarted) - | Some(PlanGddCompletionBlockerKind::ReceiptAnchorCleanupPending) => None, - } -} - pub(in crate::agent) fn autonomous_registered_derived_visuals_need_repair_at(root: &Path) -> bool { let Ok(manifest) = read_manifest_for_project(root) else { return false; @@ -214,40 +84,6 @@ fn plan_submit_error_is_business_rejection(error: &PlanningStorageError) -> bool matches!(error.code(), "PLAN_INVALID_REQUEST" | "PLAN_SIZE_LIMIT") } -/// A malformed Fast GDD is useful feedback for the planning child, but it -/// must not let one run replay an ever-growing prompt forever. Keep this -/// counter on the durable Runtime state rather than only in the in-memory -/// continuation: a process restart is part of the failure chain we bound. -const PLAN_SUBMIT_GDD_BUSINESS_REJECTION_LIMIT: u32 = 5; - -fn plan_submit_business_rejection_limit_reached(rejection_count: u32) -> bool { - rejection_count >= PLAN_SUBMIT_GDD_BUSINESS_REJECTION_LIMIT -} - -fn next_plan_submit_business_rejection_count(current: u32) -> (u32, bool) { - let next = current.saturating_add(1); - (next, plan_submit_business_rejection_limit_reached(next)) -} - -fn finish_plan_submit_business_rejection_limit_at( - root: &Path, - runtime: &AgentRuntimeState, -) -> Result { - let error = format!( - "Fast GDD 连续 {} 次未通过 Runtime 校验,已停止自动续跑;请检查最后一次拒绝 observation 后重新发起策划。", - PLAN_SUBMIT_GDD_BUSINESS_REJECTION_LIMIT - ); - let failed = fail_game_creator_agent_runtime_turn_at(root, runtime.clone(), &error)?; - let _ = append_game_creator_agent_background_task_failed_audit( - root, - &failed, - AGENT_RUNTIME_BACKGROUND_FAILURE_KIND_PLAN_SUBMIT_REJECTION_LIMIT, - ); - Ok(AgentBackgroundTaskOutcome::Finished) -} - -/// 摘掉工具还继续空转,说明自愈失败。和 Fast GDD 拒绝限额同理:把它记在持久 -/// Runtime state 上,进程重启不能把一次活锁洗成新的无限 Provider 开销。 const AGENT_RUNTIME_PLAN_UPDATE_IDLE_LIMIT: u32 = 4; /// 最终回复被收束门禁拦下后 run 会原地续跑重试。多数 blocker 是模型自己能解的 @@ -348,7 +184,6 @@ mod plan_update_idle_guard_tests { "file.write", "project.patchset", "command.exec", - PLAN_SUBMIT_GDD_TOOL, ] { assert!( agent_runtime_action_can_advance_progress(tool), @@ -384,7 +219,6 @@ mod plan_update_idle_guard_threshold_tests { #[test] fn the_stale_finalization_backstop_sits_above_the_self_healing_guards() { assert!(AGENT_RUNTIME_STALE_FINALIZATION_LIMIT > AGENT_RUNTIME_PLAN_UPDATE_IDLE_LIMIT); - assert!(AGENT_RUNTIME_STALE_FINALIZATION_LIMIT > PLAN_SUBMIT_GDD_BUSINESS_REJECTION_LIMIT); assert!(!stale_finalization_limit_reached( AGENT_RUNTIME_STALE_FINALIZATION_LIMIT - 1 )); @@ -425,82 +259,7 @@ fn finish_plan_update_idle_limit_at( Ok(AgentBackgroundTaskOutcome::Finished) } -/// A strict submit payload rejection is a normal planning observation, not a -/// Provider/lifecycle reconciliation failure. Close the exact sole-action -/// batch, persist the rejected observation, and return a same-run continuation -/// so the planning child can correct its payload in the next tool-plan turn. -fn project_plan_submit_business_rejection_at( - root: &Path, - runtime: &mut AgentRuntimeState, - task: &str, - plan: &AgentRuntimeToolPlan, - observations: &mut Vec, - loop_index: usize, - context_tracker: &mut AgentRuntimeContextWindowTracker, - pending: &AgentRuntimePendingToolAction, - error: &PlanningStorageError, -) -> Result { - let batch = read_game_creator_agent_runtime_provider_action_batch( - root, - &pending.agent_id, - &pending.run_id, - )?; - if !is_plan_submit_gdd_provider_action_batch(&batch) - || batch.actions.len() != 1 - || batch.actions[0].action_id != pending.action_id - || batch.actions[0].action_fingerprint != pending.action_fingerprint - || batch.actions[0].action != pending.action - { - return Err( - "plan.submit_gdd 业务拒绝时 Provider v4 batch/pending identity 不一致".to_string(), - ); - } - let public_error = redact_agent_runtime_error(root, &error.to_string(), 500); - let observation = AgentRuntimeToolObservation { - tool: PLAN_SUBMIT_GDD_TOOL.to_string(), - status: "rejected".to_string(), - summary: "Fast GDD 提交被 Runtime 拒绝,请根据 observation 修正后重新提交。".to_string(), - detail: Some(public_error), - }; - let (rejection_count, exhausted) = - next_plan_submit_business_rejection_count(runtime.plan_submit_gdd_rejection_count); - runtime.plan_submit_gdd_rejection_count = rejection_count; - let mut rejected = pending.clone(); - rejected.execution_mode = AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO.to_string(); - rejected.status = AGENT_RUNTIME_PENDING_ACTION_STATUS_OBSERVED_REJECTED.to_string(); - rejected.observation = Some(observation.clone()); - rejected.updated_at = unix_timestamp(); - project_game_creator_agent_runtime_provider_batch_abort( - root, - runtime, - task, - plan, - observations, - loop_index, - context_tracker, - &batch, - &rejected, - &observation, - )?; - if exhausted { - return finish_plan_submit_business_rejection_limit_at(root, runtime); - } - let continuation = continuation_for_game_creator_agent_runtime_steer( - runtime, - &AgentRuntimeToolPlan::default(), - observations, - loop_index.saturating_add(1), - context_tracker, - ); - Ok(AgentBackgroundTaskOutcome::ContinueSameRun { - state: runtime.clone(), - continuation, - }) -} - const AGENT_RUNTIME_BACKGROUND_FAILURE_KIND_TOOL_PLAN: &str = "tool-plan-failed"; -const AGENT_RUNTIME_BACKGROUND_FAILURE_KIND_PLAN_SUBMIT_REJECTION_LIMIT: &str = - "plan-submit-validation-retries-exhausted"; const AGENT_RUNTIME_BACKGROUND_FAILURE_KIND_PLAN_UPDATE_IDLE_LIMIT: &str = "plan-update-idle-rounds-exhausted"; const AGENT_RUNTIME_BACKGROUND_FAILURE_KIND_STALE_FINALIZATION_LIMIT: &str = @@ -546,25 +305,6 @@ async fn run_game_creator_agent_background_task_pass_without_deadline( let mut context_stalled = continuation.context_stalled; let relaxed_autonomous = autonomous_relaxed_profile(&runtime); - // `project_game_creator_agent_runtime_provider_batch_abort` persists the - // rejection counter together with the rejected observation before this - // terminal transition. A crash between those two durable steps must not - // turn the fifth rejection into a sixth Provider request after recovery. - if !relaxed_autonomous - && plan_submit_business_rejection_limit_reached(runtime.plan_submit_gdd_rejection_count) - { - return match finish_plan_submit_business_rejection_limit_at(&root, &runtime) { - Ok(outcome) => outcome, - Err(error) => fail_game_creator_agent_background_context_at( - &root, - &agent_id, - &session_id, - runtime, - &format!("收束已耗尽的 Fast GDD 提交拒绝失败:{error}"), - ), - }; - } - // 和上面同理:计数已经随上一轮的 blocker 一起落盘,恢复后不能把第 N 次空转 // 变成第 N+1 次 Provider 请求。 if !relaxed_autonomous && plan_update_idle_limit_reached(runtime.plan_update_idle_rounds) { @@ -1001,7 +741,6 @@ async fn run_game_creator_agent_background_task_pass_without_deadline( }; let mut planning_request_revision: AgentRuntimeProjectRevision; let mut planning_repository_context_fingerprint: String; - let mut planning_session_binding: Option; let action_start_index: usize; // 本轮 update_agent_plan 的分型结果,供下面的计划空转守卫判据使用。 // 恢复既有批次的那一支不会新提交计划,保持 None 即可:那一支本来就带着 @@ -1053,7 +792,6 @@ async fn run_game_creator_agent_background_task_pass_without_deadline( planning_request_revision = batch.project_revision_before.clone(); planning_repository_context_fingerprint = batch.planned_repository_context_fingerprint.clone(); - planning_session_binding = batch.planning_session_binding.clone(); action_start_index = usize::try_from(batch.next_action_index).unwrap_or(usize::MAX); if action_start_index >= plan.actions.len() { return fail_game_creator_agent_background_context_at( @@ -1361,7 +1099,6 @@ async fn run_game_creator_agent_background_task_pass_without_deadline( ); } planning_repository_context_fingerprint = requested_plan.repository_context_fingerprint; - planning_session_binding = requested_plan.planning_session_binding.clone(); plan = requested_plan.plan; match refresh_agent_runtime_autonomous_convergence_snapshot_after_provider_at( &root, @@ -1694,9 +1431,6 @@ async fn run_game_creator_agent_background_task_pass_without_deadline( runtime.stale_finalization_rounds = 0; } if plan.actions.is_empty() { - // blocked 的 plan_gdd blocker 有三种截然不同的继续推进态,phase 与 - // next_step 必须按类型化子状态选,不能回去猜 detail 字符串。 - let mut plan_gdd_blocker_kind: Option = None; let completion_blocker = if relaxed_autonomous { // In the free-form lane only an in-flight provider batch, a // live process session, or the minimal root-entry check may @@ -1720,17 +1454,6 @@ async fn run_game_creator_agent_background_task_pass_without_deadline( &runtime.run_id, ) }) - .or_else(|| { - plan_gdd_typed_completion_blocker_at_locked( - &root, - &agent_id, - &runtime.run_id, - ) - .map(|blocker| { - plan_gdd_blocker_kind = Some(blocker.kind); - blocker.observation - }) - }) .or_else(|| { game_creator_agent_goal_completion_blocker_at_locked(&root, &runtime) }) @@ -1848,13 +1571,6 @@ async fn run_game_creator_agent_background_task_pass_without_deadline( runtime.current_action = "等待 Provider action 批次收束".to_string(); runtime.waiting_on = "持久批次完成确认、执行、投影与 cursor 清理".to_string(); runtime.next_step = "先恢复原批次,不能请求新计划或提交最终回复".to_string(); - } else if blocker.tool == "runtime.plan_gdd" { - runtime.status = "running".to_string(); - let projection = plan_gdd_blocker_runtime_projection(plan_gdd_blocker_kind); - runtime.phase = projection.phase.to_string(); - runtime.current_action = projection.current_action.to_string(); - runtime.waiting_on = projection.waiting_on.to_string(); - runtime.next_step = projection.next_step.to_string(); } else if blocker.tool == "runtime.collaboration_policy" { runtime.status = "running".to_string(); runtime.phase = "planning".to_string(); @@ -1999,8 +1715,6 @@ async fn run_game_creator_agent_background_task_pass_without_deadline( "持久化专业 Agent 回执等待状态失败", AgentBackgroundTaskOutcome::WaitingForDelegateReceipts, )) - } else if observation.tool == "runtime.plan_gdd" { - plan_gdd_blocker_waiting_kind(plan_gdd_blocker_kind) } else { None } @@ -2117,27 +1831,8 @@ async fn run_game_creator_agent_background_task_pass_without_deadline( "Agent 工具计划最终回复去除 thinking 后为空", ); } - // 策划子 Agent 的澄清信封实际产在**工具计划那一发**:stream=false 时 - // plan.response 直接充当最终回复,下面那条 final-reply 请求根本不发, - // 于是它后面的信封重取回路一次都进不去。现场 68 条信封全部落在这条 - // 短路上(response-streams 里 finishReason 一律为 null 就是它的签名), - // 7 条坏信封也全在这里逃逸成 needs-repair 委派。 - // - // 坏信封在这里不认最终回复,把解析原因作为 observation 回灌,让流程 - // 落回真正的 final-reply 请求——重取回路在那边才生效。 - match game_creator_agent_runtime_plan_envelope_parse_error(&agent_id, &response) - { - Some(parse_error) => { - let observation = plan_envelope_repair_observation(&parse_error, 0); - runtime.observations.push(observation.summary()); - context_tracker.record(&observation); - observations.push(observation); - } - None => { - final_reply = Some(response); - final_reply_revision = Some(planning_request_revision.revision); - } - } + final_reply = Some(response); + final_reply_revision = Some(planning_request_revision.revision); } } observations = sanitize_game_creator_agent_runtime_context_observations_for_storage( @@ -2200,7 +1895,7 @@ async fn run_game_creator_agent_background_task_pass_without_deadline( &runtime.run_id, ) { - match prepare_game_creator_agent_runtime_provider_action_batch_with_planning_binding( + match prepare_game_creator_agent_runtime_provider_action_batch( &root, &runtime, &task, @@ -2208,7 +1903,6 @@ async fn run_game_creator_agent_background_task_pass_without_deadline( &observations, &planning_request_revision, &planning_repository_context_fingerprint, - planning_session_binding.as_ref(), ) .await { @@ -2670,14 +2364,9 @@ async fn run_game_creator_agent_background_task_pass_without_deadline( let mut pending_action = prepared_action .take() .expect("prepared user input action exists"); - if runtime.run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD - || runtime.agent_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID - { - let reason = if runtime.agent_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID { - "project-planning 子 Agent 不允许 user.input_request 进入等待态" - } else { - "自主构建 Run 的 user.input_request 绕过了 Provider action 预检,已拒绝进入等待态" - }; + if runtime.run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD { + let reason = + "自主构建 Run 的 user.input_request 绕过了 Provider action 预检,已拒绝进入等待态"; let _ = mark_game_creator_agent_runtime_needs_reconciliation_at( &root, &mut runtime, @@ -2704,18 +2393,6 @@ async fn run_game_creator_agent_background_task_pass_without_deadline( } return AgentBackgroundTaskOutcome::WaitingForUserInput; } - if agent_runtime_tool_rejected_by_agent_identity(&agent_id, action.tool.trim()) { - let pending_action = prepared_action - .as_ref() - .expect("prepared action exists for an identity-rejected tool"); - let _ = mark_game_creator_agent_runtime_needs_reconciliation_at( - &root, - &mut runtime, - pending_action, - "当前 Agent 身份不允许执行该工具,已拒绝进入执行层", - ); - return AgentBackgroundTaskOutcome::NeedsReconciliation; - } let command_id = game_creator_agent_runtime_tool_command_id(action.tool.trim()); let action_fingerprint = prepared_action .as_ref() @@ -2730,13 +2407,7 @@ async fn run_game_creator_agent_background_task_pass_without_deadline( let confirmation_approved = prepared_action .as_ref() .is_some_and(|pending| !pending.is_auto() && pending.approved()); - let local_policy_block = if action.tool.trim() == PLAN_SUBMIT_GDD_TOOL { - // `plan.submit_gdd` is a Runtime-owned commit action. It is - // intentionally handled below before the generic policy / - // executor path; the planning-only catalog and batch shape - // checks are its authorization boundary. - None - } else { + let local_policy_block = { command_id.and_then(|command_id| { if confirmation_approved { match game_creator_agent_runtime_tool_policy_rule_for_run( @@ -2761,152 +2432,14 @@ async fn run_game_creator_agent_background_task_pass_without_deadline( } }) }; - let policy_block = if action.tool.trim() == PLAN_SUBMIT_GDD_TOOL { - None - } else { - fail_closed_agent_runtime_confirmation_for_run( - &root, - &agent_id, - &runtime.run_id, - Some(&runtime.run_profile), - Some(&runtime.run_profile_binding_fingerprint), - local_policy_block, - ) - }; - if action.tool.trim() == PLAN_SUBMIT_GDD_TOOL { - // `plan.submit_gdd` is a dedicated commit state machine. It - // must not be allowed to fall through the generic observation, - // terminal-receipt, batch-cursor or final-reply paths. - let Some(mut pending_action) = prepared_action.take() else { - return fail_game_creator_agent_background_context_at( - &root, - &agent_id, - &session_id, - runtime, - "plan.submit_gdd 缺少 durable pending action identity", - ); - }; - if pending_action.execution_mode != AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO - || !matches!( - pending_action.status.as_str(), - AGENT_RUNTIME_PENDING_ACTION_STATUS_APPROVED - | AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING - ) - { - let _ = mark_game_creator_agent_runtime_needs_reconciliation_at( - &root, - &mut runtime, - &pending_action, - "plan.submit_gdd pending action 不是严格 auto/approved(or executing) 形状", - ); - return AgentBackgroundTaskOutcome::NeedsReconciliation; - } - pending_action.updated_at = unix_timestamp(); - if let Err(error) = - write_game_creator_agent_runtime_pending_tool_action(&root, &pending_action) - { - let _ = mark_game_creator_agent_runtime_needs_reconciliation_at( - &root, - &mut runtime, - &pending_action, - &format!("plan.submit_gdd 执行前 pending 无法持久化:{error}"), - ); - return AgentBackgroundTaskOutcome::NeedsReconciliation; - } - let action_is_current = - match mark_game_creator_agent_runtime_auto_action_executing_if_current( - &root, - &mut pending_action, - ) { - Ok(current) => current, - Err(error) => { - let _ = mark_game_creator_agent_runtime_needs_reconciliation_at( - &root, - &mut runtime, - &pending_action, - &format!( - "plan.submit_gdd 尚未执行,但无法持久化 executing 状态:{error}" - ), - ); - return AgentBackgroundTaskOutcome::NeedsReconciliation; - } - }; - if !action_is_current { - let _ = mark_game_creator_agent_runtime_needs_reconciliation_at( - &root, - &mut runtime, - &pending_action, - "plan.submit_gdd action 在执行前被 steer cursor 作废", - ); - return AgentBackgroundTaskOutcome::NeedsReconciliation; - } - match execute_plan_submit_gdd_for_pending_action(&root, &runtime, &pending_action) { - Ok(result) if !result.recovery_pending => { - if let Err(error) = ensure_project_planning_submit_child_completion_at( - &root, - &mut runtime, - &pending_action, - &result, - ) { - let _ = mark_game_creator_agent_runtime_needs_reconciliation_at( - &root, - &mut runtime, - &pending_action, - &format!("GDD 提交后子 Run 收口失败:{error}"), - ); - return AgentBackgroundTaskOutcome::NeedsReconciliation; - } - return AgentBackgroundTaskOutcome::Finished; - } - Ok(result) => { - let _ = mark_game_creator_agent_runtime_needs_reconciliation_at( - &root, - &mut runtime, - &pending_action, - &format!( - "GDD v{} 已越过提交点但投影尚未收口(recoveryPending=true)", - result.gdd_ref.version - ), - ); - return AgentBackgroundTaskOutcome::NeedsReconciliation; - } - Err(error) => { - if plan_submit_error_is_business_rejection(&error) { - match project_plan_submit_business_rejection_at( - &root, - &mut runtime, - &task, - &plan, - &mut observations, - loop_index, - &mut context_tracker, - &pending_action, - &error, - ) { - Ok(outcome) => return outcome, - Err(projection_error) => { - let _ = mark_game_creator_agent_runtime_needs_reconciliation_at( - &root, - &mut runtime, - &pending_action, - &format!( - "Fast GDD 业务拒绝 observation 投影失败:{projection_error}" - ), - ); - return AgentBackgroundTaskOutcome::NeedsReconciliation; - } - } - } - let _ = mark_game_creator_agent_runtime_needs_reconciliation_at( - &root, - &mut runtime, - &pending_action, - &format!("Fast GDD 提交未收口:{error}"), - ); - return AgentBackgroundTaskOutcome::NeedsReconciliation; - } - } - } + let policy_block = fail_closed_agent_runtime_confirmation_for_run( + &root, + &agent_id, + &runtime.run_id, + Some(&runtime.run_profile), + Some(&runtime.run_profile_binding_fingerprint), + local_policy_block, + ); let mut durable_action = None; let observation = if let Some(blocked) = policy_block { agent_runtime_tool_policy_block_observation(action.tool.trim(), blocked) @@ -3782,56 +3315,20 @@ async fn run_game_creator_agent_background_task_pass_without_deadline( &format!("持久化最终回复请求上下文失败:{error}"), ); } - // 坏信封在这里就地重取,不让它随 final reply 逃逸成一条 needs-repair 委派。 - // 与 provider_tool_plan 对工具计划协议错误的定向修复同构:把解析原因作为 - // observation 回灌,下一次 final-reply 请求就带着它。 - let mut envelope_repair_attempt = 0u32; - let final_reply_result = loop { - let attempt_slot = if envelope_repair_attempt == 0 { - final_reply_request_slot.clone() - } else { - format!("{final_reply_request_slot}-envelope-repair-{envelope_repair_attempt}") - }; - let attempt_result = request_game_creator_agent_background_final_reply_at( - &root, - &agent_id, - &runtime.session_id, - &runtime.run_id, - &task, - &plan, - final_reply_fallback.as_deref(), - &observations, - runtime.applied_steer_cursor, - &attempt_slot, - response_revision, - ) - .await; - let parse_error = match &attempt_result { - Ok(RequestedAgentRuntimeFinalReplyOutcome::Ready(Some(requested_reply))) => { - game_creator_agent_runtime_plan_envelope_parse_error( - &agent_id, - &requested_reply.reply, - ) - } - _ => None, - }; - let Some(parse_error) = parse_error else { - break attempt_result; - }; - if envelope_repair_attempt >= AGENT_RUNTIME_PLAN_ENVELOPE_REPAIR_ATTEMPTS { - break attempt_result; - } - envelope_repair_attempt += 1; - let observation = - plan_envelope_repair_observation(&parse_error, envelope_repair_attempt); - let observation_summary = observation.summary(); - runtime.observations.push(observation_summary); - context_tracker.record(&observation); - observations.push(observation); - if stop_game_creator_agent_runtime_if_cancel_requested(&root, &mut runtime) { - return AgentBackgroundTaskOutcome::Finished; - } - }; + let final_reply_result = request_game_creator_agent_background_final_reply_at( + &root, + &agent_id, + &runtime.session_id, + &runtime.run_id, + &task, + &plan, + final_reply_fallback.as_deref(), + &observations, + runtime.applied_steer_cursor, + &final_reply_request_slot, + response_revision, + ) + .await; if stop_game_creator_agent_runtime_if_cancel_requested(&root, &mut runtime) { return AgentBackgroundTaskOutcome::Finished; } @@ -4125,211 +3622,3 @@ async fn run_game_creator_agent_background_task_pass_without_deadline( } } } - -#[cfg(test)] -mod plan_envelope_repair_tests { - use super::*; - - const TRUNCATED: &str = "AGC_NEEDS_USER_INPUT_V1 -{\"questions\":[{\"id\":\"core_loop\",\"header\":\"第1轮·当前要决定:核心闭环形状\",\"question\":\"?\",\"options\":[{\"label\":\"A\",\"description\":\"甲\"}]}"; - const COMPLETE: &str = "AGC_NEEDS_USER_INPUT_V1 -{\"questions\":[{\"id\":\"core_loop\",\"header\":\"第1轮·当前要决定:核心闭环形状\",\"question\":\"?\",\"options\":[{\"label\":\"A\",\"description\":\"甲\"},{\"label\":\"B\",\"description\":\"乙\"}]}]}"; - - /// 截断的信封必须在 run 内被认出来,否则它会随 final reply 逃逸成一条 - /// needs-repair 委派,把返工额度和澄清轮次一起卷进去。 - #[test] - fn a_truncated_planning_envelope_is_detected_before_the_run_ends() { - let error = game_creator_agent_runtime_plan_envelope_parse_error( - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - TRUNCATED, - ) - .expect("truncated envelope must be reported"); - assert!(error.contains("JSON"), "{error}"); - } - - /// 完整信封与普通收尾文本都不能触发重取——后者是「这一轮不提问」的正常终态。 - #[test] - fn complete_envelopes_and_plain_replies_do_not_trigger_a_repair() { - assert!(game_creator_agent_runtime_plan_envelope_parse_error( - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - COMPLETE - ) - .is_none()); - assert!(game_creator_agent_runtime_plan_envelope_parse_error( - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - "已按默认建议补齐剩余空白,GDD 已提交待审批。" - ) - .is_none()); - } - - /// 只覆盖立项策划链路。做游戏 / 做素材的静态委派子 Agent 逐字保持既有行为: - /// 它们的坏信封仍旧按原路落成 needs-repair,不在这里被拦下重取。 - #[test] - fn other_agents_keep_the_existing_escape_path() { - for agent_id in ["design-director", "art-director", "project-supervisor"] { - assert!( - game_creator_agent_runtime_plan_envelope_parse_error(agent_id, TRUNCATED).is_none(), - "{agent_id} 不应进入策划信封重取" - ); - } - } - - /// 重取次数取原型的 MAX_WASTED_TURNS=3 口径:前两次注入重来,第三次放行落盘。 - #[test] - fn the_repair_budget_matches_the_prototype() { - assert_eq!(AGENT_RUNTIME_PLAN_ENVELOPE_REPAIR_ATTEMPTS, 2); - } - - /// 现场原样抓来的退化尾巴:信封本体完整,模型在同一个字符串里多吐了一段垃圾。 - /// 配平定界之后它是一条好信封,绝不能再烧掉一次重取额度——重取的成本是一整发 - /// provider 请求,而这一条本来就该直接放行。 - #[test] - fn a_degenerated_tail_no_longer_burns_a_repair_attempt() { - let reply = concat!( - "AGC_NEEDS_USER_INPUT_V1\n", - r#"{"questions":[{"id":"replay_progression","header":"第2轮·当前要决定:自由经营农场的长期目标","question":"它决定玩家为何持续规划、赚钱与重玩,也控制 MVP 的范围。","options":[{"label":"A · 推荐:里程碑升级+成就","description":"以累计资金解锁少量新地块或设施。"},{"label":"B · 专注农场扩建","description":"只用经营收益逐步解锁地块与设施。"},{"label":"需要原型验证","description":"制作微型原型让目标玩家试玩两种目标结构。"}]}]}સwerhu рҭ. 北京赛车? тру. [ ]"#, - ); - assert!(game_creator_agent_runtime_plan_envelope_parse_error( - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - reply - ) - .is_none()); - } - - /// 两个注入点必须给出同一条整改正文,否则模型会把它读成两种不同的失败。 - /// attempt 0 是工具计划直出那一发被拒——现场 7 条坏信封全部走这条路,而它 - /// 恰恰是重取回路唯一进不去的地方。 - #[test] - fn both_injection_points_share_one_instruction_and_name_their_stage() { - let first = plan_envelope_repair_observation("信封 JSON 括号不闭合", 0); - let retry = plan_envelope_repair_observation("信封 JSON 括号不闭合", 1); - assert_eq!(first.tool, "runtime.plan_envelope"); - assert_eq!(first.status, "failed"); - assert_eq!(first.detail, retry.detail); - assert!(first.summary.contains("工具计划直出"), "{}", first.summary); - assert!(retry.summary.contains("第 1/2 次重取"), "{}", retry.summary); - for observation in [&first, &retry] { - assert!(observation.summary.contains("括号不闭合")); - } - } -} - -#[cfg(test)] -mod plan_gdd_blocker_projection_tests { - use super::*; - - /// 三个 blocked 子状态必须落到各自的 phase。旧的子串判别只认得 - /// AwaitingApprovalDecision,另外两个会被打成 needs-reconciliation。 - #[test] - fn each_blocked_kind_projects_its_own_phase() { - assert_eq!( - plan_gdd_blocker_runtime_projection(Some( - PlanGddCompletionBlockerKind::SubmissionNotStarted - )) - .phase, - "planning" - ); - assert_eq!( - plan_gdd_blocker_runtime_projection(Some( - PlanGddCompletionBlockerKind::AwaitingApprovalDecision - )) - .phase, - "waiting-for-user-input" - ); - assert_eq!( - plan_gdd_blocker_runtime_projection(Some( - PlanGddCompletionBlockerKind::ReceiptAnchorCleanupPending - )) - .phase, - "planning" - ); - } - - /// 判不出 kind 与显式的人工核对一样,保持 fail-closed。 - #[test] - fn reconciliation_and_unknown_kind_stay_fail_closed() { - assert_eq!( - plan_gdd_blocker_runtime_projection(Some( - PlanGddCompletionBlockerKind::NeedsReconciliation - )) - .phase, - "needs-reconciliation" - ); - assert_eq!( - plan_gdd_blocker_runtime_projection(None).phase, - "needs-reconciliation" - ); - assert!(matches!( - plan_gdd_blocker_waiting_kind(None), - Some((_, _, _, AgentBackgroundTaskOutcome::NeedsReconciliation)) - )); - } - - /// 继续推进态不产生等待态,本轮后台任务不该在这里终结。 - #[test] - fn only_user_decision_and_reconciliation_end_the_background_task() { - assert!(matches!( - plan_gdd_blocker_waiting_kind(Some( - PlanGddCompletionBlockerKind::AwaitingApprovalDecision - )), - Some((_, "waiting-for-user-input", _, _)) - )); - assert!(matches!( - plan_gdd_blocker_waiting_kind(Some(PlanGddCompletionBlockerKind::NeedsReconciliation)), - Some((_, "needs-reconciliation", _, _)) - )); - assert!(plan_gdd_blocker_waiting_kind(Some( - PlanGddCompletionBlockerKind::SubmissionNotStarted - )) - .is_none()); - assert!(plan_gdd_blocker_waiting_kind(Some( - PlanGddCompletionBlockerKind::ReceiptAnchorCleanupPending - )) - .is_none()); - } - - /// The fifth rejected Fast-GDD payload is recorded, then stops the run; - /// it must not schedule a sixth Provider turn after a restart or a long - /// series of invalid serializations. - #[test] - fn plan_submit_business_rejection_limit_stops_on_the_fifth_rejection() { - for current in 0..PLAN_SUBMIT_GDD_BUSINESS_REJECTION_LIMIT - 1 { - let (next, exhausted) = next_plan_submit_business_rejection_count(current); - assert_eq!(next, current + 1); - assert!(!exhausted); - } - assert_eq!( - next_plan_submit_business_rejection_count(PLAN_SUBMIT_GDD_BUSINESS_REJECTION_LIMIT - 1), - (PLAN_SUBMIT_GDD_BUSINESS_REJECTION_LIMIT, true) - ); - } - - #[test] - fn plan_submit_business_rejection_limit_stays_terminal_after_recovery() { - assert!(!plan_submit_business_rejection_limit_reached( - PLAN_SUBMIT_GDD_BUSINESS_REJECTION_LIMIT - 1 - )); - assert!(plan_submit_business_rejection_limit_reached( - PLAN_SUBMIT_GDD_BUSINESS_REJECTION_LIMIT - )); - assert!(plan_submit_business_rejection_limit_reached(u32::MAX)); - } - - #[test] - fn plan_submit_version_limit_is_an_authority_boundary_not_provider_feedback() { - let version_limit = PlanningStorageError::new( - "PLAN_VERSION_LIMIT_REACHED", - "不能继续创建第 129 个 GDD 版本", - ); - assert!(plan_submit_error_is_business_rejection( - &PlanningStorageError::new("PLAN_INVALID_REQUEST", "候选 GDD 缺少标题") - )); - assert!(plan_submit_error_is_business_rejection( - &PlanningStorageError::new("PLAN_SIZE_LIMIT", "候选 GDD 超出大小上限") - )); - assert!( - !plan_submit_error_is_business_rejection(&version_limit), - "版本上限由既有 lineage 决定,重试相同 Provider submit 不会改变它" - ); - } -} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_recovery.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_recovery.rs index d27162e14..a46ddef59 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_recovery.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_recovery.rs @@ -570,209 +570,6 @@ pub(in crate::agent) fn resume_game_creator_agent_parallel_read_batch_at( Ok(AgentRuntimePendingActionResume::Handled(result)) } -/// A successful planning submit intentionally leaves its pending action and -/// v4 batch in place until the later receipt/observation consumer closes the -/// original action. Once the planning child has been projected terminal, -/// those sidecars are therefore recovery anchors, not ordinary terminal -/// garbage. Verify the immutable GDD against the frozen action identity -/// before treating the anchor as consumed; any mismatch must remain visible -/// as reconciliation rather than being silently deleted or replayed. -fn planning_submit_gdd_committed_for_pending_at( - root: &Path, - pending: &AgentRuntimePendingToolAction, -) -> Result { - if pending.action.tool.trim() != PLAN_SUBMIT_GDD_TOOL { - return Ok(false); - } - let binding = pending - .planning_session_binding - .as_ref() - .ok_or_else(|| "planning submit pending 缺少 frozen session binding".to_string())?; - validate_plan_provider_session_binding(binding).map_err(|error| error.to_string())?; - let chain = read_plan_gdd_chain(root).map_err(|error| error.to_string())?; - Ok(chain.iter().any(|gdd| { - gdd.submission_id == pending.action_id - && gdd.action_fingerprint == pending.action_fingerprint - && gdd.project_id == binding.project_id - && gdd.gdd_id == binding.gdd_id - && gdd.agent_id == pending.agent_id - && gdd.source == pending.source - && gdd.run_profile == pending.run_profile - && gdd.run_profile_binding_fingerprint == pending.run_profile_binding_fingerprint - && gdd.root_agent_id == binding.root_agent_id - && gdd.root_run_id == binding.root_run_id - && gdd.delegation_id == binding.delegation_id - && gdd.session_id == pending.session_id - && gdd.source_session_revision == binding.session_revision - && gdd.source_session_fingerprint == binding.session_fingerprint - && gdd.created_by_run_id == pending.run_id - })) -} - -fn planning_submit_pending_has_exact_committed_shape( - pending: &AgentRuntimePendingToolAction, -) -> bool { - pending.schema_version == AGENT_RUNTIME_PENDING_ACTION_SCHEMA_VERSION - && pending.action.tool.trim() == PLAN_SUBMIT_GDD_TOOL - && pending.action_index == 0 - && pending.execution_mode == AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO - && pending.status == AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING - && pending.observation.is_none() - && pending.planning_session_binding.is_some() -} - -fn planning_submit_batch_has_exact_committed_shape( - batch: &AgentRuntimeProviderActionBatch, -) -> bool { - is_plan_submit_gdd_provider_action_batch(batch) - && batch.status == AGENT_RUNTIME_PROVIDER_ACTION_BATCH_STATUS_READY - && batch.next_action_index == 0 - && batch.actions.len() == 1 - && planning_submit_pending_has_exact_committed_shape(&batch.actions[0]) -} - -fn planning_submit_batch_matches_pending( - batch: &AgentRuntimeProviderActionBatch, - pending: &AgentRuntimePendingToolAction, -) -> bool { - planning_submit_batch_has_exact_committed_shape(batch) - && planning_submit_pending_has_exact_committed_shape(pending) - && batch.actions[0] == *pending -} - -fn rebuild_missing_committed_plan_submit_batch_at( - root: &Path, - pending: &AgentRuntimePendingToolAction, -) -> Result { - if !planning_submit_pending_has_exact_committed_shape(pending) { - return Err("planning submit pending 不是 exact auto/executing 提交锚点".to_string()); - } - let binding = pending - .planning_session_binding - .as_ref() - .ok_or_else(|| "planning submit pending 缺少 frozen session binding".to_string())?; - validate_plan_provider_session_binding(binding).map_err(|error| error.to_string())?; - let mut plan = pending.tool_plan(); - plan.actions = vec![pending.action.clone()]; - if !plan.response.trim().is_empty() { - return Err("planning submit pending 不能携带 final response".to_string()); - } - let actions = vec![pending.clone()]; - let batch_id = agent_runtime_plan_provider_action_batch_id( - &binding.project_id, - &pending.agent_id, - &pending.task_id, - &pending.session_id, - &pending.run_id, - pending.loop_iteration, - pending.planned_steer_cursor, - &plan, - &pending.project_revision_before, - &pending.planned_repository_context_fingerprint, - &actions, - binding, - )?; - let batch = AgentRuntimeProviderActionBatch { - schema_version: AGENT_RUNTIME_PLAN_PROVIDER_ACTION_BATCH_SCHEMA_VERSION.to_string(), - batch_id, - provider_request_id: Some(binding.provider_request_id.clone()), - project_id: binding.project_id.clone(), - agent_id: pending.agent_id.clone(), - task_id: pending.task_id.clone(), - session_id: pending.session_id.clone(), - run_id: pending.run_id.clone(), - source: pending.source.clone(), - run_profile: pending.run_profile.clone(), - run_profile_binding_fingerprint: pending.run_profile_binding_fingerprint.clone(), - planning_session_binding: Some(binding.clone()), - loop_iteration: pending.loop_iteration, - planned_steer_cursor: pending.planned_steer_cursor, - status: AGENT_RUNTIME_PROVIDER_ACTION_BATCH_STATUS_READY.to_string(), - next_action_index: 0, - plan, - actions, - collaboration_contract: None, - project_revision_before: pending.project_revision_before.clone(), - planned_repository_context_fingerprint: pending - .planned_repository_context_fingerprint - .clone(), - created_at: pending.created_at, - updated_at: pending.updated_at, - }; - write_game_creator_agent_runtime_provider_action_batch(root, &batch)?; - Ok(batch) -} - -fn ensure_committed_plan_submit_anchor_pair_for_pending_at( - root: &Path, - pending: &AgentRuntimePendingToolAction, -) -> Result { - if !planning_submit_pending_has_exact_committed_shape(pending) - || !planning_submit_gdd_committed_for_pending_at(root, pending)? - { - return Ok(false); - } - let batch = if game_creator_agent_runtime_provider_action_batch_exists( - root, - &pending.agent_id, - &pending.run_id, - ) { - read_game_creator_agent_runtime_provider_action_batch( - root, - &pending.agent_id, - &pending.run_id, - )? - } else { - rebuild_missing_committed_plan_submit_batch_at(root, pending)? - }; - Ok(planning_submit_batch_matches_pending(&batch, pending)) -} - -fn restore_missing_committed_plan_submit_pending_from_batch_at( - root: &Path, - runtime: &AgentRuntimeState, -) -> Result { - if !game_creator_agent_runtime_provider_action_batch_exists( - root, - &runtime.agent_id, - &runtime.run_id, - ) { - return Ok(false); - } - let batch = read_game_creator_agent_runtime_provider_action_batch( - root, - &runtime.agent_id, - &runtime.run_id, - )?; - if !planning_submit_batch_has_exact_committed_shape(&batch) { - return Ok(false); - } - let pending = &batch.actions[0]; - if validate_agent_runtime_pending_context(root, runtime, pending).is_err() - || !planning_submit_gdd_committed_for_pending_at(root, pending)? - { - return Ok(false); - } - write_game_creator_agent_runtime_pending_tool_action(root, pending)?; - Ok(true) -} - -fn ensure_recovered_project_planning_submit_child_at( - root: &Path, - runtime: &mut AgentRuntimeState, - pending: &AgentRuntimePendingToolAction, -) -> Result<(), String> { - let result = execute_plan_submit_gdd_for_pending_action(root, runtime, pending) - .map_err(|error| format!("已提交 GDD 的同 action 重放失败:{error}"))?; - if result.recovery_pending { - return Err(format!( - "GDD v{} 同 action 恢复后仍有投影未收口(recoveryPending=true)", - result.gdd_ref.version - )); - } - ensure_project_planning_submit_child_completion_at(root, runtime, pending, &result) -} - pub(crate) fn resume_game_creator_agent_pending_tool_action_at( root: &Path, agent_id: &str, @@ -792,33 +589,11 @@ pub(crate) fn resume_game_creator_agent_pending_tool_action_at( return Ok(AgentRuntimePendingActionResume::NotFound(runtime_lock)); } if !game_creator_agent_runtime_pending_tool_action_exists(root, agent_id, &runtime.run_id) { - let restored = if game_creator_agent_runtime_provider_action_batch_exists( - root, - agent_id, - &runtime.run_id, - ) { - match restore_missing_committed_plan_submit_pending_from_batch_at(root, &runtime) { - Ok(restored) => restored, - Err(error) => { - mark_game_creator_agent_runtime_provider_batch_needs_reconciliation_at( - root, - &mut runtime, - &format!("恢复 committed planning submit 的 pending anchor 失败:{error}"), - )?; - return read_game_creator_agent_runtime_at(root, agent_id) - .map(AgentRuntimePendingActionResume::Handled); - } - } - } else { - false - }; - if !restored { - if has_reconciliation_barrier { - return read_game_creator_agent_runtime_at(root, agent_id) - .map(AgentRuntimePendingActionResume::Handled); - } - return Ok(AgentRuntimePendingActionResume::NotFound(runtime_lock)); + if has_reconciliation_barrier { + return read_game_creator_agent_runtime_at(root, agent_id) + .map(AgentRuntimePendingActionResume::Handled); } + return Ok(AgentRuntimePendingActionResume::NotFound(runtime_lock)); } let mut pending = match read_game_creator_agent_runtime_pending_tool_action( root, @@ -848,27 +623,6 @@ pub(crate) fn resume_game_creator_agent_pending_tool_action_at( let session_mismatch = pending.session_id != runtime.session_id || read_latest_game_creator_agent_runtime_task_by_run_id(root, agent_id, &runtime.run_id)? .is_some_and(|task| task.session_id != pending.session_id); - let planning_submit = pending.action.tool.trim() == PLAN_SUBMIT_GDD_TOOL; - let exact_committed_plan_submit = if planning_submit - && !session_mismatch - && validate_agent_runtime_pending_context(root, &runtime, &pending).is_ok() - { - match ensure_committed_plan_submit_anchor_pair_for_pending_at(root, &pending) { - Ok(exact) => exact, - Err(error) => { - mark_game_creator_agent_runtime_needs_reconciliation_at( - root, - &mut runtime, - &pending, - &format!("planning submit committed anchor 恢复失败:{error}"), - )?; - return read_game_creator_agent_runtime_at(root, agent_id) - .map(AgentRuntimePendingActionResume::Handled); - } - } - } else { - false - }; let mut can_repair_terminal_receipt = agent_runtime_pending_has_persisted_terminal_observation(&pending) || agent_runtime_pending_is_replayable_supervisor_delivery_action(&pending); @@ -880,7 +634,6 @@ pub(crate) fn resume_game_creator_agent_pending_tool_action_at( if has_reconciliation_barrier && !can_repair_terminal_receipt && !resumes_durable_external_generation - && !exact_committed_plan_submit { return read_game_creator_agent_runtime_at(root, agent_id) .map(AgentRuntimePendingActionResume::Handled); @@ -912,32 +665,6 @@ pub(crate) fn resume_game_creator_agent_pending_tool_action_at( } let provider_batch_exists = game_creator_agent_runtime_provider_action_batch_exists(root, agent_id, &runtime.run_id); - if planning_submit - && matches!(runtime.phase.as_str(), "completed" | "cancelled" | "failed") - && runtime.phase != "needs-reconciliation" - { - // A planning child is projected terminal at the GDD commit point, but - // its pending/batch sidecars remain the receipt consumer's anchor. - // Never run the generic terminal cleanup on this state. - if exact_committed_plan_submit && provider_batch_exists { - // Keep both exact sidecars in place and replay every terminal - // child projection. A process may have died after the Runtime - // state became completed but before event/audit/delivery landed. - return resume_game_creator_agent_provider_action_batch_at( - root, - agent_id, - runtime_lock, - ); - } - mark_game_creator_agent_runtime_needs_reconciliation_at( - root, - &mut runtime, - &pending, - "planning submit terminal anchor 不是 exact ready/0 + auto/executing 形状,或 immutable GDD 无法对账", - )?; - return read_game_creator_agent_runtime_at(root, agent_id) - .map(AgentRuntimePendingActionResume::Handled); - } if matches!(runtime.phase.as_str(), "completed" | "cancelled" | "failed") && runtime.phase != "needs-reconciliation" { @@ -946,15 +673,6 @@ pub(crate) fn resume_game_creator_agent_pending_tool_action_at( remove_game_creator_agent_runtime_confirmations(root, agent_id, &runtime.run_id)?; return Ok(AgentRuntimePendingActionResume::NotFound(runtime_lock)); } - // `plan.submit_gdd` is a create-only Runtime commit whose durable batch is - // the recovery anchor. Route it through the batch state machine before - // the generic pending-action recovery reaches the fail-closed branch for - // an `executing` action; otherwise a crash between the action marker and - // the GDD commit would be classified as an unknown generic side effect - // and the idempotent submit replay could never run. - if provider_batch_exists && planning_submit { - return resume_game_creator_agent_provider_action_batch_at(root, agent_id, runtime_lock); - } if provider_batch_exists { let batch = match read_game_creator_agent_runtime_provider_action_batch( root, @@ -1017,141 +735,31 @@ pub(crate) fn resume_game_creator_agent_pending_tool_action_at( if migrate_legacy_autonomous_confirmation_at(root, &runtime, &mut pending)? { can_repair_terminal_receipt = true; } - let (runtime_lock, planning_user_input_project_lock) = if pending.status - == AGENT_RUNTIME_PENDING_ACTION_STATUS_WAITING_FOR_USER_INPUT - && plan_clarification_pending_requires_project_lock_at(root, &pending)? - { - let expected_run_id = runtime.run_id.clone(); - let expected_session_id = runtime.session_id.clone(); - let expected_action_id = pending.action_id.clone(); - let expected_action_fingerprint = pending.action_fingerprint.clone(); - drop(runtime_lock); - // 为守住 project -> execution 的锁序,执行锁已经在上一行放掉了。接下来这 - // 两把锁都可能正被别处占住——用户刚提交澄清回答时,执行锁会被 move 进 - // 后台续跑任务,一持有就是整个 Provider 回合,远超这里的等待上限。锁被 - // 占说明系统在前进,是最不该把整轮恢复判失败的时候:本轮让出,两把锁都 - // 释放,下一轮 resume 重来。与 `recovery_scan` 里同形状的 planning - // session 恢复窗口保持同一套语义。 - let project_lock = match acquire_game_creator_agent_runtime_project_write_lock_with_wait( - root, - "planning.clarification.answer-recovery", - ) { - Ok(project_lock) => project_lock, - Err(error) if static_delegate_parent_wake_error_is_transient(&error) => { - return Ok(AgentRuntimePendingActionResume::Deferred); - } - Err(error) => { - return Err(format!("恢复 planning 澄清回答前取得项目锁失败:{error}")); - } - }; - // 用 `try_..._with_wait`(返回 `Option`)而不是 `acquire_..._with_wait` - // (拿不到就 `Err`):等待宽限一样是 25 x 10ms,但超时是「本轮没轮到」而 - // 不是「恢复失败」。 - let Some(runtime_lock) = - try_acquire_game_creator_agent_runtime_task_lock_with_wait(root, agent_id)? - else { - return Ok(AgentRuntimePendingActionResume::Deferred); - }; - runtime = read_game_creator_agent_runtime_at(root, agent_id)?.state; - if runtime.run_id != expected_run_id - || runtime.session_id != expected_session_id - || runtime.status != "waiting-for-user-input" - || runtime.phase != "waiting-for-user-input" - || game_creator_agent_runtime_has_reconciliation_barrier(root, agent_id)? - { - return Ok(AgentRuntimePendingActionResume::NotFound(runtime_lock)); - } - let Some(current_task) = - read_latest_game_creator_agent_runtime_task_by_run_id(root, agent_id, &runtime.run_id)? - else { - return Ok(AgentRuntimePendingActionResume::NotFound(runtime_lock)); - }; - if current_task.session_id != expected_session_id - || current_task.status != "waiting-for-user-input" - || current_task.phase != "waiting-for-user-input" - || !game_creator_agent_runtime_pending_tool_action_exists( - root, - agent_id, - &runtime.run_id, - ) - { - return Ok(AgentRuntimePendingActionResume::NotFound(runtime_lock)); - } - pending = - read_game_creator_agent_runtime_pending_tool_action(root, agent_id, &runtime.run_id)?; - if pending.action_id != expected_action_id - || pending.status != AGENT_RUNTIME_PENDING_ACTION_STATUS_WAITING_FOR_USER_INPUT - { - // The old recovery candidate became obsolete while its execution - // lane was released. A concurrent answer may legitimately have - // advanced the exact pending to observed-approved, or another - // current action may now own the run. Do not overwrite that newer - // state with a reconciliation projection; let the caller inspect - // the re-read runtime on its next pass. - return Ok(AgentRuntimePendingActionResume::NotFound(runtime_lock)); - } - if pending.session_id != expected_session_id - || pending.action_fingerprint != expected_action_fingerprint - { - mark_game_creator_agent_runtime_needs_reconciliation_at( - root, - &mut runtime, - &pending, - "planning 用户回答恢复发现同一 pending 的 immutable identity 漂移", - )?; - return read_game_creator_agent_runtime_at(root, agent_id) - .map(AgentRuntimePendingActionResume::Handled); - } - (runtime_lock, Some(project_lock)) - } else { - (runtime_lock, None) - }; if pending.status == AGENT_RUNTIME_PENDING_ACTION_STATUS_WAITING_FOR_USER_INPUT { - let planning_agent = runtime.agent_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID; - if planning_agent - || (runtime.run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD - && !static_delegate_clarification_pending_matches_delivery_at(root, &pending)?) + if runtime.run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + && !static_delegate_clarification_pending_matches_delivery_at(root, &pending)? { let _ = cancel_game_creator_agent_user_input_request_for_pending_at(root, &pending); mark_game_creator_agent_runtime_needs_reconciliation_at( root, &mut runtime, &pending, - if planning_agent { - "project-planning 子 Agent 恢复到 waiting-for-user-input,已拒绝继续等待" - } else { - "自主构建 Run 恢复到 legacy waiting-for-user-input,已拒绝继续等待" - }, + "自主构建 Run 恢复到 legacy waiting-for-user-input,已拒绝继续等待", )?; return read_game_creator_agent_runtime_at(root, agent_id) .map(AgentRuntimePendingActionResume::Handled); } if game_creator_agent_runtime_cancel_requested(root, &runtime) { cancel_game_creator_agent_user_input_request_for_pending_at(root, &pending)?; - match planning_user_input_project_lock.as_ref() { - Some(_) => mark_game_creator_agent_runtime_cancelled_at_locked( - root, - &mut runtime, - "Agent 后台任务已按开发者请求取消", - Some("Runtime 恢复用户输入等待时发现尚未完成的取消请求。"), - )?, - None => mark_game_creator_agent_runtime_cancelled_at( - root, - &mut runtime, - "Agent 后台任务已按开发者请求取消", - Some("Runtime 恢复用户输入等待时发现尚未完成的取消请求。"), - )?, - } + mark_game_creator_agent_runtime_cancelled_at( + root, + &mut runtime, + "Agent 后台任务已按开发者请求取消", + Some("Runtime 恢复用户输入等待时发现尚未完成的取消请求。"), + )?; return Ok(AgentRuntimePendingActionResume::NotFound(runtime_lock)); } - let recovered_user_input = match planning_user_input_project_lock.as_ref() { - Some(project_lock) => prepare_game_creator_agent_user_input_request_at_locked( - root, - &pending, - project_lock, - ), - None => prepare_game_creator_agent_user_input_request_at(root, &pending), - }; + let recovered_user_input = prepare_game_creator_agent_user_input_request_at(root, &pending); match recovered_user_input { Ok(AgentRuntimeUserInputRecovery::Waiting(request)) => { runtime.pending_tool_action = Some(pending.summary()); @@ -1194,20 +802,12 @@ pub(crate) fn resume_game_creator_agent_pending_tool_action_at( can_repair_terminal_receipt = true; } Ok(AgentRuntimeUserInputRecovery::Cancelled) => { - match planning_user_input_project_lock.as_ref() { - Some(_) => mark_game_creator_agent_runtime_cancelled_at_locked( - root, - &mut runtime, - "Agent 用户输入请求已取消", - Some("Runner 恢复时发现用户输入 sidecar 已取消。"), - )?, - None => mark_game_creator_agent_runtime_cancelled_at( - root, - &mut runtime, - "Agent 用户输入请求已取消", - Some("Runner 恢复时发现用户输入 sidecar 已取消。"), - )?, - } + mark_game_creator_agent_runtime_cancelled_at( + root, + &mut runtime, + "Agent 用户输入请求已取消", + Some("Runner 恢复时发现用户输入 sidecar 已取消。"), + )?; return Ok(AgentRuntimePendingActionResume::NotFound(runtime_lock)); } Err(error) => { @@ -1222,7 +822,6 @@ pub(crate) fn resume_game_creator_agent_pending_tool_action_at( } } } - drop(planning_user_input_project_lock); if pending.status == AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING && pending.action.tool == "canvas.asset_generate" { @@ -1446,116 +1045,12 @@ pub(in crate::agent) fn resume_game_creator_agent_provider_action_batch_at( return read_game_creator_agent_runtime_at(root, agent_id) .map(AgentRuntimePendingActionResume::Handled); } - let plan_submit_batch = is_plan_submit_gdd_provider_action_batch(&batch); - let plan_submit_gdd_committed = if plan_submit_batch { - match planning_submit_gdd_committed_for_pending_at(root, first_pending) { - Ok(committed) => committed, - Err(error) => { - mark_game_creator_agent_runtime_provider_batch_needs_reconciliation_at( - root, - &mut runtime, - &format!("planning submit commit fact 校验失败:{error}"), - )?; - return read_game_creator_agent_runtime_at(root, agent_id) - .map(AgentRuntimePendingActionResume::Handled); - } - } - } else { - false - }; - let exact_committed_plan_submit = if plan_submit_gdd_committed { - if !planning_submit_batch_has_exact_committed_shape(&batch) { - mark_game_creator_agent_runtime_provider_batch_needs_reconciliation_at( - root, - &mut runtime, - "已提交 GDD 的 planning v4 batch 不是 exact ready/0 + auto/executing 形状", - )?; - return read_game_creator_agent_runtime_at(root, agent_id) - .map(AgentRuntimePendingActionResume::Handled); - } - if game_creator_agent_runtime_pending_tool_action_exists(root, agent_id, &runtime.run_id) { - match read_game_creator_agent_runtime_pending_tool_action( - root, - agent_id, - &runtime.run_id, - ) { - Ok(pending) if planning_submit_batch_matches_pending(&batch, &pending) => {} - Ok(_) => { - mark_game_creator_agent_runtime_provider_batch_needs_reconciliation_at( - root, - &mut runtime, - "已提交 GDD 的 planning batch 与 standalone pending snapshot 不一致", - )?; - return read_game_creator_agent_runtime_at(root, agent_id) - .map(AgentRuntimePendingActionResume::Handled); - } - Err(error) => { - mark_game_creator_agent_runtime_provider_batch_needs_reconciliation_at( - root, - &mut runtime, - &format!("读取 planning submit standalone pending 失败:{error}"), - )?; - return read_game_creator_agent_runtime_at(root, agent_id) - .map(AgentRuntimePendingActionResume::Handled); - } - } - } else { - write_game_creator_agent_runtime_pending_tool_action(root, first_pending)?; - } - true - } else { - false - }; if matches!(runtime.phase.as_str(), "completed" | "cancelled" | "failed") && runtime.phase != "needs-reconciliation" { - if plan_submit_batch { - if exact_committed_plan_submit { - if let Err(error) = ensure_recovered_project_planning_submit_child_at( - root, - &mut runtime, - first_pending, - ) { - mark_game_creator_agent_runtime_needs_reconciliation_at( - root, - &mut runtime, - first_pending, - &format!("GDD 提交终态投影恢复失败:{error}"), - )?; - } - return read_game_creator_agent_runtime_at(root, agent_id) - .map(AgentRuntimePendingActionResume::Handled); - } - mark_game_creator_agent_runtime_provider_batch_needs_reconciliation_at( - root, - &mut runtime, - "planning submit child 已终态,但 exact anchors 无法与 immutable GDD 对账", - )?; - return read_game_creator_agent_runtime_at(root, agent_id) - .map(AgentRuntimePendingActionResume::Handled); - } remove_game_creator_agent_runtime_provider_action_batch(root, agent_id, &runtime.run_id)?; return Ok(AgentRuntimePendingActionResume::NotFound(runtime_lock)); } - if exact_committed_plan_submit { - // The immutable GDD is the business-consumption proof. Replaying via - // the generic main loop would consume queued steer before reaching - // the submit action and would re-apply its repository drift gate. - // Repair the same create-only action directly and publish only the - // specialized planning-child completion. - if let Err(error) = - ensure_recovered_project_planning_submit_child_at(root, &mut runtime, first_pending) - { - mark_game_creator_agent_runtime_needs_reconciliation_at( - root, - &mut runtime, - first_pending, - &format!("GDD 提交恢复后子 Run 收口失败:{error}"), - )?; - } - return read_game_creator_agent_runtime_at(root, agent_id) - .map(AgentRuntimePendingActionResume::Handled); - } if batch.status == AGENT_RUNTIME_PROVIDER_ACTION_BATCH_STATUS_WAITING_CONFIRMATION && batch.run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD { @@ -1659,10 +1154,9 @@ pub(in crate::agent) fn resume_game_creator_agent_provider_action_batch_at( )?; let repository_context_drifted = build_repository_startup_context_at(root)?.fingerprint != batch.planned_repository_context_fingerprint; - if !exact_committed_plan_submit - && (runtime.applied_steer_cursor != batch.planned_steer_cursor - || queued_steer - || repository_context_drifted) + if runtime.applied_steer_cursor != batch.planned_steer_cursor + || queued_steer + || repository_context_drifted { let current_pending = batch .actions @@ -1816,729 +1310,6 @@ pub(crate) fn resume_game_creator_agent_provider_action_batch_for_test_at( mod pending_recovery_tests { use super::*; - fn valid_plan_submit_input_for_anchor_recovery() -> PlanSubmitGddInputV1 { - serde_json::from_value(serde_json::json!({ - "schemaVersion": PLAN_SUBMIT_GDD_INPUT_SCHEMA_VERSION, - "game": { - "title": "萤火守夜者", - "genre": {"primary": "轻策略", "fusion": null}, - "artStyle": { - "visualType": "手绘平面", - "keywords": ["暖色", "剪影", "纸感"], - "moodAndColor": "夜色中的暖黄灯火", - "mvpArtBoundary": "仅制作可复用的角色、灯火和地块素材" - }, - "oneLiner": "玩家在一局十分钟的守夜旅程中分配有限灯火、判断风险并选择路线,守住营地后寻找下一处安全落脚点", - "pillars": [ - { - "name": "取舍", - "playerFeel": "每次选择都有代价", - "mechanism": "有限灯火在路线与营地之间分配", - "decisionState": "confirmed" - }, - { - "name": "重玩", - "playerFeel": "想再试一次更优路线", - "mechanism": "不同路线组合产生不同风险", - "decisionState": "confirmed" - } - ], - "coreLoop": ["观察地图", "分配灯火", "选择路线", "处理事件"], - "targetUsers": { - "coreUsers": "喜欢短局策略的玩家", - "preferences": "偏好清晰反馈和轻量决策", - "sessionLength": "10至20分钟", - "referenceGames": [] - }, - "mvpSystems": [ - { - "system": "地图", - "minimalFunction": "展示当前营地与可选路线", - "whyRequired": "让玩家理解空间选择", - "verifyMethod": "能完成一局并看懂下一步", - "decisionState": "confirmed" - }, - { - "system": "灯火", - "minimalFunction": "消耗灯火换取安全或探索", - "whyRequired": "承载核心取舍", - "verifyMethod": "两种分配策略结果可区分", - "decisionState": "confirmed" - }, - { - "system": "事件", - "minimalFunction": "路线途中触发一项选择", - "whyRequired": "提供短局变化", - "verifyMethod": "重玩时可遇到不同事件", - "decisionState": "confirmed" - } - ], - "outOfScope": ["多人联机"], - "creatorTips": { - "doFirst": "先做一张可走完的地图", - "deferForNow": "暂缓复杂成长线", - "howToVerify": "观察玩家是否能说出每次选择的后果", - "expandWhen": "核心循环连续三局都可理解后再扩展" - } - }, - "decisions": [{ - "id": "initial-request", - "topic": "初始需求", - "state": "confirmed", - "answerSource": "user_freeform", - "round": 0, - "answerSummary": "做一个短局守夜策略游戏" - }], - "prototypeValidationItems": [] - })) - .expect("valid plan submit anchor recovery input") - } - - struct CommittedPlanSubmitAnchorFixture { - runtime: AgentRuntimeState, - pending: AgentRuntimePendingToolAction, - batch: AgentRuntimeProviderActionBatch, - gdd_chain: Vec, - } - - fn committed_plan_submit_anchor_fixture_at( - root: &Path, - identity: &str, - ) -> CommittedPlanSubmitAnchorFixture { - let project_id = format!("plan-submit-{identity}"); - let root_run_id = format!("plan-submit-{identity}-root"); - let child_run_id = format!("plan-submit-{identity}-child"); - let parent_action_id = format!("plan-submit-{identity}-parent-action"); - let delegation_id = agent_runtime_delegation_id( - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &root_run_id, - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - &parent_action_id, - ); - init_local_game_project_at(root, &project_id, "策划提交单锚恢复测试") - .expect("init anchor recovery project"); - bind_game_creator_agent_runtime_run_profile_at( - root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &root_run_id, - AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE, - Some(AGENT_RUNTIME_RUN_PROFILE_STANDARD), - None, - ) - .expect("bind planning root"); - let root_runtime = start_game_creator_agent_runtime_task_at( - root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - "发起 Fast GDD 立项策划", - &root_run_id, - AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE, - "等待策划子 Agent 提交 Fast GDD", - vec!["获取 Fast GDD 提交结果".to_string()], - ) - .expect("start planning root"); - let link = AgentRuntimeTaskLink { - parent_agent_id: Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string()), - parent_run_id: Some(root_run_id.clone()), - delegation_id: Some(delegation_id.clone()), - }; - let child_session_id = resolve_agent_conversation_session_id_at( - root, - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - None, - true, - ) - .expect("resolve planning child session"); - let queued = append_unique_game_creator_agent_runtime_pending_task( - root, - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - &child_session_id, - "提交 Fast GDD", - &child_run_id, - "agent-delegate", - Some(AGENT_RUNTIME_RUN_PROFILE_STANDARD), - Some(&link), - ) - .expect("queue planning child with exact link"); - assert_eq!(queued.run_id, child_run_id); - let runtime = start_game_creator_agent_runtime_task_for_session_at( - root, - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - Some(&child_session_id), - "提交 Fast GDD", - &child_run_id, - "agent-delegate", - "提交 Fast GDD", - vec!["提交 Fast GDD".to_string()], - ) - .expect("start planning child"); - - let input = valid_plan_submit_input_for_anchor_recovery(); - let decisions = input - .decisions - .iter() - .map(|decision| PlanDecisionSummary { - id: decision.id.clone(), - topic: decision.topic.clone(), - state: decision.state.clone(), - answer_source: decision.answer_source.clone(), - round: decision.round, - answer_summary: decision.answer_summary.clone(), - }) - .collect(); - let gdd_id = "gdd-00000000-0000-4000-8000-000000000001".to_string(); - let mut session = PlanSessionV1 { - schema_version: PLAN_SESSION_SCHEMA_VERSION.to_string(), - project_id: project_id.clone(), - gdd_id: gdd_id.clone(), - session_revision: 1, - previous_fingerprint: None, - session_fingerprint: format!("sha256-serde-json-v2:{}", "0".repeat(64)), - agent_id: runtime.agent_id.clone(), - source: runtime.source.clone(), - run_profile: runtime.run_profile.clone(), - run_profile_binding_fingerprint: runtime.run_profile_binding_fingerprint.clone(), - root_agent_id: GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(), - root_run_id: root_run_id.clone(), - latest_delegation_id: delegation_id.clone(), - session_id: runtime.session_id.clone(), - active_run_id: Some(runtime.run_id.clone()), - last_run_id: runtime.run_id.clone(), - phase: "collecting".to_string(), - accumulated_agent_millis: 0, - applied_steer_cursor: runtime.applied_steer_cursor, - decisions_summary: decisions, - prototype_validation_items: input.prototype_validation_items.clone(), - applied_answers: Vec::new(), - latest_submitted_ref: None, - last_decision_ref: None, - updated_at_utc: "2026-08-14T00:00:00.000Z".to_string(), - }; - session.session_fingerprint = plan_session_fingerprint(&session).expect("session fp"); - write_plan_session_atomic_locked(root, &session).expect("write planning session"); - - let action = AgentRuntimeToolAction { - tool: PLAN_SUBMIT_GDD_TOOL.to_string(), - reason: Some("提交测试 GDD".to_string()), - input: serde_json::to_value(&input).expect("serialize submit input"), - }; - let plan = AgentRuntimeToolPlan { - thinking_summary: "提交 Fast GDD".to_string(), - plan_update: Some(AgentRuntimePlanUpdate { - explanation: "完成 Fast GDD 提交步骤".to_string(), - steps: vec![AgentRuntimePlanUpdateStep { - step: "提交 Fast GDD".to_string(), - status: AGENT_RUNTIME_PLAN_STATUS_COMPLETED.to_string(), - }], - }), - plan: vec!["提交 Fast GDD".to_string()], - actions: vec![action.clone()], - response: String::new(), - }; - let revision = - read_game_creator_agent_runtime_project_revision(root).expect("read project revision"); - let repository_fingerprint = build_repository_startup_context_at(root) - .expect("read repository context") - .fingerprint; - let mut pending = build_game_creator_agent_runtime_pending_tool_action( - root, - &runtime, - &runtime.current_task, - &plan, - &[], - &revision, - &repository_fingerprint, - &action, - 0, - AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO, - AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING, - None, - ) - .expect("build pending action"); - let mut binding = PlanProviderSessionBindingV1 { - schema_version: PLAN_PROVIDER_SESSION_BINDING_SCHEMA_VERSION.to_string(), - project_id: project_id.clone(), - gdd_id, - agent_id: runtime.agent_id.clone(), - task_id: runtime.task_id.clone(), - provider_request_id: String::new(), - session_id: runtime.session_id.clone(), - run_id: runtime.run_id.clone(), - root_agent_id: GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(), - root_run_id: root_run_id.clone(), - delegation_id: delegation_id.clone(), - goal_id: pending.goal_id.clone(), - goal_revision: pending.goal_revision, - goal_snapshot_fingerprint: pending.goal_snapshot_fingerprint.clone(), - source: runtime.source.clone(), - run_profile: runtime.run_profile.clone(), - run_profile_binding_fingerprint: runtime.run_profile_binding_fingerprint.clone(), - session_revision: session.session_revision, - session_fingerprint: session.session_fingerprint.clone(), - applied_steer_cursor: runtime.applied_steer_cursor, - request_kind: "tool-plan".to_string(), - request_slot: format!("loop-{}-repair-0", runtime.loop_iteration), - web_search_enabled: false, - request_context_fingerprint: format!("sha256-serde-json-v2:{}", "2".repeat(64)), - fingerprint: String::new(), - }; - binding.provider_request_id = - plan_provider_session_binding_base_request_id(&binding).expect("provider request id"); - binding.fingerprint = - plan_provider_session_binding_fingerprint(&binding).expect("binding fingerprint"); - pending.planning_session_binding = Some(binding.clone()); - pending.provider_batch_plan_update = plan.plan_update.clone(); - let batch_id = agent_runtime_plan_provider_action_batch_id( - &project_id, - &runtime.agent_id, - &runtime.task_id, - &runtime.session_id, - &runtime.run_id, - runtime.loop_iteration, - runtime.applied_steer_cursor, - &plan, - &revision, - &repository_fingerprint, - &[pending.clone()], - &binding, - ) - .expect("planning batch id"); - let batch = AgentRuntimeProviderActionBatch { - schema_version: AGENT_RUNTIME_PLAN_PROVIDER_ACTION_BATCH_SCHEMA_VERSION.to_string(), - batch_id, - provider_request_id: Some(binding.provider_request_id.clone()), - project_id, - agent_id: runtime.agent_id.clone(), - task_id: runtime.task_id.clone(), - session_id: runtime.session_id.clone(), - run_id: runtime.run_id.clone(), - source: runtime.source.clone(), - run_profile: runtime.run_profile.clone(), - run_profile_binding_fingerprint: runtime.run_profile_binding_fingerprint.clone(), - planning_session_binding: Some(binding), - loop_iteration: runtime.loop_iteration, - planned_steer_cursor: runtime.applied_steer_cursor, - status: AGENT_RUNTIME_PROVIDER_ACTION_BATCH_STATUS_READY.to_string(), - next_action_index: 0, - plan, - actions: vec![pending.clone()], - collaboration_contract: None, - project_revision_before: revision, - planned_repository_context_fingerprint: repository_fingerprint, - created_at: pending.created_at, - updated_at: pending.updated_at, - }; - write_game_creator_agent_runtime_pending_tool_action(root, &pending) - .expect("write submit pending anchor"); - write_game_creator_agent_runtime_provider_action_batch(root, &batch) - .expect("write submit batch anchor"); - let delivery = new_static_delegate_delivery( - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &root_runtime.session_id, - &root_run_id, - &parent_action_id, - &delegation_id, - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - &runtime.session_id, - &runtime.run_id, - ); - create_or_read_static_delegate_delivery_at(root, &delivery) - .expect("write planning child delivery"); - - let submitted = execute_plan_submit_gdd_for_pending_action(root, &runtime, &pending) - .expect("commit immutable Fast GDD"); - assert_eq!(submitted.outcome, "submitted"); - assert_eq!(submitted.gdd_ref.version, 1); - assert!(!submitted.recovery_pending); - let gdd_chain = read_plan_gdd_chain(root).expect("read committed GDD chain"); - assert_eq!(gdd_chain.len(), 1); - CommittedPlanSubmitAnchorFixture { - runtime, - pending, - batch, - gdd_chain, - } - } - - fn resume_committed_plan_submit_anchor_at(root: &Path, runtime: &AgentRuntimeState) { - let runtime_lock = - try_acquire_game_creator_agent_runtime_task_lock(root, &runtime.agent_id) - .expect("acquire planning runtime lock") - .expect("planning runtime lock available"); - let resumed = - resume_game_creator_agent_pending_tool_action_at(root, &runtime.agent_id, runtime_lock) - .expect("resume committed planning submit"); - assert!(matches!( - resumed, - AgentRuntimePendingActionResume::Handled(_) - )); - } - - fn assert_exact_plan_submit_anchor_pair_at( - root: &Path, - expected_pending: &AgentRuntimePendingToolAction, - expected_batch: &AgentRuntimeProviderActionBatch, - ) { - let pending = read_game_creator_agent_runtime_pending_tool_action( - root, - &expected_pending.agent_id, - &expected_pending.run_id, - ) - .expect("read recovered standalone pending"); - let batch = read_game_creator_agent_runtime_provider_action_batch( - root, - &expected_pending.agent_id, - &expected_pending.run_id, - ) - .expect("read recovered provider batch"); - assert_eq!( - pending.schema_version, - AGENT_RUNTIME_PENDING_ACTION_SCHEMA_VERSION - ); - assert_eq!(pending.action_id, expected_pending.action_id); - assert_eq!( - pending.action_fingerprint, - expected_pending.action_fingerprint - ); - assert_eq!( - pending.planning_session_binding, - expected_pending.planning_session_binding - ); - assert_eq!( - pending.provider_batch_plan_update, - expected_pending.provider_batch_plan_update - ); - assert_eq!(batch.batch_id, expected_batch.batch_id); - assert_eq!( - batch.provider_request_id, - expected_batch.provider_request_id - ); - assert_eq!( - batch.planning_session_binding, - expected_batch.planning_session_binding - ); - assert_eq!(batch.plan.plan_update, expected_batch.plan.plan_update); - assert_eq!(batch.actions[0].action_id, expected_pending.action_id); - assert_eq!( - batch.actions[0].action_fingerprint, - expected_pending.action_fingerprint - ); - assert_eq!( - batch.actions[0].provider_batch_plan_update, - batch.plan.plan_update - ); - assert_eq!(pending, *expected_pending); - assert_eq!(batch, *expected_batch); - } - - #[test] - fn committed_plan_submit_pending_only_restores_exact_v4_batch_without_new_gdd() { - let temporary = crate::tests::canonical_test_tempdir("plan-submit-pending-only-"); - let root = temporary.path(); - let fixture = committed_plan_submit_anchor_fixture_at(root, "pending-only"); - remove_game_creator_agent_runtime_provider_action_batch( - root, - &fixture.runtime.agent_id, - &fixture.runtime.run_id, - ) - .expect("remove provider batch anchor"); - assert!(game_creator_agent_runtime_pending_tool_action_exists( - root, - &fixture.runtime.agent_id, - &fixture.runtime.run_id - )); - assert!(!game_creator_agent_runtime_provider_action_batch_exists( - root, - &fixture.runtime.agent_id, - &fixture.runtime.run_id - )); - - resume_committed_plan_submit_anchor_at(root, &fixture.runtime); - - assert_exact_plan_submit_anchor_pair_at(root, &fixture.pending, &fixture.batch); - assert_eq!( - read_plan_gdd_chain(root).expect("reread GDD chain after pending-only recovery"), - fixture.gdd_chain - ); - assert!(!root.join(".agent/planning/gdd.v2.json").exists()); - } - - #[test] - fn committed_plan_submit_batch_only_restores_exact_v5_pending_without_new_gdd() { - let temporary = crate::tests::canonical_test_tempdir("plan-submit-batch-only-"); - let root = temporary.path(); - let fixture = committed_plan_submit_anchor_fixture_at(root, "batch-only"); - remove_game_creator_agent_runtime_pending_tool_action( - root, - &fixture.runtime.agent_id, - &fixture.runtime.run_id, - ) - .expect("remove standalone pending anchor"); - assert!(!game_creator_agent_runtime_pending_tool_action_exists( - root, - &fixture.runtime.agent_id, - &fixture.runtime.run_id - )); - assert!(game_creator_agent_runtime_provider_action_batch_exists( - root, - &fixture.runtime.agent_id, - &fixture.runtime.run_id - )); - - resume_committed_plan_submit_anchor_at(root, &fixture.runtime); - - assert_exact_plan_submit_anchor_pair_at(root, &fixture.pending, &fixture.batch); - assert_eq!( - read_plan_gdd_chain(root).expect("reread GDD chain after batch-only recovery"), - fixture.gdd_chain - ); - assert!(!root.join(".agent/planning/gdd.v2.json").exists()); - } - - #[test] - fn committed_plan_submit_surviving_pending_binding_drift_fails_closed() { - let temporary = crate::tests::canonical_test_tempdir("plan-submit-anchor-drift-"); - let root = temporary.path(); - let fixture = committed_plan_submit_anchor_fixture_at(root, "binding-drift"); - remove_game_creator_agent_runtime_provider_action_batch( - root, - &fixture.runtime.agent_id, - &fixture.runtime.run_id, - ) - .expect("remove provider batch anchor"); - let mut drifted = fixture.pending.clone(); - let binding = drifted - .planning_session_binding - .as_mut() - .expect("planning binding"); - binding.gdd_id = "gdd-00000000-0000-4000-8000-000000000099".to_string(); - binding.provider_request_id.clear(); - binding.fingerprint.clear(); - binding.provider_request_id = plan_provider_session_binding_base_request_id(binding) - .expect("recompute drifted provider request id"); - binding.fingerprint = plan_provider_session_binding_fingerprint(binding) - .expect("recompute drifted binding fingerprint"); - write_game_creator_agent_runtime_pending_tool_action(root, &drifted) - .expect("write self-consistent drifted surviving pending"); - assert_eq!(drifted.action_id, fixture.pending.action_id); - assert_eq!( - drifted.action_fingerprint, - fixture.pending.action_fingerprint - ); - assert!( - !ensure_committed_plan_submit_anchor_pair_for_pending_at(root, &drifted) - .expect("compare drifted pending with immutable GDD") - ); - - resume_committed_plan_submit_anchor_at(root, &fixture.runtime); - - let recovered = read_game_creator_agent_runtime_at(root, &fixture.runtime.agent_id) - .expect("read fail-closed planning runtime"); - assert_eq!(recovered.state.phase, "needs-reconciliation"); - assert!(recovered - .state - .error - .as_deref() - .is_some_and(|error| error.contains("不会自动重放"))); - assert!(!game_creator_agent_runtime_provider_action_batch_exists( - root, - &fixture.runtime.agent_id, - &fixture.runtime.run_id - )); - assert_eq!( - read_plan_gdd_chain(root).expect("reread GDD chain after drift rejection"), - fixture.gdd_chain - ); - assert!(!root.join(".agent/planning/gdd.v2.json").exists()); - } - - #[test] - fn completed_plan_submit_anchor_match_requires_exact_ready_executing_snapshots() { - let temporary = crate::tests::canonical_test_tempdir("plan-submit-anchor-shape-"); - let root = temporary.path(); - init_local_game_project_at(root, "plan-submit-anchor-shape", "提交锚点形状测试") - .expect("init project"); - bind_game_creator_agent_runtime_run_profile_at( - root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - "plan-submit-anchor-shape-root", - AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE, - Some(AGENT_RUNTIME_RUN_PROFILE_STANDARD), - None, - ) - .expect("bind planning root"); - let link = AgentRuntimeTaskLink { - parent_agent_id: Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string()), - parent_run_id: Some("plan-submit-anchor-shape-root".to_string()), - delegation_id: Some("plan-submit-anchor-shape-delegation".to_string()), - }; - bind_game_creator_agent_runtime_run_profile_at( - root, - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - "plan-submit-anchor-shape-child", - "agent-delegate", - Some(AGENT_RUNTIME_RUN_PROFILE_STANDARD), - Some(&link), - ) - .expect("bind planning child"); - let mut runtime = start_game_creator_agent_runtime_task_at( - root, - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - "提交 Fast GDD", - "plan-submit-anchor-shape-child", - "agent-delegate", - "提交 Fast GDD", - vec!["提交 Fast GDD".to_string()], - ) - .expect("start planning child"); - runtime.loop_iteration = 1; - let action = AgentRuntimeToolAction { - tool: PLAN_SUBMIT_GDD_TOOL.to_string(), - reason: Some("提交测试 GDD".to_string()), - input: serde_json::json!({"schemaVersion": PLAN_SUBMIT_GDD_INPUT_SCHEMA_VERSION}), - }; - let plan = AgentRuntimeToolPlan { - thinking_summary: "提交 Fast GDD".to_string(), - plan_update: Some(AgentRuntimePlanUpdate { - explanation: "完成 Fast GDD 提交步骤".to_string(), - steps: vec![AgentRuntimePlanUpdateStep { - step: "提交 Fast GDD".to_string(), - status: AGENT_RUNTIME_PLAN_STATUS_COMPLETED.to_string(), - }], - }), - plan: vec!["提交 Fast GDD".to_string()], - actions: vec![action.clone()], - response: String::new(), - }; - let revision = - read_game_creator_agent_runtime_project_revision(root).expect("read project revision"); - let repository_fingerprint = build_repository_startup_context_at(root) - .expect("read repository context") - .fingerprint; - let mut pending = build_game_creator_agent_runtime_pending_tool_action( - root, - &runtime, - &runtime.current_task, - &plan, - &[], - &revision, - &repository_fingerprint, - &action, - 0, - AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO, - AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING, - None, - ) - .expect("build pending action"); - let mut binding = PlanProviderSessionBindingV1 { - schema_version: PLAN_PROVIDER_SESSION_BINDING_SCHEMA_VERSION.to_string(), - project_id: "plan-submit-anchor-shape".to_string(), - gdd_id: "gdd-00000000-0000-4000-8000-000000000001".to_string(), - agent_id: runtime.agent_id.clone(), - task_id: runtime.task_id.clone(), - provider_request_id: String::new(), - session_id: runtime.session_id.clone(), - run_id: runtime.run_id.clone(), - root_agent_id: GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(), - root_run_id: "plan-submit-anchor-shape-root".to_string(), - delegation_id: "plan-submit-anchor-shape-delegation".to_string(), - goal_id: pending.goal_id.clone(), - goal_revision: pending.goal_revision, - goal_snapshot_fingerprint: pending.goal_snapshot_fingerprint.clone(), - source: runtime.source.clone(), - run_profile: runtime.run_profile.clone(), - run_profile_binding_fingerprint: runtime.run_profile_binding_fingerprint.clone(), - session_revision: 1, - session_fingerprint: format!("sha256-serde-json-v2:{}", "1".repeat(64)), - applied_steer_cursor: runtime.applied_steer_cursor, - request_kind: "tool-plan".to_string(), - request_slot: "loop-1-repair-0".to_string(), - web_search_enabled: false, - request_context_fingerprint: format!("sha256-serde-json-v2:{}", "2".repeat(64)), - fingerprint: String::new(), - }; - binding.provider_request_id = - plan_provider_session_binding_base_request_id(&binding).expect("base provider request"); - binding.fingerprint = - plan_provider_session_binding_fingerprint(&binding).expect("binding fingerprint"); - pending.planning_session_binding = Some(binding.clone()); - pending.provider_batch_plan_update = plan.plan_update.clone(); - let batch_id = agent_runtime_plan_provider_action_batch_id( - &binding.project_id, - &runtime.agent_id, - &runtime.task_id, - &runtime.session_id, - &runtime.run_id, - runtime.loop_iteration, - runtime.applied_steer_cursor, - &plan, - &revision, - &repository_fingerprint, - &[pending.clone()], - &binding, - ) - .expect("planning batch id"); - let batch = AgentRuntimeProviderActionBatch { - schema_version: AGENT_RUNTIME_PLAN_PROVIDER_ACTION_BATCH_SCHEMA_VERSION.to_string(), - batch_id, - provider_request_id: Some(binding.provider_request_id.clone()), - project_id: binding.project_id.clone(), - agent_id: runtime.agent_id.clone(), - task_id: runtime.task_id.clone(), - session_id: runtime.session_id.clone(), - run_id: runtime.run_id.clone(), - source: runtime.source.clone(), - run_profile: runtime.run_profile.clone(), - run_profile_binding_fingerprint: runtime.run_profile_binding_fingerprint.clone(), - planning_session_binding: Some(binding), - loop_iteration: runtime.loop_iteration, - planned_steer_cursor: runtime.applied_steer_cursor, - status: AGENT_RUNTIME_PROVIDER_ACTION_BATCH_STATUS_READY.to_string(), - next_action_index: 0, - plan, - actions: vec![pending.clone()], - collaboration_contract: None, - project_revision_before: revision, - planned_repository_context_fingerprint: repository_fingerprint, - created_at: pending.created_at, - updated_at: pending.updated_at, - }; - - assert!(planning_submit_batch_matches_pending(&batch, &pending)); - - let mut wrong_status = batch.clone(); - wrong_status.status = AGENT_RUNTIME_PROVIDER_ACTION_BATCH_STATUS_COMPLETED.to_string(); - assert!(!planning_submit_batch_matches_pending( - &wrong_status, - &pending - )); - let mut wrong_cursor = batch.clone(); - wrong_cursor.next_action_index = 1; - assert!(!planning_submit_batch_matches_pending( - &wrong_cursor, - &pending - )); - let mut approved_pending = pending.clone(); - approved_pending.status = AGENT_RUNTIME_PENDING_ACTION_STATUS_APPROVED.to_string(); - let mut approved_batch = batch.clone(); - approved_batch.actions[0] = approved_pending.clone(); - assert!(!planning_submit_batch_matches_pending( - &approved_batch, - &approved_pending - )); - let mut different_standalone = pending.clone(); - different_standalone.updated_at = different_standalone.updated_at.saturating_add(1); - assert!(!planning_submit_batch_matches_pending( - &batch, - &different_standalone - )); - - write_game_creator_agent_runtime_pending_tool_action(root, &pending) - .expect("write surviving standalone pending"); - let rebuilt = rebuild_missing_committed_plan_submit_batch_at(root, &pending) - .expect("rebuild exact v4 batch from standalone recovery material"); - assert_eq!(rebuilt, batch); - } - #[test] fn observed_unknown_canvas_generation_returns_to_same_approved_action() { let temporary = crate::tests::canonical_test_tempdir("prepared-pending-"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/provider_recovery.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/provider_recovery.rs index d183afa8a..518dabcd8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/provider_recovery.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/provider_recovery.rs @@ -24,7 +24,7 @@ enum AutonomousManifestParentWakeReconciliationOutcome { pub(crate) fn autonomous_manifest_parent_wake_error_is_transient(error: &str) -> bool { let normalized = error.to_ascii_lowercase(); - error.starts_with("项目正在被其他写操作占用:") + error.starts_with(crate::project::PROJECT_WRITE_LOCK_CONTENTION_PREFIX) || error.contains("另一个程序正在使用此文件") || normalized.contains("sharing violation") || normalized.contains("lock violation") @@ -506,12 +506,6 @@ pub(crate) fn ensure_static_delegate_user_input_wait_at_locked( let Some(delivery) = pending_deliveries.next() else { return Ok(false); }; - // Fast GDD additionally projects the completed planning child into its - // derived session before the user can see or answer the card. Ordinary - // static-delegate questions remain byte-for-byte on the existing path. - project_plan_session_awaiting_user_input_at_locked(root, runtime, delivery, project_lock)?; - // Each durable request belongs to exactly one original delivery. Other - // deliveries remain behind the completion barrier and are asked next. let result = delivery .structured_result .as_ref() diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/recovery_scan.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/recovery_scan.rs index 4d06d8a7a..ed93b5560 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/recovery_scan.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/recovery_scan.rs @@ -51,208 +51,6 @@ pub(in crate::agent) fn mark_waiting_provider_retry_needs_reconciliation_at( read_game_creator_agent_runtime_at(root, agent_id) } -fn mark_plan_session_projection_needs_reconciliation_at( - root: &Path, - task: &AgentRuntimeTaskRecord, - error: &str, -) -> Result { - let mut runtime = match read_game_creator_agent_runtime_at(root, &task.agent_id) { - Ok(result) if result.state.run_id == task.run_id => result.state, - Ok(_) | Err(_) => agent_runtime_state_from_task_record(task), - }; - if runtime.phase == "needs-reconciliation" { - return read_game_creator_agent_runtime_at(root, &task.agent_id); - } - let error = redact_agent_runtime_error(root, error, 500); - runtime.status = "failed".to_string(); - runtime.phase = "needs-reconciliation".to_string(); - runtime.current_action = "Fast GDD session 恢复需要人工核对".to_string(); - runtime.waiting_on = "开发者核对 planning session、delivery 与 continuation 身份".to_string(); - runtime.next_step = "修复冲突的持久投影后显式恢复或取消当前 run".to_string(); - runtime.pending_tool_action = None; - runtime.error = Some(error.clone()); - runtime.updated_at = unix_timestamp(); - append_game_creator_agent_runtime_task(root, &runtime)?; - refresh_game_creator_agent_runtime_task_queue(root, &mut runtime)?; - write_game_creator_agent_runtime_state(root, &runtime)?; - append_game_creator_agent_runtime_event( - root, - &runtime, - "plan.session_recovery.needs_reconciliation", - "failed", - "needs-reconciliation", - "Fast GDD session 无法在 Provider 恢复前安全投影,Runtime 已停止自动请求。", - Some(&error), - )?; - let _ = append_agent_db_record( - root, - serde_json::json!({ - "recordType": "agent.runtime.plan.session_recovery.needs_reconciliation", - "agentId": runtime.agent_id, - "taskId": runtime.task_id, - "sessionId": runtime.session_id, - "runId": runtime.run_id, - "source": runtime.source, - "error": error, - }), - ); - emit_game_creator_agent_runtime_update(root, &task.agent_id); - read_game_creator_agent_runtime_at(root, &task.agent_id) -} - -struct MissingPlanSubmitAnchorCandidate { - runtime: AgentRuntimeState, - action_id: String, - action_fingerprint: String, - commit_matches: bool, -} - -/// Detect a planning submit whose immutable GDD or Runtime action summary -/// survived while both generic recovery anchors vanished. The GDD is needed -/// for the real commit-point/child-finish gap because Runtime does not publish -/// `pending_tool_action` into state until child finish. Once this detector has -/// projected its own reconciliation state, do not append it again. -fn missing_plan_submit_anchor_candidate_at( - root: &Path, - agent_id: &str, -) -> Result, String> { - // 这里只是探测器的前置条件:读不到 state 就不可能匹配「策划子 run 缺锚点」这个形状。 - // 不可读 state 的处置属于 finalization 恢复路径(从 task record 重建并 fail-closed 到 - // needs-reconciliation);在这条最靠前的探测里用 `?` 会打断整轮 resume,反而绕过那条兜底。 - let Ok(result) = read_game_creator_agent_runtime_at(root, agent_id) else { - return Ok(None); - }; - let runtime = result.state; - if runtime.phase == "needs-reconciliation" - && runtime.current_action == "Fast GDD 提交恢复锚点需要人工核对" - { - return Ok(None); - } - if runtime.agent_id != GAME_CREATOR_PROJECT_PLANNING_AGENT_ID - || runtime.source != "agent-delegate" - || runtime.run_profile != AGENT_RUNTIME_RUN_PROFILE_STANDARD - || runtime.run_id.trim().is_empty() - || runtime.session_id.trim().is_empty() - { - return Ok(None); - } - let pending_exists = - game_creator_agent_runtime_pending_tool_action_exists(root, agent_id, &runtime.run_id); - let batch_exists = - game_creator_agent_runtime_provider_action_batch_exists(root, agent_id, &runtime.run_id); - if pending_exists || batch_exists { - return Ok(None); - } - let chain = read_plan_gdd_chain(root).map_err(|error| error.to_string())?; - let matching_gdds = chain - .iter() - .filter(|gdd| { - gdd.agent_id == runtime.agent_id - && gdd.source == runtime.source - && gdd.run_profile == runtime.run_profile - && gdd.run_profile_binding_fingerprint == runtime.run_profile_binding_fingerprint - && gdd.session_id == runtime.session_id - && gdd.created_by_run_id == runtime.run_id - }) - .collect::>(); - let (action_id, action_fingerprint, commit_matches) = - if let Some(summary) = runtime.pending_tool_action.as_ref() { - if summary.tool.trim() != PLAN_SUBMIT_GDD_TOOL - || summary.action_id.trim().is_empty() - || summary.action_fingerprint.trim().is_empty() - { - if runtime.phase == "completed" || matching_gdds.len() != 1 { - return Ok(None); - } - let gdd = matching_gdds[0]; - ( - gdd.submission_id.clone(), - gdd.action_fingerprint.clone(), - false, - ) - } else { - let commit_matches = matching_gdds.iter().any(|gdd| { - gdd.submission_id == summary.action_id - && gdd.action_fingerprint == summary.action_fingerprint - }); - ( - summary.action_id.clone(), - summary.action_fingerprint.clone(), - commit_matches, - ) - } - } else if runtime.phase != "completed" && matching_gdds.len() == 1 { - let gdd = matching_gdds[0]; - ( - gdd.submission_id.clone(), - gdd.action_fingerprint.clone(), - true, - ) - } else { - return Ok(None); - }; - Ok(Some(MissingPlanSubmitAnchorCandidate { - runtime, - action_id, - action_fingerprint, - commit_matches, - })) -} - -fn reconcile_missing_plan_submit_anchors_at( - root: &Path, - agent_id: &str, -) -> Result, String> { - let Some(candidate) = missing_plan_submit_anchor_candidate_at(root, agent_id)? else { - return Ok(None); - }; - let MissingPlanSubmitAnchorCandidate { - mut runtime, - action_id, - action_fingerprint, - commit_matches, - } = candidate; - let error = if commit_matches { - "Fast GDD 已提交,但原 plan.submit_gdd 的 pending/batch 恢复锚点同时缺失" - } else { - "策划子 Run 声称 Fast GDD 已提交,但 immutable GDD 与 Runtime action identity 无法对账" - }; - runtime.status = "failed".to_string(); - runtime.phase = "needs-reconciliation".to_string(); - runtime.current_action = "Fast GDD 提交恢复锚点需要人工核对".to_string(); - runtime.waiting_on = "开发者核对 immutable GDD 与原 submit action identity".to_string(); - runtime.next_step = "核实并恢复原精确 pending/batch 锚点后再继续".to_string(); - runtime.error = Some(error.to_string()); - runtime.updated_at = unix_timestamp(); - append_game_creator_agent_runtime_task(root, &runtime)?; - refresh_game_creator_agent_runtime_task_queue(root, &mut runtime)?; - write_game_creator_agent_runtime_state(root, &runtime)?; - append_game_creator_agent_runtime_event( - root, - &runtime, - "plan.submit_gdd.anchor_missing", - "failed", - "needs-reconciliation", - "Runner 检测到已完成策划提交缺少恢复锚点,已停止自动清理与续跑。", - Some(error), - )?; - append_agent_db_record( - root, - serde_json::json!({ - "recordType": "agent.runtime.plan_submit_gdd.anchor_missing", - "agentId": runtime.agent_id, - "taskId": runtime.task_id, - "sessionId": runtime.session_id, - "runId": runtime.run_id, - "actionId": action_id, - "actionFingerprint": action_fingerprint, - "commitMatches": commit_matches, - }), - )?; - emit_game_creator_agent_runtime_update(root, agent_id); - read_game_creator_agent_runtime_at(root, agent_id).map(Some) -} - pub(in crate::agent) fn ensure_waiting_provider_retry_projection_at( root: &Path, retry: &AgentRuntimeProviderRetryRecord, @@ -657,10 +455,6 @@ pub(crate) fn has_recoverable_game_creator_agent_background_tasks_at( } for agent_id in collect_game_creator_agent_runtime_agent_ids(root)? { - match missing_plan_submit_anchor_candidate_at(root, &agent_id) { - Ok(Some(_)) | Err(_) => return Ok(true), - Ok(None) => {} - } match read_recoverable_game_creator_agent_runtime_task(root, &agent_id) { Ok(Some(_)) | Err(_) => return Ok(true), Ok(None) => {} @@ -1110,95 +904,11 @@ fn durable_process_session_recovery_exists_at(root: &Path) -> bool { false } -/// Fast GDD approval 投影恢复失败时,把 fail-closed 收敛到受影响的那个 run。 -/// -/// 返回 `Ok(true)` 表示已经把策划根 Supervisor 标成 needs-reconciliation,调用方可以 -/// 继续扫描其余 Agent;`Ok(false)` 表示不该、或无法精确收敛,调用方必须把原错误照旧 -/// 上抛,保持全局 fail-closed。 -fn contain_plan_gdd_approval_recovery_failure_at(root: &Path, error: &str) -> Result { - // 瞬时错误绝不能收敛成 needs-reconciliation。最常见的就是 `.agent/project.lock` - // 正被另一个写操作占用——什么都没坏,下一轮扫描重试即可;把它标成人工核对等于 - // 用一次转瞬即逝的锁争用永久停掉策划根 run,比原来的强传播更糟。这里照旧上抛, - // 调用方把它变成 recovery_pending 并在下一轮重试,与本函数出现之前的行为一致。 - // 判据复用委派唤醒那条既有的瞬时特征串,避免两处各写一套导致分类漂移。 - if static_delegate_parent_wake_error_is_transient(error) { - return Ok(false); - } - let Some(_runtime_lock) = try_acquire_game_creator_agent_runtime_task_lock( - root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - )? - else { - return Ok(false); - }; - let mut runtime = - read_game_creator_agent_runtime_at(root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID)?.state; - if runtime.run_id.trim().is_empty() - || matches!( - runtime.phase.as_str(), - "completed" | "cancelled" | "needs-reconciliation" - ) - { - return Ok(false); - } - let error = sanitize_agent_runtime_text(error, 500); - runtime.status = "failed".to_string(); - runtime.phase = "needs-reconciliation".to_string(); - runtime.current_action = "Fast GDD 审批投影恢复需要人工核对".to_string(); - runtime.waiting_on = "开发者核对 planning pending、receipt 与原提交锚点".to_string(); - runtime.next_step = "修复审批投影后显式恢复该 run".to_string(); - runtime.error = Some(error.clone()); - runtime.updated_at = unix_timestamp(); - append_game_creator_agent_runtime_task(root, &runtime)?; - refresh_game_creator_agent_runtime_task_queue(root, &mut runtime)?; - write_game_creator_agent_runtime_state(root, &runtime)?; - let _ = append_game_creator_agent_runtime_event( - root, - &runtime, - "plan.gdd.approval_recovery.needs_reconciliation", - "failed", - "needs-reconciliation", - "Fast GDD 审批投影恢复已停止自动重放,等待开发者核对。", - Some(&error), - ); - let _ = append_agent_db_record( - root, - serde_json::json!({ - "recordType": "agent.runtime.plan.gdd.approval_recovery.needs_reconciliation", - "agentId": runtime.agent_id, - "taskId": runtime.task_id, - "sessionId": runtime.session_id, - "runId": runtime.run_id, - "error": error, - }), - ); - emit_game_creator_agent_runtime_update(root, &runtime.agent_id); - Ok(true) -} - pub(in crate::agent) fn resume_game_creator_agent_background_tasks_unredacted_at( root: &Path, ) -> Result, String> { validate_project_root(root)?; recover_direct_taonier_regeneration_workflow_at(root)?; - // 这是一条只覆盖策划根 Supervisor 的窄投影恢复,却挂在整轮 resume 的最前面。 - // 原来用 `?` 强传播:一次 Fast GDD 投影失败会掐掉全项目所有 Agent 的恢复——而它 - // 本身正是 receipt 投影失败后的重试入口,掐掉它等于连兜底一起废掉。 - // - // 失败必须分三路,不能两路。此前把「瞬时」和「归属不了」并成同一个 false, - // 结果瞬时锁争用走了全局上抛,让这条可反复调用的恢复入口整轮失败——而锁被占 - // 恰恰说明别处正在推进,是最不该失败的时候。 - if let Err(error) = reconcile_plan_gdd_approval_projections_at(root) { - let error = format!("恢复 GDD approval 投影失败:{error}"); - if static_delegate_parent_wake_error_is_transient(&error) { - // 瞬时争用(最常见的是 `.agent/project.lock` 正被另一个写操作占用): - // 跳过本轮投影恢复,其余恢复照常走,下一轮 resume 重试。与下面拿不到 - // runtime task 锁时直接跳过的处理是同一套语义,因此同样不落审计。 - } else if !contain_plan_gdd_approval_recovery_failure_at(root, &error)? { - // 持久失败但归属不到具体 run:只能退回原来的全局上抛。 - return Err(error); - } - } if external_agent_runner_owns_background_execution() { resume_external_agent_runner(root)?; return read_game_creator_agent_runtimes_at(root); @@ -1287,86 +997,6 @@ pub(in crate::agent) fn resume_game_creator_agent_background_tasks_unredacted_at // projection, so the continuation could issue its first request with // stale decisions/appliedAnswers. // - // Never acquire the project lock while retaining the Agent execution - // lock: normal enqueue owns project -> Session lane -> execution. Drop - // and reacquire in project -> execution order, re-read the task under - // the new lock set, project exactly once, then release the project lock - // before any later path can enter the Session lane. - // - // 但双锚缺失的 run 要让路。本块修 session 投影,是为了让**将要继续**的 - // continuation 不带着过期决定去发第一个 Provider 请求;而 `plan.submit_gdd` - // 的 pending/batch 恢复锚点同时丢失的 run 根本不会继续——下面 - // `reconcile_missing_plan_submit_anchors_at` 会把它判失败并要求人工核对。 - // 若不让路,本块会先落一条泛化的「未命中 planning session 协调器」,把那条 - // 精确得多的双锚缺失诊断永久挡在后面:两者都写 needs-reconciliation,谁先 - // 写谁赢,而先写的那条恰恰是信息量更少的。探测器自身很便宜,且对「已按双锚 - // 缺失收敛过」的状态返回 None,所以这道让路不会反复触发。 - let runtime_lock = if agent_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID - && read_recoverable_runnable_game_creator_agent_runtime_task(root, &agent_id)?.is_some() - && missing_plan_submit_anchor_candidate_at(root, &agent_id)?.is_none() - { - drop(runtime_lock); - let project_lock = match acquire_game_creator_agent_runtime_project_write_lock_with_wait( - root, - "planning.child-session.recovery", - ) { - Ok(lock) => lock, - Err(error) if static_delegate_parent_wake_error_is_transient(&error) => continue, - Err(error) => { - return Err(format!("恢复 Fast GDD session 前取得项目锁失败:{error}")); - } - }; - let Some(runtime_lock) = - try_acquire_game_creator_agent_runtime_task_lock(root, &agent_id)? - else { - continue; - }; - let Some(task) = - read_recoverable_runnable_game_creator_agent_runtime_task(root, &agent_id)? - else { - continue; - }; - let projection = match plan_session_already_projects_planning_child_task_at_locked( - root, - &task, - &project_lock, - ) { - Ok(true) => Ok(true), - Ok(false) => ensure_plan_session_for_planning_child_task_at_locked( - root, - &task, - &project_lock, - ), - Err(error) => Err(error), - }; - match projection { - Ok(true) => {} - Ok(false) => { - resumed.push(mark_plan_session_projection_needs_reconciliation_at( - root, - &task, - "PLAN_NEEDS_RECONCILIATION: project-planning 恢复任务未命中 planning session 协调器", - )?); - continue; - } - Err(error) if static_delegate_parent_wake_error_is_transient(&error) => continue, - Err(error) => { - resumed.push(mark_plan_session_projection_needs_reconciliation_at( - root, &task, &error, - )?); - continue; - } - } - drop(project_lock); - runtime_lock - } else { - runtime_lock - }; - if let Some(result) = reconcile_missing_plan_submit_anchors_at(root, &agent_id)? { - resumed.push(result); - drop(runtime_lock); - continue; - } let mut retry_projection_blocked = false; if let Some(retries) = retry_records_by_agent.remove(&agent_id) { for retry in retries { @@ -2224,245 +1854,6 @@ mod orphaned_external_generation_recovery_tests { } } - fn valid_plan_submit_input_for_recovery() -> PlanSubmitGddInputV1 { - serde_json::from_value(serde_json::json!({ - "schemaVersion": PLAN_SUBMIT_GDD_INPUT_SCHEMA_VERSION, - "game": { - "title": "萤火守夜者", - "genre": {"primary": "轻策略", "fusion": null}, - "artStyle": { - "visualType": "手绘平面", - "keywords": ["暖色", "剪影", "纸感"], - "moodAndColor": "夜色中的暖黄灯火", - "mvpArtBoundary": "仅制作可复用的角色、灯火和地块素材" - }, - "oneLiner": "玩家在一局十分钟的守夜旅程中分配有限灯火、判断风险并选择路线,守住营地后寻找下一处安全落脚点", - "pillars": [ - { - "name": "取舍", - "playerFeel": "每次选择都有代价", - "mechanism": "有限灯火在路线与营地之间分配", - "decisionState": "confirmed" - }, - { - "name": "重玩", - "playerFeel": "想再试一次更优路线", - "mechanism": "不同路线组合产生不同风险", - "decisionState": "confirmed" - } - ], - "coreLoop": ["观察地图", "分配灯火", "选择路线", "处理事件"], - "targetUsers": { - "coreUsers": "喜欢短局策略的玩家", - "preferences": "偏好清晰反馈和轻量决策", - "sessionLength": "10至20分钟", - "referenceGames": [] - }, - "mvpSystems": [ - { - "system": "地图", - "minimalFunction": "展示当前营地与可选路线", - "whyRequired": "让玩家理解空间选择", - "verifyMethod": "能完成一局并看懂下一步", - "decisionState": "confirmed" - }, - { - "system": "灯火", - "minimalFunction": "消耗灯火换取安全或探索", - "whyRequired": "承载核心取舍", - "verifyMethod": "两种分配策略结果可区分", - "decisionState": "confirmed" - }, - { - "system": "事件", - "minimalFunction": "路线途中触发一项选择", - "whyRequired": "提供短局变化", - "verifyMethod": "重玩时可遇到不同事件", - "decisionState": "confirmed" - } - ], - "outOfScope": ["多人联机"], - "creatorTips": { - "doFirst": "先做一张可走完的地图", - "deferForNow": "暂缓复杂成长线", - "howToVerify": "观察玩家是否能说出每次选择的后果", - "expandWhen": "核心循环连续三局都可理解后再扩展" - } - }, - "decisions": [{ - "id": "initial-request", - "topic": "初始需求", - "state": "confirmed", - "answerSource": "user_freeform", - "round": 0, - "answerSummary": "做一个短局守夜策略游戏" - }], - "prototypeValidationItems": [] - })) - .expect("valid plan submit recovery input") - } - - #[test] - fn committed_plan_submit_without_either_anchor_is_publicly_recoverable_and_fails_closed() { - let temporary = crate::tests::canonical_test_tempdir("plan-submit-double-anchor-"); - let root = temporary.path(); - init_local_game_project_at( - root, - "plan-submit-double-anchor", - "策划提交双锚缺失恢复测试", - ) - .expect("init project"); - bind_game_creator_agent_runtime_run_profile_at( - root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - "plan-submit-double-anchor-root", - AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE, - Some(AGENT_RUNTIME_RUN_PROFILE_STANDARD), - None, - ) - .expect("bind planning root"); - let link = AgentRuntimeTaskLink { - parent_agent_id: Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string()), - parent_run_id: Some("plan-submit-double-anchor-root".to_string()), - delegation_id: Some("plan-submit-double-anchor-delegation".to_string()), - }; - bind_game_creator_agent_runtime_run_profile_at( - root, - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - "plan-submit-double-anchor-child", - "agent-delegate", - Some(AGENT_RUNTIME_RUN_PROFILE_STANDARD), - Some(&link), - ) - .expect("bind planning child"); - let mut runtime = start_game_creator_agent_runtime_task_at( - root, - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - "提交 Fast GDD", - "plan-submit-double-anchor-child", - "agent-delegate", - "提交 Fast GDD", - vec!["提交 Fast GDD".to_string()], - ) - .expect("start planning child"); - let action_id = "action-0123456789abcdef01234567"; - let action_fingerprint = "a".repeat(64); - let context = PlanSubmitGddRuntimeContext { - project_id: "plan-submit-double-anchor".to_string(), - gdd_id: "gdd-00000000-0000-4000-8000-000000000001".to_string(), - action_id: action_id.to_string(), - action_fingerprint: action_fingerprint.clone(), - agent_id: runtime.agent_id.clone(), - source: runtime.source.clone(), - run_profile: runtime.run_profile.clone(), - run_profile_binding_fingerprint: runtime.run_profile_binding_fingerprint.clone(), - root_agent_id: GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(), - root_run_id: "plan-submit-double-anchor-root".to_string(), - parent_agent_id: Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string()), - parent_run_id: Some("plan-submit-double-anchor-root".to_string()), - delegation_id: "plan-submit-double-anchor-delegation".to_string(), - session_id: runtime.session_id.clone(), - source_session_revision: 1, - source_session_fingerprint: format!("sha256-serde-json-v2:{}", "b".repeat(64)), - created_by_run_id: runtime.run_id.clone(), - created_at_utc: "2026-08-14T00:00:00.000Z".to_string(), - approval_request_id: Some( - "gdd-approval-00000000-0000-4000-8000-000000000002".to_string(), - ), - }; - let gdd = build_plan_gdd_from_submit_input( - &valid_plan_submit_input_for_recovery(), - &context, - 1, - context - .approval_request_id - .as_deref() - .expect("approval request id"), - ) - .expect("build committed GDD"); - let gdd_bytes = canonical_plan_gdd_bytes(&gdd).expect("canonical committed GDD"); - durable_create_json_no_replace_locked( - root, - ".agent/planning/gdd.v1.json", - &gdd_bytes, - "GDD", - ) - .expect("persist committed GDD"); - assert_eq!( - read_plan_gdd_chain(root) - .expect("read committed GDD chain") - .len(), - 1 - ); - - // Model the commit-point/post-finish gap: the GDD fact is durable, - // both generic anchors are gone, but the child has not reached its - // terminal projection yet. - runtime.status = "running".to_string(); - runtime.phase = "provider-action-batch".to_string(); - runtime.pending_tool_action = None; - runtime.updated_at = unix_timestamp(); - append_game_creator_agent_runtime_task(root, &runtime).expect("append pre-finish task"); - refresh_game_creator_agent_runtime_task_queue(root, &mut runtime) - .expect("refresh pre-finish task queue"); - write_game_creator_agent_runtime_state(root, &runtime).expect("write pre-finish state"); - - assert!( - missing_plan_submit_anchor_candidate_at(root, &runtime.agent_id) - .expect("detect missing pre-finish anchors") - .is_some() - ); - assert!(has_recoverable_game_creator_agent_background_tasks_at(root) - .expect("public preflight must expose double-anchor loss")); - - let resumed = resume_game_creator_agent_background_tasks_at(root) - .expect("public recovery must fail closed in runtime state"); - assert!(resumed - .iter() - .any(|result| result.state.agent_id == runtime.agent_id - && result.state.phase == "needs-reconciliation")); - let reconciled = read_game_creator_agent_runtime_at(root, &runtime.agent_id) - .expect("read reconciled planning child"); - assert_eq!(reconciled.state.phase, "needs-reconciliation"); - assert!(reconciled - .state - .error - .as_deref() - .is_some_and(|error| error.contains("恢复锚点同时缺失"))); - assert!(read_plan_gdd_approval_pending_locked(root) - .expect("read planning approval pending") - .is_none()); - let audit_count = read_agent_db_records_bounded(root, 1024 * 1024) - .expect("read anchor-missing audit") - .0 - .iter() - .filter(|record| { - record.get("recordType").and_then(|value| value.as_str()) - == Some("agent.runtime.plan_submit_gdd.anchor_missing") - }) - .count(); - assert_eq!(audit_count, 1); - assert!( - !has_recoverable_game_creator_agent_background_tasks_at(root) - .expect("the detector must not rediscover its own reconciliation") - ); - let _ = resume_game_creator_agent_background_tasks_at(root) - .expect("second recovery scan may surface the existing reconciliation state"); - assert!(read_plan_gdd_approval_pending_locked(root) - .expect("reread planning approval pending") - .is_none()); - let audit_count_after_second_scan = read_agent_db_records_bounded(root, 1024 * 1024) - .expect("reread anchor-missing audit") - .0 - .iter() - .filter(|record| { - record.get("recordType").and_then(|value| value.as_str()) - == Some("agent.runtime.plan_submit_gdd.anchor_missing") - }) - .count(); - assert_eq!(audit_count_after_second_scan, audit_count); - } - #[test] fn recovery_scan_preserves_active_generation_orphan_then_cleans_terminal_legacy_orphan() { let temporary = crate::tests::canonical_test_tempdir("orphan-generation-recovery-"); @@ -2587,280 +1978,3 @@ mod orphaned_external_generation_recovery_tests { ); } } - -#[cfg(test)] -mod plan_gdd_approval_wait_recovery_tests { - use super::*; - - /// Fast GDD 审批等待期,根 Supervisor 到底还能不能被恢复扫描拉起。 - /// - /// 判据不在 `phase` 上:`read_recoverable_game_creator_agent_runtime_task` 按 - /// task record 的 **status** 分类,而 record 的 status 由 - /// `game_creator_agent_runtime_task_status` 从 `state.status` 推导。审批等待 - /// (`main_loop` 的 `runtime.plan_gdd` 分支)只改 phase、保留 `status="running"`; - /// 真正的澄清等待(`action_projection`)才会把 status 一并写成 - /// `waiting-for-user-input`,那才是恢复扫描要让路的外部输入等待。 - /// - /// 这个区别决定了审批决定之后还有没有生产路径驱动父 run:审批命令只调通用 - /// wake,一旦有人把审批等待也写成 `status="waiting-for-user-input"`,通用 wake - /// 会静默变成 no-op,用户点了批准/修改/退回之后不会有任何东西继续跑。这条测试 - /// 把这个区别钉成不变量。 - #[test] - fn plan_gdd_approval_wait_stays_recoverable_while_real_user_input_wait_does_not() { - let temporary = crate::tests::canonical_test_tempdir("plan-gdd-approval-wait-recovery-"); - let root = temporary.path(); - init_local_game_project_at(root, "plan-gdd-approval-wait", "Fast GDD 审批等待恢复判定") - .expect("init project"); - let mut runtime = start_game_creator_agent_runtime_task_at( - root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - "推进立项策划", - "plan-gdd-approval-wait-run", - "agent-ready-task-scheduler", - "准备推进立项策划", - vec!["推进立项策划".to_string()], - ) - .expect("start plan-root supervisor runtime"); - - // main_loop.rs 的 Fast GDD 审批等待形状。 - runtime.status = "running".to_string(); - runtime.phase = "waiting-for-user-input".to_string(); - runtime.current_action = "等待 Fast GDD 审批决定".to_string(); - runtime.waiting_on = "用户在审批卡选择批准、修改或退回".to_string(); - runtime.updated_at = unix_timestamp(); - append_game_creator_agent_runtime_task(root, &runtime).expect("append approval wait task"); - refresh_game_creator_agent_runtime_task_queue(root, &mut runtime) - .expect("refresh approval wait task queue"); - write_game_creator_agent_runtime_state(root, &runtime).expect("write approval wait state"); - - let task = - read_recoverable_runnable_game_creator_agent_runtime_task(root, &runtime.agent_id) - .expect("read approval wait task") - .expect("审批等待态必须仍可被恢复扫描拉起,否则审批决定后没有生产路径驱动父 run"); - assert_eq!(task.status, "running"); - assert_eq!(task.phase, "waiting-for-user-input"); - assert!(has_recoverable_game_creator_agent_background_tasks_at(root) - .expect("public preflight must agree with the recoverable task read")); - - // 对照组:真正的用户输入等待把 status 一并写成 waiting-for-user-input。 - runtime.status = "waiting-for-user-input".to_string(); - runtime.updated_at = unix_timestamp(); - append_game_creator_agent_runtime_task(root, &runtime) - .expect("append user input wait task"); - refresh_game_creator_agent_runtime_task_queue(root, &mut runtime) - .expect("refresh user input wait task queue"); - write_game_creator_agent_runtime_state(root, &runtime) - .expect("write user input wait state"); - - assert!( - read_recoverable_runnable_game_creator_agent_runtime_task(root, &runtime.agent_id) - .expect("read user input wait task") - .is_none(), - "status 写成 waiting-for-user-input 才是恢复扫描让路的外部输入等待" - ); - } - - /// Fast GDD approval 投影恢复失败,不能再掐掉整轮 resume。 - /// - /// 它挂在 `resume_game_creator_agent_background_tasks_unredacted_at` 的第一行, - /// 原来用 `?` 强传播;而这条 reconcile 本身正是 receipt 投影失败后的重试入口, - /// 掐掉它等于连兜底一起废掉。现在 fail-closed 精确收敛到策划根 Supervisor 这个 run。 - #[test] - fn failed_plan_gdd_approval_recovery_contains_itself_instead_of_aborting_the_scan() { - let temporary = crate::tests::canonical_test_tempdir("plan-gdd-approval-recovery-contain-"); - let root = temporary.path(); - init_local_game_project_at(root, "plan-gdd-approval-contain", "审批投影恢复失败收敛") - .expect("init project"); - let runtime = start_game_creator_agent_runtime_task_at( - root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - "推进立项策划", - "plan-gdd-approval-contain-run", - "agent-ready-task-scheduler", - "准备推进立项策划", - vec!["推进立项策划".to_string()], - ) - .expect("start plan-root supervisor runtime"); - - let planning_directory = root.join(".agent/planning"); - fs::create_dir_all(&planning_directory).expect("create planning directory"); - fs::write(planning_directory.join("gdd.v1.json"), b"{") - .expect("corrupt the GDD lineage so approval recovery fails"); - assert!(reconcile_plan_gdd_approval_projections_at(root).is_err()); - - let resumed = resume_game_creator_agent_background_tasks_at(root) - .expect("窄投影恢复失败不能把整轮 resume 掐掉"); - assert!(resumed - .iter() - .all(|result| result.state.phase != "completed")); - - let contained = read_game_creator_agent_runtime_at(root, &runtime.agent_id) - .expect("read contained supervisor runtime"); - assert_eq!(contained.state.phase, "needs-reconciliation"); - assert!( - contained - .state - .error - .as_deref() - .is_some_and(|error| error.contains("恢复 GDD approval 投影失败")), - "unexpected error: {:?}", - contained.state.error - ); - let audit_count = read_agent_db_records_bounded(root, 1024 * 1024) - .expect("read approval recovery audit") - .0 - .iter() - .filter(|record| { - record.get("recordType").and_then(|value| value.as_str()) - == Some("agent.runtime.plan.gdd.approval_recovery.needs_reconciliation") - }) - .count(); - assert_eq!(audit_count, 1); - } - - /// 瞬时错误不能被收敛成 needs-reconciliation。 - /// - /// `.agent/project.lock` 正被另一个写操作占用时什么都没坏,下一轮扫描重试即可; - /// 把它标成人工核对,等于用一次转瞬即逝的锁争用永久停掉策划根 run——那比这条 - /// 收敛出现之前的强传播还糟。此时必须照旧上抛,由调用方转成 recovery_pending。 - #[test] - fn transient_plan_gdd_approval_recovery_failure_is_retried_instead_of_reconciled() { - let temporary = - crate::tests::canonical_test_tempdir("plan-gdd-approval-recovery-transient-"); - let root = temporary.path(); - init_local_game_project_at(root, "plan-gdd-approval-transient", "审批投影恢复瞬时失败") - .expect("init project"); - let runtime = start_game_creator_agent_runtime_task_at( - root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - "推进立项策划", - "plan-gdd-approval-transient-run", - "agent-ready-task-scheduler", - "准备推进立项策划", - vec!["推进立项策划".to_string()], - ) - .expect("start plan-root supervisor runtime"); - - let held = acquire_project_write_lock(root, "test.hold-project-write-lock") - .expect("hold the project write lock"); - // 锁被占说明别处正在推进,这恰恰是 resume 最不该失败的时候:本轮跳过投影 - // 恢复即可,整轮 resume 必须照常成功。上抛会让 resume 这个可反复调用的恢复 - // 入口在一次普通锁争用下整体失败——真实调用方就是连着调它来断言幂等的。 - resume_game_creator_agent_background_tasks_at(root) - .expect("瞬时锁争用只能跳过本轮投影恢复,不得让整轮 resume 失败"); - drop(held); - - let contained = read_game_creator_agent_runtime_at(root, &runtime.agent_id) - .expect("read supervisor runtime after transient failure"); - assert_ne!( - contained.state.phase, "needs-reconciliation", - "瞬时锁争用不得把策划根 run 永久停掉" - ); - assert!(contained.state.error.is_none()); - assert!(!read_agent_db_records_bounded(root, 1024 * 1024) - .expect("read approval recovery audit") - .0 - .iter() - .any( - |record| record.get("recordType").and_then(|value| value.as_str()) - == Some("agent.runtime.plan.gdd.approval_recovery.needs_reconciliation") - )); - } -} - -#[cfg(test)] -mod plan_session_recovery_gate_tests { - use super::*; - - /// 这条用例原来伪造一个 `source=agent-background-task`、且**没有 Run Profile - /// 绑定**的策划任务。但 `agent_runtime_tool_policy_snapshot_for_run_at` 早在 - /// `M1A-2`(`09c7d7af8`)就按 agent 身份挡死了这种 run——那道门是刻意的 - /// fail-closed 收窄(放宽它,伪造的策划 run 就能拿到默认宽工具面),于是被告 - /// 根本造不出来。用例自 `M1C-2b` 写下起一天都没绿过,只是当时的定向门禁过滤器 - /// (`planning_clarification_*`)恰好匹配不到它的名字。 - /// - /// 重写时试过「事后删掉父绑定」这种更贴现实的破坏方式,实测走不到本门:读路径 - /// 的 tool policy 收容(`entrypoints.rs` 里 `Run Profile 工具策略需要人工核对`) - /// 会先把 run 打成 failed,`read_recoverable_...` 随即不再认它。也就是说**绑定 - /// 层的破坏根本到不了 session 门**——能到这里的只有「绑定完好、task record 自身 - /// 不满足 D11 契约」这一类持久不一致,这正是本门存在的理由。 - /// - /// 所以改成:绑定全部合法(创建门放行),但 task record 缺 `delegationId` - /// (裸 start 不带 task link 时就是这个形状)。合同一字未改。 - #[test] - fn planning_recovery_contains_session_identity_conflict_before_provider() { - let temporary = - crate::tests::canonical_test_tempdir("plan-session-recovery-gate-conflict-"); - let root = temporary.path(); - init_local_game_project_at(root, "plan-recovery-gate", "策划恢复门冲突收敛") - .expect("init project"); - let parent_run_id = "plan-session-recovery-conflict-root"; - let child_run_id = "plan-session-recovery-conflict-run"; - bind_game_creator_agent_runtime_run_profile_at( - root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - parent_run_id, - AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE, - Some(AGENT_RUNTIME_RUN_PROFILE_STANDARD), - None, - ) - .expect("bind supervisor plan root"); - bind_game_creator_agent_runtime_run_profile_at( - root, - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - child_run_id, - "agent-delegate", - Some(AGENT_RUNTIME_RUN_PROFILE_STANDARD), - Some(&AgentRuntimeTaskLink { - parent_agent_id: Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string()), - parent_run_id: Some(parent_run_id.to_string()), - delegation_id: Some("plan-session-recovery-conflict-delegation".to_string()), - }), - ) - .expect("bind delegated planning child"); - // 裸 start 的 task link 取自已排队的 record,这里没有,于是 task record 上 - // 的 parentRunId/delegationId 是空的——绑定说这是委派子 run,task record 却 - // 讲不出自己属于哪次委派。 - start_game_creator_agent_runtime_task_at( - root, - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - "缺少委派链的策划任务", - child_run_id, - "agent-delegate", - "准备执行策划任务", - vec!["不得发起 Provider 请求".to_string()], - ) - .expect("persist recoverable planning task"); - - let resumed = resume_game_creator_agent_background_tasks_unredacted_at(root) - .expect("identity conflict is contained to the planning task"); - let contained = resumed - .iter() - .find(|result| result.state.agent_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID) - .expect("planning task is returned as contained"); - assert_eq!(contained.state.status, "failed"); - assert_eq!(contained.state.phase, "needs-reconciliation"); - let contained_error = contained.state.error.as_deref().unwrap_or_default(); - assert!( - contained_error.contains("PLAN_SOURCE_PROFILE_MISMATCH"), - "身份冲突必须落在 planning coordinator 的类型化合同里,实际为:{contained_error}" - ); - - let public_audit = fs::read_to_string(root.join(".agent/agent.db")).unwrap_or_default(); - let record_types = public_audit - .split("\"recordType\"") - .skip(1) - .filter_map(|chunk| chunk.split('"').nth(1).map(str::to_string)) - .collect::>(); - assert!( - public_audit.contains("agent.runtime.plan.session_recovery.needs_reconciliation"), - "必须由 session 恢复门收容,实际审计记录类型:{record_types:?};\ - currentAction={}", - contained.state.current_action - ); - assert!( - !public_audit.contains(AGENT_RUNTIME_PROVIDER_REQUEST_LIFECYCLE_RECORD_TYPE), - "session identity 冲突必须在任何 Provider lifecycle 之前停止" - ); - } -} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_queue.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_queue.rs index ccbbbdcb2..5b3fc70a8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_queue.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_queue.rs @@ -477,231 +477,9 @@ pub(crate) async fn run_game_creator_agent_background_task_with_context( return AgentBackgroundTaskOutcome::WaitingForProviderHandoff; } AgentBackgroundTaskOutcome::NeedsReconciliation => { - notify_static_delegate_parent_of_child_reconciliation_at( - &root, - &agent_id, - ¤t_run_id, - ); return AgentBackgroundTaskOutcome::NeedsReconciliation; } outcome => return outcome, } } } - -/// 子 run 停在 needs-reconciliation 时,把父 run 一起抬到人工核对。 -/// -/// 委派子 run 的其余每一条终态出口都会向父 run 发布结果——finish / fail / budget 三条 -/// 都会走 publish_game_creator_agent_delegate_result_for_state——唯独 needs-reconciliation -/// 不发。那条出口拒绝**认领结果**是对的:Runtime 无法证明服务端副作用是否已经发生, -/// 伪造一份 delivery 结果比卡住更糟。但它连「这条委派不会再产生回执」都没说,于是: -/// -/// - delivery 永远停在 Dispatched; -/// - `static_delegate_completion_barrier_at` 无条件把 Dispatched 计入 waiting; -/// - 父唤醒是一次性事件驱动的,`drive_waiting_static_delegate_parent_wake_pass` 一旦看到 -/// `has_waiting()` 为真就永久 return,没有任何周期性复查。 -/// -/// 三者叠起来就是死锁。现场形态:上游网关把流掐断在 response.created 之后,策划子 run -/// 落到 needs-reconciliation,父 Supervisor 在 waiting-for-delegate-receipts 上静默二十 -/// 分钟,界面全程显示「正在启动处理」——前端认得 needs-reconciliation,但它读的是父 run, -/// 而父 run 看上去一切正常。 -/// -/// 这里不伪造任何 delivery 结果,只把父 run 也标成需要人工核对,让状态浮出水面。 -fn notify_static_delegate_parent_of_child_reconciliation_at( - root: &Path, - agent_id: &str, - run_id: &str, -) { - // 只覆盖立项策划子 Agent。做游戏与做素材的委派子 run 逐字保持既有行为——它们那条 - // 链路上同一个死锁仍然存在,要一并解开得另行评估各自的父 run 语义。 - if agent_id != GAME_CREATOR_PROJECT_PLANNING_AGENT_ID { - return; - } - let Ok(runtime) = read_game_creator_agent_runtime_at(root, agent_id) else { - return; - }; - let state = runtime.state; - if state.run_id != run_id || state.phase != "needs-reconciliation" { - return; - } - let (Some(parent_agent_id), Some(parent_run_id)) = ( - state.parent_agent_id.as_deref(), - state.parent_run_id.as_deref(), - ) else { - return; - }; - let reason = format!( - "委派子 Agent {agent_id} 停在 needs-reconciliation,本条委派不会再产生回执:{}", - state.error.as_deref().unwrap_or("(无错误详情)") - ); - // 该原语自带门槛:父 run 的 run_id 不匹配、或父 run 不在 waiting-for-delegate-receipts - // 时直接返回 Ok(()),所以重复调用与竞态都是安全的。 - let _ = mark_static_delegate_parent_wake_needs_reconciliation_at( - root, - parent_agent_id, - parent_run_id, - &reason, - ); -} - -#[cfg(test)] -mod delegated_child_reconciliation_tests { - use super::*; - - struct WedgedPair { - temporary: tempfile::TempDir, - parent_run_id: String, - child_run_id: String, - } - - /// 造一对现场同构的父子 run:父停在 waiting-for-delegate-receipts,子停在 - /// needs-reconciliation,delivery 还是 Dispatched。 - fn wedged_pair(tag: &str, child_agent_id: &str) -> WedgedPair { - let temporary = crate::tests::canonical_test_tempdir("delegate-child-reconciliation-"); - let root = temporary.path(); - init_local_game_project_at(root, tag, "子 run reconciliation 冒泡测试") - .expect("init project"); - - let parent_run_id = format!("{tag}-supervisor-run"); - bind_game_creator_agent_runtime_run_profile_at( - root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &parent_run_id, - AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE, - Some(AGENT_RUNTIME_RUN_PROFILE_STANDARD), - None, - ) - .expect("bind parent run profile"); - let mut parent = start_game_creator_agent_runtime_task_at( - root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - "委派 project-planning 收敛 Fast GDD", - &parent_run_id, - AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE, - "等待专业 Agent 回执", - vec!["取得可审批的 Fast GDD".to_string()], - ) - .expect("start parent run"); - parent.status = "running".to_string(); - parent.phase = "waiting-for-delegate-receipts".to_string(); - append_game_creator_agent_runtime_task(root, &parent).expect("append parent task"); - write_game_creator_agent_runtime_state(root, &parent).expect("write parent state"); - - let child_run_id = format!("delegated-{tag}-child-run"); - bind_game_creator_agent_runtime_run_profile_at( - root, - child_agent_id, - &child_run_id, - "agent-delegate", - Some(AGENT_RUNTIME_RUN_PROFILE_STANDARD), - Some(&AgentRuntimeTaskLink { - parent_agent_id: Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string()), - parent_run_id: Some(parent_run_id.clone()), - delegation_id: Some(format!("delegation-{tag}")), - }), - ) - .expect("bind child run profile"); - let mut child = start_game_creator_agent_runtime_task_at( - root, - child_agent_id, - "根据用户初始需求形成 Fast GDD", - &child_run_id, - "agent-delegate", - "生成澄清信封", - vec!["提出一个关键决定".to_string()], - ) - .expect("start child run"); - child.status = "failed".to_string(); - child.phase = "needs-reconciliation".to_string(); - child.parent_agent_id = Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string()); - child.parent_run_id = Some(parent_run_id.clone()); - child.error = Some( - "provider-request-needs-reconciliation: requestId=provider-request-abc".to_string(), - ); - append_game_creator_agent_runtime_task(root, &child).expect("append child task"); - write_game_creator_agent_runtime_state(root, &child).expect("write child state"); - - WedgedPair { - temporary, - parent_run_id, - child_run_id, - } - } - - fn parent_phase(root: &Path) -> String { - read_game_creator_agent_runtime_at(root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID) - .expect("read parent runtime") - .state - .phase - } - - /// 现场形态:网关把流掐断,策划子 run 落到 needs-reconciliation。此前它是**唯一** - /// 不向父 run 发布任何东西的终态出口,父 run 因此在 waiting-for-delegate-receipts - /// 上长眠——delivery 永远是 Dispatched,屏障永远 has_waiting,而父唤醒是一次性的。 - #[test] - fn a_wedged_planning_child_lifts_its_parent_out_of_the_receipt_wait() { - let pair = wedged_pair("planning-wedged", GAME_CREATOR_PROJECT_PLANNING_AGENT_ID); - let root = pair.temporary.path(); - assert_eq!(parent_phase(root), "waiting-for-delegate-receipts"); - - notify_static_delegate_parent_of_child_reconciliation_at( - root, - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - &pair.child_run_id, - ); - - assert_eq!(parent_phase(root), "needs-reconciliation"); - let parent = - read_game_creator_agent_runtime_at(root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID) - .expect("reread parent runtime") - .state; - assert_eq!(parent.run_id, pair.parent_run_id); - let error = parent.error.expect("parent carries the child's reason"); - assert!(error.contains("needs-reconciliation"), "{error}"); - } - - /// 重复调用必须无副作用:父 run 已经离开 waiting-for-delegate-receipts 之后, - /// 这条通知不得再改写它的任何状态。 - #[test] - fn notifying_twice_leaves_the_parent_untouched() { - let pair = wedged_pair("planning-twice", GAME_CREATOR_PROJECT_PLANNING_AGENT_ID); - let root = pair.temporary.path(); - notify_static_delegate_parent_of_child_reconciliation_at( - root, - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - &pair.child_run_id, - ); - let first = - read_game_creator_agent_runtime_at(root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID) - .expect("read parent after first notify") - .state; - notify_static_delegate_parent_of_child_reconciliation_at( - root, - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - &pair.child_run_id, - ); - let second = - read_game_creator_agent_runtime_at(root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID) - .expect("read parent after second notify") - .state; - assert_eq!(first.phase, second.phase); - assert_eq!(first.error, second.error); - } - - /// 做游戏与做素材的委派子 run 逐字保持既有行为:同样卡死,父 run 也不会被这条 - /// 通知碰一下。它们那条链路上同一个死锁仍然存在,解开需要另行评估父 run 语义。 - #[test] - fn other_lanes_keep_the_existing_behaviour() { - let pair = wedged_pair("design-wedged", "design-director"); - let root = pair.temporary.path(); - assert_eq!(parent_phase(root), "waiting-for-delegate-receipts"); - - notify_static_delegate_parent_of_child_reconciliation_at( - root, - "design-director", - &pair.child_run_id, - ); - - assert_eq!(parent_phase(root), "waiting-for-delegate-receipts"); - } -} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs index 45571d8c9..6897b55df 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs @@ -6,10 +6,6 @@ pub(in crate::agent) fn collect_game_creator_agent_runtime_agent_ids( let manifest = read_manifest_for_project(root)?; let mut agent_ids = std::collections::BTreeSet::new(); agent_ids.insert(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string()); - // 立项策划子 Agent 不在种子 DAG、不属于任何专业组、也不是动态孤立实例, - // 三条既有收录路径都覆盖不到它。这个集合是崩溃恢复扫描、steer 级联取消与 - // 委派回执兜底对账的共同枚举来源,漏收会让重启后的策划委派变成孤儿任务。 - agent_ids.insert(GAME_CREATOR_PROJECT_PLANNING_AGENT_ID.to_string()); for task in manifest.tasks { if let Ok(agent_id) = normalize_game_creator_runtime_agent_id(&task.id) { agent_ids.insert(agent_id); @@ -127,12 +123,6 @@ pub(crate) fn start_game_creator_supervisor_background_task_for_session_at( return Err("Project Supervisor 提交 source 不受信任".to_string()); } let run_profile = normalize_agent_runtime_run_profile(Some(run_profile))?; - reject_supervisor_plan_autonomous_profile(source, &run_profile)?; - if agent_runtime_supervisor_source_is_plan(source) - && !crate::config::game_creator_planning_capability_enabled()? - { - return Err("PLAN_CAPABILITY_DISABLED: 立项策划能力当前已停用".to_string()); - } start_game_creator_agent_background_task_with_source_at( root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, @@ -179,27 +169,6 @@ pub(in crate::agent) fn start_game_creator_agent_background_task_with_link_at( run_profile: Option<&str>, task_link: Option<&AgentRuntimeTaskLink>, ) -> Result<(AgentRuntimeResult, String), String> { - // Planning session projection needs both the project lock and the target - // session lane. Always acquire them in project -> session order. The - // in-session entry below therefore rejects an unguarded planning child - // instead of trying to acquire the project lock while holding the lane. - if agent_id.trim() == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID { - let project_write_lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( - root, - "planning.child-session.enqueue", - )?; - return start_game_creator_agent_background_task_with_link_locked_at( - root, - agent_id, - session_id, - task, - run_id, - source, - run_profile, - task_link, - &project_write_lock, - ); - } start_game_creator_agent_background_task_with_link_with_project_lock_at( root, agent_id, @@ -358,11 +327,6 @@ fn start_game_creator_agent_background_task_with_link_in_session_lane_with_proje task_link: Option<&AgentRuntimeTaskLink>, project_write_lock: Option<&ProjectWriteLock>, ) -> Result<(AgentRuntimeResult, String), String> { - if agent_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID && project_write_lock.is_none() { - return Err( - "project-planning 入队必须在取得项目写锁后再进入 Agent Session lane".to_string(), - ); - } let isolated_instance = agent_id .starts_with("child-") .then(|| resolve_isolated_agent_instance_at(root, &agent_id)) @@ -531,41 +495,6 @@ fn start_game_creator_agent_background_task_with_link_in_session_lane_with_proje return Err(format!("后台任务用户消息落盘失败,任务未执行:{error}")); } } - if game_creator_agent_runtime_terminal_status(&pending_task).is_none() { - let projection = project_write_lock.map_or(Ok(false), |project_write_lock| { - ensure_plan_session_for_planning_child_task_at_locked( - root, - &pending_task, - project_write_lock, - ) - }); - if let Err(error) = projection { - let error = redact_agent_runtime_project_paths(root, &error, 500); - let failed_task = AgentRuntimeTaskRecord { - status: "failed".to_string(), - phase: AGENT_RUNTIME_TASK_PHASE_PLANNING_SESSION_PROJECTION_FAILED.to_string(), - current_action: "Fast GDD session 未能安全绑定,后台任务未执行".to_string(), - terminal_detail: Some(error.clone()), - error: Some(error.clone()), - updated_at: unix_timestamp(), - ..pending_task.clone() - }; - append_game_creator_agent_runtime_task_record(root, &failed_task)?; - publish_game_creator_agent_delegate_result(root, &failed_task, Some(&error)); - let _ = append_agent_db_record( - root, - serde_json::json!({ - "recordType": "agent.runtime.background_task.queue_warning", - "agentId": pending_task.agent_id, - "sessionId": pending_task.session_id, - "runId": pending_task.run_id, - "warningKind": "planning-session-projection-failed", - "error": sanitize_agent_runtime_text(&error, 240), - }), - ); - return Err(format!("Fast GDD session 投影失败,任务未执行:{error}")); - } - } if requires_public_start_status { if let Err(error) = ensure_game_creator_agent_runtime_accepted_public_status_at(root, &pending_task) @@ -1756,11 +1685,7 @@ pub(in crate::agent) fn project_autonomous_manifest_ready_task_terminal_at_locke } pub(super) fn autonomous_manifest_ready_task_requires_visual_asset(task_id: &str) -> bool { - editor_api_key_is_configured() - && matches!( - task_id, - "art-director" | "design-foundation" | "art-asset-plan" - ) + false } fn render_autonomous_manifest_ready_task_owner_prompt(task: &GameCreationAppTaskState) -> String { @@ -1777,11 +1702,7 @@ fn render_autonomous_manifest_ready_task_owner_prompt(task: &GameCreationAppTask } else { "" }; - let visual_requirement = if task.id == "art-asset-plan" && editor_api_key_is_configured() { - "art-asset-plan 的固定成功路径是:调用 canvas.asset_generate 生成并登记 assets/art-spritesheet.png(assetKind=art-spritesheet),然后调用 asset.list 核对图集及四个 canonical 切片已经登记,再调用 file.write 写入 assets/manifest.art.json;完成这组动作后把结构化计划最后一步标记 completed 并立即交付。不要调用 image.inspect,不要根据图片主观观感发起返工或 agent.message;图集视觉质量由后续质量任务处理,Runtime 会在收束门内验证文件和资产登记状态。" - } else { - "任务声明中的视觉图片继续按现有 visual gate 生成、登记并验收。" - }; + let visual_requirement = "任务声明中的视觉图片按项目需求选择工具、数量、输出路径、尺寸和布局;需要图集时用 sliceCount 指定切片数量。Runtime 只核对实际声明的资源登记,不要求固定图片合同。"; let verification_requirement = match task.id.as_str() { "code-prototype" => "code-prototype 必须对可玩入口执行 game.static_smoke;完整 DAG 的最终静态与浏览器验收继续由后续质量任务承担。", task_id if agent_runtime_autonomous_uses_owner_artifact_validation(task_id) => "完成固定正式产物后直接交付,由 Runtime 在收束门内验证本人固定 owner 产物;禁止调用 game.static_smoke、project.verify、command.run_limited 或 preview 工具冒充 owner 产物验证。", @@ -1810,7 +1731,7 @@ pub(in crate::agent) fn render_autonomous_manifest_ready_task_background_prompt( if task.id == "art-director" { if autonomous_manifest_ready_task_requires_visual_asset(&task.id) { return format!( - "{base}\n\n这是 autonomous-game-build 的非只读视觉规范生成任务。{AGENT_RUNTIME_AUTONOMOUS_ART_DIRECTOR_CANVAS_ONLY_TASK_MARKER};必须用固定合同生成并登记 assets/art-spec.png(assetKind=icon-spec、aspectRatio=1:1),该受控素材事务会同时提交当前 run 的 mutation 与验证凭证。禁止调用 file.write、file.patch、file.delete、project.patchset、project.restore 或写入其它路径。生成成功后直接交付视觉规范结论;不要调用 task.update,Runtime 会在子 Run 终态后幂等投影 manifest。" + "{base}\n\n这是 autonomous-game-build 的视觉方向任务。根据项目需求决定是否调用 canvas.asset_generate,不规定固定图片名称、数量、素材类别或布局;生成成功后直接交付结论。" ); } return format!( @@ -1931,8 +1852,8 @@ mod tests { assert!(!publish_prompt.contains("验证本人固定 owner 产物")); } - #[tokio::test] - async fn art_director_ready_task_is_read_only_without_key_and_canvas_owner_with_key() { + #[test] + fn art_director_ready_task_is_read_only_without_credentials() { let task = seed_task("art-director"); { let _config_guard = crate::tests::write_test_local_config("{}".to_string()); @@ -1948,42 +1869,5 @@ mod tests { &prompt )); } - // debug 构建下 editor_api_mode() 恒为 PlatformAccount,配置里的 - // editorApi.apiKey 会被 editor_api_key_is_configured 完全忽略,只有凭据 - // override 或平台会话才算「已配置」。用支持的 override 钩子模拟。 - crate::assets::with_external_editor_api_credentials( - crate::assets::external_editor_api_credentials_for_test( - "https://editor.test".to_string(), - "art-director-ready-task-key".to_string(), - ), - async { - let prompt = render_autonomous_manifest_ready_task_background_prompt(&task); - assert!(prompt.contains("非只读视觉规范生成任务")); - assert!(prompt.contains( - crate::agent::AGENT_RUNTIME_AUTONOMOUS_ART_DIRECTOR_CANVAS_ONLY_TASK_MARKER - )); - assert!(prompt.contains("生成并登记 assets/art-spec.png")); - assert!(prompt.contains("会同时提交当前 run 的 mutation 与验证凭证")); - assert!(prompt.contains("Runtime 会在子 Run 终态后幂等投影 manifest")); - assert!(!prompt.contains("无生图凭据只读协调任务")); - assert!(autonomous_manifest_ready_task_requires_visual_asset( - "art-director" - )); - assert!(!agent_runtime_task_requires_read_only_delivery( - "art-director", - &prompt - )); - let art_asset_prompt = render_autonomous_manifest_ready_task_background_prompt( - &seed_task("art-asset-plan"), - ); - assert!(art_asset_prompt.contains("canvas.asset_generate")); - assert!(art_asset_prompt.contains("asset.list")); - assert!(art_asset_prompt.contains("file.write 写入 assets/manifest.art.json")); - assert!(art_asset_prompt.contains("不要调用 image.inspect")); - assert!(art_asset_prompt.contains("把结构化计划最后一步标记 completed")); - assert!(!art_asset_prompt.contains("按现有 visual gate 生成、登记并验收")); - }, - ) - .await; } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol.rs index 8a560b34b..296fd0634 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol.rs @@ -6,12 +6,9 @@ mod context_window; mod finalization; mod json_sidecar; mod models; -mod planning_approval; -mod planning_coordinator; -mod planning_hydrate; -mod planning_provider_usage; -mod planning_storage; -mod planning_submit; +mod planning_gdd_model; +mod planning_policy_v2; +mod planning_session_v2; mod provider_control; mod provider_retry; mod real_e2e_checkpoint; @@ -25,17 +22,13 @@ pub(in crate::agent) use context_bundle::*; pub(in crate::agent) use finalization::*; pub(in crate::agent) use json_sidecar::*; pub(in crate::agent) use models::*; -pub(crate) use planning_approval::*; -pub(crate) use planning_coordinator::*; -pub(crate) use planning_hydrate::*; -pub(crate) use planning_provider_usage::*; -pub(crate) use planning_storage::*; -pub(crate) use planning_submit::*; +pub(crate) use planning_gdd_model::*; +pub(crate) use planning_policy_v2::*; +pub(crate) use planning_session_v2::*; pub(in crate::agent) use provider_control::*; pub(in crate::agent) use provider_retry::*; pub(in crate::agent) use real_e2e_checkpoint::*; pub(in crate::agent) use response_stream::*; -pub(crate) use run_configuration::validate_project_supervisor_plan_root_binding_at as validate_project_supervisor_plan_root_binding_for_crate_at; pub(in crate::agent) use run_configuration::*; pub(in crate::agent) use steering::*; pub(in crate::agent) use verification::*; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/acceptance_graph.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/acceptance_graph.rs index c38ce29cb..9c5ab6b78 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/acceptance_graph.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/acceptance_graph.rs @@ -261,184 +261,6 @@ struct FastGddFileReadCoverage { total_lines: usize, } -fn parse_fast_gdd_file_read_evidence( - receipt: &AcceptanceEvidenceReceipt, -) -> Result { - let input_summary = receipt - .input_summary - .as_deref() - .ok_or_else(|| "Fast GDD file.read evidence 缺少输入摘要".to_string())?; - let mut input_fields = input_summary.split('·').map(str::trim); - if receipt.tool != "file.read" || input_fields.next() != Some("path=game/fast_gdd.md") { - return Err("Fast GDD 验收 evidence 必须是读取 game/fast_gdd.md 的成功回执".to_string()); - } - let input_start_line = input_fields - .next() - .and_then(|field| field.strip_prefix("startLine=")) - .and_then(|value| value.parse::().ok()) - .ok_or_else(|| "Fast GDD file.read evidence startLine 摘要无效".to_string())?; - let input_max_lines = input_fields - .next() - .and_then(|field| field.strip_prefix("maxLines=")) - .and_then(|value| value.parse::().ok()) - .filter(|value| *value > 0) - .ok_or_else(|| "Fast GDD file.read evidence maxLines 摘要无效".to_string())?; - let safe_detail = receipt - .safe_detail - .as_deref() - .ok_or_else(|| "Fast GDD file.read evidence 缺少安全内容摘要".to_string())?; - let safe_detail = serde_json::from_str::(safe_detail) - .map_err(|_| "Fast GDD file.read evidence 安全摘要无效".to_string())?; - let content_sha256 = safe_detail - .get("contentSha256") - .and_then(serde_json::Value::as_str) - .ok_or_else(|| "Fast GDD file.read evidence 缺少内容摘要".to_string())?; - if safe_detail.get("path").and_then(serde_json::Value::as_str) != Some("game/fast_gdd.md") - || !is_lowercase_sha256(content_sha256) - { - return Err("Fast GDD file.read evidence 路径或内容摘要不匹配".to_string()); - } - let lines = safe_detail - .get("lines") - .and_then(serde_json::Value::as_str) - .ok_or_else(|| "Fast GDD file.read evidence 缺少行覆盖摘要".to_string())?; - if lines == "0 of 0" { - if input_start_line != 1 { - return Err("空 Fast GDD file.read evidence 必须从 startLine=1 读取".to_string()); - } - return Ok(FastGddFileReadCoverage { - content_sha256: content_sha256.to_string(), - start_line: 0, - end_line: 0, - total_lines: 0, - }); - } - let (range, total_lines) = lines - .split_once(" of ") - .ok_or_else(|| "Fast GDD file.read evidence 行覆盖摘要无效".to_string())?; - let (start_line, end_line) = range - .split_once('-') - .ok_or_else(|| "Fast GDD file.read evidence 行范围无效".to_string())?; - let start_line = start_line - .parse::() - .map_err(|_| "Fast GDD file.read evidence 起始行无效".to_string())?; - let end_line = end_line - .parse::() - .map_err(|_| "Fast GDD file.read evidence 结束行无效".to_string())?; - let total_lines = total_lines - .parse::() - .map_err(|_| "Fast GDD file.read evidence 总行数无效".to_string())?; - let covered_lines = end_line.saturating_sub(start_line).saturating_add(1); - if start_line == 0 - || start_line != input_start_line - || end_line < start_line - || end_line > total_lines - || covered_lines > input_max_lines - { - return Err("Fast GDD file.read evidence 输入与行覆盖范围不一致".to_string()); - } - Ok(FastGddFileReadCoverage { - content_sha256: content_sha256.to_string(), - start_line, - end_line, - total_lines, - }) -} - -fn validate_fast_gdd_file_read_coverage( - receipts: &[&AcceptanceEvidenceReceipt], -) -> Result<(String, usize), String> { - let mut coverage = receipts - .iter() - .map(|receipt| parse_fast_gdd_file_read_evidence(receipt)) - .collect::, _>>()?; - let first = coverage - .first() - .ok_or_else(|| "Fast GDD 验收缺少 file.read evidence".to_string())?; - let content_sha256 = first.content_sha256.clone(); - let total_lines = first.total_lines; - if coverage - .iter() - .any(|item| item.content_sha256 != content_sha256 || item.total_lines != total_lines) - { - return Err("Fast GDD file.read evidence 不属于同一 Markdown 内容".to_string()); - } - coverage.sort_by_key(|item| (item.start_line, item.end_line)); - // 同一页被读了两遍不削弱证据,却会让下面那趟严格的 `start_line == next_line` - // walk 判成重叠:实测一次 133 行的 GDD 分成 1-120 / 121-133 两页,模型把第二页 - // 读了两次、把三个 actionId 全提交上来,于是连吃三次「必须从第 1 行无缺口、无 - // 重叠地覆盖到文件末尾」,试到第四次才猜对该交哪两个。这里先按完全相同的 - // (startLine, endLine, contentSha256) 折叠——内容 SHA 在上面已经要求全体一致, - // 所以折叠掉的确实是同一页的重复回执,不是两段不同内容。 - coverage.dedup_by(|left, right| { - left.start_line == right.start_line - && left.end_line == right.end_line - && left.content_sha256 == right.content_sha256 - }); - if total_lines == 0 { - if coverage.len() != 1 || coverage[0].start_line != 0 || coverage[0].end_line != 0 { - return Err("空 Fast GDD 的 file.read evidence 覆盖不唯一".to_string()); - } - return Ok((content_sha256, total_lines)); - } - let mut next_line = 1usize; - for item in &coverage { - if item.start_line != next_line { - return Err( - "Fast GDD file.read evidence 必须从第 1 行无缺口、无重叠地覆盖到文件末尾" - .to_string(), - ); - } - next_line = item.end_line.saturating_add(1); - } - if next_line != total_lines.saturating_add(1) { - return Err( - "Fast GDD file.read evidence 必须从第 1 行无缺口、无重叠地覆盖到文件末尾".to_string(), - ); - } - Ok((content_sha256, total_lines)) -} - -fn fast_gdd_file_read_coverage_matches_current_markdown( - root: &Path, - content_sha256: &str, - total_lines: usize, -) -> Result { - let current = read_local_project_file_at(root, PLAN_FAST_GDD_PATH) - .map_err(|error| format!("读取当前 Fast GDD Markdown 失败:{error}"))?; - let current_sha256 = format!("{:x}", Sha256::digest(current.content.as_bytes())); - Ok(content_sha256 == current_sha256 && total_lines == current.content.lines().count()) -} - -fn contract_is_plan_root_source( - root: &Path, - contract: &AgentRuntimeGoalContract, -) -> Result { - let Some(binding) = read_game_creator_agent_runtime_run_profile_binding( - root, - &contract.root_agent_id, - &contract.root_run_id, - )? - else { - return Ok(false); - }; - Ok(binding.source == AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE - && binding.profile == AGENT_RUNTIME_RUN_PROFILE_STANDARD) -} - -fn validate_fast_gdd_evidence_identity( - contract: &AgentRuntimeGoalContract, - evidence: &AgentRuntimeAcceptanceEvidenceRef, -) -> Result<(), String> { - if evidence.agent_id != contract.root_agent_id || evidence.run_id != contract.root_run_id { - return Err( - "Fast GDD 验收 evidence 必须来自当前 project-supervisor 根 Run 的 file.read 回执" - .to_string(), - ); - } - Ok(()) -} - fn validate_acceptance_required_evidence( node: &AgentRuntimeGoalContractAcceptanceNode, evidence_tools: &BTreeSet, @@ -475,7 +297,6 @@ fn validate_acceptance_graph_state( { return Err("Acceptance Graph 身份、revision 或时间无效".to_string()); } - let plan_root_source = contract_is_plan_root_source(root, contract)?; let node_by_id = contract .acceptance_nodes .iter() @@ -542,25 +363,6 @@ fn validate_acceptance_graph_state( if evaluation.status == "passed" { validate_acceptance_required_evidence(node, &evidence_tools)?; } - if plan_root_source - && node.criterion_id == PLAN_FAST_GDD_ACCEPTANCE_NODE_ID - && !evaluation.evidence.is_empty() - { - let mut fast_gdd_receipts = Vec::with_capacity(evaluation.evidence.len()); - for evidence in &evaluation.evidence { - validate_fast_gdd_evidence_identity(contract, evidence)?; - let identity = ( - evidence.agent_id.clone(), - evidence.run_id.clone(), - evidence.action_id.clone(), - ); - let receipt = evidence_tools_by_identity - .get(&identity) - .ok_or_else(|| "Fast GDD 验收 evidence 回执缺失".to_string())?; - fast_gdd_receipts.push(receipt); - } - validate_fast_gdd_file_read_coverage(&fast_gdd_receipts)?; - } } if acceptance_state_fingerprint(state)? != state.state_fingerprint { return Err("Acceptance Graph 已变化:指纹校验失败".to_string()); @@ -903,7 +705,6 @@ pub(crate) fn goal_contract_acceptance_completion_blocker_at_locked( (status != "passed").then(|| format!("{}:{status}", node.criterion_id)) }) .collect::>(); - let mut fast_gdd_gate_pending = false; if state .as_ref() .is_some_and(|state| state.project_revision != current_project_revision) @@ -911,149 +712,9 @@ pub(crate) fn goal_contract_acceptance_completion_blocker_at_locked( incomplete.push("acceptance-graph:stale".to_string()); } - // A plan root has one extra invariant beyond the generic graph status: - // the passed `file.read` receipt must still describe the exact Markdown - // revision that is about to be exposed for approval. The generic graph - // reader intentionally validates only receipt shape, because approval - // projections rewrite the Markdown after a decision. Before a pending - // card exists, however, skipping this current-content check would let an - // old `passed` evaluation reach finalization without an approval gate. - if binding.source == AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE - && binding.profile == AGENT_RUNTIME_RUN_PROFILE_STANDARD - { - let gdds = match read_plan_gdd_chain_locked(root) { - Ok(gdds) => gdds, - Err(error) => { - return Some(AgentRuntimeToolObservation { - tool: "runtime.acceptance_graph".to_string(), - status: "needs-reconciliation".to_string(), - summary: "Fast GDD lineage 无法核对验收前置门".to_string(), - detail: Some(sanitize_agent_runtime_text(&error.to_string(), 500)), - }); - } - }; - let latest_for_root = gdds.iter().rev().find(|gdd| { - gdd.root_agent_id == binding.root_agent_id - && gdd.root_run_id == binding.root_run_id - && gdd.source == "agent-delegate" - && gdd.run_profile == AGENT_RUNTIME_RUN_PROFILE_STANDARD - }); - let is_global_latest = latest_for_root.is_some_and(|latest| { - gdds.last().is_some_and(|global_latest| { - global_latest.gdd_id == latest.gdd_id - && global_latest.version == latest.version - && global_latest.fingerprint == latest.fingerprint - }) - }); - if let Some(latest) = latest_for_root.filter(|_| is_global_latest) { - let approvals = match read_plan_gdd_approvals_locked(root) { - Ok(approvals) => approvals, - Err(error) => { - return Some(AgentRuntimeToolObservation { - tool: "runtime.acceptance_graph".to_string(), - status: "needs-reconciliation".to_string(), - summary: "Fast GDD approval receipt 无法核对验收前置门".to_string(), - detail: Some(sanitize_agent_runtime_text(&error.to_string(), 500)), - }); - } - }; - if let Err(error) = validate_plan_gdd_approvals_against_gdds(&gdds, &approvals) { - return Some(AgentRuntimeToolObservation { - tool: "runtime.acceptance_graph".to_string(), - status: "needs-reconciliation".to_string(), - summary: "Fast GDD approval receipt 与 lineage 无法对账".to_string(), - detail: Some(sanitize_agent_runtime_text(&error.to_string(), 500)), - }); - } - let receipt = approvals.iter().find(|receipt| { - receipt.gdd_id == latest.gdd_id - && receipt.version == latest.version - && receipt.fingerprint == latest.fingerprint - }); - let pending = match read_plan_gdd_approval_pending_locked(root) { - Ok(pending) => pending, - Err(error) => { - return Some(AgentRuntimeToolObservation { - tool: "runtime.acceptance_graph".to_string(), - status: "needs-reconciliation".to_string(), - summary: "Fast GDD approval pending 无法核对验收前置门".to_string(), - detail: Some(sanitize_agent_runtime_text(&error.to_string(), 500)), - }); - } - }; - let durable_gate_proof = if let Some(receipt) = receipt { - if pending - .as_ref() - .is_some_and(|pending| !pending_matches_receipt(pending, latest, receipt)) - { - return Some(AgentRuntimeToolObservation { - tool: "runtime.acceptance_graph".to_string(), - status: "needs-reconciliation".to_string(), - summary: "Fast GDD approval pending 与 receipt identity 冲突".to_string(), - detail: Some(format!("gddVersion={}", latest.version)), - }); - } - true - } else if let Some(pending) = pending.as_ref() { - if !pending_matches_gdd(pending, latest) { - return Some(AgentRuntimeToolObservation { - tool: "runtime.acceptance_graph".to_string(), - status: "needs-reconciliation".to_string(), - summary: "Fast GDD approval pending identity 与当前 GDD 不一致".to_string(), - detail: Some(format!("gddVersion={}", latest.version)), - }); - } - true - } else { - false - }; - if durable_gate_proof { - // An exact pending or receipt is the durable proof that the - // approval preflight already passed. The fixed plan contract - // has no other acceptance node, so later project revisions or - // graph status rewrites must not reopen machine acceptance - // after the user-facing gate has been exposed or decided. - incomplete.clear(); - } else { - match plan_fast_gdd_acceptance_status_at_locked(root, latest) { - Ok(PlanFastGddAcceptanceStatus::Passed) - | Ok(PlanFastGddAcceptanceStatus::RepairRequired) => {} - Ok(PlanFastGddAcceptanceStatus::NeedsEvidence) => { - fast_gdd_gate_pending = true; - if !incomplete - .iter() - .any(|item| item == "fast-gdd-acceptance:pending-gate-not-passed") - { - incomplete - .push("fast-gdd-acceptance:pending-gate-not-passed".to_string()); - } - } - Err(error) => { - return Some(AgentRuntimeToolObservation { - tool: "runtime.acceptance_graph".to_string(), - status: "needs-reconciliation".to_string(), - summary: "Fast GDD 验收前置门无法通过 identity/证据校验".to_string(), - detail: Some(sanitize_agent_runtime_text(&error, 500)), - }); - } - } - } - } - } if incomplete.is_empty() { return None; } - if fast_gdd_gate_pending { - return Some(AgentRuntimeToolObservation { - tool: "runtime.acceptance_graph".to_string(), - status: "blocked".to_string(), - summary: "Fast GDD 尚未完成当前 Markdown revision 的验收取证,不能创建审批卡".to_string(), - detail: Some( - "nextRequiredAction=file.read;由当前 Supervisor 根 Run 从 startLine=1 开始分页、无缺口且无重叠地读取 game/fast_gdd.md 直到 EOF,各页保持同一内容 SHA-256,并把全部分页 file.read actionId 放入 agent.acceptance_update.evidence;取证未通过时只针对原 planning delivery 返工,不创建 gdd-approval pending。" - .to_string(), - ), - }); - } Some(AgentRuntimeToolObservation { tool: "runtime.acceptance_graph".to_string(), status: "blocked".to_string(), @@ -1068,124 +729,6 @@ pub(crate) fn goal_contract_acceptance_completion_blocker_at_locked( }) } -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub(crate) enum PlanFastGddAcceptanceStatus { - NeedsEvidence, - RepairRequired, - Passed, -} - -/// Classify the fixed Fast GDD node for the exact GDD revision being handed -/// to the approval gate. Missing/stale evidence is not a semantic failure: -/// the Supervisor must read the current Markdown first. Only an explicit -/// failed evaluation backed by complete current-root evidence can authorize a -/// repair delegation. -pub(crate) fn plan_fast_gdd_acceptance_status_at_locked( - root: &Path, - gdd: &PlanGddV1, -) -> Result { - validate_plan_gdd(gdd).map_err(|error| error.to_string())?; - if gdd.agent_id != GAME_CREATOR_PROJECT_PLANNING_AGENT_ID - || gdd.source != "agent-delegate" - || gdd.run_profile != AGENT_RUNTIME_RUN_PROFILE_STANDARD - || gdd.root_agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID - || gdd.root_run_id.trim().is_empty() - || gdd.created_by_run_id.trim().is_empty() - || gdd.delegation_id.trim().is_empty() - { - return Err( - "Fast GDD acceptance graph 的提交 identity 不属于合法 planning lineage".to_string(), - ); - } - let current_project_id = game_creator_agent_runtime_context_project_id(root)?; - if gdd.project_id != current_project_id { - return Err("Fast GDD acceptance graph 的项目 identity 不匹配".to_string()); - } - let chain = read_plan_gdd_chain_locked(root).map_err(|error| error.to_string())?; - let Some(latest) = chain.last() else { - return Ok(PlanFastGddAcceptanceStatus::NeedsEvidence); - }; - if latest.gdd_id != gdd.gdd_id - || latest.version != gdd.version - || latest.fingerprint != gdd.fingerprint - { - return Err("Fast GDD acceptance graph 不是 lineage 最新提交".to_string()); - } - let Some(contract) = read_game_creator_agent_runtime_goal_contract_at( - root, - &gdd.root_agent_id, - &gdd.root_run_id, - )? - else { - return Err("Fast GDD acceptance graph 缺少当前 plan 根固定 Goal Contract".to_string()); - }; - if contract.root_agent_id != gdd.root_agent_id - || contract.root_run_id != gdd.root_run_id - || contract.project_id != gdd.project_id - || contract.acceptance_nodes.len() != 1 - || contract.acceptance_nodes[0].criterion_id != PLAN_FAST_GDD_ACCEPTANCE_NODE_ID - { - return Err("Fast GDD acceptance graph 与提交 GDD identity 不一致".to_string()); - } - let Some(state) = read_game_creator_agent_runtime_acceptance_graph_at( - root, - &gdd.root_agent_id, - &gdd.root_run_id, - )? - else { - return Ok(PlanFastGddAcceptanceStatus::NeedsEvidence); - }; - let current_project_revision = read_game_creator_agent_runtime_project_revision(root)?.revision; - if state.project_revision != current_project_revision { - return Ok(PlanFastGddAcceptanceStatus::NeedsEvidence); - } - let node = &contract.acceptance_nodes[0]; - let Some(evaluation) = state - .evaluations - .iter() - .find(|evaluation| evaluation.criterion_id == node.criterion_id) - else { - return Ok(PlanFastGddAcceptanceStatus::NeedsEvidence); - }; - if evaluation.project_revision != current_project_revision - || evaluation.status == "not-observed" - || evaluation.evidence.is_empty() - { - return Ok(PlanFastGddAcceptanceStatus::NeedsEvidence); - } - let evidence_tools = acceptance_evidence_tools_at(root, &contract, evaluation.evidence.iter())?; - for evidence in &evaluation.evidence { - validate_fast_gdd_evidence_identity(&contract, evidence)?; - } - validate_acceptance_required_evidence( - node, - &evidence_tools - .values() - .map(|receipt| receipt.tool.clone()) - .collect(), - )?; - let fast_gdd_receipts = evidence_tools.values().collect::>(); - let (content_sha256, total_lines) = validate_fast_gdd_file_read_coverage(&fast_gdd_receipts)?; - if !fast_gdd_file_read_coverage_matches_current_markdown(root, &content_sha256, total_lines)? { - return Ok(PlanFastGddAcceptanceStatus::NeedsEvidence); - } - match evaluation.status.as_str() { - "passed" => Ok(PlanFastGddAcceptanceStatus::Passed), - "failed" => Ok(PlanFastGddAcceptanceStatus::RepairRequired), - _ => Ok(PlanFastGddAcceptanceStatus::NeedsEvidence), - } -} - -pub(crate) fn plan_fast_gdd_acceptance_passed_at_locked( - root: &Path, - gdd: &PlanGddV1, -) -> Result { - Ok( - plan_fast_gdd_acceptance_status_at_locked(root, gdd)? - == PlanFastGddAcceptanceStatus::Passed, - ) -} - pub(crate) fn render_game_creator_agent_runtime_acceptance_graph_for_prompt_at( root: &Path, agent_id: &str, @@ -1244,52 +787,6 @@ pub(crate) fn render_game_creator_agent_runtime_acceptance_graph_for_prompt_at( mod tests { use super::*; - fn page(start: usize, end: usize, total: usize) -> AcceptanceEvidenceReceipt { - AcceptanceEvidenceReceipt { - tool: "file.read".to_string(), - project_revision_before: 1, - project_revision_after: 1, - input_summary: Some(format!( - "path=game/fast_gdd.md · startLine={start} · maxLines=240" - )), - safe_detail: Some( - serde_json::json!({ - "path": "game/fast_gdd.md", - "contentSha256": "b".repeat(64), - "lines": format!("{start}-{end} of {total}"), - }) - .to_string(), - ), - } - } - - /// 同一页被读了两遍不削弱证据。实测 133 行的 GDD 被默认 maxLines=120 逼成两页, - /// 模型把第二页读了两次并提交了三个 actionId,于是连吃三次「无缺口、无重叠」。 - #[test] - fn repeating_an_identical_page_is_not_an_overlap() { - let first = page(1, 120, 133); - let second = page(121, 133, 133); - let duplicate = page(121, 133, 133); - let receipts = vec![&first, &second, &duplicate]; - let (sha, total) = - validate_fast_gdd_file_read_coverage(&receipts).expect("duplicate page is tolerated"); - assert_eq!(sha, "b".repeat(64)); - assert_eq!(total, 133); - } - - /// 真的缺口与真的重叠照旧拒绝——去重只折叠完全相同的分页。 - #[test] - fn real_gaps_and_partial_overlaps_are_still_rejected() { - let first = page(1, 120, 133); - let gapped = page(122, 133, 133); - let receipts = vec![&first, &gapped]; - validate_fast_gdd_file_read_coverage(&receipts).expect_err("a real gap must fail"); - - let overlapping = page(100, 133, 133); - let receipts = vec![&first, &overlapping]; - validate_fast_gdd_file_read_coverage(&receipts).expect_err("a partial overlap must fail"); - } - fn root_fixture() -> (tempfile::TempDir, PathBuf, AgentRuntimeRunProfileBinding) { let temporary = crate::tests::canonical_test_tempdir("acceptance-graph-"); let root = temporary.path().join("project"); @@ -1614,62 +1111,6 @@ mod tests { assert!(goal_contract_acceptance_completion_blocker_at_locked(&root, &runtime).is_none()); } - #[test] - fn non_plan_root_keeps_same_named_fast_gdd_criterion_dynamic() { - let (_temporary, root, binding) = root_fixture(); - let contract = create_game_creator_agent_runtime_goal_contract_at( - &root, - &binding.agent_id, - &binding.run_id, - "完成可验证游戏", - &AgentRuntimeGoalContractDraft { - outcome: "完成用户最终目标".to_string(), - non_negotiables: Vec::new(), - preferences: Vec::new(), - forbidden_assumptions: Vec::new(), - open_questions: Vec::new(), - acceptance_nodes: vec![AgentRuntimeGoalContractAcceptanceNodeDraft { - criterion_id: PLAN_FAST_GDD_ACCEPTANCE_NODE_ID.to_string(), - criterion: "普通 GUI 根 Run 自定义的同名验收标准".to_string(), - required: true, - required_evidence: vec!["tool:project.verify".to_string()], - dependencies: Vec::new(), - }], - }, - ) - .expect("create dynamic same-name contract"); - let runtime = start_game_creator_agent_runtime_task_at( - &root, - &binding.agent_id, - "完成可验证游戏", - &binding.run_id, - &binding.source, - "完成用户最终目标", - vec!["验收动态同名节点".to_string()], - ) - .expect("start dynamic root runtime"); - let action_id = "action-777777777777777777777777"; - append_evidence_receipt(&root, &runtime, action_id); - update_game_creator_agent_runtime_acceptance_graph_at( - &root, - &binding.agent_id, - &binding.run_id, - &contract.contract_fingerprint, - &[AgentRuntimeAcceptanceEvaluationDraft { - criterion_id: PLAN_FAST_GDD_ACCEPTANCE_NODE_ID.to_string(), - status: "passed".to_string(), - evidence: vec![AgentRuntimeAcceptanceEvidenceRef { - agent_id: binding.agent_id.clone(), - run_id: binding.run_id.clone(), - action_id: action_id.to_string(), - }], - summary: "普通动态节点通过 project.verify 验收".to_string(), - }], - ) - .expect("same-name node must not require Fast GDD file.read"); - assert!(goal_contract_acceptance_completion_blocker_at_locked(&root, &runtime).is_none()); - } - #[test] fn passed_node_requires_receipts_for_every_machine_readable_evidence_tool() { let (_temporary, root, binding) = root_fixture(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/context_window.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/context_window.rs index d78214932..f549f1063 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/context_window.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/context_window.rs @@ -1,23 +1,7 @@ use super::*; -/// Whether the fixed loop window applies to this run. -/// -/// The window exists to checkpoint progress and detect a stalled Agent across -/// long runs. The 立项策划 lane cannot reach either: its child run tops out -/// around five loops against a window of -/// `AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT`, so no boundary is ever crossed and no -/// checkpoint or stall verdict is ever produced. What the bookkeeping does -/// still produce there is a way to fail — `windowCompletedLoops` is carried -/// across a pause while `nextLoopIndex` is re-read from the pending, and the -/// two drift apart on the clarification resume, which the context-bundle -/// validator turns into a hard run failure. A mechanism that cannot pay out on -/// a lane should not be able to charge it either. -/// -/// The 做游戏 lane is the real beneficiary (runs there span dozens of loops) and -/// is deliberately left untouched. -pub(crate) fn agent_runtime_context_window_applies(runtime: &AgentRuntimeState) -> bool { - runtime.source != AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE - && runtime.agent_id != crate::GAME_CREATOR_PROJECT_PLANNING_AGENT_ID +pub(crate) fn agent_runtime_context_window_applies(_runtime: &AgentRuntimeState) -> bool { + true } impl AgentRuntimeContextWindowTracker { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/goal_contract.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/goal_contract.rs index 30088f069..9a20cd9c9 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/goal_contract.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/goal_contract.rs @@ -13,10 +13,6 @@ const AGENT_RUNTIME_GOAL_CONTRACT_NODE_DEPENDENCY_LIMIT: usize = 32; /// Planning uses a fixed Fast GDD acceptance node. Only the intent-bearing /// Goal Contract fields vary with the project; the quality gate itself is a /// protocol invariant and is not Provider-authored. -pub(crate) const PLAN_FAST_GDD_ACCEPTANCE_NODE_ID: &str = "fast-gdd-serves-intent"; -pub(crate) const PLAN_FAST_GDD_ACCEPTANCE_NODE_CRITERION: &str = - "当前 Fast GDD 服务已冻结的用户意图、不可协商约束与禁止假设"; -pub(crate) const PLAN_FAST_GDD_ACCEPTANCE_NODE_EVIDENCE: &str = "tool:file.read"; #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(deny_unknown_fields, rename_all = "camelCase")] @@ -279,30 +275,6 @@ fn validate_goal_contract_acceptance_graph( Ok(()) } -fn validate_plan_fast_gdd_acceptance_nodes( - nodes: &[AgentRuntimeGoalContractAcceptanceNode], -) -> Result<(), String> { - if nodes.len() != 1 { - return Err( - "project-supervisor-plan 的 acceptanceNodes 必须是固定的单个 Fast GDD 验收节点" - .to_string(), - ); - } - let node = &nodes[0]; - if node.criterion_id != PLAN_FAST_GDD_ACCEPTANCE_NODE_ID - || node.criterion != PLAN_FAST_GDD_ACCEPTANCE_NODE_CRITERION - || !node.required - || node.required_evidence != [PLAN_FAST_GDD_ACCEPTANCE_NODE_EVIDENCE] - || !node.dependencies.is_empty() - { - return Err( - "project-supervisor-plan 的 acceptanceNodes 必须精确使用 Fast GDD 固定验收节点" - .to_string(), - ); - } - Ok(()) -} - fn validate_goal_contract_record( root: &Path, contract: &AgentRuntimeGoalContract, @@ -372,15 +344,6 @@ fn validate_goal_contract_record( return Err("Goal Contract 语义字段未规范化".to_string()); } validate_goal_contract_acceptance_graph(&contract.acceptance_nodes)?; - if binding.source == AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE { - if !contract.preferences.is_empty() { - return Err( - "project-supervisor-plan 的 preferences 必须为空;偏好不得进入固定 Fast GDD 验收合同" - .to_string(), - ); - } - validate_plan_fast_gdd_acceptance_nodes(&contract.acceptance_nodes)?; - } for node in &contract.acceptance_nodes { if node.revision != AGENT_RUNTIME_GOAL_CONTRACT_REVISION || node.created_at != contract.created_at @@ -447,9 +410,6 @@ fn build_goal_contract( acceptance_nodes.push(node); } validate_goal_contract_acceptance_graph(&acceptance_nodes)?; - if binding.source == AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE { - validate_plan_fast_gdd_acceptance_nodes(&acceptance_nodes)?; - } let mut contract = AgentRuntimeGoalContract { schema_version: AGENT_RUNTIME_GOAL_CONTRACT_SCHEMA_VERSION.to_string(), project_id: game_creator_agent_runtime_context_project_id(root)?, @@ -654,23 +614,6 @@ mod tests { } } - fn plan_goal_contract_draft(outcome: &str) -> AgentRuntimeGoalContractDraft { - AgentRuntimeGoalContractDraft { - outcome: outcome.to_string(), - non_negotiables: vec!["保留用户明确要求".to_string()], - preferences: Vec::new(), - forbidden_assumptions: vec!["不能把提交当作审批".to_string()], - open_questions: Vec::new(), - acceptance_nodes: vec![AgentRuntimeGoalContractAcceptanceNodeDraft { - criterion_id: PLAN_FAST_GDD_ACCEPTANCE_NODE_ID.to_string(), - criterion: PLAN_FAST_GDD_ACCEPTANCE_NODE_CRITERION.to_string(), - required: true, - required_evidence: vec![PLAN_FAST_GDD_ACCEPTANCE_NODE_EVIDENCE.to_string()], - dependencies: Vec::new(), - }], - } - } - fn root_fixture(source: &str) -> (tempfile::TempDir, PathBuf, AgentRuntimeRunProfileBinding) { let temporary = crate::tests::canonical_test_tempdir("goal-contract-fixture-"); let root = temporary.path().join("project"); @@ -788,56 +731,6 @@ mod tests { assert!(error.contains("根 Project Supervisor")); } - #[test] - fn plan_goal_contract_acceptance_nodes_are_fixed_but_other_sources_stay_dynamic() { - let (_temporary, root, binding) = root_fixture(AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE); - create_game_creator_agent_runtime_goal_contract_at( - &root, - &binding.agent_id, - &binding.run_id, - "做一份 Fast GDD", - &plan_goal_contract_draft("完成一份可审批的 Fast GDD"), - ) - .expect("fixed plan contract"); - - let (_temporary, dynamic_root, dynamic_binding) = - root_fixture(AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE); - create_game_creator_agent_runtime_goal_contract_at( - &dynamic_root, - &dynamic_binding.agent_id, - &dynamic_binding.run_id, - "创建一个游戏", - &goal_contract_draft("动态图仍可用"), - ) - .expect("dynamic contract"); - - let (_temporary, invalid_root, invalid_binding) = - root_fixture(AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE); - let error = create_game_creator_agent_runtime_goal_contract_at( - &invalid_root, - &invalid_binding.agent_id, - &invalid_binding.run_id, - "做一份 Fast GDD", - &goal_contract_draft("自定义节点应被拒绝"), - ) - .expect_err("plan source must reject provider-authored nodes"); - assert!(error.contains("固定") || error.contains("acceptanceNodes")); - - let (_temporary, preference_root, preference_binding) = - root_fixture(AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE); - let mut preference_draft = plan_goal_contract_draft("偏好不能进入 plan 合同"); - preference_draft.preferences = vec!["优先复用旧素材".to_string()]; - let error = create_game_creator_agent_runtime_goal_contract_at( - &preference_root, - &preference_binding.agent_id, - &preference_binding.run_id, - "做一份 Fast GDD", - &preference_draft, - ) - .expect_err("plan preferences must stay empty"); - assert!(error.contains("preferences 必须为空")); - } - #[test] fn goal_contract_read_fails_closed_after_content_or_binding_tampering() { let (_temporary, root, binding) = root_fixture(AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/models.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/models.rs index fbba979d3..d2698b814 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/models.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/models.rs @@ -136,7 +136,6 @@ pub(crate) struct ParsedAgentRuntimeToolPlan { pub(in crate::agent) struct RequestedAgentRuntimeToolPlan { pub(in crate::agent) plan: AgentRuntimeToolPlan, - pub(in crate::agent) planning_session_binding: Option, pub(in crate::agent) repository_context_fingerprint: String, pub(in crate::agent) estimated_input_tokens: u64, pub(in crate::agent) auto_compact_token_limit: u64, @@ -245,10 +244,6 @@ pub(crate) struct AgentRuntimeProviderRequestSnapshot { pub(in crate::agent) request_slot: String, pub(in crate::agent) web_search_enabled: bool, pub(in crate::agent) allow_idle_context_compaction: bool, - /// Exact-plan requests carry the source session captured before the - /// Provider call. Ordinary requests keep this `None` and retain the - /// legacy lifecycle/batch identity path. - pub(in crate::agent) planning_session_binding: Option, } impl AgentRuntimeProviderRequestSnapshot { @@ -269,15 +264,6 @@ impl AgentRuntimeProviderRequestSnapshot { snapshot.allow_idle_context_compaction = allow; snapshot } - - pub(in crate::agent) fn with_planning_session_binding( - &self, - binding: Option, - ) -> Self { - let mut snapshot = self.clone(); - snapshot.planning_session_binding = binding; - snapshot - } } #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_approval.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_approval.rs deleted file mode 100644 index 5a8e66b50..000000000 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_approval.rs +++ /dev/null @@ -1,2682 +0,0 @@ -use super::*; - -use serde::{Deserialize, Serialize}; -use std::path::Path; - -/// Transport-independent input for the M1C-1 approval decision. The Tauri -/// command keeps `projectPath` outside this value; it is only used to locate a -/// project after the permission gate has run. -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct DecidePlanGddInputV1 { - pub(crate) gdd_id: String, - pub(crate) version: u32, - pub(crate) fingerprint: String, - pub(crate) pending_action_id: String, - pub(crate) approval_request_id: String, - pub(crate) response_id: String, - pub(crate) action: String, - pub(crate) comment: Option, -} - -fn approval_error(code: &'static str, detail: impl Into) -> PlanningStorageError { - PlanningStorageError::new(code, detail) -} - -fn receipt_ref(receipt: &PlanGddApprovalV1) -> PlanGddDecisionRefV1 { - PlanGddDecisionRefV1 { - gdd_id: receipt.gdd_id.clone(), - version: receipt.version, - fingerprint: receipt.fingerprint.clone(), - approval_request_id: receipt.approval_request_id.clone(), - response_id: receipt.response_id.clone(), - action: receipt.action.clone(), - decision_fingerprint: receipt.decision_fingerprint.clone(), - receipt_fingerprint: receipt.receipt_fingerprint.clone(), - } -} - -fn receipt_plan_ref(receipt: &PlanGddApprovalV1) -> PlanGddRef { - PlanGddRef { - gdd_id: receipt.gdd_id.clone(), - version: receipt.version, - fingerprint: receipt.fingerprint.clone(), - } -} - -fn approval_observation(receipt: &PlanGddApprovalV1) -> AgentRuntimeToolObservation { - let (summary, detail) = match receipt.action.as_str() { - "approve" => ( - format!("Fast GDD v{} 已批准", receipt.version), - "用户已批准当前版本。".to_string(), - ), - "revise" => ( - format!("Fast GDD v{} 需要修改", receipt.version), - format!( - "用户修改意见:{}", - receipt.comment.as_deref().unwrap_or_default() - ), - ), - "reject" => ( - format!("Fast GDD v{} 已退回", receipt.version), - format!( - "用户退回原因:{}", - receipt.comment.as_deref().unwrap_or_default() - ), - ), - _ => unreachable!("validated receipt action"), - }; - AgentRuntimeToolObservation { - tool: PLAN_GDD_APPROVAL_TOOL.to_string(), - status: "ok".to_string(), - summary, - detail: Some(detail), - } -} - -/// 审批卡上的「修改/退回」是用户说的话,落点和决策卡的答案一样:Supervisor 自己 -/// 的会话文件。`append_user_input_answer_message` 已经为澄清答案建立了这条通道, -/// 审批决定沿用它。没有这一步 Supervisor 只能从 delivery 的 -/// `contractStatus=user-revision-requested` 知道「用户要改」,读不到要改什么—— -/// playbook 第 6 条的「把用户原话完整附在 task 里」就没有原话可附,返工委派只能 -/// 写一句占位,子 Agent 于是自由发挥。 -fn append_plan_gdd_revision_message( - root: &Path, - receipt: &PlanGddApprovalV1, - receipt_gdd: &PlanGddV1, -) -> Result<(), String> { - let label = if receipt.action == "reject" { - "退回" - } else { - "修改" - }; - let supervisor_session_id = resolve_game_creator_agent_runtime_session_id_for_run_at( - root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &receipt_gdd.root_run_id, - )?; - append_local_conversation_message_for_session_idempotent_at( - root, - Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID), - // receipt 的 rootRun 所属 Supervisor task 才是这条用户意见的历史归属; - // 不能在 recovery 重放时按当前 active session 重新路由到别的会话。 - Some(&supervisor_session_id), - LocalConversationMessage { - role: "user".to_string(), - content: format!( - "我对 Fast GDD v{} 的审批:{}。意见原文:\n{}", - receipt.version, - label, - receipt.comment.as_deref().unwrap_or_default() - ), - agent_id: None, - }, - &format!("plan-gdd-decision-{}", receipt.response_id), - ) - .map(|_| ()) -} - -fn receipt_decision_input( - gdd: &PlanGddV1, - input: &DecidePlanGddInputV1, - comment: Option, -) -> PlanGddApprovalDecisionInputV1 { - PlanGddApprovalDecisionInputV1 { - project_id: gdd.project_id.clone(), - gdd_id: gdd.gdd_id.clone(), - version: gdd.version, - fingerprint: gdd.fingerprint.clone(), - pending_action_id: gdd.submission_id.clone(), - action_fingerprint: gdd.action_fingerprint.clone(), - approval_request_id: gdd.approval_request_id.clone(), - response_id: input.response_id.clone(), - action: input.action.clone(), - comment, - } -} - -fn validate_decision_transport(input: &DecidePlanGddInputV1) -> Result<(), PlanningStorageError> { - validate_uuid_prefixed(&input.gdd_id, "gdd-", "decision.gddId")?; - if !(1..=PLAN_MAX_VERSIONS).contains(&input.version) { - return Err(approval_error( - "PLAN_INVALID_REQUEST", - "decision.version 越界", - )); - } - if !is_typed_fingerprint(&input.fingerprint) { - return Err(approval_error( - "PLAN_INVALID_REQUEST", - "decision.fingerprint 格式非法", - )); - } - validate_action_id(&input.pending_action_id, "decision.pendingActionId") - .map_err(|error| approval_error("PLAN_INVALID_REQUEST", error.to_string()))?; - validate_uuid_prefixed( - &input.approval_request_id, - "gdd-approval-", - "decision.approvalRequestId", - ) - .map_err(|error| approval_error("PLAN_INVALID_REQUEST", error.to_string()))?; - validate_uuid_prefixed(&input.response_id, "gdd-response-", "decision.responseId") - .map_err(|error| approval_error("PLAN_INVALID_REQUEST", error.to_string()))?; - if !matches!(input.action.as_str(), "approve" | "revise" | "reject") { - return Err(approval_error( - "PLAN_INVALID_REQUEST", - "decision.action 必须是 approve/revise/reject", - )); - } - Ok(()) -} - -fn pending_identity_matches_gdd(pending: &PlanGddApprovalPendingV1, gdd: &PlanGddV1) -> bool { - pending.project_id == gdd.project_id - && pending.agent_id == PLAN_GDD_APPROVAL_AGENT_ID - && pending.gdd_ref.gdd_id == gdd.gdd_id - && pending.gdd_ref.version == gdd.version - && pending.gdd_ref.fingerprint == gdd.fingerprint - && pending.submission.tool == PLAN_GDD_APPROVAL_TOOL - && pending.submission.pending_action_id == gdd.submission_id - && pending.submission.action_fingerprint == gdd.action_fingerprint - && pending.submission.approval_request_id == gdd.approval_request_id - && pending.run_identity.source == PLAN_GDD_APPROVAL_SOURCE - && pending.run_identity.run_profile == gdd.run_profile - && pending.run_identity.run_profile_binding_fingerprint - == gdd.run_profile_binding_fingerprint - && pending.run_identity.session_id == gdd.session_id - && pending.run_identity.run_id == gdd.created_by_run_id -} - -pub(crate) fn pending_matches_gdd(pending: &PlanGddApprovalPendingV1, gdd: &PlanGddV1) -> bool { - pending_identity_matches_gdd(pending, gdd) - && pending.status == "awaiting_decision" - && pending.observation.is_none() -} - -pub(crate) fn pending_matches_receipt( - pending: &PlanGddApprovalPendingV1, - gdd: &PlanGddV1, - receipt: &PlanGddApprovalV1, -) -> bool { - if !pending_identity_matches_gdd(pending, gdd) { - return false; - } - if pending_matches_gdd(pending, gdd) { - return true; - } - let expected = approval_observation(receipt); - pending.status == format!("observed_{}", receipt.action) - && pending.observation.as_ref().is_some_and(|observation| { - observation.tool == expected.tool - && observation.status == expected.status - && observation.summary == expected.summary - && observation.detail == expected.detail - }) -} - -/// Construct the independent planning pending projection after an external -/// acceptance gate has succeeded. M1C-1 does not decide whether the gate -/// passed; the caller must supply that fact and the exact GDD identity. -pub(crate) fn create_plan_gdd_approval_pending_locked( - root: &Path, - gdd: &PlanGddV1, -) -> Result<(), PlanningStorageError> { - validate_plan_gdd(gdd)?; - let project_id = game_creator_agent_runtime_context_project_id(root) - .map_err(|error| approval_error("PLAN_PROJECT_ID_MISMATCH", error))?; - if project_id != gdd.project_id { - return Err(approval_error( - "PLAN_PROJECT_ID_MISMATCH", - "GDD projectId 与项目身份不一致", - )); - } - let gdds = read_plan_gdd_chain_locked(root)?; - let approvals = read_plan_gdd_approvals_locked(root)?; - validate_plan_gdd_approvals_against_gdds(&gdds, &approvals)?; - let Some(latest) = gdds.last() else { - return Err(approval_error( - "PLAN_STALE_APPROVAL", - "没有可供审批的 GDD 权威事实", - )); - }; - if latest.gdd_id != gdd.gdd_id - || latest.version != gdd.version - || latest.fingerprint != gdd.fingerprint - { - return Err(approval_error( - "PLAN_STALE_APPROVAL", - "审批 pending 只能绑定 lineage 最新且未决定的 GDD", - )); - } - if read_plan_gdd_approval_for_version_locked(root, gdd.version)?.is_some() { - return Err(approval_error( - "PLAN_STALE_APPROVAL", - "该 GDD 已有 receipt,不能重新创建 awaiting_decision pending", - )); - } - let pending = PlanGddApprovalPendingV1 { - schema_version: PLAN_GDD_APPROVAL_PENDING_SCHEMA_VERSION.to_string(), - kind: PLAN_GDD_APPROVAL_PENDING_KIND.to_string(), - project_id: gdd.project_id.clone(), - agent_id: PLAN_GDD_APPROVAL_AGENT_ID.to_string(), - gdd_ref: PlanGddRef { - gdd_id: gdd.gdd_id.clone(), - version: gdd.version, - fingerprint: gdd.fingerprint.clone(), - }, - submission: PlanGddApprovalPendingSubmission { - tool: PLAN_GDD_APPROVAL_TOOL.to_string(), - pending_action_id: gdd.submission_id.clone(), - action_fingerprint: gdd.action_fingerprint.clone(), - approval_request_id: gdd.approval_request_id.clone(), - }, - run_identity: PlanGddApprovalPendingRunIdentity { - source: PLAN_GDD_APPROVAL_SOURCE.to_string(), - run_profile: gdd.run_profile.clone(), - run_profile_binding_fingerprint: gdd.run_profile_binding_fingerprint.clone(), - session_id: gdd.session_id.clone(), - run_id: gdd.created_by_run_id.clone(), - }, - status: "awaiting_decision".to_string(), - observation: None, - pending_fingerprint: String::new(), - }; - let mut pending = pending; - pending.pending_fingerprint = plan_gdd_approval_pending_fingerprint(&pending)?; - if let Some(existing) = read_plan_gdd_approval_pending_locked(root)? { - if existing != pending { - return Err(approval_error( - "PLAN_NEEDS_RECONCILIATION", - "已有 planning approval pending 属于另一条 GDD identity", - )); - } - return Ok(()); - } - write_plan_gdd_approval_pending_atomic_locked(root, &pending) -} - -/// Result of the M1C-2a acceptance gate. The gate is intentionally separate -/// from `create_plan_gdd_approval_pending_locked`: a pending card is a -/// projection that may only be created after the fixed Fast GDD acceptance -/// graph has converged for the current Markdown revision. -#[derive(Clone, Debug, Eq, PartialEq)] -pub(crate) enum PlanGddAcceptanceGateOutcome { - NotApplicable, - WaitingForDeliveryClaim { - delegation_id: String, - detail: String, - }, - WaitingForEvidence { - detail: String, - }, - RepairRequired { - repair_of_delegation_id: String, - detail: String, - }, - PendingCreated, - PendingAlreadyPresent, - AlreadyDecided, -} - -fn latest_plan_gdd_for_root<'a>(gdds: &'a [PlanGddV1], root_run_id: &str) -> Option<&'a PlanGddV1> { - gdds.iter().rev().find(|gdd| { - gdd.agent_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID - && gdd.source == "agent-delegate" - && gdd.run_profile == AGENT_RUNTIME_RUN_PROFILE_STANDARD - && gdd.root_agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID - && gdd.root_run_id == root_run_id - }) -} - -/// Return whether the plan-root Supervisor must collect the current GDD -/// acceptance evidence before it can dispatch another planning child. -/// -/// This is deliberately a read-only projection of the existing acceptance -/// gate. It does not create approval pending or mutate any planning sidecar; -/// the actual pending projection remains owned by -/// `ensure_plan_gdd_approval_pending_after_acceptance_locked` after a successful -/// `agent.acceptance_update`. The caller must hold the current project write -/// lock and pass that guard explicitly. -pub(crate) fn plan_root_supervisor_acceptance_evidence_required_locked( - root: &Path, - project_lock: &ProjectWriteLock, - run_id: &str, -) -> Result { - if !project_lock.guards_project_root(root)? { - return Err("plan Supervisor 阶段判定缺少当前项目写锁".to_string()); - } - if !crate::config::game_creator_planning_capability_enabled()? { - return Ok(false); - } - if run_id.trim().is_empty() { - return Ok(false); - } - let gdds = read_plan_gdd_chain_locked(root).map_err(|error| error.to_string())?; - let Some(gdd) = latest_plan_gdd_for_root(&gdds, run_id) else { - return Ok(false); - }; - let Some(global_latest) = gdds.last() else { - return Ok(false); - }; - if global_latest.gdd_id != gdd.gdd_id - || global_latest.version != gdd.version - || global_latest.fingerprint != gdd.fingerprint - { - return Ok(false); - } - validate_plan_gdd(gdd).map_err(|error| error.to_string())?; - - let approvals = read_plan_gdd_approvals_locked(root).map_err(|error| error.to_string())?; - validate_plan_gdd_approvals_against_gdds(&gdds, &approvals) - .map_err(|error| error.to_string())?; - if read_plan_gdd_approval_for_version_locked(root, gdd.version) - .map_err(|error| error.to_string())? - .is_some() - { - return Ok(false); - } - if let Some(pending) = - read_plan_gdd_approval_pending_locked(root).map_err(|error| error.to_string())? - { - if !pending_matches_gdd(&pending, gdd) { - return Err("plan Supervisor 阶段判定发现 approval pending identity 冲突".to_string()); - } - return Ok(false); - } - - let Some(session) = - read_plan_session_with_recovery_locked(root).map_err(|error| error.to_string())? - else { - return Ok(false); - }; - if !plan_gdd_session_matches_submission(&session, gdd) { - return Ok(false); - } - let Some(delivery) = read_static_delegate_delivery_at(root, &gdd.delegation_id)? else { - return Ok(false); - }; - if delivery.status != StaticDelegateDeliveryStatus::ClaimedByParent - || delivery.terminal_status.as_deref() != Some("completed") - { - return Ok(false); - } - - Ok(matches!( - plan_fast_gdd_acceptance_status_at_locked(root, gdd)?, - PlanFastGddAcceptanceStatus::NeedsEvidence - )) -} - -pub(crate) fn ensure_plan_gdd_approval_pending_after_acceptance_locked( - root: &Path, - agent_id: &str, - run_id: &str, -) -> Result { - if !crate::config::game_creator_planning_capability_enabled()? { - // 停用是 rollout 状态而非 authority 损坏:该自动门不应把既有根 Run - // 推入 needs-reconciliation,更不能借恢复路径写入新的 pending。 - return Ok(PlanGddAcceptanceGateOutcome::NotApplicable); - } - if agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID || run_id.trim().is_empty() { - return Ok(PlanGddAcceptanceGateOutcome::NotApplicable); - } - // 先识别、后校验。`read_..._binding`(不带 `_once`)会遍历并校验整条祖先链, - // 把它排在识别前面,等于让任何祖先绑定的毛病都抢先变成 Err——连「这根本不是 - // 策划根」都来不及说,于是别人的坏链变成了这道门的失败。`agent.run_status` - // 每次都会重跑本门,带父链的非策划 supervisor run 因此会被误伤。P4 - // (`cb5af31e7`)已在同文件的 `plan_root_completion_identity_at` 修过同一形状, - // 当时漏了这个姊妹函数。识别只看 run 自己那条绑定记录。 - let Some(binding) = - read_game_creator_agent_runtime_run_profile_binding_once(root, agent_id, run_id.trim())? - else { - return Ok(PlanGddAcceptanceGateOutcome::NotApplicable); - }; - if binding.source != AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE { - return Ok(PlanGddAcceptanceGateOutcome::NotApplicable); - } - if binding.profile != AGENT_RUNTIME_RUN_PROFILE_STANDARD { - return Err("Fast GDD acceptance gate 的 plan 根 Run Profile 已漂移".to_string()); - } - // 确认是策划根之后才走完整父链:严格度一点没降,只是不再作用到别人身上。 - read_game_creator_agent_runtime_run_profile_binding(root, agent_id, run_id)?; - let gdds = read_plan_gdd_chain_locked(root).map_err(|error| error.to_string())?; - let Some(gdd) = latest_plan_gdd_for_root(&gdds, run_id) else { - // A contract may be updated before the planning child has submitted a - // GDD. That is a normal intermediate state; it must never allocate a - // user-facing approval card. - return Ok(PlanGddAcceptanceGateOutcome::NotApplicable); - }; - // The planning storage is one immutable GDD lineage per project. A - // replacement root may still have an older GDD in that lineage while a - // newer root has already submitted a later version. Such an old root is - // no longer eligible to expose an approval card; importantly, recovery - // must skip it rather than turning the whole scan into reconciliation. - let Some(global_latest) = gdds.last() else { - return Ok(PlanGddAcceptanceGateOutcome::NotApplicable); - }; - if global_latest.gdd_id != gdd.gdd_id - || global_latest.version != gdd.version - || global_latest.fingerprint != gdd.fingerprint - { - return Ok(PlanGddAcceptanceGateOutcome::NotApplicable); - } - validate_plan_gdd(gdd).map_err(|error| error.to_string())?; - if gdd.root_run_id != run_id || gdd.source != "agent-delegate" { - return Err( - "Fast GDD acceptance gate 的 GDD root/source/profile identity 不一致".to_string(), - ); - } - - let approvals = read_plan_gdd_approvals_locked(root).map_err(|error| error.to_string())?; - validate_plan_gdd_approvals_against_gdds(&gdds, &approvals) - .map_err(|error| error.to_string())?; - let receipt = read_plan_gdd_approval_for_version_locked(root, gdd.version) - .map_err(|error| error.to_string())?; - let pending = read_plan_gdd_approval_pending_locked(root).map_err(|error| error.to_string())?; - if let Some(receipt) = receipt.as_ref() { - if pending - .as_ref() - .is_some_and(|pending| !pending_matches_receipt(pending, gdd, receipt)) - { - return Err( - "Fast GDD acceptance pending 与已提交 receipt 的 identity/observation 冲突" - .to_string(), - ); - } - return Ok(PlanGddAcceptanceGateOutcome::AlreadyDecided); - } - - // An exact awaiting pending is already the durable projection of a - // passed gate. Recovery must preserve it without re-reading the file or - // allocating a new approvalRequestId. - if let Some(existing) = pending { - if !pending_matches_gdd(&existing, gdd) { - return Err("Fast GDD acceptance pending identity 与当前 GDD 不一致".to_string()); - } - return Ok(PlanGddAcceptanceGateOutcome::PendingAlreadyPresent); - } - - let root_binding = validate_project_supervisor_plan_root_binding_at(root, agent_id, run_id)?; - if root_binding.project_id != gdd.project_id - || root_binding.source != PLAN_GDD_APPROVAL_SOURCE - || root_binding.profile != AGENT_RUNTIME_RUN_PROFILE_STANDARD - { - return Err("Fast GDD acceptance gate 的根 Run binding identity 不一致".to_string()); - } - let child_binding = validate_project_planning_child_binding_at( - root, - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - &gdd.created_by_run_id, - )?; - if child_binding.project_id != gdd.project_id - || child_binding.root_agent_id != gdd.root_agent_id - || child_binding.root_run_id != gdd.root_run_id - || child_binding.source != gdd.source - || child_binding.profile != gdd.run_profile - || child_binding.binding_fingerprint != gdd.run_profile_binding_fingerprint - { - return Err("Fast GDD acceptance gate 的策划子 Run binding identity 不一致".to_string()); - } - let child_task = read_latest_game_creator_agent_runtime_task_by_run_id( - root, - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - &gdd.created_by_run_id, - )?; - let child_task = child_task - .ok_or_else(|| "Fast GDD acceptance gate 缺少策划子 Run task 记录".to_string())?; - // Submit-anchor and child-completion recovery own every pre-terminal - // projection. The approval gate must stay inert until that earlier - // workflow has produced a completed task record. - if game_creator_agent_runtime_terminal_status(&child_task).as_deref() != Some("completed") { - return Ok(PlanGddAcceptanceGateOutcome::NotApplicable); - } - if child_task.agent_id != GAME_CREATOR_PROJECT_PLANNING_AGENT_ID - || child_task.run_id != gdd.created_by_run_id - || child_task.session_id != gdd.session_id - || child_task.parent_agent_id.as_deref() != Some(gdd.root_agent_id.as_str()) - || child_task.parent_run_id.as_deref() != Some(gdd.root_run_id.as_str()) - || child_task.source != gdd.source - || child_task.run_profile != gdd.run_profile - || child_task.run_profile_binding_fingerprint != gdd.run_profile_binding_fingerprint - || child_task.delegation_id.as_deref() != Some(gdd.delegation_id.as_str()) - { - return Err( - "Fast GDD acceptance gate 的策划子 Run task 与提交 delivery identity 不一致" - .to_string(), - ); - } - let delivery = read_static_delegate_delivery_at(root, &gdd.delegation_id)?; - let delivery = - delivery.ok_or_else(|| "Fast GDD acceptance gate 缺少原 planning delivery".to_string())?; - if delivery.delegation_id != gdd.delegation_id - || delivery.parent_agent_id != gdd.root_agent_id - || delivery.parent_run_id != gdd.root_run_id - || delivery.target_agent_id != GAME_CREATOR_PROJECT_PLANNING_AGENT_ID - || delivery.target_session_id != gdd.session_id - || delivery.target_run_id != gdd.created_by_run_id - { - return Err("Fast GDD acceptance gate 的 planning delivery identity 不一致".to_string()); - } - if delivery.status == StaticDelegateDeliveryStatus::Dispatched { - // Delegate-receipt recovery owns the Dispatched -> Ready transition. - // The acceptance gate must remain inert until that durable handoff is - // complete instead of treating an ordinary crash window as corruption. - return Ok(PlanGddAcceptanceGateOutcome::NotApplicable); - } - if delivery.status == StaticDelegateDeliveryStatus::Suppressed { - return Err("Fast GDD acceptance gate 的 planning delivery 已被 suppressed".to_string()); - } - if delivery.terminal_status.as_deref() != Some("completed") { - return Err("Fast GDD acceptance gate 的 planning delivery 终态不一致".to_string()); - } - validate_active_plan_root_for_decision_locked(root, gdd).map_err(|error| error.to_string())?; - let session = read_plan_session_with_recovery_locked(root) - .map_err(|error| error.to_string())? - .ok_or_else(|| "Fast GDD acceptance gate 缺少 planning session".to_string())?; - if !plan_gdd_session_matches_submission(&session, gdd) { - return Err( - "Fast GDD acceptance gate 的 planning session 尚未精确指向当前提交".to_string(), - ); - } - - if delivery.status == StaticDelegateDeliveryStatus::Ready { - return Ok(PlanGddAcceptanceGateOutcome::WaitingForDeliveryClaim { - delegation_id: gdd.delegation_id.clone(), - detail: format!( - "planning delivery 尚未由当前 Supervisor 根 Run 认领;先通过 agent.run_status 认领 delegationId={},认领后才能按同一合同发起返工或创建审批 pending", - gdd.delegation_id - ), - }); - } - - match plan_fast_gdd_acceptance_status_at_locked(root, gdd)? { - PlanFastGddAcceptanceStatus::NeedsEvidence => { - return Ok(PlanGddAcceptanceGateOutcome::WaitingForEvidence { - detail: "Acceptance Graph 尚无当前 game/fast_gdd.md 的完整根 Run 取证;先从 startLine=1 分页无缺口读取到 EOF,并用全部 file.read actionId 更新验收节点;不得创建审批卡或提前返工".to_string(), - }); - } - PlanFastGddAcceptanceStatus::RepairRequired => { - return Ok(PlanGddAcceptanceGateOutcome::RepairRequired { - repair_of_delegation_id: gdd.delegation_id.clone(), - detail: format!( - "Acceptance Graph 已基于当前 game/fast_gdd.md 明确判定未通过;不得创建审批卡;repairOfDelegationId={}", - gdd.delegation_id - ), - }); - } - PlanFastGddAcceptanceStatus::Passed => {} - } - - create_plan_gdd_approval_pending_locked(root, gdd).map_err(|error| error.to_string())?; - Ok(PlanGddAcceptanceGateOutcome::PendingCreated) -} - -/// Run the production acceptance gate after a successful root -/// `agent.acceptance_update`. All identity and projection reads happen under -/// the project lock; callers can safely retry this function after a crash. -pub(crate) fn ensure_plan_gdd_approval_pending_after_acceptance_at( - root: &Path, - agent_id: &str, - run_id: &str, -) -> Result { - if !crate::config::game_creator_planning_capability_enabled()? { - return Ok(PlanGddAcceptanceGateOutcome::NotApplicable); - } - if agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID || run_id.trim().is_empty() { - return Ok(PlanGddAcceptanceGateOutcome::NotApplicable); - } - let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( - root, - "planning.acceptance-gate", - ) - .map_err(|error| format!("取得 acceptance-gate 项目锁失败:{error}"))?; - ensure_plan_gdd_approval_pending_after_acceptance_locked(root, agent_id, run_id) -} - -fn approval_terminal_observation_exists_locked( - root: &Path, - receipt: &PlanGddApprovalV1, -) -> Result { - // Identity alone is not a consumption proof. The summary is derived - // deterministically from the immutable receipt and must match exactly - // before any surviving submit anchor may be cleaned up. - let expected_summary = approval_observation(receipt).summary; - // 走全量扫描,不走有界尾窗。这条判据的两个调用点语义并不相同: - // `project_generic_submit_observation_locked` 在 pending 还在时用它当「这次是不是 - // 重放」的探针,首次审批时 `false` 就是正确答案,紧接着才会去写 terminal - // observation。尾窗在没命中时只能二选一,而两个都是错的——报 false 会让真正的重放 - // 被当成未消费,报 Err 会把首次审批拦在写 observation 之前,重试同构,永久停摆。 - // 所以答案必须精确:扫全量,`false` 就真的是没写过。 - let matches = crate::project::read_agent_db_records_matching(root, 8, |record| { - record.get("recordType").and_then(serde_json::Value::as_str) - == Some("agent.runtime.tool_observation") - && record.get("agentId").and_then(serde_json::Value::as_str) - == Some(GAME_CREATOR_PROJECT_PLANNING_AGENT_ID) - && record.get("runId").and_then(serde_json::Value::as_str) - == Some(receipt.run_id.as_str()) - && record.get("actionId").and_then(serde_json::Value::as_str) - == Some(receipt.pending_action_id.as_str()) - && record - .get("actionFingerprint") - .and_then(serde_json::Value::as_str) - == Some(receipt.action_fingerprint.as_str()) - && record.get("tool").and_then(serde_json::Value::as_str) - == Some(PLAN_GDD_APPROVAL_TOOL) - && record.get("status").and_then(serde_json::Value::as_str) == Some("ok") - && record.get("decision").and_then(serde_json::Value::as_str) == Some("approval") - && record.get("summary").and_then(serde_json::Value::as_str) - == Some(expected_summary.as_str()) - })?; - Ok(!matches.is_empty()) -} - -fn project_generic_submit_runtime_observation_locked( - root: &Path, - pending: &AgentRuntimePendingToolAction, - observation: &AgentRuntimeToolObservation, -) -> Result<(), String> { - let mut runtime = read_game_creator_agent_runtime_at(root, &pending.agent_id)?.state; - if runtime.agent_id != pending.agent_id - || runtime.task_id != pending.task_id - || runtime.session_id != pending.session_id - || runtime.run_id != pending.run_id - || runtime.source != pending.source - || runtime.run_profile != pending.run_profile - || runtime.run_profile_binding_fingerprint != pending.run_profile_binding_fingerprint - || runtime.phase != "completed" - { - return Err( - "plan.submit_gdd receipt observation 与 terminal Runtime identity 不一致".to_string(), - ); - } - let summary = observation.summary(); - if runtime.observations.last() != Some(&summary) { - runtime.observations.push(summary.clone()); - } - runtime.pending_tool_action = Some(pending.summary()); - // `currentAction` deliberately keeps the submit-point wording. The task - // projection below is idempotent on (runId, actionId, phase) and the submit - // already claimed that key at phase=completed; writing a second, differently - // worded projection under it is a hard identity conflict, not an append. - // That error used to abort this function after the standalone pending had - // already been rewritten, tearing it away from its still-unadvanced v4 - // batch — a torn pair the recovery scan can only mark needs-reconciliation, - // which then fails this function's `phase == "completed"` gate forever. - // The approval itself stays visible through the `gdd_decided` audit record - // and the `plan.submit_gdd.observed` event appended below. - runtime.next_step = "按审批结果等待下一步策划续跑".to_string(); - runtime.updated_at = unix_timestamp(); - append_game_creator_agent_runtime_task_projection_once(root, &runtime, &pending.action_id)?; - refresh_game_creator_agent_runtime_task_queue(root, &mut runtime)?; - write_game_creator_agent_runtime_state(root, &runtime)?; - append_game_creator_agent_runtime_action_event( - root, - &runtime, - "plan.submit_gdd.observed", - "idle", - "completed", - &summary, - Some(&pending.action_id), - &pending.action_id, - )?; - append_agent_runtime_action_receipt_with_project_revision_before( - root, - &runtime, - &pending.action_id, - &pending.action_fingerprint, - PLAN_GDD_APPROVAL_TOOL, - AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO, - pending.input_summary.as_deref(), - observation, - pending.project_revision_before.revision, - )?; - Ok(()) -} - -fn clear_generic_submit_runtime_observation_locked( - root: &Path, - pending: &AgentRuntimePendingToolAction, -) -> Result<(), String> { - let mut runtime = read_game_creator_agent_runtime_at(root, &pending.agent_id)?.state; - if runtime.run_id != pending.run_id || runtime.phase != "completed" { - return Err("清理 plan.submit_gdd observation 时 Runtime 已被其它 run 替换".to_string()); - } - runtime.pending_tool_action = None; - runtime.updated_at = unix_timestamp(); - append_game_creator_agent_runtime_task_projection_once(root, &runtime, &pending.action_id)?; - refresh_game_creator_agent_runtime_task_queue(root, &mut runtime)?; - write_game_creator_agent_runtime_state(root, &runtime)?; - append_game_creator_agent_runtime_action_event( - root, - &runtime, - "plan.submit_gdd.observation_consumed", - "idle", - "completed", - "plan.submit_gdd 审批观察已消费,原提交锚点已清理。", - Some(&pending.action_id), - &pending.action_id, - )?; - Ok(()) -} - -fn read_validated_generic_submit_batch_locked( - root: &Path, - receipt: &PlanGddApprovalV1, -) -> Result, String> { - if !game_creator_agent_runtime_provider_action_batch_exists( - root, - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - &receipt.run_id, - ) { - return Ok(None); - } - let batch = read_game_creator_agent_runtime_provider_action_batch( - root, - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - &receipt.run_id, - )?; - if !is_plan_submit_gdd_provider_action_batch(&batch) - || batch.project_id != receipt.project_id - || batch.agent_id != GAME_CREATOR_PROJECT_PLANNING_AGENT_ID - || batch.session_id != receipt.session_id - || batch.run_id != receipt.run_id - || batch.source != "agent-delegate" - || batch.run_profile != AGENT_RUNTIME_RUN_PROFILE_STANDARD - || batch.run_profile_binding_fingerprint != receipt.run_profile_binding_fingerprint - { - return Err( - "原 plan.submit_gdd Provider batch 不是 receipt 绑定的 exact identity".to_string(), - ); - } - let action = batch - .actions - .first() - .ok_or_else(|| "原 plan.submit_gdd Provider batch 缺少 action".to_string())?; - if action.action.tool != PLAN_GDD_APPROVAL_TOOL - || action.action_id != receipt.pending_action_id - || action.action_fingerprint != receipt.action_fingerprint - || action.agent_id != batch.agent_id - || action.session_id != batch.session_id - || action.run_id != batch.run_id - || action.run_profile_binding_fingerprint != batch.run_profile_binding_fingerprint - { - return Err( - "原 plan.submit_gdd Provider batch action identity 与 receipt 不一致".to_string(), - ); - } - Ok(Some(batch)) -} - -fn cleanup_completed_generic_submit_batch_after_pending_missing_locked( - root: &Path, - receipt: &PlanGddApprovalV1, -) -> Result { - let Some(batch) = read_validated_generic_submit_batch_locked(root, receipt)? else { - return Ok(true); - }; - let action = batch - .actions - .first() - .ok_or_else(|| "原 plan.submit_gdd Provider batch 缺少 action".to_string())?; - let expected_observation = approval_observation(receipt); - if batch.status != AGENT_RUNTIME_PROVIDER_ACTION_BATCH_STATUS_COMPLETED - || batch.next_action_index != 1 - || action.status != AGENT_RUNTIME_PENDING_ACTION_STATUS_OBSERVED_APPROVED - || action.observation.as_ref() != Some(&expected_observation) - { - // A terminal observation without an exact completed batch is still a - // recovery gap. Do not delete a surviving anchor based on the - // observation alone. - return Ok(false); - } - remove_game_creator_agent_runtime_provider_action_batch( - root, - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - &receipt.run_id, - )?; - Ok(!game_creator_agent_runtime_provider_action_batch_exists( - root, - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - &receipt.run_id, - )) -} - -fn project_generic_submit_observation_locked( - root: &Path, - receipt: &PlanGddApprovalV1, -) -> Result { - let pending_exists = game_creator_agent_runtime_pending_tool_action_exists( - root, - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - &receipt.run_id, - ); - if !pending_exists { - if !approval_terminal_observation_exists_locked(root, receipt)? { - return Ok(false); - } - return cleanup_completed_generic_submit_batch_after_pending_missing_locked(root, receipt); - } - let terminal_observation_already_durable = - approval_terminal_observation_exists_locked(root, receipt)?; - let mut pending = read_game_creator_agent_runtime_pending_tool_action( - root, - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - &receipt.run_id, - )?; - if pending.action_id != receipt.pending_action_id - || pending.action_fingerprint != receipt.action_fingerprint - || pending.action.tool != PLAN_GDD_APPROVAL_TOOL - { - return Err("原 plan.submit_gdd pending identity 与 approval receipt 不一致".to_string()); - } - let _batch = read_validated_generic_submit_batch_locked(root, receipt)?; - let observation = approval_observation(receipt); - pending.status = AGENT_RUNTIME_PENDING_ACTION_STATUS_OBSERVED_APPROVED.to_string(); - pending.observation = Some(observation.clone()); - pending.updated_at = unix_timestamp(); - write_game_creator_agent_runtime_pending_tool_action(root, &pending)?; - project_generic_submit_runtime_observation_locked(root, &pending, &observation)?; - append_agent_db_terminal_observation_if_missing_for_action( - root, - &pending.agent_id, - &pending.run_id, - &pending.action_id, - serde_json::json!({ - "recordType": "agent.runtime.tool_observation", - "agentId": pending.agent_id, - "taskId": pending.task_id, - "runId": pending.run_id, - "tool": observation.tool, - "status": observation.status, - "summary": observation.summary, - "actionId": pending.action_id, - "actionFingerprint": pending.action_fingerprint, - "decision": "approval", - }), - )?; - // The v4 batch is an independent recovery anchor. If it is present, - // advance its member after the terminal observation; if it is absent the - // receipt remains committed but the command reports recoveryPending. - let batch_exists = game_creator_agent_runtime_provider_action_batch_exists( - root, - &pending.agent_id, - &pending.run_id, - ); - let batch_completed = update_game_creator_agent_runtime_provider_batch_member(root, &pending)?; - // Once the terminal observation is durable, a missing batch is the normal - // post-consumption cleanup window (the batch is removed last). On the - // first attempt the observation was not present yet, so a missing batch - // remains a real recovery gap and must not be guessed through. - if batch_completed || (!batch_exists && terminal_observation_already_durable) { - clear_generic_submit_runtime_observation_locked(root, &pending)?; - remove_game_creator_agent_runtime_pending_tool_action( - root, - &pending.agent_id, - &pending.run_id, - )?; - remove_game_creator_agent_runtime_provider_action_batch( - root, - &pending.agent_id, - &pending.run_id, - )?; - return Ok(true); - } - // A missing v4 batch is a recovery gap on the first decision attempt. A - // later replay may observe the already durable terminal observation and - // safely finish cleanup; until then retain the standalone anchor. - Ok(false) -} - -fn project_plan_session_locked( - root: &Path, - gdd: &PlanGddV1, - receipt: &PlanGddApprovalV1, -) -> Result<(), PlanningStorageError> { - let Some(previous) = read_plan_session_with_recovery_locked(root)? else { - return Err(approval_error( - "PLAN_SESSION_RECOVERY_REQUIRED", - "approval receipt 已落盘,但 planning session 不存在", - )); - }; - if previous.project_id != gdd.project_id - || previous.gdd_id != gdd.gdd_id - || previous.session_id != gdd.session_id - || previous.run_profile_binding_fingerprint != gdd.run_profile_binding_fingerprint - || previous.root_run_id != gdd.root_run_id - { - return Err(approval_error( - "PLAN_SESSION_CAS_CONFLICT", - "approval receipt 与 planning session identity 不一致", - )); - } - let decision_ref = PlanDecisionRef { - version: receipt.version, - response_id: receipt.response_id.clone(), - action: receipt.action.clone(), - receipt_fingerprint: receipt.receipt_fingerprint.clone(), - }; - if previous.last_decision_ref.as_ref() == Some(&decision_ref) { - return Ok(()); - } - if previous.latest_submitted_ref.as_ref() != Some(&receipt_plan_ref(receipt)) - || previous.phase != "awaiting_gdd_approval" - || previous.active_run_id.is_some() - { - return Err(approval_error( - "PLAN_SESSION_CAS_CONFLICT", - "planning session 不是当前 GDD 的 awaiting_gdd_approval successor 起点", - )); - } - let mut next = previous.clone(); - next.session_revision = previous - .session_revision - .checked_add(1) - .ok_or_else(|| approval_error("PLAN_SESSION_CAS_CONFLICT", "sessionRevision 溢出"))?; - next.previous_fingerprint = Some(previous.session_fingerprint.clone()); - next.phase = match receipt.action.as_str() { - "approve" => "approved", - "revise" => "revision_requested", - "reject" => "rejected", - _ => unreachable!("validated receipt action"), - } - .to_string(); - next.active_run_id = None; - next.last_run_id = receipt.run_id.clone(); - next.last_decision_ref = Some(decision_ref); - next.updated_at_utc = receipt.decided_at_utc.clone(); - next.session_fingerprint = plan_session_fingerprint(&next)?; - validate_plan_session_successor(&previous, &next)?; - write_plan_session_atomic_locked(root, &next) -} - -/// Record why one receipt projection step fell back to `recoveryPending`. -/// -/// Every gap in `project_receipt_locked` collapses a distinct failure into the -/// same bool. The receipt is already the user-decision linearization point, so -/// none of these failures can surface as a command error; without this record -/// the only escaping symptom is `recoveryPending=true`, which says a projection -/// is behind but never which one or why. That is exactly how a torn anchor -/// pair reaches the recovery scan with its cause already discarded. -/// -/// Best-effort on purpose: a diagnostic must never turn a committed receipt -/// into a failed command, so the append result is deliberately dropped. -fn note_plan_gdd_projection_gap( - root: &Path, - receipt: &PlanGddApprovalV1, - step: &str, - detail: &str, -) { - let _ = crate::project::append_agent_db_record( - root, - serde_json::json!({ - "recordType": PLAN_GDD_APPROVAL_PROJECTION_GAP_RECORD_TYPE, - "projectId": receipt.project_id, - "agentId": PLAN_GDD_APPROVAL_AGENT_ID, - "gddId": receipt.gdd_id, - "version": receipt.version, - "sessionId": receipt.session_id, - "runId": receipt.run_id, - "approvalRequestId": receipt.approval_request_id, - "responseId": receipt.response_id, - "action": receipt.action, - "step": step, - "detail": redact_agent_runtime_project_paths(root, detail, 500), - }), - ); -} - -fn project_receipt_locked( - root: &Path, - gdds: &[PlanGddV1], - receipt: &PlanGddApprovalV1, -) -> Result { - let mut recovery_pending = false; - let approvals = read_plan_gdd_approvals_locked(root)?; - let index = build_plan_gdd_index_with_approvals(gdds, &approvals, &receipt.decided_at_utc)?; - if let Err(error) = write_plan_gdd_index_atomic_locked(root, &index) { - note_plan_gdd_projection_gap(root, receipt, "gdd-index-write", &error.to_string()); - recovery_pending = true; - } - let latest = gdds.last().ok_or_else(|| { - approval_error( - "PLAN_CORRUPT_AUTHORITY", - "approval receipt 缺少 GDD lineage", - ) - })?; - let receipt_gdd = gdds - .iter() - .find(|gdd| { - gdd.gdd_id == receipt.gdd_id - && gdd.version == receipt.version - && gdd.fingerprint == receipt.fingerprint - }) - .ok_or_else(|| { - approval_error( - "PLAN_CORRUPT_AUTHORITY", - "approval receipt 找不到相符的 GDD 权威事实", - ) - })?; - // Markdown follows the highest valid approved version when one exists; - // a later revise/reject candidate must not replace the approved baseline. - let projection_gdd = index - .status_cache - .approved_version - .and_then(|version| gdds.iter().find(|gdd| gdd.version == version)) - .unwrap_or(latest); - let projection_status = index - .status_cache - .versions - .iter() - .find(|status| status.version == projection_gdd.version) - .map(|status| status.status.as_str()) - .unwrap_or("ready_for_approval"); - match render_plan_fast_gdd_markdown(projection_gdd, projection_status) { - Ok(markdown) => { - if let Err(error) = write_plan_fast_gdd_markdown_atomic_locked(root, &markdown) { - note_plan_gdd_projection_gap(root, receipt, "markdown-write", &error.to_string()); - recovery_pending = true; - } - } - Err(error) => { - note_plan_gdd_projection_gap(root, receipt, "markdown-render", &error.to_string()); - recovery_pending = true; - } - } - - let comment_hash = plan_gdd_approval_comment_fingerprint(receipt.comment.as_deref())?; - let audit = serde_json::json!({ - "recordType": PLAN_GDD_APPROVAL_DECISION_AUDIT_RECORD_TYPE, - "auditSchemaVersion": PLAN_GDD_APPROVAL_DECISION_AUDIT_SCHEMA_VERSION, - "projectId": receipt.project_id, - "agentId": PLAN_GDD_APPROVAL_AGENT_ID, - "gddId": receipt.gdd_id, - "version": receipt.version, - "gddFingerprint": receipt.fingerprint, - "pendingActionId": receipt.pending_action_id, - "actionFingerprint": receipt.action_fingerprint, - "approvalRequestId": receipt.approval_request_id, - "responseId": receipt.response_id, - "source": receipt.source, - "runProfile": receipt.run_profile, - "runProfileBindingFingerprint": receipt.run_profile_binding_fingerprint, - "sessionId": receipt.session_id, - "runId": receipt.run_id, - "action": receipt.action, - "decisionFingerprint": receipt.decision_fingerprint, - "commentHash": comment_hash, - "commentLength": receipt.comment.as_deref().map_or(0, |value| value.chars().count()), - "receiptFingerprint": receipt.receipt_fingerprint, - "decidedAtUtc": receipt.decided_at_utc, - }); - if let Err(error) = crate::project::append_agent_db_plan_gdd_decision_if_missing(root, audit) { - if error.contains("PLAN_DECISION_IDENTITY_CONFLICT") { - return Err(approval_error( - "PLAN_DECISION_IDENTITY_CONFLICT", - "planning decision audit 与 receipt 幂等身份冲突", - )); - } - // Receipt is already the user-facing linearization point. Preserve - // the committed result and let a later retry repair ordinary audit - // I/O or capacity failures. - note_plan_gdd_projection_gap(root, receipt, "decision-audit-append", &error); - recovery_pending = true; - } - - let pending_observation = approval_observation(receipt); - let mut approval_pending_cleanup_eligible = false; - // Approval pending is a singleton projection for the latest GDD, not a - // per-receipt projection. A historical receipt must still repair its own - // index/Markdown/audit/runtime anchors, but it must not compare the - // current pending card with its older GDD identity. After a revise/reject - // creates a newer GDD, that comparison is expected to differ. - if receipt.version == latest.version { - let approval_pending = match read_plan_gdd_approval_pending_locked(root) { - Ok(value) => value, - Err(error) => { - note_plan_gdd_projection_gap( - root, - receipt, - "approval-pending-read", - &error.to_string(), - ); - recovery_pending = true; - None - } - }; - match approval_pending { - Some(mut pending) => { - if !pending_identity_matches_gdd(&pending, receipt_gdd) { - note_plan_gdd_projection_gap( - root, - receipt, - "approval-pending-identity", - "approval pending 与 receipt GDD identity 不一致", - ); - recovery_pending = true; - } else { - let expected_status = format!("observed_{}", receipt.action); - if !matches!(pending.status.as_str(), "awaiting_decision") - && pending.status != expected_status - { - note_plan_gdd_projection_gap( - root, - receipt, - "approval-pending-status", - &format!( - "approval pending status={} 既不是 awaiting_decision 也不是 {expected_status}", - pending.status - ), - ); - recovery_pending = true; - // Do not remove a projection whose durable state belongs - // to another decision action. - approval_pending_cleanup_eligible = false; - } else { - approval_pending_cleanup_eligible = true; - pending.status = format!("observed_{}", receipt.action); - pending.observation = Some(PlanGddApprovalObservationV1 { - tool: pending_observation.tool.clone(), - status: pending_observation.status.clone(), - summary: pending_observation.summary.clone(), - detail: pending_observation.detail.clone(), - }); - match plan_gdd_approval_pending_fingerprint(&pending) { - Ok(fingerprint) => { - pending.pending_fingerprint = fingerprint; - if let Err(error) = - write_plan_gdd_approval_pending_atomic_locked(root, &pending) - { - note_plan_gdd_projection_gap( - root, - receipt, - "approval-pending-write", - &error.to_string(), - ); - recovery_pending = true; - } - } - Err(error) => { - note_plan_gdd_projection_gap( - root, - receipt, - "approval-pending-fingerprint", - &error.to_string(), - ); - recovery_pending = true; - } - } - } - } - } - // The approval pending projection is allowed to be absent after the - // original submit anchors have durably consumed the terminal - // observation. The generic-anchor reconciliation below decides - // whether this is a normal post-consumption state or a recovery gap. - None => {} - } - } - - let generic_submit_consumed = match project_generic_submit_observation_locked(root, receipt) { - Ok(consumed) => { - if consumed { - if approval_pending_cleanup_eligible { - if let Err(error) = remove_plan_gdd_approval_pending_locked(root) { - note_plan_gdd_projection_gap( - root, - receipt, - "approval-pending-remove", - &error.to_string(), - ); - recovery_pending = true; - } - } - } else { - // The anchors were left deliberately: the standalone pending - // may already carry the receipt observation while the v4 batch - // is still un-advanced. Name that state so the next recovery - // pass is not the first place the gap becomes visible. - note_plan_gdd_projection_gap( - root, - receipt, - "generic-submit-not-consumed", - "原 plan.submit_gdd 锚点未被 receipt 完整消费,保留锚点等待重放", - ); - recovery_pending = true; - } - consumed - } - Err(error) => { - note_plan_gdd_projection_gap(root, receipt, "generic-submit-observation", &error); - recovery_pending = true; - false - } - }; - if receipt.action != "approve" { - if let Err(error) = mark_static_delegate_delivery_user_revision_requested_at( - root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &receipt_gdd.root_run_id, - &receipt_gdd.delegation_id, - ) { - note_plan_gdd_projection_gap(root, receipt, "delivery-revision-mark", &error); - recovery_pending = true; - } - if let Err(error) = append_plan_gdd_revision_message(root, receipt, receipt_gdd) { - note_plan_gdd_projection_gap(root, receipt, "delivery-revision-message", &error); - recovery_pending = true; - } - } - // A replay may target an older receipt after a newer GDD has already been - // submitted. The receipt still repairs its own audit/observation, but it - // must not try to roll the current session or delivery lineage backwards. - let session_projection_eligible = match read_plan_session_with_recovery_locked(root) { - Ok(session) => session.as_ref().is_some_and(|session| { - let receipt_ref_matches = session - .latest_submitted_ref - .as_ref() - .is_some_and(|reference| reference == &receipt_plan_ref(receipt)); - let decision_ref_matches = - session.last_decision_ref.as_ref().is_some_and(|reference| { - reference.version == receipt.version - && reference.response_id == receipt.response_id - && reference.action == receipt.action - && reference.receipt_fingerprint == receipt.receipt_fingerprint - }); - decision_ref_matches - || (receipt_ref_matches - && session.phase == "awaiting_gdd_approval" - && session.active_run_id.is_none()) - }), - Err(error) => { - note_plan_gdd_projection_gap(root, receipt, "plan-session-read", &error.to_string()); - recovery_pending = true; - false - } - }; - let mut session_projection_ready = false; - if session_projection_eligible { - if let Err(error) = project_plan_session_locked(root, receipt_gdd, receipt) { - note_plan_gdd_projection_gap(root, receipt, "plan-session-project", &error.to_string()); - recovery_pending = true; - } else { - session_projection_ready = true; - } - } - // Provider usage is an immutable fact, while the session value is only a - // projection. The final `plan.submit_gdd` request can finish immediately - // before the approval receipt is written; its v4 submit batch then keeps - // the fold deferred until this receipt consumes both generic anchors. - // Fold only after the receipt/session successor is durable and the batch - // cleanup returned an exact success. Deferred or malformed facts remain - // a recovery barrier instead of being force-written into the session. - if generic_submit_consumed && session_projection_ready { - match fold_plan_provider_usage_into_session_at_locked(root) { - Ok( - PlanProviderUsageFoldOutcome::NoSession - | PlanProviderUsageFoldOutcome::Unchanged - | PlanProviderUsageFoldOutcome::Advanced, - ) => {} - Ok(PlanProviderUsageFoldOutcome::Deferred) => { - note_plan_gdd_projection_gap( - root, - receipt, - "provider-usage-fold", - "provider usage 折叠被推迟,事实尚未可归并", - ); - recovery_pending = true; - } - Err(error) => { - note_plan_gdd_projection_gap( - root, - receipt, - "provider-usage-fold", - &error.to_string(), - ); - recovery_pending = true; - } - } - } - Ok(recovery_pending) -} - -/// Reconcile receipt-derived projections after a Runner restart. Receipts -/// are the authority; this routine also repairs the explicitly allowed -/// acceptance-gate projection when the graph already passed. It never -/// re-executes Provider or `file.read` evidence actions; before an exact -/// pending/receipt exists it may re-read the current Markdown only to verify -/// the persisted receipt hash. Recovery never changes any GDD/approval -/// identity. -pub(crate) fn reconcile_plan_gdd_approval_projections_at( - root: &Path, -) -> Result { - if !crate::config::game_creator_planning_capability_enabled() - .map_err(|error| approval_error("PLAN_CAPABILITY_DISABLED", error))? - { - return Ok(false); - } - let _lock = - acquire_project_write_lock(root, "planning.approval-recovery").map_err(|error| { - approval_error( - "PLAN_DURABILITY_FAILED", - redact_agent_runtime_project_paths(root, &error, 500), - ) - })?; - reconcile_plan_gdd_approval_projections_locked(root) -} - -pub(crate) fn reconcile_plan_gdd_approval_projections_locked( - root: &Path, -) -> Result { - let gdds = read_plan_gdd_chain_locked(root)?; - let plan_root_run_ids = gdds - .iter() - .filter(|gdd| { - gdd.agent_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID - && gdd.source == "agent-delegate" - && gdd.run_profile == AGENT_RUNTIME_RUN_PROFILE_STANDARD - && gdd.root_agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID - }) - .map(|gdd| gdd.root_run_id.as_str()) - .collect::>(); - let mut recovery_pending = false; - for root_run_id in plan_root_run_ids { - match ensure_plan_gdd_approval_pending_after_acceptance_locked( - root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - root_run_id, - ) - .map_err(|error| approval_error("PLAN_NEEDS_RECONCILIATION", error))? - { - PlanGddAcceptanceGateOutcome::PendingCreated => recovery_pending = true, - PlanGddAcceptanceGateOutcome::NotApplicable - | PlanGddAcceptanceGateOutcome::WaitingForDeliveryClaim { .. } - | PlanGddAcceptanceGateOutcome::WaitingForEvidence { .. } - | PlanGddAcceptanceGateOutcome::RepairRequired { .. } - | PlanGddAcceptanceGateOutcome::PendingAlreadyPresent - | PlanGddAcceptanceGateOutcome::AlreadyDecided => {} - } - } - let approvals = read_plan_gdd_approvals_locked(root)?; - if approvals.is_empty() { - return Ok(recovery_pending); - } - validate_plan_gdd_approvals_against_gdds(&gdds, &approvals)?; - for receipt in &approvals { - recovery_pending |= project_receipt_locked(root, &gdds, receipt)?; - } - Ok(recovery_pending) -} - -fn validate_active_plan_root_for_decision_locked( - root: &Path, - gdd: &PlanGddV1, -) -> Result<(), PlanningStorageError> { - let root_binding = validate_project_supervisor_plan_root_binding_at( - root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &gdd.root_run_id, - ) - .map_err(|error| approval_error("PLAN_SOURCE_PROFILE_MISMATCH", error))?; - if root_binding.project_id != gdd.project_id - || root_binding.source != PLAN_GDD_APPROVAL_SOURCE - || root_binding.profile != AGENT_RUNTIME_RUN_PROFILE_STANDARD - { - return Err(approval_error( - "PLAN_SOURCE_PROFILE_MISMATCH", - "审批 GDD 的根 Run binding 与项目身份不一致", - )); - } - - let child_binding = validate_project_planning_child_binding_at( - root, - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - &gdd.created_by_run_id, - ) - .map_err(|error| approval_error("PLAN_SOURCE_PROFILE_MISMATCH", error))?; - if child_binding.project_id != gdd.project_id - || child_binding.root_run_id != gdd.root_run_id - || child_binding.binding_fingerprint != gdd.run_profile_binding_fingerprint - { - return Err(approval_error( - "PLAN_SOURCE_PROFILE_MISMATCH", - "审批 GDD 的策划子 Run binding 与 GDD identity 不一致", - )); - } - let task_path = - game_creator_agent_runtime_task_path(root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID); - let records = read_all_game_creator_agent_runtime_tasks(&task_path) - .map_err(|error| approval_error("PLAN_SOURCE_PROFILE_MISMATCH", error))?; - let active_roots = latest_game_creator_agent_runtime_tasks(records) - .into_iter() - .filter(|task| { - task.agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID - && task.source == PLAN_GDD_APPROVAL_SOURCE - && task.run_profile == AGENT_RUNTIME_RUN_PROFILE_STANDARD - && task.parent_agent_id.is_none() - && task.parent_run_id.is_none() - && autonomous_game_build_root_task_is_active(task) - }) - .collect::>(); - if active_roots.len() != 1 || active_roots[0].run_id != gdd.root_run_id { - return Err(approval_error( - "PLAN_STALE_APPROVAL", - "审批 GDD 不再绑定当前唯一 active plan 根 Run", - )); - } - if active_roots[0].status == "needs-reconciliation" - || active_roots[0].phase == "needs-reconciliation" - { - return Err(approval_error( - "PLAN_STALE_APPROVAL", - "审批 GDD 前必须先完成 plan 根 Run 的人工核对", - )); - } - if active_roots[0].run_profile_binding_fingerprint != root_binding.binding_fingerprint { - return Err(approval_error( - "PLAN_SOURCE_PROFILE_MISMATCH", - "active plan 根 Run 的 binding fingerprint 已漂移", - )); - } - Ok(()) -} - -pub(crate) fn decide_plan_gdd_at( - root: &Path, - input: &DecidePlanGddInputV1, -) -> Result { - if !crate::config::game_creator_planning_capability_enabled() - .map_err(|error| approval_error("PLAN_CAPABILITY_DISABLED", error))? - { - return Err(approval_error( - "PLAN_CAPABILITY_DISABLED", - "立项策划能力当前已停用", - )); - } - validate_decision_transport(input)?; - // A decision is a one-shot user intent: the click either lands or the user - // has to find the card again. The runner is writing concurrently for the - // whole life of the plan run, so a one-shot acquire hands the button a - // failure whenever it happens to land mid-write. Wait the full window. - let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( - root, - "planning.gdd-decision", - ) - .map_err(|error| { - approval_error( - "PLAN_DURABILITY_FAILED", - redact_agent_runtime_project_paths(root, &error, 500), - ) - })?; - let project_id = game_creator_agent_runtime_context_project_id(root) - .map_err(|error| approval_error("PLAN_PROJECT_ID_MISMATCH", error))?; - let gdds = read_plan_gdd_chain_locked(root)?; - let gdd = gdds - .iter() - .find(|gdd| gdd.gdd_id == input.gdd_id && gdd.version == input.version) - .ok_or_else(|| approval_error("PLAN_STALE_APPROVAL", "待审 GDD 已不存在"))?; - if gdd.project_id != project_id - || gdd.fingerprint != input.fingerprint - || gdd.submission_id != input.pending_action_id - || gdd.approval_request_id != input.approval_request_id - { - return Err(approval_error( - "PLAN_STALE_APPROVAL", - "审批卡引用的 GDD identity 已过期", - )); - } - let normalized_comment = - normalize_plan_gdd_approval_comment(&input.action, input.comment.as_deref()) - .map_err(|error| approval_error("PLAN_INVALID_REQUEST", error.to_string()))?; - let approvals = read_plan_gdd_approvals_locked(root)?; - validate_plan_gdd_approvals_against_gdds(&gdds, &approvals)?; - let existing = approvals - .iter() - .find(|receipt| receipt.version == gdd.version); - let (receipt, outcome) = if let Some(existing) = existing { - let decision_input = receipt_decision_input(gdd, input, normalized_comment.clone()); - let expected = plan_gdd_approval_decision_fingerprint_for_identity( - &decision_input, - &existing.source, - &existing.run_profile, - &existing.run_profile_binding_fingerprint, - &existing.session_id, - &existing.run_id, - normalized_comment.as_deref(), - )?; - if existing.response_id == input.response_id && expected != existing.decision_fingerprint { - return Err(approval_error( - "PLAN_DECISION_IDENTITY_CONFLICT", - "同 responseId 的审批意图不一致", - )); - } - ( - existing.clone(), - if existing.response_id == input.response_id { - "replayed" - } else { - "already-decided" - }, - ) - } else { - let Some(latest) = gdds.last() else { - return Err(approval_error( - "PLAN_STALE_APPROVAL", - "没有可供审批的 GDD 权威事实", - )); - }; - if latest.version != gdd.version || latest.fingerprint != gdd.fingerprint { - return Err(approval_error( - "PLAN_STALE_APPROVAL", - "只能决定 lineage 最新且尚未有 receipt 的 GDD", - )); - } - validate_active_plan_root_for_decision_locked(root, gdd)?; - let pending = read_plan_gdd_approval_pending_locked(root)?.ok_or_else(|| { - approval_error("PLAN_STALE_APPROVAL", "审批 pending 已过期或尚未建立") - })?; - if !pending_matches_gdd(&pending, gdd) - || pending.gdd_ref.gdd_id != input.gdd_id - || pending.gdd_ref.version != input.version - { - return Err(approval_error( - "PLAN_STALE_APPROVAL", - "审批 pending identity 与 GDD 不一致", - )); - } - let decision_input = receipt_decision_input(gdd, input, normalized_comment.clone()); - let decision_fingerprint = plan_gdd_approval_decision_fingerprint_for_identity( - &decision_input, - PLAN_GDD_APPROVAL_SOURCE, - &gdd.run_profile, - &gdd.run_profile_binding_fingerprint, - &gdd.session_id, - &gdd.created_by_run_id, - normalized_comment.as_deref(), - )?; - let mut receipt = PlanGddApprovalV1 { - schema_version: PLAN_GDD_APPROVAL_SCHEMA_VERSION.to_string(), - project_id: gdd.project_id.clone(), - gdd_id: gdd.gdd_id.clone(), - version: gdd.version, - fingerprint: gdd.fingerprint.clone(), - pending_action_id: gdd.submission_id.clone(), - action_fingerprint: gdd.action_fingerprint.clone(), - approval_request_id: gdd.approval_request_id.clone(), - response_id: input.response_id.clone(), - decision_fingerprint, - source: PLAN_GDD_APPROVAL_SOURCE.to_string(), - run_profile: gdd.run_profile.clone(), - run_profile_binding_fingerprint: gdd.run_profile_binding_fingerprint.clone(), - session_id: gdd.session_id.clone(), - run_id: gdd.created_by_run_id.clone(), - action: input.action.clone(), - comment: normalized_comment, - decided_at_utc: current_plan_timestamp_utc(), - receipt_fingerprint: String::new(), - }; - receipt.receipt_fingerprint = plan_gdd_approval_receipt_fingerprint(&receipt)?; - let bytes = canonical_plan_gdd_approval_bytes(&receipt)?; - let path = format!("{PLAN_GDD_APPROVAL_DIR}/v{}.json", receipt.version); - durable_create_json_no_replace_locked(root, &path, &bytes, "GDD approval receipt")?; - (receipt, "committed") - }; - let recovery_pending = project_receipt_locked(root, &gdds, &receipt)?; - Ok(PlanGddDecisionResultV1 { - outcome: outcome.to_string(), - requested_response_id: input.response_id.clone(), - decision_ref: receipt_ref(&receipt), - approved_gdd_ref: (receipt.action == "approve").then(|| receipt_plan_ref(&receipt)), - recovery_pending, - }) -} - -const PLAN_GDD_COMPLETION_BLOCKER_TOOL: &str = "runtime.plan_gdd"; - -/// 为什么 `runtime.plan_gdd` 的 blocker 需要一个类型化的子状态: -/// -/// `status == "needs-reconciliation"` 已经把「要人工核对」和「继续推进」分开了, -/// 但 `"blocked"` 一侧有三种彼此完全不同的继续推进态,驱动侧必须区分才能选对 -/// phase 与 next_step。原来的做法是在 `main_loop` 里对 detail 做 -/// `contains("approvalPending=awaiting_decision")`:三个 blocked 里只有一个含这个 -/// 子串,另外两个会掉进 else 被打成 needs-reconciliation——把最正常的早期推进态和 -/// 收尾态当成故障停掉。子状态判别必须由构造方给出,不能让消费方去猜字符串。 -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub(crate) enum PlanGddCompletionBlockerKind { - /// 根 Run 还没提交 Fast GDD,下一步是 `agent.delegate`。 - SubmissionNotStarted, - /// Fast GDD 已提交,等待用户在审批卡上做决定。 - AwaitingApprovalDecision, - /// receipt 已落盘,原 `plan.submit_gdd` 恢复锚点还没清理完。 - ReceiptAnchorCleanupPending, - /// 其余一律要人工核对。 - NeedsReconciliation, -} - -pub(crate) struct PlanGddCompletionBlocker { - pub(crate) observation: AgentRuntimeToolObservation, - pub(crate) kind: PlanGddCompletionBlockerKind, -} - -fn plan_gdd_completion_blocker( - status: &str, - summary: impl Into, - detail: impl Into, -) -> PlanGddCompletionBlocker { - debug_assert_ne!( - status, "blocked", - "blocked 子状态必须走 plan_gdd_blocked_completion_blocker 显式给出 kind" - ); - PlanGddCompletionBlocker { - observation: AgentRuntimeToolObservation { - tool: PLAN_GDD_COMPLETION_BLOCKER_TOOL.to_string(), - status: status.to_string(), - summary: summary.into(), - detail: Some(detail.into()), - }, - kind: PlanGddCompletionBlockerKind::NeedsReconciliation, - } -} - -fn plan_gdd_blocked_completion_blocker( - kind: PlanGddCompletionBlockerKind, - summary: impl Into, - detail: impl Into, -) -> PlanGddCompletionBlocker { - debug_assert_ne!( - kind, - PlanGddCompletionBlockerKind::NeedsReconciliation, - "blocked blocker 不能声明成人工核对" - ); - PlanGddCompletionBlocker { - observation: AgentRuntimeToolObservation { - tool: PLAN_GDD_COMPLETION_BLOCKER_TOOL.to_string(), - status: "blocked".to_string(), - summary: summary.into(), - detail: Some(detail.into()), - }, - kind, - } -} - -fn plan_gdd_decision_audit_value(receipt: &PlanGddApprovalV1) -> Result { - let comment_hash = plan_gdd_approval_comment_fingerprint(receipt.comment.as_deref()) - .map_err(|error| error.to_string())?; - Ok(serde_json::json!({ - "recordType": PLAN_GDD_APPROVAL_DECISION_AUDIT_RECORD_TYPE, - "auditSchemaVersion": PLAN_GDD_APPROVAL_DECISION_AUDIT_SCHEMA_VERSION, - "projectId": receipt.project_id, - "agentId": PLAN_GDD_APPROVAL_AGENT_ID, - "gddId": receipt.gdd_id, - "version": receipt.version, - "gddFingerprint": receipt.fingerprint, - "pendingActionId": receipt.pending_action_id, - "actionFingerprint": receipt.action_fingerprint, - "approvalRequestId": receipt.approval_request_id, - "responseId": receipt.response_id, - "source": receipt.source, - "runProfile": receipt.run_profile, - "runProfileBindingFingerprint": receipt.run_profile_binding_fingerprint, - "sessionId": receipt.session_id, - "runId": receipt.run_id, - "action": receipt.action, - "decisionFingerprint": receipt.decision_fingerprint, - "commentHash": comment_hash, - "commentLength": receipt.comment.as_deref().map_or(0, |value| value.chars().count()), - "receiptFingerprint": receipt.receipt_fingerprint, - "decidedAtUtc": receipt.decided_at_utc, - })) -} - -fn plan_gdd_decision_audit_state_locked( - root: &Path, - receipt: &PlanGddApprovalV1, -) -> Result { - let expected = plan_gdd_decision_audit_value(receipt)?; - // 过滤器只到 (recordType, gddId, version),比写侧的幂等键 - // (recordType, gddId, version, responseId) 少一格——这是故意的:同版本不同 - // responseId 的两条 audit 写侧根本不拦,能共存,而这里就是那个跨 responseId 的冲 - // 突检测器。所以扫描范围必须是全量:有界尾窗只要把更旧的那条冲突记录挤出去,收束 - // 闸就只看得见新的那条,然后放行。 - let records = crate::project::read_agent_db_records_matching(root, 64, |record| { - record.get("recordType").and_then(serde_json::Value::as_str) - == Some(PLAN_GDD_APPROVAL_DECISION_AUDIT_RECORD_TYPE) - && record.get("gddId").and_then(serde_json::Value::as_str) - == Some(receipt.gdd_id.as_str()) - && record.get("version").and_then(serde_json::Value::as_u64) - == Some(receipt.version as u64) - })?; - let mut found = false; - for record in records { - let mut comparable = record; - if let Some(object) = comparable.as_object_mut() { - object.remove("schemaVersion"); - object.remove("updatedAt"); - } - if comparable != expected { - return Err("plan GDD decision audit 与 receipt identity 不一致".to_string()); - } - found = true; - } - Ok(found) -} - -fn plan_gdd_pending_identity_matches( - pending: &AgentRuntimePendingToolAction, - gdd: &PlanGddV1, -) -> bool { - pending.agent_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID - && pending.session_id == gdd.session_id - && pending.run_id == gdd.created_by_run_id - && pending.source == gdd.source - && pending.run_profile == gdd.run_profile - && pending.run_profile_binding_fingerprint == gdd.run_profile_binding_fingerprint - && pending.action.tool == PLAN_GDD_APPROVAL_TOOL - && pending.action_id == gdd.submission_id - && pending.action_fingerprint == gdd.action_fingerprint - && pending - .planning_session_binding - .as_ref() - .is_some_and(|binding| { - binding.project_id == gdd.project_id - && binding.gdd_id == gdd.gdd_id - && binding.session_id == gdd.session_id - && binding.session_revision == gdd.source_session_revision - && binding.session_fingerprint == gdd.source_session_fingerprint - && binding.root_agent_id == gdd.root_agent_id - && binding.root_run_id == gdd.root_run_id - && binding.delegation_id == gdd.delegation_id - }) -} - -fn plan_gdd_batch_identity_matches( - batch: &AgentRuntimeProviderActionBatch, - gdd: &PlanGddV1, -) -> bool { - is_plan_submit_gdd_provider_action_batch(batch) - && batch.project_id == gdd.project_id - && batch.agent_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID - && batch.session_id == gdd.session_id - && batch.run_id == gdd.created_by_run_id - && batch.source == gdd.source - && batch.run_profile == gdd.run_profile - && batch.run_profile_binding_fingerprint == gdd.run_profile_binding_fingerprint - && batch - .planning_session_binding - .as_ref() - .is_some_and(|binding| { - binding.project_id == gdd.project_id - && binding.gdd_id == gdd.gdd_id - && binding.session_id == gdd.session_id - && binding.session_revision == gdd.source_session_revision - && binding.session_fingerprint == gdd.source_session_fingerprint - && binding.root_agent_id == gdd.root_agent_id - && binding.root_run_id == gdd.root_run_id - && binding.delegation_id == gdd.delegation_id - }) - && batch.actions.len() == 1 - && batch.actions[0].action_id == gdd.submission_id - && batch.actions[0].action_fingerprint == gdd.action_fingerprint - && plan_gdd_pending_identity_matches(&batch.actions[0], gdd) -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum PlanGddAnchorState { - Absent, - Present, -} - -fn plan_gdd_anchor_state_locked( - root: &Path, - gdd: &PlanGddV1, -) -> Result<(PlanGddAnchorState, PlanGddAnchorState), String> { - let pending_state = if game_creator_agent_runtime_pending_tool_action_exists( - root, - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - &gdd.created_by_run_id, - ) { - let pending = read_game_creator_agent_runtime_pending_tool_action( - root, - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - &gdd.created_by_run_id, - )?; - if !plan_gdd_pending_identity_matches(&pending, gdd) { - return Err("plan.submit_gdd standalone pending 与 GDD identity 不一致".to_string()); - } - PlanGddAnchorState::Present - } else { - PlanGddAnchorState::Absent - }; - let batch_state = if game_creator_agent_runtime_provider_action_batch_exists( - root, - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - &gdd.created_by_run_id, - ) { - let batch = read_game_creator_agent_runtime_provider_action_batch( - root, - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - &gdd.created_by_run_id, - )?; - if !plan_gdd_batch_identity_matches(&batch, gdd) { - return Err("plan.submit_gdd Provider batch 与 GDD identity 不一致".to_string()); - } - PlanGddAnchorState::Present - } else { - PlanGddAnchorState::Absent - }; - Ok((pending_state, batch_state)) -} - -fn plan_gdd_session_identity_matches_gdd(session: &PlanSessionV1, gdd: &PlanGddV1) -> bool { - session.project_id == gdd.project_id - && session.gdd_id == gdd.gdd_id - && session.agent_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID - && session.source == gdd.source - && session.run_profile == gdd.run_profile - && session.run_profile_binding_fingerprint == gdd.run_profile_binding_fingerprint - && session.root_agent_id == gdd.root_agent_id - && session.root_run_id == gdd.root_run_id - && session.latest_delegation_id == gdd.delegation_id - && session.session_id == gdd.session_id - && session.latest_submitted_ref.as_ref() == Some(&receipt_plan_ref_from_gdd(gdd)) - && session.active_run_id.is_none() - && session.last_run_id == gdd.created_by_run_id -} - -fn plan_gdd_session_matches_submission(session: &PlanSessionV1, gdd: &PlanGddV1) -> bool { - plan_gdd_session_identity_matches_gdd(session, gdd) - && session.phase == "awaiting_gdd_approval" - && session.session_revision == gdd.source_session_revision.saturating_add(1) - && session.previous_fingerprint.as_deref() == Some(gdd.source_session_fingerprint.as_str()) - && session.last_decision_ref.is_none() -} - -fn receipt_plan_ref_from_gdd(gdd: &PlanGddV1) -> PlanGddRef { - PlanGddRef { - gdd_id: gdd.gdd_id.clone(), - version: gdd.version, - fingerprint: gdd.fingerprint.clone(), - } -} - -fn plan_gdd_session_matches_receipt( - session: &PlanSessionV1, - gdd: &PlanGddV1, - receipt: &PlanGddApprovalV1, -) -> bool { - if !plan_gdd_session_identity_matches_gdd(session, gdd) { - return false; - } - let expected_phase = match receipt.action.as_str() { - "approve" => "approved", - "revise" => "revision_requested", - "reject" => "rejected", - _ => return false, - }; - session.phase == expected_phase - && session.last_run_id == receipt.run_id - && session.last_decision_ref.as_ref().is_some_and(|reference| { - reference.version == receipt.version - && reference.response_id == receipt.response_id - && reference.action == receipt.action - && reference.receipt_fingerprint == receipt.receipt_fingerprint - }) -} - -// 这道门用 `PLAN_GDD_APPROVAL_SOURCE` 识别策划根,而随后的 -// `validate_project_supervisor_plan_root_binding_at` 用 `AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE` -// 复核同一条绑定。两个常量分别定义在 planning_storage 与 runtime_driver,值必须相同: -// 一旦分叉,每个策划根都会先通过识别、再被复核拒掉,全部塌成 needs-reconciliation, -// 而且没有任何测试会直接指向这个原因。钉成编译期条件。 -const _: () = { - let identified = PLAN_GDD_APPROVAL_SOURCE.as_bytes(); - let validated = AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE.as_bytes(); - assert!(identified.len() == validated.len()); - let mut index = 0; - while index < identified.len() { - assert!(identified[index] == validated[index]); - index += 1; - } -}; - -fn plan_root_completion_identity_at( - root: &Path, - agent_id: &str, - run_id: &str, -) -> Result { - if agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID || run_id.trim().is_empty() { - return Ok(false); - } - // 识别只看 run 自己那条绑定记录。链走版本会遍历并校验整条祖先链;把它排在识别 - // 前面,等于让任何祖先绑定的毛病都先变成 Err,而调用方会把 Err 一律翻成 - // needs-reconciliation——扣在一个下一行本来就会被判「不是策划根」的 run 头上,让它 - // 再也收束不了。被误伤的只可能是非策划 run:真正的策划根无父(下面 - // `validate_project_supervisor_plan_root_binding_at` 强制 parent 必须为空),链走 - // 对它本就是空转。两个入口对同一个 (agent, run) 返回的绑定值完全相同,差别仅在于 - // 是否顺带校验祖先链,所以识别判据一字未变。 - let Some(binding) = - read_game_creator_agent_runtime_run_profile_binding_once(root, agent_id, run_id.trim())? - else { - return Ok(false); - }; - if binding.source != PLAN_GDD_APPROVAL_SOURCE - || binding.profile != AGENT_RUNTIME_RUN_PROFILE_STANDARD - { - return Ok(false); - } - // 确认是策划根之后才走完整父链:严格度一点没降—— - // `validate_project_supervisor_plan_root_binding_at` 内部读的就是链走版本。 - let binding = validate_project_supervisor_plan_root_binding_at(root, agent_id, run_id)?; - let runtime = read_game_creator_agent_runtime_at(root, agent_id)?.state; - if runtime.run_id != run_id - || runtime.agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID - || runtime.source != PLAN_GDD_APPROVAL_SOURCE - || runtime.run_profile != AGENT_RUNTIME_RUN_PROFILE_STANDARD - || runtime.run_profile_binding_fingerprint != binding.binding_fingerprint - || runtime.parent_agent_id.is_some() - || runtime.parent_run_id.is_some() - || runtime.delegation_id.is_some() - { - return Err("plan 根 Runtime state 与 durable root binding 不一致".to_string()); - } - let task_path = game_creator_agent_runtime_task_path(root, agent_id); - let records = read_all_game_creator_agent_runtime_tasks(&task_path)?; - let task = latest_game_creator_agent_runtime_tasks(records) - .into_iter() - .find(|task| task.run_id == run_id); - let Some(task) = task else { - return Err("plan 根 Runtime task 记录缺失".to_string()); - }; - if task.agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID - || task.source != PLAN_GDD_APPROVAL_SOURCE - || task.run_profile != AGENT_RUNTIME_RUN_PROFILE_STANDARD - || task.run_profile_binding_fingerprint != binding.binding_fingerprint - || task.parent_agent_id.is_some() - || task.parent_run_id.is_some() - || task.delegation_id.is_some() - { - return Err("plan 根 Runtime task 与 durable root binding 不一致".to_string()); - } - Ok(true) -} - -/// Dedicated completion gate for the top-level `project-supervisor-plan` run. -/// -/// This is deliberately read-only: M1C-1 may observe an approval pending -/// projection, but only the later acceptance-gate caller may create it. A -/// committed GDD therefore remains blocked until the pending/receipt, -/// generic child anchors, terminal observation, decision audit and planning -/// session all form one exact durable state. -/// 只要 blocker 本身,不关心 blocked 的子状态。驱动侧(`main_loop`)必须改用 -/// `plan_gdd_typed_completion_blocker_at_locked`,否则又要去猜 detail 字符串。 -pub(crate) fn plan_gdd_completion_blocker_at_locked( - root: &Path, - agent_id: &str, - run_id: &str, -) -> Option { - plan_gdd_typed_completion_blocker_at_locked(root, agent_id, run_id) - .map(|blocker| blocker.observation) -} - -pub(crate) fn plan_gdd_typed_completion_blocker_at_locked( - root: &Path, - agent_id: &str, - run_id: &str, -) -> Option { - let is_plan_root = match plan_root_completion_identity_at(root, agent_id, run_id) { - Ok(value) => value, - Err(error) => { - return Some(plan_gdd_completion_blocker( - "needs-reconciliation", - "无法确认立项策划根 Run 身份,不能收束任务", - redact_agent_runtime_project_paths(root, &error, 500), - )); - } - }; - if !is_plan_root { - return None; - } - - let gdds = match read_plan_gdd_chain_locked(root) { - Ok(gdds) => gdds, - Err(error) => { - return Some(plan_gdd_completion_blocker( - "needs-reconciliation", - "Fast GDD lineage 无法读取,不能收束立项策划任务", - error.to_string(), - )); - } - }; - let plan_gdds = gdds - .iter() - .filter(|gdd| { - gdd.root_agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID - && gdd.root_run_id == run_id - && gdd.source == "agent-delegate" - && gdd.run_profile == AGENT_RUNTIME_RUN_PROFILE_STANDARD - }) - .collect::>(); - if plan_gdds.is_empty() { - return match read_game_creator_agent_runtime_goal_contract_at( - root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - run_id, - ) { - Ok(Some(_)) => Some(plan_gdd_blocked_completion_blocker( - PlanGddCompletionBlockerKind::SubmissionNotStarted, - "当前立项策划根 Run 尚未提交 Fast GDD,不能收束任务", - format!( - "rootRunId={run_id} · nextRequiredAction=agent.delegate;上一根 Run 遗留的 game/fast_gdd.md 或 Acceptance Graph 不能代替本根提交" - ), - )), - Ok(None) => None, - Err(error) => Some(plan_gdd_completion_blocker( - "needs-reconciliation", - "Goal Contract 无法读取,不能确认当前立项策划根 Run 的 GDD 提交状态", - error, - )), - }; - } - let latest = *plan_gdds.last().expect("non-empty plan GDD lineage"); - - let approvals = match read_plan_gdd_approvals_locked(root) { - Ok(approvals) => approvals, - Err(error) => { - return Some(plan_gdd_completion_blocker( - "needs-reconciliation", - "Fast GDD approval receipt 无法读取,不能收束任务", - error.to_string(), - )); - } - }; - if let Err(error) = validate_plan_gdd_approvals_against_gdds(&gdds, &approvals) { - return Some(plan_gdd_completion_blocker( - "needs-reconciliation", - "Fast GDD approval receipt 与 lineage 无法对账,不能收束任务", - error.to_string(), - )); - } - let receipt = approvals.iter().find(|receipt| { - receipt.version == latest.version - && receipt.gdd_id == latest.gdd_id - && receipt.fingerprint == latest.fingerprint - }); - - let pending = match read_plan_gdd_approval_pending_locked(root) { - Ok(pending) => pending, - Err(error) => { - return Some(plan_gdd_completion_blocker( - "needs-reconciliation", - "Fast GDD approval pending 无法读取,不能收束任务", - error.to_string(), - )); - } - }; - if receipt.is_none() { - if let Some(pending) = pending.as_ref() { - if !pending_matches_gdd(pending, latest) { - return Some(plan_gdd_completion_blocker( - "needs-reconciliation", - "Fast GDD approval pending identity 与当前 GDD 不一致", - format!("gddVersion={}", latest.version), - )); - } - } - // An exact awaiting pending is proof that the acceptance gate already - // passed. Do not re-read the Markdown after recovery or projection - // repair; only the no-pending path needs the graph preflight. - let pending_is_exact_awaiting = pending - .as_ref() - .is_some_and(|pending| pending_matches_gdd(pending, latest)); - if !pending_is_exact_awaiting { - let contract = match read_game_creator_agent_runtime_goal_contract_at( - root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - run_id, - ) { - Ok(contract) => contract, - Err(error) => { - return Some(plan_gdd_completion_blocker( - "needs-reconciliation", - "Goal Contract 无法读取,不能确认 Fast GDD 验收前置门", - error, - )); - } - }; - if contract.is_some() { - let global_latest_matches = gdds.last().is_some_and(|global_latest| { - global_latest.gdd_id == latest.gdd_id - && global_latest.version == latest.version - && global_latest.fingerprint == latest.fingerprint - }); - if !global_latest_matches { - return Some(plan_gdd_completion_blocker( - "needs-reconciliation", - "当前 Fast GDD 不是项目 lineage 最新版本,不能自动收束旧 plan 根", - format!( - "gddVersion={} · rootRunId={} · 需由当前 lineage 根 Run 继续处理", - latest.version, run_id - ), - )); - } - match plan_fast_gdd_acceptance_passed_at_locked(root, latest) { - Ok(true) => {} - // Leave the not-yet-passed phase to the Acceptance Graph - // blocker so the Supervisor can take file.read evidence. - Ok(false) => return None, - Err(error) => { - return Some(plan_gdd_completion_blocker( - "needs-reconciliation", - "Fast GDD 验收前置门 identity 无法核对", - error, - )); - } - } - } - } - } - - let session = match read_plan_session_with_recovery_locked(root) { - Ok(Some(session)) => session, - Ok(None) => { - return Some(plan_gdd_completion_blocker( - "needs-reconciliation", - "Fast GDD 已提交但 planning session 缺失,不能收束任务", - format!("gddVersion={} · runId={run_id}", latest.version), - )); - } - Err(error) => { - return Some(plan_gdd_completion_blocker( - "needs-reconciliation", - "planning session 无法恢复,不能收束 Fast GDD 任务", - error.to_string(), - )); - } - }; - if receipt.is_none() && !plan_gdd_session_matches_submission(&session, latest) { - return Some(plan_gdd_completion_blocker( - "needs-reconciliation", - "planning session 尚未收口到当前 Fast GDD 提交", - format!( - "gddVersion={} · phase={} · activeRunPresent={} · latestSubmittedMatches={}", - latest.version, - session.phase, - session.active_run_id.is_some(), - session.latest_submitted_ref.as_ref() == Some(&receipt_plan_ref_from_gdd(latest)), - ), - )); - } - - let (pending_anchor, batch_anchor) = match plan_gdd_anchor_state_locked(root, latest) { - Ok(state) => state, - Err(error) => { - return Some(plan_gdd_completion_blocker( - "needs-reconciliation", - "原 plan.submit_gdd 恢复锚点 identity 不一致,不能收束任务", - error, - )); - } - }; - let terminal_observation = match receipt { - Some(receipt) => match approval_terminal_observation_exists_locked(root, receipt) { - Ok(value) => value, - Err(error) => { - return Some(plan_gdd_completion_blocker( - "needs-reconciliation", - "无法确认 Fast GDD terminal observation,不能收束任务", - error, - )); - } - }, - None => false, - }; - - let Some(receipt) = receipt else { - let pending_matches = pending.as_ref().is_some_and(|pending| { - pending_identity_matches_gdd(pending, latest) - && pending.status == "awaiting_decision" - && pending.observation.is_none() - }); - if !pending_matches { - return Some(plan_gdd_completion_blocker( - "needs-reconciliation", - "Fast GDD 已提交但审批 pending 尚未建立或 identity 不一致", - format!( - "gddVersion={} · approvalPending={} · pendingIdentityMatches={} · childPending={} · childBatch={}", - latest.version, - pending.is_some(), - pending_matches, - matches!(pending_anchor, PlanGddAnchorState::Present), - matches!(batch_anchor, PlanGddAnchorState::Present), - ), - )); - } - return Some(plan_gdd_blocked_completion_blocker( - PlanGddCompletionBlockerKind::AwaitingApprovalDecision, - "Fast GDD 已提交,等待用户审批决定,不能收束任务", - format!( - "gddVersion={} · approvalPending=awaiting_decision · childPending={} · childBatch={};pending 只能由验收取证通过后的 acceptance-gate caller 创建", - latest.version, - matches!(pending_anchor, PlanGddAnchorState::Present), - matches!(batch_anchor, PlanGddAnchorState::Present), - ), - )); - }; - - let expected_pending_status = format!("observed_{}", receipt.action); - if let Some(pending) = pending { - let pending_matches = pending_identity_matches_gdd(&pending, latest) - && pending.status == expected_pending_status - && pending.observation.as_ref().is_some_and(|observation| { - observation.tool == PLAN_GDD_APPROVAL_TOOL - && observation.status == "ok" - && observation.summary == approval_observation(receipt).summary - && observation.detail == approval_observation(receipt).detail - }); - if !pending_matches { - return Some(plan_gdd_completion_blocker( - "needs-reconciliation", - "Fast GDD approval pending 尚未按 receipt 收口", - format!( - "gddVersion={} · expectedStatus={expected_pending_status}", - latest.version - ), - )); - } - } - if pending_anchor != PlanGddAnchorState::Absent || batch_anchor != PlanGddAnchorState::Absent { - return Some(plan_gdd_blocked_completion_blocker( - PlanGddCompletionBlockerKind::ReceiptAnchorCleanupPending, - "Fast GDD receipt 已提交,但原 plan.submit_gdd 恢复锚点尚未清理", - format!( - "gddVersion={} · terminalObservation={} · childPending={} · childBatch={}", - latest.version, - terminal_observation, - matches!(pending_anchor, PlanGddAnchorState::Present), - matches!(batch_anchor, PlanGddAnchorState::Present), - ), - )); - } - if !terminal_observation { - return Some(plan_gdd_completion_blocker( - "needs-reconciliation", - "Fast GDD receipt 已提交但 terminal observation 缺失,不能收束任务", - format!( - "gddVersion={} · childPending=false · childBatch=false", - latest.version - ), - )); - } - match plan_gdd_decision_audit_state_locked(root, receipt) { - Ok(true) => {} - Ok(false) => { - return Some(plan_gdd_completion_blocker( - "needs-reconciliation", - "Fast GDD decision audit 尚未落盘,不能收束任务", - format!( - "gddVersion={} · receiptAction={}", - latest.version, receipt.action - ), - )); - } - Err(error) => { - // Err 现在有两种来源——identity 冲突,和尾窗截断导致的「无法确认」。标题 - // 只说无法确认,具体原因由 detail 里的 error 原文给出;写死成「identity - // 不一致」会把排查的人按到错误的方向上。 - return Some(plan_gdd_completion_blocker( - "needs-reconciliation", - "Fast GDD decision audit 无法确认,不能收束任务", - error, - )); - } - } - if !plan_gdd_session_matches_receipt(&session, latest, receipt) { - return Some(plan_gdd_completion_blocker( - "needs-reconciliation", - "planning session 尚未收口到 Fast GDD receipt", - format!( - "gddVersion={} · phase={} · lastDecisionMatches={}", - latest.version, - session.phase, - session.last_decision_ref.is_some(), - ), - )); - } - None -} - -#[cfg(test)] -mod tests { - use super::*; - use std::fs; - - #[test] - fn forged_terminal_summary_cannot_consume_submit_anchor() { - let root = std::env::temp_dir().join(format!( - "genarrative-plan-approval-observation-{}", - uuid::Uuid::new_v4().simple() - )); - init_local_game_project_at(&root, "project-test-approval", "approval observation") - .expect("init project"); - let receipt = PlanGddApprovalV1 { - schema_version: PLAN_GDD_APPROVAL_SCHEMA_VERSION.to_string(), - project_id: "project-test-approval".to_string(), - gdd_id: "gdd-00000000-0000-4000-8000-000000000001".to_string(), - version: 1, - fingerprint: format!("sha256-serde-json-v2:{}", "1".repeat(64)), - pending_action_id: "action-0123456789abcdef01234567".to_string(), - action_fingerprint: "2".repeat(64), - approval_request_id: "gdd-approval-00000000-0000-4000-8000-000000000002".to_string(), - response_id: "gdd-response-00000000-0000-4000-8000-000000000003".to_string(), - decision_fingerprint: format!("sha256-serde-json-v2:{}", "3".repeat(64)), - source: PLAN_GDD_APPROVAL_SOURCE.to_string(), - run_profile: AGENT_RUNTIME_RUN_PROFILE_STANDARD.to_string(), - run_profile_binding_fingerprint: "4".repeat(64), - session_id: "session-approval-1".to_string(), - run_id: "run-planning-child-1".to_string(), - action: "approve".to_string(), - comment: None, - decided_at_utc: "2026-08-15T00:00:00.000Z".to_string(), - receipt_fingerprint: format!("sha256-serde-json-v2:{}", "5".repeat(64)), - }; - let batch_path = game_creator_agent_runtime_provider_action_batch_path( - &root, - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - &receipt.run_id, - ); - fs::create_dir_all(batch_path.parent().expect("batch parent")).expect("batch parent"); - fs::write(&batch_path, b"{}\n").expect("seed batch anchor"); - append_agent_db_terminal_observation_if_missing_for_action( - &root, - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - &receipt.run_id, - &receipt.pending_action_id, - serde_json::json!({ - "recordType": "agent.runtime.tool_observation", - "agentId": GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - "taskId": "task-planning-1", - "runId": receipt.run_id, - "actionId": receipt.pending_action_id, - "actionFingerprint": receipt.action_fingerprint, - "tool": PLAN_GDD_APPROVAL_TOOL, - "status": "ok", - "summary": "伪造的审批摘要", - "decision": "approval", - }), - ) - .expect("append forged terminal observation"); - - assert!( - !approval_terminal_observation_exists_locked(&root, &receipt) - .expect("read terminal observation") - ); - assert!(!project_generic_submit_observation_locked(&root, &receipt) - .expect("reconcile forged observation")); - assert!( - batch_path.is_file(), - "mismatched observation must retain anchor" - ); - let _ = fs::remove_dir_all(root); - } - - /// 两条审批判据必须扫全量 agent.db,不能只看有界尾窗。 - /// - /// 尾窗保留的是最新的一段,能证明「在」,证明不了「不在」,也看不见滑出窗口的更旧 - /// 记录。两条判据都受不了这种半盲的答案,但受不了的方式相反: - /// - /// - terminal observation 判据在 `project_generic_submit_observation_locked` 里当 - /// 「这次是不是重放」的探针用,首次审批时必须拿到可信的 `false`,紧接着才会去写 - /// observation。在那里 fail closed 会把首次审批拦在写之前,重试同构,永久停摆。 - /// - decision audit 判据的过滤器只到 (gddId, version),比写侧幂等键少一个 - /// responseId,就是为了抓同版本不同 responseId 的冲突副本;旧冲突一旦被挤出窗口, - /// 收束闸就会错误放行。 - /// - /// 所以 fixture 把冲突副本放在填充**之前**(窗口之外),把正常记录放在填充之后。 - #[test] - fn approval_judgements_scan_the_whole_agent_db_not_just_the_tail_window() { - use std::io::Write; - - let root = std::env::temp_dir().join(format!( - "genarrative-plan-approval-fullscan-{}", - uuid::Uuid::new_v4().simple() - )); - init_local_game_project_at(&root, "project-test-approval", "approval full scan") - .expect("init project"); - let receipt = PlanGddApprovalV1 { - schema_version: PLAN_GDD_APPROVAL_SCHEMA_VERSION.to_string(), - project_id: "project-test-approval".to_string(), - gdd_id: "gdd-00000000-0000-4000-8000-000000000011".to_string(), - version: 1, - fingerprint: format!("sha256-serde-json-v2:{}", "6".repeat(64)), - pending_action_id: "action-0123456789abcdef01234568".to_string(), - action_fingerprint: "7".repeat(64), - approval_request_id: "gdd-approval-00000000-0000-4000-8000-000000000012".to_string(), - response_id: "gdd-response-00000000-0000-4000-8000-000000000013".to_string(), - decision_fingerprint: format!("sha256-serde-json-v2:{}", "8".repeat(64)), - source: PLAN_GDD_APPROVAL_SOURCE.to_string(), - run_profile: AGENT_RUNTIME_RUN_PROFILE_STANDARD.to_string(), - run_profile_binding_fingerprint: "9".repeat(64), - session_id: "session-approval-2".to_string(), - run_id: "run-planning-child-2".to_string(), - action: "approve".to_string(), - comment: None, - decided_at_utc: "2026-08-15T00:00:00.000Z".to_string(), - receipt_fingerprint: format!("sha256-serde-json-v2:{}", "a".repeat(64)), - }; - // 另一个 GDD:它下面会有两条 responseId 不同的 audit。写侧的幂等键带 responseId, - // 两条都能落盘;判据的过滤器不带,所以它必须把这一对认成冲突。 - let mut conflicted = receipt.clone(); - conflicted.gdd_id = "gdd-00000000-0000-4000-8000-000000000021".to_string(); - conflicted.response_id = "gdd-response-00000000-0000-4000-8000-000000000023".to_string(); - let mut stale = conflicted.clone(); - stale.response_id = "gdd-response-00000000-0000-4000-8000-000000000024".to_string(); - - crate::project::append_agent_db_plan_gdd_decision_if_missing( - &root, - plan_gdd_decision_audit_value(&stale).expect("build stale decision audit"), - ) - .expect("append stale decision audit"); - - // 填充把上面那条冲突副本挤出尾窗。走 append,不覆盖 init 已经写下的内容。 - let agent_db = root.join(".agent/agent.db"); - fs::create_dir_all(agent_db.parent().expect("agent db parent")).expect("agent db parent"); - let filler = (0..crate::project::AGENT_DB_MAX_BOUNDED_RECORDS + 64) - .map(|index| format!("{{\"recordType\":\"planning-fullscan-filler\",\"i\":{index}}}\n")) - .collect::(); - fs::OpenOptions::new() - .create(true) - .append(true) - .open(&agent_db) - .expect("open agent db fixture") - .write_all(filler.as_bytes()) - .expect("pad agent db past the bounded record window"); - - for decided in [&receipt, &conflicted] { - crate::project::append_agent_db_plan_gdd_decision_if_missing( - &root, - plan_gdd_decision_audit_value(decided).expect("build decision audit"), - ) - .expect("append decision audit"); - } - let observation = approval_observation(&receipt); - append_agent_db_terminal_observation_if_missing_for_action( - &root, - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - &receipt.run_id, - &receipt.pending_action_id, - serde_json::json!({ - "recordType": "agent.runtime.tool_observation", - "agentId": GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - "taskId": "task-planning-1", - "runId": receipt.run_id, - "actionId": receipt.pending_action_id, - "actionFingerprint": receipt.action_fingerprint, - "tool": PLAN_GDD_APPROVAL_TOOL, - "status": "ok", - "summary": observation.summary, - "decision": "approval", - }), - ) - .expect("append terminal observation"); - - // fixture 自检:尾窗必须真的截断,且冲突副本必须真的在窗口之外。少了这两条, - // 下面的断言在旧实现上也能过,用例就什么都没证明。 - let (window, window_truncated) = - crate::project::read_agent_db_records_bounded(&root, 16 * 1024 * 1024) - .expect("bounded read"); - assert!(window_truncated, "fixture 必须真的把尾窗撑到截断"); - assert!( - !window.iter().any(|record| record - .get("responseId") - .and_then(serde_json::Value::as_str) - == Some(stale.response_id.as_str())), - "冲突副本必须落在尾窗之外,否则证明不了判据扫的是全量" - ); - - assert!(plan_gdd_decision_audit_state_locked(&root, &receipt) - .expect("无冲突的 decision audit 必须直接成立")); - assert!( - plan_gdd_decision_audit_state_locked(&root, &conflicted).is_err(), - "滑出尾窗的同版本冲突 audit 仍然必须被抓到" - ); - assert!(approval_terminal_observation_exists_locked(&root, &receipt) - .expect("已落盘的 terminal observation 必须认得出来")); - - // 没写过就是没写过:这里必须是可信的 Ok(false),不能是 Err。首次审批走的正是 - // 这条路,在这里 fail closed 会把 append_agent_db_terminal_observation_... - // 拦在后面,重试同构,审批永久停在 recoveryPending。 - let mut absent = receipt.clone(); - absent.run_id = "run-planning-child-absent".to_string(); - assert!(!approval_terminal_observation_exists_locked(&root, &absent) - .expect("首次审批探针不得因为扫描范围报错")); - - let _ = fs::remove_dir_all(root); - } - - /// 祖先绑定坏掉不能让一个跟立项策划无关的 supervisor run 被判成「策划根身份不明」。 - /// - /// `plan_root_completion_identity_at` 读绑定走的是会遍历完整父链的入口,而识别 - /// Fast GDD 只看 run 自己那条记录的 source/profile。链走排在识别前面,于是任何 - /// 祖先绑定的问题都先变成 Err,再被完成门统一翻成 needs-reconciliation——扣在一个 - /// 下一行就会被判为「不是策划根」的 run 头上,让它再也收束不了。 - /// - /// 真正的策划根没有父(`validate_project_supervisor_plan_root_binding_at` 强制 - /// parent 必须为空),所以链走对它本来就是空转;会被链走误伤的只可能是非策划 run。 - #[test] - fn broken_ancestor_binding_must_not_make_a_non_plan_supervisor_run_a_plan_root_suspect() { - let root = std::env::temp_dir().join(format!( - "genarrative-plan-root-identity-{}", - uuid::Uuid::new_v4().simple() - )); - init_local_game_project_at(&root, "project-test-plan-root", "plan root identity") - .expect("init project"); - - let parent_run_id = "p4-isolated-join-parent"; - let child_run_id = "p4-isolated-join-child"; - bind_game_creator_agent_runtime_run_profile_at( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - parent_run_id, - AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE, - Some(AGENT_RUNTIME_RUN_PROFILE_STANDARD), - None, - ) - .expect("bind ancestor run"); - bind_game_creator_agent_runtime_run_profile_at( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - child_run_id, - AGENT_RUNTIME_ISOLATED_JOIN_SOURCE, - Some(AGENT_RUNTIME_RUN_PROFILE_STANDARD), - Some(&AgentRuntimeTaskLink { - parent_agent_id: Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string()), - parent_run_id: Some(parent_run_id.to_string()), - delegation_id: None, - }), - ) - .expect("bind isolated-join child run"); - - // 破坏祖先:子记录仍带着 parentBindingFingerprint,父绑定却没了,正好命中 - // 父链遍历里的「父绑定缺失」。子 run 自己那条记录始终是完好可读的。 - fs::remove_file(game_creator_agent_runtime_run_profile_binding_path( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - parent_run_id, - )) - .expect("remove ancestor binding"); - - let blocker = plan_gdd_typed_completion_blocker_at_locked( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - child_run_id, - ); - assert!( - blocker.is_none(), - "isolated-join run 的 source 不是立项策划,完成门不该拦它,更不该说它策划根身份不明:{:?}", - blocker.map(|blocker| blocker.kind) - ); - let _ = fs::remove_dir_all(root); - } - - /// 同一形状的第二处:验收前置门。它比完成门更容易被踩到——`agent.run_status` - /// 每次 status observation 都会重跑这道门(见 `runtime_tools/run_status.rs` 的 - /// 「Re-run the locked gate on every plan-root status observation」),而那条路径 - /// 上没有任何上游守卫先把带父链的 run 挡掉,适用性完全交给门自己判。所以门必须 - /// 先说得出「这不是策划根」,才轮到校验祖先链。 - #[test] - fn broken_ancestor_binding_must_not_fail_the_acceptance_gate_for_a_non_plan_supervisor_run() { - let root = std::env::temp_dir().join(format!( - "genarrative-plan-acceptance-gate-identity-{}", - uuid::Uuid::new_v4().simple() - )); - init_local_game_project_at( - &root, - "project-test-acceptance-gate", - "acceptance gate identity", - ) - .expect("init project"); - - let parent_run_id = "acceptance-gate-isolated-join-parent"; - let child_run_id = "acceptance-gate-isolated-join-child"; - bind_game_creator_agent_runtime_run_profile_at( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - parent_run_id, - AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE, - Some(AGENT_RUNTIME_RUN_PROFILE_STANDARD), - None, - ) - .expect("bind ancestor run"); - bind_game_creator_agent_runtime_run_profile_at( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - child_run_id, - AGENT_RUNTIME_ISOLATED_JOIN_SOURCE, - Some(AGENT_RUNTIME_RUN_PROFILE_STANDARD), - Some(&AgentRuntimeTaskLink { - parent_agent_id: Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string()), - parent_run_id: Some(parent_run_id.to_string()), - delegation_id: None, - }), - ) - .expect("bind isolated-join child run"); - - fs::remove_file(game_creator_agent_runtime_run_profile_binding_path( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - parent_run_id, - )) - .expect("remove ancestor binding"); - - let outcome = ensure_plan_gdd_approval_pending_after_acceptance_locked( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - child_run_id, - ); - assert!( - matches!(outcome, Ok(PlanGddAcceptanceGateOutcome::NotApplicable)), - "isolated-join run 的 source 不是立项策划,验收前置门只能判 NotApplicable,\ - 不能因为别人的祖先链坏掉就让整个 agent.run_status 失败:{:?}", - outcome.map(|outcome| format!("{outcome:?}")) - ); - let _ = fs::remove_dir_all(root); - } -} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_coordinator.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_coordinator.rs deleted file mode 100644 index e47c24f67..000000000 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_coordinator.rs +++ /dev/null @@ -1,920 +0,0 @@ -use super::*; - -use uuid::Uuid; - -const PLAN_OPTION_A_PREFIX: char = 'A'; -const PLAN_OPTION_B_PREFIX: char = 'B'; -const PLAN_OPTION_PROTOTYPE_VALIDATION: &str = "需要原型验证"; -/// 策划决策卡恒为 A / B /「需要原型验证」三项,不是通用 `user.input_request` 协议的 -/// 2-3 个。role brief 早先照通用常量写成「2~3 个」,与本文件的硬校验和 brief 自己 -/// 下文的「固定提供三个选项」三方打架;模型照前者吐两项,整封信封在出卡时被拒、 -/// 回灌重试,白烧一个未推进回合,丢掉的还恰好是用户产生 `prototype_pending` 的唯一 -/// 入口。`project_planning_role_brief_states_the_parser_wire_shape_verbatim` 钉住 -/// brief 与这里同源。 -pub(crate) const PLAN_CLARIFICATION_OPTION_COUNT: usize = 3; -const PLAN_QUESTION_PREFIX: &str = "当前要决定:"; - -#[derive(Clone, Debug, Eq, PartialEq)] -struct PlanClarificationDecisionProjection { - decision: PlanDecisionSummary, - prototype_validation_item: Option, - applied_answer: PlanAppliedAnswer, -} - -fn plan_coordinator_error(kind: &str, detail: impl AsRef) -> String { - format!("{kind}: {}", detail.as_ref()) -} - -fn validate_plan_continuation_parent<'a>( - latest_delegation_id: &str, - delivery: &'a StaticDelegateDeliveryRecord, -) -> Result<&'a str, String> { - let original_id = delivery.repair_of_delegation_id.as_deref().ok_or_else(|| { - plan_coordinator_error( - "PLAN_ACTIVE_RUN_EXISTS", - "已有 planning session 时不能创建第二条根 delegation", - ) - })?; - if latest_delegation_id != original_id { - return Err(plan_coordinator_error( - "PLAN_NEEDS_RECONCILIATION", - "planning continuation 必须直接继承当前 session 的 latest delegation", - )); - } - Ok(original_id) -} - -fn plan_session_successor_base(previous: &PlanSessionV1) -> Result { - let mut next = previous.clone(); - next.session_revision = previous.session_revision.checked_add(1).ok_or_else(|| { - plan_coordinator_error("PLAN_SESSION_CAS_CONFLICT", "sessionRevision 溢出") - })?; - next.previous_fingerprint = Some(previous.session_fingerprint.clone()); - next.updated_at_utc = current_plan_timestamp_utc(); - Ok(next) -} - -fn finalize_plan_session_successor( - previous: &PlanSessionV1, - mut next: PlanSessionV1, -) -> Result { - next.session_fingerprint = format!("sha256-serde-json-v2:{}", "0".repeat(64)); - next.session_fingerprint = - plan_session_fingerprint(&next).map_err(|error| error.to_string())?; - validate_plan_session_successor(previous, &next).map_err(|error| error.to_string())?; - Ok(next) -} - -fn exact_plan_child_identity_at( - root: &Path, - task: &AgentRuntimeTaskRecord, -) -> Result, String> { - if task.agent_id != GAME_CREATOR_PROJECT_PLANNING_AGENT_ID { - return Ok(None); - } - if task.source != "agent-delegate" - || task.run_profile != AGENT_RUNTIME_RUN_PROFILE_STANDARD - || task.parent_agent_id.as_deref() != Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID) - { - return Err(plan_coordinator_error( - "PLAN_SOURCE_PROFILE_MISMATCH", - "project-planning task 不是 Supervisor 的 agent-delegate/standard 子 Run", - )); - } - let parent_run_id = task - .parent_run_id - .as_deref() - .filter(|value| !value.trim().is_empty()) - .ok_or_else(|| { - plan_coordinator_error( - "PLAN_SOURCE_PROFILE_MISMATCH", - "planning child 缺少 parentRunId", - ) - })?; - let delegation_id = task - .delegation_id - .as_deref() - .filter(|value| !value.trim().is_empty()) - .ok_or_else(|| { - plan_coordinator_error( - "PLAN_SOURCE_PROFILE_MISMATCH", - "planning child 缺少 delegationId", - ) - })?; - validate_project_supervisor_plan_root_binding_at( - root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - parent_run_id, - )?; - let binding = validate_project_planning_child_binding_at(root, &task.agent_id, &task.run_id)?; - if binding.binding_fingerprint != task.run_profile_binding_fingerprint - || binding.root_agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID - || binding.root_run_id != parent_run_id - || binding.parent_agent_id.as_deref() != Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID) - || binding.parent_run_id.as_deref() != Some(parent_run_id) - { - return Err(plan_coordinator_error( - "PLAN_SOURCE_PROFILE_MISMATCH", - "planning child task 与 durable Run Profile binding 不一致", - )); - } - let delivery = read_static_delegate_delivery_at(root, delegation_id)?.ok_or_else(|| { - plan_coordinator_error("PLAN_NEEDS_RECONCILIATION", "planning delivery 缺失") - })?; - if delivery.parent_agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID - || delivery.parent_run_id != parent_run_id - || delivery.delegation_id != delegation_id - || delivery.target_agent_id != GAME_CREATOR_PROJECT_PLANNING_AGENT_ID - || delivery.target_session_id != task.session_id - || delivery.target_run_id != task.run_id - { - return Err(plan_coordinator_error( - "PLAN_NEEDS_RECONCILIATION", - "planning child task 与 static delivery 身份不一致", - )); - } - Ok(Some((binding, delivery))) -} - -/// 剥掉 header 的 `第{round}轮·` 前缀,返回其后的正文。 -/// -/// 轮号本身由 Runtime 从委派谱系派生,模型只是照着任务正文抄;这里逐字核对它,写错就 -/// 拒收——否则卡片标题会和 `GddApprovalCard` 那个「第 N 轮 / 共 3 轮」自相矛盾。 -fn plan_header_body(header: &str, round: u32) -> Option<&str> { - let rest = header.trim_start().strip_prefix('第')?.trim_start(); - let digits = rest - .chars() - .take_while(char::is_ascii_digit) - .collect::(); - if digits.parse::().ok()? != round { - return None; - } - let rest = rest[digits.len()..].trim_start().strip_prefix('轮')?.trim(); - // 原型模板写作 `第 N 轮 · 当前要决定:…`,中文语境下模型高频吐出 `·`/`:`/`:`/`-` - // 几种分隔符;不在集合里的后果是整封信封被拒、白吃一个未推进回合。 - let rest = rest.strip_prefix(&PLAN_OPTION_LABEL_DELIMITERS[..])?; - Some(rest.trim_start()) -} - -/// 决定台账的 `topic` 取自 header。 -/// -/// 原型(`design_agent.py:1841`)直接把整条 header 当 topic;这里只是再剥掉 `第N轮·` 和 -/// 「当前要决定:」两层固定前缀,落进台账的是主题本身。 -fn plan_question_topic( - question: &AgentRuntimeUserInputQuestion, - round: u32, -) -> Result { - let remainder = plan_header_body(&question.header, round) - .and_then(|body| body.strip_prefix(PLAN_QUESTION_PREFIX)) - .ok_or_else(|| { - plan_coordinator_error( - "PLAN_INVALID_CLARIFICATION", - format!("plan header 必须形如“第{round}轮·当前要决定:<主题>”"), - ) - })?; - let topic = remainder - .split(['。', ';', ',', '?', '?']) - .next() - .unwrap_or_default(); - normalize_plan_text(topic, "plan question topic", 1, 80).map_err(|error| error.to_string()) -} - -/// 全角冒号必须在集合里:prompt 全中文,模型在中文语境下写 `A:方案名` 是高频输出, -/// 而不在集合里的后果是整个信封被拒、回灌重试,白吃一个未推进回合预算。 -/// -/// 集合按原型的 `_OPTION_A_PATTERN`(`design_agent.py`,`^A\s*[·•・::..\-]`)拉齐。 -/// 本仓库先前只收 4 个,是同一条理由下更窄的一份——模型写 `A•路线布防` 或 -/// `A. 路线布防` 就会整封被拒。`plan_header_body` 解析 `第N轮·` 时复用这同一个集合, -/// 两处一起放宽;只放宽、不收紧,既有能过的 label 逐字照过。 -const PLAN_OPTION_LABEL_DELIMITERS: [char; 8] = ['·', '•', '・', ':', ':', '.', '.', '-']; - -fn plan_option_label_has_prefix(label: &str, prefix: char) -> bool { - let Some(remainder) = label.strip_prefix(prefix).map(str::trim_start) else { - return false; - }; - let Some(delimiter) = remainder.chars().next() else { - return false; - }; - if !PLAN_OPTION_LABEL_DELIMITERS.contains(&delimiter) { - return false; - } - !remainder[delimiter.len_utf8()..].trim().is_empty() -} - -/// 这里直接比原串,靠的是一条跨模块不变量:`user.input_request` 的严格解析 -/// (`user_input.rs` 的 `normalize_single_line_user_input_text`)已经把 label `trim` -/// 过、并拒掉了含换行的 label;而用户回答这边走 `normalize_plan_text`(CRLF 归一 + -/// `trim`)。两条路落到同一形态,所以此处不必、也不该再规范化一次。 -/// -/// 一旦解析侧不再 trim,两把尺子就会错开:模型吐出带尾随空白的 label 时,用户的点选 -/// 会因为「trim 过的答案 != 没 trim 的 label」掉进自由填写分支,台账把点选记成 -/// `user_freeform`。两边 state 同为 `confirmed`,状态机看不出异常——被污染的恰好是第 -/// 23.9 节要立起来的那个字段。`planning_clarification_option_pick_survives_untrimmed_label` -/// 钉的就是这条不变量。 -#[cfg(test)] -mod option_label_delimiter_tests { - use super::*; - - /// 分隔符集合只能放宽、不能收窄,且必须覆盖原型 `_OPTION_A_PATTERN` 的那一份。 - /// - /// 锁的是「集合里每一个都被接受」这条不变量,不是某个具体标点:少一个的后果不是 - /// 「模型换个写法」,而是一封完全合法的信封被判形状错误、回灌重试,白吃一个未推进 - /// 回合——`planning_clarification_accepts_fullwidth_colon_option_labels` 记的就是 - /// 全角冒号那一次。 - /// 逐字来自原型 `design_agent.py` 的 `^A\s*[·•・::..\-]`。这里**不能**改成遍历 - /// `PLAN_OPTION_LABEL_DELIMITERS` 本身——那样从集合里删掉一个,循环也跟着少测一个, - /// 断言恒真。 - const PROTOTYPE_DELIMITERS: [char; 8] = ['·', '•', '・', ':', ':', '.', '.', '-']; - - #[test] - fn every_delimiter_in_the_set_is_accepted_on_both_option_prefixes() { - for delimiter in PROTOTYPE_DELIMITERS { - for prefix in [PLAN_OPTION_A_PREFIX, PLAN_OPTION_B_PREFIX] { - let label = format!("{prefix}{delimiter}方案短语"); - assert!( - plan_option_label_has_prefix(&label, prefix), - "分隔符 {delimiter:?} 被拒:{label}" - ); - let spaced = format!("{prefix} {delimiter} 方案短语"); - assert!( - plan_option_label_has_prefix(&spaced, prefix), - "带空格写法被拒:{spaced}" - ); - } - } - assert!( - !plan_option_label_has_prefix("A方案短语", PLAN_OPTION_A_PREFIX), - "没有分隔符不能算合法 A 选项,否则 A/B 与自由文本会混" - ); - } -} - -#[cfg(test)] -mod planning_continuation_parent_tests { - use super::*; - - fn delivery(repair_of_delegation_id: Option<&str>) -> StaticDelegateDeliveryRecord { - new_static_delegate_delivery_with_contract( - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - "supervisor-session", - "supervisor-run", - "delegate-action", - "current-delivery", - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - "planning-session", - "planning-run", - &[], - &[], - repair_of_delegation_id, - ) - } - - #[test] - fn continuation_must_extend_the_session_cursor() { - let continuation = delivery(Some("older-delivery")); - let error = validate_plan_continuation_parent("current-delivery", &continuation) - .expect_err("older delivery must not become the current planning branch"); - assert!(error.contains("latest delegation")); - } - - #[test] - fn continuation_accepts_the_current_session_cursor() { - let continuation = delivery(Some("current-delivery")); - assert_eq!( - validate_plan_continuation_parent("current-delivery", &continuation) - .expect("current delivery is a valid continuation"), - "current-delivery" - ); - } -} - -fn plan_option_label_matches_answer(label: &str, normalized_answer: &str) -> bool { - label == normalized_answer -} - -pub(crate) fn validate_exact_plan_clarification_question( - questions: &[AgentRuntimeUserInputQuestion], - round: u32, -) -> Result<(), String> { - if !(1..=3).contains(&round) || questions.len() != 1 { - return Err(plan_coordinator_error( - "PLAN_INVALID_CLARIFICATION", - "Fast GDD 每轮必须且只能包含一题,轮次必须在 1..=3", - )); - } - let question = &questions[0]; - if question.id.len() > 32 - || !question.id.is_ascii() - || question.id.replace('_', "-") == "initial-request" - { - return Err(plan_coordinator_error( - "PLAN_INVALID_CLARIFICATION", - "plan questionId 必须是最多 32 个 ASCII 字符且不能映射为 initial-request", - )); - } - let valid_shape = question.options.len() == PLAN_CLARIFICATION_OPTION_COUNT - && plan_option_label_has_prefix(&question.options[0].label, PLAN_OPTION_A_PREFIX) - && plan_option_label_has_prefix(&question.options[1].label, PLAN_OPTION_B_PREFIX) - && question.options[2].label == PLAN_OPTION_PROTOTYPE_VALIDATION; - if !valid_shape { - return Err(plan_coordinator_error( - "PLAN_INVALID_CLARIFICATION", - "plan question 必须恰好提供 A、B、需要原型验证三个选项", - )); - } - // header 的定形连同轮号一起在这里兜底:`plan_question_topic` 要求它形如 - // `第{round}轮·当前要决定:<主题>`,并把主题本身取出来给决定台账。 - plan_question_topic(question, round)?; - Ok(()) -} - -fn build_plan_clarification_decision_projection( - root_run_id: &str, - delegation_id: &str, - continuation_delegation_id: &str, - round: u32, - answer: &PlanStaticDelegateAnsweredInput, -) -> Result { - validate_exact_plan_clarification_question(std::slice::from_ref(&answer.question), round)?; - let normalized_answer = normalize_plan_text(&answer.answer, "plan answer", 1, 400) - .map_err(|error| error.to_string())?; - let question = &answer.question; - let topic = plan_question_topic(question, round)?; - let decision_id = question.id.replace('_', "-"); - let (state, answer_source) = - if plan_option_label_matches_answer(&question.options[0].label, &normalized_answer) - || plan_option_label_matches_answer(&question.options[1].label, &normalized_answer) - { - ("confirmed", "user_option") - } else if normalized_answer == PLAN_OPTION_PROTOTYPE_VALIDATION { - ("prototype_pending", "user_option") - } else { - ("confirmed", "user_freeform") - }; - let decision = PlanDecisionSummary { - id: decision_id.clone(), - topic: topic.clone(), - state: state.to_string(), - answer_source: answer_source.to_string(), - round, - answer_summary: normalized_answer.clone(), - }; - let prototype_validation_item = - (state == "prototype_pending").then(|| PlanPrototypeValidationItem { - id: decision_id.clone(), - question: format!("验证“{topic}”是否成立"), - micro_prototype: format!("用 30~90 分钟制作只覆盖“{topic}”的最小可交互原型"), - observation: format!("记录玩家在无额外提示时的行为与对“{topic}”的口头解释"), - pass_criterion: "至少 3 次独立试玩中有 2 次出现预期行为,且测试者能说明对应取舍" - .to_string(), - }); - let expected_continuation = derive_plan_continuation_delegation_id( - root_run_id, - delegation_id, - &answer.questions_sha256, - &answer.answers_sha256, - ) - .map_err(|error| error.to_string())?; - if expected_continuation != continuation_delegation_id { - return Err(plan_coordinator_error( - "PLAN_ANSWER_IDENTITY_CONFLICT", - "continuation deliveryId 与问答确定性派生值不一致", - )); - } - Ok(PlanClarificationDecisionProjection { - applied_answer: PlanAppliedAnswer { - delegation_id: delegation_id.to_string(), - continuation_delegation_id: continuation_delegation_id.to_string(), - request_id: answer.request_id.clone(), - question_id: question.id.clone(), - response_id: answer.response_id.clone(), - questions_sha256: answer.questions_sha256.clone(), - answers_sha256: answer.answers_sha256.clone(), - decision_id, - round, - }, - decision, - prototype_validation_item, - }) -} - -fn apply_plan_clarification_projection( - session: &mut PlanSessionV1, - projection: PlanClarificationDecisionProjection, -) -> Result<(), String> { - if let Some(existing) = session - .decisions_summary - .iter() - .find(|decision| decision.id == projection.decision.id) - { - if existing != &projection.decision { - return Err(plan_coordinator_error( - "PLAN_ANSWER_IDENTITY_CONFLICT", - "同 decisionId 已存在不同 decisionsSummary", - )); - } - } else { - session.decisions_summary.push(projection.decision.clone()); - } - match projection.prototype_validation_item { - Some(item) => { - if let Some(existing) = session - .prototype_validation_items - .iter() - .find(|existing| existing.id == item.id) - { - if existing != &item { - return Err(plan_coordinator_error( - "PLAN_ANSWER_IDENTITY_CONFLICT", - "同 decisionId 已存在不同 prototypeValidationItem", - )); - } - } else { - session.prototype_validation_items.push(item); - } - } - None => { - if session - .prototype_validation_items - .iter() - .any(|item| item.id == projection.applied_answer.decision_id) - { - return Err(plan_coordinator_error( - "PLAN_ANSWER_IDENTITY_CONFLICT", - "非 prototype_pending 回答携带了同 ID prototypeValidationItem", - )); - } - } - } - if let Some(existing) = session - .applied_answers - .iter() - .find(|item| item.round == projection.applied_answer.round) - { - if existing != &projection.applied_answer { - return Err(plan_coordinator_error( - "PLAN_ANSWER_IDENTITY_CONFLICT", - "同一 clarification round 已绑定不同 appliedAnswer", - )); - } - } else { - session.applied_answers.push(projection.applied_answer); - } - Ok(()) -} - -fn ensure_existing_plan_session_identity( - session: &PlanSessionV1, - task: &AgentRuntimeTaskRecord, - delivery: &StaticDelegateDeliveryRecord, -) -> Result<(), String> { - if session.agent_id != GAME_CREATOR_PROJECT_PLANNING_AGENT_ID - || session.source != "agent-delegate" - || session.run_profile != AGENT_RUNTIME_RUN_PROFILE_STANDARD - || session.root_agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID - || session.root_run_id != task.parent_run_id.clone().unwrap_or_default() - || session.session_id != task.session_id - || delivery.parent_run_id != session.root_run_id - { - return Err(plan_coordinator_error( - "PLAN_ACTIVE_RUN_EXISTS", - "已有 planning session 属于不同 project/root/session lineage", - )); - } - Ok(()) -} - -/// Recovery may encounter an already-started planning request with frozen -/// retry/handoff bytes. In that case folding is intentionally deferred and -/// the coordinator must only prove that this exact run was projected earlier. -/// A false result is not permission to start: callers must invoke the full -/// `ensure_*_locked` path, which folds prior usage before deriving a successor. -pub(crate) fn plan_session_already_projects_planning_child_task_at_locked( - root: &Path, - task: &AgentRuntimeTaskRecord, - project_lock: &ProjectWriteLock, -) -> Result { - if !project_lock.guards_project_root(root)? { - return Err(plan_coordinator_error( - "PLAN_NEEDS_RECONCILIATION", - "planning child session 恢复检查缺少当前项目写锁", - )); - } - let Some((binding, delivery)) = exact_plan_child_identity_at(root, task)? else { - return Ok(false); - }; - let Some(session) = - read_plan_session_with_recovery_locked(root).map_err(|error| error.to_string())? - else { - return Ok(false); - }; - ensure_existing_plan_session_identity(&session, task, &delivery)?; - Ok( - session.active_run_id.as_deref() == Some(task.run_id.as_str()) - && session.last_run_id == task.run_id - && session.latest_delegation_id == delivery.delegation_id - && session.run_profile_binding_fingerprint == binding.binding_fingerprint - && session.phase == "collecting", - ) -} - -/// Acquire the cross-process project write lock before projecting a planning -/// child. Callers that already hold the lock must use the `_locked` entry and -/// pass their guard instead of recursively acquiring `.agent/project.lock`. -#[cfg(test)] -pub(crate) fn ensure_plan_session_for_planning_child_task_at( - root: &Path, - task: &AgentRuntimeTaskRecord, -) -> Result { - if task.agent_id != GAME_CREATOR_PROJECT_PLANNING_AGENT_ID { - return Ok(false); - } - let project_lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( - root, - "planning.child-session.project", - )?; - ensure_plan_session_for_planning_child_task_at_locked(root, task, &project_lock) -} - -/// Project the exact planning child task into the durable plan session before -/// that task is eligible to issue its first Provider request. The guard is an -/// explicit proof that the caller owns the cross-process project write lock. -pub(crate) fn ensure_plan_session_for_planning_child_task_at_locked( - root: &Path, - task: &AgentRuntimeTaskRecord, - project_lock: &ProjectWriteLock, -) -> Result { - if !project_lock.guards_project_root(root)? { - return Err(plan_coordinator_error( - "PLAN_NEEDS_RECONCILIATION", - "planning child session 投影缺少当前项目写锁", - )); - } - let Some((binding, delivery)) = exact_plan_child_identity_at(root, task)? else { - return Ok(false); - }; - let project_id = game_creator_agent_runtime_context_project_id(root)?; - // A continuation/recovery must observe all terminal Provider intervals - // before deriving its successor session. The fold owns no second lock; - // it advances the same session while this caller retains `project_lock`. - // On an initial child this is a no-op (`NoSession`), and root usage is - // folded immediately after revision 1 has established the lineage below. - fold_plan_provider_usage_before_new_request_at_locked(root, None)?; - let current = - read_plan_session_with_recovery_locked(root).map_err(|error| error.to_string())?; - let Some(previous) = current else { - if delivery.repair_of_delegation_id.is_some() { - return Err(plan_coordinator_error( - "PLAN_SESSION_RECOVERY_REQUIRED", - "continuation planning child 存在但 plan session 缺失", - )); - } - let root_task = read_latest_game_creator_agent_runtime_task_by_run_id( - root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &delivery.parent_run_id, - )? - .ok_or_else(|| { - plan_coordinator_error("PLAN_SESSION_RECOVERY_REQUIRED", "plan root task 缺失") - })?; - let initial_request = normalize_plan_text( - &root_task.task, - "plan session initial request", - 1, - PLAN_INITIAL_REQUEST_MAX_CHARS, - ) - .map_err(|error| error.to_string())?; - let mut session = PlanSessionV1 { - schema_version: PLAN_SESSION_SCHEMA_VERSION.to_string(), - project_id, - gdd_id: format!("gdd-{}", Uuid::new_v4().hyphenated()), - session_revision: 1, - previous_fingerprint: None, - session_fingerprint: format!("sha256-serde-json-v2:{}", "0".repeat(64)), - agent_id: GAME_CREATOR_PROJECT_PLANNING_AGENT_ID.to_string(), - source: "agent-delegate".to_string(), - run_profile: AGENT_RUNTIME_RUN_PROFILE_STANDARD.to_string(), - run_profile_binding_fingerprint: binding.binding_fingerprint, - root_agent_id: GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(), - root_run_id: delivery.parent_run_id, - latest_delegation_id: delivery.delegation_id, - session_id: task.session_id.clone(), - active_run_id: Some(task.run_id.clone()), - last_run_id: task.run_id.clone(), - phase: "collecting".to_string(), - accumulated_agent_millis: 0, - applied_steer_cursor: 0, - decisions_summary: vec![PlanDecisionSummary { - id: "initial-request".to_string(), - topic: "初始需求".to_string(), - state: "confirmed".to_string(), - answer_source: "user_freeform".to_string(), - round: 0, - answer_summary: initial_request, - }], - prototype_validation_items: Vec::new(), - applied_answers: Vec::new(), - latest_submitted_ref: None, - last_decision_ref: None, - updated_at_utc: current_plan_timestamp_utc(), - }; - session.session_fingerprint = - plan_session_fingerprint(&session).map_err(|error| error.to_string())?; - write_plan_session_atomic_locked(root, &session).map_err(|error| error.to_string())?; - // Root plan Provider facts predate the first planning-child session. - // Establish revision 1 first, then fold those immutable facts under - // the same project lock so the child never starts from budget zero. - fold_plan_provider_usage_before_new_request_at_locked(root, None)?; - return Ok(true); - }; - if previous.project_id != project_id { - return Err(plan_coordinator_error( - "PLAN_ACTIVE_RUN_EXISTS", - "已有 planning session 的 projectId 与当前项目不一致", - )); - } - ensure_existing_plan_session_identity(&previous, task, &delivery)?; - if previous.active_run_id.as_deref() == Some(task.run_id.as_str()) - && previous.last_run_id == task.run_id - && previous.latest_delegation_id == delivery.delegation_id - && previous.run_profile_binding_fingerprint == binding.binding_fingerprint - && previous.phase == "collecting" - { - return Ok(true); - } - // `latest_delegation_id` is the planning session's single continuation - // cursor. A new child must extend that cursor directly; otherwise a - // Supervisor can select an older claimed delivery and make an unrelated - // branch look like the current session. Keep this check here, after the - // exact-task replay fast path above, so replaying an already projected - // child remains idempotent. - let original_id = validate_plan_continuation_parent(&previous.latest_delegation_id, &delivery)?; - let deliveries = list_static_delegate_deliveries_at(root)?; - if static_delegate_lineage_contains_unknown_contract_status( - &deliveries, - &delivery.delegation_id, - )? { - return Err(plan_coordinator_error( - "PLAN_NEEDS_RECONCILIATION", - "planning lineage 含未知 contractStatus", - )); - } - let original = deliveries - .iter() - .find(|candidate| candidate.delegation_id == original_id) - .ok_or_else(|| { - plan_coordinator_error("PLAN_NEEDS_RECONCILIATION", "continuation 原 delivery 缺失") - })?; - let (_, clarification_round) = - static_delegate_lineage_counters(&deliveries, &delivery.delegation_id); - if clarification_round == u32::MAX || clarification_round > 3 { - return Err(plan_coordinator_error( - "PLAN_NEEDS_RECONCILIATION", - "continuation clarification round 无效", - )); - } - let mut next = plan_session_successor_base(&previous)?; - if original.structured_result.as_ref().is_some_and(|result| { - result.contract_status == StaticDelegateContractStatus::NeedsUserInput - }) { - if clarification_round == 0 - || previous.applied_answers.len() as u32 + 1 != clarification_round - { - return Err(plan_coordinator_error( - "PLAN_NEEDS_RECONCILIATION", - "answer projection 与 lineage clarification round 不连续", - )); - } - let answered = read_answered_plan_static_delegate_user_input_at(root, original)?; - let projection = build_plan_clarification_decision_projection( - &previous.root_run_id, - &original.delegation_id, - &delivery.delegation_id, - clarification_round, - &answered, - )?; - apply_plan_clarification_projection(&mut next, projection)?; - } else if original.structured_result.as_ref().is_some_and(|result| { - result.contract_status == StaticDelegateContractStatus::UserRevisionRequested - }) { - validate_plan_session_for_clarification_round(&previous, clarification_round) - .map_err(|error| error.to_string())?; - } else { - // Quality repair starts a fresh clarification segment. Previously - // confirmed decisions stay authoritative, while transport bindings - // belong to the old segment and must not become a second round truth. - if clarification_round != 0 { - return Err(plan_coordinator_error( - "PLAN_NEEDS_RECONCILIATION", - "quality repair 必须把 clarification round 重置为 0", - )); - } - next.applied_answers.clear(); - } - next.run_profile_binding_fingerprint = binding.binding_fingerprint; - next.latest_delegation_id = delivery.delegation_id; - next.active_run_id = Some(task.run_id.clone()); - next.last_run_id = task.run_id.clone(); - next.phase = "collecting".to_string(); - let next = finalize_plan_session_successor(&previous, next)?; - write_plan_session_atomic_locked(root, &next).map_err(|error| error.to_string())?; - Ok(true) -} - -/// Project the planning session while the caller already owns the project -/// lock. M1C-2b planning clarification paths that also need a Runtime lane -/// must acquire this project lock before the session/execution lane; this is -/// deliberately not a repository-wide lock-order claim. -pub(crate) fn project_plan_session_awaiting_user_input_at_locked( - root: &Path, - runtime: &AgentRuntimeState, - delivery: &StaticDelegateDeliveryRecord, - project_lock: &ProjectWriteLock, -) -> Result<(), String> { - if delivery.target_agent_id != GAME_CREATOR_PROJECT_PLANNING_AGENT_ID { - return Ok(()); - } - if !project_lock.guards_project_root(root)? { - return Err(plan_coordinator_error( - "PLAN_NEEDS_RECONCILIATION", - "planning clarification 投影缺少当前项目写锁", - )); - } - validate_project_supervisor_plan_root_binding_at(root, &runtime.agent_id, &runtime.run_id)?; - fold_plan_provider_usage_before_new_request_at_locked(root, None)?; - let current_delivery = read_static_delegate_delivery_at(root, &delivery.delegation_id)? - .ok_or_else(|| { - plan_coordinator_error("PLAN_NEEDS_RECONCILIATION", "planning delivery 缺失") - })?; - if current_delivery != *delivery || current_delivery.clarification_answers_sha256.is_some() { - return Err(plan_coordinator_error( - "PLAN_NEEDS_RECONCILIATION", - "展示 planning 澄清卡前 delivery 已漂移或已绑定答案", - )); - } - let deliveries = list_static_delegate_deliveries_at(root)?; - let (_, current_round) = - static_delegate_lineage_counters(&deliveries, ¤t_delivery.delegation_id); - if current_round == u32::MAX || current_round >= 3 { - return Err(plan_coordinator_error( - "PLAN_CLARIFICATION_LIMIT_REACHED", - "Fast GDD 已完成三轮澄清,不能展示第四张卡", - )); - } - let result = current_delivery.structured_result.as_ref().ok_or_else(|| { - plan_coordinator_error("PLAN_NEEDS_RECONCILIATION", "planning delivery 缺少问题") - })?; - validate_exact_plan_clarification_question(&result.user_input_questions, current_round + 1)?; - let previous = read_plan_session_with_recovery_locked(root) - .map_err(|error| error.to_string())? - .ok_or_else(|| { - plan_coordinator_error("PLAN_SESSION_RECOVERY_REQUIRED", "plan session 缺失") - })?; - if previous.root_run_id != runtime.run_id - || previous.session_id != current_delivery.target_session_id - || previous.latest_delegation_id != current_delivery.delegation_id - || previous.last_run_id != current_delivery.target_run_id - { - return Err(plan_coordinator_error( - "PLAN_NEEDS_RECONCILIATION", - "awaiting_user_input 投影与 session/delivery lineage 不一致", - )); - } - validate_plan_session_for_clarification_round(&previous, current_round) - .map_err(|error| error.to_string())?; - if previous.phase == "awaiting_user_input" && previous.active_run_id.is_none() { - return Ok(()); - } - if previous.phase != "collecting" - || previous.active_run_id.as_deref() != Some(current_delivery.target_run_id.as_str()) - { - return Err(plan_coordinator_error( - "PLAN_NEEDS_RECONCILIATION", - "planning child 终态前 session 不在对应 collecting/active 状态", - )); - } - let mut next = plan_session_successor_base(&previous)?; - next.active_run_id = None; - next.last_run_id = current_delivery.target_run_id; - next.latest_delegation_id = current_delivery.delegation_id; - next.phase = "awaiting_user_input".to_string(); - let next = finalize_plan_session_successor(&previous, next)?; - write_plan_session_atomic_locked(root, &next).map_err(|error| error.to_string()) -} - -pub(crate) fn validate_plan_clarification_answer_for_pending_at( - root: &Path, - pending: &AgentRuntimePendingToolAction, - questions: &[AgentRuntimeUserInputQuestion], - answers: &BTreeMap, -) -> Result<(), String> { - if !plan_clarification_pending_requires_project_lock_at(root, pending)? { - return Ok(()); - } - let lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( - root, - "planning.clarification.answer", - )?; - validate_plan_clarification_answer_for_pending_at_locked( - root, pending, questions, answers, &lock, - ) -} - -pub(crate) fn validate_plan_clarification_answer_for_pending_at_locked( - root: &Path, - pending: &AgentRuntimePendingToolAction, - questions: &[AgentRuntimeUserInputQuestion], - answers: &BTreeMap, - project_lock: &ProjectWriteLock, -) -> Result<(), String> { - if !project_lock.guards_project_root(root)? { - return Err(plan_coordinator_error( - "PLAN_NEEDS_RECONCILIATION", - "planning answer 校验缺少当前项目写锁", - )); - } - validate_plan_clarification_answer_for_pending_at_unlocked(root, pending, questions, answers) -} - -/// Read-only routing probe for lock ordering. It deliberately does not read -/// or repair `session.json`: command and recovery entrypoints call this before -/// taking the Agent execution lock, then re-read the pending action after -/// acquiring project -> execution in that order. -pub(crate) fn plan_clarification_pending_requires_project_lock_at( - root: &Path, - pending: &AgentRuntimePendingToolAction, -) -> Result { - let Some(delegation_id) = agent_runtime_delegate_clarification_delegation_id(&pending.task) - else { - return Ok(false); - }; - let delivery = read_static_delegate_delivery_at(root, delegation_id)? - .ok_or_else(|| plan_coordinator_error("PLAN_NEEDS_RECONCILIATION", "澄清 delivery 缺失"))?; - Ok(delivery.target_agent_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID) -} - -fn validate_plan_clarification_answer_for_pending_at_unlocked( - root: &Path, - pending: &AgentRuntimePendingToolAction, - questions: &[AgentRuntimeUserInputQuestion], - answers: &BTreeMap, -) -> Result<(), String> { - let Some(delegation_id) = agent_runtime_delegate_clarification_delegation_id(&pending.task) - else { - return Ok(()); - }; - let delivery = read_static_delegate_delivery_at(root, delegation_id)? - .ok_or_else(|| plan_coordinator_error("PLAN_NEEDS_RECONCILIATION", "澄清 delivery 缺失"))?; - if delivery.target_agent_id != GAME_CREATOR_PROJECT_PLANNING_AGENT_ID { - return Ok(()); - } - validate_project_supervisor_plan_root_binding_at(root, &pending.agent_id, &pending.run_id)?; - if delivery.parent_agent_id != pending.agent_id - || delivery.parent_run_id != pending.run_id - || delivery.status != StaticDelegateDeliveryStatus::ClaimedByParent - || delivery.clarification_answers_sha256.is_some() - { - return Err(plan_coordinator_error( - "PLAN_NEEDS_RECONCILIATION", - "planning answer 与未回答的 claimed delivery 身份不一致", - )); - } - let deliveries = list_static_delegate_deliveries_at(root)?; - let (_, current_round) = static_delegate_lineage_counters(&deliveries, delegation_id); - if current_round == u32::MAX || current_round >= 3 { - return Err(plan_coordinator_error( - "PLAN_CLARIFICATION_LIMIT_REACHED", - "Fast GDD 已达三轮澄清上限", - )); - } - validate_exact_plan_clarification_question(questions, current_round + 1)?; - let question_id = &questions[0].id; - let answer = answers.get(question_id).ok_or_else(|| { - plan_coordinator_error( - "PLAN_INVALID_CLARIFICATION", - "planning answer 缺少唯一 questionId", - ) - })?; - normalize_plan_text(answer, "plan answer", 1, 400).map_err(|error| error.to_string())?; - // The user-input owner already holds the project write lock when it calls - // this precheck. Do not acquire the non-reentrant lock here; the answer - // bind is not a fresh Provider boundary and usage was folded when this - // pending card was projected. - let session = read_plan_session_with_recovery_locked(root) - .map_err(|error| error.to_string())? - .ok_or_else(|| { - plan_coordinator_error("PLAN_SESSION_RECOVERY_REQUIRED", "plan session 缺失") - })?; - if session.root_run_id != pending.run_id - || session.latest_delegation_id != delegation_id - || session.phase != "awaiting_user_input" - || session.active_run_id.is_some() - { - return Err(plan_coordinator_error( - "PLAN_NEEDS_RECONCILIATION", - "planning answer 的 session pre-wait anchor 不成立", - )); - } - validate_plan_session_for_clarification_round(&session, current_round) - .map_err(|error| error.to_string()) -} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_gdd_model.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_gdd_model.rs new file mode 100644 index 000000000..4ca7168cd --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_gdd_model.rs @@ -0,0 +1,1082 @@ +//! 策划 GDD 的内容模型与落盘工具:GDD 业务结构、字段校验、类型化指纹和 +//! `game/fast_gdd.md` 原子写。立项策划 V2 会话与 Fast GDD 交付物共用这一层。 + +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::fmt; +use std::fs::{self, File, OpenOptions}; +use std::io::{Read, Seek, SeekFrom, Write}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; +use uuid::{Uuid, Variant}; + +use crate::project::{normalize_relative_path, resolve_local_project_path}; + +pub(crate) const PLAN_INDEX_MAX_BYTES: usize = 256 * 1024; +pub(crate) const PLAN_FAST_GDD_PATH: &str = "game/fast_gdd.md"; +pub(crate) const PLAN_FAST_GDD_MAX_BYTES: usize = 128 * 1024; +/// 单条决定 answerSummary 的上限(`initial-request` 除外)。 +pub(crate) const PLAN_DECISION_ANSWER_SUMMARY_MAX_CHARS: usize = 800; + +/// `initial-request` 那条决定的 answerSummary 上限,也就是立项策划入口原始需求的上限。 +/// +/// 上游用 `sanitize_agent_runtime_text(task, AGENT_RUNTIME_TASK_MAX_CHARS)` 归一根 +/// task:4000 个 Unicode scalar 封顶,超长时再补一个省略号,真实上界因此是 4001。 +/// 这里早期写死 400,于是 401~4001 字的开场需求会让根 run、Goal Contract 与首跳 +/// 委派全部正常建立,直到策划子 Agent 的 task-start 才在 session 投影上硬失败 +/// (`phase=planning-session-projection-failed`);此时 session 从未创建,用同一根 +/// task 重试必然复现,用户只能重开一条链。直接绑定到上游常量,两边不会再漂开。 +pub(crate) const PLAN_INITIAL_REQUEST_MAX_CHARS: usize = + crate::agent::runtime_driver::AGENT_RUNTIME_TASK_MAX_CHARS + 1; + +/// `initial-request` 承载的是用户原话,长度上限与其余决定不同。 +fn decision_answer_summary_max_chars(decision_id: &str) -> usize { + if decision_id == "initial-request" { + PLAN_INITIAL_REQUEST_MAX_CHARS + } else { + PLAN_DECISION_ANSWER_SUMMARY_MAX_CHARS + } +} +static PLANNING_TEMP_NONCE: AtomicU64 = AtomicU64::new(1); + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct PlanningStorageError { + code: &'static str, + detail: String, +} + +impl PlanningStorageError { + pub(crate) fn new(code: &'static str, detail: impl Into) -> Self { + Self { + code, + detail: detail.into(), + } + } + + pub(crate) fn code(&self) -> &'static str { + self.code + } +} + +impl fmt::Display for PlanningStorageError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "{}: {}", self.code, self.detail) + } +} + +impl std::error::Error for PlanningStorageError {} + +pub(crate) type FingerprintError = PlanningStorageError; + +fn invalid(detail: impl Into) -> PlanningStorageError { + PlanningStorageError::new("PLAN_INVALID_SCHEMA", detail) +} + +fn io_error(label: &str, error: impl fmt::Display) -> PlanningStorageError { + PlanningStorageError::new("PLAN_STORAGE_IO", format!("{label}: {error}")) +} + +fn canonical_bytes(value: &T) -> Result, PlanningStorageError> { + serde_json::to_vec(value) + .map_err(|error| PlanningStorageError::new("PLAN_SERIALIZE_FAILED", error.to_string())) +} + +#[derive(Serialize)] +struct FingerprintEnvelope<'a, T: Serialize + ?Sized> { + domain: &'static str, + value: &'a T, +} + +/// Typed planning fingerprint. The envelope and the serialized value are +/// deliberately struct-shaped; map/string concatenation fingerprints are not +/// interchangeable with this contract. +pub(crate) fn typed_serde_fingerprint( + domain: &'static str, + value: &T, +) -> Result { + if domain.trim().is_empty() { + return Err(PlanningStorageError::new( + "PLAN_INVALID_FINGERPRINT_DOMAIN", + "fingerprint domain 不能为空", + )); + } + let bytes = typed_serde_canonical_bytes(domain, value)?; + Ok(format!("sha256-serde-json-v2:{:x}", Sha256::digest(bytes))) +} + +pub(crate) fn typed_serde_canonical_bytes( + domain: &'static str, + value: &T, +) -> Result, FingerprintError> { + if domain.trim().is_empty() { + return Err(PlanningStorageError::new( + "PLAN_INVALID_FINGERPRINT_DOMAIN", + "fingerprint domain 不能为空", + )); + } + let envelope = FingerprintEnvelope { domain, value }; + canonical_bytes(&envelope) +} +fn is_hex(value: &str, length: usize) -> bool { + value.len() == length && value.bytes().all(|byte| byte.is_ascii_hexdigit()) +} + +fn is_lower_hex(value: &str, length: usize) -> bool { + is_hex(value, length) && value.bytes().all(|byte| !byte.is_ascii_uppercase()) +} + +pub(crate) fn is_typed_fingerprint(value: &str) -> bool { + value + .strip_prefix("sha256-serde-json-v2:") + .is_some_and(|digest| is_lower_hex(digest, 64)) +} + +pub(crate) fn validate_text( + value: &str, + label: &str, + min: usize, + max: usize, +) -> Result<(), PlanningStorageError> { + if value.chars().count() < min || value.chars().count() > max { + return Err(invalid(format!( + "{label} 必须为 {min}..={max} 个 Unicode scalar" + ))); + } + if value != value.trim() { + return Err(invalid(format!("{label} 必须已完成首尾空白规范化"))); + } + if value.contains('\r') + || value.chars().any(|character| { + character == '\0' + || character == '\u{7f}' + || (character.is_control() && character != '\n' && character != '\t') + }) + { + return Err(invalid(format!("{label} 包含不允许的控制字符"))); + } + Ok(()) +} +pub(crate) fn validate_opaque_id( + value: &str, + label: &str, + allow_empty: bool, +) -> Result<(), PlanningStorageError> { + if value.is_empty() && allow_empty { + return Ok(()); + } + let mut chars = value.chars(); + let Some(first) = chars.next() else { + return Err(invalid(format!("{label} 不能为空"))); + }; + if value.chars().count() > 128 + || !first.is_ascii_alphanumeric() + || !chars.all(|character| { + character.is_ascii_alphanumeric() || matches!(character, '.' | '_' | ':' | '-') + }) + { + return Err(invalid(format!("{label} 不是合法 opaque ID"))); + } + Ok(()) +} + +pub(crate) fn validate_uuid_prefixed( + value: &str, + prefix: &str, + label: &str, +) -> Result<(), PlanningStorageError> { + let Some(uuid) = value.strip_prefix(prefix) else { + return Err(invalid(format!("{label} 必须以 {prefix} 开头"))); + }; + if uuid.len() != 36 + || !uuid.bytes().enumerate().all(|(index, byte)| { + matches!(index, 8 | 13 | 18 | 23) + .then_some(byte == b'-') + .unwrap_or_else(|| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f')) + }) + || Uuid::parse_str(uuid).ok().is_none_or(|parsed| { + parsed.hyphenated().to_string() != uuid || parsed.get_variant() != Variant::RFC4122 + }) + { + return Err(invalid(format!("{label} 不是小写 RFC 4122 UUID"))); + } + Ok(()) +} + +pub(crate) fn validate_action_id(value: &str, label: &str) -> Result<(), PlanningStorageError> { + let Some(digest) = value.strip_prefix("action-") else { + return Err(invalid(format!("{label} 必须以 action- 开头"))); + }; + if !is_lower_hex(digest, 24) { + return Err(invalid(format!("{label} 不是合法 actionId"))); + } + Ok(()) +} +pub(crate) fn validate_timestamp(value: &str, label: &str) -> Result<(), PlanningStorageError> { + validate_text(value, label, 24, 24)?; + if value.len() != 24 || !value.is_ascii() { + return Err(invalid(format!("{label} 必须是 ASCII UTC 毫秒时间"))); + } + let bytes = value.as_bytes(); + let punctuation = [ + (4, b'-'), + (7, b'-'), + (10, b'T'), + (13, b':'), + (16, b':'), + (19, b'.'), + (23, b'Z'), + ]; + if punctuation + .iter() + .any(|(index, expected)| bytes[*index] != *expected) + || bytes.iter().enumerate().any(|(index, byte)| { + !punctuation + .iter() + .any(|(punctuation_index, _)| *punctuation_index == index) + && !byte.is_ascii_digit() + }) + { + return Err(invalid(format!("{label} 必须是 UTC 毫秒时间"))); + } + let year = value[0..4] + .parse::() + .map_err(|_| invalid(format!("{label} 年份非法")))?; + let month = value[5..7] + .parse::() + .map_err(|_| invalid(format!("{label} 月份非法")))?; + let day = value[8..10] + .parse::() + .map_err(|_| invalid(format!("{label} 日期非法")))?; + let hour = value[11..13] + .parse::() + .map_err(|_| invalid(format!("{label} 小时非法")))?; + let minute = value[14..16] + .parse::() + .map_err(|_| invalid(format!("{label} 分钟非法")))?; + let second = value[17..19] + .parse::() + .map_err(|_| invalid(format!("{label} 秒非法")))?; + let millis = value[20..23] + .parse::() + .map_err(|_| invalid(format!("{label} 毫秒非法")))?; + if !(1..=12).contains(&month) || hour > 23 || minute > 59 || second > 59 || millis > 999 { + return Err(invalid(format!("{label} 的 UTC 日期时间分量越界"))); + } + let leap_year = year % 4 == 0 && (year % 100 != 0 || year % 400 == 0); + let days_in_month = match month { + 2 if leap_year => 29, + 2 => 28, + 4 | 6 | 9 | 11 => 30, + _ => 31, + }; + if day == 0 || day > days_in_month { + return Err(invalid(format!("{label} 的日期分量越界"))); + } + Ok(()) +} +fn validate_decision_state(value: &str) -> Result<(), PlanningStorageError> { + if matches!(value, "confirmed" | "default_pending" | "prototype_pending") { + Ok(()) + } else { + Err(invalid(format!("未知 decision state:{value}"))) + } +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct PlanGddGame { + pub(crate) title: String, + pub(crate) genre: PlanGenre, + pub(crate) art_style: PlanArtStyle, + pub(crate) one_liner: String, + pub(crate) pillars: Vec, + pub(crate) core_loop: Vec, + pub(crate) target_users: PlanTargetUsers, + pub(crate) platform_facts: PlanPlatformFacts, + pub(crate) mvp_systems: Vec, + pub(crate) out_of_scope: Vec, + pub(crate) creator_tips: PlanCreatorTips, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct PlanGenre { + pub(crate) primary: String, + pub(crate) fusion: Option, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct PlanArtStyle { + pub(crate) visual_type: String, + pub(crate) keywords: Vec, + pub(crate) mood_and_color: String, + pub(crate) mvp_art_boundary: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct PlanPillar { + pub(crate) name: String, + pub(crate) player_feel: String, + pub(crate) mechanism: String, + pub(crate) decision_state: String, + pub(crate) basis: Option<()>, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct PlanTargetUsers { + pub(crate) core_users: String, + pub(crate) preferences: String, + pub(crate) session_length: String, + pub(crate) reference_games: Vec, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct PlanPlatformFacts { + pub(crate) runtime: String, + pub(crate) viewports: Vec, + pub(crate) inputs: Vec, + pub(crate) preview: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct PlanMvpSystem { + pub(crate) system: String, + pub(crate) minimal_function: String, + pub(crate) why_required: String, + pub(crate) verify_method: String, + pub(crate) decision_state: String, + pub(crate) basis: Option<()>, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct PlanCreatorTips { + pub(crate) do_first: String, + pub(crate) defer_for_now: String, + pub(crate) how_to_verify: String, + pub(crate) expand_when: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct PlanDecision { + pub(crate) id: String, + pub(crate) topic: String, + pub(crate) state: String, + pub(crate) answer_source: String, + pub(crate) round: u32, + pub(crate) answer_summary: String, + pub(crate) basis: Option<()>, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct PlanPrototypeValidationItem { + pub(crate) id: String, + pub(crate) question: String, + pub(crate) micro_prototype: String, + pub(crate) observation: String, + pub(crate) pass_criterion: String, +} +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct PlanSubmitGame { + pub(crate) title: String, + pub(crate) genre: PlanGenre, + pub(crate) art_style: PlanArtStyle, + pub(crate) one_liner: String, + pub(crate) pillars: Vec, + pub(crate) core_loop: Vec, + pub(crate) target_users: PlanTargetUsers, + pub(crate) mvp_systems: Vec, + pub(crate) out_of_scope: Vec, + pub(crate) creator_tips: PlanCreatorTips, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct PlanSubmitPillar { + pub(crate) name: String, + pub(crate) player_feel: String, + pub(crate) mechanism: String, + pub(crate) decision_state: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct PlanSubmitMvpSystem { + pub(crate) system: String, + pub(crate) minimal_function: String, + pub(crate) why_required: String, + pub(crate) verify_method: String, + pub(crate) decision_state: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct PlanSubmitDecision { + pub(crate) id: String, + pub(crate) topic: String, + pub(crate) state: String, + pub(crate) answer_source: String, + pub(crate) round: u32, + pub(crate) answer_summary: String, +} +fn validate_plan_platform_facts(value: &PlanPlatformFacts) -> Result<(), PlanningStorageError> { + if value.runtime != "self-contained-web" + || value.viewports != ["desktop", "mobile"] + || value.inputs != ["keyboard", "touch"] + || value.preview != "local-http" + { + return Err(invalid("platformFacts 必须是 Runtime 固定的平台事实")); + } + Ok(()) +} + +pub(crate) fn fixed_plan_platform_facts() -> PlanPlatformFacts { + PlanPlatformFacts { + runtime: "self-contained-web".to_string(), + viewports: vec!["desktop".to_string(), "mobile".to_string()], + inputs: vec!["keyboard".to_string(), "touch".to_string()], + preview: "local-http".to_string(), + } +} + +pub(crate) fn validate_plan_game(game: &PlanGddGame) -> Result<(), PlanningStorageError> { + validate_text(&game.title, "game.title", 1, 80)?; + validate_text(&game.genre.primary, "game.genre.primary", 1, 80)?; + if let Some(fusion) = &game.genre.fusion { + validate_text(fusion, "game.genre.fusion", 1, 80)?; + } + validate_text( + &game.art_style.visual_type, + "game.artStyle.visualType", + 1, + 80, + )?; + if !(1..=6).contains(&game.art_style.keywords.len()) { + return Err(invalid("game.artStyle.keywords 必须有 1~6 项")); + } + for (index, keyword) in game.art_style.keywords.iter().enumerate() { + validate_text(keyword, &format!("game.artStyle.keywords[{index}]"), 1, 64)?; + } + validate_text( + &game.art_style.mood_and_color, + "game.artStyle.moodAndColor", + 1, + 1000, + )?; + validate_text( + &game.art_style.mvp_art_boundary, + "game.artStyle.mvpArtBoundary", + 1, + 1200, + )?; + // 设计约定:Runtime 接受范围为 10~160,模型 schema 提示范围收紧为 25~90; + // 两者有意不对称,用于避免过短概念同时保留对已有/人工 GDD 的兼容,不是缺陷或 bug。 + validate_text(&game.one_liner, "game.oneLiner", 10, 160)?; + + if !(1..=6).contains(&game.pillars.len()) { + return Err(invalid("game.pillars 必须有 1~6 条")); + } + for (index, pillar) in game.pillars.iter().enumerate() { + validate_text(&pillar.name, &format!("game.pillars[{index}].name"), 1, 80)?; + validate_text( + &pillar.player_feel, + &format!("game.pillars[{index}].playerFeel"), + 1, + 400, + )?; + validate_text( + &pillar.mechanism, + &format!("game.pillars[{index}].mechanism"), + 1, + 400, + )?; + validate_decision_state(&pillar.decision_state)?; + if pillar.basis.is_some() { + return Err(invalid("v1 的 pillar.basis 必须为 null")); + } + } + + if !(1..=8).contains(&game.core_loop.len()) { + return Err(invalid("game.coreLoop 必须有 1~8 步")); + } + for (index, step) in game.core_loop.iter().enumerate() { + validate_text(step, &format!("game.coreLoop[{index}]"), 1, 400)?; + } + + validate_text( + &game.target_users.core_users, + "game.targetUsers.coreUsers", + 1, + 400, + )?; + validate_text( + &game.target_users.preferences, + "game.targetUsers.preferences", + 1, + 400, + )?; + validate_text( + &game.target_users.session_length, + "game.targetUsers.sessionLength", + 1, + 400, + )?; + if game.target_users.reference_games.len() > 8 { + return Err(invalid("game.targetUsers.referenceGames 最多 8 项")); + } + for (index, reference) in game.target_users.reference_games.iter().enumerate() { + validate_text( + reference, + &format!("game.targetUsers.referenceGames[{index}]"), + 1, + 80, + )?; + } + validate_plan_platform_facts(&game.platform_facts)?; + + if !(1..=8).contains(&game.mvp_systems.len()) { + return Err(invalid("game.mvpSystems 必须有 1~8 项")); + } + for (index, system) in game.mvp_systems.iter().enumerate() { + validate_text( + &system.system, + &format!("game.mvpSystems[{index}].system"), + 1, + 80, + )?; + validate_text( + &system.minimal_function, + &format!("game.mvpSystems[{index}].minimalFunction"), + 1, + 400, + )?; + validate_text( + &system.why_required, + &format!("game.mvpSystems[{index}].whyRequired"), + 1, + 400, + )?; + validate_text( + &system.verify_method, + &format!("game.mvpSystems[{index}].verifyMethod"), + 1, + 400, + )?; + validate_decision_state(&system.decision_state)?; + if system.basis.is_some() { + return Err(invalid("v1 的 mvpSystem.basis 必须为 null")); + } + } + if game.out_of_scope.len() > 12 { + return Err(invalid("game.outOfScope 必须有 0~12 项")); + } + for (index, item) in game.out_of_scope.iter().enumerate() { + validate_text(item, &format!("game.outOfScope[{index}]"), 1, 80)?; + } + validate_text( + &game.creator_tips.do_first, + "game.creatorTips.doFirst", + 1, + 1000, + )?; + validate_text( + &game.creator_tips.defer_for_now, + "game.creatorTips.deferForNow", + 1, + 1000, + )?; + validate_text( + &game.creator_tips.how_to_verify, + "game.creatorTips.howToVerify", + 1, + 1000, + )?; + validate_text( + &game.creator_tips.expand_when, + "game.creatorTips.expandWhen", + 1, + 1000, + )?; + Ok(()) +} +/// Resolve a planning path and classify a linked path component as untrusted +/// rather than merely invalid. The generic resolver folds "component is a +/// symlink" into the same opaque string as every other path error, so mapping +/// its failure wholesale to `PLAN_INVALID_PATH` misreports an untrusted target; +/// `ensure_planning_parent` and `verify_regular_planning_file` already classify +/// links as `PLAN_UNTRUSTED_PATH`, and this keeps the whole module consistent. +fn resolve_planning_path( + root: &Path, + relative_path: &str, +) -> Result { + let normalized = normalize_relative_path(relative_path) + .map_err(|error| PlanningStorageError::new("PLAN_INVALID_PATH", error))?; + let mut path = root.to_path_buf(); + for part in normalized.split('/') { + path.push(part); + // 只判定链接/重解析点;缺失组件与真实 IO 错误交给通用解析器,保持既有分类。 + if fs::symlink_metadata(&path) + .is_ok_and(|metadata| planning_metadata_is_link_or_reparse(&metadata)) + { + return Err(PlanningStorageError::new( + "PLAN_UNTRUSTED_PATH", + format!("规划路径组件不能是链接或重解析点:{}", path.display()), + )); + } + } + resolve_local_project_path(root, relative_path) + .map_err(|error| PlanningStorageError::new("PLAN_INVALID_PATH", error)) +} + +fn planning_metadata_is_link_or_reparse(metadata: &fs::Metadata) -> bool { + if metadata.file_type().is_symlink() { + return true; + } + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400; + return metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0; + } + #[cfg(not(windows))] + { + false + } +} +#[cfg(unix)] +fn open_planning_parent_directory(parent: &Path) -> Result { + use std::os::unix::fs::OpenOptionsExt; + OpenOptions::new() + .read(true) + .custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC) + .open(parent) + .map_err(|error| io_error("打开 planning 父目录失败", error)) +} + +#[cfg(windows)] +fn open_planning_parent_directory(parent: &Path) -> Result { + use std::os::windows::fs::OpenOptionsExt; + let directory = OpenOptions::new() + .read(true) + // FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_BACKUP_SEMANTICS; the + // latter is required for opening a directory handle on Windows. + .custom_flags(0x0020_0000 | 0x0200_0000) + .open(parent) + .map_err(|error| io_error("打开 planning 父目录失败", error))?; + let metadata = directory + .metadata() + .map_err(|error| io_error("读取 planning 父目录句柄失败", error))?; + if planning_metadata_is_link_or_reparse(&metadata) || !metadata.is_dir() { + return Err(PlanningStorageError::new( + "PLAN_UNTRUSTED_PATH", + format!("planning 父目录句柄不是可信普通目录:{}", parent.display()), + )); + } + Ok(directory) +} + +#[cfg(not(any(unix, windows)))] +fn open_planning_parent_directory(parent: &Path) -> Result { + File::open(parent).map_err(|error| io_error("打开 planning 父目录失败", error)) +} + +fn verify_regular_planning_file( + path: &Path, + label: &str, +) -> Result { + let metadata = fs::symlink_metadata(path) + .map_err(|error| io_error(&format!("读取 {label} 元数据失败"), error))?; + if planning_metadata_is_link_or_reparse(&metadata) || !metadata.is_file() { + return Err(PlanningStorageError::new( + "PLAN_UNTRUSTED_PATH", + format!("{label} 必须是可信普通文件:{}", path.display()), + )); + } + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + if metadata.nlink() != 1 { + return Err(PlanningStorageError::new( + "PLAN_UNTRUSTED_PATH", + format!("{label} 不能是硬链接文件:{}", path.display()), + )); + } + } + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; + let file = OpenOptions::new() + .read(true) + .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT) + .open(path) + .map_err(|error| io_error(&format!("打开 {label} 句柄失败"), error))?; + crate::runner::validate_windows_regular_file_handle(&file, label) + .map_err(|error| PlanningStorageError::new("PLAN_UNTRUSTED_PATH", error))?; + } + Ok(metadata) +} + +fn read_regular_planning_file(path: &Path, label: &str) -> Result, PlanningStorageError> { + let metadata = verify_regular_planning_file(path, label)?; + if metadata.len() > PLAN_INDEX_MAX_BYTES as u64 { + return Err(PlanningStorageError::new( + "PLAN_SIZE_LIMIT", + format!("{label} 超过 {} 字节读取上限", PLAN_INDEX_MAX_BYTES), + )); + } + let mut options = OpenOptions::new(); + options.read(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC); + } + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; + // The final open must inspect the directory entry itself. Without + // OPEN_REPARSE_POINT a junction/symlink can be followed before the + // handle validator sees the target's (apparently regular) attributes. + options.custom_flags(FILE_FLAG_OPEN_REPARSE_POINT); + } + let mut file = options + .open(path) + .map_err(|error| io_error(&format!("打开 {label} 失败"), error))?; + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + let opened_metadata = file + .metadata() + .map_err(|error| io_error(&format!("复核 {label} 句柄元数据失败"), error))?; + if opened_metadata.dev() != metadata.dev() + || opened_metadata.ino() != metadata.ino() + || opened_metadata.nlink() != 1 + { + return Err(PlanningStorageError::new( + "PLAN_RECONCILIATION_REQUIRED", + format!("打开 {label} 时文件身份发生漂移"), + )); + } + } + #[cfg(windows)] + crate::runner::validate_windows_regular_file_handle(&file, label) + .map_err(|error| PlanningStorageError::new("PLAN_UNTRUSTED_PATH", error))?; + let mut bytes = Vec::with_capacity(metadata.len().min(PLAN_INDEX_MAX_BYTES as u64) as usize); + (&mut file) + .take((PLAN_INDEX_MAX_BYTES as u64).saturating_add(1)) + .read_to_end(&mut bytes) + .map_err(|error| io_error(&format!("读取 {label} 失败"), error))?; + if bytes.len() > PLAN_INDEX_MAX_BYTES { + return Err(PlanningStorageError::new( + "PLAN_SIZE_LIMIT", + format!("{label} 超过 {} 字节读取上限", PLAN_INDEX_MAX_BYTES), + )); + } + let final_metadata = file + .metadata() + .map_err(|error| io_error(&format!("复核 {label} 元数据失败"), error))?; + if final_metadata.len() != bytes.len() as u64 || final_metadata.len() != metadata.len() { + return Err(PlanningStorageError::new( + "PLAN_RECONCILIATION_REQUIRED", + format!("读取 {label} 时文件发生漂移"), + )); + } + Ok(bytes) +} + +fn sync_planning_parent(parent: &Path) -> Result<(), PlanningStorageError> { + #[cfg(unix)] + File::open(parent) + .and_then(|directory| directory.sync_all()) + .map_err(|error| io_error("同步 planning 父目录失败", error))?; + #[cfg(windows)] + { + let directory = open_planning_parent_directory(parent)?; + if let Err(error) = directory.sync_all() { + // MoveFileExW(MOVEFILE_WRITE_THROUGH) already flushes the file + // publication on Windows. Some Windows filesystems reject + // FlushFileBuffers on a directory handle with ACCESS_DENIED or + // INVALID_FUNCTION; keep the stronger native flush as the + // fallback rather than making every durable write unusable there. + if !matches!(error.raw_os_error(), Some(1 | 5 | 6)) { + return Err(io_error("同步 planning 父目录失败", error)); + } + } + } + #[cfg(not(any(unix, windows)))] + let _ = parent; + Ok(()) +} + +fn temp_planning_path(parent: &Path, target: &Path) -> PathBuf { + let nonce = PLANNING_TEMP_NONCE.fetch_add(1, Ordering::Relaxed); + let random = Uuid::new_v4(); + let file_name = target + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("planning.json"); + parent.join(format!( + ".{file_name}.tmp-{}-{nonce}-{random}", + std::process::id() + )) +} + +fn write_sync_new_file(path: &Path, bytes: &[u8], label: &str) -> Result<(), PlanningStorageError> { + if bytes.len() > PLAN_INDEX_MAX_BYTES { + return Err(PlanningStorageError::new( + "PLAN_SIZE_LIMIT", + format!("{label} 临时内容超过 {} 字节上限", PLAN_INDEX_MAX_BYTES), + )); + } + let result = (|| { + let mut options = OpenOptions::new(); + options.read(true).write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options + .mode(0o600) + .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC); + } + let mut file = options + .open(path) + .map_err(|error| io_error(&format!("创建 {label} 临时文件失败"), error))?; + #[cfg(windows)] + crate::runner::validate_windows_regular_file_handle(&file, label) + .map_err(|error| PlanningStorageError::new("PLAN_UNTRUSTED_PATH", error))?; + file.write_all(bytes) + .and_then(|_| file.sync_all()) + .map_err(|error| io_error(&format!("写入 {label} 临时文件失败"), error))?; + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + let metadata = file + .metadata() + .map_err(|error| io_error(&format!("复核 {label} 临时文件句柄失败"), error))?; + if !metadata.is_file() || metadata.nlink() != 1 { + return Err(PlanningStorageError::new( + "PLAN_UNTRUSTED_PATH", + format!("{label} 临时文件句柄不是唯一普通文件"), + )); + } + } + file.seek(SeekFrom::Start(0)) + .map_err(|error| io_error(&format!("定位 {label} 临时文件回读位置失败"), error))?; + let mut check = Vec::new(); + file.read_to_end(&mut check) + .map_err(|error| io_error(&format!("回读 {label} 临时文件失败"), error))?; + if check != bytes { + return Err(PlanningStorageError::new( + "PLAN_RECONCILIATION_REQUIRED", + format!("{label} 临时文件回读不一致"), + )); + } + Ok(()) + })(); + if result.is_err() { + let _ = fs::remove_file(path); + } + result +} + +pub(crate) fn write_plan_fast_gdd_markdown_atomic_locked( + root: &Path, + markdown: &str, +) -> Result<(), PlanningStorageError> { + let bytes = markdown.as_bytes(); + if bytes.is_empty() { + return Err(invalid("Fast GDD Markdown 不能为空")); + } + if bytes.len() > PLAN_FAST_GDD_MAX_BYTES { + return Err(PlanningStorageError::new( + "PLAN_SIZE_LIMIT", + format!( + "Fast GDD Markdown 超过 {} 字节上限", + PLAN_FAST_GDD_MAX_BYTES + ), + )); + } + if bytes.contains(&0) || !std::str::from_utf8(bytes).is_ok() { + return Err(invalid("Fast GDD Markdown 必须是无 NUL 的 UTF-8 文本")); + } + + let target = resolve_planning_path(root, PLAN_FAST_GDD_PATH)?; + let parent = target + .parent() + .ok_or_else(|| PlanningStorageError::new("PLAN_INVALID_PATH", "Fast GDD 缺少父目录"))?; + + // The projection lives outside `.agent/planning`, so it cannot use the + // planning-only parent helper. Build the relative `game/` directory one + // component at a time and reject links/reparse points at every step. + let root_metadata = + fs::symlink_metadata(root).map_err(|error| io_error("读取项目根目录失败", error))?; + if planning_metadata_is_link_or_reparse(&root_metadata) || !root_metadata.is_dir() { + return Err(PlanningStorageError::new( + "PLAN_UNTRUSTED_PATH", + "项目根目录必须是可信普通目录", + )); + } + let mut cursor = root.to_path_buf(); + let relative_parent = parent + .strip_prefix(root) + .map_err(|_| PlanningStorageError::new("PLAN_INVALID_PATH", "Fast GDD 父目录越出项目根"))?; + for component in relative_parent.components() { + use std::path::Component; + let Component::Normal(component) = component else { + return Err(PlanningStorageError::new( + "PLAN_INVALID_PATH", + "Fast GDD 父目录组件非法", + )); + }; + cursor.push(component); + match fs::symlink_metadata(&cursor) { + Ok(metadata) => { + if planning_metadata_is_link_or_reparse(&metadata) || !metadata.is_dir() { + return Err(PlanningStorageError::new( + "PLAN_UNTRUSTED_PATH", + "Fast GDD 父目录必须是可信普通目录", + )); + } + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + fs::create_dir(&cursor) + .map_err(|error| io_error("创建 Fast GDD 父目录失败", error))?; + let metadata = fs::symlink_metadata(&cursor) + .map_err(|error| io_error("复核 Fast GDD 父目录失败", error))?; + if planning_metadata_is_link_or_reparse(&metadata) || !metadata.is_dir() { + return Err(PlanningStorageError::new( + "PLAN_UNTRUSTED_PATH", + "新建 Fast GDD 父目录不是可信普通目录", + )); + } + } + Err(error) => return Err(io_error("读取 Fast GDD 父目录失败", error)), + } + } + + match fs::symlink_metadata(&target) { + Ok(_) => { + verify_regular_planning_file(&target, "现有 Fast GDD Markdown")?; + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(io_error("读取现有 Fast GDD Markdown 失败", error)), + } + + let temporary = temp_planning_path(parent, &target); + let result = (|| { + write_sync_new_file(&temporary, bytes, "Fast GDD Markdown")?; + verify_replace_target_is_safe(&target, "Fast GDD Markdown")?; + replace_planning_file_atomically(&temporary, &target, "Fast GDD Markdown")?; + let published = read_regular_planning_file(&target, "已发布 Fast GDD Markdown")?; + if published != bytes { + return Err(PlanningStorageError::new( + "PLAN_RECONCILIATION_REQUIRED", + "Fast GDD Markdown 发布后内容不一致", + )); + } + sync_planning_parent(parent) + })(); + if temporary.exists() { + let cleanup = fs::remove_file(&temporary) + .map_err(|error| io_error("清理 Fast GDD 临时文件失败", error)); + if result.is_ok() { + cleanup?; + } + } + result +} + +fn replace_planning_file_atomically( + temporary: &Path, + target: &Path, + label: &str, +) -> Result<(), PlanningStorageError> { + #[cfg(not(windows))] + { + fs::rename(temporary, target) + .map_err(|error| io_error(&format!("原子替换 {label} 失败"), error))?; + } + #[cfg(windows)] + { + use std::os::windows::ffi::OsStrExt; + use windows_sys::Win32::Storage::FileSystem::{ + MoveFileExW, MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, + }; + let from = temporary + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect::>(); + let to = target + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect::>(); + let result = unsafe { + MoveFileExW( + from.as_ptr(), + to.as_ptr(), + MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH, + ) + }; + if result == 0 { + return Err(io_error( + &format!("原子替换 {label} 失败"), + std::io::Error::last_os_error(), + )); + } + } + Ok(()) +} + +fn verify_replace_target_is_safe(target: &Path, label: &str) -> Result<(), PlanningStorageError> { + match fs::symlink_metadata(target) { + Ok(_) => { + verify_regular_planning_file(target, &format!("现有 {label}"))?; + Ok(()) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(io_error(&format!("读取现有 {label} 目标失败"), error)), + } +} + +/// Return the fixed UTC millisecond timestamp used by Runtime-owned planning +/// projections. Keeping this helper here makes tests able to inject a fixed +/// timestamp while production callers can use the same formatting contract. +pub(crate) fn current_plan_timestamp_utc() -> String { + let millis = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis(); + let seconds = millis / 1_000; + let millis_part = millis % 1_000; + let days = seconds / 86_400; + let day_seconds = seconds % 86_400; + let hour = day_seconds / 3_600; + let minute = (day_seconds % 3_600) / 60; + let second = day_seconds % 60; + + // Civil-from-days, Gregorian calendar (Howard Hinnant algorithm). + let z = days as i64 + 719_468; + let era = if z >= 0 { z } else { z - 146_096 } / 146_097; + let doe = z - era * 146_097; + let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; + let year = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let day = doy - (153 * mp + 2) / 5 + 1; + let month = mp + if mp < 10 { 3 } else { -9 }; + let year = year + i64::from(month <= 2); + format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}.{millis_part:03}Z") +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_hydrate.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_hydrate.rs deleted file mode 100644 index 3d541f687..000000000 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_hydrate.rs +++ /dev/null @@ -1,779 +0,0 @@ -use super::*; - -use std::collections::BTreeMap; -use std::path::Path; - -pub(crate) const PLAN_GDD_STATE_VIEW_SCHEMA_VERSION: &str = "plan-gdd-state-view.v1"; - -#[derive(Clone, Debug, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct PlanGddStateViewV1 { - pub(crate) schema_version: String, - pub(crate) project_id: String, - pub(crate) gdd_id: Option, - pub(crate) state: String, - pub(crate) session: Option, - pub(crate) versions: Vec, - pub(crate) display_gdd: Option, - pub(crate) pending_approval: Option, - pub(crate) approved_gdd_ref: Option, - pub(crate) recovery_pending: bool, -} - -#[derive(Clone, Debug, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct PlanGddStateSessionView { - pub(crate) session_id: String, - pub(crate) session_revision: u32, - pub(crate) session_fingerprint: String, - pub(crate) phase: String, - pub(crate) clarification_round: u32, - pub(crate) repair_depth: u32, - pub(crate) accumulated_agent_millis: u64, - pub(crate) active_run_id: Option, - pub(crate) awaiting_answer_for: Option, - pub(crate) decision_state_counts: PlanGddStateDecisionCounts, -} - -#[derive(Clone, Debug, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct PlanGddStateAwaitingAnswerView { - pub(crate) delegation_id: String, - pub(crate) request_id: String, - pub(crate) question_id: String, - pub(crate) round: u32, -} - -#[derive(Clone, Debug, Default, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct PlanGddStateDecisionCounts { - pub(crate) confirmed: u32, - pub(crate) default_pending: u32, - pub(crate) prototype_pending: u32, -} - -#[derive(Clone, Debug, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct PlanGddStateVersionView { - pub(crate) gdd_ref: PlanGddRef, - pub(crate) status: String, - pub(crate) approval_request_id: String, - pub(crate) created_at_utc: String, - pub(crate) decision: Option, -} - -#[derive(Clone, Debug, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct PlanGddStateDecisionView { - pub(crate) action: String, - pub(crate) decided_at_utc: String, -} - -#[derive(Clone, Debug, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct PlanGddStatePendingApprovalView { - pub(crate) gdd_ref: PlanGddRef, - pub(crate) pending_action_id: String, - pub(crate) action_fingerprint: String, - pub(crate) approval_request_id: String, - pub(crate) session_id: String, - pub(crate) run_id: String, -} - -fn plan_gdd_state_error(code: &'static str, detail: impl Into) -> PlanningStorageError { - PlanningStorageError::new(code, detail) -} - -fn pending_approval_view( - gdd: &PlanGddV1, - pending: Option<&PlanGddApprovalPendingV1>, -) -> Result { - let expected = PlanGddStatePendingApprovalView { - gdd_ref: PlanGddRef { - gdd_id: gdd.gdd_id.clone(), - version: gdd.version, - fingerprint: gdd.fingerprint.clone(), - }, - pending_action_id: gdd.submission_id.clone(), - action_fingerprint: gdd.action_fingerprint.clone(), - approval_request_id: gdd.approval_request_id.clone(), - session_id: gdd.session_id.clone(), - run_id: gdd.created_by_run_id.clone(), - }; - if let Some(pending) = pending { - if !pending_matches_gdd(pending, gdd) { - return Err(plan_gdd_state_error( - "PLAN_CORRUPT_AUTHORITY", - "approval pending 与当前 GDD 权威身份不一致", - )); - } - if pending.status != "awaiting_decision" { - return Err(plan_gdd_state_error( - "PLAN_CORRUPT_AUTHORITY", - "未决定 GDD 的 approval pending 状态非法", - )); - } - } - Ok(expected) -} - -fn build_pending_approval( - gdds: &[PlanGddV1], - approvals: &[PlanGddApprovalV1], - pending: Option<&PlanGddApprovalPendingV1>, -) -> Result, PlanningStorageError> { - let Some(latest) = gdds.last() else { - return Ok(None); - }; - if approvals - .iter() - .any(|receipt| receipt.version == latest.version) - { - return Ok(None); - } - // `awaiting_gdd_approval` is installed by plan.submit_gdd before the - // acceptance gate has passed. It is therefore not proof that the user - // may decide yet. The gdd-approval pending sidecar is the durable marker - // that the gate has passed; without it the caller must keep waiting or - // retry projection recovery and must not expose a decision action. - if pending.is_none() { - return Ok(None); - } - pending_approval_view(latest, pending).map(Some) -} - -fn build_awaiting_answer( - root: &Path, - session: &PlanSessionV1, - deliveries: &[StaticDelegateDeliveryRecord], -) -> Result<(Option, bool), PlanningStorageError> { - if session.phase != "awaiting_user_input" { - return Ok((None, false)); - } - let Some(delivery) = deliveries - .iter() - .find(|delivery| delivery.delegation_id == session.latest_delegation_id) - else { - return Ok((None, true)); - }; - let Some(result) = delivery.structured_result.as_ref().filter(|result| { - result.contract_status == StaticDelegateContractStatus::NeedsUserInput - && delivery.status == StaticDelegateDeliveryStatus::ClaimedByParent - && delivery.clarification_answers_sha256.is_none() - }) else { - return Ok((None, true)); - }; - let Some(question) = result.user_input_questions.first() else { - return Ok((None, true)); - }; - let pending = match read_game_creator_agent_runtime_pending_tool_action( - root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &session.root_run_id, - ) { - Ok(value) => value, - Err(_) => return Ok((None, true)), - }; - if pending.action.tool != GAME_CREATOR_USER_INPUT_REQUEST_TOOL - || !static_delegate_clarification_pending_matches_delivery_at(root, &pending) - .map_err(|error| plan_gdd_state_error("PLAN_SESSION_RECOVERY_REQUIRED", error))? - { - return Ok((None, true)); - } - let request_id = pending - .action - .input - .get("requestId") - .and_then(serde_json::Value::as_str) - .filter(|value| !value.trim().is_empty()) - .ok_or_else(|| { - plan_gdd_state_error( - "PLAN_SESSION_RECOVERY_REQUIRED", - "用户澄清 pending 缺少 requestId", - ) - })?; - let (_, round) = static_delegate_lineage_counters(deliveries, &delivery.delegation_id); - if round == u32::MAX { - return Err(plan_gdd_state_error( - "PLAN_SESSION_RECOVERY_REQUIRED", - "无法从静态委派谱系派生澄清轮次", - )); - } - Ok(( - Some(PlanGddStateAwaitingAnswerView { - delegation_id: delivery.delegation_id.clone(), - request_id: request_id.to_string(), - question_id: question.id.clone(), - round, - }), - false, - )) -} - -fn build_session_view( - root: &Path, - session: &PlanSessionV1, - deliveries: &[StaticDelegateDeliveryRecord], -) -> Result<(PlanGddStateSessionView, bool), PlanningStorageError> { - let (repair_depth, clarification_round) = if deliveries - .iter() - .any(|delivery| delivery.delegation_id == session.latest_delegation_id) - { - let counters = static_delegate_lineage_counters(deliveries, &session.latest_delegation_id); - if counters.0 == u32::MAX || counters.1 == u32::MAX { - return Err(plan_gdd_state_error( - "PLAN_SESSION_RECOVERY_REQUIRED", - "无法从静态委派谱系派生 planning session 轮次", - )); - } - counters - } else if session.applied_answers.is_empty() { - (0, 0) - } else { - return Err(plan_gdd_state_error( - "PLAN_SESSION_RECOVERY_REQUIRED", - "planning session 缺少 latestDelegationId 对应的静态委派谱系", - )); - }; - let mut decision_state_counts = PlanGddStateDecisionCounts::default(); - for decision in &session.decisions_summary { - match decision.state.as_str() { - "confirmed" => decision_state_counts.confirmed += 1, - "default_pending" => decision_state_counts.default_pending += 1, - "prototype_pending" => decision_state_counts.prototype_pending += 1, - _ => { - return Err(plan_gdd_state_error( - "PLAN_SESSION_RECOVERY_REQUIRED", - "planning session 含未知决定状态", - )); - } - } - } - let (awaiting_answer_for, recovery_pending) = build_awaiting_answer(root, session, deliveries)?; - Ok(( - PlanGddStateSessionView { - session_id: session.session_id.clone(), - session_revision: session.session_revision, - session_fingerprint: session.session_fingerprint.clone(), - phase: session.phase.clone(), - clarification_round, - repair_depth, - accumulated_agent_millis: session.accumulated_agent_millis, - active_run_id: session.active_run_id.clone(), - awaiting_answer_for, - decision_state_counts, - }, - recovery_pending, - )) -} - -fn build_state_view_locked( - root: &Path, - project_id: &str, - gdds: Vec, - approvals: Vec, - session: Option, - pending: Option, - mut recovery_pending: bool, -) -> Result { - for gdd in &gdds { - if gdd.project_id != project_id { - return Err(plan_gdd_state_error( - "PLAN_PROJECT_ID_MISMATCH", - "GDD projectId 与当前项目 manifest 不一致", - )); - } - } - if let Some(session) = session.as_ref() { - if session.project_id != project_id { - return Err(plan_gdd_state_error( - "PLAN_PROJECT_ID_MISMATCH", - "planning session projectId 与当前项目 manifest 不一致", - )); - } - } - validate_plan_gdd_approvals_against_gdds(&gdds, &approvals)?; - let deliveries = if session.is_some() { - crate::delegation::list_static_delegate_deliveries_at(root) - .map_err(|error| plan_gdd_state_error("PLAN_SESSION_RECOVERY_REQUIRED", error))? - } else { - Vec::new() - }; - let session_view = if let Some(session) = session.as_ref() { - let (view, session_recovery_pending) = build_session_view(root, session, &deliveries)?; - recovery_pending |= session_recovery_pending; - Some(view) - } else { - None - }; - - let receipt_by_version = approvals - .iter() - .map(|receipt| (receipt.version, receipt)) - .collect::>(); - let approved_version = approvals - .iter() - .filter(|receipt| receipt.action == "approve") - .map(|receipt| receipt.version) - .max(); - let versions = gdds - .iter() - .map(|gdd| { - let (status, decision) = match receipt_by_version.get(&gdd.version) { - None => ("ready_for_approval".to_string(), None), - Some(receipt) => { - let status = match receipt.action.as_str() { - "approve" if Some(gdd.version) == approved_version => "approved", - "approve" => "superseded", - "revise" => "revision_requested", - "reject" => "rejected", - _ => unreachable!("validated approval action"), - }; - ( - status.to_string(), - Some(PlanGddStateDecisionView { - action: receipt.action.clone(), - decided_at_utc: receipt.decided_at_utc.clone(), - }), - ) - } - }; - PlanGddStateVersionView { - gdd_ref: PlanGddRef { - gdd_id: gdd.gdd_id.clone(), - version: gdd.version, - fingerprint: gdd.fingerprint.clone(), - }, - status, - approval_request_id: gdd.approval_request_id.clone(), - created_at_utc: gdd.created_at_utc.clone(), - decision, - } - }) - .collect::>(); - let approved_gdd = approved_version - .and_then(|version| gdds.iter().find(|gdd| gdd.version == version).cloned()); - let pending_gdd = gdds - .iter() - .find(|gdd| !receipt_by_version.contains_key(&gdd.version)); - let pending_approval = build_pending_approval(&gdds, &approvals, pending.as_ref())?; - let display_gdd = pending_gdd - .cloned() - .or_else(|| approved_gdd.clone()) - .or_else(|| gdds.last().cloned()); - let state = if pending_gdd.is_some() { - "ready_for_approval" - } else if session - .as_ref() - .is_some_and(|value| matches!(value.phase.as_str(), "collecting" | "awaiting_user_input")) - { - "draft" - } else if let Some(latest) = gdds - .last() - .and_then(|gdd| receipt_by_version.get(&gdd.version)) - { - match latest.action.as_str() { - "approve" => "approved", - "revise" => "revision_requested", - "reject" => "rejected", - _ => unreachable!("validated approval action"), - } - } else if session.is_some() { - "draft" - } else { - "not_started" - }; - if gdds.is_empty() - && session.as_ref().is_some_and(|value| { - value.latest_submitted_ref.is_some() || value.last_decision_ref.is_some() - }) - { - return Err(plan_gdd_state_error( - "PLAN_CORRUPT_AUTHORITY", - "planning session 引用了不存在的 GDD 权威事实", - )); - } - Ok(PlanGddStateViewV1 { - schema_version: PLAN_GDD_STATE_VIEW_SCHEMA_VERSION.to_string(), - project_id: project_id.to_string(), - gdd_id: gdds.first().map(|gdd| gdd.gdd_id.clone()), - state: state.to_string(), - session: session_view, - versions, - display_gdd, - pending_approval, - approved_gdd_ref: approved_gdd.map(|gdd| PlanGddRef { - gdd_id: gdd.gdd_id, - version: gdd.version, - fingerprint: gdd.fingerprint, - }), - recovery_pending, - }) -} - -fn validate_hydrate_project_identity( - project_id: &str, - gdds: &[PlanGddV1], - approvals: &[PlanGddApprovalV1], - session: Option<&PlanSessionV1>, - pending: Option<&PlanGddApprovalPendingV1>, -) -> Result<(), PlanningStorageError> { - for gdd in gdds { - if gdd.project_id != project_id { - return Err(plan_gdd_state_error( - "PLAN_PROJECT_ID_MISMATCH", - "GDD projectId 与当前项目 manifest 不一致", - )); - } - } - for approval in approvals { - if approval.project_id != project_id { - return Err(plan_gdd_state_error( - "PLAN_PROJECT_ID_MISMATCH", - "approval receipt projectId 与当前项目 manifest 不一致", - )); - } - } - if let Some(session) = session { - if session.project_id != project_id { - return Err(plan_gdd_state_error( - "PLAN_PROJECT_ID_MISMATCH", - "planning session projectId 与当前项目 manifest 不一致", - )); - } - } - if let Some(pending) = pending { - if pending.project_id != project_id { - return Err(plan_gdd_state_error( - "PLAN_PROJECT_ID_MISMATCH", - "approval pending projectId 与当前项目 manifest 不一致", - )); - } - } - Ok(()) -} - -pub(crate) fn hydrate_game_creator_plan_gdd_state_at( - root: &Path, -) -> Result { - let manifest = crate::project::read_existing_manifest_for_project(root) - .map_err(|error| plan_gdd_state_error("PLAN_PROJECT_ID_MISMATCH", error))?; - let project_id = manifest.project_id.trim(); - if project_id.is_empty() { - return Err(plan_gdd_state_error( - "PLAN_PROJECT_ID_MISMATCH", - "项目 manifest 缺少 projectId", - )); - } - - // 能力位读的是应用配置,不是项目文件,跟项目锁没有任何关系。它必须在取锁之前算完: - // `load_game_creator_app_config` 每次都重新遍历所有配置路径读盘,把这段 IO 留在锁内 - // 会让每一次 hydrate 都多占一段写锁,而这条调用在监工每次状态变化时都会跑。 - let planning_capability_enabled = crate::config::game_creator_planning_capability_enabled() - .map_err(|error| plan_gdd_state_error("PLAN_CAPABILITY_DISABLED", error))?; - - // §18.3:先在同一把项目锁内只读校验所有 authority 的 projectId,之后才允许 - // session/index/pending recovery 写入。这样复制到另一个项目的 sidecar 只能失败关闭, - // 不会在发现错绑前改写任何投影。 - // Ride out transient contention instead of failing the caller. The GUI - // re-hydrates right after a decision lands, and the decision is exactly what - // releases the run to resume writing, so the refresh and the resumed runner - // reach for this lock at the same moment. Losing that race used to paint - // `项目正在被其他写操作占用` into the approval card of an approval that had - // already committed. The wait is the short one: this call runs again on the - // next supervisor poll, so it must never stall the panel for the full window. - let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_short_wait( - root, - "planning.hydrate", - ) - .map_err(|error| { - plan_gdd_state_error( - "PLAN_STORAGE_IO", - redact_agent_runtime_project_paths(root, &error, 500), - ) - })?; - let gdds = read_plan_gdd_chain_locked(root)?; - let approvals = read_plan_gdd_approvals_locked(root)?; - let session_read_only = read_plan_session_read_only_locked(root)?; - let pending_read_only = read_plan_gdd_approval_pending_locked(root)?; - validate_hydrate_project_identity( - project_id, - &gdds, - &approvals, - session_read_only.as_ref(), - pending_read_only.as_ref(), - )?; - if !planning_capability_enabled { - return build_state_view_locked( - root, - project_id, - gdds, - approvals, - session_read_only, - pending_read_only, - false, - ); - } - let mut recovery_pending = reconcile_plan_gdd_approval_projections_locked(root)?; - let session = read_plan_session_with_recovery_locked(root)?; - let pending = if gdds.last().is_some_and(|latest| { - approvals - .iter() - .any(|receipt| receipt.version == latest.version) - }) { - None - } else { - read_plan_gdd_approval_pending_locked(root)? - }; - let _index = read_plan_gdd_index_with_recovery_locked(root, ¤t_plan_timestamp_utc())?; - if let Some(pending) = pending.as_ref() { - if pending.status != "awaiting_decision" { - recovery_pending = true; - } - } - let unapproved_gdd = gdds.iter().find(|gdd| { - !approvals - .iter() - .any(|receipt| receipt.version == gdd.version) - }); - if let Some(gdd) = unapproved_gdd { - let Some(session) = session.as_ref() else { - return Err(plan_gdd_state_error( - "PLAN_SESSION_RECOVERY_REQUIRED", - "未审批 GDD 缺少对应 planning session", - )); - }; - let expected_ref = PlanGddRef { - gdd_id: gdd.gdd_id.clone(), - version: gdd.version, - fingerprint: gdd.fingerprint.clone(), - }; - if session.latest_submitted_ref.as_ref() != Some(&expected_ref) - || session.phase != "awaiting_gdd_approval" - || session.active_run_id.is_some() - { - return Err(plan_gdd_state_error( - "PLAN_SESSION_RECOVERY_REQUIRED", - "未审批 GDD 与 planning session 的提交 successor 不一致", - )); - } - } - if let (Some(session), Some(first_gdd)) = (session.as_ref(), gdds.first()) { - if session.gdd_id != first_gdd.gdd_id { - return Err(plan_gdd_state_error( - "PLAN_SESSION_RECOVERY_REQUIRED", - "planning session 的 GDD lineage identity 不一致", - )); - } - } - build_state_view_locked( - root, - project_id, - gdds, - approvals, - session, - pending, - recovery_pending, - ) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn hydrate_initialized_project_without_planning_returns_not_started_without_creating_storage() { - let temporary = tempfile::tempdir().expect("create hydrate fixture"); - let root = temporary.path().join("project"); - crate::project::init_local_game_project_at(&root, "hydrate-empty", "空策划项目") - .expect("initialize hydrate fixture"); - - let view = hydrate_game_creator_plan_gdd_state_at(&root).expect("hydrate empty project"); - - assert_eq!(view.schema_version, PLAN_GDD_STATE_VIEW_SCHEMA_VERSION); - assert_eq!(view.project_id, "hydrate-empty"); - assert_eq!(view.state, "not_started"); - assert!(view.gdd_id.is_none()); - assert!(view.session.is_none()); - assert!(view.versions.is_empty()); - assert!(view.display_gdd.is_none()); - assert!(view.pending_approval.is_none()); - assert!(view.approved_gdd_ref.is_none()); - assert!(!view.recovery_pending); - assert!(!root.join(PLAN_STORAGE_ROOT).exists()); - } - - #[test] - fn hydrate_rejects_copied_planning_authority_before_repairing_projections() { - let temporary = tempfile::tempdir().expect("create hydrate fixture"); - let source = temporary.path().join("source"); - let target = temporary.path().join("target"); - crate::project::init_local_game_project_at(&source, "hydrate-source", "源项目") - .expect("initialize source project"); - crate::project::init_local_game_project_at(&target, "hydrate-target", "目标项目") - .expect("initialize target project"); - let mut source_session = PlanSessionV1 { - schema_version: PLAN_SESSION_SCHEMA_VERSION.to_string(), - project_id: "hydrate-source".to_string(), - gdd_id: "gdd-00000000-0000-4000-8000-000000000001".to_string(), - session_revision: 1, - previous_fingerprint: None, - session_fingerprint: format!("sha256-serde-json-v2:{}", "0".repeat(64)), - agent_id: GAME_CREATOR_PROJECT_PLANNING_AGENT_ID.to_string(), - source: "agent-delegate".to_string(), - run_profile: AGENT_RUNTIME_RUN_PROFILE_STANDARD.to_string(), - run_profile_binding_fingerprint: "a".repeat(64), - root_agent_id: GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(), - root_run_id: "run-hydrate-source".to_string(), - latest_delegation_id: "delegation-hydrate-source".to_string(), - session_id: "session-hydrate-source".to_string(), - active_run_id: None, - last_run_id: "run-hydrate-source".to_string(), - phase: "collecting".to_string(), - accumulated_agent_millis: 0, - applied_steer_cursor: 0, - decisions_summary: vec![PlanDecisionSummary { - id: "initial-request".to_string(), - topic: "初始需求".to_string(), - state: "confirmed".to_string(), - answer_source: "user_freeform".to_string(), - round: 0, - answer_summary: "做一款短局解谜游戏".to_string(), - }], - prototype_validation_items: Vec::new(), - applied_answers: Vec::new(), - latest_submitted_ref: None, - last_decision_ref: None, - updated_at_utc: "2026-08-19T00:00:00.000Z".to_string(), - }; - source_session.session_fingerprint = - plan_session_fingerprint(&source_session).expect("fingerprint source session"); - let source_planning = source.join(PLAN_STORAGE_ROOT); - std::fs::create_dir_all(&source_planning).expect("create source planning storage"); - std::fs::write( - source.join(PLAN_SESSION_PREVIOUS_PATH), - canonical_plan_session_bytes(&source_session).expect("canonical source session"), - ) - .expect("write source session recovery copy"); - let target_planning = target.join(PLAN_STORAGE_ROOT); - std::fs::create_dir_all(&target_planning).expect("create target planning storage"); - std::fs::copy( - source.join(PLAN_SESSION_PREVIOUS_PATH), - target.join(PLAN_SESSION_PREVIOUS_PATH), - ) - .expect("copy planning authority"); - std::fs::write(target_planning.join("index.json"), b"copied-index") - .expect("write copied index projection"); - std::fs::create_dir_all(target.join("game")).expect("create target game directory"); - std::fs::write(target.join("game/fast_gdd.md"), b"copied-markdown") - .expect("write copied markdown projection"); - let target_planning = target.join(PLAN_STORAGE_ROOT); - let before_index = - std::fs::read(target_planning.join("index.json")).expect("read copied index"); - let before_markdown = - std::fs::read(target.join("game/fast_gdd.md")).expect("read copied markdown"); - let error = hydrate_game_creator_plan_gdd_state_at(&target) - .expect_err("copied planning authority must fail project identity"); - assert_eq!(error.code(), "PLAN_PROJECT_ID_MISMATCH"); - assert_eq!( - std::fs::read(target_planning.join("index.json")).expect("re-read copied index"), - before_index, - ); - assert_eq!( - std::fs::read(target.join("game/fast_gdd.md")).expect("re-read copied markdown"), - before_markdown, - ); - assert!( - !target_planning.join(PLAN_SESSION_PATH).exists(), - "身份失败前不得把 previous session 提升为 primary" - ); - } - - /// GUI 在决定落盘后紧接着重灌卡片,而决定本身正是放行 run 继续写盘的那一下—— - /// 刷新和续跑的 runner 会同时伸手拿同一把项目锁。这条断言的是短窗口内的争用要被 - /// 等过去:否则一次刚成功的审批会在卡里显示成 `项目正在被其他写操作占用`。 - #[test] - fn hydrate_rides_out_a_briefly_held_project_lock() { - let temporary = tempfile::tempdir().expect("create hydrate wait fixture"); - let root = temporary.path().join("project"); - crate::project::init_local_game_project_at(&root, "hydrate-wait", "锁等待项目") - .expect("initialize hydrate wait fixture"); - - // 两个字段都必须写真值,否则会被失效锁回收顺手删掉、锁根本占不住。 - let lock_path = root.join(".agent/project.lock"); - let held = serde_json::json!({ - "commandId": "test.hold", - "pid": std::process::id(), - "createdAt": unix_timestamp(), - "nonce": 0, - }); - std::fs::write( - &lock_path, - serde_json::to_vec(&held).expect("serialize held lock"), - ) - .expect("hold project lock"); - let holder = std::thread::spawn(move || { - std::thread::sleep(std::time::Duration::from_millis(120)); - std::fs::remove_file(&lock_path).expect("release project lock"); - }); - - let view = hydrate_game_creator_plan_gdd_state_at(&root) - .expect("hydrate 必须等过瞬时锁争用,而不是把失败画进审批卡"); - holder.join().expect("lock holder thread"); - - assert_eq!(view.state, "not_started"); - } - - #[test] - fn hydrate_redacts_project_paths_from_contended_project_lock_errors() { - let temporary = tempfile::tempdir().expect("create hydrate lock fixture"); - let root = temporary.path().join("project"); - crate::project::init_local_game_project_at(&root, "hydrate-lock", "锁竞争项目") - .expect("initialize hydrate lock fixture"); - - // `.agent/project.lock` 是 `create_new(true)` 的文件锁。hydrate 现在会等一个短 - // 窗口再放弃(见 `..._with_short_wait`),所以这里占住不放:等窗口耗尽,hydrate - // 的取锁确定性失败,这条用例考的是那条失败路径的脱敏与错误码形状。 - // - // 两个字段都必须写真值,否则会被失效锁回收顺手删掉、锁根本占不住: - // `pid` 供 unix 侧判 owner 是否存活;`createdAt` 供年龄判定—— - // `project_write_lock_age_seconds` 优先读这个 JSON 字段而**不是**文件 mtime, - // 填 0 会让锁显得有约 1.7e9 秒那么老,直接越过 600 秒的失效阈值。 - let lock_path = root.join(".agent/project.lock"); - let created_at = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("system clock before unix epoch") - .as_secs(); - let held = serde_json::json!({ - "commandId": "test.hold", - "pid": std::process::id(), - "createdAt": created_at, - "nonce": 0, - }); - std::fs::write( - &lock_path, - serde_json::to_vec(&held).expect("serialize held lock"), - ) - .expect("hold project lock"); - - let error = hydrate_game_creator_plan_gdd_state_at(&root) - .expect_err("被占用的项目锁必须让 hydrate 失败"); - let rendered = error.to_string(); - - // 方案 §18.3:返回值不包含绝对路径或内部诊断。 - assert!( - !rendered.contains(&root.display().to_string()), - "hydrate 错误不得回传项目绝对路径,实际为 {rendered}" - ); - assert!( - rendered.contains("$PROJECT_ROOT"), - "脱敏占位符应当保留,实际为 {rendered}" - ); - // hydrate 现在先取自身项目锁;错误仍须保持单一 typed code,不能被二次包装。 - assert_eq!( - rendered.matches("PLAN_STORAGE_IO").count(), - 1, - "typed 错误码不应重复拼接,实际为 {rendered}" - ); - } -} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_policy_v2.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_policy_v2.rs new file mode 100644 index 000000000..ed29b95d3 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_policy_v2.rs @@ -0,0 +1,2021 @@ +use super::*; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::fs; +use std::io::Write; +use std::path::{Path, PathBuf}; +use uuid::Uuid; + +pub(crate) const PLAN_GDD_V2_SCHEMA_VERSION: &str = "plan-gdd.v2"; +pub(crate) const PLAN_GDD_INDEX_V2_SCHEMA_VERSION: &str = "plan-gdd-index.v2"; +pub(crate) const PLAN_APPROVAL_V2_SCHEMA_VERSION: &str = "plan-approval.v2"; +const PLAN_GDD_V2_FINGERPRINT_DOMAIN: &str = "genarrative.plan.gdd.v2"; +const PLAN_APPROVAL_V2_FINGERPRINT_DOMAIN: &str = "genarrative.plan.approval.v2"; +const PLAN_GDD_V2_MAX_BYTES: usize = 64 * 1024; +const PLAN_GDD_V2_MAX_VERSIONS: u32 = 128; +pub(crate) const PLAN_ASK_QUESTION_TOOL_NAME: &str = "plan_ask_question"; +pub(crate) const PLAN_SUBMIT_GDD_TOOL_NAME: &str = "plan_submit_gdd"; + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct PlanningQuestionV2 { + pub id: String, + pub header: String, + pub question: String, + pub options: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct PlanningQuestionOptionV2 { + pub label: String, + pub description: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct PlanningGddDecisionInputV2 { + pub topic: String, + pub state: String, + #[serde(default)] + pub answer_source: Option, + pub answer_summary: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct PlanningPrototypeValidationItemInputV2 { + pub question: String, + pub micro_prototype: String, + pub observation: String, + pub pass_criterion: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct PlanningGddInputV2 { + #[serde(default)] + pub schema_version: Option, + pub game: PlanSubmitGame, + pub decisions: Vec, + pub prototype_validation_items: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct PlanningGddV2 { + pub schema_version: String, + pub project_id: String, + pub gdd_id: String, + pub version: u32, + pub created_at_utc: String, + pub game: PlanGddGame, + pub decisions: Vec, + pub prototype_validation_items: Vec, + pub fingerprint: String, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct PlanningGddFingerprintValueV2<'a> { + schema_version: &'a str, + project_id: &'a str, + gdd_id: &'a str, + version: u32, + created_at_utc: &'a str, + game: &'a PlanGddGame, + decisions: &'a Vec, + prototype_validation_items: &'a Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct PlanningGddIndexV2 { + schema_version: String, + project_id: String, + latest_version: Option, + entries: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct PlanningGddIndexEntryV2 { + gdd_id: String, + version: u32, + fingerprint: String, + status: String, + created_at_utc: String, + decision_id: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct PlanningApprovalV2 { + pub schema_version: String, + pub project_id: String, + pub session_id: String, + pub artifact_id: String, + pub version: u32, + pub fingerprint: String, + pub decision_id: String, + pub action: String, + pub comment: Option, + pub decided_at_utc: String, + pub receipt_fingerprint: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct DecidePlanningArtifactV2Input { + pub session_id: String, + pub artifact_id: String, + pub version: u32, + pub fingerprint: String, + pub decision_id: String, + pub action: String, + pub comment: Option, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PlanningApprovalCommandResultV2 { + pub approval: PlanningApprovalV2, + pub session: PlanningSessionV2, + pub current_artifact: Option, + pub replayed: bool, +} + +#[derive(Clone, Debug)] +pub(crate) enum PlanningPolicyOutputV2 { + Question(PlanningQuestionV2), + Gdd(PlanningGddInputV2), +} + +#[derive(Clone, Debug)] +pub(crate) struct PlanningPolicyPersistedV2 { + pub session: PlanningSessionV2, + pub result: PlanningTurnResultV2, + pub current_artifact: Option, +} + +fn v2_path(root: &Path, relative: &str) -> PathBuf { + root.join(".agent/planning-v2").join(relative) +} + +fn decision_state_schema() -> Value { + serde_json::json!({ + "type": "string", + "enum": ["confirmed", "assumption_pending", "prototype_pending"], + "description": "confirmed=已确认;assumption_pending=Agent 推断待确认;prototype_pending=待原型验证" + }) +} + +pub(crate) fn planning_v2_function_tools() -> Vec { + let decision_state = decision_state_schema(); + vec![ + platform_llm::LlmFunctionTool::new( + PLAN_ASK_QUESTION_TOOL_NAME, + "向用户提出一个关键问题。options 可包含一项「需要原型验证」,表示先做小原型验证。", + serde_json::json!({ + "type": "object", + "additionalProperties": false, + "properties": { + "question": { + "type": "object", + "additionalProperties": false, + "description": "决策卡内容,只问一个最关键的问题", + "properties": { + "id": {"type": "string", "description": "snake_case 问题 id"}, + "header": {"type": "string", "description": "当前要决定的主题"}, + "question": {"type": "string", "description": "问用户的问题正文"}, + "options": { + "type": "array", + "description": "可选方案", + "minItems": 2, + "maxItems": 4, + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "label": {"type": "string", "description": "选项标签"}, + "description": {"type": "string", "description": "该选项的具体方案"} + }, + "required": ["label", "description"] + } + } + }, + "required": ["id", "header", "question", "options"] + } + }, + "required": ["question"] + }), + ), + platform_llm::LlmFunctionTool::new( + PLAN_SUBMIT_GDD_TOOL_NAME, + "提交完整 GDD。", + serde_json::json!({ + "type": "object", + "additionalProperties": false, + "properties": { + "gdd": { + "type": "object", + "additionalProperties": false, + "description": "完整 GDD,按字段填全,不要增删或改名", + "properties": { + "game": { + "type": "object", + "additionalProperties": false, + "description": "玩法方案", + "properties": { + "title": {"type": "string", "description": "中文标题"}, + "genre": { + "type": "object", + "additionalProperties": false, + "description": "游戏类型", + "properties": { + "primary": {"type": "string", "description": "主类型"}, + "fusion": { + "type": ["string", "null"], + "description": "融合类型,没有则为 null" + } + }, + "required": ["primary"] + }, + "artStyle": { + "type": "object", + "additionalProperties": false, + "description": "美术风格", + "properties": { + "visualType": {"type": "string", "description": "视觉类型"}, + "keywords": { + "type": "array", + "description": "风格关键词", + "minItems": 1, + "maxItems": 5, + "items": {"type": "string"} + }, + "moodAndColor": {"type": "string", "description": "氛围与色彩"}, + "mvpArtBoundary": {"type": "string", "description": "MVP 美术边界"} + }, + "required": ["visualType", "keywords", "moodAndColor", "mvpArtBoundary"] + }, + "oneLiner": {"type": "string", "minLength": 25, "maxLength": 90, "description": "一句话概念(建议 25~90 字;Runtime 实际接受 10~160 字,两者有意不对称,不是缺陷或 bug)"}, + "pillars": { + "type": "array", + "description": "游戏支柱", + "minItems": 1, + "maxItems": 5, + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "name": {"type": "string", "description": "支柱名称"}, + "playerFeel": {"type": "string", "description": "玩家感受"}, + "mechanism": {"type": "string", "description": "实现机制"}, + "decisionState": decision_state.clone() + }, + "required": ["name", "playerFeel", "mechanism", "decisionState"] + } + }, + "coreLoop": { + "type": "array", + "description": "单局核心循环步骤", + "minItems": 1, + "maxItems": 7, + "items": {"type": "string"} + }, + "targetUsers": { + "type": "object", + "additionalProperties": false, + "description": "目标用户", + "properties": { + "coreUsers": {"type": "string", "description": "核心用户"}, + "preferences": {"type": "string", "description": "用户偏好"}, + "sessionLength": {"type": "string", "description": "单局时长"}, + "referenceGames": { + "type": "array", + "description": "参考游戏,没有则为 []", + "items": {"type": "string"} + } + }, + "required": ["coreUsers", "preferences", "sessionLength", "referenceGames"] + }, + "mvpSystems": { + "type": "array", + "description": "MVP 必须有的系统", + "minItems": 1, + "maxItems": 7, + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "system": {"type": "string", "description": "系统名称"}, + "minimalFunction": {"type": "string", "description": "最小功能"}, + "whyRequired": {"type": "string", "description": "为什么必须有"}, + "verifyMethod": {"type": "string", "description": "验证方式"}, + "decisionState": decision_state.clone() + }, + "required": [ + "system", + "minimalFunction", + "whyRequired", + "verifyMethod", + "decisionState" + ] + } + }, + "outOfScope": { + "type": "array", + "description": "暂不做的内容", + "minItems": 0, + "maxItems": 10, + "items": {"type": "string"} + }, + "creatorTips": { + "type": "object", + "additionalProperties": false, + "description": "给创作者的提示", + "properties": { + "doFirst": {"type": "string", "description": "先做什么"}, + "deferForNow": {"type": "string", "description": "暂缓什么"}, + "howToVerify": {"type": "string", "description": "如何验证"}, + "expandWhen": {"type": "string", "description": "何时扩展"} + }, + "required": ["doFirst", "deferForNow", "howToVerify", "expandWhen"] + } + }, + "required": [ + "title", + "genre", + "artStyle", + "oneLiner", + "pillars", + "coreLoop", + "targetUsers", + "mvpSystems", + "outOfScope", + "creatorTips" + ] + }, + "decisions": { + "type": "array", + "description": "已确认或待确认的决定", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "topic": {"type": "string", "description": "主题"}, + "state": decision_state.clone(), + "answerSource": { + "type": "string", + "description": "来源标记,如 user_freeform、user_choice、agent_inferred" + }, + "answerSummary": {"type": "string", "description": "结论摘要"} + }, + "description": "决定内容", + "required": ["topic", "state", "answerSummary"] + } + }, + "prototypeValidationItems": { + "type": "array", + "description": "无 prototype_pending 时为 [];有则按 decisions 中 prototype_pending 决定的顺序填写", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "question": {"type": "string", "description": "要验证的问题"}, + "microPrototype": {"type": "string", "description": "最小原型做法"}, + "observation": {"type": "string", "description": "观察什么"}, + "passCriterion": {"type": "string", "description": "通过标准"} + }, + "required": ["question", "microPrototype", "observation", "passCriterion"] + } + } + }, + "required": ["game", "decisions", "prototypeValidationItems"] + } + }, + "required": ["gdd"] + }), + ), + ] +} + +fn parse_tool_arguments(raw: &Value) -> Result { + match raw { + Value::String(text) => serde_json::from_str(text) + .map_err(|error| format!("PLANNING_INVALID_OUTPUT: 工具参数不是合法 JSON:{error}")), + Value::Object(_) => Ok(raw.clone()), + _ => Err("PLANNING_INVALID_OUTPUT: 工具参数必须是 JSON object".to_string()), + } +} + +pub(crate) fn parse_planning_policy_output_v2( + result: &PlanningTurnResultV2, +) -> Result { + let calls = result + .payload + .get("toolCalls") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + if calls.len() != 1 { + return Err( + "PLANNING_INVALID_OUTPUT: 必须且只能调用 plan_ask_question 或 plan_submit_gdd 其中一个,不要在正文输出 JSON" + .to_string(), + ); + } + let call = &calls[0]; + let name = call.get("name").and_then(Value::as_str).unwrap_or(""); + let args = parse_tool_arguments(call.get("arguments").unwrap_or(&Value::Null))?; + match name { + PLAN_ASK_QUESTION_TOOL_NAME => { + let question_value = args.get("question").cloned().ok_or_else(|| { + "PLANNING_INVALID_QUESTION: plan_ask_question 缺少 question 参数".to_string() + })?; + let question = + serde_json::from_value::(question_value).map_err(|error| { + format!("PLANNING_INVALID_QUESTION: question 结构无效:{error}") + })?; + validate_question_v2(&question)?; + Ok(PlanningPolicyOutputV2::Question(question)) + } + PLAN_SUBMIT_GDD_TOOL_NAME => { + let gdd_value = args + .get("gdd") + .cloned() + .ok_or_else(|| "PLANNING_INVALID_GDD: plan_submit_gdd 缺少 gdd 参数".to_string())?; + let gdd = serde_json::from_value::(gdd_value) + .map_err(|error| format!("PLANNING_INVALID_GDD: GDD 结构无效:{error}"))?; + Ok(PlanningPolicyOutputV2::Gdd(gdd)) + } + _ => Err(format!("PLANNING_INVALID_OUTPUT: 未知策划工具:{name}")), + } +} + +fn validate_question_v2(question: &PlanningQuestionV2) -> Result<(), String> { + let id = &question.id; + if id.len() > 32 + || !id.chars().enumerate().all(|(index, value)| { + (index == 0 && value.is_ascii_lowercase()) + || (index > 0 + && (value.is_ascii_lowercase() || value.is_ascii_digit() || value == '_')) + }) + { + return Err("PLANNING_INVALID_QUESTION: question.id 必须是 snake_case".to_string()); + } + validate_text(&question.id, "question.id", 1, 32).map_err(|error| error.to_string())?; + validate_text(&question.header, "question.header", 1, 120) + .map_err(|error| error.to_string())?; + validate_text(&question.question, "question.question", 1, 400) + .map_err(|error| error.to_string())?; + if !(2..=4).contains(&question.options.len()) { + return Err("PLANNING_INVALID_QUESTION: options 必须有 2~4 项".to_string()); + } + let mut labels = std::collections::BTreeSet::new(); + for option in &question.options { + validate_text(&option.label, "question.options.label", 1, 80) + .map_err(|error| error.to_string())?; + validate_text(&option.description, "question.options.description", 1, 400) + .map_err(|error| error.to_string())?; + if !labels.insert(option.label.as_str()) { + return Err("PLANNING_INVALID_QUESTION: options.label 不能重复".to_string()); + } + } + Ok(()) +} + +pub(crate) fn validate_question_value_v2(value: &Value) -> Result<(), String> { + let question = serde_json::from_value::(value.clone()) + .map_err(|error| format!("PLANNING_INVALID_QUESTION: question 结构无效:{error}"))?; + validate_question_v2(&question) +} + +fn normalize_v2_decision_state(value: &str) -> Result { + match value.trim() { + "confirmed" => Ok("confirmed".to_string()), + "assumption_pending" => Ok("assumption_pending".to_string()), + "prototype_pending" => Ok("prototype_pending".to_string()), + value => Err(format!( + "PLANNING_INVALID_GDD: decision state 无效:{value}" + )), + } +} + +fn normalize_answer_source(state: &str, source: Option) -> Result { + let state = normalize_v2_decision_state(state)?; + let fallback = match state.as_str() { + "confirmed" => "user_freeform".to_string(), + "assumption_pending" => "agent_inferred".to_string(), + "prototype_pending" => "user_option".to_string(), + _ => unreachable!("normalize_v2_decision_state returned an unknown state"), + }; + let source = source + .map(|value| value.trim().to_string()) + .filter(|value| { + matches!( + value.as_str(), + "user_option" | "user_freeform" | "user_revision" | "agent_inferred" + ) + }) + .unwrap_or(fallback); + Ok(match state.as_str() { + // assumption_pending 的含义就是“不是用户明确决定,而是 Agent 推断”, + // 因此无论模型是否漏填/错填来源,都统一落成 agent_inferred。 + "assumption_pending" => "agent_inferred".to_string(), + // agent_inferred 不能与 confirmed 或 prototype_pending 自相矛盾;这两类 + // 状态分别回退到最接近的用户来源,但不把来源不一致当作阻断错误。 + "confirmed" if source == "agent_inferred" => "user_freeform".to_string(), + "prototype_pending" if source == "agent_inferred" => "user_option".to_string(), + _ => source, + }) +} + +fn planning_gdd_game_from_input(game: &PlanSubmitGame) -> Result { + Ok(PlanGddGame { + title: game.title.clone(), + genre: game.genre.clone(), + art_style: game.art_style.clone(), + one_liner: game.one_liner.clone(), + pillars: game + .pillars + .iter() + .map(|value| { + Ok(PlanPillar { + name: value.name.clone(), + player_feel: value.player_feel.clone(), + mechanism: value.mechanism.clone(), + decision_state: normalize_v2_decision_state(&value.decision_state)?, + basis: None, + }) + }) + .collect::, String>>()?, + core_loop: game.core_loop.clone(), + target_users: game.target_users.clone(), + platform_facts: fixed_plan_platform_facts(), + mvp_systems: game + .mvp_systems + .iter() + .map(|value| { + Ok(PlanMvpSystem { + system: value.system.clone(), + minimal_function: value.minimal_function.clone(), + why_required: value.why_required.clone(), + verify_method: value.verify_method.clone(), + decision_state: normalize_v2_decision_state(&value.decision_state)?, + basis: None, + }) + }) + .collect::, String>>()?, + out_of_scope: game.out_of_scope.clone(), + creator_tips: game.creator_tips.clone(), + }) +} + +fn validate_v2_game(game: &PlanGddGame) -> Result<(), String> { + // `validate_plan_game` 属于旧存储模块;V2 先独立校验自己的状态枚举,再用 + // 中性的 confirmed 占位调用旧结构校验器。V2 输入、产物和 Markdown 永远使用 + // assumption_pending,不把 V1 的默认值语义带入 V2。 + let mut storage_compatible = game.clone(); + for pillar in &mut storage_compatible.pillars { + normalize_v2_decision_state(&pillar.decision_state)?; + pillar.decision_state = "confirmed".to_string(); + } + for system in &mut storage_compatible.mvp_systems { + normalize_v2_decision_state(&system.decision_state)?; + system.decision_state = "confirmed".to_string(); + } + validate_plan_game(&storage_compatible) + .map_err(|error| format!("PLANNING_INVALID_GDD: {error}")) +} + +fn validate_v2_decision_content( + index: usize, + topic: &str, + state: &str, + answer_source: Option, + answer_summary: &str, +) -> Result<(), String> { + validate_text(topic, &format!("decisions[{index}].topic"), 1, 400) + .map_err(|error| error.to_string())?; + normalize_v2_decision_state(state)?; + normalize_answer_source(state, answer_source)?; + validate_text( + answer_summary, + &format!("decisions[{index}].answerSummary"), + 1, + if index == 0 { + PLAN_INITIAL_REQUEST_MAX_CHARS + } else { + PLAN_DECISION_ANSWER_SUMMARY_MAX_CHARS + }, + ) + .map_err(|error| error.to_string())?; + Ok(()) +} + +fn validate_v2_prototype_content( + question: &str, + micro_prototype: &str, + observation: &str, + pass_criterion: &str, +) -> Result<(), String> { + for (label, value) in [ + ("question", question), + ("microPrototype", micro_prototype), + ("observation", observation), + ("passCriterion", pass_criterion), + ] { + validate_text(value, &format!("prototypeValidationItems.{label}"), 1, 800) + .map_err(|error| error.to_string())?; + } + Ok(()) +} + +fn validate_v2_decision_inputs( + decisions: &[PlanningGddDecisionInputV2], + prototype_items: &[PlanningPrototypeValidationItemInputV2], +) -> Result<(), String> { + if !(1..=64).contains(&decisions.len()) { + return Err("PLANNING_INVALID_GDD: decisions 必须有 1~64 项".to_string()); + } + for (index, decision) in decisions.iter().enumerate() { + validate_v2_decision_content( + index, + &decision.topic, + &decision.state, + decision.answer_source.clone(), + &decision.answer_summary, + )?; + } + let prototype_count = decisions + .iter() + .filter(|decision| decision.state.trim() == "prototype_pending") + .count(); + if prototype_items.len() > 16 || prototype_count != prototype_items.len() { + return Err("PLANNING_INVALID_GDD: prototypeValidationItems 必须逐项对应 prototype_pending 决定且最多 16 项".to_string()); + } + for item in prototype_items { + validate_v2_prototype_content( + &item.question, + &item.micro_prototype, + &item.observation, + &item.pass_criterion, + )?; + } + Ok(()) +} + +fn validate_v2_durable_decisions( + decisions: &[PlanDecision], + prototype_items: &[PlanPrototypeValidationItem], +) -> Result<(), String> { + // Provider 输入不携带 ID;这里只校验 Runtime 已生成或已持久化产物的 + // 内部引用完整性,避免把模型负责的内容校验与产物身份校验混在一起。 + if !(1..=64).contains(&decisions.len()) { + return Err("PLANNING_INVALID_GDD: decisions 必须有 1~64 项".to_string()); + } + + let mut prototype_ids = std::collections::BTreeSet::new(); + for (index, decision) in decisions.iter().enumerate() { + validate_v2_decision_content( + index, + &decision.topic, + &decision.state, + Some(decision.answer_source.clone()), + &decision.answer_summary, + )?; + if decision.state == "prototype_pending" { + prototype_ids.insert(decision.id.as_str()); + } + } + let mut item_ids = std::collections::BTreeSet::new(); + if prototype_ids.len() != prototype_items.len() || prototype_items.len() > 16 { + return Err("PLANNING_INVALID_GDD: prototypeValidationItems 必须逐项对应 prototype_pending 决定且最多 16 项".to_string()); + } + for item in prototype_items { + if !item_ids.insert(item.id.as_str()) || !prototype_ids.contains(item.id.as_str()) { + return Err("PLANNING_INVALID_GDD: prototypeValidationItems.id 必须与 prototype_pending 决定双射".to_string()); + } + validate_v2_prototype_content( + &item.question, + &item.micro_prototype, + &item.observation, + &item.pass_criterion, + )?; + } + Ok(()) +} + +fn runtime_decision_id(index: usize) -> String { + if index == 0 { + "initial-request".to_string() + } else { + format!("decision-{index}") + } +} + +fn build_gdd_v2( + project_id: &str, + version: u32, + input: PlanningGddInputV2, +) -> Result { + validate_planning_policy_output_v2(&PlanningPolicyOutputV2::Gdd(input.clone()))?; + let game = planning_gdd_game_from_input(&input.game)?; + validate_v2_game(&game)?; + let decisions = input + .decisions + .into_iter() + .enumerate() + .map(|(index, value)| { + Ok(PlanDecision { + id: runtime_decision_id(index), + topic: value.topic, + state: normalize_v2_decision_state(&value.state)?, + answer_source: normalize_answer_source(&value.state, value.answer_source)?, + // round 是 Runtime 元数据:首项代表初始需求,其余决策按 + // 输出顺序归入当前最多 3 轮的澄清区间,不接受模型自行指定。 + round: (index as u32).min(3), + answer_summary: value.answer_summary, + basis: None, + }) + }) + .collect::, String>>()?; + let prototype_ids = decisions + .iter() + .filter(|decision| decision.state == "prototype_pending") + .map(|decision| decision.id.clone()) + .collect::>(); + let prototype_validation_items = input + .prototype_validation_items + .into_iter() + .zip(prototype_ids) + .map(|(value, id)| PlanPrototypeValidationItem { + id, + question: value.question, + micro_prototype: value.micro_prototype, + observation: value.observation, + pass_criterion: value.pass_criterion, + }) + .collect::>(); + let mut gdd = PlanningGddV2 { + schema_version: PLAN_GDD_V2_SCHEMA_VERSION.to_string(), + project_id: project_id.to_string(), + gdd_id: format!("gdd-{}", Uuid::new_v4()), + version, + created_at_utc: current_plan_timestamp_utc(), + game, + decisions, + prototype_validation_items, + fingerprint: String::new(), + }; + gdd.fingerprint = fingerprint_gdd_v2(&gdd)?; + Ok(gdd) +} + +pub(crate) fn validate_planning_policy_output_v2( + output: &PlanningPolicyOutputV2, +) -> Result<(), String> { + match output { + PlanningPolicyOutputV2::Question(question) => validate_question_v2(question), + PlanningPolicyOutputV2::Gdd(input) => { + validate_v2_decision_inputs(&input.decisions, &input.prototype_validation_items)?; + let game = planning_gdd_game_from_input(&input.game)?; + validate_v2_game(&game) + } + } +} + +fn fingerprint_gdd_v2(value: &PlanningGddV2) -> Result { + typed_serde_fingerprint( + PLAN_GDD_V2_FINGERPRINT_DOMAIN, + &PlanningGddFingerprintValueV2 { + schema_version: &value.schema_version, + project_id: &value.project_id, + gdd_id: &value.gdd_id, + version: value.version, + created_at_utc: &value.created_at_utc, + game: &value.game, + decisions: &value.decisions, + prototype_validation_items: &value.prototype_validation_items, + }, + ) + .map_err(|error| error.to_string()) +} + +fn validate_gdd_v2(value: &PlanningGddV2, project_id: &str) -> Result<(), String> { + if value.schema_version != PLAN_GDD_V2_SCHEMA_VERSION + || value.project_id != project_id + || !(1..=PLAN_GDD_V2_MAX_VERSIONS).contains(&value.version) + || !is_typed_fingerprint(&value.fingerprint) + { + return Err("PLANNING_INVALID_GDD: V2 GDD 身份或 schema 无效".to_string()); + } + validate_uuid_prefixed(&value.gdd_id, "gdd-", "gddId").map_err(|error| error.to_string())?; + validate_timestamp(&value.created_at_utc, "createdAtUtc").map_err(|error| error.to_string())?; + validate_v2_game(&value.game)?; + validate_v2_durable_decisions(&value.decisions, &value.prototype_validation_items)?; + let expected = fingerprint_gdd_v2(value)?; + if expected != value.fingerprint { + return Err("PLANNING_INVALID_GDD: GDD fingerprint 不匹配".to_string()); + } + Ok(()) +} + +fn canonical_gdd_v2_bytes(value: &PlanningGddV2) -> Result, String> { + let bytes = serde_json::to_vec(value).map_err(|error| error.to_string())?; + if bytes.len() > PLAN_GDD_V2_MAX_BYTES { + return Err("PLANNING_SIZE_LIMIT: V2 GDD 超过 64 KiB".to_string()); + } + Ok(bytes) +} + +fn create_v2_immutable_file(root: &Path, relative: &str, bytes: &[u8]) -> Result<(), String> { + let path = v2_path(root, relative); + let parent = path + .parent() + .ok_or_else(|| "V2 文件缺少父目录".to_string())?; + ensure_game_creator_private_directory_tree(parent, "Planning V2 目录")?; + prepare_game_creator_private_path_for_read(parent, true, "Planning V2 目录")?; + match fs::OpenOptions::new() + .create_new(true) + .write(true) + .open(&path) + { + Ok(mut file) => { + // `create_new` has already made the final path visible. Every + // subsequent step must therefore be transactional from the + // caller's perspective: a failed ACL repair, partial write, or + // failed sync must not leave an empty/truncated immutable file + // that would permanently block a retry. + let result = (|| { + harden_new_game_creator_private_path(&path, false, "Planning V2 文件")?; + file.write_all(bytes) + .and_then(|_| file.sync_data()) + .map_err(|error| { + format!("写入 Planning V2 文件失败:{}: {error}", path.display()) + })?; + Ok(()) + })(); + if result.is_err() { + let _ = fs::remove_file(&path); + } + result + } + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { + prepare_game_creator_private_path_for_read(&path, false, "Planning V2 文件")?; + let existing = fs::read(&path).map_err(|read_error| { + format!( + "读取已存在 Planning V2 文件失败:{}: {read_error}", + path.display() + ) + })?; + if existing == bytes { + Ok(()) + } else { + Err(format!( + "Planning V2 不可变文件已存在且内容不同:{}", + path.display() + )) + } + } + Err(error) => Err(format!( + "创建 Planning V2 文件失败:{}: {error}", + path.display() + )), + } +} + +fn read_gdd_v2(root: &Path, version: u32) -> Result { + try_read_gdd_v2(root, version)?.ok_or_else(|| { + format!( + "读取 V2 GDD 失败:{}", + v2_path(root, &format!("gdd.v{version}.json")).display() + ) + }) +} + +fn try_read_gdd_v2(root: &Path, version: u32) -> Result, String> { + let path = v2_path(root, &format!("gdd.v{version}.json")); + if !prepare_game_creator_private_path_for_read(&path, false, "Planning V2 GDD")? { + return Ok(None); + } + let metadata = + fs::metadata(&path).map_err(|error| format!("读取 V2 GDD 元数据失败:{error}"))?; + if metadata.len() > PLAN_GDD_V2_MAX_BYTES as u64 { + return Err("PLANNING_SIZE_LIMIT: V2 GDD 超过 64 KiB".to_string()); + } + let bytes = fs::read(&path) + .map_err(|error| format!("读取 V2 GDD 失败:{}: {error}", path.display()))?; + let gdd = serde_json::from_slice::(&bytes) + .map_err(|error| format!("解析 V2 GDD 失败:{error}"))?; + let project_id = read_manifest_for_project(root)?.project_id; + validate_gdd_v2(&gdd, &project_id)?; + if gdd.version != version { + return Err("PLANNING_INVALID_GDD: GDD 文件名与 version 不一致".to_string()); + } + Ok(Some(gdd)) +} + +fn next_gdd_version_v2(session: &PlanningSessionV2) -> Result { + let next_version = session + .current_artifact_version + .map(|value| value.saturating_add(1)) + .unwrap_or(1); + u32::try_from(next_version).map_err(|_| "PLANNING_VERSION_LIMIT: GDD 版本超出范围".to_string()) +} + +fn gdd_projection_status_v2( + root: &Path, + gdd: &PlanningGddV2, +) -> Result<(&'static str, Option), String> { + Ok(match read_approval_v2(root, gdd.version)? { + Some(approval) => ( + match approval.action.as_str() { + "approve" => "approved", + "reject" => "rejected", + _ => "revision_requested", + }, + Some(approval.decision_id), + ), + None => ("ready_for_approval", None), + }) +} + +fn session_status_for_gdd_projection_v2(status: &str) -> String { + match status { + "ready_for_approval" => "awaiting_approval".to_string(), + other => other.to_string(), + } +} + +fn conversation_has_gdd_artifact_v2(messages: &[PlanningMessageV2], version: u32) -> bool { + messages.iter().any(|message| { + message.kind == "artifact" + && message.payload.get("version").and_then(Value::as_u64) == Some(u64::from(version)) + }) +} + +fn recover_gdd_client_turn_id_v2( + root: &Path, + turn_index: u64, + version: u32, +) -> Result { + let messages = read_planning_messages_v2(root)?; + if let Some(message) = messages.iter().rev().find(|message| { + message.kind == "artifact" + && message.payload.get("version").and_then(Value::as_u64) == Some(u64::from(version)) + }) { + return Ok(message.client_turn_id.clone()); + } + if let Some(message) = messages + .iter() + .rev() + .find(|message| message.role == "user" && message.turn_index == turn_index) + { + return Ok(message.client_turn_id.clone()); + } + if let Some(message) = messages.iter().rev().find(|message| message.role == "user") { + return Ok(message.client_turn_id.clone()); + } + Ok(format!("planning-v2-recover-v{version}")) +} + +fn project_committed_gdd_v2( + root: &Path, + session: &mut PlanningSessionV2, + client_turn_id: &str, + turn_index: u64, + elapsed_seconds: f64, + gdd: &PlanningGddV2, +) -> Result { + let (status, decision_id) = gdd_projection_status_v2(root, gdd)?; + update_index_v2(root, gdd, status, decision_id)?; + let markdown = render_gdd_v2_markdown(gdd, status)?; + write_plan_fast_gdd_markdown_atomic_locked(root, &markdown) + .map_err(|error| error.to_string())?; + let artifact = artifact_value_v2(gdd, status); + let messages = read_planning_messages_v2(root)?; + if !conversation_has_gdd_artifact_v2(&messages, gdd.version) { + append_planning_message_v2( + root, + &PlanningMessageV2 { + schema_version: PLANNING_MESSAGE_V2_SCHEMA_VERSION.to_string(), + message_id: format!("msg-{}", Uuid::new_v4().simple()), + client_turn_id: client_turn_id.to_string(), + turn_index, + at_utc: current_plan_timestamp_utc(), + role: "assistant".to_string(), + kind: "artifact".to_string(), + payload: artifact.clone(), + }, + )?; + } + session.current_artifact_version = Some(u64::from(gdd.version)); + session.current_question = None; + session.status = session_status_for_gdd_projection_v2(status); + if elapsed_seconds > 0.0 { + session.processing_seconds += elapsed_seconds.max(0.0); + } + session.updated_at_utc = current_plan_timestamp_utc(); + session.last_error = None; + write_planning_session_v2(root, session)?; + Ok(PlanningPolicyPersistedV2 { + session: session.clone(), + result: PlanningTurnResultV2 { + schema_version: PLANNING_TURN_RESULT_V2_SCHEMA_VERSION.to_string(), + kind: "artifact".to_string(), + payload: artifact.clone(), + }, + current_artifact: Some(artifact), + }) +} + +/// 调用方必须已持有项目写锁。`gdd.v{N}.json` 一旦创建即为提交点: +/// 只认领 session 指针的下一个连续版本,补投影,不重新生成身份。 +pub(crate) fn reconcile_committed_planning_gdd_v2( + root: &Path, +) -> Result, String> { + let Some(mut session) = read_planning_session_v2(root)? else { + return Ok(None); + }; + if matches!(session.status.as_str(), "approved" | "rejected") { + return Ok(None); + } + let next_version = next_gdd_version_v2(&session)?; + let Some(gdd) = try_read_gdd_v2(root, next_version)? else { + return Ok(None); + }; + let turn_index = session.turn_index; + let client_turn_id = recover_gdd_client_turn_id_v2(root, turn_index, gdd.version)?; + project_committed_gdd_v2(root, &mut session, &client_turn_id, turn_index, 0.0, &gdd)?; + Ok(Some(gdd)) +} + +fn read_index_v2(root: &Path) -> Result { + let path = v2_path(root, "index.json"); + let project_id = read_manifest_for_project(root)?.project_id; + prepare_game_creator_private_path_for_read(&path, false, "Planning V2 GDD index")?; + match fs::read(&path) { + Ok(bytes) => { + let index = serde_json::from_slice::(&bytes) + .map_err(|error| format!("解析 V2 GDD index 失败:{error}"))?; + if index.schema_version != PLAN_GDD_INDEX_V2_SCHEMA_VERSION + || index.project_id != project_id + { + return Err("PLANNING_INVALID_INDEX: V2 GDD index 身份无效".to_string()); + } + Ok(index) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(PlanningGddIndexV2 { + schema_version: PLAN_GDD_INDEX_V2_SCHEMA_VERSION.to_string(), + project_id, + latest_version: None, + entries: Vec::new(), + }), + Err(error) => Err(format!( + "读取 V2 GDD index 失败:{}: {error}", + path.display() + )), + } +} + +fn write_index_v2(root: &Path, index: &PlanningGddIndexV2) -> Result<(), String> { + let bytes = + serde_json::to_vec(index).map_err(|error| format!("序列化 V2 GDD index 失败:{error}"))?; + if bytes.len() > 256 * 1024 { + return Err("PLANNING_SIZE_LIMIT: V2 GDD index 超过 256 KiB".to_string()); + } + write_game_creator_private_file( + &v2_path(root, "index.json"), + &bytes, + "Planning V2 GDD index", + ) +} + +fn update_index_v2( + root: &Path, + gdd: &PlanningGddV2, + status: &str, + decision_id: Option, +) -> Result<(), String> { + let mut index = read_index_v2(root)?; + if let Some(entry) = index + .entries + .iter_mut() + .find(|entry| entry.version == gdd.version) + { + entry.status = status.to_string(); + entry.decision_id = decision_id; + } else { + index.entries.push(PlanningGddIndexEntryV2 { + gdd_id: gdd.gdd_id.clone(), + version: gdd.version, + fingerprint: gdd.fingerprint.clone(), + status: status.to_string(), + created_at_utc: gdd.created_at_utc.clone(), + decision_id, + }); + } + index.entries.sort_by_key(|entry| entry.version); + index.latest_version = index.entries.last().map(|entry| entry.version); + write_index_v2(root, &index) +} + +fn index_status_v2(root: &Path, version: u32) -> Result { + Ok(read_index_v2(root)? + .entries + .into_iter() + .find(|entry| entry.version == version) + .map(|entry| entry.status) + .unwrap_or_else(|| "ready_for_approval".to_string())) +} + +fn markdown_escape_v2(value: &str) -> String { + value + .replace('\\', "\\\\") + .replace('`', "\\`") + .replace('*', "\\*") + .replace('_', "\\_") + .replace('|', "\\|") +} + +fn render_gdd_v2_markdown(gdd: &PlanningGddV2, status: &str) -> Result { + validate_gdd_v2(gdd, &gdd.project_id)?; + let mut output = format!( + "# {}\n\n> Fast GDD v{} · 状态:{}\n> gddId:`{}`\n> fingerprint:`{}`\n\n", + markdown_escape_v2(&gdd.game.title), + gdd.version, + markdown_escape_v2(status), + gdd.gdd_id, + gdd.fingerprint + ); + output.push_str("## 决定状态\n\n"); + for decision in &gdd.decisions { + output.push_str(&format!( + "- **{}**({},{},第 {} 轮):{}\n", + markdown_escape_v2(&decision.topic), + decision.state, + decision.answer_source, + decision.round, + markdown_escape_v2(&decision.answer_summary) + )); + } + output.push_str(&format!( + "\n## 一句话描述\n\n{}\n\n", + markdown_escape_v2(&gdd.game.one_liner) + )); + output.push_str("## 核心循环\n\n"); + for (index, step) in gdd.game.core_loop.iter().enumerate() { + output.push_str(&format!("{}. {}\n", index + 1, markdown_escape_v2(step))); + } + output.push_str("\n## MVP 系统\n\n"); + for system in &gdd.game.mvp_systems { + output.push_str(&format!( + "### {}\n\n- 最小功能:{}\n- 必要原因:{}\n- 验证方式:{}\n\n", + markdown_escape_v2(&system.system), + markdown_escape_v2(&system.minimal_function), + markdown_escape_v2(&system.why_required), + markdown_escape_v2(&system.verify_method) + )); + } + output.push_str("## 制作边界\n\n"); + for item in &gdd.game.out_of_scope { + output.push_str(&format!("- {}\n", markdown_escape_v2(item))); + } + if !gdd.prototype_validation_items.is_empty() { + output.push_str("\n## 原型验证项\n\n"); + for item in &gdd.prototype_validation_items { + output.push_str(&format!( + "### {}\n\n- 问题:{}\n- 微型原型:{}\n- 观察:{}\n- 通过标准:{}\n\n", + markdown_escape_v2(&item.id), + markdown_escape_v2(&item.question), + markdown_escape_v2(&item.micro_prototype), + markdown_escape_v2(&item.observation), + markdown_escape_v2(&item.pass_criterion) + )); + } + } + if output.len() > PLAN_FAST_GDD_MAX_BYTES { + return Err("PLANNING_SIZE_LIMIT: Fast GDD Markdown 超过大小上限".to_string()); + } + Ok(output) +} + +fn artifact_value_v2(gdd: &PlanningGddV2, status: &str) -> Value { + serde_json::json!({ + "artifactId": gdd.gdd_id, + "kind": "gdd", + "version": gdd.version, + "status": status, + "fingerprint": gdd.fingerprint, + "payload": gdd, + }) +} + +pub(crate) fn current_planning_artifact_v2(root: &Path) -> Result, String> { + let Some(session) = read_planning_session_v2(root)? else { + return Ok(None); + }; + let Some(version) = session.current_artifact_version else { + return Ok(None); + }; + let version = u32::try_from(version) + .map_err(|_| "PLANNING_INVALID_GDD: artifact version 超出范围".to_string())?; + let gdd = read_gdd_v2(root, version)?; + Ok(Some(artifact_value_v2( + &gdd, + &index_status_v2(root, version)?, + ))) +} + +pub(crate) fn persist_planning_policy_output_v2( + root: &Path, + client_turn_id: &str, + session_id: &str, + turn_index: u64, + elapsed_seconds: f64, + output: PlanningPolicyOutputV2, +) -> Result { + // Provider 已经成功返回;落盘是一次性提交点。hydrate / 审批续跑会同时伸手 + // 拿项目锁,无等待取锁会把瞬时争用变成“总控执行失败”。 + let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "planning.v2.policy.persist", + )?; + let mut session = + read_planning_session_v2(root)?.ok_or_else(|| "Planning V2 Session 不存在".to_string())?; + if session.session_id != session_id || session.turn_index != turn_index { + return Err("Planning V2 Session 回合身份发生变化".to_string()); + } + let project_id = read_manifest_for_project(root)?.project_id; + if session.project_id != project_id { + return Err("Planning V2 Session projectId 与当前项目不一致".to_string()); + } + match output { + PlanningPolicyOutputV2::Question(question) => { + let payload = serde_json::to_value(&question).map_err(|error| error.to_string())?; + append_planning_message_v2( + root, + &PlanningMessageV2 { + schema_version: PLANNING_MESSAGE_V2_SCHEMA_VERSION.to_string(), + message_id: format!("msg-{}", Uuid::new_v4().simple()), + client_turn_id: client_turn_id.to_string(), + turn_index, + at_utc: current_plan_timestamp_utc(), + role: "assistant".to_string(), + kind: "question".to_string(), + payload: payload.clone(), + }, + )?; + session.question_count = session.question_count.saturating_add(1); + session.current_question = Some(payload.clone()); + session.status = "awaiting_user".to_string(); + session.processing_seconds += elapsed_seconds.max(0.0); + session.updated_at_utc = current_plan_timestamp_utc(); + session.last_error = None; + write_planning_session_v2(root, &session)?; + Ok(PlanningPolicyPersistedV2 { + session, + result: PlanningTurnResultV2 { + schema_version: PLANNING_TURN_RESULT_V2_SCHEMA_VERSION.to_string(), + kind: "question".to_string(), + payload, + }, + current_artifact: None, + }) + } + PlanningPolicyOutputV2::Gdd(input) => { + let next_version = next_gdd_version_v2(&session)?; + let gdd = if let Some(existing) = try_read_gdd_v2(root, next_version)? { + existing + } else { + let gdd = build_gdd_v2(&project_id, next_version, input)?; + validate_gdd_v2(&gdd, &project_id)?; + let bytes = canonical_gdd_v2_bytes(&gdd)?; + create_v2_immutable_file(root, &format!("gdd.v{}.json", gdd.version), &bytes)?; + gdd + }; + project_committed_gdd_v2( + root, + &mut session, + client_turn_id, + turn_index, + elapsed_seconds, + &gdd, + ) + } + } +} + +fn approval_fingerprint(value: &PlanningApprovalV2) -> Result { + #[derive(Serialize)] + #[serde(rename_all = "camelCase")] + struct Fingerprint<'a> { + schema_version: &'a str, + project_id: &'a str, + session_id: &'a str, + artifact_id: &'a str, + version: u32, + fingerprint: &'a str, + decision_id: &'a str, + action: &'a str, + comment: &'a Option, + decided_at_utc: &'a str, + } + typed_serde_fingerprint( + PLAN_APPROVAL_V2_FINGERPRINT_DOMAIN, + &Fingerprint { + schema_version: &value.schema_version, + project_id: &value.project_id, + session_id: &value.session_id, + artifact_id: &value.artifact_id, + version: value.version, + fingerprint: &value.fingerprint, + decision_id: &value.decision_id, + action: &value.action, + comment: &value.comment, + decided_at_utc: &value.decided_at_utc, + }, + ) + .map_err(|error| error.to_string()) +} + +fn validate_approval_v2(value: &PlanningApprovalV2, project_id: &str) -> Result<(), String> { + if value.schema_version != PLAN_APPROVAL_V2_SCHEMA_VERSION + || value.project_id != project_id + || !(1..=PLAN_GDD_V2_MAX_VERSIONS).contains(&value.version) + || !matches!(value.action.as_str(), "approve" | "revise" | "reject") + || !is_typed_fingerprint(&value.fingerprint) + || !is_typed_fingerprint(&value.receipt_fingerprint) + { + return Err("PLANNING_INVALID_APPROVAL: V2 审批记录身份或 schema 无效".to_string()); + } + validate_uuid_prefixed(&value.artifact_id, "gdd-", "artifactId") + .map_err(|error| error.to_string())?; + validate_text(&value.decision_id, "decisionId", 1, 128).map_err(|error| error.to_string())?; + validate_timestamp(&value.decided_at_utc, "decidedAtUtc").map_err(|error| error.to_string())?; + if matches!(value.action.as_str(), "revise" | "reject") && value.comment.is_none() { + return Err("PLANNING_INVALID_APPROVAL: 修改或退回审批记录必须填写 comment".to_string()); + } + if value + .comment + .as_deref() + .is_some_and(|comment| comment.chars().count() > 2_000) + { + return Err("PLANNING_INVALID_APPROVAL: comment 不能超过 2000 个字符".to_string()); + } + let expected = approval_fingerprint(value)?; + if expected != value.receipt_fingerprint { + return Err("PLANNING_INVALID_APPROVAL: receiptFingerprint 不匹配".to_string()); + } + Ok(()) +} + +fn read_approval_v2(root: &Path, version: u32) -> Result, String> { + let path = v2_path(root, &format!("approvals/v{version}.json")); + prepare_game_creator_private_path_for_read(&path, false, "Planning V2 审批记录")?; + match fs::read(&path) { + Ok(bytes) => { + let approval = serde_json::from_slice::(&bytes) + .map_err(|error| format!("解析 V2 审批记录失败:{error}"))?; + let project_id = read_manifest_for_project(root)?.project_id; + validate_approval_v2(&approval, &project_id)?; + Ok(Some(approval)) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(format!("读取 V2 审批记录失败:{}: {error}", path.display())), + } +} + +fn write_approval_v2(root: &Path, approval: &PlanningApprovalV2) -> Result<(), String> { + let bytes = + serde_json::to_vec(approval).map_err(|error| format!("序列化 V2 审批记录失败:{error}"))?; + create_v2_immutable_file( + root, + &format!("approvals/v{}.json", approval.version), + &bytes, + ) +} + +pub(crate) fn decide_planning_artifact_v2_at( + root: &Path, + input: DecidePlanningArtifactV2Input, +) -> Result { + // 审批按钮是一次性意图。修订后续跑和 GUI hydrate 会同时抢同一把项目锁; + // V1 `decide_plan_gdd_at` 已按完整窗口等待,V2 必须同样等过瞬时争用。 + let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "planning.v2.approval", + )?; + let mut session = + read_planning_session_v2(root)?.ok_or_else(|| "Planning V2 Session 不存在".to_string())?; + let project_id = read_manifest_for_project(root)?.project_id; + if session.project_id != project_id { + return Err("Planning V2 Session projectId 与当前项目不一致".to_string()); + } + if session.session_id != input.session_id { + return Err("Planning V2 Session ID 不匹配".to_string()); + } + if !matches!(input.action.as_str(), "approve" | "revise" | "reject") { + return Err("PLANNING_INVALID_APPROVAL: action 必须是 approve/revise/reject".to_string()); + } + let comment = input + .comment + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); + if input.decision_id.trim().is_empty() || input.decision_id.chars().count() > 128 { + return Err("PLANNING_INVALID_APPROVAL: decisionId 不能为空且长度不能超过 128".to_string()); + } + if comment + .as_deref() + .is_some_and(|value| value.chars().count() > 2_000) + { + return Err("PLANNING_INVALID_APPROVAL: comment 不能超过 2000 个字符".to_string()); + } + if matches!(input.action.as_str(), "revise" | "reject") && comment.is_none() { + return Err("PLANNING_INVALID_APPROVAL: 修改或退回必须填写 comment".to_string()); + } + let gdd = read_gdd_v2(root, input.version)?; + if gdd.gdd_id != input.artifact_id + || gdd.fingerprint != input.fingerprint + || session.current_artifact_version != Some(u64::from(input.version)) + { + return Err("PLANNING_STALE_APPROVAL: 审批引用不是当前最新 GDD".to_string()); + } + if let Some(existing) = read_approval_v2(root, input.version)? { + if existing.session_id == session.session_id + && existing.action == input.action + && existing.comment == comment + { + let status = match existing.action.as_str() { + "approve" => "approved", + "reject" => "rejected", + _ => "revision_requested", + }; + update_index_v2(root, &gdd, status, Some(existing.decision_id.clone()))?; + let markdown = render_gdd_v2_markdown(&gdd, status)?; + write_plan_fast_gdd_markdown_atomic_locked(root, &markdown) + .map_err(|error| error.to_string())?; + if session.status != status { + session.status = status.to_string(); + if status == "revision_requested" { + session.revision_count = session.revision_count.saturating_add(1); + } + session.updated_at_utc = current_plan_timestamp_utc(); + write_planning_session_v2(root, &session)?; + } + return Ok(PlanningApprovalCommandResultV2 { + approval: existing, + session, + current_artifact: Some(artifact_value_v2( + &gdd, + &index_status_v2(root, input.version)?, + )), + replayed: true, + }); + } + return Err("PLANNING_APPROVAL_CONFLICT: 当前 GDD 已存在不同审批决定".to_string()); + } + let mut approval = PlanningApprovalV2 { + schema_version: PLAN_APPROVAL_V2_SCHEMA_VERSION.to_string(), + project_id: project_id.clone(), + session_id: session.session_id.clone(), + artifact_id: gdd.gdd_id.clone(), + version: gdd.version, + fingerprint: gdd.fingerprint.clone(), + decision_id: input.decision_id.clone(), + action: input.action.clone(), + comment: comment.clone(), + decided_at_utc: current_plan_timestamp_utc(), + receipt_fingerprint: String::new(), + }; + approval.receipt_fingerprint = approval_fingerprint(&approval)?; + write_approval_v2(root, &approval)?; + if input.action != "revise" { + append_planning_message_v2( + root, + &PlanningMessageV2 { + schema_version: PLANNING_MESSAGE_V2_SCHEMA_VERSION.to_string(), + message_id: format!("msg-{}", Uuid::new_v4().simple()), + client_turn_id: input.decision_id.clone(), + turn_index: session.turn_index, + at_utc: approval.decided_at_utc.clone(), + role: "user".to_string(), + kind: "text".to_string(), + payload: serde_json::json!({"text": format!("审批:{} {}", input.action, comment.as_deref().unwrap_or_default()), "approvalAction": input.action, "artifactId": gdd.gdd_id, "version": gdd.version}), + }, + )?; + } + update_index_v2( + root, + &gdd, + match input.action.as_str() { + "approve" => "approved", + "reject" => "rejected", + _ => "revision_requested", + }, + Some(input.decision_id.clone()), + )?; + let status = match input.action.as_str() { + "approve" => "approved", + "reject" => "rejected", + _ => "revision_requested", + }; + let markdown = render_gdd_v2_markdown(&gdd, status)?; + write_plan_fast_gdd_markdown_atomic_locked(root, &markdown) + .map_err(|error| error.to_string())?; + session.status = status.to_string(); + session.current_question = None; + if input.action == "revise" { + session.revision_count = session.revision_count.saturating_add(1); + } + session.updated_at_utc = current_plan_timestamp_utc(); + write_planning_session_v2(root, &session)?; + Ok(PlanningApprovalCommandResultV2 { + approval, + session, + current_artifact: Some(artifact_value_v2(&gdd, status)), + replayed: false, + }) +} + +#[tauri::command] +pub(crate) fn decide_planning_artifact_v2( + project_path: String, + session_id: String, + artifact_id: String, + version: u32, + fingerprint: String, + decision_id: String, + action: String, + comment: Option, +) -> Result { + let root = PathBuf::from(project_path.trim()); + enforce_project_permission_policy(&root, "conversation.read")?; + enforce_project_permission_policy(&root, "conversation.write")?; + decide_planning_artifact_v2_at( + &root, + DecidePlanningArtifactV2Input { + session_id, + artifact_id, + version, + fingerprint, + decision_id, + action, + comment, + }, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn missing_answer_source_is_normalized_without_blocking() { + assert_eq!( + normalize_answer_source("confirmed", None).unwrap(), + "user_freeform" + ); + assert_eq!( + normalize_answer_source("assumption_pending", None).unwrap(), + "agent_inferred" + ); + assert_eq!( + normalize_answer_source("assumption_pending", Some("user_option".to_string())).unwrap(), + "agent_inferred" + ); + assert_eq!( + normalize_answer_source("prototype_pending", None).unwrap(), + "user_option" + ); + assert_eq!( + normalize_v2_decision_state("default_pending").is_err(), + true + ); + } + + #[test] + fn question_validation_allows_two_to_four_options() { + let question = PlanningQuestionV2 { + id: "core_loop".to_string(), + header: "当前要决定:核心循环".to_string(), + question: "玩家主要做什么?".to_string(), + options: vec![ + PlanningQuestionOptionV2 { + label: "方案 A".to_string(), + description: "先设计再验证".to_string(), + }, + PlanningQuestionOptionV2 { + label: "方案 B".to_string(), + description: "先战斗再调整".to_string(), + }, + ], + }; + assert!(validate_question_v2(&question).is_ok()); + } + + fn sample_gdd_value() -> Value { + serde_json::json!({ + "game": { + "title": "萤火守夜者", + "genre": {"primary": "轻策略", "fusion": null}, + "artStyle": { + "visualType": "手绘平面", + "keywords": ["暖色", "剪影", "纸感"], + "moodAndColor": "夜色中的暖黄灯火", + "mvpArtBoundary": "只做可复用占位素材" + }, + "oneLiner": "玩家在短局守夜旅程中分配有限灯火、判断风险并选择路线,守住营地后寻找下一处安全落脚点再继续前进", + "pillars": [ + {"name": "取舍", "playerFeel": "每次选择都有代价", "mechanism": "有限灯火在路线与营地之间分配", "decisionState": "confirmed"}, + {"name": "重玩", "playerFeel": "想再试一次更优路线", "mechanism": "不同路线组合产生不同风险", "decisionState": "confirmed"} + ], + "coreLoop": ["观察地图", "分配灯火", "选择路线", "处理事件"], + "targetUsers": {"coreUsers": "喜欢短局策略的玩家", "preferences": "偏好清晰反馈", "sessionLength": "10至20分钟", "referenceGames": []}, + "mvpSystems": [ + {"system": "地图", "minimalFunction": "展示当前营地与路线", "whyRequired": "承载空间选择", "verifyMethod": "玩家能走完一局", "decisionState": "confirmed"}, + {"system": "灯火", "minimalFunction": "在安全和探索间分配", "whyRequired": "承载核心取舍", "verifyMethod": "两种策略结果可区分", "decisionState": "confirmed"}, + {"system": "事件", "minimalFunction": "路线途中触发选择", "whyRequired": "提供短局变化", "verifyMethod": "重玩时结果不同", "decisionState": "confirmed"} + ], + "outOfScope": ["多人联机"], + "creatorTips": {"doFirst": "先做一张可走完的地图", "deferForNow": "暂缓复杂成长线", "howToVerify": "观察玩家是否能说出选择后果", "expandWhen": "连续三局都能理解后再扩展"} + }, + "decisions": [{"topic": "初始需求", "state": "confirmed", "answerSummary": "做一个短局守夜策略游戏"}], + "prototypeValidationItems": [] + }) + } + + fn tool_turn_result(name: &str, arguments: Value) -> PlanningTurnResultV2 { + PlanningTurnResultV2 { + schema_version: PLANNING_TURN_RESULT_V2_SCHEMA_VERSION.to_string(), + kind: "assistant_text".to_string(), + payload: serde_json::json!({ + "text": "", + "toolCalls": [{ + "id": "call-1", + "name": name, + "arguments": arguments + }] + }), + } + } + + #[test] + fn parses_v2_gdd_from_submit_tool_without_schema_version() { + let result = tool_turn_result( + PLAN_SUBMIT_GDD_TOOL_NAME, + serde_json::json!({"gdd": sample_gdd_value()}), + ); + let parsed = parse_planning_policy_output_v2(&result).expect("parse v2 gdd"); + let PlanningPolicyOutputV2::Gdd(gdd) = parsed else { + panic!("expected gdd"); + }; + assert!(gdd.schema_version.is_none()); + validate_planning_policy_output_v2(&PlanningPolicyOutputV2::Gdd(gdd)).expect("valid gdd"); + let text_json = PlanningTurnResultV2 { + schema_version: PLANNING_TURN_RESULT_V2_SCHEMA_VERSION.to_string(), + kind: "assistant_text".to_string(), + payload: serde_json::json!({ + "text": "{\"kind\":\"gdd\",\"gdd\":{\"schemaVersion\":\"plan-gdd.v2\"}}" + }), + }; + assert!(parse_planning_policy_output_v2(&text_json).is_err()); + } + + #[test] + fn parses_v2_question_from_ask_tool() { + let result = tool_turn_result( + PLAN_ASK_QUESTION_TOOL_NAME, + serde_json::json!({ + "question": { + "id": "core_loop", + "header": "当前要决定:核心循环", + "question": "玩家主要做什么?", + "options": [ + {"label": "方案 A", "description": "先设计再验证"}, + {"label": "方案 B", "description": "先战斗再调整"} + ] + } + }), + ); + let parsed = parse_planning_policy_output_v2(&result).expect("parse question"); + assert!(matches!(parsed, PlanningPolicyOutputV2::Question(_))); + } + + #[test] + fn planning_v2_tool_schema_describes_nested_gdd_without_schema_version() { + let tools = planning_v2_function_tools(); + assert_eq!(tools.len(), 2); + assert_eq!(tools[0].name, PLAN_ASK_QUESTION_TOOL_NAME); + assert_eq!(tools[1].name, PLAN_SUBMIT_GDD_TOOL_NAME); + let gdd_properties = tools[1].parameters["properties"]["gdd"]["properties"] + .as_object() + .expect("gdd properties"); + assert!(gdd_properties.contains_key("game")); + assert!(gdd_properties.contains_key("decisions")); + assert!(!gdd_properties.contains_key("schemaVersion")); + let decision_properties = gdd_properties["decisions"]["items"]["properties"] + .as_object() + .expect("decision properties"); + assert!(!decision_properties.contains_key("id")); + assert!(!decision_properties.contains_key("round")); + assert!(!gdd_properties["decisions"]["items"]["required"] + .as_array() + .expect("decision required") + .iter() + .any(|value| value == "id")); + let prototype_properties = gdd_properties["prototypeValidationItems"]["items"] + ["properties"] + .as_object() + .expect("prototype properties"); + assert!(!prototype_properties.contains_key("id")); + assert!(gdd_properties["game"]["properties"] + .as_object() + .expect("game properties") + .contains_key("title")); + } + + #[test] + fn runtime_assigns_decision_and_prototype_ids() { + let mut value = sample_gdd_value(); + value["decisions"] = serde_json::json!([ + {"topic": "初始需求", "state": "confirmed", "answerSummary": "做一个短局守夜策略游戏"}, + {"topic": "核心回路", "state": "prototype_pending", "answerSummary": "验证核心回路"} + ]); + value["prototypeValidationItems"] = serde_json::json!([ + {"question": "玩家是否理解核心回路?", "microPrototype": "做一个最小交互原型", "observation": "观察玩家行为", "passCriterion": "多数玩家完成目标"} + ]); + let input: PlanningGddInputV2 = serde_json::from_value(value).expect("input"); + let gdd = build_gdd_v2("project-v2", 1, input).expect("build gdd"); + assert_eq!( + gdd.decisions + .iter() + .map(|decision| decision.id.as_str()) + .collect::>(), + vec!["initial-request", "decision-1"] + ); + assert_eq!(gdd.prototype_validation_items[0].id, "decision-1"); + } + + fn sample_gdd_input() -> PlanningGddInputV2 { + serde_json::from_value(sample_gdd_value()).expect("sample gdd input") + } + + fn v2_persist_fixture() -> (tempfile::TempDir, PathBuf, PlanningSessionV2) { + let directory = tempfile::tempdir().expect("create v2 persist fixture"); + let root = directory.path().to_path_buf(); + crate::init_local_game_project_at(&root, "project-v2-persist", "V2 持久化测试") + .expect("init project"); + let project_id = crate::read_manifest_for_project(&root) + .expect("read manifest") + .project_id; + let now = current_plan_timestamp_utc(); + let session = PlanningSessionV2 { + schema_version: PLANNING_SESSION_V2_SCHEMA_VERSION.to_string(), + engine: PLANNING_SESSION_V2_ENGINE.to_string(), + session_id: "ps-test-persist".to_string(), + project_id, + mode: "gdd".to_string(), + status: "planning".to_string(), + turn_index: 1, + question_count: 0, + question_limit: Some(8), + revision_count: 0, + current_artifact_version: None, + current_question: None, + capabilities: PlanningCapabilitySnapshotV2::default(), + processing_seconds: 0.0, + created_at_utc: now.clone(), + updated_at_utc: now, + last_error: None, + }; + write_planning_session_v2(&root, &session).expect("write session"); + append_planning_message_v2( + &root, + &PlanningMessageV2 { + schema_version: PLANNING_MESSAGE_V2_SCHEMA_VERSION.to_string(), + message_id: "msg-user-1".to_string(), + client_turn_id: "turn-1".to_string(), + turn_index: 1, + at_utc: current_plan_timestamp_utc(), + role: "user".to_string(), + kind: "text".to_string(), + payload: serde_json::json!({"text": "做一个短局守夜策略游戏"}), + }, + ) + .expect("write user message"); + (directory, root, session) + } + + fn rewind_session_keep_gdd_file(root: &Path, session: &PlanningSessionV2) { + let mut session = session.clone(); + session.current_artifact_version = None; + session.status = "provider_failed".to_string(); + session.last_error = Some(PlanningErrorV2 { + code: "PLANNING_PERSIST_FAILED".to_string(), + summary: "投影失败".to_string(), + }); + write_planning_session_v2(root, &session).expect("rewind session"); + let _ = fs::remove_file(v2_path(root, "index.json")); + let _ = fs::remove_file(root.join("game/fast_gdd.md")); + let users = read_planning_messages_v2(root) + .expect("read conversation") + .into_iter() + .filter(|message| message.role == "user") + .collect::>(); + let mut content = String::new(); + for message in users { + content.push_str(&serde_json::to_string(&message).expect("serialize user message")); + content.push('\n'); + } + crate::write_game_creator_private_file( + &root.join(PLANNING_SESSION_V2_CONVERSATION_PATH), + content.as_bytes(), + "Planning V2 对话记录", + ) + .expect("rewrite conversation"); + } + + #[test] + fn persist_retries_adopt_existing_gdd_instead_of_conflicting_identity() { + let (_dir, root, session) = v2_persist_fixture(); + let first = persist_planning_policy_output_v2( + &root, + "turn-1", + &session.session_id, + session.turn_index, + 1.0, + PlanningPolicyOutputV2::Gdd(sample_gdd_input()), + ) + .expect("first persist"); + let first_id = first + .current_artifact + .as_ref() + .and_then(|value| value.get("artifactId")) + .and_then(Value::as_str) + .expect("first gdd id") + .to_string(); + assert_eq!(first.session.current_artifact_version, Some(1)); + rewind_session_keep_gdd_file(&root, &session); + let mut changed = sample_gdd_input(); + changed.game.title = "完全不同的标题".to_string(); + let retry = persist_planning_policy_output_v2( + &root, + "turn-1", + &session.session_id, + session.turn_index, + 1.0, + PlanningPolicyOutputV2::Gdd(changed), + ) + .expect("retry persist"); + assert_eq!(retry.session.current_artifact_version, Some(1)); + assert_eq!(retry.session.status, "awaiting_approval"); + assert_eq!( + retry + .current_artifact + .as_ref() + .and_then(|value| value.get("artifactId")) + .and_then(Value::as_str), + Some(first_id.as_str()) + ); + assert!(v2_path(&root, "gdd.v1.json").is_file()); + assert!(!v2_path(&root, "gdd.v2.json").exists()); + let gdd = read_gdd_v2(&root, 1).expect("read adopted gdd"); + assert_eq!(gdd.gdd_id, first_id); + assert_eq!(gdd.game.title, "萤火守夜者"); + let artifacts = read_planning_messages_v2(&root) + .expect("read conversation") + .into_iter() + .filter(|message| message.kind == "artifact") + .count(); + assert_eq!(artifacts, 1); + } + + #[test] + fn hydrate_adopts_orphan_gdd_after_projection_failure() { + let (_dir, root, session) = v2_persist_fixture(); + persist_planning_policy_output_v2( + &root, + "turn-1", + &session.session_id, + session.turn_index, + 1.0, + PlanningPolicyOutputV2::Gdd(sample_gdd_input()), + ) + .expect("first persist"); + rewind_session_keep_gdd_file(&root, &session); + let hydrated = hydrate_planning_session_v2( + root.to_string_lossy().to_string(), + Some(session.session_id.clone()), + ) + .expect("hydrate") + .expect("session"); + assert_eq!(hydrated.session.current_artifact_version, Some(1)); + assert_eq!(hydrated.session.status, "awaiting_approval"); + assert!(hydrated.current_artifact.is_some()); + assert!(hydrated + .conversation + .as_ref() + .expect("conversation") + .iter() + .any(|message| message.kind == "artifact")); + } + + #[test] + fn successful_gdd_persist_still_allocates_next_version() { + let (_dir, root, session) = v2_persist_fixture(); + persist_planning_policy_output_v2( + &root, + "turn-1", + &session.session_id, + session.turn_index, + 1.0, + PlanningPolicyOutputV2::Gdd(sample_gdd_input()), + ) + .expect("persist v1"); + let mut next = sample_gdd_input(); + next.game.title = "第二版守夜者".to_string(); + let second = persist_planning_policy_output_v2( + &root, + "turn-2", + &session.session_id, + session.turn_index, + 1.0, + PlanningPolicyOutputV2::Gdd(next), + ) + .expect("persist v2"); + assert_eq!(second.session.current_artifact_version, Some(2)); + assert!(v2_path(&root, "gdd.v1.json").is_file()); + assert!(v2_path(&root, "gdd.v2.json").is_file()); + assert_eq!( + read_gdd_v2(&root, 2).expect("read v2").game.title, + "第二版守夜者" + ); + } + + fn hold_project_lock_briefly(root: &Path, hold_millis: u64) -> std::thread::JoinHandle<()> { + let lock_path = root.join(".agent/project.lock"); + let held = serde_json::json!({ + "commandId": "test.hold", + "pid": std::process::id(), + "createdAt": unix_timestamp(), + "nonce": 0, + }); + fs::write( + &lock_path, + serde_json::to_vec(&held).expect("serialize held lock"), + ) + .expect("hold project lock"); + std::thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_millis(hold_millis)); + fs::remove_file(&lock_path).expect("release project lock"); + }) + } + + #[test] + fn v2_decision_rides_out_a_briefly_held_project_lock() { + let (_dir, root, session) = v2_persist_fixture(); + let persisted = persist_planning_policy_output_v2( + &root, + "turn-1", + &session.session_id, + session.turn_index, + 1.0, + PlanningPolicyOutputV2::Gdd(sample_gdd_input()), + ) + .expect("persist gdd"); + let artifact = persisted + .current_artifact + .as_ref() + .expect("current artifact"); + let holder = hold_project_lock_briefly(&root, 120); + let decision = decide_planning_artifact_v2_at( + &root, + DecidePlanningArtifactV2Input { + session_id: session.session_id.clone(), + artifact_id: artifact + .get("artifactId") + .and_then(Value::as_str) + .expect("artifactId") + .to_string(), + version: 1, + fingerprint: artifact + .get("fingerprint") + .and_then(Value::as_str) + .expect("fingerprint") + .to_string(), + decision_id: "gdd-response-v2-lock-wait".to_string(), + action: "revise".to_string(), + comment: Some("加强节奏".to_string()), + }, + ) + .expect("审批修改必须等过瞬时锁争用,而不是把失败甩回按钮"); + holder.join().expect("lock holder thread"); + assert_eq!(decision.session.status, "revision_requested"); + assert!(!decision.replayed); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_provider_usage.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_provider_usage.rs deleted file mode 100644 index d421901a0..000000000 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_provider_usage.rs +++ /dev/null @@ -1,972 +0,0 @@ -use super::*; - -use std::collections::BTreeMap; - -const PLAN_PROVIDER_USAGE_RECORD_TYPE: &str = "agent.runtime.plan.provider_usage"; -const PLAN_PROVIDER_USAGE_SCHEMA_VERSION: &str = "plan-provider-usage.v1"; - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(deny_unknown_fields, rename_all = "camelCase")] -pub(crate) struct PlanProviderUsageFactV1 { - record_type: String, - usage_schema_version: String, - project_id: String, - root_agent_id: String, - root_run_id: String, - root_run_profile_binding_fingerprint: String, - agent_id: String, - task_id: String, - session_id: String, - run_id: String, - source: String, - request_id: String, - request_kind: String, - request_slot: String, - web_search_enabled: bool, - planning_session_binding: Option, - outcome: String, - active_millis: u64, -} - -#[derive(Clone, Debug)] -pub(crate) struct PlanProviderUsageScope { - root_run_id: String, - root_run_profile_binding_fingerprint: String, - planning_session_binding: Option, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub(crate) enum PlanProviderUsageFoldOutcome { - NoSession, - Deferred, - Unchanged, - Advanced, -} - -fn is_provider_request_id(value: &str) -> bool { - value - .strip_prefix("provider-request-") - .is_some_and(|suffix| { - suffix.len() == 64 - && suffix - .bytes() - .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()) - }) -} - -fn is_bare_sha256(value: &str) -> bool { - value.len() == 64 - && value - .bytes() - .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()) -} - -fn validate_plan_provider_usage_fact_shape(fact: &PlanProviderUsageFactV1) -> Result<(), String> { - if fact.record_type != PLAN_PROVIDER_USAGE_RECORD_TYPE - || fact.usage_schema_version != PLAN_PROVIDER_USAGE_SCHEMA_VERSION - || fact.root_agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID - || fact.project_id.trim().is_empty() - || fact.root_run_id.trim().is_empty() - || fact.agent_id.trim().is_empty() - || fact.task_id.trim().is_empty() - || fact.session_id.trim().is_empty() - || fact.run_id.trim().is_empty() - || fact.source.trim().is_empty() - || fact.request_slot.trim().is_empty() - || !is_provider_request_id(&fact.request_id) - || !is_bare_sha256(&fact.root_run_profile_binding_fingerprint) - || !matches!( - fact.request_kind.as_str(), - "tool-plan" | "final-reply" | "context-compaction" | "final-reply-context-compaction" - ) - || !matches!( - fact.outcome.as_str(), - "completed" | "failed" | "interrupted" - ) - { - return Err("PLAN_PROVIDER_USAGE_INVALID: Provider usage fact 基础字段无效".to_string()); - } - match fact.planning_session_binding.as_ref() { - Some(binding) => { - validate_plan_provider_session_binding(binding).map_err(|error| { - format!("PLAN_PROVIDER_USAGE_INVALID: planning binding 无效:{error}") - })?; - if fact.agent_id != GAME_CREATOR_PROJECT_PLANNING_AGENT_ID - || fact.source != "agent-delegate" - || binding.project_id != fact.project_id - || binding.agent_id != fact.agent_id - || binding.task_id != fact.task_id - || binding.session_id != fact.session_id - || binding.run_id != fact.run_id - || binding.root_agent_id != fact.root_agent_id - || binding.root_run_id != fact.root_run_id - || binding.provider_request_id != fact.request_id - || binding.request_kind != fact.request_kind - || binding.request_slot != fact.request_slot - || binding.web_search_enabled != fact.web_search_enabled - { - return Err( - "PLAN_PROVIDER_USAGE_IDENTITY_CONFLICT: usage fact 与 planning binding 不一致" - .to_string(), - ); - } - } - None => { - if fact.agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID - || fact.run_id != fact.root_run_id - || fact.source != AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE - { - return Err( - "PLAN_PROVIDER_USAGE_IDENTITY_CONFLICT: root usage fact 身份不一致".to_string(), - ); - } - } - } - Ok(()) -} - -/// Capture the exact planning budget scope while the Provider start lock is -/// still held. Ordinary Runtime requests return `None`; an apparent planning -/// request with a broken durable identity fails closed instead of silently -/// escaping the budget. -pub(crate) fn capture_plan_provider_usage_scope_at_locked( - root: &Path, - snapshot: &AgentRuntimeProviderRequestSnapshot, - request_id: &str, -) -> Result, String> { - if let Some(base_binding) = snapshot.planning_session_binding.as_ref() { - if snapshot.agent_id != GAME_CREATOR_PROJECT_PLANNING_AGENT_ID { - return Err( - "PLAN_PROVIDER_USAGE_IDENTITY_CONFLICT: 非 planning Agent 携带 session binding" - .to_string(), - ); - } - let attempt_binding = plan_provider_session_binding_for_attempt( - base_binding, - &snapshot.request_slot, - request_id, - )?; - let child = - validate_project_planning_child_binding_at(root, &snapshot.agent_id, &snapshot.run_id)?; - let parent = validate_project_supervisor_plan_root_binding_at( - root, - &attempt_binding.root_agent_id, - &attempt_binding.root_run_id, - )?; - if child.binding_fingerprint != attempt_binding.run_profile_binding_fingerprint - || child.root_run_id != attempt_binding.root_run_id - || parent.binding_fingerprint - != child.parent_binding_fingerprint.clone().unwrap_or_default() - || parent.project_id != snapshot.project_id - { - return Err( - "PLAN_PROVIDER_USAGE_IDENTITY_CONFLICT: planning usage scope 与 Run Profile 绑定不一致" - .to_string(), - ); - } - return Ok(Some(PlanProviderUsageScope { - root_run_id: parent.run_id, - root_run_profile_binding_fingerprint: parent.binding_fingerprint, - planning_session_binding: Some(attempt_binding), - })); - } - - if snapshot.agent_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID { - return Err( - "PLAN_PROVIDER_USAGE_IDENTITY_CONFLICT: planning Provider 请求缺少 session binding" - .to_string(), - ); - } - if snapshot.source != AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE { - return Ok(None); - } - let binding = validate_project_supervisor_plan_root_binding_at( - root, - &snapshot.agent_id, - &snapshot.run_id, - )?; - if snapshot.source != binding.source - || snapshot.project_id != binding.project_id - || snapshot.run_id != binding.root_run_id - { - return Err( - "PLAN_PROVIDER_USAGE_IDENTITY_CONFLICT: root Provider snapshot 与绑定不一致" - .to_string(), - ); - } - Ok(Some(PlanProviderUsageScope { - root_run_id: binding.root_run_id, - root_run_profile_binding_fingerprint: binding.binding_fingerprint, - planning_session_binding: None, - })) -} - -/// Persist one immutable live-process interval. The caller stops its monotonic -/// clock before entering this function, so Agent DB locking, response handoff, -/// retry backoff and later recovery are never included in `activeMillis`. -pub(crate) fn persist_plan_provider_usage_fact_at( - root: &Path, - snapshot: &AgentRuntimeProviderRequestSnapshot, - request_id: &str, - scope: Option<&PlanProviderUsageScope>, - outcome: &str, - active_millis: u64, -) -> Result { - let Some(scope) = scope else { - return Ok(false); - }; - let fact = PlanProviderUsageFactV1 { - record_type: PLAN_PROVIDER_USAGE_RECORD_TYPE.to_string(), - usage_schema_version: PLAN_PROVIDER_USAGE_SCHEMA_VERSION.to_string(), - project_id: snapshot.project_id.clone(), - root_agent_id: GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(), - root_run_id: scope.root_run_id.clone(), - root_run_profile_binding_fingerprint: scope.root_run_profile_binding_fingerprint.clone(), - agent_id: snapshot.agent_id.clone(), - task_id: snapshot.task_id.clone(), - session_id: snapshot.session_id.clone(), - run_id: snapshot.run_id.clone(), - source: snapshot.source.clone(), - request_id: request_id.to_string(), - request_kind: snapshot.request_kind.clone(), - request_slot: snapshot.request_slot.clone(), - web_search_enabled: snapshot.web_search_enabled, - planning_session_binding: scope.planning_session_binding.clone(), - outcome: outcome.to_string(), - active_millis, - }; - validate_plan_provider_usage_fact_shape(&fact)?; - let record = serde_json::to_value(&fact) - .map_err(|error| format!("序列化 planning Provider usage 失败:{error}"))?; - append_agent_db_plan_provider_usage_idempotent(root, record) -} - -fn read_plan_provider_usage_facts_at(root: &Path) -> Result, String> { - read_agent_db_plan_provider_usage_records_at(root)? - .into_iter() - .map(|mut record| { - let object = record.as_object_mut().ok_or_else(|| { - "PLAN_PROVIDER_USAGE_INVALID: Agent DB usage record 不是 object".to_string() - })?; - object.remove("schemaVersion"); - object.remove("updatedAt"); - let fact = - serde_json::from_value::(record).map_err(|error| { - format!("PLAN_PROVIDER_USAGE_INVALID: 解析 usage fact 失败:{error}") - })?; - validate_plan_provider_usage_fact_shape(&fact)?; - Ok(fact) - }) - .collect() -} - -/// 列出当前让 usage 折叠必须延期的策划 run。返回列表而不是布尔,是因为调用方 -/// 需要区分「别的 run 有在途 exchange」和「延期就是本 run 自己造成的」——后者是 -/// 瞬态重试恢复的必经状态,不是冲突。 -fn planning_child_usage_projection_deferring_runs_at( - root: &Path, - facts: &[PlanProviderUsageFactV1], -) -> Result, String> { - let mut runs = BTreeMap::<(String, String), ()>::new(); - for fact in facts { - if fact.planning_session_binding.is_some() { - runs.insert((fact.agent_id.clone(), fact.run_id.clone()), ()); - } - } - let mut deferring = Vec::new(); - for ((agent_id, run_id), ()) in runs { - if crate::provider_retry::read_for_run_at(root, &agent_id, &run_id)?.is_some() - || crate::provider_handoff::read_for_run_at(root, &agent_id, &run_id)?.is_some() - || game_creator_agent_runtime_provider_action_batch_exists(root, &agent_id, &run_id) - { - deferring.push((agent_id, run_id)); - } - } - Ok(deferring) -} - -fn plan_provider_usage_fact_matches_session( - root: &Path, - session: &PlanSessionV1, - fact: &PlanProviderUsageFactV1, -) -> Result { - if fact.project_id != session.project_id || fact.root_run_id != session.root_run_id { - return Ok(false); - } - let root_binding = validate_project_supervisor_plan_root_binding_at( - root, - &fact.root_agent_id, - &fact.root_run_id, - )?; - if root_binding.binding_fingerprint != fact.root_run_profile_binding_fingerprint { - return Err( - "PLAN_PROVIDER_USAGE_IDENTITY_CONFLICT: usage fact 的 root binding 已漂移".to_string(), - ); - } - let Some(binding) = fact.planning_session_binding.as_ref() else { - return Ok(true); - }; - if binding.gdd_id != session.gdd_id - || binding.session_id != session.session_id - || binding.root_run_id != session.root_run_id - { - return Err( - "PLAN_PROVIDER_USAGE_IDENTITY_CONFLICT: 同 plan root usage fact 跨越 GDD/session" - .to_string(), - ); - } - let child = validate_project_planning_child_binding_at(root, &fact.agent_id, &fact.run_id)?; - if child.binding_fingerprint != binding.run_profile_binding_fingerprint - || child.root_run_id != session.root_run_id - { - return Err( - "PLAN_PROVIDER_USAGE_IDENTITY_CONFLICT: child usage fact 的 Run Profile 绑定已漂移" - .to_string(), - ); - } - Ok(true) -} - -/// Fold terminal request facts into the current plan session. Facts are the -/// immutable source; the session value is rebuilt as a checked sum rather than -/// incremented from an event, making crash replay naturally idempotent. -/// -/// Callers must already hold the cross-process project write lock and must use -/// this only at a new-request or domain-successor boundary. A persisted retry, -/// response handoff or Provider batch for a planning child defers the fold so -/// the response's frozen session binding cannot be invalidated mid-exchange. -/// A tool-plan handoff is deliberately not a blocker: that ledger remains as -/// historical execution evidence until finalization. -pub(crate) fn fold_plan_provider_usage_into_session_at_locked( - root: &Path, -) -> Result { - Ok(fold_plan_provider_usage_into_session_at_locked_with_deferring_runs(root)?.0) -} - -fn fold_plan_provider_usage_into_session_at_locked_with_deferring_runs( - root: &Path, -) -> Result<(PlanProviderUsageFoldOutcome, Vec<(String, String)>), String> { - let Some(previous) = - read_plan_session_with_recovery_locked(root).map_err(|error| error.to_string())? - else { - return Ok((PlanProviderUsageFoldOutcome::NoSession, Vec::new())); - }; - let all_facts = read_plan_provider_usage_facts_at(root)?; - let mut unique = BTreeMap::::new(); - for fact in all_facts { - match unique.get(&fact.request_id) { - Some(existing) if existing == &fact => continue, - Some(_) => { - return Err(format!( - "PLAN_PROVIDER_USAGE_IDENTITY_CONFLICT: 同 requestId 存在不同 usage fact:{}", - fact.request_id - )); - } - None => { - unique.insert(fact.request_id.clone(), fact); - } - } - } - let mut matching = Vec::new(); - for fact in unique.into_values() { - if plan_provider_usage_fact_matches_session(root, &previous, &fact)? { - matching.push(fact); - } - } - let deferring_runs = planning_child_usage_projection_deferring_runs_at(root, &matching)?; - if !deferring_runs.is_empty() { - return Ok((PlanProviderUsageFoldOutcome::Deferred, deferring_runs)); - } - - let mut total = 0_u64; - for fact in &matching { - let expected_lifecycle = if let Some(binding) = fact.planning_session_binding.as_ref() { - serde_json::json!({ - "recordType": AGENT_RUNTIME_PROVIDER_REQUEST_LIFECYCLE_RECORD_TYPE, - "auditSchemaVersion": AGENT_RUNTIME_PLAN_PROVIDER_REQUEST_LIFECYCLE_SCHEMA_VERSION, - "agentId": fact.agent_id, - "taskId": fact.task_id, - "sessionId": fact.session_id, - "runId": fact.run_id, - "source": fact.source, - "requestId": fact.request_id, - "requestKind": fact.request_kind, - "requestSlot": fact.request_slot, - "webSearchEnabled": fact.web_search_enabled, - "planningSessionBinding": binding, - "status": "started", - }) - } else { - serde_json::json!({ - "recordType": AGENT_RUNTIME_PROVIDER_REQUEST_LIFECYCLE_RECORD_TYPE, - "auditSchemaVersion": AGENT_RUNTIME_PROVIDER_REQUEST_LIFECYCLE_SCHEMA_VERSION, - "agentId": fact.agent_id, - "taskId": fact.task_id, - "sessionId": fact.session_id, - "runId": fact.run_id, - "source": fact.source, - "requestId": fact.request_id, - "requestKind": fact.request_kind, - "requestSlot": fact.request_slot, - "webSearchEnabled": fact.web_search_enabled, - "status": "started", - }) - }; - let transitions = read_agent_db_lifecycle_transitions_matching_at( - root, - AGENT_RUNTIME_PROVIDER_REQUEST_LIFECYCLE_RECORD_TYPE, - "requestId", - &fact.request_id, - &expected_lifecycle, - )?; - let expected = ["started".to_string(), fact.outcome.clone()]; - if transitions != ["started"] && transitions != expected { - return Err(format!( - "PLAN_PROVIDER_USAGE_LIFECYCLE_CONFLICT: requestId={} usage/lifecycle 不一致", - fact.request_id - )); - } - total = total.checked_add(fact.active_millis).ok_or_else(|| { - "PLAN_PROVIDER_USAGE_OVERFLOW: accumulatedAgentMillis 溢出".to_string() - })?; - } - if previous.accumulated_agent_millis > total { - return Err( - "PLAN_PROVIDER_USAGE_IDENTITY_CONFLICT: session 累计值大于 immutable facts 总和" - .to_string(), - ); - } - if previous.accumulated_agent_millis == total { - return Ok((PlanProviderUsageFoldOutcome::Unchanged, Vec::new())); - } - let mut next = previous.clone(); - next.session_revision = previous - .session_revision - .checked_add(1) - .ok_or_else(|| "PLAN_SESSION_CAS_CONFLICT: sessionRevision 溢出".to_string())?; - next.previous_fingerprint = Some(previous.session_fingerprint.clone()); - next.accumulated_agent_millis = total; - next.updated_at_utc = current_plan_timestamp_utc(); - next.session_fingerprint = format!("sha256-serde-json-v2:{}", "0".repeat(64)); - next.session_fingerprint = - plan_session_fingerprint(&next).map_err(|error| error.to_string())?; - validate_plan_session_successor(&previous, &next).map_err(|error| error.to_string())?; - write_plan_session_atomic_locked(root, &next).map_err(|error| error.to_string())?; - Ok((PlanProviderUsageFoldOutcome::Advanced, Vec::new())) -} - -/// A fresh Provider request may only freeze bytes after all prior planning -/// exchange state has cleared. Retry replay keeps using its already-frozen -/// bytes elsewhere; reaching this boundary while folding is deferred must -/// block instead of quietly issuing a request from the older session. -/// -/// `resuming_run` 是本次请求所属的 run。瞬态上游失败后,通用重试机制会留下 retry -/// sidecar 再唤醒同一个 run;这个 run 随后必然重新走到本边界,而它自己那条尚未 -/// 收口的 exchange 正是延期判据的第一条。把这种情况也判成硬失败,等于让策划子 Run -/// 撞上任何一次 502/524 都必定自杀——现场就是子 Run 在第一次 tool-plan 请求超时后 -/// 直接 failed,回执退化成 needs-repair,逼总控多烧一整轮返工委派。 -/// -/// 折叠本身是记账动作:exchange 还没收口时本来就不该把它计入 session,跳过一次是 -/// 正确的,等收口后的下一个边界会补上。真正裁决重放还是重发的是下游的 retry / -/// handoff 身份比对。因此只豁免「延期完全由 `resuming_run` 自己造成」这一种;别的 -/// 策划 run 有在途 exchange 时仍然硬失败,传 `None` 的调用方行为不变。 -pub(crate) fn fold_plan_provider_usage_before_new_request_at_locked( - root: &Path, - resuming_run: Option<(&str, &str)>, -) -> Result<(), String> { - let (outcome, deferring_runs) = - fold_plan_provider_usage_into_session_at_locked_with_deferring_runs(root)?; - match outcome { - PlanProviderUsageFoldOutcome::Deferred => { - if let Some((agent_id, run_id)) = resuming_run { - if !deferring_runs.is_empty() - && deferring_runs - .iter() - .all(|(deferring_agent, deferring_run)| { - deferring_agent == agent_id && deferring_run == run_id - }) - { - return Ok(()); - } - } - Err( - "PLAN_PROVIDER_USAGE_DEFERRED: 上一条 planning Provider exchange 尚未收口" - .to_string(), - ) - } - PlanProviderUsageFoldOutcome::NoSession - | PlanProviderUsageFoldOutcome::Unchanged - | PlanProviderUsageFoldOutcome::Advanced => Ok(()), - } -} - -#[cfg(test)] -mod tests { - use super::*; - - struct UsageFixture { - _temporary: tempfile::TempDir, - root: PathBuf, - project_id: String, - root_runtime: AgentRuntimeState, - child_runtime: AgentRuntimeState, - session: PlanSessionV1, - } - - fn usage_fixture() -> UsageFixture { - let temporary = crate::tests::canonical_test_tempdir("planning-provider-usage-"); - let root = temporary.path().join("project"); - init_local_game_project_at(&root, "planning-provider-usage", "统计 Fast GDD 活跃时间") - .expect("init usage fixture"); - let project_id = game_creator_agent_runtime_context_project_id(&root) - .expect("read usage fixture project id"); - bind_game_creator_agent_runtime_run_profile_at( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - "usage-root-run", - AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE, - Some(AGENT_RUNTIME_RUN_PROFILE_STANDARD), - None, - ) - .expect("bind usage plan root"); - let root_runtime = start_game_creator_agent_runtime_task_at( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - "收敛 Fast GDD", - "usage-root-run", - AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE, - "委派策划子 Agent", - vec!["等待 Fast GDD".to_string()], - ) - .expect("start usage plan root"); - let child_binding = bind_game_creator_agent_runtime_run_profile_at( - &root, - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - "usage-child-run", - "agent-delegate", - Some(AGENT_RUNTIME_RUN_PROFILE_STANDARD), - Some(&AgentRuntimeTaskLink { - parent_agent_id: Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string()), - parent_run_id: Some("usage-root-run".to_string()), - delegation_id: Some("usage-delegation".to_string()), - }), - ) - .expect("bind usage planning child"); - let mut child_runtime = start_game_creator_agent_runtime_task_at( - &root, - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - "形成 Fast GDD", - "usage-child-run", - "agent-delegate", - "读取项目事实", - vec!["收敛设计决定".to_string()], - ) - .expect("start usage planning child"); - child_runtime.parent_agent_id = Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string()); - child_runtime.parent_run_id = Some("usage-root-run".to_string()); - child_runtime.delegation_id = Some("usage-delegation".to_string()); - child_runtime.run_profile = AGENT_RUNTIME_RUN_PROFILE_STANDARD.to_string(); - child_runtime.run_profile_binding_fingerprint = child_binding.binding_fingerprint; - append_game_creator_agent_runtime_task(&root, &child_runtime) - .expect("persist usage planning child identity"); - write_game_creator_agent_runtime_state(&root, &child_runtime) - .expect("persist current usage planning child identity"); - - let mut session = PlanSessionV1 { - schema_version: PLAN_SESSION_SCHEMA_VERSION.to_string(), - project_id: project_id.clone(), - gdd_id: "gdd-00000000-0000-4000-8000-000000000901".to_string(), - session_revision: 1, - previous_fingerprint: None, - session_fingerprint: format!("sha256-serde-json-v2:{}", "0".repeat(64)), - agent_id: GAME_CREATOR_PROJECT_PLANNING_AGENT_ID.to_string(), - source: "agent-delegate".to_string(), - run_profile: AGENT_RUNTIME_RUN_PROFILE_STANDARD.to_string(), - run_profile_binding_fingerprint: child_runtime.run_profile_binding_fingerprint.clone(), - root_agent_id: GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(), - root_run_id: "usage-root-run".to_string(), - latest_delegation_id: "usage-delegation".to_string(), - session_id: child_runtime.session_id.clone(), - active_run_id: Some(child_runtime.run_id.clone()), - last_run_id: child_runtime.run_id.clone(), - phase: "collecting".to_string(), - accumulated_agent_millis: 0, - applied_steer_cursor: 0, - decisions_summary: vec![PlanDecisionSummary { - id: "initial-request".to_string(), - topic: "初始需求".to_string(), - state: "confirmed".to_string(), - answer_source: "user_freeform".to_string(), - round: 0, - answer_summary: "统计 Fast GDD 活跃时间".to_string(), - }], - prototype_validation_items: Vec::new(), - applied_answers: Vec::new(), - latest_submitted_ref: None, - last_decision_ref: None, - updated_at_utc: current_plan_timestamp_utc(), - }; - session.session_fingerprint = plan_session_fingerprint(&session).expect("session fp"); - write_plan_session_atomic_locked(&root, &session).expect("write usage session"); - UsageFixture { - _temporary: temporary, - root, - project_id, - root_runtime, - child_runtime, - session, - } - } - - fn request_snapshot( - fixture: &UsageFixture, - runtime: &AgentRuntimeState, - request_kind: &str, - request_slot: &str, - ) -> AgentRuntimeProviderRequestSnapshot { - AgentRuntimeProviderRequestSnapshot { - project_id: fixture.project_id.clone(), - agent_id: runtime.agent_id.clone(), - task_id: runtime.task_id.clone(), - session_id: runtime.session_id.clone(), - run_id: runtime.run_id.clone(), - source: runtime.source.clone(), - goal_id: runtime.goal_id.clone(), - goal_revision: runtime.goal_revision, - goal_snapshot_fingerprint: agent_goal_snapshot_fingerprint_for_state_at( - &fixture.root, - runtime, - ) - .expect("goal snapshot fingerprint"), - applied_steer_cursor: runtime.applied_steer_cursor, - request_kind: request_kind.to_string(), - request_slot: request_slot.to_string(), - web_search_enabled: false, - allow_idle_context_compaction: false, - planning_session_binding: None, - } - } - - fn planning_snapshot( - fixture: &UsageFixture, - request_kind: &str, - request_slot: &str, - ) -> AgentRuntimeProviderRequestSnapshot { - let snapshot = - request_snapshot(fixture, &fixture.child_runtime, request_kind, request_slot); - let binding = capture_plan_provider_session_binding_for_snapshot( - &fixture.root, - &fixture.child_runtime, - &snapshot, - &format!("sha256-serde-json-v2:{}", "7".repeat(64)), - ) - .expect("capture planning usage binding"); - snapshot.with_planning_session_binding(Some(binding)) - } - - fn persist_usage( - fixture: &UsageFixture, - snapshot: &AgentRuntimeProviderRequestSnapshot, - outcome: &str, - active_millis: u64, - append_terminal: bool, - ) -> String { - let request_id = game_creator_agent_runtime_provider_request_id(snapshot); - assert!( - append_game_creator_agent_runtime_provider_request_lifecycle( - &fixture.root, - snapshot, - &request_id, - "started", - ) - .expect("append usage started lifecycle") - ); - let scope = - capture_plan_provider_usage_scope_at_locked(&fixture.root, snapshot, &request_id) - .expect("capture usage scope"); - assert!(persist_plan_provider_usage_fact_at( - &fixture.root, - snapshot, - &request_id, - scope.as_ref(), - outcome, - active_millis, - ) - .expect("persist usage fact")); - if append_terminal { - assert!( - append_game_creator_agent_runtime_provider_request_lifecycle( - &fixture.root, - snapshot, - &request_id, - outcome, - ) - .expect("append usage terminal lifecycle") - ); - } - request_id - } - - #[test] - fn usage_fact_append_is_idempotent_and_conflicts_fail_closed() { - let fixture = usage_fixture(); - let snapshot = request_snapshot( - &fixture, - &fixture.root_runtime, - "tool-plan", - "usage-idempotent", - ); - let request_id = game_creator_agent_runtime_provider_request_id(&snapshot); - let scope = - capture_plan_provider_usage_scope_at_locked(&fixture.root, &snapshot, &request_id) - .expect("capture root usage scope"); - assert!(persist_plan_provider_usage_fact_at( - &fixture.root, - &snapshot, - &request_id, - scope.as_ref(), - "completed", - 19, - ) - .expect("append first usage fact")); - assert!(!persist_plan_provider_usage_fact_at( - &fixture.root, - &snapshot, - &request_id, - scope.as_ref(), - "completed", - 19, - ) - .expect("replay identical usage fact")); - let error = persist_plan_provider_usage_fact_at( - &fixture.root, - &snapshot, - &request_id, - scope.as_ref(), - "completed", - 20, - ) - .expect_err("same request id with a different interval must fail"); - assert!(error.contains("同 requestId 内容冲突"), "{error}"); - } - - #[test] - fn fold_counts_root_child_and_started_only_facts_once() { - let fixture = usage_fixture(); - let root_snapshot = request_snapshot( - &fixture, - &fixture.root_runtime, - "tool-plan", - "usage-root-completed", - ); - persist_usage(&fixture, &root_snapshot, "completed", 11, true); - let failed_child = planning_snapshot(&fixture, "tool-plan", "usage-child-failed"); - persist_usage(&fixture, &failed_child, "failed", 13, true); - let interrupted_child = - planning_snapshot(&fixture, "final-reply", "usage-child-interrupted"); - persist_usage(&fixture, &interrupted_child, "interrupted", 17, false); - - let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( - &fixture.root, - "test.planning_provider_usage.fold", - ) - .expect("usage fold lock"); - assert_eq!( - fold_plan_provider_usage_into_session_at_locked(&fixture.root) - .expect("fold usage facts"), - PlanProviderUsageFoldOutcome::Advanced - ); - let advanced = read_plan_session_with_recovery_locked(&fixture.root) - .expect("read advanced usage session") - .expect("advanced usage session exists"); - assert_eq!(advanced.accumulated_agent_millis, 41); - assert_eq!(advanced.session_revision, 2); - assert_eq!( - advanced.previous_fingerprint.as_deref(), - Some(fixture.session.session_fingerprint.as_str()) - ); - assert_eq!(advanced.phase, fixture.session.phase); - assert_eq!( - advanced.decisions_summary, - fixture.session.decisions_summary - ); - assert_eq!( - fold_plan_provider_usage_into_session_at_locked(&fixture.root) - .expect("replay usage fold"), - PlanProviderUsageFoldOutcome::Unchanged - ); - // The next Provider request constructs its structured injection from - // this successor. This unit fixture deliberately omits a real - // delegation delivery, so assert the exact injected source field - // directly rather than manufacturing unrelated clarification lineage. - assert_eq!(advanced.accumulated_agent_millis, 41); - } - - /// 瞬态上游失败会留下 sidecar 再唤醒同一个 run;该 run 重新走到新请求边界时, - /// 唯一的延期来源就是它自己那条未收口的 exchange。把这判成硬失败等于让策划子 - /// Run 撞上任何一次 502/524 都必定自杀。别的 run 造成的延期必须照旧硬失败。 - #[test] - fn new_request_boundary_lets_the_resuming_run_past_its_own_deferral() { - let fixture = usage_fixture(); - let snapshot = planning_snapshot(&fixture, "tool-plan", "usage-retry-resume"); - persist_usage(&fixture, &snapshot, "failed", 17, true); - - let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( - &fixture.root, - "test.planning_provider_usage.retry_resume_boundary", - ) - .expect("retry-resume boundary lock"); - let batch_path = game_creator_agent_runtime_provider_action_batch_path( - &fixture.root, - &snapshot.agent_id, - &snapshot.run_id, - ); - std::fs::create_dir_all(batch_path.parent().expect("batch parent")) - .expect("create batch parent"); - std::fs::write(&batch_path, b"in-flight-exchange").expect("create in-flight sentinel"); - - fold_plan_provider_usage_before_new_request_at_locked( - &fixture.root, - Some((&snapshot.agent_id, &snapshot.run_id)), - ) - .expect("本 run 自己的在途 exchange 不能拦住它自己的重试恢复"); - - let other_run = fold_plan_provider_usage_before_new_request_at_locked( - &fixture.root, - Some((&snapshot.agent_id, "delegated-some-other-run")), - ) - .expect_err("别的 run 的在途 exchange 仍须硬失败"); - assert!( - other_run.starts_with("PLAN_PROVIDER_USAGE_DEFERRED"), - "{other_run}" - ); - - let no_owner = fold_plan_provider_usage_before_new_request_at_locked(&fixture.root, None) - .expect_err("未声明归属的调用方行为不变"); - assert!( - no_owner.starts_with("PLAN_PROVIDER_USAGE_DEFERRED"), - "{no_owner}" - ); - - // 豁免只是本轮跳过记账,不是把这笔用量丢掉:exchange 收口后仍须折叠进来。 - std::fs::remove_file(&batch_path).expect("remove settled sentinel"); - fold_plan_provider_usage_before_new_request_at_locked( - &fixture.root, - Some((&snapshot.agent_id, &snapshot.run_id)), - ) - .expect("exchange 收口后折叠"); - let session = read_plan_session_with_recovery_locked(&fixture.root) - .expect("read folded session") - .expect("folded session exists"); - assert_eq!(session.accumulated_agent_millis, 17); - } - - #[test] - fn fold_waits_for_submit_anchor_cleanup_then_advances() { - let fixture = usage_fixture(); - let snapshot = planning_snapshot(&fixture, "tool-plan", "usage-submit-final"); - persist_usage(&fixture, &snapshot, "completed", 23, true); - - let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( - &fixture.root, - "test.planning_provider_usage.submit_cleanup_boundary", - ) - .expect("usage cleanup-boundary lock"); - let batch_path = game_creator_agent_runtime_provider_action_batch_path( - &fixture.root, - &snapshot.agent_id, - &snapshot.run_id, - ); - std::fs::create_dir_all(batch_path.parent().expect("batch parent")) - .expect("create batch parent"); - std::fs::write(&batch_path, b"submit-anchor").expect("create submit anchor sentinel"); - assert_eq!( - fold_plan_provider_usage_into_session_at_locked(&fixture.root) - .expect("defer while submit anchor exists"), - PlanProviderUsageFoldOutcome::Deferred - ); - - std::fs::remove_file(&batch_path).expect("remove consumed submit anchor"); - assert_eq!( - fold_plan_provider_usage_into_session_at_locked(&fixture.root) - .expect("fold after submit anchor cleanup"), - PlanProviderUsageFoldOutcome::Advanced - ); - let session = read_plan_session_with_recovery_locked(&fixture.root) - .expect("read folded session") - .expect("folded session exists"); - assert_eq!(session.accumulated_agent_millis, 23); - } - - #[test] - fn fold_rejects_lifecycle_outcome_or_identity_drift() { - let outcome_fixture = usage_fixture(); - let outcome_snapshot = request_snapshot( - &outcome_fixture, - &outcome_fixture.root_runtime, - "tool-plan", - "usage-outcome-conflict", - ); - let request_id = persist_usage(&outcome_fixture, &outcome_snapshot, "failed", 23, false); - assert!( - append_game_creator_agent_runtime_provider_request_lifecycle( - &outcome_fixture.root, - &outcome_snapshot, - &request_id, - "completed", - ) - .expect("append conflicting terminal lifecycle") - ); - let _outcome_lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( - &outcome_fixture.root, - "test.planning_provider_usage.outcome_conflict", - ) - .expect("outcome conflict lock"); - let error = fold_plan_provider_usage_into_session_at_locked(&outcome_fixture.root) - .expect_err("usage/lifecycle terminal mismatch must fail"); - assert!(error.contains("LIFECYCLE_CONFLICT"), "{error}"); - drop(_outcome_lock); - - let identity_fixture = usage_fixture(); - let lifecycle_snapshot = request_snapshot( - &identity_fixture, - &identity_fixture.root_runtime, - "tool-plan", - "usage-identity-conflict", - ); - let request_id = game_creator_agent_runtime_provider_request_id(&lifecycle_snapshot); - assert!( - append_game_creator_agent_runtime_provider_request_lifecycle( - &identity_fixture.root, - &lifecycle_snapshot, - &request_id, - "started", - ) - .expect("append identity lifecycle") - ); - let fact_snapshot = lifecycle_snapshot.with_web_search_enabled(true); - let scope = capture_plan_provider_usage_scope_at_locked( - &identity_fixture.root, - &fact_snapshot, - &request_id, - ) - .expect("capture drifted usage scope"); - assert!(persist_plan_provider_usage_fact_at( - &identity_fixture.root, - &fact_snapshot, - &request_id, - scope.as_ref(), - "interrupted", - 29, - ) - .expect("persist drifted usage fact")); - let _identity_lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( - &identity_fixture.root, - "test.planning_provider_usage.identity_conflict", - ) - .expect("identity conflict lock"); - let error = fold_plan_provider_usage_into_session_at_locked(&identity_fixture.root) - .expect_err("usage/lifecycle identity mismatch must fail"); - assert!(error.contains("内容冲突"), "{error}"); - } -} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_session_v2.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_session_v2.rs new file mode 100644 index 000000000..765a14a82 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_session_v2.rs @@ -0,0 +1,1745 @@ +use super::*; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::BTreeSet; +use std::fs; +use std::io::{BufRead, BufReader}; +use std::path::{Path, PathBuf}; +use std::sync::{Mutex, OnceLock}; +use std::time::Instant; +use uuid::Uuid; + +pub(crate) const PLANNING_SESSION_V2_SCHEMA_VERSION: &str = "planning-session.v2"; +pub(crate) const PLANNING_MESSAGE_V2_SCHEMA_VERSION: &str = "planning-message.v2"; +pub(crate) const PLANNING_TURN_RESULT_V2_SCHEMA_VERSION: &str = "planning-turn-result.v2"; +pub(crate) const PLANNING_SESSION_V2_ENGINE: &str = "planning-session-v2"; +pub(crate) const PLANNING_SESSION_V2_SESSION_PATH: &str = ".agent/planning-v2/session.json"; +pub(crate) const PLANNING_SESSION_V2_CONVERSATION_PATH: &str = + ".agent/planning-v2/conversation.jsonl"; +const PLANNING_SESSION_V2_DEBUG_ROOT: &str = ".agent/planning-v2/debug"; +const PLANNING_SESSION_V2_DEBUG_SCHEMA_VERSION: &str = "planning-debug.v1"; +const PLANNING_SESSION_V2_MAX_CONTEXT_CHARS: usize = 1_000_000; +const PLANNING_SESSION_V2_MAX_TEXT_CHARS: usize = 64 * 1024; +const PLANNING_SESSION_V2_MAX_CONVERSATION_BYTES: u64 = 4 * 1024 * 1024; + +static ACTIVE_PLANNING_V2_PROJECTS: OnceLock>> = OnceLock::new(); + +fn active_planning_v2_projects() -> &'static Mutex> { + ACTIVE_PLANNING_V2_PROJECTS.get_or_init(|| Mutex::new(BTreeSet::new())) +} + +struct PlanningV2ActiveGuard { + project_id: String, +} + +impl Drop for PlanningV2ActiveGuard { + fn drop(&mut self) { + if let Ok(mut active) = active_planning_v2_projects().lock() { + active.remove(&self.project_id); + } + } +} + +fn try_acquire_planning_v2_active(project_id: &str) -> Result { + let mut active = active_planning_v2_projects() + .lock() + .map_err(|_| "Planning V2 活跃回合锁损坏".to_string())?; + if !active.insert(project_id.to_string()) { + return Err("Planning V2 当前已有回合执行中".to_string()); + } + Ok(PlanningV2ActiveGuard { + project_id: project_id.to_string(), + }) +} + +fn planning_v2_is_active(project_id: &str) -> bool { + active_planning_v2_projects() + .lock() + .map(|active| active.contains(project_id)) + .unwrap_or(false) +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PlanningCapabilitySnapshotV2 { + pub tools: Vec, + pub skills: Vec, +} + +impl Default for PlanningCapabilitySnapshotV2 { + fn default() -> Self { + Self { + tools: Vec::new(), + skills: Vec::new(), + } + } +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PlanningErrorV2 { + pub code: String, + pub summary: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PlanningSessionV2 { + pub schema_version: String, + pub engine: String, + pub session_id: String, + pub project_id: String, + pub mode: String, + pub status: String, + pub turn_index: u64, + pub question_count: u64, + pub question_limit: Option, + pub revision_count: u64, + pub current_artifact_version: Option, + pub current_question: Option, + pub capabilities: PlanningCapabilitySnapshotV2, + pub processing_seconds: f64, + pub created_at_utc: String, + pub updated_at_utc: String, + pub last_error: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PlanningMessageV2 { + pub schema_version: String, + pub message_id: String, + pub client_turn_id: String, + pub turn_index: u64, + pub at_utc: String, + pub role: String, + pub kind: String, + pub payload: Value, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PlanningTurnResultV2 { + pub schema_version: String, + pub kind: String, + pub payload: Value, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PlanningSessionCommandResultV2 { + pub session: PlanningSessionV2, + pub result: Option, + pub current_artifact: Option, + pub replayed: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub conversation: Option>, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PlanningSessionStreamEventV2 { + pub session_id: String, + pub client_turn_id: String, + pub status: String, + pub delta_text: String, + pub accumulated_text: String, + pub finish_reason: Option, + pub result: Option, + pub error: Option, +} + +#[derive(Clone, Debug)] +struct PlanningTurnStartV2 { + session: PlanningSessionV2, + context_messages: Vec, + replay: Option, +} + +fn planning_session_path(root: &Path) -> PathBuf { + root.join(PLANNING_SESSION_V2_SESSION_PATH) +} + +fn planning_conversation_path(root: &Path) -> PathBuf { + root.join(PLANNING_SESSION_V2_CONVERSATION_PATH) +} + +fn planning_debug_call_dir(root: &Path, debug_call_id: &str) -> PathBuf { + root.join(PLANNING_SESSION_V2_DEBUG_ROOT) + .join(debug_call_id) +} + +fn write_planning_debug_json( + root: &Path, + debug_call_id: &str, + relative: &str, + value: &Value, +) -> Option { + let path = planning_debug_call_dir(root, debug_call_id).join(relative); + let mut bytes = serde_json::to_vec_pretty(value).ok()?; + bytes.push(b'\n'); + write_game_creator_private_file(&path, &bytes, "Planning V2 Provider 诊断") + .ok() + .map(|_| format!("{PLANNING_SESSION_V2_DEBUG_ROOT}/{debug_call_id}/{relative}")) +} + +fn append_planning_debug_event(root: &Path, debug_call_id: &str, event: Value) { + let path = planning_debug_call_dir(root, debug_call_id).join("events.jsonl"); + let Ok(line) = serde_json::to_string(&event) else { + return; + }; + let mut content = match fs::read_to_string(&path) { + Ok(content) => content, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(), + Err(_) => return, + }; + if !content.is_empty() && !content.ends_with('\n') { + content.push('\n'); + } + content.push_str(&line); + content.push('\n'); + let _ = + write_game_creator_private_file(&path, content.as_bytes(), "Planning V2 Provider 诊断事件"); +} + +fn persist_planning_debug_request_v2( + root: &Path, + debug_call_id: &str, + session: &PlanningSessionV2, + client_turn_id: &str, + provider_attempt: u8, + stream: bool, + request: &platform_llm::LlmRunRequest, +) { + let snapshot = serde_json::json!({ + "schemaVersion": PLANNING_SESSION_V2_DEBUG_SCHEMA_VERSION, + "kind": "provider_request", + "debugCallId": debug_call_id, + "sessionId": &session.session_id, + "projectId": &session.project_id, + "clientTurnId": client_turn_id, + "turnIndex": session.turn_index, + "providerAttempt": provider_attempt, + "model": request.model.as_deref(), + "apiKind": request.api_kind, + "stream": stream, + "maxOutputTokens": request.max_output_tokens, + "requestTimeoutMs": request.request_timeout_ms, + "reasoningEffort": format!("{:?}", request.response_reasoning_effort), + "textVerbosity": format!("{:?}", request.response_text_verbosity), + "toolChoice": request.tool_choice, + "messages": &request.messages, + "tools": &request.function_tools, + }); + let file = format!("requests/attempt-{provider_attempt}.json"); + let path = write_planning_debug_json(root, debug_call_id, &file, &snapshot); + append_planning_debug_event( + root, + debug_call_id, + serde_json::json!({ + "schemaVersion": PLANNING_SESSION_V2_DEBUG_SCHEMA_VERSION, + "eventType": "provider_attempt_started", + "debugCallId": debug_call_id, + "sessionId": &session.session_id, + "projectId": &session.project_id, + "clientTurnId": client_turn_id, + "turnIndex": session.turn_index, + "providerAttempt": provider_attempt, + "requestFile": path, + "atUtc": current_plan_timestamp_utc(), + }), + ); +} + +fn persist_planning_debug_response_v2( + root: &Path, + debug_call_id: &str, + session: &PlanningSessionV2, + client_turn_id: &str, + provider_attempt: u8, + response: Option<&platform_llm::LlmRunResponse>, + error: Option<&str>, +) { + let response_value = response.map(|response| { + serde_json::json!({ + "provider": response.provider, + "model": &response.model, + "responseId": &response.response_id, + "finishReason": &response.finish_reason, + "usage": &response.usage, + "text": &response.text, + "toolCalls": &response.tool_calls, + }) + }); + let snapshot = serde_json::json!({ + "schemaVersion": PLANNING_SESSION_V2_DEBUG_SCHEMA_VERSION, + "kind": "provider_response", + "debugCallId": debug_call_id, + "sessionId": &session.session_id, + "projectId": &session.project_id, + "clientTurnId": client_turn_id, + "turnIndex": session.turn_index, + "providerAttempt": provider_attempt, + "response": response_value, + "error": error, + "atUtc": current_plan_timestamp_utc(), + }); + let file = format!("responses/attempt-{provider_attempt}.json"); + let path = write_planning_debug_json(root, debug_call_id, &file, &snapshot); + append_planning_debug_event( + root, + debug_call_id, + serde_json::json!({ + "schemaVersion": PLANNING_SESSION_V2_DEBUG_SCHEMA_VERSION, + "eventType": "provider_attempt_finished", + "debugCallId": debug_call_id, + "sessionId": &session.session_id, + "projectId": &session.project_id, + "clientTurnId": client_turn_id, + "turnIndex": session.turn_index, + "providerAttempt": provider_attempt, + "responseFile": path, + "error": error, + "atUtc": current_plan_timestamp_utc(), + }), + ); +} + +fn persist_planning_debug_classification_v2( + root: &Path, + debug_call_id: &str, + session: &PlanningSessionV2, + client_turn_id: &str, + provider_attempt: u8, + parsed_type: Option<&str>, + accepted: bool, + retry_scheduled: bool, + error: Option<&str>, +) { + append_planning_debug_event( + root, + debug_call_id, + serde_json::json!({ + "schemaVersion": PLANNING_SESSION_V2_DEBUG_SCHEMA_VERSION, + "eventType": "provider_output_classified", + "debugCallId": debug_call_id, + "sessionId": &session.session_id, + "projectId": &session.project_id, + "clientTurnId": client_turn_id, + "turnIndex": session.turn_index, + "providerAttempt": provider_attempt, + "parsedType": parsed_type, + "accepted": accepted, + "retryScheduled": retry_scheduled, + "error": error, + "atUtc": current_plan_timestamp_utc(), + }), + ); +} + +fn validate_client_turn_id(value: &str) -> Result { + let value = value.trim(); + if value.is_empty() || value.chars().count() > 128 || value.contains(['\r', '\n', '\0']) { + return Err("clientTurnId 不能为空且长度不能超过 128 个字符".to_string()); + } + Ok(value.to_string()) +} + +fn validate_prompt(value: &str) -> Result { + let value = value.trim(); + if value.is_empty() { + return Err("策划输入不能为空".to_string()); + } + if value.chars().count() > PLANNING_SESSION_V2_MAX_TEXT_CHARS { + return Err("策划输入过长".to_string()); + } + Ok(value.to_string()) +} + +fn normalize_planning_input(value: Value) -> Result { + match value { + Value::String(text) => validate_prompt(&text), + Value::Object(object) => { + if let Some(text) = object.get("text").and_then(Value::as_str) { + return validate_prompt(text); + } + if let Some(input) = object.get("input").and_then(Value::as_str) { + return validate_prompt(input); + } + if let Some(label) = object.get("optionLabel").and_then(Value::as_str) { + return validate_prompt(&format!("按选项:{label}")); + } + if let Some(index) = object.get("optionIndex").and_then(Value::as_u64) { + return validate_prompt(&format!("按第 {} 个选项做", index.saturating_add(1))); + } + Err("Planning V2 input 缺少 text、input 或 optionLabel".to_string()) + } + _ => Err("Planning V2 input 必须是文本或对象".to_string()), + } +} + +fn safe_error(code: &str, detail: impl AsRef) -> PlanningErrorV2 { + PlanningErrorV2 { + code: code.to_string(), + summary: sanitize_diagnostic_message(detail.as_ref(), None), + } +} + +pub(crate) fn read_planning_session_v2(root: &Path) -> Result, String> { + let path = planning_session_path(root); + if !prepare_game_creator_private_path_for_read(&path, false, "Planning V2 Session")? { + return Ok(None); + } + let metadata = fs::metadata(&path) + .map_err(|error| format!("读取 Planning V2 Session 元数据失败:{error}"))?; + if metadata.len() > 64 * 1024 { + return Err("PLANNING_SESSION_INVALID: Planning V2 Session 超过大小上限".to_string()); + } + let content = fs::read_to_string(&path) + .map_err(|error| format!("读取 Planning V2 Session 失败:{}: {error}", path.display()))?; + let session = serde_json::from_str::(&content) + .map_err(|error| format!("解析 Planning V2 Session 失败:{}: {error}", path.display()))?; + validate_planning_session_v2(&session)?; + Ok(Some(session)) +} + +fn validate_planning_session_v2(session: &PlanningSessionV2) -> Result<(), String> { + if session.schema_version != PLANNING_SESSION_V2_SCHEMA_VERSION + || session.engine != PLANNING_SESSION_V2_ENGINE + || session.session_id.trim().is_empty() + || session.project_id.trim().is_empty() + || !matches!( + session.status.as_str(), + "idle" + | "planning" + | "awaiting_user" + | "awaiting_approval" + | "revision_requested" + | "approved" + | "rejected" + | "provider_failed" + ) + || session + .capabilities + .tools + .iter() + .any(|value| value.trim().is_empty()) + || session + .capabilities + .skills + .iter() + .any(|value| value.trim().is_empty()) + { + return Err("PLANNING_SESSION_INVALID: Planning V2 Session 字段无效".to_string()); + } + Ok(()) +} + +pub(crate) fn write_planning_session_v2( + root: &Path, + session: &PlanningSessionV2, +) -> Result<(), String> { + let content = serde_json::to_vec_pretty(session) + .map_err(|error| format!("序列化 Planning V2 Session 失败:{error}"))?; + write_game_creator_private_file( + &planning_session_path(root), + format!("{}\n", String::from_utf8_lossy(&content)).as_bytes(), + "Planning V2 Session", + ) +} + +pub(crate) fn append_planning_message_v2( + root: &Path, + message: &PlanningMessageV2, +) -> Result<(), String> { + let line = serde_json::to_string(message) + .map_err(|error| format!("序列化 Planning V2 消息失败:{error}"))?; + let path = planning_conversation_path(root); + if let Some(parent) = path.parent() { + ensure_game_creator_private_directory_tree(parent, "Planning V2 对话目录")?; + prepare_game_creator_private_path_for_read(parent, true, "Planning V2 对话目录")?; + } + let mut content = match fs::read_to_string(&path) { + Ok(content) => content, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(), + Err(error) => { + return Err(format!( + "读取 Planning V2 对话记录失败:{}: {error}", + path.display() + )) + } + }; + if !content.is_empty() && !content.ends_with('\n') { + content.push('\n'); + } + content.push_str(&line); + content.push('\n'); + if content.len() > PLANNING_SESSION_V2_MAX_CONTEXT_CHARS.saturating_mul(4) { + return Err("Planning V2 对话记录超过有界大小".to_string()); + } + write_game_creator_private_file(&path, content.as_bytes(), "Planning V2 对话记录") +} + +pub(crate) fn read_planning_messages_v2(root: &Path) -> Result, String> { + let path = planning_conversation_path(root); + if !prepare_game_creator_private_path_for_read(&path, false, "Planning V2 对话记录")? { + return Ok(Vec::new()); + } + let metadata = fs::metadata(&path) + .map_err(|error| format!("读取 Planning V2 对话记录元数据失败:{error}"))?; + if metadata.len() > PLANNING_SESSION_V2_MAX_CONVERSATION_BYTES { + return Err("PLANNING_CONTEXT_OVERFLOW: Planning V2 对话记录超过有界大小".to_string()); + } + let file = fs::File::open(&path) + .map_err(|error| format!("读取 Planning V2 对话记录失败:{}: {error}", path.display()))?; + let mut messages = Vec::new(); + for line in BufReader::new(file).lines() { + let line = line.map_err(|error| format!("读取 Planning V2 消息失败:{error}"))?; + if line.trim().is_empty() { + continue; + } + let message = serde_json::from_str::(&line) + .map_err(|error| format!("解析 Planning V2 消息失败:{error}"))?; + messages.push(message); + } + Ok(messages) +} + +fn message_text(message: &PlanningMessageV2) -> Option { + message + .payload + .get("text") + .and_then(Value::as_str) + .map(ToString::to_string) +} + +fn build_context_v2( + root: &Path, + llm: &GameCreatorLlmConfig, + skip_client_turn_id: Option<&str>, +) -> Result, String> { + let messages = read_planning_messages_v2(root)?; + let mut estimated_chars = 0_usize; + let mut output = Vec::with_capacity(messages.len()); + for message in messages { + if skip_client_turn_id + .is_some_and(|turn_id| message.client_turn_id == turn_id && message.role == "user") + { + continue; + } + let text = message_text(&message).or_else(|| { + matches!(message.kind.as_str(), "question" | "artifact") + .then(|| serde_json::to_string(&message.payload).ok()) + .flatten() + }); + let Some(text) = text else { continue }; + estimated_chars = estimated_chars.saturating_add(text.chars().count()); + if estimated_chars > PLANNING_SESSION_V2_MAX_CONTEXT_CHARS { + return Err( + "PLANNING_CONTEXT_OVERFLOW: Planning V2 会话上下文超过有界上限".to_string(), + ); + } + let role = match message.role.as_str() { + "user" => platform_llm::LlmMessage::user(text), + "assistant" => platform_llm::LlmMessage::assistant(text), + _ => continue, + }; + output.push(role); + } + let configured_limit = llm.context_window_tokens.saturating_mul(4) as usize; + if configured_limit > 0 && estimated_chars > configured_limit { + return Err( + "PLANNING_CONTEXT_OVERFLOW: Planning V2 请求上下文超过 Provider 窗口".to_string(), + ); + } + Ok(output) +} + +fn new_session_v2(project_id: String, mode: String) -> PlanningSessionV2 { + let now = current_plan_timestamp_utc(); + PlanningSessionV2 { + schema_version: PLANNING_SESSION_V2_SCHEMA_VERSION.to_string(), + engine: PLANNING_SESSION_V2_ENGINE.to_string(), + session_id: format!("ps-{}", Uuid::new_v4().simple()), + project_id, + mode, + status: "idle".to_string(), + turn_index: 0, + question_count: 0, + question_limit: Some(8), + revision_count: 0, + current_artifact_version: None, + current_question: None, + capabilities: PlanningCapabilitySnapshotV2::default(), + processing_seconds: 0.0, + created_at_utc: now.clone(), + updated_at_utc: now, + last_error: None, + } +} + +fn read_project_id_v2(root: &Path) -> Result { + read_manifest_for_project(root) + .map(|manifest| manifest.project_id) + .map_err(|error| format!("读取项目身份失败:{error}")) +} + +fn existing_turn_result_v2( + messages: &[PlanningMessageV2], + client_turn_id: &str, +) -> Option { + messages + .iter() + .rev() + .find(|message| { + message.client_turn_id == client_turn_id + && message.role == "assistant" + && message.kind != "error" + }) + .and_then(|message| { + Some(PlanningTurnResultV2 { + schema_version: PLANNING_TURN_RESULT_V2_SCHEMA_VERSION.to_string(), + kind: "assistant_text".to_string(), + payload: message.payload.clone(), + }) + }) +} + +fn successful_assistant_for_turn( + messages: &[PlanningMessageV2], + turn_index: u64, +) -> Option<&PlanningMessageV2> { + messages.iter().rev().find(|message| { + message.turn_index == turn_index + && message.role == "assistant" + && message.kind != "error" + && (message.kind == "question" && validate_question_value_v2(&message.payload).is_ok() + || message_text(message).is_some_and(|text| !text.trim().is_empty())) + }) +} + +fn has_successful_assistant_for_turn(messages: &[PlanningMessageV2], turn_index: u64) -> bool { + successful_assistant_for_turn(messages, turn_index).is_some() +} + +fn committed_gdd_replay_v2( + root: &Path, + session: &PlanningSessionV2, + client_turn_id: &str, + messages: &[PlanningMessageV2], +) -> Result, String> { + if !messages + .iter() + .any(|message| message.client_turn_id == client_turn_id && message.role == "user") + { + return Ok(None); + } + if !matches!( + session.status.as_str(), + "awaiting_approval" | "approved" | "rejected" | "revision_requested" + ) || session.current_artifact_version.is_none() + { + return Ok(None); + } + let Some(artifact) = current_planning_artifact_v2(root)? else { + return Ok(None); + }; + Ok(Some(PlanningTurnResultV2 { + schema_version: PLANNING_TURN_RESULT_V2_SCHEMA_VERSION.to_string(), + kind: "artifact".to_string(), + payload: artifact, + })) +} + +fn prepare_turn_v2( + root: &Path, + client_turn_id: &str, + prompt: &str, + mode: Option<&str>, + is_start: bool, + expected_session_id: Option<&str>, + question_id: Option<&str>, +) -> Result { + validate_project_root(root)?; + let client_turn_id = validate_client_turn_id(client_turn_id)?; + let prompt = validate_prompt(prompt)?; + // 用户提交回答或审批修改意见后的续跑是一次性意图。无等待取锁会把 + // hydrate / 刚结束的审批写盘误判成外部占用,前端再映射成总控失败。 + let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "planning.v2.turn.start", + )?; + let mut session = match read_planning_session_v2(root)? { + Some(session) => session, + None => { + let mode = mode.unwrap_or("gdd").trim(); + if mode != "gdd" { + return Err("Planning V2 当前只支持 mode=gdd".to_string()); + } + new_session_v2(read_project_id_v2(root)?, mode.to_string()) + } + }; + let project_id = read_project_id_v2(root)?; + if expected_session_id.is_some_and(|expected| session.session_id != expected.trim()) { + return Err("Planning V2 Session ID 不匹配".to_string()); + } + if session.project_id != project_id { + return Err("Planning V2 Session projectId 与当前项目不一致".to_string()); + } + if let Some(requested_mode) = mode.map(str::trim).filter(|value| !value.is_empty()) { + if requested_mode != session.mode { + return Err("Planning V2 不允许在同一 Session 切换 mode".to_string()); + } + } + if read_planning_session_v2(root)?.is_some() { + reconcile_committed_planning_gdd_v2(root)?; + if let Some(updated) = read_planning_session_v2(root)? { + session = updated; + } + } + let messages = read_planning_messages_v2(root)?; + if let Some(replay) = existing_turn_result_v2(&messages, &client_turn_id) { + return Ok(PlanningTurnStartV2 { + session, + context_messages: Vec::new(), + replay: Some(replay), + }); + } + if let Some(replay) = committed_gdd_replay_v2(root, &session, &client_turn_id, &messages)? { + return Ok(PlanningTurnStartV2 { + session, + context_messages: Vec::new(), + replay: Some(replay), + }); + } + if session.status == "planning" { + return Err("Planning V2 当前已有回合执行中".to_string()); + } + if !is_start + && !matches!( + session.status.as_str(), + "awaiting_user" | "revision_requested" | "provider_failed" + ) + { + return Err(format!( + "Planning V2 Session 当前状态不可提交用户输入:{}", + session.status + )); + } + if matches!(session.status.as_str(), "approved" | "rejected") { + return Err(format!( + "Planning V2 Session 当前状态不可继续:{}", + session.status + )); + } + if is_start && session.turn_index > 0 && messages.iter().any(|message| message.role == "user") { + return Err("Planning V2 已有会话内容,请使用 continue_planning_session_v2".to_string()); + } + let existing_user = messages + .iter() + .rev() + .find(|message| message.client_turn_id == client_turn_id && message.role == "user"); + if existing_user.is_none() { + let current_question_id = session + .current_question + .as_ref() + .and_then(|question| question.get("id")) + .and_then(Value::as_str); + if question_id != current_question_id { + return Err("待回答问题已变更,请刷新策划状态".to_string()); + } + } + let llm = resolve_game_creator_llm_config_for_agent( + &load_game_creator_app_config()?, + "planning-agent-v2", + ); + let context_messages = + build_context_v2(root, &llm, existing_user.map(|_| client_turn_id.as_str()))?; + if let Some(existing_user) = existing_user { + let existing_text = message_text(existing_user).unwrap_or_default(); + if existing_text != prompt { + return Err("同一 clientTurnId 不能提交不同用户输入".to_string()); + } + if session.status != "provider_failed" { + return Err("Planning V2 当前回合不能重复提交".to_string()); + } + session.turn_index = existing_user.turn_index; + } else { + session.turn_index = session.turn_index.saturating_add(1); + } + session.status = "planning".to_string(); + session.current_question = None; + session.last_error = None; + session.updated_at_utc = current_plan_timestamp_utc(); + if existing_user.is_none() { + append_planning_message_v2( + root, + &PlanningMessageV2 { + schema_version: PLANNING_MESSAGE_V2_SCHEMA_VERSION.to_string(), + message_id: format!("msg-{}", Uuid::new_v4().simple()), + client_turn_id: client_turn_id.clone(), + turn_index: session.turn_index, + at_utc: current_plan_timestamp_utc(), + role: "user".to_string(), + kind: "text".to_string(), + payload: serde_json::json!({"text": prompt}), + }, + )?; + } + write_planning_session_v2(root, &session)?; + Ok(PlanningTurnStartV2 { + session, + context_messages, + replay: None, + }) +} + +fn planning_v2_question_policy(session: &PlanningSessionV2) -> String { + if session.question_count >= 3 { + return "已达到 3 轮问询上限,本轮整理当前信息并调用 plan_submit_gdd。".to_string(); + } + match session.question_limit { + Some(limit) if session.question_count >= limit => format!( + "当前已向用户展示 {} 个有效问题;已达到问询上限,禁止再调用 plan_ask_question,必须调用 plan_submit_gdd 提交完整 GDD。", + session.question_count + ), + _ => format!( + "当前已向用户展示 {} 个有效问题。整个会话最多提问 3 轮;本轮可以继续问一个最重要的未决设计问题,也可以在信息足够时直接提交 GDD。", + session.question_count + ), + } +} + +fn planning_v2_system_prompt(session: &PlanningSessionV2) -> String { + // `question_limit` 是 Runtime 对“已展示问题数”的硬上限;模型提示词中的 + // 问询策略只是偏好,两者不要求数值一致。这里仅传当前已展示数, + // 达到硬上限时再明确禁止本次继续提问。 + format!( + "你是立项策划 Agent。当前会话 {},回合 {}。{}\n\n每轮必须且只能调用一个工具:问询用 plan_ask_question,出稿用 plan_submit_gdd。不要在正文输出 JSON、Markdown、解释或代码围栏。\n\n除非用户明确要求直接出稿,否则先进行关键设计澄清。\n整个会话最多提问 3 轮。\n3 轮是上限,不是配额;信息已经足够时允许 0~2 轮提前出稿。\n达到第 3 轮或信息足够时,整理当前信息并调用 plan_submit_gdd。\n审批修改后的续跑沿用本协议:可以继续问询,也可以直接提交新的完整 GDD;已确认问答和问询计数不重置。\n每轮收到用户回答后重新选择最重要的下一个未决决定,不要重复已回答的问题。\n提问优先顺序:核心行为与本局目标 → 重玩动力 → 制作边界与 MVP。\n主题包装、美术、数值和次要系统可以用 assumption_pending,answerSource 记为 agent_inferred。修改以最新用户意见为准,提交完整 GDD,不要打补丁。", + session.session_id, + session.turn_index, + planning_v2_question_policy(session), + ) +} + +fn build_provider_request_v2( + session: &PlanningSessionV2, + context_messages: Vec, + prompt: &str, + llm: &GameCreatorLlmConfig, +) -> Result { + let system = platform_llm::LlmMessage::system(planning_v2_system_prompt(session)); + let mut messages = vec![system]; + messages.extend(context_messages); + messages.push(platform_llm::LlmMessage::user(prompt.to_string())); + let api_kind = parse_game_creator_llm_api_kind(&llm.api_kind)?; + let request = platform_llm::LlmRunRequest::new(messages) + .with_api_kind(api_kind) + .with_model(llm.model.clone()) + .with_max_output_tokens(16_384) + .with_request_timeout_ms(llm.request_timeout_ms) + .with_function_tools(planning_v2_function_tools()) + // 注意:部分模型或端点可能不支持 tool_choice=required(例如思考模式下的 + // DeepSeek 会以 400 “Thinking mode does not support this tool_choice” 拒绝); + // 该 4xx 属硬错误,不进入瞬态重试。接入新模型时必须先确认端点接受 required。 + .with_tool_choice(platform_llm::LlmToolChoice::Required); + apply_game_creator_llm_reasoning_effort(request, llm) +} + +fn planning_turn_result_from_llm(response: &platform_llm::LlmRunResponse) -> PlanningTurnResultV2 { + PlanningTurnResultV2 { + schema_version: PLANNING_TURN_RESULT_V2_SCHEMA_VERSION.to_string(), + kind: "assistant_text".to_string(), + payload: serde_json::json!({ + "text": response.text, + "toolCalls": response.tool_calls.iter().map(|call| { + serde_json::json!({ + "id": call.id, + "name": call.name, + "arguments": call.arguments, + }) + }).collect::>(), + "finishReason": response.finish_reason, + "responseId": response.response_id, + "model": response.model, + }), + } +} + +struct PlanningProviderFailureV2 { + detail: String, + transient_kind: Option<&'static str>, +} + +impl PlanningProviderFailureV2 { + fn terminal(detail: String) -> Self { + Self { + detail, + transient_kind: None, + } + } +} + +async fn invoke_provider_v2( + root: &Path, + session: &PlanningSessionV2, + client_turn_id: &str, + provider_attempt: u8, + debug_call_id: &str, + prompt: &str, + context_messages: Vec, + mut on_delta: F, +) -> Result +where + F: FnMut(&str, &str, Option<&str>) + Send, +{ + let config = load_game_creator_app_config().map_err(PlanningProviderFailureV2::terminal)?; + let llm = resolve_game_creator_llm_config_for_agent(&config, "planning-agent-v2"); + let client = build_game_creator_llm_client_from_llm_config(&llm, "planning.v2") + .map_err(PlanningProviderFailureV2::terminal)?; + let request = build_provider_request_v2(session, context_messages, prompt, &llm) + .map_err(PlanningProviderFailureV2::terminal)?; + persist_planning_debug_request_v2( + root, + debug_call_id, + session, + client_turn_id, + provider_attempt, + llm.stream, + &request, + ); + if llm.stream { + let response = client + .stream_run(request, |delta| { + on_delta( + delta.accumulated_text.as_str(), + delta.delta_text.as_str(), + delta.finish_reason.as_deref(), + ); + }) + .await; + match response { + Ok(response) => { + persist_planning_debug_response_v2( + root, + debug_call_id, + session, + client_turn_id, + provider_attempt, + Some(&response), + None, + ); + Ok(planning_turn_result_from_llm(&response)) + } + Err(error) => { + let transient_kind = + game_creator_agent_runtime_transient_provider_error_kind(&error, false); + let detail = format!("Planning V2 Provider 流式调用失败:{error}"); + persist_planning_debug_response_v2( + root, + debug_call_id, + session, + client_turn_id, + provider_attempt, + None, + Some(detail.as_str()), + ); + Err(PlanningProviderFailureV2 { + detail, + transient_kind, + }) + } + } + } else { + let response = client.run(request).await; + match response { + Ok(response) => { + let text = response.text.clone(); + on_delta( + text.as_str(), + text.as_str(), + response.finish_reason.as_deref(), + ); + persist_planning_debug_response_v2( + root, + debug_call_id, + session, + client_turn_id, + provider_attempt, + Some(&response), + None, + ); + Ok(planning_turn_result_from_llm(&response)) + } + Err(error) => { + let transient_kind = + game_creator_agent_runtime_transient_provider_error_kind(&error, false); + let detail = format!("Planning V2 Provider 调用失败:{error}"); + persist_planning_debug_response_v2( + root, + debug_call_id, + session, + client_turn_id, + provider_attempt, + None, + Some(detail.as_str()), + ); + Err(PlanningProviderFailureV2 { + detail, + transient_kind, + }) + } + } + } +} + +pub(crate) fn result_text(result: &PlanningTurnResultV2) -> String { + result + .payload + .get("text") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string() +} + +fn persist_turn_failure_v2( + root: &Path, + client_turn_id: &str, + session_id: &str, + turn_index: u64, + elapsed_seconds: f64, + error: &PlanningErrorV2, +) -> Result { + let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "planning.v2.turn.fail", + )?; + let mut session = + read_planning_session_v2(root)?.ok_or_else(|| "Planning V2 Session 不存在".to_string())?; + if session.session_id != session_id || session.turn_index != turn_index { + return Err("Planning V2 Session 回合身份发生变化".to_string()); + } + append_planning_message_v2( + root, + &PlanningMessageV2 { + schema_version: PLANNING_MESSAGE_V2_SCHEMA_VERSION.to_string(), + message_id: format!("msg-{}", Uuid::new_v4().simple()), + client_turn_id: client_turn_id.to_string(), + turn_index, + at_utc: current_plan_timestamp_utc(), + role: "assistant".to_string(), + kind: "error".to_string(), + payload: serde_json::json!({"code": error.code, "summary": error.summary}), + }, + )?; + session.status = "provider_failed".to_string(); + session.processing_seconds += elapsed_seconds.max(0.0); + session.updated_at_utc = current_plan_timestamp_utc(); + session.last_error = Some(error.clone()); + write_planning_session_v2(root, &session)?; + Ok(session) +} + +async fn run_turn_v2( + root: &Path, + start: PlanningTurnStartV2, + client_turn_id: String, + prompt: String, + mut emit: F, +) -> Result +where + F: FnMut(PlanningSessionStreamEventV2) + Send, +{ + if let Some(result) = start.replay { + return Ok(PlanningSessionCommandResultV2 { + session: start.session, + result: Some(result), + current_artifact: current_planning_artifact_v2(root)?, + replayed: true, + conversation: None, + }); + } + let session_id = start.session.session_id.clone(); + let turn_index = start.session.turn_index; + emit(PlanningSessionStreamEventV2 { + session_id: session_id.clone(), + client_turn_id: client_turn_id.clone(), + status: "started".to_string(), + delta_text: String::new(), + accumulated_text: String::new(), + finish_reason: None, + result: None, + error: None, + }); + let started_at = Instant::now(); + let mut accumulated = String::new(); + let mut attempt_prompt = prompt.clone(); + let mut policy_retry = 0_u8; + let mut provider_retry = 0_u32; + let mut provider_attempt = 0_u8; + let provider_retry_llm = load_game_creator_app_config() + .map(|config| resolve_game_creator_llm_config_for_agent(&config, "planning-agent-v2")) + .ok(); + let provider_max_retries = provider_retry_llm + .as_ref() + .map(|llm| llm.max_retries) + .unwrap_or(0); + let provider_retry_backoff_ms = provider_retry_llm + .as_ref() + .map(|llm| llm.retry_backoff_ms) + .unwrap_or(0); + let debug_call_id = format!("call-{}", Uuid::new_v4().simple()); + let policy_output = loop { + accumulated.clear(); + provider_attempt = provider_attempt.saturating_add(1); + let provider_result = invoke_provider_v2( + root, + &start.session, + &client_turn_id, + provider_attempt, + &debug_call_id, + &attempt_prompt, + start.context_messages.clone(), + |all, _delta, _finish_reason| { + accumulated = all.to_string(); + }, + ) + .await; + let result = match provider_result { + Ok(result) => result, + Err(failure) => { + if failure.transient_kind.is_some() && provider_retry < provider_max_retries { + provider_retry = provider_retry.saturating_add(1); + let backoff_ms = game_creator_agent_runtime_transient_retry_backoff_ms( + provider_retry_backoff_ms, + provider_retry, + ); + tokio::time::sleep(std::time::Duration::from_millis(backoff_ms)).await; + continue; + } + let detail = if failure.transient_kind.is_some() && provider_max_retries > 0 { + format!( + "{}(瞬态故障已自动重试 {}/{} 次)", + failure.detail, provider_retry, provider_max_retries + ) + } else { + failure.detail + }; + let error = safe_error("PROVIDER_FAILED", detail); + let session = persist_turn_failure_v2( + root, + &client_turn_id, + &session_id, + turn_index, + started_at.elapsed().as_secs_f64(), + &error, + )?; + emit(PlanningSessionStreamEventV2 { + session_id, + client_turn_id, + status: "failed".to_string(), + delta_text: String::new(), + accumulated_text: accumulated, + finish_reason: None, + result: None, + error: Some(error.clone()), + }); + return Ok(PlanningSessionCommandResultV2 { + session, + result: Some(PlanningTurnResultV2 { + schema_version: PLANNING_TURN_RESULT_V2_SCHEMA_VERSION.to_string(), + kind: "error".to_string(), + payload: serde_json::json!({"code": error.code, "summary": error.summary}), + }), + current_artifact: current_planning_artifact_v2(root)?, + replayed: false, + conversation: None, + }); + } + }; + match parse_planning_policy_output_v2(&result).and_then(|output| { + validate_planning_policy_output_v2(&output)?; + if matches!(output, PlanningPolicyOutputV2::Question(_)) + && start + .session + .question_limit + .is_some_and(|limit| start.session.question_count >= limit) + { + return Err( + "PLANNING_QUESTION_LIMIT: questionCount 已达到上限,必须直接输出 GDD" + .to_string(), + ); + } + Ok(output) + }) { + Ok(output) => { + persist_planning_debug_classification_v2( + root, + &debug_call_id, + &start.session, + &client_turn_id, + provider_attempt, + Some(match &output { + PlanningPolicyOutputV2::Question(_) => "question", + PlanningPolicyOutputV2::Gdd(_) => "gdd", + }), + true, + false, + None, + ); + break output; + } + Err(detail) if policy_retry < 1 => { + persist_planning_debug_classification_v2( + root, + &debug_call_id, + &start.session, + &client_turn_id, + provider_attempt, + None, + false, + true, + Some(detail.as_str()), + ); + policy_retry = policy_retry.saturating_add(1); + let retry_detail = detail.replace('\n', "\n- "); + attempt_prompt = format!( + "{}\n\n【阻断校验失败】Runtime 拒绝了上一次输出,具体原因如下:\n- {}\n请针对以上原因逐项修复,保持未涉及内容不变,并调用 plan_ask_question 或 plan_submit_gdd;不要在正文输出 JSON,不要解释。{}", + prompt, + retry_detail, + if start.session.question_limit.is_some_and(|limit| { + start.session.question_count >= limit + }) { + "当前问题数已达到上限,禁止再调用 plan_ask_question,必须调用 plan_submit_gdd。" + } else { + "" + } + ); + } + Err(detail) => { + persist_planning_debug_classification_v2( + root, + &debug_call_id, + &start.session, + &client_turn_id, + provider_attempt, + None, + false, + false, + Some(detail.as_str()), + ); + let error = safe_error("PLANNING_INVALID_OUTPUT", detail); + let session = persist_turn_failure_v2( + root, + &client_turn_id, + &session_id, + turn_index, + started_at.elapsed().as_secs_f64(), + &error, + )?; + emit(PlanningSessionStreamEventV2 { + session_id, + client_turn_id, + status: "failed".to_string(), + delta_text: String::new(), + accumulated_text: accumulated, + finish_reason: None, + result: None, + error: Some(error.clone()), + }); + return Ok(PlanningSessionCommandResultV2 { + session, + result: Some(PlanningTurnResultV2 { + schema_version: PLANNING_TURN_RESULT_V2_SCHEMA_VERSION.to_string(), + kind: "error".to_string(), + payload: serde_json::json!({"code": error.code, "summary": error.summary}), + }), + current_artifact: current_planning_artifact_v2(root)?, + replayed: false, + conversation: None, + }); + } + } + }; + if !accumulated.is_empty() { + emit(PlanningSessionStreamEventV2 { + session_id: session_id.clone(), + client_turn_id: client_turn_id.clone(), + status: "delta".to_string(), + delta_text: accumulated.clone(), + accumulated_text: accumulated.clone(), + finish_reason: None, + result: None, + error: None, + }); + } + let elapsed_seconds = started_at.elapsed().as_secs_f64(); + match persist_planning_policy_output_v2( + root, + &client_turn_id, + &session_id, + turn_index, + elapsed_seconds, + policy_output, + ) { + Ok(persisted) => { + emit(PlanningSessionStreamEventV2 { + session_id: session_id.clone(), + client_turn_id: client_turn_id.clone(), + status: "completed".to_string(), + delta_text: String::new(), + accumulated_text: if accumulated.is_empty() { + result_text(&persisted.result) + } else { + accumulated + }, + finish_reason: persisted + .result + .payload + .get("finishReason") + .and_then(Value::as_str) + .map(ToString::to_string), + result: Some(persisted.result.clone()), + error: None, + }); + Ok(PlanningSessionCommandResultV2 { + session: persisted.session, + result: Some(persisted.result), + current_artifact: persisted.current_artifact, + replayed: false, + conversation: None, + }) + } + Err(detail) => { + let repaired = { + let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "planning.v2.gdd.reconcile", + )?; + matches!(reconcile_committed_planning_gdd_v2(root), Ok(Some(_))) + }; + if repaired { + let session = read_planning_session_v2(root)? + .ok_or_else(|| "Planning V2 Session 不存在".to_string())?; + let artifact = current_planning_artifact_v2(root)? + .ok_or_else(|| "Planning V2 已提交 GDD 但缺少当前产物投影".to_string())?; + let result = PlanningTurnResultV2 { + schema_version: PLANNING_TURN_RESULT_V2_SCHEMA_VERSION.to_string(), + kind: "artifact".to_string(), + payload: artifact.clone(), + }; + emit(PlanningSessionStreamEventV2 { + session_id: session_id.clone(), + client_turn_id: client_turn_id.clone(), + status: "completed".to_string(), + delta_text: String::new(), + accumulated_text: if accumulated.is_empty() { + result_text(&result) + } else { + accumulated + }, + finish_reason: None, + result: Some(result.clone()), + error: None, + }); + return Ok(PlanningSessionCommandResultV2 { + session, + result: Some(result), + current_artifact: Some(artifact), + replayed: false, + conversation: None, + }); + } + let error = safe_error("PLANNING_PERSIST_FAILED", detail); + let session = persist_turn_failure_v2( + root, + &client_turn_id, + &session_id, + turn_index, + elapsed_seconds, + &error, + )?; + emit(PlanningSessionStreamEventV2 { + session_id: session_id.clone(), + client_turn_id: client_turn_id.clone(), + status: "failed".to_string(), + delta_text: String::new(), + accumulated_text: accumulated, + finish_reason: None, + result: None, + error: Some(error.clone()), + }); + Ok(PlanningSessionCommandResultV2 { + session, + result: Some(PlanningTurnResultV2 { + schema_version: PLANNING_TURN_RESULT_V2_SCHEMA_VERSION.to_string(), + kind: "error".to_string(), + payload: serde_json::json!({"code": error.code, "summary": error.summary}), + }), + current_artifact: current_planning_artifact_v2(root)?, + replayed: false, + conversation: None, + }) + } + } +} + +#[tauri::command] +pub(crate) async fn start_planning_session_v2( + app: tauri::AppHandle, + project_path: String, + client_turn_id: String, + prompt: String, + mode: Option, +) -> Result { + run_planning_session_v2_command( + &app, + project_path, + None, + client_turn_id, + prompt, + mode, + true, + None, + ) + .await +} + +#[tauri::command] +pub(crate) async fn continue_planning_session_v2( + app: tauri::AppHandle, + project_path: String, + session_id: String, + client_turn_id: String, + input: Value, +) -> Result { + let question_id = input + .get("questionId") + .and_then(Value::as_str) + .map(str::to_owned); + run_planning_session_v2_command( + &app, + project_path, + Some(session_id), + client_turn_id, + normalize_planning_input(input)?, + None, + false, + question_id, + ) + .await +} + +async fn run_planning_session_v2_command( + app: &tauri::AppHandle, + project_path: String, + expected_session_id: Option, + client_turn_id: String, + prompt: String, + mode: Option, + is_start: bool, + question_id: Option, +) -> Result { + let root = PathBuf::from(project_path.trim()); + enforce_project_permission_policy(&root, "conversation.read")?; + enforce_project_permission_policy(&root, "conversation.write")?; + let prompt = validate_prompt(&prompt)?; + let client_turn_id = validate_client_turn_id(&client_turn_id)?; + let project_id = read_project_id_v2(&root)?; + let _active_guard = try_acquire_planning_v2_active(&project_id)?; + let start = prepare_turn_v2( + &root, + &client_turn_id, + &prompt, + mode.as_deref(), + is_start, + expected_session_id.as_deref(), + question_id.as_deref(), + )?; + let event_app = app.clone(); + run_turn_v2(&root, start, client_turn_id, prompt, move |event| { + let _ = event_app.emit("planning-session-v2-stream", event); + }) + .await +} + +#[tauri::command] +pub(crate) fn hydrate_planning_session_v2( + project_path: String, + session_id: Option, +) -> Result, String> { + let root = PathBuf::from(project_path.trim()); + enforce_project_permission_policy(&root, "conversation.read")?; + let project_id = read_project_id_v2(&root)?; + if planning_v2_is_active(&project_id) { + return Ok(None); + } + let Some(preflight_session) = read_planning_session_v2(&root)? else { + return Ok(None); + }; + if preflight_session.project_id != project_id { + return Err("Planning V2 Session projectId 与当前项目不一致".to_string()); + } + if let Some(session_id) = session_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + if preflight_session.session_id != session_id { + return Err("Planning V2 Session ID 不匹配".to_string()); + } + } + let _lock = match try_acquire_game_creator_agent_runtime_project_write_lock( + &root, + "planning.v2.hydrate", + ) { + Ok(lock) => lock, + Err(error) if error.starts_with(crate::project::PROJECT_WRITE_LOCK_CONTENTION_PREFIX) => { + return Ok(None) + } + Err(error) => return Err(error), + }; + let Some(mut session) = read_planning_session_v2(&root)? else { + return Ok(None); + }; + if session.status == "planning" && !planning_v2_is_active(&session.project_id) { + let messages = read_planning_messages_v2(&root)?; + if let Some(message) = successful_assistant_for_turn(&messages, session.turn_index) { + session.status = "awaiting_user".to_string(); + if message.kind == "question" { + session.current_question = Some(message.payload.clone()); + let count = messages + .iter() + .filter(|candidate| { + candidate.role == "assistant" + && candidate.kind == "question" + && candidate.turn_index <= session.turn_index + && validate_question_value_v2(&candidate.payload).is_ok() + }) + .map(|candidate| candidate.turn_index) + .collect::>() + .len() as u64; + session.question_count = session.question_count.max(count); + } + session.last_error = None; + } else { + session.status = "provider_failed".to_string(); + session.last_error = Some(safe_error( + "RECOVERY_REQUIRED", + "上次 Planning V2 Provider 回合在进程退出前未完成,请重新提交用户输入", + )); + } + session.updated_at_utc = current_plan_timestamp_utc(); + write_planning_session_v2(&root, &session)?; + } + reconcile_committed_planning_gdd_v2(&root)?; + let session = + read_planning_session_v2(&root)?.ok_or_else(|| "Planning V2 Session 不存在".to_string())?; + Ok(Some(PlanningSessionCommandResultV2 { + session, + result: None, + current_artifact: current_planning_artifact_v2(&root)?, + replayed: false, + conversation: Some(read_planning_messages_v2(&root)?), + })) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn client_turn_id_and_prompt_are_trimmed_and_bounded() { + assert_eq!(validate_client_turn_id(" turn-1 ").unwrap(), "turn-1"); + assert_eq!(validate_prompt(" 做方案 ").unwrap(), "做方案"); + assert!(validate_client_turn_id(&"x".repeat(129)).is_err()); + assert!(validate_prompt(" ").is_err()); + } + + #[test] + fn existing_turn_result_replays_assistant_text_or_error() { + let messages = vec![PlanningMessageV2 { + schema_version: PLANNING_MESSAGE_V2_SCHEMA_VERSION.to_string(), + message_id: "msg-1".to_string(), + client_turn_id: "turn-1".to_string(), + turn_index: 1, + at_utc: "2026-09-03T00:00:00.000Z".to_string(), + role: "assistant".to_string(), + kind: "text".to_string(), + payload: serde_json::json!({"text": "已完成"}), + }]; + let result = existing_turn_result_v2(&messages, "turn-1").expect("replay result"); + assert_eq!(result.kind, "assistant_text"); + assert_eq!(result_text(&result), "已完成"); + } + + #[test] + fn error_message_is_not_treated_as_successful_idempotent_replay() { + let messages = vec![PlanningMessageV2 { + schema_version: PLANNING_MESSAGE_V2_SCHEMA_VERSION.to_string(), + message_id: "msg-1".to_string(), + client_turn_id: "turn-1".to_string(), + turn_index: 1, + at_utc: "2026-09-03T00:00:00.000Z".to_string(), + role: "assistant".to_string(), + kind: "error".to_string(), + payload: serde_json::json!({"code": "PROVIDER_FAILED"}), + }]; + assert!(existing_turn_result_v2(&messages, "turn-1").is_none()); + } + + #[test] + fn session_validation_rejects_unknown_status_or_schema() { + let mut session = new_session_v2("project-1".to_string(), "gdd".to_string()); + assert!(validate_planning_session_v2(&session).is_ok()); + session.status = "unknown".to_string(); + assert!(validate_planning_session_v2(&session).is_err()); + session.status = "idle".to_string(); + session.schema_version = "planning-session.v1".to_string(); + assert!(validate_planning_session_v2(&session).is_err()); + } + + #[test] + fn continue_input_accepts_contract_shaped_option_payload() { + assert_eq!( + normalize_planning_input(serde_json::json!({ + "kind": "option", + "optionIndex": 0, + "optionLabel": "工作台设计迭代" + })) + .unwrap(), + "按选项:工作台设计迭代" + ); + assert_eq!( + normalize_planning_input(serde_json::json!({ "optionIndex": 1 })).unwrap(), + "按第 2 个选项做" + ); + } + + fn v2_revision_session_fixture() -> (tempfile::TempDir, PathBuf, PlanningSessionV2) { + let directory = tempfile::tempdir().expect("create v2 lock wait fixture"); + let root = directory.path().to_path_buf(); + crate::init_local_game_project_at(&root, "project-v2-lock", "V2 锁等待测试") + .expect("init project"); + let project_id = crate::read_manifest_for_project(&root) + .expect("read manifest") + .project_id; + let mut session = new_session_v2(project_id, "gdd".to_string()); + session.status = "revision_requested".to_string(); + session.turn_index = 1; + write_planning_session_v2(&root, &session).expect("write session"); + (directory, root, session) + } + + fn hold_project_lock_briefly(root: &Path, hold_millis: u64) -> std::thread::JoinHandle<()> { + let lock_path = root.join(".agent/project.lock"); + fs::write(&lock_path, b"test lock").expect("hold project lock"); + std::thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_millis(hold_millis)); + fs::remove_file(&lock_path).expect("release project lock"); + }) + } + + #[test] + fn turn_start_rides_out_a_briefly_held_project_lock() { + let (_dir, root, _session) = v2_revision_session_fixture(); + let holder = hold_project_lock_briefly(&root, 120); + let start = prepare_turn_v2(&root, "turn-revise-1", "加强节奏", None, false, None, None) + .expect("修订续跑必须等过瞬时锁争用,而不是把失败甩回总控"); + holder.join().expect("lock holder thread"); + assert_eq!(start.session.status, "planning"); + assert!(start.replay.is_none()); + } + + #[test] + fn question_answer_must_target_the_persisted_question() { + let (_dir, root, mut session) = v2_revision_session_fixture(); + session.status = "awaiting_user".to_string(); + session.current_question = Some(serde_json::json!({"id": "question-2"})); + write_planning_session_v2(&root, &session).unwrap(); + for question_id in [Some("question-1"), None] { + let result = prepare_turn_v2( + &root, + "late-answer", + "选择第一个", + None, + false, + Some(&session.session_id), + question_id, + ); + assert!(matches!(result, Err(error) if error.contains("待回答问题已变更"))); + let persisted = read_planning_session_v2(&root).unwrap().unwrap(); + assert_eq!(persisted.status, "awaiting_user"); + assert_eq!(persisted.turn_index, session.turn_index); + assert_eq!(persisted.current_question, session.current_question); + assert!(read_planning_messages_v2(&root).unwrap().is_empty()); + } + let result = prepare_turn_v2( + &root, + "current-answer", + "选择第一个", + None, + false, + Some(&session.session_id), + Some("question-2"), + ) + .expect("当前问题回答应正常推进"); + assert_eq!(result.session.status, "planning"); + } + + #[test] + fn turn_rejects_a_different_session_before_writing() { + let (_dir, root, session) = v2_revision_session_fixture(); + let result = prepare_turn_v2( + &root, + "wrong-session", + "加强节奏", + None, + false, + Some("old-session"), + None, + ); + assert!(matches!(result, Err(error) if error.contains("Session ID 不匹配"))); + assert_eq!( + read_planning_session_v2(&root).unwrap().unwrap().turn_index, + session.turn_index + ); + assert!(read_planning_messages_v2(&root).unwrap().is_empty()); + } + + #[test] + fn completed_answer_replays_after_question_changes() { + let (_dir, root, mut session) = v2_revision_session_fixture(); + session.status = "awaiting_user".to_string(); + session.current_question = Some(serde_json::json!({"id": "question-2"})); + write_planning_session_v2(&root, &session).unwrap(); + append_planning_message_v2( + &root, + &PlanningMessageV2 { + schema_version: PLANNING_MESSAGE_V2_SCHEMA_VERSION.to_string(), + message_id: "completed-answer".to_string(), + client_turn_id: "answer-1".to_string(), + turn_index: 1, + at_utc: current_plan_timestamp_utc(), + role: "assistant".to_string(), + kind: "text".to_string(), + payload: serde_json::json!({"text": "已处理"}), + }, + ) + .unwrap(); + let result = prepare_turn_v2( + &root, + "answer-1", + "选择第一个", + None, + false, + Some(&session.session_id), + Some("question-1"), + ) + .expect("已完成回答继续重放,不再次推进"); + assert!(result.replay.is_some()); + assert_eq!(result.session.turn_index, session.turn_index); + assert_eq!(result.session.current_question, session.current_question); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_storage.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_storage.rs deleted file mode 100644 index 1d6e98691..000000000 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_storage.rs +++ /dev/null @@ -1,5561 +0,0 @@ -use super::*; - -use serde::de::{DeserializeOwned, DeserializeSeed, MapAccess, SeqAccess, Visitor}; -use serde::{Deserialize, Deserializer, Serialize}; -use sha2::{Digest, Sha256}; -use std::fmt; -use std::fs::{self, File, OpenOptions}; -use std::io::{Read, Seek, SeekFrom, Write}; -use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicU64, Ordering}; -use uuid::{Uuid, Variant}; - -struct DuplicateKeySeed; - -struct DuplicateKeyVisitor; - -impl<'de> DeserializeSeed<'de> for DuplicateKeySeed { - type Value = (); - - fn deserialize(self, deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - deserializer.deserialize_any(DuplicateKeyVisitor) - } -} - -impl<'de> Visitor<'de> for DuplicateKeyVisitor { - type Value = (); - - fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str("a JSON value without duplicate object keys") - } - - fn visit_bool(self, _: bool) -> Result { - Ok(()) - } - - fn visit_i64(self, _: i64) -> Result { - Ok(()) - } - - fn visit_u64(self, _: u64) -> Result { - Ok(()) - } - - fn visit_f64(self, _: f64) -> Result { - Ok(()) - } - - fn visit_str(self, _: &str) -> Result { - Ok(()) - } - - fn visit_borrowed_str(self, _: &'de str) -> Result { - Ok(()) - } - - fn visit_string(self, _: String) -> Result { - Ok(()) - } - - fn visit_none(self) -> Result { - Ok(()) - } - - fn visit_unit(self) -> Result { - Ok(()) - } - - fn visit_some(self, deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - deserializer.deserialize_any(DuplicateKeyVisitor) - } - - fn visit_seq(self, mut sequence: A) -> Result - where - A: SeqAccess<'de>, - { - while sequence.next_element_seed(DuplicateKeySeed)?.is_some() {} - Ok(()) - } - - fn visit_map(self, mut map: A) -> Result - where - A: MapAccess<'de>, - { - let mut keys = std::collections::BTreeSet::new(); - while let Some(key) = map.next_key::()? { - if !keys.insert(key.clone()) { - return Err(serde::de::Error::custom(format!( - "duplicate JSON object key: {key}" - ))); - } - map.next_value_seed(DuplicateKeySeed)?; - } - Ok(()) - } -} - -pub(crate) const PLAN_GDD_SCHEMA_VERSION: &str = "plan-gdd.v1"; -pub(crate) const PLAN_GDD_INDEX_SCHEMA_VERSION: &str = "plan-gdd-index.v1"; -pub(crate) const PLAN_SESSION_SCHEMA_VERSION: &str = "plan-session.v1"; -pub(crate) const PLAN_SUBMIT_GDD_INPUT_SCHEMA_VERSION: &str = "plan-submit-gdd-input.v1"; -pub(crate) const PLAN_GDD_FINGERPRINT_DOMAIN: &str = "genarrative.plan.gdd.v1"; -pub(crate) const PLAN_SESSION_FINGERPRINT_DOMAIN: &str = "genarrative.plan.session.v1"; -pub(crate) const PLAN_GDD_MAX_BYTES: usize = 64 * 1024; -pub(crate) const PLAN_SESSION_MAX_BYTES: usize = 64 * 1024; -pub(crate) const PLAN_INDEX_MAX_BYTES: usize = 256 * 1024; -pub(crate) const PLAN_MAX_VERSIONS: u32 = 128; -pub(crate) const PLAN_STORAGE_ROOT: &str = ".agent/planning"; -pub(crate) const PLAN_GDD_INDEX_PATH: &str = ".agent/planning/index.json"; -pub(crate) const PLAN_SESSION_PATH: &str = ".agent/planning/session.json"; -pub(crate) const PLAN_SESSION_PREVIOUS_PATH: &str = ".agent/planning/.session.json.previous"; -pub(crate) const PLAN_FAST_GDD_PATH: &str = "game/fast_gdd.md"; -pub(crate) const PLAN_FAST_GDD_MAX_BYTES: usize = 128 * 1024; - -/// 单条决定 answerSummary 的上限(`initial-request` 除外)。 -pub(crate) const PLAN_DECISION_ANSWER_SUMMARY_MAX_CHARS: usize = 400; - -/// `initial-request` 那条决定的 answerSummary 上限,也就是立项策划入口原始需求的上限。 -/// -/// 上游用 `sanitize_agent_runtime_text(task, AGENT_RUNTIME_TASK_MAX_CHARS)` 归一根 -/// task:4000 个 Unicode scalar 封顶,超长时再补一个省略号,真实上界因此是 4001。 -/// 这里早期写死 400,于是 401~4001 字的开场需求会让根 run、Goal Contract 与首跳 -/// 委派全部正常建立,直到策划子 Agent 的 task-start 才在 session 投影上硬失败 -/// (`phase=planning-session-projection-failed`);此时 session 从未创建,用同一根 -/// task 重试必然复现,用户只能重开一条链。直接绑定到上游常量,两边不会再漂开。 -pub(crate) const PLAN_INITIAL_REQUEST_MAX_CHARS: usize = - crate::agent::runtime_driver::AGENT_RUNTIME_TASK_MAX_CHARS + 1; - -/// `initial-request` 承载的是用户原话,长度上限与其余决定不同。 -fn decision_answer_summary_max_chars(decision_id: &str) -> usize { - if decision_id == "initial-request" { - PLAN_INITIAL_REQUEST_MAX_CHARS - } else { - PLAN_DECISION_ANSWER_SUMMARY_MAX_CHARS - } -} -pub(crate) const PLAN_GDD_APPROVAL_SCHEMA_VERSION: &str = "plan-gdd-approval.v1"; -pub(crate) const PLAN_GDD_APPROVAL_PENDING_SCHEMA_VERSION: &str = "plan-gdd-approval-pending.v1"; -pub(crate) const PLAN_GDD_APPROVAL_DIR: &str = ".agent/planning/approvals"; -pub(crate) const PLAN_GDD_APPROVAL_PENDING_PATH: &str = ".agent/planning/pending.json"; -pub(crate) const PLAN_GDD_APPROVAL_MAX_BYTES: usize = 16 * 1024; -pub(crate) const PLAN_GDD_APPROVAL_PENDING_MAX_BYTES: usize = 16 * 1024; -// The fixed Chinese action prefix is part of the durable observation detail; -// leave room for the full 1,000-scalar user comment without truncation. -pub(crate) const PLAN_GDD_APPROVAL_OBSERVATION_DETAIL_MAX_SCALARS: usize = 1_100; -pub(crate) const PLAN_GDD_APPROVAL_FINGERPRINT_DOMAIN: &str = - "genarrative.plan.gdd-approval-receipt.v1"; -pub(crate) const PLAN_GDD_APPROVAL_DECISION_FINGERPRINT_DOMAIN: &str = - "genarrative.plan.gdd-decision.v1"; -pub(crate) const PLAN_GDD_APPROVAL_PENDING_FINGERPRINT_DOMAIN: &str = - "genarrative.plan.gdd-approval-pending.v1"; -pub(crate) const PLAN_GDD_APPROVAL_COMMENT_FINGERPRINT_DOMAIN: &str = - "genarrative.plan.gdd-comment.v1"; -pub(crate) const PLAN_GDD_APPROVAL_DECISION_AUDIT_SCHEMA_VERSION: &str = - "agent-runtime-plan-gdd-decided.v1"; -pub(crate) const PLAN_GDD_APPROVAL_DECISION_AUDIT_RECORD_TYPE: &str = - "agent.runtime.plan.gdd_decided"; -pub(crate) const PLAN_GDD_APPROVAL_PROJECTION_GAP_RECORD_TYPE: &str = - "agent.runtime.plan.gdd_projection_gap"; -pub(crate) const PLAN_GDD_APPROVAL_PENDING_KIND: &str = "gdd-approval"; -pub(crate) const PLAN_GDD_APPROVAL_SOURCE: &str = "project-supervisor-plan"; -pub(crate) const PLAN_GDD_APPROVAL_AGENT_ID: &str = "project-supervisor"; -pub(crate) const PLAN_GDD_APPROVAL_TOOL: &str = "plan.submit_gdd"; - -static PLANNING_TEMP_NONCE: AtomicU64 = AtomicU64::new(1); - -#[derive(Clone, Debug, Eq, PartialEq)] -pub(crate) struct PlanningStorageError { - code: &'static str, - detail: String, -} - -impl PlanningStorageError { - pub(crate) fn new(code: &'static str, detail: impl Into) -> Self { - Self { - code, - detail: detail.into(), - } - } - - pub(crate) fn code(&self) -> &'static str { - self.code - } -} - -impl fmt::Display for PlanningStorageError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(formatter, "{}: {}", self.code, self.detail) - } -} - -impl std::error::Error for PlanningStorageError {} - -pub(crate) type FingerprintError = PlanningStorageError; - -fn invalid(detail: impl Into) -> PlanningStorageError { - PlanningStorageError::new("PLAN_INVALID_SCHEMA", detail) -} - -fn conflict(detail: impl Into) -> PlanningStorageError { - PlanningStorageError::new("PLAN_IDENTITY_CONFLICT", detail) -} - -fn io_error(label: &str, error: impl fmt::Display) -> PlanningStorageError { - PlanningStorageError::new("PLAN_STORAGE_IO", format!("{label}: {error}")) -} - -fn canonical_bytes(value: &T) -> Result, PlanningStorageError> { - serde_json::to_vec(value) - .map_err(|error| PlanningStorageError::new("PLAN_SERIALIZE_FAILED", error.to_string())) -} - -#[derive(Serialize)] -struct FingerprintEnvelope<'a, T: Serialize + ?Sized> { - domain: &'static str, - value: &'a T, -} - -/// Typed planning fingerprint. The envelope and the serialized value are -/// deliberately struct-shaped; map/string concatenation fingerprints are not -/// interchangeable with this contract. -pub(crate) fn typed_serde_fingerprint( - domain: &'static str, - value: &T, -) -> Result { - if domain.trim().is_empty() { - return Err(PlanningStorageError::new( - "PLAN_INVALID_FINGERPRINT_DOMAIN", - "fingerprint domain 不能为空", - )); - } - let bytes = typed_serde_canonical_bytes(domain, value)?; - Ok(format!("sha256-serde-json-v2:{:x}", Sha256::digest(bytes))) -} - -pub(crate) fn typed_serde_canonical_bytes( - domain: &'static str, - value: &T, -) -> Result, FingerprintError> { - if domain.trim().is_empty() { - return Err(PlanningStorageError::new( - "PLAN_INVALID_FINGERPRINT_DOMAIN", - "fingerprint domain 不能为空", - )); - } - let envelope = FingerprintEnvelope { domain, value }; - canonical_bytes(&envelope) -} - -fn is_hex(value: &str, length: usize) -> bool { - value.len() == length && value.bytes().all(|byte| byte.is_ascii_hexdigit()) -} - -fn is_lower_hex(value: &str, length: usize) -> bool { - is_hex(value, length) && value.bytes().all(|byte| !byte.is_ascii_uppercase()) -} - -pub(crate) fn is_typed_fingerprint(value: &str) -> bool { - value - .strip_prefix("sha256-serde-json-v2:") - .is_some_and(|digest| is_lower_hex(digest, 64)) -} - -pub(crate) fn is_bare_fingerprint(value: &str) -> bool { - is_lower_hex(value, 64) -} - -pub(crate) fn validate_text( - value: &str, - label: &str, - min: usize, - max: usize, -) -> Result<(), PlanningStorageError> { - if value.chars().count() < min || value.chars().count() > max { - return Err(invalid(format!( - "{label} 必须为 {min}..={max} 个 Unicode scalar" - ))); - } - if value != value.trim() { - return Err(invalid(format!("{label} 必须已完成首尾空白规范化"))); - } - if value.contains('\r') - || value.chars().any(|character| { - character == '\0' - || character == '\u{7f}' - || (character.is_control() && character != '\n' && character != '\t') - }) - { - return Err(invalid(format!("{label} 包含不允许的控制字符"))); - } - Ok(()) -} - -pub(crate) fn normalize_plan_text( - value: &str, - label: &str, - min: usize, - max: usize, -) -> Result { - let normalized = value.replace("\r\n", "\n").replace('\r', "\n"); - let normalized = normalized.trim().to_string(); - validate_text(&normalized, label, min, max)?; - if normalized.as_bytes().len() > max.saturating_mul(4).saturating_add(256) { - return Err(invalid(format!("{label} 序列化字节数过大"))); - } - Ok(normalized) -} - -pub(crate) fn validate_opaque_id( - value: &str, - label: &str, - allow_empty: bool, -) -> Result<(), PlanningStorageError> { - if value.is_empty() && allow_empty { - return Ok(()); - } - let mut chars = value.chars(); - let Some(first) = chars.next() else { - return Err(invalid(format!("{label} 不能为空"))); - }; - if value.chars().count() > 128 - || !first.is_ascii_alphanumeric() - || !chars.all(|character| { - character.is_ascii_alphanumeric() || matches!(character, '.' | '_' | ':' | '-') - }) - { - return Err(invalid(format!("{label} 不是合法 opaque ID"))); - } - Ok(()) -} - -pub(crate) fn validate_uuid_prefixed( - value: &str, - prefix: &str, - label: &str, -) -> Result<(), PlanningStorageError> { - let Some(uuid) = value.strip_prefix(prefix) else { - return Err(invalid(format!("{label} 必须以 {prefix} 开头"))); - }; - if uuid.len() != 36 - || !uuid.bytes().enumerate().all(|(index, byte)| { - matches!(index, 8 | 13 | 18 | 23) - .then_some(byte == b'-') - .unwrap_or_else(|| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f')) - }) - || Uuid::parse_str(uuid).ok().is_none_or(|parsed| { - parsed.hyphenated().to_string() != uuid || parsed.get_variant() != Variant::RFC4122 - }) - { - return Err(invalid(format!("{label} 不是小写 RFC 4122 UUID"))); - } - Ok(()) -} - -pub(crate) fn validate_action_id(value: &str, label: &str) -> Result<(), PlanningStorageError> { - let Some(digest) = value.strip_prefix("action-") else { - return Err(invalid(format!("{label} 必须以 action- 开头"))); - }; - if !is_lower_hex(digest, 24) { - return Err(invalid(format!("{label} 不是合法 actionId"))); - } - Ok(()) -} - -pub(crate) fn validate_timestamp(value: &str, label: &str) -> Result<(), PlanningStorageError> { - validate_text(value, label, 24, 24)?; - if value.len() != 24 || !value.is_ascii() { - return Err(invalid(format!("{label} 必须是 ASCII UTC 毫秒时间"))); - } - let bytes = value.as_bytes(); - let punctuation = [ - (4, b'-'), - (7, b'-'), - (10, b'T'), - (13, b':'), - (16, b':'), - (19, b'.'), - (23, b'Z'), - ]; - if punctuation - .iter() - .any(|(index, expected)| bytes[*index] != *expected) - || bytes.iter().enumerate().any(|(index, byte)| { - !punctuation - .iter() - .any(|(punctuation_index, _)| *punctuation_index == index) - && !byte.is_ascii_digit() - }) - { - return Err(invalid(format!("{label} 必须是 UTC 毫秒时间"))); - } - let year = value[0..4] - .parse::() - .map_err(|_| invalid(format!("{label} 年份非法")))?; - let month = value[5..7] - .parse::() - .map_err(|_| invalid(format!("{label} 月份非法")))?; - let day = value[8..10] - .parse::() - .map_err(|_| invalid(format!("{label} 日期非法")))?; - let hour = value[11..13] - .parse::() - .map_err(|_| invalid(format!("{label} 小时非法")))?; - let minute = value[14..16] - .parse::() - .map_err(|_| invalid(format!("{label} 分钟非法")))?; - let second = value[17..19] - .parse::() - .map_err(|_| invalid(format!("{label} 秒非法")))?; - let millis = value[20..23] - .parse::() - .map_err(|_| invalid(format!("{label} 毫秒非法")))?; - if !(1..=12).contains(&month) || hour > 23 || minute > 59 || second > 59 || millis > 999 { - return Err(invalid(format!("{label} 的 UTC 日期时间分量越界"))); - } - let leap_year = year % 4 == 0 && (year % 100 != 0 || year % 400 == 0); - let days_in_month = match month { - 2 if leap_year => 29, - 2 => 28, - 4 | 6 | 9 | 11 => 30, - _ => 31, - }; - if day == 0 || day > days_in_month { - return Err(invalid(format!("{label} 的日期分量越界"))); - } - Ok(()) -} - -fn validate_decision_state(value: &str) -> Result<(), PlanningStorageError> { - if matches!(value, "confirmed" | "default_pending" | "prototype_pending") { - Ok(()) - } else { - Err(invalid(format!("未知 decision state:{value}"))) - } -} - -fn validate_answer_source(value: &str) -> Result<(), PlanningStorageError> { - if matches!( - value, - "user_freeform" | "user_option" | "user_revision" | "default" - ) { - Ok(()) - } else { - Err(invalid(format!("未知 answerSource:{value}"))) - } -} - -fn validate_unique<'a, I>(values: I, label: &str) -> Result<(), PlanningStorageError> -where - I: IntoIterator, -{ - let mut seen = std::collections::BTreeSet::new(); - for value in values { - if !seen.insert(value) { - return Err(invalid(format!("{label} 不能重复:{value}"))); - } - } - Ok(()) -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct PlanGddV1 { - pub(crate) schema_version: String, - pub(crate) project_id: String, - pub(crate) gdd_id: String, - pub(crate) version: u32, - pub(crate) submission_id: String, - pub(crate) approval_request_id: String, - pub(crate) action_fingerprint: String, - pub(crate) agent_id: String, - pub(crate) source: String, - pub(crate) run_profile: String, - pub(crate) run_profile_binding_fingerprint: String, - pub(crate) root_agent_id: String, - pub(crate) root_run_id: String, - pub(crate) delegation_id: String, - pub(crate) session_id: String, - pub(crate) source_session_revision: u32, - pub(crate) source_session_fingerprint: String, - pub(crate) created_by_run_id: String, - pub(crate) created_at_utc: String, - pub(crate) game: PlanGddGame, - pub(crate) decisions: Vec, - pub(crate) prototype_validation_items: Vec, - pub(crate) fingerprint: String, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct PlanGddGame { - pub(crate) title: String, - pub(crate) genre: PlanGenre, - pub(crate) art_style: PlanArtStyle, - pub(crate) one_liner: String, - pub(crate) pillars: Vec, - pub(crate) core_loop: Vec, - pub(crate) target_users: PlanTargetUsers, - pub(crate) platform_facts: PlanPlatformFacts, - pub(crate) mvp_systems: Vec, - pub(crate) out_of_scope: Vec, - pub(crate) creator_tips: PlanCreatorTips, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct PlanGenre { - pub(crate) primary: String, - pub(crate) fusion: Option, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct PlanArtStyle { - pub(crate) visual_type: String, - pub(crate) keywords: Vec, - pub(crate) mood_and_color: String, - pub(crate) mvp_art_boundary: String, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct PlanPillar { - pub(crate) name: String, - pub(crate) player_feel: String, - pub(crate) mechanism: String, - pub(crate) decision_state: String, - pub(crate) basis: Option<()>, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct PlanTargetUsers { - pub(crate) core_users: String, - pub(crate) preferences: String, - pub(crate) session_length: String, - pub(crate) reference_games: Vec, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct PlanPlatformFacts { - pub(crate) runtime: String, - pub(crate) viewports: Vec, - pub(crate) inputs: Vec, - pub(crate) preview: String, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct PlanMvpSystem { - pub(crate) system: String, - pub(crate) minimal_function: String, - pub(crate) why_required: String, - pub(crate) verify_method: String, - pub(crate) decision_state: String, - pub(crate) basis: Option<()>, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct PlanCreatorTips { - pub(crate) do_first: String, - pub(crate) defer_for_now: String, - pub(crate) how_to_verify: String, - pub(crate) expand_when: String, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct PlanDecision { - pub(crate) id: String, - pub(crate) topic: String, - pub(crate) state: String, - pub(crate) answer_source: String, - pub(crate) round: u32, - pub(crate) answer_summary: String, - pub(crate) basis: Option<()>, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct PlanPrototypeValidationItem { - pub(crate) id: String, - pub(crate) question: String, - pub(crate) micro_prototype: String, - pub(crate) observation: String, - pub(crate) pass_criterion: String, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct PlanSubmitGddInputV1 { - pub(crate) schema_version: String, - pub(crate) game: PlanSubmitGame, - pub(crate) decisions: Vec, - pub(crate) prototype_validation_items: Vec, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct PlanSubmitGame { - pub(crate) title: String, - pub(crate) genre: PlanGenre, - pub(crate) art_style: PlanArtStyle, - pub(crate) one_liner: String, - pub(crate) pillars: Vec, - pub(crate) core_loop: Vec, - pub(crate) target_users: PlanTargetUsers, - pub(crate) mvp_systems: Vec, - pub(crate) out_of_scope: Vec, - pub(crate) creator_tips: PlanCreatorTips, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct PlanSubmitPillar { - pub(crate) name: String, - pub(crate) player_feel: String, - pub(crate) mechanism: String, - pub(crate) decision_state: String, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct PlanSubmitMvpSystem { - pub(crate) system: String, - pub(crate) minimal_function: String, - pub(crate) why_required: String, - pub(crate) verify_method: String, - pub(crate) decision_state: String, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct PlanSubmitDecision { - pub(crate) id: String, - pub(crate) topic: String, - pub(crate) state: String, - pub(crate) answer_source: String, - pub(crate) round: u32, - pub(crate) answer_summary: String, -} - -fn validate_plan_platform_facts(value: &PlanPlatformFacts) -> Result<(), PlanningStorageError> { - if value.runtime != "self-contained-web" - || value.viewports != ["desktop", "mobile"] - || value.inputs != ["keyboard", "touch"] - || value.preview != "local-http" - { - return Err(invalid("platformFacts 必须是 Runtime 固定的平台事实")); - } - Ok(()) -} - -fn validate_plan_game(game: &PlanGddGame) -> Result<(), PlanningStorageError> { - validate_text(&game.title, "game.title", 1, 80)?; - validate_text(&game.genre.primary, "game.genre.primary", 1, 40)?; - if let Some(fusion) = &game.genre.fusion { - validate_text(fusion, "game.genre.fusion", 1, 40)?; - } - validate_text( - &game.art_style.visual_type, - "game.artStyle.visualType", - 1, - 80, - )?; - if !(3..=5).contains(&game.art_style.keywords.len()) { - return Err(invalid("game.artStyle.keywords 必须有 3~5 项")); - } - validate_unique( - game.art_style.keywords.iter().map(String::as_str), - "game.artStyle.keywords", - )?; - for (index, keyword) in game.art_style.keywords.iter().enumerate() { - validate_text(keyword, &format!("game.artStyle.keywords[{index}]"), 1, 32)?; - } - validate_text( - &game.art_style.mood_and_color, - "game.artStyle.moodAndColor", - 1, - 400, - )?; - validate_text( - &game.art_style.mvp_art_boundary, - "game.artStyle.mvpArtBoundary", - 1, - 400, - )?; - validate_text(&game.one_liner, "game.oneLiner", 45, 90)?; - - if !(2..=4).contains(&game.pillars.len()) { - return Err(invalid("game.pillars 必须有 2~4 条")); - } - validate_unique( - game.pillars.iter().map(|item| item.name.as_str()), - "game.pillars.name", - )?; - for (index, pillar) in game.pillars.iter().enumerate() { - validate_text(&pillar.name, &format!("game.pillars[{index}].name"), 1, 40)?; - validate_text( - &pillar.player_feel, - &format!("game.pillars[{index}].playerFeel"), - 1, - 240, - )?; - validate_text( - &pillar.mechanism, - &format!("game.pillars[{index}].mechanism"), - 1, - 240, - )?; - validate_decision_state(&pillar.decision_state)?; - if pillar.basis.is_some() { - return Err(invalid("v1 的 pillar.basis 必须为 null")); - } - } - - if !(4..=8).contains(&game.core_loop.len()) { - return Err(invalid("game.coreLoop 必须有 4~8 步")); - } - for (index, step) in game.core_loop.iter().enumerate() { - validate_text(step, &format!("game.coreLoop[{index}]"), 1, 120)?; - } - - validate_text( - &game.target_users.core_users, - "game.targetUsers.coreUsers", - 1, - 240, - )?; - validate_text( - &game.target_users.preferences, - "game.targetUsers.preferences", - 1, - 240, - )?; - validate_text( - &game.target_users.session_length, - "game.targetUsers.sessionLength", - 1, - 240, - )?; - if game.target_users.reference_games.len() > 5 { - return Err(invalid("game.targetUsers.referenceGames 最多 5 项")); - } - for (index, reference) in game.target_users.reference_games.iter().enumerate() { - validate_text( - reference, - &format!("game.targetUsers.referenceGames[{index}]"), - 1, - 80, - )?; - } - validate_plan_platform_facts(&game.platform_facts)?; - - if !(3..=6).contains(&game.mvp_systems.len()) { - return Err(invalid("game.mvpSystems 必须有 3~6 项")); - } - validate_unique( - game.mvp_systems.iter().map(|item| item.system.as_str()), - "game.mvpSystems.system", - )?; - for (index, system) in game.mvp_systems.iter().enumerate() { - validate_text( - &system.system, - &format!("game.mvpSystems[{index}].system"), - 1, - 40, - )?; - validate_text( - &system.minimal_function, - &format!("game.mvpSystems[{index}].minimalFunction"), - 1, - 240, - )?; - validate_text( - &system.why_required, - &format!("game.mvpSystems[{index}].whyRequired"), - 1, - 240, - )?; - validate_text( - &system.verify_method, - &format!("game.mvpSystems[{index}].verifyMethod"), - 1, - 240, - )?; - validate_decision_state(&system.decision_state)?; - if system.basis.is_some() { - return Err(invalid("v1 的 mvpSystem.basis 必须为 null")); - } - } - if !(1..=12).contains(&game.out_of_scope.len()) { - return Err(invalid("game.outOfScope 必须有 1~12 项")); - } - validate_unique( - game.out_of_scope.iter().map(String::as_str), - "game.outOfScope", - )?; - for (index, item) in game.out_of_scope.iter().enumerate() { - validate_text(item, &format!("game.outOfScope[{index}]"), 1, 80)?; - } - validate_text( - &game.creator_tips.do_first, - "game.creatorTips.doFirst", - 1, - 400, - )?; - validate_text( - &game.creator_tips.defer_for_now, - "game.creatorTips.deferForNow", - 1, - 400, - )?; - validate_text( - &game.creator_tips.how_to_verify, - "game.creatorTips.howToVerify", - 1, - 400, - )?; - validate_text( - &game.creator_tips.expand_when, - "game.creatorTips.expandWhen", - 1, - 400, - )?; - Ok(()) -} - -fn validate_decisions( - decisions: &[PlanDecision], - prototype_items: &[PlanPrototypeValidationItem], -) -> Result<(), PlanningStorageError> { - if !(1..=32).contains(&decisions.len()) { - return Err(invalid("decisions 必须有 1~32 项")); - } - validate_unique( - decisions.iter().map(|item| item.id.as_str()), - "decisions.id", - )?; - if decisions.first().map(|decision| decision.id.as_str()) != Some("initial-request") - || decisions - .iter() - .filter(|decision| decision.id == "initial-request") - .count() - != 1 - { - return Err(invalid("decisions 必须恰好包含首项 initial-request")); - } - let mut prototype_decisions = std::collections::BTreeSet::new(); - for (index, decision) in decisions.iter().enumerate() { - if decision.id == "initial-request" { - if index != 0 - || decision.state != "confirmed" - || decision.answer_source != "user_freeform" - || decision.round != 0 - { - return Err(invalid( - "initial-request 必须是首项 confirmed/user_freeform/round=0", - )); - } - } else { - let mut chars = decision.id.chars(); - let valid_id = chars - .next() - .is_some_and(|character| character.is_ascii_lowercase()) - && chars.all(|character| { - character.is_ascii_lowercase() || character.is_ascii_digit() || character == '-' - }) - && decision.id.len() <= 32; - if !valid_id { - return Err(invalid(format!("decisions[{index}].id 不是合法决定 ID"))); - } - } - validate_text(&decision.id, &format!("decisions[{index}].id"), 1, 32)?; - validate_text(&decision.topic, &format!("decisions[{index}].topic"), 1, 80)?; - validate_decision_state(&decision.state)?; - validate_answer_source(&decision.answer_source)?; - if decision.round > 3 { - return Err(invalid(format!("decisions[{index}].round 不能超过 3"))); - } - if decision.answer_source == "user_revision" && decision.round != 0 { - return Err(invalid(format!( - "decisions[{index}] 的 user_revision 必须使用 round=0" - ))); - } - // round=0 不属于澄清轮:默认建议使用 default,审批修改使用 - // user_revision。两者都可以标记为 prototype_pending;用户明确修改的 - // 决定则可以标记 confirmed。 - if decision.round == 0 && decision.id != "initial-request" { - match decision.answer_source.as_str() { - "default" - if matches!( - decision.state.as_str(), - "default_pending" | "prototype_pending" - ) => {} - "user_revision" - if matches!(decision.state.as_str(), "confirmed" | "prototype_pending") => {} - "default" => { - return Err(invalid(format!( - "decisions[{index}] round=0 的 default 只能是 default_pending 或 prototype_pending" - ))); - } - "user_revision" => { - return Err(invalid(format!( - "decisions[{index}] round=0 的 user_revision 只能是 confirmed 或 prototype_pending" - ))); - } - _ => { - return Err(invalid(format!( - "decisions[{index}] round=0 的 answerSource 只能是 default 或 user_revision" - ))); - } - } - } - validate_text( - &decision.answer_summary, - &format!("decisions[{index}].answerSummary"), - 1, - decision_answer_summary_max_chars(&decision.id), - )?; - if decision.basis.is_some() { - return Err(invalid("v1 的 decision.basis 必须为 null")); - } - if decision.state == "prototype_pending" { - prototype_decisions.insert(decision.id.as_str()); - } - } - if prototype_items.len() > 3 { - return Err(invalid("prototypeValidationItems 最多 3 项")); - } - validate_unique( - prototype_items.iter().map(|item| item.id.as_str()), - "prototypeValidationItems.id", - )?; - if prototype_decisions.len() != prototype_items.len() - || prototype_items - .iter() - .any(|item| !prototype_decisions.contains(item.id.as_str())) - { - return Err(invalid( - "prototypeValidationItems 必须逐项对应全部 prototype_pending 决定", - )); - } - for (index, item) in prototype_items.iter().enumerate() { - if !item.id.chars().all(|character| { - character.is_ascii_lowercase() || character.is_ascii_digit() || character == '-' - }) { - return Err(invalid(format!( - "prototypeValidationItems[{index}].id 非法" - ))); - } - validate_text( - &item.id, - &format!("prototypeValidationItems[{index}].id"), - 1, - 32, - )?; - validate_text( - &item.question, - &format!("prototypeValidationItems[{index}].question"), - 1, - 400, - )?; - validate_text( - &item.micro_prototype, - &format!("prototypeValidationItems[{index}].microPrototype"), - 1, - 400, - )?; - validate_text( - &item.observation, - &format!("prototypeValidationItems[{index}].observation"), - 1, - 400, - )?; - validate_text( - &item.pass_criterion, - &format!("prototypeValidationItems[{index}].passCriterion"), - 1, - 400, - )?; - } - Ok(()) -} - -fn validate_plan_gdd_shape(value: &PlanGddV1) -> Result<(), PlanningStorageError> { - if value.schema_version != PLAN_GDD_SCHEMA_VERSION { - return Err(invalid("未知 GDD schemaVersion")); - } - validate_opaque_id(&value.project_id, "projectId", false)?; - validate_uuid_prefixed(&value.gdd_id, "gdd-", "gddId")?; - if !(1..=PLAN_MAX_VERSIONS).contains(&value.version) { - return Err(invalid("GDD version 超出 1..=128")); - } - validate_action_id(&value.submission_id, "submissionId")?; - validate_uuid_prefixed( - &value.approval_request_id, - "gdd-approval-", - "approvalRequestId", - )?; - if !is_bare_fingerprint(&value.action_fingerprint) { - return Err(invalid("actionFingerprint 必须是 64 位小写裸 digest")); - } - if value.agent_id != "project-planning" - || value.source != "agent-delegate" - || value.run_profile != "standard" - || value.root_agent_id != "project-supervisor" - { - return Err(invalid("GDD 的 plan identity 常量不匹配")); - } - if !is_bare_fingerprint(&value.run_profile_binding_fingerprint) { - return Err(invalid("runProfileBindingFingerprint 必须是裸 digest")); - } - validate_opaque_id(&value.root_run_id, "rootRunId", false)?; - validate_opaque_id(&value.delegation_id, "delegationId", false)?; - validate_opaque_id(&value.session_id, "sessionId", false)?; - if value.source_session_revision == 0 { - return Err(invalid("sourceSessionRevision 必须大于 0")); - } - if !is_typed_fingerprint(&value.source_session_fingerprint) { - return Err(invalid( - "sourceSessionFingerprint 必须是 planning typed fingerprint", - )); - } - validate_opaque_id(&value.created_by_run_id, "createdByRunId", false)?; - validate_timestamp(&value.created_at_utc, "createdAtUtc")?; - validate_plan_game(&value.game)?; - validate_decisions(&value.decisions, &value.prototype_validation_items)?; - if !is_typed_fingerprint(&value.fingerprint) { - return Err(invalid("GDD fingerprint 格式非法")); - } - Ok(()) -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct PlanGddFingerprintValue<'a> { - schema_version: &'a str, - project_id: &'a str, - gdd_id: &'a str, - version: u32, - submission_id: &'a str, - approval_request_id: &'a str, - action_fingerprint: &'a str, - agent_id: &'a str, - source: &'a str, - run_profile: &'a str, - run_profile_binding_fingerprint: &'a str, - root_agent_id: &'a str, - root_run_id: &'a str, - delegation_id: &'a str, - session_id: &'a str, - source_session_revision: u32, - source_session_fingerprint: &'a str, - created_by_run_id: &'a str, - created_at_utc: &'a str, - game: &'a PlanGddGame, - decisions: &'a Vec, - prototype_validation_items: &'a Vec, -} - -impl<'a> From<&'a PlanGddV1> for PlanGddFingerprintValue<'a> { - fn from(value: &'a PlanGddV1) -> Self { - Self { - schema_version: &value.schema_version, - project_id: &value.project_id, - gdd_id: &value.gdd_id, - version: value.version, - submission_id: &value.submission_id, - approval_request_id: &value.approval_request_id, - action_fingerprint: &value.action_fingerprint, - agent_id: &value.agent_id, - source: &value.source, - run_profile: &value.run_profile, - run_profile_binding_fingerprint: &value.run_profile_binding_fingerprint, - root_agent_id: &value.root_agent_id, - root_run_id: &value.root_run_id, - delegation_id: &value.delegation_id, - session_id: &value.session_id, - source_session_revision: value.source_session_revision, - source_session_fingerprint: &value.source_session_fingerprint, - created_by_run_id: &value.created_by_run_id, - created_at_utc: &value.created_at_utc, - game: &value.game, - decisions: &value.decisions, - prototype_validation_items: &value.prototype_validation_items, - } - } -} - -pub(crate) fn plan_gdd_fingerprint(value: &PlanGddV1) -> Result { - validate_plan_gdd_shape(value)?; - typed_serde_fingerprint( - PLAN_GDD_FINGERPRINT_DOMAIN, - &PlanGddFingerprintValue::from(value), - ) -} - -pub(crate) fn validate_plan_gdd(value: &PlanGddV1) -> Result<(), PlanningStorageError> { - validate_plan_gdd_shape(value)?; - let expected = plan_gdd_fingerprint(value)?; - if value.fingerprint != expected { - return Err(PlanningStorageError::new( - "PLAN_FINGERPRINT_MISMATCH", - "GDD fingerprint 与 canonical payload 不一致", - )); - } - Ok(()) -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct PlanGddIndexV1 { - pub(crate) schema_version: String, - pub(crate) project_id: String, - pub(crate) gdd_id: String, - pub(crate) entries: Vec, - pub(crate) status_cache: PlanGddIndexStatusCache, - pub(crate) rebuilt_at_utc: String, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct PlanGddIndexEntry { - pub(crate) version: u32, - pub(crate) submission_id: String, - pub(crate) approval_request_id: String, - pub(crate) action_fingerprint: String, - pub(crate) fingerprint: String, - pub(crate) file: String, - pub(crate) agent_id: String, - pub(crate) source: String, - pub(crate) run_profile: String, - pub(crate) run_profile_binding_fingerprint: String, - pub(crate) root_run_id: String, - pub(crate) delegation_id: String, - pub(crate) session_id: String, - pub(crate) source_session_revision: u32, - pub(crate) source_session_fingerprint: String, - pub(crate) created_by_run_id: String, - pub(crate) created_at_utc: String, - pub(crate) submitted_at_utc: String, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct PlanGddIndexStatusCache { - pub(crate) latest_version: u32, - pub(crate) pending_version: Option, - pub(crate) approved_version: Option, - pub(crate) versions: Vec, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct PlanGddIndexVersionStatus { - pub(crate) version: u32, - pub(crate) status: String, -} - -fn validate_plan_index_entry( - entry: &PlanGddIndexEntry, - expected_version: u32, - gdd_id: &str, -) -> Result<(), PlanningStorageError> { - if entry.version != expected_version { - return Err(invalid("index entries 必须从 1 连续递增")); - } - validate_action_id(&entry.submission_id, "index.submissionId")?; - validate_uuid_prefixed( - &entry.approval_request_id, - "gdd-approval-", - "index.approvalRequestId", - )?; - if !is_bare_fingerprint(&entry.action_fingerprint) - || !is_typed_fingerprint(&entry.fingerprint) - || !is_bare_fingerprint(&entry.run_profile_binding_fingerprint) - || !is_typed_fingerprint(&entry.source_session_fingerprint) - { - return Err(invalid("index entry 的 fingerprint 格式非法")); - } - let expected_file = format!("gdd.v{expected_version}.json"); - if entry.file != expected_file { - return Err(invalid("index entry.file 与 version 不一致")); - } - if entry.agent_id != "project-planning" - || entry.source != "agent-delegate" - || entry.run_profile != "standard" - { - return Err(invalid("index entry 的 plan identity 常量不匹配")); - } - validate_opaque_id(&entry.root_run_id, "index.rootRunId", false)?; - validate_opaque_id(&entry.delegation_id, "index.delegationId", false)?; - validate_opaque_id(&entry.session_id, "index.sessionId", false)?; - validate_opaque_id(&entry.created_by_run_id, "index.createdByRunId", false)?; - if entry.source_session_revision == 0 { - return Err(invalid("index.sourceSessionRevision 必须大于 0")); - } - validate_timestamp(&entry.created_at_utc, "index.createdAtUtc")?; - validate_timestamp(&entry.submitted_at_utc, "index.submittedAtUtc")?; - if entry.created_at_utc != entry.submitted_at_utc { - return Err(invalid("v1 index.submittedAtUtc 必须等于 createdAtUtc")); - } - if gdd_id.is_empty() { - return Err(invalid("index 缺少 gddId")); - } - Ok(()) -} - -pub(crate) fn validate_plan_gdd_index(value: &PlanGddIndexV1) -> Result<(), PlanningStorageError> { - if value.schema_version != PLAN_GDD_INDEX_SCHEMA_VERSION { - return Err(invalid("未知 GDD index schemaVersion")); - } - validate_opaque_id(&value.project_id, "index.projectId", false)?; - validate_uuid_prefixed(&value.gdd_id, "gdd-", "index.gddId")?; - if value.entries.len() > PLAN_MAX_VERSIONS as usize { - return Err(invalid("index entries 超过 lineage 版本上限")); - } - for (index, entry) in value.entries.iter().enumerate() { - validate_plan_index_entry(entry, index as u32 + 1, &value.gdd_id)?; - } - if value.entries.is_empty() { - if value.status_cache.latest_version != 0 - || value.status_cache.pending_version.is_some() - || value.status_cache.approved_version.is_some() - || !value.status_cache.versions.is_empty() - { - return Err(invalid("空 index 的 statusCache 必须为空")); - } - } else { - let latest = value.entries.len() as u32; - if value.status_cache.latest_version != latest - || value.status_cache.versions.len() != value.entries.len() - { - return Err(invalid("index statusCache 与 entries 长度不一致")); - } - for (index, status) in value.status_cache.versions.iter().enumerate() { - if status.version != index as u32 + 1 - || !matches!( - status.status.as_str(), - "ready_for_approval" - | "revision_requested" - | "rejected" - | "approved" - | "superseded" - ) - { - return Err(invalid("index statusCache.versions 非法")); - } - } - for optional in [ - value.status_cache.pending_version, - value.status_cache.approved_version, - ] - .into_iter() - .flatten() - { - if optional == 0 || optional > latest { - return Err(invalid("index statusCache 版本引用越界")); - } - } - if let Some(pending) = value.status_cache.pending_version { - let status = &value.status_cache.versions[pending as usize - 1].status; - if status != "ready_for_approval" { - return Err(invalid( - "index pendingVersion 必须指向 ready_for_approval 版本", - )); - } - } - if let Some(approved) = value.status_cache.approved_version { - let status = &value.status_cache.versions[approved as usize - 1].status; - if status != "approved" { - return Err(invalid("index approvedVersion 必须指向 approved 版本")); - } - } - } - validate_timestamp(&value.rebuilt_at_utc, "index.rebuiltAtUtc")?; - Ok(()) -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct PlanGddRef { - pub(crate) gdd_id: String, - pub(crate) version: u32, - pub(crate) fingerprint: String, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct PlanDecisionRef { - pub(crate) version: u32, - pub(crate) response_id: String, - pub(crate) action: String, - pub(crate) receipt_fingerprint: String, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct PlanGddApprovalV1 { - pub(crate) schema_version: String, - pub(crate) project_id: String, - pub(crate) gdd_id: String, - pub(crate) version: u32, - pub(crate) fingerprint: String, - pub(crate) pending_action_id: String, - pub(crate) action_fingerprint: String, - pub(crate) approval_request_id: String, - pub(crate) response_id: String, - pub(crate) decision_fingerprint: String, - pub(crate) source: String, - pub(crate) run_profile: String, - pub(crate) run_profile_binding_fingerprint: String, - pub(crate) session_id: String, - pub(crate) run_id: String, - pub(crate) action: String, - pub(crate) comment: Option, - pub(crate) decided_at_utc: String, - pub(crate) receipt_fingerprint: String, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct PlanGddApprovalPendingV1 { - pub(crate) schema_version: String, - pub(crate) kind: String, - pub(crate) project_id: String, - pub(crate) agent_id: String, - pub(crate) gdd_ref: PlanGddRef, - pub(crate) submission: PlanGddApprovalPendingSubmission, - pub(crate) run_identity: PlanGddApprovalPendingRunIdentity, - pub(crate) status: String, - pub(crate) observation: Option, - pub(crate) pending_fingerprint: String, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct PlanGddApprovalPendingSubmission { - pub(crate) tool: String, - pub(crate) pending_action_id: String, - pub(crate) action_fingerprint: String, - pub(crate) approval_request_id: String, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct PlanGddApprovalPendingRunIdentity { - pub(crate) source: String, - pub(crate) run_profile: String, - pub(crate) run_profile_binding_fingerprint: String, - pub(crate) session_id: String, - pub(crate) run_id: String, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct PlanGddApprovalObservationV1 { - pub(crate) tool: String, - pub(crate) status: String, - pub(crate) summary: String, - pub(crate) detail: Option, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct PlanGddApprovalDecisionInputV1 { - pub(crate) project_id: String, - pub(crate) gdd_id: String, - pub(crate) version: u32, - pub(crate) fingerprint: String, - pub(crate) pending_action_id: String, - pub(crate) action_fingerprint: String, - pub(crate) approval_request_id: String, - pub(crate) response_id: String, - pub(crate) action: String, - pub(crate) comment: Option, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct PlanGddDecisionRefV1 { - pub(crate) gdd_id: String, - pub(crate) version: u32, - pub(crate) fingerprint: String, - pub(crate) approval_request_id: String, - pub(crate) response_id: String, - pub(crate) action: String, - pub(crate) decision_fingerprint: String, - pub(crate) receipt_fingerprint: String, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct PlanGddDecisionResultV1 { - pub(crate) outcome: String, - pub(crate) requested_response_id: String, - pub(crate) decision_ref: PlanGddDecisionRefV1, - pub(crate) approved_gdd_ref: Option, - pub(crate) recovery_pending: bool, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct PlanGddApprovalDecisionFingerprintValue<'a> { - project_id: &'a str, - gdd_id: &'a str, - version: u32, - fingerprint: &'a str, - pending_action_id: &'a str, - action_fingerprint: &'a str, - approval_request_id: &'a str, - response_id: &'a str, - source: &'a str, - run_profile: &'a str, - run_profile_binding_fingerprint: &'a str, - session_id: &'a str, - run_id: &'a str, - action: &'a str, - normalized_comment: Option<&'a str>, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct PlanGddApprovalReceiptFingerprintValue<'a> { - schema_version: &'a str, - project_id: &'a str, - gdd_id: &'a str, - version: u32, - fingerprint: &'a str, - pending_action_id: &'a str, - action_fingerprint: &'a str, - approval_request_id: &'a str, - response_id: &'a str, - decision_fingerprint: &'a str, - source: &'a str, - run_profile: &'a str, - run_profile_binding_fingerprint: &'a str, - session_id: &'a str, - run_id: &'a str, - action: &'a str, - comment: Option<&'a str>, - decided_at_utc: &'a str, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct PlanGddApprovalPendingFingerprintValue<'a> { - schema_version: &'a str, - kind: &'a str, - project_id: &'a str, - agent_id: &'a str, - gdd_ref: &'a PlanGddRef, - submission: &'a PlanGddApprovalPendingSubmission, - run_identity: &'a PlanGddApprovalPendingRunIdentity, - status: &'a str, - observation: Option<&'a PlanGddApprovalObservationV1>, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct PlanGddApprovalCommentFingerprintValue<'a> { - comment: Option<&'a str>, -} - -pub(crate) fn normalize_plan_gdd_approval_comment( - action: &str, - comment: Option<&str>, -) -> Result, PlanningStorageError> { - if !matches!(action, "approve" | "revise" | "reject") { - return Err(invalid(format!("未知 GDD 审批动作:{action}"))); - } - let normalized = comment - .map(|value| { - value - .replace("\r\n", "\n") - .replace('\r', "\n") - .trim() - .to_string() - }) - .filter(|value| !value.is_empty()); - match action { - "approve" => { - if let Some(value) = normalized.as_deref() { - validate_text(value, "approval.comment", 1, 1000)?; - } - Ok(normalized) - } - "revise" | "reject" => { - let value = normalized.ok_or_else(|| { - invalid(format!("{action} 审批必须提供 1~1000 scalar 的 comment")) - })?; - validate_text(&value, "approval.comment", 1, 1000)?; - Ok(Some(value)) - } - _ => unreachable!(), - } -} - -fn validate_approval_response_id(value: &str) -> Result<(), PlanningStorageError> { - validate_uuid_prefixed(value, "gdd-response-", "responseId") -} - -pub(crate) fn plan_gdd_approval_decision_fingerprint_for_identity( - input: &PlanGddApprovalDecisionInputV1, - source: &str, - run_profile: &str, - run_profile_binding_fingerprint: &str, - session_id: &str, - run_id: &str, - normalized_comment: Option<&str>, -) -> Result { - typed_serde_fingerprint( - PLAN_GDD_APPROVAL_DECISION_FINGERPRINT_DOMAIN, - &PlanGddApprovalDecisionFingerprintValue { - project_id: &input.project_id, - gdd_id: &input.gdd_id, - version: input.version, - fingerprint: &input.fingerprint, - pending_action_id: &input.pending_action_id, - action_fingerprint: &input.action_fingerprint, - approval_request_id: &input.approval_request_id, - response_id: &input.response_id, - source, - run_profile, - run_profile_binding_fingerprint, - session_id, - run_id, - action: &input.action, - normalized_comment, - }, - ) -} - -pub(crate) fn plan_gdd_approval_receipt_fingerprint( - value: &PlanGddApprovalV1, -) -> Result { - typed_serde_fingerprint( - PLAN_GDD_APPROVAL_FINGERPRINT_DOMAIN, - &PlanGddApprovalReceiptFingerprintValue { - schema_version: &value.schema_version, - project_id: &value.project_id, - gdd_id: &value.gdd_id, - version: value.version, - fingerprint: &value.fingerprint, - pending_action_id: &value.pending_action_id, - action_fingerprint: &value.action_fingerprint, - approval_request_id: &value.approval_request_id, - response_id: &value.response_id, - decision_fingerprint: &value.decision_fingerprint, - source: &value.source, - run_profile: &value.run_profile, - run_profile_binding_fingerprint: &value.run_profile_binding_fingerprint, - session_id: &value.session_id, - run_id: &value.run_id, - action: &value.action, - comment: value.comment.as_deref(), - decided_at_utc: &value.decided_at_utc, - }, - ) -} - -pub(crate) fn plan_gdd_approval_comment_fingerprint( - comment: Option<&str>, -) -> Result { - typed_serde_fingerprint( - PLAN_GDD_APPROVAL_COMMENT_FINGERPRINT_DOMAIN, - &PlanGddApprovalCommentFingerprintValue { comment }, - ) -} - -pub(crate) fn plan_gdd_approval_pending_fingerprint( - value: &PlanGddApprovalPendingV1, -) -> Result { - typed_serde_fingerprint( - PLAN_GDD_APPROVAL_PENDING_FINGERPRINT_DOMAIN, - &PlanGddApprovalPendingFingerprintValue { - schema_version: &value.schema_version, - kind: &value.kind, - project_id: &value.project_id, - agent_id: &value.agent_id, - gdd_ref: &value.gdd_ref, - submission: &value.submission, - run_identity: &value.run_identity, - status: &value.status, - observation: value.observation.as_ref(), - }, - ) -} - -pub(crate) fn validate_plan_gdd_approval( - value: &PlanGddApprovalV1, -) -> Result<(), PlanningStorageError> { - if value.schema_version != PLAN_GDD_APPROVAL_SCHEMA_VERSION { - return Err(invalid("未知 plan-gdd-approval schemaVersion")); - } - validate_opaque_id(&value.project_id, "approval.projectId", false)?; - validate_uuid_prefixed(&value.gdd_id, "gdd-", "approval.gddId")?; - if !(1..=PLAN_MAX_VERSIONS).contains(&value.version) { - return Err(invalid("approval.version 越界")); - } - if !is_typed_fingerprint(&value.fingerprint) { - return Err(invalid("approval.fingerprint 非法")); - } - validate_action_id(&value.pending_action_id, "approval.pendingActionId")?; - if !is_bare_fingerprint(&value.action_fingerprint) { - return Err(invalid("approval.actionFingerprint 非法")); - } - validate_uuid_prefixed( - &value.approval_request_id, - "gdd-approval-", - "approval.approvalRequestId", - )?; - validate_approval_response_id(&value.response_id)?; - if !is_typed_fingerprint(&value.decision_fingerprint) - || !is_typed_fingerprint(&value.receipt_fingerprint) - { - return Err(invalid("approval decision/receipt fingerprint 非法")); - } - if value.source != PLAN_GDD_APPROVAL_SOURCE - || value.run_profile != AGENT_RUNTIME_RUN_PROFILE_STANDARD - { - return Err(invalid("approval source/runProfile 不匹配")); - } - if !is_bare_fingerprint(&value.run_profile_binding_fingerprint) { - return Err(invalid("approval.runProfileBindingFingerprint 非法")); - } - validate_opaque_id(&value.session_id, "approval.sessionId", false)?; - validate_opaque_id(&value.run_id, "approval.runId", false)?; - if !matches!(value.action.as_str(), "approve" | "revise" | "reject") { - return Err(invalid("未知 approval.action")); - } - let normalized = - normalize_plan_gdd_approval_comment(value.action.as_str(), value.comment.as_deref())?; - if normalized != value.comment { - return Err(invalid("approval.comment 未完成规范化")); - } - validate_timestamp(&value.decided_at_utc, "approval.decidedAtUtc")?; - let decision_input = PlanGddApprovalDecisionInputV1 { - project_id: value.project_id.clone(), - gdd_id: value.gdd_id.clone(), - version: value.version, - fingerprint: value.fingerprint.clone(), - pending_action_id: value.pending_action_id.clone(), - action_fingerprint: value.action_fingerprint.clone(), - approval_request_id: value.approval_request_id.clone(), - response_id: value.response_id.clone(), - action: value.action.clone(), - comment: value.comment.clone(), - }; - let normalized = normalize_plan_gdd_approval_comment(&value.action, value.comment.as_deref())?; - let expected_decision = plan_gdd_approval_decision_fingerprint_for_identity( - &decision_input, - &value.source, - &value.run_profile, - &value.run_profile_binding_fingerprint, - &value.session_id, - &value.run_id, - normalized.as_deref(), - )?; - if expected_decision != value.decision_fingerprint { - return Err(PlanningStorageError::new( - "PLAN_FINGERPRINT_MISMATCH", - "approval decisionFingerprint 与 canonical payload 不一致", - )); - } - let expected_receipt = plan_gdd_approval_receipt_fingerprint(value)?; - if expected_receipt != value.receipt_fingerprint { - return Err(PlanningStorageError::new( - "PLAN_FINGERPRINT_MISMATCH", - "approval receiptFingerprint 与 canonical payload 不一致", - )); - } - Ok(()) -} - -pub(crate) fn validate_plan_gdd_approval_pending( - value: &PlanGddApprovalPendingV1, -) -> Result<(), PlanningStorageError> { - if value.schema_version != PLAN_GDD_APPROVAL_PENDING_SCHEMA_VERSION - || value.kind != PLAN_GDD_APPROVAL_PENDING_KIND - { - return Err(invalid("未知 planning approval pending schema/kind")); - } - validate_opaque_id(&value.project_id, "pending.projectId", false)?; - if value.agent_id != PLAN_GDD_APPROVAL_AGENT_ID { - return Err(invalid( - "approval pending agentId 必须是 project-supervisor", - )); - } - validate_plan_gdd_ref(&value.gdd_ref, "pending.gddRef")?; - if value.submission.tool != PLAN_GDD_APPROVAL_TOOL { - return Err(invalid("approval pending submission.tool 不匹配")); - } - validate_action_id( - &value.submission.pending_action_id, - "pending.pendingActionId", - )?; - if !is_bare_fingerprint(&value.submission.action_fingerprint) { - return Err(invalid("pending.actionFingerprint 非法")); - } - validate_uuid_prefixed( - &value.submission.approval_request_id, - "gdd-approval-", - "pending.approvalRequestId", - )?; - if value.run_identity.source != PLAN_GDD_APPROVAL_SOURCE - || value.run_identity.run_profile != AGENT_RUNTIME_RUN_PROFILE_STANDARD - { - return Err(invalid("pending runIdentity source/runProfile 不匹配")); - } - if !is_bare_fingerprint(&value.run_identity.run_profile_binding_fingerprint) { - return Err(invalid( - "pending.runIdentity.runProfileBindingFingerprint 非法", - )); - } - validate_opaque_id(&value.run_identity.session_id, "pending.sessionId", false)?; - validate_opaque_id(&value.run_identity.run_id, "pending.runId", false)?; - if !matches!( - value.status.as_str(), - "awaiting_decision" | "observed_approve" | "observed_revise" | "observed_reject" - ) { - return Err(invalid("未知 approval pending status")); - } - match (value.status.as_str(), value.observation.as_ref()) { - ("awaiting_decision", None) => {} - ("awaiting_decision", Some(_)) => { - return Err(invalid("awaiting_decision pending 不能带 observation")) - } - ("observed_approve" | "observed_revise" | "observed_reject", Some(observation)) => { - validate_plan_gdd_approval_observation( - value.gdd_ref.version, - value.status.as_str(), - observation, - )?; - } - (_, None) => return Err(invalid("observed approval pending 缺少 observation")), - (_, Some(_)) => return Err(invalid("awaiting approval pending 状态非法")), - } - if !is_typed_fingerprint(&value.pending_fingerprint) { - return Err(invalid("pendingFingerprint 非法")); - } - let expected = plan_gdd_approval_pending_fingerprint(value)?; - if expected != value.pending_fingerprint { - return Err(PlanningStorageError::new( - "PLAN_FINGERPRINT_MISMATCH", - "approval pendingFingerprint 与 canonical payload 不一致", - )); - } - Ok(()) -} - -/// Validate the deterministic observation projection stored in an approval -/// pending. The receipt remains the authority for the exact comment; this -/// validator only proves that the replaceable projection has the fixed -/// action/version envelope and a normalized comment body. -pub(crate) fn validate_plan_gdd_approval_observation( - version: u32, - pending_status: &str, - observation: &PlanGddApprovalObservationV1, -) -> Result<(), PlanningStorageError> { - if !(1..=PLAN_MAX_VERSIONS).contains(&version) { - return Err(invalid("approval observation version 越界")); - } - if observation.tool != PLAN_GDD_APPROVAL_TOOL || observation.status != "ok" { - return Err(invalid("approval pending observation identity 不匹配")); - } - validate_text(&observation.summary, "pending.observation.summary", 1, 400)?; - let (action, expected_summary, detail_prefix) = match pending_status { - "observed_approve" => ("approve", format!("Fast GDD v{version} 已批准"), None), - "observed_revise" => ( - "revise", - format!("Fast GDD v{version} 需要修改"), - Some("用户修改意见:"), - ), - "observed_reject" => ( - "reject", - format!("Fast GDD v{version} 已退回"), - Some("用户退回原因:"), - ), - _ => return Err(invalid("未知 approval pending observation status")), - }; - if observation.summary != expected_summary { - return Err(invalid( - "approval pending observation summary 与 status/version 不匹配", - )); - } - let detail = observation - .detail - .as_deref() - .ok_or_else(|| invalid("approval pending observation 缺少 detail"))?; - validate_text( - detail, - "pending.observation.detail", - 1, - PLAN_GDD_APPROVAL_OBSERVATION_DETAIL_MAX_SCALARS, - )?; - match detail_prefix { - None if detail == "用户已批准当前版本。" => Ok(()), - None => Err(invalid("approve observation detail 不符合固定正文")), - Some(prefix) => { - let comment = detail - .strip_prefix(prefix) - .ok_or_else(|| invalid("审批 observation detail 缺少固定 comment 前缀"))?; - let normalized = normalize_plan_gdd_approval_comment(action, Some(comment))?; - if normalized.as_deref() != Some(comment) { - return Err(invalid("审批 observation detail comment 未完成规范化")); - } - Ok(()) - } - } -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct PlanAppliedAnswer { - pub(crate) delegation_id: String, - pub(crate) continuation_delegation_id: String, - pub(crate) request_id: String, - pub(crate) question_id: String, - pub(crate) response_id: String, - pub(crate) questions_sha256: String, - pub(crate) answers_sha256: String, - pub(crate) decision_id: String, - pub(crate) round: u32, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct PlanSessionV1 { - pub(crate) schema_version: String, - pub(crate) project_id: String, - pub(crate) gdd_id: String, - pub(crate) session_revision: u32, - pub(crate) previous_fingerprint: Option, - pub(crate) session_fingerprint: String, - pub(crate) agent_id: String, - pub(crate) source: String, - pub(crate) run_profile: String, - pub(crate) run_profile_binding_fingerprint: String, - pub(crate) root_agent_id: String, - pub(crate) root_run_id: String, - pub(crate) latest_delegation_id: String, - pub(crate) session_id: String, - pub(crate) active_run_id: Option, - pub(crate) last_run_id: String, - pub(crate) phase: String, - pub(crate) accumulated_agent_millis: u64, - pub(crate) applied_steer_cursor: u64, - pub(crate) decisions_summary: Vec, - pub(crate) prototype_validation_items: Vec, - pub(crate) applied_answers: Vec, - pub(crate) latest_submitted_ref: Option, - pub(crate) last_decision_ref: Option, - pub(crate) updated_at_utc: String, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct PlanDecisionSummary { - pub(crate) id: String, - pub(crate) topic: String, - pub(crate) state: String, - pub(crate) answer_source: String, - pub(crate) round: u32, - pub(crate) answer_summary: String, -} - -fn validate_plan_gdd_ref(value: &PlanGddRef, label: &str) -> Result<(), PlanningStorageError> { - validate_uuid_prefixed(&value.gdd_id, "gdd-", &format!("{label}.gddId"))?; - if !(1..=PLAN_MAX_VERSIONS).contains(&value.version) { - return Err(invalid(format!("{label}.version 越界"))); - } - if !is_typed_fingerprint(&value.fingerprint) { - return Err(invalid(format!("{label}.fingerprint 非法"))); - } - Ok(()) -} - -fn validate_plan_session_shape(value: &PlanSessionV1) -> Result<(), PlanningStorageError> { - if value.schema_version != PLAN_SESSION_SCHEMA_VERSION { - return Err(invalid("未知 plan session schemaVersion")); - } - validate_opaque_id(&value.project_id, "session.projectId", false)?; - validate_uuid_prefixed(&value.gdd_id, "gdd-", "session.gddId")?; - if value.session_revision == 0 { - return Err(invalid("sessionRevision 必须从 1 开始")); - } - if let Some(previous) = &value.previous_fingerprint { - if !is_typed_fingerprint(previous) { - return Err(invalid("previousFingerprint 非法")); - } - } - if !is_typed_fingerprint(&value.session_fingerprint) { - return Err(invalid("sessionFingerprint 非法")); - } - if value.agent_id != "project-planning" - || value.source != "agent-delegate" - || value.run_profile != "standard" - || value.root_agent_id != "project-supervisor" - { - return Err(invalid("session 的 plan identity 常量不匹配")); - } - if !is_bare_fingerprint(&value.run_profile_binding_fingerprint) { - return Err(invalid("session.runProfileBindingFingerprint 非法")); - } - validate_opaque_id(&value.root_run_id, "session.rootRunId", false)?; - validate_opaque_id( - &value.latest_delegation_id, - "session.latestDelegationId", - true, - )?; - validate_opaque_id(&value.session_id, "session.sessionId", false)?; - if let Some(active) = &value.active_run_id { - validate_opaque_id(active, "session.activeRunId", false)?; - } - validate_opaque_id(&value.last_run_id, "session.lastRunId", false)?; - if !matches!( - value.phase.as_str(), - "collecting" - | "awaiting_user_input" - | "awaiting_gdd_approval" - | "revision_requested" - | "approved" - | "rejected" - | "recovery_required" - ) { - return Err(invalid("未知 session phase")); - } - if value.decisions_summary.is_empty() || value.decisions_summary.len() > 32 { - return Err(invalid("session.decisionsSummary 必须有 1~32 项")); - } - validate_unique( - value.decisions_summary.iter().map(|item| item.id.as_str()), - "session.decisionsSummary.id", - )?; - for (index, decision) in value.decisions_summary.iter().enumerate() { - validate_text( - &decision.id, - &format!("session.decisionsSummary[{index}].id"), - 1, - 32, - )?; - validate_text( - &decision.topic, - &format!("session.decisionsSummary[{index}].topic"), - 1, - 80, - )?; - validate_decision_state(&decision.state)?; - validate_answer_source(&decision.answer_source)?; - if decision.round > 3 { - return Err(invalid("session decision round 不能超过 3")); - } - validate_text( - &decision.answer_summary, - &format!("session.decisionsSummary[{index}].answerSummary"), - 1, - decision_answer_summary_max_chars(&decision.id), - )?; - } - if value.decisions_summary[0].id != "initial-request" { - return Err(invalid( - "session.decisionsSummary 首项必须是 initial-request", - )); - } - validate_decisions( - &value - .decisions_summary - .iter() - .map(|decision| PlanDecision { - id: decision.id.clone(), - topic: decision.topic.clone(), - state: decision.state.clone(), - answer_source: decision.answer_source.clone(), - round: decision.round, - answer_summary: decision.answer_summary.clone(), - basis: None, - }) - .collect::>(), - &value.prototype_validation_items, - )?; - if value.applied_answers.len() > 3 { - return Err(invalid("session.appliedAnswers 最多 3 项")); - } - let mut previous_round = 0; - let mut answer_keys = std::collections::BTreeSet::new(); - let mut answer_decisions = std::collections::BTreeSet::new(); - for (index, answer) in value.applied_answers.iter().enumerate() { - validate_opaque_id(&answer.delegation_id, "appliedAnswers.delegationId", false)?; - validate_opaque_id( - &answer.continuation_delegation_id, - "appliedAnswers.continuationDelegationId", - false, - )?; - validate_opaque_id(&answer.request_id, "appliedAnswers.requestId", false)?; - validate_opaque_id(&answer.question_id, "appliedAnswers.questionId", false)?; - validate_text(&answer.response_id, "appliedAnswers.responseId", 1, 160)?; - if !is_lower_hex(&answer.questions_sha256, 64) || !is_lower_hex(&answer.answers_sha256, 64) - { - return Err(invalid("appliedAnswers 的裸 sha256 非法")); - } - validate_opaque_id(&answer.decision_id, "appliedAnswers.decisionId", false)?; - if !(1..=3).contains(&answer.round) || (index > 0 && answer.round <= previous_round) { - return Err(invalid("appliedAnswers.round 必须在 1..=3 且递增")); - } - if !answer_keys.insert((&answer.request_id, &answer.response_id)) { - return Err(invalid( - "appliedAnswers 的 (requestId,responseId) 组合不能重复", - )); - } - if !answer_decisions.insert(answer.decision_id.as_str()) { - return Err(invalid("appliedAnswers.decisionId 不能重复")); - } - let Some(decision) = value - .decisions_summary - .iter() - .find(|decision| decision.id == answer.decision_id) - else { - return Err(invalid( - "appliedAnswers.decisionId 必须引用 decisionsSummary", - )); - }; - if decision.round != answer.round { - return Err(invalid( - "appliedAnswers.decisionId 必须引用同一 round 的 decisionsSummary", - )); - } - let expected_continuation = derive_plan_continuation_delegation_id( - &value.root_run_id, - &answer.delegation_id, - &answer.questions_sha256, - &answer.answers_sha256, - )?; - if answer.continuation_delegation_id != expected_continuation { - return Err(conflict( - "appliedAnswers.continuationDelegationId 与确定性委派派生值不一致", - )); - } - previous_round = answer.round; - } - if let Some(reference) = &value.latest_submitted_ref { - validate_plan_gdd_ref(reference, "latestSubmittedRef")?; - if reference.gdd_id != value.gdd_id { - return Err(invalid("latestSubmittedRef.gddId 与 session 不一致")); - } - } - if let Some(reference) = &value.last_decision_ref { - validate_plan_gdd_ref( - &PlanGddRef { - gdd_id: value.gdd_id.clone(), - version: reference.version, - fingerprint: reference.receipt_fingerprint.clone(), - }, - "lastDecisionRef", - )?; - if !matches!(reference.action.as_str(), "approve" | "revise" | "reject") { - return Err(invalid("lastDecisionRef.action 非法")); - } - validate_uuid_prefixed( - &reference.response_id, - "gdd-response-", - "lastDecisionRef.responseId", - )?; - if value - .latest_submitted_ref - .as_ref() - .is_some_and(|submitted| reference.version > submitted.version) - { - return Err(invalid( - "lastDecisionRef.version 不能晚于 latestSubmittedRef.version", - )); - } - } - match value.phase.as_str() { - "awaiting_gdd_approval" if value.latest_submitted_ref.is_none() => { - return Err(invalid("awaiting_gdd_approval 必须有 latestSubmittedRef")); - } - "awaiting_gdd_approval" if value.last_decision_ref.is_some() => { - return Err(invalid( - "awaiting_gdd_approval 不得已经存在 lastDecisionRef", - )); - } - "revision_requested" | "approved" | "rejected" if value.last_decision_ref.is_none() => { - return Err(invalid("终态 session 必须有 lastDecisionRef")); - } - "approved" - if value - .last_decision_ref - .as_ref() - .is_some_and(|reference| reference.action != "approve") => - { - return Err(invalid( - "approved session 的 lastDecisionRef.action 必须是 approve", - )); - } - "revision_requested" - if value - .last_decision_ref - .as_ref() - .is_some_and(|reference| reference.action != "revise") => - { - return Err(invalid( - "revision_requested session 的 lastDecisionRef.action 必须是 revise", - )); - } - "rejected" - if value - .last_decision_ref - .as_ref() - .is_some_and(|reference| reference.action != "reject") => - { - return Err(invalid( - "rejected session 的 lastDecisionRef.action 必须是 reject", - )); - } - _ => {} - } - if value.phase == "awaiting_user_input" && value.latest_delegation_id.is_empty() { - return Err(invalid( - "awaiting_user_input 必须有 latestDelegationId 以定位问题 delivery", - )); - } - if value - .applied_answers - .iter() - .any(|answer| value.latest_delegation_id == answer.delegation_id) - { - // Once an answer has been applied, the session may point at its - // deterministic continuation or at a later user-revision descendant, - // but it must never rewind to an already answered question delivery. - // Exact descendant proof depends on the delivery sidecars and is - // enforced by `validate_plan_session_latest_delegation_lineage_at` at - // every runtime read/write boundary rather than guessed from phase. - return Err(conflict( - "latestDelegationId 不能回退到已消费回答的 delegationId", - )); - } - if matches!( - value.phase.as_str(), - "awaiting_gdd_approval" - | "revision_requested" - | "approved" - | "rejected" - | "recovery_required" - ) && value.active_run_id.is_some() - { - return Err(invalid( - "session 进入审批/终态/recovery_required 后不得保留 activeRunId", - )); - } - if value.phase == "awaiting_user_input" && value.active_run_id.is_some() { - return Err(invalid( - "awaiting_user_input 的策划子 run 已终态,不得保留 activeRunId", - )); - } - if value.session_revision == 1 && value.previous_fingerprint.is_some() { - return Err(invalid( - "revision=1 的 session previousFingerprint 必须为 null", - )); - } - if value.session_revision > 1 && value.previous_fingerprint.is_none() { - return Err(invalid( - "revision>1 的 session previousFingerprint 不能为空", - )); - } - validate_timestamp(&value.updated_at_utc, "session.updatedAtUtc")?; - Ok(()) -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct PlanSessionFingerprintValue<'a> { - schema_version: &'a str, - project_id: &'a str, - gdd_id: &'a str, - session_revision: u32, - previous_fingerprint: &'a Option, - agent_id: &'a str, - source: &'a str, - run_profile: &'a str, - run_profile_binding_fingerprint: &'a str, - root_agent_id: &'a str, - root_run_id: &'a str, - latest_delegation_id: &'a str, - session_id: &'a str, - active_run_id: &'a Option, - last_run_id: &'a str, - phase: &'a str, - accumulated_agent_millis: u64, - applied_steer_cursor: u64, - decisions_summary: &'a Vec, - prototype_validation_items: &'a Vec, - applied_answers: &'a Vec, - latest_submitted_ref: &'a Option, - last_decision_ref: &'a Option, - updated_at_utc: &'a str, -} - -impl<'a> From<&'a PlanSessionV1> for PlanSessionFingerprintValue<'a> { - fn from(value: &'a PlanSessionV1) -> Self { - Self { - schema_version: &value.schema_version, - project_id: &value.project_id, - gdd_id: &value.gdd_id, - session_revision: value.session_revision, - previous_fingerprint: &value.previous_fingerprint, - agent_id: &value.agent_id, - source: &value.source, - run_profile: &value.run_profile, - run_profile_binding_fingerprint: &value.run_profile_binding_fingerprint, - root_agent_id: &value.root_agent_id, - root_run_id: &value.root_run_id, - latest_delegation_id: &value.latest_delegation_id, - session_id: &value.session_id, - active_run_id: &value.active_run_id, - last_run_id: &value.last_run_id, - phase: &value.phase, - accumulated_agent_millis: value.accumulated_agent_millis, - applied_steer_cursor: value.applied_steer_cursor, - decisions_summary: &value.decisions_summary, - prototype_validation_items: &value.prototype_validation_items, - applied_answers: &value.applied_answers, - latest_submitted_ref: &value.latest_submitted_ref, - last_decision_ref: &value.last_decision_ref, - updated_at_utc: &value.updated_at_utc, - } - } -} - -pub(crate) fn plan_session_fingerprint( - value: &PlanSessionV1, -) -> Result { - validate_plan_session_shape(value)?; - typed_serde_fingerprint( - PLAN_SESSION_FINGERPRINT_DOMAIN, - &PlanSessionFingerprintValue::from(value), - ) -} - -pub(crate) fn validate_plan_session(value: &PlanSessionV1) -> Result<(), PlanningStorageError> { - validate_plan_session_shape(value)?; - let expected = plan_session_fingerprint(value)?; - if value.session_fingerprint != expected { - return Err(PlanningStorageError::new( - "PLAN_FINGERPRINT_MISMATCH", - "sessionFingerprint 与 canonical payload 不一致", - )); - } - Ok(()) -} - -/// Prove the dynamic part of `latestDelegationId` that the standalone session -/// schema cannot establish. After a clarification answer, a later id is only -/// valid when the durable static-delivery chain reaches the last deterministic -/// continuation and every crossed edge is classified by a -/// `UserRevisionRequested` parent. -/// This keeps revise/reject continuations valid without accepting an arbitrary -/// recomputed session fingerprint that points at an unrelated delivery. -fn validate_plan_session_latest_delegation_lineage_at( - root: &Path, - value: &PlanSessionV1, -) -> Result<(), PlanningStorageError> { - let Some(last_answer) = value.applied_answers.last() else { - return Ok(()); - }; - if value.latest_delegation_id == last_answer.continuation_delegation_id { - return Ok(()); - } - - let deliveries = list_static_delegate_deliveries_at(root).map_err(|error| { - PlanningStorageError::new( - "PLAN_NEEDS_RECONCILIATION", - format!("读取 session latest delegation 谱系失败:{error}"), - ) - })?; - if static_delegate_lineage_contains_unknown_contract_status( - &deliveries, - &value.latest_delegation_id, - ) - .map_err(|error| { - PlanningStorageError::new( - "PLAN_NEEDS_RECONCILIATION", - format!("检查 session latest delegation 谱系失败:{error}"), - ) - })? { - return Err(PlanningStorageError::new( - "PLAN_NEEDS_RECONCILIATION", - "session latest delegation 谱系含未知 contractStatus", - )); - } - - let by_id = deliveries - .iter() - .map(|delivery| (delivery.delegation_id.as_str(), delivery)) - .collect::>(); - let mut cursor = value.latest_delegation_id.as_str(); - let mut visited = std::collections::BTreeSet::new(); - loop { - if !visited.insert(cursor) { - return Err(conflict( - "session latest delegation 谱系形成循环,不能证明回答后继关系", - )); - } - let delivery = by_id - .get(cursor) - .copied() - .ok_or_else(|| conflict(format!("session latest delegation 谱系缺少节点:{cursor}")))?; - if delivery.parent_agent_id != value.root_agent_id - || delivery.parent_run_id != value.root_run_id - || delivery.target_agent_id != value.agent_id - || delivery.target_session_id != value.session_id - { - return Err(conflict( - "session latest delegation 谱系跨越了 root/agent/session identity", - )); - } - if delivery.delegation_id == last_answer.continuation_delegation_id { - break; - } - let parent_id = delivery.repair_of_delegation_id.as_deref().ok_or_else(|| { - conflict("session latest delegation 不是最后一次回答 continuation 的后继") - })?; - let parent = by_id.get(parent_id).copied().ok_or_else(|| { - conflict(format!( - "session latest delegation 谱系缺少节点:{parent_id}" - )) - })?; - if !parent.structured_result.as_ref().is_some_and(|result| { - result.contract_status == StaticDelegateContractStatus::UserRevisionRequested - }) { - return Err(conflict( - "session latest delegation 含非 UserRevisionRequested 父边,不能保留 appliedAnswers", - )); - } - cursor = parent_id; - } - Ok(()) -} - -/// Validate the session projection against the clarification round derived -/// from the static-delegate lineage. The lineage counter intentionally stays -/// outside this storage module; callers must supply the independently read -/// value rather than letting a durable session become a second source of -/// truth. -pub(crate) fn validate_plan_session_for_clarification_round( - value: &PlanSessionV1, - clarification_round: u32, -) -> Result<(), PlanningStorageError> { - validate_plan_session(value)?; - if clarification_round > 3 || value.applied_answers.len() as u32 != clarification_round { - return Err(PlanningStorageError::new( - "PLAN_NEEDS_RECONCILIATION", - "session appliedAnswers 数量与委派链 clarification_round 不一致", - )); - } - Ok(()) -} - -pub(crate) fn validate_plan_session_successor( - previous: &PlanSessionV1, - next: &PlanSessionV1, -) -> Result<(), PlanningStorageError> { - validate_plan_session(previous)?; - validate_plan_session(next)?; - if previous.phase == "recovery_required" { - return Err(conflict("recovery_required session 不能继续推进 successor")); - } - if next.project_id != previous.project_id - || next.gdd_id != previous.gdd_id - || next.session_id != previous.session_id - || next.agent_id != previous.agent_id - || next.source != previous.source - || next.run_profile != previous.run_profile - || next.root_agent_id != previous.root_agent_id - || next.root_run_id != previous.root_run_id - { - return Err(conflict( - "session successor 跨越了 project/session identity", - )); - } - let expected_revision = previous - .session_revision - .checked_add(1) - .ok_or_else(|| conflict("sessionRevision 溢出,不能创建 successor"))?; - if next.session_revision != expected_revision - || next.previous_fingerprint.as_deref() != Some(previous.session_fingerprint.as_str()) - { - return Err(conflict( - "session successor 必须是 revision+1 且 previousFingerprint 精确回链", - )); - } - if next.accumulated_agent_millis < previous.accumulated_agent_millis - || next.applied_steer_cursor < previous.applied_steer_cursor - { - return Err(conflict( - "session successor 的累计运行时间和 steer cursor 只能单调增加", - )); - } - let active_run_changed = next.active_run_id != previous.active_run_id - && next.active_run_id.is_some() - && next.last_run_id == next.active_run_id.clone().unwrap_or_default(); - if next.run_profile_binding_fingerprint != previous.run_profile_binding_fingerprint - && !active_run_changed - { - return Err(conflict( - "session 只有在绑定新的 active planning child 时才能更换 Run Profile binding fingerprint", - )); - } - Ok(()) -} - -/// Derive the deterministic continuation identity used by the static delegate -/// clarification contract. Keeping the derivation here lets the durable -/// session projection reject a forged continuation id without importing the -/// delegation writer or trusting a caller-provided string. -pub(crate) fn derive_plan_continuation_delegation_id( - parent_run_id: &str, - repair_of_delegation_id: &str, - questions_sha256: &str, - answers_sha256: &str, -) -> Result { - validate_opaque_id(parent_run_id, "continuation.parentRunId", false)?; - validate_opaque_id( - repair_of_delegation_id, - "continuation.repairOfDelegationId", - false, - )?; - if !is_lower_hex(questions_sha256, 64) || !is_lower_hex(answers_sha256, 64) { - return Err(invalid( - "continuation questionsSha256/answersSha256 必须是裸 64 位小写 digest", - )); - } - let continuation_action_identity = format!( - "clarification-continuation-{:x}", - Sha256::digest(format!( - "{parent_run_id}\n{repair_of_delegation_id}\n{questions_sha256}\n{answers_sha256}" - )) - ); - Ok(agent_runtime_delegation_id( - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - parent_run_id, - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - &continuation_action_identity, - )) -} - -fn reject_noncanonical_storage_bytes( - bytes: &[u8], - label: &str, -) -> Result<(), PlanningStorageError> { - if bytes.starts_with(&[0xef, 0xbb, 0xbf]) { - return Err(PlanningStorageError::new( - "PLAN_NON_CANONICAL_BYTES", - format!("{label} 不得包含 UTF-8 BOM"), - )); - } - if bytes - .last() - .is_some_and(|byte| *byte == b'\n' || *byte == b'\r' || *byte == b' ' || *byte == b'\t') - { - return Err(PlanningStorageError::new( - "PLAN_NON_CANONICAL_BYTES", - format!("{label} 不得以换行或尾部空白结束"), - )); - } - Ok(()) -} - -fn parse_strict_canonical( - bytes: &[u8], - label: &str, - max_bytes: usize, -) -> Result -where - T: DeserializeOwned + Serialize, -{ - if bytes.len() > max_bytes { - return Err(PlanningStorageError::new( - "PLAN_SIZE_LIMIT", - format!("{label} 超过 {max_bytes} 字节上限"), - )); - } - reject_noncanonical_storage_bytes(bytes, label)?; - let mut duplicate_checker = serde_json::Deserializer::from_slice(bytes); - duplicate_checker - .deserialize_any(DuplicateKeyVisitor) - .map_err(|error| { - PlanningStorageError::new( - "PLAN_INVALID_JSON", - format!("{label} JSON 重复键或结构无效:{error}"), - ) - })?; - duplicate_checker.end().map_err(|error| { - PlanningStorageError::new( - "PLAN_INVALID_JSON", - format!("{label} JSON 尾部存在额外内容:{error}"), - ) - })?; - let value = serde_json::from_slice::(bytes).map_err(|error| { - PlanningStorageError::new("PLAN_INVALID_JSON", format!("{label} JSON 无效:{error}")) - })?; - let canonical = canonical_bytes(&value)?; - if canonical != bytes { - return Err(PlanningStorageError::new( - "PLAN_NON_CANONICAL_BYTES", - format!("{label} 必须是字段声明顺序的 compact canonical JSON"), - )); - } - Ok(value) -} - -pub(crate) fn canonical_plan_gdd_bytes(value: &PlanGddV1) -> Result, PlanningStorageError> { - validate_plan_gdd(value)?; - let bytes = canonical_bytes(value)?; - if bytes.len() > PLAN_GDD_MAX_BYTES { - return Err(PlanningStorageError::new( - "PLAN_SIZE_LIMIT", - "GDD 超过 64 KiB 字节上限", - )); - } - Ok(bytes) -} - -pub(crate) fn parse_plan_gdd_bytes(bytes: &[u8]) -> Result { - let value = parse_strict_canonical::(bytes, "GDD", PLAN_GDD_MAX_BYTES)?; - validate_plan_gdd(&value)?; - Ok(value) -} - -pub(crate) fn canonical_plan_session_bytes( - value: &PlanSessionV1, -) -> Result, PlanningStorageError> { - validate_plan_session(value)?; - let bytes = canonical_bytes(value)?; - if bytes.len() > PLAN_SESSION_MAX_BYTES { - return Err(PlanningStorageError::new( - "PLAN_SIZE_LIMIT", - "session 超过 64 KiB 字节上限", - )); - } - Ok(bytes) -} - -pub(crate) fn parse_plan_session_bytes( - bytes: &[u8], -) -> Result { - let value = - parse_strict_canonical::(bytes, "plan session", PLAN_SESSION_MAX_BYTES)?; - validate_plan_session(&value)?; - Ok(value) -} - -pub(crate) fn canonical_plan_index_bytes( - value: &PlanGddIndexV1, -) -> Result, PlanningStorageError> { - validate_plan_gdd_index(value)?; - let bytes = canonical_bytes(value)?; - if bytes.len() > PLAN_INDEX_MAX_BYTES { - return Err(PlanningStorageError::new( - "PLAN_SIZE_LIMIT", - "GDD index 超过 256 KiB 字节上限", - )); - } - Ok(bytes) -} - -pub(crate) fn parse_plan_index_bytes(bytes: &[u8]) -> Result { - let value = parse_strict_canonical::(bytes, "GDD index", PLAN_INDEX_MAX_BYTES)?; - validate_plan_gdd_index(&value)?; - Ok(value) -} - -pub(crate) fn canonical_plan_gdd_approval_bytes( - value: &PlanGddApprovalV1, -) -> Result, PlanningStorageError> { - validate_plan_gdd_approval(value)?; - let bytes = canonical_bytes(value)?; - if bytes.len() > PLAN_GDD_APPROVAL_MAX_BYTES { - return Err(PlanningStorageError::new( - "PLAN_SIZE_LIMIT", - "GDD approval receipt 超过 16 KiB 字节上限", - )); - } - Ok(bytes) -} - -pub(crate) fn parse_plan_gdd_approval_bytes( - bytes: &[u8], -) -> Result { - let value = parse_strict_canonical::( - bytes, - "GDD approval receipt", - PLAN_GDD_APPROVAL_MAX_BYTES, - )?; - validate_plan_gdd_approval(&value)?; - Ok(value) -} - -pub(crate) fn canonical_plan_gdd_approval_pending_bytes( - value: &PlanGddApprovalPendingV1, -) -> Result, PlanningStorageError> { - validate_plan_gdd_approval_pending(value)?; - let bytes = canonical_bytes(value)?; - if bytes.len() > PLAN_GDD_APPROVAL_PENDING_MAX_BYTES { - return Err(PlanningStorageError::new( - "PLAN_SIZE_LIMIT", - "GDD approval pending 超过 16 KiB 字节上限", - )); - } - Ok(bytes) -} - -pub(crate) fn parse_plan_gdd_approval_pending_bytes( - bytes: &[u8], -) -> Result { - let value = parse_strict_canonical::( - bytes, - "GDD approval pending", - PLAN_GDD_APPROVAL_PENDING_MAX_BYTES, - )?; - validate_plan_gdd_approval_pending(&value)?; - Ok(value) -} - -pub(crate) fn validate_plan_submit_gdd_input( - value: &PlanSubmitGddInputV1, -) -> Result<(), PlanningStorageError> { - if value.schema_version != PLAN_SUBMIT_GDD_INPUT_SCHEMA_VERSION { - return Err(invalid("未知 plan.submit_gdd input schemaVersion")); - } - // Provider input deliberately omits Runtime-injected platform facts and - // all durable identity/fingerprint fields. Reuse the same business - // limits by projecting it to the durable shape with null bases. - let game = PlanGddGame { - title: value.game.title.clone(), - genre: value.game.genre.clone(), - art_style: value.game.art_style.clone(), - one_liner: value.game.one_liner.clone(), - pillars: value - .game - .pillars - .iter() - .map(|pillar| PlanPillar { - name: pillar.name.clone(), - player_feel: pillar.player_feel.clone(), - mechanism: pillar.mechanism.clone(), - decision_state: pillar.decision_state.clone(), - basis: None, - }) - .collect(), - core_loop: value.game.core_loop.clone(), - target_users: value.game.target_users.clone(), - platform_facts: PlanPlatformFacts { - runtime: "self-contained-web".to_string(), - viewports: vec!["desktop".to_string(), "mobile".to_string()], - inputs: vec!["keyboard".to_string(), "touch".to_string()], - preview: "local-http".to_string(), - }, - mvp_systems: value - .game - .mvp_systems - .iter() - .map(|system| PlanMvpSystem { - system: system.system.clone(), - minimal_function: system.minimal_function.clone(), - why_required: system.why_required.clone(), - verify_method: system.verify_method.clone(), - decision_state: system.decision_state.clone(), - basis: None, - }) - .collect(), - out_of_scope: value.game.out_of_scope.clone(), - creator_tips: value.game.creator_tips.clone(), - }; - validate_plan_game(&game)?; - let decisions = value - .decisions - .iter() - .map(|decision| PlanDecision { - id: decision.id.clone(), - topic: decision.topic.clone(), - state: decision.state.clone(), - answer_source: decision.answer_source.clone(), - round: decision.round, - answer_summary: decision.answer_summary.clone(), - basis: None, - }) - .collect::>(); - validate_decisions(&decisions, &value.prototype_validation_items) -} - -pub(crate) fn canonical_plan_submit_gdd_input_bytes( - value: &PlanSubmitGddInputV1, -) -> Result, PlanningStorageError> { - validate_plan_submit_gdd_input(value)?; - let bytes = canonical_bytes(value)?; - if bytes.len() > PLAN_GDD_MAX_BYTES { - return Err(PlanningStorageError::new( - "PLAN_SIZE_LIMIT", - "plan.submit_gdd input 超过 64 KiB 字节上限", - )); - } - Ok(bytes) -} - -pub(crate) fn validate_plan_gdd_chain(values: &[PlanGddV1]) -> Result<(), PlanningStorageError> { - if values.len() > PLAN_MAX_VERSIONS as usize { - return Err(PlanningStorageError::new( - "PLAN_VERSION_LIMIT_REACHED", - "单一 GDD lineage 不能超过 128 个版本", - )); - } - let Some(first) = values.first() else { - return Ok(()); - }; - if first.version != 1 { - return Err(conflict("GDD 版本链必须从 version=1 开始")); - } - validate_plan_gdd(first)?; - let mut submission_ids = std::collections::BTreeSet::new(); - let mut approval_request_ids = std::collections::BTreeSet::new(); - let mut action_fingerprints = std::collections::BTreeSet::new(); - submission_ids.insert(first.submission_id.as_str()); - approval_request_ids.insert(first.approval_request_id.as_str()); - action_fingerprints.insert(first.action_fingerprint.as_str()); - for (index, value) in values.iter().enumerate().skip(1) { - validate_plan_gdd(value)?; - let expected_version = index as u32 + 1; - if value.version != expected_version - || value.gdd_id != first.gdd_id - || value.project_id != first.project_id - { - return Err(conflict("GDD 版本链必须连续且绑定同一 projectId/gddId")); - } - if !submission_ids.insert(value.submission_id.as_str()) - || !approval_request_ids.insert(value.approval_request_id.as_str()) - || !action_fingerprints.insert(value.action_fingerprint.as_str()) - { - return Err(conflict( - "GDD 版本链的 submissionId/approvalRequestId/actionFingerprint 必须唯一", - )); - } - } - Ok(()) -} - -pub(crate) fn validate_next_plan_gdd_version( - existing: &[PlanGddV1], - candidate: &PlanGddV1, -) -> Result<(), PlanningStorageError> { - validate_plan_gdd(candidate)?; - validate_plan_gdd_chain(existing)?; - let expected_version = existing.len() as u32 + 1; - if expected_version > PLAN_MAX_VERSIONS { - return Err(PlanningStorageError::new( - "PLAN_VERSION_LIMIT_REACHED", - "不能继续创建第 129 个 GDD 版本", - )); - } - if candidate.version != expected_version - || existing.first().is_some_and(|first| { - first.project_id != candidate.project_id || first.gdd_id != candidate.gdd_id - }) - { - return Err(conflict("candidate GDD 不是版本链的唯一 next version")); - } - Ok(()) -} - -pub(crate) fn build_plan_gdd_index( - gdds: &[PlanGddV1], - rebuilt_at_utc: &str, -) -> Result { - validate_plan_gdd_chain(gdds)?; - validate_timestamp(rebuilt_at_utc, "rebuiltAtUtc")?; - let Some(first) = gdds.first() else { - return Err(invalid("没有 GDD 权威事实时不能构造 plan-gdd-index.v1")); - }; - let entries = gdds - .iter() - .map(plan_gdd_index_entry_from_gdd) - .collect::>(); - let latest_version = gdds.last().expect("non-empty GDD chain").version; - // M1B-1 does not yet own approval receipts. A later GDD candidate - // supersedes the older candidate in this pre-receipt projection, while - // only the latest candidate can be awaiting approval. M1C-1 must rebuild - // these statuses from the authoritative receipt facts once that schema is - // available; the index itself never becomes a source of truth. - let statuses = gdds - .iter() - .map(|gdd| PlanGddIndexVersionStatus { - version: gdd.version, - status: if gdd.version == latest_version { - "ready_for_approval".to_string() - } else { - "superseded".to_string() - }, - }) - .collect::>(); - Ok(PlanGddIndexV1 { - schema_version: PLAN_GDD_INDEX_SCHEMA_VERSION.to_string(), - project_id: first.project_id.clone(), - gdd_id: first.gdd_id.clone(), - entries, - status_cache: PlanGddIndexStatusCache { - latest_version: gdds.len() as u32, - pending_version: Some(latest_version), - approved_version: None, - versions: statuses, - }, - rebuilt_at_utc: rebuilt_at_utc.to_string(), - }) -} - -pub(crate) fn validate_plan_gdd_approvals_against_gdds( - gdds: &[PlanGddV1], - approvals: &[PlanGddApprovalV1], -) -> Result<(), PlanningStorageError> { - validate_plan_gdd_chain(gdds)?; - let mut by_version = std::collections::BTreeSet::new(); - for receipt in approvals { - validate_plan_gdd_approval(receipt)?; - if !by_version.insert(receipt.version) { - return Err(PlanningStorageError::new( - "PLAN_CORRUPT_AUTHORITY", - format!("GDD v{} 存在多个 approval receipt", receipt.version), - )); - } - let Some(gdd) = gdds.iter().find(|gdd| gdd.version == receipt.version) else { - return Err(PlanningStorageError::new( - "PLAN_CORRUPT_AUTHORITY", - format!("approval receipt 引用了不存在的 GDD v{}", receipt.version), - )); - }; - if receipt.project_id != gdd.project_id - || receipt.gdd_id != gdd.gdd_id - || receipt.fingerprint != gdd.fingerprint - || receipt.pending_action_id != gdd.submission_id - || receipt.action_fingerprint != gdd.action_fingerprint - || receipt.approval_request_id != gdd.approval_request_id - || receipt.run_profile != gdd.run_profile - || receipt.run_profile_binding_fingerprint != gdd.run_profile_binding_fingerprint - || receipt.session_id != gdd.session_id - || receipt.run_id != gdd.created_by_run_id - { - return Err(PlanningStorageError::new( - "PLAN_CORRUPT_AUTHORITY", - format!( - "approval receipt v{} 与 GDD 权威身份不一致", - receipt.version - ), - )); - } - } - if approvals.len() > gdds.len() { - return Err(PlanningStorageError::new( - "PLAN_CORRUPT_AUTHORITY", - "approval receipt 数量超过 GDD lineage", - )); - } - let missing = gdds - .iter() - .filter(|gdd| !by_version.contains(&gdd.version)) - .count(); - if missing > 1 { - return Err(PlanningStorageError::new( - "PLAN_NEEDS_RECONCILIATION", - "同一 GDD lineage 不能同时存在多个无 receipt 版本", - )); - } - if let Some(last) = gdds.last() { - if missing == 1 && by_version.contains(&last.version) { - return Err(PlanningStorageError::new( - "PLAN_NEEDS_RECONCILIATION", - "无 receipt GDD 必须是 lineage 最新版本", - )); - } - } - Ok(()) -} - -pub(crate) fn build_plan_gdd_index_with_approvals( - gdds: &[PlanGddV1], - approvals: &[PlanGddApprovalV1], - rebuilt_at_utc: &str, -) -> Result { - validate_plan_gdd_approvals_against_gdds(gdds, approvals)?; - validate_timestamp(rebuilt_at_utc, "rebuiltAtUtc")?; - let Some(first) = gdds.first() else { - if approvals.is_empty() { - return Err(invalid("没有 GDD 权威事实时不能构造 plan-gdd-index.v1")); - } - return Err(PlanningStorageError::new( - "PLAN_CORRUPT_AUTHORITY", - "approval receipt 存在但没有 GDD 权威事实", - )); - }; - let entries = gdds - .iter() - .map(plan_gdd_index_entry_from_gdd) - .collect::>(); - let receipt_by_version = approvals - .iter() - .map(|receipt| (receipt.version, receipt)) - .collect::>(); - let approved_version = approvals - .iter() - .filter(|receipt| receipt.action == "approve") - .map(|receipt| receipt.version) - .max(); - let pending_version = gdds - .iter() - .find(|gdd| !receipt_by_version.contains_key(&gdd.version)) - .map(|gdd| gdd.version); - let statuses = gdds - .iter() - .map(|gdd| { - let status = match receipt_by_version.get(&gdd.version) { - None => "ready_for_approval", - Some(receipt) => match receipt.action.as_str() { - "approve" if Some(gdd.version) == approved_version => "approved", - "approve" => "superseded", - "revise" => "revision_requested", - "reject" => "rejected", - _ => unreachable!("validated approval action"), - }, - }; - PlanGddIndexVersionStatus { - version: gdd.version, - status: status.to_string(), - } - }) - .collect::>(); - Ok(PlanGddIndexV1 { - schema_version: PLAN_GDD_INDEX_SCHEMA_VERSION.to_string(), - project_id: first.project_id.clone(), - gdd_id: first.gdd_id.clone(), - entries, - status_cache: PlanGddIndexStatusCache { - latest_version: gdds.len() as u32, - pending_version, - approved_version, - versions: statuses, - }, - rebuilt_at_utc: rebuilt_at_utc.to_string(), - }) -} - -fn plan_gdd_index_entry_from_gdd(gdd: &PlanGddV1) -> PlanGddIndexEntry { - PlanGddIndexEntry { - version: gdd.version, - submission_id: gdd.submission_id.clone(), - approval_request_id: gdd.approval_request_id.clone(), - action_fingerprint: gdd.action_fingerprint.clone(), - fingerprint: gdd.fingerprint.clone(), - file: format!("gdd.v{}.json", gdd.version), - agent_id: gdd.agent_id.clone(), - source: gdd.source.clone(), - run_profile: gdd.run_profile.clone(), - run_profile_binding_fingerprint: gdd.run_profile_binding_fingerprint.clone(), - root_run_id: gdd.root_run_id.clone(), - delegation_id: gdd.delegation_id.clone(), - session_id: gdd.session_id.clone(), - source_session_revision: gdd.source_session_revision, - source_session_fingerprint: gdd.source_session_fingerprint.clone(), - created_by_run_id: gdd.created_by_run_id.clone(), - created_at_utc: gdd.created_at_utc.clone(), - submitted_at_utc: gdd.created_at_utc.clone(), - } -} - -pub(crate) fn validate_plan_gdd_index_against_gdds( - index: &PlanGddIndexV1, - gdds: &[PlanGddV1], -) -> Result<(), PlanningStorageError> { - validate_plan_gdd_chain(gdds)?; - validate_plan_gdd_index(index)?; - let Some(first) = gdds.first() else { - return Err(invalid("index 没有可对应的 GDD 权威事实")); - }; - if index.project_id != first.project_id || index.gdd_id != first.gdd_id { - return Err(conflict("index projectId/gddId 与 GDD 权威事实不一致")); - } - if index.entries.len() != gdds.len() - || index - .entries - .iter() - .zip(gdds) - .any(|(entry, gdd)| entry != &plan_gdd_index_entry_from_gdd(gdd)) - { - return Err(conflict("index entries 必须逐项等于对应 GDD 的权威字段")); - } - let expected_status_cache = build_plan_gdd_index(gdds, &index.rebuilt_at_utc)?.status_cache; - if index.status_cache != expected_status_cache { - return Err(conflict( - "index statusCache 必须由当前 M1B-1 GDD lineage 确定性重建", - )); - } - Ok(()) -} - -pub(crate) fn validate_plan_gdd_index_against_gdds_and_approvals( - index: &PlanGddIndexV1, - gdds: &[PlanGddV1], - approvals: &[PlanGddApprovalV1], -) -> Result<(), PlanningStorageError> { - validate_plan_gdd_chain(gdds)?; - validate_plan_gdd_index(index)?; - let Some(first) = gdds.first() else { - return Err(invalid("index 没有可对应的 GDD 权威事实")); - }; - if index.project_id != first.project_id || index.gdd_id != first.gdd_id { - return Err(conflict("index projectId/gddId 与 GDD 权威事实不一致")); - } - if index.entries.len() != gdds.len() - || index - .entries - .iter() - .zip(gdds) - .any(|(entry, gdd)| entry != &plan_gdd_index_entry_from_gdd(gdd)) - { - return Err(conflict("index entries 必须逐项等于对应 GDD 的权威字段")); - } - let expected_status_cache = - build_plan_gdd_index_with_approvals(gdds, approvals, &index.rebuilt_at_utc)?.status_cache; - if index.status_cache != expected_status_cache { - return Err(conflict( - "index statusCache 必须由 GDD/receipt 权威事实确定性重建", - )); - } - Ok(()) -} - -fn approval_directory_is_present(root: &Path) -> Result { - let path = resolve_planning_path(root, PLAN_GDD_APPROVAL_DIR)?; - // 下面这次 stat 仍然必要:`resolve_planning_path` 只保证解析那一刻整条链 - // 可信,而判定「目录存在」要读的是使用时刻的那一项,顺带还要排掉链接以外 - // 的另一种不可信形态——普通文件占位。 - match fs::symlink_metadata(path) { - Ok(metadata) => { - if planning_metadata_is_link_or_reparse(&metadata) || !metadata.is_dir() { - return Err(PlanningStorageError::new( - "PLAN_UNTRUSTED_PATH", - "approval receipt 目录必须是可信普通目录", - )); - } - Ok(true) - } - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), - Err(error) => Err(io_error("探测 approval receipt 目录失败", error)), - } -} - -pub(crate) fn build_plan_gdd_index_for_root_locked( - root: &Path, - gdds: &[PlanGddV1], - rebuilt_at_utc: &str, -) -> Result { - let approvals = read_plan_gdd_approvals_locked(root)?; - if approvals.is_empty() && !approval_directory_is_present(root)? { - return build_plan_gdd_index(gdds, rebuilt_at_utc); - } - build_plan_gdd_index_with_approvals(gdds, &approvals, rebuilt_at_utc) -} - -fn is_recoverable_index_projection_error(error: &PlanningStorageError) -> bool { - matches!( - error.code(), - "PLAN_INVALID_JSON" - | "PLAN_NON_CANONICAL_BYTES" - | "PLAN_INVALID_SCHEMA" - | "PLAN_FINGERPRINT_MISMATCH" - | "PLAN_IDENTITY_CONFLICT" - | "PLAN_SIZE_LIMIT" - ) -} - -pub(crate) fn read_plan_gdd_index_with_recovery_locked( - root: &Path, - rebuilt_at_utc: &str, -) -> Result, PlanningStorageError> { - validate_timestamp(rebuilt_at_utc, "rebuiltAtUtc")?; - let gdds = read_plan_gdd_chain_locked(root)?; - let target = resolve_planning_path(root, PLAN_GDD_INDEX_PATH)?; - let target_exists = match fs::symlink_metadata(&target) { - Ok(_) => true, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => false, - Err(error) => return Err(io_error("探测 planning index 失败", error)), - }; - - if gdds.is_empty() { - if target_exists { - return Err(conflict("planning index 存在但没有对应的 GDD 权威事实")); - } - return Ok(None); - } - - let rebuild = || { - let rebuilt = build_plan_gdd_index_for_root_locked(root, &gdds, rebuilt_at_utc)?; - write_plan_gdd_index_atomic_locked(root, &rebuilt)?; - Ok(Some(rebuilt)) - }; - - if !target_exists { - return rebuild(); - } - - let bytes = match read_regular_planning_file(&target, "planning GDD index") { - Ok(bytes) => bytes, - Err(error) if is_recoverable_index_projection_error(&error) => return rebuild(), - Err(error) => return Err(error), - }; - let parsed = match parse_plan_index_bytes(&bytes) { - Ok(value) => value, - Err(error) if is_recoverable_index_projection_error(&error) => return rebuild(), - Err(error) => return Err(error), - }; - let approvals = read_plan_gdd_approvals_locked(root)?; - let validation = if approvals.is_empty() && !approval_directory_is_present(root)? { - validate_plan_gdd_index_against_gdds(&parsed, &gdds) - } else { - validate_plan_gdd_index_against_gdds_and_approvals(&parsed, &gdds, &approvals) - }; - match validation { - Ok(()) => Ok(Some(parsed)), - Err(error) if is_recoverable_index_projection_error(&error) => rebuild(), - Err(error) => Err(error), - } -} - -fn gdd_version_from_file_name(name: &str) -> Result, PlanningStorageError> { - if !name.starts_with("gdd.v") { - return Ok(None); - } - let Some(number) = name - .strip_prefix("gdd.v") - .and_then(|value| value.strip_suffix(".json")) - else { - return Err(PlanningStorageError::new( - "PLAN_INVALID_PATH", - format!("孤儿 GDD 文件名不符合 gdd.vN.json:{name}"), - )); - }; - if number.is_empty() - || number.starts_with('0') - || !number.bytes().all(|byte| byte.is_ascii_digit()) - { - return Err(PlanningStorageError::new( - "PLAN_INVALID_PATH", - format!("孤儿 GDD 文件名版本非法:{name}"), - )); - } - let version = number.parse::().map_err(|_| { - PlanningStorageError::new("PLAN_INVALID_PATH", format!("GDD 文件版本溢出:{name}")) - })?; - if !(1..=PLAN_MAX_VERSIONS).contains(&version) { - return Err(PlanningStorageError::new( - "PLAN_VERSION_LIMIT_REACHED", - format!("GDD 文件版本超出 1..=128:{name}"), - )); - } - Ok(Some(version)) -} - -fn approval_version_from_file_name(name: &str) -> Result, PlanningStorageError> { - if !name.starts_with('v') { - return Ok(None); - } - let Some(number) = name - .strip_prefix('v') - .and_then(|value| value.strip_suffix(".json")) - else { - return Err(PlanningStorageError::new( - "PLAN_INVALID_PATH", - format!("审批 receipt 文件名不符合 vN.json:{name}"), - )); - }; - if number.is_empty() - || number.starts_with('0') - || !number.bytes().all(|byte| byte.is_ascii_digit()) - { - return Err(PlanningStorageError::new( - "PLAN_INVALID_PATH", - format!("审批 receipt 文件名版本非法:{name}"), - )); - } - let version = number.parse::().map_err(|_| { - PlanningStorageError::new( - "PLAN_INVALID_PATH", - format!("审批 receipt 版本溢出:{name}"), - ) - })?; - if !(1..=PLAN_MAX_VERSIONS).contains(&version) { - return Err(PlanningStorageError::new( - "PLAN_VERSION_LIMIT_REACHED", - format!("审批 receipt 版本超出 1..=128:{name}"), - )); - } - Ok(Some(version)) -} - -/// Enumerate only exact `gdd.vN.json` facts and validate the complete -/// continuous lineage. Unrelated planning projections are ignored; a -/// malformed file that claims to be a GDD is rejected instead of guessed. -pub(crate) fn read_plan_gdd_chain(root: &Path) -> Result, PlanningStorageError> { - let _lock = acquire_project_write_lock(root, "planning.read-gdd-chain") - .map_err(|error| io_error("读取 planning GDD 链时取得项目锁失败", error))?; - read_plan_gdd_chain_locked(root) -} - -pub(crate) fn read_plan_gdd_chain_locked( - root: &Path, -) -> Result, PlanningStorageError> { - let planning_root = resolve_planning_path(root, PLAN_STORAGE_ROOT)?; - let metadata = match fs::symlink_metadata(&planning_root) { - Ok(metadata) => metadata, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), - Err(error) => return Err(io_error("读取 planning 根目录失败", error)), - }; - if planning_metadata_is_link_or_reparse(&metadata) || !metadata.is_dir() { - return Err(PlanningStorageError::new( - "PLAN_UNTRUSTED_PATH", - "planning 根路径必须是可信普通目录", - )); - } - let mut versions = Vec::<(u32, PlanGddV1)>::new(); - for entry in - fs::read_dir(&planning_root).map_err(|error| io_error("枚举 planning 根目录失败", error))? - { - let entry = entry.map_err(|error| io_error("读取 planning 目录项失败", error))?; - let file_name = entry.file_name(); - let name = file_name.to_str().ok_or_else(|| { - PlanningStorageError::new( - "PLAN_INVALID_PATH", - "planning 目录包含非 UTF-8 文件名,拒绝静默忽略", - ) - })?; - let Some(version) = gdd_version_from_file_name(name)? else { - continue; - }; - let path = entry.path(); - let bytes = read_regular_planning_file(&path, &format!("GDD v{version}"))?; - let value = parse_plan_gdd_bytes(&bytes)?; - if value.version != version { - return Err(conflict(format!( - "GDD 文件名版本 v{version} 与 payload version={} 不一致", - value.version - ))); - } - versions.push((version, value)); - } - versions.sort_by_key(|(version, _)| *version); - let values = versions - .into_iter() - .map(|(_, value)| value) - .collect::>(); - validate_plan_gdd_chain(&values)?; - Ok(values) -} - -pub(crate) fn read_plan_gdd_approvals_locked( - root: &Path, -) -> Result, PlanningStorageError> { - let approvals_root = resolve_planning_path(root, PLAN_GDD_APPROVAL_DIR)?; - let metadata = match fs::symlink_metadata(&approvals_root) { - Ok(metadata) => metadata, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), - Err(error) => return Err(io_error("读取 approval receipt 目录失败", error)), - }; - if planning_metadata_is_link_or_reparse(&metadata) || !metadata.is_dir() { - return Err(PlanningStorageError::new( - "PLAN_UNTRUSTED_PATH", - "approval receipt 目录必须是可信普通目录", - )); - } - let mut receipts = Vec::<(u32, PlanGddApprovalV1)>::new(); - for entry in fs::read_dir(&approvals_root) - .map_err(|error| io_error("枚举 approval receipt 目录失败", error))? - { - let entry = entry.map_err(|error| io_error("读取 approval receipt 目录项失败", error))?; - let name = entry - .file_name() - .to_str() - .ok_or_else(|| { - PlanningStorageError::new( - "PLAN_INVALID_PATH", - "approval receipt 目录包含非 UTF-8 文件名", - ) - })? - .to_string(); - let Some(version) = approval_version_from_file_name(&name)? else { - continue; - }; - let path = entry.path(); - let bytes = read_regular_planning_file(&path, &format!("GDD approval receipt v{version}"))?; - let value = parse_plan_gdd_approval_bytes(&bytes)?; - if value.version != version { - return Err(conflict(format!( - "approval receipt 文件名版本 v{version} 与 payload version={} 不一致", - value.version - ))); - } - receipts.push((version, value)); - } - receipts.sort_by_key(|(version, _)| *version); - for pair in receipts.windows(2) { - if pair[0].0 == pair[1].0 { - return Err(conflict(format!( - "同一 GDD 版本存在多个 approval receipt:v{}", - pair[0].0 - ))); - } - } - Ok(receipts.into_iter().map(|(_, value)| value).collect()) -} - -pub(crate) fn read_plan_gdd_approval_for_version_locked( - root: &Path, - version: u32, -) -> Result, PlanningStorageError> { - if !(1..=PLAN_MAX_VERSIONS).contains(&version) { - return Err(invalid("approval receipt version 越界")); - } - let relative = format!("{PLAN_GDD_APPROVAL_DIR}/v{version}.json"); - let path = resolve_planning_path(root, &relative)?; - match fs::symlink_metadata(&path) { - Ok(_) => { - let bytes = - read_regular_planning_file(&path, &format!("GDD approval receipt v{version}"))?; - let value = parse_plan_gdd_approval_bytes(&bytes)?; - if value.version != version { - return Err(conflict(format!( - "approval receipt 文件名版本 v{version} 与 payload version={} 不一致", - value.version - ))); - } - Ok(Some(value)) - } - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), - Err(error) => Err(io_error("读取 GDD approval receipt 失败", error)), - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub(crate) enum PlanningCreateOutcome { - Created, - Replayed, -} - -fn validate_planning_relative_path(relative_path: &str) -> Result { - let normalized = normalize_relative_path(relative_path) - .map_err(|error| PlanningStorageError::new("PLAN_INVALID_PATH", error))?; - if !is_agent_planning_storage_path(&normalized) { - return Err(PlanningStorageError::new( - "PLAN_INVALID_PATH", - "规划存储路径必须位于 .agent/planning/**", - )); - } - if normalized != normalized.to_ascii_lowercase() { - return Err(PlanningStorageError::new( - "PLAN_INVALID_PATH", - "planning 路径必须使用固定小写文件名", - )); - } - let allowed = normalized == PLAN_GDD_INDEX_PATH - || normalized == PLAN_SESSION_PATH - || normalized == PLAN_SESSION_PREVIOUS_PATH - || normalized == ".agent/planning/pending.json" - || normalized.starts_with(".agent/planning/gdd.v") - || normalized.starts_with(".agent/planning/approvals/v"); - if !allowed - || (normalized.starts_with(".agent/planning/gdd.v") - && !is_version_file_name(&normalized, ".agent/planning/gdd.v", false)) - || (normalized.starts_with(".agent/planning/approvals/v") - && !is_version_file_name(&normalized, ".agent/planning/approvals/v", true)) - { - return Err(PlanningStorageError::new( - "PLAN_INVALID_PATH", - format!("不允许的 planning 文件名:{normalized}"), - )); - } - Ok(normalized) -} - -fn validate_planning_payload_bytes( - relative_path: &str, - bytes: &[u8], - label: &str, -) -> Result<(), PlanningStorageError> { - if relative_path == PLAN_GDD_INDEX_PATH { - parse_plan_index_bytes(bytes)?; - return Ok(()); - } - if relative_path == PLAN_SESSION_PATH || relative_path == PLAN_SESSION_PREVIOUS_PATH { - parse_plan_session_bytes(bytes)?; - return Ok(()); - } - if relative_path.starts_with(".agent/planning/gdd.v") { - let value = parse_plan_gdd_bytes(bytes)?; - let expected = relative_path - .strip_prefix(".agent/planning/") - .ok_or_else(|| invalid("GDD 路径前缀非法"))?; - let Some(version) = gdd_version_from_file_name(expected)? else { - return Err(invalid("GDD 路径文件名非法")); - }; - if value.version != version { - return Err(conflict(format!( - "{label} 文件名 version={version} 与 payload version={} 不一致", - value.version - ))); - } - return Ok(()); - } - if relative_path == PLAN_GDD_APPROVAL_PENDING_PATH { - parse_plan_gdd_approval_pending_bytes(bytes)?; - return Ok(()); - } - if relative_path.starts_with(".agent/planning/approvals/v") { - let expected = relative_path - .strip_prefix(".agent/planning/approvals/") - .ok_or_else(|| invalid("approval receipt 路径前缀非法"))?; - let Some(version) = approval_version_from_file_name(expected)? else { - return Err(invalid("approval receipt 路径文件名非法")); - }; - let value = parse_plan_gdd_approval_bytes(bytes)?; - if value.version != version { - return Err(conflict(format!( - "{label} 文件名 version={version} 与 payload version={} 不一致", - value.version - ))); - } - return Ok(()); - } - Err(PlanningStorageError::new( - "PLAN_UNSUPPORTED_SCHEMA", - format!("{relative_path} 的 durable schema 未知"), - )) -} - -fn validate_planning_payload_at_root( - root: &Path, - relative_path: &str, - bytes: &[u8], - label: &str, -) -> Result<(), PlanningStorageError> { - validate_planning_payload_bytes(relative_path, bytes, label)?; - if relative_path == PLAN_GDD_INDEX_PATH { - let index = parse_plan_index_bytes(bytes)?; - let gdds = read_plan_gdd_chain_locked(root)?; - let approvals = read_plan_gdd_approvals_locked(root)?; - if approvals.is_empty() && !approval_directory_is_present(root)? { - validate_plan_gdd_index_against_gdds(&index, &gdds)?; - } else { - validate_plan_gdd_index_against_gdds_and_approvals(&index, &gdds, &approvals)?; - } - } - Ok(()) -} - -fn is_version_file_name(path: &str, prefix: &str, approval: bool) -> bool { - let Some(rest) = path.strip_prefix(prefix) else { - return false; - }; - let expected_suffix = ".json"; - let Some(number) = rest.strip_suffix(expected_suffix) else { - return false; - }; - if number.is_empty() || !number.bytes().all(|byte| byte.is_ascii_digit()) { - return false; - } - if number.starts_with('0') { - return false; - } - if number - .parse::() - .ok() - .is_none_or(|value| !(1..=PLAN_MAX_VERSIONS).contains(&value)) - { - return false; - } - if approval { - path.starts_with(".agent/planning/approvals/v") - } else { - path.starts_with(".agent/planning/gdd.v") - } -} - -/// Resolve a planning path and classify a linked path component as untrusted -/// rather than merely invalid. The generic resolver folds "component is a -/// symlink" into the same opaque string as every other path error, so mapping -/// its failure wholesale to `PLAN_INVALID_PATH` misreports an untrusted target; -/// `ensure_planning_parent` and `verify_regular_planning_file` already classify -/// links as `PLAN_UNTRUSTED_PATH`, and this keeps the whole module consistent. -fn resolve_planning_path( - root: &Path, - relative_path: &str, -) -> Result { - let normalized = normalize_relative_path(relative_path) - .map_err(|error| PlanningStorageError::new("PLAN_INVALID_PATH", error))?; - let mut path = root.to_path_buf(); - for part in normalized.split('/') { - path.push(part); - // 只判定链接/重解析点;缺失组件与真实 IO 错误交给通用解析器,保持既有分类。 - if fs::symlink_metadata(&path) - .is_ok_and(|metadata| planning_metadata_is_link_or_reparse(&metadata)) - { - return Err(PlanningStorageError::new( - "PLAN_UNTRUSTED_PATH", - format!("规划路径组件不能是链接或重解析点:{}", path.display()), - )); - } - } - resolve_local_project_path(root, relative_path) - .map_err(|error| PlanningStorageError::new("PLAN_INVALID_PATH", error)) -} - -fn planning_metadata_is_link_or_reparse(metadata: &fs::Metadata) -> bool { - if metadata.file_type().is_symlink() { - return true; - } - #[cfg(windows)] - { - use std::os::windows::fs::MetadataExt; - const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400; - return metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0; - } - #[cfg(not(windows))] - { - false - } -} - -fn ensure_planning_parent(path: &Path) -> Result<&Path, PlanningStorageError> { - let parent = path - .parent() - .ok_or_else(|| PlanningStorageError::new("PLAN_INVALID_PATH", "规划文件缺少父目录"))?; - // Create missing components one at a time. `create_dir_all` can follow a - // directory symlink inserted between its internal component checks; the - // explicit loop lets us reject every component immediately after creation. - let mut missing = Vec::::new(); - let mut cursor = parent.to_path_buf(); - loop { - match fs::symlink_metadata(&cursor) { - Ok(metadata) => { - if planning_metadata_is_link_or_reparse(&metadata) || !metadata.is_dir() { - return Err(PlanningStorageError::new( - "PLAN_UNTRUSTED_PATH", - format!("planning 路径父级不是可信目录:{}", cursor.display()), - )); - } - break; - } - Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - let component = cursor.file_name().ok_or_else(|| { - PlanningStorageError::new("PLAN_INVALID_PATH", "规划父目录组件无效") - })?; - missing.push(component.to_os_string()); - if !cursor.pop() { - return Err(PlanningStorageError::new( - "PLAN_INVALID_PATH", - "规划父目录无法回溯到已存在项目根", - )); - } - } - Err(error) => return Err(io_error("读取 planning 父目录失败", error)), - } - } - while let Some(component) = missing.pop() { - cursor.push(component); - match fs::create_dir(&cursor) { - Ok(()) => {} - Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {} - Err(error) => return Err(io_error("创建 planning 父目录失败", error)), - } - let metadata = fs::symlink_metadata(&cursor) - .map_err(|error| io_error("复核 planning 父目录失败", error))?; - if planning_metadata_is_link_or_reparse(&metadata) || !metadata.is_dir() { - return Err(PlanningStorageError::new( - "PLAN_UNTRUSTED_PATH", - format!("planning 新建父级不是可信目录:{}", cursor.display()), - )); - } - } - Ok(parent) -} - -/// Open the directory that owns a planning target without following a final -/// symlink/reparse point. Publishing relative to this held directory keeps -/// the no-replace operation anchored to the directory we validated while the -/// project lock is held. -#[cfg(unix)] -fn open_planning_parent_directory(parent: &Path) -> Result { - use std::os::unix::fs::OpenOptionsExt; - OpenOptions::new() - .read(true) - .custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC) - .open(parent) - .map_err(|error| io_error("打开 planning 父目录失败", error)) -} - -#[cfg(windows)] -fn open_planning_parent_directory(parent: &Path) -> Result { - use std::os::windows::fs::OpenOptionsExt; - let directory = OpenOptions::new() - .read(true) - // FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_BACKUP_SEMANTICS; the - // latter is required for opening a directory handle on Windows. - .custom_flags(0x0020_0000 | 0x0200_0000) - .open(parent) - .map_err(|error| io_error("打开 planning 父目录失败", error))?; - let metadata = directory - .metadata() - .map_err(|error| io_error("读取 planning 父目录句柄失败", error))?; - if planning_metadata_is_link_or_reparse(&metadata) || !metadata.is_dir() { - return Err(PlanningStorageError::new( - "PLAN_UNTRUSTED_PATH", - format!("planning 父目录句柄不是可信普通目录:{}", parent.display()), - )); - } - Ok(directory) -} - -#[cfg(not(any(unix, windows)))] -fn open_planning_parent_directory(parent: &Path) -> Result { - File::open(parent).map_err(|error| io_error("打开 planning 父目录失败", error)) -} - -fn verify_regular_planning_file( - path: &Path, - label: &str, -) -> Result { - let metadata = fs::symlink_metadata(path) - .map_err(|error| io_error(&format!("读取 {label} 元数据失败"), error))?; - if planning_metadata_is_link_or_reparse(&metadata) || !metadata.is_file() { - return Err(PlanningStorageError::new( - "PLAN_UNTRUSTED_PATH", - format!("{label} 必须是可信普通文件:{}", path.display()), - )); - } - #[cfg(unix)] - { - use std::os::unix::fs::MetadataExt; - if metadata.nlink() != 1 { - return Err(PlanningStorageError::new( - "PLAN_UNTRUSTED_PATH", - format!("{label} 不能是硬链接文件:{}", path.display()), - )); - } - } - #[cfg(windows)] - { - use std::os::windows::fs::OpenOptionsExt; - const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; - let file = OpenOptions::new() - .read(true) - .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT) - .open(path) - .map_err(|error| io_error(&format!("打开 {label} 句柄失败"), error))?; - crate::runner::validate_windows_regular_file_handle(&file, label) - .map_err(|error| PlanningStorageError::new("PLAN_UNTRUSTED_PATH", error))?; - } - Ok(metadata) -} - -fn read_regular_planning_file(path: &Path, label: &str) -> Result, PlanningStorageError> { - let metadata = verify_regular_planning_file(path, label)?; - if metadata.len() > PLAN_INDEX_MAX_BYTES as u64 { - return Err(PlanningStorageError::new( - "PLAN_SIZE_LIMIT", - format!("{label} 超过 {} 字节读取上限", PLAN_INDEX_MAX_BYTES), - )); - } - let mut options = OpenOptions::new(); - options.read(true); - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt; - options.custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC); - } - #[cfg(windows)] - { - use std::os::windows::fs::OpenOptionsExt; - const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; - // The final open must inspect the directory entry itself. Without - // OPEN_REPARSE_POINT a junction/symlink can be followed before the - // handle validator sees the target's (apparently regular) attributes. - options.custom_flags(FILE_FLAG_OPEN_REPARSE_POINT); - } - let mut file = options - .open(path) - .map_err(|error| io_error(&format!("打开 {label} 失败"), error))?; - #[cfg(unix)] - { - use std::os::unix::fs::MetadataExt; - let opened_metadata = file - .metadata() - .map_err(|error| io_error(&format!("复核 {label} 句柄元数据失败"), error))?; - if opened_metadata.dev() != metadata.dev() - || opened_metadata.ino() != metadata.ino() - || opened_metadata.nlink() != 1 - { - return Err(PlanningStorageError::new( - "PLAN_RECONCILIATION_REQUIRED", - format!("打开 {label} 时文件身份发生漂移"), - )); - } - } - #[cfg(windows)] - crate::runner::validate_windows_regular_file_handle(&file, label) - .map_err(|error| PlanningStorageError::new("PLAN_UNTRUSTED_PATH", error))?; - let mut bytes = Vec::with_capacity(metadata.len().min(PLAN_INDEX_MAX_BYTES as u64) as usize); - (&mut file) - .take((PLAN_INDEX_MAX_BYTES as u64).saturating_add(1)) - .read_to_end(&mut bytes) - .map_err(|error| io_error(&format!("读取 {label} 失败"), error))?; - if bytes.len() > PLAN_INDEX_MAX_BYTES { - return Err(PlanningStorageError::new( - "PLAN_SIZE_LIMIT", - format!("{label} 超过 {} 字节读取上限", PLAN_INDEX_MAX_BYTES), - )); - } - let final_metadata = file - .metadata() - .map_err(|error| io_error(&format!("复核 {label} 元数据失败"), error))?; - if final_metadata.len() != bytes.len() as u64 || final_metadata.len() != metadata.len() { - return Err(PlanningStorageError::new( - "PLAN_RECONCILIATION_REQUIRED", - format!("读取 {label} 时文件发生漂移"), - )); - } - Ok(bytes) -} - -fn sync_planning_parent(parent: &Path) -> Result<(), PlanningStorageError> { - #[cfg(unix)] - File::open(parent) - .and_then(|directory| directory.sync_all()) - .map_err(|error| io_error("同步 planning 父目录失败", error))?; - #[cfg(windows)] - { - let directory = open_planning_parent_directory(parent)?; - if let Err(error) = directory.sync_all() { - // MoveFileExW(MOVEFILE_WRITE_THROUGH) already flushes the file - // publication on Windows. Some Windows filesystems reject - // FlushFileBuffers on a directory handle with ACCESS_DENIED or - // INVALID_FUNCTION; keep the stronger native flush as the - // fallback rather than making every durable write unusable there. - if !matches!(error.raw_os_error(), Some(1 | 5 | 6)) { - return Err(io_error("同步 planning 父目录失败", error)); - } - } - } - #[cfg(not(any(unix, windows)))] - let _ = parent; - Ok(()) -} - -fn temp_planning_path(parent: &Path, target: &Path) -> PathBuf { - let nonce = PLANNING_TEMP_NONCE.fetch_add(1, Ordering::Relaxed); - let random = Uuid::new_v4(); - let file_name = target - .file_name() - .and_then(|name| name.to_str()) - .unwrap_or("planning.json"); - parent.join(format!( - ".{file_name}.tmp-{}-{nonce}-{random}", - std::process::id() - )) -} - -fn write_sync_new_file(path: &Path, bytes: &[u8], label: &str) -> Result<(), PlanningStorageError> { - if bytes.len() > PLAN_INDEX_MAX_BYTES { - return Err(PlanningStorageError::new( - "PLAN_SIZE_LIMIT", - format!("{label} 临时内容超过 {} 字节上限", PLAN_INDEX_MAX_BYTES), - )); - } - let result = (|| { - let mut options = OpenOptions::new(); - options.read(true).write(true).create_new(true); - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt; - options - .mode(0o600) - .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC); - } - let mut file = options - .open(path) - .map_err(|error| io_error(&format!("创建 {label} 临时文件失败"), error))?; - #[cfg(windows)] - crate::runner::validate_windows_regular_file_handle(&file, label) - .map_err(|error| PlanningStorageError::new("PLAN_UNTRUSTED_PATH", error))?; - file.write_all(bytes) - .and_then(|_| file.sync_all()) - .map_err(|error| io_error(&format!("写入 {label} 临时文件失败"), error))?; - #[cfg(unix)] - { - use std::os::unix::fs::MetadataExt; - let metadata = file - .metadata() - .map_err(|error| io_error(&format!("复核 {label} 临时文件句柄失败"), error))?; - if !metadata.is_file() || metadata.nlink() != 1 { - return Err(PlanningStorageError::new( - "PLAN_UNTRUSTED_PATH", - format!("{label} 临时文件句柄不是唯一普通文件"), - )); - } - } - file.seek(SeekFrom::Start(0)) - .map_err(|error| io_error(&format!("定位 {label} 临时文件回读位置失败"), error))?; - let mut check = Vec::new(); - file.read_to_end(&mut check) - .map_err(|error| io_error(&format!("回读 {label} 临时文件失败"), error))?; - if check != bytes { - return Err(PlanningStorageError::new( - "PLAN_RECONCILIATION_REQUIRED", - format!("{label} 临时文件回读不一致"), - )); - } - Ok(()) - })(); - if result.is_err() { - let _ = fs::remove_file(path); - } - result -} - -#[cfg(unix)] -fn planning_component(path: &Path, label: &str) -> Result { - use std::ffi::CString; - let component = path - .file_name() - .and_then(|name| name.to_str()) - .ok_or_else(|| { - PlanningStorageError::new("PLAN_INVALID_PATH", format!("{label} 文件名无效")) - })?; - CString::new(component.as_bytes()).map_err(|_| { - PlanningStorageError::new("PLAN_INVALID_PATH", format!("{label} 文件名包含 NUL")) - }) -} - -/// Install a prepared file under a target name without replacing an existing -/// directory entry. Native no-replace rename is preferred; a hard-link -/// fallback is used only when the platform explicitly reports that the native -/// primitive is unavailable. -#[allow(unreachable_code)] -#[allow(unused_variables)] -fn publish_planning_noreplace( - parent: &Path, - temporary: &Path, - target: &Path, - label: &str, -) -> Result<(), std::io::Error> { - #[cfg(target_os = "linux")] - { - use std::os::unix::io::AsRawFd; - let directory = open_planning_parent_directory(parent) - .map_err(|error| std::io::Error::other(error.to_string()))?; - let source = planning_component(temporary, label) - .map_err(|error| std::io::Error::other(error.to_string()))?; - let destination = planning_component(target, label) - .map_err(|error| std::io::Error::other(error.to_string()))?; - // SAFETY: both names are validated single components relative to the - // held, no-follow directory descriptor. - let result = unsafe { - libc::renameat2( - directory.as_raw_fd(), - source.as_ptr(), - directory.as_raw_fd(), - destination.as_ptr(), - libc::RENAME_NOREPLACE, - ) - }; - if result == 0 { - return Ok(()); - } - let error = std::io::Error::last_os_error(); - if !matches!( - error.raw_os_error(), - Some(libc::ENOSYS | libc::EINVAL | libc::ENOTSUP | libc::EOPNOTSUPP) - ) { - return Err(error); - } - } - #[cfg(target_vendor = "apple")] - { - use std::os::unix::io::AsRawFd; - let directory = open_planning_parent_directory(parent) - .map_err(|error| std::io::Error::other(error.to_string()))?; - let source = planning_component(temporary, label) - .map_err(|error| std::io::Error::other(error.to_string()))?; - let destination = planning_component(target, label) - .map_err(|error| std::io::Error::other(error.to_string()))?; - // SAFETY: both names are validated single components relative to the - // held, no-follow directory descriptor. - let result = unsafe { - libc::renameatx_np( - directory.as_raw_fd(), - source.as_ptr(), - directory.as_raw_fd(), - destination.as_ptr(), - libc::RENAME_EXCL, - ) - }; - if result == 0 { - return Ok(()); - } - let error = std::io::Error::last_os_error(); - if !matches!( - error.raw_os_error(), - Some(libc::ENOSYS | libc::EINVAL | libc::ENOTSUP | libc::EOPNOTSUPP) - ) { - return Err(error); - } - } - #[cfg(windows)] - { - use std::os::windows::ffi::OsStrExt; - use windows_sys::Win32::Storage::FileSystem::{MoveFileExW, MOVEFILE_WRITE_THROUGH}; - let from = temporary - .as_os_str() - .encode_wide() - .chain(std::iter::once(0)) - .collect::>(); - let to = target - .as_os_str() - .encode_wide() - .chain(std::iter::once(0)) - .collect::>(); - // Omitting MOVEFILE_REPLACE_EXISTING is the Windows no-replace mode. - let result = unsafe { MoveFileExW(from.as_ptr(), to.as_ptr(), MOVEFILE_WRITE_THROUGH) }; - if result != 0 { - return Ok(()); - } - return Err(std::io::Error::last_os_error()); - } - // Filesystems/platforms without a native no-replace rename get the - // documented, create-only hard-link fallback. The caller removes the - // temporary link before validating the published inode. - #[cfg(not(windows))] - { - fs::hard_link(temporary, target) - } - #[cfg(windows)] - { - unreachable!("Windows uses MoveFileExW no-replace above") - } -} - -fn verify_planning_hardlink_publish_identity( - temporary: &Path, - target: &Path, - label: &str, -) -> Result<(), PlanningStorageError> { - let temporary_metadata = fs::symlink_metadata(temporary) - .map_err(|error| io_error(&format!("读取 {label} fallback 临时文件身份失败"), error))?; - let target_metadata = fs::symlink_metadata(target) - .map_err(|error| io_error(&format!("读取 {label} fallback 目标身份失败"), error))?; - if planning_metadata_is_link_or_reparse(&temporary_metadata) - || planning_metadata_is_link_or_reparse(&target_metadata) - || !temporary_metadata.is_file() - || !target_metadata.is_file() - { - return Err(PlanningStorageError::new( - "PLAN_UNTRUSTED_PATH", - format!("{label} fallback 发布身份不是可信普通文件"), - )); - } - #[cfg(unix)] - { - use std::os::unix::fs::MetadataExt; - if temporary_metadata.dev() != target_metadata.dev() - || temporary_metadata.ino() != target_metadata.ino() - || temporary_metadata.nlink() < 2 - { - return Err(PlanningStorageError::new( - "PLAN_RECONCILIATION_REQUIRED", - format!("{label} fallback 发布前 temp/target 文件身份不一致"), - )); - } - Ok(()) - } - #[cfg(not(unix))] - { - Err(PlanningStorageError::new( - "PLAN_UNSUPPORTED_PLATFORM", - format!("{label} fallback 无法证明 temp/target 文件身份"), - )) - } -} - -pub(crate) fn durable_create_json_no_replace_locked( - root: &Path, - relative_path: &str, - bytes: &[u8], - label: &str, -) -> Result { - let relative_path = validate_planning_relative_path(relative_path)?; - if matches!( - relative_path.as_str(), - PLAN_GDD_INDEX_PATH | PLAN_SESSION_PATH | PLAN_SESSION_PREVIOUS_PATH - ) { - return Err(PlanningStorageError::new( - "PLAN_DEDICATED_WRITER_REQUIRED", - format!("{relative_path} 只能由对应的原子/CAS writer 写入"), - )); - } - reject_noncanonical_storage_bytes(bytes, label)?; - validate_planning_payload_at_root(root, &relative_path, bytes, label)?; - let target = resolve_planning_path(root, &relative_path)?; - let parent = ensure_planning_parent(&target)?; - if relative_path.starts_with(".agent/planning/gdd.v") { - // Validate the complete on-disk lineage before even considering a - // same-byte replay; an orphaned vN must not become authoritative just - // because its individual payload is well formed. - read_plan_gdd_chain_locked(root)?; - } - - match fs::symlink_metadata(&target) { - Ok(_) => { - let existing = read_regular_planning_file(&target, label)?; - validate_planning_payload_at_root(root, &relative_path, &existing, label)?; - if existing == bytes { - return Ok(PlanningCreateOutcome::Replayed); - } - return Err(conflict(format!( - "{label} 已存在且 canonical bytes 不同:{}", - target.display() - ))); - } - Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} - Err(error) => return Err(io_error(&format!("读取 {label} 目标失败"), error)), - } - - if relative_path.starts_with(".agent/planning/gdd.v") { - let candidate = parse_plan_gdd_bytes(bytes)?; - let existing = read_plan_gdd_chain_locked(root)?; - validate_next_plan_gdd_version(&existing, &candidate)?; - } - - let temporary = temp_planning_path(parent, &target); - write_sync_new_file(&temporary, bytes, label)?; - let publish_result = publish_planning_noreplace(parent, &temporary, &target, label); - let outcome = match publish_result { - Ok(()) => { - // Native rename consumes the temporary name. The hard-link - // fallback leaves two names for the same inode, so unlink the - // temporary name before regular-file identity validation (nlink - // must be exactly one for an authoritative planning fact). - match fs::symlink_metadata(&temporary) { - Ok(_) => { - verify_planning_hardlink_publish_identity(&temporary, &target, label)?; - fs::remove_file(&temporary) - .map_err(|error| io_error(&format!("清理 {label} 临时文件失败"), error))?; - } - Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} - Err(error) => return Err(io_error(&format!("读取 {label} 临时文件失败"), error)), - } - let published = read_regular_planning_file(&target, label)?; - validate_planning_payload_at_root(root, &relative_path, &published, label)?; - if published != bytes { - return Err(PlanningStorageError::new( - "PLAN_RECONCILIATION_REQUIRED", - format!("{label} 发布后回读不一致"), - )); - } - PlanningCreateOutcome::Created - } - Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { - let existing = read_regular_planning_file(&target, label)?; - validate_planning_payload_at_root(root, &relative_path, &existing, label)?; - let outcome = if existing == bytes { - PlanningCreateOutcome::Replayed - } else { - return Err(conflict(format!( - "{label} 并发发布产生 identity conflict:{}", - target.display() - ))); - }; - fs::remove_file(&temporary) - .map_err(|cleanup| io_error(&format!("清理 {label} 临时文件失败"), cleanup))?; - outcome - } - Err(error) => { - let _ = fs::remove_file(&temporary); - return Err(io_error(&format!("发布 {label} 失败"), error)); - } - }; - sync_planning_parent(parent).map_err(|error| { - // The target may already have been atomically published when the - // directory flush fails. This is an unknown commit-point result, - // not a normal rejection: callers must reconcile the target before - // claiming committed/replayed semantics. - PlanningStorageError::new( - "PLAN_COMMIT_UNKNOWN", - format!("{label} 已发布但父目录同步结果未知:{error}"), - ) - })?; - Ok(outcome) -} - -pub(crate) fn read_plan_gdd_approval_pending_locked( - root: &Path, -) -> Result, PlanningStorageError> { - let path = resolve_planning_path(root, PLAN_GDD_APPROVAL_PENDING_PATH)?; - match fs::symlink_metadata(&path) { - Ok(_) => { - let bytes = read_regular_planning_file(&path, "GDD approval pending")?; - Ok(Some(parse_plan_gdd_approval_pending_bytes(&bytes)?)) - } - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), - Err(error) => Err(io_error("读取 GDD approval pending 失败", error)), - } -} - -pub(crate) fn write_plan_gdd_approval_pending_atomic_locked( - root: &Path, - value: &PlanGddApprovalPendingV1, -) -> Result<(), PlanningStorageError> { - let bytes = canonical_plan_gdd_approval_pending_bytes(value)?; - let target = resolve_planning_path(root, PLAN_GDD_APPROVAL_PENDING_PATH)?; - let parent = ensure_planning_parent(&target)?; - if let Ok(_) = fs::symlink_metadata(&target) { - verify_regular_planning_file(&target, "现有 GDD approval pending")?; - let existing = read_regular_planning_file(&target, "现有 GDD approval pending")?; - parse_plan_gdd_approval_pending_bytes(&existing)?; - if existing == bytes { - return Ok(()); - } - } - let temporary = temp_planning_path(parent, &target); - let result = (|| { - write_sync_new_file(&temporary, &bytes, "GDD approval pending")?; - verify_replace_target_is_safe(&target, "GDD approval pending")?; - replace_planning_file_atomically(&temporary, &target, "GDD approval pending")?; - let published = read_regular_planning_file(&target, "已发布 GDD approval pending")?; - if published != bytes { - return Err(PlanningStorageError::new( - "PLAN_RECONCILIATION_REQUIRED", - "GDD approval pending 发布后 canonical bytes 不一致", - )); - } - parse_plan_gdd_approval_pending_bytes(&published)?; - sync_planning_parent(parent) - })(); - let _ = fs::remove_file(&temporary); - result -} - -pub(crate) fn remove_plan_gdd_approval_pending_locked( - root: &Path, -) -> Result<(), PlanningStorageError> { - let path = resolve_planning_path(root, PLAN_GDD_APPROVAL_PENDING_PATH)?; - match fs::symlink_metadata(&path) { - Ok(_) => { - verify_regular_planning_file(&path, "GDD approval pending")?; - let bytes = read_regular_planning_file(&path, "GDD approval pending")?; - parse_plan_gdd_approval_pending_bytes(&bytes)?; - fs::remove_file(&path) - .map_err(|error| io_error("清理 GDD approval pending 失败", error))?; - if let Some(parent) = path.parent() { - sync_planning_parent(parent)?; - } - } - Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} - Err(error) => return Err(io_error("读取 GDD approval pending 目标失败", error)), - } - Ok(()) -} - -/// Rebuild and atomically replace the derived GDD index. The index is not an -/// immutable fact: a new GDD version must replace it, while a corrupt or -/// missing index can always be rebuilt from the authoritative GDD chain. -pub(crate) fn write_plan_gdd_index_atomic_locked( - root: &Path, - value: &PlanGddIndexV1, -) -> Result<(), PlanningStorageError> { - let bytes = canonical_plan_index_bytes(value)?; - let gdds = read_plan_gdd_chain_locked(root)?; - let approvals = read_plan_gdd_approvals_locked(root)?; - if approvals.is_empty() && !approval_directory_is_present(root)? { - validate_plan_gdd_index_against_gdds(value, &gdds)?; - } else { - validate_plan_gdd_index_against_gdds_and_approvals(value, &gdds, &approvals)?; - } - let target = resolve_planning_path(root, PLAN_GDD_INDEX_PATH)?; - let parent = ensure_planning_parent(&target)?; - match fs::symlink_metadata(&target) { - Ok(_) => { - verify_regular_planning_file(&target, "现有 GDD index")?; - } - Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} - Err(error) => return Err(io_error("读取现有 GDD index 失败", error)), - } - - let temporary = temp_planning_path(parent, &target); - let result = (|| { - write_sync_new_file(&temporary, &bytes, "GDD index")?; - verify_replace_target_is_safe(&target, "GDD index")?; - replace_planning_file_atomically(&temporary, &target, "GDD index")?; - let published = read_regular_planning_file(&target, "已发布 GDD index")?; - let parsed = parse_plan_index_bytes(&published)?; - let current_gdds = read_plan_gdd_chain_locked(root)?; - let current_approvals = read_plan_gdd_approvals_locked(root)?; - if current_approvals.is_empty() && !approval_directory_is_present(root)? { - validate_plan_gdd_index_against_gdds(&parsed, ¤t_gdds)?; - } else { - validate_plan_gdd_index_against_gdds_and_approvals( - &parsed, - ¤t_gdds, - ¤t_approvals, - )?; - } - if published != bytes { - return Err(PlanningStorageError::new( - "PLAN_RECONCILIATION_REQUIRED", - "GDD index 发布后 canonical bytes 不一致", - )); - } - sync_planning_parent(parent) - })(); - if temporary.exists() { - let cleanup = fs::remove_file(&temporary) - .map_err(|error| io_error("清理 GDD index 临时文件失败", error)); - if result.is_ok() { - cleanup?; - } - } - result -} - -pub(crate) fn write_plan_fast_gdd_markdown_atomic_locked( - root: &Path, - markdown: &str, -) -> Result<(), PlanningStorageError> { - let bytes = markdown.as_bytes(); - if bytes.is_empty() { - return Err(invalid("Fast GDD Markdown 不能为空")); - } - if bytes.len() > PLAN_FAST_GDD_MAX_BYTES { - return Err(PlanningStorageError::new( - "PLAN_SIZE_LIMIT", - format!( - "Fast GDD Markdown 超过 {} 字节上限", - PLAN_FAST_GDD_MAX_BYTES - ), - )); - } - if bytes.contains(&0) || !std::str::from_utf8(bytes).is_ok() { - return Err(invalid("Fast GDD Markdown 必须是无 NUL 的 UTF-8 文本")); - } - - let target = resolve_planning_path(root, PLAN_FAST_GDD_PATH)?; - let parent = target - .parent() - .ok_or_else(|| PlanningStorageError::new("PLAN_INVALID_PATH", "Fast GDD 缺少父目录"))?; - - // The projection lives outside `.agent/planning`, so it cannot use the - // planning-only parent helper. Build the relative `game/` directory one - // component at a time and reject links/reparse points at every step. - let root_metadata = - fs::symlink_metadata(root).map_err(|error| io_error("读取项目根目录失败", error))?; - if planning_metadata_is_link_or_reparse(&root_metadata) || !root_metadata.is_dir() { - return Err(PlanningStorageError::new( - "PLAN_UNTRUSTED_PATH", - "项目根目录必须是可信普通目录", - )); - } - let mut cursor = root.to_path_buf(); - let relative_parent = parent - .strip_prefix(root) - .map_err(|_| PlanningStorageError::new("PLAN_INVALID_PATH", "Fast GDD 父目录越出项目根"))?; - for component in relative_parent.components() { - use std::path::Component; - let Component::Normal(component) = component else { - return Err(PlanningStorageError::new( - "PLAN_INVALID_PATH", - "Fast GDD 父目录组件非法", - )); - }; - cursor.push(component); - match fs::symlink_metadata(&cursor) { - Ok(metadata) => { - if planning_metadata_is_link_or_reparse(&metadata) || !metadata.is_dir() { - return Err(PlanningStorageError::new( - "PLAN_UNTRUSTED_PATH", - "Fast GDD 父目录必须是可信普通目录", - )); - } - } - Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - fs::create_dir(&cursor) - .map_err(|error| io_error("创建 Fast GDD 父目录失败", error))?; - let metadata = fs::symlink_metadata(&cursor) - .map_err(|error| io_error("复核 Fast GDD 父目录失败", error))?; - if planning_metadata_is_link_or_reparse(&metadata) || !metadata.is_dir() { - return Err(PlanningStorageError::new( - "PLAN_UNTRUSTED_PATH", - "新建 Fast GDD 父目录不是可信普通目录", - )); - } - } - Err(error) => return Err(io_error("读取 Fast GDD 父目录失败", error)), - } - } - - match fs::symlink_metadata(&target) { - Ok(_) => { - verify_regular_planning_file(&target, "现有 Fast GDD Markdown")?; - } - Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} - Err(error) => return Err(io_error("读取现有 Fast GDD Markdown 失败", error)), - } - - let temporary = temp_planning_path(parent, &target); - let result = (|| { - write_sync_new_file(&temporary, bytes, "Fast GDD Markdown")?; - verify_replace_target_is_safe(&target, "Fast GDD Markdown")?; - replace_planning_file_atomically(&temporary, &target, "Fast GDD Markdown")?; - let published = read_regular_planning_file(&target, "已发布 Fast GDD Markdown")?; - if published != bytes { - return Err(PlanningStorageError::new( - "PLAN_RECONCILIATION_REQUIRED", - "Fast GDD Markdown 发布后内容不一致", - )); - } - sync_planning_parent(parent) - })(); - if temporary.exists() { - let cleanup = fs::remove_file(&temporary) - .map_err(|error| io_error("清理 Fast GDD 临时文件失败", error)); - if result.is_ok() { - cleanup?; - } - } - result -} - -fn replace_planning_file_atomically( - temporary: &Path, - target: &Path, - label: &str, -) -> Result<(), PlanningStorageError> { - #[cfg(not(windows))] - { - fs::rename(temporary, target) - .map_err(|error| io_error(&format!("原子替换 {label} 失败"), error))?; - } - #[cfg(windows)] - { - use std::os::windows::ffi::OsStrExt; - use windows_sys::Win32::Storage::FileSystem::{ - MoveFileExW, MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, - }; - let from = temporary - .as_os_str() - .encode_wide() - .chain(std::iter::once(0)) - .collect::>(); - let to = target - .as_os_str() - .encode_wide() - .chain(std::iter::once(0)) - .collect::>(); - let result = unsafe { - MoveFileExW( - from.as_ptr(), - to.as_ptr(), - MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH, - ) - }; - if result == 0 { - return Err(io_error( - &format!("原子替换 {label} 失败"), - std::io::Error::last_os_error(), - )); - } - } - Ok(()) -} - -fn verify_replace_target_is_safe(target: &Path, label: &str) -> Result<(), PlanningStorageError> { - match fs::symlink_metadata(target) { - Ok(_) => { - verify_regular_planning_file(target, &format!("现有 {label}"))?; - Ok(()) - } - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), - Err(error) => Err(io_error(&format!("读取现有 {label} 目标失败"), error)), - } -} - -pub(crate) fn write_plan_session_atomic_locked( - root: &Path, - value: &PlanSessionV1, -) -> Result<(), PlanningStorageError> { - let bytes = canonical_plan_session_bytes(value)?; - validate_plan_session_latest_delegation_lineage_at(root, value)?; - let target = resolve_planning_path(root, PLAN_SESSION_PATH)?; - let previous = resolve_planning_path(root, PLAN_SESSION_PREVIOUS_PATH)?; - let parent = ensure_planning_parent(&target)?; - let target_state = match fs::symlink_metadata(&target) { - Ok(_) => { - verify_regular_planning_file(&target, "现有 plan session")?; - let old_bytes = read_regular_planning_file(&target, "现有 plan session")?; - let old = parse_plan_session_bytes(&old_bytes)?; - validate_plan_session_latest_delegation_lineage_at(root, &old)?; - Some((old, old_bytes)) - } - Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, - Err(error) => return Err(io_error("读取现有 plan session 失败", error)), - }; - let previous_state = match fs::symlink_metadata(&previous) { - Ok(_) => { - verify_regular_planning_file(&previous, "现有 plan session previous")?; - let old_bytes = read_regular_planning_file(&previous, "现有 plan session previous")?; - let old = parse_plan_session_bytes(&old_bytes)?; - validate_plan_session_latest_delegation_lineage_at(root, &old)?; - Some(old) - } - Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, - Err(error) => return Err(io_error("读取现有 plan session previous 失败", error)), - }; - - let same_current = target_state - .as_ref() - .is_some_and(|(_, current_bytes)| current_bytes == &bytes); - if let Some((current, _)) = target_state.as_ref() { - if !same_current { - validate_plan_session_successor(current, value)?; - } - } else if let Some(previous_value) = previous_state.as_ref() { - if previous_value != value { - validate_plan_session_successor(previous_value, value)?; - } - } else if value.session_revision != 1 || value.previous_fingerprint.is_some() { - return Err(conflict( - "首个 plan session 必须是 revision=1 且 previousFingerprint=null", - )); - } - if let (Some((current, _)), Some(previous_value)) = - (target_state.as_ref(), previous_state.as_ref()) - { - if current != previous_value { - validate_plan_session_successor(previous_value, current)?; - } - } - if same_current { - return Ok(()); - } - - let temporary = temp_planning_path(parent, &target); - let previous_temp = temp_planning_path(parent, &previous); - let result = (|| { - write_sync_new_file(&temporary, &bytes, "plan session")?; - - if let Some((_, old_bytes)) = target_state.as_ref() { - // The recovery copy is written only after the successor is durable - // in a sibling temp file. A crash therefore leaves old primary or - // a valid previous copy, never a half-written JSON document. - write_sync_new_file(&previous_temp, old_bytes, "plan session previous")?; - replace_planning_file_atomically(&previous_temp, &previous, "plan session previous")?; - sync_planning_parent(parent)?; - } - verify_replace_target_is_safe(&target, "plan session")?; - replace_planning_file_atomically(&temporary, &target, "plan session")?; - let published = read_regular_planning_file(&target, "已发布 plan session")?; - if published != bytes { - return Err(PlanningStorageError::new( - "PLAN_RECONCILIATION_REQUIRED", - "plan session 发布后 canonical bytes 不一致", - )); - } - let published = parse_plan_session_bytes(&published)?; - validate_plan_session_latest_delegation_lineage_at(root, &published)?; - sync_planning_parent(parent) - })(); - let _ = fs::remove_file(&temporary); - let _ = fs::remove_file(&previous_temp); - result -} - -/// Read the session primary and its single recovery copy according to the -/// revision/hash-chain rules in §10.2. A corrupt primary is never silently -/// replaced by a valid previous copy. -fn read_optional_plan_session_file( - root: &Path, - path: &Path, - label: &str, -) -> Result, PlanningStorageError> { - match fs::symlink_metadata(path) { - Ok(_) => { - let bytes = read_regular_planning_file(path, label)?; - let value = parse_plan_session_bytes(&bytes)?; - validate_plan_session_latest_delegation_lineage_at(root, &value)?; - Ok(Some(value)) - } - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), - Err(error) => Err(io_error(&format!("读取 {label} 失败"), error)), - } -} - -pub(crate) fn read_plan_session_with_recovery( - root: &Path, -) -> Result, PlanningStorageError> { - let primary_path = resolve_planning_path(root, PLAN_SESSION_PATH)?; - let previous_path = resolve_planning_path(root, PLAN_SESSION_PREVIOUS_PATH)?; - let primary_exists = fs::symlink_metadata(&primary_path) - .map(|_| true) - .or_else(|error| { - if error.kind() == std::io::ErrorKind::NotFound { - Ok(false) - } else { - Err(error) - } - }) - .map_err(|error| io_error("探测 plan session primary 失败", error))?; - let previous_exists = fs::symlink_metadata(&previous_path) - .map(|_| true) - .or_else(|error| { - if error.kind() == std::io::ErrorKind::NotFound { - Ok(false) - } else { - Err(error) - } - }) - .map_err(|error| io_error("探测 plan session previous 失败", error))?; - if !primary_exists && !previous_exists { - return Ok(None); - } - let _lock = acquire_project_write_lock(root, "planning.session.read") - .map_err(|error| io_error("读取 plan session 时取得项目锁失败", error))?; - read_plan_session_with_recovery_locked(root) -} - -pub(crate) fn read_plan_session_with_recovery_locked( - root: &Path, -) -> Result, PlanningStorageError> { - let primary_path = resolve_planning_path(root, PLAN_SESSION_PATH)?; - let previous_path = resolve_planning_path(root, PLAN_SESSION_PREVIOUS_PATH)?; - let primary_state = - read_optional_plan_session_file(root, &primary_path, "plan session primary")?; - let previous_state = - read_optional_plan_session_file(root, &previous_path, "plan session previous")?; - match (primary_state, previous_state) { - (None, None) => Ok(None), - (Some(primary), None) => Ok(Some(primary)), - (None, Some(previous)) => { - let primary_path = resolve_planning_path(root, PLAN_SESSION_PATH)?; - let previous_path = resolve_planning_path(root, PLAN_SESSION_PREVIOUS_PATH)?; - // Re-read while holding the project lock. A writer may have - // published a new primary between the optimistic read and lock - // acquisition; never overwrite that newer fact. - if let Some(current_primary) = - read_optional_plan_session_file(root, &primary_path, "锁内 plan session primary")? - { - let current_previous = read_optional_plan_session_file( - root, - &previous_path, - "锁内 plan session previous", - )?; - return match current_previous { - Some(current_previous) => { - if current_primary != current_previous { - validate_plan_session_successor(¤t_previous, ¤t_primary)?; - } - Ok(Some(current_primary)) - } - None => Ok(Some(current_primary)), - }; - } - let current_previous = read_optional_plan_session_file( - root, - &previous_path, - "锁内 plan session previous", - )? - .ok_or_else(|| { - PlanningStorageError::new( - "PLAN_RECONCILIATION_REQUIRED", - "提升 session previous 时 recovery 文件已消失", - ) - })?; - if current_previous != previous { - return Err(conflict( - "提升 session previous 前 recovery 文件发生身份漂移", - )); - } - verify_replace_target_is_safe(&primary_path, "plan session previous 提升")?; - replace_planning_file_atomically( - &previous_path, - &primary_path, - "plan session previous 提升", - )?; - sync_planning_parent(primary_path.parent().expect("session has parent"))?; - let promoted = read_optional_plan_session_file( - root, - &primary_path, - "提升后的 plan session primary", - )? - .ok_or_else(|| { - PlanningStorageError::new( - "PLAN_RECONCILIATION_REQUIRED", - "plan session previous 提升后 primary 缺失", - ) - })?; - if promoted != previous { - return Err(PlanningStorageError::new( - "PLAN_RECONCILIATION_REQUIRED", - "plan session previous 提升后内容不一致", - )); - } - Ok(Some(promoted)) - } - (Some(primary_value), Some(previous_value)) => { - if primary_value == previous_value { - let current_primary = read_optional_plan_session_file( - root, - &primary_path, - "锁内 plan session primary", - )? - .ok_or_else(|| { - PlanningStorageError::new( - "PLAN_RECONCILIATION_REQUIRED", - "清理 session previous 时 primary 缺失", - ) - })?; - let current_previous = read_optional_plan_session_file( - root, - &previous_path, - "锁内 plan session previous", - )? - .ok_or_else(|| { - PlanningStorageError::new( - "PLAN_RECONCILIATION_REQUIRED", - "清理 session previous 时 recovery 文件缺失", - ) - })?; - if current_primary != primary_value || current_previous != previous_value { - return Err(conflict("清理 session previous 前文件发生身份漂移")); - } - fs::remove_file(&previous_path) - .map_err(|error| io_error("清理 plan session previous 失败", error))?; - sync_planning_parent(primary_path.parent().expect("session has parent"))?; - return Ok(Some(current_primary)); - } - validate_plan_session_successor(&previous_value, &primary_value)?; - let current_primary = - read_optional_plan_session_file(root, &primary_path, "锁内 plan session primary")? - .ok_or_else(|| { - PlanningStorageError::new( - "PLAN_RECONCILIATION_REQUIRED", - "清理 session previous 时 primary 缺失", - ) - })?; - let current_previous = read_optional_plan_session_file( - root, - &previous_path, - "锁内 plan session previous", - )? - .ok_or_else(|| { - PlanningStorageError::new( - "PLAN_RECONCILIATION_REQUIRED", - "清理 session previous 时 recovery 文件缺失", - ) - })?; - validate_plan_session_successor(¤t_previous, ¤t_primary)?; - fs::remove_file(&previous_path) - .map_err(|error| io_error("清理 plan session previous 失败", error))?; - sync_planning_parent(primary_path.parent().expect("session has parent"))?; - Ok(Some(current_primary)) - } - } -} - -/// Inspect a planning session without promoting or deleting either recovery -/// file. Hydrate uses this before project identity has been established: a -/// copied sidecar must never be "repaired" in the receiving project. -pub(crate) fn read_plan_session_read_only_locked( - root: &Path, -) -> Result, PlanningStorageError> { - let primary_path = resolve_planning_path(root, PLAN_SESSION_PATH)?; - let previous_path = resolve_planning_path(root, PLAN_SESSION_PREVIOUS_PATH)?; - let primary = read_optional_plan_session_file(root, &primary_path, "plan session primary")?; - let previous = read_optional_plan_session_file(root, &previous_path, "plan session previous")?; - match (primary, previous) { - (None, None) => Ok(None), - (Some(primary), None) => Ok(Some(primary)), - (Some(primary), Some(previous)) => { - if primary != previous { - validate_plan_session_successor(&previous, &primary)?; - } - Ok(Some(primary)) - } - (None, Some(previous)) => Ok(Some(previous)), - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn golden_gdd() -> PlanGddV1 { - let mut value = PlanGddV1 { - schema_version: PLAN_GDD_SCHEMA_VERSION.to_string(), - project_id: "project-golden-001".to_string(), - gdd_id: "gdd-00000000-0000-4000-8000-000000000001".to_string(), - version: 1, - submission_id: "action-0123456789abcdef01234567".to_string(), - approval_request_id: "gdd-approval-00000000-0000-4000-8000-000000000002".to_string(), - action_fingerprint: "1".repeat(64), - agent_id: "project-planning".to_string(), - source: "agent-delegate".to_string(), - run_profile: "standard".to_string(), - run_profile_binding_fingerprint: "2".repeat(64), - root_agent_id: "project-supervisor".to_string(), - root_run_id: "run-golden-root-001".to_string(), - delegation_id: "clarification-continuation-4444444444444444".to_string(), - session_id: "session-golden-001".to_string(), - source_session_revision: 3, - source_session_fingerprint: format!("sha256-serde-json-v2:{}", "3".repeat(64)), - created_by_run_id: "run-golden-plan-001".to_string(), - created_at_utc: "2026-08-10T00:00:00.000Z".to_string(), - game: PlanGddGame { - title: "萤火守夜人".to_string(), - genre: PlanGenre { - primary: "轻量动作解谜".to_string(), - fusion: None, - }, - art_style: PlanArtStyle { - visual_type: "低多边形剪影".to_string(), - keywords: vec!["萤火".to_string(), "深蓝".to_string(), "暖金".to_string()], - mood_and_color: "深蓝夜色配暖金反馈".to_string(), - mvp_art_boundary: "仅玩家、灯塔、三类障碍与HUD".to_string(), - }, - one_liner: "玩家扮演守夜人,在会熄灭的群岛间收集萤火、点亮灯塔并规划安全返回路线,每局用有限光源换取更远探索。".to_string(), - pillars: vec![ - PlanPillar { - name: "光源抉择".to_string(), - player_feel: "每一步都在安全与收益间权衡".to_string(), - mechanism: "光量同时承担生命、视野与开门消耗".to_string(), - decision_state: "confirmed".to_string(), - basis: None, - }, - PlanPillar { - name: "短局探索".to_string(), - player_feel: "十分钟内完成一次清晰冒险".to_string(), - mechanism: "岛屿分支和撤离时机形成重玩差异".to_string(), - decision_state: "prototype_pending".to_string(), - basis: None, - }, - ], - core_loop: vec![ - "观察剩余光量与岛屿分支".to_string(), - "选择路线和光源投入".to_string(), - "移动、收集并处理障碍".to_string(), - "点亮灯塔或及时撤离".to_string(), - ], - target_users: PlanTargetUsers { - core_users: "喜欢短局策略与轻量探索的玩家".to_string(), - preferences: "清晰反馈、低操作压力、可复盘选择".to_string(), - session_length: "10至15分钟".to_string(), - reference_games: Vec::new(), - }, - platform_facts: PlanPlatformFacts { - runtime: "self-contained-web".to_string(), - viewports: vec!["desktop".to_string(), "mobile".to_string()], - inputs: vec!["keyboard".to_string(), "touch".to_string()], - preview: "local-http".to_string(), - }, - mvp_systems: vec![ - PlanMvpSystem { - system: "光量资源".to_string(), - minimal_function: "移动和交互消耗光量".to_string(), - why_required: "承载核心取舍".to_string(), - verify_method: "观察玩家是否因光量改变路线".to_string(), - decision_state: "confirmed".to_string(), - basis: None, - }, - PlanMvpSystem { - system: "分支岛屿".to_string(), - minimal_function: "每局提供两次二选一路线".to_string(), - why_required: "形成重玩差异".to_string(), - verify_method: "记录第二局路线变化".to_string(), - decision_state: "prototype_pending".to_string(), - basis: None, - }, - PlanMvpSystem { - system: "灯塔结算".to_string(), - minimal_function: "点亮终点或撤离时结算".to_string(), - why_required: "闭合本局目标".to_string(), - verify_method: "玩家能理解三类结算".to_string(), - decision_state: "default_pending".to_string(), - basis: None, - }, - ], - out_of_scope: vec!["多人".to_string()], - creator_tips: PlanCreatorTips { - do_first: "先验证光量与路线取舍".to_string(), - defer_for_now: "完整剧情和大量岛屿".to_string(), - how_to_verify: "让三名玩家各试玩两局并说明路线理由".to_string(), - expand_when: "多数玩家会主动改变第二局路线".to_string(), - }, - }, - decisions: vec![ - PlanDecision { - id: "initial-request".to_string(), - topic: "初始需求".to_string(), - state: "confirmed".to_string(), - answer_source: "user_freeform".to_string(), - round: 0, - answer_summary: "做一款围绕有限光源探索群岛的短局动作解谜游戏".to_string(), - basis: None, - }, - PlanDecision { - id: "route-replay".to_string(), - topic: "路线重玩".to_string(), - state: "prototype_pending".to_string(), - answer_source: "user_option".to_string(), - round: 1, - answer_summary: "用微型原型验证分支是否驱动重玩".to_string(), - basis: None, - }, - ], - prototype_validation_items: vec![PlanPrototypeValidationItem { - id: "route-replay".to_string(), - question: "分支路线是否驱动第二局选择变化".to_string(), - micro_prototype: "制作两次二选一路线和光量结算".to_string(), - observation: "记录第二局是否主动改变分支并说明原因".to_string(), - pass_criterion: "三名测试者中至少两名主动改变路线且能说出取舍".to_string(), - }], - fingerprint: format!("sha256-serde-json-v2:{}", "0".repeat(64)), - }; - value.fingerprint = plan_gdd_fingerprint(&value).expect("golden GDD fingerprint"); - value - } - - fn golden_session() -> PlanSessionV1 { - let mut value = PlanSessionV1 { - schema_version: PLAN_SESSION_SCHEMA_VERSION.to_string(), - project_id: "project-golden-001".to_string(), - gdd_id: "gdd-00000000-0000-4000-8000-000000000001".to_string(), - session_revision: 1, - previous_fingerprint: None, - session_fingerprint: format!("sha256-serde-json-v2:{}", "0".repeat(64)), - agent_id: "project-planning".to_string(), - source: "agent-delegate".to_string(), - run_profile: "standard".to_string(), - run_profile_binding_fingerprint: "2".repeat(64), - root_agent_id: "project-supervisor".to_string(), - root_run_id: "run-golden-root-001".to_string(), - latest_delegation_id: "delegation-golden-001".to_string(), - session_id: "session-golden-001".to_string(), - active_run_id: Some("run-golden-plan-001".to_string()), - last_run_id: "run-golden-plan-001".to_string(), - phase: "collecting".to_string(), - accumulated_agent_millis: 10, - applied_steer_cursor: 0, - decisions_summary: vec![PlanDecisionSummary { - id: "initial-request".to_string(), - topic: "初始需求".to_string(), - state: "confirmed".to_string(), - answer_source: "user_freeform".to_string(), - round: 0, - answer_summary: "做一款围绕有限光源探索群岛的短局动作解谜游戏".to_string(), - }], - prototype_validation_items: Vec::new(), - applied_answers: Vec::new(), - latest_submitted_ref: None, - last_decision_ref: None, - updated_at_utc: "2026-08-10T00:00:00.000Z".to_string(), - }; - value.session_fingerprint = plan_session_fingerprint(&value).expect("session fingerprint"); - value - } - - fn golden_submit_input() -> PlanSubmitGddInputV1 { - let gdd = golden_gdd(); - PlanSubmitGddInputV1 { - schema_version: PLAN_SUBMIT_GDD_INPUT_SCHEMA_VERSION.to_string(), - game: PlanSubmitGame { - title: gdd.game.title, - genre: gdd.game.genre, - art_style: gdd.game.art_style, - one_liner: gdd.game.one_liner, - pillars: gdd - .game - .pillars - .into_iter() - .map(|pillar| PlanSubmitPillar { - name: pillar.name, - player_feel: pillar.player_feel, - mechanism: pillar.mechanism, - decision_state: pillar.decision_state, - }) - .collect(), - core_loop: gdd.game.core_loop, - target_users: gdd.game.target_users, - mvp_systems: gdd - .game - .mvp_systems - .into_iter() - .map(|system| PlanSubmitMvpSystem { - system: system.system, - minimal_function: system.minimal_function, - why_required: system.why_required, - verify_method: system.verify_method, - decision_state: system.decision_state, - }) - .collect(), - out_of_scope: gdd.game.out_of_scope, - creator_tips: gdd.game.creator_tips, - }, - decisions: gdd - .decisions - .into_iter() - .map(|decision| PlanSubmitDecision { - id: decision.id, - topic: decision.topic, - state: decision.state, - answer_source: decision.answer_source, - round: decision.round, - answer_summary: decision.answer_summary, - }) - .collect(), - prototype_validation_items: gdd.prototype_validation_items, - } - } - - #[test] - fn typed_serde_gdd_golden_vector_matches_spec() { - let value = golden_gdd(); - let canonical = PlanGddFingerprintValue::from(&value); - let bytes = typed_serde_canonical_bytes(PLAN_GDD_FINGERPRINT_DOMAIN, &canonical) - .expect("golden canonical bytes"); - assert_eq!(bytes.len(), 3857); - assert_eq!( - value.fingerprint, - "sha256-serde-json-v2:a59856de7ef134cf2f49c4dedd2ba10ae4ab2340a9634d402eb792b6ee5458f0" - ); - assert_eq!( - typed_serde_fingerprint(PLAN_GDD_FINGERPRINT_DOMAIN, &canonical) - .expect("golden fingerprint"), - value.fingerprint - ); - } - - #[test] - fn typed_fingerprint_changes_for_protected_bytes() { - let value = golden_gdd(); - let first = plan_gdd_fingerprint(&value).expect("fingerprint"); - let mut changed = value.clone(); - changed.game.title = "萤火守夜者".to_string(); - changed.fingerprint = first.clone(); - assert_ne!( - plan_gdd_fingerprint(&changed).expect("changed fingerprint"), - first - ); - let mut reordered = value.clone(); - reordered.game.art_style.keywords.reverse(); - reordered.fingerprint = first.clone(); - assert_ne!( - plan_gdd_fingerprint(&reordered).expect("reordered fingerprint"), - first - ); - let mut identity = value; - identity.root_run_id = "run-golden-root-002".to_string(); - identity.fingerprint = first.clone(); - assert_ne!( - plan_gdd_fingerprint(&identity).expect("identity fingerprint"), - first - ); - } - - #[test] - fn timestamp_validation_rejects_invalid_calendar_and_non_ascii_values() { - let mut value = golden_gdd(); - value.created_at_utc = "2026-99-99T99:99:99.999Z".to_string(); - assert_eq!( - plan_gdd_fingerprint(&value).unwrap_err().code(), - "PLAN_INVALID_SCHEMA" - ); - value.created_at_utc = "😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀".to_string(); - assert_eq!( - plan_gdd_fingerprint(&value).unwrap_err().code(), - "PLAN_INVALID_SCHEMA" - ); - } - - #[test] - fn strict_parser_rejects_duplicate_keys_and_noncanonical_suffix() { - let value = golden_gdd(); - let bytes = canonical_plan_gdd_bytes(&value).expect("canonical GDD"); - assert!(parse_plan_gdd_bytes(&bytes).is_ok()); - let mut newline = bytes.clone(); - newline.push(b'\n'); - assert_eq!( - parse_plan_gdd_bytes(&newline).unwrap_err().code(), - "PLAN_NON_CANONICAL_BYTES" - ); - let duplicate = br#"{"schemaVersion":"plan-gdd.v1","schemaVersion":"plan-gdd.v1"}"#; - assert_eq!( - parse_plan_gdd_bytes(duplicate).unwrap_err().code(), - "PLAN_INVALID_JSON" - ); - } - - #[test] - fn immutable_writer_is_create_only_and_generic_gate_is_write_only() { - let directory = tempfile::tempdir().expect("temp root"); - let root = directory.path(); - let value = golden_gdd(); - let bytes = canonical_plan_gdd_bytes(&value).expect("canonical GDD"); - let path = ".agent/planning/gdd.v1.json"; - let malformed_root = tempfile::tempdir().expect("malformed root"); - assert_eq!( - durable_create_json_no_replace_locked(malformed_root.path(), path, br"{}", "GDD",) - .unwrap_err() - .code(), - "PLAN_INVALID_JSON" - ); - assert_eq!( - durable_create_json_no_replace_locked(root, path, &bytes, "GDD").expect("first create"), - PlanningCreateOutcome::Created - ); - assert_eq!( - durable_create_json_no_replace_locked(root, path, &bytes, "GDD").expect("replay"), - PlanningCreateOutcome::Replayed - ); - let mut changed = bytes.clone(); - let changed_index = changed.len() - 2; - changed[changed_index] ^= 1; - assert_eq!( - durable_create_json_no_replace_locked(root, path, &changed, "GDD") - .unwrap_err() - .code(), - "PLAN_INVALID_JSON" - ); - assert!(write_local_project_file_at(root, path, "tamper").is_err()); - assert!(delete_local_project_file_at(root, path).is_err()); - assert!(write_local_project_file_at(root, "game/fast_gdd.md", "tamper").is_err()); - assert!(delete_local_project_file_at(root, "game/fast_gdd.md").is_err()); - } - - #[test] - fn submit_input_has_strict_canonical_parser_and_runtime_field_boundary() { - let value = golden_submit_input(); - let bytes = canonical_plan_submit_gdd_input_bytes(&value).expect("submit input bytes"); - let parse = |bytes: &[u8]| -> Result { - let value = parse_strict_canonical::( - bytes, - "plan.submit_gdd input", - PLAN_GDD_MAX_BYTES, - )?; - validate_plan_submit_gdd_input(&value)?; - Ok(value) - }; - assert_eq!(parse(&bytes).expect("parse input"), value); - let mut newline = bytes.clone(); - newline.push(b'\n'); - assert_eq!( - parse(&newline).unwrap_err().code(), - "PLAN_NON_CANONICAL_BYTES" - ); - let mut object = serde_json::from_slice::(&bytes).expect("input json"); - object["projectId"] = serde_json::Value::String("forged-project".to_string()); - let forged = serde_json::to_vec(&object).expect("forged input"); - assert!(parse(&forged).is_err()); - } - - #[test] - fn gdd_chain_and_index_are_authority_checked() { - let directory = tempfile::tempdir().expect("temp root"); - let root = directory.path(); - let first = golden_gdd(); - let first_bytes = canonical_plan_gdd_bytes(&first).expect("first bytes"); - durable_create_json_no_replace_locked( - root, - ".agent/planning/gdd.v1.json", - &first_bytes, - "GDD", - ) - .expect("write first GDD"); - let mut second = first.clone(); - second.version = 2; - second.submission_id = "action-abcdefabcdefabcdefabcdef".to_string(); - second.approval_request_id = - "gdd-approval-00000000-0000-4000-8000-000000000003".to_string(); - second.action_fingerprint = "4".repeat(64); - second.fingerprint = plan_gdd_fingerprint(&second).expect("second fingerprint"); - let second_bytes = canonical_plan_gdd_bytes(&second).expect("second bytes"); - durable_create_json_no_replace_locked( - root, - ".agent/planning/gdd.v2.json", - &second_bytes, - "GDD", - ) - .expect("write second GDD"); - let chain = read_plan_gdd_chain(root).expect("read chain"); - assert_eq!(chain, vec![first.clone(), second.clone()]); - let index = build_plan_gdd_index(&chain, "2026-08-10T00:00:00.000Z").expect("index"); - assert_eq!( - index - .status_cache - .versions - .iter() - .map(|item| item.status.as_str()) - .collect::>(), - vec!["superseded", "ready_for_approval"] - ); - validate_plan_gdd_index_against_gdds(&index, &chain).expect("index authority"); - let mut tampered = index.clone(); - tampered.entries[1].root_run_id = "run-tampered".to_string(); - assert_eq!( - validate_plan_gdd_index_against_gdds(&tampered, &chain) - .unwrap_err() - .code(), - "PLAN_IDENTITY_CONFLICT" - ); - let mut status_tampered = index.clone(); - status_tampered.status_cache.versions[0].status = "ready_for_approval".to_string(); - assert_eq!( - validate_plan_gdd_index_against_gdds(&status_tampered, &chain) - .unwrap_err() - .code(), - "PLAN_IDENTITY_CONFLICT" - ); - let index_bytes = canonical_plan_index_bytes(&index).expect("index bytes"); - assert_eq!( - durable_create_json_no_replace_locked( - root, - PLAN_GDD_INDEX_PATH, - &index_bytes, - "GDD index" - ) - .unwrap_err() - .code(), - "PLAN_DEDICATED_WRITER_REQUIRED" - ); - write_plan_gdd_index_atomic_locked(root, &index).expect("write index"); - let mut rebuilt = index.clone(); - rebuilt.rebuilt_at_utc = "2026-08-11T00:00:00.000Z".to_string(); - write_plan_gdd_index_atomic_locked(root, &rebuilt).expect("replace index"); - assert_eq!( - parse_plan_index_bytes( - &fs::read( - root.join(PLAN_GDD_INDEX_PATH.replace('/', std::path::MAIN_SEPARATOR_STR)) - ) - .expect("read index") - ) - .expect("parse replaced index"), - rebuilt - ); - fs::remove_file(root.join(PLAN_GDD_INDEX_PATH.replace('/', std::path::MAIN_SEPARATOR_STR))) - .expect("remove index for recovery"); - let recovered = read_plan_gdd_index_with_recovery_locked(root, "2026-08-12T00:00:00.000Z") - .expect("recover missing index") - .expect("recovered index"); - assert_eq!(recovered.entries, index.entries); - assert_eq!(recovered.rebuilt_at_utc, "2026-08-12T00:00:00.000Z"); - - fs::write( - root.join(PLAN_GDD_INDEX_PATH.replace('/', std::path::MAIN_SEPARATOR_STR)), - br"{}", - ) - .expect("corrupt index"); - let recovered_corrupt = - read_plan_gdd_index_with_recovery_locked(root, "2026-08-13T00:00:00.000Z") - .expect("recover corrupt index") - .expect("recovered corrupt index"); - assert_eq!(recovered_corrupt.entries, index.entries); - assert_eq!(recovered_corrupt.rebuilt_at_utc, "2026-08-13T00:00:00.000Z"); - assert!(build_plan_gdd_index(&[], "2026-08-10T00:00:00.000Z").is_err()); - } - - #[test] - fn session_successor_and_recovery_are_cas_checked() { - let directory = tempfile::tempdir().expect("temp root"); - let root = directory.path(); - let first = golden_session(); - let first_bytes = canonical_plan_session_bytes(&first).expect("session bytes"); - assert_eq!( - durable_create_json_no_replace_locked( - root, - PLAN_SESSION_PATH, - &first_bytes, - "plan session" - ) - .unwrap_err() - .code(), - "PLAN_DEDICATED_WRITER_REQUIRED" - ); - write_plan_session_atomic_locked(root, &first).expect("write session v1"); - let mut second = first.clone(); - second.session_revision = 2; - second.previous_fingerprint = Some(first.session_fingerprint.clone()); - second.accumulated_agent_millis += 10; - second.active_run_id = None; - second.phase = "awaiting_user_input".to_string(); - second.session_fingerprint = plan_session_fingerprint(&second).expect("v2 fingerprint"); - write_plan_session_atomic_locked(root, &second).expect("write session v2"); - assert!(root.join(PLAN_SESSION_PREVIOUS_PATH).exists()); - assert_eq!( - read_plan_session_with_recovery(root).expect("recover session"), - Some(second.clone()) - ); - assert!(!root.join(PLAN_SESSION_PREVIOUS_PATH).exists()); - let mut invalid_next = second.clone(); - invalid_next.session_revision = 4; - invalid_next.previous_fingerprint = Some(second.session_fingerprint.clone()); - invalid_next.session_fingerprint = plan_session_fingerprint(&invalid_next).expect("bad fp"); - assert_eq!( - write_plan_session_atomic_locked(root, &invalid_next) - .unwrap_err() - .code(), - "PLAN_IDENTITY_CONFLICT" - ); - } - - #[test] - fn session_applied_answers_bind_unique_round_and_continuation() { - let directory = tempfile::tempdir().expect("temp root"); - let root = directory.path(); - let mut session = golden_session(); - session.phase = "awaiting_user_input".to_string(); - session.active_run_id = None; - session.decisions_summary.push(PlanDecisionSummary { - id: "route-replay".to_string(), - topic: "路线重玩".to_string(), - state: "confirmed".to_string(), - answer_source: "user_option".to_string(), - round: 1, - answer_summary: "验证分支是否驱动重玩".to_string(), - }); - let questions_sha256 = "a".repeat(64); - let answers_sha256 = "b".repeat(64); - let delegation_id = "delegation-question-001".to_string(); - let continuation = derive_plan_continuation_delegation_id( - &session.root_run_id, - &delegation_id, - &questions_sha256, - &answers_sha256, - ) - .expect("continuation id"); - session.applied_answers.push(PlanAppliedAnswer { - delegation_id, - continuation_delegation_id: continuation, - request_id: "request-question-001".to_string(), - question_id: "route_replay".to_string(), - response_id: "app-user-input-001".to_string(), - questions_sha256, - answers_sha256, - decision_id: "route-replay".to_string(), - round: 1, - }); - session.latest_delegation_id = session - .applied_answers - .last() - .expect("answer") - .continuation_delegation_id - .clone(); - session.session_fingerprint = plan_session_fingerprint(&session).expect("answer fp"); - validate_plan_session(&session).expect("valid applied answer"); - - let mut next_question = session.clone(); - next_question.latest_delegation_id = "delegation-next-question-002".to_string(); - next_question.session_fingerprint = - plan_session_fingerprint(&next_question).expect("next question fp"); - validate_plan_session(&next_question) - .expect("awaiting_user_input may anchor the new question delivery"); - - let mut forged_collecting = next_question.clone(); - forged_collecting.phase = "collecting".to_string(); - forged_collecting.session_fingerprint = - plan_session_fingerprint(&forged_collecting).expect("recompute forged fingerprint"); - assert_eq!( - write_plan_session_atomic_locked(root, &forged_collecting) - .expect_err("runtime boundary must reject an unrelated latest delegation") - .code(), - "PLAN_NEEDS_RECONCILIATION" - ); - - let mut duplicate = session.clone(); - duplicate - .applied_answers - .push(duplicate.applied_answers[0].clone()); - duplicate.session_fingerprint = session.session_fingerprint.clone(); - assert!(validate_plan_session(&duplicate).is_err()); - } - - #[test] - fn session_runtime_lineage_rejects_mixed_quality_repair_with_recomputed_fingerprint() { - let directory = tempfile::tempdir().expect("temp root"); - let root = directory.path(); - let mut session = golden_session(); - session.decisions_summary.push(PlanDecisionSummary { - id: "route-replay".to_string(), - topic: "路线重玩".to_string(), - state: "confirmed".to_string(), - answer_source: "user_option".to_string(), - round: 1, - answer_summary: "验证分支是否驱动重玩".to_string(), - }); - let questions_sha256 = "a".repeat(64); - let answers_sha256 = "b".repeat(64); - let question_delegation_id = "delegation-question-mixed-001".to_string(); - let continuation_id = derive_plan_continuation_delegation_id( - &session.root_run_id, - &question_delegation_id, - &questions_sha256, - &answers_sha256, - ) - .expect("continuation id"); - session.applied_answers.push(PlanAppliedAnswer { - delegation_id: question_delegation_id, - continuation_delegation_id: continuation_id.clone(), - request_id: "request-question-mixed-001".to_string(), - question_id: "route_replay".to_string(), - response_id: "app-user-input-mixed-001".to_string(), - questions_sha256, - answers_sha256, - decision_id: "route-replay".to_string(), - round: 1, - }); - - let quality_repair_id = "delegation-quality-repair-mixed-002"; - let latest_id = "delegation-latest-mixed-003"; - let build_delivery = - |delegation_id: &str, - repair_of: Option<&str>, - contract_status: crate::delegation::StaticDelegateContractStatus| { - let mut delivery = crate::delegation::new_static_delegate_delivery_with_contract( - &session.root_agent_id, - "session-golden-parent-001", - &session.root_run_id, - &format!("{delegation_id}-action"), - delegation_id, - &session.agent_id, - &session.session_id, - &format!("{delegation_id}-run"), - &[], - &[], - repair_of, - ); - let mut result = crate::delegation::StaticDelegateStructuredResult::default(); - result.contract_status = contract_status; - delivery.status = crate::delegation::StaticDelegateDeliveryStatus::ClaimedByParent; - delivery.terminal_status = Some("completed".to_string()); - delivery.result_summary = Some("planning lineage test".to_string()); - delivery.structured_result = Some(result); - delivery.claimed_by_action_id = Some(format!("{delegation_id}-claim")); - delivery - }; - let continuation = build_delivery( - &continuation_id, - None, - crate::delegation::StaticDelegateContractStatus::UserRevisionRequested, - ); - let quality_repair = build_delivery( - quality_repair_id, - Some(&continuation_id), - crate::delegation::StaticDelegateContractStatus::NeedsRepair, - ); - let latest = build_delivery( - latest_id, - Some(quality_repair_id), - crate::delegation::StaticDelegateContractStatus::UserRevisionRequested, - ); - for delivery in [&continuation, &quality_repair, &latest] { - crate::delegation::write_static_delegate_delivery_at(root, delivery) - .expect("write mixed lineage delivery"); - } - - session.latest_delegation_id = latest_id.to_string(); - session.session_fingerprint = - plan_session_fingerprint(&session).expect("recompute forged fingerprint"); - validate_plan_session(&session).expect("standalone session shape remains valid"); - assert_eq!( - write_plan_session_atomic_locked(root, &session) - .expect_err("quality repair edge must clear old applied answers") - .code(), - "PLAN_IDENTITY_CONFLICT" - ); - } - - #[test] - fn session_recovery_rejects_corrupt_primary_and_forked_previous() { - let directory = tempfile::tempdir().expect("temp root"); - let root = directory.path(); - let first = golden_session(); - write_plan_session_atomic_locked(root, &first).expect("write session"); - let primary_path = root.join(PLAN_SESSION_PATH.replace('/', std::path::MAIN_SEPARATOR_STR)); - let previous_path = - root.join(PLAN_SESSION_PREVIOUS_PATH.replace('/', std::path::MAIN_SEPARATOR_STR)); - fs::write(&primary_path, b"{}").expect("corrupt primary"); - let error = read_plan_session_with_recovery(root).expect_err("corrupt primary rejected"); - assert_eq!(error.code(), "PLAN_INVALID_JSON"); - assert!(!previous_path.exists()); - - // Missing primary may be promoted only when the recovery copy is the - // sole valid fact. - fs::write( - &primary_path, - canonical_plan_session_bytes(&first).expect("restore primary"), - ) - .expect("restore primary"); - fs::rename(&primary_path, &previous_path).expect("move to previous"); - assert_eq!( - read_plan_session_with_recovery(root).expect("promote previous"), - Some(first.clone()) - ); - assert!(primary_path.exists()); - assert!(!previous_path.exists()); - - let mut second = first.clone(); - second.session_revision = 2; - second.previous_fingerprint = Some(first.session_fingerprint.clone()); - second.accumulated_agent_millis += 1; - second.active_run_id = None; - second.phase = "awaiting_user_input".to_string(); - second.session_fingerprint = plan_session_fingerprint(&second).expect("second fp"); - write_plan_session_atomic_locked(root, &second).expect("write successor"); - let mut forked_previous = first.clone(); - forked_previous.updated_at_utc = "2026-08-11T00:00:00.000Z".to_string(); - forked_previous.session_fingerprint = - plan_session_fingerprint(&forked_previous).expect("fork fp"); - fs::write( - &previous_path, - canonical_plan_session_bytes(&forked_previous).expect("fork bytes"), - ) - .expect("write fork"); - let error = read_plan_session_with_recovery(root).expect_err("fork rejected"); - assert_eq!(error.code(), "PLAN_IDENTITY_CONFLICT"); - } - - #[cfg(unix)] - #[test] - fn immutable_writer_rejects_symlink_and_hardlink_targets() { - use std::os::unix::fs::symlink; - - let directory = tempfile::tempdir().expect("temp root"); - let root = directory.path(); - let value = golden_gdd(); - let bytes = canonical_plan_gdd_bytes(&value).expect("GDD bytes"); - let outside = directory.path().join("outside.json"); - fs::write(&outside, &bytes).expect("outside bytes"); - let symlink_path = root.join(".agent/planning/gdd.v1.json"); - ensure_planning_parent(&symlink_path).expect("planning dir"); - symlink(&outside, &symlink_path).expect("symlink"); - assert_eq!( - durable_create_json_no_replace_locked( - root, - ".agent/planning/gdd.v1.json", - &bytes, - "GDD" - ) - .unwrap_err() - .code(), - "PLAN_UNTRUSTED_PATH" - ); - - let hardlink_directory = tempfile::tempdir().expect("hardlink root"); - let hardlink_root = hardlink_directory.path(); - let hardlink_outside = hardlink_root.join("outside.json"); - fs::write(&hardlink_outside, &bytes).expect("hardlink outside"); - let hardlink_path = hardlink_root.join(".agent/planning/gdd.v1.json"); - ensure_planning_parent(&hardlink_path).expect("hardlink planning dir"); - fs::hard_link(&hardlink_outside, &hardlink_path).expect("hardlink"); - assert_eq!( - durable_create_json_no_replace_locked( - hardlink_root, - ".agent/planning/gdd.v1.json", - &bytes, - "GDD", - ) - .unwrap_err() - .code(), - "PLAN_UNTRUSTED_PATH" - ); - } - - /// M1C-1 新增的审批路径最初绕过 `resolve_planning_path`,直接用通用解析器。 - /// 通用解析器只认 `is_symlink()`,把结果一律映射成 `PLAN_INVALID_PATH`;而 - /// 规划解析器认的是 `FILE_ATTRIBUTE_REPARSE_POINT` 全量重解析标记,并把被 - /// 篡改的路径如实报成 `PLAN_UNTRUSTED_PATH`。审批回执正是 GDD 完成门的判据, - /// 它的路径分类必须和 GDD/session 一致,否则调用方按错误码分流时会把「路径 - /// 不可信」当成「路径写错了」。 - #[cfg(any(unix, windows))] - #[test] - fn approval_paths_classify_a_linked_planning_root_as_untrusted_not_merely_invalid() { - let directory = tempfile::tempdir().expect("temp root"); - let root = directory.path(); - // 旁路目录留在项目内:真正要挡的是「planning 根被指向别处」,不是「逃出根」。 - let decoy = root.join("decoy-planning"); - fs::create_dir_all(decoy.join("approvals")).expect("decoy approvals"); - fs::create_dir_all(root.join(".agent")).expect("agent dir"); - let planning_link = { - let mut path = root.to_path_buf(); - for part in PLAN_STORAGE_ROOT.split('/') { - path.push(part); - } - path - }; - #[cfg(unix)] - std::os::unix::fs::symlink(&decoy, &planning_link).expect("planning symlink"); - // 用 junction 而不是 `symlink_dir`:后者要开发者模式/管理员权限,普通开发 - // 机上建不起来,用例会静默跳过成永远通过的空壳;junction 无需提权,而且它 - // 正是本仓各处点名要挡的那种 Windows 重解析点。 - #[cfg(windows)] - { - use std::os::windows::process::CommandExt; - let status = std::process::Command::new("cmd") - .arg("/C") - .raw_arg(format!( - "mklink /J \"{}\" \"{}\"", - planning_link.display(), - decoy.display() - )) - .status() - .expect("spawn mklink"); - assert!(status.success(), "junction 建不起来则本用例失去判据"); - } - - // 写入路径不在列:它的父链早已由 `ensure_planning_parent` 逐段校验, - // 本来就会报 PLAN_UNTRUSTED_PATH,不构成这次改动的判据。 - assert_eq!( - approval_directory_is_present(root).unwrap_err().code(), - "PLAN_UNTRUSTED_PATH" - ); - assert_eq!( - read_plan_gdd_approvals_locked(root).unwrap_err().code(), - "PLAN_UNTRUSTED_PATH" - ); - assert_eq!( - read_plan_gdd_approval_for_version_locked(root, 1) - .unwrap_err() - .code(), - "PLAN_UNTRUSTED_PATH" - ); - assert_eq!( - read_plan_gdd_approval_pending_locked(root) - .unwrap_err() - .code(), - "PLAN_UNTRUSTED_PATH" - ); - assert_eq!( - remove_plan_gdd_approval_pending_locked(root) - .unwrap_err() - .code(), - "PLAN_UNTRUSTED_PATH" - ); - } -} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_submit.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_submit.rs deleted file mode 100644 index 94953a186..000000000 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_submit.rs +++ /dev/null @@ -1,5393 +0,0 @@ -use super::planning_storage::PLAN_SUBMIT_GDD_INPUT_SCHEMA_VERSION as PLAN_SUBMIT_INPUT_SCHEMA; -use super::*; - -use sha2::{Digest, Sha256}; -use std::time::{SystemTime, UNIX_EPOCH}; -use uuid::Uuid; - -/// Native planning action name. The catalog/policy registration lives in the -/// Runtime action modules; the storage handler keeps the durable boundary in a -/// separate module so it can be called before the generic executor. -pub(crate) const PLAN_SUBMIT_GDD_TOOL: &str = "plan.submit_gdd"; - -/// The small, Runtime-owned identity envelope that travels with a planning -/// submit action. A Provider response is asynchronous: by the time the -/// action is resumed the planning session may have advanced. Keeping the -/// source snapshot next to the durable action makes that distinction -/// explicit and prevents the executor from silently rebinding an old -/// response to a newer session. -pub(crate) const PLAN_PROVIDER_SESSION_BINDING_SCHEMA_VERSION: &str = - "plan-provider-session-binding.v1"; -pub(crate) const PLAN_PROVIDER_SESSION_BINDING_FINGERPRINT_DOMAIN: &str = - "genarrative.plan.provider-session-binding.v1"; -pub(crate) const PLAN_PROVIDER_REQUEST_ID_DOMAIN: &str = "genarrative.plan.provider-request-id.v1"; -pub(crate) const PLAN_PROVIDER_STRUCTURED_INJECTIONS_SCHEMA_VERSION: &str = - "plan-provider-structured-injections.v1"; -pub(crate) const PLAN_PROVIDER_STRUCTURED_INJECTIONS_MESSAGE_HEADER: &str = - "AGC_PLAN_PROVIDER_STRUCTURED_INJECTIONS_V1"; -pub(crate) const PLAN_PROVIDER_STRUCTURED_INJECTIONS_MAX_BYTES: usize = 64 * 1024; - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(deny_unknown_fields, rename_all = "camelCase")] -pub(in crate::agent) struct PlanProviderFacingSessionV1 { - pub(in crate::agent) phase: String, - pub(in crate::agent) decisions_summary: Vec, - pub(in crate::agent) prototype_validation_items: Vec, - pub(in crate::agent) latest_submitted_ref: Option, - pub(in crate::agent) last_decision_ref: Option, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(deny_unknown_fields, rename_all = "camelCase")] -pub(in crate::agent) struct PlanProviderApprovalObservationV1 { - pub(in crate::agent) tool: String, - pub(in crate::agent) status: String, - pub(in crate::agent) summary: String, - pub(in crate::agent) detail: Option, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(deny_unknown_fields, rename_all = "camelCase")] -pub(in crate::agent) struct PlanProviderStructuredInjectionsV1 { - pub(in crate::agent) schema_version: String, - pub(in crate::agent) clarification_round: u32, - pub(in crate::agent) accumulated_agent_millis: u64, - pub(in crate::agent) session: PlanProviderFacingSessionV1, - pub(in crate::agent) platform_facts: PlanPlatformFacts, - pub(in crate::agent) approval_observation: Option, -} - -pub(in crate::agent) fn fixed_plan_platform_facts() -> PlanPlatformFacts { - PlanPlatformFacts { - runtime: "self-contained-web".to_string(), - viewports: vec!["desktop".to_string(), "mobile".to_string()], - inputs: vec!["keyboard".to_string(), "touch".to_string()], - preview: "local-http".to_string(), - } -} - -/// Capture the only Provider-visible planning sidecar. The compact bytes -/// returned here are used twice without rebuilding: once as the dedicated -/// request message and once as request-context fingerprint material. -pub(crate) fn capture_plan_provider_structured_injections_at( - root: &Path, - session_id: &str, - observations: &[AgentRuntimeToolObservation], -) -> Result, String> { - let session = read_plan_session_with_recovery_locked(root) - .map_err(|error| error.to_string())? - .ok_or_else(|| "planning Provider 请求缺少 plan session primary".to_string())?; - if session.session_id != session_id { - return Err("planning Provider 注入的 sessionId 与当前请求不一致".to_string()); - } - let deliveries = list_static_delegate_deliveries_at(root)?; - let (_, clarification_round) = - static_delegate_lineage_counters(&deliveries, &session.latest_delegation_id); - if clarification_round == u32::MAX { - return Err("planning Provider 无法从委派链推导 clarificationRound".to_string()); - } - if static_delegate_lineage_contains_unknown_contract_status( - &deliveries, - &session.latest_delegation_id, - ) - .map_err(|error| format!("planning Provider 无法确认委派 contractStatus:{error}"))? - { - return Err( - "planning Provider 委派谱系含更新版本 contractStatus,当前版本拒绝继续".to_string(), - ); - } - validate_plan_session_for_clarification_round(&session, clarification_round) - .map_err(|error| error.to_string())?; - let approval_observation = observations - .iter() - .rev() - .find(|observation| observation.tool == PLAN_SUBMIT_GDD_TOOL && observation.status == "ok") - .map(|observation| PlanProviderApprovalObservationV1 { - tool: observation.tool.clone(), - status: observation.status.clone(), - summary: observation.summary.clone(), - detail: observation.detail.clone(), - }); - let value = PlanProviderStructuredInjectionsV1 { - schema_version: PLAN_PROVIDER_STRUCTURED_INJECTIONS_SCHEMA_VERSION.to_string(), - clarification_round, - accumulated_agent_millis: session.accumulated_agent_millis, - session: PlanProviderFacingSessionV1 { - phase: session.phase, - decisions_summary: session.decisions_summary, - prototype_validation_items: session.prototype_validation_items, - latest_submitted_ref: session.latest_submitted_ref, - last_decision_ref: session.last_decision_ref, - }, - platform_facts: fixed_plan_platform_facts(), - approval_observation, - }; - let bytes = serde_json::to_vec(&value) - .map_err(|error| format!("序列化 planning Provider structured injections 失败:{error}"))?; - if bytes.len() > PLAN_PROVIDER_STRUCTURED_INJECTIONS_MAX_BYTES { - return Err("planning Provider structured injections 超出 64 KiB".to_string()); - } - Ok(bytes) -} - -pub(crate) fn render_plan_provider_structured_injections_message( - wire_bytes: &[u8], -) -> Result { - if wire_bytes.is_empty() || wire_bytes.len() > PLAN_PROVIDER_STRUCTURED_INJECTIONS_MAX_BYTES { - return Err("planning Provider structured injections wire bytes 非法".to_string()); - } - let parsed = serde_json::from_slice::(wire_bytes) - .map_err(|error| format!("解析 planning Provider structured injections 失败:{error}"))?; - let canonical = serde_json::to_vec(&parsed) - .map_err(|error| format!("重算 planning Provider structured injections 失败:{error}"))?; - if canonical != wire_bytes { - return Err( - "planning Provider structured injections 不是 canonical compact JSON".to_string(), - ); - } - let json = std::str::from_utf8(wire_bytes) - .map_err(|_| "planning Provider structured injections 不是 UTF-8".to_string())?; - Ok(format!( - "{PLAN_PROVIDER_STRUCTURED_INJECTIONS_MESSAGE_HEADER}\n{json}" - )) -} -const PLAN_PROVIDER_REQUEST_ATTEMPT_ID_DOMAIN: &str = - "genarrative.plan.provider-request-attempt.v1"; - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(deny_unknown_fields, rename_all = "camelCase")] -pub(crate) struct PlanProviderSessionBindingV1 { - pub(crate) schema_version: String, - pub(crate) project_id: String, - pub(crate) gdd_id: String, - pub(crate) agent_id: String, - pub(crate) task_id: String, - pub(crate) provider_request_id: String, - pub(crate) session_id: String, - pub(crate) run_id: String, - pub(crate) root_agent_id: String, - pub(crate) root_run_id: String, - pub(crate) delegation_id: String, - pub(crate) goal_id: Option, - pub(crate) goal_revision: u64, - pub(crate) goal_snapshot_fingerprint: String, - pub(crate) source: String, - pub(crate) run_profile: String, - pub(crate) run_profile_binding_fingerprint: String, - pub(crate) session_revision: u32, - pub(crate) session_fingerprint: String, - pub(crate) applied_steer_cursor: u64, - pub(crate) request_kind: String, - pub(crate) request_slot: String, - pub(crate) web_search_enabled: bool, - pub(crate) request_context_fingerprint: String, - pub(crate) fingerprint: String, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct PlanProviderSessionBindingFingerprintValue<'a> { - schema_version: &'a str, - project_id: &'a str, - gdd_id: &'a str, - agent_id: &'a str, - task_id: &'a str, - provider_request_id: &'a str, - session_id: &'a str, - run_id: &'a str, - root_agent_id: &'a str, - root_run_id: &'a str, - delegation_id: &'a str, - goal_id: Option<&'a str>, - goal_revision: u64, - goal_snapshot_fingerprint: &'a str, - source: &'a str, - run_profile: &'a str, - run_profile_binding_fingerprint: &'a str, - session_revision: u32, - session_fingerprint: &'a str, - applied_steer_cursor: u64, - request_kind: &'a str, - request_slot: &'a str, - web_search_enabled: bool, - request_context_fingerprint: &'a str, -} - -impl<'a> From<&'a PlanProviderSessionBindingV1> for PlanProviderSessionBindingFingerprintValue<'a> { - fn from(value: &'a PlanProviderSessionBindingV1) -> Self { - Self { - schema_version: &value.schema_version, - project_id: &value.project_id, - gdd_id: &value.gdd_id, - agent_id: &value.agent_id, - task_id: &value.task_id, - provider_request_id: &value.provider_request_id, - session_id: &value.session_id, - run_id: &value.run_id, - root_agent_id: &value.root_agent_id, - root_run_id: &value.root_run_id, - delegation_id: &value.delegation_id, - goal_id: value.goal_id.as_deref(), - goal_revision: value.goal_revision, - goal_snapshot_fingerprint: &value.goal_snapshot_fingerprint, - source: &value.source, - run_profile: &value.run_profile, - run_profile_binding_fingerprint: &value.run_profile_binding_fingerprint, - session_revision: value.session_revision, - session_fingerprint: &value.session_fingerprint, - applied_steer_cursor: value.applied_steer_cursor, - request_kind: &value.request_kind, - request_slot: &value.request_slot, - web_search_enabled: value.web_search_enabled, - request_context_fingerprint: &value.request_context_fingerprint, - } - } -} - -pub(crate) fn plan_provider_session_binding_fingerprint( - value: &PlanProviderSessionBindingV1, -) -> Result { - typed_serde_fingerprint( - PLAN_PROVIDER_SESSION_BINDING_FINGERPRINT_DOMAIN, - &PlanProviderSessionBindingFingerprintValue::from(value), - ) -} - -fn deterministic_uuid_prefixed(prefix: &str, material: &str) -> String { - let digest = Sha256::digest(material.as_bytes()); - let mut bytes = [0_u8; 16]; - bytes.copy_from_slice(&digest[..16]); - // RFC 4122 version 4 / variant bits keep the generated value compatible - // with the existing opaque UUID-prefixed validators while remaining - // deterministic across a retry of the same durable action. - bytes[6] = (bytes[6] & 0x0f) | 0x40; - bytes[8] = (bytes[8] & 0x3f) | 0x80; - format!("{prefix}{}", Uuid::from_bytes(bytes).hyphenated()) -} - -pub(crate) fn plan_provider_approval_request_id( - action_id: &str, - action_fingerprint: &str, - session_fingerprint: &str, -) -> String { - deterministic_uuid_prefixed( - "gdd-approval-", - &format!("{action_id}\n{action_fingerprint}\n{session_fingerprint}"), - ) -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct PlanProviderRequestIdentityValue<'a> { - project_id: &'a str, - gdd_id: &'a str, - agent_id: &'a str, - task_id: &'a str, - session_id: &'a str, - run_id: &'a str, - root_agent_id: &'a str, - root_run_id: &'a str, - delegation_id: &'a str, - source: &'a str, - run_profile: &'a str, - run_profile_binding_fingerprint: &'a str, - goal_id: Option<&'a str>, - goal_revision: u64, - goal_snapshot_fingerprint: &'a str, - session_revision: u32, - session_fingerprint: &'a str, - applied_steer_cursor: u64, - request_kind: &'a str, - request_slot: &'a str, - web_search_enabled: bool, - request_context_fingerprint: &'a str, -} - -fn plan_provider_request_id( - value: &PlanProviderRequestIdentityValue<'_>, -) -> Result { - let bytes = typed_serde_canonical_bytes(PLAN_PROVIDER_REQUEST_ID_DOMAIN, value)?; - Ok(format!("provider-request-{:x}", Sha256::digest(bytes))) -} - -/// Recompute the base Provider request identity from a frozen binding. The -/// binding fingerprint protects the envelope bytes, while this independent -/// derivation protects the request ID algorithm itself; otherwise a forged -/// binding could choose an arbitrary providerRequestId and still pass a -/// self-consistent fingerprint check. -pub(crate) fn plan_provider_session_binding_base_request_id( - binding: &PlanProviderSessionBindingV1, -) -> Result { - let request_slot = binding - .request_slot - .split_once("-transient-") - .map(|(base, _)| base) - .unwrap_or(binding.request_slot.as_str()); - plan_provider_request_id(&PlanProviderRequestIdentityValue { - project_id: &binding.project_id, - gdd_id: &binding.gdd_id, - agent_id: &binding.agent_id, - task_id: &binding.task_id, - session_id: &binding.session_id, - run_id: &binding.run_id, - root_agent_id: &binding.root_agent_id, - root_run_id: &binding.root_run_id, - delegation_id: &binding.delegation_id, - source: &binding.source, - run_profile: &binding.run_profile, - run_profile_binding_fingerprint: &binding.run_profile_binding_fingerprint, - goal_id: binding.goal_id.as_deref(), - goal_revision: binding.goal_revision, - goal_snapshot_fingerprint: &binding.goal_snapshot_fingerprint, - session_revision: binding.session_revision, - session_fingerprint: &binding.session_fingerprint, - applied_steer_cursor: binding.applied_steer_cursor, - request_kind: &binding.request_kind, - request_slot, - web_search_enabled: binding.web_search_enabled, - request_context_fingerprint: &binding.request_context_fingerprint, - }) -} - -fn plan_provider_session_binding_expected_request_id( - binding: &PlanProviderSessionBindingV1, -) -> Result { - let base_request_id = plan_provider_session_binding_base_request_id(binding)?; - let Some((base_slot, attempt_text)) = binding.request_slot.split_once("-transient-") else { - return Ok(base_request_id); - }; - if base_slot.is_empty() { - return Err(submit_error( - "PLAN_NEEDS_RECONCILIATION", - "planning Provider attempt requestSlot 缺少 base slot", - )); - } - let attempt = attempt_text.parse::().map_err(|_| { - submit_error( - "PLAN_NEEDS_RECONCILIATION", - "planning Provider attempt requestSlot 的 attempt 无效", - ) - })?; - if attempt == 0 || attempt > 64 { - return Err(submit_error( - "PLAN_NEEDS_RECONCILIATION", - "planning Provider attempt 超出允许范围", - )); - } - Ok(plan_provider_request_attempt_id(&base_request_id, attempt)) -} - -fn is_provider_request_id(value: &str) -> bool { - value - .strip_prefix("provider-request-") - .is_some_and(is_bare_fingerprint) -} - -pub(crate) fn validate_plan_provider_session_binding( - binding: &PlanProviderSessionBindingV1, -) -> Result<(), PlanningStorageError> { - if binding.schema_version != PLAN_PROVIDER_SESSION_BINDING_SCHEMA_VERSION { - return Err(submit_error( - "PLAN_NEEDS_RECONCILIATION", - "planning provider session binding schema 不受支持", - )); - } - validate_opaque_id(&binding.project_id, "binding.projectId", false)?; - validate_uuid_prefixed(&binding.gdd_id, "gdd-", "binding.gddId")?; - if binding.agent_id != GAME_CREATOR_PROJECT_PLANNING_AGENT_ID { - return Err(submit_error( - "PLAN_NEEDS_RECONCILIATION", - "planning provider session binding agentId 非法", - )); - } - validate_opaque_id(&binding.task_id, "binding.taskId", false)?; - if !is_provider_request_id(&binding.provider_request_id) { - return Err(submit_error( - "PLAN_NEEDS_RECONCILIATION", - "planning provider session binding providerRequestId 非法", - )); - } - validate_opaque_id(&binding.session_id, "binding.sessionId", false)?; - validate_opaque_id(&binding.run_id, "binding.runId", false)?; - if binding.root_agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { - return Err(submit_error( - "PLAN_NEEDS_RECONCILIATION", - "planning provider session binding rootAgentId 非法", - )); - } - validate_opaque_id(&binding.root_run_id, "binding.rootRunId", false)?; - validate_opaque_id(&binding.delegation_id, "binding.delegationId", false)?; - if binding.source != "agent-delegate" - || binding.run_profile != AGENT_RUNTIME_RUN_PROFILE_STANDARD - || !is_bare_fingerprint(&binding.run_profile_binding_fingerprint) - || binding.session_revision == 0 - || !is_typed_fingerprint(&binding.session_fingerprint) - || !matches!( - binding.request_kind.as_str(), - "tool-plan" | "final-reply" | "context-compaction" | "final-reply-context-compaction" - ) - || binding.request_slot.trim().is_empty() - || binding.web_search_enabled - || !is_typed_fingerprint(&binding.request_context_fingerprint) - { - return Err(submit_error( - "PLAN_NEEDS_RECONCILIATION", - "planning provider session binding 字段不满足 strict identity 合同", - )); - } - match binding.goal_id.as_deref() { - Some(goal_id) - if !goal_id.trim().is_empty() - && binding.goal_revision > 0 - && is_bare_fingerprint(&binding.goal_snapshot_fingerprint) => {} - None if binding.goal_revision == 0 && binding.goal_snapshot_fingerprint.is_empty() => {} - _ => { - return Err(submit_error( - "PLAN_NEEDS_RECONCILIATION", - "planning provider session binding Goal 三元组无效", - )); - } - } - let expected_provider_request_id = plan_provider_session_binding_expected_request_id(binding)?; - if binding.provider_request_id != expected_provider_request_id { - return Err(submit_error( - "PLAN_NEEDS_RECONCILIATION", - "planning provider session binding providerRequestId 与 canonical identity 不一致", - )); - } - if !is_typed_fingerprint(&binding.fingerprint) - || binding.fingerprint != plan_provider_session_binding_fingerprint(binding)? - { - return Err(submit_error( - "PLAN_NEEDS_RECONCILIATION", - "planning provider session binding fingerprint 与 canonical identity 不一致", - )); - } - Ok(()) -} - -/// Capture the same planning source/session identity that was used to build a -/// concrete Provider request. Unlike the batch-only helper above, this -/// variant takes the immutable request snapshot, so retry attempts and repair -/// slots cannot silently acquire a different provider request identity. -pub(crate) fn capture_plan_provider_session_binding_for_snapshot( - root: &std::path::Path, - runtime: &AgentRuntimeState, - snapshot: &AgentRuntimeProviderRequestSnapshot, - request_context_fingerprint: &str, -) -> Result { - let session = read_plan_session_with_recovery_locked(root) - .map_err(|error| error.to_string())? - .ok_or_else(|| "planning provider request 建立前缺少 durable session".to_string())?; - if snapshot.project_id != session.project_id - || snapshot.agent_id != runtime.agent_id - || snapshot.task_id != runtime.task_id - || snapshot.session_id != runtime.session_id - || snapshot.run_id != runtime.run_id - || snapshot.source != runtime.source - || snapshot.applied_steer_cursor != runtime.applied_steer_cursor - || !matches!( - snapshot.request_kind.as_str(), - "tool-plan" | "final-reply" | "context-compaction" | "final-reply-context-compaction" - ) - || (snapshot.agent_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID - && snapshot.web_search_enabled) - || session.agent_id != runtime.agent_id - || session.source != runtime.source - || session.run_profile != runtime.run_profile - || session.run_profile_binding_fingerprint != runtime.run_profile_binding_fingerprint - || session.session_id != runtime.session_id - || session.active_run_id.as_deref() != Some(runtime.run_id.as_str()) - || session.root_agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID - || session.applied_steer_cursor != runtime.applied_steer_cursor - { - return Err( - "planning Provider request snapshot 与当前 session/runtime identity 不匹配".to_string(), - ); - } - let delegation_id = runtime - .delegation_id - .clone() - .ok_or_else(|| "planning provider request 建立时缺少 delegationId".to_string())?; - if session.latest_delegation_id != delegation_id { - return Err("planning provider request 建立时 delegation identity 已漂移".to_string()); - } - if !is_typed_fingerprint(request_context_fingerprint) { - return Err("planning provider requestContextFingerprint 非法".to_string()); - } - if runtime.goal_id != snapshot.goal_id - || runtime.goal_revision != snapshot.goal_revision - || agent_goal_snapshot_fingerprint_for_state_at(root, runtime) - .map_err(|error| error.to_string())? - != snapshot.goal_snapshot_fingerprint - { - return Err("planning Provider request snapshot Goal identity 不匹配".to_string()); - } - let provider_request_id = plan_provider_request_id(&PlanProviderRequestIdentityValue { - project_id: &session.project_id, - gdd_id: &session.gdd_id, - agent_id: &snapshot.agent_id, - task_id: &snapshot.task_id, - session_id: &snapshot.session_id, - run_id: &snapshot.run_id, - root_agent_id: GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - root_run_id: &session.root_run_id, - delegation_id: &delegation_id, - source: &snapshot.source, - run_profile: &runtime.run_profile, - run_profile_binding_fingerprint: &runtime.run_profile_binding_fingerprint, - goal_id: snapshot.goal_id.as_deref(), - goal_revision: snapshot.goal_revision, - goal_snapshot_fingerprint: &snapshot.goal_snapshot_fingerprint, - session_revision: session.session_revision, - session_fingerprint: &session.session_fingerprint, - applied_steer_cursor: snapshot.applied_steer_cursor, - request_kind: &snapshot.request_kind, - request_slot: &snapshot.request_slot, - web_search_enabled: snapshot.web_search_enabled, - request_context_fingerprint, - }) - .map_err(|error| error.to_string())?; - let mut binding = PlanProviderSessionBindingV1 { - schema_version: PLAN_PROVIDER_SESSION_BINDING_SCHEMA_VERSION.to_string(), - project_id: session.project_id, - gdd_id: session.gdd_id, - agent_id: snapshot.agent_id.clone(), - task_id: snapshot.task_id.clone(), - provider_request_id, - session_id: snapshot.session_id.clone(), - run_id: snapshot.run_id.clone(), - root_agent_id: GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(), - root_run_id: session.root_run_id, - delegation_id, - goal_id: snapshot.goal_id.clone(), - goal_revision: snapshot.goal_revision, - goal_snapshot_fingerprint: snapshot.goal_snapshot_fingerprint.clone(), - source: snapshot.source.clone(), - run_profile: runtime.run_profile.clone(), - run_profile_binding_fingerprint: runtime.run_profile_binding_fingerprint.clone(), - session_revision: session.session_revision, - session_fingerprint: session.session_fingerprint, - applied_steer_cursor: snapshot.applied_steer_cursor, - request_kind: snapshot.request_kind.clone(), - request_slot: snapshot.request_slot.clone(), - web_search_enabled: snapshot.web_search_enabled, - request_context_fingerprint: request_context_fingerprint.to_string(), - fingerprint: String::new(), - }; - binding.fingerprint = - plan_provider_session_binding_fingerprint(&binding).map_err(|error| error.to_string())?; - validate_plan_provider_session_binding(&binding).map_err(|error| error.to_string())?; - Ok(binding) -} - -pub(crate) fn validate_plan_provider_session_binding_current_at( - root: &std::path::Path, - binding: &PlanProviderSessionBindingV1, -) -> Result<(), String> { - validate_plan_provider_session_binding(binding).map_err(|error| error.to_string())?; - let session = read_plan_session_with_recovery(root).map_err(|error| error.to_string())?; - validate_plan_provider_session_binding_against(root, binding, session) -} - -/// 调用方已经持有项目写锁时的同一道校验。 -/// -/// 不持锁的版本会在内部自己去抢项目写锁,在持锁上下文里必然拿不到;而拿不到的 -/// 错误会被上层包成 reconciliation 前缀,主循环见到该前缀直接静默返回,Run 既 -/// 不失败也不重试,父 run 于是永远等不到回执。所以持锁路径必须走这一支。 -pub(crate) fn validate_plan_provider_session_binding_current_at_locked( - root: &std::path::Path, - binding: &PlanProviderSessionBindingV1, -) -> Result<(), String> { - validate_plan_provider_session_binding(binding).map_err(|error| error.to_string())?; - let session = - read_plan_session_with_recovery_locked(root).map_err(|error| error.to_string())?; - validate_plan_provider_session_binding_against(root, binding, session) -} - -fn validate_plan_provider_session_binding_against( - root: &std::path::Path, - binding: &PlanProviderSessionBindingV1, - session: Option, -) -> Result<(), String> { - let session = session - .ok_or_else(|| "planning provider session 已丢失,不能创建 durable batch".to_string())?; - if session.project_id != binding.project_id - || session.gdd_id != binding.gdd_id - || session.agent_id != binding.agent_id - || session.source != binding.source - || session.run_profile != binding.run_profile - || session.run_profile_binding_fingerprint != binding.run_profile_binding_fingerprint - || session.root_agent_id != binding.root_agent_id - || session.root_run_id != binding.root_run_id - || session.session_id != binding.session_id - || session.session_revision != binding.session_revision - || session.session_fingerprint != binding.session_fingerprint - || session.latest_delegation_id != binding.delegation_id - || session.applied_steer_cursor != binding.applied_steer_cursor - { - return Err("planning provider session 在 batch 持久化前已漂移".to_string()); - } - let runtime = read_game_creator_agent_runtime_at(root, &binding.agent_id) - .map_err(|error| error.to_string())? - .state; - if runtime.agent_id != binding.agent_id - || runtime.task_id != binding.task_id - || runtime.session_id != binding.session_id - || runtime.run_id != binding.run_id - || runtime.source != binding.source - || runtime.run_profile != binding.run_profile - || runtime.run_profile_binding_fingerprint != binding.run_profile_binding_fingerprint - || runtime.parent_agent_id.as_deref() != Some(binding.root_agent_id.as_str()) - || runtime.parent_run_id.as_deref() != Some(binding.root_run_id.as_str()) - || runtime.delegation_id.as_deref() != Some(binding.delegation_id.as_str()) - || runtime.goal_id != binding.goal_id - || runtime.goal_revision != binding.goal_revision - || runtime.applied_steer_cursor != binding.applied_steer_cursor - { - return Err("planning provider session binding 与当前 Runtime/Goal 身份不一致".to_string()); - } - let child_binding = - validate_project_planning_child_binding_at(root, &binding.agent_id, &binding.run_id)?; - if child_binding.root_agent_id != binding.root_agent_id - || child_binding.root_run_id != binding.root_run_id - || child_binding.parent_agent_id.as_deref() != Some(binding.root_agent_id.as_str()) - || child_binding.parent_run_id.as_deref() != Some(binding.root_run_id.as_str()) - { - return Err("planning provider frozen binding 与当前委派根身份不一致".to_string()); - } - let current_goal_snapshot_fingerprint = - agent_goal_snapshot_fingerprint_for_state_at(root, &runtime) - .map_err(|error| error.to_string())?; - if current_goal_snapshot_fingerprint != binding.goal_snapshot_fingerprint { - return Err( - "planning provider session binding Goal snapshot 在 batch 持久化前已漂移".to_string(), - ); - } - Ok(()) -} - -/// Materialize the binding for one concrete Provider attempt. Retry identity -/// keeps the base binding, while lifecycle/batch records must point at the -/// actual attempt request ID and slot. This helper is the only place allowed -/// to derive that per-attempt identity. -pub(crate) fn plan_provider_session_binding_for_attempt( - base: &PlanProviderSessionBindingV1, - request_slot: &str, - provider_request_id: &str, -) -> Result { - validate_plan_provider_session_binding(base).map_err(|error| error.to_string())?; - let expected = if request_slot == base.request_slot { - base.provider_request_id.clone() - } else if let Some(attempt) = request_slot - .strip_prefix(base.request_slot.as_str()) - .and_then(|suffix| suffix.strip_prefix("-transient-")) - { - let attempt = attempt - .parse::() - .map_err(|_| "planning Provider retry requestSlot 的 attempt 无效".to_string())?; - if attempt == 0 || attempt > 64 { - return Err("planning Provider retry attempt 超出允许范围".to_string()); - } - plan_provider_request_attempt_id(&base.provider_request_id, attempt) - } else { - return Err("planning Provider attempt requestSlot 不是 base/transient 形状".to_string()); - }; - if expected != provider_request_id { - return Err("planning Provider attempt providerRequestId 与 binding 不一致".to_string()); - } - let mut binding = base.clone(); - binding.request_slot = request_slot.to_string(); - binding.provider_request_id = provider_request_id.to_string(); - binding.fingerprint = String::new(); - binding.fingerprint = - plan_provider_session_binding_fingerprint(&binding).map_err(|error| error.to_string())?; - validate_plan_provider_session_binding(&binding).map_err(|error| error.to_string())?; - Ok(binding) -} - -/// A protocol-repair request may change its request slot and request-context -/// fingerprint, but it must remain on the exact source session/Run/Goal -/// lineage captured for repair-0. Checking this before a later repair is -/// sent prevents a newer session from being attached to an object derived -/// from the older request. -pub(crate) fn validate_plan_provider_session_binding_repair_lineage( - initial: &PlanProviderSessionBindingV1, - candidate: &PlanProviderSessionBindingV1, -) -> Result<(), String> { - validate_plan_provider_session_binding(initial).map_err(|error| error.to_string())?; - validate_plan_provider_session_binding(candidate).map_err(|error| error.to_string())?; - if initial.schema_version != candidate.schema_version - || initial.project_id != candidate.project_id - || initial.gdd_id != candidate.gdd_id - || initial.agent_id != candidate.agent_id - || initial.task_id != candidate.task_id - || initial.session_id != candidate.session_id - || initial.run_id != candidate.run_id - || initial.root_agent_id != candidate.root_agent_id - || initial.root_run_id != candidate.root_run_id - || initial.delegation_id != candidate.delegation_id - || initial.goal_id != candidate.goal_id - || initial.goal_revision != candidate.goal_revision - || initial.goal_snapshot_fingerprint != candidate.goal_snapshot_fingerprint - || initial.source != candidate.source - || initial.run_profile != candidate.run_profile - || initial.run_profile_binding_fingerprint != candidate.run_profile_binding_fingerprint - || initial.session_revision != candidate.session_revision - || initial.session_fingerprint != candidate.session_fingerprint - || initial.applied_steer_cursor != candidate.applied_steer_cursor - || initial.request_kind != candidate.request_kind - || initial.web_search_enabled != candidate.web_search_enabled - { - return Err( - "planning Provider repair 请求与 repair-0 的 source session lineage 不一致".to_string(), - ); - } - Ok(()) -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct PlanProviderRequestAttemptIdentityValue<'a> { - base_provider_request_id: &'a str, - attempt: usize, -} - -pub(crate) fn plan_provider_request_attempt_id(base_request_id: &str, attempt: usize) -> String { - if attempt == 0 { - return base_request_id.to_string(); - } - let value = PlanProviderRequestAttemptIdentityValue { - base_provider_request_id: base_request_id, - attempt, - }; - let bytes = typed_serde_canonical_bytes(PLAN_PROVIDER_REQUEST_ATTEMPT_ID_DOMAIN, &value) - .expect("serializing a typed Provider request attempt identity cannot fail"); - format!("provider-request-{:x}", Sha256::digest(bytes)) -} -#[derive(Clone, Debug, Eq, PartialEq)] -pub(crate) struct PlanSubmitGddRuntimeContext { - pub(crate) project_id: String, - pub(crate) gdd_id: String, - pub(crate) action_id: String, - pub(crate) action_fingerprint: String, - pub(crate) agent_id: String, - pub(crate) source: String, - pub(crate) run_profile: String, - pub(crate) run_profile_binding_fingerprint: String, - pub(crate) root_agent_id: String, - pub(crate) root_run_id: String, - pub(crate) parent_agent_id: Option, - pub(crate) parent_run_id: Option, - pub(crate) delegation_id: String, - pub(crate) session_id: String, - pub(crate) source_session_revision: u32, - pub(crate) source_session_fingerprint: String, - pub(crate) created_by_run_id: String, - pub(crate) created_at_utc: String, - /// Runtime may provide a preallocated approval request identity. If it - /// is absent, the handler allocates one exactly once before the durable - /// GDD create; replay reads the existing identity and never regenerates a - /// version. - pub(crate) approval_request_id: Option, -} - -#[derive(Clone, Debug, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct PlanSubmitGddResultV1 { - /// `submitted` means this call created the immutable GDD fact; - /// `replayed` means the same durable action identity was already created. - pub(crate) outcome: String, - pub(crate) gdd_ref: PlanGddRef, - pub(crate) pending_action_id: String, - pub(crate) approval_request_id: String, - pub(crate) recovery_pending: bool, -} - -impl PlanSubmitGddResultV1 { - fn from_gdd(gdd: &PlanGddV1, replayed: bool, recovery_pending: bool) -> Self { - Self { - outcome: if replayed { - "replayed".to_string() - } else { - "submitted".to_string() - }, - gdd_ref: PlanGddRef { - gdd_id: gdd.gdd_id.clone(), - version: gdd.version, - fingerprint: gdd.fingerprint.clone(), - }, - pending_action_id: gdd.submission_id.clone(), - approval_request_id: gdd.approval_request_id.clone(), - recovery_pending, - } - } -} - -fn submit_error(code: &'static str, detail: impl Into) -> PlanningStorageError { - PlanningStorageError::new(code, detail) -} - -/// A limit derived from existing immutable authority is not feedback the -/// Provider can correct by submitting the same turn again. Preserve input-side -/// limits, but turn the durable-authority branch into reconciliation before it -/// reaches the main-loop retry classifier. -fn existing_planning_authority_error( - error: PlanningStorageError, - authority: &str, -) -> PlanningStorageError { - match error.code() { - "PLAN_SIZE_LIMIT" => submit_error( - "PLAN_NEEDS_RECONCILIATION", - format!("{authority} 读取超出大小上限,不能作为 Provider submit 重试处理"), - ), - "PLAN_VERSION_LIMIT_REACHED" => submit_error( - "PLAN_NEEDS_RECONCILIATION", - format!("{authority} 已达版本上限,不能作为 Provider submit 重试处理"), - ), - _ => error, - } -} - -fn session_recovery_error(error: &PlanningStorageError) -> PlanningStorageError { - submit_error( - "PLAN_SESSION_RECOVERY_REQUIRED", - format!( - "planning session 需要恢复后才能继续(kind={})", - error.code() - ), - ) -} - -fn validate_runtime_context( - context: &PlanSubmitGddRuntimeContext, -) -> Result<(), PlanningStorageError> { - validate_opaque_id(&context.project_id, "runtime.projectId", false)?; - validate_uuid_prefixed(&context.gdd_id, "gdd-", "runtime.gddId")?; - validate_action_id(&context.action_id, "runtime.actionId")?; - if !is_bare_fingerprint(&context.action_fingerprint) { - return Err(submit_error( - "PLAN_INVALID_REQUEST", - "Runtime actionFingerprint 必须是 64 位小写裸 digest", - )); - } - if context.agent_id != GAME_CREATOR_PROJECT_PLANNING_AGENT_ID - || context.source != "agent-delegate" - || context.run_profile != AGENT_RUNTIME_RUN_PROFILE_STANDARD - || context.root_agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID - || context.parent_agent_id.as_deref() != Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID) - || context.parent_run_id.as_deref() != Some(context.root_run_id.as_str()) - { - return Err(submit_error( - "PLAN_SOURCE_PROFILE_MISMATCH", - "plan.submit_gdd 只能由 project-planning 的 agent-delegate standard 子 Run 调用", - )); - } - if !is_bare_fingerprint(&context.run_profile_binding_fingerprint) { - return Err(submit_error( - "PLAN_SOURCE_PROFILE_MISMATCH", - "Runtime run-profile binding fingerprint 非法", - )); - } - validate_opaque_id(&context.root_run_id, "runtime.rootRunId", false)?; - validate_opaque_id( - &context.parent_run_id.clone().unwrap_or_default(), - "runtime.parentRunId", - false, - )?; - validate_opaque_id(&context.delegation_id, "runtime.delegationId", false)?; - validate_opaque_id(&context.session_id, "runtime.sessionId", false)?; - validate_opaque_id(&context.created_by_run_id, "runtime.createdByRunId", false)?; - if context.source_session_revision == 0 - || !is_typed_fingerprint(&context.source_session_fingerprint) - { - return Err(submit_error( - "PLAN_SESSION_CAS_CONFLICT", - "Runtime source session revision/fingerprint 无效", - )); - } - validate_timestamp(&context.created_at_utc, "runtime.createdAtUtc")?; - if let Some(approval_request_id) = context.approval_request_id.as_deref() { - validate_uuid_prefixed( - approval_request_id, - "gdd-approval-", - "runtime.approvalRequestId", - )?; - } - Ok(()) -} - -/// Convert a strict Provider payload into the durable GDD envelope. All -/// fields not present in the Provider payload (platform facts, identity, -/// version, timestamp and fingerprints) are supplied by the Runtime context. -pub(crate) fn build_plan_gdd_from_submit_input( - input: &PlanSubmitGddInputV1, - context: &PlanSubmitGddRuntimeContext, - version: u32, - approval_request_id: &str, -) -> Result { - validate_plan_submit_gdd_input(input) - .map_err(|error| submit_error("PLAN_INVALID_REQUEST", error.to_string()))?; - validate_runtime_context(context)?; - validate_uuid_prefixed(approval_request_id, "gdd-approval-", "approvalRequestId")?; - if !(1..=PLAN_MAX_VERSIONS).contains(&version) { - return Err(submit_error( - "PLAN_VERSION_LIMIT_REACHED", - "GDD version 超出 1..=128", - )); - } - - let game = PlanGddGame { - title: input.game.title.clone(), - genre: input.game.genre.clone(), - art_style: input.game.art_style.clone(), - one_liner: input.game.one_liner.clone(), - pillars: input - .game - .pillars - .iter() - .map(|pillar| PlanPillar { - name: pillar.name.clone(), - player_feel: pillar.player_feel.clone(), - mechanism: pillar.mechanism.clone(), - decision_state: pillar.decision_state.clone(), - basis: None, - }) - .collect(), - core_loop: input.game.core_loop.clone(), - target_users: input.game.target_users.clone(), - platform_facts: fixed_plan_platform_facts(), - mvp_systems: input - .game - .mvp_systems - .iter() - .map(|system| PlanMvpSystem { - system: system.system.clone(), - minimal_function: system.minimal_function.clone(), - why_required: system.why_required.clone(), - verify_method: system.verify_method.clone(), - decision_state: system.decision_state.clone(), - basis: None, - }) - .collect(), - out_of_scope: input.game.out_of_scope.clone(), - creator_tips: input.game.creator_tips.clone(), - }; - let decisions = input - .decisions - .iter() - .map(|decision| PlanDecision { - id: decision.id.clone(), - topic: decision.topic.clone(), - state: decision.state.clone(), - answer_source: decision.answer_source.clone(), - round: decision.round, - answer_summary: decision.answer_summary.clone(), - basis: None, - }) - .collect(); - let mut gdd = PlanGddV1 { - schema_version: PLAN_GDD_SCHEMA_VERSION.to_string(), - project_id: context.project_id.clone(), - gdd_id: context.gdd_id.clone(), - version, - submission_id: context.action_id.clone(), - approval_request_id: approval_request_id.to_string(), - action_fingerprint: context.action_fingerprint.clone(), - agent_id: context.agent_id.clone(), - source: context.source.clone(), - run_profile: context.run_profile.clone(), - run_profile_binding_fingerprint: context.run_profile_binding_fingerprint.clone(), - root_agent_id: context.root_agent_id.clone(), - root_run_id: context.root_run_id.clone(), - delegation_id: context.delegation_id.clone(), - session_id: context.session_id.clone(), - source_session_revision: context.source_session_revision, - source_session_fingerprint: context.source_session_fingerprint.clone(), - created_by_run_id: context.created_by_run_id.clone(), - created_at_utc: context.created_at_utc.clone(), - game, - decisions, - prototype_validation_items: input.prototype_validation_items.clone(), - // The shape validator intentionally requires a typed fingerprint even - // while computing the canonical digest. Seed a non-semantic - // placeholder; `PlanGddFingerprintValue` excludes this field from the - // hashed payload and the computed value replaces it immediately. - fingerprint: format!("sha256-serde-json-v2:{}", "0".repeat(64)), - }; - gdd.fingerprint = plan_gdd_fingerprint(&gdd)?; - Ok(gdd) -} - -fn submit_input_from_gdd(gdd: &PlanGddV1) -> PlanSubmitGddInputV1 { - PlanSubmitGddInputV1 { - schema_version: PLAN_SUBMIT_INPUT_SCHEMA.to_string(), - game: PlanSubmitGame { - title: gdd.game.title.clone(), - genre: gdd.game.genre.clone(), - art_style: gdd.game.art_style.clone(), - one_liner: gdd.game.one_liner.clone(), - pillars: gdd - .game - .pillars - .iter() - .map(|pillar| PlanSubmitPillar { - name: pillar.name.clone(), - player_feel: pillar.player_feel.clone(), - mechanism: pillar.mechanism.clone(), - decision_state: pillar.decision_state.clone(), - }) - .collect(), - core_loop: gdd.game.core_loop.clone(), - target_users: gdd.game.target_users.clone(), - mvp_systems: gdd - .game - .mvp_systems - .iter() - .map(|system| PlanSubmitMvpSystem { - system: system.system.clone(), - minimal_function: system.minimal_function.clone(), - why_required: system.why_required.clone(), - verify_method: system.verify_method.clone(), - decision_state: system.decision_state.clone(), - }) - .collect(), - out_of_scope: gdd.game.out_of_scope.clone(), - creator_tips: gdd.game.creator_tips.clone(), - }, - decisions: gdd - .decisions - .iter() - .map(|decision| PlanSubmitDecision { - id: decision.id.clone(), - topic: decision.topic.clone(), - state: decision.state.clone(), - answer_source: decision.answer_source.clone(), - round: decision.round, - answer_summary: decision.answer_summary.clone(), - }) - .collect(), - prototype_validation_items: gdd.prototype_validation_items.clone(), - } -} - -fn submit_payload_matches_gdd( - input: &PlanSubmitGddInputV1, - gdd: &PlanGddV1, -) -> Result { - validate_plan_submit_gdd_input(input)?; - Ok(*input == submit_input_from_gdd(gdd)) -} - -fn gdd_submit_identity_matches(gdd: &PlanGddV1, context: &PlanSubmitGddRuntimeContext) -> bool { - gdd.submission_id == context.action_id - && gdd.action_fingerprint == context.action_fingerprint - && gdd.project_id == context.project_id - && gdd.agent_id == context.agent_id - && gdd.source == context.source - && gdd.run_profile == context.run_profile - && gdd.run_profile_binding_fingerprint == context.run_profile_binding_fingerprint - && gdd.root_agent_id == context.root_agent_id - && gdd.root_run_id == context.root_run_id - && gdd.delegation_id == context.delegation_id - && gdd.session_id == context.session_id - && gdd.source_session_revision == context.source_session_revision - && gdd.source_session_fingerprint == context.source_session_fingerprint - && gdd.created_by_run_id == context.created_by_run_id - && context - .approval_request_id - .as_deref() - .is_none_or(|approval_request_id| gdd.approval_request_id == approval_request_id) -} - -fn session_identity_matches_context( - session: &PlanSessionV1, - context: &PlanSubmitGddRuntimeContext, -) -> bool { - session.project_id == context.project_id - && session.gdd_id == context.gdd_id - && session.agent_id == context.agent_id - && session.source == context.source - && session.run_profile == context.run_profile - && session.run_profile_binding_fingerprint == context.run_profile_binding_fingerprint - && session.root_agent_id == context.root_agent_id - && session.root_run_id == context.root_run_id - && session.session_id == context.session_id -} - -fn build_submit_session_successor( - previous: &PlanSessionV1, - context: &PlanSubmitGddRuntimeContext, - gdd: &PlanGddV1, -) -> Result { - let mut next = previous.clone(); - next.session_revision = previous - .session_revision - .checked_add(1) - .ok_or_else(|| submit_error("PLAN_SESSION_CAS_CONFLICT", "sessionRevision 溢出"))?; - next.previous_fingerprint = Some(previous.session_fingerprint.clone()); - next.active_run_id = None; - next.last_run_id = context.created_by_run_id.clone(); - next.latest_delegation_id = context.delegation_id.clone(); - next.phase = "awaiting_gdd_approval".to_string(); - next.latest_submitted_ref = Some(PlanGddRef { - gdd_id: gdd.gdd_id.clone(), - version: gdd.version, - fingerprint: gdd.fingerprint.clone(), - }); - next.last_decision_ref = None; - next.updated_at_utc = context.created_at_utc.clone(); - next.session_fingerprint = plan_session_fingerprint(&next)?; - validate_plan_session_successor(previous, &next)?; - Ok(next) -} - -fn validate_current_session_cas( - session: &PlanSessionV1, - context: &PlanSubmitGddRuntimeContext, -) -> Result<(), PlanningStorageError> { - if !session_identity_matches_context(session, context) { - return Err(submit_error( - "PLAN_SOURCE_PROFILE_MISMATCH", - "当前 planning session 身份与 Runtime 不一致", - )); - } - if session.session_revision != context.source_session_revision - || session.session_fingerprint != context.source_session_fingerprint - { - return Err(submit_error( - "PLAN_SESSION_CAS_CONFLICT", - "planning session 已被其它动作推进", - )); - } - if session.active_run_id.as_deref() != Some(context.created_by_run_id.as_str()) { - return Err(submit_error( - "PLAN_SOURCE_PROFILE_MISMATCH", - "plan.submit_gdd 必须绑定当前活跃策划子 Run", - )); - } - if session.phase != "collecting" { - return Err(submit_error( - "PLAN_PENDING_GDD_EXISTS", - "当前 planning session 仍有未决 GDD", - )); - } - if session.latest_delegation_id != context.delegation_id { - return Err(submit_error( - "PLAN_SOURCE_PROFILE_MISMATCH", - "submit action 的 delegation identity 未逐字匹配当前 session", - )); - } - Ok(()) -} - -/// `user_revision` 只证明审批意见,不证明澄清。首次 collecting、澄清续跑和没有待处理 -/// 用户修订的普通质量返工 session 都没有 revise/reject `lastDecisionRef`;用户修订周期 -/// 内的 continuation(包括其质量返工)会把该引用带到新的 collecting successor 上。 -/// continuation 的直接父边由 planning coordinator 校验,replay 不走这里。 -fn validate_user_revision_requires_approval_decision( - session: &PlanSessionV1, - input: &PlanSubmitGddInputV1, -) -> Result<(), PlanningStorageError> { - if !input - .decisions - .iter() - .any(|decision| decision.answer_source == "user_revision") - { - return Ok(()); - } - if session - .last_decision_ref - .as_ref() - .is_some_and(|reference| matches!(reference.action.as_str(), "revise" | "reject")) - { - return Ok(()); - } - Err(submit_error( - "PLAN_INVALID_REQUEST", - "user_revision 只能用于当前 session 已有 revise/reject 审批决定的续跑提交", - )) -} - -fn validate_durable_child_binding( - root: &std::path::Path, - context: &PlanSubmitGddRuntimeContext, -) -> Result<(), PlanningStorageError> { - let binding = validate_project_planning_child_binding_at( - root, - &context.agent_id, - &context.created_by_run_id, - ) - .map_err(|error| submit_error("PLAN_SOURCE_PROFILE_MISMATCH", error))?; - if binding.project_id != context.project_id - || binding.root_agent_id != context.root_agent_id - || binding.root_run_id != context.root_run_id - || binding.parent_agent_id != context.parent_agent_id - || binding.parent_run_id != context.parent_run_id - || binding.source != context.source - || binding.profile != context.run_profile - || binding.binding_fingerprint != context.run_profile_binding_fingerprint - { - return Err(submit_error( - "PLAN_SOURCE_PROFILE_MISMATCH", - "当前策划子 Run 的 durable run-profile binding 与 action identity 不一致", - )); - } - Ok(()) -} - -fn generated_approval_request_id() -> String { - format!("gdd-approval-{}", Uuid::new_v4().hyphenated()) -} - -/// Return the fixed UTC millisecond timestamp used by Runtime-owned planning -/// projections. Keeping this helper here makes tests able to inject a fixed -/// timestamp while production callers can use the same formatting contract. -pub(crate) fn current_plan_timestamp_utc() -> String { - let millis = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_millis(); - let seconds = millis / 1_000; - let millis_part = millis % 1_000; - let days = seconds / 86_400; - let day_seconds = seconds % 86_400; - let hour = day_seconds / 3_600; - let minute = (day_seconds % 3_600) / 60; - let second = day_seconds % 60; - - // Civil-from-days, Gregorian calendar (Howard Hinnant algorithm). - let z = days as i64 + 719_468; - let era = if z >= 0 { z } else { z - 146_096 } / 146_097; - let doe = z - era * 146_097; - let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; - let year = yoe + era * 400; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let day = doy - (153 * mp + 2) / 5 + 1; - let month = mp + if mp < 10 { 3 } else { -9 }; - let year = year + i64::from(month <= 2); - format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}.{millis_part:03}Z") -} - -/// Main-loop adapter: build the durable submit context only from the current -/// Runtime, its already-persisted action identity and the current plan session. -/// The core handler re-reads the session under the commit lock, so the -/// optimistic snapshot taken here is a CAS input rather than a second source -/// of truth. -pub(crate) fn execute_plan_submit_gdd_for_pending_action( - root: &std::path::Path, - runtime: &AgentRuntimeState, - pending: &AgentRuntimePendingToolAction, -) -> Result { - if pending.action.tool.trim() != PLAN_SUBMIT_GDD_TOOL - || pending.agent_id != runtime.agent_id - || pending.task_id != runtime.task_id - || pending.session_id != runtime.session_id - || pending.run_id != runtime.run_id - || pending.source != runtime.source - || pending.run_profile != runtime.run_profile - || pending.run_profile_binding_fingerprint != runtime.run_profile_binding_fingerprint - { - return Err(submit_error( - "PLAN_SUBMISSION_IDENTITY_CONFLICT", - "prepared action 与当前 Runtime identity 不一致", - )); - } - let input = serde_json::from_value::(pending.action.input.clone()) - .map_err(|error| submit_error("PLAN_INVALID_REQUEST", error.to_string()))?; - // This both validates the complete nested shape and enforces the 64 KiB - // canonical payload limit. The Provider parser has already rejected - // duplicate JSON keys before the action becomes a `Value`. - canonical_plan_submit_gdd_input_bytes(&input) - .map_err(|error| submit_error("PLAN_INVALID_REQUEST", error.to_string()))?; - - let frozen_binding = pending.planning_session_binding.as_ref().ok_or_else(|| { - submit_error( - "PLAN_NEEDS_RECONCILIATION", - "plan.submit_gdd pending action 缺少 frozen provider session binding", - ) - })?; - validate_plan_provider_session_binding(frozen_binding)?; - if frozen_binding.project_id - != game_creator_agent_runtime_context_project_id(root) - .map_err(|error| submit_error("PLAN_SOURCE_PROFILE_MISMATCH", error))? - || frozen_binding.agent_id != pending.agent_id - || frozen_binding.task_id != pending.task_id - || frozen_binding.session_id != pending.session_id - || frozen_binding.run_id != pending.run_id - || frozen_binding.source != pending.source - || frozen_binding.run_profile != pending.run_profile - || frozen_binding.run_profile_binding_fingerprint != pending.run_profile_binding_fingerprint - || frozen_binding.applied_steer_cursor != pending.planned_steer_cursor - || frozen_binding.root_agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID - || frozen_binding.request_kind != "tool-plan" - { - return Err(submit_error( - "PLAN_SUBMISSION_IDENTITY_CONFLICT", - "pending action 与 frozen provider session binding 不一致", - )); - } - - let child_binding = - validate_project_planning_child_binding_at(root, &runtime.agent_id, &runtime.run_id) - .map_err(|error| submit_error("PLAN_SOURCE_PROFILE_MISMATCH", error))?; - let durable_task = read_latest_game_creator_agent_runtime_task_by_run_id( - root, - &runtime.agent_id, - &runtime.run_id, - ) - .map_err(|error| submit_error("PLAN_SOURCE_PROFILE_MISMATCH", error))? - .ok_or_else(|| { - submit_error( - "PLAN_SOURCE_PROFILE_MISMATCH", - "project-planning 子 Run 缺少 durable task identity", - ) - })?; - if child_binding.project_id != frozen_binding.project_id - || child_binding.root_run_id != frozen_binding.root_run_id - || durable_task.task_id != frozen_binding.task_id - || durable_task.session_id != frozen_binding.session_id - || durable_task.run_id != frozen_binding.run_id - || durable_task.delegation_id.as_deref() != Some(frozen_binding.delegation_id.as_str()) - { - return Err(submit_error( - "PLAN_SOURCE_PROFILE_MISMATCH", - "planning child durable binding/task 与 frozen provider identity 不一致", - )); - } - // For a new submission, all source identity comes from the frozen - // binding. Current session is only a CAS check in the core handler; it - // must never be used to reinterpret an older Provider response. - let gdd_id = frozen_binding.gdd_id.clone(); - let delegation_id = frozen_binding.delegation_id.clone(); - let context = PlanSubmitGddRuntimeContext { - project_id: frozen_binding.project_id.clone(), - gdd_id, - action_id: pending.action_id.clone(), - action_fingerprint: pending.action_fingerprint.clone(), - agent_id: runtime.agent_id.clone(), - source: runtime.source.clone(), - run_profile: runtime.run_profile.clone(), - run_profile_binding_fingerprint: runtime.run_profile_binding_fingerprint.clone(), - root_agent_id: frozen_binding.root_agent_id.clone(), - root_run_id: frozen_binding.root_run_id.clone(), - parent_agent_id: runtime.parent_agent_id.clone(), - parent_run_id: runtime.parent_run_id.clone(), - delegation_id, - session_id: frozen_binding.session_id.clone(), - source_session_revision: frozen_binding.session_revision, - source_session_fingerprint: frozen_binding.session_fingerprint.clone(), - created_by_run_id: runtime.run_id.clone(), - created_at_utc: current_plan_timestamp_utc(), - approval_request_id: Some(plan_provider_approval_request_id( - &pending.action_id, - &pending.action_fingerprint, - &frozen_binding.session_fingerprint, - )), - }; - execute_plan_submit_gdd(root, &context, &input) -} - -fn markdown_escape(value: &str) -> String { - value - .replace('\\', "\\\\") - .replace('`', "\\`") - .replace('*', "\\*") - .replace('_', "\\_") - .replace('[', "\\[") - .replace(']', "\\]") - .replace('<', "\\<") - .replace('>', "\\>") - .replace('|', "\\|") -} - -fn markdown_bullets(values: &[String]) -> String { - values - .iter() - .map(|value| format!("- {}", markdown_escape(value))) - .collect::>() - .join("\n") -} - -/// Render a deterministic, human-readable projection. The JSON GDD remains -/// the authority; this function never parses Markdown back into facts. -pub(crate) fn render_plan_fast_gdd_markdown( - gdd: &PlanGddV1, - status: &str, -) -> Result { - validate_plan_gdd(gdd)?; - validate_text(status, "GDD status", 1, 64)?; - let mut markdown = String::new(); - markdown.push_str("# "); - markdown.push_str(&markdown_escape(&gdd.game.title)); - markdown.push_str("\n\n"); - markdown.push_str(&format!( - "> Fast GDD v{} · 状态:{}\n> gddId:`{}`\n> fingerprint:`{}`\n> approvalRequestId:`{}`\n\n", - gdd.version, - markdown_escape(status), - gdd.gdd_id, - gdd.fingerprint, - gdd.approval_request_id - )); - - markdown.push_str("## 决定状态\n\n"); - for decision in &gdd.decisions { - markdown.push_str(&format!( - "- **{}**({},{},第 {} 轮):{}\n", - markdown_escape(&decision.topic), - markdown_escape(&decision.state), - markdown_escape(&decision.answer_source), - decision.round, - markdown_escape(&decision.answer_summary) - )); - } - markdown.push_str("\n## 一句话描述\n\n"); - markdown.push_str(&markdown_escape(&gdd.game.one_liner)); - markdown.push_str("\n\n## 游戏分类与美术\n\n"); - markdown.push_str(&format!( - "- 主类型:{}\n- 融合类型:{}\n- 视觉类型:{}\n- 关键词:{}\n- 色彩氛围:{}\n- MVP 美术边界:{}\n", - markdown_escape(&gdd.game.genre.primary), - gdd.game - .genre - .fusion - .as_deref() - .map(markdown_escape) - .unwrap_or_else(|| "无".to_string()), - markdown_escape(&gdd.game.art_style.visual_type), - markdown_escape(&gdd.game.art_style.keywords.join("、")), - markdown_escape(&gdd.game.art_style.mood_and_color), - markdown_escape(&gdd.game.art_style.mvp_art_boundary) - )); - markdown.push_str("\n## 游戏支柱\n\n"); - for pillar in &gdd.game.pillars { - markdown.push_str(&format!( - "### {}\n\n- 玩家感受:{}\n- 机制:{}\n- 决定状态:{}\n\n", - markdown_escape(&pillar.name), - markdown_escape(&pillar.player_feel), - markdown_escape(&pillar.mechanism), - markdown_escape(&pillar.decision_state) - )); - } - markdown.push_str("## 核心循环\n\n"); - for (index, step) in gdd.game.core_loop.iter().enumerate() { - markdown.push_str(&format!("{}. {}\n", index + 1, markdown_escape(step))); - } - markdown.push_str("\n## 目标用户\n\n"); - markdown.push_str(&format!( - "- 核心用户:{}\n- 偏好:{}\n- 单局时长:{}\n- 参考游戏:{}\n", - markdown_escape(&gdd.game.target_users.core_users), - markdown_escape(&gdd.game.target_users.preferences), - markdown_escape(&gdd.game.target_users.session_length), - if gdd.game.target_users.reference_games.is_empty() { - "无".to_string() - } else { - markdown_escape(&gdd.game.target_users.reference_games.join("、")) - } - )); - markdown.push_str("\n## Runtime 平台事实\n\n"); - markdown.push_str(&format!( - "- Runtime:{}\n- 视口:{}\n- 输入:{}\n- 预览:{}\n", - markdown_escape(&gdd.game.platform_facts.runtime), - markdown_escape(&gdd.game.platform_facts.viewports.join(" / ")), - markdown_escape(&gdd.game.platform_facts.inputs.join(" / ")), - markdown_escape(&gdd.game.platform_facts.preview) - )); - markdown.push_str("\n## MVP 系统\n\n"); - for system in &gdd.game.mvp_systems { - markdown.push_str(&format!( - "### {}\n\n- 最小功能:{}\n- 必要原因:{}\n- 验证方式:{}\n- 决定状态:{}\n\n", - markdown_escape(&system.system), - markdown_escape(&system.minimal_function), - markdown_escape(&system.why_required), - markdown_escape(&system.verify_method), - markdown_escape(&system.decision_state) - )); - } - markdown.push_str("## 制作边界\n\n"); - markdown.push_str(&markdown_bullets(&gdd.game.out_of_scope)); - markdown.push_str("\n\n## 创作者提示\n\n"); - markdown.push_str(&format!( - "- 先做:{}\n- 暂缓:{}\n- 如何验证:{}\n- 何时扩展:{}\n", - markdown_escape(&gdd.game.creator_tips.do_first), - markdown_escape(&gdd.game.creator_tips.defer_for_now), - markdown_escape(&gdd.game.creator_tips.how_to_verify), - markdown_escape(&gdd.game.creator_tips.expand_when) - )); - if !gdd.prototype_validation_items.is_empty() { - markdown.push_str("\n## 原型验证项\n\n"); - for item in &gdd.prototype_validation_items { - markdown.push_str(&format!( - "### {}\n\n- 问题:{}\n- 微型原型:{}\n- 观察:{}\n- 通过标准:{}\n\n", - markdown_escape(&item.id), - markdown_escape(&item.question), - markdown_escape(&item.micro_prototype), - markdown_escape(&item.observation), - markdown_escape(&item.pass_criterion) - )); - } - } - if markdown.as_bytes().len() > PLAN_FAST_GDD_MAX_BYTES { - return Err(submit_error( - "PLAN_SIZE_LIMIT", - "渲染后的 Fast GDD Markdown 超出大小上限", - )); - } - Ok(markdown) -} - -fn project_submit_successors_locked( - root: &std::path::Path, - context: &PlanSubmitGddRuntimeContext, - gdd: &PlanGddV1, - previous_session: Option<&PlanSessionV1>, -) -> bool { - let mut recovery_pending = false; - let chain = match read_plan_gdd_chain_locked(root) { - Ok(chain) => chain, - Err(_) => { - return true; - } - }; - // A submit replay may be repairing a projection written for the previous - // lineage length. Rebuild from the complete current GDD/receipt facts on - // every committed submit so a newly created vN can never leave a vN-1 - // index behind. - let index = match build_plan_gdd_index_for_root_locked(root, &chain, &context.created_at_utc) { - Ok(index) => { - if write_plan_gdd_index_atomic_locked(root, &index).is_err() { - recovery_pending = true; - } - Some(index) - } - Err(_) => { - recovery_pending = true; - None - } - }; - // Markdown is a projection of the current latest authority, not of the - // action being replayed. Replaying an older submission must never roll - // `game/fast_gdd.md` back over a newer immutable version. - let projection_gdd = chain.last().unwrap_or(gdd); - let projection_status = index - .as_ref() - .and_then(|index| { - index - .status_cache - .versions - .iter() - .find(|status| status.version == projection_gdd.version) - }) - .map(|status| status.status.as_str()) - .unwrap_or("ready_for_approval"); - if let Ok(markdown) = render_plan_fast_gdd_markdown(projection_gdd, projection_status) { - if write_plan_fast_gdd_markdown_atomic_locked(root, &markdown).is_err() { - recovery_pending = true; - } - } else { - recovery_pending = true; - } - if let Some(previous_session) = previous_session { - let session_identity_matches_gdd = previous_session.project_id == gdd.project_id - && previous_session.gdd_id == gdd.gdd_id - && previous_session.agent_id == gdd.agent_id - && previous_session.source == gdd.source - && previous_session.run_profile == gdd.run_profile - && previous_session.run_profile_binding_fingerprint - == gdd.run_profile_binding_fingerprint - && previous_session.root_agent_id == gdd.root_agent_id - && previous_session.root_run_id == gdd.root_run_id - && previous_session.session_id == gdd.session_id; - let same_ref = session_identity_matches_gdd - && previous_session - .latest_submitted_ref - .as_ref() - .is_some_and(|reference| { - reference.gdd_id == gdd.gdd_id - && reference.version == gdd.version - && reference.fingerprint == gdd.fingerprint - }) - && previous_session.session_revision == gdd.source_session_revision.saturating_add(1) - && previous_session.previous_fingerprint.as_deref() - == Some(gdd.source_session_fingerprint.as_str()) - && previous_session.active_run_id.is_none() - && previous_session.last_run_id == gdd.created_by_run_id - && previous_session.latest_delegation_id == gdd.delegation_id - && previous_session.phase == "awaiting_gdd_approval" - && previous_session.last_decision_ref.is_none(); - // A replay may repair a session projection that was interrupted after - // the GDD create, but it must never overwrite a different legal - // successor. The only safe forward path is an exact source-session - // snapshot (including the still-active child run) or an already - // projected session pointing at this immutable ref. - // These judgements are deliberately the same set `validate_current_session_cas` - // already enforced at the submit gate. Do not narrow them with a - // `latest_submitted_ref.is_none()` style assertion: revision plus the - // recomputed `sessionFingerprint` (`validate_plan_session` rejects a session - // whose fingerprint does not hash its own content) already pin the session to - // the exact snapshot the submitter observed, so any extra field-shape check - // only re-encodes the obsolete "one submission per lineage" rule. A user - // revision round legitimately arrives carrying the previous version's - // `latestSubmittedRef`; refusing it strands a committed GDD behind a - // `recoveryPending` that no replay can clear. - let source_session_matches_gdd = session_identity_matches_gdd - && previous_session.session_revision == gdd.source_session_revision - && previous_session.session_fingerprint == gdd.source_session_fingerprint - && previous_session.active_run_id.as_deref() == Some(gdd.created_by_run_id.as_str()); - if !session_identity_matches_gdd { - recovery_pending = true; - } else if same_ref { - // The ref is already installed. Only repair derived projections; - // do not create another session revision on replay. - } else if !source_session_matches_gdd { - // Never regress a newer session projection to an older replay. - recovery_pending = true; - } else { - match build_submit_session_successor(previous_session, context, gdd) - .and_then(|next| write_plan_session_atomic_locked(root, &next)) - { - Ok(()) => {} - Err(_) => recovery_pending = true, - } - } - } else { - recovery_pending = true; - } - recovery_pending -} - -/// Execute the dedicated submit point. All reads and mutation occur under a -/// single project lock. The immutable GDD create is the linearization point; -/// failures after it are reported as a successful result with -/// `recoveryPending=true` so a retry can repair projections without allocating -/// another version. -pub(crate) fn execute_plan_submit_gdd( - root: &std::path::Path, - context: &PlanSubmitGddRuntimeContext, - input: &PlanSubmitGddInputV1, -) -> Result { - if !crate::config::game_creator_planning_capability_enabled() - .map_err(|error| PlanningStorageError::new("PLAN_CAPABILITY_DISABLED", error))? - { - return Err(PlanningStorageError::new( - "PLAN_CAPABILITY_DISABLED", - "立项策划能力当前已停用", - )); - } - validate_runtime_context(context)?; - validate_plan_submit_gdd_input(input) - .map_err(|error| submit_error("PLAN_INVALID_REQUEST", error.to_string()))?; - validate_durable_child_binding(root, context)?; - let _lock = acquire_project_write_lock(root, "planning.submit_gdd") - .map_err(|error| submit_error("PLAN_DURABILITY_FAILED", error))?; - // The optimistic pre-check only avoids entering the handler with an - // obviously forged identity. Re-read the durable child binding after the - // lock is held so a binding rotation/replacement cannot race the GDD - // commit point. - validate_durable_child_binding(root, context)?; - - let chain = read_plan_gdd_chain_locked(root) - .map_err(|error| existing_planning_authority_error(error, "既有 GDD 权威事实"))?; - let approvals = read_plan_gdd_approvals_locked(root) - .map_err(|error| existing_planning_authority_error(error, "既有 GDD approval receipt"))?; - // Keep a session read error until after durable action identity replay is - // resolved. The GDD create is the commit point: if session projection was - // lost/corrupted after that point, a retry must still return the committed - // GDD with recoveryPending instead of pretending the action never ran. - let session_read = read_plan_session_with_recovery_locked(root); - let current_session = session_read.as_ref().ok().and_then(Option::as_ref); - - // First resolve the durable action identity. This branch intentionally - // runs before pending/version checks: replay must be idempotent even when a - // previous attempt already advanced the session or projections. - if let Some(existing) = chain - .iter() - .find(|gdd| gdd.submission_id == context.action_id) - { - if !gdd_submit_identity_matches(existing, context) || existing.gdd_id != context.gdd_id { - return Err(submit_error( - "PLAN_SUBMISSION_IDENTITY_CONFLICT", - "同一 submissionId 已绑定不同的 Runtime identity", - )); - } - if !submit_payload_matches_gdd(input, existing)? { - return Err(submit_error( - "PLAN_SUBMISSION_IDENTITY_CONFLICT", - "同一 submissionId 的 submit payload 不一致", - )); - } - if let Some(session) = current_session { - if !session_identity_matches_context(session, context) { - return Err(submit_error( - "PLAN_NEEDS_RECONCILIATION", - "同 submission replay 命中的 planning session 属于另一条 identity lineage", - )); - } - } - if session_read.is_err() { - let _ = project_submit_successors_locked(root, context, existing, None); - return Ok(PlanSubmitGddResultV1::from_gdd(existing, true, true)); - } - let recovery_pending = - project_submit_successors_locked(root, context, existing, current_session); - return Ok(PlanSubmitGddResultV1::from_gdd( - existing, - true, - recovery_pending, - )); - } - - // A new mutation cannot proceed against a missing/corrupt session. Do - // this before pending/version checks so a broken authority is not hidden - // behind the generic "pending GDD" response. - if let Err(error) = &session_read { - return Err(session_recovery_error(error)); - } - - // Before allocating the first version, reconcile the derived index with - // the authoritative GDD chain while the project lock is still held. In - // particular, an index left behind without any GDD fact is not a clean - // empty-project state: allowing a new v1 would silently overwrite an - // unexplained durable identity and make the orphan impossible to audit. - read_plan_gdd_index_with_recovery_locked(root, &context.created_at_utc)?; - - if chain - .iter() - .any(|gdd| gdd.action_fingerprint == context.action_fingerprint) - { - return Err(submit_error( - "PLAN_SUBMISSION_IDENTITY_CONFLICT", - "actionFingerprint 已被其它 submissionId 使用", - )); - } - validate_plan_gdd_approvals_against_gdds(&chain, &approvals)?; - if let Some(latest) = chain.last() { - if !approvals - .iter() - .any(|receipt| receipt.version == latest.version) - { - return Err(submit_error( - "PLAN_PENDING_GDD_EXISTS", - "最新 GDD 尚未完成用户审批;只能重放原 submissionId", - )); - } - } - - let Some(current_session) = current_session else { - return Err(submit_error( - "PLAN_SESSION_RECOVERY_REQUIRED", - "planning session 不存在,不能提交 GDD", - )); - }; - validate_plan_session(current_session)?; - validate_current_session_cas(current_session, context)?; - validate_user_revision_requires_approval_decision(current_session, input)?; - let version = chain - .last() - .map(|latest| latest.version.saturating_add(1)) - .unwrap_or(1); - if version > PLAN_MAX_VERSIONS { - return Err(existing_planning_authority_error( - submit_error( - "PLAN_VERSION_LIMIT_REACHED", - "不能继续创建第 129 个 GDD 版本", - ), - "既有 GDD lineage", - )); - } - let approval_request_id = context - .approval_request_id - .clone() - .unwrap_or_else(generated_approval_request_id); - let candidate = - build_plan_gdd_from_submit_input(input, context, version, &approval_request_id)?; - validate_next_plan_gdd_version(&chain, &candidate)?; - let bytes = canonical_plan_gdd_bytes(&candidate)?; - - // Durable create is the submit point. Everything below is best-effort - // projection/recovery and must not turn a committed GDD into a new version. - let create_outcome = match durable_create_json_no_replace_locked( - root, - &format!("{PLAN_STORAGE_ROOT}/gdd.v{}.json", candidate.version), - &bytes, - "GDD", - ) { - Ok(outcome) => outcome, - Err(error) => { - if error.code() == "PLAN_COMMIT_UNKNOWN" { - // The immutable target may have been published but the - // parent-directory flush was not confirmed. Do not turn - // this unknown result into a committed replay. - return Err(error); - } - // The no-replace writer may have published the immutable target - // and then failed while syncing or cleaning up. Re-read the - // authority before surfacing an error so a post-create failure - // is reported as a replay with recoveryPending rather than as a - // false rejection that could invite a new version. - if let Ok(chain_after) = read_plan_gdd_chain_locked(root) { - if let Some(existing) = chain_after - .iter() - .find(|gdd| gdd.submission_id == context.action_id) - { - if gdd_submit_identity_matches(existing, context) - && existing.gdd_id == context.gdd_id - && submit_payload_matches_gdd(input, existing).unwrap_or(false) - { - let _projection_recovery_pending = project_submit_successors_locked( - root, - context, - existing, - Some(current_session), - ); - return Ok(PlanSubmitGddResultV1::from_gdd(existing, true, true)); - } - } - } - return Err(error); - } - }; - match create_outcome { - PlanningCreateOutcome::Replayed => { - // A race can publish the same target between the chain scan and - // create. Re-read the authority and return the exact existing GDD. - let chain = read_plan_gdd_chain_locked(root)?; - let existing = chain - .iter() - .find(|gdd| gdd.submission_id == context.action_id) - .ok_or_else(|| { - submit_error("PLAN_NEEDS_RECONCILIATION", "GDD 并发发布后无法回读") - })?; - let recovery_pending = - project_submit_successors_locked(root, context, existing, Some(current_session)); - Ok(PlanSubmitGddResultV1::from_gdd( - existing, - true, - recovery_pending, - )) - } - PlanningCreateOutcome::Created => { - let recovery_pending = - project_submit_successors_locked(root, context, &candidate, Some(current_session)); - Ok(PlanSubmitGddResultV1::from_gdd( - &candidate, - false, - recovery_pending, - )) - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::fs; - use std::path::PathBuf; - - fn create_plan_gdd_approval_pending_for_test( - root: &std::path::Path, - gdd: &PlanGddV1, - ) -> Result<(), PlanningStorageError> { - let _lock = acquire_project_write_lock(root, "test.planning.approval-pending.create") - .map_err(|error| PlanningStorageError::new("PLAN_DURABILITY_FAILED", error))?; - create_plan_gdd_approval_pending_locked(root, gdd) - } - - fn valid_input() -> PlanSubmitGddInputV1 { - PlanSubmitGddInputV1 { - schema_version: PLAN_SUBMIT_INPUT_SCHEMA.to_string(), - game: PlanSubmitGame { - title: "萤火守夜者".to_string(), - genre: PlanGenre { - primary: "轻策略".to_string(), - fusion: None, - }, - art_style: PlanArtStyle { - visual_type: "手绘平面".to_string(), - keywords: vec!["暖色".to_string(), "剪影".to_string(), "纸感".to_string()], - mood_and_color: "夜色中的暖黄灯火".to_string(), - mvp_art_boundary: "仅制作可复用的角色、灯火和地块素材".to_string(), - }, - one_liner: "玩家在一局十分钟的守夜旅程中分配有限灯火、判断风险并选择路线,守住营地后寻找下一处安全落脚点" - .to_string(), - pillars: vec![ - PlanSubmitPillar { - name: "取舍".to_string(), - player_feel: "每次选择都有代价".to_string(), - mechanism: "有限灯火在路线与营地之间分配".to_string(), - decision_state: "confirmed".to_string(), - }, - PlanSubmitPillar { - name: "重玩".to_string(), - player_feel: "想再试一次更优路线".to_string(), - mechanism: "不同路线组合产生不同风险".to_string(), - decision_state: "confirmed".to_string(), - }, - ], - core_loop: vec![ - "观察地图".to_string(), - "分配灯火".to_string(), - "选择路线".to_string(), - "处理事件".to_string(), - ], - target_users: PlanTargetUsers { - core_users: "喜欢短局策略的玩家".to_string(), - preferences: "偏好清晰反馈和轻量决策".to_string(), - session_length: "10至20分钟".to_string(), - reference_games: vec![], - }, - mvp_systems: vec![ - PlanSubmitMvpSystem { - system: "地图".to_string(), - minimal_function: "展示当前营地与可选路线".to_string(), - why_required: "让玩家理解空间选择".to_string(), - verify_method: "能完成一局并看懂下一步".to_string(), - decision_state: "confirmed".to_string(), - }, - PlanSubmitMvpSystem { - system: "灯火".to_string(), - minimal_function: "消耗灯火换取安全或探索".to_string(), - why_required: "承载核心取舍".to_string(), - verify_method: "两种分配策略结果可区分".to_string(), - decision_state: "confirmed".to_string(), - }, - PlanSubmitMvpSystem { - system: "事件".to_string(), - minimal_function: "路线途中触发一项选择".to_string(), - why_required: "提供短局变化".to_string(), - verify_method: "重玩时可遇到不同事件".to_string(), - decision_state: "confirmed".to_string(), - }, - ], - out_of_scope: vec!["多人联机".to_string()], - creator_tips: PlanCreatorTips { - do_first: "先做一张可走完的地图".to_string(), - defer_for_now: "暂缓复杂成长线".to_string(), - how_to_verify: "观察玩家是否能说出每次选择的后果".to_string(), - expand_when: "核心循环连续三局都可理解后再扩展".to_string(), - }, - }, - decisions: vec![PlanSubmitDecision { - id: "initial-request".to_string(), - topic: "初始需求".to_string(), - state: "confirmed".to_string(), - answer_source: "user_freeform".to_string(), - round: 0, - answer_summary: "做一个短局守夜策略游戏".to_string(), - }], - prototype_validation_items: vec![], - } - } - - fn context() -> PlanSubmitGddRuntimeContext { - PlanSubmitGddRuntimeContext { - project_id: "project-test-001".to_string(), - gdd_id: "gdd-00000000-0000-4000-8000-000000000001".to_string(), - action_id: "action-0123456789abcdef01234567".to_string(), - action_fingerprint: "1".repeat(64), - agent_id: GAME_CREATOR_PROJECT_PLANNING_AGENT_ID.to_string(), - source: "agent-delegate".to_string(), - run_profile: AGENT_RUNTIME_RUN_PROFILE_STANDARD.to_string(), - run_profile_binding_fingerprint: "2".repeat(64), - root_agent_id: GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(), - root_run_id: "run-root-001".to_string(), - parent_agent_id: Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string()), - parent_run_id: Some("run-root-001".to_string()), - delegation_id: "delegation-001".to_string(), - session_id: "session-001".to_string(), - source_session_revision: 1, - source_session_fingerprint: "sha256-serde-json-v2:".to_string() + &"3".repeat(64), - created_by_run_id: "run-child-001".to_string(), - created_at_utc: "2026-08-14T00:00:00.000Z".to_string(), - approval_request_id: Some( - "gdd-approval-00000000-0000-4000-8000-000000000002".to_string(), - ), - } - } - - fn provider_binding() -> PlanProviderSessionBindingV1 { - let mut binding = PlanProviderSessionBindingV1 { - schema_version: PLAN_PROVIDER_SESSION_BINDING_SCHEMA_VERSION.to_string(), - project_id: "project-test-001".to_string(), - gdd_id: "gdd-00000000-0000-4000-8000-000000000001".to_string(), - agent_id: GAME_CREATOR_PROJECT_PLANNING_AGENT_ID.to_string(), - task_id: "task-plan-001".to_string(), - provider_request_id: String::new(), - session_id: "session-001".to_string(), - run_id: "run-child-001".to_string(), - root_agent_id: GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(), - root_run_id: "run-root-001".to_string(), - delegation_id: "delegation-001".to_string(), - goal_id: None, - goal_revision: 0, - goal_snapshot_fingerprint: String::new(), - source: "agent-delegate".to_string(), - run_profile: AGENT_RUNTIME_RUN_PROFILE_STANDARD.to_string(), - run_profile_binding_fingerprint: "2".repeat(64), - session_revision: 1, - session_fingerprint: format!("sha256-serde-json-v2:{}", "3".repeat(64)), - applied_steer_cursor: 0, - request_kind: "tool-plan".to_string(), - request_slot: "loop-2-repair-0".to_string(), - web_search_enabled: false, - request_context_fingerprint: format!("sha256-serde-json-v2:{}", "4".repeat(64)), - fingerprint: String::new(), - }; - binding.provider_request_id = - plan_provider_session_binding_base_request_id(&binding).expect("base request id"); - binding.fingerprint = - plan_provider_session_binding_fingerprint(&binding).expect("binding fingerprint"); - validate_plan_provider_session_binding(&binding).expect("valid planning binding"); - binding - } - - fn next_repair_binding(initial: &PlanProviderSessionBindingV1) -> PlanProviderSessionBindingV1 { - let mut binding = initial.clone(); - binding.request_slot = "loop-2-repair-1".to_string(); - binding.request_context_fingerprint = format!("sha256-serde-json-v2:{}", "5".repeat(64)); - binding.provider_request_id = - plan_provider_session_binding_base_request_id(&binding).expect("repair request id"); - binding.fingerprint = String::new(); - binding.fingerprint = plan_provider_session_binding_fingerprint(&binding) - .expect("repair binding fingerprint"); - validate_plan_provider_session_binding(&binding).expect("valid repair binding"); - binding - } - - fn submit_fixture() -> (PathBuf, PlanSubmitGddRuntimeContext, PlanSubmitGddInputV1) { - submit_fixture_from(valid_input()) - } - - /// 与 `submit_fixture` 同构,但由调用方提供 input,并用它初始化 session 的 - /// 当前决定快照,便于构造澄清后或审批修订后的提交场景。 - fn submit_fixture_from( - input: PlanSubmitGddInputV1, - ) -> (PathBuf, PlanSubmitGddRuntimeContext, PlanSubmitGddInputV1) { - let root = std::env::temp_dir().join(format!( - "genarrative-planning-submit-{}", - Uuid::new_v4().simple() - )); - init_local_game_project_at(&root, "project-test-001", "M1B-2 submit fixture") - .expect("project init"); - bind_game_creator_agent_runtime_run_profile_at( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - "run-root-001", - AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE, - Some(AGENT_RUNTIME_RUN_PROFILE_STANDARD), - None, - ) - .expect("bind plan root"); - start_game_creator_agent_runtime_task_at( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - "收敛 Fast GDD", - "run-root-001", - AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE, - "等待 project-planning 提交 GDD", - vec!["等待策划子 Run 提交 Fast GDD".to_string()], - ) - .expect("start plan root task"); - let child_binding = bind_game_creator_agent_runtime_run_profile_at( - &root, - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - "run-child-001", - "agent-delegate", - Some(AGENT_RUNTIME_RUN_PROFILE_STANDARD), - Some(&AgentRuntimeTaskLink { - parent_agent_id: Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string()), - parent_run_id: Some("run-root-001".to_string()), - delegation_id: Some("delegation-001".to_string()), - }), - ) - .expect("bind planning child"); - - let mut context = context(); - context.project_id = "project-test-001".to_string(); - context.run_profile_binding_fingerprint = child_binding.binding_fingerprint; - let decisions = input - .decisions - .iter() - .map(|decision| PlanDecisionSummary { - id: decision.id.clone(), - topic: decision.topic.clone(), - state: decision.state.clone(), - answer_source: decision.answer_source.clone(), - round: decision.round, - answer_summary: decision.answer_summary.clone(), - }) - .collect(); - let mut session = PlanSessionV1 { - schema_version: PLAN_SESSION_SCHEMA_VERSION.to_string(), - project_id: context.project_id.clone(), - gdd_id: context.gdd_id.clone(), - session_revision: 1, - previous_fingerprint: None, - session_fingerprint: format!("sha256-serde-json-v2:{}", "0".repeat(64)), - agent_id: context.agent_id.clone(), - source: context.source.clone(), - run_profile: context.run_profile.clone(), - run_profile_binding_fingerprint: context.run_profile_binding_fingerprint.clone(), - root_agent_id: context.root_agent_id.clone(), - root_run_id: context.root_run_id.clone(), - latest_delegation_id: context.delegation_id.clone(), - session_id: context.session_id.clone(), - active_run_id: Some(context.created_by_run_id.clone()), - last_run_id: context.created_by_run_id.clone(), - phase: "collecting".to_string(), - accumulated_agent_millis: 0, - applied_steer_cursor: 0, - decisions_summary: decisions, - prototype_validation_items: input.prototype_validation_items.clone(), - applied_answers: Vec::new(), - latest_submitted_ref: None, - last_decision_ref: None, - updated_at_utc: context.created_at_utc.clone(), - }; - session.session_fingerprint = plan_session_fingerprint(&session).expect("session fp"); - context.source_session_revision = session.session_revision; - context.source_session_fingerprint = session.session_fingerprint.clone(); - write_plan_session_atomic_locked(&root, &session).expect("write session fixture"); - (root, context, input) - } - - fn cleanup_fixture(root: PathBuf) { - let _ = fs::remove_dir_all(root); - } - - /// Build the smallest durable plan lineage that can reach the M1C-2a - /// acceptance gate. Keeping this fixture beside the submit fixtures - /// makes the test exercise the real GDD/session projections rather than a - /// hand-written pending card. - fn acceptance_gate_fixture(claim_delivery: bool) -> (PathBuf, PlanGddV1, AgentRuntimeState) { - let (root, context, input) = submit_fixture(); - let child_runtime = start_game_creator_agent_runtime_task_at( - &root, - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - "提交 Fast GDD", - &context.created_by_run_id, - "agent-delegate", - "提交一份可审批的 Fast GDD", - vec!["读取项目事实".to_string(), "提交 GDD".to_string()], - ) - .expect("start planning child task"); - let mut child_completed = child_runtime.clone(); - child_completed.session_id = context.session_id.clone(); - child_completed.parent_agent_id = Some(context.root_agent_id.clone()); - child_completed.parent_run_id = Some(context.root_run_id.clone()); - child_completed.delegation_id = Some(context.delegation_id.clone()); - child_completed.status = "completed".to_string(); - child_completed.phase = "completed".to_string(); - child_completed.current_action = "Fast GDD 已提交".to_string(); - append_game_creator_agent_runtime_task(&root, &child_completed) - .expect("append completed planning child task"); - - let delivery = new_static_delegate_delivery( - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - "root-session-001", - &context.root_run_id, - "parent-action-001", - &context.delegation_id, - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - &context.session_id, - &context.created_by_run_id, - ); - create_or_read_static_delegate_delivery_at(&root, &delivery) - .expect("create planning delivery"); - mark_static_delegate_delivery_ready_at( - &root, - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - &context.session_id, - &context.created_by_run_id, - &context.delegation_id, - "completed", - "Fast GDD 已提交", - ) - .expect("close planning delivery"); - if claim_delivery { - let claimed = claim_ready_static_delegate_receipts_at( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &context.root_run_id, - "action-dddddddddddddddddddddddd", - ) - .expect("claim planning delivery"); - assert_eq!(claimed.len(), 1); - } - - let contract = create_game_creator_agent_runtime_goal_contract_at( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &context.root_run_id, - "收敛 Fast GDD", - &AgentRuntimeGoalContractDraft { - outcome: "形成服务用户意图的 Fast GDD".to_string(), - non_negotiables: vec!["保留用户明确要求".to_string()], - preferences: Vec::new(), - forbidden_assumptions: vec!["不得把提交当作审批".to_string()], - open_questions: Vec::new(), - acceptance_nodes: vec![AgentRuntimeGoalContractAcceptanceNodeDraft { - criterion_id: PLAN_FAST_GDD_ACCEPTANCE_NODE_ID.to_string(), - criterion: PLAN_FAST_GDD_ACCEPTANCE_NODE_CRITERION.to_string(), - required: true, - required_evidence: vec![PLAN_FAST_GDD_ACCEPTANCE_NODE_EVIDENCE.to_string()], - dependencies: Vec::new(), - }], - }, - ) - .expect("freeze plan Goal Contract"); - - let gdd = build_plan_gdd_from_submit_input( - &input, - &context, - 1, - &context - .approval_request_id - .clone() - .expect("approval request id"), - ) - .expect("build committed GDD"); - let gdd_bytes = canonical_plan_gdd_bytes(&gdd).expect("canonical GDD"); - durable_create_json_no_replace_locked( - &root, - ".agent/planning/gdd.v1.json", - &gdd_bytes, - "GDD", - ) - .expect("write immutable GDD"); - let index = - build_plan_gdd_index(&[gdd.clone()], &context.created_at_utc).expect("build GDD index"); - write_plan_gdd_index_atomic_locked(&root, &index).expect("write GDD index"); - let markdown = render_plan_fast_gdd_markdown(&gdd, "ready_for_approval") - .expect("render Fast GDD Markdown"); - write_plan_fast_gdd_markdown_atomic_locked(&root, &markdown) - .expect("write Fast GDD Markdown"); - let source_session = read_plan_session_with_recovery(&root) - .expect("read source session") - .expect("source session exists"); - let successor = build_submit_session_successor(&source_session, &context, &gdd) - .expect("build submitted session successor"); - write_plan_session_atomic_locked(&root, &successor).expect("write submitted session"); - - let root_runtime = - read_game_creator_agent_runtime_at(&root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID) - .expect("read root runtime") - .state; - let markdown_sha256 = format!("{:x}", Sha256::digest(markdown.as_bytes())); - let markdown_lines = markdown.lines().count(); - append_agent_runtime_action_receipt( - &root, - &root_runtime, - "action-aaaaaaaaaaaaaaaaaaaaaaaa", - &"b".repeat(64), - "file.read", - AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO, - Some("path=game/fast_gdd.md · startLine=1 · maxLines=120"), - &AgentRuntimeToolObservation { - tool: "file.read".to_string(), - status: "ok".to_string(), - summary: "已读取 game/fast_gdd.md".to_string(), - detail: Some(format!( - "game/fast_gdd.md · sha256={markdown_sha256} · lines 1-{markdown_lines} of {markdown_lines}" - )), - }, - ) - .expect("append root file.read receipt"); - - // Keep the contract in scope for a caller that wants to inspect the - // fixture while making the unused value explicit in the test helper. - assert_eq!(contract.contract_fingerprint.len(), 64); - (root, gdd, root_runtime) - } - - fn append_file_read_receipt_from_real_observation( - root: &Path, - runtime: &AgentRuntimeState, - action_id: &str, - path: &str, - start_line: usize, - max_lines: usize, - ) -> AgentRuntimeToolObservation { - let action = AgentRuntimeToolAction { - tool: "file.read".to_string(), - reason: Some("读取 Fast GDD 验收证据".to_string()), - input: serde_json::json!({ - "path": path, - "startLine": start_line, - "maxLines": max_lines, - }), - }; - let observation = observe_agent_runtime_file(root, &action.input); - assert_eq!(observation.status, "ok"); - let input_summary = agent_runtime_tool_action_input_summary(root, &action) - .expect("file.read input summary"); - append_agent_runtime_action_receipt( - root, - runtime, - action_id, - &"c".repeat(64), - "file.read", - AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO, - Some(&input_summary), - &observation, - ) - .expect("append real file.read receipt"); - observation - } - - #[test] - fn runtime_injection_adds_fixed_platform_facts_and_identity() { - let gdd = build_plan_gdd_from_submit_input( - &valid_input(), - &context(), - 1, - "gdd-approval-00000000-0000-4000-8000-000000000002", - ) - .expect("build GDD"); - assert_eq!(gdd.game.platform_facts.runtime, "self-contained-web"); - assert_eq!(gdd.game.platform_facts.viewports, ["desktop", "mobile"]); - assert_eq!(gdd.source, "agent-delegate"); - assert!(validate_plan_gdd(&gdd).is_ok()); - } - - #[test] - fn provider_structured_injections_reject_unknown_delegate_lineage() { - let (root, context, _) = submit_fixture(); - let mut delivery = new_static_delegate_delivery( - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - "m1c0b-provider-root-session", - &context.root_run_id, - "m1c0b-provider-parent-action", - &context.delegation_id, - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - &context.session_id, - &context.created_by_run_id, - ); - delivery.status = StaticDelegateDeliveryStatus::Ready; - delivery.terminal_status = Some("completed".to_string()); - delivery.result_summary = Some("future status fixture".to_string()); - let mut structured_result = StaticDelegateStructuredResult::default(); - structured_result.contract_status = - StaticDelegateContractStatus::Unknown("future-contract-status".to_string()); - delivery.structured_result = Some(structured_result); - write_static_delegate_delivery_at(&root, &delivery) - .expect("write unknown planning delivery"); - - let error = capture_plan_provider_structured_injections_at(&root, &context.session_id, &[]) - .expect_err("unknown planning lineage must block Provider injection"); - assert!( - error.contains("更新版本 contractStatus"), - "unexpected error: {error}" - ); - cleanup_fixture(root); - } - - #[test] - fn provider_binding_is_strict_and_recomputes_canonical_request_identity() { - let binding = provider_binding(); - let round_trip = serde_json::from_value::( - serde_json::to_value(&binding).expect("serialize binding"), - ) - .expect("round-trip binding"); - assert_eq!(round_trip, binding); - - let mut with_unknown = serde_json::to_value(&binding).expect("serialize binding"); - with_unknown - .as_object_mut() - .expect("binding object") - .insert("unexpected".to_string(), serde_json::json!(true)); - assert!( - serde_json::from_value::(with_unknown).is_err(), - "unknown binding fields must fail closed" - ); - - let mut forged_request_id = binding.clone(); - forged_request_id.provider_request_id = format!("provider-request-{}", "f".repeat(64)); - forged_request_id.fingerprint = String::new(); - forged_request_id.fingerprint = - plan_provider_session_binding_fingerprint(&forged_request_id) - .expect("forge self-consistent binding fingerprint"); - assert_eq!( - validate_plan_provider_session_binding(&forged_request_id) - .expect_err("arbitrary providerRequestId must fail") - .code(), - "PLAN_NEEDS_RECONCILIATION" - ); - - let mut forged_context = binding.clone(); - forged_context.request_context_fingerprint = - format!("sha256-serde-json-v2:{}", "6".repeat(64)); - forged_context.fingerprint = String::new(); - forged_context.fingerprint = plan_provider_session_binding_fingerprint(&forged_context) - .expect("forge context binding fingerprint"); - assert!(validate_plan_provider_session_binding(&forged_context).is_err()); - } - - #[test] - fn provider_request_attempt_id_matches_typed_canonical_golden_vector() { - const BASE_REQUEST_ID: &str = - "provider-request-0000000000000000000000000000000000000000000000000000000000000000"; - const CANONICAL_ENVELOPE: &str = concat!( - "{\"domain\":\"genarrative.plan.provider-request-attempt.v1\",", - "\"value\":{\"baseProviderRequestId\":\"", - "provider-request-0000000000000000000000000000000000000000000000000000000000000000", - "\",\"attempt\":2}}" - ); - - assert_eq!( - plan_provider_request_attempt_id(BASE_REQUEST_ID, 0), - BASE_REQUEST_ID - ); - - let value = PlanProviderRequestAttemptIdentityValue { - base_provider_request_id: BASE_REQUEST_ID, - attempt: 2, - }; - assert_eq!( - typed_serde_canonical_bytes(PLAN_PROVIDER_REQUEST_ATTEMPT_ID_DOMAIN, &value) - .expect("serialize attempt identity"), - CANONICAL_ENVELOPE.as_bytes() - ); - assert_eq!( - plan_provider_request_attempt_id(BASE_REQUEST_ID, 2), - "provider-request-89e5d07c6761b6521ef77dc5f477854fd820b98b20b4e7f56cd80ef3464574a8" - ); - } - - #[test] - fn provider_binding_attempt_and_repair_identity_stay_on_the_frozen_lineage() { - let base = provider_binding(); - let attempt_id = plan_provider_request_attempt_id(&base.provider_request_id, 2); - let attempt = plan_provider_session_binding_for_attempt( - &base, - "loop-2-repair-0-transient-2", - &attempt_id, - ) - .expect("derive planning transient attempt"); - assert_eq!(attempt.provider_request_id, attempt_id); - assert_eq!( - plan_provider_session_binding_base_request_id(&attempt) - .expect("recover base request id"), - base.provider_request_id - ); - validate_plan_provider_session_binding(&attempt).expect("attempt binding validates"); - assert!(plan_provider_session_binding_for_attempt( - &base, - "loop-2-repair-0-transient-2", - &format!("provider-request-{}", "e".repeat(64)), - ) - .is_err()); - - let repair = next_repair_binding(&base); - validate_plan_provider_session_binding_repair_lineage(&base, &repair) - .expect("repair request may change slot/context only"); - let mut advanced_session = repair; - advanced_session.session_revision = 2; - advanced_session.session_fingerprint = format!("sha256-serde-json-v2:{}", "7".repeat(64)); - advanced_session.provider_request_id = - plan_provider_session_binding_base_request_id(&advanced_session) - .expect("advanced session request id"); - advanced_session.fingerprint = String::new(); - advanced_session.fingerprint = plan_provider_session_binding_fingerprint(&advanced_session) - .expect("advanced session binding fingerprint"); - validate_plan_provider_session_binding(&advanced_session) - .expect("advanced session is independently valid"); - assert!( - validate_plan_provider_session_binding_repair_lineage(&base, &advanced_session) - .is_err(), - "one repair chain must not jump to a newer session" - ); - } - - #[test] - fn planning_provider_four_request_kinds_share_v3_lifecycle_and_one_user_injection() { - let (root, context, _) = submit_fixture(); - ensure_agent_conversation_session_at( - &root, - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - &context.session_id, - "M1B-2 planning Provider 请求矩阵", - ) - .expect("ensure planning Provider matrix conversation session"); - let mut runtime = start_game_creator_agent_runtime_task_for_session_at( - &root, - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - Some(&context.session_id), - "形成并提交 Fast GDD", - &context.created_by_run_id, - "agent-delegate", - "构建 planning Provider 请求矩阵", - vec!["核对四类 planning Provider 请求".to_string()], - ) - .expect("start planning Provider matrix runtime"); - assert_eq!(runtime.session_id, context.session_id); - assert_eq!(runtime.run_id, context.created_by_run_id); - runtime.parent_agent_id = context.parent_agent_id.clone(); - runtime.parent_run_id = context.parent_run_id.clone(); - runtime.delegation_id = Some(context.delegation_id.clone()); - runtime.updated_at = unix_timestamp(); - append_game_creator_agent_runtime_task(&root, &runtime) - .expect("append planning Provider matrix task link"); - refresh_game_creator_agent_runtime_task_queue(&root, &mut runtime) - .expect("refresh planning Provider matrix task queue"); - write_game_creator_agent_runtime_state(&root, &runtime) - .expect("persist planning Provider matrix task link"); - let delivery = new_static_delegate_delivery( - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - "planning-provider-root-session", - &context.root_run_id, - "planning-provider-parent-action", - &context.delegation_id, - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - &runtime.session_id, - &runtime.run_id, - ); - write_static_delegate_delivery_at(&root, &delivery) - .expect("write planning Provider matrix delivery"); - - let structured_wire = - capture_plan_provider_structured_injections_at(&root, &runtime.session_id, &[]) - .expect("capture planning structured injections"); - let structured_message = - render_plan_provider_structured_injections_message(&structured_wire) - .expect("render planning structured injections"); - let structured_prefix = format!("{PLAN_PROVIDER_STRUCTURED_INJECTIONS_MESSAGE_HEADER}\n"); - let request_kinds = [ - "tool-plan", - "final-reply", - "context-compaction", - "final-reply-context-compaction", - ]; - let mut request_ids = Vec::new(); - - for (index, request_kind) in request_kinds.into_iter().enumerate() { - let request_slot = format!("m1b2-{request_kind}-{index}"); - let snapshot = capture_game_creator_agent_runtime_provider_request_snapshot( - &root, - &runtime.agent_id, - &runtime.session_id, - &runtime.run_id, - request_kind, - &request_slot, - runtime.applied_steer_cursor, - ) - .expect("capture planning Provider request snapshot"); - let request = platform_llm::LlmRunRequest::new(vec![ - platform_llm::LlmMessage::system("planning request matrix"), - platform_llm::LlmMessage::user(structured_message.clone()), - platform_llm::LlmMessage::user(format!("requestKind={request_kind}")), - ]) - .with_model("planning-request-matrix") - .with_api_kind(platform_llm::LlmApiKind::OpenAiResponses) - .with_max_output_tokens(512) - .with_web_search(false); - let matching_injections = request - .messages - .iter() - .filter(|message| message.content.starts_with(&structured_prefix)) - .collect::>(); - assert_eq!(matching_injections.len(), 1); - assert_eq!( - matching_injections[0].role, - platform_llm::LlmMessageRole::User - ); - let request_context_fingerprint = - game_creator_agent_runtime_plan_provider_request_context_fingerprint( - &GameCreatorLlmConfig::default(), - &request, - ) - .expect("fingerprint planning Provider request wire"); - let binding = { - let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( - &root, - "test.planning_provider_four_request_kinds", - ) - .expect("lock planning Provider request binding"); - let current = read_game_creator_agent_runtime_at(&root, &runtime.agent_id) - .expect("read current planning runtime") - .state; - capture_plan_provider_session_binding_for_snapshot( - &root, - ¤t, - &snapshot, - &request_context_fingerprint, - ) - .expect("capture planning Provider session binding") - }; - assert_eq!(binding.request_kind, request_kind); - assert_eq!(binding.request_slot, request_slot); - validate_plan_provider_session_binding_current_at(&root, &binding) - .expect("validate current planning Provider binding"); - let request_id = binding.provider_request_id.clone(); - let snapshot = snapshot.with_planning_session_binding(Some(binding)); - assert!( - append_game_creator_agent_runtime_provider_request_lifecycle( - &root, - &snapshot, - &request_id, - "started", - ) - .expect("append planning Provider started lifecycle") - ); - assert!( - append_game_creator_agent_runtime_provider_request_lifecycle( - &root, - &snapshot, - &request_id, - "completed", - ) - .expect("append planning Provider completed lifecycle") - ); - assert_eq!( - read_agent_db_lifecycle_transitions_at( - &root, - AGENT_RUNTIME_PROVIDER_REQUEST_LIFECYCLE_RECORD_TYPE, - "requestId", - &request_id, - ) - .expect("read planning Provider lifecycle"), - vec!["started", "completed"] - ); - if request_kind != "tool-plan" { - assert!(!game_creator_agent_runtime_provider_action_batch_exists( - &root, - &runtime.agent_id, - &runtime.run_id, - )); - } - request_ids.push((request_kind, request_id)); - } - - let records = read_agent_db_records_bounded(&root, 1024 * 1024) - .expect("read planning Provider lifecycle records") - .0; - for (request_kind, request_id) in request_ids { - let lifecycle = records - .iter() - .filter(|record| { - record.get("recordType").and_then(serde_json::Value::as_str) - == Some(AGENT_RUNTIME_PROVIDER_REQUEST_LIFECYCLE_RECORD_TYPE) - && record.get("requestId").and_then(serde_json::Value::as_str) - == Some(request_id.as_str()) - }) - .collect::>(); - assert_eq!(lifecycle.len(), 2); - for record in lifecycle { - assert_eq!( - record - .get("auditSchemaVersion") - .and_then(serde_json::Value::as_str), - Some(AGENT_RUNTIME_PLAN_PROVIDER_REQUEST_LIFECYCLE_SCHEMA_VERSION) - ); - assert_eq!( - record - .get("requestKind") - .and_then(serde_json::Value::as_str), - Some(request_kind) - ); - assert_eq!( - record - .pointer("/planningSessionBinding/requestKind") - .and_then(serde_json::Value::as_str), - Some(request_kind) - ); - } - } - cleanup_fixture(root); - } - - #[test] - fn planning_structured_injection_wire_enforces_the_64_kib_boundary() { - let mut value = PlanProviderStructuredInjectionsV1 { - schema_version: PLAN_PROVIDER_STRUCTURED_INJECTIONS_SCHEMA_VERSION.to_string(), - clarification_round: 0, - accumulated_agent_millis: 0, - session: PlanProviderFacingSessionV1 { - phase: "collecting".to_string(), - decisions_summary: Vec::new(), - prototype_validation_items: Vec::new(), - latest_submitted_ref: None, - last_decision_ref: None, - }, - platform_facts: fixed_plan_platform_facts(), - approval_observation: Some(PlanProviderApprovalObservationV1 { - tool: PLAN_SUBMIT_GDD_TOOL.to_string(), - status: "ok".to_string(), - summary: "提交完成".to_string(), - detail: Some(String::new()), - }), - }; - let target = PLAN_PROVIDER_STRUCTURED_INJECTIONS_MAX_BYTES; - let mut padding = 0usize; - loop { - value - .approval_observation - .as_mut() - .expect("approval observation") - .detail = Some("x".repeat(padding)); - let bytes = serde_json::to_vec(&value).expect("serialize boundary fixture"); - if bytes.len() >= target { - assert_eq!(bytes.len(), target, "ASCII detail padding is byte-linear"); - assert!(render_plan_provider_structured_injections_message(&bytes).is_ok()); - let mut oversized = bytes.clone(); - oversized.push(b' '); - assert!(render_plan_provider_structured_injections_message(&oversized).is_err()); - break; - } - padding = padding.saturating_add(target - bytes.len()); - } - } - - /// 生产里唯一带 planning binding 追加 `started` 的调用点(Provider 请求启动) - /// 是持着项目写锁进来的,而在这次修复之前没有任何用例覆盖持锁形态:既有用例 - /// 都在 append 之前就把锁 drop 了。于是不持锁校验里那次自我抢锁一路失败、被包 - /// 成 reconciliation 前缀、被主循环静默吞掉,策划子 Run 停在 running/planning - /// 不动,父 run 永远等不到回执。这条用例锁的就是持锁形态本身。 - #[test] - fn planning_provider_started_lifecycle_appends_while_holding_the_project_write_lock() { - let (root, context, _) = submit_fixture(); - ensure_agent_conversation_session_at( - &root, - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - &context.session_id, - "planning Provider locked started", - ) - .expect("ensure planning Provider locked conversation session"); - let mut runtime = start_game_creator_agent_runtime_task_for_session_at( - &root, - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - Some(&context.session_id), - "形成 Fast GDD", - &context.created_by_run_id, - "agent-delegate", - "验证持锁 lifecycle 追加", - vec!["验证持锁 started".to_string()], - ) - .expect("start planning Provider locked runtime"); - runtime.parent_agent_id = context.parent_agent_id.clone(); - runtime.parent_run_id = context.parent_run_id.clone(); - runtime.delegation_id = Some(context.delegation_id.clone()); - runtime.updated_at = unix_timestamp(); - append_game_creator_agent_runtime_task(&root, &runtime) - .expect("append planning Provider locked task"); - refresh_game_creator_agent_runtime_task_queue(&root, &mut runtime) - .expect("refresh planning Provider locked queue"); - write_game_creator_agent_runtime_state(&root, &runtime) - .expect("persist planning Provider locked runtime"); - let snapshot = capture_game_creator_agent_runtime_provider_request_snapshot( - &root, - &runtime.agent_id, - &runtime.session_id, - &runtime.run_id, - "tool-plan", - "m1b2-locked-started", - runtime.applied_steer_cursor, - ) - .expect("capture planning Provider locked snapshot"); - - let control_lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( - &root, - "test.planning_provider_started_under_lock", - ) - .expect("hold the project write lock like the provider request start path"); - let binding = capture_plan_provider_session_binding_for_snapshot( - &root, - &runtime, - &snapshot, - &format!("sha256-serde-json-v2:{}", "8".repeat(64)), - ) - .expect("capture planning Provider locked binding"); - let request_id = binding.provider_request_id.clone(); - let snapshot = snapshot.with_planning_session_binding(Some(binding)); - - // 不持锁版本在持锁上下文里必然失败,而且失败带 reconciliation 前缀—— - // 这正是被静默吞掉的那条错误。 - let error = append_game_creator_agent_runtime_provider_request_lifecycle( - &root, - &snapshot, - &request_id, - "started", - ) - .expect_err("unlocked lifecycle append must not be usable while holding the lock"); - assert!(error.starts_with(AGENT_RUNTIME_PROVIDER_REQUEST_RECONCILIATION_PREFIX)); - assert!(read_agent_db_lifecycle_transitions_at( - &root, - AGENT_RUNTIME_PROVIDER_REQUEST_LIFECYCLE_RECORD_TYPE, - "requestId", - &request_id, - ) - .expect("read lifecycle after the unlocked attempt") - .is_empty()); - - assert!( - append_game_creator_agent_runtime_provider_request_lifecycle_at_locked( - &root, - &snapshot, - &request_id, - "started", - ) - .expect("locked lifecycle append must succeed under the control lock") - ); - assert!(!read_agent_db_lifecycle_transitions_at( - &root, - AGENT_RUNTIME_PROVIDER_REQUEST_LIFECYCLE_RECORD_TYPE, - "requestId", - &request_id, - ) - .expect("read lifecycle after the locked append") - .is_empty()); - drop(control_lock); - cleanup_fixture(root); - } - - #[test] - fn planning_provider_started_rechecks_parent_and_delegation_binding() { - let (root, context, _) = submit_fixture(); - ensure_agent_conversation_session_at( - &root, - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - &context.session_id, - "planning Provider binding tamper", - ) - .expect("ensure planning Provider tamper conversation session"); - let mut runtime = start_game_creator_agent_runtime_task_for_session_at( - &root, - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - Some(&context.session_id), - "形成 Fast GDD", - &context.created_by_run_id, - "agent-delegate", - "验证 planning Provider binding", - vec!["验证 parent/delegation binding".to_string()], - ) - .expect("start planning Provider tamper runtime"); - runtime.parent_agent_id = context.parent_agent_id.clone(); - runtime.parent_run_id = context.parent_run_id.clone(); - runtime.delegation_id = Some(context.delegation_id.clone()); - runtime.updated_at = unix_timestamp(); - append_game_creator_agent_runtime_task(&root, &runtime) - .expect("append planning Provider tamper task"); - refresh_game_creator_agent_runtime_task_queue(&root, &mut runtime) - .expect("refresh planning Provider tamper queue"); - write_game_creator_agent_runtime_state(&root, &runtime) - .expect("persist planning Provider tamper runtime"); - let snapshot = capture_game_creator_agent_runtime_provider_request_snapshot( - &root, - &runtime.agent_id, - &runtime.session_id, - &runtime.run_id, - "tool-plan", - "m1b2-binding-tamper", - runtime.applied_steer_cursor, - ) - .expect("capture planning Provider tamper snapshot"); - let binding = { - let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( - &root, - "test.planning_provider_started_rechecks_binding", - ) - .expect("lock planning Provider tamper binding"); - capture_plan_provider_session_binding_for_snapshot( - &root, - &runtime, - &snapshot, - &format!("sha256-serde-json-v2:{}", "9".repeat(64)), - ) - .expect("capture planning Provider tamper binding") - }; - let request_id = binding.provider_request_id.clone(); - let snapshot = snapshot.with_planning_session_binding(Some(binding)); - - // Tamper after capture but before lifecycle started. The durable - // started record must never be emitted for the stale parent/delegation - // identity. - runtime.parent_run_id = Some("tampered-root-run".to_string()); - runtime.delegation_id = Some("tampered-delegation".to_string()); - runtime.updated_at = unix_timestamp(); - append_game_creator_agent_runtime_task(&root, &runtime) - .expect("append tampered planning runtime"); - refresh_game_creator_agent_runtime_task_queue(&root, &mut runtime) - .expect("refresh tampered planning runtime queue"); - write_game_creator_agent_runtime_state(&root, &runtime) - .expect("persist tampered planning runtime"); - - let error = append_game_creator_agent_runtime_provider_request_lifecycle( - &root, - &snapshot, - &request_id, - "started", - ) - .expect_err("tampered parent/delegation binding must fail before started"); - assert!(error.contains("reconciliation") || error.contains("binding")); - assert!(read_agent_db_lifecycle_transitions_at( - &root, - AGENT_RUNTIME_PROVIDER_REQUEST_LIFECYCLE_RECORD_TYPE, - "requestId", - &request_id, - ) - .expect("read tamper lifecycle") - .is_empty()); - cleanup_fixture(root); - } - - #[test] - fn planning_provider_lifecycle_started_rejects_parent_or_delegation_drift_after_capture() { - for drift_kind in ["parent", "delegation"] { - let (root, context, _) = submit_fixture(); - ensure_agent_conversation_session_at( - &root, - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - &context.session_id, - "M1B-2 planning identity drift", - ) - .expect("ensure planning identity-drift conversation session"); - let mut runtime = start_game_creator_agent_runtime_task_for_session_at( - &root, - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - Some(&context.session_id), - "形成并提交 Fast GDD", - &context.created_by_run_id, - "agent-delegate", - "构建 planning identity-drift request", - vec!["核对 captured binding 身份".to_string()], - ) - .expect("start planning identity-drift runtime"); - assert_eq!(runtime.session_id, context.session_id); - assert_eq!(runtime.run_id, context.created_by_run_id); - runtime.parent_agent_id = context.parent_agent_id.clone(); - runtime.parent_run_id = context.parent_run_id.clone(); - runtime.delegation_id = Some(context.delegation_id.clone()); - runtime.updated_at = unix_timestamp(); - append_game_creator_agent_runtime_task(&root, &runtime) - .expect("append planning identity-drift task link"); - refresh_game_creator_agent_runtime_task_queue(&root, &mut runtime) - .expect("refresh planning identity-drift task queue"); - write_game_creator_agent_runtime_state(&root, &runtime) - .expect("persist planning identity-drift runtime"); - - let snapshot = capture_game_creator_agent_runtime_provider_request_snapshot( - &root, - &runtime.agent_id, - &runtime.session_id, - &runtime.run_id, - "tool-plan", - "m1b2-identity-drift-0", - runtime.applied_steer_cursor, - ) - .expect("capture planning request before identity drift"); - let binding = { - let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( - &root, - "test.planning_identity_drift_binding", - ) - .expect("lock planning identity-drift binding"); - let current = read_game_creator_agent_runtime_at(&root, &runtime.agent_id) - .expect("read current planning identity-drift runtime") - .state; - capture_plan_provider_session_binding_for_snapshot( - &root, - ¤t, - &snapshot, - &format!("sha256-serde-json-v2:{}", "f".repeat(64)), - ) - .expect("capture planning identity-drift binding") - }; - let request_id = binding.provider_request_id.clone(); - let snapshot = snapshot.with_planning_session_binding(Some(binding)); - - match drift_kind { - "parent" => runtime.parent_run_id = Some("drifted-parent-run".to_string()), - "delegation" => runtime.delegation_id = Some("drifted-delegation".to_string()), - _ => unreachable!(), - } - runtime.updated_at = unix_timestamp(); - append_game_creator_agent_runtime_task(&root, &runtime) - .expect("append drifted planning runtime task"); - refresh_game_creator_agent_runtime_task_queue(&root, &mut runtime) - .expect("refresh drifted planning runtime task queue"); - write_game_creator_agent_runtime_state(&root, &runtime) - .expect("write drifted planning runtime state"); - - let error = append_game_creator_agent_runtime_provider_request_lifecycle( - &root, - &snapshot, - &request_id, - "started", - ) - .expect_err("captured planning binding drift must reject lifecycle started"); - assert!(error.starts_with(AGENT_RUNTIME_PROVIDER_REQUEST_RECONCILIATION_PREFIX)); - let records = read_agent_db_records_bounded(&root, 1024 * 1024) - .expect("read identity-drift lifecycle records") - .0; - assert!(!records.iter().any(|record| { - record.get("recordType").and_then(serde_json::Value::as_str) - == Some(AGENT_RUNTIME_PROVIDER_REQUEST_LIFECYCLE_RECORD_TYPE) - && record.get("requestId").and_then(serde_json::Value::as_str) - == Some(request_id.as_str()) - && record.get("status").and_then(serde_json::Value::as_str) == Some("started") - })); - assert!(!game_creator_agent_runtime_provider_action_batch_exists( - &root, - &runtime.agent_id, - &runtime.run_id - )); - cleanup_fixture(root); - } - } - - #[test] - fn replay_payload_comparison_is_strict() { - let input = valid_input(); - let gdd = build_plan_gdd_from_submit_input( - &input, - &context(), - 1, - "gdd-approval-00000000-0000-4000-8000-000000000002", - ) - .expect("build GDD"); - assert!(submit_payload_matches_gdd(&input, &gdd).expect("match")); - let mut changed = input; - changed.game.title.push('改'); - assert!(!submit_payload_matches_gdd(&changed, &gdd).expect("mismatch")); - } - - #[test] - fn markdown_renderer_is_deterministic_and_contains_authority_metadata() { - let gdd = build_plan_gdd_from_submit_input( - &valid_input(), - &context(), - 1, - "gdd-approval-00000000-0000-4000-8000-000000000002", - ) - .expect("build GDD"); - let first = render_plan_fast_gdd_markdown(&gdd, "ready_for_approval").expect("render"); - let second = render_plan_fast_gdd_markdown(&gdd, "ready_for_approval").expect("render"); - assert_eq!(first, second); - assert!(first.contains("Fast GDD v1")); - assert!(first.contains(&gdd.gdd_id)); - assert!(first.contains(&gdd.fingerprint)); - assert!(first.contains("## Runtime 平台事实")); - } - - #[test] - fn timestamp_helper_matches_fixed_millisecond_contract() { - let timestamp = current_plan_timestamp_utc(); - assert_eq!(timestamp.len(), 24); - validate_timestamp(×tamp, "now").expect("valid UTC timestamp"); - } - - #[test] - fn submit_handler_creates_projection_and_replays_without_new_version() { - let (root, context, input) = submit_fixture(); - let first = execute_plan_submit_gdd(&root, &context, &input).expect("submit v1"); - assert_eq!(first.outcome, "submitted"); - assert_eq!(first.gdd_ref.version, 1); - assert!(!first.recovery_pending); - assert!(root.join(".agent/planning/gdd.v1.json").is_file()); - assert!(root.join(".agent/planning/index.json").is_file()); - assert!(root.join("game/fast_gdd.md").is_file()); - - let replay = execute_plan_submit_gdd(&root, &context, &input).expect("replay v1"); - assert_eq!(replay.outcome, "replayed"); - assert_eq!(replay.gdd_ref, first.gdd_ref); - assert!(!replay.recovery_pending); - assert_eq!(read_plan_gdd_chain(&root).expect("read chain").len(), 1); - - let mut changed = input.clone(); - changed.game.title.push('改'); - let conflict = execute_plan_submit_gdd(&root, &context, &changed) - .expect_err("same submission different payload must conflict"); - assert_eq!(conflict.code(), "PLAN_SUBMISSION_IDENTITY_CONFLICT"); - cleanup_fixture(root); - } - - #[test] - fn planning_capability_disabled_rejects_new_gdd_without_writing_authority() { - let _config_guard = crate::tests::write_test_local_config( - r#"{"planning":{"capabilityEnabled":false}}"#.to_string(), - ); - let (root, context, input) = submit_fixture(); - let error = execute_plan_submit_gdd(&root, &context, &input) - .expect_err("disabled planning capability must reject submit"); - assert_eq!(error.code(), "PLAN_CAPABILITY_DISABLED"); - assert!(!root.join(".agent/planning/gdd.v1.json").exists()); - cleanup_fixture(root); - } - - #[test] - fn committed_submit_rebuilds_each_derived_projection_without_allocating_v2() { - let (root, context, input) = submit_fixture(); - let source_session_bytes = - fs::read(root.join(".agent/planning/session.json")).expect("read source session"); - let first = execute_plan_submit_gdd(&root, &context, &input).expect("submit v1"); - assert_eq!(first.gdd_ref.version, 1); - - fs::remove_file(root.join(".agent/planning/index.json")) - .expect("remove derived index projection"); - let index_replay = - execute_plan_submit_gdd(&root, &context, &input).expect("rebuild index projection"); - assert_eq!(index_replay.outcome, "replayed"); - assert!(!index_replay.recovery_pending); - assert!(root.join(".agent/planning/index.json").is_file()); - - fs::remove_file(root.join("game/fast_gdd.md")).expect("remove derived markdown projection"); - let markdown_replay = - execute_plan_submit_gdd(&root, &context, &input).expect("rebuild markdown projection"); - assert_eq!(markdown_replay.outcome, "replayed"); - assert!(!markdown_replay.recovery_pending); - assert!(root.join("game/fast_gdd.md").is_file()); - - let previous = root.join(".agent/planning/.session.json.previous"); - if previous.exists() { - fs::remove_file(&previous).expect("remove successor recovery copy"); - } - fs::write( - root.join(".agent/planning/session.json"), - source_session_bytes, - ) - .expect("restore source session crash snapshot"); - let session_replay = execute_plan_submit_gdd(&root, &context, &input) - .expect("rebuild session successor projection"); - assert_eq!(session_replay.outcome, "replayed"); - assert!(!session_replay.recovery_pending); - let session = read_plan_session_with_recovery(&root) - .expect("read repaired session") - .expect("repaired session exists"); - assert_eq!( - session.session_revision, - context.source_session_revision + 1 - ); - assert_eq!(session.phase, "awaiting_gdd_approval"); - assert_eq!(session.latest_submitted_ref.as_ref(), Some(&first.gdd_ref)); - - assert_eq!( - read_plan_gdd_chain(&root).expect("read final chain").len(), - 1 - ); - assert!(!root.join(".agent/planning/gdd.v2.json").exists()); - cleanup_fixture(root); - } - - #[test] - fn submit_handler_rejects_session_cas_before_creating_any_fact() { - let (root, mut context, input) = submit_fixture(); - context.source_session_revision = 2; - let error = execute_plan_submit_gdd(&root, &context, &input) - .expect_err("stale session must be rejected"); - assert_eq!(error.code(), "PLAN_SESSION_CAS_CONFLICT"); - assert!(!root.join(".agent/planning/gdd.v1.json").exists()); - assert!(!root.join(".agent/planning/index.json").exists()); - assert!(!root.join("game/fast_gdd.md").exists()); - cleanup_fixture(root); - } - - #[test] - fn submit_rejects_orphan_index_before_allocating_first_version() { - let (root, context, input) = submit_fixture(); - fs::write(root.join(".agent/planning/index.json"), b"{}\n") - .expect("seed orphan planning index"); - - let error = execute_plan_submit_gdd(&root, &context, &input) - .expect_err("orphan index must block a new v1 submission"); - assert_eq!(error.code(), "PLAN_IDENTITY_CONFLICT"); - assert!(!root.join(".agent/planning/gdd.v1.json").exists()); - assert!(!root.join("game/fast_gdd.md").exists()); - cleanup_fixture(root); - } - - #[test] - fn submit_routes_oversized_existing_gdd_to_reconciliation_not_provider_retry() { - let (root, context, input) = submit_fixture(); - fs::write( - root.join(".agent/planning/gdd.v1.json"), - vec![b'x'; 256 * 1024 + 1], - ) - .expect("seed oversized immutable GDD authority"); - - let error = execute_plan_submit_gdd(&root, &context, &input) - .expect_err("oversized existing authority must block new submit"); - assert_eq!(error.code(), "PLAN_NEEDS_RECONCILIATION"); - cleanup_fixture(root); - } - - #[test] - fn submit_routes_exhausted_existing_lineage_to_reconciliation_not_provider_retry() { - let (root, context, input) = submit_fixture(); - let approvals_root = root.join(PLAN_GDD_APPROVAL_DIR); - fs::create_dir_all(&approvals_root).expect("create immutable approval authority directory"); - for version in 1..=PLAN_MAX_VERSIONS { - let mut gdd = build_plan_gdd_from_submit_input( - &input, - &context, - version, - &format!("gdd-approval-00000000-0000-4000-8000-{version:012x}"), - ) - .expect("build fixture GDD version"); - gdd.submission_id = format!("action-{version:024x}"); - gdd.action_fingerprint = format!("{version:064x}"); - gdd.fingerprint = plan_gdd_fingerprint(&gdd).expect("fixture GDD fingerprint"); - fs::write( - root.join(format!("{PLAN_STORAGE_ROOT}/gdd.v{version}.json")), - canonical_plan_gdd_bytes(&gdd).expect("fixture GDD canonical bytes"), - ) - .expect("write fixture GDD authority"); - - let decision_input = PlanGddApprovalDecisionInputV1 { - project_id: gdd.project_id.clone(), - gdd_id: gdd.gdd_id.clone(), - version, - fingerprint: gdd.fingerprint.clone(), - pending_action_id: gdd.submission_id.clone(), - action_fingerprint: gdd.action_fingerprint.clone(), - approval_request_id: gdd.approval_request_id.clone(), - response_id: format!("gdd-response-00000000-0000-4000-8000-{version:012x}"), - action: "approve".to_string(), - comment: None, - }; - let decision_fingerprint = plan_gdd_approval_decision_fingerprint_for_identity( - &decision_input, - PLAN_GDD_APPROVAL_SOURCE, - &gdd.run_profile, - &gdd.run_profile_binding_fingerprint, - &gdd.session_id, - &gdd.created_by_run_id, - None, - ) - .expect("fixture approval decision fingerprint"); - let mut approval = PlanGddApprovalV1 { - schema_version: PLAN_GDD_APPROVAL_SCHEMA_VERSION.to_string(), - project_id: gdd.project_id.clone(), - gdd_id: gdd.gdd_id.clone(), - version, - fingerprint: gdd.fingerprint.clone(), - pending_action_id: gdd.submission_id.clone(), - action_fingerprint: gdd.action_fingerprint.clone(), - approval_request_id: gdd.approval_request_id.clone(), - response_id: decision_input.response_id, - decision_fingerprint, - source: PLAN_GDD_APPROVAL_SOURCE.to_string(), - run_profile: gdd.run_profile.clone(), - run_profile_binding_fingerprint: gdd.run_profile_binding_fingerprint.clone(), - session_id: gdd.session_id.clone(), - run_id: gdd.created_by_run_id.clone(), - action: "approve".to_string(), - comment: None, - decided_at_utc: gdd.created_at_utc.clone(), - receipt_fingerprint: String::new(), - }; - approval.receipt_fingerprint = plan_gdd_approval_receipt_fingerprint(&approval) - .expect("fixture receipt fingerprint"); - fs::write( - approvals_root.join(format!("v{version}.json")), - canonical_plan_gdd_approval_bytes(&approval) - .expect("fixture approval canonical bytes"), - ) - .expect("write fixture approval authority"); - } - - let error = execute_plan_submit_gdd(&root, &context, &input) - .expect_err("the 129th version is a durable lineage boundary"); - assert_eq!(error.code(), "PLAN_NEEDS_RECONCILIATION"); - assert!(error.to_string().contains("既有 GDD lineage")); - cleanup_fixture(root); - } - - #[test] - fn submit_handler_rejects_second_submission_while_first_is_pending() { - let (root, context, input) = submit_fixture(); - let first = execute_plan_submit_gdd(&root, &context, &input).expect("submit v1"); - let mut second_context = context.clone(); - second_context.action_id = "action-abcdefabcdefabcdefabcdef".to_string(); - second_context.action_fingerprint = "4".repeat(64); - let error = execute_plan_submit_gdd(&root, &second_context, &input) - .expect_err("second pending GDD must be rejected"); - assert_eq!(error.code(), "PLAN_PENDING_GDD_EXISTS"); - assert_eq!(first.gdd_ref.version, 1); - assert_eq!(read_plan_gdd_chain(&root).expect("read chain").len(), 1); - cleanup_fixture(root); - } - - #[test] - fn submit_allows_unasked_default_decisions_after_the_exact_session_prefix() { - let (root, context, mut input) = submit_fixture(); - input.decisions.push(PlanSubmitDecision { - id: "default-session-length".to_string(), - topic: "单局时长".to_string(), - state: "default_pending".to_string(), - answer_source: "default".to_string(), - round: 0, - answer_summary: "默认按十到二十分钟一局设计".to_string(), - }); - - let result = execute_plan_submit_gdd(&root, &context, &input) - .expect("an unasked default may follow the exact session decisions"); - let chain = read_plan_gdd_chain(&root).expect("read submitted chain"); - assert_eq!(result.gdd_ref.version, 1); - assert_eq!(chain[0].decisions.len(), input.decisions.len()); - assert_eq!(chain[0].decisions.last().unwrap().basis, None); - cleanup_fixture(root); - } - - #[test] - fn submit_rejects_user_revision_without_revise_or_reject_decision() { - let (root, context, mut input) = submit_fixture(); - input.decisions.push(PlanSubmitDecision { - id: "invented-confirmation".to_string(), - topic: "审批新增决定".to_string(), - state: "confirmed".to_string(), - answer_source: "user_revision".to_string(), - round: 0, - answer_summary: "用户在审批意见中明确提出".to_string(), - }); - - let error = execute_plan_submit_gdd(&root, &context, &input) - .expect_err("first collecting submit cannot forge user_revision"); - assert_eq!(error.code(), "PLAN_INVALID_REQUEST"); - assert!(error.to_string().contains("revise/reject")); - assert!(!root.join(".agent/planning/gdd.v1.json").exists()); - cleanup_fixture(root); - } - - /// 新版本的决定快照由本次提交负责,旧 session 不再覆盖其内容。 - #[test] - fn a_rewritten_answer_summary_is_preserved_while_a_stale_session_is_still_a_cas_conflict() { - let (root, context, mut input) = submit_fixture(); - input.decisions[0].answer_summary.push_str("(被改写)"); - execute_plan_submit_gdd(&root, &context, &input) - .expect("the current submit snapshot owns its decision text"); - let chain = read_plan_gdd_chain(&root).expect("read submitted chain"); - assert!(chain[0].decisions[0].answer_summary.ends_with("(被改写)")); - cleanup_fixture(root); - - // 同一份合法 input,只把 session revision 弄陈旧(权威已被推进)。 - let (root, mut stale_context, input) = submit_fixture(); - stale_context.source_session_revision = 2; - let cas = execute_plan_submit_gdd(&root, &stale_context, &input) - .expect_err("a stale session must still be a CAS conflict"); - assert_eq!(cas.code(), "PLAN_SESSION_CAS_CONFLICT"); - assert!(!root.join(".agent/planning/gdd.v1.json").exists()); - cleanup_fixture(root); - } - - /// 一轮澄清落进 durable 台账之后的 fixture:confirmed 一条 + 用户亲选的 - /// prototype_pending 一条(后者必须配同 id 的原型验证项,双射由 - /// `validate_decisions` 兜)。 - fn clarified_input() -> PlanSubmitGddInputV1 { - let mut input = valid_input(); - input.decisions.push(PlanSubmitDecision { - id: "core-loop-shape".to_string(), - topic: "核心防守方式".to_string(), - state: "confirmed".to_string(), - answer_source: "user_freeform".to_string(), - round: 1, - answer_summary: "不要那两个,我要玩家只能移动光源给守卫开路".to_string(), - }); - input.decisions.push(PlanSubmitDecision { - id: "touch-readability".to_string(), - topic: "触控可读性".to_string(), - state: "prototype_pending".to_string(), - answer_source: "user_option".to_string(), - round: 2, - answer_summary: "需要原型验证".to_string(), - }); - input - .prototype_validation_items - .push(PlanPrototypeValidationItem { - id: "touch-readability".to_string(), - question: "验证触控可读性是否成立".to_string(), - micro_prototype: "用 30~90 分钟制作只覆盖触控可读性的最小可交互原型".to_string(), - observation: "记录玩家在无额外提示时的行为".to_string(), - pass_criterion: "至少 3 次独立试玩中有 2 次出现预期行为".to_string(), - }); - input - } - - /// B3 的自救出口:用户的自由填写答非所问时,子 Agent 必须能按答案的**真实内容** - /// 重新命名这条决定。topic 归它所有,Runtime 不覆盖也不比对。 - #[test] - fn planning_child_may_rename_a_decision_topic_to_match_what_the_user_actually_said() { - let (root, context, mut input) = submit_fixture_from(clarified_input()); - input.decisions[1].topic = "重玩动力(用户实际回答的是这个)".to_string(); - execute_plan_submit_gdd(&root, &context, &input).expect("a renamed topic is the child's"); - let chain = read_plan_gdd_chain(&root).expect("read submitted chain"); - assert_eq!( - chain[0].decisions[1].topic, - "重玩动力(用户实际回答的是这个)" - ); - // 当前提交快照保留 Provider 生成的答案正文。 - assert_eq!( - chain[0].decisions[1].answer_summary, - "不要那两个,我要玩家只能移动光源给守卫开路" - ); - cleanup_fixture(root); - } - - /// 修订可以删除、重写或重新定义旧决定;Runtime 不把旧 session 快照当内容门禁。 - #[test] - fn a_revision_may_downgrade_or_drop_an_obsolete_decision() { - let (root, context, mut input) = submit_fixture_from(clarified_input()); - input.decisions[1].state = "default_pending".to_string(); - input.decisions[1].answer_source = "default".to_string(); - input.decisions[1].answer_summary = "按默认建议填写,等待用户确认".to_string(); - execute_plan_submit_gdd(&root, &context, &input).expect("revision may change a decision"); - let chain = read_plan_gdd_chain(&root).expect("read submitted chain"); - // 新快照按 Provider 提交内容保存。 - assert_eq!(chain[0].decisions[1].state, "default_pending"); - assert_eq!( - chain[0].decisions[1].answer_summary, - "按默认建议填写,等待用户确认" - ); - cleanup_fixture(root); - - let (root, context, mut input) = submit_fixture_from(clarified_input()); - input.decisions.remove(1); - execute_plan_submit_gdd(&root, &context, &input) - .expect("revision may remove an obsolete decision"); - cleanup_fixture(root); - } - - /// 修订可以重新定义原型验证范围,但结构约束仍然有效。 - #[test] - fn a_user_picked_prototype_validation_cannot_be_rewritten_by_the_planning_child() { - let (root, context, mut input) = submit_fixture_from(clarified_input()); - input.decisions[2].state = "confirmed".to_string(); - input.prototype_validation_items.clear(); - execute_plan_submit_gdd(&root, &context, &input) - .expect("revision may remove an obsolete prototype item"); - cleanup_fixture(root); - } - - /// B1:`initial-request` 承载用户原话,上限必须跟上游根 task 的真实上界一致 - /// (`AGENT_RUNTIME_TASK_MAX_CHARS` 再加一个截断省略号),其余决定仍是 400。 - #[test] - fn initial_request_answer_summary_matches_the_upstream_root_task_bound() { - assert_eq!( - PLAN_INITIAL_REQUEST_MAX_CHARS, - crate::agent::runtime_driver::AGENT_RUNTIME_TASK_MAX_CHARS + 1 - ); - - let mut input = valid_input(); - input.decisions[0].answer_summary = "需".repeat(PLAN_INITIAL_REQUEST_MAX_CHARS); - validate_plan_submit_gdd_input(&input) - .expect("a full-length initial request is a legal decision summary"); - - input.decisions[0].answer_summary = "需".repeat(PLAN_INITIAL_REQUEST_MAX_CHARS + 1); - validate_plan_submit_gdd_input(&input) - .expect_err("one scalar past the upstream bound must still fail"); - - let mut input = valid_input(); - input.decisions.push(PlanSubmitDecision { - id: "other-decision".to_string(), - topic: "其它决定".to_string(), - state: "default_pending".to_string(), - answer_source: "default".to_string(), - round: 0, - answer_summary: "默".repeat(PLAN_DECISION_ANSWER_SUMMARY_MAX_CHARS + 1), - }); - validate_plan_submit_gdd_input(&input) - .expect_err("non-initial decisions keep the 400 scalar bound"); - } - - /// N1:0 轮直出(用户一次把需求说全)时全部决定都是 round=0。Agent 必须能把 - /// 「我没问过、但选错会做不出首个可玩闭环」的判断标成 prototype_pending 并配 - /// 微型原型,而不是被迫标成 default_pending 假装那是个默认值。 - #[test] - fn an_unasked_risk_may_be_flagged_for_prototype_validation_at_round_zero() { - let mut input = valid_input(); - input.decisions.push(PlanSubmitDecision { - id: "touch-readability".to_string(), - topic: "触控放置手感与信息可读性".to_string(), - state: "prototype_pending".to_string(), - answer_source: "default".to_string(), - round: 0, - answer_summary: "没有向用户提问,选错会让首个可玩闭环立不住".to_string(), - }); - input - .prototype_validation_items - .push(PlanPrototypeValidationItem { - id: "touch-readability".to_string(), - question: "玩家能否在不读说明的情况下完成一次放置".to_string(), - micro_prototype: "用 30~90 分钟做一屏占位网格与三种占位单位".to_string(), - observation: "记录首次放置耗时与误触次数".to_string(), - pass_criterion: "3 名试玩者中 2 名在 30 秒内完成首次放置".to_string(), - }); - validate_plan_submit_gdd_input(&input) - .expect("an unasked risk may be flagged for prototype validation"); - - let (root, context, input) = submit_fixture_from(input); - execute_plan_submit_gdd(&root, &context, &input) - .expect("a zero-clarification draft may carry prototype validation items"); - let chain = read_plan_gdd_chain(&root).expect("read submitted chain"); - assert_eq!(chain[0].decisions[1].state, "prototype_pending"); - assert_eq!(chain[0].prototype_validation_items.len(), 1); - cleanup_fixture(root); - } - - /// round=0 区分默认建议与审批修订来源;澄清来源仍不能伪装成 round=0。 - #[test] - fn round_zero_accepts_user_revision_but_rejects_clarification_sources() { - let mut confirmed = valid_input(); - confirmed.decisions.push(PlanSubmitDecision { - id: "invented".to_string(), - topic: "审批修改的决定".to_string(), - state: "confirmed".to_string(), - answer_source: "user_revision".to_string(), - round: 0, - answer_summary: "用户在审批意见中明确修改".to_string(), - }); - validate_plan_submit_gdd_input(&confirmed) - .expect("user_revision may be confirmed at round=0"); - - let mut sourced = valid_input(); - sourced.decisions.push(PlanSubmitDecision { - id: "invented".to_string(), - topic: "澄清来源不能伪装为 round=0".to_string(), - state: "prototype_pending".to_string(), - answer_source: "user_option".to_string(), - round: 0, - answer_summary: "伪造".to_string(), - }); - validate_plan_submit_gdd_input(&sourced) - .expect_err("round=0 may not carry a clarification answer source"); - } - - #[test] - fn submit_replay_reports_missing_session_without_guessing_or_allocating_version() { - let (root, context, input) = submit_fixture(); - let first = execute_plan_submit_gdd(&root, &context, &input).expect("submit v1"); - fs::remove_file(root.join(".agent/planning/session.json")).expect("remove session"); - fs::remove_file(root.join(".agent/planning/.session.json.previous")) - .expect("remove session recovery copy"); - let replay = - execute_plan_submit_gdd(&root, &context, &input).expect("replay committed GDD"); - assert_eq!(replay.outcome, "replayed"); - assert_eq!(replay.gdd_ref, first.gdd_ref); - assert!(replay.recovery_pending); - assert_eq!(read_plan_gdd_chain(&root).expect("read chain").len(), 1); - assert!(!root.join(".agent/planning/session.json").exists()); - assert!(!root.join(".agent/planning/.session.json.previous").exists()); - assert!(!root.join(".agent/planning/gdd.v2.json").exists()); - cleanup_fixture(root); - } - - fn approval_input( - gdd: &PlanGddV1, - action: &str, - response_id: &str, - comment: Option, - ) -> DecidePlanGddInputV1 { - DecidePlanGddInputV1 { - gdd_id: gdd.gdd_id.clone(), - version: gdd.version, - fingerprint: gdd.fingerprint.clone(), - pending_action_id: gdd.submission_id.clone(), - approval_request_id: gdd.approval_request_id.clone(), - response_id: response_id.to_string(), - action: action.to_string(), - comment, - } - } - - /// Hold `.agent/project.lock` for `hold_millis`, then release it from a - /// background thread. Both fields must carry real values or the stale-lock - /// reclaim in `acquire_project_write_lock` deletes the file on the first - /// contended attempt and nothing is actually held: `pid` is what the unix - /// liveness check reads, `createdAt` is what the age check reads (it is - /// preferred over the file mtime). - fn hold_project_lock_briefly(root: &Path, hold_millis: u64) -> std::thread::JoinHandle<()> { - let lock_path = root.join(".agent/project.lock"); - let held = serde_json::json!({ - "commandId": "test.hold", - "pid": std::process::id(), - "createdAt": unix_timestamp(), - "nonce": 0, - }); - fs::write( - &lock_path, - serde_json::to_vec(&held).expect("serialize held lock"), - ) - .expect("hold project lock"); - std::thread::spawn(move || { - std::thread::sleep(std::time::Duration::from_millis(hold_millis)); - fs::remove_file(&lock_path).expect("release project lock"); - }) - } - - /// 审批按钮是一次性意图:点下去要么落盘,要么用户得重新找到卡片。批准放行后 - /// runner 立刻续跑并开始写盘,所以决定命令和运行时会同时伸手去拿同一把项目锁。 - /// 这里断言的是决定不能因为撞上这种瞬时争用而失败——线上症状是点了批准、审批 - /// 其实成功了,卡里却弹出 `项目正在被其他写操作占用`。 - #[test] - fn decision_rides_out_a_briefly_held_project_lock() { - let (root, context, input) = submit_fixture(); - execute_plan_submit_gdd(&root, &context, &input).expect("submit v1"); - let gdd = read_plan_gdd_chain(&root) - .expect("read submitted GDD") - .pop() - .expect("GDD exists"); - create_plan_gdd_approval_pending_for_test(&root, &gdd).expect("create approval pending"); - let decision_input = approval_input( - &gdd, - "approve", - "gdd-response-00000000-0000-4000-8000-000000000041", - None, - ); - - let holder = hold_project_lock_briefly(&root, 120); - let decision = decide_plan_gdd_at(&root, &decision_input) - .expect("决定必须等过瞬时锁争用,而不是把失败甩回按钮"); - holder.join().expect("lock holder thread"); - - assert_eq!(decision.outcome, "committed"); - cleanup_fixture(root); - } - - #[test] - fn approval_pending_is_single_latest_unreceipted_projection() { - let (root, context, input) = submit_fixture(); - execute_plan_submit_gdd(&root, &context, &input).expect("submit v1"); - let gdd = read_plan_gdd_chain(&root) - .expect("read submitted GDD") - .pop() - .expect("GDD exists"); - - create_plan_gdd_approval_pending_for_test(&root, &gdd).expect("create approval pending"); - let pending = read_plan_gdd_approval_pending_locked(&root) - .expect("read approval pending") - .expect("pending exists"); - assert_eq!(pending.status, "awaiting_decision"); - // Recreating the exact card is an idempotent replay. - create_plan_gdd_approval_pending_for_test(&root, &gdd).expect("replay approval pending"); - - let mut forged_next = gdd.clone(); - forged_next.version = 2; - forged_next.submission_id = "action-fedcba9876543210fedcba98".to_string(); - forged_next.approval_request_id = - "gdd-approval-00000000-0000-4000-8000-000000000003".to_string(); - forged_next.action_fingerprint = "4".repeat(64); - forged_next.fingerprint = plan_gdd_fingerprint(&forged_next).expect("next fingerprint"); - let stale = create_plan_gdd_approval_pending_for_test(&root, &forged_next) - .expect_err("a non-latest GDD cannot receive an approval card"); - assert_eq!(stale.code(), "PLAN_STALE_APPROVAL"); - - let decision = decide_plan_gdd_at( - &root, - &approval_input( - &gdd, - "approve", - "gdd-response-00000000-0000-4000-8000-000000000010", - None, - ), - ) - .expect("commit approval receipt"); - assert_eq!(decision.outcome, "committed"); - let after_receipt = create_plan_gdd_approval_pending_for_test(&root, &gdd) - .expect_err("a receipt must close awaiting_decision recreation"); - assert_eq!(after_receipt.code(), "PLAN_STALE_APPROVAL"); - cleanup_fixture(root); - } - - #[test] - fn approval_rejects_reconciliation_root_without_creating_receipt() { - let (root, context, input) = submit_fixture(); - execute_plan_submit_gdd(&root, &context, &input).expect("submit v1"); - let gdd = read_plan_gdd_chain(&root) - .expect("read submitted GDD") - .pop() - .expect("GDD exists"); - create_plan_gdd_approval_pending_for_test(&root, &gdd).expect("create approval pending"); - - let task_path = - game_creator_agent_runtime_task_path(&root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID); - let mut root_task = latest_game_creator_agent_runtime_tasks( - read_all_game_creator_agent_runtime_tasks(&task_path).expect("read root tasks"), - ) - .into_iter() - .find(|task| task.run_id == context.root_run_id) - .expect("plan root task"); - root_task.status = "running".to_string(); - root_task.phase = "needs-reconciliation".to_string(); - root_task.updated_at = unix_timestamp(); - let root_runtime = agent_runtime_state_from_task_record(&root_task); - append_game_creator_agent_runtime_task(&root, &root_runtime) - .expect("append reconciled root"); - - let error = decide_plan_gdd_at( - &root, - &approval_input( - &gdd, - "approve", - "gdd-response-00000000-0000-4000-8000-000000000099", - None, - ), - ) - .expect_err("reconciliation root must not create an approval receipt"); - assert_eq!(error.code(), "PLAN_STALE_APPROVAL"); - assert!(!root.join(".agent/planning/approvals/v1.json").exists()); - cleanup_fixture(root); - } - - #[test] - fn planning_capability_disabled_rejects_decision_without_creating_receipt() { - let (root, context, input) = submit_fixture(); - execute_plan_submit_gdd(&root, &context, &input).expect("submit v1"); - let gdd = read_plan_gdd_chain(&root) - .expect("read submitted GDD") - .pop() - .expect("GDD exists"); - create_plan_gdd_approval_pending_for_test(&root, &gdd).expect("create approval pending"); - let _config_guard = crate::tests::write_test_local_config( - r#"{"planning":{"capabilityEnabled":false}}"#.to_string(), - ); - - let error = decide_plan_gdd_at( - &root, - &approval_input( - &gdd, - "approve", - "gdd-response-00000000-0000-4000-8000-000000000098", - None, - ), - ) - .expect_err("disabled planning capability must reject decision"); - assert_eq!(error.code(), "PLAN_CAPABILITY_DISABLED"); - assert!(!root.join(".agent/planning/approvals/v1.json").exists()); - cleanup_fixture(root); - } - - #[test] - fn planning_capability_disabled_keeps_existing_authority_read_only() { - let (root, _gdd, root_runtime) = acceptance_gate_fixture(true); - let _config_guard = crate::tests::write_test_local_config( - r#"{"planning":{"capabilityEnabled":false}}"#.to_string(), - ); - - let outcome = ensure_plan_gdd_approval_pending_after_acceptance_at( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &root_runtime.run_id, - ) - .expect("disabled acceptance recovery is a no-op"); - assert_eq!(outcome, PlanGddAcceptanceGateOutcome::NotApplicable); - assert!(read_plan_gdd_approval_pending_locked(&root) - .expect("read pending") - .is_none()); - - let view = hydrate_game_creator_plan_gdd_state_at(&root) - .expect("disabled hydrate exposes an existing sidecar read-only"); - assert_eq!(view.state, "ready_for_approval"); - assert!(!view.recovery_pending); - assert!(!root.join(".agent/planning/approval.pending.json").exists()); - cleanup_fixture(root); - } - - #[test] - fn plan_gdd_completion_blocker_requires_pending_and_terminal_observation() { - let (root, context, input) = submit_fixture(); - execute_plan_submit_gdd(&root, &context, &input).expect("submit v1"); - let gdd = read_plan_gdd_chain(&root) - .expect("read submitted GDD") - .pop() - .expect("GDD exists"); - - let blocker = plan_gdd_completion_blocker_at_locked( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &context.root_run_id, - ) - .expect("missing approval pending must block reconciliation"); - assert_eq!(blocker.status, "needs-reconciliation"); - assert!(blocker.summary.contains("审批 pending")); - - create_plan_gdd_approval_pending_for_test(&root, &gdd).expect("create approval pending"); - let blocker = plan_gdd_completion_blocker_at_locked( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &context.root_run_id, - ) - .expect("awaiting approval must block completion"); - assert_eq!(blocker.status, "blocked"); - assert!(blocker.summary.contains("等待用户审批")); - // 驱动侧按类型化子状态选 phase:这一条必须是「等用户决定」,不能被当成人工核对。 - assert_eq!( - plan_gdd_typed_completion_blocker_at_locked( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &context.root_run_id, - ) - .expect("awaiting approval blocker") - .kind, - PlanGddCompletionBlockerKind::AwaitingApprovalDecision - ); - - let decision_input = approval_input( - &gdd, - "approve", - "gdd-response-00000000-0000-4000-8000-000000000030", - None, - ); - let decision = decide_plan_gdd_at(&root, &decision_input).expect("commit receipt"); - assert_eq!(decision.outcome, "committed"); - assert!(decision.recovery_pending); - let blocker = plan_gdd_completion_blocker_at_locked( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &context.root_run_id, - ) - .expect("receipt without terminal observation must reconcile"); - assert_eq!(blocker.status, "needs-reconciliation"); - assert!( - blocker.summary.contains("terminal observation"), - "unexpected blocker: {}", - blocker.summary - ); - - append_agent_db_terminal_observation_if_missing_for_action( - &root, - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - &gdd.created_by_run_id, - &gdd.submission_id, - serde_json::json!({ - "recordType": "agent.runtime.tool_observation", - "agentId": GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - "taskId": "task-plan-001", - "runId": gdd.created_by_run_id, - "actionId": gdd.submission_id, - "actionFingerprint": gdd.action_fingerprint, - "tool": PLAN_GDD_APPROVAL_TOOL, - "status": "ok", - "summary": "Fast GDD v1 已批准", - "decision": "approval", - }), - ) - .expect("append exact terminal observation"); - assert!(!reconcile_plan_gdd_approval_projections_at(&root) - .expect("reconcile receipt projections")); - assert!(plan_gdd_completion_blocker_at_locked( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &context.root_run_id, - ) - .is_none()); - cleanup_fixture(root); - } - - #[test] - fn plan_gdd_completion_blocker_is_scoped_to_exact_plan_root() { - let (root, context, input) = submit_fixture(); - execute_plan_submit_gdd(&root, &context, &input).expect("submit v1"); - assert!(plan_gdd_completion_blocker_at_locked( - &root, - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - "run-child-001", - ) - .is_none()); - assert!(plan_gdd_completion_blocker_at_locked( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - "run-not-current", - ) - .is_none()); - cleanup_fixture(root); - } - - #[test] - fn m1c2a_plan_root_without_its_own_gdd_cannot_reuse_legacy_markdown() { - let (root, context, _input) = submit_fixture(); - create_game_creator_agent_runtime_goal_contract_at( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &context.root_run_id, - "收敛 Fast GDD", - &AgentRuntimeGoalContractDraft { - outcome: "形成服务用户意图的 Fast GDD".to_string(), - non_negotiables: Vec::new(), - preferences: Vec::new(), - forbidden_assumptions: Vec::new(), - open_questions: Vec::new(), - acceptance_nodes: vec![AgentRuntimeGoalContractAcceptanceNodeDraft { - criterion_id: PLAN_FAST_GDD_ACCEPTANCE_NODE_ID.to_string(), - criterion: PLAN_FAST_GDD_ACCEPTANCE_NODE_CRITERION.to_string(), - required: true, - required_evidence: vec![PLAN_FAST_GDD_ACCEPTANCE_NODE_EVIDENCE.to_string()], - dependencies: Vec::new(), - }], - }, - ) - .expect("freeze current root contract"); - fs::write( - root.join(PLAN_FAST_GDD_PATH), - "# 上一根 Run 遗留的 Fast GDD\n", - ) - .expect("write legacy Markdown"); - - let blocker = plan_gdd_completion_blocker_at_locked( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &context.root_run_id, - ) - .expect("current root without submission must block"); - assert_eq!(blocker.status, "blocked"); - assert!(blocker.summary.contains("尚未提交 Fast GDD")); - // 这是策划最正常的早期推进态,下一步是 agent.delegate。它的 detail 里没有 - // approvalPending 字段,旧的子串判别会把它打成 needs-reconciliation 停掉整条 run。 - assert_eq!( - plan_gdd_typed_completion_blocker_at_locked( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &context.root_run_id, - ) - .expect("submission-not-started blocker") - .kind, - PlanGddCompletionBlockerKind::SubmissionNotStarted - ); - cleanup_fixture(root); - } - - #[test] - fn plan_gdd_completion_blocker_rejects_mismatched_observed_pending() { - let (root, context, input) = submit_fixture(); - execute_plan_submit_gdd(&root, &context, &input).expect("submit v1"); - let gdd = read_plan_gdd_chain(&root) - .expect("read submitted GDD") - .pop() - .expect("GDD exists"); - create_plan_gdd_approval_pending_for_test(&root, &gdd).expect("create approval pending"); - decide_plan_gdd_at( - &root, - &approval_input( - &gdd, - "approve", - "gdd-response-00000000-0000-4000-8000-000000000031", - None, - ), - ) - .expect("commit receipt"); - - let mut pending = read_plan_gdd_approval_pending_locked(&root) - .expect("read observed pending") - .expect("observed pending remains during recovery"); - pending.run_identity.run_id = "run-other-001".to_string(); - pending.pending_fingerprint = - plan_gdd_approval_pending_fingerprint(&pending).expect("recompute pending fingerprint"); - write_plan_gdd_approval_pending_atomic_locked(&root, &pending) - .expect("write mismatched projection"); - - let blocker = plan_gdd_completion_blocker_at_locked( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &context.root_run_id, - ) - .expect("mismatched pending must require reconciliation"); - assert_eq!(blocker.status, "needs-reconciliation"); - assert!( - blocker.summary.contains("pending 尚未按 receipt 收口"), - "unexpected blocker: {}", - blocker.summary - ); - assert_eq!( - plan_gdd_typed_completion_blocker_at_locked( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &context.root_run_id, - ) - .expect("mismatched pending blocker") - .kind, - PlanGddCompletionBlockerKind::NeedsReconciliation - ); - cleanup_fixture(root); - } - - #[test] - fn approval_comment_accepts_full_scalar_limit_and_observation_prefix() { - let (root, context, input) = submit_fixture(); - execute_plan_submit_gdd(&root, &context, &input).expect("submit v1"); - let gdd = read_plan_gdd_chain(&root) - .expect("read submitted GDD") - .pop() - .expect("GDD exists"); - let comment = "界".repeat(1_000); - let normalized = normalize_plan_gdd_approval_comment("revise", Some(&comment)) - .expect("1,000 scalar comment is valid") - .expect("comment remains present"); - assert_eq!(normalized.chars().count(), 1_000); - let mut pending = PlanGddApprovalPendingV1 { - schema_version: PLAN_GDD_APPROVAL_PENDING_SCHEMA_VERSION.to_string(), - kind: PLAN_GDD_APPROVAL_PENDING_KIND.to_string(), - project_id: gdd.project_id.clone(), - agent_id: PLAN_GDD_APPROVAL_AGENT_ID.to_string(), - gdd_ref: PlanGddRef { - gdd_id: gdd.gdd_id.clone(), - version: gdd.version, - fingerprint: gdd.fingerprint.clone(), - }, - submission: PlanGddApprovalPendingSubmission { - tool: PLAN_GDD_APPROVAL_TOOL.to_string(), - pending_action_id: gdd.submission_id.clone(), - action_fingerprint: gdd.action_fingerprint.clone(), - approval_request_id: gdd.approval_request_id.clone(), - }, - run_identity: PlanGddApprovalPendingRunIdentity { - source: PLAN_GDD_APPROVAL_SOURCE.to_string(), - run_profile: gdd.run_profile.clone(), - run_profile_binding_fingerprint: gdd.run_profile_binding_fingerprint.clone(), - session_id: gdd.session_id.clone(), - run_id: gdd.created_by_run_id.clone(), - }, - status: "observed_revise".to_string(), - observation: Some(PlanGddApprovalObservationV1 { - tool: PLAN_GDD_APPROVAL_TOOL.to_string(), - status: "ok".to_string(), - summary: format!("Fast GDD v{} 需要修改", gdd.version), - detail: Some(format!("用户修改意见:{normalized}")), - }), - pending_fingerprint: String::new(), - }; - pending.pending_fingerprint = - plan_gdd_approval_pending_fingerprint(&pending).expect("pending fingerprint"); - validate_plan_gdd_approval_pending(&pending) - .expect("observation prefix must leave room for the full comment"); - let mut tampered = pending.clone(); - tampered - .observation - .as_mut() - .expect("observation exists") - .summary = "伪造的审批摘要".to_string(); - tampered.pending_fingerprint = - plan_gdd_approval_pending_fingerprint(&tampered).expect("tampered fingerprint"); - assert!(validate_plan_gdd_approval_pending(&tampered).is_err()); - let too_long = format!("{comment}界"); - assert!(normalize_plan_gdd_approval_comment("revise", Some(&too_long)).is_err()); - let decision_error = decide_plan_gdd_at( - &root, - &approval_input( - &gdd, - "revise", - "gdd-response-00000000-0000-4000-8000-000000000011", - Some(too_long), - ), - ) - .expect_err("invalid command comment must be rejected at transport boundary"); - assert_eq!(decision_error.code(), "PLAN_INVALID_REQUEST"); - assert_eq!( - PLAN_GDD_APPROVAL_FINGERPRINT_DOMAIN, - "genarrative.plan.gdd-approval-receipt.v1" - ); - cleanup_fixture(root); - } - - /// 审批卡上的「修改/退回」意见必须原文落进 Supervisor 会话——那是 playbook - /// 第 6 条「把用户原话完整附在 task 里」唯一的原话来源。delivery 的 - /// `contractStatus=user-revision-requested` 只说明「用户要改」,不带内容, - /// Supervisor 拿不到原文就只能在返工委派里写一句占位。 - /// - /// 追加必须幂等:`reconcile_plan_gdd_approval_projections_locked` 每次 hydrate - /// 都会为全部回执重跑 `project_receipt_locked`。 - #[test] - fn a_revision_comment_reaches_the_supervisor_conversation_once() { - let (root, gdd, _root_runtime) = acceptance_gate_fixture(true); - create_plan_gdd_approval_pending_for_test(&root, &gdd).expect("create approval pending"); - decide_plan_gdd_at( - &root, - &approval_input( - &gdd, - "revise", - "gdd-response-00000000-0000-4000-8000-000000000050", - Some("把游戏名称改成日本语".to_string()), - ), - ) - .expect("commit revise receipt"); - let supervisor_messages = || { - read_local_conversation_for_session_at( - &root, - Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID), - None, - ) - .expect("read supervisor conversation") - .messages - }; - let after_decision = supervisor_messages(); - assert!( - after_decision.iter().any(|message| message.role == "user" - && message.content.contains("把游戏名称改成日本语")), - "用户修改意见必须原文进入 Supervisor 会话" - ); - reconcile_plan_gdd_approval_projections_locked(&root).expect("replay receipt projections"); - assert_eq!( - supervisor_messages().len(), - after_decision.len(), - "投影重放不得重复追加同一条审批意见" - ); - cleanup_fixture(root); - } - - /// reject 之后能不能在同一 lineage 重做,**不由提交门的 phase 判据决定**。 - /// - /// 提交门要求 session 的 activeRunId 等于当前策划子 run,而 schema 不变量禁止 - /// `rejected`(以及 `revision_requested`、`approved`、`awaiting_*`)保留 - /// activeRunId——两者互斥,所以终态 phase 永远到不了那条 phase 判据。真正决定重做 - /// 能力的是 M1C-2b 的 continuation 起点 writer:技术方案 §8.6 要求它「以新 - /// activeRunId 写 revision+1 successor」,而唯一能同时带 activeRunId 又过提交门的 - /// phase 只有 `collecting`。 - /// - /// 这条测试把该约束锁住,免得日后有人以为「把 rejected 加进提交门允许集」就能让 - /// reject 重做——那只会多一个不可达分支,真正的续跑仍然起不来。 - #[test] - fn rejected_session_needs_a_collecting_continuation_not_a_wider_submit_gate() { - let (root, context, input) = submit_fixture(); - execute_plan_submit_gdd(&root, &context, &input).expect("submit v1"); - let gdd = read_plan_gdd_chain(&root) - .expect("read submitted GDD") - .pop() - .expect("GDD exists"); - create_plan_gdd_approval_pending_for_test(&root, &gdd).expect("create approval pending"); - decide_plan_gdd_at( - &root, - &approval_input( - &gdd, - "reject", - "gdd-response-00000000-0000-4000-8000-000000000040", - Some("当前方向需要重新梳理".to_string()), - ), - ) - .expect("commit reject receipt"); - - let session = read_plan_session_with_recovery(&root) - .expect("read session after reject") - .expect("session exists after reject"); - assert_eq!(session.phase, "rejected"); - assert!(session.active_run_id.is_none()); - - // 终态 session 直接挂 activeRunId 连指纹都算不出来——schema 层就禁止。 - let mut forged = session.clone(); - forged.active_run_id = Some(context.created_by_run_id.clone()); - let error = - plan_session_fingerprint(&forged).expect_err("终态 session 不得保留 activeRunId"); - assert_eq!(error.code(), "PLAN_INVALID_SCHEMA"); - - // continuation 起点把 phase 落回 collecting 之后,同一 lineage 才能继续提交。 - let mut continuation = session.clone(); - continuation.session_revision += 1; - continuation.previous_fingerprint = Some(session.session_fingerprint.clone()); - continuation.phase = "collecting".to_string(); - continuation.active_run_id = Some(context.created_by_run_id.clone()); - continuation.session_fingerprint = - plan_session_fingerprint(&continuation).expect("continuation session fp"); - let mut next_context = context.clone(); - next_context.source_session_revision = continuation.session_revision; - next_context.source_session_fingerprint = continuation.session_fingerprint.clone(); - validate_current_session_cas(&continuation, &next_context) - .expect("reject 之后的 continuation 必须能提交同一 lineage 的下一版本"); - - // 提交闸放行还不够:投影守卫必须认同一条 continuation。这条 session 必然带着 - // v1 的 latestSubmittedRef 和 reject 的 lastDecisionRef,投影守卫若据此判它不是 - // 合法起点,v2 就会越过提交点却收不了口,留下一个任何重放都清不掉的 - // recoveryPending。 - write_plan_session_atomic_locked(&root, &continuation).expect("write continuation session"); - next_context.action_id = "action-89abcdef0123456789abcdef".to_string(); - next_context.action_fingerprint = "4".repeat(64); - next_context.approval_request_id = - Some("gdd-approval-00000000-0000-4000-8000-000000000041".to_string()); - let mut revised_input = input.clone(); - revised_input.game.title = "审批修订后的标题".to_string(); - revised_input.decisions.push(PlanSubmitDecision { - id: "approval-scope".to_string(), - topic: "审批修改范围".to_string(), - state: "confirmed".to_string(), - answer_source: "user_revision".to_string(), - round: 0, - answer_summary: "用户要求采用新的首版范围".to_string(), - }); - let resubmit = execute_plan_submit_gdd(&root, &next_context, &revised_input) - .expect("continuation 提交 v2"); - assert_eq!(resubmit.gdd_ref.version, 2); - let chain = read_plan_gdd_chain(&root).expect("read revised GDD chain"); - assert_eq!(chain[1].game.title, "审批修订后的标题"); - assert_eq!( - chain[1].decisions.last().unwrap().answer_source, - "user_revision" - ); - assert!( - !resubmit.recovery_pending, - "提交闸放行的 continuation,投影守卫也必须放行" - ); - cleanup_fixture(root); - } - - #[test] - fn approval_actions_rebuild_receipt_aware_index_and_are_idempotent() { - for (action, comment, expected_status) in [ - ("approve", None, "approved"), - ("revise", Some("请收窄首局范围"), "revision_requested"), - ("reject", Some("当前方向需要重新梳理"), "rejected"), - ] { - let (root, context, input) = submit_fixture(); - execute_plan_submit_gdd(&root, &context, &input).expect("submit v1"); - let gdd = read_plan_gdd_chain(&root) - .expect("read submitted GDD") - .pop() - .expect("GDD exists"); - create_plan_gdd_approval_pending_for_test(&root, &gdd) - .expect("create approval pending"); - let first_input = approval_input( - &gdd, - action, - "gdd-response-00000000-0000-4000-8000-000000000020", - comment.map(str::to_string), - ); - let first = decide_plan_gdd_at(&root, &first_input).expect("commit receipt"); - assert_eq!(first.outcome, "committed"); - assert_eq!(first.decision_ref.action, action); - assert_eq!(first.approved_gdd_ref.is_some(), action == "approve"); - - let approvals = read_plan_gdd_approvals_locked(&root).expect("read receipts"); - let index = build_plan_gdd_index_with_approvals( - &read_plan_gdd_chain(&root).expect("read GDD chain"), - &approvals, - "2026-08-15T00:00:00.000Z", - ) - .expect("receipt-aware index"); - assert_eq!(index.status_cache.versions[0].status, expected_status); - assert_eq!(index.status_cache.pending_version, None); - assert_eq!( - index.status_cache.approved_version, - (action == "approve").then_some(1) - ); - - let replay = decide_plan_gdd_at(&root, &first_input).expect("replay same response"); - assert_eq!(replay.outcome, "replayed"); - let already_decided = decide_plan_gdd_at( - &root, - &approval_input( - &gdd, - action, - "gdd-response-00000000-0000-4000-8000-000000000021", - comment.map(str::to_string), - ), - ) - .expect("different response returns existing decision"); - assert_eq!(already_decided.outcome, "already-decided"); - let conflicting = decide_plan_gdd_at( - &root, - &approval_input( - &gdd, - action, - "gdd-response-00000000-0000-4000-8000-000000000020", - if action == "approve" { - Some("changed intent".to_string()) - } else { - Some("changed intent".to_string()) - }, - ), - ) - .expect_err("same response with changed intent must conflict"); - assert_eq!(conflicting.code(), "PLAN_DECISION_IDENTITY_CONFLICT"); - cleanup_fixture(root); - } - } - - #[tokio::test] - async fn approval_receipt_consumes_submit_batch_and_folds_final_provider_usage_once() { - let (root, mut context, input) = submit_fixture(); - let mut child_runtime = start_game_creator_agent_runtime_task_at( - &root, - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - "提交 Fast GDD", - &context.created_by_run_id, - "agent-delegate", - "提交一份可审批的 Fast GDD", - vec!["读取项目事实".to_string(), "提交 GDD".to_string()], - ) - .expect("start planning child task"); - child_runtime.session_id = context.session_id.clone(); - child_runtime.parent_agent_id = Some(context.root_agent_id.clone()); - child_runtime.parent_run_id = Some(context.root_run_id.clone()); - child_runtime.delegation_id = Some(context.delegation_id.clone()); - child_runtime.run_profile_binding_fingerprint = - context.run_profile_binding_fingerprint.clone(); - append_game_creator_agent_runtime_task(&root, &child_runtime) - .expect("persist planning child identity"); - write_game_creator_agent_runtime_state(&root, &child_runtime) - .expect("persist current planning child identity"); - - let mut snapshot = AgentRuntimeProviderRequestSnapshot { - project_id: context.project_id.clone(), - agent_id: child_runtime.agent_id.clone(), - task_id: child_runtime.task_id.clone(), - session_id: child_runtime.session_id.clone(), - run_id: child_runtime.run_id.clone(), - source: child_runtime.source.clone(), - goal_id: child_runtime.goal_id.clone(), - goal_revision: child_runtime.goal_revision, - goal_snapshot_fingerprint: agent_goal_snapshot_fingerprint_for_state_at( - &root, - &child_runtime, - ) - .expect("planning goal snapshot fingerprint"), - applied_steer_cursor: child_runtime.applied_steer_cursor, - request_kind: "tool-plan".to_string(), - request_slot: "loop-1-repair-0".to_string(), - web_search_enabled: false, - allow_idle_context_compaction: false, - planning_session_binding: None, - }; - let binding = capture_plan_provider_session_binding_for_snapshot( - &root, - &child_runtime, - &snapshot, - &format!("sha256-serde-json-v2:{}", "7".repeat(64)), - ) - .expect("capture submit Provider binding"); - snapshot.planning_session_binding = Some(binding.clone()); - let request_id = game_creator_agent_runtime_provider_request_id(&snapshot); - assert_eq!(request_id, binding.provider_request_id); - assert!( - append_game_creator_agent_runtime_provider_request_lifecycle( - &root, - &snapshot, - &request_id, - "started", - ) - .expect("append final submit request start") - ); - let usage_scope = - capture_plan_provider_usage_scope_at_locked(&root, &snapshot, &request_id) - .expect("capture final submit usage scope"); - assert!(persist_plan_provider_usage_fact_at( - &root, - &snapshot, - &request_id, - usage_scope.as_ref(), - "completed", - 23, - ) - .expect("persist final submit usage fact")); - assert!( - append_game_creator_agent_runtime_provider_request_lifecycle( - &root, - &snapshot, - &request_id, - "completed", - ) - .expect("append final submit request completion") - ); - - let plan = AgentRuntimeToolPlan { - thinking_summary: "Fast GDD 已收敛,提交审批".to_string(), - plan_update: None, - plan: vec!["提交 Fast GDD".to_string()], - actions: vec![AgentRuntimeToolAction { - tool: PLAN_SUBMIT_GDD_TOOL.to_string(), - reason: Some("提交当前策划版本".to_string()), - input: serde_json::to_value(&input).expect("serialize plan.submit_gdd input"), - }], - response: String::new(), - }; - let project_revision = - read_game_creator_agent_runtime_project_revision(&root).expect("read revision"); - let repository_fingerprint = build_repository_startup_context_at(&root) - .expect("build repository context") - .fingerprint; - let batch = - match prepare_game_creator_agent_runtime_provider_action_batch_with_planning_binding( - &root, - &child_runtime, - "提交一份可审批的 Fast GDD", - &plan, - &[], - &project_revision, - &repository_fingerprint, - Some(&binding), - ) - .await - .expect("prepare exact plan.submit_gdd batch") - { - AgentRuntimeProviderActionBatchPreparation::Ready(batch) => batch, - other => panic!("plan.submit_gdd batch must be ready: {other:?}"), - }; - let pending = batch.actions[0].clone(); - write_game_creator_agent_runtime_pending_tool_action(&root, &pending) - .expect("persist standalone submit anchor"); - context.action_id = pending.action_id.clone(); - context.action_fingerprint = pending.action_fingerprint.clone(); - - let submit_result = execute_plan_submit_gdd(&root, &context, &input).expect("commit GDD"); - assert_eq!( - fold_plan_provider_usage_into_session_at_locked(&root) - .expect("submit batch must defer final usage"), - PlanProviderUsageFoldOutcome::Deferred - ); - // Close the child through the production writer rather than a - // hand-rolled terminal state. It stamps the task projection with the - // submit `actionId`, which is what makes the receipt consumption below - // collide on the (runId, actionId, phase) idempotency key. A plain - // `append_game_creator_agent_runtime_task` leaves no keyed row, so the - // collision never happens and this test passes over a lane that fails - // 100% of the time in production. - let delivery = new_static_delegate_delivery( - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - "root-session-001", - &context.root_run_id, - "parent-action-001", - &context.delegation_id, - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - &context.session_id, - &context.created_by_run_id, - ); - create_or_read_static_delegate_delivery_at(&root, &delivery) - .expect("create planning delivery"); - mark_static_delegate_delivery_ready_at( - &root, - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - &context.session_id, - &context.created_by_run_id, - &context.delegation_id, - "completed", - &format!("Fast GDD v{} 已提交。", submit_result.gdd_ref.version), - ) - .expect("close planning delivery"); - let mut child_completed = child_runtime.clone(); - ensure_project_planning_submit_child_completion_at( - &root, - &mut child_completed, - &pending, - &submit_result, - ) - .expect("close planning child at the submit point"); - - let gdd = read_plan_gdd_chain(&root) - .expect("read submitted GDD") - .pop() - .expect("GDD exists"); - create_plan_gdd_approval_pending_for_test(&root, &gdd).expect("create approval pending"); - let decision_input = approval_input( - &gdd, - "approve", - "gdd-response-00000000-0000-4000-8000-000000000022", - None, - ); - let first = decide_plan_gdd_at(&root, &decision_input).expect("commit approval receipt"); - assert_eq!(first.outcome, "committed"); - assert!(!first.recovery_pending); - assert!(!game_creator_agent_runtime_pending_tool_action_exists( - &root, - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - &context.created_by_run_id, - )); - assert!(!game_creator_agent_runtime_provider_action_batch_exists( - &root, - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - &context.created_by_run_id, - )); - let folded = read_plan_session_with_recovery(&root) - .expect("read folded receipt session") - .expect("receipt session exists"); - assert_eq!(folded.phase, "approved"); - assert_eq!(folded.accumulated_agent_millis, 23); - let folded_revision = folded.session_revision; - - let replay = decide_plan_gdd_at(&root, &decision_input).expect("replay approval decision"); - assert_eq!(replay.outcome, "replayed"); - assert!(!replay.recovery_pending); - assert!(!reconcile_plan_gdd_approval_projections_at(&root) - .expect("replay receipt recovery projections")); - let replayed = read_plan_session_with_recovery(&root) - .expect("read replayed receipt session") - .expect("replayed receipt session exists"); - assert_eq!(replayed.session_revision, folded_revision); - assert_eq!(replayed.accumulated_agent_millis, 23); - cleanup_fixture(root); - } - - #[test] - fn projection_failure_after_gdd_create_returns_recovery_pending_and_replays() { - let (root, context, input) = submit_fixture(); - fs::create_dir(root.join("game/fast_gdd.md")).expect("block markdown target"); - let first = execute_plan_submit_gdd(&root, &context, &input).expect("submit fact"); - assert_eq!(first.outcome, "submitted"); - assert!(first.recovery_pending); - assert!(root.join(".agent/planning/gdd.v1.json").is_file()); - fs::remove_dir(root.join("game/fast_gdd.md")).expect("remove blocking directory"); - let replay = execute_plan_submit_gdd(&root, &context, &input).expect("repair projections"); - assert_eq!(replay.outcome, "replayed"); - assert!(!replay.recovery_pending); - assert!(root.join("game/fast_gdd.md").is_file()); - cleanup_fixture(root); - } - - #[test] - fn m1c2a_acceptance_gate_creates_pending_only_after_current_root_evidence() { - let (root, gdd, root_runtime) = acceptance_gate_fixture(true); - let contract = read_game_creator_agent_runtime_goal_contract_at( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &root_runtime.run_id, - ) - .expect("read plan contract") - .expect("plan contract exists"); - let evidence = serde_json::json!({ - "agentId": GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - "runId": root_runtime.run_id, - "actionId": "action-aaaaaaaaaaaaaaaaaaaaaaaa", - }); - let waiting = observe_agent_runtime_acceptance_update( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &root_runtime.run_id, - &serde_json::json!({ - "contractFingerprint": contract.contract_fingerprint, - "evaluations": [{ - "criterionId": PLAN_FAST_GDD_ACCEPTANCE_NODE_ID, - "status": "failed", - "evidence": [evidence.clone()], - "summary": "尚未确认当前 GDD 服务用户意图", - }], - }), - ); - assert_eq!(waiting.status, "ok"); - let waiting_detail = serde_json::from_str::( - waiting.detail.as_deref().expect("waiting detail"), - ) - .expect("parse waiting detail"); - assert_eq!(waiting_detail["approvalPending"], "not-created"); - assert_eq!(waiting_detail["nextRequiredAction"], "agent.delegate"); - assert_eq!(waiting_detail["repairOfDelegationId"], gdd.delegation_id); - assert!(read_plan_gdd_approval_pending_locked(&root) - .expect("read pending before gate") - .is_none()); - - let passed = observe_agent_runtime_acceptance_update( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &root_runtime.run_id, - &serde_json::json!({ - "contractFingerprint": contract.contract_fingerprint, - "evaluations": [{ - "criterionId": PLAN_FAST_GDD_ACCEPTANCE_NODE_ID, - "status": "passed", - "evidence": [evidence], - "summary": "当前 GDD 服务用户意图", - }], - }), - ); - assert_eq!(passed.status, "ok"); - let passed_detail = serde_json::from_str::( - passed.detail.as_deref().expect("passed detail"), - ) - .expect("parse passed detail"); - assert_eq!(passed_detail["approvalPending"], "created"); - let pending = read_plan_gdd_approval_pending_locked(&root) - .expect("read created pending") - .expect("pending exists"); - assert_eq!(pending.gdd_ref.gdd_id, gdd.gdd_id); - assert_eq!(pending.gdd_ref.version, gdd.version); - assert_eq!( - pending.submission.approval_request_id, - gdd.approval_request_id - ); - - fs::write( - root.join(PLAN_FAST_GDD_PATH), - "审批卡已证明原 revision 通过\n", - ) - .expect("mutate Markdown after pending"); - let replay = ensure_plan_gdd_approval_pending_after_acceptance_at( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &root_runtime.run_id, - ) - .expect("replay acceptance gate"); - assert_eq!(replay, PlanGddAcceptanceGateOutcome::PendingAlreadyPresent); - - decide_plan_gdd_at( - &root, - &approval_input( - &gdd, - "approve", - "gdd-response-00000000-0000-4000-8000-000000000031", - None, - ), - ) - .expect("approve accepted Fast GDD"); - assert!(read_game_creator_agent_runtime_acceptance_graph_at( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &root_runtime.run_id, - ) - .expect("read graph after approval Markdown rewrite") - .is_some()); - assert!( - goal_contract_acceptance_completion_blocker_at_locked(&root, &root_runtime).is_none() - ); - cleanup_fixture(root); - } - - #[test] - fn m1c2a_unapproved_gdd_requires_acceptance_evidence_before_delegate() { - let (root, _gdd, root_runtime) = acceptance_gate_fixture(true); - - assert_eq!( - plan_root_supervisor_stage_at( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &root_runtime.run_id, - ) - .expect("classify plan root stage"), - PlanRootSupervisorStage::AwaitingAcceptanceEvidence - ); - let project_lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( - &root, - "test.plan-root-stage-locked", - ) - .expect("acquire plan root stage lock"); - assert_eq!( - plan_root_supervisor_stage_at_locked( - &root, - &project_lock, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &root_runtime.run_id, - ) - .expect("classify locked plan root stage"), - PlanRootSupervisorStage::AwaitingAcceptanceEvidence - ); - drop(project_lock); - assert_eq!( - agent_runtime_plan_root_supervisor_tools_for_stage( - PlanRootSupervisorStage::AwaitingAcceptanceEvidence - ), - &["file.read", "agent.acceptance_update", "agent.run_status"] - ); - - cleanup_fixture(root); - } - - #[test] - fn m1c2a_failed_acceptance_requires_claim_before_repair_dispatch() { - let (root, gdd, root_runtime) = acceptance_gate_fixture(false); - let contract = read_game_creator_agent_runtime_goal_contract_at( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &root_runtime.run_id, - ) - .expect("read plan contract") - .expect("plan contract exists"); - let input = serde_json::json!({ - "contractFingerprint": contract.contract_fingerprint, - "evaluations": [{ - "criterionId": PLAN_FAST_GDD_ACCEPTANCE_NODE_ID, - "status": "failed", - "evidence": [{ - "agentId": GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - "runId": root_runtime.run_id, - "actionId": "action-aaaaaaaaaaaaaaaaaaaaaaaa", - }], - "summary": "当前 GDD 尚未服务用户意图", - }], - }); - - let before_claim = observe_agent_runtime_acceptance_update( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &root_runtime.run_id, - &input, - ); - assert_eq!(before_claim.status, "ok"); - let before_claim_detail = serde_json::from_str::( - before_claim.detail.as_deref().expect("before-claim detail"), - ) - .expect("parse before-claim detail"); - assert_eq!( - before_claim_detail["nextRequiredAction"], - "agent.run_status" - ); - assert_eq!(before_claim_detail["delegationId"], gdd.delegation_id); - assert!(before_claim_detail.get("repairOfDelegationId").is_none()); - - let after_claim = observe_agent_runtime_run_status( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &root_runtime.run_id, - Some("action-eeeeeeeeeeeeeeeeeeeeeeee"), - &serde_json::json!({"scope": "self"}), - ); - assert_eq!(after_claim.status, "ok"); - let after_claim_detail = after_claim.detail.as_deref().expect("after-claim detail"); - assert!(after_claim_detail.contains("planGddAcceptanceGate")); - assert!(after_claim_detail.contains("\"nextRequiredAction\":\"agent.delegate\"")); - assert!(after_claim_detail.contains(&gdd.delegation_id)); - assert!(read_plan_gdd_approval_pending_locked(&root) - .expect("read pending") - .is_none()); - cleanup_fixture(root); - } - - #[tokio::test] - async fn m1c2a_claim_without_graph_requests_evidence_before_any_repair() { - let (root, _gdd, root_runtime) = acceptance_gate_fixture(false); - let claim = execute_game_creator_agent_runtime_tool_action_with_action_id( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &root_runtime.run_id, - &root_runtime.current_task, - &AgentRuntimeToolAction { - tool: "agent.run_status".to_string(), - reason: Some("认领 planning delivery 后开始验收取证".to_string()), - input: serde_json::json!({"scope": "self"}), - }, - Some("action-191919191919191919191919"), - ) - .await; - assert_eq!(claim.status, "ok"); - let detail = claim.detail.as_deref().expect("claim gate detail"); - assert!(detail.contains("\"nextRequiredAction\":\"file.read\"")); - assert!(!detail.contains("\"repairOfDelegationId\":\"")); - assert!(read_plan_gdd_approval_pending_locked(&root) - .expect("read pending before evidence") - .is_none()); - cleanup_fixture(root); - } - - #[tokio::test] - async fn m1c2a_ready_passed_acceptance_creates_pending_when_run_status_claims_delivery() { - let (root, gdd, root_runtime) = acceptance_gate_fixture(false); - let contract = read_game_creator_agent_runtime_goal_contract_at( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &root_runtime.run_id, - ) - .expect("read plan contract") - .expect("plan contract exists"); - let passed = observe_agent_runtime_acceptance_update( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &root_runtime.run_id, - &serde_json::json!({ - "contractFingerprint": contract.contract_fingerprint, - "evaluations": [{ - "criterionId": PLAN_FAST_GDD_ACCEPTANCE_NODE_ID, - "status": "passed", - "evidence": [{ - "agentId": GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - "runId": root_runtime.run_id, - "actionId": "action-aaaaaaaaaaaaaaaaaaaaaaaa", - }], - "summary": "当前 GDD 服务用户意图", - }], - }), - ); - assert_eq!(passed.status, "ok"); - let passed_detail = serde_json::from_str::( - passed.detail.as_deref().expect("passed detail"), - ) - .expect("parse passed detail"); - assert_eq!(passed_detail["nextRequiredAction"], "agent.run_status"); - assert_eq!(passed_detail["delegationId"], gdd.delegation_id); - assert!(read_plan_gdd_approval_pending_locked(&root) - .expect("read pending before claim") - .is_none()); - - let claim = execute_game_creator_agent_runtime_tool_action_with_action_id( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &root_runtime.run_id, - &root_runtime.current_task, - &AgentRuntimeToolAction { - tool: "agent.run_status".to_string(), - reason: Some("认领 planning delivery 并运行验收门".to_string()), - input: serde_json::json!({"scope": "self"}), - }, - Some("action-ffffffffffffffffffffffff"), - ) - .await; - assert_eq!(claim.status, "ok"); - assert!(claim - .detail - .as_deref() - .expect("claim detail") - .contains("\"approvalPending\":\"created\"")); - let pending = read_plan_gdd_approval_pending_locked(&root) - .expect("read pending after claim") - .expect("pending exists after claim"); - assert_eq!(pending.gdd_ref.gdd_id, gdd.gdd_id); - assert_eq!(pending.gdd_ref.version, gdd.version); - cleanup_fixture(root); - } - - #[tokio::test] - async fn m1c2a_run_status_replays_gate_after_an_earlier_action_claimed_delivery() { - let (root, gdd, root_runtime) = acceptance_gate_fixture(true); - let contract = read_game_creator_agent_runtime_goal_contract_at( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &root_runtime.run_id, - ) - .expect("read plan contract") - .expect("plan contract exists"); - update_game_creator_agent_runtime_acceptance_graph_at( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &root_runtime.run_id, - &contract.contract_fingerprint, - &[AgentRuntimeAcceptanceEvaluationDraft { - criterion_id: PLAN_FAST_GDD_ACCEPTANCE_NODE_ID.to_string(), - status: "passed".to_string(), - evidence: vec![AgentRuntimeAcceptanceEvidenceRef { - agent_id: GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(), - run_id: root_runtime.run_id.clone(), - action_id: "action-aaaaaaaaaaaaaaaaaaaaaaaa".to_string(), - }], - summary: "当前 GDD 服务用户意图".to_string(), - }], - ) - .expect("record passed graph without running gate"); - assert!(read_plan_gdd_approval_pending_locked(&root) - .expect("read missing pending") - .is_none()); - - let replay = execute_game_creator_agent_runtime_tool_action_with_action_id( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &root_runtime.run_id, - &root_runtime.current_task, - &AgentRuntimeToolAction { - tool: "agent.run_status".to_string(), - reason: Some("重放已认领 delivery 的验收门".to_string()), - input: serde_json::json!({"scope": "self"}), - }, - Some("action-121212121212121212121212"), - ) - .await; - assert_eq!(replay.status, "ok"); - let detail = replay.detail.as_deref().expect("run status detail"); - assert!(!detail.contains("readyDelegateReceipts")); - assert!(detail.contains("\"approvalPending\":\"created\"")); - let pending = read_plan_gdd_approval_pending_locked(&root) - .expect("read replayed pending") - .expect("pending created after zero-ready replay"); - assert_eq!(pending.gdd_ref.gdd_id, gdd.gdd_id); - cleanup_fixture(root); - } - - #[tokio::test] - async fn m1c2a_run_status_tolerates_dispatched_delivery_after_gdd_submit() { - let (root, gdd, root_runtime) = acceptance_gate_fixture(false); - let mut delivery = read_static_delegate_delivery_at(&root, &gdd.delegation_id) - .expect("read planning delivery") - .expect("planning delivery exists"); - delivery.status = StaticDelegateDeliveryStatus::Dispatched; - delivery.terminal_status = None; - delivery.result_summary = None; - delivery.structured_result = None; - delivery.claimed_by_action_id = None; - delivery.updated_at = unix_timestamp(); - write_static_delegate_delivery_at(&root, &delivery) - .expect("restore submit-to-completion delivery window"); - - let observation = execute_game_creator_agent_runtime_tool_action_with_action_id( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &root_runtime.run_id, - &root_runtime.current_task, - &AgentRuntimeToolAction { - tool: "agent.run_status".to_string(), - reason: Some("轮询尚未收口的 planning delivery".to_string()), - input: serde_json::json!({"scope": "self"}), - }, - Some("action-131313131313131313131313"), - ) - .await; - assert_eq!(observation.status, "ok"); - assert!(read_plan_gdd_approval_pending_locked(&root) - .expect("read pending after dispatched poll") - .is_none()); - cleanup_fixture(root); - } - - #[test] - fn m1c2a_rejects_wrong_path_and_child_actor_file_read_evidence() { - for case in ["wrong-path", "child-actor"] { - let (root, gdd, root_runtime) = acceptance_gate_fixture(true); - let contract = read_game_creator_agent_runtime_goal_contract_at( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &root_runtime.run_id, - ) - .expect("read plan contract") - .expect("plan contract exists"); - let (evidence_runtime, path, action_id) = if case == "wrong-path" { - fs::write(root.join("game/other.md"), "自洽但不是 Fast GDD\n") - .expect("write other Markdown"); - ( - root_runtime.clone(), - "game/other.md", - "action-141414141414141414141414", - ) - } else { - let child = read_game_creator_agent_runtime_at( - &root, - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - ) - .expect("read planning child runtime") - .state; - assert_eq!(child.run_id, gdd.created_by_run_id); - (child, PLAN_FAST_GDD_PATH, "action-151515151515151515151515") - }; - append_file_read_receipt_from_real_observation( - &root, - &evidence_runtime, - action_id, - path, - 1, - 120, - ); - let observation = observe_agent_runtime_acceptance_update( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &root_runtime.run_id, - &serde_json::json!({ - "contractFingerprint": contract.contract_fingerprint, - "evaluations": [{ - "criterionId": PLAN_FAST_GDD_ACCEPTANCE_NODE_ID, - "status": "passed", - "evidence": [{ - "agentId": evidence_runtime.agent_id, - "runId": evidence_runtime.run_id, - "actionId": action_id, - }], - "summary": "不合法来源不得通过 Fast GDD 验收", - }], - }), - ); - assert_eq!(observation.status, "rejected", "case={case}"); - assert!(read_plan_gdd_approval_pending_locked(&root) - .expect("read pending after rejected evidence") - .is_none()); - cleanup_fixture(root); - } - } - - #[test] - fn m1c2a_old_markdown_hash_stays_repairable_without_creating_pending() { - let (root, _gdd, root_runtime) = acceptance_gate_fixture(true); - let contract = read_game_creator_agent_runtime_goal_contract_at( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &root_runtime.run_id, - ) - .expect("read plan contract") - .expect("plan contract exists"); - fs::write( - root.join(PLAN_FAST_GDD_PATH), - "同 project revision 下的新 Fast GDD 内容\n", - ) - .expect("replace Markdown without advancing project revision"); - - let observation = observe_agent_runtime_acceptance_update( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &root_runtime.run_id, - &serde_json::json!({ - "contractFingerprint": contract.contract_fingerprint, - "evaluations": [{ - "criterionId": PLAN_FAST_GDD_ACCEPTANCE_NODE_ID, - "status": "passed", - "evidence": [{ - "agentId": GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - "runId": root_runtime.run_id, - "actionId": "action-aaaaaaaaaaaaaaaaaaaaaaaa", - }], - "summary": "旧内容回执不能给当前 Markdown 建审批卡", - }], - }), - ); - assert_eq!(observation.status, "ok"); - let detail = observation - .detail - .as_deref() - .expect("repairable gate detail"); - assert!(detail.contains("\"approvalPending\":\"not-created\"")); - assert!(detail.contains("\"nextRequiredAction\":\"file.read\"")); - assert!(!detail.contains("repairOfDelegationId")); - assert!(read_game_creator_agent_runtime_acceptance_graph_at( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &root_runtime.run_id, - ) - .expect("read durable graph with old content receipt") - .is_some()); - let blocker = goal_contract_acceptance_completion_blocker_at_locked(&root, &root_runtime) - .expect("old content receipt must block without reconciliation"); - assert_eq!(blocker.status, "blocked"); - assert!(blocker - .detail - .as_deref() - .is_some_and(|detail| detail.contains("从 startLine=1 开始分页"))); - assert!(read_plan_gdd_approval_pending_locked(&root) - .expect("read pending after old hash") - .is_none()); - cleanup_fixture(root); - } - - #[test] - fn m1c2a_requires_complete_paginated_fast_gdd_coverage() { - let long_markdown = (1..=200) - .map(|line| format!("第 {line} 行 Fast GDD")) - .collect::>() - .join("\n"); - - let (partial_root, _gdd, partial_runtime) = acceptance_gate_fixture(true); - fs::write(partial_root.join(PLAN_FAST_GDD_PATH), &long_markdown) - .expect("write long partial fixture"); - let partial_contract = read_game_creator_agent_runtime_goal_contract_at( - &partial_root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &partial_runtime.run_id, - ) - .expect("read partial contract") - .expect("partial contract exists"); - append_file_read_receipt_from_real_observation( - &partial_root, - &partial_runtime, - "action-161616161616161616161616", - PLAN_FAST_GDD_PATH, - 1, - 120, - ); - let partial = observe_agent_runtime_acceptance_update( - &partial_root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &partial_runtime.run_id, - &serde_json::json!({ - "contractFingerprint": partial_contract.contract_fingerprint, - "evaluations": [{ - "criterionId": PLAN_FAST_GDD_ACCEPTANCE_NODE_ID, - "status": "passed", - "evidence": [{ - "agentId": GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - "runId": partial_runtime.run_id, - "actionId": "action-161616161616161616161616", - }], - "summary": "只读前 120 行不能通过", - }], - }), - ); - assert_eq!(partial.status, "rejected"); - assert!(read_plan_gdd_approval_pending_locked(&partial_root) - .expect("read partial pending") - .is_none()); - cleanup_fixture(partial_root); - - let (complete_root, gdd, complete_runtime) = acceptance_gate_fixture(true); - fs::write(complete_root.join(PLAN_FAST_GDD_PATH), &long_markdown) - .expect("write long complete fixture"); - let complete_contract = read_game_creator_agent_runtime_goal_contract_at( - &complete_root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &complete_runtime.run_id, - ) - .expect("read complete contract") - .expect("complete contract exists"); - for (action_id, start_line) in [ - ("action-171717171717171717171717", 1usize), - ("action-181818181818181818181818", 121usize), - ] { - append_file_read_receipt_from_real_observation( - &complete_root, - &complete_runtime, - action_id, - PLAN_FAST_GDD_PATH, - start_line, - 120, - ); - } - let complete = observe_agent_runtime_acceptance_update( - &complete_root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &complete_runtime.run_id, - &serde_json::json!({ - "contractFingerprint": complete_contract.contract_fingerprint, - "evaluations": [{ - "criterionId": PLAN_FAST_GDD_ACCEPTANCE_NODE_ID, - "status": "passed", - "evidence": [ - { - "agentId": GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - "runId": complete_runtime.run_id, - "actionId": "action-171717171717171717171717", - }, - { - "agentId": GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - "runId": complete_runtime.run_id, - "actionId": "action-181818181818181818181818", - } - ], - "summary": "完整分页覆盖当前 Fast GDD", - }], - }), - ); - assert_eq!(complete.status, "ok"); - assert!(complete - .detail - .as_deref() - .is_some_and(|detail| detail.contains("\"approvalPending\":\"created\""))); - let pending = read_plan_gdd_approval_pending_locked(&complete_root) - .expect("read complete pending") - .expect("complete coverage creates pending"); - assert_eq!(pending.gdd_ref.version, gdd.version); - cleanup_fixture(complete_root); - } - - #[test] - fn m1c2a_exact_pending_overrides_stale_acceptance_graph() { - let (root, _gdd, root_runtime) = acceptance_gate_fixture(true); - let contract = read_game_creator_agent_runtime_goal_contract_at( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &root_runtime.run_id, - ) - .expect("read plan contract") - .expect("plan contract exists"); - update_game_creator_agent_runtime_acceptance_graph_at( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &root_runtime.run_id, - &contract.contract_fingerprint, - &[AgentRuntimeAcceptanceEvaluationDraft { - criterion_id: PLAN_FAST_GDD_ACCEPTANCE_NODE_ID.to_string(), - status: "passed".to_string(), - evidence: vec![AgentRuntimeAcceptanceEvidenceRef { - agent_id: GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(), - run_id: root_runtime.run_id.clone(), - action_id: "action-aaaaaaaaaaaaaaaaaaaaaaaa".to_string(), - }], - summary: "当前 GDD 服务用户意图".to_string(), - }], - ) - .expect("record passed acceptance"); - ensure_plan_gdd_approval_pending_after_acceptance_at( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &root_runtime.run_id, - ) - .expect("create approval pending"); - - let mut revision = - read_game_creator_agent_runtime_project_revision(&root).expect("read project revision"); - revision.revision = revision.revision.saturating_add(1); - revision.updated_at = unix_timestamp(); - write_game_creator_agent_runtime_project_revision(&root, &revision) - .expect("advance project revision after pending"); - - assert!( - goal_contract_acceptance_completion_blocker_at_locked(&root, &root_runtime).is_none(), - "an exact approval pending must keep the already-passed gate durable" - ); - cleanup_fixture(root); - } - - #[test] - fn m1c2a_exact_receipt_overrides_later_failed_acceptance_graph() { - let (root, gdd, root_runtime) = acceptance_gate_fixture(true); - let contract = read_game_creator_agent_runtime_goal_contract_at( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &root_runtime.run_id, - ) - .expect("read plan contract") - .expect("plan contract exists"); - let evidence = AgentRuntimeAcceptanceEvidenceRef { - agent_id: GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(), - run_id: root_runtime.run_id.clone(), - action_id: "action-aaaaaaaaaaaaaaaaaaaaaaaa".to_string(), - }; - update_game_creator_agent_runtime_acceptance_graph_at( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &root_runtime.run_id, - &contract.contract_fingerprint, - &[AgentRuntimeAcceptanceEvaluationDraft { - criterion_id: PLAN_FAST_GDD_ACCEPTANCE_NODE_ID.to_string(), - status: "passed".to_string(), - evidence: vec![evidence.clone()], - summary: "当前 GDD 服务用户意图".to_string(), - }], - ) - .expect("record passed acceptance"); - ensure_plan_gdd_approval_pending_after_acceptance_at( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &root_runtime.run_id, - ) - .expect("create approval pending"); - decide_plan_gdd_at( - &root, - &approval_input( - &gdd, - "approve", - "gdd-response-00000000-0000-4000-8000-000000000032", - None, - ), - ) - .expect("commit approval receipt"); - - update_game_creator_agent_runtime_acceptance_graph_at( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &root_runtime.run_id, - &contract.contract_fingerprint, - &[AgentRuntimeAcceptanceEvaluationDraft { - criterion_id: PLAN_FAST_GDD_ACCEPTANCE_NODE_ID.to_string(), - status: "failed".to_string(), - evidence: vec![evidence], - summary: "审批后的图状态不得重新打开验收门".to_string(), - }], - ) - .expect("record later failed graph status"); - - assert!( - goal_contract_acceptance_completion_blocker_at_locked(&root, &root_runtime).is_none(), - "an immutable approval receipt must remain authoritative over later graph status" - ); - cleanup_fixture(root); - } - - #[test] - fn m1c2a_acceptance_gate_recovery_recreates_same_pending_identity() { - let (root, gdd, root_runtime) = acceptance_gate_fixture(true); - let contract = read_game_creator_agent_runtime_goal_contract_at( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &root_runtime.run_id, - ) - .expect("read plan contract") - .expect("plan contract exists"); - update_game_creator_agent_runtime_acceptance_graph_at( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &root_runtime.run_id, - &contract.contract_fingerprint, - &[AgentRuntimeAcceptanceEvaluationDraft { - criterion_id: PLAN_FAST_GDD_ACCEPTANCE_NODE_ID.to_string(), - status: "passed".to_string(), - evidence: vec![AgentRuntimeAcceptanceEvidenceRef { - agent_id: GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(), - run_id: root_runtime.run_id.clone(), - action_id: "action-aaaaaaaaaaaaaaaaaaaaaaaa".to_string(), - }], - summary: "当前 GDD 服务用户意图".to_string(), - }], - ) - .expect("record passed acceptance"); - ensure_plan_gdd_approval_pending_after_acceptance_at( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &root_runtime.run_id, - ) - .expect("create initial pending"); - let original = read_plan_gdd_approval_pending_locked(&root) - .expect("read initial pending") - .expect("initial pending exists"); - remove_plan_gdd_approval_pending_locked(&root).expect("remove pending for recovery window"); - - assert!( - reconcile_plan_gdd_approval_projections_at(&root).expect("reconcile acceptance gate"), - "recovery should repair the missing pending projection" - ); - let recovered = read_plan_gdd_approval_pending_locked(&root) - .expect("read recovered pending") - .expect("recovered pending exists"); - assert_eq!(recovered, original); - assert_eq!(recovered.gdd_ref.gdd_id, gdd.gdd_id); - let agent_db_before_replay = fs::read(root.join(".agent/agent.db")) - .expect("read Agent DB before idempotent recovery replay"); - assert!( - !reconcile_plan_gdd_approval_projections_at(&root).expect("replay acceptance recovery"), - "second recovery must not rewrite an exact pending projection" - ); - assert_eq!( - read_plan_gdd_approval_pending_locked(&root) - .expect("read replayed pending") - .expect("pending survives replay"), - original - ); - assert_eq!( - fs::read(root.join(".agent/agent.db")) - .expect("read Agent DB after idempotent recovery replay"), - agent_db_before_replay - ); - cleanup_fixture(root); - } - - #[test] - fn m1c2a_acceptance_gate_rejects_receipt_pending_conflict() { - let (root, gdd, root_runtime) = acceptance_gate_fixture(true); - let contract = read_game_creator_agent_runtime_goal_contract_at( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &root_runtime.run_id, - ) - .expect("read plan contract") - .expect("plan contract exists"); - update_game_creator_agent_runtime_acceptance_graph_at( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &root_runtime.run_id, - &contract.contract_fingerprint, - &[AgentRuntimeAcceptanceEvaluationDraft { - criterion_id: PLAN_FAST_GDD_ACCEPTANCE_NODE_ID.to_string(), - status: "passed".to_string(), - evidence: vec![AgentRuntimeAcceptanceEvidenceRef { - agent_id: GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(), - run_id: root_runtime.run_id.clone(), - action_id: "action-aaaaaaaaaaaaaaaaaaaaaaaa".to_string(), - }], - summary: "当前 GDD 服务用户意图".to_string(), - }], - ) - .expect("record passed acceptance"); - ensure_plan_gdd_approval_pending_after_acceptance_at( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &root_runtime.run_id, - ) - .expect("create approval pending"); - let mut forged_pending = read_plan_gdd_approval_pending_locked(&root) - .expect("read awaiting pending") - .expect("awaiting pending exists"); - - decide_plan_gdd_at( - &root, - &approval_input( - &gdd, - "approve", - "gdd-response-00000000-0000-4000-8000-000000000020", - None, - ), - ) - .expect("commit approval receipt"); - forged_pending.status = "observed_revise".to_string(); - forged_pending.observation = Some(PlanGddApprovalObservationV1 { - tool: PLAN_GDD_APPROVAL_TOOL.to_string(), - status: "ok".to_string(), - summary: format!("Fast GDD v{} 需要修改", gdd.version), - detail: Some("用户修改意见:请保留原意并补全约束".to_string()), - }); - forged_pending.pending_fingerprint = plan_gdd_approval_pending_fingerprint(&forged_pending) - .expect("fingerprint forged pending"); - write_plan_gdd_approval_pending_atomic_locked(&root, &forged_pending) - .expect("write forged pending"); - - let error = ensure_plan_gdd_approval_pending_after_acceptance_at( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &root_runtime.run_id, - ) - .expect_err("receipt must not hide a conflicting pending projection"); - assert!(error.contains("identity/observation 冲突")); - let blocker = goal_contract_acceptance_completion_blocker_at_locked(&root, &root_runtime) - .expect("conflicting durable approval identity must fail closed"); - assert_eq!(blocker.status, "needs-reconciliation"); - assert!(blocker.summary.contains("pending 与 receipt identity 冲突")); - cleanup_fixture(root); - } -} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/provider_control.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/provider_control.rs index 7d7ebc0f0..ac7bb01a2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/provider_control.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/provider_control.rs @@ -69,7 +69,6 @@ pub(in crate::agent) fn capture_game_creator_agent_runtime_provider_request_snap request_slot: request_slot.to_string(), web_search_enabled: false, allow_idle_context_compaction: false, - planning_session_binding: None, }) } @@ -123,7 +122,6 @@ pub(in crate::agent) fn capture_idle_game_creator_agent_runtime_context_compacti request_slot: request_slot.to_string(), web_search_enabled: false, allow_idle_context_compaction: true, - planning_session_binding: None, }) } @@ -350,75 +348,22 @@ fn append_game_creator_agent_runtime_provider_request_lifecycle_with_session_rea status: &str, project_write_lock_held: bool, ) -> Result { - let snapshot = if let Some(binding) = snapshot.planning_session_binding.as_ref() { - let request_slot = game_creator_agent_runtime_provider_request_slot_for_id( - snapshot, - request_id, - ) - .ok_or_else(|| { - format!( - "{AGENT_RUNTIME_PROVIDER_REQUEST_RECONCILIATION_PREFIX}: planning requestId 不属于 binding" - ) - })?; - let attempt_binding = - plan_provider_session_binding_for_attempt(binding, &request_slot, request_id)?; - snapshot - .with_request_slot(request_slot) - .with_planning_session_binding(Some(attempt_binding)) - } else { - snapshot.clone() - }; - if snapshot.planning_session_binding.is_some() && status == "started" { - let binding = snapshot - .planning_session_binding - .as_ref() - .expect("planning binding checked above"); - let validated = if project_write_lock_held { - validate_plan_provider_session_binding_current_at_locked(root, binding) - } else { - validate_plan_provider_session_binding_current_at(root, binding) - }; - validated.map_err(|error| { - format!("{AGENT_RUNTIME_PROVIDER_REQUEST_RECONCILIATION_PREFIX}: {error}") - })?; - } - let schema_version = if snapshot.planning_session_binding.is_some() { - AGENT_RUNTIME_PLAN_PROVIDER_REQUEST_LIFECYCLE_SCHEMA_VERSION - } else { - AGENT_RUNTIME_PROVIDER_REQUEST_LIFECYCLE_SCHEMA_VERSION - }; - let record = if let Some(binding) = snapshot.planning_session_binding.as_ref() { - serde_json::json!({ - "recordType": AGENT_RUNTIME_PROVIDER_REQUEST_LIFECYCLE_RECORD_TYPE, - "auditSchemaVersion": 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, - "webSearchEnabled": snapshot.web_search_enabled, - "planningSessionBinding": binding, - "status": status, - }) - } else { - serde_json::json!({ - "recordType": AGENT_RUNTIME_PROVIDER_REQUEST_LIFECYCLE_RECORD_TYPE, - "auditSchemaVersion": 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, - "webSearchEnabled": snapshot.web_search_enabled, - "status": status, - }) - }; + let snapshot = snapshot.clone(); + let _ = project_write_lock_held; + let record = 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, + "webSearchEnabled": snapshot.web_search_enabled, + "status": status, + }); append_agent_db_lifecycle_record_idempotent( root, "requestId", @@ -849,7 +794,6 @@ mod provider_reconciliation_diagnostic_tests { request_slot: "loop-1-repair-0".to_string(), web_search_enabled: false, allow_idle_context_compaction: false, - planning_session_binding: None, }; let response = platform_llm::LlmRunResponse { provider: platform_llm::LlmProvider::OpenAiCompatible, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/provider_retry.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/provider_retry.rs index 27b7a3164..1541acc41 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/provider_retry.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/provider_retry.rs @@ -325,225 +325,6 @@ pub(in crate::agent) fn game_creator_agent_runtime_llm_request_fingerprint( Ok(format!("{:x}", Sha256::digest(serialized))) } -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct PlanProviderWireDigest { - role: String, - wire_bytes: u32, - wire_sha256: String, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct PlanProviderToolWireDigest { - name: String, - kind: String, - wire_bytes: u32, - wire_sha256: String, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct PlanProviderStructuredInjectionDigest { - wire_bytes: u32, - wire_sha256: String, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct PlanProviderRequestContextValue { - effective_model: String, - api_kind: String, - stream: bool, - official_fallback: Option, - anthropic_strict_tool_support: Option, - open_ai_chat_token_budget_field: Option, - max_output_tokens: Option, - response_reasoning_effort: Option, - response_text_verbosity: Option, - tool_choice: Option, - composition: String, - source_kind: String, - web_search_enabled: bool, - messages: Vec, - native_tools: Vec, - structured_injections: PlanProviderStructuredInjectionDigest, -} - -fn plan_provider_api_kind_name(api_kind: platform_llm::LlmApiKind) -> &'static str { - match api_kind { - platform_llm::LlmApiKind::OpenAiResponses => "openai_responses", - platform_llm::LlmApiKind::OpenAiChat => "openai_chat", - platform_llm::LlmApiKind::Anthropic => "anthropic", - } -} - -fn plan_provider_message_role_name(role: platform_llm::LlmMessageRole) -> &'static str { - match role { - platform_llm::LlmMessageRole::System => "system", - platform_llm::LlmMessageRole::User => "user", - platform_llm::LlmMessageRole::Assistant => "assistant", - } -} - -fn plan_provider_tool_choice_name(choice: platform_llm::LlmToolChoice) -> &'static str { - match choice { - platform_llm::LlmToolChoice::Auto => "auto", - platform_llm::LlmToolChoice::Required => "required", - } -} - -fn plan_provider_reasoning_name(effort: platform_llm::LlmResponseReasoningEffort) -> &'static str { - match effort { - platform_llm::LlmResponseReasoningEffort::Low => "low", - platform_llm::LlmResponseReasoningEffort::Medium => "medium", - platform_llm::LlmResponseReasoningEffort::High => "high", - // master 2026-08-14 裁决:max 是独立强度,不得静默映射成 high。 - platform_llm::LlmResponseReasoningEffort::Max => "max", - } -} - -fn plan_provider_verbosity_name(verbosity: platform_llm::LlmResponseTextVerbosity) -> &'static str { - match verbosity { - platform_llm::LlmResponseTextVerbosity::Low => "low", - platform_llm::LlmResponseTextVerbosity::Medium => "medium", - platform_llm::LlmResponseTextVerbosity::High => "high", - } -} - -fn plan_provider_wire_digest(value: &T) -> Result<(u32, String), String> { - let bytes = serde_json::to_vec(value) - .map_err(|error| format!("序列化 plan Provider wire DTO 失败:{error}"))?; - let byte_len = u32::try_from(bytes.len()) - .map_err(|_| "plan Provider wire DTO 超出 u32 字节上限".to_string())?; - Ok((byte_len, format!("{:x}", Sha256::digest(bytes)))) -} - -/// Typed, request-semantic fingerprint for exact planning requests. The -/// generic runtime identity intentionally keeps its historical hash contract; -/// only planning requests use this strict v1 context payload. -pub(in crate::agent) fn game_creator_agent_runtime_plan_provider_request_context_fingerprint( - llm: &GameCreatorLlmConfig, - request: &LlmRunRequest, -) -> Result { - let api_kind = request.api_kind; - let effective_model = request - .model - .as_deref() - .filter(|model| !model.trim().is_empty()) - .unwrap_or(llm.model.as_str()) - .trim() - .to_string(); - if effective_model.is_empty() { - return Err("plan Provider request effectiveModel 不能为空".to_string()); - } - let messages = request - .messages - .iter() - .map(|message| { - let (wire_bytes, wire_sha256) = plan_provider_wire_digest(message)?; - Ok(PlanProviderWireDigest { - role: plan_provider_message_role_name(message.role).to_string(), - wire_bytes, - wire_sha256, - }) - }) - .collect::, String>>()?; - let native_tools = request - .function_tools - .iter() - .map(|tool| { - let (wire_bytes, wire_sha256) = plan_provider_wire_digest(tool)?; - let kind = if tool.name.contains("update_agent_plan") - || tool.name.contains("respond_to_user") - { - "control" - } else { - "action" - }; - Ok(PlanProviderToolWireDigest { - name: tool.name.clone(), - kind: kind.to_string(), - wire_bytes, - wire_sha256, - }) - }) - .collect::, String>>()?; - let structured_prefix = format!("{PLAN_PROVIDER_STRUCTURED_INJECTIONS_MESSAGE_HEADER}\n"); - let mut structured_messages = request.messages.iter().filter_map(|message| { - message - .content - .strip_prefix(&structured_prefix) - .map(|json| (message.role, json)) - }); - let (structured_role, structured_json) = structured_messages - .next() - .ok_or_else(|| "plan Provider request 缺少 structured injections message".to_string())?; - if structured_role != platform_llm::LlmMessageRole::User { - return Err( - "plan Provider structured injections 必须是 dedicated user message".to_string(), - ); - } - if structured_messages.next().is_some() { - return Err("plan Provider request 包含重复 structured injections message".to_string()); - } - let structured_injection_wire_bytes = structured_json.as_bytes(); - let expected_message = - render_plan_provider_structured_injections_message(structured_injection_wire_bytes)?; - if expected_message != format!("{structured_prefix}{structured_json}") { - return Err("plan Provider structured injections message 不是 canonical wire".to_string()); - } - let structured_bytes = u32::try_from(structured_injection_wire_bytes.len()) - .map_err(|_| "plan Provider structured injections 超出 u32 字节上限".to_string())?; - let structured_sha256 = format!("{:x}", Sha256::digest(structured_injection_wire_bytes)); - let configured_api_kind = parse_game_creator_llm_api_kind(&llm.api_kind).ok(); - let anthropic_strict_tool_support = if api_kind == platform_llm::LlmApiKind::Anthropic { - configured_api_kind == Some(platform_llm::LlmApiKind::Anthropic) - && effective_model == llm.model.trim() - && game_creator_supports_anthropic_strict_tools( - platform_llm::LlmApiKind::Anthropic, - llm.base_url.as_str(), - llm.model.as_str(), - ) - } else { - false - }; - let context = PlanProviderRequestContextValue { - effective_model, - api_kind: plan_provider_api_kind_name(api_kind).to_string(), - stream: llm.stream, - official_fallback: (api_kind != platform_llm::LlmApiKind::Anthropic).then_some(false), - anthropic_strict_tool_support: (api_kind == platform_llm::LlmApiKind::Anthropic) - .then_some(anthropic_strict_tool_support), - open_ai_chat_token_budget_field: (api_kind == platform_llm::LlmApiKind::OpenAiChat) - .then_some("legacy_max_tokens".to_string()), - max_output_tokens: request.max_output_tokens, - response_reasoning_effort: request - .response_reasoning_effort - .map(plan_provider_reasoning_name) - .map(str::to_string), - response_text_verbosity: request - .response_text_verbosity - .map(plan_provider_verbosity_name) - .map(str::to_string), - tool_choice: request - .tool_choice - .map(plan_provider_tool_choice_name) - .map(str::to_string), - composition: "runtime".to_string(), - source_kind: "runtime".to_string(), - web_search_enabled: request.enable_web_search, - messages, - native_tools, - structured_injections: PlanProviderStructuredInjectionDigest { - wire_bytes: structured_bytes, - wire_sha256: structured_sha256, - }, - }; - typed_serde_fingerprint("genarrative.plan.provider-request-context.v1", &context) - .map_err(|error| error.to_string()) -} - pub(in crate::agent) fn game_creator_agent_runtime_provider_config_fingerprint( llm: &GameCreatorLlmConfig, ) -> Result { @@ -661,11 +442,7 @@ pub(in crate::agent) fn game_creator_agent_runtime_provider_retry_identity_for_m } else { None }; - let request_fingerprint = if snapshot.planning_session_binding.is_some() { - game_creator_agent_runtime_plan_provider_request_context_fingerprint(llm, request)? - } else { - game_creator_agent_runtime_llm_request_fingerprint(request)? - }; + let request_fingerprint = game_creator_agent_runtime_llm_request_fingerprint(request)?; Ok(AgentRuntimeProviderRetryIdentity { project_id: snapshot.project_id.clone(), agent_id: snapshot.agent_id.clone(), @@ -688,7 +465,6 @@ pub(in crate::agent) fn game_creator_agent_runtime_provider_retry_identity_for_m )?, web_search_enabled: snapshot.web_search_enabled, allow_idle_context_compaction: snapshot.allow_idle_context_compaction, - planning_session_binding: snapshot.planning_session_binding.clone(), }) } @@ -710,7 +486,6 @@ pub(in crate::agent) fn game_creator_agent_runtime_provider_snapshot_from_retry_ request_slot: identity.base_request_slot.clone(), web_search_enabled: identity.web_search_enabled, allow_idle_context_compaction: identity.allow_idle_context_compaction, - planning_session_binding: identity.planning_session_binding.clone(), } } @@ -761,9 +536,6 @@ pub(in crate::agent) fn game_creator_agent_runtime_provider_retry_drift_fields( if persisted.allow_idle_context_compaction != rebuilt.allow_idle_context_compaction { fields.push("allowIdleContextCompaction"); } - if persisted.planning_session_binding != rebuilt.planning_session_binding { - fields.push("planningSessionBinding"); - } fields } @@ -780,7 +552,6 @@ pub(in crate::agent) fn game_creator_agent_runtime_provider_handoff_reconciliati let request_id = resolve_game_creator_agent_runtime_provider_request_attempt_at_locked( root, &base_request_id, - snapshot.planning_session_binding.is_some(), ) .map(|value| value.0) .unwrap_or(base_request_id); @@ -1554,7 +1325,6 @@ where resolve_game_creator_agent_runtime_provider_request_attempt_at_locked( root, &base_request_id, - attempt_snapshot.planning_session_binding.is_some(), ) .map(|value| value.0) .unwrap_or(base_request_id); @@ -1849,19 +1619,6 @@ pub(in crate::agent) async fn request_game_creator_agent_runtime_llm_with_transi pub(crate) fn game_creator_agent_runtime_provider_request_id( snapshot: &AgentRuntimeProviderRequestSnapshot, ) -> String { - if let Some(binding) = snapshot.planning_session_binding.as_ref() { - if snapshot.request_slot == binding.request_slot { - return binding.provider_request_id.clone(); - } - if let Some(attempt) = snapshot - .request_slot - .strip_prefix(binding.request_slot.as_str()) - .and_then(|suffix| suffix.strip_prefix("-transient-")) - .and_then(|value| value.parse::().ok()) - { - return plan_provider_request_attempt_id(&binding.provider_request_id, attempt); - } - } format!( "provider-request-{:x}", Sha256::digest( @@ -1903,11 +1660,8 @@ pub(in crate::agent) fn game_creator_agent_runtime_provider_request_slot_for_id( ) -> Option { let base_request_id = game_creator_agent_runtime_provider_request_id(snapshot); for attempt in 0..=64_usize { - let candidate = if snapshot.planning_session_binding.is_some() { - plan_provider_request_attempt_id(&base_request_id, attempt) - } else { - game_creator_agent_runtime_provider_request_attempt_id(&base_request_id, attempt) - }; + let candidate = + game_creator_agent_runtime_provider_request_attempt_id(&base_request_id, attempt); if candidate == request_id { return Some(if attempt == 0 { snapshot.request_slot.clone() @@ -1922,18 +1676,13 @@ pub(in crate::agent) fn game_creator_agent_runtime_provider_request_slot_for_id( pub(in crate::agent) fn resolve_game_creator_agent_runtime_provider_request_attempt_at_locked( root: &Path, base_request_id: &str, - planning: bool, ) -> Result<(String, bool), String> { const MAX_INTERRUPTED_ATTEMPTS: usize = 64; for attempt in 0..=MAX_INTERRUPTED_ATTEMPTS { - let candidates = if planning { - vec![plan_provider_request_attempt_id(base_request_id, attempt)] - } else { - vec![game_creator_agent_runtime_provider_request_attempt_id( - base_request_id, - attempt, - )] - }; + let candidates = vec![game_creator_agent_runtime_provider_request_attempt_id( + base_request_id, + attempt, + )]; for request_id in candidates { let transitions = read_agent_db_lifecycle_transitions_at( root, @@ -1973,28 +1722,6 @@ pub(crate) fn append_game_creator_agent_runtime_provider_lifecycle_for_test( mod tests { use super::*; - fn planning_structured_injections_message(accumulated_agent_millis: u64) -> LlmMessage { - let value = PlanProviderStructuredInjectionsV1 { - schema_version: PLAN_PROVIDER_STRUCTURED_INJECTIONS_SCHEMA_VERSION.to_string(), - clarification_round: 0, - accumulated_agent_millis, - session: PlanProviderFacingSessionV1 { - phase: "collecting".to_string(), - decisions_summary: Vec::new(), - prototype_validation_items: Vec::new(), - latest_submitted_ref: None, - last_decision_ref: None, - }, - platform_facts: fixed_plan_platform_facts(), - approval_observation: None, - }; - let bytes = serde_json::to_vec(&value).expect("serialize structured injections fixture"); - LlmMessage::user( - render_plan_provider_structured_injections_message(&bytes) - .expect("render structured injections fixture"), - ) - } - #[test] fn provider_config_fingerprint_separates_all_agent_modes() { let llm = GameCreatorLlmConfig::default(); @@ -2039,199 +1766,6 @@ mod tests { ); } - #[test] - fn planning_request_context_fingerprint_tracks_provider_wire_semantics() { - let llm = GameCreatorLlmConfig::default(); - let request = platform_llm::LlmRunRequest::new(vec![ - platform_llm::LlmMessage::system("策划系统提示"), - planning_structured_injections_message(10), - platform_llm::LlmMessage::user("策划请求"), - ]) - .with_model("planning-model") - .with_api_kind(platform_llm::LlmApiKind::OpenAiResponses) - .with_max_output_tokens(4_000) - .with_response_reasoning_effort(platform_llm::LlmResponseReasoningEffort::High) - .with_response_text_verbosity(platform_llm::LlmResponseTextVerbosity::Low) - .with_function_tools(vec![platform_llm::LlmFunctionTool::new( - "runtime_tool_plan_submit_gdd", - "提交 GDD", - serde_json::json!({"type": "object", "additionalProperties": false}), - ) - .with_strict(true)]) - .with_tool_choice(platform_llm::LlmToolChoice::Required) - .with_web_search(false); - let fingerprint = - game_creator_agent_runtime_plan_provider_request_context_fingerprint(&llm, &request) - .expect("planning request context fingerprint"); - assert!(fingerprint.starts_with("sha256-serde-json-v2:")); - - let mut wrong_structured_role = request.clone(); - wrong_structured_role.messages[1] = - platform_llm::LlmMessage::system(wrong_structured_role.messages[1].content.clone()); - let wrong_role_error = - game_creator_agent_runtime_plan_provider_request_context_fingerprint( - &llm, - &wrong_structured_role, - ) - .expect_err("structured injections under a non-user role must fail closed"); - assert!(wrong_role_error.contains("dedicated user message")); - - let mut assistant_structured_role = request.clone(); - assistant_structured_role.messages[1] = platform_llm::LlmMessage::assistant( - assistant_structured_role.messages[1].content.clone(), - ); - let assistant_role_error = - game_creator_agent_runtime_plan_provider_request_context_fingerprint( - &llm, - &assistant_structured_role, - ) - .expect_err("structured injections under the assistant role must fail closed"); - assert!(assistant_role_error.contains("dedicated user message")); - - let mut duplicate_structured_message = request.clone(); - duplicate_structured_message - .messages - .insert(2, duplicate_structured_message.messages[1].clone()); - let duplicate_error = game_creator_agent_runtime_plan_provider_request_context_fingerprint( - &llm, - &duplicate_structured_message, - ) - .expect_err("duplicate structured injections must fail closed"); - assert!(duplicate_error.contains("重复 structured injections")); - - let mut missing_structured_message = request.clone(); - missing_structured_message.messages.remove(1); - let missing_error = game_creator_agent_runtime_plan_provider_request_context_fingerprint( - &llm, - &missing_structured_message, - ) - .expect_err("missing structured injections must fail closed"); - assert!(missing_error.contains("缺少 structured injections")); - - let mut changed_message = request.clone(); - changed_message.messages[2] = platform_llm::LlmMessage::user("策划请求已变化"); - let mut changed_injection = request.clone(); - changed_injection.messages[1] = planning_structured_injections_message(11); - let mut changed_tool = request.clone(); - changed_tool.function_tools[0].description = "提交另一份 GDD".to_string(); - let changed_tokens = request.clone().with_max_output_tokens(4_001); - let changed_api = request - .clone() - .with_api_kind(platform_llm::LlmApiKind::OpenAiChat); - for changed in [ - changed_message, - changed_injection, - changed_tool, - changed_tokens, - changed_api, - ] { - assert_ne!( - game_creator_agent_runtime_plan_provider_request_context_fingerprint( - &llm, &changed, - ) - .expect("changed planning request context fingerprint"), - fingerprint - ); - } - - let mut transport_only = llm.clone(); - transport_only.request_timeout_ms = transport_only.request_timeout_ms.saturating_add(1); - transport_only.retry_backoff_ms = transport_only.retry_backoff_ms.saturating_add(1); - assert_eq!( - game_creator_agent_runtime_plan_provider_request_context_fingerprint( - &transport_only, - &request, - ) - .expect("transport-only planning request context fingerprint"), - fingerprint, - "request timeout/backoff are explicitly outside Provider request semantics" - ); - } - - #[test] - fn planning_structured_injections_reject_wire_over_64_kib() { - let value = PlanProviderStructuredInjectionsV1 { - schema_version: PLAN_PROVIDER_STRUCTURED_INJECTIONS_SCHEMA_VERSION.to_string(), - clarification_round: 0, - accumulated_agent_millis: 0, - session: PlanProviderFacingSessionV1 { - phase: "collecting".to_string(), - decisions_summary: Vec::new(), - prototype_validation_items: Vec::new(), - latest_submitted_ref: None, - last_decision_ref: None, - }, - platform_facts: fixed_plan_platform_facts(), - approval_observation: Some(PlanProviderApprovalObservationV1 { - tool: PLAN_SUBMIT_GDD_TOOL.to_string(), - status: "ok".to_string(), - summary: "x".repeat(64 * 1024), - detail: None, - }), - }; - let bytes = serde_json::to_vec(&value).expect("serialize oversized planning injection"); - assert!(bytes.len() > 64 * 1024); - let error = render_plan_provider_structured_injections_message(&bytes) - .expect_err("oversized planning injection must fail closed"); - assert!(error.contains("wire bytes 非法")); - } - - #[test] - fn planning_structured_injections_accept_exact_64_kib_and_reject_one_byte_over() { - const MAX_BYTES: usize = 64 * 1024; - let mut value = PlanProviderStructuredInjectionsV1 { - schema_version: PLAN_PROVIDER_STRUCTURED_INJECTIONS_SCHEMA_VERSION.to_string(), - clarification_round: 0, - accumulated_agent_millis: 0, - session: PlanProviderFacingSessionV1 { - phase: "collecting".to_string(), - decisions_summary: Vec::new(), - prototype_validation_items: Vec::new(), - latest_submitted_ref: None, - last_decision_ref: None, - }, - platform_facts: fixed_plan_platform_facts(), - approval_observation: Some(PlanProviderApprovalObservationV1 { - tool: PLAN_SUBMIT_GDD_TOOL.to_string(), - status: "ok".to_string(), - summary: String::new(), - detail: None, - }), - }; - let empty_bytes = serde_json::to_vec(&value).expect("serialize empty structured input"); - assert!(empty_bytes.len() < MAX_BYTES); - value - .approval_observation - .as_mut() - .expect("approval observation") - .summary = "x".repeat(MAX_BYTES - empty_bytes.len()); - let exact_bytes = serde_json::to_vec(&value).expect("serialize exact structured input"); - assert_eq!(exact_bytes.len(), MAX_BYTES); - let exact_message = render_plan_provider_structured_injections_message(&exact_bytes) - .expect("exactly 64 KiB structured injection must be accepted"); - let prefix = format!("{PLAN_PROVIDER_STRUCTURED_INJECTIONS_MESSAGE_HEADER}\n"); - assert!(exact_message.starts_with(&prefix)); - assert_eq!( - exact_message - .strip_prefix(&prefix) - .expect("structured injection prefix") - .as_bytes(), - exact_bytes.as_slice() - ); - - value - .approval_observation - .as_mut() - .expect("approval observation") - .summary - .push('x'); - let over_bytes = serde_json::to_vec(&value).expect("serialize oversized structured input"); - assert_eq!(over_bytes.len(), MAX_BYTES + 1); - let error = render_plan_provider_structured_injections_message(&over_bytes) - .expect_err("one byte over 64 KiB must fail closed"); - assert!(error.contains("wire bytes 非法")); - } - #[test] fn provider_request_fingerprint_keeps_max_distinct_from_high() { let base = LlmRunRequest::single_turn("系统", "任务"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/real_e2e_checkpoint.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/real_e2e_checkpoint.rs index 16b519b90..3e7e052dd 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/real_e2e_checkpoint.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/real_e2e_checkpoint.rs @@ -1032,7 +1032,6 @@ pub(crate) async fn await_game_creator_agent_runtime_tool_plan_checkpoint_for_te ), web_search_enabled: snapshot.web_search_enabled, allow_idle_context_compaction: snapshot.allow_idle_context_compaction, - planning_session_binding: snapshot.planning_session_binding.clone(), }; await_game_creator_agent_runtime_provider_request_with_snapshot_and_control_recheck( root, @@ -1134,7 +1133,6 @@ where match resolve_game_creator_agent_runtime_provider_request_attempt_at_locked( root, &base_request_id, - snapshot.planning_session_binding.is_some(), ) { Ok(resolution) => resolution, Err(error) => { @@ -1181,91 +1179,20 @@ where return Err(error); } } - let plan_usage_scope = match capture_plan_provider_usage_scope_at_locked( - root, - &snapshot, - &request_id, - ) { - Ok(scope) => scope, - Err(error) => { - let _ = mark_game_creator_agent_runtime_provider_request_needs_reconciliation_at_locked( - root, - &snapshot, - &request_id, - ); - unregister_game_creator_agent_runtime_provider_request(&key, &active); - return Err(format!( - "{AGENT_RUNTIME_PROVIDER_REQUEST_RECONCILIATION_PREFIX}: requestId={request_id} · planningUsageScope={error}" - )); - } - }; drop(control_lock); let notified = active.notify.notified(); tokio::pin!(notified); tokio::pin!(request); - let (result, active_millis) = if active.interrupted.load(Ordering::Acquire) { - (Ok(None), 0) + let result = if active.interrupted.load(Ordering::Acquire) { + Ok(None) } else { - let active_started = tokio::time::Instant::now(); - let result = tokio::select! { + tokio::select! { biased; _ = &mut notified => Ok(None), result = &mut request => result.map(Some), - }; - let elapsed = active_started.elapsed().as_millis(); - let active_millis = match u64::try_from(elapsed) { - Ok(value) => value, - Err(_) => { - unregister_game_creator_agent_runtime_provider_request(&key, &active); - if let Ok(_control_lock) = - acquire_game_creator_agent_runtime_project_write_lock_with_wait( - root, - "runtime.provider_request.planning_usage_overflow_reconciliation", - ) - { - let _ = - 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} · planningUsage=activeMillis overflow" - )); - } - }; - (result, active_millis) + } }; let result = result.map_err(|error| redact_agent_runtime_error(root, &error, 500)); - let usage_outcome = match &result { - Ok(Some(_)) => "completed", - Ok(None) => "interrupted", - Err(_) => "failed", - }; - if let Err(error) = persist_plan_provider_usage_fact_at( - root, - &snapshot, - &request_id, - plan_usage_scope.as_ref(), - usage_outcome, - active_millis, - ) { - unregister_game_creator_agent_runtime_provider_request(&key, &active); - if let Ok(_control_lock) = acquire_game_creator_agent_runtime_project_write_lock_with_wait( - root, - "runtime.provider_request.planning_usage_reconciliation", - ) { - let _ = 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} · planningUsage={error}" - )); - } if let Ok(Some(response)) = result.as_ref() { if let Err(error) = success_commit(&request_id, response) { unregister_game_creator_agent_runtime_provider_request(&key, &active); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/response_stream.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/response_stream.rs index 1b371e3ba..7352ad379 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/response_stream.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/response_stream.rs @@ -275,7 +275,6 @@ pub(in crate::agent) fn mark_game_creator_agent_runtime_response_stream_committe request_slot: request_slot.clone(), web_search_enabled: false, allow_idle_context_compaction: false, - planning_session_binding: None, }; write_game_creator_agent_runtime_response_stream_ready_at( root, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/run_configuration.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/run_configuration.rs index fe936f77e..61396cd9e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/run_configuration.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/run_configuration.rs @@ -99,7 +99,6 @@ pub(in crate::agent) fn validate_agent_runtime_run_profile_binding_record( { return Err("Agent Runtime Run Profile 身份指纹无效".to_string()); } - reject_supervisor_plan_autonomous_profile(&binding.source, &binding.profile)?; if binding.parent_agent_id.is_some() != binding.parent_run_id.is_some() { return Err("Agent Runtime Run Profile 绑定父 Run 身份不完整".to_string()); } @@ -126,83 +125,6 @@ pub(in crate::agent) fn validate_agent_runtime_run_profile_binding_record( Ok(()) } -/// Validate the sole root identity that may dispatch the planning child under D11. -/// -/// This deliberately does not infer authority from an in-memory runtime or from a -/// source string alone. The durable binding must describe a top-level -/// `project-supervisor-plan` standard run whose root fields point back to itself -/// and which has no parent link. -pub(crate) fn validate_project_supervisor_plan_root_binding_at( - root: &Path, - agent_id: &str, - run_id: &str, -) -> Result { - let agent_id = normalize_game_creator_runtime_agent_id(agent_id)?; - let run_id = run_id.trim(); - if agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID || run_id.is_empty() { - return Err("project-planning 父 Run 必须是 project-supervisor 的非空根 Run".to_string()); - } - let binding = read_game_creator_agent_runtime_run_profile_binding(root, &agent_id, run_id)? - .ok_or_else(|| "project-planning 父 Run 缺少 Run Profile 绑定".to_string())?; - if binding.agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID - || binding.run_id != run_id - || binding.root_agent_id != binding.agent_id - || binding.root_run_id != binding.run_id - || binding.parent_agent_id.is_some() - || binding.parent_run_id.is_some() - || binding.source != AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE - || binding.profile != AGENT_RUNTIME_RUN_PROFILE_STANDARD - { - return Err( - "project-planning 父 Run 必须是 project-supervisor-plan standard 顶层根 Run" - .to_string(), - ); - } - Ok(binding) -} - -/// Validate the exact D11 identity of the statically delegated planning child. -/// -/// The parent binding is checked independently and the child's root IDs and -/// parent-binding fingerprint are required to agree with it. Any missing, -/// malformed, or cross-lineage binding fails closed. -pub(in crate::agent) fn validate_project_planning_child_binding_at( - root: &Path, - agent_id: &str, - run_id: &str, -) -> Result { - let agent_id = normalize_game_creator_runtime_agent_id(agent_id)?; - let run_id = run_id.trim(); - if agent_id != GAME_CREATOR_PROJECT_PLANNING_AGENT_ID || run_id.is_empty() { - return Err("project-planning child 身份不匹配".to_string()); - } - let binding = read_game_creator_agent_runtime_run_profile_binding(root, &agent_id, run_id)? - .ok_or_else(|| "project-planning 缺少 Run Profile 绑定".to_string())?; - if binding.agent_id != GAME_CREATOR_PROJECT_PLANNING_AGENT_ID - || binding.run_id != run_id - || binding.source != "agent-delegate" - || binding.profile != AGENT_RUNTIME_RUN_PROFILE_STANDARD - || binding.parent_agent_id.as_deref() != Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID) - || binding.parent_run_id.as_deref().is_none() - { - return Err("project-planning Run Profile 身份不符合静态委派合同".to_string()); - } - let parent_agent_id = binding.parent_agent_id.as_deref().unwrap_or_default(); - let parent_run_id = binding.parent_run_id.as_deref().unwrap_or_default(); - let parent = - validate_project_supervisor_plan_root_binding_at(root, parent_agent_id, parent_run_id)?; - if binding.root_agent_id != parent.root_agent_id - || binding.root_run_id != parent.root_run_id - || binding.parent_binding_fingerprint.as_deref() - != Some(parent.binding_fingerprint.as_str()) - { - return Err( - "project-planning child 与 project-supervisor-plan 根 Run 身份不一致".to_string(), - ); - } - Ok(binding) -} - pub(in crate::agent) fn read_game_creator_agent_runtime_run_profile_binding_once( root: &Path, agent_id: &str, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/steering.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/steering.rs index 6ec369a4e..9037da1e1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/steering.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/steering.rs @@ -763,7 +763,6 @@ fn goal_contract_root_steer_task_at( { return Ok(None); } - reject_supervisor_plan_root_steer(&task.source)?; if !agent_runtime_supervisor_source_is_trusted(&task.source) { return Ok(None); } @@ -1155,11 +1154,6 @@ pub(crate) fn steer_game_creator_agent_runtime_task_for_profile_at( if accepted_via.is_empty() { return Err("追加指令缺少 acceptedVia".to_string()); } - if let Some(task) = - read_latest_game_creator_agent_runtime_task_by_run_id(root, &agent_id, run_id)? - { - reject_supervisor_plan_root_steer(&task.source)?; - } if let Some(root_task) = goal_contract_root_steer_task_at(root, &agent_id, session_id, run_id)? { return transition_goal_contract_root_steer_at( @@ -1182,7 +1176,6 @@ pub(crate) fn steer_game_creator_agent_runtime_task_for_profile_at( if state.run_id != run_id || state.session_id != session_id { return Err("追加指令与当前 Agent 的 session/run 身份不匹配".to_string()); } - reject_supervisor_plan_root_steer(&state.source)?; if let Some(expected_run_profile) = expected_run_profile { let expected_run_profile = normalize_agent_runtime_run_profile(Some(expected_run_profile))?; let (persisted_run_profile, persisted_binding_fingerprint) = diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_state.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_state.rs index 79d824021..48052eed0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_state.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_state.rs @@ -8,8 +8,6 @@ pub(crate) const AGENT_RUNTIME_PUBLIC_STATUS_MESSAGE_ID_PREFIX: &str = "runtime- /// phase 落盘之后,整份 journal 从那一行起再也读不出来:`agent.run_status` 对该 /// Agent 永久失败,父 run 只能瞎转到 needs-reconciliation。实测就是这么炸的。 /// 让写方和白名单引用同一个常量,两边不可能再漂移。 -pub(crate) const AGENT_RUNTIME_TASK_PHASE_PLANNING_SESSION_PROJECTION_FAILED: &str = - "planning-session-projection-failed"; fn game_creator_agent_runtime_public_status_message_id( agent_id: &str, @@ -1631,7 +1629,6 @@ pub(crate) fn default_game_creator_agent_runtime_state( waiting_on: "开发者输入".to_string(), next_step: "等待输入".to_string(), loop_iteration: 0, - plan_submit_gdd_rejection_count: 0, plan_update_idle_rounds: 0, stale_finalization_rounds: 0, max_loop_iterations: AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT as u32, @@ -1698,66 +1695,12 @@ pub(crate) fn default_game_creator_agent_runtime_allowed_tools() -> Vec /// Return the durable Runtime tool surface for an Agent identity. /// -/// Delegated `project-planning` runs are intentionally narrower than the -/// normal Runtime catalog. Keeping this decision in the state constructor -/// prevents a freshly-created planning state from briefly advertising the -/// broad catalog before its policy snapshot is hydrated. pub(crate) fn default_game_creator_agent_runtime_allowed_tools_for_agent( - agent_id: &str, + _agent_id: &str, ) -> Vec { - if agent_id.trim() == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID { - return AGENT_RUNTIME_PROJECT_PLANNING_ACTION_TOOLS - .iter() - .map(|tool| (*tool).to_string()) - .collect(); - } default_game_creator_agent_runtime_allowed_tools() } -#[cfg(test)] -mod planning_state_tests { - use super::*; - - #[test] - fn planning_state_normalization_cannot_expand_tool_surface() { - let mut state = default_game_creator_agent_runtime_state( - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - "planning-normalize-run", - ); - state.allowed_tools = default_game_creator_agent_runtime_allowed_tools(); - state.tool_policy.allowed_tools = default_game_creator_agent_runtime_allowed_tools(); - state.tool_policy.auto_tools = default_game_creator_agent_runtime_allowed_tools(); - normalize_game_creator_agent_runtime_state( - &mut state, - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - ); - assert_eq!( - state.allowed_tools, - vec![ - "file.read".to_string(), - "file.list".to_string(), - PLAN_SUBMIT_GDD_TOOL.to_string() - ] - ); - assert_eq!(state.tool_policy.allowed_tools, state.allowed_tools); - assert!(state - .tool_policy - .denied_tools - .iter() - .any(|tool| tool == "project.search")); - assert!(state - .tool_policy - .denied_tools - .iter() - .any(|tool| tool == "file.write")); - assert!(!state - .tool_policy - .denied_tools - .iter() - .any(|tool| tool == PLAN_SUBMIT_GDD_TOOL)); - } -} - #[cfg(test)] mod runtime_error_redaction_tests { use super::*; @@ -1950,16 +1893,7 @@ pub(super) fn normalize_game_creator_agent_runtime_state( state.next_step = "修复计划快照后恢复当前 run".to_string(); state.error = Some(sanitize_agent_runtime_text(&error, 500)); } - let planning_agent = agent_id.trim() == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID - || state.agent_id.trim() == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID; - if planning_agent { - // State hydration is an authority boundary. Never let an old or - // caller-supplied full catalog expand a planning child back into a - // general-purpose Agent. - state.allowed_tools = default_game_creator_agent_runtime_allowed_tools_for_agent( - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - ); - } else if state.allowed_tools.is_empty() { + if state.allowed_tools.is_empty() { state.allowed_tools = default_game_creator_agent_runtime_allowed_tools(); } else { for tool in default_game_creator_agent_runtime_allowed_tools() { @@ -1971,37 +1905,7 @@ pub(super) fn normalize_game_creator_agent_runtime_state( if state.updated_at == 0 { state.updated_at = unix_timestamp(); } - if planning_agent { - let exact = default_game_creator_agent_runtime_allowed_tools_for_agent( - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - ); - state.tool_policy.allowed_tools = exact.clone(); - // Recovery/normalization may receive a stale snapshot. Preserve - // permission-derived denies for the exact planning tools, while the - // planning ceiling keeps every other executable tool fail-closed. - let exact_denied = state - .tool_policy - .denied_tools - .iter() - .filter(|tool| exact.iter().any(|allowed| allowed == *tool)) - .cloned() - .collect::>(); - state.tool_policy.auto_tools.retain(|tool| { - exact.iter().any(|allowed| allowed == tool) - && !exact_denied.iter().any(|denied| denied == tool) - }); - state.tool_policy.confirm_tools.retain(|tool| { - exact.iter().any(|allowed| allowed == tool) - && !exact_denied.iter().any(|denied| denied == tool) - }); - state.tool_policy.denied_tools = exact_denied; - state.tool_policy.denied_tools.extend( - agent_runtime_executable_tools() - .into_iter() - .filter(|tool| !exact.iter().any(|allowed| allowed == tool)) - .map(str::to_string), - ); - } else if state.tool_policy.allowed_tools.is_empty() { + if state.tool_policy.allowed_tools.is_empty() { state.tool_policy.allowed_tools = agent_runtime_executable_tools() .into_iter() .map(str::to_string) @@ -4206,7 +4110,6 @@ fn validate_game_creator_agent_runtime_task_status_phase( | "completion-contract-failed" | "conversation-write-failed" | "public-status-write-failed" - | AGENT_RUNTIME_TASK_PHASE_PLANNING_SESSION_PROJECTION_FAILED | "parent-terminal" | "parent-link-missing" ) { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools.rs index 1478ae0aa..633f5ffe8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools.rs @@ -10,6 +10,7 @@ mod goal_contract; mod helpers; mod isolated_joins; mod media; +pub(in crate::agent) use media::design_foundation_ui_page_output_path_is_valid; mod policy; mod preview; mod process_ops; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delegation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delegation.rs index 948eda19a..d9df5d1f0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delegation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delegation.rs @@ -414,58 +414,6 @@ pub(crate) fn observe_agent_runtime_agent_delegate_at_locked( detail: None, }; } - // D11 reserves the planning child for the exact top-level plan root. Do - // this before parsing or persisting the delegation so forged source/profile - // combinations cannot create a child that later looks like a valid plan - // continuation. - if target_agent_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID { - if let Err(error) = - validate_project_supervisor_plan_root_binding_at(root, agent_id, parent_run_id) - { - return AgentRuntimeToolObservation { - tool: "agent.delegate".to_string(), - status: "failed".to_string(), - summary: redact_agent_runtime_project_paths(root, &error, 240), - detail: None, - }; - } - } - // M1A-4 补上 D11 的对称方向:父 Run 若在 task journal 里自称 - // project-supervisor-plan(弱判据,取自 task record 而不是 binding,binding - // 缺失或损坏时也能取到),就只能委派 project-planning。绝不能把 - // validate_project_supervisor_plan_root_binding_at 返回 Err 误判成"父根本不是 - // plan 根"从而放行——那会在 binding 损坏时对任意子 Agent 创建 fail-open。判定 - // 必须是:弱判据为假→不管;弱判据为真+强判据 validate 通过→只放行 - // target==project-planning;弱判据为真+强判据不通过→拒绝创建任何子 Agent。 - let parent_task_for_plan_root_symmetry = - match read_latest_game_creator_agent_runtime_task_by_run_id(root, agent_id, parent_run_id) { - Ok(task) => task, - Err(error) => { - return AgentRuntimeToolObservation { - tool: "agent.delegate".to_string(), - status: "failed".to_string(), - summary: redact_agent_runtime_project_paths(root, &error, 240), - detail: None, - }; - } - }; - if parent_task_for_plan_root_symmetry - .is_some_and(|task| agent_runtime_supervisor_source_is_plan(&task.source)) - { - let plan_root_binding_validated = - validate_project_supervisor_plan_root_binding_at(root, agent_id, parent_run_id).is_ok(); - if !plan_root_binding_validated || target_agent_id != GAME_CREATOR_PROJECT_PLANNING_AGENT_ID - { - return AgentRuntimeToolObservation { - tool: "agent.delegate".to_string(), - status: "failed".to_string(), - summary: format!( - "立项策划根 Run 只能委派 project-planning,已拒绝创建子 Agent(kind={AGENT_RUNTIME_PLAN_ROOT_CHILD_TARGET_UNSUPPORTED_KIND})" - ), - detail: None, - }; - } - } let action_identity = action_id .filter(|value| !value.trim().is_empty()) .map(str::to_string) @@ -534,15 +482,7 @@ pub(crate) fn observe_agent_runtime_agent_delegate_at_locked( agent_runtime_tool_input_text(input, &["repairOfDelegationId", "repair_of_delegation_id"]); let repair_of_delegation_id = (!repair_of_delegation_id.is_empty()).then_some(repair_of_delegation_id); - let required_visual_artifact = if editor_api_key_is_configured() { - match target_agent_id.as_str() { - "design-foundation" => Some("assets/ui-prototype.png"), - "art-asset-plan" => Some("assets/art-spritesheet.png"), - _ => None, - } - } else { - None - }; + let required_visual_artifact: Option<&str> = None; if repair_of_delegation_id.is_none() && required_visual_artifact.is_some_and(|required| { !expected_artifacts @@ -613,101 +553,11 @@ pub(crate) fn observe_agent_runtime_agent_delegate_at_locked( delegation_action_identity, ); let delegated_task_text = task; - // 澄清续跑与质量返工都带 repairOfDelegationId,但预算完全不同,末尾那句说明 - // 必须分开渲染(见 StaticDelegateHopNote 的注释)。判据用 - // `clarification_continuation_identity.is_some()` 加 target 是 project-planning: - // 前者只有 validate_static_delegate_clarification_continuation_at 认可的续跑才非空, - // 后者保证只影响立项策划链路——project-planning 的 run binding 由 - // validate_project_planning_child_binding_at 强制挂在 project-supervisor-plan 根下, - // 做游戏 / 做素材的澄清续跑仍走 Repair 分支,逐字保持既有行为。 - let plan_clarification_rounds = if clarification_continuation_identity.is_some() - && target_agent_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID - { - match repair_of_delegation_id.as_deref() { - Some(original) => { - let deliveries = match list_static_delegate_deliveries_at(root) { - Ok(deliveries) => deliveries, - Err(error) => { - return AgentRuntimeToolObservation { - tool: "agent.delegate".to_string(), - status: "failed".to_string(), - summary: redact_agent_runtime_project_paths(root, &error, 240), - detail: None, - }; - } - }; - let (_, parent_round) = static_delegate_lineage_counters(&deliveries, original); - let limit = match static_delegate_clarification_round_limit_at( - root, - agent_id, - parent_run_id, - ) { - Ok(limit) => limit, - Err(error) => { - return AgentRuntimeToolObservation { - tool: "agent.delegate".to_string(), - status: "failed".to_string(), - summary: redact_agent_runtime_project_paths(root, &error, 240), - detail: None, - }; - } - }; - // 谱系损坏时 counters 返回 u32::MAX 哨兵。正常路径上 - // validate_static_delegate_repair_request_at 已经先一步拒掉这种链, - // 但这里不赌:算不出可信轮号就退回 Repair 文案,宁可保守也不写假轮号。 - parent_round - .checked_add(1) - .filter(|rounds_used| *rounds_used <= limit) - .map(|rounds_used| (rounds_used, limit)) - } - None => None, - } - } else { - None - }; - // 用户修订跳同样带 repairOfDelegationId,但它是人触发的,不该拿到「唯一返工轮」 - // 那句话。判据用原 delivery 的 contractStatus,并同样只作用于立项策划链路: - // `mark_static_delegate_delivery_user_revision_requested_at` 只由策划审批调用, - // 这里再加一道 target 门,做游戏 / 做素材的返工跳逐字保持 Repair 分支。 - let user_revision_hop = match repair_of_delegation_id.as_deref() { - Some(original) - if plan_clarification_rounds.is_none() - && target_agent_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID => - { - match static_delegate_original_awaits_user_revision_at(root, original) { - Ok(value) => value, - Err(error) => { - return AgentRuntimeToolObservation { - tool: "agent.delegate".to_string(), - status: "failed".to_string(), - summary: redact_agent_runtime_project_paths(root, &error, 240), - detail: None, - }; - } - } - } - _ => false, - }; - let hop_note = match ( - repair_of_delegation_id.as_deref(), - plan_clarification_rounds, - ) { - (Some(original_delegation_id), Some((rounds_used, rounds_limit))) => { - StaticDelegateHopNote::PlanClarification { - original_delegation_id, - rounds_used, - rounds_limit, - } - } - (Some(original_delegation_id), None) if user_revision_hop => { - StaticDelegateHopNote::UserRevision { - original_delegation_id, - } - } - (Some(original_delegation_id), None) => StaticDelegateHopNote::Repair { + let hop_note = match repair_of_delegation_id.as_deref() { + Some(original_delegation_id) => StaticDelegateHopNote::Repair { original_delegation_id, }, - (None, _) => StaticDelegateHopNote::None, + None => StaticDelegateHopNote::None, }; let delegated_task = match render_static_delegate_task_contract( &delegated_task_text, @@ -1376,43 +1226,6 @@ pub(crate) fn observe_agent_runtime_agent_spawn_isolated( detail: None, }; }; - // M1A-4:策划链路没有临时并行检查的场景,父 Run 若在 task journal 里自称 - // project-supervisor-plan(弱判据,取自 task record,binding 缺失或损坏时 - // 也能取到)就一律拒绝创建动态隔离 child,不区分强判据是否通过——弱判据为 - // 真时无论 binding 是否能被 validate_project_supervisor_plan_root_binding_at - // 验证,agent.spawn_isolated 对策划根 Run 都不是合法通道,必须 fail closed。 - let parent_task_for_plan_root_isolation = - match read_latest_game_creator_agent_runtime_task_by_run_id( - root, - parent_agent_id, - parent_run_id, - ) { - Ok(task) => task, - Err(error) => { - return AgentRuntimeToolObservation { - tool: "agent.spawn_isolated".to_string(), - status: "failed".to_string(), - summary: redact_agent_runtime_project_paths( - root, - &format!("无法核对动态隔离父 Run task journal,已拒绝创建 child:{error}"), - 240, - ), - detail: None, - }; - } - }; - if parent_task_for_plan_root_isolation - .is_some_and(|task| agent_runtime_supervisor_source_is_plan(&task.source)) - { - return AgentRuntimeToolObservation { - tool: "agent.spawn_isolated".to_string(), - status: "failed".to_string(), - summary: format!( - "立项策划根 Run 不支持动态隔离子 Agent(kind={AGENT_RUNTIME_PLAN_ROOT_CHILD_TARGET_UNSUPPORTED_KIND})" - ), - detail: None, - }; - } let binding = match read_game_creator_agent_runtime_run_profile_binding( root, parent_agent_id, @@ -1513,14 +1326,9 @@ pub(crate) fn observe_agent_runtime_agent_spawn_isolated( }; for child in &request.children { let template = match normalize_game_creator_runtime_agent_id(&child.template_agent_id) { - // 立项策划子 Agent 只能由 Project Supervisor 通过 agent.delegate 静态委派 - // 发起(D11)。它一旦进入 catalog 就会天然满足「非 child- 前缀、非 - // Supervisor 本体」这个放行条件,使任何持有 agent.spawn_isolated 的 Agent - // 都能拿它当动态孵生模板——这是登记带来的隐性扩权,必须显式排除。 Ok(template) if !template.starts_with("child-") - && template != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID - && template != GAME_CREATOR_PROJECT_PLANNING_AGENT_ID => + && template != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID => { template } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/file_ops.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/file_ops.rs index 220dae8ed..4d4616f77 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/file_ops.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/file_ops.rs @@ -416,39 +416,6 @@ pub(in crate::agent) fn observe_agent_runtime_file_delete( return agent_runtime_mutation_gate_failure_observation(root, "file.delete", &error); } } - if agent_id == "art-asset-plan" && path == "assets/art-spritesheet.png" { - let manifest = match read_existing_manifest_for_project(root) { - Ok(manifest) => manifest, - Err(error) => { - return AgentRuntimeToolObservation { - tool: "file.delete".to_string(), - status: "blocked".to_string(), - summary: "无法确认首版美术素材登记状态,未执行删除".to_string(), - detail: Some(redact_agent_runtime_project_paths(root, &error, 500)), - }; - } - }; - let registered_fixed_asset_exists = manifest.assets.iter().any(|asset| { - asset.local_path == "assets/art-spritesheet.png" - && asset.kind == "art-spritesheet" - && asset.media_type.starts_with("image/") - && asset.source.kind == GameCreationAppAssetSourceKind::Canvas - && resolve_local_project_path(root, &asset.local_path) - .ok() - .is_some_and(|path| path.is_file()) - }); - if registered_fixed_asset_exists { - return AgentRuntimeToolObservation { - tool: "file.delete".to_string(), - status: "blocked".to_string(), - summary: "首版美术素材已生成并登记,禁止删除固定正式产物".to_string(), - detail: Some( - "path=assets/art-spritesheet.png · 请复用现有画布资产并核对 assets/manifest.art.json,不得重复生成或扣费" - .to_string(), - ), - }; - } - } if let Err(error) = prepare_agent_runtime_project_mutation_locked(root, agent_id, run_id, "file.delete") { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/goal_contract.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/goal_contract.rs index 9925c65de..fafede498 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/goal_contract.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/goal_contract.rs @@ -149,87 +149,20 @@ pub(crate) fn observe_agent_runtime_acceptance_update( })(); match result { Ok(state) => { - let gate = ensure_plan_gdd_approval_pending_after_acceptance_at(root, agent_id, run_id); - match gate { - Err(error) => AgentRuntimeToolObservation { - tool: "agent.acceptance_update".to_string(), - status: "needs-reconciliation".to_string(), - summary: format!( - "Acceptance Graph 已更新到 revision {},但审批前置门需要人工核对", - state.revision - ), - detail: Some(redact_agent_runtime_project_paths( - root, - &format!( - "Acceptance Graph 已落盘,但 acceptance-gate 未能安全收束:{error}" - ), - 800, - )), - }, - Ok(gate) => { - let mut detail = serde_json::json!({ - "contractFingerprint": state.contract_fingerprint, - "acceptanceRevision": state.revision, - "stateFingerprint": state.state_fingerprint, - "evaluations": state.evaluations.iter().map(|evaluation| serde_json::json!({ - "criterionId": evaluation.criterion_id, - "status": evaluation.status, - })).collect::>(), - }); - match gate { - PlanGddAcceptanceGateOutcome::WaitingForDeliveryClaim { - delegation_id, - detail: gate_detail, - } => { - detail["approvalPending"] = - serde_json::Value::String("not-created".to_string()); - detail["nextRequiredAction"] = - serde_json::Value::String("agent.run_status".to_string()); - detail["delegationId"] = serde_json::Value::String(delegation_id); - detail["gateDetail"] = serde_json::Value::String(gate_detail); - } - PlanGddAcceptanceGateOutcome::WaitingForEvidence { - detail: gate_detail, - } => { - detail["approvalPending"] = - serde_json::Value::String("not-created".to_string()); - detail["nextRequiredAction"] = - serde_json::Value::String("file.read".to_string()); - detail["gateDetail"] = serde_json::Value::String(gate_detail); - } - PlanGddAcceptanceGateOutcome::RepairRequired { - repair_of_delegation_id, - detail: gate_detail, - } => { - detail["approvalPending"] = - serde_json::Value::String("not-created".to_string()); - detail["nextRequiredAction"] = - serde_json::Value::String("agent.delegate".to_string()); - detail["repairOfDelegationId"] = - serde_json::Value::String(repair_of_delegation_id); - detail["gateDetail"] = serde_json::Value::String(gate_detail); - } - PlanGddAcceptanceGateOutcome::PendingCreated => { - detail["approvalPending"] = - serde_json::Value::String("created".to_string()); - } - PlanGddAcceptanceGateOutcome::PendingAlreadyPresent => { - detail["approvalPending"] = - serde_json::Value::String("already-present".to_string()); - } - PlanGddAcceptanceGateOutcome::AlreadyDecided => { - detail["approvalPending"] = - serde_json::Value::String("already-decided".to_string()); - } - PlanGddAcceptanceGateOutcome::NotApplicable => {} - } - AgentRuntimeToolObservation { - tool: "agent.acceptance_update".to_string(), - status: "ok".to_string(), - summary: format!("Acceptance Graph 已更新到 revision {}", state.revision), - detail: Some(serde_json::to_string(&detail).unwrap_or_default()), - } - } + let detail = serde_json::json!({ + "contractFingerprint": state.contract_fingerprint, + "acceptanceRevision": state.revision, + "stateFingerprint": state.state_fingerprint, + "evaluations": state.evaluations.iter().map(|evaluation| serde_json::json!({ + "criterionId": evaluation.criterion_id, + "status": evaluation.status, + })).collect::>(), + }); + AgentRuntimeToolObservation { + tool: "agent.acceptance_update".to_string(), + status: "ok".to_string(), + summary: format!("Acceptance Graph 已更新到 revision {}", state.revision), + detail: Some(serde_json::to_string(&detail).unwrap_or_default()), } } Err(error) => AgentRuntimeToolObservation { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/media.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/media.rs index fa17cbfcc..e570d026c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/media.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/media.rs @@ -19,7 +19,7 @@ fn agent_runtime_canvas_asset_kind_is_supported(asset_kind: &str) -> bool { AGENT_RUNTIME_CANVAS_ASSET_KINDS.contains(&asset_kind) } -fn design_foundation_ui_page_output_path_is_valid(path: &str) -> bool { +pub(in crate::agent) fn design_foundation_ui_page_output_path_is_valid(path: &str) -> bool { let Some(page_id) = path .strip_prefix("assets/ui-pages/") .and_then(|value| value.strip_suffix(".png")) @@ -540,90 +540,40 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio .or_else(|| input.get("replace_existing")) .and_then(serde_json::Value::as_bool) .unwrap_or(false); - let requested_options = PlatformArtAssetGenerationOptions { + let slice_count = input + .get("sliceCount") + .or_else(|| input.get("slice_count")) + .and_then(serde_json::Value::as_u64) + .map(|value| value as usize); + let mut requested_options = PlatformArtAssetGenerationOptions { output_path: (!output_path.trim().is_empty()).then_some(output_path), aspect_ratio, image_size, asset_kind, asset_label, replace_existing, + slice_count, }; - let canonical_options = match agent_id { - "art-director" => Some(PlatformArtAssetGenerationOptions { - output_path: Some(AGENT_RUNTIME_ART_SPEC_PATH.to_string()), - aspect_ratio: "1:1".to_string(), - image_size: "1K".to_string(), - asset_kind: "icon-spec".to_string(), - asset_label: "游戏统一视觉规范图".to_string(), - replace_existing: false, - }), - "design-foundation" - if requested_options - .output_path - .as_deref() - .is_some_and(design_foundation_ui_page_output_path_is_valid) => - { - Some(PlatformArtAssetGenerationOptions { - output_path: requested_options.output_path.clone(), - aspect_ratio: "16:9".to_string(), - image_size: "2K".to_string(), - asset_kind: "ui-prototype".to_string(), - asset_label: if requested_options.asset_label.trim().is_empty() { - "游戏功能页面设计图".to_string() - } else { - requested_options.asset_label.clone() - }, - replace_existing: false, - }) + if let Some(pending) = pending_action { + match recover_persisted_visual_generation_options( + root, + pending, + &prompt, + &requested_options, + ) { + Ok(Some(recovered)) => requested_options = recovered, + Ok(None) => {} + Err(error) => { + return AgentRuntimeToolObservation { + tool: "canvas.asset_generate".to_string(), + status: AGENT_RUNTIME_TOOL_OBSERVATION_STATUS_NEEDS_RECONCILIATION.to_string(), + summary: redact_agent_runtime_project_paths(root, &error, 240), + detail: None, + } + } } - "design-foundation" => Some(PlatformArtAssetGenerationOptions { - output_path: Some("assets/ui-prototype.png".to_string()), - aspect_ratio: "16:9".to_string(), - image_size: "2K".to_string(), - asset_kind: "ui-prototype".to_string(), - asset_label: "游戏横屏界面原型图".to_string(), - replace_existing: false, - }), - "art-asset-plan" => Some(PlatformArtAssetGenerationOptions { - output_path: Some("assets/art-spritesheet.png".to_string()), - aspect_ratio: "1:1".to_string(), - image_size: "1K".to_string(), - asset_kind: "art-spritesheet".to_string(), - asset_label: "游戏首版核心美术素材".to_string(), - replace_existing: false, - }), - _ => None, - }; - let mut options = if let Some(canonical) = canonical_options { - let mismatch = requested_options - .output_path - .as_deref() - .is_some_and(|value| Some(value) != canonical.output_path.as_deref()) - || (!requested_options.aspect_ratio.is_empty() - && requested_options.aspect_ratio != canonical.aspect_ratio) - || (!requested_options.image_size.is_empty() - && requested_options.image_size != canonical.image_size) - || (!requested_options.asset_kind.is_empty() - && requested_options.asset_kind != canonical.asset_kind) - || (!requested_options.asset_label.is_empty() - && requested_options.asset_label != canonical.asset_label); - if mismatch { - return AgentRuntimeToolObservation { - tool: "canvas.asset_generate".to_string(), - status: "failed".to_string(), - summary: format!( - "图片产物型专业任务不能覆盖固定输出合同:outputPath={} · aspectRatio={} · imageSize={} · assetKind={} · assetLabel={}", - canonical.output_path.as_deref().unwrap_or("null"), - canonical.aspect_ratio, - canonical.image_size, - canonical.asset_kind, - canonical.asset_label, - ), - detail: None, - }; - } - canonical - } else { + } + let mut options = { let defaults = PlatformArtAssetGenerationOptions::default(); PlatformArtAssetGenerationOptions { output_path: requested_options.output_path, @@ -648,6 +598,7 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio requested_options.asset_label }, replace_existing, + slice_count, } }; options.replace_existing = replace_existing; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/policy.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/policy.rs index 8fc4f477f..4d174de7d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/policy.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/policy.rs @@ -80,90 +80,10 @@ pub(crate) fn game_creator_agent_runtime_tool_policy_rule_for_run( stored_binding_fingerprint: Option<&str>, command_id: &str, ) -> Option { - if command_id == PLAN_SUBMIT_GDD_TOOL - && agent_id.trim() != GAME_CREATOR_PROJECT_PLANNING_AGENT_ID - { - return Some(AgentRuntimeToolPolicyBlock::Denied(format!( - "plan.submit_gdd 仅允许 project-planning Agent:{}", - agent_id.trim() - ))); - } - if agent_id.trim() == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID { - if let Err(error) = agent_runtime_run_profile_identity_at( - root, - agent_id, - run_id, - stored_profile, - stored_binding_fingerprint, - ) { - return Some(AgentRuntimeToolPolicyBlock::Denied(error)); - } - if let Err(error) = validate_project_planning_child_binding_at(root, agent_id, run_id) { - return Some(AgentRuntimeToolPolicyBlock::Denied(error)); - } - // Project/Agent permission policy remains authoritative even for the - // narrower planning ceiling. Evaluate it before applying the exact - // allowlist so a denied read cannot be turned into an auto action and - // a confirmed read remains pending confirmation. - let permission_block = - game_creator_agent_runtime_tool_policy_rule(root, agent_id, command_id); - if matches!( - permission_block, - Some(AgentRuntimeToolPolicyBlock::Denied(_)) - ) { - return permission_block; - } - if command_id == PLAN_SUBMIT_GDD_TOOL - && matches!( - permission_block, - Some(AgentRuntimeToolPolicyBlock::RequiresConfirmation(_)) - ) - { - return Some(AgentRuntimeToolPolicyBlock::Denied( - "plan.submit_gdd 是 Runtime-owned create-only 提交,不支持转成通用确认 pending" - .to_string(), - )); - } - if !matches!(command_id, "file.read" | "file.list" | PLAN_SUBMIT_GDD_TOOL) { - return Some(AgentRuntimeToolPolicyBlock::Denied(format!( - "project-planning exact 工具面拒绝:{command_id}" - ))); - } - return permission_block; - } let blocked = game_creator_agent_runtime_tool_policy_rule(root, agent_id, command_id); if matches!(blocked, Some(AgentRuntimeToolPolicyBlock::Denied(_))) { return blocked; } - // 立项策划根 Run 的委派不再要人工确认。plan 根的工具面本身按阶段收窄 - // (`agent_runtime_plan_root_supervisor_tools_for_stage`):Delegate 阶段只广告 - // `agent.delegate` 这一个工具,它就是当前唯一能推进链路的动作;让用户确认「要不要 - // 执行唯一能做的那件事」没有决策含量,返工那一轮同理。这条链上真正由人把关的关口 - // 是 §13.0 的 Fast GDD 审批卡,那个不动。 - // - // source 只是弱判据,一旦它说 plan 就必须过 `validate_project_supervisor_plan_root_binding_at` - // 这个强判据——否则做游戏那条链的委派确认会被漂移或伪造的 binding 悄悄放开。绑定 - // 读盘只发生在「本来就要确认的 agent.delegate」这一个组合上,不给其它工具的每次 - // 策略判定加磁盘读。 - if command_id == "agent.delegate" - && matches!( - blocked, - Some(AgentRuntimeToolPolicyBlock::RequiresConfirmation(_)) - ) - { - match read_game_creator_agent_runtime_run_profile_binding(root, agent_id, run_id) { - Ok(Some(binding)) if binding.source == AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE => { - if let Err(error) = - validate_project_supervisor_plan_root_binding_at(root, agent_id, run_id) - { - return Some(AgentRuntimeToolPolicyBlock::Denied(error)); - } - return None; - } - Ok(_) => {} - Err(error) => return Some(AgentRuntimeToolPolicyBlock::Denied(error)), - } - } let (run_profile, _) = match agent_runtime_run_profile_identity_at( root, agent_id, @@ -321,14 +241,6 @@ pub(in crate::agent) fn game_creator_agent_runtime_tool_policy_rule( agent_id: &str, command_id: &str, ) -> Option { - if command_id == PLAN_SUBMIT_GDD_TOOL - && agent_id.trim() != GAME_CREATOR_PROJECT_PLANNING_AGENT_ID - { - return Some(AgentRuntimeToolPolicyBlock::Denied(format!( - "plan.submit_gdd 仅允许 project-planning Agent:{}", - agent_id.trim() - ))); - } let view = match read_project_permission_policy_at(root) { Ok(view) => view, Err(error) => return Some(AgentRuntimeToolPolicyBlock::Denied(error)), @@ -442,13 +354,6 @@ pub(crate) fn game_creator_agent_runtime_tool_policy_block_after_lock( command_id: &str, pending_action: Option<&AgentRuntimePendingToolAction>, ) -> Option { - if pending_action.is_some_and(|pending| { - agent_runtime_tool_rejected_by_agent_identity(agent_id, &pending.action.tool) - }) { - return Some(AgentRuntimeToolPolicyBlock::Denied( - "当前 Agent 身份不允许执行该原始工具".to_string(), - )); - } let blocked = match pending_action { Some(pending) => game_creator_agent_runtime_tool_policy_rule_for_run( root, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/run_status.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/run_status.rs index 1163c3b05..5e43df9a0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/run_status.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/run_status.rs @@ -224,48 +224,6 @@ pub(crate) fn observe_agent_runtime_run_status( })(); } - // `agent.run_status` already executes under the project snapshot lock. - // Re-run the locked gate on every plan-root status observation, even - // when no Ready delivery remains: an earlier run_status action may - // have durably claimed this delivery before its gate could persist. - let plan_gdd_acceptance_gate = - match ensure_plan_gdd_approval_pending_after_acceptance_locked( - root, agent_id, run_id, - )? { - PlanGddAcceptanceGateOutcome::NotApplicable => None, - PlanGddAcceptanceGateOutcome::WaitingForDeliveryClaim { .. } => { - return Err( - "agent.run_status 已认领 planning delivery,但 acceptance gate 仍观察到 Ready 状态" - .to_string(), - ); - } - PlanGddAcceptanceGateOutcome::WaitingForEvidence { - detail: gate_detail, - } => Some(serde_json::json!({ - "approvalPending": "not-created", - "nextRequiredAction": "file.read", - "gateDetail": gate_detail, - })), - PlanGddAcceptanceGateOutcome::RepairRequired { - repair_of_delegation_id, - detail: gate_detail, - } => Some(serde_json::json!({ - "approvalPending": "not-created", - "nextRequiredAction": "agent.delegate", - "repairOfDelegationId": repair_of_delegation_id, - "gateDetail": gate_detail, - })), - PlanGddAcceptanceGateOutcome::PendingCreated => Some(serde_json::json!({ - "approvalPending": "created", - })), - PlanGddAcceptanceGateOutcome::PendingAlreadyPresent => Some(serde_json::json!({ - "approvalPending": "already-present", - })), - PlanGddAcceptanceGateOutcome::AlreadyDecided => Some(serde_json::json!({ - "approvalPending": "already-decided", - })), - }; - let claimed_delegate_deliveries = if can_manage_static_delegate_receipts { claimed_static_delegate_deliveries_at(root, agent_id, run_id)? } else { @@ -305,11 +263,6 @@ pub(crate) fn observe_agent_runtime_run_status( .map_err(|error| format!("序列化专业 Agent claimed contracts 失败:{error}"))?; detail = format!("claimedDelegateContracts: {payload}\n\n{detail}"); } - if let Some(gate) = plan_gdd_acceptance_gate { - let payload = serde_json::to_string(&gate) - .map_err(|error| format!("序列化 Fast GDD acceptance gate 结果失败:{error}"))?; - detail = format!("planGddAcceptanceGate: {payload}\n\n{detail}"); - } // Ready payloads are complete evidence. The ordinary status summary may be shortened, // but evidence must fit its budget before the corresponding claim is committed. diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/task_ops.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/task_ops.rs index b66c10e73..f5360e521 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/task_ops.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/task_ops.rs @@ -144,18 +144,6 @@ pub(in crate::agent) fn observe_agent_runtime_task_create( }; } let group_input = agent_runtime_tool_input_text(input, &["group", "area"]); - // 立项策划子 Agent 不属于任何专业组,其 groupId 映射不出 GameCreationAppAgentGroup, - // 会落进下面的 Design 兜底、静默写出一条「归属 Design 组」的假任务污染任务审计。 - // 它按设计根本不该持有 task.create(工具面是 exact allowlist),这里只做兜底: - // 不静默改归属,要求显式传 group。 - if group_input.trim().is_empty() && agent_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID { - return AgentRuntimeToolObservation { - tool: "task.create".to_string(), - status: "failed".to_string(), - summary: "立项策划 Agent 不属于任何专业组,创建任务必须显式指定 group".to_string(), - detail: None, - }; - } let group = if group_input.trim().is_empty() { game_creator_agent_role_definition(agent_id) .map(|(group, _role)| group.id) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/skill_pack.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/skill_pack.rs index aec1efbc2..9cb4fda28 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/skill_pack.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/skill_pack.rs @@ -242,6 +242,27 @@ pub(crate) fn render_agc_skill_pack_index() -> Result { Ok(lines.join("\n")) } +pub(crate) fn read_agc_skill_resource(resource: &str) -> Result { + let manifest = validated_skill_pack_manifest()?; + let normalized = resource.trim().trim_start_matches('/').replace('\\', "/"); + let (skill_name, relative) = normalized + .split_once('/') + .ok_or_else(|| "Skill 资源路径必须是 skill/file".to_string())?; + let entry = manifest + .skills + .iter() + .find(|entry| entry.name == skill_name) + .ok_or_else(|| "未登记的 AGC Skill 资源".to_string())?; + if !entry.files.iter().any(|file| file == relative) || !is_safe_skill_relative_path(relative) { + return Err("未登记或不安全的 AGC Skill 资源".to_string()); + } + let bundled_path = format!("{skill_name}/{relative}"); + let bytes = + bundled_skill_file(&bundled_path).ok_or_else(|| "AGC Skill 资源不存在".to_string())?; + let canonical = canonical_skill_text_bytes(&bundled_path, bytes)?; + String::from_utf8(canonical.into_owned()).map_err(|_| "AGC Skill 资源不是 UTF-8".to_string()) +} + pub(crate) fn install_agc_skill_pack(isolated_os_home: &Path) -> Result { let manifest = validated_skill_pack_manifest()?; let skills_root = isolated_os_home.join(".agents").join("skills"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs b/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs index 3f34dc890..68ce6d844 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs @@ -9,20 +9,15 @@ use serde::Deserialize; use serde_json::{json, Value}; use crate::agent::{ - agent_runtime_native_executable_tools, agent_runtime_plan_root_supervisor_tools_for_stage, - AgentRuntimePlanUpdate, AgentRuntimeToolAction, AgentRuntimeToolPlan, PlanRootSupervisorStage, - AGENT_RUNTIME_BACKGROUND_TOOL_ACTION_LIMIT, AGENT_RUNTIME_CANVAS_ASSET_KINDS, - AGENT_RUNTIME_PLAN_STEP_LIMIT, PLAN_FAST_GDD_ACCEPTANCE_NODE_CRITERION, - PLAN_FAST_GDD_ACCEPTANCE_NODE_EVIDENCE, PLAN_FAST_GDD_ACCEPTANCE_NODE_ID, - PLAN_SUBMIT_GDD_INPUT_SCHEMA_VERSION, PLAN_SUBMIT_GDD_TOOL, + agent_runtime_native_executable_tools, AgentRuntimePlanUpdate, AgentRuntimeToolAction, + AgentRuntimeToolPlan, AGENT_RUNTIME_BACKGROUND_TOOL_ACTION_LIMIT, + AGENT_RUNTIME_CANVAS_ASSET_KINDS, AGENT_RUNTIME_PLAN_STEP_LIMIT, }; -use crate::GAME_CREATOR_PROJECT_PLANNING_AGENT_ID; #[cfg(test)] use crate::GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID; pub(crate) const AGENT_RUNTIME_UPDATE_PLAN_FUNCTION_NAME: &str = "update_agent_plan"; pub(crate) const AGENT_RUNTIME_RESPOND_FUNCTION_NAME: &str = "respond_to_user"; -pub(crate) const PLAN_SUBMIT_GDD_FUNCTION_NAME: &str = "runtime_tool_plan_submit_gdd"; const AGENT_RUNTIME_NATIVE_TOOL_PREFIX: &str = "runtime_tool_"; #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -223,9 +218,6 @@ struct NativeResponseArguments { } pub(crate) fn native_runtime_function_name(tool: &str) -> Option { - if tool.trim() == PLAN_SUBMIT_GDD_TOOL { - return Some(PLAN_SUBMIT_GDD_FUNCTION_NAME.to_string()); - } agent_runtime_native_capability_registry() .ok()? .get(tool) @@ -279,12 +271,6 @@ pub(crate) fn build_agent_runtime_native_function_tools() -> Result Result, String> { @@ -294,11 +280,7 @@ pub(crate) fn build_agent_runtime_native_function_tools_for_agent( AGENT_RUNTIME_RESPOND_FUNCTION_NAME.to_string(), ]); - let planning_agent = agent_id.trim() == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID; for definition in agent_runtime_native_capability_registry()?.iter() { - if planning_agent && !matches!(definition.dispatch().as_str(), "file.read" | "file.list") { - continue; - } let name = definition.function_name().to_string(); if !names.insert(name.clone()) { return Err(format!("Runtime 原生函数名重复:{name}")); @@ -313,125 +295,10 @@ pub(crate) fn build_agent_runtime_native_function_tools_for_agent( ); } - if planning_agent { - if !names.insert(PLAN_SUBMIT_GDD_FUNCTION_NAME.to_string()) { - return Err(format!( - "Runtime 原生函数名重复:{PLAN_SUBMIT_GDD_FUNCTION_NAME}" - )); - } - functions.push(plan_submit_gdd_function_tool()); - return Ok(functions); - } Ok(functions) } -/// Narrow the plan root Supervisor's advertised function catalog down to -/// `agent_runtime_plan_root_supervisor_tools()` plus the two protocol controls. -/// -/// This is an intersection, not an assertion: the caller may already have -/// narrowed the catalog further for a protocol-repair turn (for example -/// `restrict_agent_runtime_supervisor_collaboration_repair_tools`), and this -/// pass must never widen it back. -/// -/// An empty result means the repair-branch allowlist and the plan-root -/// allowlist are disjoint, which would send a request with no callable tool at -/// all; that is a configuration error, so fail closed instead. -pub(crate) fn retain_plan_root_supervisor_native_tools( - functions: &mut Vec, - stage: PlanRootSupervisorStage, -) -> Result<(), String> { - // plan 根不带 `update_agent_plan`。它的四步流程(冻结→委派→取证→审批)是 - // Runtime 早就知道的固定形状,模型维护一份结构化计划不产生任何信息,却提供了 - // 一个「看起来像动作、实际什么都不推进」的合法输出:实测一次跑通的 run 里 14 - // 轮有 5 轮是纯 `plan_update.explanation_only`,全部被 blocked,每轮烧一次 - // Provider 调用。既有的空转自愈本来就会在连续空转后把这个函数摘掉 - // (`provider_request_builders` 里的 idle repair),这里只是把"出事后补救" - // 提前成"从头就不给"。 - // - // 移除它不会卡住收束:`structured_plan_completion_blocker` 第一行就是 - // `agent_runtime_has_structured_plan`(`plan_revision > 0`),从不调用就恒为 - // 假,那道门不参与;plan 根的收束由 `runtime.plan_gdd` 的审批门管。前端 - // 步骤条为空时不渲染,plan 根的进度改由 current_action/waiting_on/next_step 呈现。 - let mut allowed = BTreeSet::from([AGENT_RUNTIME_RESPOND_FUNCTION_NAME.to_string()]); - for tool in agent_runtime_plan_root_supervisor_tools_for_stage(stage) { - allowed.insert( - native_runtime_function_name(tool) - .ok_or_else(|| format!("无法生成 plan 根 Supervisor 工具函数名:{tool}"))?, - ); - } - functions.retain(|function| allowed.contains(&function.name)); - if functions.is_empty() { - return Err("plan 根 Supervisor 工具目录收窄后为空".to_string()); - } - Ok(()) -} - -/// Narrow only the request-scoped Goal Contract schema used by the plan root. -/// The capability registry itself must remain dynamic: autonomous game-build and ordinary -/// Supervisor runs still author their own acceptance graph. -pub(crate) fn restrict_plan_root_goal_contract_schema( - functions: &mut [LlmFunctionTool], -) -> Result<(), String> { - let goal_contract_function = native_runtime_function_name("agent.goal_contract") - .ok_or_else(|| "无法生成 Goal Contract 工具函数名".to_string())?; - let Some(function) = functions - .iter_mut() - .find(|function| function.name == goal_contract_function) - else { - return Err("根 plan 请求缺少 agent.goal_contract 工具".to_string()); - }; - function.parameters = action_function_parameters(json!({ - "type": "object", - "required": ["outcome", "nonNegotiables", "preferences", "forbiddenAssumptions", "openQuestions", "acceptanceNodes"], - "additionalProperties": false, - "properties": { - "outcome": { "type": "string", "minLength": 1, "maxLength": 4000 }, - "nonNegotiables": string_array_schema(16), - "preferences": { "type": "array", "maxItems": 0, "items": { "type": "string" } }, - "forbiddenAssumptions": string_array_schema(16), - "openQuestions": string_array_schema(16), - "acceptanceNodes": { - "type": "array", "minItems": 1, "maxItems": 1, - "items": { - "type": "object", - "required": ["criterionId", "criterion", "required", "requiredEvidence", "dependsOn"], - "additionalProperties": false, - "properties": { - "criterionId": { "type": "string", "enum": [PLAN_FAST_GDD_ACCEPTANCE_NODE_ID] }, - "criterion": { "type": "string", "enum": [PLAN_FAST_GDD_ACCEPTANCE_NODE_CRITERION] }, - "required": { "type": "boolean", "enum": [true] }, - "requiredEvidence": { - "type": "array", "minItems": 1, "maxItems": 1, - "items": { "type": "string", "enum": [PLAN_FAST_GDD_ACCEPTANCE_NODE_EVIDENCE] } - }, - "dependsOn": { "type": "array", "maxItems": 0, "items": { "type": "string" } } - } - } - } - } - })); - Ok(()) -} - -pub(crate) fn agent_runtime_native_tool_allowed_for_agent(agent_id: &str, tool: &str) -> bool { - if agent_id.trim() == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID { - // update_agent_plan/respond_to_user are protocol controls and are - // validated outside the action capability registry. - return matches!( - tool.trim(), - "file.read" - | "file.list" - | PLAN_SUBMIT_GDD_TOOL - | AGENT_RUNTIME_UPDATE_PLAN_FUNCTION_NAME - | AGENT_RUNTIME_RESPOND_FUNCTION_NAME - ); - } - // This capability is planning-only. Do not let the global registry - // lookup (or a stale ordinary Agent snapshot) turn it into an executable - // action for Supervisor or a specialist. - if tool.trim() == PLAN_SUBMIT_GDD_TOOL { - return false; - } +pub(crate) fn agent_runtime_native_tool_allowed_for_agent(tool: &str) -> bool { agent_runtime_native_capability_registry() .ok() .and_then(|registry| registry.get(tool.trim())) @@ -439,37 +306,21 @@ pub(crate) fn agent_runtime_native_tool_allowed_for_agent(agent_id: &str, tool: } fn validate_native_tool_identity( - agent_id: &str, runtime_tool: Option<&str>, ) -> Result<(), AgentRuntimeToolPlanProtocolError> { if let Some(tool) = runtime_tool { - if !agent_runtime_native_tool_allowed_for_agent(agent_id, tool) { + if !agent_runtime_native_tool_allowed_for_agent(tool) { return Err(protocol_error( AgentRuntimeToolPlanProtocolErrorKind::UnknownFunction, - format!( - "Agent 原生工具协议错误:Agent {} 不允许调用 {}", - agent_id.trim(), - tool - ), + format!("Agent 原生工具协议错误:未注册的原生工具 {tool}"), )); } } Ok(()) } -/// 不带身份的解析入口,**只允许测试使用**(理由同 -/// `build_agent_runtime_native_function_tools`:哨兵会跳过按身份的原始工具 -/// identity 复核)。 -#[cfg(test)] pub(crate) fn parse_agent_runtime_native_tool_calls( calls: &[LlmToolCall], -) -> Result { - parse_agent_runtime_native_tool_calls_for_agent("__all_agents__", calls) -} - -pub(crate) fn parse_agent_runtime_native_tool_calls_for_agent( - agent_id: &str, - calls: &[LlmToolCall], ) -> Result { if calls.is_empty() { return Err(protocol_error( @@ -481,7 +332,6 @@ pub(crate) fn parse_agent_runtime_native_tool_calls_for_agent( let mut plan_update = None; let mut response = None; let mut actions = Vec::new(); - let mut submit_gdd_action_count = 0usize; let mut call_ids = Vec::with_capacity(calls.len()); let mut function_names = Vec::with_capacity(calls.len()); @@ -527,7 +377,7 @@ pub(crate) fn parse_agent_runtime_native_tool_calls_for_agent( } let runtime_tool = runtime_tool_for_native_function(&call.name); - validate_native_tool_identity(agent_id, runtime_tool.as_deref())?; + validate_native_tool_identity(runtime_tool.as_deref())?; if runtime_tool.is_none() { return Err(protocol_error( AgentRuntimeToolPlanProtocolErrorKind::UnknownFunction, @@ -552,9 +402,6 @@ pub(crate) fn parse_agent_runtime_native_tool_calls_for_agent( input = normalize_native_project_patchset_input(input)?; } let tool = runtime_tool.expect("原生函数 binding 已在参数解析前验证"); - if tool == PLAN_SUBMIT_GDD_TOOL { - submit_gdd_action_count = submit_gdd_action_count.saturating_add(1); - } let action = AgentRuntimeToolAction { tool, reason: Some(arguments.reason), @@ -577,14 +424,6 @@ pub(crate) fn parse_agent_runtime_native_tool_calls_for_agent( "Agent 原生工具协议错误:最终回复不能与动作工具同时提交", )); } - if submit_gdd_action_count > 0 - && (submit_gdd_action_count != 1 || actions.len() != 1 || response.is_some()) - { - return Err(protocol_error( - AgentRuntimeToolPlanProtocolErrorKind::BatchConstraint, - "Agent 原生工具协议错误:plan.submit_gdd 必须是唯一 action,且不能与 respond_to_user 同响应(可与 update_agent_plan 同响应)", - )); - } if response .as_deref() .is_some_and(|value| value.trim().is_empty()) @@ -963,9 +802,6 @@ fn validate_native_delegate_string_list( } fn runtime_tool_for_native_function(name: &str) -> Option { - if name == PLAN_SUBMIT_GDD_FUNCTION_NAME { - return Some(PLAN_SUBMIT_GDD_TOOL.to_string()); - } agent_runtime_native_capability_registry() .ok()? .get_by_function_name(name) @@ -997,15 +833,6 @@ fn response_function_tool() -> LlmFunctionTool { .with_strict(true) } -fn plan_submit_gdd_function_tool() -> LlmFunctionTool { - LlmFunctionTool::new( - PLAN_SUBMIT_GDD_FUNCTION_NAME, - "提交当前立项策划 Session 的 Fast GDD。只能提交设计字段;Runtime 会注入项目、版本、时间、平台事实和指纹,并以 create-only durable GDD 作为提交点。该动作必须是本轮唯一 action,可与 update_agent_plan 同响应,但不能与 respond_to_user 或其它动作混合。", - action_function_parameters(plan_submit_gdd_input_schema()), - ) - .with_strict(true) -} - fn plan_update_schema() -> Value { json!({ "type": "object", @@ -1034,196 +861,6 @@ fn plan_update_schema() -> Value { }) } -fn bounded_plan_string_schema(max_length: usize) -> Value { - json!({ - "type": "string", - "minLength": 1, - "maxLength": max_length - }) -} - -fn nullable_plan_string_schema(max_length: usize) -> Value { - json!({ - "type": ["string", "null"], - "minLength": 1, - "maxLength": max_length - }) -} - -fn plan_string_array_schema(min_items: usize, max_items: usize, item_max_length: usize) -> Value { - json!({ - "type": "array", - "minItems": min_items, - "maxItems": max_items, - "items": bounded_plan_string_schema(item_max_length) - }) -} - -/// Strict provider-facing shape for `plan-submit-gdd-input.v1`. -/// -/// Runtime-injected identity, platform facts, version and fingerprint fields -/// deliberately do not appear here. The durable handler performs the -/// structural, identity and CAS checks after parsing this wire shape. -fn plan_submit_gdd_input_schema() -> Value { - let decision_state = json!({ - "type": "string", - "enum": ["confirmed", "default_pending", "prototype_pending"] - }); - let answer_source = json!({ - "type": "string", - "enum": ["user_freeform", "user_option", "user_revision", "default"] - }); - let pillar = json!({ - "type": "object", - "required": ["name", "playerFeel", "mechanism", "decisionState"], - "additionalProperties": false, - "properties": { - "name": bounded_plan_string_schema(40), - "playerFeel": bounded_plan_string_schema(240), - "mechanism": bounded_plan_string_schema(240), - "decisionState": decision_state.clone() - } - }); - let mvp_system = json!({ - "type": "object", - "required": ["system", "minimalFunction", "whyRequired", "verifyMethod", "decisionState"], - "additionalProperties": false, - "properties": { - "system": bounded_plan_string_schema(40), - "minimalFunction": bounded_plan_string_schema(240), - "whyRequired": bounded_plan_string_schema(240), - "verifyMethod": bounded_plan_string_schema(240), - "decisionState": decision_state.clone() - } - }); - let decisions = json!({ - "type": "object", - "required": ["id", "topic", "state", "answerSource", "round", "answerSummary"], - "additionalProperties": false, - "properties": { - "id": { - "type": "string", - "minLength": 1, - "maxLength": 32, - "pattern": "^[a-z][a-z0-9-]{0,31}$" - }, - "topic": bounded_plan_string_schema(80), - "state": decision_state.clone(), - "answerSource": answer_source.clone(), - "round": { "type": "integer", "minimum": 0, "maximum": 3 }, - "answerSummary": bounded_plan_string_schema(400) - } - }); - let prototype_item = json!({ - "type": "object", - "required": ["id", "question", "microPrototype", "observation", "passCriterion"], - "additionalProperties": false, - "properties": { - "id": { - "type": "string", - "minLength": 1, - "maxLength": 32, - "pattern": "^[a-z][a-z0-9-]{0,31}$" - }, - "question": bounded_plan_string_schema(400), - "microPrototype": bounded_plan_string_schema(400), - "observation": bounded_plan_string_schema(400), - "passCriterion": bounded_plan_string_schema(400) - } - }); - json!({ - "type": "object", - "required": ["schemaVersion", "game", "decisions", "prototypeValidationItems"], - "additionalProperties": false, - "properties": { - "schemaVersion": { - "type": "string", - "enum": [PLAN_SUBMIT_GDD_INPUT_SCHEMA_VERSION] - }, - "game": { - "type": "object", - "required": ["title", "genre", "artStyle", "oneLiner", "pillars", "coreLoop", "targetUsers", "mvpSystems", "outOfScope", "creatorTips"], - "additionalProperties": false, - "properties": { - "title": bounded_plan_string_schema(80), - "genre": { - "type": "object", - "required": ["primary", "fusion"], - "additionalProperties": false, - "properties": { - "primary": bounded_plan_string_schema(40), - "fusion": nullable_plan_string_schema(40) - } - }, - "artStyle": { - "type": "object", - "required": ["visualType", "keywords", "moodAndColor", "mvpArtBoundary"], - "additionalProperties": false, - "properties": { - "visualType": bounded_plan_string_schema(80), - "keywords": plan_string_array_schema(3, 5, 32), - "moodAndColor": bounded_plan_string_schema(400), - "mvpArtBoundary": bounded_plan_string_schema(400) - } - }, - "oneLiner": { - "type": "string", - "minLength": 45, - "maxLength": 90 - }, - "pillars": { - "type": "array", - "minItems": 2, - "maxItems": 4, - "items": pillar - }, - "coreLoop": plan_string_array_schema(4, 8, 120), - "targetUsers": { - "type": "object", - "required": ["coreUsers", "preferences", "sessionLength", "referenceGames"], - "additionalProperties": false, - "properties": { - "coreUsers": bounded_plan_string_schema(240), - "preferences": bounded_plan_string_schema(240), - "sessionLength": bounded_plan_string_schema(240), - "referenceGames": plan_string_array_schema(0, 5, 80) - } - }, - "mvpSystems": { - "type": "array", - "minItems": 3, - "maxItems": 6, - "items": mvp_system - }, - "outOfScope": plan_string_array_schema(1, 12, 80), - "creatorTips": { - "type": "object", - "required": ["doFirst", "deferForNow", "howToVerify", "expandWhen"], - "additionalProperties": false, - "properties": { - "doFirst": bounded_plan_string_schema(400), - "deferForNow": bounded_plan_string_schema(400), - "howToVerify": bounded_plan_string_schema(400), - "expandWhen": bounded_plan_string_schema(400) - } - } - } - }, - "decisions": { - "type": "array", - "minItems": 1, - "maxItems": 32, - "items": decisions - }, - "prototypeValidationItems": { - "type": "array", - "maxItems": 3, - "items": prototype_item - } - } - }) -} - fn rebase_action_input_schema_refs_in_scope(value: &mut Value, has_local_resource_id: bool) { let Value::Object(object) = value else { return; @@ -1344,7 +981,6 @@ fn string_array_schema(max_items: usize) -> Value { fn runtime_tool_description(tool: &str) -> &'static str { match tool { - PLAN_SUBMIT_GDD_TOOL => "提交当前立项策划 Session 的 Fast GDD;只能提交 plan-submit-gdd-input.v1 设计字段,Runtime 注入身份、版本、时间、平台事实和指纹。", "user.input_request" => "向用户提出一至三个结构化问题,并暂停当前 run 等待回答。", "memory.read" => "读取当前 Agent、Session、项目或黑板记忆。", "memory.write" => "写入当前 Agent 自己或项目范围的稳定记忆。", @@ -1386,7 +1022,7 @@ fn runtime_tool_description(tool: &str) -> &'static str { "preview.validate" => "用真实浏览器验证桌面和移动预览并保存证据。", "image.inspect" => "让视觉模型检查一至两张项目内图片。", "canvas.asset_generate" => { - "通过已配置的 External Editor API 生成图片并登记到画布、素材库和项目 assets;art-director 先生成 icon-spec 规范图,ui-prototype 与透明 art-spritesheet 都固定复用该规范图;只有唯一返工委派可显式替换已登记正式图片。" + "通过已配置的 External Editor API 按项目需求生成图片或图集并登记到画布、素材库和项目 assets;可使用已登记资源作为参考,也可通过 sliceCount 指定图集切片数量。" } "ui.workflow.run" => { "先用 discover 从受控 game/ui-pages.json 或页面声明标记自动发现全部功能页面,再把已登记 ui-prototype 与每个页面的设计图桥接成独立 UI JSON State;可同时载入已登记图片、图标和项目字体,执行 Provider 结构识别、多树合并与分批组件绑定、回读阶段,并且只有所有页面已绑定且已应用到 game/ 后才允许 finalize。项目根目录由 Runtime 注入,模型不得传入宿主路径。" @@ -1412,7 +1048,6 @@ fn runtime_tool_description(tool: &str) -> &'static str { fn runtime_tool_input_schema(tool: &str) -> Value { match tool { - PLAN_SUBMIT_GDD_TOOL => plan_submit_gdd_input_schema(), "user.input_request" => json!({ "type": "object", "required": ["questions"], @@ -2114,68 +1749,6 @@ mod tests { } } - /// plan 根 Supervisor 只广告 7 个原生工具 + 2 个协议控制。全量注册表里其余 - /// 约 36 个在这条链路上全部会被执行层拒绝,广告出去只会诱导 Supervisor 自己 - /// 下场写文件、跑命令、查任务图。断言写成精确集合而不是「不包含某几个」, - /// 这样将来往注册表里加工具不会静默漏进 plan 根。 - #[test] - fn plan_root_supervisor_tool_catalog_is_an_exact_allowlist() { - let functions = build_agent_runtime_native_function_tools_for_agent( - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - ) - .expect("build supervisor catalog"); - let before = functions.len(); - // 逐个阶段都必须是精确集合:委派前那两档各自只剩一个推进动作,正是活锁 - // 的解药——模型在那一档连 agent.run_status 都调不出来。 - for stage in [ - PlanRootSupervisorStage::GoalContract, - PlanRootSupervisorStage::Delegate, - PlanRootSupervisorStage::AwaitingAcceptanceEvidence, - PlanRootSupervisorStage::Delegated, - ] { - let mut staged = functions.clone(); - retain_plan_root_supervisor_native_tools(&mut staged, stage) - .expect("retain plan root tools"); - // plan 根只保留 respond_to_user 这一个协议函数;update_agent_plan 从 - // 头就不广告。 - let mut expected = vec![AGENT_RUNTIME_RESPOND_FUNCTION_NAME.to_string()]; - for tool in agent_runtime_plan_root_supervisor_tools_for_stage(stage) { - expected.push(native_runtime_function_name(tool).expect("plan root function name")); - } - expected.sort(); - let mut actual = staged - .iter() - .map(|function| function.name.clone()) - .collect::>(); - actual.sort(); - assert_eq!(actual, expected, "阶段 {stage:?} 的工具目录不是精确集合"); - assert!( - before > staged.len(), - "收窄必须真的裁掉工具,否则这条测试是空跑" - ); - } - // 广告层不得再出现 user.input_request(澄清卡由 Runtime 在 parent-wake 屏障处 - // 直接按信封原文构造)与 update_agent_plan(plan 根不维护结构化计划)。 - let user_input_function = - native_runtime_function_name("user.input_request").expect("user input function name"); - for stage in [ - PlanRootSupervisorStage::GoalContract, - PlanRootSupervisorStage::Delegate, - PlanRootSupervisorStage::AwaitingAcceptanceEvidence, - PlanRootSupervisorStage::Delegated, - ] { - let mut staged = functions.clone(); - retain_plan_root_supervisor_native_tools(&mut staged, stage) - .expect("retain plan root tools"); - assert!(!staged - .iter() - .any(|function| function.name == user_input_function)); - assert!(!staged - .iter() - .any(|function| function.name == AGENT_RUNTIME_UPDATE_PLAN_FUNCTION_NAME)); - } - } - #[test] fn native_goal_contract_and_acceptance_update_expose_dynamic_graph_contract() { let goal = runtime_tool_input_schema("agent.goal_contract"); @@ -2222,23 +1795,6 @@ mod tests { ["required"], json!(["agentId", "runId", "actionId"]) ); - - let goal_function = native_runtime_function_name("agent.goal_contract") - .expect("goal contract function name"); - let mut functions = vec![LlmFunctionTool::new( - goal_function.clone(), - "goal", - action_function_parameters(runtime_tool_input_schema("agent.goal_contract")), - )]; - restrict_plan_root_goal_contract_schema(&mut functions).expect("restrict plan schema"); - let fixed = &functions[0].parameters["properties"]["input"]; - assert_eq!(fixed["properties"]["acceptanceNodes"]["maxItems"], 1); - assert_eq!(fixed["properties"]["preferences"]["maxItems"], 0); - assert_eq!( - fixed["properties"]["acceptanceNodes"]["items"]["properties"]["criterionId"]["enum"], - json!([PLAN_FAST_GDD_ACCEPTANCE_NODE_ID]) - ); - assert_eq!(functions[0].name, goal_function); } #[test] @@ -2262,109 +1818,6 @@ mod tests { } } - #[test] - fn planning_submit_gdd_schema_is_strict_and_runtime_identity_free() { - let schema = runtime_tool_input_schema(PLAN_SUBMIT_GDD_TOOL); - assert_eq!( - schema["properties"]["schemaVersion"]["enum"], - json!([PLAN_SUBMIT_GDD_INPUT_SCHEMA_VERSION]) - ); - assert!(schema["properties"]["game"]["properties"] - .get("platformFacts") - .is_none()); - assert!(schema["properties"]["game"]["properties"] - .get("projectId") - .is_none()); - let wrapped = action_function_parameters(schema); - let mut issues = Vec::new(); - collect_openai_strict_schema_issues(&wrapped, "plan.submit_gdd", &mut issues); - assert!(issues.is_empty(), "{}", issues.join("\n")); - } - - #[test] - fn planning_submit_gdd_is_not_in_global_catalog() { - let functions = build_agent_runtime_native_function_tools().expect("global native catalog"); - assert!(!functions - .iter() - .any(|function| function.name == PLAN_SUBMIT_GDD_FUNCTION_NAME)); - } - - fn submit_call(id: &str) -> LlmToolCall { - LlmToolCall { - id: id.to_string(), - name: PLAN_SUBMIT_GDD_FUNCTION_NAME.to_string(), - arguments: json!({ - "reason": "提交完整 Fast GDD", - "input": {} - }) - .to_string(), - } - } - - #[test] - fn planning_submit_gdd_native_batch_rejects_mixed_actions_and_response() { - let mixed = parse_agent_runtime_native_tool_calls_for_agent( - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - &[ - submit_call("submit-mixed"), - LlmToolCall { - id: "read-mixed".to_string(), - name: native_runtime_function_name("file.read").expect("file.read name"), - arguments: json!({ - "reason": "读取", - "input": {"path": "README.md", "startLine": 1, "maxLines": 1} - }) - .to_string(), - }, - ], - ) - .expect_err("submit must not mix with another action"); - assert_eq!( - mixed.kind(), - AgentRuntimeToolPlanProtocolErrorKind::BatchConstraint - ); - - let with_response = parse_agent_runtime_native_tool_calls_for_agent( - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - &[ - submit_call("submit-response"), - LlmToolCall { - id: "response".to_string(), - name: AGENT_RUNTIME_RESPOND_FUNCTION_NAME.to_string(), - arguments: json!({"response": "已提交"}).to_string(), - }, - ], - ) - .expect_err("submit must not mix with final response"); - assert_eq!( - with_response.kind(), - AgentRuntimeToolPlanProtocolErrorKind::BatchConstraint - ); - } - - #[test] - fn planning_submit_gdd_native_batch_allows_plan_update_control() { - let parsed = parse_agent_runtime_native_tool_calls_for_agent( - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - &[ - submit_call("submit-plan-update"), - LlmToolCall { - id: "plan-update".to_string(), - name: AGENT_RUNTIME_UPDATE_PLAN_FUNCTION_NAME.to_string(), - arguments: json!({ - "explanation": "提交 GDD", - "steps": [{"step": "提交", "status": "in_progress"}] - }) - .to_string(), - }, - ], - ) - .expect("submit may share a response with plan control"); - assert_eq!(parsed.plan.actions.len(), 1); - assert_eq!(parsed.plan.actions[0].tool, PLAN_SUBMIT_GDD_TOOL); - assert!(parsed.plan.plan_update.is_some()); - } - #[test] fn strict_native_function_schemas_match_openai_subset() { let functions = 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 9c9b1d506..fadbc7a67 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/cli.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/cli.rs @@ -113,15 +113,6 @@ pub(crate) enum CliCommand { AgentResume { project_path: PathBuf, }, - PlanGddStatus { - project_path: PathBuf, - }, - PlanGddDecide { - project_path: PathBuf, - action: String, - response_id: Option, - read_comment_from_stdin: bool, - }, RunnerStatus, RunnerShutdownIfIdle, AgentRun { @@ -157,7 +148,6 @@ impl CliCommand { | Self::AgentGoalResume { .. } | Self::AgentGoalClear { .. } | Self::AgentResume { .. } - | Self::PlanGddDecide { .. } | Self::RunnerShutdownIfIdle ) } @@ -165,10 +155,7 @@ impl CliCommand { pub(crate) fn is_read_only_status(&self) -> bool { matches!( self, - Self::AgentRuntimeStatus { .. } - | Self::AgentGoalStatus { .. } - | Self::PlanGddStatus { .. } - | Self::RunnerStatus + Self::AgentRuntimeStatus { .. } | Self::AgentGoalStatus { .. } | Self::RunnerStatus ) } @@ -216,8 +203,6 @@ impl CliCommand { | Self::AgentRetry { project_path, .. } | Self::AgentSteer { project_path, .. } | Self::AgentResume { project_path } - | Self::PlanGddStatus { project_path } - | Self::PlanGddDecide { project_path, .. } | Self::PreviewServe { project_path } | Self::AgentRun { project_path, .. } => Some((project_path, false)), Self::LlmStatus | Self::RunnerStatus | Self::RunnerShutdownIfIdle => None, @@ -441,56 +426,6 @@ fn read_cli_agent_steer_instruction(reader: &mut impl Read) -> Result Result { - const MAX_STDIN_BYTES: u64 = 8 * 1024; - let mut bytes = Vec::new(); - reader - .take(MAX_STDIN_BYTES + 1) - .read_to_end(&mut bytes) - .map_err(|error| format!("从 stdin 读取 GDD 审批意见失败:{error}"))?; - if bytes.len() as u64 > MAX_STDIN_BYTES { - return Err(format!( - "GDD 审批意见 stdin 超过 {MAX_STDIN_BYTES} 字节上限" - )); - } - let comment = - String::from_utf8(bytes).map_err(|_| "GDD 审批意见 stdin 必须是 UTF-8 文本".to_string())?; - let comment = comment.trim(); - if comment.is_empty() { - return Err("GDD 审批意见 stdin 不能为空".to_string()); - } - // 长度上界交给 normalize_plan_gdd_approval_comment:审批意见的 1~1000 scalar - // 约束是写入侧权威,CLI 再抄一份就会有两个会漂移的判据。 - Ok(comment.to_string()) -} - -fn take_cli_named_flag_value( - args: &mut Vec, - flag: &str, - usage: &str, -) -> Result, String> { - let positions = args - .iter() - .enumerate() - .filter_map(|(index, arg)| (arg == flag).then_some(index)) - .collect::>(); - if positions.len() > 1 { - return Err(usage.to_string()); - } - let Some(index) = positions.first().copied() else { - return Ok(None); - }; - let value = args - .get(index + 1) - .map(String::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .ok_or_else(|| usage.to_string())? - .to_string(); - args.drain(index..=index + 1); - Ok(Some(value)) -} - fn read_cli_agent_goal_payload(reader: &mut impl Read) -> Result { const MAX_STDIN_BYTES: u64 = 64 * 1024; let mut bytes = Vec::new(); @@ -720,53 +655,6 @@ pub(crate) fn parse_cli_command(args: &[String]) -> Result, S project_path: PathBuf::from(&args[1]), })); } - if args.first().map(String::as_str) == Some("--plan-gdd-status") { - const USAGE: &str = "用法:--plan-gdd-status <本地项目绝对路径>"; - if args.len() != 2 || args[1].trim().is_empty() { - return Err(USAGE.to_string()); - } - return Ok(Some(CliCommand::PlanGddStatus { - project_path: PathBuf::from(&args[1]), - })); - } - if args.first().map(String::as_str) == Some("--plan-gdd-decide") { - const USAGE: &str = "用法:--plan-gdd-decide <本地项目绝对路径> [--response-id ] [--stdin]"; - let mut rest = args[1..].to_vec(); - let read_comment_from_stdin = - match rest.iter().filter(|arg| arg.as_str() == "--stdin").count() { - 0 => false, - 1 => { - let index = rest - .iter() - .position(|arg| arg == "--stdin") - .ok_or_else(|| USAGE.to_string())?; - rest.remove(index); - true - } - _ => return Err(USAGE.to_string()), - }; - let response_id = take_cli_named_flag_value(&mut rest, "--response-id", USAGE)?; - if rest.len() != 2 || rest.iter().any(|value| value.trim().is_empty()) { - return Err(USAGE.to_string()); - } - let action = rest[1].trim().to_string(); - if !matches!(action.as_str(), "approve" | "revise" | "reject") { - return Err(USAGE.to_string()); - } - // revise/reject 的 comment 是写入侧硬约束,缺了必然在落盘前失败。在解析期就拒绝, - // 错误才指得回命令行本身,而不是变成一条读起来像后端故障的存储错误。 - if action != "approve" && !read_comment_from_stdin { - return Err( - "revise/reject 审批必须通过 --stdin 提供 1~1000 scalar 的修改意见".to_string(), - ); - } - return Ok(Some(CliCommand::PlanGddDecide { - project_path: PathBuf::from(&rest[0]), - action, - response_id, - read_comment_from_stdin, - })); - } if args.first().map(String::as_str) == Some("--agent-enqueue") { let mut rest = args[1..].to_vec(); let initialize = if let Some(index) = rest.iter().position(|arg| arg == "--init") { @@ -828,7 +716,7 @@ pub(crate) fn parse_cli_command(args: &[String]) -> Result, S })); } if args.first().map(String::as_str) == Some("--swarm-chat") { - const USAGE: &str = "用法:--swarm-chat [--init] [--autonomous-game-build] [--plan] <本地项目绝对路径> [parentAgentId]"; + const USAGE: &str = "用法:--swarm-chat [--init] [--autonomous-game-build] <本地项目绝对路径> [parentAgentId]"; let mut rest = args[1..].to_vec(); let initialize = if let Some(index) = rest.iter().position(|arg| arg == "--init") { rest.remove(index); @@ -852,34 +740,9 @@ pub(crate) fn parse_cli_command(args: &[String]) -> Result, S } _ => return Err(USAGE.to_string()), }; - let plan = match rest.iter().filter(|arg| arg.as_str() == "--plan").count() { - 0 => false, - 1 => { - let index = rest - .iter() - .position(|arg| arg == "--plan") - .expect("counted plan flag"); - rest.remove(index); - true - } - _ => return Err(USAGE.to_string()), - }; if !(1..=2).contains(&rest.len()) || rest.iter().any(|value| value.trim().is_empty()) { return Err(USAGE.to_string()); } - // 立项策划根 Run 只跑 standard 档(后端 reject_supervisor_plan_autonomous_profile - // 同样否决),且必须挂在总控上;这里先拦一道,免得建完项目才失败。 - if plan - && (autonomous_game_build - || rest.get(1).is_some_and(|parent| { - parent.trim() != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID - })) - { - return Err( - "--plan 仅允许 project-supervisor 的 standard 档,不能搭配 --autonomous-game-build" - .to_string(), - ); - } return Ok(Some(CliCommand::SwarmChat { project_path: PathBuf::from(&rest[0]), parent_agent_id: rest @@ -892,11 +755,7 @@ pub(crate) fn parse_cli_command(args: &[String]) -> Result, S } else { AGENT_RUNTIME_RUN_PROFILE_STANDARD.to_string() }, - supervisor_source: if plan { - AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE - } else { - AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE - }, + supervisor_source: AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, })); } if args.first().map(String::as_str) == Some("--agent-task") { @@ -1515,63 +1374,6 @@ pub(crate) fn run_cli_command(command: CliCommand) -> Result<(), String> { ); Ok(()) } - CliCommand::PlanGddStatus { project_path } => { - let project_path = canonicalize_cli_path(&project_path, "本地项目路径", false)?; - let state = - hydrate_game_creator_plan_gdd_state_for_path(&project_path.display().to_string())?; - println!("plan.gdd.status"); - println!( - "planGddStateJson={}", - serialize_agent_runtime_cli_payload(&state)? - ); - Ok(()) - } - CliCommand::PlanGddDecide { - project_path, - action, - response_id, - read_comment_from_stdin, - } => { - let project_path = canonicalize_cli_path(&project_path, "本地项目路径", false)?; - require_external_agent_runner_for_cli_runtime_write(&project_path)?; - let comment = if read_comment_from_stdin { - Some(read_cli_plan_gdd_approval_comment( - &mut std::io::stdin().lock(), - )?) - } else { - None - }; - let project_path_value = project_path.display().to_string(); - // 审批卡的 identity 只有投影这一个权威来源。CLI 不接受手工传 gddId/fingerprint: - // 那样每个调用方都要自己拼一遍身份,拼错的后果是 PLAN_STALE_APPROVAL, - // 而不是一条能读懂的用法错误。 - let state = hydrate_game_creator_plan_gdd_state_for_path(&project_path_value)?; - let pending = state - .pending_approval - .ok_or_else(|| "当前没有待决定的 Fast GDD 审批".to_string())?; - // 每次调用换新 responseId 是安全方向:重复键的最坏后果是 replayed 降级成 - // already-decided(两者都成功),而复用键改 action 会撞「同 responseId 的 - // 审批意图不一致」硬错误。要复放同一次决定时才显式传 --response-id。 - let response_id = response_id - .unwrap_or_else(|| format!("gdd-response-{}", uuid::Uuid::new_v4().hyphenated())); - let result = decide_game_creator_plan_gdd( - project_path_value, - pending.gdd_ref.gdd_id.clone(), - pending.gdd_ref.version, - pending.gdd_ref.fingerprint.clone(), - pending.pending_action_id.clone(), - pending.approval_request_id.clone(), - response_id, - action, - comment, - )?; - println!("plan.gdd.decided"); - println!( - "planGddDecisionJson={}", - serialize_agent_runtime_cli_payload(&result)? - ); - Ok(()) - } CliCommand::RunnerStatus => { println!( "runnerJson={}", @@ -2079,116 +1881,6 @@ mod tests { ); } - #[test] - fn parses_plan_gdd_headless_approval_entries() { - assert_eq!( - parse_cli_command(&[ - "--plan-gdd-status".to_string(), - "/tmp/game-project".to_string(), - ]) - .expect("parse plan gdd status") - .expect("plan gdd status command"), - CliCommand::PlanGddStatus { - project_path: PathBuf::from("/tmp/game-project"), - } - ); - - assert_eq!( - parse_cli_command(&[ - "--plan-gdd-decide".to_string(), - "/tmp/game-project".to_string(), - " approve ".to_string(), - ]) - .expect("parse plan gdd approve") - .expect("plan gdd approve command"), - CliCommand::PlanGddDecide { - project_path: PathBuf::from("/tmp/game-project"), - action: "approve".to_string(), - response_id: None, - read_comment_from_stdin: false, - } - ); - - assert_eq!( - parse_cli_command(&[ - "--plan-gdd-decide".to_string(), - "/tmp/game-project".to_string(), - "revise".to_string(), - "--response-id".to_string(), - " gdd-response-1b4e28ba-2fa1-11d2-883f-0016d3cca427 ".to_string(), - "--stdin".to_string(), - ]) - .expect("parse plan gdd revise") - .expect("plan gdd revise command"), - CliCommand::PlanGddDecide { - project_path: PathBuf::from("/tmp/game-project"), - action: "revise".to_string(), - response_id: Some("gdd-response-1b4e28ba-2fa1-11d2-883f-0016d3cca427".to_string()), - read_comment_from_stdin: true, - } - ); - - // revise/reject 没有 --stdin 时必须在解析期就失败,否则错误会伪装成写入侧故障。 - for action in ["revise", "reject"] { - assert!(parse_cli_command(&[ - "--plan-gdd-decide".to_string(), - "/tmp/game-project".to_string(), - action.to_string(), - ]) - .is_err()); - } - for args in [ - vec!["--plan-gdd-decide"], - vec!["--plan-gdd-decide", "/tmp/game-project"], - vec!["--plan-gdd-decide", "/tmp/game-project", "confirm"], - vec!["--plan-gdd-decide", "/tmp/game-project", "approve", "extra"], - vec![ - "--plan-gdd-decide", - "/tmp/game-project", - "approve", - "--response-id", - ], - vec!["--plan-gdd-status", "/tmp/game-project", "extra"], - ] { - assert!( - parse_cli_command(&args.into_iter().map(str::to_string).collect::>()) - .is_err() - ); - } - } - - #[test] - fn plan_gdd_decision_requires_started_external_runner_but_status_does_not() { - let decide = CliCommand::PlanGddDecide { - project_path: PathBuf::from("/tmp/game-project"), - action: "approve".to_string(), - response_id: None, - read_comment_from_stdin: false, - }; - assert!(decide.requires_external_agent_runner()); - assert!(decide.requires_started_external_agent_runner()); - assert!(!decide.is_read_only_status()); - - let status = CliCommand::PlanGddStatus { - project_path: PathBuf::from("/tmp/game-project"), - }; - assert!(!status.requires_external_agent_runner()); - assert!(status.is_read_only_status()); - } - - #[test] - fn reads_plan_gdd_approval_comment_from_stdin() { - assert_eq!( - read_cli_plan_gdd_approval_comment(&mut Cursor::new(" 把核心循环写具体 \n")) - .expect("read approval comment"), - "把核心循环写具体" - ); - assert!(read_cli_plan_gdd_approval_comment(&mut Cursor::new(" \n")).is_err()); - assert!( - read_cli_plan_gdd_approval_comment(&mut Cursor::new("a".repeat(9 * 1024))).is_err() - ); - } - #[test] fn reads_strict_structured_goal_payload_from_stdin() { let mut stdin = Cursor::new( @@ -2351,50 +2043,6 @@ mod tests { ); } - #[test] - fn swarm_chat_plan_flag_selects_plan_source_on_standard_profile() { - let project_path = std::env::current_dir().expect("current directory"); - let command = parse_cli_command(&[ - "--swarm-chat".to_string(), - "--init".to_string(), - "--plan".to_string(), - project_path.display().to_string(), - ]) - .expect("parse plan swarm chat") - .expect("plan swarm chat command"); - assert_eq!( - command, - CliCommand::SwarmChat { - project_path, - parent_agent_id: GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(), - initialize: true, - run_profile: AGENT_RUNTIME_RUN_PROFILE_STANDARD.to_string(), - supervisor_source: AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE, - } - ); - assert!(parse_cli_command(&[ - "--swarm-chat".to_string(), - "--plan".to_string(), - "--autonomous-game-build".to_string(), - "/tmp/game-project".to_string(), - ]) - .is_err()); - assert!(parse_cli_command(&[ - "--swarm-chat".to_string(), - "--plan".to_string(), - "/tmp/game-project".to_string(), - "code-prototype".to_string(), - ]) - .is_err()); - assert!(parse_cli_command(&[ - "--swarm-chat".to_string(), - "--plan".to_string(), - "--plan".to_string(), - "/tmp/game-project".to_string(), - ]) - .is_err()); - } - #[test] fn swarm_chat_rejects_missing_or_extra_arguments() { assert!(parse_cli_command(&["--swarm-chat".to_string()]).is_err()); diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index 2f564e863..351fcf45c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -1,4 +1,5 @@ use super::*; +use crate::agent::read_direct_project_chat_history_at; use crate::ui_editor::resource::font::FontAsset; use sha2::{Digest, Sha256}; use std::collections::{BTreeMap, HashSet}; @@ -304,24 +305,6 @@ pub(crate) async fn import_ui_editor_assets( } } -#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -struct HydratePlanGddStateInput { - project_path: String, -} - -fn parse_hydrate_plan_gdd_state_input( - request: &tauri::ipc::Request<'_>, -) -> Result { - match request.body() { - tauri::ipc::InvokeBody::Json(value) => serde_json::from_value(value.clone()) - .map_err(|error| format!("hydrate_game_creator_plan_gdd_state 输入无效:{error}")), - tauri::ipc::InvokeBody::Raw(_) => { - Err("hydrate_game_creator_plan_gdd_state 只接受 JSON 输入 {projectPath}".to_string()) - } - } -} - pub(crate) fn closest_existing_project_picker_directory(path: &Path) -> Option { let mut candidate = if path.exists() && path.is_dir() { Some(path) @@ -1390,12 +1373,6 @@ pub(crate) fn start_game_creator_supervisor_runtime_task( if !agent_runtime_supervisor_source_is_trusted(source) { return Err("Project Supervisor 提交 source 不受信任".to_string()); } - reject_supervisor_plan_autonomous_profile(source, run_profile)?; - if agent_runtime_supervisor_source_is_plan(source) - && !crate::config::game_creator_planning_capability_enabled()? - { - return Err("PLAN_CAPABILITY_DISABLED: 立项策划能力当前已停用".to_string()); - } start_game_creator_supervisor_background_task_for_session_at( root, session_id.as_deref(), @@ -1571,7 +1548,6 @@ pub(crate) async fn steer_game_creator_agent_runtime_task( .map(str::trim) .filter(|value| !value.is_empty()) { - reject_supervisor_plan_root_steer(source)?; if !agent_runtime_supervisor_source_is_trusted(source) { return Err("Project Supervisor steer source 不受信任".to_string()); } @@ -1808,79 +1784,6 @@ pub(crate) fn answer_game_creator_agent_runtime_user_input( ) } -#[tauri::command] -pub(crate) fn decide_game_creator_plan_gdd( - project_path: String, - gdd_id: String, - version: u32, - fingerprint: String, - pending_action_id: String, - approval_request_id: String, - response_id: String, - action: String, - comment: Option, -) -> Result { - let root = validated_local_project_directory_path(project_path.trim())?; - enforce_project_permission_policy(&root, "conversation.read")?; - enforce_project_permission_policy(&root, "conversation.write")?; - enforce_project_permission_policy(&root, "agent.run_status")?; - enforce_project_permission_policy(&root, "agent.resume")?; - let mut result = match decide_plan_gdd_at( - &root, - &DecidePlanGddInputV1 { - gdd_id, - version, - fingerprint, - pending_action_id, - approval_request_id, - response_id, - action, - comment, - }, - ) { - Ok(result) => result, - Err(error) => { - return Err(error.to_string()); - } - }; - if !result.recovery_pending { - if let Err(error) = wake_pending_game_creator_agent_background_tasks_at(&root) { - let detail = error.to_string(); - crate::error_report::report_diagnostic_error( - "agent", - &detail, - None, - Some("wake_pending_game_creator_agent_background_tasks"), - None, - ); - // The receipt is already the user-decision linearization point; - // surface a recoverable projection state instead of turning a - // durable approval into a false command failure. - result.recovery_pending = true; - } - } - Ok(result) -} - -#[tauri::command] -pub(crate) fn hydrate_game_creator_plan_gdd_state( - request: tauri::ipc::Request<'_>, -) -> Result { - let input = parse_hydrate_plan_gdd_state_input(&request)?; - hydrate_game_creator_plan_gdd_state_for_path(&input.project_path) -} - -/// Transport-independent projection read shared by the Tauri command and the -/// headless CLI entry. Both must cross the same permission gate, otherwise the -/// CLI would become a way to read a project the policy denies. -pub(crate) fn hydrate_game_creator_plan_gdd_state_for_path( - project_path: &str, -) -> Result { - let root = validated_local_project_directory_path(project_path.trim())?; - enforce_project_permission_policy(&root, "conversation.read")?; - hydrate_game_creator_plan_gdd_state_at(&root).map_err(|error| error.to_string()) -} - #[tauri::command] pub(crate) fn read_game_creator_agent_runtime( project_path: String, @@ -2006,13 +1909,16 @@ pub(crate) fn write_game_creator_app_config( let _guard = GAME_CREATOR_CONFIG_WRITE_LOCK .lock() .map_err(|_| "配置写入锁不可用")?; - config.selected_model_id = load_game_creator_app_config()?.selected_model_id; - persist_game_creator_app_config(config) + let (current, overlays) = load_game_creator_app_config_for_write()?; + config.selected_model_id = current.selected_model_id; + config.selected_model_is_default = current.selected_model_is_default; + persist_game_creator_app_config(config, overlays, false) } #[tauri::command] pub(crate) fn select_game_creator_model( model_id: String, + is_default: bool, ) -> Result { let _guard = GAME_CREATOR_CONFIG_WRITE_LOCK .lock() @@ -2025,19 +1931,67 @@ pub(crate) fn select_game_creator_model( { return Err("模型标识无效".into()); } - let mut config = load_game_creator_app_config()?; + let (mut config, overlays) = load_game_creator_app_config_for_write()?; config.selected_model_id = model_id; - persist_game_creator_app_config(config) + config.selected_model_is_default = is_default; + persist_game_creator_app_config(config, overlays, true) } fn persist_game_creator_app_config( config: GameCreatorAppConfig, + overlays: Vec<(PathBuf, serde_json::Value)>, + model_only: bool, ) -> Result { let config = normalize_game_creator_app_config(config)?; let path = writable_game_creator_config_path()?; let content = serialize_game_creator_app_config_for_renderer_write(&config)?; - write_game_creator_config_atomically(&path, &format!("{content}\n"))?; - game_creator_app_config_view(load_game_creator_app_config()?) + let mut writes = vec![(path, format!("{content}\n"))]; + let saved: serde_json::Value = serde_json::from_str(&content) + .map_err(|error| format!("解析已序列化客户端配置失败:{error}"))?; + for (overlay_path, mut overlay) in overlays { + let previous = overlay.clone(); + if let Some(fields) = overlay.as_object_mut() { + for (key, value) in fields.iter_mut() { + if matches!(key.as_str(), "selectedModelId" | "selectedModelIsDefault") + == model_only + { + if let Some(saved_value) = saved.get(key) { + // 仅同步已有覆盖项;其它字段继续保留原有覆盖语义。 + if key == "agentLlm" { + *value = saved_value.clone(); + } else { + update_existing_config_overlay_fields(value, saved_value); + } + } + } + } + } + if overlay != previous { + let content = serde_json::to_string_pretty(&overlay) + .map_err(|error| format!("序列化客户端覆盖配置失败:{error}"))?; + writes.push((overlay_path, format!("{content}\n"))); + } + } + crate::config::write_game_creator_config_batch(&writes)?; + // `config` is already normalized and is exactly what was persisted. + // Avoid reloading it here: a reload repeats the Windows private-path and + // ACL checks and made saving the settings panel appear to hang. + game_creator_app_config_view(config) +} + +fn update_existing_config_overlay_fields( + overlay: &mut serde_json::Value, + saved: &serde_json::Value, +) { + if let (Some(fields), Some(saved_fields)) = (overlay.as_object_mut(), saved.as_object()) { + for (key, value) in fields { + if let Some(saved_value) = saved_fields.get(key) { + update_existing_config_overlay_fields(value, saved_value); + } + } + } else if !overlay.is_null() { + *overlay = saved.clone(); + } } #[tauri::command] @@ -4748,6 +4702,15 @@ pub(crate) fn read_local_conversation( read_local_conversation_for_session_at(root, agent_id.as_deref(), session_id.as_deref()) } +#[tauri::command] +pub(crate) fn read_direct_project_conversation( + project_path: String, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "conversation.read")?; + read_direct_project_chat_history_at(root) +} + #[tauri::command] pub(crate) fn append_local_conversation_message( project_path: String, @@ -4776,6 +4739,20 @@ pub(crate) fn append_local_conversation_message( } } +#[tauri::command] +pub(crate) fn append_direct_project_conversation_message( + project_path: String, + message: LocalConversationMessage, + message_id: Option, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "conversation.write")?; + let item = + direct_project_local_message_item(&message.role, &message.content, message_id.as_deref())?; + append_direct_project_history_item_at(root, &item)?; + read_direct_project_chat_history_at(root) +} + #[tauri::command] pub(crate) fn build_local_project_index( project_path: String, diff --git a/apps/ai-game-creator-shell/src-tauri/src/config.rs b/apps/ai-game-creator-shell/src-tauri/src/config.rs index 8fbfff37b..8c02b4e6c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/config.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/config.rs @@ -1417,24 +1417,15 @@ pub(crate) fn harden_new_game_creator_private_path( path.display() )); } - // A newly-created object normally inherits the creator's security - // descriptor. AGC-owned roots may request the one-shot UAC repair; - // a user-selected path is still hardened strictly after creation so - // a race cannot turn an attacker-owned object into a credential file. - if game_creator_private_path_allows_auto_elevation(path) { - secure_windows_game_creator_path_for_current_user_with_auto_elevation( - path, - is_directory, - true, - )?; - } else { - secure_windows_game_creator_path_for_current_user_with_owner_policy( - path, - is_directory, - true, - true, - )?; - } + // This invocation created the object, so its owner is the current + // user. Tighten the inherited descriptor in-process; UAC repair is + // reserved for existing, externally-owned objects. + secure_windows_game_creator_path_for_current_user_with_owner_policy( + path, + is_directory, + true, + true, + )?; } #[cfg(unix)] { @@ -2588,6 +2579,34 @@ pub(crate) fn consume_windows_acl_repair_authorization( Ok(()) } +#[cfg(windows)] +fn windows_command_line_quote(value: &str) -> String { + format!("\"{}\"", value.replace('"', "\\\"")) +} + +#[cfg(windows)] +fn windows_acl_repair_argument_list( + path: &str, + target_user_sid: &str, + nonce: &str, + scope: WindowsAclRepairScope, +) -> String { + [ + "--repair-private-acl", + path, + "--target-user-sid", + target_user_sid, + "--authorization", + nonce, + "--scope", + scope.wire_name(), + ] + .into_iter() + .map(windows_command_line_quote) + .collect::>() + .join(" ") +} + /// Starts a one-shot elevated copy of the current executable. The elevated /// process performs only the allow-listed ACL repair command and exits with a /// truthful status; UAC cancellation is never treated as success. @@ -2612,12 +2631,15 @@ fn attempt_elevated_windows_acl_repair( let repair_path = windows_acl_repair_target(path, scope); let nonce = create_windows_acl_repair_authorization(&repair_path, target_user_sid, scope)?; let escaped_executable = executable.to_string_lossy().replace('\'', "''"); - let escaped_path = repair_path.to_string_lossy().replace('\'', "''"); - let escaped_target_user_sid = target_user_sid.replace('\'', "''"); - let escaped_nonce = nonce.replace('\'', "''"); + let arguments = windows_acl_repair_argument_list( + &repair_path.to_string_lossy(), + target_user_sid, + &nonce, + scope, + ) + .replace('\'', "''"); let script = format!( - "$ErrorActionPreference = 'Stop'; try {{ $p = Start-Process -Verb RunAs -Wait -PassThru -FilePath '{escaped_executable}' -ArgumentList @('--repair-private-acl','{escaped_path}','--target-user-sid','{escaped_target_user_sid}','--authorization','{escaped_nonce}','--scope','{}'); if ($null -eq $p) {{ exit 1223 }}; exit $p.ExitCode }} catch {{ exit 1223 }}", - scope.wire_name() + "$ErrorActionPreference = 'Stop'; try {{ $p = Start-Process -Verb RunAs -Wait -PassThru -FilePath '{escaped_executable}' -ArgumentList '{arguments}'; if ($null -eq $p) {{ exit 1223 }}; exit $p.ExitCode }} catch {{ exit 1223 }}" ); use std::os::windows::process::CommandExt; let status = std::process::Command::new("powershell.exe") @@ -3272,9 +3294,10 @@ pub(crate) fn configure_game_creator_runtime_config_dir( Ok(()) } -/// Validates a persisted AGC config file without following links. Existing -/// regular files are tightened through the same Windows owner/DACL gate used -/// by credential files before any read is allowed. +/// Validates a persisted AGC config file without following links. +/// +/// Reading configuration must remain read-only. ACL hardening is performed +/// when the file is created or replaced, not on every settings-panel read. fn validate_game_creator_config_file_entry(path: &Path) -> Result { #[cfg(windows)] validate_game_creator_private_path_ancestors_with_auto_elevation(path, "客户端配置文件")?; @@ -3293,8 +3316,6 @@ fn validate_game_creator_config_file_entry(path: &Path) -> Result if metadata.file_type().is_symlink() || !metadata.is_file() { return Err("客户端配置文件必须是普通文件,不能是链接或其他对象".to_string()); } - #[cfg(windows)] - secure_windows_game_creator_path_for_current_user_with_auto_elevation(path, false, true)?; Ok(true) } @@ -3352,6 +3373,31 @@ pub(crate) fn load_game_creator_app_config() -> Result Result<(GameCreatorAppConfig, Vec<(PathBuf, serde_json::Value)>), String> { + let writable_path = writable_game_creator_config_path()?; + let mut config = GameCreatorAppConfig::default(); + let mut overlays = Vec::new(); + let mut after_writable = false; + for path in game_creator_config_paths() { + if path == writable_path { + after_writable = true; + } + if let Some(content) = read_game_creator_config_file(&path)? { + merge_game_creator_config_content(&mut config, &path, &content)?; + if after_writable && path != writable_path { + let value = serde_json::from_str(&content) + .map_err(|error| format!("解析客户端覆盖配置失败:{error}"))?; + overlays.push((path, value)); + } + } + } + if game_creator_official_llm_route_locked() { + lock_game_creator_app_config_to_official_route(&mut config); + } + Ok((config, overlays)) +} + pub(crate) fn scrub_locked_game_creator_config_file(config: &mut GameCreatorAppConfigFile) -> bool { let mut changed = config.agent_mode.as_deref() != Some(GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER) @@ -3588,6 +3634,44 @@ pub(crate) fn merge_game_creator_config_file( config: &mut GameCreatorAppConfig, path: &Path, ) -> Result<(), String> { + if let Some(content) = read_game_creator_config_file(path)? { + merge_game_creator_config_content(config, path, &content)?; + } + Ok(()) +} + +/// Only files inside the managed runtime config directory (real credentials) +/// may use the private-read channel: it hardens the owner/DACL on every read. +/// Repository-adjacent fallback templates and overlay files are shared inputs +/// that can be git-tracked; privatizing one on read silently locks the +/// worktree template to whichever account happened to run the dev CLI, so +/// they must go through the non-mutating snapshot channel instead. +pub(crate) fn game_creator_config_path_is_runtime_managed(path: &Path) -> bool { + game_creator_runtime_config_dir().is_some_and(|directory| path.starts_with(directory)) +} + +fn read_game_creator_snapshot_file_to_string( + path: &Path, + label: &str, + max_bytes: u64, +) -> Result { + let (mut file, metadata) = open_project_snapshot_regular_file(path, label)?; + if metadata.len() > max_bytes { + return Err(format!("{label}过大,已拒绝读取:{}", path.display())); + } + let mut content = String::with_capacity(metadata.len() as usize); + file.read_to_string(&mut content) + .map_err(|error| format!("读取{label}失败:{}: {error}", path.display()))?; + let final_metadata = file + .metadata() + .map_err(|error| format!("复核{label}失败:{}: {error}", path.display()))?; + if final_metadata.len() != metadata.len() { + return Err(format!("{label}读取期间文件发生漂移:{}", path.display())); + } + Ok(content) +} + +pub(crate) fn read_game_creator_config_file(path: &Path) -> Result, String> { let backup_path = game_creator_config_backup_path(path); let path_exists = validate_game_creator_config_file_entry(path)?; let read_path = if path_exists { @@ -3595,11 +3679,23 @@ pub(crate) fn merge_game_creator_config_file( } else if validate_game_creator_config_file_entry(&backup_path)? { backup_path.as_path() } else { - return Ok(()); + return Ok(None); }; - let content = read_game_creator_private_file_to_string(read_path, "客户端配置", 256 * 1024)?; - let file_config = serde_json::from_str::(&content) - .map_err(|error| format!("解析客户端配置失败:{}: {error}", read_path.display()))?; + let content = if game_creator_config_path_is_runtime_managed(read_path) { + read_game_creator_private_file_to_string(read_path, "客户端配置", 256 * 1024)? + } else { + read_game_creator_snapshot_file_to_string(read_path, "客户端配置", 256 * 1024)? + }; + Ok(Some(content)) +} + +fn merge_game_creator_config_content( + config: &mut GameCreatorAppConfig, + path: &Path, + content: &str, +) -> Result<(), String> { + let file_config = serde_json::from_str::(content) + .map_err(|error| format!("解析客户端配置失败:{}: {error}", path.display()))?; if let Some(agent_mode) = file_config.agent_mode { config.agent_mode = agent_mode; } @@ -3615,21 +3711,15 @@ pub(crate) fn merge_game_creator_config_file( if let Some(editor_api) = file_config.editor_api { merge_game_creator_editor_api_config(&mut config.editor_api, editor_api); } - if let Some(planning) = file_config.planning { - if let Some(capability_enabled) = planning.capability_enabled { - config.planning.capability_enabled = capability_enabled; - } - } if let Some(selected_model_id) = file_config.selected_model_id { config.selected_model_id = selected_model_id; } + if let Some(selected_model_is_default) = file_config.selected_model_is_default { + config.selected_model_is_default = selected_model_is_default; + } Ok(()) } -pub(crate) fn game_creator_planning_capability_enabled() -> Result { - Ok(load_game_creator_app_config()?.planning.capability_enabled) -} - fn game_creator_config_backup_path(path: &Path) -> PathBuf { path.with_file_name(format!( ".{}.previous", @@ -3639,6 +3729,48 @@ fn game_creator_config_backup_path(path: &Path) -> PathBuf { )) } +pub(crate) fn write_game_creator_config_batch(writes: &[(PathBuf, String)]) -> Result<(), String> { + if writes.len() == 1 { + return write_game_creator_config_atomically(&writes[0].0, &writes[0].1); + } + let originals = writes + .iter() + .map(|(path, _)| read_game_creator_config_file(path)) + .collect::, _>>()?; + for (index, (path, content)) in writes.iter().enumerate() { + if let Err(mut error) = write_game_creator_config_atomically(path, content) { + // 写入可能在替换后的权限检查失败,因此失败目标也需要核对并恢复。 + for rollback_index in (0..=index).rev() { + let path = &writes[rollback_index].0; + let original = &originals[rollback_index]; + if read_game_creator_config_file(path).ok().as_ref() == Some(original) { + continue; + } + let restored = match original { + Some(content) => write_game_creator_config_atomically(path, content), + None => fs::remove_file(path) + .or_else(|error| { + if error.kind() == std::io::ErrorKind::NotFound { + Ok(()) + } else { + Err(error) + } + }) + .map_err(|error| error.to_string()), + }; + if let Err(restore_error) = restored { + error.push_str(&format!( + ";恢复配置失败:{}: {restore_error}", + path.display() + )); + } + } + return Err(error); + } + } + Ok(()) +} + pub(crate) fn write_game_creator_config_atomically( path: &Path, content: &str, @@ -4321,6 +4453,24 @@ mod private_path_elevation_policy_tests { assert!(windows_acl_error_may_need_elevation(detail)); } + #[cfg(windows)] + #[test] + fn acl_repair_argument_list_keeps_space_containing_path_quoted() { + let path = r"C:\Users\lingh\Documents\Genarrative GameAgent\gameagent-f84a5353\.agent\project.lock"; + let arguments = windows_acl_repair_argument_list( + path, + "S-1-5-21-1-2-3-1001", + "0123456789abcdef0123456789abcdef", + WindowsAclRepairScope::Managed, + ); + assert_eq!( + arguments, + format!( + "\"--repair-private-acl\" \"{path}\" \"--target-user-sid\" \"S-1-5-21-1-2-3-1001\" \"--authorization\" \"0123456789abcdef0123456789abcdef\" \"--scope\" \"managed\"" + ) + ); + } + #[cfg(windows)] #[test] fn custom_runtime_config_path_uses_explicit_user_selected_scope() { diff --git a/apps/ai-game-creator-shell/src-tauri/src/delegation.rs b/apps/ai-game-creator-shell/src-tauri/src/delegation.rs index f49737d6b..80c807e96 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/delegation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/delegation.rs @@ -2635,77 +2635,6 @@ mod tests { assert!(error.contains("JSON 无效"), "unexpected error: {error}"); } - /// 现场那条 501 字符的真实问询:一个问题、三个选项,没有任何一项接近 schema 上限。 - #[test] - fn user_input_relay_channel_admits_one_ordinary_chinese_question() { - let response = format!( - "{STATIC_DELEGATE_USER_INPUT_PREFIX}{}", - serde_json::json!({ - "questions": [{ - "id": "plan_round_1", - "header": "第1轮·当前要决定:影子能力在首个可玩闭环中的核心作用", - "question": "它会同时决定关卡布局、操作手感与原型优先级,也决定第一批谜题按什么规则组合;现在确认可以避免把三种玩法都做浅,也避免原型做到一半再推翻核心规则。", - "options": [ - { - "label": "A · 影子化为可独立移动的暗影分身", - "description": "机器人定位光源后,影子沿地面或墙面移动,可压住机关、挡住感应光或穿过窄缝;规则直观、谜题组合清晰,代价是要处理影子可达范围与回收。", - }, - { - "label": "B · 影子作为可拉伸的暗影桥梁", - "description": "玩家调整光源与站位,让影子延展成短暂平台或连接导电点;更偏空间构图解谜,代价是碰撞、长度和落脚可读性需要更严格。", - }, - { - "label": "需要原型验证", - "description": "先用三十到九十分钟做一个最小原型,把两种方案的操作手感、关卡搭建成本和可读性各跑一遍,再决定首个可玩闭环采用哪一种,避免一开始就压死方向。", - }, - ], - }] - }) - ); - // 修复前这个上限是写死的 500,而这条问询没有任何一个字段接近 schema 上限。 - const PREVIOUS_HARDCODED_CAP: usize = 500; - assert!( - response.chars().count() > PREVIOUS_HARDCODED_CAP, - "现场问询 {} 字符,应当超过旧的写死上限", - response.chars().count() - ); - parse_static_delegate_user_input_request(Some(&response)) - .expect("an ordinary one-question clarification must pass the relay channel"); - } - - /// 现场原样抓来的两条退化信封:模型把信封写完整之后,在同一个字符串里继续吐了 - /// 一段垃圾。函数调用的 arguments JSON 一次成型(repairAttempt 全为 0),信封本体 - /// 一个字节都没坏,坏的只是尾巴——这不是截断,是长嵌套 JSON 尾部的解码退化。 - /// - /// 27 个历史 run 的 68 条信封里这类占 2 条。修复前它们和真正写坏的信封同样被 - /// serde 的 trailing characters 判死,整条委派链停在 needs-repair。 - #[test] - fn a_degenerated_tail_after_a_complete_envelope_does_not_kill_the_envelope() { - let cases = [ - // abtest-tide2A-2:尾巴是「 马会」。 - concat!( - "AGC_NEEDS_USER_INPUT_V1\n", - r#"{"questions":[{"id":"replay_motivation","header":"第1轮·当前要决定:固定五岛海图的重复游玩动力","question":"现在确认它,才能锁定首个可玩闭环之外的得分与重开目标。","options":[{"label":"A · 推荐:固定布局冲榜","description":"每局地图与信件配置固定,玩家通过更优路线、潮汐 timing 和装卸顺序刷新送达数与总分;优点是实现最小、可读性强,代价是内容变化较少。"},{"label":"B · 轮换信件组合","description":"地图固定但每局从预设信件组合中轮换收件岛与期限;优点是重玩变化更明显,代价是需要额外平衡组合并降低可预测性。"},{"label":"需要原型验证","description":"用30~90分钟做可点击五岛地图与两种信件配置原型,让3名偏好轻策略的玩家各玩3局,观察是否主动重开及路线是否有差异;通过标准是多数玩家愿意重开且能说出改进路线。"}]}]} 马会"#, - ), - // verify-farm-4:尾巴是古吉拉特语字母、西里尔字母和中文垃圾词的混合物。 - concat!( - "AGC_NEEDS_USER_INPUT_V1\n", - r#"{"questions":[{"id":"replay_progression","header":"第2轮·当前要决定:自由经营农场的长期目标","question":"这会决定玩家为何持续规划、赚钱与重玩,并控制 MVP 的范围。","options":[{"label":"A · 推荐:里程碑升级+成就","description":"以累计资金解锁少量新地块或设施,同时完成可选成就;优点是目标清晰又保留自由安排,代价是需要同时做基础升级与成就追踪。"},{"label":"B · 专注农场扩建","description":"只用经营收益逐步解锁地块与设施,成就仅作展示;优点是系统更聚焦、反馈直接,代价是挑战层次和重玩目标较少。"},{"label":"需要原型验证","description":"制作 30–90 分钟微型原型,让 2–3 名目标玩家试玩两种目标结构,观察他们是否主动设定计划、理解进展并愿意继续经营;多数玩家能完成一次扩建且愿意追求第二个目标即通过。"}]}]}સwerhu рҭ. 北京赛车? тру. [ ]"#, - ), - ]; - for response in cases { - let (questions, sha256) = parse_static_delegate_user_input_request(Some(response)) - .expect("a complete envelope followed by garbage must still parse"); - let questions = questions.expect("questions present"); - assert_eq!(questions.len(), 1); - assert_eq!(questions[0].options.len(), 3); - assert_eq!(sha256.as_deref().map(str::len), Some(64)); - } - } - - /// 同一批里另外 5 条是另一种形态:结尾少了最外面的 `]}`,括号差 2 层。 - /// 那是模型真的没把结构写完,不能靠补括号替它猜一个没表达出来的形状—— - /// 这条必须继续失败,交给 run 内重取。 #[test] fn an_envelope_missing_its_closing_brackets_still_fails() { // verify-farm-2 现场原文,结尾是 `}]}` 而非 `}]}]}`。 diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index 90ffd9333..de5353beb 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -510,11 +510,6 @@ struct AgentRuntimeState { next_step: String, #[serde(default)] loop_iteration: u32, - /// Consecutive strict Fast-GDD submit rejections for this exact planning - /// child run. It is Runtime-owned durable state so a restart cannot turn - /// an invalid-provider-output loop back into an unbounded retry. - #[serde(default)] - plan_submit_gdd_rejection_count: u32, /// Consecutive rounds where this run produced no action and no structured /// plan step advance. Runtime-owned durable state so a runner restart /// cannot launder an explanation-only planning loop back into an unbounded @@ -1019,16 +1014,10 @@ struct GameCreatorAppConfigFile { llm: Option, agent_llm: Option>, editor_api: Option, - planning: Option, #[serde(default, skip_serializing_if = "Option::is_none")] selected_model_id: Option, -} - -#[derive(Clone, Debug, Default, Deserialize, Serialize)] -#[serde(rename_all = "camelCase")] -struct GameCreatorPlanningConfigFile { - #[serde(skip_serializing_if = "Option::is_none")] - capability_enabled: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + selected_model_is_default: Option, } #[derive(Clone, Debug, Default, Deserialize, Serialize)] @@ -1081,16 +1070,9 @@ struct GameCreatorAppConfig { agent_llm: BTreeMap, editor_api: GameCreatorEditorApiConfig, #[serde(default)] - planning: GameCreatorPlanningConfig, - #[serde(default)] selected_model_id: String, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(rename_all = "camelCase")] -struct GameCreatorPlanningConfig { - #[serde(default = "default_game_creator_planning_capability_enabled")] - capability_enabled: bool, + #[serde(default)] + selected_model_is_default: bool, } #[derive(Clone, Debug, Deserialize, Serialize)] @@ -1489,14 +1471,12 @@ const DEFAULT_GAME_INDEX_HTML: &str = r#" Genarrative Game Draft - -
还没有生成游戏。回到聊天输入创意并确认生成后,这里会写入可试玩原型。
+
"#; +const DEFAULT_GAME_STYLE_CSS: &str = "body { margin: 0; display: grid; min-height: 100vh; place-items: center; background: #101827; color: #d9e7ff; font: 16px system-ui, sans-serif; }\nmain { width: min(720px, calc(100vw - 32px)); }\n"; +const DEFAULT_GAME_SCRIPT_JS: &str = "import Phaser from 'phaser';\nimport './style.css';\n\nclass PlaceholderScene extends Phaser.Scene {\n create() { this.add.text(24, 24, '还没有生成游戏。回到聊天输入创意并确认生成后,这里会写入可试玩原型。'); }\n}\n\nnew Phaser.Game({ type: Phaser.AUTO, width: 720, height: 420, parent: 'game', scene: PlaceholderScene });\n"; const DEFAULT_EDITOR_BASE_URL: &str = "http://127.0.0.1:3000"; const GAME_CREATOR_CONFIG_FILE_NAME: &str = "game-creator.config.json"; @@ -1534,10 +1514,6 @@ fn default_game_creator_llm_tool_output_token_limit() -> u64 { DEFAULT_GAME_CREATOR_LLM_TOOL_OUTPUT_TOKEN_LIMIT } -fn default_game_creator_planning_capability_enabled() -> bool { - true -} - const DEFAULT_CANVAS_SYNC_API_BASE_URL: &str = "https://dev.genarrative.world"; const DEFAULT_GAME_CREATOR_APP_CONFIG_JSON: &str = include_str!("../../game-creator.config.json"); const GAME_CREATOR_LLM_MAX_OUTPUT_TOKENS: u32 = 320000; @@ -1609,16 +1585,8 @@ impl Default for GameCreatorAppConfig { llm, agent_llm: BTreeMap::new(), editor_api: GameCreatorEditorApiConfig::default(), - planning: GameCreatorPlanningConfig::default(), selected_model_id: String::new(), - } - } -} - -impl Default for GameCreatorPlanningConfig { - fn default() -> Self { - Self { - capability_enabled: default_game_creator_planning_capability_enabled(), + selected_model_is_default: false, } } } @@ -2030,8 +1998,136 @@ pub(crate) fn sanitize_diagnostic_message(value: &str, private_root: Option<&Pat sanitized.chars().take(2_048).collect() } -fn show_startup_error_dialog(log_path: &Path) { - app_log!("Genarrative startup failed; see {}", log_path.display()); +/// 启动阶段的致命失败必须让用户看得见:release 双击启动时 stderr 不可见,只写日志 +/// 等于什么都没发生。Windows 用系统消息框,其它平台退化为 stderr。日志路径尚未 +/// 确定时仍然要提示,只是不给路径。 +#[cfg(windows)] +fn show_startup_error_dialog(log_path: Option<&Path>) { + use std::os::windows::ffi::OsStrExt; + use windows_sys::Win32::UI::WindowsAndMessaging::{ + MessageBoxW, MB_ICONERROR, MB_OK, MB_SETFOREGROUND, + }; + + if STARTUP_ERROR_DIALOG_SHOWN.swap(true, std::sync::atomic::Ordering::AcqRel) { + return; + } + let title = std::ffi::OsStr::new("Genarrative AI Game Creator") + .encode_wide() + .chain(Some(0)) + .collect::>(); + let message_text = match log_path { + Some(log_path) => format!( + "应用启动失败,请把下面的诊断日志发给开发人员:\n{}", + log_path.display() + ), + None => { + "应用启动失败,诊断日志路径尚未确定;请把这条提示和复现步骤发给开发人员。".to_string() + } + }; + let message = std::ffi::OsStr::new(&message_text) + .encode_wide() + .chain(Some(0)) + .collect::>(); + // SAFETY: both UTF-16 buffers are NUL-terminated and live for the duration of the call. + unsafe { + MessageBoxW( + std::ptr::null_mut(), + message.as_ptr(), + title.as_ptr(), + MB_OK | MB_ICONERROR | MB_SETFOREGROUND, + ); + } +} + +#[cfg(not(windows))] +fn show_startup_error_dialog(log_path: Option<&Path>) { + if STARTUP_ERROR_DIALOG_SHOWN.swap(true, std::sync::atomic::Ordering::AcqRel) { + return; + } + match log_path { + Some(log_path) => eprintln!( + "Genarrative AI Game Creator startup failed; see {}", + log_path.display() + ), + None => eprintln!( + "Genarrative AI Game Creator startup failed before the diagnostics log path was known" + ), + } +} + +/// 配置目录就绪前的启动日志路径:优先用已经生效的配置目录(例如 `--config-dir` +/// 已经设置好的目录),否则退到平台配置根。两者都不可用时返回 `None`,此时 +/// `StartupLogSlot::fail` 仍然必须给出用户可见提示。 +fn early_startup_log_path(identifier: &str) -> Option { + let configured_dir = game_creator_runtime_config_dir(); + resolve_early_startup_log_path(configured_dir.as_deref(), identifier) +} + +fn resolve_early_startup_log_path( + configured_dir: Option<&Path>, + identifier: &str, +) -> Option { + match configured_dir { + Some(directory) => Some(directory.join("diagnostics/startup.log")), + None => { + platform_config_root().map(|root| root.join(identifier).join("diagnostics/startup.log")) + } + } +} + +#[cfg(windows)] +fn platform_config_root() -> Option { + std::env::var_os("APPDATA").map(PathBuf::from) +} + +#[cfg(target_os = "macos")] +fn platform_config_root() -> Option { + std::env::var_os("HOME").map(|home| PathBuf::from(home).join("Library/Application Support")) +} + +#[cfg(not(any(windows, target_os = "macos")))] +fn platform_config_root() -> Option { + std::env::var_os("XDG_CONFIG_HOME") + .map(PathBuf::from) + .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".config"))) +} + +/// 启动诊断日志槽位。`configure_game_creator_runtime_config_dir` 之前只能退回按 +/// 标识符推导的 APPDATA 路径,成功后再切换到真实配置目录,保证早期失败也有落点。 +#[derive(Debug, Default)] +struct StartupLogSlot(Mutex>); + +impl StartupLogSlot { + fn new(path: Option) -> Self { + Self(Mutex::new(path)) + } + + fn set(&self, path: PathBuf) { + match self.0.lock() { + Ok(mut guard) => *guard = Some(path), + Err(poisoned) => *poisoned.into_inner() = Some(path), + } + } + + fn path(&self) -> Option { + match self.0.lock() { + Ok(guard) => guard.clone(), + Err(poisoned) => poisoned.into_inner().clone(), + } + } + + fn append(&self, line: &str) { + if let Some(path) = self.path() { + let _ = append_bounded_diagnostic_line(&path, line); + } + } + + /// 启动阶段的致命失败:先落盘,再给出用户可见提示。日志路径未知时仍然要 + /// 提示,否则早期失败依旧表现为“双击没反应”。 + fn fail(&self, line: &str) { + self.append(line); + show_startup_error_dialog(self.path().as_deref()); + } } #[derive(Clone, Debug)] @@ -2358,8 +2454,13 @@ fn main() { } let mut tauri_context = tauri::generate_context!(); - let startup_log: Option = None; - let setup_log = startup_log.clone(); + // 配置目录确定之前先推导启动日志路径:优先用已经生效的配置目录(例如 + // `--config-dir`),否则退到平台配置根,保证 + // `configure_game_creator_runtime_config_dir` 自身失败也有落点。 + let startup_log = Arc::new(StartupLogSlot::new(early_startup_log_path( + tauri_context.config().identifier.as_str(), + ))); + let setup_log = Arc::clone(&startup_log); let app = tauri::Builder::default() .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_dialog::init()) @@ -2370,37 +2471,27 @@ fn main() { .manage(ProjectResourcePreviewReadManager::default()) .setup(move |app| { error_report::initialize_notifications(app.handle()); - if let Some(path) = setup_log.as_deref() { - let _ = append_bounded_diagnostic_line(path, "startup.setup.begin"); - let _ = append_bounded_diagnostic_line(path, "startup.appdata.configure.begin"); - } + setup_log.append("startup.setup.begin"); + setup_log.append("startup.appdata.configure.begin"); configure_game_creator_runtime_config_dir(app.handle()).inspect_err(|error| { - if let Some(path) = setup_log.as_deref() { - let details = sanitize_diagnostic_message(&error.to_string(), path.parent()); - let _ = append_bounded_diagnostic_line( - path, - &format!("startup.appdata.configure.failed details={details}"), - ); - show_startup_error_dialog(path); - } + // 日志路径未知也必须记录并提示:不能因为拿不到路径就静默失败。 + let config_dir = game_creator_runtime_config_dir(); + let details = + sanitize_diagnostic_message(&error.to_string(), config_dir.as_deref()); + setup_log.fail(&format!( + "startup.appdata.configure.failed details={details}" + )); })?; - let startup_log = game_creator_runtime_config_dir() - .map(|directory| directory.join("diagnostics/startup.log")); - if let Some(path) = startup_log.as_deref() { - let _ = append_bounded_diagnostic_line(path, "startup.appdata.configure.complete"); + if let Some(directory) = game_creator_runtime_config_dir() { + setup_log.set(directory.join("diagnostics/startup.log")); } + setup_log.append("startup.appdata.configure.complete"); let config_dir = game_creator_runtime_config_dir().ok_or_else(|| { let error = std::io::Error::new( std::io::ErrorKind::NotFound, "客户端 AppData 配置目录未初始化", ); - if let Some(path) = setup_log.as_deref() { - let _ = append_bounded_diagnostic_line( - path, - "startup.appdata.resolve.failed details=config-dir-uninitialized", - ); - show_startup_error_dialog(path); - } + setup_log.fail("startup.appdata.resolve.failed details=config-dir-uninitialized"); error })?; load_platform_session_fixture_from_env(&config_dir).map_err(|error| { @@ -2409,20 +2500,13 @@ fn main() { format!("加载平台登录态测试 fixture 失败:{error}"), ) })?; - if let Some(path) = setup_log.as_deref() { - let _ = append_bounded_diagnostic_line(path, "startup.runner.configure.begin"); - } + setup_log.append("startup.runner.configure.begin"); configure_external_agent_runner(&config_dir) .inspect_err(|error| { - if let Some(path) = setup_log.as_deref() { - let details = - sanitize_diagnostic_message(error, Some(config_dir.as_path())); - let _ = append_bounded_diagnostic_line( - path, - &format!("startup.runner.configure.failed details={details}"), - ); - show_startup_error_dialog(path); - } + let details = sanitize_diagnostic_message(error, Some(config_dir.as_path())); + setup_log.fail(&format!( + "startup.runner.configure.failed details={details}" + )); }) .map_err(|error| { std::io::Error::new( @@ -2430,20 +2514,13 @@ fn main() { format!("配置 Agent Runner 失败:{error}"), ) })?; - if let Some(path) = setup_log.as_deref() { - let _ = append_bounded_diagnostic_line(path, "startup.runner.configure.complete"); - } + setup_log.append("startup.runner.configure.complete"); let gui_owner_lock = acquire_external_agent_runner_gui_owner_lock(&config_dir) .inspect_err(|error| { - if let Some(path) = setup_log.as_deref() { - let details = - sanitize_diagnostic_message(error, Some(config_dir.as_path())); - let _ = append_bounded_diagnostic_line( - path, - &format!("startup.runner.owner-lock.failed details={details}"), - ); - show_startup_error_dialog(path); - } + let details = sanitize_diagnostic_message(error, Some(config_dir.as_path())); + setup_log.fail(&format!( + "startup.runner.owner-lock.failed details={details}" + )); }) .map_err(|error| { std::io::Error::new( @@ -2453,23 +2530,16 @@ fn main() { })?; let gui_owner_epoch = gui_owner_lock.owner_epoch().to_string(); app.manage(gui_owner_lock); - if let Some(path) = setup_log.as_deref() { - let _ = append_bounded_diagnostic_line(path, "startup.runner.start.begin"); - } + setup_log.append("startup.runner.start.begin"); set_game_creator_agent_runtime_update_app_handle(app.handle().clone()); let manifest_event_sink = start_game_creator_manifest_invalidation_event_sink(app.handle().clone())?; attach_external_agent_runner_gui_owner(&manifest_event_sink, &gui_owner_epoch) .inspect_err(|error| { - if let Some(path) = setup_log.as_deref() { - let details = - sanitize_diagnostic_message(error, Some(config_dir.as_path())); - let _ = append_bounded_diagnostic_line( - path, - &format!("startup.runner.attach-owner.failed details={details}"), - ); - show_startup_error_dialog(path); - } + let details = sanitize_diagnostic_message(error, Some(config_dir.as_path())); + setup_log.fail(&format!( + "startup.runner.attach-owner.failed details={details}" + )); }) .map_err(|error| { std::io::Error::new( @@ -2477,15 +2547,13 @@ fn main() { format!("绑定 Agent Runner GUI owner 失败:{error}"), ) })?; - if let Some(path) = setup_log.as_deref() { - let _ = append_bounded_diagnostic_line(path, "startup.runner.start.complete"); - } - if let Some(path) = setup_log.as_deref() { - let _ = append_bounded_diagnostic_line(path, "startup.setup.complete"); - } + setup_log.append("startup.runner.start.complete"); + setup_log.append("startup.setup.complete"); Ok(()) }) .invoke_handler(tauri::generate_handler![ + start_game_creator_external_mcp, + stop_game_creator_external_mcp, create_automatic_local_game_project, init_local_game_project, import_local_godot_project, @@ -2510,6 +2578,10 @@ fn main() { chat_with_game_creator_role_agent, chat_with_game_creator_role_agent_stream, chat_with_game_creator_direct_codex, + start_planning_session_v2, + continue_planning_session_v2, + decide_planning_artifact_v2, + hydrate_planning_session_v2, start_game_creator_agent_runtime_task, start_game_creator_supervisor_runtime_task, compact_game_creator_agent_runtime_context, @@ -2526,8 +2598,6 @@ fn main() { confirm_game_creator_agent_runtime_task, reject_game_creator_agent_runtime_task, answer_game_creator_agent_runtime_user_input, - decide_game_creator_plan_gdd, - hydrate_game_creator_plan_gdd_state, read_game_creator_agent_runtime, read_game_creator_agent_runtimes, resume_game_creator_agent_runtime_tasks, @@ -2591,7 +2661,9 @@ fn main() { set_active_game_creator_agent_session, archive_game_creator_agent_session, read_local_conversation, + read_direct_project_conversation, append_local_conversation_message, + append_direct_project_conversation_message, build_local_project_index, create_local_project_checkpoint, export_local_project_package, @@ -2641,19 +2713,13 @@ fn main() { .build(tauri_context); let app = match app { Ok(app) => { - if let Some(path) = startup_log.as_deref() { - let _ = append_bounded_diagnostic_line(path, "startup.build.complete"); - } + startup_log.append("startup.build.complete"); app } Err(error) => { - if let Some(path) = startup_log.as_deref() { + if let Some(path) = startup_log.path() { let details = sanitize_diagnostic_message(&error.to_string(), path.parent()); - let _ = append_bounded_diagnostic_line( - path, - &format!("startup.build.failed details={details}"), - ); - show_startup_error_dialog(path); + startup_log.fail(&format!("startup.build.failed details={details}")); } app_log!("failed to build Genarrative AI Game Creator shell: {error}"); std::process::exit(1); @@ -2683,6 +2749,55 @@ mod diagnostic_log_tests { assert!(previous.contains(&"x".repeat(32))); } + #[test] + fn startup_log_slot_keeps_early_failures_after_the_real_config_dir_is_known() { + let directory = tempfile::tempdir().expect("create diagnostics directory"); + let path = directory.path().join("startup.log"); + let slot = StartupLogSlot::new(None); + + // 配置目录未知时不能凭空造出日志文件。 + slot.append("startup.setup.begin"); + assert!(!path.exists()); + + slot.set(path.clone()); + slot.append("startup.setup.begin"); + slot.append("startup.appdata.configure.complete"); + + let content = fs::read_to_string(&path).expect("read startup log"); + assert!(content.contains("startup.setup.begin")); + assert!(content.contains("startup.appdata.configure.complete")); + } + + #[test] + fn early_startup_log_path_prefers_the_already_applied_config_dir() { + let directory = tempfile::tempdir().expect("create config directory"); + assert_eq!( + resolve_early_startup_log_path(Some(directory.path()), "world.genarrative.test"), + Some(directory.path().join("diagnostics/startup.log")) + ); + + // 配置目录尚未生效时才退到平台配置根,且仍要按标识符分层。 + if let Some(fallback) = resolve_early_startup_log_path(None, "world.genarrative.test") { + assert!(fallback.ends_with("world.genarrative.test/diagnostics/startup.log")); + } + } + + #[test] + fn startup_log_slot_fail_without_path_still_reports_instead_of_going_silent() { + let directory = tempfile::tempdir().expect("create diagnostics directory"); + let slot = StartupLogSlot::new(None); + + // 路径未知时 fail 不能静默:它必须仍然走到用户可见提示,同时不造日志文件。 + slot.fail("startup.runner.owner-lock.failed details=test"); + assert_eq!(fs::read_dir(directory.path()).expect("read dir").count(), 0); + + let path = directory.path().join("startup.log"); + let slot = StartupLogSlot::new(Some(path.clone())); + slot.fail("startup.runner.owner-lock.failed details=test"); + let content = fs::read_to_string(&path).expect("read startup log"); + assert!(content.contains("startup.runner.owner-lock.failed details=test")); + } + #[test] fn diagnostic_message_redacts_sensitive_values_and_absolute_paths() { assert_eq!( diff --git a/apps/ai-game-creator-shell/src-tauri/src/preview.rs b/apps/ai-game-creator-shell/src-tauri/src/preview.rs index ab6c73245..f79c9bc39 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/preview.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/preview.rs @@ -1405,9 +1405,14 @@ fn find_bytes(haystack: &[u8], needle: &[u8]) -> Option { .position(|candidate| candidate == needle) } -/// 解析项目游戏根:项目根存在 `index.html` 时使用项目根(新布局), -/// 否则回退到旧布局的 `game/` 子目录。 +/// npm 工程只预览构建结果;单 HTML 工程保留现有入口布局。 pub(crate) fn project_game_root(root: &Path) -> PathBuf { + if root.join("package.json").is_file() || root.join("dist/index.html").is_file() { + return root.join("dist"); + } + if root.join("game/package.json").is_file() || root.join("game/dist/index.html").is_file() { + return root.join("game/dist"); + } if root.join("index.html").is_file() { root.to_path_buf() } else { @@ -1419,9 +1424,24 @@ pub(crate) fn resolve_preview_path(root: &Path, url_path: &str) -> Result Result Result metadata, @@ -1586,6 +1643,43 @@ mod tests { use super::*; use std::fs; + #[test] + fn npm_preview_requires_build_and_prefers_bundled_assets() { + let base = PathBuf::from(std::env::var("HOME").unwrap()).join("data/tmp"); + fs::create_dir_all(&base).unwrap(); + let root = tempfile::tempdir_in(base).unwrap(); + fs::write(root.path().join("package.json"), "{}").unwrap(); + fs::write(root.path().join("index.html"), "source").unwrap(); + assert!(resolve_preview_path(root.path(), "/").is_err()); + fs::create_dir_all(root.path().join("dist/assets")).unwrap(); + fs::create_dir_all(root.path().join("assets")).unwrap(); + fs::write(root.path().join("dist/index.html"), "").unwrap(); + fs::write(root.path().join("dist/assets/main.js"), "bundled").unwrap(); + fs::write(root.path().join("assets/main.js"), "source").unwrap(); + fs::write(root.path().join("assets/hero.png"), "image").unwrap(); + assert_eq!( + resolve_preview_path(root.path(), "/assets/main.js").unwrap(), + root.path() + .join("dist/assets/main.js") + .canonicalize() + .unwrap() + ); + assert!(resolve_preview_path(root.path(), "/assets/hero.png").is_err()); + fs::create_dir_all(root.path().join("game")).unwrap(); + fs::write(root.path().join("game/index.html"), "source").unwrap(); + assert!(resolve_preview_path(root.path(), "/game/index.html").is_err()); + assert!(resolve_preview_path(root.path(), "/assets/%2e%2e/index.html").is_err()); + #[cfg(unix)] + { + std::os::unix::fs::symlink( + root.path().join("index.html"), + root.path().join("dist/assets/leak.html"), + ) + .unwrap(); + assert!(resolve_preview_path(root.path(), "/assets/leak.html").is_err()); + } + } + #[test] fn root_layout_serves_root_entry_and_keeps_legacy_paths_available() { let root = tempfile::tempdir().expect("create preview root"); 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 4ffe94674..003d4464b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project.rs @@ -16,6 +16,7 @@ mod resource_dependency_graph; mod resource_editor; mod resource_layout; mod verification; +mod write_lock; pub(crate) use agent_db::*; pub(crate) use asset_canvas::*; @@ -30,3 +31,4 @@ pub(crate) use resource_dependency_graph::*; pub(crate) use resource_editor::*; pub(crate) use resource_layout::*; pub(crate) use verification::*; +pub(crate) use write_lock::*; diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/agent_db.rs b/apps/ai-game-creator-shell/src-tauri/src/project/agent_db.rs index bd2f3e18b..08691c487 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/agent_db.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/agent_db.rs @@ -2,13 +2,10 @@ use super::*; use std::collections::BTreeSet; #[cfg(windows)] -use super::filesystem::PROJECT_FILE_FLAG_OPEN_REPARSE_POINT; +use super::write_lock::PROJECT_FILE_FLAG_OPEN_REPARSE_POINT; const AGENT_DB_MAX_RECORD_BYTES: usize = 1024 * 1024; const AGENT_DB_ACTION_RECEIPT_RECORD_TYPE: &str = "agent.runtime.action_receipt"; -const AGENT_DB_PLAN_GDD_DECISION_RECORD_TYPE: &str = "agent.runtime.plan.gdd_decided"; -const AGENT_DB_PLAN_PROVIDER_USAGE_RECORD_TYPE: &str = "agent.runtime.plan.provider_usage"; -const AGENT_DB_PLAN_GDD_DECISION_AUDIT_SCHEMA_V1: &str = "agent-runtime-plan-gdd-decided.v1"; 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"; @@ -16,8 +13,6 @@ const AGENT_DB_PROVIDER_REQUEST_LIFECYCLE_SCHEMA_V1: &str = "game-creator-provider-request-lifecycle.v1"; const AGENT_DB_PROVIDER_REQUEST_LIFECYCLE_SCHEMA_V2: &str = "game-creator-provider-request-lifecycle.v2"; -const AGENT_DB_PROVIDER_REQUEST_LIFECYCLE_SCHEMA_V3: &str = - "game-creator-provider-request-lifecycle.v3"; 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"; @@ -35,22 +30,12 @@ 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; -// Planning decisions are a separate durability lane. They must retain a -// complete 128-version lineage even when ordinary/lifecycle records consume -// the rest of the Agent DB budget, so they cannot share either existing tail. -const AGENT_DB_PLAN_GDD_DECISION_MAX_RECORD_BYTES: usize = 16 * 1024; -const AGENT_DB_PLAN_GDD_DECISION_RESERVE_RECORDS: u64 = 128; -const AGENT_DB_PLAN_GDD_DECISION_RESERVE_BYTES: u64 = - (AGENT_DB_PLAN_GDD_DECISION_MAX_RECORD_BYTES as u64 + 1) - * AGENT_DB_PLAN_GDD_DECISION_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 - - AGENT_DB_PLAN_GDD_DECISION_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 - - AGENT_DB_PLAN_GDD_DECISION_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; /// 尾窗一次最多返回的记录数。超出即 `truncated`,丢的永远是更旧的记录。 @@ -66,14 +51,10 @@ pub(super) enum AgentDbRecordAppendClass { ActionTerminal, LifecycleTerminal, FinalizationCritical, - PlanGddDecision, } pub(super) 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_PLAN_GDD_DECISION_RECORD_TYPE) { - return AgentDbRecordAppendClass::PlanGddDecision; - } if record_type == Some(AGENT_DB_ACTION_RECEIPT_RECORD_TYPE) || agent_db_record_uses_terminal_reserve(record) { @@ -93,9 +74,6 @@ pub(super) fn agent_db_record_append_class(record: &serde_json::Value) -> AgentD { return AgentDbRecordAppendClass::LifecycleTerminal; } - if record_type == Some(AGENT_DB_PLAN_PROVIDER_USAGE_RECORD_TYPE) { - return AgentDbRecordAppendClass::LifecycleTerminal; - } AgentDbRecordAppendClass::Ordinary } @@ -1011,12 +989,6 @@ pub(crate) fn append_agent_db_record(root: &Path, record: serde_json::Value) -> Some(AGENT_DB_ACTION_RECEIPT_RECORD_TYPE) => { return Err("Agent 持久动作回执必须使用幂等终态 receipt 追加入口".to_string()) } - Some(AGENT_DB_PLAN_GDD_DECISION_RECORD_TYPE) => { - return Err("Agent DB planning decision 必须使用专用幂等追加入口".to_string()) - } - Some(AGENT_DB_PLAN_PROVIDER_USAGE_RECORD_TYPE) => { - return Err("Agent DB planning Provider usage 必须使用专用幂等追加入口".to_string()) - } Some( AGENT_DB_PROVIDER_REQUEST_LIFECYCLE_RECORD_TYPE | AGENT_DB_FINALIZATION_LIFECYCLE_RECORD_TYPE, @@ -1171,7 +1143,6 @@ fn validate_agent_db_append_class_record_size( Some( AGENT_DB_PROVIDER_REQUEST_LIFECYCLE_RECORD_TYPE | AGENT_DB_FINALIZATION_LIFECYCLE_RECORD_TYPE - | AGENT_DB_PLAN_PROVIDER_USAGE_RECORD_TYPE ) ) && line.len() > AGENT_DB_LIFECYCLE_TERMINAL_MAX_RECORD_BYTES { @@ -1180,14 +1151,6 @@ fn validate_agent_db_append_class_record_size( AGENT_DB_LIFECYCLE_TERMINAL_MAX_RECORD_BYTES )); } - if record_type.as_deref() == Some(AGENT_DB_PLAN_GDD_DECISION_RECORD_TYPE) - && line.len() > AGENT_DB_PLAN_GDD_DECISION_MAX_RECORD_BYTES - { - return Err(format!( - "Agent DB planning decision 单条记录超过 {} 字节上限", - AGENT_DB_PLAN_GDD_DECISION_MAX_RECORD_BYTES - )); - } if append_class == AgentDbRecordAppendClass::FinalizationCritical && line.len() > AGENT_DB_FINALIZATION_CRITICAL_MAX_RECORD_BYTES { @@ -1248,260 +1211,6 @@ pub(crate) fn append_agent_db_lifecycle_record_idempotent( Ok(true) } -fn validate_agent_db_plan_provider_usage_record( - record: &serde_json::Value, - stored: bool, -) -> Result<(), String> { - const FIELDS: &[&str] = &[ - "recordType", - "usageSchemaVersion", - "projectId", - "rootAgentId", - "rootRunId", - "rootRunProfileBindingFingerprint", - "agentId", - "taskId", - "sessionId", - "runId", - "source", - "requestId", - "requestKind", - "requestSlot", - "webSearchEnabled", - "planningSessionBinding", - "outcome", - "activeMillis", - ]; - let object = record - .as_object() - .ok_or_else(|| "Agent DB planning Provider usage 必须是 object".to_string())?; - let expected_len = FIELDS.len().saturating_add(if stored { 2 } else { 0 }); - if object.len() != expected_len - || FIELDS.iter().any(|field| !object.contains_key(*field)) - || (stored && (!object.contains_key("schemaVersion") || !object.contains_key("updatedAt"))) - || (!stored && (object.contains_key("schemaVersion") || object.contains_key("updatedAt"))) - { - return Err("Agent DB planning Provider usage 字段集合无效".to_string()); - } - if object.get("recordType").and_then(serde_json::Value::as_str) - != Some(AGENT_DB_PLAN_PROVIDER_USAGE_RECORD_TYPE) - || object - .get("usageSchemaVersion") - .and_then(serde_json::Value::as_str) - != Some("plan-provider-usage.v1") - { - return Err("Agent DB planning Provider usage schema 无效".to_string()); - } - if stored - && (object - .get("schemaVersion") - .and_then(serde_json::Value::as_str) - != Some(GAME_CREATOR_AGENT_DB_SCHEMA_VERSION) - || object - .get("updatedAt") - .and_then(serde_json::Value::as_u64) - .is_none_or(|value| value == 0)) - { - return Err("Agent DB planning Provider usage 持久化 envelope 无效".to_string()); - } - for field in [ - "projectId", - "rootAgentId", - "rootRunId", - "agentId", - "taskId", - "sessionId", - "runId", - "source", - "requestSlot", - ] { - if object - .get(field) - .and_then(serde_json::Value::as_str) - .is_none_or(|value| !is_safe_agent_db_lifecycle_identity(value)) - { - return Err(format!( - "Agent DB planning Provider usage 字段安全形状无效:{field}" - )); - } - } - if object - .get("requestId") - .and_then(serde_json::Value::as_str) - .is_none_or(|value| !is_valid_agent_db_provider_request_id(value)) - { - return Err("Agent DB planning Provider usage requestId 无效".to_string()); - } - if object - .get("rootRunProfileBindingFingerprint") - .and_then(serde_json::Value::as_str) - .is_none_or(|value| !is_valid_agent_db_sha256(value)) - { - return Err("Agent DB planning Provider usage root binding fingerprint 无效".to_string()); - } - if !matches!( - object - .get("requestKind") - .and_then(serde_json::Value::as_str), - Some("tool-plan" | "final-reply" | "context-compaction" | "final-reply-context-compaction") - ) { - return Err("Agent DB planning Provider usage requestKind 无效".to_string()); - } - if object - .get("webSearchEnabled") - .and_then(serde_json::Value::as_bool) - .is_none() - { - return Err("Agent DB planning Provider usage webSearchEnabled 无效".to_string()); - } - if !matches!( - object.get("outcome").and_then(serde_json::Value::as_str), - Some("completed" | "failed" | "interrupted") - ) || object - .get("activeMillis") - .and_then(serde_json::Value::as_u64) - .is_none() - || !matches!( - object.get("planningSessionBinding"), - Some(serde_json::Value::Null | serde_json::Value::Object(_)) - ) - { - return Err("Agent DB planning Provider usage terminal payload 无效".to_string()); - } - Ok(()) -} - -fn scan_agent_db_plan_provider_usage_records_unlocked( - file: &mut File, - path: &Path, -) -> Result, String> { - 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 本地索引超过 {} 字节 planning Provider usage 扫描上限:{}", - 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 = 0_usize; - let mut usage_records = Vec::new(); - while let Some(line) = read_agent_db_jsonl_line_bounded(&mut reader, path)? { - if !line.complete { - return Err(format!( - "Agent 本地索引 planning Provider usage 扫描发现不完整 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 本地索引超过 {} 条 planning Provider usage 扫描上限:{}", - 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(AGENT_DB_PLAN_PROVIDER_USAGE_RECORD_TYPE) - { - continue; - } - validate_agent_db_plan_provider_usage_record(&record, true)?; - usage_records.push(record); - } - Ok(usage_records) -} - -pub(crate) fn append_agent_db_plan_provider_usage_idempotent( - root: &Path, - record: serde_json::Value, -) -> Result { - validate_agent_db_plan_provider_usage_record(&record, false)?; - let request_id = record - .get("requestId") - .and_then(serde_json::Value::as_str) - .expect("validated planning Provider usage requestId") - .to_string(); - #[cfg(test)] - take_agent_db_record_failure_injection(root, Some(AGENT_DB_PLAN_PROVIDER_USAGE_RECORD_TYPE))?; - - 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 mut matches = 0_usize; - for existing in - scan_agent_db_plan_provider_usage_records_unlocked(&mut storage.file, &storage.path)? - { - if existing - .get("requestId") - .and_then(serde_json::Value::as_str) - != Some(request_id.as_str()) - { - continue; - } - if !agent_db_stored_record_matches_expected_payload(&existing, &record) { - return Err(format!( - "Agent DB planning Provider usage 同 requestId 内容冲突:{request_id}" - )); - } - matches = matches.saturating_add(1); - if matches > 1 { - return Err(format!( - "Agent DB planning Provider usage 同 requestId 存在重复事实:{request_id}" - )); - } - } - if matches == 1 { - return Ok(false); - } - let line = serialize_agent_db_record(record)?; - validate_agent_db_append_class_record_size(AgentDbRecordAppendClass::LifecycleTerminal, &line)?; - append_agent_db_classified_line_unlocked( - &mut storage, - &line, - AgentDbRecordAppendClass::LifecycleTerminal, - )?; - Ok(true) -} - -pub(crate) fn read_agent_db_plan_provider_usage_records_at( - root: &Path, -) -> Result, 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 本地索引 planning Provider usage 查询")?; - verify_agent_db_directory_current(&directory)?; - let Some(mut storage) = open_agent_db_storage(directory, true, false)? else { - return Ok(Vec::new()); - }; - verify_agent_db_storage_current(&storage)?; - repair_truncated_jsonl_tail_unlocked(&mut storage.file, &storage.path, "Agent 本地索引")?; - verify_agent_db_storage_current(&storage)?; - scan_agent_db_plan_provider_usage_records_unlocked(&mut storage.file, &storage.path) -} - fn validate_agent_db_lifecycle_record_input<'a>( identity_field: &str, identity_value: &str, @@ -1580,14 +1289,11 @@ fn validate_agent_db_lifecycle_record_semantics( match record_type { AGENT_DB_PROVIDER_REQUEST_LIFECYCLE_RECORD_TYPE => { let audit_schema = agent_db_provider_lifecycle_schema_version(record)?; - if matches!( - audit_schema, - AGENT_DB_PROVIDER_REQUEST_LIFECYCLE_SCHEMA_V2 - | AGENT_DB_PROVIDER_REQUEST_LIFECYCLE_SCHEMA_V3 - ) && record - .get("webSearchEnabled") - .and_then(serde_json::Value::as_bool) - .is_none() + if audit_schema == AGENT_DB_PROVIDER_REQUEST_LIFECYCLE_SCHEMA_V2 + && record + .get("webSearchEnabled") + .and_then(serde_json::Value::as_bool) + .is_none() { return Err("Agent DB Provider lifecycle webSearchEnabled 必须为 bool".to_string()); } @@ -1618,15 +1324,6 @@ fn validate_agent_db_lifecycle_record_semantics( if !is_safe_agent_db_lifecycle_identity(request_slot) { return Err("Agent DB Provider lifecycle requestSlot 安全形状无效".to_string()); } - if audit_schema == AGENT_DB_PROVIDER_REQUEST_LIFECYCLE_SCHEMA_V3 { - validate_agent_db_plan_provider_binding( - record.get("planningSessionBinding").ok_or_else(|| { - "Agent DB planning Provider lifecycle 缺少 planningSessionBinding" - .to_string() - })?, - record, - )?; - } let status = record .get("status") .and_then(serde_json::Value::as_str) @@ -1675,21 +1372,6 @@ fn validate_agent_db_lifecycle_record_fields( "webSearchEnabled", "status", ]; - const PROVIDER_FIELDS_V3: &[&str] = &[ - "recordType", - "auditSchemaVersion", - "agentId", - "taskId", - "sessionId", - "runId", - "source", - "requestId", - "requestKind", - "requestSlot", - "webSearchEnabled", - "planningSessionBinding", - "status", - ]; const FINALIZATION_FIELDS: &[&str] = &[ "recordType", "auditSchemaVersion", @@ -1719,7 +1401,6 @@ fn validate_agent_db_lifecycle_record_fields( match agent_db_provider_lifecycle_schema_version(record)? { AGENT_DB_PROVIDER_REQUEST_LIFECYCLE_SCHEMA_V1 => PROVIDER_FIELDS_V1, AGENT_DB_PROVIDER_REQUEST_LIFECYCLE_SCHEMA_V2 => PROVIDER_FIELDS_V2, - AGENT_DB_PROVIDER_REQUEST_LIFECYCLE_SCHEMA_V3 => PROVIDER_FIELDS_V3, _ => unreachable!("provider lifecycle schema was validated"), } } @@ -1746,166 +1427,12 @@ fn agent_db_provider_lifecycle_schema_version(record: &serde_json::Value) -> Res { Some( schema @ (AGENT_DB_PROVIDER_REQUEST_LIFECYCLE_SCHEMA_V1 - | AGENT_DB_PROVIDER_REQUEST_LIFECYCLE_SCHEMA_V2 - | AGENT_DB_PROVIDER_REQUEST_LIFECYCLE_SCHEMA_V3), + | AGENT_DB_PROVIDER_REQUEST_LIFECYCLE_SCHEMA_V2), ) => Ok(schema), _ => Err("Agent DB Provider lifecycle audit schema 无效".to_string()), } } -fn validate_agent_db_plan_provider_binding( - binding: &serde_json::Value, - outer: &serde_json::Value, -) -> Result<(), String> { - const FIELDS: &[&str] = &[ - "schemaVersion", - "projectId", - "gddId", - "agentId", - "taskId", - "providerRequestId", - "sessionId", - "runId", - "rootAgentId", - "rootRunId", - "delegationId", - "goalId", - "goalRevision", - "goalSnapshotFingerprint", - "source", - "runProfile", - "runProfileBindingFingerprint", - "sessionRevision", - "sessionFingerprint", - "appliedSteerCursor", - "requestKind", - "requestSlot", - "webSearchEnabled", - "requestContextFingerprint", - "fingerprint", - ]; - let object = binding - .as_object() - .ok_or_else(|| "Agent DB planning binding 必须是 object".to_string())?; - if object.len() != FIELDS.len() || !FIELDS.iter().all(|field| object.contains_key(*field)) { - return Err("Agent DB planning binding 字段集合无效".to_string()); - } - let string_field = |field: &str| { - object - .get(field) - .and_then(serde_json::Value::as_str) - .ok_or_else(|| format!("Agent DB planning binding 缺少合法字段:{field}")) - }; - for field in [ - "schemaVersion", - "projectId", - "gddId", - "agentId", - "taskId", - "providerRequestId", - "sessionId", - "runId", - "rootAgentId", - "rootRunId", - "delegationId", - "source", - "runProfile", - "runProfileBindingFingerprint", - "sessionFingerprint", - "requestKind", - "requestSlot", - "requestContextFingerprint", - "fingerprint", - ] { - if !is_safe_agent_db_lifecycle_identity(string_field(field)?) { - return Err(format!( - "Agent DB planning binding 字段安全形状无效:{field}" - )); - } - } - if string_field("providerRequestId")? - != outer - .get("requestId") - .and_then(serde_json::Value::as_str) - .unwrap_or_default() - { - return Err( - "Agent DB planning binding providerRequestId 与外层 requestId 不一致".to_string(), - ); - } - match object.get("goalId").and_then(serde_json::Value::as_str) { - Some(goal_id) - if is_safe_agent_db_lifecycle_identity(goal_id) - && object - .get("goalRevision") - .and_then(serde_json::Value::as_u64) - .is_some_and(|value| value > 0) - && object - .get("goalSnapshotFingerprint") - .and_then(serde_json::Value::as_str) - .is_some_and(is_safe_agent_db_lifecycle_identity) => {} - None if object - .get("goalRevision") - .and_then(serde_json::Value::as_u64) - == Some(0) - && object - .get("goalSnapshotFingerprint") - .and_then(serde_json::Value::as_str) - == Some("") => {} - _ => return Err("Agent DB planning binding Goal 三元组无效".to_string()), - } - for (binding_field, outer_field) in [ - ("agentId", "agentId"), - ("taskId", "taskId"), - ("sessionId", "sessionId"), - ("runId", "runId"), - ("source", "source"), - ("requestKind", "requestKind"), - ("requestSlot", "requestSlot"), - ] { - if string_field(binding_field)? - != outer - .get(outer_field) - .and_then(serde_json::Value::as_str) - .unwrap_or_default() - { - return Err(format!( - "Agent DB planning binding {binding_field} 与外层字段不一致" - )); - } - } - if object - .get("schemaVersion") - .and_then(serde_json::Value::as_str) - != Some("plan-provider-session-binding.v1") - || object - .get("sessionRevision") - .and_then(serde_json::Value::as_u64) - .is_none_or(|value| value == 0) - || object - .get("appliedSteerCursor") - .and_then(serde_json::Value::as_u64) - .is_none() - || object - .get("webSearchEnabled") - .and_then(serde_json::Value::as_bool) - != Some(false) - { - return Err("Agent DB planning binding 基础字段无效".to_string()); - } - let typed = - serde_json::from_value::(binding.clone()) - .map_err(|error| format!("Agent DB planning binding strict 解析失败:{error}"))?; - crate::agent::validate_plan_provider_session_binding(&typed) - .map_err(|error| format!("Agent DB planning binding 语义无效:{error}"))?; - let expected_fingerprint = crate::agent::plan_provider_session_binding_fingerprint(&typed) - .map_err(|error| format!("Agent DB planning binding fingerprint 计算失败:{error}"))?; - if typed.fingerprint != expected_fingerprint { - return Err("Agent DB planning binding fingerprint 不匹配".to_string()); - } - Ok(()) -} - fn validate_agent_db_finalization_lifecycle_semantics( record: &serde_json::Value, ) -> Result<(), String> { @@ -2550,324 +2077,6 @@ pub(crate) fn append_agent_db_process_reconciliation_if_missing_for_action( ) } -pub(crate) fn append_agent_db_plan_submit_gdd_committed_if_missing_for_action( - root: &Path, - agent_id: &str, - run_id: &str, - action_id: &str, - record: serde_json::Value, -) -> Result { - const RECORD_TYPE: &str = "agent.runtime.plan_submit_gdd.committed"; - const FIELDS: &[&str] = &[ - "recordType", - "agentId", - "taskId", - "sessionId", - "runId", - "actionId", - "actionFingerprint", - "gddId", - "version", - "gddFingerprint", - "approvalRequestId", - "recoveryPending", - ]; - if !agent_db_record_has_exact_payload_fields(&record, FIELDS) - || record.get("recordType").and_then(serde_json::Value::as_str) != Some(RECORD_TYPE) - || record.get("agentId").and_then(serde_json::Value::as_str) != Some(agent_id) - || record.get("runId").and_then(serde_json::Value::as_str) != Some(run_id) - || record.get("actionId").and_then(serde_json::Value::as_str) != Some(action_id) - || record - .get("recoveryPending") - .and_then(serde_json::Value::as_bool) - != Some(false) - { - return Err("Agent DB planning submit committed 幂等记录身份或字段集合无效".to_string()); - } - for field in ["taskId", "sessionId"] { - if record - .get(field) - .and_then(serde_json::Value::as_str) - .is_none_or(|value| value.trim().is_empty() || value.chars().any(char::is_control)) - { - return Err(format!( - "Agent DB planning submit committed 缺少合法字段:{field}" - )); - } - } - let action_fingerprint = record - .get("actionFingerprint") - .and_then(serde_json::Value::as_str) - .unwrap_or_default(); - let gdd_id = record - .get("gddId") - .and_then(serde_json::Value::as_str) - .unwrap_or_default(); - let gdd_fingerprint = record - .get("gddFingerprint") - .and_then(serde_json::Value::as_str) - .unwrap_or_default(); - let approval_request_id = record - .get("approvalRequestId") - .and_then(serde_json::Value::as_str) - .unwrap_or_default(); - if !action_id.strip_prefix("action-").is_some_and(|suffix| { - suffix.len() == 24 && suffix.bytes().all(|byte| byte.is_ascii_hexdigit()) - }) || !is_valid_agent_db_sha256(action_fingerprint) - || !gdd_id - .strip_prefix("gdd-") - .is_some_and(|suffix| uuid::Uuid::parse_str(suffix).is_ok()) - || !gdd_fingerprint - .strip_prefix("sha256-serde-json-v2:") - .is_some_and(is_valid_agent_db_sha256) - || !approval_request_id - .strip_prefix("gdd-approval-") - .is_some_and(|suffix| uuid::Uuid::parse_str(suffix).is_ok()) - || record - .get("version") - .and_then(serde_json::Value::as_u64) - .is_none_or(|version| !(1..=128).contains(&version)) - { - return Err("Agent DB planning submit committed durable identity 无效".to_string()); - } - append_agent_db_record_if_missing_for_action_internal( - root, - RECORD_TYPE, - agent_id, - run_id, - action_id, - record, - || {}, - ) -} - -/// Append the dedicated planning-decision audit lane. This intentionally -/// does not reuse action-id idempotency: a response id is scoped by -/// `(gddId, version)` and may be reused on another version, while two windows -/// deciding one version with different intent must fail closed. -pub(crate) fn append_agent_db_plan_gdd_decision_if_missing( - root: &Path, - record: serde_json::Value, -) -> Result { - const RECORD_TYPE: &str = AGENT_DB_PLAN_GDD_DECISION_RECORD_TYPE; - const FIELDS: &[&str] = &[ - "recordType", - "auditSchemaVersion", - "projectId", - "agentId", - "gddId", - "version", - "gddFingerprint", - "pendingActionId", - "actionFingerprint", - "approvalRequestId", - "responseId", - "source", - "runProfile", - "runProfileBindingFingerprint", - "sessionId", - "runId", - "action", - "decisionFingerprint", - "commentHash", - "commentLength", - "receiptFingerprint", - "decidedAtUtc", - ]; - if !agent_db_record_has_exact_payload_fields(&record, FIELDS) - || record.get("recordType").and_then(serde_json::Value::as_str) != Some(RECORD_TYPE) - || record - .get("auditSchemaVersion") - .and_then(serde_json::Value::as_str) - != Some(AGENT_DB_PLAN_GDD_DECISION_AUDIT_SCHEMA_V1) - || record.get("agentId").and_then(serde_json::Value::as_str) != Some("project-supervisor") - || record.get("source").and_then(serde_json::Value::as_str) - != Some("project-supervisor-plan") - || record.get("runProfile").and_then(serde_json::Value::as_str) != Some("standard") - || !matches!( - record.get("action").and_then(serde_json::Value::as_str), - Some("approve" | "revise" | "reject") - ) - { - return Err("Agent DB planning decision 字段集合或固定身份无效".to_string()); - } - let project_id = record - .get("projectId") - .and_then(serde_json::Value::as_str) - .unwrap_or_default(); - let gdd_id = record - .get("gddId") - .and_then(serde_json::Value::as_str) - .unwrap_or_default(); - let version = record - .get("version") - .and_then(serde_json::Value::as_u64) - .unwrap_or(0); - let gdd_fingerprint = record - .get("gddFingerprint") - .and_then(serde_json::Value::as_str) - .unwrap_or_default(); - let pending_action_id = record - .get("pendingActionId") - .and_then(serde_json::Value::as_str) - .unwrap_or_default(); - let action_fingerprint = record - .get("actionFingerprint") - .and_then(serde_json::Value::as_str) - .unwrap_or_default(); - let approval_request_id = record - .get("approvalRequestId") - .and_then(serde_json::Value::as_str) - .unwrap_or_default(); - let response_id = record - .get("responseId") - .and_then(serde_json::Value::as_str) - .unwrap_or_default(); - let binding_fingerprint = record - .get("runProfileBindingFingerprint") - .and_then(serde_json::Value::as_str) - .unwrap_or_default(); - let session_id = record - .get("sessionId") - .and_then(serde_json::Value::as_str) - .unwrap_or_default(); - let run_id = record - .get("runId") - .and_then(serde_json::Value::as_str) - .unwrap_or_default(); - let decision_fingerprint = record - .get("decisionFingerprint") - .and_then(serde_json::Value::as_str) - .unwrap_or_default(); - let comment_hash = record - .get("commentHash") - .and_then(serde_json::Value::as_str) - .unwrap_or_default(); - let receipt_fingerprint = record - .get("receiptFingerprint") - .and_then(serde_json::Value::as_str) - .unwrap_or_default(); - let comment_length = record - .get("commentLength") - .and_then(serde_json::Value::as_u64) - .unwrap_or(u64::MAX); - let valid_uuid_suffix = |value: &str, prefix: &str| { - value.strip_prefix(prefix).is_some_and(|suffix| { - uuid::Uuid::parse_str(suffix) - .ok() - .is_some_and(|parsed| parsed.hyphenated().to_string() == suffix) - }) - }; - let valid_typed = |value: &str| { - value - .strip_prefix("sha256-serde-json-v2:") - .is_some_and(|suffix| is_valid_agent_db_sha256(suffix)) - }; - if project_id.is_empty() - || !is_safe_agent_db_lifecycle_identity(project_id) - || !valid_uuid_suffix(gdd_id, "gdd-") - || !(1..=128).contains(&version) - || !valid_typed(gdd_fingerprint) - || !pending_action_id - .strip_prefix("action-") - .is_some_and(|suffix| { - suffix.len() == 24 - && suffix - .bytes() - .all(|byte| (b'a'..=b'f').contains(&byte) || byte.is_ascii_digit()) - }) - || !is_valid_agent_db_sha256(action_fingerprint) - || !valid_uuid_suffix(approval_request_id, "gdd-approval-") - || !valid_uuid_suffix(response_id, "gdd-response-") - || !is_valid_agent_db_sha256(binding_fingerprint) - || !is_safe_agent_db_lifecycle_identity(session_id) - || !is_safe_agent_db_lifecycle_identity(run_id) - || !valid_typed(decision_fingerprint) - || !valid_typed(comment_hash) - || comment_length > 1_000 - || !valid_typed(receipt_fingerprint) - { - return Err("Agent DB planning decision durable identity/fingerprint 无效".to_string()); - } - let decided_at = record - .get("decidedAtUtc") - .and_then(serde_json::Value::as_str) - .unwrap_or_default(); - crate::agent::validate_timestamp(decided_at, "agent.db.decidedAtUtc") - .map_err(|error| error.to_string())?; - - 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 length = storage - .file - .metadata() - .map_err(|error| format!("读取 Agent 本地索引元数据失败:{error}"))? - .len(); - if length > AGENT_DB_MAX_ACTION_RECEIPT_SCAN_BYTES { - return Err("Agent 本地索引超过扫描上限,无法追加 planning decision".to_string()); - } - storage - .file - .seek(SeekFrom::Start(0)) - .map_err(|error| format!("定位 Agent 本地索引失败:{error}"))?; - let mut reader = BufReader::new(&mut storage.file); - let mut count = 0usize; - while let Some(line) = read_agent_db_jsonl_line_bounded(&mut reader, &storage.path)? { - if !line.complete { - break; - } - if line.content.iter().all(|byte| byte.is_ascii_whitespace()) { - continue; - } - count = count.saturating_add(1); - if count > AGENT_DB_MAX_SCAN_RECORDS { - return Err("Agent 本地索引超过记录扫描上限".to_string()); - } - let stored = serde_json::from_slice::(&line.content) - .map_err(|error| format!("解析 Agent 本地索引失败:{error}"))?; - if stored.get("recordType").and_then(serde_json::Value::as_str) == Some(RECORD_TYPE) - && stored.get("gddId").and_then(serde_json::Value::as_str) == Some(gdd_id) - && stored.get("version").and_then(serde_json::Value::as_u64) == Some(version) - && stored.get("responseId").and_then(serde_json::Value::as_str) == Some(response_id) - { - if !agent_db_record_has_exact_payload_fields(&stored, FIELDS) { - return Err("Agent DB planning decision 已有记录字段集合损坏".to_string()); - } - let mut comparable = stored.clone(); - if let Some(object) = comparable.as_object_mut() { - object.remove("schemaVersion"); - object.remove("updatedAt"); - } - if comparable == record { - return Ok(false); - } - return Err( - "PLAN_DECISION_IDENTITY_CONFLICT: planning decision 幂等键 payload 不一致" - .to_string(), - ); - } - } - drop(reader); - let line = serialize_agent_db_record(record)?; - validate_agent_db_append_class_record_size(AgentDbRecordAppendClass::PlanGddDecision, &line)?; - append_agent_db_classified_line_unlocked( - &mut storage, - &line, - AgentDbRecordAppendClass::PlanGddDecision, - )?; - Ok(true) -} - pub(crate) fn append_agent_db_record_if_missing_for_action_with_before_lock( root: &Path, record_type: &str, @@ -4318,12 +3527,12 @@ fn project_append_locks() -> &'static Mutex>>> { PROJECT_APPEND_LOCKS.get_or_init(|| Mutex::new(BTreeMap::new())) } -pub(super) struct ProjectAppendLock { +pub(crate) struct ProjectAppendLock { process_lock: Arc>, os_lock_path: PathBuf, } -pub(super) struct ProjectAppendGuard<'a> { +pub(crate) struct ProjectAppendGuard<'a> { _process_guard: std::sync::MutexGuard<'a, ()>, _os_lock: File, } @@ -4335,7 +3544,7 @@ impl ProjectAppendLock { .map_err(|_| format!("获取{error_label}进程内锁失败:锁已损坏")) } - pub(super) fn lock(&self, error_label: &str) -> Result, String> { + pub(crate) fn lock(&self, error_label: &str) -> Result, String> { let process_guard = self .process_lock .lock() @@ -4348,7 +3557,7 @@ impl ProjectAppendLock { } } -pub(super) fn project_append_lock_for(path: &Path) -> Result { +pub(crate) fn project_append_lock_for(path: &Path) -> Result { let mut locks = project_append_locks() .lock() .map_err(|_| "获取本地追加写锁失败:锁已损坏".to_string())?; @@ -4539,7 +3748,7 @@ fn try_open_project_append_os_lock(path: &Path, error_label: &str) -> Result, } @@ -4816,13 +4023,6 @@ impl AgentDbReservedTailCapacity { AgentDbRecordAppendClass::LifecycleTerminal => { self.observe_unlinked_lifecycle(in_record_tail, tail_bytes); } - AgentDbRecordAppendClass::PlanGddDecision => { - self.plan_decision_tail_records = self - .plan_decision_tail_records - .saturating_add(usize::from(in_record_tail)); - self.plan_decision_tail_bytes = - self.plan_decision_tail_bytes.saturating_add(tail_bytes); - } AgentDbRecordAppendClass::FinalizationCritical => { if !self.observe_finalization_record(record, in_record_tail, tail_bytes) { self.observe_unlinked_lifecycle(in_record_tail, tail_bytes); @@ -5149,24 +4349,6 @@ fn ensure_agent_db_classified_capacity_unlocked( )); } } - AgentDbRecordAppendClass::PlanGddDecision => { - if capacity.plan_decision_tail_records - > AGENT_DB_PLAN_GDD_DECISION_RESERVE_RECORDS as usize - { - return Err(format!( - "Agent 本地索引已达到 {} 条 planning decision 尾部配额,无法继续追加:{}", - AGENT_DB_PLAN_GDD_DECISION_RESERVE_RECORDS, - path.display() - )); - } - if capacity.plan_decision_tail_bytes > AGENT_DB_PLAN_GDD_DECISION_RESERVE_BYTES { - return Err(format!( - "Agent 本地索引将超过 {} 字节 planning decision 尾部配额:{}", - AGENT_DB_PLAN_GDD_DECISION_RESERVE_BYTES, - path.display() - )); - } - } AgentDbRecordAppendClass::Ordinary => unreachable!(), } Ok(()) diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/agent_db/security_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/project/agent_db/security_tests.rs index c7aa53808..4bf1219ac 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/agent_db/security_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/agent_db/security_tests.rs @@ -334,57 +334,6 @@ fn provider_lifecycle_record_with_schema( record } -fn planning_provider_lifecycle_record_without_goal(status: &str) -> serde_json::Value { - let mut binding = crate::agent::PlanProviderSessionBindingV1 { - schema_version: crate::agent::PLAN_PROVIDER_SESSION_BINDING_SCHEMA_VERSION.to_string(), - project_id: "planning-provider-project".to_string(), - gdd_id: "gdd-00000000-0000-4000-8000-000000000001".to_string(), - agent_id: crate::GAME_CREATOR_PROJECT_PLANNING_AGENT_ID.to_string(), - task_id: "planning-provider-task".to_string(), - provider_request_id: String::new(), - session_id: "planning-provider-session".to_string(), - run_id: "planning-provider-run".to_string(), - root_agent_id: crate::GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(), - root_run_id: "planning-provider-root-run".to_string(), - delegation_id: "planning-provider-delegation".to_string(), - goal_id: None, - goal_revision: 0, - goal_snapshot_fingerprint: String::new(), - source: "agent-delegate".to_string(), - run_profile: crate::AGENT_RUNTIME_RUN_PROFILE_STANDARD.to_string(), - run_profile_binding_fingerprint: "1".repeat(64), - session_revision: 1, - session_fingerprint: format!("sha256-serde-json-v2:{}", "2".repeat(64)), - applied_steer_cursor: 0, - request_kind: "tool-plan".to_string(), - request_slot: "loop-0-plan-submit".to_string(), - web_search_enabled: false, - request_context_fingerprint: format!("sha256-serde-json-v2:{}", "3".repeat(64)), - fingerprint: String::new(), - }; - binding.provider_request_id = - crate::agent::plan_provider_session_binding_base_request_id(&binding) - .expect("compute planning Provider request id"); - binding.fingerprint = crate::agent::plan_provider_session_binding_fingerprint(&binding) - .expect("compute planning Provider binding fingerprint"); - let request_id = binding.provider_request_id.clone(); - serde_json::json!({ - "recordType": AGENT_DB_PROVIDER_REQUEST_LIFECYCLE_RECORD_TYPE, - "auditSchemaVersion": AGENT_DB_PROVIDER_REQUEST_LIFECYCLE_SCHEMA_V3, - "agentId": binding.agent_id, - "taskId": binding.task_id, - "sessionId": binding.session_id, - "runId": binding.run_id, - "source": binding.source, - "requestId": request_id, - "requestKind": binding.request_kind, - "requestSlot": binding.request_slot, - "webSearchEnabled": false, - "planningSessionBinding": binding, - "status": status, - }) -} - fn finalization_lifecycle_record(finalization_id: &str, stage: &str) -> serde_json::Value { finalization_lifecycle_record_with_schema( finalization_id, @@ -1782,55 +1731,6 @@ fn provider_lifecycle_accepts_v1_and_strict_v2_without_changing_request_identity fs::remove_dir_all(root).ok(); } -#[test] -fn planning_provider_lifecycle_v3_accepts_the_explicit_no_goal_triple() { - let root = unique_agent_db_test_root("planning-provider-lifecycle-v3-no-goal"); - let started = planning_provider_lifecycle_record_without_goal("started"); - let request_id = started["requestId"] - .as_str() - .expect("planning Provider request id") - .to_string(); - assert_eq!( - started["planningSessionBinding"]["goalId"], - serde_json::Value::Null - ); - assert_eq!(started["planningSessionBinding"]["goalRevision"], 0); - assert_eq!( - started["planningSessionBinding"]["goalSnapshotFingerprint"], - "" - ); - - assert!(append_agent_db_lifecycle_record_idempotent( - &root, - "requestId", - &request_id, - "status", - "started", - started, - ) - .expect("append no-Goal planning Provider started lifecycle")); - assert!(append_agent_db_lifecycle_record_idempotent( - &root, - "requestId", - &request_id, - "status", - "completed", - planning_provider_lifecycle_record_without_goal("completed"), - ) - .expect("append no-Goal planning Provider completed lifecycle")); - assert_eq!( - read_agent_db_lifecycle_transitions_at( - &root, - AGENT_DB_PROVIDER_REQUEST_LIFECYCLE_RECORD_TYPE, - "requestId", - &request_id, - ) - .expect("read no-Goal planning Provider lifecycle"), - vec!["started", "completed"] - ); - fs::remove_dir_all(root).ok(); -} - #[test] fn provider_lifecycle_rejects_schema_specific_web_search_shape_and_identity_conflicts() { let request_id = provider_request_id('8'); @@ -2466,19 +2366,13 @@ fn agent_db_capacity_reserves_terminal_receipt_space_without_rotation() { #[test] fn ordinary_append_soft_limit_preserves_action_receipt_record_slots() { - // 守恒律必须覆盖全部预留车道。M1C-1 新增了 planning 决策车道 - // (`AGENT_DB_PLAN_GDD_DECISION_RESERVE_BYTES`,(16 KiB+1)×128)并从 - // `AGENT_DB_MAX_ORDINARY_APPEND_BYTES` 里扣掉,这条断言却还停在两车道, - // 差额恰好是新车道的 2_097_280 字节。少一条车道就意味着这条断言不再能证明 - // 「普通追加不会吃掉任何终态预留」——它才是这个测试存在的理由。 assert_eq!( AGENT_DB_MAX_ORDINARY_APPEND_BYTES + AGENT_DB_LIFECYCLE_TERMINAL_RESERVE_BYTES - + AGENT_DB_TERMINAL_RESERVE_BYTES - + AGENT_DB_PLAN_GDD_DECISION_RESERVE_BYTES, + + AGENT_DB_TERMINAL_RESERVE_BYTES, AGENT_DB_MAX_ACTION_RECEIPT_SCAN_BYTES ); - assert_eq!(AGENT_DB_MAX_ORDINARY_APPEND_RECORDS, 999_680); + 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) diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/checkpoint.rs b/apps/ai-game-creator-shell/src-tauri/src/project/checkpoint.rs index 9c99aaede..380cc2836 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/checkpoint.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/checkpoint.rs @@ -3,7 +3,7 @@ use super::*; use super::filesystem::validate_portable_project_path_component; #[cfg(windows)] -use super::filesystem::PROJECT_FILE_FLAG_OPEN_REPARSE_POINT; +use super::write_lock::PROJECT_FILE_FLAG_OPEN_REPARSE_POINT; #[cfg(windows)] fn windows_regular_file_handle_identity(file: &File, label: &str) -> Result<(u32, u64), String> { diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/export.rs b/apps/ai-game-creator-shell/src-tauri/src/project/export.rs index d52959460..6b4f7f43c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/export.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/export.rs @@ -4,26 +4,7 @@ pub(crate) fn export_local_project_package_at( root: &Path, ) -> Result { validate_project_root(root)?; - ensure_project_export_package_dir(root, "game")?; - let game_index_path = resolve_local_project_path(root, "game/index.html")?; - if !game_index_path.is_file() { - return Err("导出试玩包前需要先生成 game/index.html".to_string()); - } - let game_index_metadata = checked_export_package_metadata(&game_index_path, "game/index.html")?; - if !game_index_metadata.is_file() { - return Err("导出试玩包前需要先生成 game/index.html".to_string()); - } - prepare_game_creator_private_path_for_read(&game_index_path, false, "游戏入口")?; - let game_index = fs::read_to_string(&game_index_path) - .map_err(|error| format!("读取游戏入口失败:{}: {error}", game_index_path.display()))?; - if game_index.trim().is_empty() { - return Err("导出试玩包前 game/index.html 不能为空".to_string()); - } - let lower_game_index = game_index.to_ascii_lowercase(); - if !lower_game_index.contains(" Result, String> { let mut files = Vec::new(); - collect_project_export_package_dir_files(root, "game", &mut files)?; - if resolve_local_project_path(root, "assets")?.exists() { + let game_root = crate::preview::project_game_root(root); + let built = game_root == root.join("dist") || game_root == root.join("game/dist"); + if built { + let relative = relative_project_path(root, &game_root)?; + collect_project_export_package_dir_files(root, &relative, &mut files)?; + for (name, _, _) in &mut files { + *name = format!( + "game/{}", + name.strip_prefix(&format!("{relative}/")) + .ok_or("构建产物路径非法")? + ); + } + } else { + collect_project_export_package_dir_files(root, "game", &mut files)?; + } + if !built && resolve_local_project_path(root, "assets")?.exists() { collect_project_export_package_dir_files(root, "assets", &mut files)?; } let readme_path = resolve_local_project_path(root, "exports/README.md")?; @@ -312,3 +307,42 @@ pub(crate) fn normalize_export_package_entry_path(relative_path: &str) -> Result } normalize_relative_path(relative_path) } + +#[cfg(test)] +mod npm_export_tests { + use super::*; + + #[test] + fn npm_package_contains_only_dist_and_publish_readme() { + let base = PathBuf::from(std::env::var("HOME").unwrap()).join("data/tmp"); + fs::create_dir_all(&base).unwrap(); + let root = tempfile::tempdir_in(base).unwrap(); + for directory in ["dist/assets", "assets", "exports", "node_modules", "game"] { + fs::create_dir_all(root.path().join(directory)).unwrap(); + } + for file in [ + "package.json", + "dist/index.html", + "dist/assets/main.js", + "assets/hero.png", + "exports/README.md", + "node_modules/private.js", + "game/source.js", + ] { + fs::write(root.path().join(file), "test").unwrap(); + } + let files = collect_project_export_package_files(root.path()).unwrap(); + let names = files + .iter() + .map(|(name, _, _)| name.as_str()) + .collect::>(); + assert_eq!( + names, + vec![ + "exports/README.md", + "game/assets/main.js", + "game/index.html" + ] + ); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs b/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs index 062a74e3c..6f067628b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs @@ -1,280 +1,5 @@ use super::*; -#[cfg(windows)] -pub(crate) const PROJECT_FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; - -static PROJECT_WRITE_LOCK_NONCE: std::sync::atomic::AtomicU64 = - std::sync::atomic::AtomicU64::new(1); -const PROJECT_WRITE_LOCK_STALE_AFTER_SECONDS: u64 = 600; -const PROJECT_WRITE_LOCK_MAX_BYTES: u64 = 4 * 1024; - -#[derive(Debug)] -pub(crate) struct ProjectWriteLock { - path: PathBuf, - content: String, - /// In the free-form autonomous lane a single Runtime process may have - /// several specialist actions in flight at once. A file lock is still - /// useful across processes, but making same-process contenders fail turns - /// ordinary parallel work into a dead run (and can deadlock nested tool - /// calls). Such a contender receives an in-process/advisory guard instead - /// of deleting the real holder's lock on drop. - bypassed_same_process: bool, -} - -impl ProjectWriteLock { - pub(crate) fn guards_project_root(&self, root: &Path) -> Result { - let expected_path = resolve_local_project_path(root, PROJECT_WRITE_LOCK_PATH)?; - if self.bypassed_same_process { - // The relaxed guard deliberately has no ownership of the durable - // `.agent/project.lock` file. It still binds the observation to - // the validated project root so callers cannot use a guard from a - // different project. - return Ok(self.path == expected_path); - } - Ok(self.path == expected_path - && fs::read_to_string(&self.path).is_ok_and(|content| content == self.content)) - } -} - -impl Drop for ProjectWriteLock { - fn drop(&mut self) { - if self.bypassed_same_process { - return; - } - if fs::read_to_string(&self.path).is_ok_and(|content| content == self.content) { - let _ = fs::remove_file(&self.path); - } - } -} - -#[cfg(unix)] -fn project_write_lock_process_is_alive(process_id: u64) -> Option { - let process_id = i32::try_from(process_id).ok().filter(|value| *value > 0)?; - let result = unsafe { libc::kill(process_id, 0) }; - if result == 0 { - return Some(true); - } - match std::io::Error::last_os_error().raw_os_error() { - Some(libc::ESRCH) => Some(false), - Some(libc::EPERM) => Some(true), - _ => None, - } -} - -#[cfg(windows)] -fn project_write_lock_process_is_alive(process_id: u64) -> Option { - use std::ffi::c_void; - - #[link(name = "kernel32")] - unsafe extern "system" { - fn OpenProcess(access: u32, inherit_handle: i32, process_id: u32) -> *mut c_void; - fn GetExitCodeProcess(process: *mut c_void, exit_code: *mut u32) -> i32; - fn CloseHandle(handle: *mut c_void) -> i32; - } - - let process_id = u32::try_from(process_id).ok().filter(|value| *value > 0)?; - const PROCESS_QUERY_LIMITED_INFORMATION: u32 = 0x1000; - const STILL_ACTIVE: u32 = 259; - // SAFETY: OpenProcess returns an owned kernel handle or null; it is - // closed below. We only request the query permission needed here. - let process = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, process_id) }; - if process.is_null() { - // ERROR_INVALID_PARAMETER means the process no longer exists. For - // access-denied/other failures we cannot prove liveness, so keep the - // conservative unknown result and let the normal bounded wait decide. - return match std::io::Error::last_os_error().raw_os_error() { - Some(87) => Some(false), - _ => None, - }; - } - let mut exit_code = 0_u32; - // SAFETY: `exit_code` is a writable scalar and `process` is a live handle. - let result = unsafe { GetExitCodeProcess(process, &mut exit_code) }; - // SAFETY: `process` is an owned handle returned by OpenProcess. - unsafe { CloseHandle(process) }; - if result == 0 { - return None; - } - Some(exit_code == STILL_ACTIVE) -} - -#[cfg(not(any(unix, windows)))] -fn project_write_lock_process_is_alive(_process_id: u64) -> Option { - None -} - -fn project_write_lock_owner_pid(path: &Path) -> Option { - fs::read_to_string(path) - .ok() - .and_then(|content| serde_json::from_str::(&content).ok()) - .and_then(|payload| payload.get("pid").and_then(serde_json::Value::as_u64)) -} - -fn project_write_lock_is_owned_by_current_process(path: &Path) -> bool { - project_write_lock_owner_pid(path) == Some(u64::from(std::process::id())) -} - -fn project_write_lock_age_seconds(path: &Path, metadata: &fs::Metadata) -> u64 { - let created_at = fs::read_to_string(path) - .ok() - .and_then(|content| serde_json::from_str::(&content).ok()) - .and_then(|payload| payload.get("createdAt").and_then(serde_json::Value::as_u64)); - if let Some(created_at) = created_at { - return unix_timestamp().saturating_sub(created_at); - } - metadata - .modified() - .ok() - .and_then(|modified| modified.elapsed().ok()) - .map(|elapsed| elapsed.as_secs()) - .unwrap_or_default() -} - -fn project_write_lock_can_be_reclaimed(path: &Path) -> bool { - let Ok(metadata) = fs::symlink_metadata(path) else { - return false; - }; - if metadata.file_type().is_symlink() - || windows_metadata_is_reparse_point(&metadata) - || !metadata.is_file() - || metadata.len() > PROJECT_WRITE_LOCK_MAX_BYTES - { - return false; - } - let content = fs::read_to_string(path).ok(); - let owner_pid = content - .as_deref() - .and_then(|content| serde_json::from_str::(content).ok()) - .and_then(|payload| payload.get("pid").and_then(serde_json::Value::as_u64)); - if let Some(owner_alive) = owner_pid.and_then(project_write_lock_process_is_alive) { - return !owner_alive; - } - project_write_lock_age_seconds(path, &metadata) > PROJECT_WRITE_LOCK_STALE_AFTER_SECONDS -} - -fn project_write_lock_open_error_is_contention(error: &std::io::Error) -> bool { - if error.kind() == std::io::ErrorKind::AlreadyExists { - return true; - } - #[cfg(windows)] - { - // Windows can report an existing or delete-pending create_new target as - // ACCESS_DENIED instead of ALREADY_EXISTS while another thread drops it. - return error.kind() == std::io::ErrorKind::PermissionDenied - || matches!(error.raw_os_error(), Some(5 | 32 | 33)); - } - #[cfg(not(windows))] - false -} - -#[cfg(all(test, windows))] -#[test] -fn project_write_lock_treats_windows_target_races_as_contention() { - for code in [5, 32, 33] { - assert!( - project_write_lock_open_error_is_contention(&std::io::Error::from_raw_os_error(code)), - "Windows project lock error {code} must enter the bounded contention wait" - ); - } -} - -fn resolve_project_write_lock_path(root: &Path) -> Result { - let normalized = normalize_relative_path(PROJECT_WRITE_LOCK_PATH)?; - let (parent_relative, file_name) = normalized - .rsplit_once('/') - .ok_or_else(|| "项目写锁路径必须包含安全父目录".to_string())?; - let parent = resolve_local_project_path(root, parent_relative)?; - // create_new is the authority for the final lock component. On Windows a - // delete-pending lock can make a metadata preflight fail with ACCESS_DENIED - // before the existing bounded contention wait has a chance to run. - Ok(parent.join(file_name)) -} - -pub(crate) fn acquire_project_write_lock( - root: &Path, - command_id: &str, -) -> Result { - validate_project_root(root)?; - let mut path = resolve_project_write_lock_path(root)?; - if let Some(parent) = path.parent() { - ensure_game_creator_private_directory_tree(parent, "项目锁目录")?; - prepare_game_creator_private_path_for_read(parent, true, "项目锁目录")?; - } - // Re-check the parent after creation so skipping metadata only for the final - // create_new target cannot weaken the normal ancestor link/reparse checks. - path = resolve_project_write_lock_path(root)?; - let payload = serde_json::json!({ - "commandId": command_id, - "pid": std::process::id(), - "createdAt": unix_timestamp(), - "nonce": PROJECT_WRITE_LOCK_NONCE.fetch_add(1, std::sync::atomic::Ordering::Relaxed), - }); - let content = serde_json::to_string_pretty(&payload) - .map_err(|error| format!("生成项目写锁失败:{error}"))?; - let mut retried_after_reclaim = false; - loop { - let mut options = fs::OpenOptions::new(); - options.create_new(true).write(true); - #[cfg(windows)] - { - use std::os::windows::fs::OpenOptionsExt; - options.custom_flags(PROJECT_FILE_FLAG_OPEN_REPARSE_POINT); - } - match options.open(&path) { - Ok(mut file) => { - if let Err(error) = harden_new_game_creator_private_path(&path, false, "项目写锁") - { - drop(file); - let _ = fs::remove_file(&path); - return Err(error); - } - if let Err(error) = file.write_all(content.as_bytes()) { - let _ = fs::remove_file(&path); - return Err(format!("写入项目写锁失败:{}: {error}", path.display())); - } - prepare_game_creator_private_path_for_read(&path, false, "项目写锁")?; - return Ok(ProjectWriteLock { - path, - content: content.clone(), - bypassed_same_process: false, - }); - } - Err(error) - if project_write_lock_open_error_is_contention(&error) - && !retried_after_reclaim - && project_write_lock_can_be_reclaimed(&path) => - { - fs::remove_file(&path).map_err(|error| { - format!("清理失效项目写锁失败:{}: {error}", path.display()) - })?; - retried_after_reclaim = true; - } - Err(error) - if project_write_lock_open_error_is_contention(&error) - && crate::agent::autonomous_game_build_root_run_active_at(root) - && project_write_lock_is_owned_by_current_process(&path) => - { - // The autonomous game-build lane intentionally permits - // parallel specialist actions. If the durable lock belongs - // to this very process, contention is an in-process overlap, - // not another application editing the project. Return an - // advisory guard and leave the real lock untouched. - return Ok(ProjectWriteLock { - path, - content: String::new(), - bypassed_same_process: true, - }); - } - Err(error) if project_write_lock_open_error_is_contention(&error) => { - return Err(format!("项目正在被其他写操作占用:{}", path.display())); - } - Err(error) => { - return Err(format!("创建项目写锁失败:{}: {error}", path.display())); - } - } - } -} - pub(crate) fn list_local_project_files_at( root: &Path, ) -> Result { @@ -305,6 +30,7 @@ pub(crate) fn list_local_project_files_at( if is_agent_runtime_private_control_path(&relative_path) || is_agent_checkpoint_control_path(&relative_path) || is_agent_workbench_control_path(&relative_path) + || is_agent_planning_storage_path(&relative_path) { continue; } @@ -408,10 +134,11 @@ pub(crate) fn reject_agent_runtime_private_control_path( /// the planning Agent needs `file.read`/`file.list` observations while its /// durable writer is still the only component allowed to mutate the sidecar. pub(crate) fn is_agent_planning_storage_path(normalized_path: &str) -> bool { - normalized_path.eq_ignore_ascii_case(".agent/planning") - || normalized_path - .to_ascii_lowercase() - .starts_with(".agent/planning/") + let normalized_path = normalized_path.to_ascii_lowercase(); + normalized_path == ".agent/planning" + || normalized_path.starts_with(".agent/planning/") + || normalized_path == ".agent/planning-v2" + || normalized_path.starts_with(".agent/planning-v2/") } pub(crate) fn is_agent_planning_managed_write_path(normalized_path: &str) -> bool { @@ -424,7 +151,7 @@ pub(crate) fn reject_agent_planning_storage_write_path( ) -> Result<(), String> { if is_agent_planning_managed_write_path(normalized_path) { return Err( - "`.agent/planning/**` 与 `game/fast_gdd.md` 只能由立项策划 Runtime 专用存储层写入,通用文件写入被拒绝" + "`.agent/planning/**`、`.agent/planning-v2/**` 与 `game/fast_gdd.md` 只能由立项策划 Runtime 专用存储层写入,通用文件写入被拒绝" .to_string(), ); } @@ -844,7 +571,9 @@ pub(crate) fn validate_project_root(root: &Path) -> Result<(), String> { Ok(()) } -fn windows_metadata_is_reparse_point(metadata: &fs::Metadata) -> bool { +/// 路径是否是指向别处的 reparse point(符号链接、junction 等)。项目锁文件与项目文件 +/// 遍历都要靠它拒绝“名字在项目里、内容在项目外”的对象,因此对 `write_lock` 可见。 +pub(crate) fn windows_metadata_is_reparse_point(metadata: &fs::Metadata) -> bool { #[cfg(windows)] { use std::os::windows::fs::MetadataExt; diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs b/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs index 6059702a5..c9e73140c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs @@ -9,6 +9,32 @@ static MANIFEST_LOCK_OPEN_GUARD: OnceLock> = OnceLock::new(); pub(crate) const GAME_CREATION_PROJECT_NAME_MAX_CHARS: usize = 80; +const DEFAULT_GAME_PACKAGE_JSON: &str = r#"{ + "name": "agc-game", + "private": true, + "type": "module", + "scripts": { + "build": "vite build", + "dev": "vite" + }, + "dependencies": { + "phaser": "4.2.1" + }, + "devDependencies": { + "vite": "^6.2.0" + } +} +"#; + +const DEFAULT_GAME_VITE_CONFIG: &str = r#"import { defineConfig } from 'vite'; + +export default defineConfig({ + root: '.', + base: './', + build: { outDir: 'dist', emptyOutDir: true }, +}); +"#; + pub(crate) fn normalize_game_creation_project_name(value: &str) -> Result { let name = value.trim(); if name.is_empty() { @@ -445,6 +471,11 @@ pub(crate) fn init_local_game_project_at( let name = normalize_game_creation_project_name(name)?; prepare_game_creator_project_root_for_read(root, true, "本地项目目录")?; + let create_npm_scaffold = !manifest_storage_exists(&root.join(".agent/manifest.json"))? + && !root.join("index.html").exists() + && !root.join("game/index.html").exists() + && !root.join("package.json").exists() + && !root.join("game/package.json").exists(); for relative in ["game", "assets", "memory", "memory/agents", "exports"] { let path = root.join(relative); ensure_game_creator_private_directory_tree(&path, "本地项目目录")?; @@ -459,6 +490,32 @@ pub(crate) fn init_local_game_project_at( "默认游戏入口", )?; } + if create_npm_scaffold { + for (relative, content, label) in [ + ( + "game/package.json", + DEFAULT_GAME_PACKAGE_JSON, + "游戏 npm 配置", + ), + ( + "game/package-lock.json", + include_str!("../../resources/agc-game-package-lock.json"), + "游戏 npm 锁文件", + ), + ( + "game/vite.config.js", + DEFAULT_GAME_VITE_CONFIG, + "游戏 Vite 配置", + ), + ("game/style.css", DEFAULT_GAME_STYLE_CSS, "游戏样式"), + ("game/game.js", DEFAULT_GAME_SCRIPT_JS, "游戏入口脚本"), + ] { + let path = root.join(relative); + if !prepare_game_creator_private_path_for_read(&path, false, label)? { + crate::write_game_creator_private_file(&path, content.as_bytes(), label)?; + } + } + } let agent_db_path = root.join(".agent/agent.db"); if !agent_db_path.exists() { @@ -1514,3 +1571,41 @@ pub(crate) fn trim_optional_string(value: Option) -> Option { mod import_tests; #[cfg(test)] mod recovery_tests; + +#[cfg(test)] +mod npm_scaffold_tests { + use super::*; + + #[test] + fn npm_scaffold_uses_package_import_and_preserves_user_changes() { + let root = tempfile::tempdir_in(std::env::temp_dir().canonicalize().unwrap()).unwrap(); + init_local_game_project_at(root.path(), "npm-scaffold", "游戏").unwrap(); + let package: serde_json::Value = + serde_json::from_slice(&fs::read(root.path().join("game/package.json")).unwrap()) + .unwrap(); + assert_eq!(package["dependencies"]["phaser"], "4.2.1"); + assert!(fs::read_to_string(root.path().join("game/game.js")) + .unwrap() + .contains("import Phaser from 'phaser'")); + assert!(root.path().join("game/package-lock.json").is_file()); + fs::write(root.path().join("game/game.js"), "user source").unwrap(); + init_local_game_project_at(root.path(), "npm-scaffold", "游戏").unwrap(); + assert_eq!( + fs::read_to_string(root.path().join("game/game.js")).unwrap(), + "user source" + ); + } + + #[test] + fn npm_scaffold_does_not_migrate_an_existing_html_project() { + let root = tempfile::tempdir_in(std::env::temp_dir().canonicalize().unwrap()).unwrap(); + fs::create_dir(root.path().join("game")).unwrap(); + fs::write(root.path().join("game/index.html"), "existing html").unwrap(); + init_local_game_project_at(root.path(), "existing-html", "已有游戏").unwrap(); + assert!(!root.path().join("game/package.json").exists()); + assert_eq!( + fs::read_to_string(root.path().join("game/index.html")).unwrap(), + "existing html" + ); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/verification.rs b/apps/ai-game-creator-shell/src-tauri/src/project/verification.rs index acc780b40..12a677216 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/verification.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/verification.rs @@ -14,16 +14,12 @@ pub(crate) fn run_limited_local_command_at( return Err("项目目录必须是绝对路径".to_string()); } - let game_index_path = root.join("game/index.html"); - prepare_game_creator_private_path_for_read(&game_index_path, false, "游戏入口")?; - let html = fs::read_to_string(&game_index_path) - .map_err(|error| format!("读取游戏入口失败:{}: {error}", game_index_path.display()))?; - if !html.contains(" Result<(PathBuf, String), String> { + let game_root = crate::preview::project_game_root(root); + let index = crate::preview::resolve_preview_path(root, "/")?; + prepare_game_creator_private_path_for_read(&index, false, "游戏入口")?; + let html = fs::read_to_string(&index).map_err(|error| format!("读取游戏入口失败:{error}"))?; + let lower = html.to_ascii_lowercase(); + if !lower.contains(" Result<(), String> { + let tags = regex::Regex::new(r"(?is)<(?:script|link|img|audio|video|source)\b[^>]*>").unwrap(); + let attributes = + regex::Regex::new(r#"(?is)\s+([^\s=/>]+)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))"#) + .unwrap(); + for tag in tags.find_iter(html) { + for attribute in attributes.captures_iter(tag.as_str()) { + let name = attribute.get(1).unwrap().as_str(); + if !name.eq_ignore_ascii_case("src") && !name.eq_ignore_ascii_case("href") { + continue; + } + let value = attribute + .get(2) + .or_else(|| attribute.get(3)) + .or_else(|| attribute.get(4)) + .unwrap() + .as_str(); + if value.is_empty() + || value.starts_with('#') + || value.starts_with("//") + || value.contains(':') + { + continue; + } + let path = value.split(['?', '#']).next().unwrap_or(value); + let path = path.strip_prefix("./").unwrap_or(path); + crate::preview::resolve_preview_path( + root, + &format!("/{}", path.trim_start_matches('/')), + ) + .map_err(|error| format!("构建入口引用不可用:{value}: {error}"))?; + } + } + Ok(()) +} + pub(crate) const PROJECT_VERIFICATION_OUTPUT_MAX_BYTES: usize = 24 * 1024; const PROJECT_VERIFICATION_PACKAGE_MAX_BYTES: u64 = 512 * 1024; const PROJECT_VERIFICATION_MIN_TIMEOUT_SECONDS: u64 = 1; @@ -786,12 +835,7 @@ pub(crate) fn normalize_policy_command_ids(values: Vec) -> Result"#; + fs::write(root.path().join("dist/index.html"), html).unwrap(); + assert!(validate_built_game_references(root.path(), html).is_err()); + fs::write(root.path().join("dist/assets/main.js"), "export {};").unwrap(); + fs::write(root.path().join("dist/assets/main.css"), "body{}").unwrap(); + assert!(validate_built_game_references(root.path(), html).is_ok()); + let lazy_html = r#"src='not-a-reference.png'"#; + assert!(validate_built_game_references(root.path(), lazy_html).is_ok()); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/write_lock.rs b/apps/ai-game-creator-shell/src-tauri/src/project/write_lock.rs new file mode 100644 index 000000000..f615b2ddb --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/project/write_lock.rs @@ -0,0 +1,887 @@ +use super::*; + +#[cfg(windows)] +pub(crate) const PROJECT_FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; + +static PROJECT_WRITE_LOCK_NONCE: std::sync::atomic::AtomicU64 = + std::sync::atomic::AtomicU64::new(1); +const PROJECT_WRITE_LOCK_STALE_AFTER_SECONDS: u64 = 600; +/// 崩溃可能停在 `create_new` 成功、payload 落盘之前,此时锁文件没有任何持有者 +/// 信息。写入方正常情况下在毫秒级完成落盘,所以只需要很短的宽限期就能确认它 +/// 已经放弃,而不是让项目在整整 10 分钟里都不可写。 +const PROJECT_WRITE_LOCK_UNWRITTEN_GRACE_SECONDS: u64 = 30; +/// 进程启动时间与锁 `createdAt` 之间的允许偏差(秒),用来抵消时间戳精度差异。 +const PROJECT_WRITE_LOCK_PID_REUSE_TOLERANCE_SECONDS: u64 = 5; +const PROJECT_WRITE_LOCK_MAX_BYTES: u64 = 4 * 1024; + +#[derive(Debug)] +pub(crate) struct ProjectWriteLock { + path: PathBuf, + content: String, + /// In the free-form autonomous lane a single Runtime process may have + /// several specialist actions in flight at once. A file lock is still + /// useful across processes, but making same-process contenders fail turns + /// ordinary parallel work into a dead run (and can deadlock nested tool + /// calls). Such a contender receives an in-process/advisory guard instead + /// of deleting the real holder's lock on drop. + bypassed_same_process: bool, +} + +impl ProjectWriteLock { + pub(crate) fn guards_project_root(&self, root: &Path) -> Result { + let expected_path = resolve_local_project_path(root, PROJECT_WRITE_LOCK_PATH)?; + if self.bypassed_same_process { + // The relaxed guard deliberately has no ownership of the durable + // `.agent/project.lock` file. It still binds the observation to + // the validated project root so callers cannot use a guard from a + // different project. + return Ok(self.path == expected_path); + } + Ok(self.path == expected_path + && fs::read_to_string(&self.path).is_ok_and(|content| content == self.content)) + } +} + +impl Drop for ProjectWriteLock { + fn drop(&mut self) { + if self.bypassed_same_process { + return; + } + if fs::read_to_string(&self.path).is_ok_and(|content| content == self.content) { + let _ = fs::remove_file(&self.path); + } + } +} + +#[cfg(unix)] +fn project_write_lock_process_is_alive(process_id: u64) -> Option { + // Unix 的 pid_t 是有符号 32 位且恒大于 0,超出该范围的取值不可能是本机 + // 任何进程,说明锁文件里的 PID 已经损坏,可以直接判定持有者不存在。 + let Some(process_id) = i32::try_from(process_id).ok().filter(|value| *value > 0) else { + return Some(false); + }; + let result = unsafe { libc::kill(process_id, 0) }; + if result == 0 { + return Some(true); + } + match std::io::Error::last_os_error().raw_os_error() { + Some(libc::ESRCH) => Some(false), + Some(libc::EPERM) => Some(true), + _ => None, + } +} + +#[cfg(windows)] +fn project_write_lock_process_is_alive(process_id: u64) -> Option { + use std::ffi::c_void; + + #[link(name = "kernel32")] + unsafe extern "system" { + fn OpenProcess(access: u32, inherit_handle: i32, process_id: u32) -> *mut c_void; + fn GetExitCodeProcess(process: *mut c_void, exit_code: *mut u32) -> i32; + fn CloseHandle(handle: *mut c_void) -> i32; + } + + // Windows 进程号是 32 位且恒大于 0,超出该范围的取值不可能是本机任何 + // 进程,说明锁文件里的 PID 已经损坏,可以直接判定持有者不存在。 + let Some(process_id) = u32::try_from(process_id).ok().filter(|value| *value > 0) else { + return Some(false); + }; + const PROCESS_QUERY_LIMITED_INFORMATION: u32 = 0x1000; + const STILL_ACTIVE: u32 = 259; + // SAFETY: OpenProcess returns an owned kernel handle or null; it is + // closed below. We only request the query permission needed here. + let process = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, process_id) }; + if process.is_null() { + // ERROR_INVALID_PARAMETER means the process no longer exists. For + // access-denied/other failures we cannot prove liveness, so keep the + // conservative unknown result and let the normal bounded wait decide. + return match std::io::Error::last_os_error().raw_os_error() { + Some(87) => Some(false), + _ => None, + }; + } + let mut exit_code = 0_u32; + // SAFETY: `exit_code` is a writable scalar and `process` is a live handle. + let result = unsafe { GetExitCodeProcess(process, &mut exit_code) }; + // SAFETY: `process` is an owned handle returned by OpenProcess. + unsafe { CloseHandle(process) }; + if result == 0 { + return None; + } + Some(exit_code == STILL_ACTIVE) +} + +#[cfg(not(any(unix, windows)))] +fn project_write_lock_process_is_alive(_process_id: u64) -> Option { + None +} + +/// 读取进程的启动时间(Unix 秒)。用来区分“锁记录里的 PID 仍然属于原来的持有 +/// 者”和“PID 已经被系统复用给另一个进程”。无法判定的平台返回 `None`,此时 +/// 保持原有的保守回收策略。 +#[cfg(windows)] +pub(crate) fn project_write_lock_process_start_time_seconds(process_id: u64) -> Option { + use std::ffi::c_void; + + #[repr(C)] + struct FileTime { + low_date_time: u32, + high_date_time: u32, + } + + #[link(name = "kernel32")] + unsafe extern "system" { + fn OpenProcess(access: u32, inherit_handle: i32, process_id: u32) -> *mut c_void; + fn GetProcessTimes( + process: *mut c_void, + creation_time: *mut FileTime, + exit_time: *mut FileTime, + kernel_time: *mut FileTime, + user_time: *mut FileTime, + ) -> i32; + fn CloseHandle(handle: *mut c_void) -> i32; + } + + const PROCESS_QUERY_LIMITED_INFORMATION: u32 = 0x1000; + /// Windows FILETIME 起点(1601-01-01)到 Unix 纪元之间的 100 纳秒数。 + const FILETIME_UNIX_EPOCH_OFFSET: u64 = 116_444_736_000_000_000; + let process_id = u32::try_from(process_id).ok().filter(|value| *value > 0)?; + // SAFETY: OpenProcess returns an owned kernel handle or null; it is closed below. + let process = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, process_id) }; + if process.is_null() { + return None; + } + // SAFETY: every FileTime is plain data filled by GetProcessTimes. + let mut creation = unsafe { std::mem::zeroed::() }; + let mut exit = unsafe { std::mem::zeroed::() }; + let mut kernel = unsafe { std::mem::zeroed::() }; + let mut user = unsafe { std::mem::zeroed::() }; + // SAFETY: `process` is a live handle and all four pointers are writable scalars. + let result = + unsafe { GetProcessTimes(process, &mut creation, &mut exit, &mut kernel, &mut user) }; + // SAFETY: `process` is an owned handle returned by OpenProcess. + unsafe { CloseHandle(process) }; + if result == 0 { + return None; + } + let file_time = (u64::from(creation.high_date_time) << 32) | u64::from(creation.low_date_time); + file_time + .checked_sub(FILETIME_UNIX_EPOCH_OFFSET) + .map(|unix_100ns| unix_100ns / 10_000_000) +} + +#[cfg(target_os = "linux")] +pub(crate) fn project_write_lock_process_start_time_seconds(process_id: u64) -> Option { + let process_id = u32::try_from(process_id).ok().filter(|value| *value > 0)?; + // SAFETY: sysconf has no memory safety preconditions and returns -1 on failure. + let clock_ticks = unsafe { libc::sysconf(libc::_SC_CLK_TCK) }; + if clock_ticks <= 0 { + return None; + } + let stat = fs::read_to_string(format!("/proc/{process_id}/stat")).ok()?; + let start_ticks = stat + .rsplit_once(") ")? + .1 + .split_whitespace() + .nth(19)? + .parse::() + .ok()?; + let boot_time = fs::read_to_string("/proc/stat") + .ok()? + .lines() + .find_map(|line| line.strip_prefix("btime "))? + .trim() + .parse::() + .ok()?; + Some(boot_time + start_ticks / clock_ticks as u64) +} + +#[cfg(not(any(windows, target_os = "linux")))] +pub(crate) fn project_write_lock_process_start_time_seconds(_process_id: u64) -> Option { + None +} + +/// 一次读到的锁文件字节与解析结果。回收判据和随后的删除必须基于同一份快照: +/// 分别重读 `pid` / `createdAt` / `processStartedAt` 会把旧 inode 的持有者信息 +/// 和新 inode 的启动身份拼在一起,也会让判定与删除命中不同的文件。 +#[derive(Debug, Clone)] +pub(crate) struct ProjectWriteLockSnapshot { + content: Vec, + command_id: Option, + pid: Option, + created_at: Option, + process_started_at: Option, +} + +impl ProjectWriteLockSnapshot { + pub(crate) fn read(path: &Path) -> Option { + let content = fs::read(path).ok()?; + let payload = serde_json::from_slice::(&content).ok(); + let number = |key: &str| { + payload + .as_ref() + .and_then(|payload| payload.get(key)) + .and_then(serde_json::Value::as_u64) + }; + Some(Self { + command_id: payload + .as_ref() + .and_then(|payload| payload.get("commandId")) + .and_then(serde_json::Value::as_str) + .map(str::to_string), + pid: number("pid"), + created_at: number("createdAt"), + process_started_at: number("processStartedAt"), + content, + }) + } + + /// 持锁方身份的单行描述。Issue #318 的现场只有一句"别人在写",无法回答"到底是谁、 + /// 是不是自己人",所以争用错误和等待日志都要带上这几个字段。 + /// `ownerIsSelf` 用 `pid` 判定:`true` 是同进程另一条写通道,`false` 才是真外部进程。 + pub(crate) fn describe_holder(&self) -> String { + format!( + "commandId={} pid={} createdAt={} ownerIsSelf={}", + self.command_id.as_deref().unwrap_or("unknown"), + self.pid + .map(|pid| pid.to_string()) + .unwrap_or_else(|| "unknown".to_string()), + self.created_at + .map(|created_at| created_at.to_string()) + .unwrap_or_else(|| "unknown".to_string()), + match self.pid { + Some(pid) if pid == u64::from(std::process::id()) => "true", + Some(_) => "false", + None => "unknown", + }, + ) + } +} + +fn project_write_lock_is_owned_by_current_process(path: &Path) -> bool { + ProjectWriteLockSnapshot::read(path).and_then(|snapshot| snapshot.pid) + == Some(u64::from(std::process::id())) +} + +/// 读取锁文件 mtime 的 Unix 秒数;读不到时返回 `None`。调用方必须把“mtime 未知” +/// 和“mtime 等于纪元 0”区分开:后者会被算成极大的年龄,反而把保守判定反转成 +/// “立刻回收”,甚至把活持有者的锁当成 PID 复用抢走。 +fn project_write_lock_file_modified_seconds(metadata: &fs::Metadata) -> Option { + metadata + .modified() + .ok() + .and_then(|modified| modified.duration_since(UNIX_EPOCH).ok()) + .map(|duration| duration.as_secs()) +} + +/// 锁文件年龄(秒)。`createdAt` 与 mtime 都无法确定时返回 `None`:未知年龄只能 +/// 按“不回收”处理,不能退化成 0 或极大值。 +fn project_write_lock_age_seconds( + snapshot: &ProjectWriteLockSnapshot, + modified_at: Option, + now: u64, +) -> Option { + if let Some(created_at) = snapshot.created_at { + return Some(now.saturating_sub(created_at)); + } + modified_at.map(|modified_at| now.saturating_sub(modified_at)) +} + +/// 回收判据。进程存活与启动时间查询作为参数传入,便于用确定性用例覆盖真实进程 +/// 难以构造的分支(存活状态无法判定、mtime 不可读)。 +pub(crate) fn project_write_lock_reclaim_decision( + snapshot: &ProjectWriteLockSnapshot, + modified_at: Option, + now: u64, + process_is_alive: impl Fn(u64) -> Option, + process_started_at: impl Fn(u64) -> Option, +) -> bool { + let Some(owner_pid) = snapshot.pid else { + // 没有可用的持有者信息(空锁、坏锁、无数字 pid 的锁):只按短宽限期回收。 + return project_write_lock_age_seconds(snapshot, modified_at, now) + .is_some_and(|age| age > PROJECT_WRITE_LOCK_UNWRITTEN_GRACE_SECONDS); + }; + match process_is_alive(owner_pid) { + Some(false) => true, + Some(true) => { + // PID 会被系统复用,必须确认当前同名进程就是当时的持有者。 + match (snapshot.process_started_at, process_started_at(owner_pid)) { + // 新锁自带启动身份:同一进程的身份恒定,不一致即为 PID 复用。 + (Some(stored), Some(actual)) => stored != actual, + // 旧锁没有启动身份,只能用“启动时间晚于锁创建时间”推断 PID 复用; + // 锁创建时间未知时不做推断,避免把“未知”当成“复用”抢走活持有者。 + (None, Some(actual)) => { + let Some(lock_created_at) = snapshot.created_at.or(modified_at) else { + return false; + }; + actual + > lock_created_at + .saturating_add(PROJECT_WRITE_LOCK_PID_REUSE_TOLERANCE_SECONDS) + } + _ => false, + } + } + // 无法判定持有者是否存活时保持保守策略:只有明显过期才回收。 + None => project_write_lock_age_seconds(snapshot, modified_at, now) + .is_some_and(|age| age > PROJECT_WRITE_LOCK_STALE_AFTER_SECONDS), + } +} + +/// 判定残留锁可回收时返回判定所依据的快照,否则返回 `None`。 +fn project_write_lock_reclaimable_snapshot(path: &Path) -> Option { + let metadata = fs::symlink_metadata(path).ok()?; + if metadata.file_type().is_symlink() + || windows_metadata_is_reparse_point(&metadata) + || !metadata.is_file() + || metadata.len() > PROJECT_WRITE_LOCK_MAX_BYTES + { + return None; + } + let snapshot = ProjectWriteLockSnapshot::read(path)?; + project_write_lock_reclaim_decision( + &snapshot, + project_write_lock_file_modified_seconds(&metadata), + unix_timestamp(), + project_write_lock_process_is_alive, + project_write_lock_process_start_time_seconds, + ) + .then_some(snapshot) +} + +/// 删除判定为残留的锁文件。判定只是快照观察,删除前必须重新核对字节,确认删掉的 +/// 仍是判定时的那个文件:并发方可能已经回收并装上了自己的活锁。文件已经消失或 +/// 已被替换时返回 `false`,让调用方重试 `create_new` 重新竞争,而不是报错。 +pub(crate) fn project_write_lock_reclaim( + path: &Path, + snapshot: &ProjectWriteLockSnapshot, +) -> Result { + match fs::read(path) { + Ok(content) if content == snapshot.content => {} + Ok(_) => return Ok(false), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false), + Err(error) => { + return Err(format!("读取失效项目写锁失败:{}: {error}", path.display())); + } + } + match fs::remove_file(path) { + Ok(()) => Ok(true), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(error) => Err(format!("清理失效项目写锁失败:{}: {error}", path.display())), + } +} + +/// `.agent/project.lock` 的争用错误前缀。`project_gates.rs`、`provider_recovery.rs`、 +/// `planning_session_v2.rs`、`direct_runtime.rs` 和前端 `App.tsx` 都按这个前缀把争用 +/// 识别成"可以等一下"的瞬时状态;文案扩展时要保持前缀逐字不变。 +pub(crate) const PROJECT_WRITE_LOCK_CONTENTION_PREFIX: &str = "项目正在被其他写操作占用:"; + +/// 锁分类判据必须能被两个平台覆盖,所以平台由参数传入而不是藏在 `#[cfg]` 后面: +/// CI 只有 Linux runner,`#[cfg(windows)]` 的用例在 CI 里一次都不会跑,而 Windows 特有的 +/// `ACCESS_DENIED(5)` / sharing violation(32) / lock violation(33) 分支恰恰是最危险的一段。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ProjectWriteLockPlatform { + Windows, + Unix, +} + +pub(crate) const PROJECT_WRITE_LOCK_PLATFORM: ProjectWriteLockPlatform = if cfg!(windows) { + ProjectWriteLockPlatform::Windows +} else { + ProjectWriteLockPlatform::Unix +}; + +/// 一次 `create_new` 失败在**等待契约**上的归类。三类的处置完全不同:可重试、权限拒绝 +/// 必须失败关闭、其它 I/O 错误原样上报。混成一句「项目正在被其他写操作占用」会把 ACL +/// 问题、删除挂起和真实跨进程争用一起藏起来(Issue #318 第 3 条)。 +/// +/// 判据只能是错误码本身。**不要用 `path.exists()` 这种一次 metadata 观察决定"要不要重试"**: +/// 目标被删除时目录项先消失、删除挂起随后才结束,`create_new` 会在这个拆链窗口里返回 +/// `ACCESS_DENIED(5)`,而 `exists()` 往往已经报 false。本机实测 60000 次建锁 / 删锁竞争里 +/// 有 396-538 例命中"5 + 目标不可见";按"目标不存在"判成权限拒绝,等待层就会立刻失败关闭 +/// ——正是 Issue #318 要消灭的"毫秒级直接失败",只是换成了更误导的 ACL 文案。 +/// 终态投影放在等待预算耗尽之后做,见 `ProjectWriteLockFailure::exhausted_projection`。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ProjectWriteLockOpenFailure { + /// 目标被占用、删除挂起或正处于删除拆链窗口:短暂重叠,可重试。 + Retryable, + /// 明确不是争用:权限 / ACL 拒绝,失败关闭。 + Permission, + /// 其它 I/O 错误,原样上报。 + Other, +} + +fn project_write_lock_open_failure_for( + platform: ProjectWriteLockPlatform, + error: &std::io::Error, +) -> ProjectWriteLockOpenFailure { + if error.kind() == std::io::ErrorKind::AlreadyExists { + return ProjectWriteLockOpenFailure::Retryable; + } + match platform { + ProjectWriteLockPlatform::Windows => { + // 32 / 33 是 sharing / lock violation,只可能在目标被占用时出现,恒定可重试。 + // ACCESS_DENIED(5) 既可能是 delete-pending / 删除拆链窗口,也可能是真实 ACL + // 拒绝,错误码上不可区分,因此同样先按可重试处理,由等待方在预算耗尽后再判定终态。 + if matches!(error.raw_os_error(), Some(5 | 32 | 33)) + || error.kind() == std::io::ErrorKind::PermissionDenied + { + return ProjectWriteLockOpenFailure::Retryable; + } + } + ProjectWriteLockPlatform::Unix => { + // Unix 没有删除挂起:目标存在必然先命中 AlreadyExists,EACCES 就是权限拒绝, + // 可以立刻判定,不必让调用方白等一个等待窗口。 + if error.kind() == std::io::ErrorKind::PermissionDenied { + return ProjectWriteLockOpenFailure::Permission; + } + } + } + ProjectWriteLockOpenFailure::Other +} + +/// 一次取锁失败的完整形状。 +/// +/// 等待层需要它做两件事:按分类决定是否重试,以及在预算耗尽后用**当时的**目标状态做终态 +/// 投影。把"这一次失败"整份传下去,调用方就不必回头解析错误文案。 +#[derive(Debug)] +pub(crate) enum ProjectWriteLockFailure { + /// 目标被占用、删除挂起或正处于删除拆链窗口:允许进入有界等待。 + /// 保留平台与 `create_new` 的原始错误,等待层才能在预算耗尽后做终态投影。 + Retryable { + platform: ProjectWriteLockPlatform, + path: PathBuf, + source: std::io::Error, + }, + /// 已经定稿、不可重试的失败文案:权限拒绝、其它 I/O 错误、前置校验失败。 + Terminal(String), +} + +/// 只有 Windows 的 `ACCESS_DENIED(5)` 才可能在等待之后被改判:它在分类阶段与删除拆链 +/// 窗口不可区分。平台必须一起传进来——Linux 上 errno 5 是 `EIO` 而不是 `EACCES`, +/// 只看 `kind()` 会让同一条判据在两个平台上得出不同结论,而这条判据正是要在 CI 上跑。 +fn project_write_lock_permission_is_ambiguous( + platform: ProjectWriteLockPlatform, + source: &std::io::Error, +) -> bool { + platform == ProjectWriteLockPlatform::Windows + && (source.kind() == std::io::ErrorKind::PermissionDenied + || source.raw_os_error() == Some(5)) +} + +impl ProjectWriteLockFailure { + /// 是否允许进入有界等待。判据是失败分类,不是错误文案。 + pub(crate) fn is_retryable(&self) -> bool { + matches!(self, Self::Retryable { .. }) + } + + /// 零等待入口的文案。可重试的失败保持争用前缀逐字不变:`provider_recovery.rs`、 + /// `planning_session_v2.rs`、`direct_runtime.rs` 和前端 `App.tsx` 都按这个前缀把错误 + /// 当成可等待的瞬时状态,改前缀等于顺手改掉它们的重试语义。 + pub(crate) fn message(&self) -> String { + match self { + Self::Terminal(message) => message.clone(), + Self::Retryable { + platform, + path, + source, + } => project_write_lock_contention_error( + path, + ProjectWriteLockSnapshot::read(path).as_ref(), + project_write_lock_permission_is_ambiguous(*platform, source) && !path.exists(), + ), + } + } + + /// 等待预算耗尽后的终态投影:`(日志分类, 给用户的文案)`。 + /// + /// 删除拆链窗口是微秒级:真的等过预算(`waited`)仍在失败、且目标此刻仍然不存在, + /// 说明这不是瞬时争用而是权限 / ACL 拒绝,此时才改判。单次试探(`max_attempts == 1`) + /// 没有等待证据,保持争用语义,不做终态改判。 + pub(crate) fn exhausted_projection(&self, waited: bool) -> (&'static str, String) { + let Self::Retryable { + platform, + path, + source, + } = self + else { + return ("terminal", self.message()); + }; + if waited && project_write_lock_permission_is_ambiguous(*platform, source) && !path.exists() + { + return ( + "permission_denied", + project_write_lock_permission_error(path, source), + ); + } + ("contention", self.message()) + } +} + +/// 争用错误必须带上持锁方身份。锁文件处于 delete-pending 或尚未写完时读不到身份, +/// 也必须显式表达成"不可读",不能默认成"没有持锁方"。 +/// +/// `permission_ambiguous` 为真表示目标此刻不存在、而错误码是 Windows 上无法与权限拒绝 +/// 区分的 `ACCESS_DENIED(5)`:零等待入口没有等待窗口可以证伪,文案必须把两种处置都说 +/// 出来,而不是替调用方猜一个。 +fn project_write_lock_contention_error( + path: &Path, + snapshot: Option<&ProjectWriteLockSnapshot>, + permission_ambiguous: bool, +) -> String { + match snapshot { + Some(snapshot) => format!( + "{PROJECT_WRITE_LOCK_CONTENTION_PREFIX}{}(持锁方 {})", + path.display(), + snapshot.describe_holder() + ), + None if permission_ambiguous => format!( + "{PROJECT_WRITE_LOCK_CONTENTION_PREFIX}{}(持锁方身份不可读:锁文件此刻不存在,可能是删除挂起、删除拆链窗口或权限 / ACL 拒绝)", + path.display() + ), + None => format!( + "{PROJECT_WRITE_LOCK_CONTENTION_PREFIX}{}(持锁方身份不可读:锁文件可能处于删除挂起或尚未写完)", + path.display() + ), + } +} + +fn project_write_lock_permission_error(path: &Path, error: &std::io::Error) -> String { + // 文案刻意不含争用前缀:`..._with_wait`、`provider_recovery.rs` 和前端都按前缀把 + // 错误当成"等一下就好"的瞬时状态,权限拒绝必须失败关闭。 + format!( + "项目写锁路径权限被拒绝,不是写锁争用(请检查项目目录与 .agent 目录的 ACL):{}: {error}", + path.display() + ) +} + +/// 等待预算耗尽时写进 App 日志的持锁方快照。 +pub(crate) fn project_write_lock_contention_diagnostic(root: &Path) -> String { + let Ok(path) = resolve_project_write_lock_path(root) else { + return "持锁方身份不可解析".to_string(); + }; + match ProjectWriteLockSnapshot::read(&path) { + Some(snapshot) => snapshot.describe_holder(), + None => "持锁方身份不可读(锁文件可能处于删除挂起或尚未写完)".to_string(), + } +} + +/// **重试性只由错误码决定,不由一次 metadata 观察决定。** 这条用例把平台作为参数, +/// 因此 CI 的 Linux runner 也会执行 Windows 分支:删除拆链窗口里 `create_new` 报 +/// `ACCESS_DENIED(5)` 而目标已经不可见,用 `exists()` 判"要不要等"会把这批瞬时失败判死。 +#[test] +fn project_write_lock_retryability_comes_from_the_error_code_not_a_metadata_probe() { + for error in [ + std::io::Error::from_raw_os_error(5), + std::io::Error::from_raw_os_error(32), + std::io::Error::from_raw_os_error(33), + std::io::Error::from(std::io::ErrorKind::PermissionDenied), + std::io::Error::from(std::io::ErrorKind::AlreadyExists), + ] { + assert_eq!( + project_write_lock_open_failure_for(ProjectWriteLockPlatform::Windows, &error), + ProjectWriteLockOpenFailure::Retryable, + "Windows 上 {error:?} 必须进入有界等待" + ); + } + assert_eq!( + project_write_lock_open_failure_for( + ProjectWriteLockPlatform::Unix, + &std::io::Error::from(std::io::ErrorKind::AlreadyExists) + ), + ProjectWriteLockOpenFailure::Retryable, + "Unix 上目标存在必须是争用" + ); + // Unix 没有删除挂起,EACCES 就是权限拒绝,可以立刻判定,不必白等一个等待窗口。 + assert_eq!( + project_write_lock_open_failure_for( + ProjectWriteLockPlatform::Unix, + &std::io::Error::from(std::io::ErrorKind::PermissionDenied) + ), + ProjectWriteLockOpenFailure::Permission + ); + for platform in [ + ProjectWriteLockPlatform::Windows, + ProjectWriteLockPlatform::Unix, + ] { + assert_eq!( + project_write_lock_open_failure_for( + platform, + &std::io::Error::from(std::io::ErrorKind::NotFound) + ), + ProjectWriteLockOpenFailure::Other, + "其它 I/O 错误必须原样上报,不进入等待" + ); + } +} + +/// 终态改判的三个条件必须同时成立:真的等过预算、目标此刻仍不存在、错误码是 Windows 上 +/// 不可区分的 `ACCESS_DENIED(5)`。缺任何一个都保持争用语义(前缀逐字不变)。 +/// +/// 平台连同错误一起构造,用例因此不依赖宿主的 errno 语义:Linux 上 errno 5 是 `EIO` +/// 而不是 `EACCES`,只看 `kind()` 会让同一条判据在 CI 与 Windows 上得出不同结论。 +#[test] +fn project_write_lock_exhausted_projection_needs_a_waited_budget_and_a_missing_target() { + let temporary = tempfile::tempdir().expect("create projection root"); + let path = temporary.path().join("project.lock"); + let ambiguous = ProjectWriteLockFailure::Retryable { + platform: ProjectWriteLockPlatform::Windows, + path: path.clone(), + source: std::io::Error::from_raw_os_error(5), + }; + assert!( + ambiguous.is_retryable(), + "ACCESS_DENIED 必须允许进入有界等待" + ); + + // 目标不存在 + 真的等过预算:改判权限拒绝,文案不得再含争用前缀。 + let (projection, message) = ambiguous.exhausted_projection(true); + assert_eq!(projection, "permission_denied"); + assert!( + !message.starts_with(PROJECT_WRITE_LOCK_CONTENTION_PREFIX), + "预算耗尽且目标缺失时不得再投影成争用:{message}" + ); + + // 同一形状的单次试探没有等待证据:保持争用语义,前缀逐字不变。 + let (projection, message) = ambiguous.exhausted_projection(false); + assert_eq!(projection, "contention"); + assert!( + message.starts_with(PROJECT_WRITE_LOCK_CONTENTION_PREFIX), + "{message}" + ); + + // 目标此刻存在(真实争用或带句柄的删除挂起):永远按争用上报。 + fs::write(&path, b"{}").expect("write a visible lock fixture"); + let (projection, message) = ambiguous.exhausted_projection(true); + assert_eq!(projection, "contention"); + assert!( + message.starts_with(PROJECT_WRITE_LOCK_CONTENTION_PREFIX), + "{message}" + ); + fs::remove_file(&path).expect("remove lock fixture"); + + // 32 / 33 只可能在目标被占用时出现,不会因为目标缺失被改判成权限拒绝。 + let occupied = ProjectWriteLockFailure::Retryable { + platform: ProjectWriteLockPlatform::Windows, + path: path.clone(), + source: std::io::Error::from_raw_os_error(32), + }; + assert_eq!(occupied.exhausted_projection(true).0, "contention"); + + // Unix 侧没有这种不可区分的错误码:EACCES 在分类阶段就是终态,永远不会被改判。 + let unix_denied = ProjectWriteLockFailure::Retryable { + platform: ProjectWriteLockPlatform::Unix, + path: path.clone(), + source: std::io::Error::from_raw_os_error(5), + }; + assert_eq!(unix_denied.exhausted_projection(true).0, "contention"); + + // 明确判定的权限拒绝(Unix EACCES)不携带争用前缀,调用方不会当成瞬时状态。 + let denied = ProjectWriteLockFailure::Terminal(project_write_lock_permission_error( + &path, + &std::io::Error::from(std::io::ErrorKind::PermissionDenied), + )); + assert!(!denied.is_retryable()); + assert!( + !denied + .message() + .starts_with(PROJECT_WRITE_LOCK_CONTENTION_PREFIX), + "{}", + denied.message() + ); +} + +#[cfg(all(test, windows))] +#[test] +fn project_write_lock_hardens_space_containing_path_in_process() { + let parent = tempfile::tempdir().expect("create spaced lock parent"); + let root = parent + .path() + .join("Genarrative GameAgent") + .join("gameagent-space"); + fs::create_dir_all(&root).expect("create spaced project root"); + let lock = acquire_project_write_lock(&root, "planning.v2.approval") + .expect("acquire project lock under a space-containing path"); + let lock_path = root.join(".agent").join("project.lock"); + assert!(lock_path.is_file(), "project lock must exist while held"); + crate::secure_windows_game_creator_path_for_current_user(&lock_path, false, false) + .expect("new project lock must already satisfy the private DACL contract"); + drop(lock); + assert!( + !lock_path.exists(), + "project lock must be removed when the guard is dropped" + ); +} + +fn resolve_project_write_lock_path(root: &Path) -> Result { + let normalized = normalize_relative_path(PROJECT_WRITE_LOCK_PATH)?; + let (parent_relative, file_name) = normalized + .rsplit_once('/') + .ok_or_else(|| "项目写锁路径必须包含安全父目录".to_string())?; + let parent = resolve_local_project_path(root, parent_relative)?; + // create_new is the authority for the final lock component. On Windows a + // delete-pending lock can make a metadata preflight fail with ACCESS_DENIED + // before the existing bounded contention wait has a chance to run. + Ok(parent.join(file_name)) +} + +pub(crate) fn acquire_project_write_lock( + root: &Path, + command_id: &str, +) -> Result { + acquire_project_write_lock_failure(root, command_id).map_err(|failure| failure.message()) +} + +/// 与 `acquire_project_write_lock` 同一实现,但把失败分类交给调用方。 +/// +/// 有界等待必须按失败类型决定是否重试:用错误文案前缀做控制流时,改一次文案就等于改一次 +/// 重试语义。前置校验失败没有可重试语义,统一作为终态文案上报。 +pub(crate) fn acquire_project_write_lock_failure( + root: &Path, + command_id: &str, +) -> Result { + validate_project_root(root).map_err(ProjectWriteLockFailure::Terminal)?; + let mut path = + resolve_project_write_lock_path(root).map_err(ProjectWriteLockFailure::Terminal)?; + if let Some(parent) = path.parent() { + ensure_game_creator_private_directory_tree(parent, "项目锁目录") + .map_err(ProjectWriteLockFailure::Terminal)?; + prepare_game_creator_private_path_for_read(parent, true, "项目锁目录") + .map_err(ProjectWriteLockFailure::Terminal)?; + } + // Re-check the parent after creation so skipping metadata only for the final + // create_new target cannot weaken the normal ancestor link/reparse checks. + path = resolve_project_write_lock_path(root).map_err(ProjectWriteLockFailure::Terminal)?; + let payload = serde_json::json!({ + "commandId": command_id, + "pid": std::process::id(), + // 进程启动身份:崩溃残留锁要靠它区分“PID 被复用”和“持有者仍然活着”。 + "processStartedAt": project_write_lock_process_start_time_seconds(u64::from( + std::process::id() + )), + "createdAt": unix_timestamp(), + "nonce": PROJECT_WRITE_LOCK_NONCE.fetch_add(1, std::sync::atomic::Ordering::Relaxed), + }); + let content = serde_json::to_string_pretty(&payload) + .map_err(|error| format!("生成项目写锁失败:{error}")) + .map_err(ProjectWriteLockFailure::Terminal)?; + let mut retried_after_reclaim = false; + loop { + let mut options = fs::OpenOptions::new(); + options.create_new(true).write(true); + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + options.custom_flags(PROJECT_FILE_FLAG_OPEN_REPARSE_POINT); + } + match options.open(&path) { + Ok(mut file) => { + if let Err(error) = file.write_all(content.as_bytes()) { + drop(file); + let _ = fs::remove_file(&path); + return Err(ProjectWriteLockFailure::Terminal(format!( + "写入项目写锁失败:{}: {error}", + path.display() + ))); + } + if let Err(error) = file.sync_all() { + drop(file); + let _ = fs::remove_file(&path); + return Err(ProjectWriteLockFailure::Terminal(format!( + "落盘项目写锁失败:{}: {error}", + path.display() + ))); + } + drop(file); + if let Err(error) = harden_new_game_creator_private_path(&path, false, "项目写锁") + { + let _ = fs::remove_file(&path); + return Err(ProjectWriteLockFailure::Terminal(error)); + } + let actual = match fs::read_to_string(&path) { + Ok(actual) => actual, + Err(error) => { + let _ = fs::remove_file(&path); + return Err(ProjectWriteLockFailure::Terminal(format!( + "读取项目写锁失败:{}: {error}", + path.display() + ))); + } + }; + if actual != content { + let _ = fs::remove_file(&path); + return Err(ProjectWriteLockFailure::Terminal(format!( + "项目写锁内容校验失败:{}", + path.display() + ))); + } + return Ok(ProjectWriteLock { + path, + content: content.clone(), + bypassed_same_process: false, + }); + } + Err(error) => { + // 是否可重试只看错误码(平台判据见 `project_write_lock_open_failure_for`): + // 拿 `path.exists()` 当场判死会在删除拆链窗口里把瞬时争用变成永久失败。 + let failure = + project_write_lock_open_failure_for(PROJECT_WRITE_LOCK_PLATFORM, &error); + if failure == ProjectWriteLockOpenFailure::Permission { + // 只有 Unix 的 EACCES 能在这里被明确判定(Windows 的 ACCESS_DENIED + // 归可重试,终态由等待方在预算耗尽后投影)。权限拒绝不会重试,所以在 + // 这里记录:它必须能在 App 日志里和"别人正在写"区分开。 + app_log!( + "project.write_lock.permission_denied commandId={command_id} path={} osError={:?}", + path.display(), + error.raw_os_error() + ); + return Err(ProjectWriteLockFailure::Terminal( + project_write_lock_permission_error(&path, &error), + )); + } + if failure == ProjectWriteLockOpenFailure::Other { + return Err(ProjectWriteLockFailure::Terminal(format!( + "创建项目写锁失败:{}: {error}", + path.display() + ))); + } + if !retried_after_reclaim { + if let Some(snapshot) = project_write_lock_reclaimable_snapshot(&path) { + if project_write_lock_reclaim(&path, &snapshot) + .map_err(ProjectWriteLockFailure::Terminal)? + { + app_log!( + "project.write_lock.reclaim_stale commandId={command_id} path={} holder={}", + path.display(), + snapshot.describe_holder() + ); + retried_after_reclaim = true; + continue; + } + } + } + if crate::agent::autonomous_game_build_root_run_active_at(root) + && project_write_lock_is_owned_by_current_process(&path) + { + // The autonomous game-build lane intentionally permits + // parallel specialist actions. If the durable lock belongs + // to this very process, contention is an in-process overlap, + // not another application editing the project. Return an + // advisory guard and leave the real lock untouched. + return Ok(ProjectWriteLock { + path, + content: String::new(), + bypassed_same_process: true, + }); + } + // 争用不在零等待入口里记日志:有界等待会把这个函数调用上千次, + // 每次记一行会淹掉日志。等待方在预算耗尽时记一条带等待时长的记录。 + return Err(ProjectWriteLockFailure::Retryable { + platform: PROJECT_WRITE_LOCK_PLATFORM, + path: path.clone(), + source: error, + }); + } + } + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/provider_handoff.rs b/apps/ai-game-creator-shell/src-tauri/src/provider_handoff.rs index 5cd416a41..6dc87e65f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/provider_handoff.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/provider_handoff.rs @@ -331,7 +331,6 @@ mod tests { provider_config_fingerprint: "2".repeat(64), web_search_enabled: false, allow_idle_context_compaction: false, - planning_session_binding: None, } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/provider_retry.rs b/apps/ai-game-creator-shell/src-tauri/src/provider_retry.rs index e9899a7fb..24cbfb575 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/provider_retry.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/provider_retry.rs @@ -8,8 +8,7 @@ use sha2::{Digest, Sha256}; use crate::agent::{ agent_runtime_json_sidecar_backup_path, read_agent_runtime_json_sidecar_with_max_bytes, - remove_agent_runtime_json_sidecar_backup, validate_plan_provider_session_binding, - write_agent_runtime_json_sidecar_with_max_bytes, PlanProviderSessionBindingV1, + remove_agent_runtime_json_sidecar_backup, write_agent_runtime_json_sidecar_with_max_bytes, }; pub(crate) const PROVIDER_RETRY_SCHEMA_VERSION: &str = "game-creator-provider-retry.v1"; @@ -39,8 +38,6 @@ pub(crate) struct AgentRuntimeProviderRetryIdentity { pub(crate) provider_config_fingerprint: String, pub(crate) web_search_enabled: bool, pub(crate) allow_idle_context_compaction: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) planning_session_binding: Option, } #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] @@ -377,17 +374,7 @@ pub(crate) fn validate_identity( return Err(format!("Provider 重试身份 {label} 不能为空")); } } - // Exact planning requests persist the typed request-context fingerprint; - // historical/non-planning requests retain the bare 64-hex fingerprint. - // Do not apply the legacy bare-only check to a planning retry identity or - // every transient planning failure would fail closed before its retry - // sidecar can be written. - let request_fingerprint_valid = if identity.planning_session_binding.is_some() { - is_typed_sha256(&identity.request_fingerprint) - } else { - is_sha256(&identity.request_fingerprint) - }; - if !request_fingerprint_valid { + if !is_sha256(&identity.request_fingerprint) { return Err("Provider 重试身份的 requestFingerprint 无效".to_string()); } if !is_sha256(&identity.provider_config_fingerprint) { @@ -401,26 +388,6 @@ pub(crate) fn validate_identity( None if identity.goal_revision == 0 && identity.goal_snapshot_fingerprint.is_empty() => {} _ => return Err("Provider 重试身份的 Goal 绑定无效".to_string()), } - if let Some(binding) = identity.planning_session_binding.as_ref() { - validate_plan_provider_session_binding(binding) - .map_err(|error| format!("Provider 重试身份的 planning binding 无效:{error}"))?; - if binding.project_id != identity.project_id - || binding.agent_id != identity.agent_id - || binding.task_id != identity.task_id - || binding.session_id != identity.session_id - || binding.run_id != identity.run_id - || binding.source != identity.source - || binding.request_kind != identity.request_kind - || binding.request_slot != identity.base_request_slot - || binding.web_search_enabled != identity.web_search_enabled - || binding.goal_id != identity.goal_id - || binding.goal_revision != identity.goal_revision - || binding.goal_snapshot_fingerprint != identity.goal_snapshot_fingerprint - || binding.request_context_fingerprint != identity.request_fingerprint - { - return Err("Provider 重试身份与 planning binding 外层字段不一致".to_string()); - } - } Ok(()) } @@ -477,12 +444,6 @@ fn is_sha256(value: &str) -> bool { value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit()) } -fn is_typed_sha256(value: &str) -> bool { - value - .strip_prefix("sha256-serde-json-v2:") - .is_some_and(is_sha256) -} - fn remaining_ms_at(retry_at_ms: u64, at_ms: u64) -> u64 { retry_at_ms.saturating_sub(at_ms) } @@ -589,62 +550,6 @@ mod tests { provider_config_fingerprint: "e".repeat(64), web_search_enabled: true, allow_idle_context_compaction: false, - planning_session_binding: None, - } - } - - fn planning_identity(run_id: &str) -> AgentRuntimeProviderRetryIdentity { - let request_context_fingerprint = format!("sha256-serde-json-v2:{}", "d".repeat(64)); - let mut binding = PlanProviderSessionBindingV1 { - schema_version: crate::PLAN_PROVIDER_SESSION_BINDING_SCHEMA_VERSION.to_string(), - project_id: "project-provider-retry".to_string(), - gdd_id: "gdd-00000000-0000-4000-8000-000000000001".to_string(), - agent_id: crate::GAME_CREATOR_PROJECT_PLANNING_AGENT_ID.to_string(), - task_id: "task-provider-retry".to_string(), - provider_request_id: String::new(), - session_id: "session-provider-retry".to_string(), - run_id: run_id.to_string(), - root_agent_id: crate::GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(), - root_run_id: "run-provider-retry-root".to_string(), - delegation_id: "delegation-provider-retry".to_string(), - goal_id: None, - goal_revision: 0, - goal_snapshot_fingerprint: String::new(), - source: "agent-delegate".to_string(), - run_profile: crate::AGENT_RUNTIME_RUN_PROFILE_STANDARD.to_string(), - run_profile_binding_fingerprint: "a".repeat(64), - session_revision: 1, - session_fingerprint: format!("sha256-serde-json-v2:{}", "b".repeat(64)), - applied_steer_cursor: 2, - request_kind: "tool-plan".to_string(), - request_slot: "loop-2-repair-0".to_string(), - web_search_enabled: false, - request_context_fingerprint: request_context_fingerprint.clone(), - fingerprint: String::new(), - }; - binding.provider_request_id = - crate::agent::plan_provider_session_binding_base_request_id(&binding) - .expect("planning base request id"); - binding.fingerprint = crate::agent::plan_provider_session_binding_fingerprint(&binding) - .expect("planning binding fingerprint"); - AgentRuntimeProviderRetryIdentity { - project_id: binding.project_id.clone(), - agent_id: binding.agent_id.clone(), - task_id: binding.task_id.clone(), - session_id: binding.session_id.clone(), - run_id: binding.run_id.clone(), - source: binding.source.clone(), - goal_id: binding.goal_id.clone(), - goal_revision: binding.goal_revision, - goal_snapshot_fingerprint: binding.goal_snapshot_fingerprint.clone(), - applied_steer_cursor: binding.applied_steer_cursor, - request_kind: binding.request_kind.clone(), - base_request_slot: binding.request_slot.clone(), - request_fingerprint: request_context_fingerprint, - provider_config_fingerprint: "e".repeat(64), - web_search_enabled: false, - allow_idle_context_compaction: false, - planning_session_binding: Some(binding), } } @@ -721,44 +626,6 @@ mod tests { assert!(error.contains("unknown field")); } - #[test] - fn planning_provider_retry_round_trips_typed_request_identity() { - let directory = tempdir().expect("create temp directory"); - let identity = planning_identity("run-planning-retry"); - validate_identity(&identity).expect("planning retry identity validates"); - let record = write_first(directory.path(), &identity); - assert_eq!( - read_matching_at(directory.path(), &identity) - .expect("read planning retry") - .expect("planning retry exists"), - record - ); - assert!(record - .identity - .request_fingerprint - .starts_with("sha256-serde-json-v2:")); - assert_eq!( - record - .identity - .planning_session_binding - .as_ref() - .map(|binding| binding.request_context_fingerprint.as_str()), - Some(record.identity.request_fingerprint.as_str()) - ); - - let mut bare_request_fingerprint = identity.clone(); - bare_request_fingerprint.request_fingerprint = "d".repeat(64); - assert!(validate_identity(&bare_request_fingerprint).is_err()); - - let mut mismatched_binding = identity; - mismatched_binding - .planning_session_binding - .as_mut() - .expect("planning binding") - .request_context_fingerprint = format!("sha256-serde-json-v2:{}", "c".repeat(64)); - assert!(validate_identity(&mismatched_binding).is_err()); - } - #[test] fn provider_retry_reads_atomic_previous_when_primary_is_missing() { let directory = tempdir().expect("create temp directory"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs index ee56cb6e1..cccbe0600 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs @@ -2667,7 +2667,6 @@ fn durable_provider_retry_prevents_shutdown_and_reopens_writes() { provider_config_fingerprint: "b".repeat(64), web_search_enabled: false, allow_idle_context_compaction: false, - planning_session_binding: None, }; let retry = crate::provider_retry::write_next_at( &root, @@ -2771,7 +2770,6 @@ fn durable_provider_handoff_prevents_shutdown_even_when_corrupt() { provider_config_fingerprint: "e".repeat(64), web_search_enabled: false, allow_idle_context_compaction: false, - planning_session_binding: None, }; let response = platform_llm::LlmRunResponse { provider: platform_llm::LlmProvider::OpenAiCompatible, diff --git a/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/input.rs b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/input.rs index 28625dd1e..fdcc3ba13 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/input.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/input.rs @@ -46,13 +46,6 @@ pub(super) enum SwarmNewRunLaunch<'a> { impl SwarmNewRunLaunch<'_> { pub(super) fn expected_parent_source(self) -> Option<&'static str> { match self { - // 受限入口按 source 精确认领自己的 Run:立项策划使用 standard 档, - // 只按 profile 匹配会让 --plan 把上一条 CLI 链路的残留 Run 当成自己的。 - Self::ProjectSupervisor { source, .. } - if source == AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE => - { - Some(source) - } Self::ProjectSupervisor { .. } | Self::ExplicitParentDebug => None, } } @@ -70,17 +63,11 @@ pub(super) fn resolve_swarm_new_run_launch<'a>( return Err(format!("不支持的 Agent Runtime Run Profile:{run_profile}")); } if parent_agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { - if !matches!( - supervisor_source, - AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE | AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE - ) { + if supervisor_source != AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE { return Err(format!( "不支持的 Project Supervisor source:{supervisor_source}" )); } - // 与 GUI「做方案」入口同一条门禁;能力开关和 source 可信性由 task_start 统一判定, - // 这里只把档位冲突提前到起 Run 之前。 - reject_supervisor_plan_autonomous_profile(supervisor_source, run_profile)?; return Ok(SwarmNewRunLaunch::ProjectSupervisor { source: supervisor_source, run_profile, diff --git a/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/tests.rs index 9409e2bf4..9c3c1aedf 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/tests.rs @@ -104,65 +104,6 @@ fn explicit_parent_debug_keeps_standard_profile_only() { .is_err()); } -#[test] -fn plan_entry_never_defers_to_the_interaction_kernel() { - // GUI 的「做方案」是一个直接起 plan 根 Run 的按钮;无头入口若把 reply/execute - // 的判定交给模型,同一句需求就会时而起 Run、时而只回一段口头建议。 - assert!(!swarm_turn_uses_interaction_kernel( - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - Some(AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE), - )); - // 做游戏 / 做素材两条链路继续走交互内核,行为不变。 - assert_eq!( - swarm_turn_uses_interaction_kernel(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, None), - game_creator_agent_uses_interaction_kernel(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID) - ); - assert_eq!( - swarm_turn_uses_interaction_kernel( - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - Some(AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE), - ), - game_creator_agent_uses_interaction_kernel(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID) - ); -} - -#[test] -fn plan_launch_claims_its_own_source_and_rejects_autonomous_profile() { - assert_eq!( - resolve_swarm_new_run_launch( - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - AGENT_RUNTIME_RUN_PROFILE_STANDARD, - AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE, - ) - .expect("resolve plan launch"), - SwarmNewRunLaunch::ProjectSupervisor { - source: AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE, - run_profile: AGENT_RUNTIME_RUN_PROFILE_STANDARD, - } - ); - // 立项策划 Run 和做游戏 Run 都是 standard 档,只有按 source 认领才不会串链。 - assert_eq!( - SwarmNewRunLaunch::ProjectSupervisor { - source: AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE, - run_profile: AGENT_RUNTIME_RUN_PROFILE_STANDARD, - } - .expected_parent_source(), - Some(AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE) - ); - assert!(resolve_swarm_new_run_launch( - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD, - AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE, - ) - .is_err()); - assert!(resolve_swarm_new_run_launch( - "code-prototype", - AGENT_RUNTIME_RUN_PROFILE_STANDARD, - AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE, - ) - .is_err()); -} - #[test] fn same_run_steer_preserves_bound_autonomous_profile() { let root = std::env::temp_dir().join(format!( diff --git a/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/turn_dispatch.rs b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/turn_dispatch.rs index c8a868db9..0254f6301 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/turn_dispatch.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/turn_dispatch.rs @@ -261,17 +261,10 @@ pub(super) fn handle_swarm_user_turn( } } -/// 「做方案」入口在 GUI 上就是一个按钮:直接起 plan 根 Run,没有「直接回复还是 -/// 调用持久能力」这一步。无头入口必须同构——否则同一句需求有时起 Run、有时只得到 -/// 一段口头建议,交付物落不了盘,还得靠用户在措辞里补一句「产出 Fast GDD」把模型 -/// 推向 execute。判定权不该交给措辞。 pub(super) fn swarm_turn_uses_interaction_kernel( parent_agent_id: &str, - expected_parent_source: Option<&str>, + _expected_parent_source: Option<&str>, ) -> bool { - if expected_parent_source.is_some_and(agent_runtime_supervisor_source_is_plan) { - return false; - } game_creator_agent_uses_interaction_kernel(parent_agent_id) } @@ -391,12 +384,7 @@ fn steer_and_wait_for_swarm_turn( ) -> Result { require_external_agent_runner_for_cli_runtime_write(root)?; let steer_id = format!("swarm-steer-{}", unix_millis()); - // 立项策划根 Run 不接受 steer(换根会作废旧委派链剩余的问询轮次),GUI 侧不发起、 - // 后端另有否决。无头入口只有把 plan source 交给后端,这道否决才会生效;其余入口 - // 继续沿用不带 source 的旧行为,免得给 autonomous game-build / CLI 链路加新的一致性判据。 - let steer_source = expected_parent_source - .filter(|source| agent_runtime_supervisor_source_is_plan(source)) - .map(str::to_string); + let steer_source = expected_parent_source.map(str::to_string); let result = tauri::async_runtime::block_on(steer_game_creator_agent_runtime_task( root.display().to_string(), parent_agent_id.to_string(), diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/delegation.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/delegation.rs index 53ee57d2d..7dd5c64a7 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/delegation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/delegation.rs @@ -1132,225 +1132,6 @@ async fn background_agent_runtime_delegate_respects_project_policy() { fs::remove_dir_all(root).ok(); } -#[tokio::test] -async fn visual_specialists_reject_overriding_their_fixed_image_contract() { - let root = unique_project_path(); - init_local_game_project_at(&root, "project-1", "视觉固定输出合同测试").expect("project init"); - write_project_permission_policy_at( - &root, - ProjectPermissionPolicy { - denied_commands: Vec::new(), - confirm_commands: Vec::new(), - agent_policies: BTreeMap::new(), - }, - ) - .expect("allow canvas generation"); - let action = AgentRuntimeToolAction { - tool: "canvas.asset_generate".to_string(), - reason: Some("尝试覆盖策划固定图片合同".to_string()), - input: serde_json::json!({ - "prompt": "生成横屏界面原型", - "outputPath": "assets/wrong-prototype.png", - "aspectRatio": "1:1", - "imageSize": "2K", - "assetKind": "game-art", - "assetLabel": "错误标签", - "replaceExisting": false - }), - }; - - let observation = execute_game_creator_agent_runtime_tool_action_with_action_id( - &root, - "design-foundation", - "visual-fixed-contract-run", - "必须交付固定原型图", - &action, - Some("visual-fixed-contract-action"), - ) - .await; - - assert_eq!(observation.status, "failed"); - for expected in [ - "不能覆盖固定输出合同", - "outputPath=assets/ui-prototype.png", - "aspectRatio=16:9", - "imageSize=2K", - "assetKind=ui-prototype", - "assetLabel=游戏横屏界面原型图", - ] { - assert!(observation.summary.contains(expected)); - } - assert_eq!(observation.detail, None); - assert!(!root.join("assets/wrong-prototype.png").exists()); - assert!(read_manifest_for_project(&root) - .expect("manifest after rejected override") - .assets - .is_empty()); - - let one_k_action = AgentRuntimeToolAction { - tool: "canvas.asset_generate".to_string(), - reason: Some("尝试使用会返回 3:2 文件的 1K 规格".to_string()), - input: serde_json::json!({ - "prompt": "生成横屏界面原型", - "outputPath": "assets/ui-prototype.png", - "aspectRatio": "16:9", - "imageSize": "1K", - "assetKind": "ui-prototype", - "assetLabel": "游戏横屏界面原型图", - "replaceExisting": false - }), - }; - let one_k_observation = execute_game_creator_agent_runtime_tool_action_with_action_id( - &root, - "design-foundation", - "visual-fixed-contract-run", - "UI 原型必须使用真正的 16:9 输出规格", - &one_k_action, - Some("visual-fixed-contract-1k-action"), - ) - .await; - assert_eq!(one_k_observation.status, "failed"); - assert!(one_k_observation.summary.contains("不能覆盖固定输出合同")); - - fs::remove_dir_all(root).ok(); -} - -#[test] -fn design_foundation_rejects_scene_image_stale_run_and_stale_sha_visual_proofs() { - let _platform_session = crate::platform_session::install_test_platform_session( - "visual-semantic-gate-test-user", - "visual-semantic-gate-test-key", - "https://dev.genarrative.world", - ); - let _config_guard = crate::tests::write_test_local_config( - r#"{"editorApi":{"apiKey":"visual-semantic-gate-test-key"}}"#.to_string(), - ); - let root = unique_project_path(); - init_local_game_project_at(&root, "project-1", "UI 原型语义完成门禁测试") - .expect("project init"); - register_canvas_visual_asset_fixture(&root, "assets/art-spec.png", "icon-spec"); - register_canvas_visual_asset_fixture(&root, AGENT_RUNTIME_UI_PROTOTYPE_PATH, "ui-prototype"); - let run_id = "design-ui-semantic-gate-run"; - let state = start_game_creator_agent_runtime_task_at( - &root, - "design-foundation", - "交付真正可实现的 UI 原型", - run_id, - "agent-background-task", - "准备完成", - vec!["生成并检查 UI 原型".to_string()], - ) - .expect("start UI prototype runtime"); - let revision = read_game_creator_agent_runtime_project_revision(&root) - .expect("read UI prototype revision") - .revision; - - let uninspected = finish_game_creator_agent_background_runtime_turn_at( - &root, - state.clone(), - "只有文件登记不能证明它是真正的 UI 原型。", - revision, - &[], - ) - .expect("missing visual verdict remains recoverable"); - let uninspected_blocker = match uninspected { - AgentBackgroundFinalizationOutcome::Stale(blocker) => blocker, - _ => panic!("uninspected image must not complete design-foundation"), - }; - assert!(uninspected_blocker - .detail - .as_deref() - .is_some_and(|detail| detail.contains("requiredInspection=image.inspect"))); - - append_ui_prototype_inspection_fixture_with_profile(&root, run_id, true, "ui-prototype.v1"); - let legacy_v1 = finish_game_creator_agent_background_runtime_turn_at( - &root, - state.clone(), - "历史 v1 审计不能放行当前 run。", - revision, - &[], - ) - .expect("legacy v1 remains recoverable but not authoritative"); - assert!(matches!( - legacy_v1, - AgentBackgroundFinalizationOutcome::Stale(ref blocker) - if blocker.tool == "runtime.visual_asset" - )); - - append_ui_prototype_inspection_fixture(&root, run_id, false); - let scene_blocked = finish_game_creator_agent_background_runtime_turn_at( - &root, - state.clone(), - "场景图不能冒充 UI 原型。", - revision, - &[], - ) - .expect("scene verdict remains recoverable"); - let scene_blocker = match scene_blocked { - AgentBackgroundFinalizationOutcome::Stale(blocker) => blocker, - _ => panic!("scene image must not complete design-foundation"), - }; - assert_eq!(scene_blocker.tool, "runtime.visual_asset"); - assert!(scene_blocker.summary.contains("尚未通过结构化 UI 视觉检查")); - assert!(scene_blocker - .detail - .as_deref() - .is_some_and(|detail| detail.contains("informationHud=false"))); - - append_ui_prototype_inspection_fixture(&root, "another-design-run", true); - let wrong_run = finish_game_creator_agent_background_runtime_turn_at( - &root, - state.clone(), - "其他 run 的证据不能放行。", - revision, - &[], - ) - .expect("wrong run verdict remains recoverable"); - assert!(matches!( - wrong_run, - AgentBackgroundFinalizationOutcome::Stale(ref blocker) - if blocker.tool == "runtime.visual_asset" - )); - - append_ui_prototype_inspection_fixture(&root, run_id, true); - let mut replacement = valid_test_png_bytes(); - replacement.extend_from_slice(b"changed-ui-prototype"); - fs::write(root.join(AGENT_RUNTIME_UI_PROTOTYPE_PATH), replacement) - .expect("replace UI prototype fixture"); - let stale_sha = finish_game_creator_agent_background_runtime_turn_at( - &root, - state.clone(), - "旧图片 SHA 的证据不能放行。", - revision, - &[], - ) - .expect("stale sha verdict remains recoverable"); - let stale_sha_blocker = match stale_sha { - AgentBackgroundFinalizationOutcome::Stale(blocker) => blocker, - _ => panic!("stale image proof must not complete design-foundation"), - }; - assert!(stale_sha_blocker - .detail - .as_deref() - .is_some_and(|detail| detail.contains("requiredInspection=image.inspect"))); - - append_ui_prototype_inspection_fixture(&root, run_id, true); - let completed = finish_game_creator_agent_background_runtime_turn_at( - &root, - state, - "当前图片已通过全部八项 UI 原型检查。", - revision, - &[], - ) - .expect("current passed UI proof allows finalization"); - assert!(matches!( - completed, - AgentBackgroundFinalizationOutcome::Completed(_) - )); - - fs::remove_dir_all(root).ok(); -} - #[tokio::test] async fn supervisor_collaboration_canvas_asset_generate_rechecks_after_project_lock() { let root = unique_project_path(); @@ -1804,6 +1585,8 @@ async fn canvas_replacement_rejects_parent_run_that_terminates_during_external_r "替换不符合原创要求的正式图片", &serde_json::json!({ "prompt": "生成原创晶体与潮汐构装体图集", + "outputPath": "assets/art-spritesheet.png", + "assetKind": "art-spritesheet", "replaceExisting": true }), ), @@ -2181,61 +1964,21 @@ fn gui_and_cli_autonomous_game_build_runs_still_allow_publish_delegates() { } #[test] -fn visual_specialist_delegations_require_image_artifacts_but_read_only_work_allows_none() { - let _platform_session = crate::platform_session::install_test_platform_session( - "visual-delegation-contract-user", - "visual-delegation-contract-key", - "https://dev.genarrative.world", - ); - let _config_guard = crate::tests::write_test_local_config( - r#"{"editorApi":{"apiKey":"visual-delegation-contract-key"}}"#.to_string(), - ); +fn read_only_delegation_persists_acceptance_criteria_without_artifacts() { let root = unique_project_path(); - init_local_game_project_at(&root, "project-1", "视觉委派合同测试").expect("project init"); + init_local_game_project_at(&root, "project-1", "只读委派合同测试").expect("project init"); let parent_run_id = "visual-delegate-contract-parent-run"; start_game_creator_agent_runtime_task_at( &root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - "校验视觉专业任务合同", + "校验只读任务合同", parent_run_id, "agent-chat", "准备委派", - vec!["拒绝用文本回执冒充图片".to_string()], + vec!["保存只读验收标准".to_string()], ) .expect("start supervisor parent runtime"); - for (agent_id, required_path) in [ - ("design-foundation", "assets/ui-prototype.png"), - ("art-asset-plan", "assets/art-spritesheet.png"), - ] { - let action_id = format!("reject-empty-visual-{agent_id}"); - let observation = observe_agent_runtime_agent_delegate( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - parent_run_id, - Some(&action_id), - &serde_json::json!({ - "agentId": agent_id, - "task": "交付真实图片", - "acceptanceCriteria": ["必须能看到图片"], - "expectedArtifacts": [], - "repairOfDelegationId": null, - "runId": null - }), - ); - assert_eq!(observation.status, "failed"); - assert!(observation.summary.contains(required_path)); - let delegation_id = agent_runtime_delegation_id( - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - parent_run_id, - agent_id, - &action_id, - ); - assert!(read_static_delegate_delivery_at(&root, &delegation_id) - .expect("read rejected visual delivery") - .is_none()); - } - let target_agent_id = "quality-review"; let action_id = "allow-read-only-empty-artifacts"; let target_lock = try_acquire_game_creator_agent_runtime_task_lock(&root, target_agent_id) diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/parallel_actions.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/parallel_actions.rs index bd25431d6..429a5710c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/parallel_actions.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/parallel_actions.rs @@ -653,7 +653,7 @@ fn parallel_read_batch_finishes_before_concurrent_steer_is_accepted() { #[tokio::test] async fn provider_action_batch_verification_then_parallel_reads_share_current_gate_snapshot() { let root = unique_project_path(); - init_local_game_project_at( + init_existing_html_project_at( &root, "project-provider-batch-verification-parallel-read", "Provider 批次验证后并行读取测试", diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/policy_batches.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/policy_batches.rs index 3ec30ca5a..926b906ce 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/policy_batches.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/policy_batches.rs @@ -1427,10 +1427,6 @@ async fn agent_runtime_file_tools_cannot_modify_collaboration_policy_or_advance_ } /// 回归:模型在同一轮里既调 update_agent_plan 又调协作工具时,普通批次必须能通过预检。 -/// -/// `provider_batch_plan_update` 只为 planning v4 的 standalone member 恢复而冻结,普通批次成员 -/// 一律保持 None;预检期望值若不按批次类型分叉,就会同时要求成员「等于 plan update」和「不得携带 -/// planning recovery material」,让「更新计划 + 委派专业 Agent」这一最常规动作直接失败。 #[tokio::test] async fn supervisor_collaboration_batch_keeps_plan_update_off_members() { let root = unique_project_path(); @@ -1502,14 +1498,6 @@ async fn supervisor_collaboration_batch_keeps_plan_update_off_members() { panic!("collaboration wave must form ready durable batch"); }; assert!(batch.plan.plan_update.is_some()); - assert!(batch.planning_session_binding.is_none()); - assert!( - batch - .actions - .iter() - .all(|pending| pending.provider_batch_plan_update.is_none()), - "普通批次成员不得携带 planning recovery material", - ); let recovered = read_game_creator_agent_runtime_provider_action_batch( &root, diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/static_deliveries.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/static_deliveries.rs index 4ec311a4a..ea991326f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/static_deliveries.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/static_deliveries.rs @@ -423,448 +423,6 @@ fn drive_static_delegate_clarification_round( /// 走到「标记 needs-user-input -> 认领 -> 用户作答」为止,然后尝试真实 continuation 派发, /// 但断言这一跳必须失败,返回失败的 observation 供调用方核对错误文案。 #[allow(clippy::too_many_arguments)] -fn drive_static_delegate_clarification_round_expect_rejection( - root: &Path, - state: &mut AgentRuntimeState, - parent_agent_id: &str, - parent_run_id: &str, - target_agent_id: &str, - target_session_id: &str, - target_run_id: &str, - task_text: &str, - acceptance_criteria: &[String], - expected_artifacts: &[String], - original_delegation_id: &str, - questions_body: &str, - answer_key: &str, - answer_value: &str, - mark_claim_action_id: &str, - answer_response_id: &str, - delegate_action_id: &str, -) -> AgentRuntimeToolObservation { - mark_and_claim_static_delegate_needs_user_input( - root, - parent_agent_id, - parent_run_id, - target_agent_id, - target_session_id, - target_run_id, - original_delegation_id, - expected_artifacts, - questions_body, - mark_claim_action_id, - ); - let (questions_sha256, answers_sha256) = answer_static_delegate_clarification_wait( - root, - state, - parent_agent_id, - parent_run_id, - original_delegation_id, - BTreeMap::from([(answer_key.to_string(), answer_value.to_string())]), - answer_response_id, - ); - let observation = dispatch_static_delegate_clarification_continuation( - root, - parent_agent_id, - parent_run_id, - target_agent_id, - task_text, - acceptance_criteria, - expected_artifacts, - original_delegation_id, - &questions_sha256, - &answers_sha256, - delegate_action_id, - ); - assert_eq!(observation.status, "failed", "{observation:?}"); - observation -} - -struct PlanningClarificationFixture { - root: PathBuf, - supervisor: AgentRuntimeState, - current_delivery: StaticDelegateDeliveryRecord, - planning_lock: AgentRuntimeTaskLock, - acceptance_criteria: Vec, - expected_artifacts: Vec, -} - -struct AnsweredPlanningClarification { - original_delivery: StaticDelegateDeliveryRecord, - question_id: String, - response_id: String, - questions_sha256: String, - answers_sha256: String, - awaiting_session: PlanSessionV1, -} - -const PLAN_TEST_OPTION_A: &str = "A · 采用当前推荐方案(推荐)"; -const PLAN_TEST_OPTION_B: &str = "B · 采用另一条可行路线"; -const PLAN_TEST_OPTION_PROTOTYPE: &str = "需要原型验证"; - -fn planning_clarification_question_body(round: u32) -> (String, String) { - planning_clarification_question_body_with_labels(round, PLAN_TEST_OPTION_A, PLAN_TEST_OPTION_B) -} - -fn planning_clarification_question_body_with_labels( - round: u32, - label_a: &str, - label_b: &str, -) -> (String, String) { - let question_id = format!("round_{round}_decision"); - let body = serde_json::json!({ - "questions": [{ - "id": question_id, - "header": format!("第{round}轮·当前要决定:第{round}轮核心取舍"), - "question": format!("第{round}轮核心取舍现在确认后才能继续收敛 Fast GDD。"), - "options": [ - { - "label": label_a, - "description": "采用当前推荐方案继续收敛,代价是优先投入这条路线的验证。" - }, - { - "label": label_b, - "description": "采用另一条可行路线,代价是放弃当前推荐的部分确定性。" - }, - { - "label": PLAN_TEST_OPTION_PROTOTYPE, - "description": "用 30~90 分钟微型原型让三名测试者试玩,记录取舍行为并按至少两次符合预期判定。" - } - ] - }] - }) - .to_string(); - (question_id, body) -} - -fn planning_clarification_fixture(tag: &str) -> PlanningClarificationFixture { - let root = unique_project_path(); - init_local_game_project_at( - &root, - &format!("project-planning-clarification-{tag}"), - "Fast GDD planning 澄清投影测试", - ) - .expect("project init"); - let root_run_id = format!("planning-clarification-{tag}-root-run"); - bind_game_creator_agent_runtime_run_profile_at( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &root_run_id, - AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE, - Some(AGENT_RUNTIME_RUN_PROFILE_STANDARD), - None, - ) - .expect("bind planning root"); - let supervisor = start_game_creator_agent_runtime_task_at( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - "做一款需要三轮关键决定的短局游戏", - &root_run_id, - AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE, - "委派 project-planning 收敛 Fast GDD", - vec!["取得可审批的 Fast GDD".to_string()], - ) - .expect("start planning root"); - let planning_lock = try_acquire_game_creator_agent_runtime_task_lock( - &root, - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - ) - .expect("acquire planning target lane") - .expect("planning target lane available"); - let acceptance_criteria = vec!["形成可审批且保留用户决定来源的 Fast GDD".to_string()]; - let expected_artifacts = Vec::::new(); - let action_id = format!("planning-clarification-{tag}-initial-delegate"); - let observation = observe_agent_runtime_agent_delegate( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &root_run_id, - Some(&action_id), - &serde_json::json!({ - "agentId": GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - "task": "根据用户初始需求形成 Fast GDD", - "acceptanceCriteria": acceptance_criteria, - "expectedArtifacts": expected_artifacts, - "repairOfDelegationId": null, - "runId": null - }), - ); - assert_eq!(observation.status, "ok", "{observation:?}"); - let delegation_id = agent_runtime_delegation_id( - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &root_run_id, - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - &action_id, - ); - let current_delivery = read_static_delegate_delivery_at(&root, &delegation_id) - .expect("read initial planning delivery") - .expect("initial planning delivery exists"); - let initial_session = read_plan_session_with_recovery(&root) - .expect("read initial planning session") - .expect("initial planning session exists"); - assert_eq!(initial_session.session_revision, 1); - assert_eq!(initial_session.phase, "collecting"); - assert_eq!( - initial_session.active_run_id.as_deref(), - Some(current_delivery.target_run_id.as_str()) - ); - assert_eq!( - initial_session.latest_delegation_id, - current_delivery.delegation_id - ); - assert!(initial_session.applied_answers.is_empty()); - - let initial_task = read_latest_game_creator_agent_runtime_task_by_delegation_id( - &root, - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - ¤t_delivery.delegation_id, - ) - .expect("read initial planning child task") - .expect("initial planning child task exists"); - assert!( - ensure_plan_session_for_planning_child_task_at(&root, &initial_task) - .expect("replay initial planning projection") - ); - assert_eq!( - read_plan_session_with_recovery(&root) - .expect("reread replayed initial session") - .expect("replayed initial session exists"), - initial_session, - "同一初始 child task 重放不得增加 session revision" - ); - - PlanningClarificationFixture { - root, - supervisor, - current_delivery, - planning_lock, - acceptance_criteria, - expected_artifacts, - } -} - -fn answer_planning_clarification_round( - fixture: &mut PlanningClarificationFixture, - round: u32, - answer: &str, - tag: &str, -) -> AnsweredPlanningClarification { - answer_planning_clarification_round_with_labels( - fixture, - round, - answer, - tag, - PLAN_TEST_OPTION_A, - PLAN_TEST_OPTION_B, - ) -} - -fn answer_planning_clarification_round_with_labels( - fixture: &mut PlanningClarificationFixture, - round: u32, - answer: &str, - tag: &str, - label_a: &str, - label_b: &str, -) -> AnsweredPlanningClarification { - let original_delivery = fixture.current_delivery.clone(); - let (question_id, questions_body) = - planning_clarification_question_body_with_labels(round, label_a, label_b); - mark_and_claim_static_delegate_needs_user_input( - &fixture.root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &fixture.supervisor.run_id, - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - &original_delivery.target_session_id, - &original_delivery.target_run_id, - &original_delivery.delegation_id, - &fixture.expected_artifacts, - &questions_body, - &format!("planning-{tag}-round-{round}-claim"), - ); - let deliveries = claimed_static_delegate_deliveries_at( - &fixture.root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &fixture.supervisor.run_id, - ) - .expect("read planning claimed deliveries"); - assert!(ensure_static_delegate_user_input_wait_at( - &fixture.root, - &mut fixture.supervisor, - &deliveries, - ) - .expect("project planning clarification wait")); - let first_pending = read_game_creator_agent_runtime_pending_tool_action( - &fixture.root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &fixture.supervisor.run_id, - ) - .expect("read first planning clarification pending"); - let awaiting_session = read_plan_session_with_recovery(&fixture.root) - .expect("read awaiting planning session") - .expect("awaiting planning session exists"); - assert_eq!(awaiting_session.phase, "awaiting_user_input"); - assert!(awaiting_session.active_run_id.is_none()); - assert_eq!( - awaiting_session.latest_delegation_id, - original_delivery.delegation_id - ); - assert_eq!(awaiting_session.applied_answers.len(), (round - 1) as usize); - - assert!(ensure_static_delegate_user_input_wait_at( - &fixture.root, - &mut fixture.supervisor, - &deliveries, - ) - .expect("replay planning clarification wait")); - let replayed_pending = read_game_creator_agent_runtime_pending_tool_action( - &fixture.root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &fixture.supervisor.run_id, - ) - .expect("read replayed planning clarification pending"); - assert_eq!(replayed_pending.action_id, first_pending.action_id); - assert_eq!( - read_plan_session_with_recovery(&fixture.root) - .expect("reread replayed awaiting session") - .expect("replayed awaiting session exists"), - awaiting_session, - "重复 wake 不得增加 planning session revision" - ); - - let response_id = format!("planning-{tag}-round-{round}-response"); - let supervisor_run_id = fixture.supervisor.run_id.clone(); - let (questions_sha256, answers_sha256) = answer_static_delegate_clarification_wait( - &fixture.root, - &mut fixture.supervisor, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &supervisor_run_id, - &original_delivery.delegation_id, - BTreeMap::from([(question_id.clone(), answer.to_string())]), - &response_id, - ); - let original_delivery = - read_static_delegate_delivery_at(&fixture.root, &original_delivery.delegation_id) - .expect("reread answered planning delivery") - .expect("answered planning delivery exists"); - assert_eq!( - original_delivery.clarification_answers_sha256.as_deref(), - Some(answers_sha256.as_str()) - ); - - AnsweredPlanningClarification { - original_delivery, - question_id, - response_id, - questions_sha256, - answers_sha256, - awaiting_session, - } -} - -fn dispatch_answered_planning_continuation( - fixture: &mut PlanningClarificationFixture, - answered: &AnsweredPlanningClarification, - tag: &str, -) -> StaticDelegateDeliveryRecord { - let continuation_id = clarification_continuation_delegation_id( - &fixture.root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &fixture.supervisor.run_id, - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - &answered.original_delivery.delegation_id, - &answered.questions_sha256, - &answered.answers_sha256, - ); - let observation = dispatch_static_delegate_clarification_continuation( - &fixture.root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &fixture.supervisor.run_id, - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - "根据用户回答继续收敛 Fast GDD", - &fixture.acceptance_criteria, - &fixture.expected_artifacts, - &answered.original_delivery.delegation_id, - &answered.questions_sha256, - &answered.answers_sha256, - &format!("planning-{tag}-continuation"), - ); - assert_eq!(observation.status, "ok", "{observation:?}"); - let continuation = read_static_delegate_delivery_at(&fixture.root, &continuation_id) - .expect("read planning continuation delivery") - .expect("planning continuation delivery exists"); - fixture.current_delivery = continuation.clone(); - continuation -} - -fn prepare_first_planning_clarification_wait( - fixture: &mut PlanningClarificationFixture, - tag: &str, -) -> ( - AgentRuntimePendingToolAction, - AgentRuntimeUserInputRequestView, - String, -) { - let current = fixture.current_delivery.clone(); - let (question_id, questions_body) = planning_clarification_question_body(1); - mark_and_claim_static_delegate_needs_user_input( - &fixture.root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &fixture.supervisor.run_id, - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - ¤t.target_session_id, - ¤t.target_run_id, - ¤t.delegation_id, - &fixture.expected_artifacts, - &questions_body, - &format!("planning-{tag}-claim"), - ); - let deliveries = claimed_static_delegate_deliveries_at( - &fixture.root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &fixture.supervisor.run_id, - ) - .expect("read claimed planning clarification"); - assert!(ensure_static_delegate_user_input_wait_at( - &fixture.root, - &mut fixture.supervisor, - &deliveries, - ) - .expect("create planning clarification wait")); - let pending = read_game_creator_agent_runtime_pending_tool_action( - &fixture.root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &fixture.supervisor.run_id, - ) - .expect("read planning clarification pending"); - let request = match prepare_game_creator_agent_user_input_request_at(&fixture.root, &pending) - .expect("prepare planning clarification request") - { - AgentRuntimeUserInputRecovery::Waiting(request) => request, - other => panic!("unexpected planning clarification recovery: {other:?}"), - }; - (pending, request, question_id) -} - -fn planning_provider_lifecycle_count(root: &Path) -> usize { - read_agent_db_records_bounded(root, 1024 * 1024) - .expect("read Agent DB lifecycle records") - .0 - .iter() - .filter(|record| { - record.get("recordType").and_then(Value::as_str) - == Some("agent.runtime.provider_request.lifecycle") - }) - .count() -} - -fn cleanup_planning_clarification_fixture(fixture: PlanningClarificationFixture) { - let root = fixture.root.clone(); - drop(fixture.planning_lock); - fs::remove_dir_all(root).ok(); -} - -/// 走真实 agent.delegate 路径派发一次不携带任何澄清字段的「质量返工」,不对结果做断言。 -#[allow(clippy::too_many_arguments)] fn dispatch_static_delegate_plain_repair( root: &Path, parent_agent_id: &str, @@ -939,38 +497,12 @@ fn visual_specialist_prompts_require_real_registered_image_deliveries() { let _config_guard = crate::tests::write_test_local_config( r#"{"editorApi":{"apiKey":"visual-prompt-test-key"}}"#.to_string(), ); - let design_prompt = game_creator_agent_runtime_tool_plan_system_prompt_for_agent( - "design-foundation", - AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, - ); + let design_prompt = + game_creator_agent_runtime_tool_plan_system_prompt_for_agent("design-foundation"); for expected in [ - "文本策划只是中间结果", + "根据当前玩法需求编写规格和界面建议", "canvas.asset_generate", - "16:9", - "2K", - "assets/ui-prototype.png", - "assetKind=ui-prototype", - "asset.list", - "image.inspect", - "ui-prototype.v2", - "informationHud", - "gameplaySurface", - "objectiveEntities", - "failureRestartFlow", - "responsiveLayout", - "不得假设为塔防", - "原创标题、实体、资源、目标名称与视觉语言", - "assets/art-spec.png", - "icon-spec", - "referenceImageSrcs 第一项", - "POST /api/external/v1/editor/images/generations(kind=ui-design)", - "不得误用 POST /api/external/v1/editor/ui-designs/assets/extractions", - "canvas.asset_generate 成功只表示候选图片已生成并登记,不等于视觉验收完成", - "由 Runtime 在收束门内同时核对固定 owner 文档", - "已有同路径画布资产时先核对登记", - "检查已通过时不得重复生成或再次扣费", - "纯场景图", - "不得把计划写完当成 completed", + "不要使用固定图片合同", ] { assert!( design_prompt.contains(expected), @@ -978,19 +510,13 @@ fn visual_specialist_prompts_require_real_registered_image_deliveries() { ); } - let director_prompt = game_creator_agent_runtime_tool_plan_system_prompt_for_agent( - "art-director", - AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, - ); + let director_prompt = + game_creator_agent_runtime_tool_plan_system_prompt_for_agent("art-director"); for expected in [ - "assets/art-spec.png", - "assetKind=icon-spec", - "POST /api/external/v1/editor/images/generations(kind=spec)", - "后续 UI 和透明图集共同引用", - "不得用 generationInputs.artSpec JSON", - "成功只表示固定候选已生成并登记,不等于视觉门已经通过", - "由 Runtime 在收束时核对当前 revision", - "缺少 resourceId 时不得提交最终回复", + "确定原创视觉方向", + "canvas.asset_generate", + "根据项目实际需要选择", + "不要假设固定图片名称、数量、素材类别或布局", ] { assert!( director_prompt.contains(expected), @@ -998,33 +524,12 @@ fn visual_specialist_prompts_require_real_registered_image_deliveries() { ); } - let art_prompt = game_creator_agent_runtime_tool_plan_system_prompt_for_agent( - "art-asset-plan", - AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, - ); + let art_prompt = game_creator_agent_runtime_tool_plan_system_prompt_for_agent("art-asset-plan"); for expected in [ - "资产清单和美术计划只是中间结果", - "canvas.asset_generate", - "assets/manifest.art.json", - "assets/art-spritesheet.png", - "固定使用 1:1、1K", - "assetKind=art-spritesheet", - "用 asset.list 确认 assets/art-spec.png 已登记", - "由 Runtime 形成 iconDescriptions", - "权威 resourceId 作为 referenceId", - "POST /api/external/v1/editor/icon-spritesheets/generations", - "screenColor=auto", - "不得回退普通生图", - "回读 observation 与 asset.list", - "真实 alpha", - "warning.code=postprocess-failed-source-preserved", - "不得登记、验收或自动重试", - "仅 sliceWarning", - "已有有效同路径资产时不得重复生成或扣费", + "按项目实际需求规划和生成美术素材", "asset.list", - "不得运行 game.static_smoke 或 preview.validate", - "不得编辑 game/index.html", - "透明证据不足时不得提交最终回复", + "canvas.asset_generate", + "不得套用固定图片包或固定 2x2", ] { assert!( art_prompt.contains(expected), @@ -1032,10 +537,8 @@ fn visual_specialist_prompts_require_real_registered_image_deliveries() { ); } - let ordinary_prompt = game_creator_agent_runtime_tool_plan_system_prompt_for_agent( - "quality-review", - AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, - ); + let ordinary_prompt = + game_creator_agent_runtime_tool_plan_system_prompt_for_agent("quality-review"); assert!(!ordinary_prompt.contains("assets/ui-prototype.png")); assert!(!ordinary_prompt.contains("assets/art-spritesheet.png")); } @@ -1043,24 +546,18 @@ fn visual_specialist_prompts_require_real_registered_image_deliveries() { #[test] fn visual_prompts_degrade_to_text_contracts_without_editor_api_key() { let _config_guard = crate::tests::write_test_local_config("{}".to_string()); - let design_prompt = game_creator_agent_runtime_tool_plan_system_prompt_for_agent( - "design-foundation", - AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, - ); + let design_prompt = + game_creator_agent_runtime_tool_plan_system_prompt_for_agent("design-foundation"); assert!(design_prompt.contains("当前未配置 External Editor API Key")); assert!(design_prompt.contains("memory/project.md 与 game/game_design.md")); assert!(design_prompt.contains("不调用 canvas.asset_generate")); - let art_prompt = game_creator_agent_runtime_tool_plan_system_prompt_for_agent( - "art-asset-plan", - AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, - ); + let art_prompt = game_creator_agent_runtime_tool_plan_system_prompt_for_agent("art-asset-plan"); assert!(art_prompt.contains("assets/manifest.art.json")); assert!(art_prompt.contains("不伪造 assets/art-spritesheet.png")); let supervisor_prompt = game_creator_agent_runtime_tool_plan_system_prompt_for_agent( GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, ); assert!(supervisor_prompt.contains("当前未配置 External Editor API Key")); assert!(supervisor_prompt.contains("不得要求调用 canvas.asset_generate")); @@ -1080,7 +577,6 @@ async fn project_supervisor_prompts_are_total_control_and_reject_isolated_templa ); game_creator_agent_runtime_tool_plan_system_prompt_for_agent( GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, ) }; for expected in [ @@ -4006,1414 +3502,6 @@ fn clarification_continuation_chain_supports_multiple_rounds() { /// 生产实测里 Supervisor 手抄这两个数组会反复抄错——一次两轮澄清的 plan run 中 /// 「静态委派返工必须完整继承原 acceptanceCriteria 和 expectedArtifacts」出现 4 次, /// 每建一条 continuation 先白跑两轮工具调用才成功。 -#[test] -fn planning_clarification_continuation_inherits_the_original_contract() { - let mut fixture = planning_clarification_fixture("inherit-contract"); - let answered = answer_planning_clarification_round( - &mut fixture, - 1, - PLAN_TEST_OPTION_A, - "inherit-contract", - ); - let original = answered.original_delivery.clone(); - assert!( - !original.acceptance_criteria.is_empty(), - "原委派合同为空会让下面的继承断言失去意义" - ); - - let continuation_id = clarification_continuation_delegation_id( - &fixture.root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &fixture.supervisor.run_id, - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - &original.delegation_id, - &answered.questions_sha256, - &answered.answers_sha256, - ); - let delegate_input = serde_json::json!({ - "agentId": GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - "task": "根据用户回答继续收敛 Fast GDD", - "acceptanceCriteria": serde_json::Value::Null, - "expectedArtifacts": serde_json::Value::Null, - "repairOfDelegationId": original.delegation_id, - "runId": serde_json::Value::Null, - "continuationOfDelegationId": original.delegation_id, - "questionsSha256": serde_json::Value::Null, - "answersSha256": serde_json::Value::Null, - }); - let observation = observe_agent_runtime_agent_delegate( - &fixture.root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &fixture.supervisor.run_id, - Some("planning-inherit-contract-continuation"), - &delegate_input, - ); - assert_eq!(observation.status, "ok", "{observation:?}"); - - let continuation = read_static_delegate_delivery_at(&fixture.root, &continuation_id) - .expect("read inherited continuation delivery") - .expect("inherited continuation delivery exists"); - assert_eq!( - continuation.acceptance_criteria, - original.acceptance_criteria - ); - assert_eq!(continuation.expected_artifacts, original.expected_artifacts); -} - -#[test] -fn planning_clarification_three_rounds_project_session_and_structured_injection() { - let mut fixture = planning_clarification_fixture("three-rounds"); - let expected_decisions = [ - (PLAN_TEST_OPTION_A, "confirmed", "user_option"), - (PLAN_TEST_OPTION_B, "confirmed", "user_option"), - ( - PLAN_TEST_OPTION_PROTOTYPE, - "prototype_pending", - "user_option", - ), - ]; - - for (index, (answer, expected_state, expected_source)) in - expected_decisions.into_iter().enumerate() - { - let round = u32::try_from(index + 1).expect("round fits u32"); - let answered = - answer_planning_clarification_round(&mut fixture, round, answer, "three-rounds"); - assert_eq!( - answered.awaiting_session.session_revision, - round.saturating_mul(2), - "每轮展示卡前应只增加一个 awaiting successor" - ); - let continuation = dispatch_answered_planning_continuation( - &mut fixture, - &answered, - &format!("three-rounds-{round}"), - ); - let session = read_plan_session_with_recovery(&fixture.root) - .expect("read collecting planning session") - .expect("collecting planning session exists"); - assert_eq!(session.session_revision, round.saturating_mul(2) + 1); - assert_eq!(session.phase, "collecting"); - assert_eq!( - session.active_run_id.as_deref(), - Some(continuation.target_run_id.as_str()) - ); - assert_eq!(session.last_run_id, continuation.target_run_id); - assert_eq!(session.latest_delegation_id, continuation.delegation_id); - assert_eq!(session.applied_answers.len(), round as usize); - validate_plan_session_for_clarification_round(&session, round) - .expect("session projection matches durable clarification lineage"); - - let projected_answer = session - .applied_answers - .last() - .expect("current round applied answer"); - let decision_id = answered.question_id.replace('_', "-"); - assert_eq!( - projected_answer.delegation_id, - answered.original_delivery.delegation_id - ); - assert_eq!( - projected_answer.continuation_delegation_id, - continuation.delegation_id - ); - assert_eq!( - projected_answer.request_id, - answered - .original_delivery - .clarification_request_id - .clone() - .expect("answered delivery request id") - ); - assert_eq!(projected_answer.question_id, answered.question_id); - assert_eq!(projected_answer.response_id, answered.response_id); - assert_eq!(projected_answer.questions_sha256, answered.questions_sha256); - assert_eq!(projected_answer.answers_sha256, answered.answers_sha256); - assert_eq!(projected_answer.decision_id, decision_id); - assert_eq!(projected_answer.round, round); - - let decision = session - .decisions_summary - .iter() - .find(|decision| decision.id == decision_id) - .expect("projected planning decision"); - assert_eq!(decision.state, expected_state); - assert_eq!(decision.answer_source, expected_source); - assert_eq!(decision.round, round); - assert_eq!(decision.answer_summary, *answer); - if round == 3 { - assert!(session - .prototype_validation_items - .iter() - .any(|item| item.id == decision_id)); - } - } - - let final_session = read_plan_session_with_recovery(&fixture.root) - .expect("read final three-round session") - .expect("final three-round session exists"); - let injection = capture_plan_provider_structured_injections_at( - &fixture.root, - &fixture.current_delivery.target_session_id, - &[], - ) - .expect("capture round-three planning injection"); - let injection = - serde_json::from_slice::(&injection).expect("parse round-three planning injection"); - assert_eq!(injection["clarificationRound"], 3); - assert_eq!( - injection["accumulatedAgentMillis"], - final_session.accumulated_agent_millis - ); - assert_eq!(injection["session"]["phase"], "collecting"); - - cleanup_planning_clarification_fixture(fixture); -} - -#[test] -fn planning_clarification_freeform_answer_is_confirmed_and_preserves_original_text() { - let mut fixture = planning_clarification_fixture("freeform"); - let freeform = "改成双路线并让玩家在十分钟内完成一次取舍"; - let answered = answer_planning_clarification_round(&mut fixture, 1, freeform, "freeform"); - dispatch_answered_planning_continuation(&mut fixture, &answered, "freeform"); - - let session = read_plan_session_with_recovery(&fixture.root) - .expect("read freeform planning session") - .expect("freeform planning session exists"); - let decision = session - .decisions_summary - .iter() - .find(|decision| decision.id == answered.question_id.replace('_', "-")) - .expect("freeform decision exists"); - assert_eq!(decision.state, "confirmed"); - assert_eq!(decision.answer_source, "user_freeform"); - assert_eq!(decision.answer_summary, freeform); - - cleanup_planning_clarification_fixture(fixture); -} - -/// 用户回答走 `normalize_plan_text`(CRLF 归一 + `trim`),选项 label 却是信封原文。 -/// 模型只要吐出带尾随空白的 label,点选 A/B 就会因为「trim 过的答案 != 没 trim 的 -/// label」掉进自由填写分支,台账把点选记成 `user_freeform`。两边 state 同为 -/// `confirmed`,状态机看不出异常——被污染的恰好是第 23.9 节要立起来的那个字段。 -#[test] -fn planning_clarification_option_pick_survives_untrimmed_label() { - let mut fixture = planning_clarification_fixture("untrimmed-label"); - let padded_label_a = format!("{PLAN_TEST_OPTION_A} "); - let answered = answer_planning_clarification_round_with_labels( - &mut fixture, - 1, - &padded_label_a, - "untrimmed-label", - &padded_label_a, - PLAN_TEST_OPTION_B, - ); - dispatch_answered_planning_continuation(&mut fixture, &answered, "untrimmed-label"); - - let session = read_plan_session_with_recovery(&fixture.root) - .expect("read untrimmed-label planning session") - .expect("untrimmed-label planning session exists"); - let decision = session - .decisions_summary - .iter() - .find(|decision| decision.id == answered.question_id.replace('_', "-")) - .expect("untrimmed-label decision exists"); - assert_eq!(decision.state, "confirmed"); - assert_eq!( - decision.answer_source, "user_option", - "label 带尾随空白也必须认成点选,不能记成自由填写" - ); - assert_eq!( - decision.answer_summary, PLAN_TEST_OPTION_A, - "台账落的是规范化后的 label" - ); - - cleanup_planning_clarification_fixture(fixture); -} - -/// prompt 全中文,模型在中文语境下写 `A:方案名` 是高频输出。全角冒号不在分隔符集合里 -/// 时,合法信封会被判形状错误、回灌重试,白吃一个未推进回合预算——而第 23.9 节自己 -/// 就记着无界重试把单次 prompt 撑到 15 万 token 的实测。 -#[test] -fn planning_clarification_accepts_fullwidth_colon_option_labels() { - let mut fixture = planning_clarification_fixture("fullwidth-colon"); - let label_a = "A:采用当前推荐方案(推荐)"; - let label_b = "B:采用另一条可行路线"; - let answered = answer_planning_clarification_round_with_labels( - &mut fixture, - 1, - label_b, - "fullwidth-colon", - label_a, - label_b, - ); - dispatch_answered_planning_continuation(&mut fixture, &answered, "fullwidth-colon"); - - let session = read_plan_session_with_recovery(&fixture.root) - .expect("read fullwidth-colon planning session") - .expect("fullwidth-colon planning session exists"); - let decision = session - .decisions_summary - .iter() - .find(|decision| decision.id == answered.question_id.replace('_', "-")) - .expect("fullwidth-colon decision exists"); - assert_eq!(decision.state, "confirmed"); - assert_eq!(decision.answer_source, "user_option"); - assert_eq!(decision.answer_summary, label_b); - - cleanup_planning_clarification_fixture(fixture); -} - -#[test] -fn planning_clarification_rejects_non_ab_option_envelope_before_pending() { - let mut fixture = planning_clarification_fixture("invalid-option-shape"); - let current = fixture.current_delivery.clone(); - let (_, valid_body) = planning_clarification_question_body(1); - let invalid_body = valid_body.replace(PLAN_TEST_OPTION_B, "C · 另一条路线"); - mark_and_claim_static_delegate_needs_user_input( - &fixture.root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &fixture.supervisor.run_id, - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - ¤t.target_session_id, - ¤t.target_run_id, - ¤t.delegation_id, - &fixture.expected_artifacts, - &invalid_body, - "planning-invalid-option-shape-claim", - ); - let claimed = claimed_static_delegate_deliveries_at( - &fixture.root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &fixture.supervisor.run_id, - ) - .expect("read invalid option-shape delivery"); - let error = - ensure_static_delegate_user_input_wait_at(&fixture.root, &mut fixture.supervisor, &claimed) - .expect_err("non A/B option envelope must fail closed"); - assert!(error.contains("PLAN_INVALID_CLARIFICATION"), "{error}"); - assert!( - !game_creator_agent_runtime_pending_tool_action_path( - &fixture.root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &fixture.supervisor.run_id, - ) - .exists(), - "非法选项信封不得建立 pending" - ); - - cleanup_planning_clarification_fixture(fixture); -} - -#[test] -fn planning_clarification_user_revision_after_answer_preserves_round_for_revise_and_reject() { - for action in ["revise", "reject"] { - let mut fixture = planning_clarification_fixture(&format!("user-{action}")); - let answered = answer_planning_clarification_round( - &mut fixture, - 1, - PLAN_TEST_OPTION_A, - &format!("user-{action}"), - ); - let submitted_delivery = dispatch_answered_planning_continuation( - &mut fixture, - &answered, - &format!("user-{action}"), - ); - - let evidence = build_static_delegate_structured_result_at( - &fixture.root, - "completed", - &fixture.expected_artifacts, - false, - None, - None, - None, - None, - ) - .expect("build user-revision evidence-ready result"); - assert_eq!( - evidence.contract_status, - StaticDelegateContractStatus::EvidenceReady - ); - mark_static_delegate_delivery_ready_with_result_at( - &fixture.root, - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - &submitted_delivery.target_session_id, - &submitted_delivery.target_run_id, - &submitted_delivery.delegation_id, - "completed", - "Fast GDD 已提交并等待用户决定", - evidence, - ) - .expect("mark submitted planning delivery ready"); - claim_ready_static_delegate_receipts_at( - &fixture.root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &fixture.supervisor.run_id, - &format!("planning-user-{action}-claim"), - ) - .expect("claim submitted planning delivery"); - - let collecting = read_plan_session_with_recovery(&fixture.root) - .expect("read answered collecting session") - .expect("answered collecting session exists"); - assert_eq!(collecting.applied_answers.len(), 1); - assert_eq!( - collecting.latest_delegation_id, - submitted_delivery.delegation_id - ); - - let submitted_ref = PlanGddRef { - gdd_id: collecting.gdd_id.clone(), - version: 1, - fingerprint: format!("sha256-serde-json-v2:{}", "a".repeat(64)), - }; - let mut awaiting_approval = collecting.clone(); - awaiting_approval.session_revision += 1; - awaiting_approval.previous_fingerprint = Some(collecting.session_fingerprint.clone()); - awaiting_approval.active_run_id = None; - awaiting_approval.phase = "awaiting_gdd_approval".to_string(); - awaiting_approval.latest_submitted_ref = Some(submitted_ref); - awaiting_approval.last_decision_ref = None; - awaiting_approval.updated_at_utc = "2026-08-17T01:00:00.000Z".to_string(); - awaiting_approval.session_fingerprint = format!("sha256-serde-json-v2:{}", "0".repeat(64)); - awaiting_approval.session_fingerprint = - plan_session_fingerprint(&awaiting_approval).expect("approval-wait fingerprint"); - validate_plan_session_successor(&collecting, &awaiting_approval) - .expect("project approval-wait successor"); - write_plan_session_atomic_locked(&fixture.root, &awaiting_approval) - .expect("persist approval-wait session"); - - let mut decided = awaiting_approval.clone(); - decided.session_revision += 1; - decided.previous_fingerprint = Some(awaiting_approval.session_fingerprint.clone()); - decided.phase = if action == "revise" { - "revision_requested" - } else { - "rejected" - } - .to_string(); - decided.last_decision_ref = Some(PlanDecisionRef { - version: 1, - response_id: if action == "revise" { - "gdd-response-00000000-0000-4000-8000-000000000001" - } else { - "gdd-response-00000000-0000-4000-8000-000000000002" - } - .to_string(), - action: action.to_string(), - receipt_fingerprint: format!("sha256-serde-json-v2:{}", "b".repeat(64)), - }); - decided.updated_at_utc = "2026-08-17T01:00:01.000Z".to_string(); - decided.session_fingerprint = format!("sha256-serde-json-v2:{}", "0".repeat(64)); - decided.session_fingerprint = - plan_session_fingerprint(&decided).expect("decision fingerprint"); - validate_plan_session_successor(&awaiting_approval, &decided) - .expect("project revise/reject successor"); - write_plan_session_atomic_locked(&fixture.root, &decided) - .expect("persist revise/reject session"); - - mark_static_delegate_delivery_user_revision_requested_at( - &fixture.root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &fixture.supervisor.run_id, - &submitted_delivery.delegation_id, - ) - .expect("mark submitted delivery user-revision-requested"); - - // 审批改写 delivery 之后、派发修订委派之前,Supervisor 必然先调一次 - // `agent.run_status`,它会把这条已 observed 的 claim 整个重放一遍。claim 里的 - // structuredResult 仍是审批前那份 EvidenceReady 快照,而 delivery 已经是 - // UserRevisionRequested——这一步按全等判就会报「claim 与 delivery 身份或结果 - // 冲突」,Supervisor 从此拿不到回执,也就永远建不出下面那条修订委派。 - // - // 生产实测正是卡在这里:run_status 连续 failed、空转到第 43 轮。此前这个用例 - // 从 mark 直接跳到 dispatch,跳过的恰好是唯一会失败的那一步。 - let barrier = static_delegate_completion_barrier_at( - &fixture.root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &fixture.supervisor.run_id, - ) - .expect("审批改写 delivery 后,claim 重放必须仍然成立"); - - // 屏障必须同时说出两件事:任务还不能收束,但**没有外部事件可等**。 - // - // 只判「不能收束」不够——修复前它正是这样:main_loop 把用户修订待办当成 - // 「有在等的委派」,park 成「等待专业 Agent 委派回执 / 回执全部 ready 后自动 - // 唤醒当前父 run」,而那条回执只能来自下面这条还没派出去的修订委派。生产实测 - // 8 分钟零事件。has_external_wait() 为假才能让本轮落进 user_revision 分支去 - // 调 agent.delegate。 - assert!( - !barrier.is_clear(), - "用户修订待办没派出续作前,父 run 不能被判为可收束" - ); - assert!( - !barrier.has_external_wait(), - "用户修订待办没有任何外部事件可等,park 住就是等自己派出的委派" - ); - assert!( - barrier.has_waiting(), - "自动恢复路径仍须收手:续作派出前不得跨过这条 Supervisor 决策边界" - ); - - let revision_action_id = format!("planning-user-{action}-continuation"); - let revision = dispatch_static_delegate_plain_repair( - &fixture.root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &fixture.supervisor.run_id, - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - "按用户审批意见修订 Fast GDD", - &fixture.acceptance_criteria, - &fixture.expected_artifacts, - &submitted_delivery.delegation_id, - &revision_action_id, - ); - assert_eq!(revision.status, "ok", "{revision:?}"); - let revision_delegation_id = agent_runtime_delegation_id( - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &fixture.supervisor.run_id, - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - &revision_action_id, - ); - let revised_session = read_plan_session_with_recovery(&fixture.root) - .expect("read user-revision continuation session") - .expect("user-revision continuation session exists"); - assert_eq!(revised_session.phase, "collecting"); - assert_eq!(revised_session.latest_delegation_id, revision_delegation_id); - assert_eq!(revised_session.applied_answers, collecting.applied_answers); - assert_eq!( - revised_session.decisions_summary, - collecting.decisions_summary - ); - assert_eq!( - revised_session - .last_decision_ref - .as_ref() - .map(|reference| reference.action.as_str()), - Some(action) - ); - - let injection = capture_plan_provider_structured_injections_at( - &fixture.root, - &revised_session.session_id, - &[], - ) - .expect("capture user-revision planning injection"); - let injection = serde_json::from_slice::(&injection) - .expect("parse user-revision planning injection"); - assert_eq!( - injection.get("clarificationRound").and_then(Value::as_u64), - Some(1) - ); - let expected_decisions = serde_json::to_value(&collecting.decisions_summary) - .expect("serialize expected decisionsSummary"); - assert_eq!( - injection.pointer("/session/decisionsSummary"), - Some(&expected_decisions) - ); - - cleanup_planning_clarification_fixture(fixture); - } -} - -#[test] -fn planning_clarification_answer_and_continuation_replay_are_idempotent() { - let mut fixture = planning_clarification_fixture("replay"); - let answered = - answer_planning_clarification_round(&mut fixture, 1, PLAN_TEST_OPTION_A, "replay"); - let request_id = answered - .original_delivery - .clarification_request_id - .as_deref() - .expect("answered request id"); - let delivery_before_bind_replay = answered.original_delivery.clone(); - let session_before_bind_replay = read_plan_session_with_recovery(&fixture.root) - .expect("read session before bind replay") - .expect("session before bind replay exists"); - bind_static_delegate_clarification_answer_at( - &fixture.root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &fixture.supervisor.run_id, - &answered.original_delivery.delegation_id, - request_id, - &answered.questions_sha256, - &answered.answers_sha256, - ) - .expect("identical answer binding replay"); - assert_eq!( - read_static_delegate_delivery_at(&fixture.root, &answered.original_delivery.delegation_id,) - .expect("read replayed answer delivery") - .expect("replayed answer delivery exists"), - delivery_before_bind_replay - ); - assert_eq!( - read_plan_session_with_recovery(&fixture.root) - .expect("read session after bind replay") - .expect("session after bind replay exists"), - session_before_bind_replay - ); - - let continuation = - dispatch_answered_planning_continuation(&mut fixture, &answered, "replay-first"); - let session_after_first = read_plan_session_with_recovery(&fixture.root) - .expect("read session after first continuation") - .expect("session after first continuation exists"); - let deliveries_after_first = list_static_delegate_deliveries_at(&fixture.root) - .expect("list deliveries after first continuation"); - let replay = dispatch_static_delegate_clarification_continuation( - &fixture.root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &fixture.supervisor.run_id, - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - "根据同一用户回答继续收敛 Fast GDD", - &fixture.acceptance_criteria, - &fixture.expected_artifacts, - &answered.original_delivery.delegation_id, - &answered.questions_sha256, - &answered.answers_sha256, - "planning-replay-second-action", - ); - assert_eq!(replay.status, "ok", "{replay:?}"); - assert_eq!( - list_static_delegate_deliveries_at(&fixture.root).expect("list deliveries after replay"), - deliveries_after_first, - "同一问答重放不得创建第二条 continuation delivery" - ); - let continuation_task = read_latest_game_creator_agent_runtime_task_by_delegation_id( - &fixture.root, - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - &continuation.delegation_id, - ) - .expect("read replayed continuation task") - .expect("replayed continuation task exists"); - assert!( - ensure_plan_session_for_planning_child_task_at(&fixture.root, &continuation_task) - .expect("replay continuation session projection") - ); - assert_eq!( - read_plan_session_with_recovery(&fixture.root) - .expect("read session after continuation replay") - .expect("session after continuation replay exists"), - session_after_first, - "同一 continuation task 重放不得增加 session revision" - ); - - cleanup_planning_clarification_fixture(fixture); -} - -#[test] -fn planning_clarification_conflicting_answer_fails_without_projection() { - let mut fixture = planning_clarification_fixture("answer-conflict"); - let answered = - answer_planning_clarification_round(&mut fixture, 1, PLAN_TEST_OPTION_A, "answer-conflict"); - let request_id = answered - .original_delivery - .clarification_request_id - .as_deref() - .expect("answered request id"); - let delivery_before = answered.original_delivery.clone(); - let session_before = read_plan_session_with_recovery(&fixture.root) - .expect("read session before answer conflict") - .expect("session before answer conflict exists"); - let deliveries_before = list_static_delegate_deliveries_at(&fixture.root) - .expect("list deliveries before answer conflict"); - - let different_answer = bind_static_delegate_clarification_answer_at( - &fixture.root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &fixture.supervisor.run_id, - &answered.original_delivery.delegation_id, - request_id, - &answered.questions_sha256, - &"0".repeat(64), - ) - .expect_err("different answer digest must fail closed"); - assert!(different_answer.contains("不同请求或答案")); - let different_request = bind_static_delegate_clarification_answer_at( - &fixture.root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &fixture.supervisor.run_id, - &answered.original_delivery.delegation_id, - "planning-conflicting-request", - &answered.questions_sha256, - &answered.answers_sha256, - ) - .expect_err("different request id must fail closed"); - assert!(different_request.contains("不同请求或答案")); - assert_eq!( - read_static_delegate_delivery_at(&fixture.root, &answered.original_delivery.delegation_id,) - .expect("read delivery after answer conflict") - .expect("delivery after answer conflict exists"), - delivery_before - ); - assert_eq!( - read_plan_session_with_recovery(&fixture.root) - .expect("read session after answer conflict") - .expect("session after answer conflict exists"), - session_before - ); - assert_eq!( - list_static_delegate_deliveries_at(&fixture.root) - .expect("list deliveries after answer conflict"), - deliveries_before - ); - - cleanup_planning_clarification_fixture(fixture); -} - -#[test] -fn planning_clarification_fourth_round_is_rejected_before_pending() { - let mut fixture = planning_clarification_fixture("fourth-round"); - for round in 1..=3 { - let answered = answer_planning_clarification_round( - &mut fixture, - round, - PLAN_TEST_OPTION_A, - "fourth-round", - ); - dispatch_answered_planning_continuation( - &mut fixture, - &answered, - &format!("fourth-round-{round}"), - ); - } - let session_before = read_plan_session_with_recovery(&fixture.root) - .expect("read session before fourth question") - .expect("session before fourth question exists"); - assert_eq!(session_before.applied_answers.len(), 3); - let deliveries_before = list_static_delegate_deliveries_at(&fixture.root) - .expect("list deliveries before fourth question"); - let (_, fourth_question) = planning_clarification_question_body(4); - let current = fixture.current_delivery.clone(); - mark_and_claim_static_delegate_needs_user_input( - &fixture.root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &fixture.supervisor.run_id, - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - ¤t.target_session_id, - ¤t.target_run_id, - ¤t.delegation_id, - &fixture.expected_artifacts, - &fourth_question, - "planning-fourth-round-claim", - ); - let claimed = claimed_static_delegate_deliveries_at( - &fixture.root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &fixture.supervisor.run_id, - ) - .expect("read fourth-round claimed delivery"); - let error = - ensure_static_delegate_user_input_wait_at(&fixture.root, &mut fixture.supervisor, &claimed) - .expect_err("fourth planning clarification card must be rejected"); - assert!( - error.contains("PLAN_CLARIFICATION_LIMIT_REACHED"), - "{error}" - ); - assert!( - !game_creator_agent_runtime_pending_tool_action_path( - &fixture.root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &fixture.supervisor.run_id, - ) - .exists(), - "第四轮必须在创建 Supervisor pending 前拒绝" - ); - assert_eq!( - read_plan_session_with_recovery(&fixture.root) - .expect("read session after fourth-round rejection") - .expect("session after fourth-round rejection exists"), - session_before - ); - assert_eq!( - list_static_delegate_deliveries_at(&fixture.root) - .expect("list deliveries after fourth-round rejection") - .len(), - deliveries_before.len(), - "第四轮拒绝不得创建 continuation delivery" - ); - - cleanup_planning_clarification_fixture(fixture); -} - -#[test] -fn planning_clarification_session_recovery_projects_existing_child_once_without_provider() { - let mut fixture = planning_clarification_fixture("recovery"); - let answered = - answer_planning_clarification_round(&mut fixture, 1, PLAN_TEST_OPTION_A, "recovery"); - let continuation = - dispatch_answered_planning_continuation(&mut fixture, &answered, "recovery-first"); - let continuation_task = read_latest_game_creator_agent_runtime_task_by_delegation_id( - &fixture.root, - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - &continuation.delegation_id, - ) - .expect("read recovery continuation task") - .expect("recovery continuation task exists"); - let projected = read_plan_session_with_recovery(&fixture.root) - .expect("read initially projected continuation session") - .expect("initially projected continuation session exists"); - assert_eq!( - projected.session_revision, - answered.awaiting_session.session_revision + 1 - ); - - let primary_path = fixture - .root - .join(PLAN_SESSION_PATH.replace('/', std::path::MAIN_SEPARATOR_STR)); - let previous_path = fixture - .root - .join(PLAN_SESSION_PREVIOUS_PATH.replace('/', std::path::MAIN_SEPARATOR_STR)); - // A normal read consumes the single recovery copy once it has verified the - // successor chain. Recreate the exact awaiting predecessor to model the - // crash window after the continuation delivery is durable but before its - // successor session primary is published. - fs::write( - &previous_path, - canonical_plan_session_bytes(&answered.awaiting_session) - .expect("serialize awaiting recovery copy"), - ) - .expect("write awaiting recovery copy"); - fs::remove_file(&primary_path).expect("simulate crash before session successor publish"); - let promoted = read_plan_session_with_recovery(&fixture.root) - .expect("promote awaiting session after simulated crash") - .expect("promoted awaiting session exists"); - assert_eq!(promoted, answered.awaiting_session); - - let deliveries_before = list_static_delegate_deliveries_at(&fixture.root) - .expect("list deliveries before projection recovery"); - let provider_lifecycle_before = planning_provider_lifecycle_count(&fixture.root); - assert!( - ensure_plan_session_for_planning_child_task_at(&fixture.root, &continuation_task) - .expect("recover existing continuation session projection") - ); - let recovered = read_plan_session_with_recovery(&fixture.root) - .expect("read recovered continuation session") - .expect("recovered continuation session exists"); - assert_eq!(recovered.session_revision, promoted.session_revision + 1); - assert_eq!(recovered.phase, "collecting"); - assert_eq!( - recovered.active_run_id.as_deref(), - Some(continuation.target_run_id.as_str()) - ); - assert_eq!(recovered.latest_delegation_id, continuation.delegation_id); - assert_eq!(recovered.applied_answers.len(), 1); - validate_plan_session_for_clarification_round(&recovered, 1) - .expect("recovered session matches durable lineage"); - assert!( - ensure_plan_session_for_planning_child_task_at(&fixture.root, &continuation_task) - .expect("replay recovered continuation projection") - ); - assert_eq!( - read_plan_session_with_recovery(&fixture.root) - .expect("reread replayed recovered session") - .expect("replayed recovered session exists"), - recovered, - "恢复重放不得增加第二个 successor" - ); - assert_eq!( - list_static_delegate_deliveries_at(&fixture.root) - .expect("list deliveries after projection recovery"), - deliveries_before, - "恢复只能补 session 投影,不能创建新 child/delivery" - ); - assert_eq!( - planning_provider_lifecycle_count(&fixture.root), - provider_lifecycle_before, - "session 投影恢复不得产生 Provider 请求 lifecycle" - ); - - cleanup_planning_clarification_fixture(fixture); -} - -#[test] -fn planning_clarification_runtime_answer_obeys_project_then_execution_lock_order() { - let mut fixture = planning_clarification_fixture("answer-lock-order"); - let (pending, _, _) = - prepare_first_planning_clarification_wait(&mut fixture, "answer-lock-order"); - let execution_lock = try_acquire_game_creator_agent_runtime_task_lock( - &fixture.root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - ) - .expect("probe Supervisor execution lane") - .expect("Supervisor execution lane available"); - let thread_root = fixture.root.clone(); - let run_id = fixture.supervisor.run_id.clone(); - let action_id = pending.action_id.clone(); - let (started_sender, started_receiver) = std::sync::mpsc::channel(); - let worker = std::thread::spawn(move || { - started_sender - .send(()) - .expect("signal ordered answer lock acquisition"); - acquire_game_creator_agent_runtime_user_input_answer_locks_for_test( - &thread_root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &run_id, - &action_id, - ) - }); - started_receiver - .recv_timeout(std::time::Duration::from_secs(1)) - .expect("ordered answer lock worker started"); - - // The worker deliberately blocks on the occupied execution lane after it - // takes the project lock. Give Windows' parallel test scheduler enough - // time to run it after the start signal; the observed lock contention, - // rather than a short scheduling deadline, is the ordering assertion. - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); - let mut project_lock_observed = false; - while std::time::Instant::now() < deadline { - match acquire_project_write_lock(&fixture.root, "test.answer-lock-order.probe") { - Ok(project_lock) => { - drop(project_lock); - std::thread::sleep(std::time::Duration::from_millis(5)); - } - Err(error) if error.starts_with("项目正在被其他写操作占用:") => { - project_lock_observed = true; - break; - } - Err(error) => panic!("probe project lock failed unexpectedly: {error}"), - } - } - assert!( - project_lock_observed, - "planning answer 必须先持有 project lock,再等待已占用的 execution lane" - ); - drop(execution_lock); - let (project_lock, runtime_lock) = worker - .join() - .expect("join ordered answer lock worker") - .expect("acquire ordered answer locks"); - assert!(project_lock.is_some(), "planning answer 必须取得项目写锁"); - drop(runtime_lock); - drop(project_lock); - cleanup_planning_clarification_fixture(fixture); -} - -#[test] -fn planning_clarification_answer_prepared_recovery_releases_execution_before_project_wait() { - let mut fixture = planning_clarification_fixture("prepared-lock-order"); - let base_url = spawn_mock_llm_non_transient_provider_error(None); - let _config_guard = write_test_local_config(format!( - r#"{{ - "agentLlm": {{ - "project-supervisor": {{ - "apiKey": "planning-prepared-lock-order-key", - "baseUrl": {base_url:?}, - "model": "planning-prepared-lock-order-model", - "apiKind": "openai_responses", - "stream": false, - "maxRetries": 0, - "retryBackoffMs": 1 - }} - }} -}}"# - )); - let (pending, request, question_id) = - prepare_first_planning_clarification_wait(&mut fixture, "prepared-lock-order"); - fs::write( - fixture - .root - .join(AGENT_RUNTIME_USER_INPUT_STOP_AFTER_PREPARED_FOR_TEST), - b"armed", - ) - .expect("arm answer-prepared crash window"); - let response_id = "planning-prepared-lock-order-response"; - let injected = answer_game_creator_agent_user_input_request_for_pending_at( - &fixture.root, - &pending, - &request.request_id, - response_id, - BTreeMap::from([(question_id, PLAN_TEST_OPTION_A.to_string())]), - ) - .expect_err("answer must stop after durable answer-prepared"); - assert!(injected.contains("answer-prepared"), "{injected}"); - - let project_lock = acquire_project_write_lock( - &fixture.root, - "test.answer-prepared-recovery.project-holder", - ) - .expect("hold project lock across recovery reorder"); - let thread_root = fixture.root.clone(); - let (started_sender, started_receiver) = std::sync::mpsc::channel(); - let worker = std::thread::spawn(move || { - let runtime_lock = try_acquire_game_creator_agent_runtime_task_lock( - &thread_root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - ) - .expect("acquire initial recovery execution lane") - .expect("initial recovery execution lane available"); - started_sender - .send(()) - .expect("signal answer-prepared recovery start"); - resume_game_creator_agent_pending_tool_action_at( - &thread_root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - runtime_lock, - ) - }); - started_receiver - .recv_timeout(std::time::Duration::from_secs(1)) - .expect("answer-prepared recovery started with execution lane"); - - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(1); - let reacquired_execution = loop { - if let Some(runtime_lock) = try_acquire_game_creator_agent_runtime_task_lock( - &fixture.root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - ) - .expect("probe released recovery execution lane") - { - break runtime_lock; - } - assert!( - std::time::Instant::now() < deadline, - "answer-prepared recovery 必须先释放 execution lane,再等待 project lock" - ); - std::thread::sleep(std::time::Duration::from_millis(5)); - }; - drop(reacquired_execution); - drop(project_lock); - let recovery = worker - .join() - .expect("join answer-prepared recovery") - .expect("recover answer-prepared request"); - drop(recovery); - - let release_deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); - loop { - if let Some(runtime_lock) = try_acquire_game_creator_agent_runtime_task_lock( - &fixture.root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - ) - .expect("probe post-recovery Supervisor lane") - { - drop(runtime_lock); - break; - } - assert!( - std::time::Instant::now() < release_deadline, - "answer-prepared continuation 必须在有界时间内释放 execution lane" - ); - std::thread::sleep(std::time::Duration::from_millis(20)); - } - let answered_delivery = - read_static_delegate_delivery_at(&fixture.root, &fixture.current_delivery.delegation_id) - .expect("read answer-prepared delivery") - .expect("answer-prepared delivery exists"); - assert!(answered_delivery.clarification_answers_sha256.is_some()); - cleanup_planning_clarification_fixture(fixture); -} - -/// 锁序重排窗口的另一半:放掉执行锁、拿到项目锁之后,重取执行锁可能撞上「用户刚 -/// 提交澄清回答、后台续跑仍握着执行锁」——那把锁会被 move 进 spawn 出去的续跑任务, -/// 一持有就是整个 Provider 回合,远超重取的 25 x 10ms 等待上限。 -/// -/// 锁被占恰恰说明别处正在推进,是最不该判失败的时候。所以这里只能让出本轮 -/// (`Deferred`),不能让错误经 `?` 一路上抛——上抛会掐掉 -/// `resume_game_creator_agent_background_tasks_at` 对**整批** Agent 的恢复,并把一次 -/// 纯瞬时的锁竞争直接甩给前端。 -#[test] -fn planning_clarification_recovery_defers_when_execution_lane_is_still_held() { - let mut fixture = planning_clarification_fixture("deferred-execution-contention"); - let (pending, request, question_id) = - prepare_first_planning_clarification_wait(&mut fixture, "deferred-execution-contention"); - fs::write( - fixture - .root - .join(AGENT_RUNTIME_USER_INPUT_STOP_AFTER_PREPARED_FOR_TEST), - b"armed", - ) - .expect("arm answer-prepared crash window"); - let response_id = "planning-deferred-contention-response"; - let injected = answer_game_creator_agent_user_input_request_for_pending_at( - &fixture.root, - &pending, - &request.request_id, - response_id, - BTreeMap::from([(question_id, PLAN_TEST_OPTION_A.to_string())]), - ) - .expect_err("answer must stop after durable answer-prepared"); - assert!(injected.contains("answer-prepared"), "{injected}"); - - // 先按住项目锁,把恢复线程卡在「已经放掉执行锁、正在等项目锁」的窗口里。 - let project_lock = - acquire_project_write_lock(&fixture.root, "test.deferred-contention.project-holder") - .expect("hold project lock across recovery reorder"); - let thread_root = fixture.root.clone(); - let (started_sender, started_receiver) = std::sync::mpsc::channel(); - let worker = std::thread::spawn(move || { - let runtime_lock = try_acquire_game_creator_agent_runtime_task_lock( - &thread_root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - ) - .expect("acquire initial recovery execution lane") - .expect("initial recovery execution lane available"); - started_sender - .send(()) - .expect("signal deferred recovery start"); - resume_game_creator_agent_pending_tool_action_at( - &thread_root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - runtime_lock, - ) - }); - started_receiver - .recv_timeout(std::time::Duration::from_secs(1)) - .expect("deferred recovery started with execution lane"); - - // 恢复线程一放掉执行锁就抢走它并按住不放,模拟用户回答的后台续跑。 - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(1); - let stolen_execution = loop { - if let Some(runtime_lock) = try_acquire_game_creator_agent_runtime_task_lock( - &fixture.root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - ) - .expect("probe released recovery execution lane") - { - break runtime_lock; - } - assert!( - std::time::Instant::now() < deadline, - "恢复必须先释放 execution lane 再等待 project lock,否则本用例失去判据" - ); - std::thread::sleep(std::time::Duration::from_millis(5)); - }; - // 放开项目锁:恢复线程随即拿到它,再去重取执行锁——而执行锁在我们手上。 - drop(project_lock); - - let recovery = worker - .join() - .expect("join deferred recovery") - .expect("执行锁被别处占用是瞬时争用,恢复只能让出本轮,不得让整轮 resume 失败"); - assert!( - matches!(recovery, AgentRuntimePendingActionResume::Deferred), - "重取执行锁没抢到时必须让出本轮,交给下一轮 resume 重试" - ); - drop(stolen_execution); - cleanup_planning_clarification_fixture(fixture); -} - -#[test] -fn planning_clarification_parent_wake_defers_while_execution_lane_is_busy_and_replays_once() { - let mut fixture = planning_clarification_fixture("parent-wake-lock-order"); - let current = fixture.current_delivery.clone(); - let (_, questions_body) = planning_clarification_question_body(1); - mark_and_claim_static_delegate_needs_user_input( - &fixture.root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &fixture.supervisor.run_id, - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - ¤t.target_session_id, - ¤t.target_run_id, - ¤t.delegation_id, - &fixture.expected_artifacts, - &questions_body, - "planning-parent-wake-lock-order-claim", - ); - fixture.supervisor.status = "running".to_string(); - fixture.supervisor.phase = "waiting-for-delegate-receipts".to_string(); - fixture.supervisor.current_action = "等待创建用户澄清请求".to_string(); - fixture.supervisor.waiting_on = "lane 外 parent-wake".to_string(); - fixture.supervisor.next_step = "按 project → execution 锁序创建 pending".to_string(); - fixture.supervisor.pending_tool_action = None; - append_game_creator_agent_runtime_task(&fixture.root, &fixture.supervisor) - .expect("persist parent receipt-wait task"); - refresh_game_creator_agent_runtime_task_queue(&fixture.root, &mut fixture.supervisor) - .expect("refresh parent receipt-wait queue"); - write_game_creator_agent_runtime_state(&fixture.root, &fixture.supervisor) - .expect("persist parent receipt-wait state"); - let parent_task = read_latest_game_creator_agent_runtime_task_by_run_id( - &fixture.root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &fixture.supervisor.run_id, - ) - .expect("read receipt-wait parent task") - .expect("receipt-wait parent task exists"); - let provider_lifecycle_before = planning_provider_lifecycle_count(&fixture.root); - let execution_lock = try_acquire_game_creator_agent_runtime_task_lock( - &fixture.root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - ) - .expect("acquire busy parent execution lane") - .expect("parent execution lane available"); - assert!( - !wake_waiting_static_delegate_parent_run_for_test_at(&fixture.root, &parent_task) - .expect("busy parent wake must defer"), - "parent-wake 不得越过已占用的 execution lane 写 pending" - ); - assert!(!game_creator_agent_runtime_pending_tool_action_exists( - &fixture.root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &fixture.supervisor.run_id, - )); - drop(execution_lock); - - assert!( - wake_waiting_static_delegate_parent_run_for_test_at(&fixture.root, &parent_task) - .expect("parent wake after lane release"), - "lane 释放后必须创建唯一澄清 pending" - ); - let pending = read_game_creator_agent_runtime_pending_tool_action( - &fixture.root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &fixture.supervisor.run_id, - ) - .expect("read parent-wake pending"); - let awaiting_session = read_plan_session_with_recovery(&fixture.root) - .expect("read parent-wake planning session") - .expect("parent-wake planning session exists"); - assert_eq!(awaiting_session.phase, "awaiting_user_input"); - assert_eq!( - read_game_creator_agent_runtime_at( - &fixture.root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - ) - .expect("read parent-wake runtime") - .state - .phase, - "waiting-for-user-input" - ); - assert!( - !wake_waiting_static_delegate_parent_run_for_test_at(&fixture.root, &parent_task) - .expect("replay completed parent wake") - ); - assert_eq!( - read_game_creator_agent_runtime_pending_tool_action( - &fixture.root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &fixture.supervisor.run_id, - ) - .expect("read replayed parent-wake pending") - .action_id, - pending.action_id - ); - assert_eq!( - read_plan_session_with_recovery(&fixture.root) - .expect("read replayed parent-wake session") - .expect("replayed parent-wake session exists"), - awaiting_session - ); - assert_eq!( - planning_provider_lifecycle_count(&fixture.root), - provider_lifecycle_before, - "parent-wake 投影不得请求 Provider" - ); - cleanup_planning_clarification_fixture(fixture); -} - -#[tokio::test] -async fn planning_clarification_main_loop_releases_execution_lane_before_parent_wake() { - let fixture = planning_clarification_fixture("main-loop-lane-release"); - let current = fixture.current_delivery.clone(); - let (_, questions_body) = planning_clarification_question_body(1); - mark_and_claim_static_delegate_needs_user_input( - &fixture.root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &fixture.supervisor.run_id, - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - ¤t.target_session_id, - ¤t.target_run_id, - ¤t.delegation_id, - &fixture.expected_artifacts, - &questions_body, - "planning-main-loop-lane-release-claim", - ); - let provider_lifecycle_before = planning_provider_lifecycle_count(&fixture.root); - let execution_lock = try_acquire_game_creator_agent_runtime_task_lock( - &fixture.root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - ) - .expect("acquire main-loop Supervisor execution lane") - .expect("main-loop Supervisor execution lane available"); - - let outcome = run_game_creator_agent_background_task_with_context( - fixture.root.clone(), - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(), - fixture.supervisor.current_task.clone(), - fixture.supervisor.clone(), - AgentRuntimeContinuationContext::default(), - ) - .await; - assert!(matches!( - outcome, - AgentBackgroundTaskOutcome::WaitingForDelegateReceipts - )); - let receipt_wait = - read_game_creator_agent_runtime_at(&fixture.root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID) - .expect("read main-loop receipt wait") - .state; - assert_eq!(receipt_wait.status, "running"); - assert_eq!(receipt_wait.phase, "waiting-for-delegate-receipts"); - assert!( - !game_creator_agent_runtime_pending_tool_action_exists( - &fixture.root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &fixture.supervisor.run_id, - ), - "main loop 持有 execution lane 时不得直接投影澄清 pending" - ); - - drop(execution_lock); - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); - let pending = loop { - match read_game_creator_agent_runtime_pending_tool_action( - &fixture.root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &fixture.supervisor.run_id, - ) { - Ok(pending) => break pending, - Err(error) => { - assert!( - std::time::Instant::now() < deadline, - "execution lane 释放后 parent-wake 未在有界时间内创建 pending:{error}" - ); - tokio::time::sleep(std::time::Duration::from_millis(20)).await; - } - } - }; - let awaiting_session = loop { - match read_plan_session_with_recovery(&fixture.root) { - Ok(Some(session)) => break session, - Ok(None) => panic!("main-loop awaiting planning session must exist"), - Err(error) => { - assert!( - std::time::Instant::now() < deadline, - "parent-wake 写入 pending 后未在有界时间内释放项目锁:{error}" - ); - tokio::time::sleep(std::time::Duration::from_millis(20)).await; - } - } - }; - assert_eq!(awaiting_session.session_revision, 2); - assert_eq!(awaiting_session.phase, "awaiting_user_input"); - assert!(awaiting_session.active_run_id.is_none()); - assert_eq!(awaiting_session.latest_delegation_id, current.delegation_id); - assert!(awaiting_session.applied_answers.is_empty()); - let waiting = - read_game_creator_agent_runtime_at(&fixture.root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID) - .expect("read main-loop user-input wait") - .state; - assert_eq!(waiting.status, "waiting-for-user-input"); - assert_eq!(waiting.phase, "waiting-for-user-input"); - assert_eq!( - waiting - .pending_tool_action - .as_ref() - .map(|summary| summary.action_id.as_str()), - Some(pending.action_id.as_str()) - ); - assert_eq!( - planning_provider_lifecycle_count(&fixture.root), - provider_lifecycle_before, - "main-loop receipt wait 与 lane 外 parent-wake 都不得启动 Provider" - ); - - cleanup_planning_clarification_fixture(fixture); -} - -#[test] -fn planning_clarification_recovery_treats_concurrent_answer_as_obsolete_candidate() { - let mut fixture = planning_clarification_fixture("recovery-obsolete-answer"); - let (mut pending, request, question_id) = - prepare_first_planning_clarification_wait(&mut fixture, "recovery-obsolete-answer"); - let project_lock = acquire_project_write_lock( - &fixture.root, - "test.recovery-obsolete-answer.project-holder", - ) - .expect("hold project lock for recovery race"); - let thread_root = fixture.root.clone(); - let (started_sender, started_receiver) = std::sync::mpsc::channel(); - let worker = std::thread::spawn(move || { - let runtime_lock = try_acquire_game_creator_agent_runtime_task_lock( - &thread_root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - ) - .expect("acquire old recovery candidate lane") - .expect("old recovery candidate lane available"); - started_sender - .send(()) - .expect("signal obsolete recovery candidate start"); - resume_game_creator_agent_pending_tool_action_at( - &thread_root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - runtime_lock, - ) - }); - started_receiver - .recv_timeout(std::time::Duration::from_secs(1)) - .expect("obsolete recovery candidate started"); - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(1); - let execution_lock = loop { - if let Some(runtime_lock) = try_acquire_game_creator_agent_runtime_task_lock( - &fixture.root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - ) - .expect("probe old recovery lane release") - { - break runtime_lock; - } - assert!( - std::time::Instant::now() < deadline, - "旧恢复候选必须在等待 project lock 前释放 execution lane" - ); - std::thread::sleep(std::time::Duration::from_millis(5)); - }; - let (_, observation) = answer_game_creator_agent_user_input_request_for_pending_at_locked( - &fixture.root, - &pending, - &request.request_id, - "planning-recovery-obsolete-response", - BTreeMap::from([(question_id, PLAN_TEST_OPTION_A.to_string())]), - &project_lock, - ) - .expect("advance answer while old recovery waits"); - pending.status = AGENT_RUNTIME_PENDING_ACTION_STATUS_OBSERVED_APPROVED.to_string(); - pending.observation = Some(observation); - pending.updated_at = unix_timestamp(); - write_game_creator_agent_runtime_pending_tool_action(&fixture.root, &pending) - .expect("persist concurrently advanced pending"); - let mut runtime = - read_game_creator_agent_runtime_at(&fixture.root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID) - .expect("read concurrently advanced runtime") - .state; - runtime.status = "running".to_string(); - runtime.phase = "observation".to_string(); - runtime.current_action = "已收到用户回答".to_string(); - runtime.pending_tool_action = Some(pending.summary()); - runtime.updated_at = unix_timestamp(); - append_game_creator_agent_runtime_task(&fixture.root, &runtime) - .expect("persist concurrently advanced task"); - refresh_game_creator_agent_runtime_task_queue(&fixture.root, &mut runtime) - .expect("refresh concurrently advanced queue"); - write_game_creator_agent_runtime_state(&fixture.root, &runtime) - .expect("persist concurrently advanced runtime"); - drop(execution_lock); - drop(project_lock); - - match worker - .join() - .expect("join obsolete recovery candidate") - .expect("obsolete recovery candidate must not fail") - { - AgentRuntimePendingActionResume::NotFound(runtime_lock) => drop(runtime_lock), - AgentRuntimePendingActionResume::Handled(_) => { - panic!("concurrently advanced answer must make the old recovery candidate obsolete") - } - AgentRuntimePendingActionResume::Deferred => { - panic!("两把锁都已放开,本轮不该让出") - } - } - let current = - read_game_creator_agent_runtime_at(&fixture.root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID) - .expect("read runtime after obsolete recovery") - .state; - assert_eq!(current.phase, "observation"); - assert_ne!(current.phase, "needs-reconciliation"); - cleanup_planning_clarification_fixture(fixture); -} - const CLARIFICATION_QUESTION_BODY: &str = concat!( "{\"questions\":[{\"id\":\"confirm\",\"header\":\"确认\",", "\"question\":\"请确认继续?\",", @@ -5423,149 +3511,6 @@ const CLARIFICATION_QUESTION_BODY: &str = concat!( "]}]}" ); -#[test] -fn clarification_round_limit_rejects_fourth_round() { - let root = unique_project_path(); - init_local_game_project_at( - &root, - "project-clarification-round-limit", - "澄清轮次上限边界测试", - ) - .expect("project init"); - let run_id = "project-supervisor-clarification-round-limit-run"; - let mut state = start_game_creator_agent_runtime_task_at( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - "协调专业 Agent 完成玩法方案", - run_id, - "agent-chat", - "协调专业 Agent", - vec!["取得用户多轮澄清后继续专业委派".to_string()], - ) - .expect("start supervisor run"); - - let acceptance_criteria = vec!["明确美术资源清单".to_string()]; - let expected_artifacts: Vec = vec![]; - let target_agent_id = "design-director"; - let target_lock = try_acquire_game_creator_agent_runtime_task_lock(&root, target_agent_id) - .expect("acquire round-limit target lane") - .expect("round-limit target lane available"); - - let d0_id = "round-limit-d0"; - create_dispatched_static_delegate_delivery( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &state.session_id, - run_id, - "round-limit-d0-action", - d0_id, - target_agent_id, - "round-limit-d0-child-session", - "round-limit-d0-child-run", - &acceptance_criteria, - &expected_artifacts, - None, - ); - - // 第 1、2、3 轮澄清续接必须依次成功(round 分别推导为 1、2、3,均未越过默认上限 3)。 - let (d1_id, d1_session, d1_run) = drive_static_delegate_clarification_round( - &root, - &mut state, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - run_id, - target_agent_id, - "round-limit-d0-child-session", - "round-limit-d0-child-run", - "继续推进玩法方案", - &acceptance_criteria, - &expected_artifacts, - d0_id, - CLARIFICATION_QUESTION_BODY, - "confirm", - "确认", - "round-limit-claim-1", - "round-limit-response-1", - "round-limit-delegate-1", - ); - let (d2_id, d2_session, d2_run) = drive_static_delegate_clarification_round( - &root, - &mut state, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - run_id, - target_agent_id, - &d1_session, - &d1_run, - "继续推进玩法方案", - &acceptance_criteria, - &expected_artifacts, - &d1_id, - CLARIFICATION_QUESTION_BODY, - "confirm", - "确认", - "round-limit-claim-2", - "round-limit-response-2", - "round-limit-delegate-2", - ); - let (d3_id, d3_session, d3_run) = drive_static_delegate_clarification_round( - &root, - &mut state, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - run_id, - target_agent_id, - &d2_session, - &d2_run, - "继续推进玩法方案", - &acceptance_criteria, - &expected_artifacts, - &d2_id, - CLARIFICATION_QUESTION_BODY, - "confirm", - "确认", - "round-limit-claim-3", - "round-limit-response-3", - "round-limit-delegate-3", - ); - let all_deliveries = list_static_delegate_deliveries_at(&root).expect("list deliveries"); - assert_eq!( - static_delegate_lineage_counters(&all_deliveries, &d3_id), - (0, 3), - "D3 应为返工深度 0、澄清轮次 3——已经用满默认上限" - ); - - // 第 4 轮:D3 -> D4 必须被拒绝,且错误文案必须是澄清轮次上限专用文案, - // 不能是返工深度门的文案——两个维度互相独立,不能串扰。 - let rejection = drive_static_delegate_clarification_round_expect_rejection( - &root, - &mut state, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - run_id, - target_agent_id, - &d3_session, - &d3_run, - "继续推进玩法方案", - &acceptance_criteria, - &expected_artifacts, - &d3_id, - CLARIFICATION_QUESTION_BODY, - "confirm", - "确认", - "round-limit-claim-4", - "round-limit-response-4", - "round-limit-delegate-4", - ); - assert!( - rejection.summary.contains("澄清轮次已达上限"), - "第 4 轮失败摘要应包含澄清轮次上限文案,实际为:{rejection:?}" - ); - assert!( - !rejection.summary.contains("深度最多为 1"), - "第 4 轮的拒绝理由不能是返工深度门的文案,实际为:{rejection:?}" - ); - - drop(target_lock); - fs::remove_dir_all(root).ok(); -} - #[test] fn alternating_repair_and_clarification_hop_blocks_second_repair_at_depth_two() { // 最脆弱的一处钉子测试:交替链 D1(根) -> 质量返工 D2(depth=1) -> D2 转 NeedsUserInput @@ -5739,169 +3684,6 @@ fn alternating_repair_and_clarification_hop_blocks_second_repair_at_depth_two() fs::remove_dir_all(root).ok(); } -#[test] -fn user_revision_continuation_passes_real_gate_at_depth_one_but_stays_single_child() { - // 这里手工把 delivery 状态切到 UserRevisionRequested,是对 M1C-1 未来审批写入的 - // durable fixture;委派本身仍走真实 observe_agent_runtime_agent_delegate 生产路径。 - let root = unique_project_path(); - init_local_game_project_at( - &root, - "project-user-revision-real-gate", - "用户修订真实委派门测试", - ) - .expect("project init"); - let run_id = "project-supervisor-user-revision-real-gate-run"; - let state = start_game_creator_agent_runtime_task_at( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - "验收策划子 Agent 并处理用户修订", - run_id, - "agent-chat", - "等待策划回执", - vec!["完成用户修订后的方案".to_string()], - ) - .expect("start supervisor run"); - let acceptance_criteria = vec!["必须交付策划产物".to_string()]; - let expected_artifacts = vec!["game/user-revision-real-gate.md".to_string()]; - let target_agent_id = "design-director"; - let target_lock = try_acquire_game_creator_agent_runtime_task_lock(&root, target_agent_id) - .expect("acquire target lane") - .expect("target lane available"); - - let d0_id = "user-revision-real-gate-d0"; - create_dispatched_static_delegate_delivery( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &state.session_id, - run_id, - "user-revision-real-gate-d0-action", - d0_id, - target_agent_id, - "user-revision-real-gate-d0-session", - "user-revision-real-gate-d0-run", - &acceptance_criteria, - &expected_artifacts, - None, - ); - mark_and_claim_static_delegate_needs_repair( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - run_id, - target_agent_id, - "user-revision-real-gate-d0-session", - "user-revision-real-gate-d0-run", - d0_id, - &expected_artifacts, - "user-revision-real-gate-d0-claim", - ); - - let d1_action = "user-revision-real-gate-d1-repair-action"; - let d1_id = agent_runtime_delegation_id( - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - run_id, - target_agent_id, - d1_action, - ); - let d1_observation = dispatch_static_delegate_plain_repair( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - run_id, - target_agent_id, - "补齐用户修订前的策划产物", - &acceptance_criteria, - &expected_artifacts, - d0_id, - d1_action, - ); - assert_eq!( - d1_observation.status, "ok", - "首次质量返工必须放行:{d1_observation:?}" - ); - let d1_delivery = read_static_delegate_delivery_at(&root, &d1_id) - .expect("read D1") - .expect("D1 exists"); - mark_and_claim_static_delegate_needs_repair( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - run_id, - target_agent_id, - &d1_delivery.target_session_id, - &d1_delivery.target_run_id, - &d1_id, - &expected_artifacts, - "user-revision-real-gate-d1-claim", - ); - - rewrite_claimed_static_delegate_as_user_revision_requested(&root, &d1_id, &expected_artifacts); - - // D1 的链上 depth 已经是 1;只有 UserRevisionRequested 分支能让这次真实委派继续。 - let d2_action = "user-revision-real-gate-d2-revision-action"; - let d2_id = agent_runtime_delegation_id( - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - run_id, - target_agent_id, - d2_action, - ); - let d2_observation = dispatch_static_delegate_plain_repair( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - run_id, - target_agent_id, - "按用户审批修改方案", - &acceptance_criteria, - &expected_artifacts, - &d1_id, - d2_action, - ); - assert_eq!( - d2_observation.status, "ok", - "UserRevisionRequested 不能被 depth=1 质量返工门误拒:{d2_observation:?}" - ); - let d2_delivery = read_static_delegate_delivery_at(&root, &d2_id) - .expect("read D2") - .expect("D2 exists"); - let deliveries = list_static_delegate_deliveries_at(&root).expect("list deliveries"); - assert_eq!( - static_delegate_lineage_counters(&deliveries, &d2_id), - (1, 0), - "用户修订续跑应保留已有 depth=1 且不重置此前 round" - ); - assert_eq!( - d2_delivery.repair_of_delegation_id.as_deref(), - Some(d1_id.as_str()) - ); - - // 新分支放行 depth 门之后,函数末尾的「同一静态委派最多允许一轮返工」兄弟检查必须 - // 仍然生效。这条是用户修订路径唯一剩下的扇出约束:depth 门对它不再适用,若兄弟检查 - // 也漏掉该状态,一个 UserRevisionRequested 父节点就能挂任意多个并存子委派。 - let d2b_observation = dispatch_static_delegate_plain_repair( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - run_id, - target_agent_id, - "对同一父节点再发一次用户修订", - &acceptance_criteria, - &expected_artifacts, - &d1_id, - "user-revision-real-gate-d2b-revision-action", - ); - assert_eq!( - d2b_observation.status, "failed", - "用户修订不得绕过兄弟检查扇出第二条并存子委派:{d2b_observation:?}" - ); - assert!( - d2b_observation.summary.contains("最多允许一轮返工"), - "拒绝理由必须是兄弟检查而不是别的门:{d2b_observation:?}" - ); - - drop(target_lock); - fs::remove_dir_all(root).ok(); -} - -/// 与 `project_supervisor_concurrent_repair_dispatch_creates_exactly_one_delivery` 同构, -/// 但父节点是 `UserRevisionRequested` 且链上 depth 已经是 1。两件事一起钉: -/// ① 并发下用户修订仍然只放行一条(兄弟检查在锁内有效,不因新分支失效); -/// ② 恰好放行「一条」而不是「零条」——新分支被删掉时两条都会被 depth 门拒,本用例变红。 #[test] fn concurrent_user_revision_dispatch_creates_exactly_one_delivery() { let root = unique_project_path(); @@ -6640,70 +4422,3 @@ fn static_delegate_repair_request_rejects_cross_run_reference() { drop(target_lock); fs::remove_dir_all(root).ok(); } - -#[test] -fn project_planning_is_a_delegatable_identity_outside_the_seed_dag() { - // 1 + 2:身份可解析、角色身份可合成 - assert_eq!( - normalize_game_creator_runtime_agent_id(GAME_CREATOR_PROJECT_PLANNING_AGENT_ID) - .expect("project-planning 必须是 catalog 合法成员"), - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID - ); - let (group, role) = game_creator_agent_role_definition(GAME_CREATOR_PROJECT_PLANNING_AGENT_ID) - .expect("project-planning 必须能合成角色身份,否则委派第一轮即硬失败"); - assert_eq!(role.task_id, GAME_CREATOR_PROJECT_PLANNING_AGENT_ID); - assert_eq!(group.id, PROJECT_PLANNING_AGENT_DEFINITION.id); - - // 3:不属于任何专业组,因此不进 specialist_nodes / 种子 DAG - assert!( - !GAME_CREATOR_AGENT_GROUP_DEFINITIONS - .iter() - .any(|definition| definition - .roles - .iter() - .any(|candidate| candidate.task_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID)), - "project-planning 一旦进入任何专业组,就会被 build.rs 要求同步进种子 DAG,破坏「做游戏链路一行不动」" - ); - assert!( - !new_game_creation_app_seed_tasks() - .iter() - .any(|task| task.id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID), - "种子 DAG 不得因为登记策划 Agent 而新增节点" - ); - - // 4:不得成为动态孵生模板 - let root = unique_project_path(); - init_local_game_project_at(&root, "project-planning-identity", "策划身份登记测试") - .expect("project init"); - let run_id = "planning-identity-run"; - let mut state = start_game_creator_agent_runtime_task_at( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - "发起立项策划", - run_id, - "agent-chat", - "立项策划", - vec!["形成一份可审批的 Fast GDD".to_string()], - ) - .expect("start supervisor run"); - let _ = &mut state; - - let spawn = observe_agent_runtime_agent_spawn_isolated( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - run_id, - Some("planning-identity-spawn-action"), - &serde_json::json!({ - "children": [{ - "templateAgentId": GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - "task": "试图把策划 Agent 当作动态孵生模板", - }], - }), - ); - assert_eq!( - spawn.status, "failed", - "project-planning 只能由 Supervisor 静态委派,不能被 agent.spawn_isolated 当模板:{spawn:?}" - ); - - fs::remove_dir_all(root).ok(); -} diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/command_runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/command_runtime.rs index 8e28db619..bb710da12 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/command_runtime.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/command_runtime.rs @@ -2330,7 +2330,7 @@ async fn executed_project_verification_audit_failure_requires_reconciliation() { #[test] fn project_verification_gate_accepts_only_exact_game_static_smoke() { let root = unique_project_path(); - init_local_game_project_at(&root, "project-1", "精确静态验证项目").expect("project init"); + init_existing_html_project_at(&root, "project-1", "精确静态验证项目").expect("project init"); advance_project_revision_for_test(&root, "playtest", "playtest-run", "file.write"); fs::write( root.join("game/index.html"), @@ -2536,7 +2536,7 @@ fn agent_runtime_project_verify_summary_hashes_command_without_head_or_tail() { #[test] fn limited_local_command_runs_static_game_smoke_and_writes_log() { let root = unique_project_path(); - init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init"); + init_existing_html_project_at(&root, "project-1", "像素动作原型").expect("project init"); fs::write( root.join("game/index.html"), fake_llm_game_draft().game_html, @@ -2866,7 +2866,7 @@ async fn project_verification_runs_without_prepost_and_records_failure_and_timeo #[test] fn limited_local_command_appends_playtest_to_existing_trace() { let root = unique_project_path(); - init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init"); + init_existing_html_project_at(&root, "project-1", "像素动作原型").expect("project init"); fs::write( root.join("game/index.html"), fake_llm_game_draft().game_html, @@ -2915,7 +2915,7 @@ fn limited_local_command_appends_playtest_to_existing_trace() { #[test] fn limited_local_command_rejects_placeholder_game_smoke() { let root = unique_project_path(); - init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init"); + init_existing_html_project_at(&root, "project-1", "像素动作原型").expect("project init"); let error = run_limited_local_command_at(&root, "game.static_smoke") .expect_err("placeholder game should fail smoke"); @@ -2927,7 +2927,7 @@ fn limited_local_command_rejects_placeholder_game_smoke() { #[test] fn limited_local_command_rejects_forbidden_runtime_apis() { let root = unique_project_path(); - init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init"); + init_existing_html_project_at(&root, "project-1", "像素动作原型").expect("project init"); let html = fake_llm_game_draft() .game_html .replace("const marker =", "fetch('/secret');\n const marker ="); @@ -2943,7 +2943,7 @@ fn limited_local_command_rejects_forbidden_runtime_apis() { #[test] fn limited_local_command_rejects_blank_canvas_game_smoke() { let root = unique_project_path(); - init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init"); + init_existing_html_project_at(&root, "project-1", "像素动作原型").expect("project init"); let html = r#" @@ -2970,7 +2970,8 @@ fn limited_local_command_rejects_blank_canvas_game_smoke() { #[test] fn limited_local_command_rejects_invalid_javascript_before_surface_checks() { let root = unique_project_path(); - init_local_game_project_at(&root, "project-1", "脚本语法优先验证测试").expect("project init"); + init_existing_html_project_at(&root, "project-1", "脚本语法优先验证测试") + .expect("project init"); let html = r#" @@ -2994,7 +2995,7 @@ fn limited_local_command_rejects_invalid_javascript_before_surface_checks() { #[test] fn limited_local_command_rejects_token_rich_truncated_script() { let root = unique_project_path(); - init_local_game_project_at(&root, "project-1", "截断游戏入口测试").expect("project init"); + init_existing_html_project_at(&root, "project-1", "截断游戏入口测试").expect("project init"); let html = r#" diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/configuration.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/configuration.rs index 72fb58321..adb540bc2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/configuration.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/configuration.rs @@ -482,14 +482,6 @@ fn canonical_agent_reasoning_effort_defaults_are_exhaustive_and_auditable() { template.llm.as_ref().and_then(|llm| llm.max_retries), Some(DEFAULT_GAME_CREATOR_LLM_MAX_RETRIES) ); - assert_eq!( - template - .planning - .as_ref() - .and_then(|planning| planning.capability_enabled), - Some(true), - "bundled runtime config must keep planning capability enabled by default" - ); assert!( template.agent_llm.unwrap_or_default().is_empty(), "bundled template must not persist canonical defaults as explicit overrides" @@ -736,8 +728,8 @@ fn app_config_commands_write_runtime_config_file() { api_key: " editor-key ".to_string(), }, agent_llm, - planning: GameCreatorPlanningConfig::default(), selected_model_id: "default".to_string(), + selected_model_is_default: false, }) .expect("write runtime config"); @@ -775,6 +767,7 @@ fn app_config_commands_write_runtime_config_file() { .expect("read persisted runtime config"); assert!(!persisted.contains("editorApi")); assert!(!persisted.contains("editor-key")); + assert!(!root.join(GAME_CREATOR_LOCAL_CONFIG_FILE_NAME).exists()); let read_back = read_game_creator_app_config().expect("read runtime config"); assert_eq!(read_back.config.llm.model, "runtime-model"); @@ -806,6 +799,121 @@ fn app_config_commands_write_runtime_config_file() { fs::remove_dir_all(root).expect("cleanup runtime config dir"); } +#[test] +fn app_config_batch_restores_main_when_overlay_write_fails() { + for main_exists in [false, true] { + let root = unique_project_path(); + fs::create_dir_all(&root).expect("config dir"); + let main = root.join(GAME_CREATOR_CONFIG_FILE_NAME); + let overlay = root.join(GAME_CREATOR_LOCAL_CONFIG_FILE_NAME); + let original = "{\"llm\":{\"stream\":false}}\n"; + if main_exists { + fs::write(&main, original).expect("main config"); + } + fs::write(&overlay, original).expect("overlay config"); + // 普通目录占据备份路径,让覆盖文件在替换前失败。 + fs::create_dir(root.join(format!(".{}.previous", GAME_CREATOR_LOCAL_CONFIG_FILE_NAME))) + .expect("block overlay replacement"); + crate::config::write_game_creator_config_batch(&[ + (main.clone(), "{\"llm\":{\"stream\":true}}\n".to_string()), + (overlay.clone(), "{\"llm\":{\"stream\":true}}\n".to_string()), + ]) + .expect_err("overlay replacement must fail"); + if main_exists { + assert_eq!(fs::read_to_string(&main).expect("restored main"), original); + } else { + assert!(!main.exists()); + } + assert_eq!( + fs::read_to_string(&overlay).expect("unchanged overlay"), + original + ); + fs::remove_dir_all(root).expect("cleanup config dir"); + } +} + +#[test] +fn app_config_save_updates_conflicting_local_overlay() { + let root = unique_project_path(); + fs::create_dir_all(&root).expect("config dir"); + let _guard = use_test_runtime_config_dir(root.clone()); + let overlay_path = root.join(GAME_CREATOR_LOCAL_CONFIG_FILE_NAME); + fs::write( + &overlay_path, + r#"{"llm":{"reasoningEffort":"low"},"selectedModelId":"existing","selectedModelIsDefault":true,"custom":{"keep":true}}"#, + ) + .expect("write overlay"); + let mut config = load_game_creator_app_config().expect("load config"); + config.llm.reasoning_effort = "high".to_string(); + config.selected_model_is_default = false; + let saved = write_game_creator_app_config(config).expect("save config"); + let effective = load_game_creator_app_config().expect("effective config"); + assert_eq!(saved.config.llm.reasoning_effort, "high"); + assert_eq!(effective.llm.reasoning_effort, "high"); + assert!(saved.config.selected_model_is_default); + assert!(effective.selected_model_is_default); + let overlay: serde_json::Value = + serde_json::from_str(&fs::read_to_string(&overlay_path).expect("read overlay")) + .expect("parse overlay"); + assert_eq!(overlay["selectedModelId"], "existing"); + assert_eq!(overlay["selectedModelIsDefault"], true); + assert_eq!( + overlay["llm"], + serde_json::json!({"reasoningEffort": "high"}) + ); + assert_eq!(overlay["custom"]["keep"], true); + fs::remove_dir_all(root).expect("cleanup config dir"); +} + +#[test] +fn app_config_model_selection_only_updates_model_overlay() { + let root = unique_project_path(); + fs::create_dir_all(&root).expect("config dir"); + let _guard = use_test_runtime_config_dir(root.clone()); + let overlay_path = root.join(GAME_CREATOR_LOCAL_CONFIG_FILE_NAME); + fs::write( + &overlay_path, + r#"{"selectedModelId":"old","selectedModelIsDefault":false,"llm":{"reasoningEffort":"low"}}"#, + ) + .expect("write overlay"); + let saved = select_game_creator_model("new-model".to_string(), true).expect("select model"); + let effective = load_game_creator_app_config().expect("effective config"); + assert_eq!(saved.config.selected_model_id, "new-model"); + assert_eq!(effective.selected_model_id, "new-model"); + assert!(saved.config.selected_model_is_default); + assert!(effective.selected_model_is_default); + let overlay: serde_json::Value = + serde_json::from_str(&fs::read_to_string(&overlay_path).expect("read overlay")) + .expect("parse overlay"); + assert_eq!( + overlay["llm"], + serde_json::json!({"reasoningEffort": "low"}) + ); + fs::remove_dir_all(root).expect("cleanup config dir"); +} + +#[test] +fn model_selection_default_flag_round_trips() { + let root = unique_project_path(); + fs::create_dir_all(&root).expect("runtime config dir"); + let _guard = use_test_runtime_config_dir(root.clone()); + + let saved = + select_game_creator_model("quality".to_string(), true).expect("select default model"); + assert_eq!(saved.config.selected_model_id, "quality"); + assert!(saved.config.selected_model_is_default); + + let saved = + select_game_creator_model("fast".to_string(), false).expect("select explicit model"); + assert_eq!(saved.config.selected_model_id, "fast"); + assert!(!saved.config.selected_model_is_default); + + let reloaded = read_game_creator_app_config().expect("read runtime config"); + assert_eq!(reloaded.config.selected_model_id, "fast"); + assert!(!reloaded.config.selected_model_is_default); + fs::remove_dir_all(root).expect("cleanup runtime config dir"); +} + #[test] fn app_config_write_rejects_invalid_api_kind() { let root = unique_project_path(); @@ -822,8 +930,8 @@ fn app_config_write_rejects_invalid_api_kind() { }, editor_api: GameCreatorEditorApiConfig::default(), agent_llm: BTreeMap::new(), - planning: GameCreatorPlanningConfig::default(), selected_model_id: "default".to_string(), + selected_model_is_default: false, }); assert!(result @@ -848,8 +956,8 @@ fn app_config_write_rejects_invalid_reasoning_effort() { }, editor_api: GameCreatorEditorApiConfig::default(), agent_llm: BTreeMap::new(), - planning: GameCreatorPlanningConfig::default(), selected_model_id: "default".to_string(), + selected_model_is_default: false, }); assert!(result @@ -874,8 +982,8 @@ fn app_config_write_rejects_too_small_request_timeout() { }, editor_api: GameCreatorEditorApiConfig::default(), agent_llm: BTreeMap::new(), - planning: GameCreatorPlanningConfig::default(), selected_model_id: "default".to_string(), + selected_model_is_default: false, }); assert!(result @@ -1076,6 +1184,56 @@ fn llm_config_check_reports_per_agent_status_without_leaking_keys() { fs::remove_dir_all(root).ok(); } +#[test] +fn fallback_template_read_stays_on_snapshot_channel_outside_runtime_dir() { + let root = unique_project_path(); + let template_dir = root.join("apps").join("ai-game-creator-shell"); + fs::create_dir_all(&template_dir).expect("fallback template dir"); + let template = template_dir.join(GAME_CREATOR_CONFIG_FILE_NAME); + fs::write( + &template, + "{\n \"llm\": { \"model\": \"fallback-template-model\" }\n}\n", + ) + .expect("write fallback template"); + // 设置一个与模板无关的 runtime dir,让分类断言真正覆盖"按路径归属"而非 + // "runtime dir 为 None 时恒 false"的全局开关。 + let runtime_root = unique_project_path(); + fs::create_dir_all(&runtime_root).expect("unrelated runtime config dir"); + let _guard = use_test_runtime_config_dir(runtime_root.clone()); + + // 仓库旁边的回退模板是共享输入,读取绝不能走会收紧 owner/DACL 的私有通道。 + assert!(!game_creator_config_path_is_runtime_managed(&template)); + assert!(game_creator_config_path_is_runtime_managed( + &runtime_root.join(GAME_CREATOR_CONFIG_FILE_NAME) + )); + let content = read_game_creator_config_file(&template).expect("read fallback template"); + assert!(content + .expect("fallback template content") + .contains("fallback-template-model")); + fs::remove_dir_all(root).ok(); + fs::remove_dir_all(runtime_root).ok(); +} + +#[test] +fn runtime_config_read_stays_on_private_channel_inside_runtime_dir() { + let root = unique_project_path(); + fs::create_dir_all(&root).expect("runtime config dir"); + let config_path = root.join(GAME_CREATOR_CONFIG_FILE_NAME); + fs::write( + &config_path, + "{\n \"llm\": { \"model\": \"managed-config-model\" }\n}\n", + ) + .expect("write runtime config"); + let _guard = use_test_runtime_config_dir(root.clone()); + + assert!(game_creator_config_path_is_runtime_managed(&config_path)); + let content = read_game_creator_config_file(&config_path).expect("read runtime config"); + assert!(content + .expect("runtime config content") + .contains("managed-config-model")); + fs::remove_dir_all(root).ok(); +} + #[test] fn llm_config_check_reports_agent_specific_config_paths() { let root = unique_project_path(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/goal.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/goal.rs index da15d08a6..6f2b06cb6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/goal.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/goal.rs @@ -1944,7 +1944,7 @@ fn validate_llm_game_draft_accepts_win_condition_as_goal() { #[tokio::test] async fn agent_run_resume_restarts_generation_from_latest_goal() { let root = unique_project_path(); - init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init"); + init_existing_html_project_at(&root, "project-1", "像素动作原型").expect("project init"); write_agent_run_trace( &root, "run-control-resume", diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs index bf2635753..e2c15a956 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs @@ -299,6 +299,22 @@ impl Drop for TestRuntimeConfigDirGuard { } } +fn init_existing_html_project_at( + root: &Path, + project_id: &str, + name: &str, +) -> Result { + // 已有单 HTML 项目在初始化前就有入口,不能使用新建 npm 项目的脚手架。 + let game_dir = root.join("game"); + fs::create_dir_all(&game_dir).expect("create existing HTML fixture directory"); + fs::write( + game_dir.join("index.html"), + "还没有生成游戏", + ) + .expect("write existing HTML fixture entry"); + init_local_game_project_at(root, project_id, name) +} + fn unique_project_path() -> PathBuf { let millis = SystemTime::now() .duration_since(UNIX_EPOCH) @@ -918,8 +934,6 @@ fn pending_tool_action_for_test( source: state.source.clone(), run_profile: default_agent_runtime_run_profile(), run_profile_binding_fingerprint: String::new(), - planning_session_binding: None, - provider_batch_plan_update: None, task: state.current_task.clone(), goal_id: state.goal_id.clone(), goal_revision: state.goal_revision, @@ -3706,52 +3720,6 @@ fn ui_prototype_assessment_fixture(passed: bool) -> String { .to_string() } -fn append_ui_prototype_inspection_fixture(root: &Path, run_id: &str, passed: bool) { - append_ui_prototype_inspection_fixture_with_profile( - root, - run_id, - passed, - AGENT_RUNTIME_UI_PROTOTYPE_VALIDATION_PROFILE, - ); -} - -fn append_ui_prototype_inspection_fixture_with_profile( - root: &Path, - run_id: &str, - passed: bool, - validation_profile: &str, -) { - let image_bytes = - fs::read(root.join(AGENT_RUNTIME_UI_PROTOTYPE_PATH)).expect("read UI prototype fixture"); - let image_sha256 = format!("{:x}", Sha256::digest(&image_bytes)); - let issues = if passed { - Vec::::new() - } else { - vec!["只有战场场景和来袭箭头,缺少资源栏、单位卡槽、波次状态与主要控件".to_string()] - }; - append_agent_db_record( - root, - serde_json::json!({ - "recordType": "agent.runtime.image.inspect", - "agentId": "design-foundation", - "runId": run_id, - "images": [{ - "path": AGENT_RUNTIME_UI_PROTOTYPE_PATH, - "sha256": image_sha256, - "bytes": image_bytes.len(), - }], - "responseId": "resp_ui_prototype_fixture", - "conclusionChars": 20, - "inspectionKind": AGENT_RUNTIME_UI_PROTOTYPE_INSPECTION_KIND, - "validationProfile": validation_profile, - "passed": passed, - "checks": ui_prototype_checks_fixture(passed), - "issues": issues, - }), - ) - .expect("append UI prototype inspection fixture"); -} - fn supervisor_collaboration_delegate_action_for_test( agent_id: &str, repair_of_delegation_id: Option<&str>, @@ -6101,6 +6069,7 @@ mod command_runtime; pub(crate) mod configuration; mod goal; mod project; +mod project_lock_recovery; mod project_tools; mod provider; mod response_stream; diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs index 54f8eae14..048a20a18 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs @@ -264,12 +264,10 @@ fn agent_runtime_context_window_counts_distinct_agent_message_bodies() { } #[test] -fn agent_runtime_context_window_is_off_for_the_planning_lane_and_on_elsewhere() { +fn agent_runtime_context_window_applies_to_every_lane() { let root = unique_project_path(); init_local_game_project_at(&root, "project-window-lane", "窗口分档项目").expect("project init"); - // 判据只读 agent_id / source 两个字段,所以起一次普通 run 再克隆覆盖即可; - // 直接以 project-planning 起 run 需要额外的 Run Profile 绑定,与本用例无关。 let started = start_game_creator_agent_runtime_task_at( &root, "design-director", @@ -310,94 +308,6 @@ fn agent_runtime_context_window_is_off_for_the_planning_lane_and_on_elsewhere() assert_eq!(checkpoint, AgentRuntimeContextCheckpoint::Continue); } } - - // 立项策划链路(策划根 + 策划子):计数恒为 0,永远不越窗口边界。 - for state in [ - lane_state( - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE, - ), - lane_state( - crate::GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - "agent-delegate", - ), - ] { - assert!(!agent_runtime_context_window_applies(&state)); - let mut tracker = - AgentRuntimeContextWindowTracker::from_continuation(&Default::default(), &state); - for next_loop_index in 1..=(AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT * 2 + 1) { - tracker.record(&AgentRuntimeToolObservation { - tool: "project.search".to_string(), - status: "ok".to_string(), - summary: "重复窗口标记".to_string(), - detail: None, - }); - assert_eq!( - tracker.complete_loop(next_loop_index), - AgentRuntimeContextCheckpoint::Continue, - "立项策划链路不产出 checkpoint,也不得被判停滞" - ); - } - } -} - -/// 计数恒为 0 的直接收益:暂停后恢复时,无论 `nextLoopIndex` 落在窗口的哪个位置, -/// 持久化的 bundle 都能通过窗口校验。这正是澄清应答后 `windowCompletedLoops=6 max=5` -/// 把整根 run 判失败的那条路径。 -#[test] -fn planning_lane_context_bundle_survives_every_resume_offset() { - let root = unique_project_path(); - init_local_game_project_at(&root, "project-window-resume", "窗口恢复项目") - .expect("project init"); - let mut state = start_game_creator_agent_runtime_task_at( - &root, - "design-director", - "验证澄清恢复", - "planning-window-resume-run", - AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE, - "窗口恢复测试", - vec!["提交 GDD".to_string()], - ) - .expect("start planning runtime state"); - assert!(!agent_runtime_context_window_applies(&state)); - - // continuation 逐轮接力,复现真实恢复路径上「计数跨暂停携带、序号从 pending 重读」 - // 的形状——这正是两个源分岔的地方。 - let mut continuation = AgentRuntimeContinuationContext::default(); - for next_loop_index in 0..=(AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT * 2) { - let mut tracker = - AgentRuntimeContextWindowTracker::from_continuation(&continuation, &state); - tracker.record(&AgentRuntimeToolObservation { - tool: "project.search".to_string(), - status: "ok".to_string(), - summary: "窗口标记".to_string(), - detail: None, - }); - assert_eq!( - tracker.complete_loop(next_loop_index), - AgentRuntimeContextCheckpoint::Continue - ); - let bundle = build_game_creator_agent_runtime_context_bundle( - &root, - &state, - &state.current_task, - &AgentRuntimeToolPlan::default(), - &[], - next_loop_index, - &tracker, - ) - .expect("build planning context bundle"); - write_game_creator_agent_runtime_context_bundle(&root, &bundle) - .expect("write planning context bundle"); - state.loop_iteration = u32::try_from(next_loop_index).unwrap_or(u32::MAX); - let loaded = read_game_creator_agent_runtime_context_bundle(&root, &state) - .unwrap_or_else(|error| { - panic!("nextLoopIndex={next_loop_index} 的策划 bundle 必须通过窗口校验:{error}") - }) - .unwrap_or_else(|| panic!("nextLoopIndex={next_loop_index} 的策划 bundle 必须存在")); - assert_eq!(loaded.window_completed_loops, 0); - continuation = continuation_from_game_creator_agent_runtime_context_bundle(loaded); - } } #[test] @@ -550,7 +460,7 @@ fn canonical_visual_completion_requires_persisted_route_kind_and_current_spec_re #[tokio::test] async fn background_agent_runtime_can_start_local_preview() { let root = unique_project_path(); - init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init"); + init_existing_html_project_at(&root, "project-1", "月光厨房").expect("project init"); fs::write( root.join("game/index.html"), fake_llm_game_draft().game_html, @@ -1165,6 +1075,7 @@ async fn canonical_art_spec_and_ui_requests_use_the_shared_reference_chain() { asset_kind: "ui-prototype".to_string(), asset_label: "游戏横屏界面原型图".to_string(), replace_existing: false, + slice_count: None, }, ) .await; @@ -1288,6 +1199,7 @@ async fn platform_art_external_request_does_not_hold_project_lock_or_overwrite_m #[tokio::test] async fn generate_local_game_draft_fails_after_max_passes_without_final_artifacts() { let root = unique_project_path(); + init_existing_html_project_at(&root, "project-1", "HTML fixture").expect("project init"); let mut invalid_draft = fake_llm_game_draft(); invalid_draft.game_html = r#" @@ -2589,7 +2501,7 @@ fn local_project_file_commands_read_write_list_and_delete_text_files() { #[test] fn local_project_export_package_uses_runtime_whitelist_and_records() { let root = unique_project_path(); - init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init"); + init_existing_html_project_at(&root, "project-1", "像素动作原型").expect("project init"); write_local_project_file_at(&root, "game/index.html", &fake_llm_game_draft().game_html) .expect("write playable html"); write_local_project_file_at(&root, "assets/hero.txt", "hero asset").expect("write asset"); @@ -2708,7 +2620,7 @@ fn local_project_export_package_list_skips_symlink_packages() { #[test] fn local_project_export_package_rejects_symlink_assets() { let root = unique_project_path(); - init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init"); + init_existing_html_project_at(&root, "project-1", "像素动作原型").expect("project init"); write_local_project_file_at(&root, "game/index.html", &fake_llm_game_draft().game_html) .expect("write playable html"); write_local_project_file_at(&root, "exports/README.md", "playtest notes") @@ -2730,7 +2642,7 @@ fn local_project_export_package_rejects_symlink_assets() { #[test] fn local_project_export_package_rejects_symlink_runtime_dirs_and_readme() { let root = unique_project_path(); - init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init"); + init_existing_html_project_at(&root, "project-1", "像素动作原型").expect("project init"); write_local_project_file_at(&root, "game/index.html", &fake_llm_game_draft().game_html) .expect("write playable html"); write_local_project_file_at(&root, "memory/project.md", "private memory") @@ -2762,7 +2674,7 @@ fn local_project_export_package_rejects_symlink_runtime_dirs_and_readme() { #[test] fn local_project_export_package_requires_playable_html_and_readme() { let root = unique_project_path(); - init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init"); + init_existing_html_project_at(&root, "project-1", "像素动作原型").expect("project init"); let missing_readme = export_local_project_package_at(&root).expect_err("default html is not playable"); assert!(missing_readme.contains("游戏入口必须包含可渲染画布")); @@ -3639,7 +3551,7 @@ fn cli_agent_resume_without_appdata_fails_before_runtime_dispatch() { #[test] fn local_preview_server_serves_game_index() { let root = unique_project_path(); - init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init"); + init_existing_html_project_at(&root, "project-1", "像素动作原型").expect("project init"); fs::write(root.join("assets/player.png"), b"PNGDATA").expect("asset"); let (preview, stop) = start_local_game_preview_for_project(&root).expect("preview start"); @@ -4618,7 +4530,7 @@ fn local_preview_bridge_injection_stays_outside_unclosed_inert_html_contexts() { #[test] fn local_preview_server_drains_split_browser_headers_before_response() { let root = unique_project_path(); - init_local_game_project_at(&root, "project-1", "鍍忕礌鍔ㄤ綔鍘熷瀷").expect("project init"); + init_existing_html_project_at(&root, "project-1", "鍍忕礌鍔ㄤ綔鍘熷瀷").expect("project init"); let (preview, stop) = start_local_game_preview_for_project(&root).expect("preview start"); let mut stream = TcpStream::connect(("127.0.0.1", preview.port)).expect("preview connect"); @@ -4698,7 +4610,7 @@ fn preview_content_type_covers_common_game_assets() { #[test] fn local_preview_head_preserves_asset_content_length() { let root = unique_project_path(); - init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init"); + init_existing_html_project_at(&root, "project-1", "像素动作原型").expect("project init"); fs::write(root.join("assets/player.png"), b"PNGDATA").expect("asset"); let response = build_preview_response(&root, "HEAD", "/assets/player.png"); @@ -4740,7 +4652,7 @@ fn local_preview_project_revision_reports_the_current_atomic_sidecar() { #[test] fn local_preview_start_rejects_a_stale_validated_revision_atomically() { let root = unique_project_path(); - init_local_game_project_at(&root, "project-1", "预览 revision 启动门禁测试") + init_existing_html_project_at(&root, "project-1", "预览 revision 启动门禁测试") .expect("project init"); let revision = advance_project_revision_for_test( &root, @@ -4767,7 +4679,7 @@ fn local_preview_start_rejects_a_stale_validated_revision_atomically() { #[test] fn stale_preview_cleanup_does_not_stop_a_newer_matching_project_server() { let root = unique_project_path(); - init_local_game_project_at(&root, "project-1", "预览原子停止测试").expect("project init"); + init_existing_html_project_at(&root, "project-1", "预览原子停止测试").expect("project init"); let registry = PreviewRegistry::default(); let (first, first_stop) = start_local_game_preview_for_project(&root).expect("first preview start"); @@ -4798,7 +4710,8 @@ fn stale_preview_cleanup_does_not_stop_a_newer_matching_project_server() { #[test] fn stale_preview_cleanup_cannot_be_blocked_by_project_stop_policy() { let root = unique_project_path(); - init_local_game_project_at(&root, "project-1", "预览补偿清理策略测试").expect("project init"); + init_existing_html_project_at(&root, "project-1", "预览补偿清理策略测试") + .expect("project init"); write_project_permission_policy_at( &root, ProjectPermissionPolicy { @@ -4825,6 +4738,7 @@ fn stale_preview_cleanup_cannot_be_blocked_by_project_stop_policy() { #[test] fn local_preview_serves_generated_playable_game() { let root = unique_project_path(); + init_existing_html_project_at(&root, "project-1", "HTML fixture").expect("project init"); write_local_game_draft_at(&root, "像素风横版动作", &fake_llm_game_draft()) .expect("draft should generate"); @@ -4856,6 +4770,7 @@ fn ui_prototype_generation_uses_dedicated_prompt_and_art_spec() { asset_kind: "ui-prototype".to_string(), asset_label: "游戏横屏界面原型图".to_string(), replace_existing: false, + slice_count: None, }; let prompt = build_platform_art_asset_prompt( "原创网格贪吃蛇:分数与状态 HUD、四类不同分值食物、开始、方向键/WASD、触控方向键、失败与重开", diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/project_lock_recovery.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/project_lock_recovery.rs new file mode 100644 index 000000000..f7de9d54e --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/project_lock_recovery.rs @@ -0,0 +1,444 @@ +use super::*; +use std::process::Stdio; + +// Issue #310 复现:异常退出后在项目里残留 `.agent/project.lock`,下一次打开 +// 项目时所有写操作都被拒绝。 +// +// 下面的用例是回归护栏:持有者确实不存在时残留锁必须被安全回收,同时不能抢走 +// 仍然活着持有者的锁。 + +const PROJECT_LOCK_RELATIVE_PATH: &str = ".agent/project.lock"; +/// 一个确定不会被占用的进程号:高于两个平台实际分配的进程号上限,因此 Windows +/// 的 OpenProcess 对它返回 ERROR_INVALID_PARAMETER,Unix 的 kill(pid, 0) 返回 +/// ESRCH。取值必须落在 Unix 有符号 32 位 pid 范围内,否则在 Unix 上会先命中 +/// “进程号不可表示”分支,而不是这条“死进程”分支。 +const DEAD_OWNER_PID: u64 = i32::MAX as u64 - 1; + +fn write_project_lock_fixture(root: &Path, content: &[u8]) { + fs::write(root.join(PROJECT_LOCK_RELATIVE_PATH), content).expect("写入项目写锁 fixture"); +} + +fn project_lock_fixture_payload(pid: u64, created_at: u64) -> Vec { + serde_json::to_vec_pretty(&serde_json::json!({ + "commandId": "repro.crashed-writer", + "pid": pid, + "createdAt": created_at, + "nonce": 1, + })) + .expect("序列化项目写锁 fixture") +} + +fn backdate_project_lock_fixture(root: &Path, seconds: u64) { + let file = fs::OpenOptions::new() + .write(true) + .open(root.join(PROJECT_LOCK_RELATIVE_PATH)) + .expect("打开项目写锁 fixture"); + file.set_modified(SystemTime::now() - Duration::from_secs(seconds)) + .expect("回拨项目写锁 fixture mtime"); +} + +/// 写入锁 fixture 并读回同一份快照,供纯判据用例使用。 +fn project_lock_snapshot(root: &Path, content: &[u8]) -> ProjectWriteLockSnapshot { + write_project_lock_fixture(root, content); + ProjectWriteLockSnapshot::read(&root.join(PROJECT_LOCK_RELATIVE_PATH)) + .expect("读取项目写锁快照") +} + +#[cfg(windows)] +fn spawn_unrelated_live_process() -> std::process::Child { + std::process::Command::new("ping") + .args(["-n", "30", "127.0.0.1"]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("启动无关的活进程") +} + +#[cfg(target_os = "linux")] +fn spawn_unrelated_live_process() -> std::process::Child { + std::process::Command::new("sleep") + .arg("30") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("启动无关的活进程") +} + +fn stop_unrelated_live_process(mut child: std::process::Child) { + let _ = child.kill(); + let _ = child.wait(); +} + +/// 基线:记录着已死进程号的残留锁本来就应该被回收。 +#[test] +fn project_write_lock_reclaims_dead_owner_pid() { + let root = unique_project_path(); + init_local_game_project_at(&root, "lock-dead-owner", "锁回收-死进程").expect("初始化项目"); + write_project_lock_fixture( + &root, + &project_lock_fixture_payload(DEAD_OWNER_PID, unix_timestamp()), + ); + + let acquired = acquire_project_write_lock(&root, "repro.acquire-after-crash"); + assert!( + acquired.is_ok(), + "死进程残留锁未被回收,实际错误:{:?}", + acquired.err() + ); + drop(acquired); + fs::remove_dir_all(root).ok(); +} + +/// 锁文件被外部改写或截断损坏、PID 超出平台进程号空间(例如 u64::MAX)时,它不 +/// 可能属于任何活进程,必须直接回收而不是再等满 600 秒;Unix 的 pid_t 只有有符号 +/// 32 位,越界取值尤其容易在这里被漏掉。 +#[test] +fn project_write_lock_reclaims_unrepresentable_owner_pid() { + let root = unique_project_path(); + init_local_game_project_at(&root, "lock-invalid-pid", "锁回收-非法进程号").expect("初始化项目"); + write_project_lock_fixture( + &root, + &project_lock_fixture_payload(u64::MAX, unix_timestamp()), + ); + + let acquired = acquire_project_write_lock(&root, "repro.acquire-after-crash"); + assert!( + acquired.is_ok(), + "非法进程号的残留锁未被回收,实际错误:{:?}", + acquired.err() + ); + drop(acquired); + fs::remove_dir_all(root).ok(); +} + +/// 空锁超过 30 秒宽限期即回收:崩溃停在 `create_new` 与落盘 payload 之间时写入方 +/// 已经放弃,不能继续阻塞项目。 +#[test] +fn project_write_lock_reclaims_empty_body_after_short_grace() { + let root = unique_project_path(); + init_local_game_project_at(&root, "lock-empty-body", "锁回收-空锁").expect("初始化项目"); + write_project_lock_fixture(&root, b""); + backdate_project_lock_fixture(&root, 120); + + let acquired = acquire_project_write_lock(&root, "repro.acquire-after-crash"); + assert!( + acquired.is_ok(), + "空残留锁超过宽限期仍未被回收,实际错误:{:?}", + acquired.err() + ); + drop(acquired); + fs::remove_dir_all(root).ok(); +} + +/// 旧格式锁(无 `processStartedAt`)记录的 PID 已被复用给另一个活进程时,按“进程 +/// 启动时间晚于锁创建时间”推断原持有者已退出并回收。 +#[cfg(any(windows, target_os = "linux"))] +#[test] +fn project_write_lock_reclaims_pid_reused_by_other_live_process() { + let root = unique_project_path(); + init_local_game_project_at(&root, "lock-pid-reuse", "锁回收-PID复用").expect("初始化项目"); + + let unrelated = spawn_unrelated_live_process(); + // 锁是在一小时前被写下的,而记录里的 PID 现在属于刚刚才启动的另一个进程: + // 这只能是 PID 复用,原持有者早已退出。 + write_project_lock_fixture( + &root, + &project_lock_fixture_payload(u64::from(unrelated.id()), unix_timestamp() - 3_600), + ); + + let acquired = acquire_project_write_lock(&root, "repro.acquire-after-crash"); + stop_unrelated_live_process(unrelated); + assert!( + acquired.is_ok(), + "PID 复用后的残留锁未被回收,实际错误:{:?}", + acquired.err() + ); + drop(acquired); + fs::remove_dir_all(root).ok(); +} + +/// 新格式锁自带进程启动身份:即使时间戳看起来“刚刚写过”,只要身份对不上就判定 +/// PID 复用并回收;这条路径不依赖系统时钟是否发生跳变。 +#[cfg(any(windows, target_os = "linux"))] +#[test] +fn project_write_lock_reclaims_pid_reused_identity_mismatch() { + let root = unique_project_path(); + init_local_game_project_at(&root, "lock-pid-identity", "锁回收-身份不一致") + .expect("初始化项目"); + let unrelated = spawn_unrelated_live_process(); + let started_at = project_write_lock_process_start_time_seconds(u64::from(unrelated.id())) + .expect("读取活进程启动时间"); + write_project_lock_fixture( + &root, + &serde_json::to_vec_pretty(&serde_json::json!({ + "commandId": "repro.crashed-writer", + "pid": u64::from(unrelated.id()), + "processStartedAt": started_at + 1, + "createdAt": unix_timestamp(), + "nonce": 1, + })) + .expect("序列化项目写锁 fixture"), + ); + + let acquired = acquire_project_write_lock(&root, "repro.acquire-after-crash"); + stop_unrelated_live_process(unrelated); + assert!( + acquired.is_ok(), + "启动身份不一致的残留锁未被回收,实际错误:{:?}", + acquired.err() + ); + drop(acquired); + fs::remove_dir_all(root).ok(); +} + +/// 护栏:PID 与启动身份都吻合说明持有者真的活着,绝不能抢锁。 +#[cfg(any(windows, target_os = "linux"))] +#[test] +fn project_write_lock_keeps_matching_process_identity() { + let root = unique_project_path(); + init_local_game_project_at(&root, "lock-live-identity", "锁回收-身份一致").expect("初始化项目"); + let unrelated = spawn_unrelated_live_process(); + let started_at = project_write_lock_process_start_time_seconds(u64::from(unrelated.id())) + .expect("读取活进程启动时间"); + write_project_lock_fixture( + &root, + &serde_json::to_vec_pretty(&serde_json::json!({ + "commandId": "repro.live-writer", + "pid": u64::from(unrelated.id()), + "processStartedAt": started_at, + "createdAt": unix_timestamp(), + "nonce": 1, + })) + .expect("序列化项目写锁 fixture"), + ); + + let error = acquire_project_write_lock(&root, "repro.acquire-concurrent") + .expect_err("持有者仍然活着时不能回收"); + stop_unrelated_live_process(unrelated); + assert!( + error.contains("项目正在被其他写操作占用"), + "活持有者必须进入占用分支,实际错误:{error}" + ); + fs::remove_dir_all(root).ok(); +} + +/// 护栏:刚创建的空锁可能只是写入方还没落盘,绝不能被别人抢走。 +#[test] +fn project_write_lock_keeps_fresh_empty_body() { + let root = unique_project_path(); + init_local_game_project_at(&root, "lock-fresh-empty", "锁回收-新鲜空锁").expect("初始化项目"); + write_project_lock_fixture(&root, b""); + + let error = acquire_project_write_lock(&root, "repro.acquire-concurrent") + .expect_err("刚创建的空锁必须保持占用"); + assert!( + error.contains("项目正在被其他写操作占用"), + "并发写必须进入占用分支,实际错误:{error}" + ); + fs::remove_dir_all(root).ok(); +} + +/// 护栏:旧格式锁(有 `pid` 和 `createdAt`、没有 `processStartedAt`)的持有者确实 +/// 活着,且进程启动时间早于锁创建时间时,不能被当成 PID 复用抢走。 +#[cfg(any(windows, target_os = "linux"))] +#[test] +fn project_write_lock_keeps_live_old_format_holder_started_before_created_at() { + let root = unique_project_path(); + init_local_game_project_at(&root, "lock-old-format-live", "锁回收-旧格式活持有者") + .expect("初始化项目"); + let unrelated = spawn_unrelated_live_process(); + let started_at = project_write_lock_process_start_time_seconds(u64::from(unrelated.id())) + .expect("读取活进程启动时间"); + // 锁是在持有者启动之后才写下的,所以进程启动时间自然早于 createdAt。 + write_project_lock_fixture( + &root, + &project_lock_fixture_payload(u64::from(unrelated.id()), started_at + 60), + ); + + let error = acquire_project_write_lock(&root, "repro.acquire-concurrent"); + stop_unrelated_live_process(unrelated); + assert!( + matches!(&error, Err(message) if message.contains("项目正在被其他写操作占用")), + "旧格式活持有者不能被当成 PID 复用,实际结果:{error:?}" + ); + fs::remove_dir_all(root).ok(); +} + +/// 护栏:回收判定只是快照观察,删除前必须重新核对内容。判定之后被并发方替换成 +/// 自己的活锁时不能删除它;文件已经消失时也不算失败。 +#[test] +fn project_write_lock_reclaim_skips_replaced_or_removed_lock_file() { + let root = unique_project_path(); + init_local_game_project_at(&root, "lock-reclaim-race", "锁回收-并发替换").expect("初始化项目"); + let lock_path = root.join(PROJECT_LOCK_RELATIVE_PATH); + write_project_lock_fixture( + &root, + &project_lock_fixture_payload(4_242, unix_timestamp()), + ); + let stale_snapshot = ProjectWriteLockSnapshot::read(&lock_path).expect("读取残留锁快照"); + + // 并发方回收了残留锁并装上了自己的活锁。 + write_project_lock_fixture( + &root, + &project_lock_fixture_payload(u64::from(std::process::id()), unix_timestamp()), + ); + assert!( + !project_write_lock_reclaim(&lock_path, &stale_snapshot).expect("核对被替换的锁文件"), + "内容已经变化时必须放弃删除" + ); + assert!(lock_path.exists(), "并发方新装的活锁不能被删掉"); + + fs::remove_file(&lock_path).expect("删除锁文件"); + assert!( + !project_write_lock_reclaim(&lock_path, &stale_snapshot).expect("核对已消失的锁文件"), + "锁文件已经消失时不算失败" + ); + fs::remove_dir_all(root).ok(); +} + +/// 护栏:存活状态无法判定(`None`)时保持保守——年轻锁必须保留,明显过期才回收。 +#[test] +fn project_write_lock_decision_keeps_young_lock_when_liveness_is_unknown() { + let root = unique_project_path(); + init_local_game_project_at(&root, "lock-unknown-liveness", "锁回收-存活未知") + .expect("初始化项目"); + let now = unix_timestamp(); + let snapshot = project_lock_snapshot(&root, &project_lock_fixture_payload(4_242, now)); + + assert!( + !project_write_lock_reclaim_decision(&snapshot, Some(now), now, |_| None, |_| None), + "存活状态无法判定时,新鲜锁必须保持占用" + ); + assert!( + project_write_lock_reclaim_decision(&snapshot, Some(now), now + 601, |_| None, |_| None), + "存活状态无法判定且明显过期时必须回收" + ); + fs::remove_dir_all(root).ok(); +} + +/// 护栏:mtime 不可读(未知)时不能退化成“年龄极大”。未知年龄按不回收处理, +/// 未知创建时间也不能满足“启动时间晚于创建时间”的 PID 复用推断。 +#[test] +fn project_write_lock_decision_keeps_lock_when_mtime_is_unknown() { + let root = unique_project_path(); + init_local_game_project_at(&root, "lock-unknown-mtime", "锁回收-mtime未知") + .expect("初始化项目"); + let now = unix_timestamp(); + + let empty = project_lock_snapshot(&root, b""); + assert!( + !project_write_lock_reclaim_decision(&empty, None, now + 601, |_| None, |_| None), + "mtime 未知时空锁必须保持占用" + ); + assert!( + project_write_lock_reclaim_decision(&empty, Some(now - 120), now, |_| None, |_| None), + "mtime 已知且超过宽限期的空锁必须回收" + ); + + let live_old_format = project_lock_snapshot( + &root, + &serde_json::to_vec_pretty(&serde_json::json!({ + "commandId": "repro.live-writer", + "pid": 4_242, + "nonce": 1, + })) + .expect("序列化项目写锁 fixture"), + ); + assert!( + !project_write_lock_reclaim_decision( + &live_old_format, + None, + now + 601, + |_| Some(true), + |_| Some(now + 3_600) + ), + "创建时间未知时不能按 PID 复用抢走活持有者" + ); + fs::remove_dir_all(root).ok(); +} + +/// Issue #318 取证缺口:争用错误必须点名持锁方,现场才能回答"谁在持锁、是不是自己人"。 +/// 另一个活进程持锁时必须报成争用、带出身份,并且不能把它的锁当成残留回收掉。 +#[cfg(any(windows, target_os = "linux"))] +#[test] +fn project_write_lock_contention_names_a_live_external_holder() { + let root = unique_project_path(); + fs::create_dir_all(root.join(".agent")).expect("创建 .agent 目录"); + let holder = spawn_unrelated_live_process(); + let holder_pid = holder.id(); + write_project_lock_fixture( + &root, + &serde_json::to_vec_pretty(&serde_json::json!({ + "commandId": "external-editor-writer", + "pid": holder_pid, + "createdAt": unix_timestamp(), + "nonce": 7, + })) + .expect("序列化外部持有者锁 fixture"), + ); + let held_content = fs::read(root.join(PROJECT_LOCK_RELATIVE_PATH)).expect("读回持有者锁"); + + let error = + acquire_project_write_lock(&root, "file.write").expect_err("活的外部进程持锁时必须报争用"); + assert!( + error.starts_with("项目正在被其他写操作占用:"), + "争用必须保持共享前缀:{error}" + ); + assert!( + error.contains("commandId=external-editor-writer"), + "错误必须点名持锁命令:{error}" + ); + assert!( + error.contains(&format!("pid={holder_pid}")), + "错误必须点名持锁进程:{error}" + ); + assert!( + error.contains("ownerIsSelf=false"), + "错误必须说明持锁方不是本进程:{error}" + ); + assert_eq!( + fs::read(root.join(PROJECT_LOCK_RELATIVE_PATH)).expect("复查持有者锁"), + held_content, + "活外部持有者的锁文件不得被回收或改写" + ); + assert!( + project_write_lock_contention_diagnostic(&root).contains(&format!("pid={holder_pid}")), + "等待日志必须带同一份持锁方身份" + ); + + stop_unrelated_live_process(holder); + fs::remove_dir_all(root).ok(); +} + +/// Issue #318 第 3 条:权限拒绝不能再投影成"被其他写操作占用"。 +/// 文案刻意不含争用前缀,有界等待和前端才不会把它当成"等一下就好"的瞬时状态。 +#[cfg(unix)] +#[test] +fn project_write_lock_does_not_project_permission_denial_as_contention() { + use std::os::unix::fs::PermissionsExt; + + let root = unique_project_path(); + let agent_directory = root.join(".agent"); + fs::create_dir_all(&agent_directory).expect("创建 .agent 目录"); + let original = fs::metadata(&agent_directory) + .expect("读取控制目录元数据") + .permissions(); + fs::set_permissions(&agent_directory, fs::Permissions::from_mode(0o500)) + .expect("去掉控制目录写权限"); + + let outcome = acquire_project_write_lock(&root, "file.write"); + fs::set_permissions(&agent_directory, original).expect("恢复控制目录权限"); + + // CI 容器以 root 运行,0o500 目录照样可以创建文件;此时本用例的前提不成立, + // 直接跳过。分类判据本身另有不依赖 ACL 环境的纯函数用例覆盖。 + let Err(error) = outcome else { + return; + }; + + assert!( + !error.starts_with("项目正在被其他写操作占用:"), + "权限拒绝不得投影成写锁争用:{error}" + ); + + fs::remove_dir_all(root).ok(); +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs index 6175d5e76..1eacbe9c9 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs @@ -98,6 +98,7 @@ async fn request_llm_game_draft_uses_openai_compatible_provider_output() { #[tokio::test] async fn generate_local_game_draft_sends_asset_context_to_llm() { let root = unique_project_path(); + init_existing_html_project_at(&root, "project-1", "HTML fixture").expect("project init"); let uploaded = upload_local_asset_at(&root, "../角色.png", "image/png", b"fake-png") .expect("asset upload"); append_local_conversation_message_at( @@ -1344,7 +1345,7 @@ async fn provider_action_batch_agent_db_failure_does_not_advance_cursor() { #[tokio::test] async fn provider_action_batch_mutation_then_verification_rolls_forward_gate() { let root = unique_project_path(); - init_local_game_project_at( + init_existing_html_project_at( &root, "project-provider-batch-mutation-verification", "Provider 批次修改后验证测试", @@ -3493,7 +3494,6 @@ fn provider_retry_waiting_same_attempt_for_distinct_requests_does_not_conflict() provider_config_fingerprint: "b".repeat(64), web_search_enabled: false, allow_idle_context_compaction: false, - planning_session_binding: None, }, next_request_slot: format!("{base_request_slot}-transient-1"), next_attempt: 1, @@ -5864,7 +5864,7 @@ async fn background_agent_runtime_cancellation_wins_over_inflight_llm_error() { #[tokio::test] async fn agent_loop_uses_per_agent_llm_overrides() { let root = unique_project_path(); - init_local_game_project_at(&root, "local-project-draft", "未命名游戏原型") + init_existing_html_project_at(&root, "local-project-draft", "未命名游戏原型") .expect("init project"); let (planner_sender, planner_receiver) = mpsc::channel(); let planner_base_url = spawn_mock_llm_server_responses_with_capture( @@ -6081,7 +6081,7 @@ async fn agent_role_briefs_run_same_wave_llm_agents_in_parallel() { #[tokio::test] async fn agent_loop_writes_spec_findings_and_retries_generator() { let root = unique_project_path(); - init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init"); + init_existing_html_project_at(&root, "project-1", "月光厨房").expect("project init"); let mut first_draft = fake_llm_game_draft(); first_draft.game_html = r#" diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/response_stream.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/response_stream.rs index 32a9c3ec9..9f1410fe2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/response_stream.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/response_stream.rs @@ -980,180 +980,6 @@ async fn provider_retry_final_reply_thinking_only_response_retries_before_handof fs::remove_dir_all(config_dir).ok(); } -#[test] -fn visual_specialist_finalization_requires_existing_registered_canvas_image() { - let _platform_session = crate::platform_session::install_test_platform_session( - "visual-finalization-test-user", - "visual-finalization-test-key", - "https://dev.genarrative.world", - ); - let _config_guard = crate::tests::write_test_local_config( - r#"{"editorApi":{"apiKey":"visual-finalization-test-key"}}"#.to_string(), - ); - for (agent_id, local_path, kind) in [ - ( - "design-foundation", - "assets/ui-prototype.png", - "ui-prototype", - ), - ( - "art-asset-plan", - "assets/art-spritesheet.png", - "art-spritesheet", - ), - ] { - let root = unique_project_path(); - init_local_game_project_at(&root, "project-1", "视觉 Runtime 完成门禁测试") - .expect("project init"); - register_canvas_visual_asset_fixture(&root, "assets/art-spec.png", "icon-spec"); - let run_id = format!("visual-finalization-{agent_id}"); - let state = start_game_creator_agent_runtime_task_at( - &root, - agent_id, - "必须交付真实图片", - &run_id, - "agent-background-task", - "准备完成", - vec!["生成并登记图片".to_string()], - ) - .expect("start visual runtime"); - let revision = read_game_creator_agent_runtime_project_revision(&root) - .expect("read visual revision") - .revision; - - let missing_file = finish_game_creator_agent_background_runtime_turn_at( - &root, - state.clone(), - "不能在缺图时完成", - revision, - &[], - ) - .expect("missing image is recoverable blocker"); - let blocker = match missing_file { - AgentBackgroundFinalizationOutcome::Stale(blocker) => blocker, - _ => panic!("missing image must block finalization"), - }; - assert_eq!(blocker.tool, "runtime.visual_asset"); - assert!(blocker.summary.contains("不能完成任务")); - assert!(blocker - .detail - .as_deref() - .is_some_and(|detail| detail.contains(local_path))); - - let absolute_path = root.join(local_path); - fs::create_dir_all(absolute_path.parent().expect("visual parent")) - .expect("create unregistered visual directory"); - fs::write( - &absolute_path, - if kind == "art-spritesheet" { - transparent_test_png_bytes() - } else { - valid_test_png_bytes() - }, - ) - .expect("write unregistered visual image"); - let unregistered = finish_game_creator_agent_background_runtime_turn_at( - &root, - state.clone(), - "不能在图片未登记时完成", - revision, - &[], - ) - .expect("unregistered image is recoverable blocker"); - assert!(matches!( - unregistered, - AgentBackgroundFinalizationOutcome::Stale(ref blocker) - if blocker.tool == "runtime.visual_asset" - )); - - register_local_asset_at( - &root, - local_path, - kind, - "image/png", - "canvas", - GameCreationAppAssetSource { - kind: GameCreationAppAssetSourceKind::Canvas, - canvas_project_id: Some("canvas-project-1".to_string()), - resource_id: Some(format!("resource-{kind}")), - asset_object_id: Some(format!("asset-object-{kind}")), - task_id: Some(format!("task-{kind}")), - prompt: Some("测试视觉资产".to_string()), - model: Some("gpt-image-2".to_string()), - generation_route: None, - generation_kind: None, - reference_resource_ids: Vec::new(), - }, - ) - .expect("register legacy canvas image"); - let legacy = finish_game_creator_agent_background_runtime_turn_at( - &root, - state.clone(), - "旧文件不能冒充正式视觉产物", - revision, - &[], - ) - .expect("legacy image is a recoverable blocker"); - assert!(matches!( - legacy, - AgentBackgroundFinalizationOutcome::Stale(ref blocker) - if blocker.tool == "runtime.visual_asset" - && blocker.detail.as_deref().is_some_and(|detail| detail.contains("legacy")) - )); - register_local_asset_at( - &root, - local_path, - kind, - "image/png", - "canvas", - GameCreationAppAssetSource { - kind: GameCreationAppAssetSourceKind::Canvas, - canvas_project_id: Some("canvas-project-1".to_string()), - resource_id: Some(format!("resource-{kind}")), - asset_object_id: Some(format!("asset-object-{kind}")), - task_id: Some(format!("task-{kind}")), - prompt: Some("测试视觉资产".to_string()), - model: Some("gpt-image-2".to_string()), - generation_route: Some( - if kind == "ui-prototype" { - "/api/external/v1/editor/images/generations" - } else { - "/api/external/v1/editor/icon-spritesheets/generations" - } - .to_string(), - ), - generation_kind: Some( - if kind == "ui-prototype" { - "ui-design" - } else { - "icon-spritesheet" - } - .to_string(), - ), - reference_resource_ids: vec!["resource-icon-spec".to_string()], - }, - ) - .expect("register provenanced canvas image"); - if agent_id == "design-foundation" { - append_ui_prototype_inspection_fixture(&root, &run_id, true); - } - let completed = finish_game_creator_agent_background_runtime_turn_at( - &root, - state, - "真实图片已经生成并登记。", - revision, - &[], - ) - .expect("registered image allows finalization"); - assert!(matches!( - completed, - AgentBackgroundFinalizationOutcome::Completed(_) - )); - - fs::remove_dir_all(root).ok(); - } -} - #[test] fn background_finalization_rechecks_stale_credential_before_assistant_persistence() { let root = unique_project_path(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/action_execution.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/action_execution.rs index b4dd0ec94..762b67983 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/action_execution.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/action_execution.rs @@ -1,4 +1,5 @@ use super::support::*; +use crate::tests::init_existing_html_project_at; #[test] fn agent_runtime_agent_message_uses_durable_semantic_idempotency() { @@ -539,7 +540,7 @@ async fn background_agent_runtime_can_read_other_agent_status() { #[tokio::test] async fn background_agent_runtime_can_run_limited_static_smoke() { let root = unique_project_path(); - init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init"); + init_existing_html_project_at(&root, "project-1", "月光厨房").expect("project init"); let playable_game_html = fake_llm_game_draft().game_html; write_project_permission_policy_at( &root, @@ -1549,49 +1550,6 @@ async fn background_agent_runtime_deletes_file_then_verifies_before_completion() fs::remove_dir_all(root).ok(); } -#[tokio::test] -async fn art_asset_plan_cannot_delete_registered_fixed_visual_asset() { - let root = unique_project_path(); - init_local_game_project_at(&root, "project-1", "固定美术资产删除门禁").expect("project init"); - register_canvas_visual_asset_fixture(&root, "assets/art-spritesheet.png", "art-spritesheet"); - write_project_permission_policy_at( - &root, - ProjectPermissionPolicy { - denied_commands: Vec::new(), - confirm_commands: Vec::new(), - agent_policies: BTreeMap::new(), - }, - ) - .expect("allow delete tools"); - - let action = AgentRuntimeToolAction { - tool: "file.delete".to_string(), - reason: Some("尝试删除已登记的固定美术素材".to_string()), - input: serde_json::json!({ "path": "assets/art-spritesheet.png" }), - }; - let observation = execute_game_creator_agent_runtime_tool_action_with_action_id( - &root, - "art-asset-plan", - "art-delete-protection-run", - "已登记的固定美术素材必须复用", - &action, - Some("art-delete-protection-action"), - ) - .await; - - assert_eq!(observation.status, "blocked"); - assert!(observation.summary.contains("禁止删除固定正式产物")); - assert!(root.join("assets/art-spritesheet.png").is_file()); - assert_eq!( - read_game_creator_agent_runtime_project_revision(&root) - .expect("read revision") - .revision, - 0 - ); - - fs::remove_dir_all(root).ok(); -} - #[tokio::test] async fn design_foundation_file_tools_cannot_modify_the_game_entry() { let root = unique_project_path(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/planning_strategy/tool_planning.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/planning_strategy/tool_planning.rs index 1ce915e4e..8571c7133 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/planning_strategy/tool_planning.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/planning_strategy/tool_planning.rs @@ -1260,25 +1260,19 @@ async fn background_agent_runtime_task_executes_plan_tool_observation_loop() { assert!(plan_request.contains("后台分析当前玩法循环")); assert!(!plan_request.contains("核心循环:收集月光食材并躲避暗影")); assert!(!plan_request.contains("黑板:必须先确认核心循环")); - let final_request = receiver - .recv_timeout(Duration::from_secs(2)) - .expect("final reply llm request"); + let final_request = wait_for_captured_mock_request(&receiver, "final reply llm request").await; assert!(final_request.contains("工具观察")); assert!(final_request.contains("核心循环:收集月光食材并躲避暗影")); assert!(final_request.contains("黑板:必须先确认核心循环")); - let mut runtime = read_game_creator_agent_runtime_at(&root, "design-director") - .expect("read runtime") - .state; - for _ in 0..250 { - if runtime.status == "idle" { - break; - } - std::thread::sleep(Duration::from_millis(20)); - runtime = read_game_creator_agent_runtime_at(&root, "design-director") - .expect("read runtime") - .state; - } + let runtime = wait_for_agent_runtime_terminal_and_lane_release( + &root, + "design-director", + "design-loop-run", + "idle", + "completed", + ) + .state; assert_eq!(runtime.status, "idle"); assert_eq!(runtime.phase, "completed"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/policy.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/policy.rs index 895731a90..015087ef8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/policy.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/policy.rs @@ -1,5 +1,4 @@ use super::support::*; -use crate::PLAN_SUBMIT_GDD_TOOL; #[test] fn agent_runtime_default_allowed_tools_match_executable_whitelist() { @@ -18,61 +17,6 @@ fn agent_runtime_default_allowed_tools_match_executable_whitelist() { assert!(!expected.contains(&"conversation.write".to_string())); } -#[test] -fn planning_agent_original_tool_identity_is_not_widened_by_command_aliases() { - assert!(agent_runtime_tool_allowed_for_agent( - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - "file.read" - )); - assert!(agent_runtime_tool_allowed_for_agent( - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - "file.list" - )); - assert!(!agent_runtime_tool_allowed_for_agent( - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - "project.search" - )); - assert!(agent_runtime_tool_allowed_for_agent( - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - "project.search" - )); -} - -#[tokio::test] -async fn planning_runtime_rejects_search_alias_and_user_input_before_execution() { - let root = unique_project_path(); - init_local_game_project_at(&root, "planning-boundary", "策划 Agent 运行时边界") - .expect("project init"); - - for (tool, input) in [ - ( - "project.search", - serde_json::json!({ "query": "should-not-run" }), - ), - ( - GAME_CREATOR_USER_INPUT_REQUEST_TOOL, - serde_json::json!({ "questions": [] }), - ), - ] { - let observation = execute_game_creator_agent_runtime_tool_action( - &root, - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - "planning-boundary-run", - "验证策划 Agent 工具边界", - &AgentRuntimeToolAction { - tool: tool.to_string(), - reason: Some("边界测试".to_string()), - input, - }, - ) - .await; - assert_eq!(observation.status, "rejected", "{tool}: {observation:?}"); - assert!(observation.summary.contains("不允许"), "{observation:?}"); - } - - fs::remove_dir_all(root).ok(); -} - #[test] fn agent_runtime_failure_redacts_legacy_plan_detail_and_all_error_projections() { let root = unique_project_path(); @@ -266,543 +210,6 @@ fn agent_runtime_tool_policy_snapshot_reflects_project_policy() { fs::remove_dir_all(root).ok(); } -#[test] -fn planning_tool_policy_snapshot_keeps_exact_permission_decisions() { - let root = unique_project_path(); - init_local_game_project_at(&root, "planning-policy-snapshot", "策划工具策略快照") - .expect("project init"); - write_project_permission_policy_at( - &root, - ProjectPermissionPolicy { - denied_commands: vec!["file.read".to_string()], - confirm_commands: vec!["file.list".to_string(), PLAN_SUBMIT_GDD_TOOL.to_string()], - agent_policies: BTreeMap::new(), - }, - ) - .expect("write planning policy"); - - let parent_run_id = "planning-policy-parent-run"; - bind_game_creator_agent_runtime_run_profile_at( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - parent_run_id, - "project-supervisor-plan", - Some(AGENT_RUNTIME_RUN_PROFILE_STANDARD), - None, - ) - .expect("bind plan root"); - let child_run_id = "planning-policy-child-run"; - bind_game_creator_agent_runtime_run_profile_at( - &root, - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - child_run_id, - "agent-delegate", - Some(AGENT_RUNTIME_RUN_PROFILE_STANDARD), - Some(&AgentRuntimeTaskLink { - parent_agent_id: Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string()), - parent_run_id: Some(parent_run_id.to_string()), - delegation_id: Some("planning-policy-delegation".to_string()), - }), - ) - .expect("bind planning child"); - - let snapshot = agent_runtime_tool_policy_snapshot_for_run_at( - &root, - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - child_run_id, - None, - None, - ) - .expect("read planning policy snapshot"); - assert_eq!( - snapshot.allowed_tools, - vec!["file.list", "file.read", PLAN_SUBMIT_GDD_TOOL] - ); - assert!(snapshot.denied_tools.iter().any(|tool| tool == "file.read")); - assert!(!snapshot.auto_tools.iter().any(|tool| tool == "file.read")); - assert!(!snapshot - .confirm_tools - .iter() - .any(|tool| tool == "file.read")); - assert!(snapshot - .confirm_tools - .iter() - .any(|tool| tool == "file.list")); - assert!(!snapshot - .auto_tools - .iter() - .any(|tool| tool == PLAN_SUBMIT_GDD_TOOL)); - assert!(snapshot - .denied_tools - .iter() - .any(|tool| tool == PLAN_SUBMIT_GDD_TOOL)); - assert!(!snapshot - .confirm_tools - .iter() - .any(|tool| tool == PLAN_SUBMIT_GDD_TOOL)); - assert!(snapshot - .denied_tools - .iter() - .any(|tool| tool == "project.search")); - - assert!(matches!( - game_creator_agent_runtime_tool_policy_rule_for_run( - &root, - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - child_run_id, - None, - None, - "file.read", - ), - Some(AgentRuntimeToolPolicyBlock::Denied(reason)) - if reason.contains("项目权限策略拒绝执行") - )); - assert!(matches!( - game_creator_agent_runtime_tool_policy_rule_for_run( - &root, - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - child_run_id, - None, - None, - "file.list", - ), - Some(AgentRuntimeToolPolicyBlock::RequiresConfirmation(reason)) - if reason.contains("项目权限策略要求用户确认") - )); - assert!(matches!( - game_creator_agent_runtime_tool_policy_rule_for_run( - &root, - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - child_run_id, - None, - None, - PLAN_SUBMIT_GDD_TOOL, - ), - Some(AgentRuntimeToolPolicyBlock::Denied(reason)) - if reason.contains("不支持转成通用确认 pending") - )); - assert!(matches!( - game_creator_agent_runtime_tool_policy_rule_for_run( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - parent_run_id, - None, - None, - PLAN_SUBMIT_GDD_TOOL, - ), - Some(AgentRuntimeToolPolicyBlock::Denied(reason)) - if reason.contains("仅允许 project-planning") - )); - assert!(matches!( - game_creator_agent_runtime_tool_policy_rule_for_run( - &root, - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - child_run_id, - Some("autonomous-game-build"), - Some("forged-binding-fingerprint"), - "file.list", - ), - Some(AgentRuntimeToolPolicyBlock::Denied(reason)) - if reason.contains("Run Profile") - )); - - fs::remove_dir_all(root).ok(); -} - -/// M1A-4 A1/A2 正向路径:plan 根 run 只能委派 project-planning,委派其它任意 -/// 专业 Agent(含 code-prototype、art-director)必须被拒绝且带新 typed kind; -/// 委派 project-planning 本身必须继续通过,不能误伤 M1A-2 已落地的正向路径; -/// agent.spawn_isolated 对 plan 根 run 一律拒绝。 -#[test] -fn plan_root_delegate_rejects_non_planning_targets_but_keeps_planning_path() { - let root = unique_project_path(); - init_local_game_project_at(&root, "plan-root-delegate-symmetry", "策划根委派对称收口") - .expect("project init"); - let parent_run_id = "plan-root-delegate-symmetry-run"; - start_game_creator_agent_runtime_task_at( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - "策划立项", - parent_run_id, - AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE, - "准备委派策划", - vec!["委派 project-planning".to_string()], - ) - .expect("start plan root task"); - bind_game_creator_agent_runtime_run_profile_at( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - parent_run_id, - AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE, - Some(AGENT_RUNTIME_RUN_PROFILE_STANDARD), - None, - ) - .expect("bind plan root"); - - for non_planning_target in ["code-prototype", "art-director", "design-director"] { - let observation = observe_agent_runtime_agent_delegate( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - parent_run_id, - Some(&format!("plan-root-reject-{non_planning_target}")), - &serde_json::json!({ - "agentId": non_planning_target, - "task": "策划根 run 不应能委派专业 Agent", - "acceptanceCriteria": ["不应通过"], - "expectedArtifacts": [], - "repairOfDelegationId": null, - "runId": null - }), - ); - assert_eq!(observation.status, "failed", "{observation:?}"); - assert!( - observation - .summary - .contains(AGENT_RUNTIME_PLAN_ROOT_CHILD_TARGET_UNSUPPORTED_KIND), - "{observation:?}" - ); - } - - let isolated_observation = observe_agent_runtime_agent_spawn_isolated( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - parent_run_id, - Some("plan-root-reject-spawn-isolated"), - &serde_json::json!({ - "children": [{ - "templateAgentId": "code-prototype", - "task": "策划根 run 不应能创建隔离 child", - "acceptanceCriteria": ["不应通过"], - "expectedArtifacts": [], - "writeScopes": ["game/**"] - }], - "joinMode": "all" - }), - ); - assert_eq!( - isolated_observation.status, "failed", - "{isolated_observation:?}" - ); - assert!( - isolated_observation - .summary - .contains(AGENT_RUNTIME_PLAN_ROOT_CHILD_TARGET_UNSUPPORTED_KIND), - "{isolated_observation:?}" - ); - - let planning_lock = try_acquire_game_creator_agent_runtime_task_lock( - &root, - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - ) - .expect("acquire planning lane") - .expect("planning lane available"); - let planning_observation = observe_agent_runtime_agent_delegate( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - parent_run_id, - Some("plan-root-allow-planning"), - &serde_json::json!({ - "agentId": GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - "task": "输出 Fast GDD", - "acceptanceCriteria": ["必须给出可审批的 Fast GDD"], - "expectedArtifacts": [], - "repairOfDelegationId": null, - "runId": null - }), - ); - assert_eq!( - planning_observation.status, "ok", - "{planning_observation:?}" - ); - drop(planning_lock); - - fs::remove_dir_all(root).ok(); -} - -/// M1A-4 最关键的回归:plan 根 run 的 task record 自称 -/// project-supervisor-plan(弱判据为真),但 durable Run Profile 绑定缺失或 -/// 与该 source 不一致(强判据不通过)时,agent.delegate 与 -/// agent.spawn_isolated 两条通道都必须 fail closed——不能因为 -/// `validate_project_supervisor_plan_root_binding_at` 返回 Err 就被误判成 -/// "根本不是 plan 根" 从而放行任意子 Agent 创建。 -#[test] -fn plan_root_delegate_and_spawn_isolated_fail_closed_when_binding_missing_or_corrupted() { - let root = unique_project_path(); - init_local_game_project_at(&root, "plan-root-fail-closed", "策划根绑定损坏拒绝") - .expect("project init"); - - // 变体一:task record 自称 plan,但从未绑定过 durable binding(缺失)。 - let missing_binding_run_id = "plan-root-fail-closed-missing-binding-run"; - start_game_creator_agent_runtime_task_at( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - "策划立项", - missing_binding_run_id, - AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE, - "准备委派策划", - vec!["委派 project-planning".to_string()], - ) - .expect("start plan root task without binding"); - - // 变体二:task record 自称 plan,但该 run_id 实际绑定的 source 是 gui - // (即 binding 与 task 的自称身份不一致,validate_...(..) 必须失败)。 - let mismatched_binding_run_id = "plan-root-fail-closed-mismatched-binding-run"; - bind_game_creator_agent_runtime_run_profile_at( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - mismatched_binding_run_id, - AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, - Some(AGENT_RUNTIME_RUN_PROFILE_STANDARD), - None, - ) - .expect("bind mismatched gui binding"); - start_game_creator_agent_runtime_task_at( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - "策划立项", - mismatched_binding_run_id, - AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE, - "准备委派策划", - vec!["委派 project-planning".to_string()], - ) - .expect("start plan root task with mismatched binding"); - - for run_id in [missing_binding_run_id, mismatched_binding_run_id] { - for target in ["code-prototype", GAME_CREATOR_PROJECT_PLANNING_AGENT_ID] { - let observation = observe_agent_runtime_agent_delegate( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - run_id, - Some(&format!("plan-root-fail-closed-{run_id}-{target}")), - &serde_json::json!({ - "agentId": target, - "task": "binding 损坏时任何子 Agent 创建都不得通过", - "acceptanceCriteria": ["不应通过"], - "expectedArtifacts": [], - "repairOfDelegationId": null, - "runId": null - }), - ); - assert_eq!( - observation.status, "failed", - "run_id={run_id} target={target}: {observation:?}" - ); - } - - let isolated_observation = observe_agent_runtime_agent_spawn_isolated( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - run_id, - Some(&format!("plan-root-fail-closed-spawn-{run_id}")), - &serde_json::json!({ - "children": [{ - "templateAgentId": "code-prototype", - "task": "binding 损坏时不得创建隔离 child", - "acceptanceCriteria": ["不应通过"], - "expectedArtifacts": [], - "writeScopes": ["game/**"] - }], - "joinMode": "all" - }), - ); - assert_eq!( - isolated_observation.status, "failed", - "run_id={run_id}: {isolated_observation:?}" - ); - assert!( - isolated_observation - .summary - .contains(AGENT_RUNTIME_PLAN_ROOT_CHILD_TARGET_UNSUPPORTED_KIND), - "{isolated_observation:?}" - ); - } - - // 补上最关键的单点:非 project-planning 目标必须由新 typed kind 拒绝 - // (而不是被 M1A-2 现役的 target==project-planning 专属分支恰好挡住)。 - let non_planning_observation = observe_agent_runtime_agent_delegate( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - missing_binding_run_id, - Some("plan-root-fail-closed-kind-check"), - &serde_json::json!({ - "agentId": "code-prototype", - "task": "binding 缺失时委派专业 Agent 必须带新 kind", - "acceptanceCriteria": ["不应通过"], - "expectedArtifacts": [], - "repairOfDelegationId": null, - "runId": null - }), - ); - assert!( - non_planning_observation - .summary - .contains(AGENT_RUNTIME_PLAN_ROOT_CHILD_TARGET_UNSUPPORTED_KIND), - "{non_planning_observation:?}" - ); - - fs::remove_dir_all(root).ok(); -} - -/// 对照组:gui 根 run 的委派与 agent.spawn_isolated 行为必须逐字不变。 -#[test] -fn gui_root_delegate_and_spawn_isolated_are_unaffected_by_plan_root_symmetry() { - let root = unique_project_path(); - init_local_game_project_at(&root, "gui-root-delegate-control", "对照组:gui 根不受影响") - .expect("project init"); - let parent_run_id = "gui-root-delegate-control-run"; - start_game_creator_agent_runtime_task_at( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - "普通游戏立项", - parent_run_id, - AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, - "准备委派专业组", - vec!["委派专业组".to_string()], - ) - .expect("start gui root task"); - bind_game_creator_agent_runtime_run_profile_at( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - parent_run_id, - AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, - Some(AGENT_RUNTIME_RUN_PROFILE_STANDARD), - None, - ) - .expect("bind gui root"); - - let design_lock = try_acquire_game_creator_agent_runtime_task_lock(&root, "design-director") - .expect("acquire design lane") - .expect("design lane available"); - let design_observation = observe_agent_runtime_agent_delegate( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - parent_run_id, - Some("gui-root-control-delegate"), - &serde_json::json!({ - "agentId": "design-director", - "task": "输出核心循环", - "acceptanceCriteria": ["必须给出一句可执行核心循环"], - "expectedArtifacts": [], - "repairOfDelegationId": null, - "runId": null - }), - ); - assert_eq!(design_observation.status, "ok", "{design_observation:?}"); - drop(design_lock); - - let isolated_observation = observe_agent_runtime_agent_spawn_isolated( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - parent_run_id, - Some("gui-root-control-spawn-isolated"), - &serde_json::json!({ - "children": [{ - "templateAgentId": "code-prototype", - "task": "完成 feature-a 子任务", - "acceptanceCriteria": ["game/feature-a/output.txt 存在"], - "expectedArtifacts": ["game/feature-a/output.txt"], - "writeScopes": ["game/feature-a/**"] - }], - "joinMode": "all" - }), - ); - assert_eq!( - isolated_observation.status, "ok", - "{isolated_observation:?}" - ); - - fs::remove_dir_all(root).ok(); -} - -/// 立项策划根 Run 的委派免确认,并且这条豁免既不外溢到做游戏那条根,也不外溢到别的工具。 -/// -/// plan 根的工具面按阶段收窄到「当前唯一能推进链路的动作」,Delegate 阶段就只有 -/// `agent.delegate`;再要用户点一次确认没有决策含量,人的关口留在 Fast GDD 审批卡。 -/// 判据取自持久 binding 的强判据,所以这里连同 gui 根一起断言:同一份策略下,做游戏的 -/// 委派必须照旧停在待确认。 -#[test] -fn plan_root_delegate_is_auto_while_other_roots_and_tools_still_confirm() { - let root = unique_project_path(); - init_local_game_project_at(&root, "plan-root-delegate-auto", "立项策划根委派免确认") - .expect("project init"); - write_project_permission_policy_at( - &root, - ProjectPermissionPolicy { - denied_commands: Vec::new(), - confirm_commands: vec![ - "agent.delegate".to_string(), - "agent.spawn_isolated".to_string(), - ], - agent_policies: BTreeMap::new(), - }, - ) - .expect("write policy"); - - let plan_run_id = "plan-root-delegate-run"; - bind_game_creator_agent_runtime_run_profile_at( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - plan_run_id, - AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE, - Some(AGENT_RUNTIME_RUN_PROFILE_STANDARD), - None, - ) - .expect("bind plan root"); - assert!( - game_creator_agent_runtime_tool_policy_rule_for_run( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - plan_run_id, - None, - None, - "agent.delegate", - ) - .is_none(), - "立项策划根的 agent.delegate 不应再要人工确认" - ); - assert!( - matches!( - game_creator_agent_runtime_tool_policy_rule_for_run( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - plan_run_id, - None, - None, - "agent.spawn_isolated", - ), - Some(AgentRuntimeToolPolicyBlock::RequiresConfirmation(_)) - ), - "免确认只给 agent.delegate,plan 根上其它 confirm 工具必须照旧" - ); - - let gui_run_id = "gui-root-delegate-run"; - bind_game_creator_agent_runtime_run_profile_at( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - gui_run_id, - AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, - Some(AGENT_RUNTIME_RUN_PROFILE_STANDARD), - None, - ) - .expect("bind gui root"); - assert!( - matches!( - game_creator_agent_runtime_tool_policy_rule_for_run( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - gui_run_id, - None, - None, - "agent.delegate", - ), - Some(AgentRuntimeToolPolicyBlock::RequiresConfirmation(_)) - ), - "做游戏那条根的委派确认不能被 plan 根的豁免带走" - ); - - fs::remove_dir_all(root).ok(); -} - #[tokio::test] async fn background_agent_runtime_asset_generation_respects_project_policy() { let root = unique_project_path(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/support.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/support.rs index b99ff0052..aa366c760 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/support.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/support.rs @@ -109,17 +109,14 @@ pub(super) use crate::{ AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT, AGENT_RUNTIME_LOCK_STALE_AFTER_SECONDS, AGENT_RUNTIME_PENDING_ACTION_SCHEMA_VERSION, AGENT_RUNTIME_PENDING_ACTION_STATUS_APPROVED, AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING, - AGENT_RUNTIME_PENDING_ACTION_STATUS_OBSERVED_APPROVED, - AGENT_RUNTIME_PLAN_ROOT_CHILD_TARGET_UNSUPPORTED_KIND, AGENT_RUNTIME_PLAN_STATUS_COMPLETED, + AGENT_RUNTIME_PENDING_ACTION_STATUS_OBSERVED_APPROVED, AGENT_RUNTIME_PLAN_STATUS_COMPLETED, AGENT_RUNTIME_RESPOND_FUNCTION_NAME, AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD, AGENT_RUNTIME_RUN_PROFILE_STANDARD, AGENT_RUNTIME_SCHEMA_VERSION, AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, - AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE, AGENT_RUNTIME_TOOL_OBSERVATION_STATUS_NEEDS_RECONCILIATION, AGENT_RUNTIME_UI_PROTOTYPE_INSPECTION_KIND, AGENT_RUNTIME_UI_PROTOTYPE_LEGACY_VALIDATION_PROFILE, AGENT_RUNTIME_UI_PROTOTYPE_PATH, AGENT_RUNTIME_UI_PROTOTYPE_VALIDATION_PROFILE, AGENT_RUNTIME_UPDATE_PLAN_FUNCTION_NAME, - GAME_CREATOR_CONFIG_FILE_NAME, GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, GAME_CREATOR_USER_INPUT_REQUEST_TOOL, - PROJECT_BLACKBOARD_MEMORY_PATH, + GAME_CREATOR_CONFIG_FILE_NAME, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + GAME_CREATOR_USER_INPUT_REQUEST_TOOL, PROJECT_BLACKBOARD_MEMORY_PATH, }; diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_state.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_state.rs index b77477437..88db4e255 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_state.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_state.rs @@ -238,30 +238,19 @@ fn runtime_task_reader_accepts_rare_persisted_phases() { // 写侧不校验 phase,只有读侧校验:白名单漏登记一个 phase,落盘之后整份 journal // 从那一行起再也读不出来,`agent.run_status` 对该 Agent 永久失败。这里用写方 // 引用的同一个常量,漏登记会在这条用例上先红。 - let planning_session_projection_failed = runtime_task_json_line( - "planning-session-projection-failed-run", - "failed", - AGENT_RUNTIME_TASK_PHASE_PLANNING_SESSION_PROJECTION_FAILED, - ); fs::write( &path, - format!( - "{brief}\n{parent_link_missing}\n{public_status_write_failed}\n{planning_session_projection_failed}\n" - ), + format!("{brief}\n{parent_link_missing}\n{public_status_write_failed}\n"), ) .expect("write rare persisted task phases"); let records = read_all_game_creator_agent_runtime_tasks(&path) .expect("known persisted task phases must remain readable"); - assert_eq!(records.len(), 4); + assert_eq!(records.len(), 3); assert_eq!(records[0].phase, "brief"); assert_eq!(records[1].phase, "parent-link-missing"); assert_eq!(records[2].phase, "public-status-write-failed"); - assert_eq!( - records[3].phase, - AGENT_RUNTIME_TASK_PHASE_PLANNING_SESSION_PROJECTION_FAILED - ); fs::remove_dir_all(root).ok(); } @@ -2001,7 +1990,7 @@ fn agent_runtime_context_bundle_accepts_only_the_clarification_relay_task_diverg GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, task, "plan-clarification-relay-run", - AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE, + AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, "等待澄清回答", vec!["回答澄清后续跑".to_string()], ) @@ -3989,137 +3978,6 @@ async fn background_task_recovers_when_assistant_message_cannot_persist() { fs::remove_dir_all(root).ok(); } -#[tokio::test] -async fn task_update_requires_registered_visual_asset_before_completion() { - let _platform_session = crate::platform_session::install_test_platform_session( - "visual-task-update-test-user", - "visual-task-update-test-key", - "https://dev.genarrative.world", - ); - let _config_guard = crate::tests::write_test_local_config( - r#"{"editorApi":{"apiKey":"visual-task-update-test-key"}}"#.to_string(), - ); - for (task_id, local_path, kind, missing_summary) in [ - ( - "art-director", - "assets/art-spec.png", - "icon-spec", - "统一视觉规范图尚未按正式视觉流程生成并登记", - ), - ( - "design-foundation", - "assets/ui-prototype.png", - "ui-prototype", - "策划界面原型图尚未按正式视觉流程生成并登记", - ), - ( - "art-asset-plan", - "assets/art-spritesheet.png", - "art-spritesheet", - "首版美术素材图尚未按正式视觉流程生成并登记", - ), - ] { - let root = unique_project_path(); - init_local_game_project_at(&root, "project-1", "视觉任务完成门禁测试") - .expect("project init"); - write_project_permission_policy_at( - &root, - ProjectPermissionPolicy { - denied_commands: Vec::new(), - confirm_commands: Vec::new(), - agent_policies: BTreeMap::new(), - }, - ) - .expect("allow task update"); - let run_id = format!("visual-task-update-{task_id}"); - let action = AgentRuntimeToolAction { - tool: "task.update".to_string(), - reason: Some("标记视觉任务完成".to_string()), - input: serde_json::json!({ - "taskId": task_id, - "status": "completed" - }), - }; - let missing = execute_game_creator_agent_runtime_tool_action_with_action_id( - &root, - task_id, - &run_id, - "完成视觉任务", - &action, - Some("visual-task-update-missing"), - ) - .await; - assert_eq!(missing.status, "failed"); - assert!(missing.summary.contains(missing_summary)); - let manifest = read_manifest_for_project(&root).expect("manifest after rejected update"); - assert_eq!( - manifest - .tasks - .iter() - .find(|task| task.id == task_id) - .expect("visual task") - .status, - GameCreationAppTaskStatus::Pending - ); - - if task_id != "art-director" { - register_canvas_visual_asset_fixture(&root, "assets/art-spec.png", "icon-spec"); - } - if task_id == "art-asset-plan" { - register_canvas_visual_asset_fixture(&root, "assets/ui-prototype.png", "ui-prototype"); - } - register_canvas_visual_asset_fixture(&root, local_path, kind); - if task_id == "design-foundation" { - append_ui_prototype_inspection_fixture(&root, &run_id, false); - let scene_rejected = execute_game_creator_agent_runtime_tool_action_with_action_id( - &root, - task_id, - &run_id, - "拒绝用场景图完成 UI 原型任务", - &action, - Some("visual_task_update_scene_rejected"), - ) - .await; - assert_eq!(scene_rejected.status, "failed"); - assert!(scene_rejected.summary.contains("结构化 UI 视觉检查")); - let manifest = - read_manifest_for_project(&root).expect("manifest after rejected scene image"); - assert_eq!( - manifest - .tasks - .iter() - .find(|task| task.id == task_id) - .expect("design visual task") - .status, - GameCreationAppTaskStatus::Pending - ); - append_ui_prototype_inspection_fixture(&root, &run_id, true); - } - let completed = execute_game_creator_agent_runtime_tool_action_with_action_id( - &root, - task_id, - &run_id, - "完成视觉任务", - &action, - Some("visual-task-update-completed"), - ) - .await; - assert_eq!(completed.status, "ok", "{completed:?}"); - let manifest = read_manifest_for_project(&root).expect("manifest after visual completion"); - assert_eq!( - manifest - .tasks - .iter() - .find(|task| task.id == task_id) - .expect("visual task") - .status, - GameCreationAppTaskStatus::Completed - ); - - fs::remove_dir_all(root).ok(); - } -} - #[test] fn schedule_ready_manifest_tasks_command_requires_auto_policy() { let root = unique_project_path(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/sessions.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/sessions.rs index b9092b0fb..a59915adc 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/sessions.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/sessions.rs @@ -675,6 +675,7 @@ async fn background_agent_runtime_file_and_memory_writes_respect_project_policy( #[test] fn generate_local_game_draft_writes_memory_design_and_game() { let root = unique_project_path(); + init_existing_html_project_at(&root, "project-1", "HTML fixture").expect("project init"); let draft = fake_llm_game_draft(); let result = write_local_game_draft_at(&root, "像素风横版动作 \n\n[外链](https://example.com) ![示意图](x.png)' + } + />, + ); + + expect(container.querySelector('script')).toBeNull(); + expect(container.querySelector('a')).toBeNull(); + expect(container.querySelector('img')).toBeNull(); + expect(container.textContent).toContain('外链'); + expect(container.textContent).toContain('图片:示意图'); + }); + + it('支持表格并允许窄视口横向滚动', () => { + const { container } = render( + , + ); + + expect(container.querySelectorAll('table th')).toHaveLength(2); + expect(container.querySelector('div.overflow-x-auto')).toBeTruthy(); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/ZoomPercentageInput.test.tsx b/apps/ai-game-creator-shell/tests/ZoomPercentageInput.test.tsx new file mode 100644 index 000000000..f02a71e3d --- /dev/null +++ b/apps/ai-game-creator-shell/tests/ZoomPercentageInput.test.tsx @@ -0,0 +1,67 @@ +// @vitest-environment jsdom + +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { ZoomPercentageInput } from '../src/view/ui-editor/components/preview/ZoomPercentageInput'; + +describe('ZoomPercentageInput', () => { + it('edits the displayed percentage and commits on blur without fitting', () => { + const onCommit = vi.fn(); + render(); + + const input = screen.getByRole('spinbutton', { name: /画布缩放/ }); + expect((input as HTMLInputElement).value).toBe('50'); + + fireEvent.focus(input); + fireEvent.change(input, { target: { value: '125' } }); + expect(onCommit).not.toHaveBeenCalled(); + fireEvent.blur(input); + + expect(onCommit).toHaveBeenCalledWith(125); + expect((input as HTMLInputElement).value).toBe('125'); + }); + + it.each([ + { value: '0', expected: 25 }, + { value: '999', expected: 200 }, + ])('clamps $value to $expected on blur', ({ value, expected }) => { + const onCommit = vi.fn(); + render(); + + const input = screen.getByRole('spinbutton', { name: /画布缩放/ }); + fireEvent.focus(input); + fireEvent.change(input, { target: { value } }); + fireEvent.blur(input); + + expect(onCommit).toHaveBeenCalledWith(expected); + expect((input as HTMLInputElement).value).toBe(String(expected)); + }); + + it('restores the current percentage when the draft is invalid', () => { + const onCommit = vi.fn(); + render(); + + const input = screen.getByRole('spinbutton', { name: /画布缩放/ }); + fireEvent.focus(input); + fireEvent.change(input, { target: { value: '' } }); + fireEvent.blur(input); + + expect(onCommit).not.toHaveBeenCalled(); + expect((input as HTMLInputElement).value).toBe('80'); + }); + + it('tracks viewport updates while not editing', () => { + const onCommit = vi.fn(); + const view = render( + , + ); + const input = screen.getByRole('spinbutton', { name: /画布缩放/ }); + + view.rerender( + , + ); + + expect((input as HTMLInputElement).value).toBe('140'); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/agentSwarmTestEntry.test.ts b/apps/ai-game-creator-shell/tests/agentSwarmTestEntry.test.ts index 71213e1c5..f0152b2ec 100644 --- a/apps/ai-game-creator-shell/tests/agentSwarmTestEntry.test.ts +++ b/apps/ai-game-creator-shell/tests/agentSwarmTestEntry.test.ts @@ -26,7 +26,6 @@ import { cleanupSwarmTestProject, cleanupSwarmTestRuntimeConfig, configFileName, - defaultRealSwarmPlanTask, defaultRealSwarmTestTask, defaultRuntimeConfigDirCandidates, discoverRuntimeConfigDir, @@ -36,12 +35,9 @@ import { inspectSwarmProjectArtifacts, localConfigFileName, nextSwarmAutoPilotReply, - parsePlanGddDecisionOutput, - parsePlanGddStatusOutput, parseRunnerShutdownOutput, parseSettledSwarmTurnReport, parseSwarmTestArguments, - planGddAutoApprovalIsPending, prepareSwarmTestProject, prepareSwarmTestRuntimeConfig, removeDirectoryWithTimeout, @@ -51,7 +47,6 @@ import { shouldStartPersistentPreview, swarmAutoPilotShouldCloseInput, swarmAutoPilotSitsAtPrompt, - swarmOutputAwaitsPlanGddApproval, terminateChildTree, testProjectPrefix, testProjectSentinelName, @@ -658,7 +653,6 @@ describe('Swarm test argument parsing', () => { keepProject: false, openBrowser: true, task: null, - plan: false, timeoutMinutes: null, dryRun: false, help: false, @@ -679,7 +673,6 @@ describe('Swarm test argument parsing', () => { '--no-open', '--task', '生成一款可试玩的塔防游戏', - '--plan', '--timeout-minutes', '75', '--dry-run', @@ -691,7 +684,6 @@ describe('Swarm test argument parsing', () => { keepProject: true, openBrowser: false, task: '生成一款可试玩的塔防游戏', - plan: true, timeoutMinutes: 75, dryRun: true, help: true, @@ -700,7 +692,7 @@ describe('Swarm test argument parsing', () => { expect(() => parseSwarmTestArguments(['--task', ''])).toThrowError(); }); - it('auto-answers only the prompts the plan lane actually blocks on', () => { + it('auto-answers only the confirmation and choice prompts', () => { expect( nextSwarmAutoPilotReply( '[待确认] agent=project-supervisor run=r action=a tool=agent.delegate\n输入 approve 或 reject:', @@ -740,75 +732,6 @@ describe('Swarm test argument parsing', () => { ).toBe(false); }); - it('triggers the approval while the chat CLI is still running', () => { - // Run 停在审批位时状态是 waiting-for-user-input,而 swarm CLI 把这个状态算作 - // 「本轮还在跑」,turn 永远不 settle。等 CLI 退出再审批那一步根本到不了,所以 - // 触发必须认这条运行期状态行。 - expect( - swarmOutputAwaitsPlanGddApproval( - '[状态] project-supervisor running/waiting-for-user-input run=r queue=0/1/0/0 | 等待 Fast GDD 审批决定', - ), - ).toBe(true); - // 另外三个 plan_gdd blocked 子状态各有自己的文案,都不该触发审批。 - expect( - swarmOutputAwaitsPlanGddApproval( - '[状态] project-supervisor running/planning | 推进本根 Run 的 Fast GDD 提交', - ), - ).toBe(false); - expect( - swarmOutputAwaitsPlanGddApproval( - '[状态] project-supervisor running/planning | 等待 Fast GDD 审批投影收尾', - ), - ).toBe(false); - expect( - swarmOutputAwaitsPlanGddApproval( - '[状态] project-supervisor needs-reconciliation | Fast GDD 审批投影需要人工核对', - ), - ).toBe(false); - }); - - it('reads the headless Fast GDD approval projection off the CLI output', () => { - const state = parsePlanGddStatusOutput( - [ - 'plan.gdd.status', - 'planGddStateJson={"state":"ready_for_approval","pendingApproval":{"gddRef":{"gddId":"gdd-1","version":1,"fingerprint":"fp"},"pendingActionId":"action-1","approvalRequestId":"approval-1"},"session":{"clarificationRound":2,"phase":"awaiting_gdd_approval"}}', - '', - ].join('\n'), - ); - expect(state.state).toBe('ready_for_approval'); - expect(state.session.clarificationRound).toBe(2); - expect(planGddAutoApprovalIsPending(state)).toBe(true); - // CLI 在 Windows 上按行输出会带 CR,解析必须先剥掉再判前缀。 - expect( - parsePlanGddStatusOutput('planGddStateJson={"state":"approved"}\r\n') - .state, - ).toBe('approved'); - expect( - parsePlanGddDecisionOutput( - 'plan.gdd.decided\nplanGddDecisionJson={"outcome":"decided","decisionRef":{"version":1}}', - ).outcome, - ).toBe('decided'); - }); - - it('auto-approves only when the projection really carries a pending card', () => { - // 审批状态是「有没有待决定」的唯一权威。没有待决定时自动批准会把一次空跑 - // 报成通过,所以这里必须 false 而不是继续往下走。 - expect( - planGddAutoApprovalIsPending({ - state: 'approved', - pendingApproval: null, - }), - ).toBe(false); - expect(planGddAutoApprovalIsPending({ state: 'draft' })).toBe(false); - expect(planGddAutoApprovalIsPending(null)).toBe(false); - // 缺前缀或 JSON 坏掉都要报错,不能静默当成「没有待决定」。 - expect(() => parsePlanGddStatusOutput('plan.gdd.status')).toThrowError(); - expect(() => - parsePlanGddStatusOutput('planGddStateJson={oops'), - ).toThrowError(); - expect(() => parsePlanGddDecisionOutput('plan.gdd.decided')).toThrowError(); - }); - it('keeps persistent preview only for manual chat mode', () => { expect(shouldStartPersistentPreview(parseSwarmTestArguments([]))).toBe( true, @@ -818,32 +741,6 @@ describe('Swarm test argument parsing', () => { parseSwarmTestArguments(['--task', '生成一款可试玩的塔防游戏']), ), ).toBe(false); - // 做方案链路不出可试玩产物,交互模式同样不该起预览。 - expect( - shouldStartPersistentPreview(parseSwarmTestArguments(['--plan'])), - ).toBe(false); - }); - - it('bounds the plan lane to its own five-minute design target', () => { - // 交互模式也要有上限:立项策划没有可试玩产物可等,卡住就该早失败。 - expect(resolveSwarmTestTimeoutMs(parseSwarmTestArguments(['--plan']))).toBe( - 6 * 60_000, - ); - expect( - resolveSwarmTestTimeoutMs( - parseSwarmTestArguments([ - '--plan', - '--task', - '做一个解谜游戏的立项方案', - ]), - ), - ).toBe(6 * 60_000); - // 显式 --timeout-minutes 仍然压过默认值。 - expect( - resolveSwarmTestTimeoutMs( - parseSwarmTestArguments(['--plan', '--timeout-minutes', '20']), - ), - ).toBe(20 * 60_000); }); it('applies a bounded default only to non-interactive tasks', () => { @@ -1964,12 +1861,6 @@ describe('package script registration', () => { expect(rootPackage.scripts?.['agc:test:chat:manual']).toBe( 'npm --prefix apps/ai-game-creator-shell run test:chat:manual --', ); - expect(rootPackage.scripts?.['agc:test:plan']).toBe( - 'npm --prefix apps/ai-game-creator-shell run test:plan --', - ); - expect(rootPackage.scripts?.['agc:test:plan:manual']).toBe( - 'npm --prefix apps/ai-game-creator-shell run test:plan:manual --', - ); expect(appPackage.scripts?.['test:chat']).toBe( `node scripts/agent-swarm-test-chat.mjs --task "${defaultRealSwarmTestTask}" --no-open`, ); @@ -1979,12 +1870,6 @@ describe('package script registration', () => { expect(appPackage.scripts?.['test:chat:manual']).toBe( 'node scripts/agent-swarm-test-chat.mjs', ); - expect(appPackage.scripts?.['test:plan']).toBe( - `node scripts/agent-swarm-test-chat.mjs --plan --task "${defaultRealSwarmPlanTask}"`, - ); - expect(appPackage.scripts?.['test:plan:manual']).toBe( - 'node scripts/agent-swarm-test-chat.mjs --plan', - ); expect(checkConfigSource).toMatch( /packageConfig\.scripts\?\.config\s*!==\s*'node scripts\/game-creator-config-wizard\.mjs'/u, ); diff --git a/apps/ai-game-creator-shell/tests/appSurface/harness.ts b/apps/ai-game-creator-shell/tests/appSurface/harness.ts index 76ae72518..01f3dd0c0 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/harness.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/harness.ts @@ -15,6 +15,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { PlanGddStateViewV1 } from '../../src/app/types'; import * as clientApi from '../../src/services/clientApi'; +import { resetLlmModelCatalogCacheForTest } from '../../src/services/llmModelCatalog'; import { useLauncherHomeDraftStore } from '../../src/view/home/useHomeDraftStore'; const nativeClipboardMock = vi.hoisted(() => ({ @@ -459,6 +460,8 @@ function createProjectSupervisorRuntimeHarness({ initialProjectRevision = 0, runtimeMapLoader, expectedRunProfile = 'autonomous-game-build', + planningV2Result = null, + planningV2StartResult = null, }: { projectPath?: string; sessionId?: string; @@ -470,6 +473,8 @@ function createProjectSupervisorRuntimeHarness({ initialProjectRevision?: number; runtimeMapLoader?: () => Promise>>; expectedRunProfile?: 'standard' | 'autonomous-game-build'; + planningV2Result?: Record | null; + planningV2StartResult?: Record | null; } = {}) { const manifest = createGameCreationAppManifest( 'local-project-draft', @@ -515,10 +520,10 @@ function createProjectSupervisorRuntimeHarness({ let answerRuntime: Record | null = null; let answerFailuresRemaining = 0; // 默认不配置策划状态:hydrate 与接入本 harness 之前一样抛出,既有用例行为不变。 - let currentPlanGddState: PlanGddStateViewV1 | null = null; - let planGddDecisionError: string | null = null; - let planGddHydrateCount = 0; - const planGddDecisionCalls: Array> = []; + let currentPlanningV2Result = planningV2Result; + let currentPlanningV2StartResult = planningV2StartResult; + let planningV2DecisionError: string | null = null; + const planningV2DecisionCalls: Array> = []; let runtimeUpdateHandler: | ((event: { payload: { @@ -836,25 +841,34 @@ function createProjectSupervisorRuntimeHarness({ }); return runtimeResult(); } - if (command === 'hydrate_game_creator_plan_gdd_state') { - planGddHydrateCount += 1; - if (!currentPlanGddState) { - throw new Error('PLAN_STATE_NOT_CONFIGURED'); - } - return currentPlanGddState; + if (command === 'hydrate_planning_session_v2') { + return currentPlanningV2Result; } - if (command === 'decide_game_creator_plan_gdd') { - planGddDecisionCalls.push({ ...(args ?? {}) }); - if (planGddDecisionError) { - const failure = planGddDecisionError; - planGddDecisionError = null; + if ( + command === 'start_planning_session_v2' || + command === 'continue_planning_session_v2' + ) { + const nextResult = + command === 'start_planning_session_v2' + ? (currentPlanningV2StartResult ?? currentPlanningV2Result) + : currentPlanningV2Result; + if (!nextResult) { + throw new Error('PLANNING_V2_RESULT_NOT_CONFIGURED'); + } + currentPlanningV2Result = nextResult; + return nextResult; + } + if (command === 'decide_planning_artifact_v2') { + planningV2DecisionCalls.push({ ...(args ?? {}) }); + if (planningV2DecisionError) { + const failure = planningV2DecisionError; + planningV2DecisionError = null; throw new Error(failure); } - return { - schemaVersion: 'plan-gdd-decision-result.v1', - outcome: 'decided', - recoveryPending: false, - }; + if (!currentPlanningV2Result) { + throw new Error('PLANNING_V2_RESULT_NOT_CONFIGURED'); + } + return currentPlanningV2Result; } throw new Error(`unexpected invoke ${command}`); }, @@ -916,16 +930,16 @@ function createProjectSupervisorRuntimeHarness({ failNextAnswers(count = 1) { answerFailuresRemaining = count; }, - setPlanGddState(state: PlanGddStateViewV1 | null) { - currentPlanGddState = state; + setPlanningV2Result(state: Record | null) { + currentPlanningV2Result = state; }, - failNextPlanGddDecision(message: string) { - planGddDecisionError = message; + setPlanningV2StartResult(state: Record | null) { + currentPlanningV2StartResult = state; }, - planGddDecisionCalls, - planGddHydrateCount() { - return planGddHydrateCount; + failNextPlanningV2Decision(message: string) { + planningV2DecisionError = message; }, + planningV2DecisionCalls, appendSupervisorMessage(message: Record) { currentSupervisorMessages.push(message); }, @@ -998,12 +1012,14 @@ async function openMainProject(projectPath: string) { } beforeEach(() => { + resetLlmModelCatalogCacheForTest(); vi.spyOn(clientApi, 'loadClientLlmModels').mockResolvedValue({ defaultModelId: 'quality', models: [ { id: 'quality', displayName: '高质量' }, { id: 'fast', displayName: '快速' }, ], + revision: 1, }); Object.defineProperty(window, 'PointerEvent', { configurable: true, diff --git a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts index ccdddba87..cd8dde108 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts @@ -1,11 +1,9 @@ -import { PROJECT_SUPERVISOR_PLAN_SOURCE } from '../../src/app/constants'; import type { ProjectSupervisorComponentProps } from '../../src/features/app-shell/model'; import { useHomeProjectCreation } from '../../src/features/app-shell/useHomeProjectCreation'; import { WorkspaceLauncherShell } from '../../src/features/app-shell/WorkspaceLauncher'; import type { LauncherView } from '../../src/view/layout'; import { act, - agentRuntimeUserInputRequest, App, cleanup, createGameCreationAppManifest, @@ -29,6 +27,33 @@ import { within, } from './harness'; +function emptyPlanningV2StartResult(projectId = 'local-project-draft') { + return { + session: { + schemaVersion: 'planning-session.v2', + engine: 'planning-session-v2', + sessionId: 'home-planning-v2-session', + projectId, + mode: 'gdd', + status: 'planning', + turnIndex: 1, + questionCount: 0, + questionLimit: 8, + revisionCount: 0, + currentArtifactVersion: null, + currentQuestion: null, + capabilities: { tools: [], skills: [] }, + processingSeconds: 0.5, + createdAtUtc: '2026-09-03T00:00:00Z', + updatedAtUtc: '2026-09-03T00:00:01Z', + lastError: null, + }, + result: null, + currentArtifact: null, + replayed: false, + }; +} + function ApprovedGddStartHarness() { const [, setLauncherView] = React.useState( 'project-development', @@ -62,6 +87,47 @@ function ApprovedGddStartHarness() { } export function registerClientHomeTests() { + it('adds a model selector to the home composer and shows the default model', async () => { + const invoke = vi.fn(async (command: string, args?: unknown) => { + if (command === 'read_game_creator_app_config') { + return { config: { selectedModelId: 'quality' } }; + } + if (command === 'select_game_creator_model') { + return { + config: { selectedModelId: (args as { modelId: string }).modelId }, + }; + } + throw new Error(`unexpected invoke ${command}`); + }); + window.__TAURI__ = { core: { invoke } }; + renderLauncherAt('/?launcher'); + + // 挂载即加载目录,触发按钮直接落在默认模型上,不出现「选择模型」空态。 + await waitFor(() => + expect(invoke).toHaveBeenCalledWith('read_game_creator_app_config'), + ); + const modelTrigger = await screen.findByRole('button', { + name: '对话模型', + }); + await waitFor(() => expect(modelTrigger.textContent).toContain('高质量')); + const createButton = screen.getByRole('button', { name: '开启创作' }); + expect(createButton).toHaveProperty('disabled', false); + + fireEvent.click(modelTrigger); + await waitFor(() => + expect(screen.getByRole('option', { name: '快速' })).not.toBeNull(), + ); + fireEvent.click(screen.getByRole('option', { name: '快速' })); + await waitFor(() => + expect(invoke).toHaveBeenCalledWith('select_game_creator_model', { + modelId: 'fast', + isDefault: false, + }), + ); + expect(modelTrigger.textContent).toContain('快速'); + expect(createButton).toHaveProperty('disabled', false); + }); + it('anchors the empty home input placeholder to the editor while the page scrolls', () => { renderLauncherAt('/?launcher'); @@ -1220,7 +1286,17 @@ export function registerHomeProjectCreationTests() { }); it('keeps only open and create project actions without exposing a Linux fallback', () => { - const invoke = vi.fn(); + const invoke = vi.fn(async (command: string, args?: unknown) => { + if (command === 'read_game_creator_app_config') { + return { config: { selectedModelId: 'quality' } }; + } + if (command === 'select_game_creator_model') { + return { + config: { selectedModelId: (args as { modelId: string }).modelId }, + }; + } + throw new Error(`unexpected invoke ${command}`); + }); window.__TAURI__ = { core: { invoke } }; renderLauncherProjectsAt('/?launcher'); @@ -1242,7 +1318,12 @@ export function registerHomeProjectCreationTests() { screen.queryByRole('button', { name: '在文件管理器中显示' }), ).toBeNull(); expect(screen.queryByRole('button', { name: /Godot 项目/ })).toBeNull(); - expect(invoke).not.toHaveBeenCalled(); + // 首页模型选择器会在挂载时读取模型目录(read_game_creator_app_config), + // 这里只校验没有打开工作区窗口或其它项目操作被触发。 + expect(invoke).not.toHaveBeenCalledWith( + 'open_game_creator_workspace_window', + expect.anything(), + ); }); it('opens the directory selected by the native picker', async () => { @@ -1700,11 +1781,11 @@ export function registerHomeProjectCreationTests() { }); it.each([ - ['做方案', false, 'standard', PROJECT_SUPERVISOR_PLAN_SOURCE], - ['做方案', true, 'standard', PROJECT_SUPERVISOR_PLAN_SOURCE], + ['做方案', false], + ['做方案', true], ] as const)( - 'routes %s %s creation to the expected root run', - async (modeLabel, automatic, runProfile, source) => { + 'routes %s %s creation to Planning Session V2', + async (modeLabel, automatic) => { const projectPath = `/tmp/home-${modeLabel}-${automatic ? 'enter' : 'submit'}`; const manifest = createGameCreationAppManifest( 'local-project-draft', @@ -1712,7 +1793,7 @@ export function registerHomeProjectCreationTests() { ); const supervisorHarness = createProjectSupervisorRuntimeHarness({ projectPath, - expectedRunProfile: runProfile, + planningV2StartResult: emptyPlanningV2StartResult(), }); const invoke = vi.fn( async (command: string, args?: Record) => { @@ -1752,29 +1833,23 @@ export function registerHomeProjectCreationTests() { } else { fireEvent.click( screen.getByRole('button', { - name: source ? '进入立项策划' : '开启创作', + name: '进入立项策划', }), ); } await waitFor(() => { expect(invoke).toHaveBeenCalledWith( - 'start_game_creator_supervisor_runtime_task', + 'start_planning_session_v2', expect.objectContaining({ projectPath, - runProfile, - ...(source ? { source } : {}), + mode: 'gdd', }), ); }); const startCall = invoke.mock.calls.find( - ([command]) => command === 'start_game_creator_supervisor_runtime_task', + ([command]) => command === 'start_planning_session_v2', ); - if (source) { - expect(startCall?.[1]).toMatchObject({ source }); - } else { - expect(startCall?.[1]).not.toHaveProperty('source'); - } expect(startCall?.[1]).not.toHaveProperty('attachments'); expect(JSON.stringify(startCall?.[1] ?? {})).not.toContain( '本轮用户附件', @@ -1787,6 +1862,10 @@ export function registerHomeProjectCreationTests() { 'chat_with_game_creator_agent', expect.anything(), ); + expect(invoke).not.toHaveBeenCalledWith( + 'suggest_automatic_project_name', + expect.anything(), + ); expect( invoke.mock.calls.filter( ([command]) => command === 'create_automatic_local_game_project', @@ -1803,7 +1882,9 @@ export function registerHomeProjectCreationTests() { ); const supervisorHarness = createProjectSupervisorRuntimeHarness({ projectPath, - expectedRunProfile: 'standard', + planningV2StartResult: emptyPlanningV2StartResult( + 'home-planning-attachment', + ), }); const fileBytes = Array.from(new TextEncoder().encode('png')); const invoke = vi.fn( @@ -1856,10 +1937,10 @@ export function registerHomeProjectCreationTests() { await waitFor(() => { expect(invoke).toHaveBeenCalledWith( - 'start_game_creator_supervisor_runtime_task', + 'start_planning_session_v2', expect.objectContaining({ projectPath, - source: PROJECT_SUPERVISOR_PLAN_SOURCE, + mode: 'gdd', }), ); }); @@ -1870,7 +1951,7 @@ export function registerHomeProjectCreationTests() { bytes: fileBytes, }); const startCall = invoke.mock.calls.find( - ([command]) => command === 'start_game_creator_supervisor_runtime_task', + ([command]) => command === 'start_planning_session_v2', ); expect(startCall?.[1]).not.toHaveProperty('attachments'); expect(JSON.stringify(startCall?.[1] ?? {})).not.toContain('本轮用户附件'); @@ -1901,7 +1982,52 @@ export function registerHomeProjectCreationTests() { projectPath, expectedRunProfile: 'standard', }); - let planRootRunId = ''; + supervisorHarness.setPlanningV2StartResult({ + session: { + schemaVersion: 'planning-session.v2', + engine: 'planning-session-v2', + sessionId: 'home-planning-v2-session', + projectId: 'local-project-draft', + mode: 'gdd', + status: 'awaiting_user', + turnIndex: 1, + questionCount: 1, + questionLimit: 8, + revisionCount: 0, + currentArtifactVersion: null, + currentQuestion: { + id: 'visual_direction', + header: '当前要决定:首版美术方向', + question: '首版角色规范图采用哪种美术方向?', + options: [ + { label: '像素', description: '低成本像素风。' }, + { label: '扁平', description: '清晰的扁平插画风。' }, + ], + }, + capabilities: { tools: [], skills: [] }, + processingSeconds: 1, + createdAtUtc: '2026-09-03T00:00:00Z', + updatedAtUtc: '2026-09-03T00:00:01Z', + lastError: null, + }, + result: { + schemaVersion: 'planning-turn-result.v2', + kind: 'question', + payload: { + question: { + id: 'visual_direction', + header: '当前要决定:首版美术方向', + question: '首版角色规范图采用哪种美术方向?', + options: [ + { label: '像素', description: '低成本像素风。' }, + { label: '扁平', description: '清晰的扁平插画风。' }, + ], + }, + }, + }, + currentArtifact: null, + replayed: false, + }); const invoke = vi.fn( async (command: string, args?: Record) => { if (command === 'pick_local_project_directory') { @@ -1917,33 +2043,7 @@ export function registerHomeProjectCreationTests() { manifest, }; } - if (command === 'start_game_creator_supervisor_runtime_task') { - planRootRunId = String(args?.runId ?? ''); - } - const result = await supervisorHarness.invoke(command, args); - if (command !== 'read_game_creator_agent_runtime' || !planRootRunId) { - return result; - } - // 后端此刻的真实形态:pending 是 user.input_request,读命令把澄清请求投影在 - // 结果的**顶层**(`AgentRuntimeResult.user_input_request`,与 `state` 平级), - // 前端的 agentRuntimeStateFromResult 也优先读顶层。放进 state 会被顶层的 null - // 盖掉,那是 fixture 写错,不是产品缺陷。 - const runtimeResult = result as { state: Record }; - return { - ...runtimeResult, - state: { - ...runtimeResult.state, - status: 'waiting-for-user-input', - phase: 'waiting-for-user-input', - }, - userInputRequest: agentRuntimeUserInputRequest({ - agentId: 'project-supervisor', - sessionId: supervisorHarness.sessionId, - runId: planRootRunId, - requestId: 'request-plan-round-1', - actionId: 'action-plan-round-1', - }), - }; + return supervisorHarness.invoke(command, args); }, ); window.__TAURI__ = { @@ -1963,8 +2063,8 @@ export function registerHomeProjectCreationTests() { await waitFor(() => { expect(invoke).toHaveBeenCalledWith( - 'start_game_creator_supervisor_runtime_task', - expect.objectContaining({ source: PROJECT_SUPERVISOR_PLAN_SOURCE }), + 'start_planning_session_v2', + expect.objectContaining({ prompt: '2D射击游戏', mode: 'gdd' }), ); }); @@ -2122,6 +2222,7 @@ export function registerHomeProjectCreationTests() { 'existing-project', '已有项目', ); + const persistedMessages: Array> = []; const invoke = vi.fn( async (command: string, args?: Record) => { if (command === 'get_local_game_manifest') { @@ -2131,14 +2232,45 @@ export function registerHomeProjectCreationTests() { if (command === 'append_local_permission_log') { return {}; } - if (command === 'append_local_conversation_message') { + if (command === 'hydrate_game_creator_plan_gdd_state') { + return null; + } + if (command === 'read_project_permission_policy') { + return { + path: '.agent/policy.json', + policy: { deniedCommands: [], confirmCommands: [] }, + }; + } + if ( + command === 'read_local_conversation' || + command === 'read_direct_project_conversation' + ) { return { path: `${projectPath}/.agent/conversations/project.jsonl`, agentId: null, - messages: [], + sessionId: null, + messages: [...persistedMessages], }; } + if (command === 'append_local_conversation_message') { + throw new Error( + 'DirectProject must not use browser conversation writer', + ); + } if (command === 'chat_with_game_creator_direct_codex') { + const clientTurnId = String(args?.clientTurnId ?? ''); + persistedMessages.push( + { + role: 'user', + content: String(args?.prompt ?? ''), + messageId: `direct-codex:${clientTurnId}:user`, + }, + { + role: 'assistant', + content: 'DIRECT_EXISTING_PROJECT_OK', + messageId: `direct-codex:${clientTurnId}:assistant`, + }, + ); return 'DIRECT_EXISTING_PROJECT_OK'; } throw new Error(`unexpected invoke ${command}`); @@ -2165,32 +2297,18 @@ export function registerHomeProjectCreationTests() { }, ); }); - const persistedTurnCall = invoke.mock.calls.findIndex( - ([command, args]) => - command === 'append_local_conversation_message' && - (args as Record | undefined)?.messageId !== undefined, - ); const directTurnCall = invoke.mock.calls.findIndex( ([command]) => command === 'chat_with_game_creator_direct_codex', ); - expect(persistedTurnCall).toBeGreaterThanOrEqual(0); - expect(persistedTurnCall).toBeLessThan(directTurnCall); - const persistedTurnArgs = invoke.mock.calls[persistedTurnCall]?.[1] as - | Record - | undefined; const directTurnArgs = invoke.mock.calls[directTurnCall]?.[1] as | Record | undefined; - expect(persistedTurnArgs).toEqual({ - projectPath, - agentId: null, - messageId: `direct-codex:${String(directTurnArgs?.clientTurnId ?? '')}:user`, - message: { - role: 'user', - content: '继续修改已有项目', - agentId: null, - }, - }); + expect(directTurnCall).toBeGreaterThanOrEqual(0); + expect(invoke).not.toHaveBeenCalledWith( + 'append_local_conversation_message', + expect.anything(), + ); + expect(directTurnArgs?.clientTurnId).toEqual(expect.any(String)); expect(invoke).not.toHaveBeenCalledWith( 'create_automatic_local_game_project', ); @@ -2200,155 +2318,136 @@ export function registerHomeProjectCreationTests() { ); }); - it.each([false, true])( - 'recovers a direct Codex auth failure or preserves its readable reason (refresh=%s)', - async (refreshSucceeds) => { - vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { - if (String(input) === '/api/auth/refresh') { - return new Response( - JSON.stringify({ token: 'refreshed-direct-token' }), + it('shows a readable reason when the direct Codex turn is rejected', async () => { + const projectPath = + 'C:\\Users\\tester\\Documents\\Genarrative GameAgent\\failed-direct-project'; + const manifest = createGameCreationAppManifest( + 'failed-direct-project', + '直连失败项目', + ); + const persistedMessages: Array> = []; + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'get_local_game_manifest') { + expect(args).toEqual({ projectPath }); + return manifest; + } + if ( + command === 'read_local_conversation' || + command === 'read_direct_project_conversation' + ) { + return { + path: `${projectPath}/.agent/conversations/project.jsonl`, + agentId: null, + sessionId: null, + messages: [...persistedMessages], + }; + } + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'read_project_permission_policy') { + return { + path: '.agent/policy.json', + policy: { deniedCommands: [], confirmCommands: [] }, + }; + } + if (command === 'append_local_conversation_message') { + const message = args?.message as Record; + const messageId = String(args?.messageId ?? ''); + if ( + !messageId || + !persistedMessages.some( + (candidate) => candidate.messageId === messageId, + ) + ) { + persistedMessages.push({ + schemaVersion: 'game-creator-conversation.v1', + ...message, + messageId, + updatedAt: Number( + message.updatedAt ?? persistedMessages.length + 1, + ), + }); + } + return { + path: `${projectPath}/.agent/conversations/project.jsonl`, + agentId: null, + sessionId: null, + messages: [...persistedMessages], + }; + } + if (command === 'chat_with_game_creator_direct_codex') { + const clientTurnId = String(args?.clientTurnId ?? ''); + persistedMessages.push( { - status: refreshSucceeds ? 200 : 401, + schemaVersion: 'game-creator-conversation.v1', + role: 'user', + content: String(args?.prompt ?? ''), + messageId: `direct-codex:${clientTurnId}:user`, + updatedAt: 1, + }, + { + schemaVersion: 'game-creator-conversation.v1', + role: 'assistant', + content: '陶泥儿智能创作 鉴权失败,请重新登录后重试', + messageId: `direct-codex:${clientTurnId}:assistant`, + updatedAt: 2, }, ); + throw new Error('codex-app-server-error:unauthorized'); } - if (String(input) === '/api/auth/me') { - return new Response(JSON.stringify({ user: testAuthUser })); - } - throw new Error(`unexpected fetch ${String(input)}`); - }); - let directAttempts = 0; - const expectedReply = refreshSucceeds - ? '续期后对话完成' - : '陶泥儿智能创作 鉴权失败,请重新登录后重试'; - const projectPath = `C:\\Users\\tester\\Documents\\Genarrative GameAgent\\failed-direct-project-${refreshSucceeds}`; - const manifest = createGameCreationAppManifest( - 'failed-direct-project', - '直连失败项目', - ); - const persistedMessages: Array> = []; - const invoke = vi.fn( - async (command: string, args?: Record) => { - if (command === 'get_local_game_manifest') { - expect(args).toEqual({ projectPath }); - return manifest; - } - if (command === 'read_local_conversation') { - return { - path: `${projectPath}/.agent/conversations/project.jsonl`, - agentId: null, - sessionId: null, - messages: [...persistedMessages], - }; - } - if (command === 'append_local_permission_log') { - return {}; - } - if (command === 'read_project_permission_policy') { - return { - path: '.agent/policy.json', - policy: { deniedCommands: [], confirmCommands: [] }, - }; - } - if (command === 'append_local_conversation_message') { - const message = args?.message as Record; - const messageId = String(args?.messageId ?? ''); - if ( - !messageId || - !persistedMessages.some( - (candidate) => candidate.messageId === messageId, - ) - ) { - persistedMessages.push({ - schemaVersion: 'game-creator-conversation.v1', - ...message, - messageId, - updatedAt: Number( - message.updatedAt ?? persistedMessages.length + 1, - ), - }); - } - return { - path: `${projectPath}/.agent/conversations/project.jsonl`, - agentId: null, - sessionId: null, - messages: [...persistedMessages], - }; - } - if (command === 'chat_with_game_creator_direct_codex') { - directAttempts += 1; - if (refreshSucceeds && directAttempts === 2) { - expect(invoke).toHaveBeenCalledWith( - 'install_platform_account_session', - expect.objectContaining({ - userId: testAuthUser.id, - accessToken: 'refreshed-direct-token', - }), - ); - return expectedReply; - } - throw new Error('codex-app-server-error:unauthorized'); - } - if ( - command === 'read_platform_account_session_generation' || - command === 'install_platform_account_session' || - command === 'clear_platform_account_session' - ) - return null; - throw new Error(`unexpected invoke ${command}`); - }, - ); - window.__TAURI__ = { core: { invoke } }; + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; - render( - React.createElement(App, { - initialProjectPath: projectPath, - initialProjectManifest: manifest, - projectSupervisorOnly: true, - initialSupervisorMessage: '生成一个游戏', + render( + React.createElement(App, { + initialProjectPath: projectPath, + initialProjectManifest: manifest, + projectSupervisorOnly: true, + initialSupervisorMessage: '生成一个游戏', + }), + ); + + expect( + await screen.findByText('陶泥儿智能创作 鉴权失败,请重新登录后重试'), + ).not.toBeNull(); + await waitFor(() => { + expect(persistedMessages).toHaveLength(2); + }); + expect(persistedMessages).toEqual( + expect.arrayContaining([ + expect.objectContaining({ role: 'user', content: '生成一个游戏' }), + expect.objectContaining({ + role: 'assistant', + content: '陶泥儿智能创作 鉴权失败,请重新登录后重试', }), - ); + ]), + ); + expect(JSON.stringify(persistedMessages)).not.toContain( + 'codex-app-server-error:unauthorized', + ); + cleanup(); - expect(await screen.findByText(expectedReply)).not.toBeNull(); - await waitFor(() => { - expect(persistedMessages).toHaveLength(2); - }); - expect(persistedMessages).toEqual( - expect.arrayContaining([ - expect.objectContaining({ role: 'user', content: '生成一个游戏' }), - expect.objectContaining({ - role: 'assistant', - content: expectedReply, - }), - ]), - ); - expect(JSON.stringify(persistedMessages)).not.toContain( - 'codex-app-server-error:unauthorized', - ); - cleanup(); - - render( - React.createElement(App, { - initialProjectPath: projectPath, - initialProjectManifest: manifest, - projectSupervisorOnly: true, - }), - ); - expect(await screen.findByText('生成一个游戏')).not.toBeNull(); - expect(await screen.findByText(expectedReply)).not.toBeNull(); - expect( - invoke.mock.calls.filter( - ([command]) => command === 'chat_with_game_creator_direct_codex', - ), - ).toHaveLength(refreshSucceeds ? 2 : 1); - if (refreshSucceeds) { - const attempts = invoke.mock.calls.filter( - ([command]) => command === 'chat_with_game_creator_direct_codex', - ); - expect(attempts[1]?.[1]).toEqual(attempts[0]?.[1]); - } - }, - ); + render( + React.createElement(App, { + initialProjectPath: projectPath, + initialProjectManifest: manifest, + projectSupervisorOnly: true, + }), + ); + expect(await screen.findByText('生成一个游戏')).not.toBeNull(); + expect( + await screen.findByText('陶泥儿智能创作 鉴权失败,请重新登录后重试'), + ).not.toBeNull(); + expect( + invoke.mock.calls.filter( + ([command]) => command === 'chat_with_game_creator_direct_codex', + ), + ).toHaveLength(1); + }); } export function registerRecentProjectsTests() { diff --git a/apps/ai-game-creator-shell/tests/appSurface/plan-gdd.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/plan-gdd.suite.ts index d63bb13b1..a1ee2b5ef 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/plan-gdd.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/plan-gdd.suite.ts @@ -3,7 +3,7 @@ import { resolve } from 'node:path'; import { PlanGddStageProgress } from '../../src/features/project-workspace/GddApprovalCard'; import { - agentRuntimeUserInputRequest, + act, App, createPlanGddStateView, createProjectSupervisorRuntimeHarness, @@ -20,59 +20,185 @@ import { function mountFormalSupervisor( harness: ReturnType, + planningStartMode = false, ) { window.history.pushState({}, '', '/'); render( React.createElement(App, { initialProjectPath: harness.projectPath, orchestrationMode: 'single-supervisor', - planningStartMode: true, + planningStartMode, projectSupervisorOnly: true, }), ); } +function planningV2ApprovalResult() { + const state = createPlanGddStateView(); + const displayGdd = state.displayGdd; + const session = state.session; + if (!displayGdd || !session) { + throw new Error('fixture 应当带 GDD 和 session'); + } + return planningV2ResultFor( + session, + displayGdd, + 'awaiting_approval', + 'ready_for_approval', + ); +} + +function planningV2ResultFor( + session: NonNullable, + displayGdd: NonNullable, + sessionStatus: string, + artifactStatus: string, +) { + return { + session: { + schemaVersion: 'planning-session.v2', + engine: 'planning-session-v2', + sessionId: session.sessionId, + projectId: 'local-project-draft', + mode: 'gdd', + status: sessionStatus, + turnIndex: 3, + questionCount: session.clarificationRound, + questionLimit: 8, + revisionCount: 0, + currentArtifactVersion: displayGdd.version, + currentQuestion: null, + capabilities: { tools: [], skills: [] }, + processingSeconds: session.accumulatedAgentMillis / 1000, + createdAtUtc: displayGdd.createdAtUtc, + updatedAtUtc: displayGdd.createdAtUtc, + lastError: null, + }, + result: null, + currentArtifact: { + artifactId: displayGdd.gddId, + kind: 'gdd', + version: displayGdd.version, + status: artifactStatus, + fingerprint: displayGdd.fingerprint, + payload: { + schemaVersion: 'plan-gdd.v2', + projectId: displayGdd.projectId, + gddId: displayGdd.gddId, + version: displayGdd.version, + createdAtUtc: displayGdd.createdAtUtc, + game: displayGdd.game, + decisions: displayGdd.decisions, + prototypeValidationItems: displayGdd.prototypeValidationItems, + fingerprint: displayGdd.fingerprint, + }, + }, + replayed: false, + conversation: [], + }; +} + +/** 已批准:审批卡整张收掉,只剩标题栏和它下面的交付行。 */ +function planningV2ApprovedResult() { + const state = createPlanGddStateView(); + const displayGdd = state.displayGdd; + const session = state.session; + if (!displayGdd || !session) { + throw new Error('fixture 应当带 GDD 和 session'); + } + return planningV2ResultFor(session, displayGdd, 'approved', 'approved'); +} + +/** 策划中、尚未提交 GDD 的状态:没有待审批,也没有可展示的 GDD。 */ +function planningV2WorkingResult(questionCount = 2) { + const questionResult = planningV2QuestionResult(); + return { + ...questionResult, + session: { + ...questionResult.session, + status: 'planning', + questionCount, + currentQuestion: null, + }, + result: null, + }; +} + +function planningV2QuestionResult() { + return { + session: { + schemaVersion: 'planning-session.v2', + engine: 'planning-session-v2', + sessionId: 'plan-session-v2-question', + projectId: 'local-project-draft', + mode: 'gdd', + status: 'awaiting_user', + turnIndex: 1, + questionCount: 1, + questionLimit: 8, + revisionCount: 0, + currentArtifactVersion: null, + currentQuestion: { + id: 'core_loop', + header: '当前要决定:核心循环', + question: '玩家在一局中主要反复做什么?', + options: [ + { label: '持续闪避', description: '保持移动并躲避来袭弹幕。' }, + { label: '规划路线', description: '观察弹幕并选择安全路线。' }, + ], + }, + capabilities: { tools: [], skills: [] }, + processingSeconds: 1.2, + createdAtUtc: '2026-09-03T00:00:00Z', + updatedAtUtc: '2026-09-03T00:00:01Z', + lastError: null, + }, + result: { + schemaVersion: 'planning-turn-result.v2', + kind: 'question', + payload: { + question: { + id: 'core_loop', + header: '当前要决定:核心循环', + question: '玩家在一局中主要反复做什么?', + options: [ + { label: '持续闪避', description: '保持移动并躲避来袭弹幕。' }, + { label: '规划路线', description: '观察弹幕并选择安全路线。' }, + ], + }, + }, + }, + currentArtifact: null, + replayed: false, + }; +} + async function mountApprovalCard( harness: ReturnType, + planningStartMode = false, ) { window.__TAURI__ = { core: { invoke: harness.invoke }, event: { listen: harness.listen }, }; - mountFormalSupervisor(harness); + mountFormalSupervisor(harness, planningStartMode); return await screen.findByLabelText('GDD 审批卡'); } async function mountPlanningSurface( harness: ReturnType, + planningStartMode = false, ) { window.__TAURI__ = { core: { invoke: harness.invoke }, event: { listen: harness.listen }, }; - mountFormalSupervisor(harness); + mountFormalSupervisor(harness, planningStartMode); return await screen.findByLabelText('立项策划阶段进度'); } type PlanGddStateView = ReturnType; -/** 策划中、尚未提交 GDD 的状态:没有待审批,也没有可展示的 GDD。 */ -function draftPlanGddState( - sessionOverrides: Partial> = {}, -) { - const base = createPlanGddStateView(); - return createPlanGddStateView({ - state: 'draft', - versions: [], - displayGdd: null, - pendingApproval: null, - approvedGddRef: null, - session: base.session - ? { ...base.session, phase: 'collecting', ...sessionOverrides } - : null, - }); -} - /** 已批准:审批卡整张收掉,只剩标题栏和它下面的交付行。 */ function approvedPlanGddState() { const base = createPlanGddStateView(); @@ -121,26 +247,127 @@ function typeComment(dialog: HTMLElement, text: string) { } export function registerPlanGddApprovalTests() { + it('ignores a late hydrate from the previously opened project', async () => { + const message = (text: string) => ({ + role: 'assistant', + kind: 'assistant_text', + messageId: text, + atUtc: '2026-09-08T00:00:00Z', + text, + payload: { text }, + }); + const harness = createProjectSupervisorRuntimeHarness({ + planningV2Result: { + ...planningV2ApprovalResult(), + conversation: [message('当前项目的策划记录')], + }, + }); + window.__TAURI__ = { + core: { invoke: harness.invoke }, + event: { listen: harness.listen }, + }; + window.history.pushState({}, '', '/?dev'); + render(React.createElement(App, { planningStartMode: true })); + const openProject = async (path: string) => { + fireEvent.change(screen.getByLabelText('本地项目目录'), { + target: { value: path }, + }); + fireEvent.click(screen.getByRole('button', { name: '初始化' })); + const pending = screen + .getByText(`创建 ${path}`) + .closest('.pending-command'); + await act(async () => { + fireEvent.click( + within(pending as HTMLElement).getByRole('button', { name: '确认' }), + ); + }); + }; + await openProject('/tmp/planning-a'); + const originalInvoke = harness.invoke.getMockImplementation()!; + let finishOldHydrate!: (result: unknown) => void; + harness.invoke.mockImplementation(async (command, args) => { + if ( + command === 'hydrate_planning_session_v2' && + args?.projectPath === '/tmp/planning-a' + ) { + return new Promise((resolve) => { + finishOldHydrate = resolve; + }); + } + return originalInvoke(command, args); + }); + fireEvent.focus(window); + await waitFor(() => expect(finishOldHydrate).toBeTypeOf('function')); + await openProject('/tmp/planning-b'); + expect(screen.getByText('当前项目的策划记录')).not.toBeNull(); + await act(async () => { + finishOldHydrate({ + ...planningV2ApprovalResult(), + conversation: [message('旧项目的迟到策划')], + }); + }); + expect(screen.queryByText('旧项目的迟到策划')).toBeNull(); + expect(screen.getByText('当前项目的策划记录')).not.toBeNull(); + }); + + it.each(['question', 'empty'] as const)( + 'ignores a late %s hydrate after a newer hydrate has restored the GDD', + async (lateResult) => { + const harness = createProjectSupervisorRuntimeHarness({ + planningV2Result: planningV2ApprovalResult(), + }); + await mountApprovalCard(harness, true); + const originalInvoke = harness.invoke.getMockImplementation()!; + let finishOldHydrate!: (result: unknown) => void; + let deferNextHydrate = true; + harness.invoke.mockImplementation(async (command, args) => { + if (command === 'hydrate_planning_session_v2' && deferNextHydrate) { + deferNextHydrate = false; + return new Promise((resolve) => { + finishOldHydrate = resolve; + }); + } + return originalInvoke(command, args); + }); + + fireEvent.focus(window); + await waitFor(() => expect(finishOldHydrate).toBeTypeOf('function')); + await act(async () => { + fireEvent.focus(window); + }); + expect(screen.getByLabelText('GDD 审批卡')).not.toBeNull(); + + await act(async () => { + finishOldHydrate( + lateResult === 'question' ? planningV2QuestionResult() : null, + ); + }); + expect(screen.getByLabelText('GDD 审批卡')).not.toBeNull(); + expect(screen.queryByText('玩家在一局中主要反复做什么?')).toBeNull(); + }, + ); + it('re-hydrates authority after a failed GDD decision and keeps the failure visible', async () => { - const harness = createProjectSupervisorRuntimeHarness(); - harness.setPlanGddState(createPlanGddStateView()); + const harness = createProjectSupervisorRuntimeHarness({ + planningV2Result: planningV2ApprovalResult(), + }); await mountApprovalCard(harness); - const hydrateCallsBeforeDecision = harness.planGddHydrateCount(); - // 决定失败后,hydrate 应当读到权威侧已经变成「投影恢复中」的事实。 - harness.setPlanGddState(createPlanGddStateView({ recoveryPending: true })); - harness.failNextPlanGddDecision('PLAN_STALE_APPROVAL'); + const hydrateCallsBeforeDecision = harness.invoke.mock.calls.filter( + ([command]) => command === 'hydrate_planning_session_v2', + ).length; + harness.failNextPlanningV2Decision('PLAN_STALE_APPROVAL'); fireEvent.click(screen.getByRole('button', { name: '批准 v1' })); // 失败分支必须重灌权威状态,否则卡片会停在已失效的 pending 身份上。 await waitFor(() => { - expect(harness.planGddHydrateCount()).toBeGreaterThan( - hydrateCallsBeforeDecision, - ); + expect( + harness.invoke.mock.calls.filter( + ([command]) => command === 'hydrate_planning_session_v2', + ).length, + ).toBeGreaterThan(hydrateCallsBeforeDecision); }); - // 重灌后「重试同步」入口出现——这是 recoveryPending 下唯一被允许的动作。 - await screen.findByRole('button', { name: '重试同步' }); // 而且重灌不能把决定失败的原因擦掉:hydrate 入口会 setPlanGddError(null), // 两句顺序写反这条断言就红。 expect(screen.getByRole('alert').textContent).toContain( @@ -148,9 +375,10 @@ export function registerPlanGddApprovalTests() { ); }); - it('reuses one responseId while the decision is unchanged and mints a new one once the comment changes', async () => { - const harness = createProjectSupervisorRuntimeHarness(); - harness.setPlanGddState(createPlanGddStateView()); + it('reuses one decisionId while the decision is unchanged and mints a new one once the comment changes', async () => { + const harness = createProjectSupervisorRuntimeHarness({ + planningV2Result: planningV2ApprovalResult(), + }); await mountApprovalCard(harness); const dialog = openReviseDialog(); @@ -160,14 +388,14 @@ export function registerPlanGddApprovalTests() { within(dialog).getByRole('button', { name: '提交决定' }), ).toHaveProperty('disabled', false); }); - harness.failNextPlanGddDecision('PLAN_DURABILITY_FAILED'); + harness.failNextPlanningV2Decision('PLAN_DURABILITY_FAILED'); fireEvent.click(screen.getByRole('button', { name: '提交决定' })); await waitFor(() => { - expect(harness.planGddDecisionCalls).toHaveLength(1); + expect(harness.planningV2DecisionCalls).toHaveLength(1); }); - // 原样重试:属于方案 §13.2 的 busy/超时/网络重试,必须复用同一 responseId。 - harness.failNextPlanGddDecision('PLAN_DURABILITY_FAILED'); + // 原样重试:属于方案 §13.2 的 busy/超时/网络重试,必须复用同一 decisionId。 + harness.failNextPlanningV2Decision('PLAN_DURABILITY_FAILED'); await waitFor(() => { expect( within(dialog).getByRole('button', { name: '提交决定' }), @@ -175,16 +403,16 @@ export function registerPlanGddApprovalTests() { }); fireEvent.click(screen.getByRole('button', { name: '提交决定' })); await waitFor(() => { - expect(harness.planGddDecisionCalls).toHaveLength(2); + expect(harness.planningV2DecisionCalls).toHaveLength(2); }); - expect(harness.planGddDecisionCalls[1].responseId).toBe( - harness.planGddDecisionCalls[0].responseId, + expect(harness.planningV2DecisionCalls[1].decisionId).toBe( + harness.planningV2DecisionCalls[0].decisionId, ); - // 改写修改意见:审批意图变了,必须换新的 responseId,否则后端会以 - // 「同 responseId 的审批意图不一致」硬拒,用户改写后的原因永远落不了盘。 + // 改写修改意见:审批意图变了,必须换新的 decisionId,否则后端会以 + // 「同 decisionId 的审批意图不一致」硬拒,用户改写后的原因永远落不了盘。 typeComment(dialog, '把核心循环压到两步,并去掉天气系统'); - harness.failNextPlanGddDecision('PLAN_DURABILITY_FAILED'); + harness.failNextPlanningV2Decision('PLAN_DURABILITY_FAILED'); await waitFor(() => { expect( within(dialog).getByRole('button', { name: '提交决定' }), @@ -192,156 +420,56 @@ export function registerPlanGddApprovalTests() { }); fireEvent.click(screen.getByRole('button', { name: '提交决定' })); await waitFor(() => { - expect(harness.planGddDecisionCalls).toHaveLength(3); + expect(harness.planningV2DecisionCalls).toHaveLength(3); }); - expect(harness.planGddDecisionCalls[2].responseId).not.toBe( - harness.planGddDecisionCalls[0].responseId, + expect(harness.planningV2DecisionCalls[2].decisionId).not.toBe( + harness.planningV2DecisionCalls[0].decisionId, ); - expect(harness.planGddDecisionCalls[2].comment).toBe( + expect(harness.planningV2DecisionCalls[2].comment).toBe( '把核心循环压到两步,并去掉天气系统', ); }); it('labels the clarification round the user is actually on rather than the 0-indexed answered count', async () => { - const harness = createProjectSupervisorRuntimeHarness(); - const base = createPlanGddStateView(); - const session = base.session; - if (!session) { - throw new Error('fixture 应当带 session'); - } - // clarificationRound 是「已答轮数」(0-indexed):等待第 3 轮回答时它恒为 2。 - // 直接渲染成「轮次 2/3」会暗示还剩一轮,而这已经是硬上限的最后一轮。 - harness.setPlanGddState( - createPlanGddStateView({ + // questionCount 是「已展示问题数」:等待第 3 轮回答时它恒为 3,当前轮次 + // 由 currentQuestion 钉住。直接渲染成「轮次 2/8」会暗示还剩一轮。 + const questionResult = planningV2QuestionResult(); + const harness = createProjectSupervisorRuntimeHarness({ + planningV2Result: { + ...questionResult, session: { - ...session, - phase: 'awaiting_user_input', - clarificationRound: 2, - awaitingAnswerFor: { - delegationId: 'delegation-0003', - requestId: 'request-0003', - questionId: 'question-0003', - round: 2, - }, + ...questionResult.session, + questionCount: 3, }, - }), - ); - await mountApprovalCard(harness); + result: null, + }, + }); + await mountPlanningSurface(harness); const progress = await screen.findByLabelText('立项策划阶段进度'); - expect(within(progress).getByText('第 3 轮 / 共 3 轮')).not.toBeNull(); - expect(within(progress).queryByText('轮次 2/3')).toBeNull(); + expect(within(progress).getByText('第 3 轮 / 共 8 轮')).not.toBeNull(); + expect(within(progress).queryByText(/轮次 \d+\/\d+/)).toBeNull(); }); it('falls back to an answered-count label when no clarification answer is outstanding', async () => { - const harness = createProjectSupervisorRuntimeHarness(); - // awaitingAnswerFor 为 null(含恢复态)时不去猜当前是第几轮,只报已完成多少轮。 - harness.setPlanGddState(createPlanGddStateView()); - await mountApprovalCard(harness); + // currentQuestion 为 null(含恢复态)时不去猜当前是第几轮,只报已完成多少轮。 + const harness = createProjectSupervisorRuntimeHarness({ + planningV2Result: planningV2WorkingResult(2), + }); + await mountPlanningSurface(harness); const progress = await screen.findByLabelText('立项策划阶段进度'); - expect(within(progress).getByText('已完成 2/3 轮澄清')).not.toBeNull(); - }); - - it('blocks the already-open comment dialog once recovery starts without discarding the typed reason', async () => { - const harness = createProjectSupervisorRuntimeHarness(); - harness.setPlanGddState(createPlanGddStateView()); - await mountApprovalCard(harness); - - const dialog = openReviseDialog(); - const textarea = typeComment(dialog, '战斗节奏太慢,请压缩到三个回合'); - await waitFor(() => { - expect( - ( - within(dialog).getByRole('button', { - name: '提交决定', - }) as HTMLButtonElement - ).disabled, - ).toBe(false); - }); - - // 弹层是打开之后才被后台 hydrate 翻掉决定资格的:触发按钮的 disabled 管不到它。 - harness.setPlanGddState(createPlanGddStateView({ recoveryPending: true })); - fireEvent.focus(window); - - await waitFor(() => { - expect( - ( - within(dialog).getByRole('button', { - name: '提交决定', - }) as HTMLButtonElement - ).disabled, - ).toBe(true); - }); - // 已经写好的原因不能被丢掉——所以是禁用加说明,不是自动关弹层。 - expect(textarea.value).toBe('战斗节奏太慢,请压缩到三个回合'); - expect(harness.planGddDecisionCalls).toHaveLength(0); - }); - - it('shows only the approval card while the GDD decision is outstanding', async () => { - // 审批等待复用了 waiting-for-user-input 这个 phase,但它没有 userInputRequest;而 - // 策划子 Agent 的 plan.submit_gdd pending 正是 planning/pending.json 里 - // submission.pendingActionId 指向的那一个,交互面就是审批卡本身。两处都套通用面的话, - // 用户会同时看到一句「待回答问题未能读取」和一张点了必然失败的 plan.submit_gdd - // 确认卡,把真正该看的审批卡压在下面。 - const supervisorRunId = 'plan-root-awaiting-approval-run'; - let collaboratingRuntimes: Array> = []; - const harness = createProjectSupervisorRuntimeHarness({ - expectedRunProfile: 'standard', - initialRuntime: { - runId: supervisorRunId, - source: 'project-supervisor-plan', - runProfile: 'standard', - status: 'running', - phase: 'waiting-for-user-input', - currentTask: '剧情向恋爱养成游戏', - waitingOn: '用户在审批卡选择批准、修改或退回', - nextStep: '等待 decide_game_creator_plan_gdd', - updatedAt: 8000, - }, - runtimeMapLoader: async () => collaboratingRuntimes, - }); - collaboratingRuntimes = [ - harness.runtimeState({ - agentId: 'project-planning', - taskId: 'project-planning', - sessionId: 'agent-session-project-planning', - runId: 'delegated-delegation-344abab06ded0da1cf572fa3', - source: 'agent-delegate', - parentAgentId: 'project-supervisor', - parentRunId: supervisorRunId, - delegationId: 'delegation-344abab06ded0da1cf572fa3', - runProfile: 'standard', - status: 'idle', - phase: 'completed', - currentTask: '完成立项策划并给出 Fast GDD', - pendingToolAction: { - actionId: 'action-0123456789abcdef01234567', - actionFingerprint: 'a'.repeat(64), - tool: 'plan.submit_gdd', - inputSummary: null, - reason: '提交完整原创 MVP Fast GDD。', - requestedAt: 8000, - }, - updatedAt: 8000, - }), - ]; - harness.setPlanGddState(createPlanGddStateView()); - await mountApprovalCard(harness); - - expect(screen.queryByText('待回答问题未能读取,请稍后重试。')).toBeNull(); - expect(screen.queryByText(/plan\.submit_gdd/)).toBeNull(); - expect(screen.queryByLabelText('立项策划 Agent待确认动作')).toBeNull(); - expect(screen.getByRole('button', { name: '批准 v1' })).not.toBeNull(); + expect(within(progress).getByText('已完成 2/8 轮澄清')).not.toBeNull(); }); it('paints the planning state once in the stage strip and keeps the fingerprint inside the GDD dialog', async () => { // 阶段进度和审批卡曾各自带框叠在一起,「立项策划 / 待审批」在标题栏和卡头各画一遍, // 卡头还露一截指纹。合成一个面之后:状态与版本只在标题栏,卡头只剩游戏标题和一句 // 话,指纹整条放进正文弹层做追溯。 - const harness = createProjectSupervisorRuntimeHarness(); - const view = createPlanGddStateView(); - harness.setPlanGddState(view); + const result = planningV2ApprovalResult(); + const harness = createProjectSupervisorRuntimeHarness({ + planningV2Result: result, + }); const card = await mountApprovalCard(harness); const surface = screen.getByLabelText('立项策划'); @@ -355,7 +483,7 @@ export function registerPlanGddApprovalTests() { expect(within(card).queryByText(/^版本 v1$/)).toBeNull(); expect(within(card).queryByText(/指纹/)).toBeNull(); expect(within(card).getByRole('heading', { level: 2 }).textContent).toBe( - view.displayGdd?.game.title, + result.currentArtifact.payload.game.title, ); fireEvent.click( @@ -363,7 +491,7 @@ export function registerPlanGddApprovalTests() { ); const dialog = screen.getByRole('dialog'); expect(within(dialog).getByLabelText('GDD 版本与指纹').textContent).toBe( - `Fast GDD v1 · ${view.displayGdd?.fingerprint}`, + `Fast GDD v1 · ${result.currentArtifact.fingerprint}`, ); }); @@ -371,8 +499,9 @@ export function registerPlanGddApprovalTests() { // 批准之后审批卡按设计整张收掉,此前那一刻起用户就再也够不到 GDD:approvedGddRef // 前端没人读,渲染好的 Markdown 也没有出口。交付行补的就是这个缺口——不额外占一 // 张卡,只在标题栏下多一行:文件在哪、看正文、用外部程序打开。 - const harness = createProjectSupervisorRuntimeHarness(); - harness.setPlanGddState(approvedPlanGddState()); + const harness = createProjectSupervisorRuntimeHarness({ + planningV2Result: planningV2ApprovedResult(), + }); await mountPlanningSurface(harness); expect(screen.queryByLabelText('GDD 审批卡')).toBeNull(); @@ -393,8 +522,9 @@ export function registerPlanGddApprovalTests() { }); it('asks the shell to open the rendered GDD for the current project', async () => { - const harness = createProjectSupervisorRuntimeHarness(); - harness.setPlanGddState(approvedPlanGddState()); + const harness = createProjectSupervisorRuntimeHarness({ + planningV2Result: planningV2ApprovedResult(), + }); await mountPlanningSurface(harness); const delivery = await screen.findByLabelText('GDD 交付'); @@ -432,147 +562,67 @@ export function registerPlanGddApprovalTests() { }); }); - it('keeps the delivery row hidden while the approval projection is still recovering', async () => { - // 恢复态下权威投影还没收敛,磁盘上那份 Markdown 未必是用户批的那版。此时给出口 - // 等于让用户读一份可能已经失效的交付物。 - const harness = createProjectSupervisorRuntimeHarness(); - harness.setPlanGddState({ - ...approvedPlanGddState(), - recoveryPending: true, - }); - await mountPlanningSurface(harness); - - expect(screen.queryByLabelText('GDD 交付')).toBeNull(); - }); - it('keeps the supervisor runtime panel off the planning lane while the planner is working', async () => { // 完整面板是给做游戏链路的:十几个专业 Agent、多步计划、逐 Agent 重试。策划链路 - // 只有一个 project-planning 子 Run、一两步计划,面板画出来的全是 D11 拓扑的内部 - // 记账(currentAction 原文、计划 1/2、「专业 Agent 协作:1」、子 Agent 卡)。 - // 状态由顶部的阶段进度承担;底部在策划正常进行时什么都不该画。 - const supervisorRunId = 'plan-root-collecting-run'; + // 只有一个策划会话,面板画出来的全是内部记账。状态由顶部的阶段进度承担; + // 底部在策划正常进行时什么都不该画。 const harness = createProjectSupervisorRuntimeHarness({ - expectedRunProfile: 'standard', - initialRuntime: { - runId: supervisorRunId, - source: 'project-supervisor-plan', - runProfile: 'standard', - status: 'running', - phase: 'waiting-for-delegate-receipts', - currentTask: '剧情向恋爱养成游戏', - currentAction: '等待 project-planning 提交 GDD', - waitingOn: '策划子 Run 提交 Fast GDD', - nextStep: '读取 GDD 并进入审批', - plan: ['委派策划子 Run', '读取 GDD 并审批'], - planSteps: [ - { step: '委派策划子 Run', status: 'active' }, - { step: '读取 GDD 并审批', status: 'pending' }, - ], - activePlanStepIndex: 0, - updatedAt: 7000, - }, - runtimeMapLoader: async () => [ - harness.runtimeState({ - agentId: 'project-planning', - taskId: 'project-planning', - sessionId: 'agent-session-project-planning', - runId: 'delegated-delegation-collecting', - source: 'agent-delegate', - parentAgentId: 'project-supervisor', - parentRunId: supervisorRunId, - delegationId: 'delegation-collecting', - runProfile: 'standard', - status: 'running', - phase: 'running', - currentTask: '完成立项策划并给出 Fast GDD', - currentAction: '整理第 1 轮澄清答案', - updatedAt: 7000, - }), - ], + planningV2Result: planningV2WorkingResult(), }); - harness.setPlanGddState(draftPlanGddState()); const progress = await mountPlanningSurface(harness); expect(within(progress).getByText('策划中')).not.toBeNull(); expectSupervisorRuntimePanelAbsent(); expect(screen.queryByLabelText('立项策划运行状态')).toBeNull(); - expect(screen.queryByText('等待 project-planning 提交 GDD')).toBeNull(); - expect(screen.queryByText('整理第 1 轮澄清答案')).toBeNull(); expect(screen.queryByText(/计划 \d+\/\d+/)).toBeNull(); }); it('still surfaces the clarification card on the planning lane', async () => { - // 澄清卡是策划链路唯一需要用户动手的交互面之一,瘦身不能把它一起收掉。 - const supervisorRunId = 'plan-root-clarifying-run'; - const request = agentRuntimeUserInputRequest({ - agentId: 'project-supervisor', - sessionId: 'supervisor-session-active', - runId: supervisorRunId, - requestId: 'request-plan-round-1', - actionId: 'action-plan-round-1', - }); + // 澄清卡是 V2 策划链路唯一需要用户动手的交互面之一。 const harness = createProjectSupervisorRuntimeHarness({ - expectedRunProfile: 'standard', - initialRuntime: { - runId: supervisorRunId, - source: 'project-supervisor-plan', - runProfile: 'standard', - status: 'waiting-for-user-input', - phase: 'waiting-for-user-input', - currentTask: '剧情向恋爱养成游戏', - currentAction: '等待用户回答第 1 轮澄清', - userInputRequest: request, - updatedAt: 7100, - }, + planningV2Result: planningV2QuestionResult(), }); - harness.setPlanGddState( - draftPlanGddState({ - clarificationRound: 0, - awaitingAnswerFor: { - delegationId: 'delegation-0001', - requestId: 'request-plan-round-1', - questionId: 'visual_direction', - round: 0, - }, - }), - ); - await mountPlanningSurface(harness); + await mountPlanningSurface(harness, true); const strip = await screen.findByLabelText('立项策划运行状态'); expect(within(strip).getByLabelText('Needs input')).not.toBeNull(); expect( - within(strip).getByText('首版角色规范图采用哪种美术方向?'), + within(strip).getByText('玩家在一局中主要反复做什么?'), ).not.toBeNull(); expectSupervisorRuntimePanelAbsent(); - expect(screen.queryByText('等待用户回答第 1 轮澄清')).toBeNull(); + expect(screen.queryByText(/agent\.delegate/)).toBeNull(); }); - it('offers a restart on the planning lane once the planning run has failed', async () => { - // 失败是另一处需要用户动手的时刻。恢复入口跟着搬进窄条,而不是随面板一起消失。 + it('surfaces a V2 provider failure on the planning lane', async () => { + // V2 provider_failed 由同一策划会话承接,错误显示在窄条中,用户可用输入框重新提交。 const harness = createProjectSupervisorRuntimeHarness({ - expectedRunProfile: 'standard', - initialRuntime: { - runId: 'plan-root-failed-run', - source: 'project-supervisor-plan', - runProfile: 'standard', - status: 'failed', - phase: 'failed', - currentTask: '剧情向恋爱养成游戏', - currentAction: '策划子 Run 退出', - error: '项目总控 Agent Codex 执行失败,请查看运行详情后重试', - updatedAt: 7200, + planningV2Result: { + ...planningV2QuestionResult(), + session: { + ...planningV2QuestionResult().session, + status: 'provider_failed', + currentQuestion: null, + lastError: { + code: 'PROVIDER_FAILED', + summary: 'Planning V2 Provider 调用失败', + }, + }, + result: { + schemaVersion: 'planning-turn-result.v2', + kind: 'error', + payload: { + code: 'PROVIDER_FAILED', + summary: 'Planning V2 Provider 调用失败', + }, + }, }, }); - harness.setPlanGddState(draftPlanGddState()); - await mountPlanningSurface(harness); + await mountPlanningSurface(harness, true); - const recovery = await screen.findByLabelText('立项策划失败恢复'); - expect( - within(recovery).getByRole('button', { name: '重新启动策划' }), - ).not.toBeNull(); - expect(screen.getByRole('alert').textContent).toContain('执行失败'); + const strip = await screen.findByLabelText('立项策划运行状态'); + expect(within(strip).getByRole('alert').textContent).toContain('执行失败'); expectSupervisorRuntimePanelAbsent(); - expect(screen.queryByText('策划子 Run 退出')).toBeNull(); + expect(screen.queryByText(/project-planning/)).toBeNull(); }); it('keeps a stylesheet rule for every class the planning components reference', () => { @@ -661,4 +711,76 @@ export function registerPlanGddApprovalTests() { /\.game-workbench-chat\s+\.plan-gdd-surface--with-card\s*\{[^}]*grid-template-rows:\s*auto minmax\(0, 1fr\)/s, ); }); + + it('routes a formal planning entry through Runtime V2 commands', async () => { + const harness = createProjectSupervisorRuntimeHarness({ + planningV2Result: planningV2ApprovalResult(), + }); + window.__TAURI__ = { + core: { invoke: harness.invoke }, + event: { listen: harness.listen }, + }; + window.history.pushState({}, '', '/'); + render( + React.createElement(App, { + initialProjectPath: harness.projectPath, + orchestrationMode: 'single-supervisor', + planningStartMode: true, + projectSupervisorOnly: true, + }), + ); + + await screen.findByLabelText('GDD 审批卡'); + const planningHydrateCall = harness.invoke.mock.calls.find( + ([command]) => command === 'hydrate_planning_session_v2', + ); + expect(planningHydrateCall).toBeDefined(); + fireEvent.click(screen.getByRole('button', { name: '批准 v1' })); + await waitFor(() => { + expect( + harness.invoke.mock.calls.some( + ([command]) => command === 'decide_planning_artifact_v2', + ), + ).toBe(true); + }); + }); + + it('starts a new V2 planning session and renders its question card', async () => { + const harness = createProjectSupervisorRuntimeHarness({ + planningV2StartResult: planningV2QuestionResult(), + }); + window.__TAURI__ = { + core: { invoke: harness.invoke }, + event: { listen: harness.listen }, + }; + window.history.pushState({}, '', '/'); + render( + React.createElement(App, { + initialProjectPath: harness.projectPath, + initialSupervisorMessage: '做一个2D弹幕射击游戏', + orchestrationMode: 'single-supervisor', + planningStartMode: true, + projectSupervisorOnly: true, + }), + ); + + await screen.findByLabelText('Needs input'); + expect( + harness.invoke.mock.calls.some( + ([command]) => command === 'start_planning_session_v2', + ), + ).toBe(true); + expect(screen.getByText('玩家在一局中主要反复做什么?')).not.toBeNull(); + expect(screen.queryByText(/agent\.delegate/)).toBeNull(); + fireEvent.click(screen.getByRole('button', { name: /持续闪避/ })); + fireEvent.click(screen.getByRole('button', { name: '提交回答' })); + await waitFor(() => { + expect(harness.invoke).toHaveBeenCalledWith( + 'continue_planning_session_v2', + expect.objectContaining({ + input: { text: '持续闪避', questionId: 'core_loop' }, + }), + ); + }); + }); } diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-assets.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-assets.suite.ts index 1df0e257a..ef3335a59 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-assets.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-assets.suite.ts @@ -923,7 +923,9 @@ export function registerProjectAssetTests() { submitChat('/read game/index.html'); expect(await screen.findByText(/文件:game\/index\.html/)).not.toBeNull(); - expect(screen.getByText(/<\/canvas>/)).not.toBeNull(); + const fileContent = screen.getByText(''); + expect(fileContent.tagName).toBe('CODE'); + expect(fileContent.closest('pre')).not.toBeNull(); expect(invoke).toHaveBeenCalledWith('read_local_project_file', { projectPath: '/tmp/authorized-game', relativePath: 'game/index.html', @@ -992,7 +994,9 @@ export function registerProjectAssetTests() { fireEvent.click(screen.getByRole('button', { name: '确认' })); expect(await screen.findByText(/文件:game\/index\.html/)).not.toBeNull(); - expect(screen.getByText(/<\/canvas>/)).not.toBeNull(); + const fileContent = screen.getByText(''); + expect(fileContent.tagName).toBe('CODE'); + expect(fileContent.closest('pre')).not.toBeNull(); expect(invoke).toHaveBeenCalledWith('read_local_project_file', { projectPath: '/tmp/authorized-game', relativePath: 'game/index.html', diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-commands.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-commands.suite.ts index c4f51ff77..528710a1e 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-commands.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-commands.suite.ts @@ -3805,7 +3805,13 @@ export function registerProjectCommandTests() { submitChat('/files'); expect(await screen.findByText(/本地项目文件:/)).not.toBeNull(); - expect(screen.getByText(/- game\//)).not.toBeNull(); + expect( + screen.getByText( + (_, element) => + element?.tagName === 'LI' && + element.textContent?.trim() === '- game/', + ), + ).not.toBeNull(); expect(screen.getByText(/- game\/index\.html/)).not.toBeNull(); expect(screen.getByText(/- assets\/uploads\/hero\.png/)).not.toBeNull(); expect(invoke).toHaveBeenCalledWith('list_local_project_files', { diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts index 79815398b..e92aab6d7 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts @@ -1986,6 +1986,9 @@ export function registerProjectWorkbenchFoundationTests() { expect(styles).toMatch( /\.game-resource-card-open\s*\{[^}]*cursor:\s*pointer[^}]*touch-action:\s*manipulation/s, ); + expect(styles).toMatch( + /\.game-resource-card-media-control\s*\{[^}]*transform:\s*scale\(var\(--genarrative-image-canvas-inverse-scale,\s*1\)\)[^}]*transform-origin:\s*bottom\s+right/s, + ); expect(styles).toMatch( /\.game-resource-canvas\s*\{[^}]*user-select:\s*none/s, ); @@ -4695,6 +4698,50 @@ export function registerUserSurfaceBoundaryTests() { } export function registerProjectSupervisorSurfaceTests() { + it('allows selecting the model on the first direct-project entry', async () => { + const projectPath = '/tmp/first-entry-model-select'; + const manifest = createGameCreationAppManifest( + 'first-entry-model-select', + '首次进入模型选择', + ); + const supervisorHarness = createProjectSupervisorRuntimeHarness({ + projectPath, + initialSessionExists: false, + }); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'get_local_game_manifest') { + return manifest; + } + return supervisorHarness.invoke(command, args); + }, + ); + window.__TAURI__ = { + core: { invoke }, + event: { listen: supervisorHarness.listen }, + }; + + render( + React.createElement(App, { + initialProjectPath: projectPath, + initialProjectManifest: manifest, + projectSupervisorOnly: true, + }), + ); + + const surface = await screen.findByLabelText('陶泥儿项目对话'); + const trigger = within(surface).getByRole('button', { name: '对话模型' }); + await waitFor(() => expect(trigger.hasAttribute('disabled')).toBe(false)); + fireEvent.click(trigger); + await waitFor(() => + expect( + within(surface).getByRole('option', { name: '快速' }), + ).not.toBeNull(), + ); + fireEvent.click(within(surface).getByRole('option', { name: '快速' })); + await waitFor(() => expect(trigger.textContent).toContain('快速')); + }); + it('runs a top workbench play request without a second confirmation', async () => { const projectPath = '/tmp/top-play-request'; const supervisorHarness = createProjectSupervisorRuntimeHarness({ diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/assert-planning-and-status-shortcuts.ts b/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/assert-planning-and-status-shortcuts.ts index cfb73e050..30d180a03 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/assert-planning-and-status-shortcuts.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/assert-planning-and-status-shortcuts.ts @@ -1,5 +1,6 @@ import { expect, fireEvent, screen, submitChat, within } from '../../harness'; import type { PreviewShortcutInvoke } from './invoke-mock'; +import { messageBubble } from './message-bubble'; export async function assertPlanningAndStatusShortcutFlow( invoke: PreviewShortcutInvoke, @@ -70,7 +71,9 @@ export async function assertPlanningAndStatusShortcutFlow( }; submitChat('/blockers'); const blockerMessages = await screen.findAllByText(/当前阻塞项:/); - const blockerMessage = blockerMessages[blockerMessages.length - 1]; + const blockerMessage = messageBubble( + blockerMessages[blockerMessages.length - 1], + ); expect(blockerMessage.textContent).toContain('项目:未命名游戏原型'); expect(blockerMessage.textContent).toContain( 'Run:run-main-shortcut-trace · passed / done · 1/3 轮 · evaluator-passed', @@ -170,7 +173,7 @@ export async function assertPlanningAndStatusShortcutFlow( }; submitChat('/ready'); const readyMessages = await screen.findAllByText(/试玩就绪度:/); - const readyMessage = readyMessages[readyMessages.length - 1]; + const readyMessage = messageBubble(readyMessages[readyMessages.length - 1]); expect(readyMessage.textContent).toContain('项目:未命名游戏原型'); expect(readyMessage.textContent).toContain( 'Run:run-main-shortcut-trace · passed / done · 1/3 轮 · evaluator-passed', @@ -270,7 +273,9 @@ export async function assertPlanningAndStatusShortcutFlow( }; submitChat('/evidence'); const evidenceMessages = await screen.findAllByText(/验证证据台账:/); - const evidenceMessage = evidenceMessages[evidenceMessages.length - 1]; + const evidenceMessage = messageBubble( + evidenceMessages[evidenceMessages.length - 1], + ); expect(evidenceMessage.textContent).toContain('项目:未命名游戏原型'); expect(evidenceMessage.textContent).toContain( 'Run trace:已有 run-main-shortcut-trace · passed / done · 1/3 轮 · evaluator-passed', @@ -377,7 +382,9 @@ export async function assertPlanningAndStatusShortcutFlow( }; submitChat('/deps'); const dependencyMessages = await screen.findAllByText(/任务依赖链:/); - const dependencyMessage = dependencyMessages[dependencyMessages.length - 1]; + const dependencyMessage = messageBubble( + dependencyMessages[dependencyMessages.length - 1], + ); expect(dependencyMessage.textContent).toContain( 'Run:run-main-shortcut-trace · passed / done · 1/3 轮 · evaluator-passed', ); @@ -479,7 +486,9 @@ export async function assertPlanningAndStatusShortcutFlow( }; submitChat('/revise'); const revisionMessages = await screen.findAllByText(/改版草稿:/); - const revisionMessage = revisionMessages[revisionMessages.length - 1]; + const revisionMessage = messageBubble( + revisionMessages[revisionMessages.length - 1], + ); expect(revisionMessage.textContent).toContain('项目:未命名游戏原型'); expect(revisionMessage.textContent).toContain( 'Run:run-main-shortcut-trace · passed / done · 1/3 轮 · evaluator-passed', @@ -604,7 +613,9 @@ export async function assertPlanningAndStatusShortcutFlow( }; submitChat('/privacy'); const privacyMessages = await screen.findAllByText(/隐私与导出边界:/); - const privacyMessage = privacyMessages[privacyMessages.length - 1]; + const privacyMessage = messageBubble( + privacyMessages[privacyMessages.length - 1], + ); expect(privacyMessage.textContent).toContain('项目:未命名游戏原型'); expect(privacyMessage.textContent).toContain( '本地目录:/tmp/authorized-game', @@ -616,10 +627,10 @@ export async function assertPlanningAndStatusShortcutFlow( '本地预览:未启动;仅限 127.0.0.1 本机访问', ); expect(privacyMessage.textContent).toContain( - '试玩包:尚未导出;只应包含 game/**、assets/** 和 exports/README.md', + '试玩包:尚未导出;只应包含 game/、assets/ 和 exports/README.md', ); expect(privacyMessage.textContent).toContain( - '内部文件:.agent/**、memory/**、日志、trace、配置和密钥不得进入试玩包', + '内部文件:.agent/、memory/、日志、trace、配置和密钥不得进入试玩包', ); expect(privacyMessage.textContent).toContain( '素材来源:2 个;上传 1 / 画板 1', @@ -697,7 +708,9 @@ export async function assertPlanningAndStatusShortcutFlow( submitChat('/criteria'); expect(await screen.findByText(/当前验收标准:/)).not.toBeNull(); const criteriaMessages = screen.getAllByText(/当前验收标准:/); - const criteriaMessage = criteriaMessages[criteriaMessages.length - 1]; + const criteriaMessage = messageBubble( + criteriaMessages[criteriaMessages.length - 1], + ); expect(criteriaMessage.textContent).toContain( 'ready:美术组 / Asset 生成首版美术素材(art-asset-plan)', ); @@ -740,7 +753,7 @@ export async function assertPlanningAndStatusShortcutFlow( submitChat('/groups'); expect(await screen.findByText(/专业组进度:/)).not.toBeNull(); const groupMessages = screen.getAllByText(/专业组进度:/); - const groupMessage = groupMessages[groupMessages.length - 1]; + const groupMessage = messageBubble(groupMessages[groupMessages.length - 1]); expect(groupMessage.textContent).toContain( '美术组:完成 0/3 · active 0 · carry 0 · ready 1', ); @@ -793,7 +806,9 @@ export async function assertPlanningAndStatusShortcutFlow( submitChat('/balance'); expect(await screen.findByText(/数值状态:/)).not.toBeNull(); const balanceMessages = screen.getAllByText(/数值状态:/); - const balanceMessage = balanceMessages[balanceMessages.length - 1]; + const balanceMessage = messageBubble( + balanceMessages[balanceMessages.length - 1], + ); expect(balanceMessage.textContent).toContain('项目:未命名游戏原型'); expect(balanceMessage.textContent).toContain( 'balance-director:Director 确定数值口径 · 待处理', @@ -874,7 +889,9 @@ export async function assertPlanningAndStatusShortcutFlow( submitChat('/budget'); expect(await screen.findByText(/运行预算:/)).not.toBeNull(); const budgetMessages = screen.getAllByText(/运行预算:/); - const budgetMessage = budgetMessages[budgetMessages.length - 1]; + const budgetMessage = messageBubble( + budgetMessages[budgetMessages.length - 1], + ); expect(budgetMessage.textContent).toContain( 'Run:run-main-shortcut-trace · passed / done · 1/3 轮 · evaluator-passed', ); @@ -923,7 +940,7 @@ export async function assertPlanningAndStatusShortcutFlow( submitChat('/qa'); expect(await screen.findByText(/质量检查:/)).not.toBeNull(); const qaMessages = screen.getAllByText(/质量检查:/); - const qaMessage = qaMessages[qaMessages.length - 1]; + const qaMessage = messageBubble(qaMessages[qaMessages.length - 1]); expect(qaMessage.textContent).toContain( 'Run:run-main-shortcut-trace · passed / done · 1/3 轮 · evaluator-passed', ); @@ -988,7 +1005,9 @@ export async function assertPlanningAndStatusShortcutFlow( submitChat('/changes'); expect(await screen.findByText(/最近变更:/)).not.toBeNull(); const changeMessages = screen.getAllByText(/最近变更:/); - const changeMessage = changeMessages[changeMessages.length - 1]; + const changeMessage = messageBubble( + changeMessages[changeMessages.length - 1], + ); expect(changeMessage.textContent).toContain( 'Run:run-main-shortcut-trace · passed / done · 1/3 轮 · evaluator-passed', ); @@ -1064,7 +1083,9 @@ export async function assertPlanningAndStatusShortcutFlow( submitChat('/review'); expect(await screen.findByText(/评审状态:/)).not.toBeNull(); const reviewMessages = screen.getAllByText(/评审状态:/); - const reviewMessage = reviewMessages[reviewMessages.length - 1]; + const reviewMessage = messageBubble( + reviewMessages[reviewMessages.length - 1], + ); expect(reviewMessage.textContent).toContain('Evaluator:通过'); expect(reviewMessage.textContent).toContain('返工焦点:暂无'); expect(reviewMessage.textContent).toContain( @@ -1150,7 +1171,9 @@ export async function assertPlanningAndStatusShortcutFlow( submitChat('/timeline'); expect(await screen.findByText(/项目时间线:/)).not.toBeNull(); const timelineMessages = screen.getAllByText(/项目时间线:/); - const timelineMessage = timelineMessages[timelineMessages.length - 1]; + const timelineMessage = messageBubble( + timelineMessages[timelineMessages.length - 1], + ); expect(timelineMessage.textContent).toContain( 'Run:run-main-shortcut-trace · passed / done', ); @@ -1209,7 +1232,9 @@ export async function assertPlanningAndStatusShortcutFlow( submitChat('/handoff'); expect(await screen.findByText(/项目交接:/)).not.toBeNull(); const handoffMessages = screen.getAllByText(/项目交接:/); - const handoffMessage = handoffMessages[handoffMessages.length - 1]; + const handoffMessage = messageBubble( + handoffMessages[handoffMessages.length - 1], + ); expect(handoffMessage.textContent).toContain( '- Run:run-main-shortcut-trace', ); @@ -1335,14 +1360,14 @@ export async function assertPlanningAndStatusShortcutFlow( }; submitChat('/todo'); const todoMessages = await screen.findAllByText(/下一轮小步:/); - const todoMessage = todoMessages[todoMessages.length - 1]; + const todoMessage = messageBubble(todoMessages[todoMessages.length - 1]); expect(todoMessage.textContent).toContain('项目:未命名游戏原型'); expect(todoMessage.textContent).toContain( 'Run:run-main-shortcut-trace · passed / done · 1/3 轮 · evaluator-passed', ); expect(todoMessage.textContent).toContain('编排下一步:preview'); expect(todoMessage.textContent).toContain( - '1. ready:美术组 / Asset 生成首版美术素材(art-asset-plan) · 待处理 · 验收:角色、场景、UI 和动画需求已映射到画板或本地资产,且至少一张首版核心素材图已生成并登记', + 'ready:美术组 / Asset 生成首版美术素材(art-asset-plan) · 待处理 · 验收:角色、场景、UI 和动画需求已映射到画板或本地资产,且至少一张首版核心素材图已生成并登记', ); expect(todoMessage.textContent).toContain( '边界:只整理下一步;不读取任务文件;不启动 run;不修改项目', @@ -1428,7 +1453,7 @@ export async function assertPlanningAndStatusShortcutFlow( }; submitChat('/plan'); const planMessages = await screen.findAllByText(/下一轮分工计划:/); - const planMessage = planMessages[planMessages.length - 1]; + const planMessage = messageBubble(planMessages[planMessages.length - 1]); expect(planMessage.textContent).toContain('项目:未命名游戏原型'); expect(planMessage.textContent).toContain( 'Run:run-main-shortcut-trace · passed / done · 1/3 轮 · evaluator-passed', @@ -1533,7 +1558,7 @@ export async function assertPlanningAndStatusShortcutFlow( }; submitChat('/guide'); const guideMessages = await screen.findAllByText(/使用导引:/); - const guideMessage = guideMessages[guideMessages.length - 1]; + const guideMessage = messageBubble(guideMessages[guideMessages.length - 1]); expect(guideMessage.textContent).toContain('项目:未命名游戏原型'); expect(guideMessage.textContent).toContain('当前阶段:可导出'); expect(guideMessage.textContent).toContain( @@ -1627,7 +1652,9 @@ export async function assertPlanningAndStatusShortcutFlow( }; submitChat('/progress'); const progressMessages = await screen.findAllByText(/项目进度:/); - const progressMessage = progressMessages[progressMessages.length - 1]; + const progressMessage = messageBubble( + progressMessages[progressMessages.length - 1], + ); expect(progressMessage.textContent).toContain('项目:未命名游戏原型'); expect(progressMessage.textContent).toContain('当前阶段:已生成'); expect(progressMessage.textContent).toMatch( diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/assert-playtest-and-release-shortcuts.ts b/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/assert-playtest-and-release-shortcuts.ts index acb85a840..2e7cf976c 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/assert-playtest-and-release-shortcuts.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/assert-playtest-and-release-shortcuts.ts @@ -1,5 +1,6 @@ import { expect, fireEvent, screen, submitChat, within } from '../../harness'; import type { PreviewShortcutInvoke } from './invoke-mock'; +import { messageBubble } from './message-bubble'; export async function assertPlaytestAndReleaseShortcutFlow( invoke: PreviewShortcutInvoke, @@ -170,7 +171,9 @@ export async function assertPlaytestAndReleaseShortcutFlow( }; submitChat('/listing'); const listingMessages = await screen.findAllByText(/作品页草稿:/); - const listingMessage = listingMessages[listingMessages.length - 1]; + const listingMessage = messageBubble( + listingMessages[listingMessages.length - 1], + ); expect(listingMessage.textContent).toContain('标题:未命名游戏原型'); expect(listingMessage.textContent).toContain( '一句话卖点:做一个厨房弹幕游戏', @@ -262,7 +265,9 @@ export async function assertPlaytestAndReleaseShortcutFlow( ).length; submitChat('/playtest'); const playtestMessages = await screen.findAllByText(/试玩状态:/); - const playtestMessage = playtestMessages[playtestMessages.length - 1]; + const playtestMessage = messageBubble( + playtestMessages[playtestMessages.length - 1], + ); expect(playtestMessage.textContent).toContain( '原型:最近 run 已通过 run-main-shortcut-trace', ); @@ -329,26 +334,26 @@ export async function assertPlaytestAndReleaseShortcutFlow( }; submitChat('/test-plan'); const testPlanMessages = await screen.findAllByText(/手动测试计划:/); - const testPlanMessage = testPlanMessages[testPlanMessages.length - 1]; + const testPlanMessage = messageBubble( + testPlanMessages[testPlanMessages.length - 1], + ); expect(testPlanMessage.textContent).toContain('项目:未命名游戏原型'); expect(testPlanMessage.textContent).toContain('目标:做一个厨房弹幕游戏'); expect(testPlanMessage.textContent).toContain( '当前证据:最近 run 已通过 run-main-shortcut-trace;预览 未启动;入口 未见 trace 产物', ); expect(testPlanMessage.textContent).toContain( - '1. 启动预览:/run 后确认首屏不空白', + '启动预览:/run 后确认首屏不空白', ); expect(testPlanMessage.textContent).toContain( - '2. 30 秒理解:目标、操作、得分/失败和重开可见', + '30 秒理解:目标、操作、得分/失败和重开可见', ); expect(testPlanMessage.textContent).toContain( - '3. 输入验证:键盘/点击/触屏至少一种可完成核心动作', + '输入验证:键盘/点击/触屏至少一种可完成核心动作', ); + expect(testPlanMessage.textContent).toContain('结局验证:胜利或失败后可重开'); expect(testPlanMessage.textContent).toContain( - '4. 结局验证:胜利或失败后可重开', - ); - expect(testPlanMessage.textContent).toContain( - '5. 回归检查:/mobile;/accessibility;/performance;/audio', + '回归检查:/mobile;/accessibility;/performance;/audio', ); expect(testPlanMessage.textContent).toContain( 'preview-readiness:程序组 / Preview 执行静态自检 · 待处理', @@ -432,7 +437,9 @@ export async function assertPlaytestAndReleaseShortcutFlow( }; submitChat('/audience'); const audienceMessages = await screen.findAllByText(/首批试玩对象:/); - const audienceMessage = audienceMessages[audienceMessages.length - 1]; + const audienceMessage = messageBubble( + audienceMessages[audienceMessages.length - 1], + ); expect(audienceMessage.textContent).toContain('项目:未命名游戏原型'); expect(audienceMessage.textContent).toContain('目标:做一个厨房弹幕游戏'); expect(audienceMessage.textContent).toContain( @@ -545,7 +552,9 @@ export async function assertPlaytestAndReleaseShortcutFlow( }; submitChat('/invite'); const inviteMessages = await screen.findAllByText(/试玩邀请:/); - const inviteMessage = inviteMessages[inviteMessages.length - 1]; + const inviteMessage = messageBubble( + inviteMessages[inviteMessages.length - 1], + ); expect(inviteMessage.textContent).toContain('项目:未命名游戏原型'); expect(inviteMessage.textContent).toContain('目标:做一个厨房弹幕游戏'); expect(inviteMessage.textContent).toContain( @@ -723,7 +732,9 @@ export async function assertPlaytestAndReleaseShortcutFlow( }; submitChat('/survey'); const surveyMessages = await screen.findAllByText(/试玩问卷:/); - const surveyMessage = surveyMessages[surveyMessages.length - 1]; + const surveyMessage = messageBubble( + surveyMessages[surveyMessages.length - 1], + ); expect(surveyMessage.textContent).toContain('项目:未命名游戏原型'); expect(surveyMessage.textContent).toContain('目标:做一个厨房弹幕游戏'); expect(surveyMessage.textContent).toContain( @@ -830,7 +841,7 @@ export async function assertPlaytestAndReleaseShortcutFlow( }; submitChat('/cover'); const coverMessages = await screen.findAllByText(/封面与缩略图:/); - const coverMessage = coverMessages[coverMessages.length - 1]; + const coverMessage = messageBubble(coverMessages[coverMessages.length - 1]); expect(coverMessage.textContent).toContain('项目:未命名游戏原型'); expect(coverMessage.textContent).toContain( '当前状态:最近 run 已通过 run-main-shortcut-trace;预览 未启动', @@ -939,7 +950,9 @@ export async function assertPlaytestAndReleaseShortcutFlow( }; submitChat('/screenshots'); const screenshotMessages = await screen.findAllByText(/宣传截图:/); - const screenshotMessage = screenshotMessages[screenshotMessages.length - 1]; + const screenshotMessage = messageBubble( + screenshotMessages[screenshotMessages.length - 1], + ); expect(screenshotMessage.textContent).toContain('项目:未命名游戏原型'); expect(screenshotMessage.textContent).toContain( '当前状态:最近 run 已通过 run-main-shortcut-trace;预览 未启动', @@ -1048,7 +1061,9 @@ export async function assertPlaytestAndReleaseShortcutFlow( }; submitChat('/trailer'); const trailerMessages = await screen.findAllByText(/试玩短视频:/); - const trailerMessage = trailerMessages[trailerMessages.length - 1]; + const trailerMessage = messageBubble( + trailerMessages[trailerMessages.length - 1], + ); expect(trailerMessage.textContent).toContain('项目:未命名游戏原型'); expect(trailerMessage.textContent).toContain('目标:做一个厨房弹幕游戏'); expect(trailerMessage.textContent).toContain( @@ -1158,7 +1173,7 @@ export async function assertPlaytestAndReleaseShortcutFlow( }; submitChat('/faq'); const faqMessages = await screen.findAllByText(/试玩 FAQ:/); - const faqMessage = faqMessages[faqMessages.length - 1]; + const faqMessage = messageBubble(faqMessages[faqMessages.length - 1]); expect(faqMessage.textContent).toContain('项目:未命名游戏原型'); expect(faqMessage.textContent).toContain('目标:做一个厨房弹幕游戏'); expect(faqMessage.textContent).toContain( @@ -1263,7 +1278,7 @@ export async function assertPlaytestAndReleaseShortcutFlow( }; submitChat('/post'); const postMessages = await screen.findAllByText(/社区发布文案:/); - const postMessage = postMessages[postMessages.length - 1]; + const postMessage = messageBubble(postMessages[postMessages.length - 1]); expect(postMessage.textContent).toContain('项目:未命名游戏原型'); expect(postMessage.textContent).toContain('一句话:做一个厨房弹幕游戏'); expect(postMessage.textContent).toContain( @@ -1374,7 +1389,7 @@ export async function assertPlaytestAndReleaseShortcutFlow( }; submitChat('/store'); const storeMessages = await screen.findAllByText(/上架资料:/); - const storeMessage = storeMessages[storeMessages.length - 1]; + const storeMessage = messageBubble(storeMessages[storeMessages.length - 1]); expect(storeMessage.textContent).toContain('项目:未命名游戏原型'); expect(storeMessage.textContent).toContain('一句话:做一个厨房弹幕游戏'); expect(storeMessage.textContent).toContain( @@ -1484,7 +1499,9 @@ export async function assertPlaytestAndReleaseShortcutFlow( }; submitChat('/media-kit'); const mediaKitMessages = await screen.findAllByText(/媒体资料包:/); - const mediaKitMessage = mediaKitMessages[mediaKitMessages.length - 1]; + const mediaKitMessage = messageBubble( + mediaKitMessages[mediaKitMessages.length - 1], + ); expect(mediaKitMessage.textContent).toContain('项目:未命名游戏原型'); expect(mediaKitMessage.textContent).toContain('一句话:做一个厨房弹幕游戏'); expect(mediaKitMessage.textContent).toContain( @@ -1591,8 +1608,9 @@ export async function assertPlaytestAndReleaseShortcutFlow( }; submitChat('/release-notes'); const releaseNotesMessages = await screen.findAllByText(/试玩更新说明:/); - const releaseNotesMessage = - releaseNotesMessages[releaseNotesMessages.length - 1]; + const releaseNotesMessage = messageBubble( + releaseNotesMessages[releaseNotesMessages.length - 1], + ); expect(releaseNotesMessage.textContent).toContain('项目:未命名游戏原型'); expect(releaseNotesMessage.textContent).toContain( '一句话:做一个厨房弹幕游戏', @@ -1709,7 +1727,9 @@ export async function assertPlaytestAndReleaseShortcutFlow( }; submitChat('/known-issues'); const knownIssueMessages = await screen.findAllByText(/已知问题清单:/); - const knownIssueMessage = knownIssueMessages[knownIssueMessages.length - 1]; + const knownIssueMessage = messageBubble( + knownIssueMessages[knownIssueMessages.length - 1], + ); expect(knownIssueMessage.textContent).toContain('项目:未命名游戏原型'); expect(knownIssueMessage.textContent).toContain( '当前状态:run-main-shortcut-trace · passed / done · 1/3 轮 · evaluator-passed;预览 未启动', diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/assert-project-and-design-shortcuts.ts b/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/assert-project-and-design-shortcuts.ts index 6da7eacc0..4d0019dff 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/assert-project-and-design-shortcuts.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/assert-project-and-design-shortcuts.ts @@ -1,5 +1,6 @@ import { expect, fireEvent, screen, submitChat, within } from '../../harness'; import type { PreviewShortcutInvoke } from './invoke-mock'; +import { messageBubble } from './message-bubble'; export async function assertProjectAndDesignShortcutFlow( invoke: PreviewShortcutInvoke, @@ -43,11 +44,12 @@ export async function assertProjectAndDesignShortcutFlow( ), ).toBe(true); submitChat('/agent-conversations'); - expect( - await screen.findByText( - /Agent 对话读取命令:[\s\S]*美术组 \/ Asset · 生成首版美术素材:\/read \.agent\/conversations\/agents\/art-asset-plan\.jsonl/, - ), - ).not.toBeNull(); + const agentConversationMessage = + await screen.findByText('Agent 对话读取命令:'); + const agentConversationBubble = messageBubble(agentConversationMessage); + expect(agentConversationBubble.textContent).toContain( + '美术组 / Asset · 生成首版美术素材:/read .agent/conversations/agents/art-asset-plan.jsonl', + ); fireEvent.click(screen.getByRole('button', { name: '读取拆解创作方向对话' })); expect(screen.getByLabelText('创作想法')).toHaveProperty( 'value', @@ -60,11 +62,12 @@ export async function assertProjectAndDesignShortcutFlow( }), ); submitChat('/agent-memories'); - expect( - await screen.findByText( - /Agent 私有记忆读取命令:[\s\S]*美术组 \/ Asset · 生成首版美术素材:\/read memory\/agents\/art\/asset\.md/, - ), - ).not.toBeNull(); + const agentMemoryMessage = + await screen.findByText('Agent 私有记忆读取命令:'); + const agentMemoryBubble = messageBubble(agentMemoryMessage); + expect(agentMemoryBubble.textContent).toContain( + '美术组 / Asset · 生成首版美术素材:/read memory/agents/art/asset.md', + ); fireEvent.click(screen.getByRole('button', { name: '读取拆解创作方向记忆' })); expect(screen.getByLabelText('创作想法')).toHaveProperty( 'value', @@ -126,7 +129,7 @@ export async function assertProjectAndDesignShortcutFlow( submitChat('/goal'); expect(await screen.findByText(/创作目标:/)).not.toBeNull(); const goalMessages = screen.getAllByText(/创作目标:/); - const goalMessage = goalMessages[goalMessages.length - 1]; + const goalMessage = messageBubble(goalMessages[goalMessages.length - 1]); expect(goalMessage.textContent).toContain('Manifest:做一个厨房弹幕游戏'); expect(goalMessage.textContent).toContain( 'Run:run-main-shortcut-trace · passed / done · 1/3 轮 · evaluator-passed', @@ -184,7 +187,7 @@ export async function assertProjectAndDesignShortcutFlow( }; submitChat('/spec'); const specMessages = await screen.findAllByText(/创作规格包:/); - const specMessage = specMessages[specMessages.length - 1]; + const specMessage = messageBubble(specMessages[specMessages.length - 1]); expect(specMessage.textContent).toContain('项目:未命名游戏原型'); expect(specMessage.textContent).toContain('目标:做一个厨房弹幕游戏'); expect(specMessage.textContent).toContain( @@ -284,7 +287,7 @@ export async function assertProjectAndDesignShortcutFlow( submitChat('/mvp'); expect(await screen.findByText(/MVP 范围:/)).not.toBeNull(); const mvpMessages = screen.getAllByText(/MVP 范围:/); - const mvpMessage = mvpMessages[mvpMessages.length - 1]; + const mvpMessage = messageBubble(mvpMessages[mvpMessages.length - 1]); expect(mvpMessage.textContent).toContain('项目:未命名游戏原型'); expect(mvpMessage.textContent).toContain('目标:做一个厨房弹幕游戏'); expect(mvpMessage.textContent).toContain( @@ -380,7 +383,7 @@ export async function assertProjectAndDesignShortcutFlow( }; submitChat('/pitch'); const pitchMessages = await screen.findAllByText(/试玩定位:/); - const pitchMessage = pitchMessages[pitchMessages.length - 1]; + const pitchMessage = messageBubble(pitchMessages[pitchMessages.length - 1]); expect(pitchMessage.textContent).toContain('项目:未命名游戏原型'); expect(pitchMessage.textContent).toContain('一句话:做一个厨房弹幕游戏'); expect(pitchMessage.textContent).toContain( @@ -475,7 +478,7 @@ export async function assertProjectAndDesignShortcutFlow( }; submitChat('/demo'); const demoMessages = await screen.findAllByText(/试玩讲解稿:/); - const demoMessage = demoMessages[demoMessages.length - 1]; + const demoMessage = messageBubble(demoMessages[demoMessages.length - 1]); expect(demoMessage.textContent).toContain('项目:未命名游戏原型'); expect(demoMessage.textContent).toContain( '30 秒开场:这是《未命名游戏原型》,目标是做一个厨房弹幕游戏', @@ -575,7 +578,9 @@ export async function assertProjectAndDesignShortcutFlow( submitChat('/rules'); expect(await screen.findByText(/玩法操作:/)).not.toBeNull(); const controlMessages = screen.getAllByText(/玩法操作:/); - const controlMessage = controlMessages[controlMessages.length - 1]; + const controlMessage = messageBubble( + controlMessages[controlMessages.length - 1], + ); expect(controlMessage.textContent).toContain('项目:未命名游戏原型'); expect(controlMessage.textContent).toContain('目标:做一个厨房弹幕游戏'); expect(controlMessage.textContent).toContain( @@ -679,7 +684,9 @@ export async function assertProjectAndDesignShortcutFlow( }; submitChat('/tutorial'); const tutorialMessages = await screen.findAllByText(/新手引导:/); - const tutorialMessage = tutorialMessages[tutorialMessages.length - 1]; + const tutorialMessage = messageBubble( + tutorialMessages[tutorialMessages.length - 1], + ); expect(tutorialMessage.textContent).toContain('项目:未命名游戏原型'); expect(tutorialMessage.textContent).toContain('首屏目标:做一个厨房弹幕游戏'); expect(tutorialMessage.textContent).toContain( @@ -782,7 +789,9 @@ export async function assertProjectAndDesignShortcutFlow( }; submitChat('/mobile'); const mobileMessages = await screen.findAllByText(/移动试玩:/); - const mobileMessage = mobileMessages[mobileMessages.length - 1]; + const mobileMessage = messageBubble( + mobileMessages[mobileMessages.length - 1], + ); expect(mobileMessage.textContent).toContain('项目:未命名游戏原型'); expect(mobileMessage.textContent).toContain('目标:做一个厨房弹幕游戏'); expect(mobileMessage.textContent).toContain( @@ -891,8 +900,9 @@ export async function assertProjectAndDesignShortcutFlow( }; submitChat('/compatibility'); const compatibilityMessages = await screen.findAllByText(/兼容性说明:/); - const compatibilityMessage = - compatibilityMessages[compatibilityMessages.length - 1]; + const compatibilityMessage = messageBubble( + compatibilityMessages[compatibilityMessages.length - 1], + ); expect(compatibilityMessage.textContent).toContain('项目:未命名游戏原型'); expect(compatibilityMessage.textContent).toContain( '目标:做一个厨房弹幕游戏', @@ -1006,8 +1016,9 @@ export async function assertProjectAndDesignShortcutFlow( }; submitChat('/accessibility'); const accessibilityMessages = await screen.findAllByText(/可读性与无障碍:/); - const accessibilityMessage = - accessibilityMessages[accessibilityMessages.length - 1]; + const accessibilityMessage = messageBubble( + accessibilityMessages[accessibilityMessages.length - 1], + ); expect(accessibilityMessage.textContent).toContain('项目:未命名游戏原型'); expect(accessibilityMessage.textContent).toContain( '目标:做一个厨房弹幕游戏', @@ -1129,8 +1140,9 @@ export async function assertProjectAndDesignShortcutFlow( }; submitChat('/localization'); const localizationMessages = await screen.findAllByText(/本地化与文案:/); - const localizationMessage = - localizationMessages[localizationMessages.length - 1]; + const localizationMessage = messageBubble( + localizationMessages[localizationMessages.length - 1], + ); expect(localizationMessage.textContent).toContain('项目:未命名游戏原型'); expect(localizationMessage.textContent).toContain('目标:做一个厨房弹幕游戏'); expect(localizationMessage.textContent).toContain( @@ -1259,8 +1271,9 @@ export async function assertProjectAndDesignShortcutFlow( }; submitChat('/performance'); const performanceMessages = await screen.findAllByText(/性能与加载:/); - const performanceMessage = - performanceMessages[performanceMessages.length - 1]; + const performanceMessage = messageBubble( + performanceMessages[performanceMessages.length - 1], + ); expect(performanceMessage.textContent).toContain('项目:未命名游戏原型'); expect(performanceMessage.textContent).toContain('目标:做一个厨房弹幕游戏'); expect(performanceMessage.textContent).toContain( @@ -1376,7 +1389,9 @@ export async function assertProjectAndDesignShortcutFlow( }; submitChat('/polish'); const polishMessages = await screen.findAllByText(/试玩前打磨:/); - const polishMessage = polishMessages[polishMessages.length - 1]; + const polishMessage = messageBubble( + polishMessages[polishMessages.length - 1], + ); expect(polishMessage.textContent).toContain('项目:未命名游戏原型'); expect(polishMessage.textContent).toContain('目标:做一个厨房弹幕游戏'); expect(polishMessage.textContent).toContain( @@ -1488,7 +1503,9 @@ export async function assertProjectAndDesignShortcutFlow( }; submitChat('/credits'); const creditMessages = await screen.findAllByText(/素材署名:/); - const creditMessage = creditMessages[creditMessages.length - 1]; + const creditMessage = messageBubble( + creditMessages[creditMessages.length - 1], + ); expect(creditMessage.textContent).toContain('当前资产:2 个'); expect(creditMessage.textContent).toContain('来源分布:上传 1 / 画板 1'); expect(creditMessage.textContent).toContain( diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/assert-project-tools-and-preview.ts b/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/assert-project-tools-and-preview.ts index ce71e30ed..408b6041e 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/assert-project-tools-and-preview.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/assert-project-tools-and-preview.ts @@ -7,6 +7,7 @@ import { within, } from '../../harness'; import type { PreviewShortcutInvoke } from './invoke-mock'; +import { messageBubble } from './message-bubble'; export async function assertProjectToolsAndPreviewFlow( invoke: PreviewShortcutInvoke, @@ -36,7 +37,9 @@ export async function assertProjectToolsAndPreviewFlow( }; submitChat('/feedback'); const feedbackMessages = await screen.findAllByText(/试玩反馈:/); - const feedbackMessage = feedbackMessages[feedbackMessages.length - 1]; + const feedbackMessage = messageBubble( + feedbackMessages[feedbackMessages.length - 1], + ); expect(feedbackMessage.textContent).toContain( 'Run:run-main-shortcut-trace · passed / done · 1/3 轮 · evaluator-passed', ); @@ -125,7 +128,9 @@ export async function assertProjectToolsAndPreviewFlow( }; submitChat('/retention'); const retentionMessages = await screen.findAllByText(/复玩观察:/); - const retentionMessage = retentionMessages[retentionMessages.length - 1]; + const retentionMessage = messageBubble( + retentionMessages[retentionMessages.length - 1], + ); expect(retentionMessage.textContent).toContain('项目:未命名游戏原型'); expect(retentionMessage.textContent).toContain('目标:做一个厨房弹幕游戏'); expect(retentionMessage.textContent).toContain( @@ -233,7 +238,7 @@ export async function assertProjectToolsAndPreviewFlow( }; submitChat('/share'); const shareMessages = await screen.findAllByText(/试玩交付:/); - const shareMessage = shareMessages[shareMessages.length - 1]; + const shareMessage = messageBubble(shareMessages[shareMessages.length - 1]); expect(shareMessage.textContent).toContain('项目:未命名游戏原型'); expect(shareMessage.textContent).toContain('目录:/tmp/authorized-game'); expect(shareMessage.textContent).toContain( @@ -438,11 +443,11 @@ export async function assertProjectToolsAndPreviewFlow( expect(composerInput).toHaveProperty('value', '/read game/index.html'); await waitFor(() => expect(document.activeElement).toBe(composerInput)); submitChat('/run-artifacts'); - expect( - await screen.findByText( - /最近 Run 产物读取命令:[\s\S]*exports\/README\.md · 128B · fnv1a64:exports:\/read exports\/README\.md/, - ), - ).not.toBeNull(); + const runArtifactsHeading = + await screen.findByText('最近 Run 产物读取命令:'); + expect(messageBubble(runArtifactsHeading).textContent).toContain( + 'exports/README.md · 128B · fnv1a64:exports:/read exports/README.md', + ); fireEvent.click(screen.getByRole('button', { name: '读取首个 Run 产物' })); expect(composerInput).toHaveProperty('value', '/read exports/README.md'); expect(invoke).not.toHaveBeenCalledWith( @@ -457,8 +462,9 @@ export async function assertProjectToolsAndPreviewFlow( submitChat('/passes'); const passArtifactMessages = await screen.findAllByText(/Agent 轮次产物读取命令:/); - const passArtifactMessage = - passArtifactMessages[passArtifactMessages.length - 1]; + const passArtifactMessage = messageBubble( + passArtifactMessages[passArtifactMessages.length - 1], + ); expect(passArtifactMessage.textContent).toContain( '.agent/passes/pass-1/agenda.md · 96B · fnv1a64:agenda', ); @@ -564,13 +570,12 @@ export async function assertProjectToolsAndPreviewFlow( expect(screen.getByText(/下一步:设计实现组 \/ Director/)).not.toBeNull(); fireEvent.click(screen.getByRole('button', { name: 'Trace' })); - expect( - await screen.findByText( - (content) => - content.trimStart().startsWith('Run:run-main-shortcut-trace') && - content.includes('产物快照:'), - ), - ).not.toBeNull(); + const traceHeading = await screen.findByText( + (content, element) => + element?.tagName === 'P' && + content.trimStart().startsWith('Run:run-main-shortcut-trace'), + ); + expect(messageBubble(traceHeading).textContent).toContain('产物快照:'); expect(screen.getByText(/产物快照:/)).not.toBeNull(); expect( screen.getByText(/exports\/README\.md · fnv1a64:exports/), diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/message-bubble.ts b/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/message-bubble.ts new file mode 100644 index 000000000..60bd9e43b --- /dev/null +++ b/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/message-bubble.ts @@ -0,0 +1,7 @@ +export function messageBubble(element: Element): HTMLElement { + const bubble = element.closest('.message'); + if (!bubble) { + throw new Error('Expected chat message bubble'); + } + return bubble; +} diff --git a/apps/ai-game-creator-shell/tests/appSurface/runtime-settings.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/runtime-settings.suite.ts index 90bdc35b3..b3c9142a3 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/runtime-settings.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/runtime-settings.suite.ts @@ -692,9 +692,16 @@ export function registerRuntimeSettingsTests() { }) => void) | undefined; let readCount = 0; + let modelReadResolved = false; const invoke = vi.fn((command: string) => { if (command === 'read_game_creator_app_config') { readCount += 1; + // 首页模型选择器会在挂载时读取一次配置;让首次读取立即完成, + // 以免一直处于 pending 干扰运行时配置对话框的读取计数。 + if (!modelReadResolved) { + modelReadResolved = true; + return Promise.resolve({ config: { selectedModelId: 'quality' } }); + } return new Promise((resolve) => { resolveRead = resolve as typeof resolveRead; }); @@ -704,6 +711,8 @@ export function registerRuntimeSettingsTests() { window.__TAURI__ = { core: { invoke } }; renderLauncherAt('/?launcher'); + // 首页模型选择器挂载后会在目录请求之后读取一次配置,等它完成再打开对话框。 + await waitFor(() => expect(readCount).toBe(1)); fireEvent.click(screen.getByRole('button', { name: '配置' })); expect( @@ -723,10 +732,13 @@ export function registerRuntimeSettingsTests() { true, ); + // 首页模型选择器挂载时会读取一次配置(上面已让首次读取立即完成), + // 这里只校验:对话框处于「正在读取」时点击读取/保存不会新增读取请求。 + const readsWhileReading = readCount; fireEvent.click(screen.getByRole('button', { name: '读取' })); fireEvent.click(screen.getByRole('button', { name: '保存' })); - expect(readCount).toBe(1); + expect(readCount).toBe(readsWhileReading); await act(async () => { resolveRead?.({ path: '/home/test/AppData/game-creator.config.json', diff --git a/apps/ai-game-creator-shell/tests/assetCanvasSurface.test.tsx b/apps/ai-game-creator-shell/tests/assetCanvasSurface.test.tsx index 10ff7a0c7..8e69a5476 100644 --- a/apps/ai-game-creator-shell/tests/assetCanvasSurface.test.tsx +++ b/apps/ai-game-creator-shell/tests/assetCanvasSurface.test.tsx @@ -11,6 +11,7 @@ import type { ImageCanvasHostScope, ImageCanvasProjectPort, } from '@genarrative/image-canvas-core'; +import { canvasViewportToWorldTransform } from '@genarrative/image-canvas-react'; import { act, fireEvent, @@ -1125,7 +1126,7 @@ describe('Tauri 素材创作无限画布独立 Surface', () => { expect( document.querySelector('.genarrative-image-canvas__world') ?.style.transform, - ).toBe('translate(-2570px, -2495px) scale(1)'); + ).toBe(canvasViewportToWorldTransform({ x: -2570, y: -2495, scale: 1 })); expect(screen.getByText('已定位当前最终图')).toBeTruthy(); }); @@ -1170,7 +1171,7 @@ describe('Tauri 素材创作无限画布独立 Surface', () => { expect( document.querySelector('.genarrative-image-canvas__world') ?.style.transform, - ).toBe('translate(-2570px, -2495px) scale(1)'); + ).toBe(canvasViewportToWorldTransform({ x: -2570, y: -2495, scale: 1 })); expect(screen.getByText('已定位当前最终图')).toBeTruthy(); fireEvent.click(layerButton); @@ -2062,7 +2063,9 @@ describe('Tauri 素材创作无限画布独立 Surface', () => { clientY: 60, }); await waitFor(() => - expect(world.style.transform).toBe('translate(30px, 30px) scale(0.5)'), + expect(world.style.transform).toBe( + canvasViewportToWorldTransform({ x: 30, y: 30, scale: 0.5 }), + ), ); fireEvent.pointerMove(window, { pointerId: 16, @@ -2070,7 +2073,9 @@ describe('Tauri 素材创作无限画布独立 Surface', () => { clientY: 30, }); await waitFor(() => - expect(world.style.transform).toBe('translate(0px, 0px) scale(0.5)'), + expect(world.style.transform).toBe( + canvasViewportToWorldTransform({ x: 0, y: 0, scale: 0.5 }), + ), ); fireEvent.pointerUp(window, { pointerId: 16, @@ -2315,7 +2320,9 @@ describe('Tauri 素材创作无限画布独立 Surface', () => { const world = document.querySelector( '.genarrative-image-canvas__world', ) as HTMLElement; - expect(world.style.transform).toBe('translate(0px, 0px) scale(0.5)'); + expect(world.style.transform).toBe( + canvasViewportToWorldTransform({ x: 0, y: 0, scale: 0.5 }), + ); fireEvent.wheel(viewport, { deltaX: 4, @@ -2324,7 +2331,9 @@ describe('Tauri 素材创作无限画布独立 Surface', () => { clientY: 80, }); await waitFor(() => - expect(world.style.transform).toBe('translate(-4px, -10px) scale(0.5)'), + expect(world.style.transform).toBe( + canvasViewportToWorldTransform({ x: -4, y: -10, scale: 0.5 }), + ), ); fireEvent.wheel(viewport, { deltaX: 0, @@ -2334,7 +2343,9 @@ describe('Tauri 素材创作无限画布独立 Surface', () => { shiftKey: true, }); await waitFor(() => - expect(world.style.transform).toBe('translate(-14px, -10px) scale(0.5)'), + expect(world.style.transform).toBe( + canvasViewportToWorldTransform({ x: -14, y: -10, scale: 0.5 }), + ), ); fireEvent.wheel(viewport, { deltaX: 0, @@ -2344,7 +2355,13 @@ describe('Tauri 素材创作无限画布独立 Surface', () => { ctrlKey: true, }); await waitFor(() => - expect(world.style.transform).not.toContain('scale(0.5)'), + expect(world.style.transform).toBe( + canvasViewportToWorldTransform({ + x: 100 - 228 * 0.55, + y: 80 - 180 * 0.55, + scale: 0.55, + }), + ), ); }); diff --git a/apps/ai-game-creator-shell/tests/conversationModelSelect.test.tsx b/apps/ai-game-creator-shell/tests/conversationModelSelect.test.tsx index fd7e49b36..25b1372f8 100644 --- a/apps/ai-game-creator-shell/tests/conversationModelSelect.test.tsx +++ b/apps/ai-game-creator-shell/tests/conversationModelSelect.test.tsx @@ -1,23 +1,33 @@ // @vitest-environment jsdom import { + act, cleanup, fireEvent, render, screen, waitFor, + within, } from '@testing-library/react'; +import { createRef } from 'react'; import { afterEach, beforeEach, expect, test, vi } from 'vitest'; import { resolveTauriInvoke } from '../src/app/tauri'; -import { ConversationModelSelect } from '../src/features/project-workspace/ConversationModelSelect'; +import { + ConversationModelSelect, + type ConversationModelSelectHandle, +} from '../src/features/project-workspace/ConversationModelSelect'; import { loadClientLlmModels } from '../src/services/clientApi'; +import { resetLlmModelCatalogCacheForTest } from '../src/services/llmModelCatalog'; vi.mock('../src/app/tauri', () => ({ resolveTauriInvoke: vi.fn() })); vi.mock('../src/services/clientApi', () => ({ loadClientLlmModels: vi.fn() })); const invoke = vi.fn(); +let savedModelId = 'quality'; +let savedModelIsDefault = true; beforeEach(() => { vi.clearAllMocks(); + resetLlmModelCatalogCacheForTest(); vi.mocked(resolveTauriInvoke).mockReturnValue(invoke); vi.mocked(loadClientLlmModels).mockResolvedValue({ defaultModelId: 'quality', @@ -25,13 +35,22 @@ beforeEach(() => { { id: 'quality', displayName: '高质量' }, { id: 'fast', displayName: '快速' }, ], + revision: 1, + }); + savedModelId = 'quality'; + savedModelIsDefault = true; + invoke.mockImplementation(async (command, input) => { + if (command === 'select_game_creator_model') { + savedModelId = String(input.modelId); + savedModelIsDefault = Boolean(input.isDefault); + } + return { + config: { + selectedModelId: savedModelId, + selectedModelIsDefault: savedModelIsDefault, + }, + }; }); - invoke.mockImplementation(async (command, input) => ({ - config: { - selectedModelId: - command === 'select_game_creator_model' ? input.modelId : 'quality', - }, - })); }); afterEach(cleanup); @@ -45,6 +64,7 @@ test('only displays aliases and persists selection through the native command', await waitFor(() => expect(invoke).toHaveBeenCalledWith('select_game_creator_model', { modelId: 'fast', + isDefault: false, }), ); await waitFor(() => expect(onReady).toHaveBeenLastCalledWith(true)); @@ -53,15 +73,31 @@ test('only displays aliases and persists selection through the native command', ).toContain('快速'); }); -test('does not mark a removed selection ready or expose the old identifier', async () => { - invoke.mockResolvedValue({ - config: { selectedModelId: 'private-old-model' }, - }); +test('falls back to the default model when the saved selection was removed', async () => { + invoke.mockImplementation(async (command, input) => ({ + config: { + selectedModelId: + command === 'select_game_creator_model' + ? (input as { modelId: string }).modelId + : 'private-old-model', + selectedModelIsDefault: + command === 'select_game_creator_model' + ? Boolean((input as { isDefault: boolean }).isDefault) + : false, + }, + })); const onReady = vi.fn(); render(); - await screen.findByText('请选择可用模型'); - expect(onReady).toHaveBeenLastCalledWith(false); + await screen.findByText('所选模型已停用,已切换为默认模型'); + await waitFor(() => expect(onReady).toHaveBeenLastCalledWith(true)); expect(screen.queryByText('private-old-model')).toBeNull(); + expect(invoke).toHaveBeenCalledWith('select_game_creator_model', { + modelId: 'quality', + isDefault: true, + }); + expect( + screen.getByRole('button', { name: '对话模型' }).textContent, + ).toContain('高质量'); }); test('failed catalog can be refreshed without enabling submission', async () => { @@ -78,7 +114,9 @@ test('failed catalog can be refreshed without enabling submission', async () => test('a failed save keeps submission unavailable', async () => { invoke.mockImplementation(async (command) => { if (command === 'select_game_creator_model') throw new Error('disk full'); - return { config: { selectedModelId: 'quality' } }; + return { + config: { selectedModelId: 'quality', selectedModelIsDefault: true }, + }; }); const onReady = vi.fn(); render(); @@ -88,3 +126,347 @@ test('a failed save keeps submission unavailable', async () => { await screen.findByText('模型选择保存失败'); expect(onReady).toHaveBeenLastCalledWith(false); }); + +test('closes the menu when clicking outside', async () => { + const onReady = vi.fn(); + render(); + await screen.findByRole('button', { name: '对话模型' }); + fireEvent.click(screen.getByRole('button', { name: '对话模型' })); + expect(screen.getByRole('option', { name: '快速' })).not.toBeNull(); + + fireEvent.mouseDown(document.body); + await waitFor(() => + expect(screen.queryByRole('option', { name: '快速' })).toBeNull(), + ); +}); + +test('closes the menu on Escape', async () => { + const onReady = vi.fn(); + render(); + await screen.findByRole('button', { name: '对话模型' }); + fireEvent.click(screen.getByRole('button', { name: '对话模型' })); + expect(screen.getByRole('option', { name: '快速' })).not.toBeNull(); + + fireEvent.keyDown(document, { key: 'Escape' }); + await waitFor(() => + expect(screen.queryByRole('option', { name: '快速' })).toBeNull(), + ); +}); + +test('marks the default model in the menu', async () => { + const onReady = vi.fn(); + render(); + await screen.findByRole('button', { name: '对话模型' }); + fireEvent.click(screen.getByRole('button', { name: '对话模型' })); + + const qualityOption = screen.getByRole('option', { name: /高质量/ }); + expect(within(qualityOption).getByText('默认')).not.toBeNull(); + const fastOption = screen.getByRole('option', { name: '快速' }); + expect(within(fastOption).queryByText('默认')).toBeNull(); +}); + +test('keeps model options disabled while a selection save is in flight', async () => { + let resolveSave: ((value: unknown) => void) | undefined; + invoke.mockImplementation(async (command) => { + if (command === 'select_game_creator_model') { + return new Promise((resolve) => { + resolveSave = resolve; + }); + } + return { + config: { selectedModelId: 'quality', selectedModelIsDefault: true }, + }; + }); + const onReady = vi.fn(); + render(); + await screen.findByRole('button', { name: '对话模型' }); + await waitFor(() => expect(onReady).toHaveBeenLastCalledWith(true)); + + fireEvent.click(screen.getByRole('button', { name: '对话模型' })); + await screen.findByRole('option', { name: '快速' }); + fireEvent.click(screen.getByRole('option', { name: '快速' })); + + // 保存期间重新打开菜单:可以查看,但选项应禁用,避免并发选择。 + fireEvent.click(screen.getByRole('button', { name: '对话模型' })); + expect(screen.getByRole('option', { name: '快速' })).toHaveProperty( + 'disabled', + true, + ); + expect(screen.getByRole('option', { name: /高质量/ })).toHaveProperty( + 'disabled', + true, + ); + expect(onReady).toHaveBeenLastCalledWith(false); + + // 配置写回按队列落盘,保存请求在下一个微任务才发出。 + await waitFor(() => expect(resolveSave).toBeDefined()); + resolveSave?.({ config: { selectedModelId: 'fast' } }); + await waitFor(() => expect(onReady).toHaveBeenLastCalledWith(true)); + expect(screen.getByRole('option', { name: '快速' })).toHaveProperty( + 'disabled', + false, + ); +}); + +test('keeps the last good catalog when a background refresh fails', async () => { + const onReady = vi.fn(); + render(); + await waitFor(() => expect(onReady).toHaveBeenLastCalledWith(true)); + vi.mocked(loadClientLlmModels).mockRejectedValueOnce(new Error('offline')); + fireEvent(window, new Event('focus')); + await screen.findByText('模型列表加载失败'); + fireEvent.click(screen.getByRole('button', { name: '对话模型' })); + expect(screen.getByRole('option', { name: /高质量/ })).not.toBeNull(); + expect(onReady).toHaveBeenLastCalledWith(true); +}); + +test('applies a new catalog revision on focus', async () => { + const onReady = vi.fn(); + render(); + await waitFor(() => expect(onReady).toHaveBeenLastCalledWith(true)); + vi.mocked(loadClientLlmModels).mockResolvedValue({ + defaultModelId: 'quality', + models: [ + { id: 'quality', displayName: '高质量' }, + { id: 'fast', displayName: '快速' }, + { id: 'vision', displayName: '视觉' }, + ], + revision: 2, + }); + fireEvent(window, new Event('focus')); + await waitFor(() => expect(loadClientLlmModels).toHaveBeenCalledTimes(2)); + fireEvent.click(screen.getByRole('button', { name: '对话模型' })); + expect(screen.getByRole('option', { name: '视觉' })).not.toBeNull(); +}); + +test('pre-send validation falls back when the selected model is disabled', async () => { + let savedModelId = 'quality'; + let savedModelIsDefault = false; + invoke.mockImplementation(async (command, input) => { + if (command === 'select_game_creator_model') { + savedModelId = String(input.modelId); + savedModelIsDefault = Boolean(input.isDefault); + } + return { + config: { + selectedModelId: savedModelId, + selectedModelIsDefault: savedModelIsDefault, + }, + }; + }); + const ref = createRef(); + const onReady = vi.fn(); + render( + , + ); + await waitFor(() => expect(onReady).toHaveBeenLastCalledWith(true)); + fireEvent.click(screen.getByRole('button', { name: '对话模型' })); + fireEvent.click(screen.getByRole('option', { name: '快速' })); + await waitFor(() => expect(savedModelId).toBe('fast')); + vi.mocked(loadClientLlmModels).mockResolvedValue({ + defaultModelId: 'quality', + models: [{ id: 'quality', displayName: '高质量' }], + revision: 2, + }); + await act(async () => { + await expect(ref.current?.ensureUsable()).resolves.toBe(true); + }); + expect(screen.getByText('所选模型已停用,已切换为默认模型')).not.toBeNull(); + expect(savedModelId).toBe('quality'); +}); + +test('follows the new server default when the saved selection was the default', async () => { + const onReady = vi.fn(); + render(); + await waitFor(() => expect(onReady).toHaveBeenLastCalledWith(true)); + vi.mocked(loadClientLlmModels).mockResolvedValue({ + defaultModelId: 'fast', + models: [ + { id: 'quality', displayName: '高质量' }, + { id: 'fast', displayName: '快速' }, + ], + revision: 2, + }); + fireEvent(window, new Event('focus')); + await waitFor(() => expect(savedModelId).toBe('fast')); + expect(savedModelIsDefault).toBe(true); + expect( + screen.getByText('默认模型已更新,已切换为新的默认模型'), + ).not.toBeNull(); + expect( + screen.getByRole('button', { name: '对话模型' }).textContent, + ).toContain('快速'); +}); + +test('keeps an explicit selection when the server default changes', async () => { + const onReady = vi.fn(); + render(); + await waitFor(() => expect(onReady).toHaveBeenLastCalledWith(true)); + fireEvent.click(screen.getByRole('button', { name: '对话模型' })); + fireEvent.click(screen.getByRole('option', { name: '快速' })); + await waitFor(() => expect(savedModelId).toBe('fast')); + expect(savedModelIsDefault).toBe(false); + vi.mocked(loadClientLlmModels).mockResolvedValue({ + defaultModelId: 'quality', + models: [ + { id: 'quality', displayName: '高质量' }, + { id: 'fast', displayName: '快速' }, + ], + revision: 2, + }); + fireEvent(window, new Event('focus')); + await waitFor(() => + expect( + screen.getByRole('button', { name: '对话模型' }).textContent, + ).toContain('快速'), + ); + expect(savedModelId).toBe('fast'); + expect(savedModelIsDefault).toBe(false); +}); + +test('recovers the selector when reading the native config fails', async () => { + invoke.mockRejectedValueOnce(new Error('config unreadable')); + const onReady = vi.fn(); + render(); + await screen.findByText('读取客户端配置失败'); + await waitFor(() => expect(onReady).toHaveBeenLastCalledWith(false)); + + // 失败后必须恢复可交互:选项与刷新按钮都不能被永久禁用。 + fireEvent.click(screen.getByRole('button', { name: '对话模型' })); + expect( + screen.getByRole('option', { name: /高质量/ }).hasAttribute('disabled'), + ).toBe(false); + fireEvent.click(screen.getByRole('button', { name: '刷新模型列表' })); + await waitFor(() => expect(onReady).toHaveBeenLastCalledWith(true)); + expect(screen.queryByText('读取客户端配置失败')).toBeNull(); +}); + +test('pre-send validation waits for an in-flight selection save', async () => { + let resolveSave: (() => void) | undefined; + invoke.mockImplementation(async (command, input) => { + if (command === 'select_game_creator_model') { + await new Promise((resolve) => { + resolveSave = () => { + savedModelId = String(input.modelId); + savedModelIsDefault = Boolean(input.isDefault); + resolve(); + }; + }); + } + return { + config: { + selectedModelId: savedModelId, + selectedModelIsDefault: savedModelIsDefault, + }, + }; + }); + const ref = createRef(); + const onReady = vi.fn(); + render( + , + ); + await waitFor(() => expect(onReady).toHaveBeenLastCalledWith(true)); + + fireEvent.click(screen.getByRole('button', { name: '对话模型' })); + fireEvent.click(screen.getByRole('option', { name: '快速' })); + + let settled = false; + let pending!: Promise; + await act(async () => { + pending = ref.current!.ensureUsable().finally(() => { + settled = true; + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + expect(settled).toBe(false); + + resolveSave?.(); + await act(async () => { + await expect(pending).resolves.toBe(true); + }); + expect(savedModelId).toBe('fast'); + expect(savedModelIsDefault).toBe(false); +}); + +test('reuses a single in-flight catalog request', async () => { + const onReady = vi.fn(); + render(); + await waitFor(() => expect(onReady).toHaveBeenLastCalledWith(true)); + expect(loadClientLlmModels).toHaveBeenCalledTimes(1); + + fireEvent(window, new Event('focus')); + fireEvent(window, new Event('focus')); + fireEvent.click(screen.getByRole('button', { name: '对话模型' })); + await waitFor(() => expect(loadClientLlmModels).toHaveBeenCalledTimes(2)); + expect(loadClientLlmModels).toHaveBeenCalledTimes(2); +}); + +test('keeps refreshing when the server omits the catalog revision', async () => { + vi.mocked(loadClientLlmModels).mockResolvedValue({ + defaultModelId: 'quality', + models: [ + { id: 'quality', displayName: '高质量' }, + { id: 'fast', displayName: '快速' }, + ], + revision: undefined as unknown as number, + }); + const onReady = vi.fn(); + render(); + await waitFor(() => expect(onReady).toHaveBeenLastCalledWith(true)); + + // 旧服务端不返回 revision 时,两次响应的 revision 都是 undefined, + // 不能因此判定「未变化」而停止刷新。 + vi.mocked(loadClientLlmModels).mockResolvedValue({ + defaultModelId: 'quality', + models: [ + { id: 'quality', displayName: '高质量' }, + { id: 'vision', displayName: '视觉' }, + ], + revision: undefined as unknown as number, + }); + fireEvent(window, new Event('focus')); + await waitFor(() => expect(loadClientLlmModels).toHaveBeenCalledTimes(2)); + fireEvent.click(screen.getByRole('button', { name: '对话模型' })); + expect(await screen.findByRole('option', { name: '视觉' })).not.toBeNull(); +}); + +test('applies a catalog when the revision goes backwards', async () => { + const onReady = vi.fn(); + render(); + await waitFor(() => expect(onReady).toHaveBeenLastCalledWith(true)); + + // 服务端目录重建后 revision 可能回退,仍要按「已变化」处理。 + vi.mocked(loadClientLlmModels).mockResolvedValue({ + defaultModelId: 'quality', + models: [ + { id: 'quality', displayName: '高质量' }, + { id: 'vision', displayName: '视觉' }, + ], + revision: 0, + }); + fireEvent(window, new Event('focus')); + await waitFor(() => expect(loadClientLlmModels).toHaveBeenCalledTimes(2)); + fireEvent.click(screen.getByRole('button', { name: '对话模型' })); + expect(await screen.findByRole('option', { name: '视觉' })).not.toBeNull(); +}); + +test('keeps the applied catalog when the revision is unchanged', async () => { + const onReady = vi.fn(); + render(); + await waitFor(() => expect(onReady).toHaveBeenLastCalledWith(true)); + + // revision 未变化时不更新界面,沿用已应用的目录。 + vi.mocked(loadClientLlmModels).mockResolvedValue({ + defaultModelId: 'quality', + models: [ + { id: 'quality', displayName: '高质量' }, + { id: 'vision', displayName: '视觉' }, + ], + revision: 1, + }); + fireEvent(window, new Event('focus')); + await waitFor(() => expect(loadClientLlmModels).toHaveBeenCalledTimes(2)); + fireEvent.click(screen.getByRole('button', { name: '对话模型' })); + expect(screen.queryByRole('option', { name: '视觉' })).toBeNull(); + expect(screen.getByRole('option', { name: '快速' })).not.toBeNull(); +}); diff --git a/apps/ai-game-creator-shell/tests/previewWorkspaceZoom.test.tsx b/apps/ai-game-creator-shell/tests/previewWorkspaceZoom.test.tsx new file mode 100644 index 000000000..50a89c1e2 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/previewWorkspaceZoom.test.tsx @@ -0,0 +1,209 @@ +// @vitest-environment jsdom + +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { Node as UiNode } from '../src/features/ui-editor/types/Node'; +import type { UIDesignImage } from '../src/features/ui-editor/types/UIDesignImage'; +import { PreviewWorkspace } from '../src/view/ui-editor/components/preview/PreviewWorkspace'; +import type { UiEditorCanvasProjection } from '../src/view/ui-editor/useUiEditorPage'; + +class TestResizeObserver { + observe() {} + disconnect() {} + unobserve() {} +} + +const root: UiNode = { + id: 'root', + layout: { + transform: { + anchor_min: [0, 0], + anchor_max: [1, 1], + offset_min: [0, 0], + offset_max: [0, 0], + }, + custom_minimum_size: [0, 0], + size_flags_horizontal: 1, + size_flags_vertical: 1, + size_flags_stretch_ratio: 1, + container: 'None', + }, + metadata: { + name: '根节点', + description: '', + layout_status: 'NoProblem', + components_status: 'NoProblem', + allow_llm_edit_layout: true, + allow_llm_edit_component: true, + source: 'System', + }, + components: [], + children_display_mode: 'Stack', + children: [], +}; + +const activeImage: UIDesignImage = { + metadata: { + name: '测试界面', + description: '', + role: null, + slave_to: null, + }, + path: 'assets/page.png', + pixel_size: [1200, 800], + pixels_per_unit: 1, +}; + +function createCanvas( + overrides: Partial = {}, +): UiEditorCanvasProjection { + return { + isLocked: false, + activeImage, + activeImageId: 'page-1', + previewUrls: { 'page-1': 'data:image/png;base64,' }, + images: { 'page-1': activeImage }, + sprites: {}, + fontFaces: {}, + tree: { src_ui_design: 'page-1', root }, + selectedNode: null, + selectedNodeId: null, + keepChildrenUnchanged: false, + hiddenNodeIds: new Set(), + focusRequest: null, + status: null, + isNodePreviewVisible: vi.fn(() => true), + toggleNodePreviewVisibility: vi.fn(), + selectExclusiveChild: vi.fn(), + selectNode: vi.fn(), + clearNodeSelection: vi.fn(), + updateNodeTransform: vi.fn(), + insertNode: vi.fn(), + insertNodeAfter: vi.fn(), + deleteNode: vi.fn(), + openClearDialog: vi.fn(), + ...overrides, + }; +} + +function logicalViewportScale(container: HTMLElement) { + const world = container.querySelector( + '.genarrative-image-canvas__world', + ) as HTMLElement; + const inverseScale = Number( + world.style.getPropertyValue('--genarrative-image-canvas-inverse-scale'), + ); + if (!Number.isFinite(inverseScale) || inverseScale <= 0) { + throw new Error(`无法从 world 读取逻辑缩放值`); + } + return 1 / inverseScale; +} + +beforeEach(() => { + vi.stubGlobal('ResizeObserver', TestResizeObserver); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe('PreviewWorkspace quick zoom', () => { + it('uses the toolbar zoom step while the preview is hovered', () => { + const rendered = render(); + const preview = screen.getByRole('region', { name: 'UI 预览画布' }); + const before = logicalViewportScale(rendered.container); + + fireEvent.pointerEnter(preview); + const wasNotCancelled = fireEvent.keyDown(window, { + key: '=', + code: 'Equal', + ctrlKey: true, + cancelable: true, + }); + + expect(wasNotCancelled).toBe(false); + expect(logicalViewportScale(rendered.container)).toBeCloseTo(before * 1.16); + }); + + it('requires hover or focus and ignores interactive targets', () => { + const rendered = render(); + const preview = screen.getByRole('region', { name: 'UI 预览画布' }); + const zoomButton = screen.getByRole('button', { name: '放大画布' }); + const initial = logicalViewportScale(rendered.container); + + expect( + fireEvent.keyDown(window, { + key: '=', + ctrlKey: true, + cancelable: true, + }), + ).toBe(true); + expect(logicalViewportScale(rendered.container)).toBe(initial); + + fireEvent.pointerEnter(preview); + expect( + fireEvent.keyDown(zoomButton, { + key: '=', + ctrlKey: true, + cancelable: true, + }), + ).toBe(true); + expect(logicalViewportScale(rendered.container)).toBe(initial); + + fireEvent.pointerLeave(preview); + fireEvent.focus(preview); + fireEvent.keyDown(window, { + key: '-', + code: 'Minus', + ctrlKey: true, + cancelable: true, + }); + expect(logicalViewportScale(rendered.container)).toBeCloseTo( + initial * 0.86, + ); + }); + + it('keeps actual-size and fit shortcuts within the preview scope', () => { + const rendered = render(); + const preview = screen.getByRole('region', { name: 'UI 预览画布' }); + const fitted = logicalViewportScale(rendered.container); + + fireEvent.focus(preview); + fireEvent.keyDown(window, { + key: '1', + ctrlKey: true, + cancelable: true, + }); + expect(logicalViewportScale(rendered.container)).toBe(1); + + fireEvent.keyDown(window, { + key: '0', + ctrlKey: true, + cancelable: true, + }); + expect(logicalViewportScale(rendered.container)).toBe(fitted); + }); + + it('leaves browser zoom untouched when the preview has no content', () => { + render( + , + ); + + expect( + fireEvent.keyDown(window, { + key: '=', + ctrlKey: true, + cancelable: true, + }), + ).toBe(true); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/previewZoomKeyboard.test.ts b/apps/ai-game-creator-shell/tests/previewZoomKeyboard.test.ts new file mode 100644 index 000000000..5ef72b7db --- /dev/null +++ b/apps/ai-game-creator-shell/tests/previewZoomKeyboard.test.ts @@ -0,0 +1,176 @@ +// @vitest-environment jsdom + +import { fireEvent } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + handlePreviewZoomKeyDown, + type PreviewZoomKeyboardActions, + type PreviewZoomKeyboardContext, + previewZoomUsesMetaModifier, +} from '../src/view/ui-editor/components/preview/previewZoomKeyboard'; + +function createActions(): PreviewZoomKeyboardActions { + return { + fit: vi.fn(), + resetToActualSize: vi.fn(), + zoomIn: vi.fn(), + zoomOut: vi.fn(), + }; +} + +function dispatchShortcut({ + actions = createActions(), + context = {}, + event, + target, +}: { + actions?: PreviewZoomKeyboardActions; + context?: Partial; + event: KeyboardEventInit; + target?: HTMLElement; +}) { + const resolvedTarget = target ?? document.createElement('div'); + document.body.append(resolvedTarget); + const handler = vi.fn((keyboardEvent: KeyboardEvent) => + handlePreviewZoomKeyDown( + keyboardEvent, + { + hasZoomableViewport: true, + isFocused: false, + isHovered: true, + usesMetaModifier: false, + ...context, + }, + actions, + ), + ); + resolvedTarget.addEventListener('keydown', handler); + fireEvent.keyDown(resolvedTarget, event); + const keyboardEvent = handler.mock.calls[0]?.[0]; + return { actions, handler, keyboardEvent }; +} + +afterEach(() => { + document.body.replaceChildren(); +}); + +describe('preview zoom keyboard shortcuts', () => { + it.each([ + { key: '+', code: 'Equal' }, + { key: '=', code: 'Equal' }, + { key: '+', code: 'NumpadAdd' }, + ])('zooms in for the supported main and numpad keys (%o)', (event) => { + const { actions, keyboardEvent } = dispatchShortcut({ + event: { ...event, ctrlKey: true, cancelable: true, repeat: true }, + }); + + expect(actions.zoomIn).toHaveBeenCalledTimes(1); + expect(keyboardEvent?.defaultPrevented).toBe(true); + }); + + it.each([ + { key: '-', code: 'Minus' }, + { key: '-', code: 'NumpadSubtract' }, + ])('zooms out for the supported main and numpad keys (%o)', (event) => { + const { actions, keyboardEvent } = dispatchShortcut({ + event: { ...event, ctrlKey: true, cancelable: true }, + }); + + expect(actions.zoomOut).toHaveBeenCalledTimes(1); + expect(keyboardEvent?.defaultPrevented).toBe(true); + }); + + it('does not treat underscore as zoom out', () => { + const { actions, keyboardEvent } = dispatchShortcut({ + event: { + key: '_', + code: 'Minus', + ctrlKey: true, + shiftKey: true, + cancelable: true, + }, + }); + + expect(actions.zoomOut).not.toHaveBeenCalled(); + expect(keyboardEvent?.defaultPrevented).toBe(false); + }); + + it.each([ + { key: '0', action: 'fit' as const }, + { key: '1', action: 'resetToActualSize' as const }, + ])('keeps the existing $key shortcut', ({ key, action }) => { + const { actions } = dispatchShortcut({ + event: { key, ctrlKey: true, cancelable: true }, + }); + + expect(actions[action]).toHaveBeenCalledTimes(1); + }); + + it('uses Cmd on Apple platforms and Ctrl elsewhere', () => { + expect(previewZoomUsesMetaModifier('MacIntel')).toBe(true); + expect(previewZoomUsesMetaModifier('iPad')).toBe(true); + expect(previewZoomUsesMetaModifier('Win32')).toBe(false); + + const appleActions = createActions(); + dispatchShortcut({ + actions: appleActions, + context: { usesMetaModifier: true }, + event: { key: '=', ctrlKey: true, cancelable: true }, + }); + expect(appleActions.zoomIn).not.toHaveBeenCalled(); + dispatchShortcut({ + actions: appleActions, + context: { usesMetaModifier: true }, + event: { key: '=', metaKey: true, cancelable: true }, + }); + expect(appleActions.zoomIn).toHaveBeenCalledTimes(1); + }); + + it.each([ + { isHovered: false, isFocused: false, hasZoomableViewport: true }, + { isHovered: true, isFocused: false, hasZoomableViewport: false }, + ])('leaves inactive preview shortcuts to the host (%o)', (context) => { + const { actions, keyboardEvent } = dispatchShortcut({ + context, + event: { key: '=', ctrlKey: true, cancelable: true }, + }); + + expect(actions.zoomIn).not.toHaveBeenCalled(); + expect(keyboardEvent?.defaultPrevented).toBe(false); + }); + + it('works while the preview is focused without being hovered', () => { + const { actions } = dispatchShortcut({ + context: { isFocused: true, isHovered: false }, + event: { key: '=', ctrlKey: true, cancelable: true }, + }); + + expect(actions.zoomIn).toHaveBeenCalledTimes(1); + }); + + it.each(['input', 'button', 'a'])('ignores interactive %s targets', (tag) => { + const target = document.createElement(tag); + if (target instanceof HTMLAnchorElement) target.href = '#preview'; + const { actions, keyboardEvent } = dispatchShortcut({ + target, + event: { key: '=', ctrlKey: true, cancelable: true }, + }); + + expect(actions.zoomIn).not.toHaveBeenCalled(); + expect(keyboardEvent?.defaultPrevented).toBe(false); + }); + + it('does not override an event already handled by another control', () => { + const target = document.createElement('div'); + target.addEventListener('keydown', (event) => event.preventDefault(), { + once: true, + }); + const { actions } = dispatchShortcut({ + target, + event: { key: '=', ctrlKey: true, cancelable: true }, + }); + + expect(actions.zoomIn).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/start-dev-stack.test.ts b/apps/ai-game-creator-shell/tests/start-dev-stack.test.ts index d9130a640..fb4534a86 100644 --- a/apps/ai-game-creator-shell/tests/start-dev-stack.test.ts +++ b/apps/ai-game-creator-shell/tests/start-dev-stack.test.ts @@ -7,25 +7,39 @@ import { describe, expect, test, vi } from 'vitest'; import { ensureBackend, + formatOwnerLabel, isBackendReady, isProcessGroupAlive, + isWorktreeApiServerOwner, + isWorktreeSpacetimeOwner, preflightExistingVite, + readBackendServiceFailure, readLinuxProcessGroupAlive, + readWindowsPortOwnerIdentities, resolveBackendTargetsFromState, runWindowsTaskkill, spawnChild, stopChild, terminateChildTree, + verifyAgcBackendOwnership, + waitForBackendReady, waitForChildTermination, } from '../scripts/start-dev-stack.mjs'; const expectedDatabase = 'genarrative-game-creator-dev'; const expectedDataDir = resolve('server-rs/.spacetimedb/ai-game-creator/data'); +const expectedExePath = resolve('server-rs/target/debug/api-server.exe'); +const ownedBackend = async () => ({ + ok: true, + reason: 'owned', + owners: new Map(), +}); function backendState(spacetimeDataDir?: string, includeBgfilterWorker = true) { return { schemaVersion: spacetimeDataDir ? 2 : 1, database: expectedDatabase, + updatedAt: '', ...(spacetimeDataDir ? { spacetimeDataDir } : {}), services: { 'api-server': { @@ -106,6 +120,7 @@ describe('AI 游戏创作配套后端复用门禁', () => { isBackendReady({ state: backendState(expectedDataDir, false), isReady, + verifyOwnership: ownedBackend, }), ).resolves.toBe(false); expect(isReady).not.toHaveBeenCalled(); @@ -115,6 +130,7 @@ describe('AI 游戏创作配套后端复用门禁', () => { isBackendReady({ state: backendState(expectedDataDir), isReady, + verifyOwnership: ownedBackend, }), ).resolves.toBe(false); expect(isReady).toHaveBeenCalledWith('http://127.0.0.1:8082/healthz'); @@ -124,9 +140,277 @@ describe('AI 游戏创作配套后端复用门禁', () => { isBackendReady({ state: backendState(expectedDataDir), isReady, + verifyOwnership: ownedBackend, }), ).resolves.toBe(true); }); + + test('后端服务失败时返回具体失败服务,避免外层无限等待', () => { + const state = backendState(expectedDataDir); + state.services['bgfilter-worker'].status = 'failed'; + state.services['bgfilter-worker'].exitCode = 1; + state.services['bgfilter-worker'].signal = null; + + expect(readBackendServiceFailure(state)).toEqual({ + serviceName: 'bgfilter-worker', + failure: 'code=1', + }); + }); + + test('不匹配的旧状态失败记录不会阻断当前后端启动', () => { + const state = backendState(resolve('server-rs/.spacetimedb/other/data')); + state.services['bgfilter-worker'].status = 'failed'; + state.services['bgfilter-worker'].exitCode = 1; + + expect(readBackendServiceFailure(state)).toBeNull(); + }); + + test('等待后端时立即传播状态文件中的服务失败', async () => { + const initialState = backendState(expectedDataDir); + initialState.updatedAt = '2026-09-04T08:00:00.000Z'; + const state = backendState(expectedDataDir); + state.updatedAt = '2026-09-04T08:00:01.000Z'; + state.services['bgfilter-worker'].status = 'failed'; + state.services['bgfilter-worker'].exitCode = 98; + const child = Object.assign(new EventEmitter(), { + exitCode: null, + signalCode: null, + }); + let readCount = 0; + + await expect( + waitForBackendReady(child, 100, { + checkBackendReady: async () => false, + readState: () => (readCount++ === 0 ? initialState : state), + }), + ).rejects.toThrow('配套后端启动失败: bgfilter-worker code=98'); + }); + + test('端口上的后端不属于当前工作树时拒绝复用', async () => { + const isReady = vi.fn(async () => true); + const onOwnershipRejected = vi.fn(); + + await expect( + isBackendReady({ + state: backendState(expectedDataDir), + isReady, + verifyOwnership: async () => ({ + ok: false, + reason: 'api-server-owner-mismatch', + apiOwner: { processId: 4321, name: 'api-server.exe' }, + }), + onOwnershipRejected, + }), + ).resolves.toBe(false); + + expect(onOwnershipRejected).toHaveBeenCalledWith( + expect.objectContaining({ reason: 'api-server-owner-mismatch' }), + ); + expect(isReady).not.toHaveBeenCalled(); + }); + + test('归属探测不可用时退化为旧行为而不是让本地启动失败', async () => { + const isReady = vi.fn(async () => true); + + await expect( + isBackendReady({ + state: backendState(expectedDataDir), + isReady, + verifyOwnership: async () => ({ + ok: true, + reason: 'owner-probe-unavailable', + owners: new Map(), + }), + }), + ).resolves.toBe(true); + expect(isReady).toHaveBeenCalledWith('http://127.0.0.1:8082/healthz'); + }); +}); + +describe('AI 游戏创作配套后端归属校验', () => { + const urls = { + apiUrl: 'http://127.0.0.1:8082', + spacetimeUrl: 'http://127.0.0.1:3101', + bgfilterWorkerUrl: 'http://127.0.0.1:8083', + }; + + function ownerMap({ + apiExe = expectedExePath, + dataDir = expectedDataDir, + } = {}) { + return new Map([ + [ + 8082, + { + port: 8082, + processId: 11, + name: 'api-server.exe', + executablePath: apiExe, + commandLine: null, + }, + ], + [ + 8083, + { + port: 8083, + processId: 12, + name: 'api-server.exe', + executablePath: expectedExePath, + commandLine: null, + }, + ], + [ + 3101, + { + port: 3101, + processId: 13, + name: 'spacetimedb-standalone.exe', + executablePath: null, + commandLine: `spacetimedb-standalone.exe start --data-dir ${dataDir}`, + }, + ], + ]); + } + + test('api-server 可执行文件来自其它工作树时判定为不归属', () => { + const result = verifyAgcBackendOwnership({ + ...urls, + platform: 'win32', + expectedExePath, + expectedDataDir, + readPortOwners: () => + ownerMap({ + apiExe: resolve( + '.worktrees/other/server-rs/target/debug/api-server.exe', + ), + }), + }); + + expect(result.ok).toBe(false); + expect(result.reason).toBe('api-server-owner-mismatch'); + }); + + test('SpacetimeDB 使用其它 data dir 时判定为不归属', () => { + const result = verifyAgcBackendOwnership({ + ...urls, + platform: 'win32', + expectedExePath, + expectedDataDir, + readPortOwners: () => + ownerMap({ dataDir: resolve('server-rs/.spacetimedb/local/data') }), + }); + + expect(result.ok).toBe(false); + expect(result.reason).toBe('spacetime-owner-mismatch'); + }); + + test('可执行文件与 data dir 都匹配时允许复用', () => { + const result = verifyAgcBackendOwnership({ + ...urls, + platform: 'win32', + expectedExePath, + expectedDataDir, + readPortOwners: () => ownerMap(), + }); + + expect(result).toMatchObject({ ok: true, reason: 'owned' }); + }); + + test('归属探测不可用时不阻断本地启动', () => { + const result = verifyAgcBackendOwnership({ + ...urls, + platform: 'win32', + expectedExePath, + expectedDataDir, + readPortOwners: () => null, + }); + + expect(result).toMatchObject({ + ok: true, + reason: 'owner-probe-unavailable', + }); + }); + + test('非 Windows 平台保持原有复用行为', () => { + const result = verifyAgcBackendOwnership({ ...urls, platform: 'linux' }); + expect(result).toMatchObject({ ok: true, reason: 'platform-unsupported' }); + }); + + test('可执行文件路径与 data dir 归属判定忽略大小写和 \\\\?\\ 前缀', () => { + expect( + isWorktreeApiServerOwner( + { + processId: 1, + executablePath: `\\\\?\\${expectedExePath.toUpperCase()}`, + }, + { expectedExePath }, + ), + ).toBe(true); + expect( + isWorktreeSpacetimeOwner( + { + processId: 2, + name: 'spacetimedb-standalone.exe', + commandLine: `start --data-dir ${expectedDataDir.toUpperCase()}`, + }, + { expectedDataDir }, + ), + ).toBe(true); + expect( + isWorktreeSpacetimeOwner( + { processId: 3, name: 'node.exe', commandLine: expectedDataDir }, + { expectedDataDir }, + ), + ).toBe(false); + }); + + test('端口监听进程探测解析 PowerShell 输出', () => { + const spawnImpl = vi.fn(() => ({ + status: 0, + error: null, + stdout: JSON.stringify([ + { + port: 8082, + processId: 4321, + name: 'api-server.exe', + executablePath: expectedExePath, + commandLine: null, + }, + ]), + })); + + const owners = readWindowsPortOwnerIdentities([8082, 0], { + spawnImpl, + env: {}, + }); + + expect(owners?.get(8082)).toMatchObject({ processId: 4321 }); + expect(spawnImpl).toHaveBeenCalledWith( + 'powershell.exe', + expect.any(Array), + expect.objectContaining({ env: { GENARRATIVE_QUERY_PORTS: '8082' } }), + ); + }); + + test('探测失败时返回 null 以触发退化分支', () => { + expect( + readWindowsPortOwnerIdentities([8082], { + spawnImpl: () => ({ status: 1, error: null, stdout: '' }), + env: {}, + }), + ).toBeNull(); + expect(readWindowsPortOwnerIdentities([], { env: {} })).toBeNull(); + }); + + test('归属日志包含 pid 与进程标识', () => { + expect( + formatOwnerLabel({ + processId: 4321, + executablePath: 'C:\\a\\api-server.exe', + }), + ).toBe('pid=4321 C:\\a\\api-server.exe'); + expect(formatOwnerLabel(null)).toBe('未知进程'); + }); }); describe('AI 游戏创作启动子进程生命周期', () => { diff --git a/apps/ai-game-creator-shell/tests/uiEditorKeyboardShortcuts.test.ts b/apps/ai-game-creator-shell/tests/uiEditorKeyboardShortcuts.test.ts new file mode 100644 index 000000000..f21c284db --- /dev/null +++ b/apps/ai-game-creator-shell/tests/uiEditorKeyboardShortcuts.test.ts @@ -0,0 +1,109 @@ +// @vitest-environment jsdom + +import { fireEvent } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { handleUiEditorKeyDown } from '../src/view/ui-editor/uiEditorKeyboardShortcuts'; + +describe('ui editor keyboard shortcuts', () => { + afterEach(() => { + document.body.replaceChildren(); + }); + + it.each([ + { key: 'z', ctrlKey: true }, + { key: 'z', ctrlKey: true, shiftKey: true }, + { key: 'y', ctrlKey: true }, + ])('does not change history from inside a modal (%o)', (shortcut) => { + const dialog = document.createElement('div'); + dialog.setAttribute('role', 'dialog'); + const button = document.createElement('button'); + dialog.append(button); + document.body.append(dialog); + const historyUndo = vi.fn(() => true); + const historyRedo = vi.fn(() => true); + const listener = (event: KeyboardEvent) => + handleUiEditorKeyDown(event, { + selectedNodeId: null, + activeImageId: null, + deleteNode: vi.fn(), + historyUndo, + historyRedo, + }); + window.addEventListener('keydown', listener); + + fireEvent.keyDown(button, shortcut); + + expect(historyUndo).not.toHaveBeenCalled(); + expect(historyRedo).not.toHaveBeenCalled(); + window.removeEventListener('keydown', listener); + }); + + it('keeps undo available from a non-modal button', () => { + const button = document.createElement('button'); + document.body.append(button); + const historyUndo = vi.fn(() => true); + const historyRedo = vi.fn(() => true); + const listener = (event: KeyboardEvent) => + handleUiEditorKeyDown(event, { + selectedNodeId: null, + activeImageId: null, + deleteNode: vi.fn(), + historyUndo, + historyRedo, + }); + window.addEventListener('keydown', listener); + + fireEvent.keyDown(button, { key: 'z', ctrlKey: true }); + + expect(historyUndo).toHaveBeenCalledTimes(1); + window.removeEventListener('keydown', listener); + }); + + it.each(['Delete', 'Backspace'])( + 'does not delete a node when the zoom slider has focus (%s)', + (key) => { + const slider = document.createElement('input'); + slider.type = 'range'; + document.body.append(slider); + const deleteNode = vi.fn(() => ({ ok: true })); + const listener = (event: KeyboardEvent) => + handleUiEditorKeyDown(event, { + selectedNodeId: 'node-1', + activeImageId: 'page-1', + deleteNode, + historyUndo: vi.fn(() => true), + historyRedo: vi.fn(() => true), + }); + window.addEventListener('keydown', listener); + + fireEvent.keyDown(slider, { key }); + + expect(deleteNode).not.toHaveBeenCalled(); + window.removeEventListener('keydown', listener); + }, + ); + + it('keeps node deletion available after zoom button focus is released', () => { + const zoomControls = document.createElement('div'); + const zoomIn = document.createElement('button'); + zoomControls.append(zoomIn); + document.body.append(zoomControls); + const deleteNode = vi.fn(() => ({ ok: true })); + const listener = (event: KeyboardEvent) => + handleUiEditorKeyDown(event, { + selectedNodeId: 'node-1', + activeImageId: 'page-1', + deleteNode, + historyUndo: vi.fn(() => true), + historyRedo: vi.fn(() => true), + }); + window.addEventListener('keydown', listener); + + fireEvent.click(zoomIn); + fireEvent.keyDown(window, { key: 'Delete' }); + + expect(deleteNode).toHaveBeenCalledWith('node-1', 'page-1'); + window.removeEventListener('keydown', listener); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts b/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts index 6c21bc416..2c4206167 100644 --- a/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts +++ b/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts @@ -473,6 +473,75 @@ describe('UiEditorPage', () => { expect(result.current.canvas.hiddenNodeIds.size).toBe(0); }); + it('clears selection when deleting a node removes the selected descendant', async () => { + const { result } = await renderLoadedSession(stateWithPages(['page'])); + const rootId = 'page-root'; + let parentId: string | undefined; + let childId: string | undefined; + act(() => { + parentId = result.current.input.insertNode(rootId, 'page')?.value; + childId = result.current.input.insertNode(parentId!, 'page')?.value; + result.current.input.selectNode(childId!); + }); + + act(() => result.current.input.deleteNode(parentId!, 'page')); + + expect(result.current.canvas.selectedNodeId).toBeNull(); + expect(result.current.history.canUndo).toBe(true); + }); + + it('deletes the selected node from the page with Delete', async () => { + const state = stateWithPages(['page']); + state.ui_trees[0]!.root.children = [node('page-child')]; + const stateStore: IUiDesignStateStore = { + load: vi.fn().mockResolvedValue({ revision: 0, state }), + save: vi.fn(), + generateCode: vi.fn().mockRejectedValue(new Error('测试未配置代码生成')), + }; + + render( + createElement(UiEditorPage, { + projectPath: '/tmp/ui-editor-delete-keyboard', + resourceId: 'ui-resource', + stateStore, + }), + ); + + const child = await screen.findByText('page-child'); + fireEvent.click(child); + fireEvent.keyDown(window, { key: 'Delete' }); + + await waitFor(() => expect(screen.queryByText('page-child')).toBeNull()); + }); + + it('does not delete a selected node when Delete originates inside a dialog', async () => { + const state = stateWithPages(['page']); + state.ui_trees[0]!.root.children = [node('page-child')]; + const stateStore: IUiDesignStateStore = { + load: vi.fn().mockResolvedValue({ revision: 0, state }), + save: vi.fn(), + generateCode: vi.fn().mockRejectedValue(new Error('测试未配置代码生成')), + }; + + render( + createElement(UiEditorPage, { + projectPath: '/tmp/ui-editor-delete-dialog', + resourceId: 'ui-resource', + stateStore, + }), + ); + + const child = await screen.findByText('page-child'); + fireEvent.click(child); + const dialog = document.createElement('div'); + dialog.setAttribute('role', 'dialog'); + document.body.appendChild(dialog); + fireEvent.keyDown(dialog, { key: 'Delete' }); + + expect(screen.queryAllByText('page-child').length).toBeGreaterThan(0); + dialog.remove(); + }); + it('keeps Inspector status highlighting separate from node navigation', async () => { const { result } = await renderLoadedSession(stateWithPages(['page'])); const rootId = 'page-root'; diff --git a/apps/ai-game-creator-shell/tests/uiTreeUtils.test.ts b/apps/ai-game-creator-shell/tests/uiTreeUtils.test.ts new file mode 100644 index 000000000..196077400 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/uiTreeUtils.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; + +import { + countUiNodeDescendants, + findUiNode, +} from '../src/features/ui-editor/treeUtils'; +import type { Node } from '../src/features/ui-editor/types/Node'; + +function node(id: string, children: Node[] = []): Node { + return { + id, + layout: {} as Node['layout'], + metadata: {} as Node['metadata'], + components: [], + children_display_mode: 'Stack', + children, + }; +} + +describe('ui tree utilities', () => { + it('finds a nested node and counts all descendants', () => { + const nested = node('nested', [node('leaf')]); + const root = node('root', [node('page'), nested]); + + expect(findUiNode(root, 'nested')).toBe(nested); + expect(findUiNode(root, 'missing')).toBeNull(); + expect(countUiNodeDescendants(nested)).toBe(1); + expect(countUiNodeDescendants(root)).toBe(3); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/workflowCompletionNotice.test.ts b/apps/ai-game-creator-shell/tests/workflowCompletionNotice.test.ts new file mode 100644 index 000000000..01016c4ae --- /dev/null +++ b/apps/ai-game-creator-shell/tests/workflowCompletionNotice.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest'; + +import { + appendWorkflowCheckPrompt, + workflowStepLabel, +} from '../src/view/ui-editor/components/workflowCompletionNotice'; + +describe('workflow completion notice helpers', () => { + it('appends the review prompt to a terminal status', () => { + expect(appendWorkflowCheckPrompt('已应用 3 条建议。')).toBe( + '已应用 3 条建议,请检查。', + ); + }); + + it('maps every workflow step to a user-facing label', () => { + expect(workflowStepLabel('reference-analysis')).toBe('分析参考图'); + expect(workflowStepLabel('structure-recognition')).toBe('识别界面结构'); + expect(workflowStepLabel('visual-binding')).toBe('绑定视觉素材'); + }); +}); diff --git a/docs/README.md b/docs/README.md index 7453573fe..fe1f59936 100644 --- a/docs/README.md +++ b/docs/README.md @@ -22,13 +22,15 @@ - [LLM 累计额度结算](./technical/【技术方案】LLM累计额度结算-2026-09-05.md):Router 累计额度、首次基线与原子钱包结算。 - [AI 游戏创作智能体 App 实施计划](./technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md):当前 DirectProject、受控语义工具、UI workflow、资源和运行时合同。 +- [策划会话 Runtime V2 接入与旧链路退役方案](./technical/【技术方案】策划会话RuntimeV2接入与旧链路退役-2026-09-03.md):新单 Agent 策划会话、GDD 策略、未来 MCP/Skill 兼容插槽、阶段任务与退役验收合同。 +- [DirectProject Codex 原始历史与异常恢复](./technical/【技术方案】DirectProject%20Codex原始历史与异常恢复-2026-09-04.md):原始 Responses item 持久化、线程注入与异常回合收尾。 - [DirectProject 客户端 Skill 与 MCP 扩展导入方案](./technical/【技术方案】DirectProject客户端Skill与MCP扩展导入方案-2026-08-31.md):客户端扩展导入、按独立 Skill/MCP 拆分、命名、启用和启动时注入边界。 - [AGC 客户端更新检查与下载](./technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md):启动版本检测、OSS 清单格式和下载约定。 - [DirectProject 本轮附件路径映射](./technical/【技术方案】DirectProject本轮附件路径映射-2026-08-31.md):Direct 首轮只映射附件原名与项目相对路径,不灌正文、不区别 GDD。 - [Direct 回合行为审计账本](./technical/【技术方案】Direct回合行为审计账本-2026-08-31.md):Direct GUI 回合把 native 读 / MCP / 写文件落成项目内有界时间线,用于判断有没有打开本轮附件。 - [项目开发工作台 PRD](./prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md):当前工作台页面和验收边界。 - [AGC 错误报告与诊断上传](./technical/【技术方案】AGC错误报告与诊断上传-2026-08-31.md):当前进程错误事件、应用级日志和管理员查看器合同。 -- [立项策划 Agent(Fast GDD)](<./technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md>):当前策划入口、审批和恢复合同。 +- [立项策划 Agent(Fast GDD)](<./technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md>):旧 `project-supervisor-plan` / `project-planning` 历史会话的入口、审批和恢复合同;V2 切换时未完成旧会话强制失败。 - [GameAgent 资源自由画板与快速编辑](./technical/【技术方案】GameAgent资源自由画板与快速编辑-2026-08-20.md) - [UI 工作流资源桥接与 Runtime 执行](./【技术方案】UI工作流资源桥接与Runtime执行-2026-08-24.md) - [UI 编辑器 Godot 容器布局](./technical/【技术方案】UI编辑器Godot容器布局模型-2026-08-18.md) diff --git a/docs/openapi/genarrative-external-v1.openapi.json b/docs/openapi/genarrative-external-v1.openapi.json index 78e074daf..507747e57 100644 --- a/docs/openapi/genarrative-external-v1.openapi.json +++ b/docs/openapi/genarrative-external-v1.openapi.json @@ -3372,8 +3372,14 @@ }, "sliceLayout": { "type": "string", - "enum": ["grid-2x2"], - "description": "可选固定图集切片合同。省略时沿用全图 alpha 连通域自动拆分;传 grid-2x2 时服务端要求生成四个固定象限,并按左上、右上、左下、右下各持久化一个独立切片。该模式适用于需要恰好四类核心运行时素材的游戏,不会猜测等分裁切。" + "deprecated": true, + "description": "历史兼容字段,新的调用请使用 sliceCount。" + }, + "sliceCount": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "description": "可选的目标切片数量;省略时按图像内容自动识别。" }, "screenColor": { "type": ["string", "null"], @@ -3597,15 +3603,21 @@ }, "iconImageSrcs": { "type": "array", - "description": "默认模式识别图集中全部有效 alpha 连通域并持久化的独立素材,按视觉阅读顺序命名为“素材 N”;数量由图集内容决定,不由 iconDescriptions 数量决定。sliceLayout=grid-2x2 时固定返回左上、右上、左下、右下四个格子的切片,各格内的零散视觉细节不会被拆成额外素材。", + "description": "识别图集中有效 alpha 连通域并持久化的独立素材,按视觉阅读顺序命名为“素材 N”;可通过 sliceCount 指定目标数量。", "items": { "$ref": "#/components/schemas/EditorIconSpritesheetIconResult" } }, "sliceLayout": { "type": "string", - "enum": ["grid-2x2"], - "description": "仅当请求使用固定切片合同且主图完成透明化、切片持久化后返回。调用方可将该字段与 iconImageSrcs=4 共同作为固定四类素材的来源证明。" + "deprecated": true, + "description": "历史兼容字段。" + }, + "sliceCount": { + "type": "integer", + "minimum": 0, + "maximum": 100, + "description": "实际生成的切片数量。" }, "sliceWarning": { "anyOf": [ diff --git a/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md b/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md index 13bb6403e..14980e6d7 100644 --- a/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md +++ b/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md @@ -185,7 +185,7 @@ completed -> starting(nextSlice) idle -> focused(document|art|audio|version) -> idle ``` -- 文档:合法 Agent 文本回执直接使用对话投影内容;项目文件只允许读取当前 manifest 已登记资产或已完成任务产物中的 Markdown、文本、JSON、YAML、TOML,必须经过 `file.read` auto 权限、相对路径、项目边界、普通文件、符号链接 / 硬链接、读取漂移、2 MiB、UTF-8 与扩展名白名单校验。正文使用不执行 HTML、不加载远程图片、不产生可点击外链的安全 Markdown 渲染,并在中央画布内独立滚动;读取失败显示错误空态。 +- 文档:合法 Agent 文本回执直接使用对话投影内容;项目文件只允许读取当前 manifest 已登记资产或已完成任务产物中的 Markdown、文本、JSON、YAML、TOML,必须经过 `file.read` auto 权限、相对路径、项目边界、普通文件、符号链接 / 硬链接、读取漂移、2 MiB、UTF-8 与扩展名白名单校验。正文使用不执行 HTML、不加载远程图片、不产生可点击外链的安全 Markdown 渲染,并在中央画布内独立滚动;读取失败显示错误空态。聊天侧 `/read` 回执中的文件正文必须作为代码块渲染为 `
`,以便用户审阅源码字面量但不执行其中的 HTML;Markdown 渲染使用 `react-markdown` 的 `skipHtml`,依赖库对代码 span / fenced code 的文本转义;不得在整段 Markdown 上预转义 HTML,否则会把代码中的 `` 双重转义为字面量 `<tag>`。
 - 美术:PNG、JPEG、WEBP、GIF、SVG、AVIF、BMP、MP4、WebM、MOV 只在资源卡本体中按既有受控读取、文件签名与解码门禁展示;中央详情不重复加载或放大图片 / 视频本体。SVG 继续拒绝脚本、事件处理器、外部资源引用和实体声明。
 - 音频:只读取 manifest 已登记音频或已成功导入且登记到 manifest 的附件,按文件签名接受 MP3、WAV、OGG / Opus、M4A、AAC、FLAC;聚焦态展示实际格式、浏览器解码后的时长以及带播放进度和暂停能力的内置播放器。音频任务声明中的未登记路径继续不得读取或播放。
 - 版本:只展示 manifest 中正式、不可变的迭代版本记录;版本卡展示项目修订、创建原因与父版本,聚焦态同时展示直接子版本和资源绑定。点击版本卡后高亮仍存在于当前资源投影中的引用资源;缺失历史资源只保留绑定身份,不生成幽灵资源卡。资源替换仍留给后续切片。
diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md
index 00fe2141a..e874353b4 100644
--- a/docs/project-memory/shared-memory/decision-log.md
+++ b/docs/project-memory/shared-memory/decision-log.md
@@ -15,6 +15,97 @@
 - 关联文档:相关 PRD、技术文档、提交或 Issue
 ```
 
+## 2026-09-05 Planning V2 一次性写路径对齐 V1 的项目锁等待窗口
+
+- 背景:V2 审批修改意见后立即 `continue_planning_session_v2`。审批、回合启动、策略落盘和 hydrate 原先无等待取锁,和 GUI 重灌或其它写操作撞车就返回 `项目正在被其他写操作占用`,前端再映射成总控失败。V1 已用完整/短窗口处理同一形状。
+- 决策:V2 审批、回合启动、策略落盘、失败投影和 GDD 认领使用完整等待窗口;V2 hydrate 使用短窗口。不引入可重入项目锁,不放宽失效回收。
+- 影响范围:`planning_policy_v2.rs`、`planning_session_v2.rs`、V2 hydrate 前端瞬时争用处理。
+- 验证方式:Rust 定向测试覆盖短暂占用下的审批、修订续跑和 hydrate。
+- 关联文档:`docs/technical/【技术方案】策划会话RuntimeV2接入与旧链路退役-2026-09-03.md`、`docs/project-memory/shared-memory/pitfalls.md`。
+
+## 2026-09-05 本进程新建 Windows 私有对象不因继承 DACL 自动 UAC
+
+- 背景:#211 要求 sidecar 满足当前用户独占、禁止继承的 DACL。新建文件会先继承父目录 ACE,生产路径把这种短暂不合格送进 UAC;`project.lock` 还在独占句柄上 harden。含空格项目路径上提权 ArgumentList 被拆开,修复以 exit 1 失败。GDD 审批改意见因此弹权限,V1 锁创建不会。
+- 决策:`harden_new_game_creator_private_path` 只在本进程收紧 owner/DACL,失败则删除刚创建的对象,不 UAC 接管。项目锁先写再释放句柄再 harden,并用内容回读防换绑;UAC 仍只用于允许范围内的已有外人本对象。提权 helper 的 ArgumentList 改为一条按 Windows 规则加引号的字符串。
+- 影响范围:`config.rs` 的新建 harden 与提权命令行、`project/write_lock.rs` 的项目锁创建;不改变锁竞争、失效回收、Drop 删除,也不放宽 symlink / reparse / 外人本 fail-closed。
+- 验证方式:Windows 定向测试覆盖 `Genarrative GameAgent\gameagent-*` 取锁与私有 DACL,以及带空格路径的 quoted ArgumentList。
+- 关联文档:`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`、`docs/project-memory/shared-memory/pitfalls.md`。
+
+## 2026-09-05 Planning V2 的 3 轮策略与 8 个问题门禁有意不对称
+
+- 决策:模型提示最多提问 3 轮,并在达到 3 后要求出稿;Runtime `question_limit` 默认 8,对偏离模型策略的合法问题保留接收空间,达到 8 才拒绝新 question。前者是模型行为指令,后者是运行时接收边界,数值有意不同,不是缺陷或配置不一致。
+- 评审口径:Session/UI 的 8 是容量,不是必须问满的配额;正常路径在 3 轮或更早出稿符合设计。不得仅因数值不同,把模型提示改为按 `question_limit` 出稿、把 3 改成可继续到 8 的软目标,或把 Runtime 门禁收紧为 3。
+- 影响范围:仅补充文档解释,现有提示词、运行逻辑、校验和测试保持不变。
+- 关联文档:[策划会话 Runtime V2 接入与旧链路退役方案 §1.3](../../technical/【技术方案】策划会话RuntimeV2接入与旧链路退役-2026-09-03.md#13-问询策略与-runtime-门禁的不对称设计)。
+
+## 2026-09-04 Planning V2 将决定 ID 从 Provider 输入移回 Runtime
+
+- 背景:`plan_submit_gdd` 原先要求模型生成 `decisions[].id` 及原型验证项引用 ID。该字段既不是方案内容,又容易出现 `initial_request`、`initialRequest` 或错误层级,导致合法 GDD 在 Runtime 事后校验阶段失败。
+- 决策:Provider-facing `plan_submit_gdd` schema 和入参删除决定/原型验证项 ID。Runtime 按决定数组顺序生成首项 `initial-request`、后续 `decision-{序号}`,并按 `prototype_pending` 决定顺序给原型验证项绑定同一 ID。最终持久化 `plan-gdd.v2` 仍保留 ID,供审批、引用和 fingerprint 使用。V2 尚未上线,不为旧 Provider 输入或历史 V2 artifact 增加兼容转换;不符合新契约的历史数据按现有失败策略处理。
+- 影响范围:`planning_policy_v2.rs` 的工具 schema、Provider 入参解析、Runtime 产物构建与定向测试;Planning V2 技术方案。
+- 验证方式:定向 Rust 测试确认 schema 不含 ID、无 ID 输入可生成 Runtime ID;并运行 `cargo fmt --check`、`npm run check:encoding`、`git diff --check`。
+- 关联文档:`docs/technical/【技术方案】策划会话RuntimeV2接入与旧链路退役-2026-09-03.md`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_policy_v2.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_session_v2.rs`。
+
+## 2026-09-04 Planning V2 持久化每次 Provider 尝试的诊断产物
+
+- 背景:Provider 已成功返回但策略解析失败时,原 V2 只保留最终错误,无法核对实际请求、原始工具参数和单次重试结果。
+- 决策:在 `.agent/planning-v2/debug//` 保存每次尝试的 request、response 和分类事件;诊断文件不进入会话上下文,不参与恢复、重试或 GDD 业务判断,写入失败不改变主流程结果。
+- 影响范围:`planning_session_v2.rs` 的 Provider 调用外围和 Planning V2 技术方案持久化目录说明。
+- 验证方式:通过 Provider 请求/响应产物可还原每次尝试及 `toolCalls.arguments`,并确认主流程仍按原有解析、重试和状态转换执行。
+- 关联文档:`docs/technical/【技术方案】策划会话RuntimeV2接入与旧链路退役-2026-09-03.md`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_session_v2.rs`。
+
+## 2026-09-04 Planning V2 把 `gdd.vN.json` 创建成功当作提交点
+
+- 背景:V2 persist 先 create-only 写入不可变 GDD,再更新 index、Markdown、conversation 和 session。后续任一步失败会把 session 标成 `provider_failed`,但不回滚已创建文件;重试会重新生成 UUID/时间戳并撞上“已存在且内容不同”,hydrate 又只信 `current_artifact_version`,项目会卡死。
+- 决策:`gdd.v{N}.json` 创建成功即提交点,禁止回滚不可变文件。persist / hydrate / 回合启动若发现 session 指针的下一个连续版本已在磁盘,必须读取既有 GDD 补投影,不得用新的 LLM 入参重建身份。session 指针写成功前的投影失败仍可返回 persist 错误,但恢复路径必须认领该版本。
+- 影响范围:`planning_policy_v2.rs` persist/认领、`planning_session_v2.rs` hydrate 与回合启动;V2 技术方案。
+- 验证方式:Rust 测试覆盖孤儿 GDD 重试认领、hydrate 认领、成功提交后仍分配下一版本;`cargo fmt --check`、`npm run check:encoding`、`git diff --check`。
+- 关联文档:`docs/technical/【技术方案】策划会话RuntimeV2接入与旧链路退役-2026-09-03.md`。
+
+## 2026-09-04 PlanningSessionRuntime V2 用协议工具输出问询和 GDD
+
+- 背景:原型已验证 `plan_ask_question` / `plan_submit_gdd` 两个协议工具、深层 schema、提示词只留策略、`tool_choice=auto` 可跑通;生产 V2 仍解析正文 `{kind,question|gdd}` JSON,并把形状骨架写在 system prompt 里。浅 schema + 正文 JSON 会误导模型把 GDD 写成普通文本;`tool_choice=required` 与 DeepSeek thinking 不能同时使用。
+- 决策:V2 Provider 请求固定挂这两个协议工具,`tool_choice=auto`,`strict=false`。模型必须恰好调用其中一个;Runtime 解析 `toolCalls` 归一为 Question/Artifact,正文 JSON 视为非法。system prompt 只保留问询/出稿策略和当前问询进度,不再附 JSON 骨架或数量清单。入参不再要求模型回声 `schemaVersion`,落盘 GDD 仍由 Runtime 写入 `plan-gdd.v2`。既有结构门禁(含 `initial-request` 首项、`validate_plan_game` 数量/字数)不变,失败仍回灌一次。不把协议工具写入 `capabilities.tools`,不执行 MCP/Skill,不把 `tool_call`/`tool_result` 写入会话消息。
+- 影响范围:`planning_session_v2.rs` 请求构造、重试文案与 Provider 结果投影;`planning_policy_v2.rs` 工具 schema、解析和入参 `schemaVersion`;V2 技术方案。
+- 验证方式:Planning V2 定向 Rust 测试覆盖工具解析、缺 `schemaVersion` 的合法 GDD、正文 JSON 拒收、工具 schema 含嵌套 `game` 字段、提示词不再含骨架;`cargo fmt --check`、`npm run check:encoding`、`git diff --check`。
+- 关联文档:`docs/technical/【技术方案】策划会话RuntimeV2接入与旧链路退役-2026-09-03.md`。
+
+## 2026-09-03 新建策划会话采用 PlanningSessionRuntime V2,旧 Supervisor 链路直接退役
+
+- 背景:现有“做方案”依赖 `project-supervisor-plan` 根 Run、`project-planning` 子 Run、静态委派、delivery、Acceptance Graph 和审批前 evidence。新策划 Agent 只需要单 Agent 会话、问询、GDD 和审批;继续在旧 Runtime 上逐条放宽会保留身份/编排耦合。未来策划 Agent 可能支持无限多轮、MCP 和 Skill,需要避免把当前 8 题/GDD/no-tools 固化为 Runtime 根结构。
+- 决策:新增独立 `PlanningSessionRuntime`,复用 Provider/流式、会话持久化、项目锁、原子写和基础错误恢复;当前启用 `mode=gdd`、最多展示 8 个有效问题、GDD 审批和用户修改。新建“做方案”会话不创建 Supervisor root、planning child、delegation 或 acceptance evidence。V2 使用独立 `.agent/planning-v2/` 与 V2 schema,继续输出 `game/fast_gdd.md`;不自动转换旧会话。
+- 兼容性:Session 保存 `mode`、可空 `questionLimit`、`capabilities.tools/skills`;完整会话记录与 Provider 请求上下文分离;消息模型预留 tool/skill 事件类型但本期不执行 MCP/Skill。无限问询、上下文摘要、多产物和能力执行以后作为策略/能力层扩展,不重新引入 Supervisor 身份模型。
+- 当前进度:P0 合同冻结、P1 会话内核和 P2 GDD/审批核心已落地;P3 正式入口/UI 接入已开始,P5 旧链路退役尚未开始。
+- 退役:V2 切换时旧链路直接封存;所有未完成旧会话投影为 `legacy_retired` 失败,禁止继续问询、审批、恢复或 continuation。旧 GDD、approval、conversation 和 `.agent/planning` 文件只读保留;旧入口 caller 关闭,但不删除旧代码、旧测试或旧数据。
+- 影响范围:AGC 做方案入口、Rust/Tauri planning session/Provider adapter、GDD/审批 V2、前端 planning lane、阶段任务与 BDD 验收;做游戏/做素材 DirectProject 不变。
+- 验证方式:按 `docs/technical/【技术方案】策划会话RuntimeV2接入与旧链路退役-2026-09-03.md` 的 P0~P5 阶段验收执行;至少覆盖第 8 个问题、上限后 question 抑制、Provider 失败、非法输出、批准/修改/退回、重启恢复、旧会话切换强制失败、迟到 Provider 结果丢弃和当前空能力快照。
+- 关联文档:`docs/technical/【技术方案】策划会话RuntimeV2接入与旧链路退役-2026-09-03.md`、`docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md`。
+
+## 2026-09-03 PlanningSessionRuntime V2 统一 Agent 推断语义
+
+- 背景:旧 V1 使用 `default_pending` / `answerSource=default` 表示未提问时由 Agent 按默认建议补齐的字段;该语义会让 V2 的 Agent 推断看起来像产品默认值,也会造成原型与生产字段不一致。
+- 决策:V2 只使用 `confirmed`、`assumption_pending`、`prototype_pending` 三种决定状态;`assumption_pending` 的来源统一为 `agent_inferred`。`answerSource` 仍不是独立阻断项,缺失或不一致时按状态归一为 `user_freeform`、`user_option` 或 `agent_inferred`。V1 的 `default_pending` / `default` 校验和历史数据保持不动,不作为 V2 合同的一部分。
+- 问询策略:V2 出稿前必须确认玩家核心行为、单局目标/核心循环、MVP 制作边界;其中任一仅由 Agent 推断时继续问一个关键问题。`questionLimit` 是 Runtime 对已展示问题数的硬上限,提示词中的“默认最多三轮”只是策略偏好,不要求与硬上限数值一致。
+- 影响范围:V2 GDD 输入/产物、Provider system prompt、前端 V2 类型与决定状态展示;旧 Supervisor/V1 存储、校验和历史产物不变。
+- 验证方式:V2 解析 `assumption_pending` 不报错并落盘为 `assumption_pending/agent_inferred`;`default_pending` 不作为 V2 合法状态;核心三项未确认时提示词要求继续问询;相关 Rust/TS 定向测试、类型和编码检查通过。
+- 关联文档:`docs/technical/【技术方案】策划会话RuntimeV2接入与旧链路退役-2026-09-03.md`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_policy_v2.rs`。
+
+## 2026-09-04 PlanningSessionRuntime V2 将既有输出阻断原因回灌给 Provider
+
+- 背景:原型已将会导致输出拒收的字段、类型、数量和长度契约写入提示词,并在校验失败重试时回灌具体原因;生产 V2 仍只有简要 GDD 形状提示,模型可能重复犯同一结构错误。
+- 决策:生产 V2 只同步当前已经存在的 question/GDD 校验契约到 Provider system prompt,并在现有一次重试中明确列出本次阻断原因、要求逐项修复;不扩大校验范围、不新增门禁、不增加重试次数,也不把 `answerSource` 变成阻断条件。
+- 影响范围:`planning_session_v2.rs` 的 Provider prompt 与现有非法输出重试提示;`planning_policy_v2.rs` 校验逻辑、问询上限和持久化契约不变。
+- 验证方式:运行 Planning V2 定向 Rust 测试、`cargo fmt --check`、`git diff --check`,确认提示词构造和现有校验路径通过;不改变既有校验结果。
+- 关联文档:`docs/technical/【技术方案】策划会话RuntimeV2接入与旧链路退役-2026-09-03.md`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_session_v2.rs`。
+
+## 2026-09-04 PlanningSessionRuntime V2 提示词只保留形状和策略
+
+- 背景:把逐字段长度、数量、类型清单写入 system prompt 后,提示词与 JSON 骨架、Rust 校验器三重叠,模型也难以消化长清单;精确数字已由校验失败的一次重试回灌。
+- 决策:V2 system prompt 只保留问询/GDD 骨架、`game` 与 `decisions` / `prototypeValidationItems` 同级边界、状态枚举、首条 `initial-request` 约束,以及一行易错数量范围(options / keywords / pillars / coreLoop / mvpSystems / outOfScope / oneLiner)。不把逐字段长度、控制字符、label 去重等校验细则写入 prompt;校验范围、门禁和重试次数不变。
+- 影响范围:`planning_session_v2.rs` 的 Provider system prompt;`planning_policy_v2.rs` 校验逻辑与非法输出重试路径不变。
+- 验证方式:提示词含骨架与同级边界、不含逐字段长度清单;现有 Planning V2 定向测试、编码和 diff 检查通过。
+- 关联文档:`docs/technical/【技术方案】策划会话RuntimeV2接入与旧链路退役-2026-09-03.md`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_session_v2.rs`。
+
 ## 2026-09-03 AGC 登录 route event 使用 handler 已验证主体归属
 
 - 背景:登录请求进入时尚未拥有 `AuthenticatedAccessToken`,通用 tracking middleware 无法从响应 extensions 归属登录成功用户;将 AGC marker 直接写入按用户/业务日幂等的 `daily_login` 又会受到不同来源登录顺序影响。
@@ -1507,6 +1598,7 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
 - 背景:主站与图片画板的泥点余额入口、余额明细和充值弹窗存在不同实现,旧充值口径仍展示六档泥点、首充双倍和会员购买 / 升级入口,容易让展示、商品资格与后端余额真相发生漂移。
 - 决策:主站与图片画板统一复用公共泥点资产入口,收起态展示总额与充值,展开态只展示不限时泥点、每日免费泥点和使用详情;充值中心 BFF 继续统一下发总额、三桶余额、限时到期时间、每日免费基础重置额及下次重置时间,前端不得自行相减推算,但会员周期限时泥点仅用于存量兼容和后端结算,当前版本不在前台展示。钱包明细每次展开都重新读取充值中心 BFF,打开期间实时总额变化时继续补读;图片画板的生成扣费或退款完成后同时刷新总额与充值中心拆分。充值中心读请求必须使用 revision 门禁,支付创建、到账确认等权威响应写入时使旧读失效,避免旧响应覆盖新的每日免费 / 不限时明细。默认泥点商品收敛为 `60 / ¥6`、`180 + 90 / ¥18`、`300 + 150 / ¥30`、`680 + 340 / ¥68` 四档,`60` 档无赠送,后三档按现有 `user_id + product_id` 独立资格规则首次购买加赠 `50%`。当前版本关闭会员购买页签、会员商品和购买 / 升级入口。
 - 2026-07-17 追加:主站、图片画板与 AI 游戏创作独立 App 的泥点账单统一复用 `packages/shared/src/components/PlatformProfileWalletLedgerModal`。共享组件只依赖 `ProfileWalletLedgerResponse`,承接来源 label、金额正负号、UTC 日期、余额兜底和 loading / empty / error 展示;`/api/profile/wallet-ledger` 请求、鉴权、打开状态与重试生命周期继续由各宿主持有,不把账户事实或后端副作用下沉到共享 UI。
+- 2026-09-07 追加:资产扣费在既有钱包流水 metadata 中记录服务端确定的 `assetKind`,`GET /api/profile/wallet-ledger` 只把白名单类型映射为可选用户文案 `reason`,不暴露原始 metadata、资源 ID、任务 ID 或未知内部枚举。共享账单组件优先展示非空 `reason`;历史、未知和空 metadata 继续按 `sourceType` 回退为“资产操作消耗”,不得由客户端猜测业务类型。
 - 影响范围:`profile_recharge_product_config` 默认商品、充值中心 read model、共享前后端契约、主站与图片画板泥点资产入口、充值弹窗、后台充值商品默认值。
 - 验证方式:充值与统一入口定向前端测试、`npm run typecheck`、充值商品定向 Rust 测试、`cargo check -p spacetime-module -p spacetime-client -p api-server --manifest-path server-rs/Cargo.toml`、`npm run check:encoding`、`git diff --check`。
 - 关联文档:`docs/【项目基线】当前产品与工程约束-2026-05-15.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。
@@ -7960,6 +8052,19 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
 - 新建/恢复 ephemeral Codex thread 时,replay 使用 `contextWindowTokens`、`autoCompactTokenLimit`、本次 `maxOutputTokens` 与 4096 安全余量计算预算,从最新记录向前选择连续完整的 `user` / `assistant` / `tool` 行;超预算旧前缀被省略,单条记录不截断,当前 user request 始终保留。
 - 发生省略时在 prompt 开头加入普通 `system: Earlier conversation history was omitted due to context budget.` 提示;当前请求本身超过硬上下文预算则直接失败。该策略是 Direct 专用滑动窗口,不复用 Runtime Agent 的摘要、tail 或 session compaction 生命周期。
 
+## 2026-09-05 共享 CanvasWorld 统一超采样与资源页迁移
+
+- 背景:资源总览页曾在业务层重复实现 world 的渲染与尺寸逻辑,需与网站 / Tauri 共用的 `@genarrative/image-canvas-react` 统一。
+- 决策:`CanvasWorld` 的公开契约为逻辑 `viewport`,最终视觉比例由 `viewport.scale` 定义。渲染细节由共享包封装,调用方不得自行换算。所有调用方继续使用共享默认正方形 `CANVAS_WORLD_SIZE = 12000`,不引入 `worldWidth/worldHeight` 等矩形 API。资源页 `navigationBounds` 只保留布局、fit、依赖线 geometry 和 data 属性用途,不再驱动 world DOM 尺寸。非缩放描边只应用于资源依赖关系线等几何 overlay,静态卡片图标不继承统一 SVG 描边规则;位图维持浏览器默认插值,不全局启用 `pixelated`。
+- 影响范围:资源总览迁移为直接渲染共享 `CanvasWorld`,卡片控件通过共享 inverse-scale CSS 变量保持屏幕级尺寸;Asset Canvas、UI Editor 和依赖线继续消费相同 viewport 坐标与事件换算,不改变业务状态或持久化合同。
+- 验证方式:调用方测试以预期逻辑 `{ x, y, scale }` 调用公开 helper `canvasViewportToWorldTransform`,再与实际 world 的 `style.transform` 比较;不手写渲染换算,不以缩放按钮文案替代 viewport 断言。运行共享包与 AGC 定向类型 / 测试、资源页集成测试、`npm run check:encoding` 和 `git diff --check`,覆盖 SVG 描边、拖拽坐标、关系线端点和默认 world 尺寸。
+
+## 2026-09-07 DirectProject 用户消息由 AGC 预写并过滤 Codex 回显
+
+- `.agent/conversations/project.jsonl` 中的 DirectProject 用户消息由 AGC 在 `turn/start` 前以 `direct-codex:{clientTurnId}:user` 幂等追加;写入失败时禁止发起 Codex turn,失败或中断也保留该 user item。
+- Codex app-server 回显的 `userMessage` / `role=user` item 不是第二个历史来源。AGC 只处理其观察和关联,不再把该 echo 追加到项目历史;Codex 的 assistant、tool 和其它有效 response item 仍按现有 append-only 规则落盘。
+- 本地 AGC user-item 写入必须使用允许 user item 的内部入口,Codex raw item 写入使用过滤入口,避免“过滤回显”反过来阻断预写。相同 `clientTurnId` 只能复用相同规范化 prompt,内容冲突必须失败关闭。
+
 ## 2026-08-31 AGC 错误报告与诊断上传
 
 - AGC 采用 IDEA 风格的当前进程错误池:按 fingerprint 合并 React / window / Promise / Tauri / Agent 错误,重启后不恢复,不使用 run 或 run_id。
@@ -7983,6 +8088,37 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
 - 生成文件名保留可读清洗前缀,并追加 asset ID 的 SHA-256 摘要前缀以避免不同 ID 碰撞;不迁移既有旧路径,调用方需在采用新命名后使用新返回路径。
 - Radial90 的前端预览与 Rust 导出统一使用角点映射和顺时针起始角规则,顺时针填充从角点前一条边开始,避免两端渲染偏移。
 
+## 2026-09-03 策划会话 Runtime V2 P1 内核落地
+
+- 新增 `PlanningSessionRuntime V2` 内核入口:`start_planning_session_v2`、`continue_planning_session_v2`、`hydrate_planning_session_v2`。P1 只负责单 Agent 会话生命周期、Provider 流式/普通调用、消息持久化、回合幂等、单项目单活跃回合、耗时和失败恢复,不接入 GDD 解析、审批或旧 Supervisor。
+- V2 会话快照落在 `.agent/planning-v2/session.json`,完整消息落在 `.agent/planning-v2/conversation.jsonl`。同一 `clientTurnId` 已有成功 assistant 记录时直接重放;若只有 error 记录则允许沿用同一用户意图重试,不重复追加用户消息。进程退出后 hydrate 发现 `planning` 会投影为 `provider_failed/RECOVERY_REQUIRED`,不伪造成功或自动重试。
+- Provider 调用复用现有 `platform-llm` 的 API-kind、流式解析、超时和重试配置;当前策略快照固定 `mode=gdd`、`questionLimit=8`、`tools=[]`、`skills=[]`。GDD 业务规则留给 P2,生产 UI 接入留给 P3。
+
+## 2026-09-03 策划会话 Runtime V2 P2 产物闭环
+
+- V2 新增 `GddPlanningPolicy`:Provider 输出只接受合法 question 或完整 GDD JSON;question 在 `questionCount < questionLimit` 时保存并展示,第 8 个问题仍可展示,第 8 个之后再次返回 question 时只允许一次内部强制出稿重试,额外 question 不落盘、不进入用户等待态。
+- V2 GDD 使用独立 `plan-gdd.v2`,只保留项目身份、版本、游戏内容、决定、原型验证项和指纹,不带旧 Supervisor/Run/delegation 字段。每个版本写入 `.agent/planning-v2/gdd.vN.json`,同步更新 V2 index 和 `game/fast_gdd.md`;旧版本不可覆盖。
+- 新增 `decide_planning_artifact_v2` 与 `plan-approval.v2`。批准、修改、退回均绑定当前 Session、artifact、version 和 fingerprint;修改/退回必须有意见,修改后 Session 回到 `revision_requested`,下一轮由同一 V2 Session 继续。`answerSource` 缺失或未知值回退,不成为单独阻断项。
+
+## 2026-09-03 策划会话 Runtime V2 P3 入口与 UI 接入开始
+
+- 正式 AGC“做方案”入口在 `planningStartMode` 下直接调用 `start_planning_session_v2`、`continue_planning_session_v2` 和 `decide_planning_artifact_v2`,不再为新策划回合创建 Supervisor root、child Run 或 delegation。
+- 前端以适配层复用现有聊天区、澄清输入卡、GDD 审批卡和阶段进度条;V2 hydrate 返回 `conversation` 消息,用于页面刷新和重启后恢复可见历史。
+- V2 会话使用独立 `planning-session-v2-stream` 事件,旧 Supervisor Runtime 轮询、专业 Agent 轮询和旧 Runtime 事件不会介入 V2 会话。
+- 没有 V2 authority 的项目仍按旧读取路径打开;旧链路封存、未完成旧会话强制失败和旧入口彻底关闭仍留在 P5。
+
+## 2026-09-05 策划会话 Runtime V2 P4 收口
+
+- Planning V2 的 P4 灰度与回归验收已通过人工校验:正常提问/回答/GDD/批准链路、GDD 修改后再次批准链路、Provider 失败恢复、非法输出失败边界、重启恢复、前端工作台展示以及编码/差异门禁均完成验证。
+- P4 收口不代表旧链路退役;旧 `project-supervisor-plan` / `project-planning` 源码和入口仍保留,旧活跃会话封存、迟到结果隔离、旧入口关闭和只读历史展示统一留在后续 P5。
+- P5 收缩为最小退役:旧 `project-supervisor-plan` caller 统一立即返回退役错误,不启动旧 Runtime/Provider;不扩展 V1 `PlanSessionV1` schema,不做旧数据迁移,旧文件继续保留只读,V2 只读取 `.agent/planning-v2`。
+
+## 2026-09-05 修正 Planning V2 流式交互契约
+
+- Planning V2 不要求把 Provider 的文本 stream delta 逐条投影为用户可见对话。V2 的用户交互是结构化工具调用结果:`plan_ask_question` 渲染澄清选项卡,`plan_submit_gdd` 渲染 GDD 输出和审批卡;中间纯文本不是用户对话内容。
+- `planning-session-v2-stream` 若继续存在,只能作为内部状态/兼容事件能力,不构成实时逐 delta 的功能契约;Provider 是否使用流式传输不影响 V2 的业务验收。
+- 文档措辞约束:凡出现“流式响应”“流式事件”或 `text_delta`,均须注明其属于 Provider adapter/Runtime 内部实现能力;不得将其描述为前端必须逐条接收的用户可见消息。V2 的唯一用户交互结果是 `plan_ask_question` 和 `plan_submit_gdd` 的结构化工具结果,Provider 完成前是否产生多个 delta 不参与验收。
+
 ## 2026-08-29 AGC 官方 LLM 代理与 Windows 私有路径修复
 ## 2026-08-29 AGC 官方 LLM 代理与 Windows 私有路径修复
 
@@ -8034,3 +8170,40 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
 - 配置契约提升为 `game-creator-config.v2`:新模板默认 `stream=true`、受控搜索开启;无版本旧 DirectProject 配置仅在省略 `webSearchEnabled` 时把历史默认补为开启,旧配置显式 `false` 不覆盖,v2 显式 `false` 同样保留;Provider / Anthropic 未提供搜索覆盖时保持关闭。本地覆盖配置只补 schema 版本,不凭不完整 overlay 推断或写入 `agentMode` / 搜索布尔值;未知版本失败关闭。
 - 搜索结果始终是不可信外部输入,只可作为资料;工具 schema、参数、客户端权限、Agent 身份、系统规则和工具协议不得由网页内容修改。搜索结果与状态投影不携带 API Key、请求头、宿主绝对路径或 Provider 原始错误正文。
 - 验证锁定:`configuration`、`direct_tools_mcp`、`direct_tool_bridge`、`codex_app_server` 定向 Rust 测试,以及前端状态格式化 / AppSurface 测试;真实 Provider 登录态与真实公网搜索仍需单独现场 smoke。
+
+## 2026-09-08 策划 V1 链路源码整体退役(四不写)
+
+- 策划会话 Runtime V2 全量接管“做方案”入口后,旧 V1 链路(`project-supervisor-plan` 根 Run、`project-planning` 子 Agent、`plan.submit_gdd` 工具、Fast GDD 审批门禁与恢复车道)按四不写原则整体删除源码,不保留兼容实现、墓碑注释或防御性测试。
+- 删除范围:`runtime_protocol` 六个 V1 模块与四条 V1 提示词、prompt manifest planning 目录与生成常量、`capabilities.planning` 配置开关、CLI `--swarm-chat --plan` 与 `--plan-gdd-status/--plan-gdd-decide`、provider_retry 的 planning session binding、provider_action_batch 的 V1 提交批次校验、main_loop/pending_recovery/recovery_scan/acceptance_graph 的 plan 根特判、agent_db 三条 V1 专用持久车道(`agent.runtime.plan.provider_usage`、`agent.runtime.plan.gdd_decided`、`agent.runtime.plan_submit_gdd.committed`)及预留配额、前端旧 V1 读模型、`scripts/agent-swarm-test-chat.mjs --plan` 与 `test:plan*` 脚本。
+- 保留项:V2 与 V1 共用的 GDD 数据模型收敛到 `runtime_protocol/planning_gdd_model.rs`;`.agent/planning/` 旧文件不迁移、不删除,旧 `fast_gdd.md` 仍可只读打开;前端 `PROJECT_SUPERVISOR_PLAN_SOURCE` 字符串与 `GddApprovalCard.tsx`(`PlanGddSurface`)是 V2 现役的适配/展示面,不属于退役对象。
+- 旧 sidecar 中带已删字段(`clarification`、`continuation`、`requestId`、`turnId`、`pendingApproval` 等)的记录会因 `deny_unknown_fields` 拒绝反序列化,这是退役语义的一部分,不做迁移。
+- 测试基线说明:收尾时测试套件存在 25 个既有失败(mock LLM 时序敏感类,HEAD worktree 对照验证与本次无关),后续清理时不要误记到本次退役头上。
+## 2026-09-08 Planning V2 正常 run 优先的恢复旁路
+
+- 恢复只利用已经落盘的合法 question、GDD 和 approval receipt;不调用 Provider、不要求模型额外输出恢复字段、不设置复杂状态机或自动重试。
+- 正常 run 进行时不读取或写入其 Planning V2 文件,也不增加文件锁或等待;无活跃 run 时恢复只做一次非阻塞锁尝试,竞争即退出,交给既有轮询。
+- question 恢复为成功结果,approval 重放复用 receipt 原始 decisionId。
+
+## 2026-09-08 Planning V2 用户输入与异步投影身份
+
+- 问题回答携带被回答卡片的 questionId,在已有回合锁内核对 Session 和当前问题;自由文本回答同样绑定问题,已完成回合保留幂等重放。此身份匹配服务于用户提交,不增加恢复门禁或模型输出要求。
+- hydrate 结果(包括空结果)写入前端状态前同时核对请求序列和当前项目路径;过期结果直接丢弃,不重试、不阻塞正常 run。
+
+## 2026-09-09 项目写锁残留回收与启动诊断
+
+- 决策:`.agent/project.lock` 记录 `processStartedAt`;PID 存活时用启动身份区分“原持有者仍在”与“PID 被复用”,身份不一致才回收。旧锁无该字段时用“进程启动时间晚于锁 `createdAt` + 5 秒容差”推断,锁创建时间未知时不做该推断。空锁 / 坏锁(崩溃停在 `create_new` 与落盘之间)宽限期 30 秒,无法判定存活时保持 600 秒,mtime 不可读时按未知年龄不回收。活持有者始终不回收。
+- 决策:回收判据与删除基于同一次读到的锁文件快照——payload 只解析一次,删除前重新核对字节,只有内容仍是判定时的内容才 unlink;文件已消失或被替换时重试 `create_new`,不把并发回收当成错误。
+- 决策:启动诊断日志用 `StartupLogSlot`,优先用已生效的配置目录(含 `--config-dir`),否则退到平台配置根(Windows APPDATA、macOS Application Support、其它平台 `XDG_CONFIG_HOME` / `~/.config`),配置目录就绪后再切换;`startup.*.failed` 与 `show_startup_error_dialog` 必须可达,日志路径未知时也必须给出用户可见提示;Windows 启动失败弹系统消息框,其它平台写 stderr。
+- 边界:`agent-runner.lock` / `agent-runner.gui-owner.lock` 是 OS 独占句柄锁,进程退出即释放,残留文件不阻塞下次启动;不要把它们当成项目写锁的同类残留处理。
+- 边界:锁文件里的 PID 若超出平台进程号空间(Unix `pid_t` 是有符号 32 位、Windows 是 32 位,均恒大于 0),它不可能属于任何活进程,按“持有者不存在”直接回收,不再落回 600 秒保守分支。
+- 验证:`project_lock_recovery` 11 条与 `diagnostic_log` 7 条定向测试通过,真实二进制双实例复现“第二个实例写 `startup.runner.owner-lock.failed` 并弹出可见提示”。
+
+## 2026-09-10 Direct 写通道纳入统一项目锁等待窗口并补齐持锁方可诊断
+
+- 背景:Issue #318。`agc_write_file` 是用户直接触发、失败即整轮无法落盘的项目写入通道,却用零等待取锁,任何重叠都在 24-42ms 内被投影成“项目正在被其他写操作占用”;同一形状已在 2026-07-22 由 `file.write / file.patch / file.delete` 用有界等待修过,本项目技术方案的 2026-08-13 一节也已规定这类争用结果“统一投影为争用并进入既有有界等待”。现场取证还缺 `commandId / pid / createdAt / ownerIsSelf`,无法回答“谁在持锁”,加上 `create_new` 把 ACL 拒绝、delete-pending 和真实跨进程争用压成同一句话,排障被引向“残留锁”。
+- 决策:① Direct 写路径改用 `acquire_game_creator_agent_runtime_project_write_lock_with_wait`,与其它写入口同语义,同一轮并行写按同一把锁串行。①′ 这条等待是同步轮询(最多约 10 秒),handler 是 async,因此写路径经 `spawn_blocking` 走阻塞线程池:直接在 handler 里同步等待会占住 tokio worker,争用窗口内并行写多个文件时会波及共享同一 runtime 的只读端点与 UI 命令,破坏 Issue #318 现场“只读工具全部正常”的诊断特征。② 争用错误前缀逐字不变并追加持锁方身份;`create_new` 失败拆成可重试(进入有界等待)/ 权限拒绝(失败关闭,文案不含争用前缀)/ 其它三类,**重试性只看错误码**:Windows 的 `ACCESS_DENIED(5)` 与删除拆链窗口在错误码上不可区分,一律先按可重试处理,等满预算且目标仍不存在时才由 `exhausted_projection` 改判成权限拒绝(单次试探不改判);`sharing violation(32)` 与 `lock violation(33)` 恒定归可重试。③ 等待预算耗尽时按 `project.write_lock.wait_exhausted` 记录持锁方快照、等待时长与 `projection=`(contention / permission_denied),Unix 上明确判定的权限拒绝按 `project.write_lock.permission_denied` 记录;这条日志与终态改判都只在真的等过(`max_attempts > 1`)时发生,hydrate 的单次试探既不写日志也不改判。④ 重试与否改由类型决定:`acquire_project_write_lock_failure` 返回 `ProjectWriteLockFailure::{Retryable, Terminal}`,有界等待按 `is_retryable()` 分流,`acquire_project_write_lock` 只是它的文案包装;`PROJECT_WRITE_LOCK_CONTENTION_PREFIX` 同时收口 `provider_recovery.rs` / `planning_session_v2.rs` / `direct_runtime.rs` 三处手写文案。⑤ 平台判据的每一环都要带平台:终态改判也曾只比 `ErrorKind`,而 errno 5 在 Windows 是 `ACCESS_DENIED`、在 Linux 是 `EIO`,CI 直接把它判成 `contention`;现由 `Retryable { platform, path, source }` 携带平台。
+- 为什么不按“目标是否存在”当场分类:目标被删除时目录项先消失、删除挂起随后才结束,`create_new` 会在这个拆链窗口里返回 `ACCESS_DENIED(5)` 而 `exists()` 已经报 false(本机 6 万次建锁 / 删锁竞争实测 396-538 例命中该组合)。按一次 metadata 观察判成权限拒绝,等待层会立刻失败关闭,等于把 Issue #318 的“毫秒级直接失败”换成更误导的 ACL 文案;这也是对 2026-08-13 已定口径“这些结果统一投影为争用并进入既有有界等待”的回归。
+- 复用既有实现:回收判据沿用 2026-09-09 的 `ProjectWriteLockSnapshot` + `project_write_lock_reclaim_decision` + 字节 CAS 删除,不新增第二套回收机制;本次只给快照补 `commandId` 与 `describe_holder()`,供错误文案和日志使用。
+- 不做什么:不放宽 `.agent/project.lock` 的项目级串行化语义,不引入可重入项目锁,不改“同一调用链禁止二次获取 `.agent/project.lock`”的既有约定,不改 AGC 多进程拓扑,不改 `pendingOperations` 语义,也不改“活持有者始终不回收”的既有判据。其它仍用零等待取锁的入口(`command.exec / project.verify / memory / conversation / task / checkpoint / 预览 / UI 编辑器 / 资源编辑器 / Tauri 命令`)不在本次范围。
+- 验证方式:`project_lock_recovery` 追加持锁方身份与权限拒绝两条;`direct_tool_bridge` 追加同进程重叠写等待、同轮并行写、有界等待不占 runtime worker(默认 `current_thread` runtime 加心跳任务,同步阻塞会立刻让心跳停摆)、ACL 拒绝不投影成争用四条;`project/write_lock` 追加“重试性只由错误码决定”与“终态改判三条件”两条平台无关用例(平台作参数传入,Linux CI 覆盖 Windows 分支)。Windows 本机定向结果:`project_write_lock` 18 条、`bridge_write_file` 4 条、`parent_wake` 16 条、`waits_across` 3 条全过;CI(`71e9ad313`)四个 job 全绿,其中 Native shell tests 的首轮失败正是第 ⑤ 条平台判据缺陷。
+- 关联文档:`docs/project-memory/shared-memory/pitfalls.md`、`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`、Issue #318。
diff --git a/docs/project-memory/shared-memory/development-workflow.md b/docs/project-memory/shared-memory/development-workflow.md
index c7c81f915..d72defe9e 100644
--- a/docs/project-memory/shared-memory/development-workflow.md
+++ b/docs/project-memory/shared-memory/development-workflow.md
@@ -37,6 +37,8 @@
 
 ## 验证路由
 
+AGC 测试构造单 HTML 项目时,必须在初始化之前写入 HTML,避免自动建立 npm 工程;npm 预览和导出测试应提供 dist 产物。已有图片生成 pending/operation 属于持久化恢复合同,修改工具默认参数后仍须验证旧动作恢复不重复提交、不因默认值变化被误判为新意图。
+
 SpacetimeDB 任务统一先读取 `.codex/skills/genarrative-spacetimedb/SKILL.md`;该项目适配层按需调用已安装的官方 `spacetimedb` 插件 skill,插件提供通用 SDK/CLI/MCP 知识,项目 skill 负责 Genarrative 架构边界和验证门禁。
 
 按改动范围选择定向门禁,不以无关全量扫描代替契约验证:
diff --git a/docs/project-memory/shared-memory/document-map.md b/docs/project-memory/shared-memory/document-map.md
index 9d754cb5d..4f3754bf2 100644
--- a/docs/project-memory/shared-memory/document-map.md
+++ b/docs/project-memory/shared-memory/document-map.md
@@ -22,14 +22,19 @@
 AI 游戏创作 / DirectProject / UI workflow:
 
 1. `docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`
-2. `docs/technical/【技术方案】DirectProject客户端Skill与MCP扩展导入方案-2026-08-31.md`
-3. `docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md`
-4. `docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md`
-5. `docs/technical/【技术方案】DirectProject本轮附件路径映射-2026-08-31.md`
-6. `docs/technical/【技术方案】Direct回合行为审计账本-2026-08-31.md`
-7. `docs/technical/【技术方案】GameAgent资源自由画板与快速编辑-2026-08-20.md`
-8. `docs/【技术方案】UI工作流资源桥接与Runtime执行-2026-08-24.md`
-9. UI 编辑器、宿主壳和当前测试专题文档
+2. `docs/technical/【技术方案】策划会话RuntimeV2接入与旧链路退役-2026-09-03.md`
+3. `docs/technical/【技术方案】DirectProject客户端Skill与MCP扩展导入方案-2026-08-31.md`
+4. `docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md`
+5. `docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md`(仅存量旧链路)
+2. `docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md`
+3. `docs/technical/【技术方案】DirectProject客户端Skill与MCP扩展导入方案-2026-08-31.md`
+4. `docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md`
+5. `docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md`
+6. `docs/technical/【技术方案】DirectProject本轮附件路径映射-2026-08-31.md`
+7. `docs/technical/【技术方案】Direct回合行为审计账本-2026-08-31.md`
+8. `docs/technical/【技术方案】GameAgent资源自由画板与快速编辑-2026-08-20.md`
+9. `docs/【技术方案】UI工作流资源桥接与Runtime执行-2026-08-24.md`
+10. UI 编辑器、宿主壳和当前测试专题文档
 
 图片画布 / 媒体生成:
 
diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md
index ae3b84c2f..8eb7964ef 100644
--- a/docs/project-memory/shared-memory/pitfalls.md
+++ b/docs/project-memory/shared-memory/pitfalls.md
@@ -2,6 +2,50 @@
 
 > 当前口径:本文件保留可复用的排障经验;历史条目的旧路由、旧版本和已删除文档仅作根因背景,不得据此恢复退役入口。当前命令、路由和 schema 以代码与 `docs/README.md` 为准。
 
+## 2026-09-05 Planning V2 审批和续跑必须等过项目锁瞬时争用
+
+- **现象**:策划 V2 在 GDD 审批提交修改意见后提示 `项目正在被其他写操作占用:...\\.agent\\project.lock`,聊天区再出现 `项目总控 Agent 执行失败,请稍后重试`。
+- **原因**:V1 `decide_plan_gdd_at` / hydrate 已按完整或短窗口等待项目锁。V2 的审批、回合启动、策略落盘和 hydrate 直接 `acquire_project_write_lock`,与 GUI 重灌、刚结束的审批写盘或后台扫描撞车就立刻失败。修订后续跑走 `continue_planning_session_v2`,失败被前端写进总控错误位。这不是锁没释放,也不是 UAC。
+- **处理**:一次性用户意图(审批、回合启动、策略落盘、失败投影、GDD 认领)走完整等待窗口;V2 hydrate 走短窗口。前端 V2 hydrate 对锁争用保持上一份状态,不把瞬时占用画进审批卡。
+- **排查顺序**:先看错误是否点名 `project.lock` 且发生在提交修改意见或立刻续跑;不要当成总控 Runtime 或 Provider 失败。锁文件在失败后通常已被 Drop 删掉,现场缺文件不否定争用。
+- **验证**:Rust 定向覆盖 V2 审批、修订续跑和 hydrate 等过短暂占用的项目锁。
+
+## 2026-09-05 新建项目锁不要把继承 DACL 当成 UAC 事件
+
+- **现象**:策划 V2 在 GDD 审批提交修改意见时弹出权限窗口,目标是 `Documents\Genarrative GameAgent\gameagent-*\.agent\project.lock`,随后 `AGC ACL 提权修复未成功(exit code Some(1))`。
+- **原因**:#211 把新建 sidecar 纳入私有 DACL 门禁。父目录已是当前用户独占且禁止继承时,刚 `create_new` 的锁文件仍会短暂带继承 ACE;生产路径把这类 DACL 不合格送进 `--repair-private-acl`。独占句柄还会妨碍本进程 `SetNamedSecurityInfoW`。提权再用 `Start-Process -ArgumentList` 数组,含空格路径被拆开,helper 参数个数不对并以 1 退出。这不是 V2 审批协议或 Provider 权限请求。
+- **处理**:本进程新建对象只在进程内收紧 DACL,不因继承 ACE 自动 UAC。项目锁先写入并释放独占句柄,再 harden,回读内容校验后返回;不再对这把新锁走 `prepare_for_read`。UAC 仍留给允许范围内的外人本对象;提权命令行改为一条已加引号的 ArgumentList。
+- **排查顺序**:先看错误是否点名 `project.lock` 且含 `禁止继承` / `exit code Some(1)`;不要当成策划 V2 或 Provider 鉴权问题。含空格的 `Genarrative GameAgent` 项目根是复现条件,不是业务失败。
+- **验证**:Windows 定向覆盖含空格项目根取锁、新锁已满足私有 DACL、Drop 删除,以及提权参数把带空格路径保留为一个 quoted token。
+
+## 2026-09-04 Planning V2 不可变 GDD 创建后不能当没提交
+
+- **现象**:`gdd.vN.json` 已 create-only 落盘,但 index / Markdown / conversation / session 任一步失败后,session 停在 `provider_failed` 且 `current_artifact_version` 仍指向旧版本。重试会用新 UUID/时间戳再写同一版本号,命中“已存在且内容不同”。
+- **处理**:把该文件当作提交点。恢复时只认领 session 指针的下一个连续版本并补投影,不要删文件,也不要重建 GDD 身份。hydrate 和同一回合重试都必须走这条认领路径。
+- **排查顺序**:先看 `.agent/planning-v2/gdd.vN.json` 是否已存在、再看 `session.json` 的 `currentArtifactVersion` 是否落后;不要为了重试去覆盖不可变文件。
+- **验证**:孤儿文件重试后仍是同一 `gddId`/vN,hydrate 能看到当前产物。
+
+## 2026-09-04 DeepSeek thinking 不能与 tool_choice=required 同时使用
+
+- **现象**:DeepSeek V4(默认 thinking)对 `tool_choice=required` 或指定函数返回 HTTP 400:`Thinking mode does not support this tool_choice`。
+- **处理**:策划 V2 协议工具固定 `tool_choice=auto`,由 Runtime 校验必须恰好调用 `plan_ask_question` 或 `plan_submit_gdd`。不要按模型名分支,也不要用 required 强行出稿。
+- **验证**:请求体含 `tools` 且 `tool_choice=auto`;无工具调用时走既有非法输出重试。
+## 2026-09-09 常用设置跨文件保存失败
+
+主配置与 local overlay 的单文件原子写入不能保证整体成功;覆盖层写入失败会留下混合配置。保存前先序列化全部变更,多文件保存保留原内容,错误时逆序恢复并报告回滚失败;单文件保持原写入路径,成功后不回读、不触发外部诊断。此回滚仅处理可捕获错误,不承诺进程崩溃下的事务恢复。
+
+## 2026-09-09 `npm run agc` 的 Ctrl+C 不能只依赖 shell 包装层与端口健康检查
+
+- **现象**:`npm run agc` 按 Ctrl+C 后终端回到提示符,但上个工作树的 `api-server.exe` / SpacetimeDB 仍在监听 `8082` / `8083` / `3101`;切到另一个 worktree 再启动 AGC 时,前端仍然连到上个工作树的后端,在改过数据库 / schema 的工作树上会串库。
+- **原因**:
+  1. Windows 下所有长驻服务都由 Node `shell: true` 经 `cmd.exe /d /s /c` 包装层启动,Ctrl+C 会先杀掉包装层(退出码 `0xC000013A`)。`scripts/dev.mjs` 的 `stopProcess` 见到直接子进程已退出就直接 `return`,`start-dev-stack.mjs` / `start-tauri-dev.mjs` 对已退出 PID 的 `taskkill /PID  /T /F` 只会失败并返回 `stopped: false`,于是更深的 `cargo → api-server.exe` 没有任何人收。
+  2. 即使走到按根 PID 遍历进程树,遍历依赖快照里的父子链;中间层(包装层)先消失时链路断开,遍历只能拿到根 PID,深处的后端不可达。
+  3. 复用判据只看 `.app/dev-stack.json` 的 status 与 `/healthz`、`/readyz`、`/v1/ping`,从不校验端口上的进程属于哪个工作树;残留后端照样“健康”,因此被当成自己的后端复用。
+- **处理**:新增 `scripts/dev-windows-process.mjs`,同时提供按根 PID 遍历与按身份匹配(`server-rs/target/debug/api-server.exe` 绝对路径、SpacetimeDB `--data-dir`)两条独立清理路径。`dev.mjs` 在直接子进程已退出时也继续清理,并在退出时按身份兜底清扫本工作树后端(复用他人 standalone 时不清理)。`start-dev-stack.mjs` 在收到信号和 `finally` 各清扫一次本工作树 `api-server.exe`(仅限本次自己拉起后端的情况),复用前先校验端口监听进程归属,无法证明归属就不复用、改为启动自己的后端并允许端口漂移。
+- **排查顺序**:先看 `.app/dev-stack.json` 的 status 与实际监听端口是否一致,再用 `Get-CimInstance Win32_Process` 按本工作树 `server-rs\target\debug\api-server.exe` 路径与 SpacetimeDB `--data-dir` 核对残留进程;不要因为 `/healthz` 返回 200 就认定后端属于当前工作树。
+- **验证**:`node --check scripts/dev.mjs scripts/dev-windows-process.mjs apps/ai-game-creator-shell/scripts/start-dev-stack.mjs`;`npx vitest run scripts/dev-windows-process.test.ts apps/ai-game-creator-shell/tests/start-dev-stack.test.ts scripts/dev.test.ts`;真机确认 Ctrl+C 后没有匹配本工作树 `api-server.exe` 路径的残留进程。
+- **关联**:`scripts/dev.mjs`、`scripts/dev-windows-process.mjs`、`apps/ai-game-creator-shell/scripts/start-dev-stack.mjs`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
+
 ## 2026-09-02 Tauri 事件桥在浏览器预览中必须 fail-safe
 
 - **现象**:Vitest/jsdom 挂载 AGC 客户端时,错误报告通知调用 `@tauri-apps/api/event.listen`,因缺少 `window.__TAURI_INTERNALS__` 产生未处理拒绝;测试断言虽通过,CI 仍以 unhandled errors 失败。
@@ -4141,6 +4185,14 @@
 - 关联:`apps/ai-game-creator-shell/scripts/game-creator-config-wizard.mjs`、`apps/ai-game-creator-shell/scripts/agent-swarm-test-chat.mjs`、`apps/ai-game-creator-shell/scripts/check-config.mjs`、`apps/ai-game-creator-shell/tests/agentSwarmTestEntry.test.ts`。
 - 真实验收状态:外部 Provider 与画布 API 均可调用不等于全链路验收通过。2026-07-27 新起的独立轮次使用 `npm run agc:test:chat -- --timeout-minutes 75`,约 `59m50s` 后以退出码 `0` 完整 **PASS**:同一轮完成固定 `16` 个 manifest task exactly-once、七份基础产物、两张真实画布 PNG、当前 revision 静态检查、desktop / mobile `lane-defense-v1` playtest、唯一终态回复和安全清理;`turn.report` 的 busy / pending / running / confirmation / user-input / reconciliation 均为 `0`。此前失败轮、部分产物、单项接口成功和确定性结果仍不得与本轮拼接。
 
+## 仓库回退配置模板不能被读取通道私有化锁定
+
+- 现象:Windows 上 `apps/ai-game-creator-shell/game-creator.config.json` 莫名其妙被"加锁"(DACL 被剥成只剩一个陌生 SID,连 `Get-Acl` 都 unauthorized),开发 agent 和其他用户无法修改,cargo 也因 `include_str!` 读不到文件而不能编译;手动解锁后过一段时间又被锁。
+- 原因:无 AppHandle 的开发 CLI(`llm-status`、`agent-run`、`agc:test:chat` 等)经 `game_creator_config_paths()` 从 CWD / `current_exe` 向上回溯 8 级探测到 worktree 里的 git 跟踪模板后,读取走了为 AppData 私密凭据设计的私有通道 `open_project_private_regular_file` → `prepare_game_creator_private_path_for_read`;该函数名为 "for read",在 Windows 上却无条件收紧目标 DACL 为"仅当前进程用户、禁止继承"。沙箱 agent 是其 checkout 文件的 owner,校验通过后被静默私有化,其他账号全部 Access Denied。
+- 处理:配置读取按路径归属分流——`read_game_creator_config_file` 只对位于 `game_creator_runtime_config_dir()`(AppData 托管目录)内的真实凭据走私有加固读取;仓库旁边的回退模板 / local 覆盖一律走 `open_project_snapshot_regular_file` 非变异快照通道,读取绝不修改 owner / DACL。这与 `open_project_private_regular_file` 注释中"非用户明确选择的文件用 snapshot 读"的既有原则一致。
+- 教训:任何名为"读前准备"的函数若附带权限收紧副作用,都必须按路径是否属于本进程托管范围设白名单;共享仓库文件、git 跟踪文件永远不在加固范围内。排查"文件莫名被锁"时优先查 DACL owner 是哪位 SID,再倒推哪个进程以该身份运行过。
+- 验证:`tests::configuration::fallback_template_read_stays_on_snapshot_channel_outside_runtime_dir` 与 `runtime_config_read_stays_on_private_channel_inside_runtime_dir` 锁定两条通道的分流;`node scripts/check-config.mjs` 通过。
+
 ## 项目总控空态和持久 Runtime 不能依赖同一份 Session 索引
 
 - 现象:新项目尚未发消息时右侧总控区域只剩整块空白;已有 `needs-reconciliation` Runtime 的项目重新打开后,也可能看不到失败状态卡。
@@ -4996,3 +5048,38 @@
 - 外层 `start-tauri-dev.mjs` 应在启动 Tauri 前完成配套开发服务准备,并统一收束自有服务进程树;不要让冷编译和数据库发布挤占 Tauri 的前端就绪等待。自动发布必须保留数据库,不能靠清库解决启动问题。
 - CLI 与 standalone 可能是两个独立软链接。必须同时核对 `spacetime --version` 和 `spacetimedb-standalone --version`,不能把 CLI 的版本记录当作宿主版本证明;PATH 中存在宿主时启动器检查两者一致。更换宿主前停机备份数据,按原目录启动,不通过清库处理版本错配。
 - Router 配置缺失不应只在首次请求时报错。API/All 启动必须先校验官方地址、固定模型、provisioning secret、管理员 Token 和凭据加密密钥;否则服务看似 healthy,但登录后的 provisioning/模型调用才延迟失败。
+
+## 共享画布框选需要识别 world 的真实后代命中
+
+- 现象:共享 `CanvasWorld` 在 world 外层增加 `.genarrative-image-canvas__world-content` 后,点击或拖拽空白画布时事件 `target` 是内层 div;框选逻辑若只检查外层 world 本身,就会只清除焦点而不创建选框。
+- 原因:viewport 上的 pointer 事件通过冒泡接收,`event.currentTarget` 是 viewport,空白区域的 `event.target` 可能是 world 的任意后代,不保证命中外层元素。
+- 处理:框选命中判断使用 `Element.closest('.genarrative-image-canvas__world')`,并保留 viewport 自身命中路径;回归测试通过真实 `CanvasWorld` DOM 的 `pointerdown` 冒泡覆盖内层 world content。
+- 验证:`src/components/image-editor/useImageCanvasStageInteractions.test.tsx` 覆盖内层 world content 命中,定向交互测试通过。
+- 关联:`packages/image-canvas-react/src/useImageCanvasStageInteractions.ts`、`packages/image-canvas-react/src/CanvasWorld.tsx`。
+
+## 跨平台“死进程 PID” fixture 必须落在 Unix 有符号 32 位范围内(2026-09-09)
+
+- 现象:`project_lock_recovery::project_write_lock_reclaims_dead_owner_pid` 在 Windows 本地通过,在 Linux CI 报“死进程残留锁未被回收,实际错误:项目正在被其他写操作占用”。
+- 原因:fixture 用 `0xFFFF_FFF0` 当死 PID;Unix 的 `pid_t` 是有符号 32 位,`i32::try_from` 直接失败,存活判定返回 `None`(无法判定)而不是 `Some(false)`,于是落回 600 秒保守分支,残留锁不再被回收。
+- 处理:实现层把“平台不可能分配出的进程号”(0 或超出平台 pid 宽度)判为持有者不存在并直接回收;fixture 改用 `i32::MAX as u64 - 1`,另加 `u64::MAX` 非法进程号用例。
+- 验证:WSL Ubuntu 上 `cargo test --bin genarrative-ai-game-creator-shell project_lock_recovery` 7 条全过;Windows 上把可表示性判据临时回退到 HEAD 后,只有 `project_write_lock_reclaims_unrepresentable_owner_pid` 失败,说明该用例确实覆盖这条分支;Linux CI 的原始失败记录覆盖越界 PID 分支。
+- 关联:`apps/ai-game-creator-shell/src-tauri/src/project/write_lock.rs`、`apps/ai-game-creator-shell/src-tauri/src/tests/project_lock_recovery.rs`。
+
+## 锁文件回收的判定与删除必须基于同一份快照(2026-09-09)
+
+- 现象:两个实例同时恢复同一个崩溃项目时,后判定的一方可能删掉另一方刚装上的活锁;`remove_file` 的 `NotFound` 还会被当成硬失败,直接报“清理失效项目写锁失败”。
+- 原因:`project_write_lock_can_be_reclaimed` 只是快照观察,调用方拿到 true 后无条件 unlink;helper 还分别重读 `createdAt` / `pid` / `processStartedAt`,并发替换会拼出“旧 inode 的死 PID + 新 inode 的启动身份”。
+- 处理:payload 只解析一次并连同字节一起快照;删除前重新核对字节,只有内容仍是判定时的内容才 unlink;文件已消失或被替换时返回 false 并重试 `create_new`,不报错。
+- 补充:`project_write_lock_file_modified_seconds` 读不到 mtime 时不要返回 `0`——纪元 0 会被算成极大年龄,把保守判定反转成“立刻回收”,甚至把活持有者当 PID 复用抢走;要用 `Option` 区分“mtime 未知”和“mtime 等于纪元 0”。
+- 关联:`apps/ai-game-creator-shell/src-tauri/src/project/write_lock.rs`。
+
+## 2026-09-10 Direct 写通道零等待取锁把毫秒级竞争放大成整轮阻断
+
+- **现象**:AGC 新建项目后第一轮 Direct 对话里,唯一的项目写入通道 `agc_write_file` 每次都返回 `项目正在被其他写操作占用:<项目根>\.agent\project.lock`;同一轮 15 次写入全部 `status=failed` 且 `durationMs` 只有 24-42ms,而只读工具(`agc_list_project_files`、`agc_list_registered_assets`、`client.session.info`)全部正常,整轮无法写入任何项目文件。
+- **原因**:`.agent/project.lock` 是 `create_new` 存在性锁,Direct 通道却调零等待的 `acquire_project_write_lock`,与 App 自身其它写通道(美术 lane、revision、conversation、预览等)撞车就直接判死;而 `file.write / file.patch / file.delete` 等入口走的是约 10 秒有界等待。**失败耗时本身就是判据**:几十毫秒说明这个入口根本没等,同等争用在其它通道会被等待窗口吸收。此外错误文案不带 `commandId / pid / createdAt / ownerIsSelf`,又把 ACL 拒绝、delete-pending 和真实跨进程争用压成同一句话,现场很容易被误判成“残留锁”。
+- **处理**:Direct 写路径改用统一的有界等待;`create_new` 失败按可重试 / 权限 / 其它分三类并给不同文案;争用错误与等待日志都带持锁方身份,`ownerIsSelf` 区分“自己人”和“别人”。
+- **补充:重试性不能由一次 metadata 观察决定**。Windows 上 `create_new` 在目标被删除的拆链窗口里会返回 `ACCESS_DENIED(5)`,而此刻 `exists()` 往往已经报 false——本机 6 万次建锁 / 删锁竞争实测 396-538 例命中“5 + 目标不可见”。用 `path.exists()` 当场判成权限拒绝,等待层会立刻失败关闭,把同一个问题换成更误导的 ACL 文案。正确形状是:重试性只看错误码(`ACCESS_DENIED(5)` / sharing violation(32) / lock violation(33) / 已存在都可重试),终态改判放到等满预算之后——真的等过、目标此刻仍不存在,才改判成权限拒绝。分类判据把平台作为参数传入,Linux CI 才能覆盖 Windows 分支(CI 没有 Windows runner,`#[cfg(windows)]` 用例在 CI 里一次都不跑)。**判据的每一环都要带平台**:只看 `ErrorKind` 的判据在 Linux 上会给出相反结论——errno 5 在 Windows 是 `ACCESS_DENIED`、在 Linux 是 `EIO`(`Uncategorized`)。所以“等满预算再改判成权限拒绝”这第二个判据也必须连同平台与原始错误码一起传,否则 Windows 侧的行为在 CI 上永远测不到(本批第一次推送就是 CI 抓到 `permission_denied` 被改判成 `contention`:判据只比了 `kind()`)。
+- **补充:同步有界等待不能直接跑在 async handler 里**。这条等待是 2 000 × 5ms 的同步轮询(最多约 10 秒),而 `handle_direct_tool_bridge` 是 async:直接在 handler 里等待会占住一个 tokio worker,争用窗口内同一轮并行写多个文件时会有多个 worker 被占,而 bridge 与只读端点、UI 命令共享同一个 runtime——于是“只读工具全部正常”这条现场诊断特征会在争用窗口内失效,把排障引向错误方向(本次现场正是靠它判断“写锁没释放”的)。做法是把整条写路径挪进 `tokio::task::spawn_blocking`(仓库既有模式),等待语义与错误文案都不变;用例用默认 `current_thread` runtime 加心跳任务锁住这一点:handler 一旦同步阻塞,同一 runtime 上的心跳就完全停摆。**这类“零等待改成有界等待”的改动都要同时问一句:调用方是不是 async,等待窗口会不会占住执行器。**
+- **排查顺序**:① 先看失败耗时——几十毫秒说明该入口没等,是等待窗口缺失,不是锁没释放。② 看错误里的 `ownerIsSelf`:`true` 指向同进程另一条写通道,`false` 指向外部进程;该字段只比 PID,PID 复用会把外人报成自己人,只当线索、不当判据(回收判据另有 `processStartedAt` 兜底)。③ **锁文件在失败后通常已被 Drop 删掉,现场缺文件不否定争用**;同理 `agc_list_registered_assets` 的 `pendingOperations: []` 只表示没有在跑的付费生成,与项目写锁无关,不构成“锁没有持有者”的证据。④ `.agent/.manifest.json.lock` 是 manifest 的持久 OS 文件锁(Windows 不共享写句柄 / Unix `flock`),0 字节长期存在是设计如此,不是残留锁,也不要用项目写锁的回收判据去处理它。⑤ 看到“项目写锁路径权限被拒绝”时注意它的含义:这是**等满等待窗口后**的终态改判(Windows 上真实 ACL 拒绝就走这条路),不是某一瞬间的 metadata 观察;反过来,`项目正在被其他写操作占用:…(持锁方身份不可读:锁文件此刻不存在…)` 是零等待入口无法区分拆链窗口与 ACL 拒绝时的并列表述,两者不要互相否定。
+- **验证**:Rust 定向覆盖同进程重叠写等待、同轮并行写、有界等待不占 runtime worker、活外部进程持锁带身份、权限拒绝不投影成争用,以及两条平台无关判据用例(重试性只由错误码决定、终态改判三条件);`runtime_project_write_lock_waits_for_delete_pending_target` 继续覆盖带句柄的 delete-pending 必须等到成功。
+- **关联**:`apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs`、`apps/ai-game-creator-shell/src-tauri/src/project/write_lock.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs`。
diff --git a/docs/project-memory/shared-memory/project-overview.md b/docs/project-memory/shared-memory/project-overview.md
index 765f3a318..ca060acb0 100644
--- a/docs/project-memory/shared-memory/project-overview.md
+++ b/docs/project-memory/shared-memory/project-overview.md
@@ -51,9 +51,11 @@ SpacetimeDB crate、SDK、CLI / standalone 与生成 bindings 按 `2.8.3` 对齐
 
 ## AGC DirectProject 与 UI workflow
 
+- 新 Web 游戏为 `game/` 下的 npm + Vite + Phaser 4.2.1 工程,使用包导入且允许其它依赖;npm 预览与导出只读取 dist,运行素材需纳入构建,已有单 HTML/Godot 不自动迁移。
+
 - 通用 Agent Rust 分层为 `agent-runtime-core`(catalog、执行生命周期、ToolHost/spawn/all-join/Provider 契约)、`agent-runtime-orchestration`(动态无环任务图、ready、依赖波次、返工下游闭包和受限自主扩图提案)与 `platform-agent` 游戏适配器;循环返工通过新 pass / epoch 表达,不在单张依赖图中建立回边。LLM 可经宿主结构化 function call 提出新增节点/边,编排层只生成经校验的新候选图,epoch 与持久化仍由宿主掌控。
 - DirectProject 始终连接客户端内置的 `agc_tools` STDIO MCP,并在启动时额外读取客户端扩展仓库中已启用的第三方 MCP 独立项。第三方 STDIO/HTTP 配置只写入本次隔离 `CODEX_HOME`,单项非 required,启停、重命名和内容指纹进入 app-server pool identity;完整 Plugin Runtime、hooks/apps 和单文件脚本手动指定入口仍关闭。Skill 正文与 references 由 Codex 原生按需读取;`agc_tools` 负责标准美术准备、已登记资源有界查询、视频 / 角色动画 / 音效 / BGM 的 create-or-derive、已登记图片去背景、desktop/mobile 浏览器试玩和受控 `agc_web_search`;付费资源调用仍由客户端绑定回合、幂等账本、请求上限和投影权威。
-- DirectProject 的 Codex 原生文件、搜索、命令、图片查看和 Skill 仅在真实 `game/` cwd 与 `workspaceWrite(writableRoots=[game])` 内可用;原生命令网络保持关闭。多 Agent、Apps、插件、hooks、图片生成、Goals、Workspace Dependencies、Tool Suggestion 和原生浏览器/电脑控制保持关闭。app-server 使用隔离 `CODEX_HOME`,provider 凭据只由 AGC 客户端代理持有,不能进入模型上下文或 shell 环境。
+- DirectProject 的 Codex 原生文件、搜索、命令、图片查看和 Skill 仅在用户项目 cwd 与 `workspaceWrite(writableRoots=[project])` 内可用;原生命令允许联网以支持 npm 安装,npm 缓存位于项目内 `.npm-cache/`。多 Agent、Apps、插件、hooks、图片生成、Goals、Workspace Dependencies、Tool Suggestion 和原生浏览器/电脑控制保持关闭。app-server 使用隔离 `CODEX_HOME`,provider 凭据只由 AGC 客户端代理持有,不能进入模型上下文或 shell 环境。
 - `ui-prototype`(设计图片)与 UI 编辑器 `UI` JSON 是不同资源。白名单 `ui.workflow.run` 按页面执行 `prepare → recognize → status → finalize`,由 provider-backed 识别、合并和组件绑定持久化 State/revision,并把 `reference-ready → structure-ready → merge-ready → binding-ready → application-ready → completed` 投影到 manifest。Provider 缺失、请求失败、工具缺失、结果不匹配或仍有待审节点时保留真实阶段并返回 blocker,不得用 deterministic seed 伪造完成。
 - UI workflow 的资源桥接与 Runtime 边界以 `docs/【技术方案】UI工作流资源桥接与Runtime执行-2026-08-24.md` 和 AGC 实施计划的 2026-08-24 覆盖段为准;只生成图片、登记空 JSON 或进入普通图片画布都不构成 workflow 完成。
 
diff --git a/docs/technical/【技术方案】AGC后台模型别名与对话选择-2026-09-05.md b/docs/technical/【技术方案】AGC后台模型别名与对话选择-2026-09-05.md
index d70a5d096..b8e1a5335 100644
--- a/docs/technical/【技术方案】AGC后台模型别名与对话选择-2026-09-05.md
+++ b/docs/technical/【技术方案】AGC后台模型别名与对话选择-2026-09-05.md
@@ -5,13 +5,18 @@
 - 后台 owner 在“AGC 模型”维护列表;每项包含稳定 `id`、必填 `alias`、服务端 `modelId`、`enabled`。默认项必须启用。标识唯一,别名唯一,列表最多 32 项。
 - 配置保存到私有 `agc_model_catalog` 单例表,使用 revision 乐观锁,重启及多 api-server 实例共享同一事实。缺少配置时使用初始目录,高质量对应 `gpt-6-astra`,快速对应 `gpt-5.6-luna`。
 - `GET/PUT /admin/api/agc-models` 仅 owner 可用,返回完整配置;PUT 携带上次读取的 revision,冲突拒绝覆盖。
-- `GET /api/llm/models` 返回启用项的 `id/displayName` 和 `defaultModelId`,不返回实际模型名、Router 目录、凭据或能力原始数据。
+- `GET /api/llm/models` 返回启用项的 `id/displayName`、`defaultModelId` 和目录 `revision`,不返回实际模型名、Router 目录、凭据或能力原始数据。
+- 客户端缓存最近 `revision`,在项目切换 / 对话表面挂载 / 下拉展开 / 窗口聚焦时条件刷新:`revision` 未变化不更新界面,同一时刻只保留一个在途请求,刷新失败保留上一次有效目录与本地选择。发起对话前用同一份快照校验所选模型仍启用,已停用或删除则回退默认模型并提示。
 - AGC Responses 请求的 `model` 是稳定目录标识。服务端按当前目录映射实际模型名;未知、停用项拒绝,不回退其它模型。旧客户端无 AGC 标记时使用后台默认项。
-- 输入框右下角选择模型,只显示别名;选择保存到客户端配置 `selectedModelId`,从下一次请求生效。加载失败或选项停用时禁用提交并允许刷新,不显示实际 ID 作为兜底文案。
+- 输入框右下角选择模型,只显示别名;选择保存到客户端配置 `selectedModelId` 与 `selectedModelIsDefault`(当前选择是否来自平台默认项),从下一次请求生效。加载失败或选项停用时禁用提交并允许刷新,不显示实际 ID 作为兜底文案。
+- `selectedModelIsDefault` 为真表示选择由平台默认项驱动(首次进入、默认项变化、所选模型失效回退),后台默认项变化时客户端跟随切换并提示;用户手动选择后置为假,不再被默认项变化覆盖。
+- 首页聊天框架的右下角同样提供模型选择入口(与项目对话右侧一致)。首页入口与项目对话共用同一份目录缓存、挂载即加载(失败时沿用上一次成功目录),选择仅影响后续创建/发送的轮次,不阻塞「开启创作」,因此模型目录不可用时仍可创建项目并使用后台默认项。
+- 项目右侧对话的模型选择器在对话进行中保持可交互:切换模型只写回客户端配置并作用于下一轮,当前回合不受影响;发送按钮仍由 `controlBusy` / `modelReady` 把关。
 - 设置页恢复到布局改版前的官方代理版本,不包含模型管理或模型选择,保留配置安全清理和官方代理锁定。
 
 ## 验收
 
 - 目录领域校验、未知/停用模型拒绝、客户端响应不包含实际模型名。
 - 后台鉴权、持久化 revision 冲突处理;客户端选择保存后重新读取,设置保存不覆盖选择。
+- 目录 `revision` 条件刷新与并发触发去重、发送前回退默认模型、刷新失败可恢复。
 - AGC/admin-web 类型检查与定向测试、编码检查、Rust 定向检查、schema 一致性与 diff 检查。
diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md
index 66d3594ac..6d0008076 100644
--- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md
+++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md
@@ -1,5 +1,27 @@
 # AI 游戏创作智能体 App 实施计划
 
+## 图片生成恢复与测试边界
+
+已有持久生成账本的 Provider 待执行动作恢复时,若动作省略了旧视觉 Agent 自动补齐的参数,只在 Agent、动作身份、生成种类和冻结提示词均匹配旧合同后补齐缺省参数;显式参数不得被覆盖。新请求继续按当前自由图片合同执行,不能重新引入固定视觉产物门禁。恢复复用原 operation 与幂等账本,不因默认值变化重复提交已受理请求。
+
+单 HTML 测试须在项目初始化前准备 HTML;npm 项目的预览与导出测试须准备构建目录。图片测试按现行数量和布局合同验证资源、透明度、引用和持久恢复,不继续要求固定四切片。
+
+本地 Provider smoke 在启动 Agent 前准备已有的 `game/index.html`,按 JSON Generator 的单 HTML 项目合同验证生成、资源引用与浏览器预览。Agent 子进程失败时,诊断必须包含退出码、终止信号及有长度上限的 stderr/stdout 尾部,避免编译 warning 淹没实际错误。定位此阶段失败时单独运行 `npm run ai-game-creator-shell:agent-run:smoke`。
+
+## 常用设置职责
+
+常用设置负责运行参数的读取、编辑和保存,配置读写独立于账号权限诊断。账号权限由登录会话与实际智能服务请求链路处理,设置面板只维护配置草稿与读写反馈。
+
+保存配置复用写入前读取的高优先级本地覆盖内容:常用设置同步覆盖文件中已有的对应配置项,并保留当前模型 ID 和默认模型标记;模型选择仅同步 `selectedModelId` 与 `selectedModelIsDefault`;无冲突时不写覆盖文件。所有内容先完成序列化,多文件写入前保存原始内容,任一写入失败时逆序恢复已变更文件,回滚失败须明确报告。各文件沿用现有原子写入,不提供断电或进程崩溃下的多文件事务保证。全部成功后直接返回规范化配置,不执行保存后回读或外部诊断;单文件保存保持原路径。
+
+## 2026-09-08 Web 游戏 npm 与 Phaser 4 产物合同
+
+新建 Web 游戏使用 npm 工程:默认 `game/package.json` 声明 Phaser 4.2.1 和 Vite 构建工具,源码使用 `import Phaser from 'phaser'`,`package-lock.json` 由 npm 维护。默认脚手架文件位于 `game/`,包含 `index.html`、`game.js`、`style.css`、`vite.config.js` 和 npm 配置/锁文件;已有根目录 npm 工程沿用原根,可按需求拆分模块并添加任意其它 npm 依赖,不设置包名白名单。客户端不手工分发 Phaser bundle,不用 import map 模拟 package 导入。
+
+Agent 在包含 `package.json` 的目录执行 `npm ci`(依赖变更使用 `npm install`)和 `npm run build`;默认可从工作区根执行 `npm --prefix game ci` 与 `npm --prefix game run build`。DirectProject 保持 workspace-write 目录边界并开放网络,以支持 npm 依赖解析和安装;凭据仍由客户端代理持有,不进入项目或原生命令环境。其它执行模式维持现有权限。新游戏完成后执行 `npm run build` 并用真实浏览器试玩;依赖安装与构建失败必须反馈真实错误,不能回退成未解析裸模块导入的静态页面。
+
+npm 游戏的可预览产物固定为对应 package 目录下的 `dist/index.html`,静态 smoke 校验构建入口及本地文件引用,实际可玩性由浏览器验证。预览服务与导出读取该构建目录,所有运行素材必须由构建纳入 dist;npm 预览不回退读取源码或项目素材目录,保证试玩与导出一致。源码投影包含 package、锁文件、配置和真实游戏源码,排除 `node_modules/`、`dist/`、控制文件与凭据;npm 试玩包将 dist 文件映射到 `game/` 并附带发布说明,不包含源码依赖安装目录。现有单 HTML 项目不自动迁移,Godot 项目保持原合同。旧 JSON Generator 仅接受已初始化的单 HTML 项目,npm 项目或新建请求在调用 LLM 前明确拒绝并引导使用 DirectProject,避免静态草案伪装为 npm 构建产物。本节覆盖下文仅适用于旧单 HTML 产物的 Canvas API、手写动画循环和禁止外部本地脚本要求。
+
 ## 2026-09-02 项目名称显示与自动提炼
 
 - `.agent/manifest.json` 的 `name` 仍是本地项目显示名唯一事实源;项目组页行尾更多菜单提供行内重命名,保存必须走 Tauri 受控命令、项目写锁、manifest 写锁与既有 ACL/权限校验。重命名只更新 manifest,不改变项目目录、`projectId`、项目类型、任务、资源、版本或远端同步状态;保存成功后当前项目上下文、窗口标题和最近项目检查结果必须回读新 manifest 并保持一致。
@@ -58,6 +80,10 @@ Runtime 确认卡与普通聊天确认卡必须共用“信息区 + 固定操作
 
 ## 2026-08-19 UI Editor 节点右键菜单
 
+## 2026-09-05 UI Editor 节点树快捷删除
+
+左侧 `UI Tree` 每个可删除节点行在右侧提供桌面端快捷删除按钮,眼睛与删除按钮组成固定宽度的右侧动作列;整棵树保留横向滚动,深层节点的缩进、完整名称和组件数随树内容一起滚动,不使用省略号截断。沿用右键菜单的页面根节点禁删规则。快捷删除与右键删除共用页面级 `requestDelete` 入口:叶子节点直接调用现有删除命令;包含后代节点时先打开模态确认,正文显示节点名称及将同时删除的后代节点数量,按钮为“取消 / 删除”。确认期间使用现有 `ThemedModal` 的模态行为,取消或完成后关闭弹窗。底层 `deleteNode` 命令继续保持无确认,以兼容键盘 `Delete` 及已有状态测试;锁定态下快捷按钮和右键删除均不可执行。
+
 ## 2026-08-20 UI Editor 最终预览互斥子节点
 
 最终预览中,选中一个 `Exclusive` 父节点时,它的子节点切换条必须在该父节点自身的预览坐标空间内、紧贴节点上方悬浮;不得固定在预览容器左上角,也不得另行按屏幕坐标换算。点击 tab 必须显式选中对应子节点,不能按通用“切换可见性”语义把当前分支隐藏。`Exclusive` 父节点首次加载且尚未发生可见性操作时,必须默认且仅显示第一个直接子节点;用户手动隐藏全部直接子节点后必须保持全部隐藏,不得再次回退显示第一个子节点;空容器不显示子节点。切换条始终按内容宽度展开并允许溢出节点边界,不设最大宽度或内部滚动区域。从 `Exclusive` 切回 `Stack` 时必须清除全部直接子节点因互斥选择产生的隐藏状态并立即显示所有子节点,后代节点自身的独立隐藏状态保持不变。切换条仅改变现有子节点可见性状态,不能触发参考图重读、视口重新适配或预览树的异步重建。
@@ -183,7 +209,7 @@ Supervisor 认领该回执后,由父 run 自己为每个原 delivery 逐一创
 
 - Windows AppData 安全迁移:首次创建客户端 AppData 时必须以进程 `TokenUser` SID 显式设置 owner,并写入当前用户私有 DACL,不能把可能为 Administrators 的 `TokenOwner` 当作用户身份。发现历史目录 owner 不属于当前 `TokenUser` 时,不在原目录上放宽权限,而是拒绝 reparse point / junction / symlink 后,将旧目录原子重命名到同级唯一 `.owner-mismatch-backup-*` 备份,再新建并验证当前用户 owner 与私有 DACL;迁移或备份失败必须失败关闭,不覆盖旧配置。
 - Windows 私有文件初始化:父目录已归当前 `TokenUser` 后,新建 `.agent/.manifest.json.lock`、`agent-runner.lock`、endpoint 临时文件、project-owner 诊断临时文件与 real-E2E 私有文件的 owner 仍可能采用 token 默认 owner `Administrators`。manifest 固定锁和 Runner 固定 stale lock 只有在 Windows 不共享独占句柄已取得、且句柄确认普通文件、非 reparse point、链接数为一时才允许初始化或修复为当前 `TokenUser`,随后必须再次复核句柄并按既有 owner/DACL 门禁验证;其它临时文件只允许在本进程 `create_new` 成功且仍持有同一独占句柄时初始化 `TokenUser` owner / DACL,再写入、原子安装并严格复核,初始化失败必须清理刚创建的文件。既有 durable endpoint / diagnostic 读取不得自动接管;活锁不得截断,只有 sharing / lock violation `32/33` 表示占用,access denied 等其它错误立即返回。父进程观察到 Runner 子进程退出后立即返回错误,不等待完整 30 秒 deadline。
-- Windows ACL 提权边界:自定义 `--config-dir` 的启动前置检查必须把 `managed / user-selected` scope 一并传入提权子进程,不能依赖父进程内存中的配置目录覆盖;native picker 返回的文件或项目目录在同一进程登记短时授权,后续导入 / 项目操作只对登记路径(目录可覆盖其后代)允许 `user-selected` 自动提权,直接伪造 IPC 绝对路径不得获得该能力。项目文件列表 / 索引递归逐项拒绝 symlink 与 Windows reparse point,并在 metadata / read 前先完成 ACL 准备。
+- Windows ACL 提权边界:自定义 `--config-dir` 的启动前置检查必须把 `managed / user-selected` scope 一并传入提权子进程,不能依赖父进程内存中的配置目录覆盖;native picker 返回的文件或项目目录在同一进程登记短时授权,后续导入 / 项目操作只对登记路径(目录可覆盖其后代)允许 `user-selected` 自动提权,直接伪造 IPC 绝对路径不得获得该能力。项目文件列表 / 索引递归逐项拒绝 symlink 与 Windows reparse point,并在 metadata / read 前先完成 ACL 准备。本进程刚创建的普通文件或目录只在当前进程收紧 owner / 私有 DACL,不因继承 ACE 自动 UAC;UAC 只修复允许范围内、owner 不属于当前用户的已有对象。提权 `Start-Process -ArgumentList` 必须是一条按 Windows 命令行规则加引号的字符串,不能把带空格路径拆成多个 argv。
 - 启动恢复和续跑边界:本条取代上一条中“只有 accepted 才可恢复”的窄口径。若进程在 Supervisor 用户消息已持久、accepted 未持久之间崩溃,只读 preflight 可以把该 `preparing` 识别为可恢复,但不改写 task/conversation;真实 resume 持有 Agent 锁后必须先幂等补写 accepted,再提升为 `pending / queued`。用户消息或 accepted conversation 已落盘而辅助审计失败时,以 conversation 为公开真相继续入队,不留下“已接收但永不执行”的任务;根终态首次公开写入的瞬时失败必须在终态投影后用相同 message ID 重试。receipt / isolated-join 等带 parent 的 Supervisor continuation 不再另写 Session 终态,只保留单一后端公开事件;`runtime-task-*` 与 `runtime-public-status-*` 共享同 run 的不透明关联摘要,秒级时间戳下多个连续任务必须按实际 run 对应的 `user -> accepted -> terminal` 顺序交错展示。
 - ready-task 启动活性:`background_task.queued`、`autonomous_ready_task.scheduled`、Runner heartbeat 或执行锁已移交都不等于 child 已启动。实际持有执行权的 Runner 必须在释放项目写锁后同步写入 child 的 running task、`turn.started` 与 started journal,再把已启动 state 和 per-Agent 执行锁交给已确认开始轮询的独立 execution worker;同步启动或 worker 接管失败时,要在仍持有执行锁期间依次把 child 和 manifest Graph 节点明确落为 failed,再释放锁并让 parent 收到调度错误。`autonomous_ready_task.scheduled` 只作诊断审计,其写入失败不能阻断 durable child 启动;external client 只 wake Runner,不在客户端抢占执行。Supervisor 进度卡通过 durable `startedAt`(旧 Run 从完整 task journal 恢复,最新 task-record fallback 保持 0)显示真实持续时间,并以父 Run 与当前关联专业 Agent 的最大事件时间计算运行态活跃度:运行超过 5 分钟无新事件时显示“运行中 · 疑似停滞”和静默时长;等待用户、等待确认、Provider retry、视觉资产、进程会话、pausing 与 paused 不误报。父 Run terminal 后,持续时间冻结在父 Run 自身最后活动,不随 child 晚到收口事件增长。消息时间统一校验为 JavaScript 可表示的 Date;越界值显示“时间未知”且不写无效 `datetime`。实时回复只显示 response stream 自己的 `updatedAt`,缺失时同样显示“时间未知”,不能借用其它 Runtime 活动时间或随前端时钟漂移。该提示只提供可观测性,不改变 Runtime/manifest 正式状态。
 - ready-task 对账取消续跑:未知工具结果仍停在 `needs-reconciliation` 且禁止自动重放;人工核对后显式取消原 child,保留 cancel tombstone,旧 child 和旧父 Run 按真实终态收口。若随后创建同 Session、同 Supervisor source、同有效任务语义的 continuation,新完成合同只对同时具有历史 `failed / needs-reconciliation`、最终 `cancelled` 和 durable tombstone 的 ready-task,把当前 manifest 对应 failed 节点恢复为 pending,并由 scheduler 创建全新 child Run。manifest 的读取、failed 筛选、每任务一次的 child journal 索引、证据重验和写回必须位于同一项目写锁域;较新的无 child 根 Run 只有在 durable journal 精确表明为旧 failed Graph 在进入调度前即失败时才能跨过,scheduler 自身失败必须阻断借用更老 tombstone。普通失败、无 tombstone、不同 source/Session/任务语义或证据冲突均保持失败关闭;不得复活旧 pending action、补造 observation 或把取消任务标成 completed。
@@ -226,7 +252,7 @@ Supervisor 认领该回执后,由父 run 自己为每个原 delivery 逐一创
 
 - 模式升级:`agentMode` 扩为 `codex_app_server / codex_cli / provider`,新默认为 `codex_app_server`;V1.51 的一次性 `codex exec` 保留为显式兼容模式,HTTP Provider 保留为非 Responses 配置及故障回退模式。
 - 进程与节点:External Runner 按“有效 Agent LLM 凭据/Responses 路由 + `projectId/agentId/sessionId/runId`”隔离长期 `codex app-server --stdio`,即每个权威节点 run 直接持有自己的 Codex CLI 子进程与 ephemeral thread,每次完整权威请求映射 turn。同一节点 turn 串行,节点之间进程级隔离;单节点连接失败不得使其它节点同时失去终态。Codex thread 不写 durable recovery;节点完成、重启、retry、handoff 和 finalization 仍只认 AGC 账本。
-- DirectProject replay:Codex thread 仍保持 `ephemeral=true`。`.agent/conversations/project.jsonl` 是聊天唯一、append-only 事实源;每个 GUI turn 在发起 app-server turn 前,先把渲染后的规范化 user prompt 以 `direct-codex:{clientTurnId}:user` 幂等追加,持久化失败则不发起 turn 并公开 failed;LLM 失败 / 中断时保留该 user 记录,重试同一 `clientTurnId` 只复用它。仅当 app-server 连接没有可用的项目 thread(通常是进程重启或 thread 被淘汰)时,AGC 才读取历史,按原顺序渲染为简单 `user:` / `assistant:` / `tool:` 行,再追加本次新 user request,发送给新建 thread;已有 thread 的普通消息仍只发送新 user。为避免持久增长的历史超过模型上下文,replay builder 使用全局 `contextWindowTokens`、`autoCompactTokenLimit`、本次 `maxOutputTokens` 和 4096 安全余量计算输入预算,从最新记录向前选择连续、完整的消息;超预算的旧前缀只在本次 prompt 中省略,不改写 JSONL、不写 summary/sidecar、不拆分单条记录。发生省略时在 prompt 开头加入普通 `system: Earlier conversation history was omitted due to context budget.` 行;当前 user request 始终保留,若其自身超过硬上下文预算则直接失败。这里的简单 role 前缀和普通 assistant partial(末尾 `unexpected interrupt happened here`)仍是产品合同:保持 prompt 形状稳定、避免 envelope breaking change,并让模型明确知道上次输出在断开处结束。app-server 意外中断时,已收到的 partial 文本按普通 `assistant` 消息追加;断开处理和下一次发送都可尝试写入,依赖普通 `messageId` 幂等。项目打开只读取历史,不因 user-only 记录自动重发;Direct 不提供 retry 入口。Runtime Agent 继续使用独立的 runtime/context 恢复链路,不读取 DirectProject 对话作为原生 thread history。
+- DirectProject replay:Codex thread 仍保持 `ephemeral=true`。`.agent/conversations/project.jsonl` 是聊天唯一、append-only 事实源;AGC 是 DirectProject 用户消息的唯一持久化来源:每个 GUI turn 在发起 app-server turn 前,先把渲染后的规范化 user prompt 以 `direct-codex:{clientTurnId}:user` 幂等追加,持久化失败则不发起 turn 并公开 failed;LLM 失败 / 中断时保留该 user 记录,重试同一 `clientTurnId` 只复用它。Codex 回显的 `userMessage` / `role=user` item 只用于事件观察和关联校验,不得再次追加到项目 JSONL,避免把服务端 echo 当作第二条用户消息;本地 user item 写入与 Codex echo 过滤必须区分来源,不能用同一个“忽略 user item”入口阻断 AGC 自己的预写。仅当 app-server 连接没有可用的项目 thread(通常是进程重启或 thread 被淘汰)时,AGC 才读取历史,按原顺序渲染为简单 `user:` / `assistant:` / `tool:` 行,再追加本次新 user request,发送给新建 thread;已有 thread 的普通消息仍只发送新 user。为避免持久增长的历史超过模型上下文,replay builder 使用全局 `contextWindowTokens`、`autoCompactTokenLimit`、本次 `maxOutputTokens` 和 4096 安全余量计算输入预算,从最新记录向前选择连续、完整的消息;超预算的旧前缀只在本次 prompt 中省略,不改写 JSONL、不写 summary/sidecar、不拆分单条记录。发生省略时在 prompt 开头加入普通 `system: Earlier conversation history was omitted due to context budget.` 行;当前 user request 始终保留,若其自身超过硬上下文预算则直接失败。这里的简单 role 前缀和普通 assistant partial(末尾 `unexpected interrupt happened here`)仍是产品合同:保持 prompt 形状稳定、避免 envelope breaking change,并让模型明确知道上次输出在断开处结束。app-server 意外中断时,已收到的 partial 文本按普通 `assistant` 消息追加;断开处理和下一次发送都可尝试写入,依赖普通 `messageId` 幂等。项目打开只读取历史,不因 user-only 记录自动重发;Direct 不提供 retry 入口。Runtime Agent 继续使用独立的 runtime/context 恢复链路,不读取 DirectProject 对话作为原生 thread history。
 - LLM 配置:`apiKind` 始终只接受 `openai_responses`;非空 Key 转换为 app-server model provider,base URL 生效,Key 仅走专用环境变量;空 Key 只桥接用户 Codex `auth.json`,不继承环境 `CODEX_API_KEY`。设置面板在 app-server 模式继续显示并保存 model、effort、stream、全局/逐 Agent Key 与路由配置;`openai_chat / anthropic` 明确提示切 `provider`,不得悄悄忽略。`stream=true` 接入 app-server 文本 delta;`webSearchEnabled=true` 只允许 DirectProject 经客户端审核的 `agc_web_search` 使用,不得启用 Codex 原生 webSearch 或任意网络。
 - 安全与取消:临时 cwd、隔离 `CODEX_HOME` 与 OS HOME、read-only、network off、never approval,并在启动前关闭 web/multi-agent/shell/browser/plugin/image 等原生能力;取消从 turn-start pending 阶段就跟踪且只 interrupt 当前 turn。已发送 turn 后连接断开或终态丢失进入 reconciliation,只关闭当前节点进程且不重放同一 request slot;明确 failed/interrupted 不按 transport 重试。
 - remote-control 认证边界:没有 ChatGPT `auth.json` 的 API Key / provider-proxy app-server 在启动时设置 Codex 内部环境变量 `CODEX_INTERNAL_APP_SERVER_REMOTE_CONTROL_DISABLED=1`,让 remote-control 以 `desired_state=Disabled` 启动,避免上游进入 1Hz 认证重试;不再依赖需要 ChatGPT 登录态的 `remoteControl/disable` RPC。只有实际桥接 ChatGPT 登录态的 AuthBridge 保持 remote-control 可用。API Key 子进程同时使用 `RUST_LOG=warn` 收敛剩余预期噪音,不伪造 `auth.json` 或静默继续。
@@ -234,6 +260,13 @@ Supervisor 认领该回执后,由父 run 自己为每个原 delivery 逐一创
 - 旧配置迁移:既有 AppData 若没有 `agentMode`,只有全局和逐 Agent 路由均为 `openai_responses` 时迁移到 `codex_app_server`;存在 `openai_chat / anthropic` 时显式保留 `provider`,避免打开项目自动恢复时把所有节点批量写成 `invalid-config`。用户确认端点支持 Responses 后,可在设置中显式切换并保留原 model/base URL/API Key。
 - 验收:fake JSON-RPC fixture、三态 UI/config、配置指纹、unknown-terminal 零重放、旧两种模式回归和显式 ignored 真实 smoke 全部通过后,才可视为模式切换完成。
 
+### 2026-09-03 AGC 客户端能力以 MCP 暴露
+
+- MCP 暴露是客户端能力层,不替换 `codex_app_server / codex_cli / provider` 或客户端对话。客户端对话入口继续驱动 Codex app-server;app-server 通过客户端随附的 stdio MCP 子进程调用审核后的客户端能力。客户端不再替 Codex 做业务语义门禁、意图判断和完成判定。
+- MCP 会话在客户端握手时绑定当前账号、项目和实例,工具参数不得携带 `projectPath`、Token、Cookie、objectKey 或内部 URL。仅暴露稳定业务白名单与 `resources/list/read`,所有文件、资源、画布、预览和 operation 副作用继续复用客户端权限、锁、计费、幂等账本、manifest/revision 与恢复机制。
+- 客户端对话继续写入现有 conversation projection;外部 Host 如需旁路保存返回文本,可显式调用 `conversation.record_codex_response`。客户端将有界、脱敏正文、SHA-256、安全摘要和状态追加到项目级 journal,UI 只展示记录,不从文本推断业务状态或触发副作用。
+- MCP 子进程只由当前客户端为绑定项目启动,并在该项目工作目录内运行;客户端回合结束或客户端退出后子进程随 Codex app-server 一并回收。账号和项目权限仍由客户端业务桥接层校验,未知副作用保持 `needs-reconciliation`,只能通过 operation 查询恢复。稳定验收覆盖客户端对话驱动的 MCP 工具调用、Skill 指导资源、跨项目/账号拒绝以及旧 Provider/Codex 回归。
+
 ### 2026-08-10 Supervisor 边做边聊与条件中断
 
 - 根 Project Supervisor 的运行中消息继续进入当前 `taskId / sessionId / runId`,先持久显示“正在判断、当前任务继续”,再由独立 LLM 生成非终态语义回复并给出 `interruptCurrentProvider`。过程回复不能调用终态 `respond_to_user`,不能把制作 Run、Goal 或 task 提前完成。
@@ -549,6 +582,7 @@ game-project/
 - 中间主视窗提供 `资源管理 / 运行` 切换。`code-prototype` 任务完成前运行入口保持视觉不可用,但仍可点击查看“当前无可运行版本”,不能使用会阻断说明交互的原生 `disabled` 或 `aria-disabled`;完成后才允许进入运行表现层。切回资源管理只修改前端展示态,不伪造后端预览暂停结果。
 - 资源管理从当前 `GameCreationAppManifest`(包含可选 `versions`)、合法 Agent 文本回执和已导入附件派生资源,固定按文档、项目版本、美术资源、音乐音效资源分区;未知任务产物不再兜底为版本,任务声明中的未登记音频也不冒充正式音频。`按依赖 / 按类型` 使用各自前端排列,dependency 模式额外绘制当前 manifest 与资源投影可证明的依赖关系。排列与图层都不写回 manifest,不能推断或伪造缺失依赖。
 - 资源卡支持点击聚焦、搜索和类型筛选。2026-07-28 起完成两套二维坐标与本地 CAS sidecar;2026-07-31 起 dependency 模式增加不持久化的原生 SVG 关系图层。2026-08-03 mentor 决定暂缓资源卡拖动,当前卡片不挂载 Pointer Down / Move / Up / Cancel 拖动入口,只允许自动布局和点击聚焦。聚焦态替换中央主视窗内容,保留左侧导航、右侧对话和底部 Agent 状态栏,退出后恢复搜索、布局模式、滚动位置与选中资源;不提供工具栏、工具侧边栏或可拖动标题栏。2026-08-10 起聚焦态以资源元数据、Rust 权威深度、同类型上下游 / 任务流和版本信息为首屏;美术图片与视频只保留卡内本体,不在详情重复放大。安全文档正文与按意图读取的音频控制位于元数据之后;美术编辑、音频编辑 / 替换、版本替换或运行模块仍不在本阶段。
+- `@genarrative/image-canvas-react` 的 `CanvasWorld` 接收逻辑 `viewport`,`viewport.scale` 表示最终视觉比例;渲染细节由共享包封装。调用方测试将预期逻辑 `{ x, y, scale }` 传入公开 helper `canvasViewportToWorldTransform`,再比较实际 world 的 `style.transform`;不自行换算渲染值,不以缩放按钮文案替代 viewport 断言。world 继续使用共享默认正方形 `CANVAS_WORLD_SIZE = 12000`,不新增矩形尺寸 API;资源页的 `navigationBounds` 只用于布局、fit、关系线 geometry 和数据属性,不能再设置 world DOM 宽高。非缩放描边只应用于资源依赖关系线等几何 overlay,Lucide 等静态卡片图标保持自身正常缩放,避免被统一 SVG 规则压窄;普通位图不强制 `pixelated` 插值。依赖线、卡片拖拽、平移和滚轮锚点继续使用同一逻辑 viewport 坐标。
 
 #### 资源管理串行改造:本体卡、分区缩放与依赖聚类
 
@@ -870,7 +904,7 @@ game-project/
 - 聊天输入 `/status` 会读取 `.agent/manifest.json` 并在聊天里汇总项目目录、任务状态、资产数量、预览状态和最近命令,不向普通用户暴露任务或文件面板。
 - 聊天输入 `/files` 会通过 `file.list` 只读列出本地项目内的文件摘要;主窗口最近项目文件可一键读取,也可一键填入 `/read` 或 `/asset-register` 草稿,但资产登记仍必须走聊天确认;`/checkpoints` 复用 `file.list` / `file.read` 只读列出最近 checkpoint id、文件数、大小和可复制的 `/diff` / `/restore` 命令,主窗口最近 checkpoint 列表也可填入对应草稿;普通用户仍不暴露文件读写面板。
 - 聊天输入 `/assets` 会读取 `.agent/manifest.json` 并在聊天里列出本地项目资产路径、类型和来源,资产列表消息和主窗口最近项目资产入口都可一键填入对应资产的 `/read` 草稿;聊天输入 `/art` 只使用当前已加载 manifest 盘点美术素材,并提供首版美术生成或读取美术清单草稿,不直接读取文件、不触发平台生成或画板同步;聊天输入 `/audio` 只使用当前已加载 manifest 盘点音频素材,并提供登记音效或读取音频清单草稿,不直接读取文件、不触发资产写入;`/asset-register 路径 [kind] [mediaType]` 可确认后登记项目内已有资产;主窗口音效快捷入口只填入 `/asset-register assets/audio/sfx.wav audio audio/wav` 草稿,不直接写 manifest;普通用户仍不暴露资产面板。
-- 聊天输入 `/read 本地相对路径` 会通过 `file.read` 只读返回项目内文本文件内容并在聊天中截断长文本;普通用户仍不暴露文件写入或删除能力。
+- 聊天输入 `/read 本地相对路径` 会通过 `file.read` 只读返回项目内文本文件内容并在聊天中截断长文本;聊天回执中的正文必须包装为安全的 `
` 代码块,保留源码字面量但不得执行 HTML;普通用户仍不暴露文件写入或删除能力。
 - 主窗口常用生成产物入口只把 `game/index.html`、`game/game_design.md`、`game/balance.json`、`assets/manifest.art.json`、`assets/manifest.audio.json` 和 `exports/README.md` 的 `/read` 草稿填入聊天输入框;聊天输入 `/artifacts` 只列出这组固定读取命令并提供首个 `/read` 草稿,聊天输入 `/run-artifacts` 只列出最近 run trace 里的产物读取命令并提供首个 `/read` 草稿,聊天输入 `/run-files` 只列出 `.agent/output.jsonl`、`.agent/activity.jsonl` 和 `.agent/context.bundle.json` 的读取命令并提供首个 `/read` 草稿;读取仍由聊天侧 `file.read` 权限流执行。
 - 聊天输入 `/logs` 只列出 `.agent/logs/command.log`、`.agent/logs/preview.log` 和 `.agent/logs/agent.log` 对应的 `/read ...` 草稿 / 命令,并提供首个 `/read` 草稿;该命令不直接读取日志,不新增普通用户日志面板,实际读取仍由聊天侧 `file.read` 权限流执行。
 - 聊天输入 `/tasks` 会读取 `.agent/manifest.json` 并在聊天里列出专业组、角色、任务状态、产物交接和下一步可执行任务;普通用户仍不暴露任务面板。
@@ -1063,7 +1097,7 @@ game-project/
 - 内部 owner 验证只接受 GUI / CLI 完整 16 任务 DAG 中 `agent-ready-task-scheduler` 启动的确定性直接 child、当前活跃根和完整 project/source/profile/Agent/run/parent/root/binding 身份。错误 source、delegated run、历史或终态根、非当前活跃根、跨 Agent/run 凭证均失败关闭;再次 mutation 使旧凭证失效,相同身份恢复可按当前事实确定性重验。本阶段不扩到后置 `publish-package`。`code-prototype` 与 `preview-readiness` 继续执行真实 `game.static_smoke`,`preview-playtest` 继续独立执行浏览器验收;任何 owner 文件凭证都不能替代可玩证据。
 - `design-foundation` 的 2026-07-26 职责隔离继续有效:项目文件仍只允许 `memory/project.md`、`game/game_design.md` 和配置 Key 时的固定 `assets/ui-prototype.png`,禁止修改 `game/index.html`、调用 smoke / preview / process 或恢复整项目。未配置 External Editor API Key 时 `art-director` 保持只读协调;配置 Key 时它是条件 Canvas owner,必须生成并登记 `assets/art-spec.png`,成功 `canvas.asset_generate` 为本人当前 revision 形成普通验证凭证,不能被只读分类吞掉。配置 Key 时 UI 原型、透明图集、Canvas 登记和视觉门仍按既有合同执行,内部 owner 文件验证不替代图片证据。
 - `canvas.asset_generate.replaceExisting` 默认并必须保持 `false`;只有静态专业 Agent 的 `delegated-*` 唯一 repair run 才能申请 `true`。Runtime 要求当前 delivery 带 `repairOfDelegationId`,原 delivery 已被同一父 Agent / 父 run 认领,原始与返工合同的目标 Agent 和精确 `expectedArtifacts` 路径一致;普通 run、未声明路径、错误 Agent、未认领原交付或缺失原图都失败关闭。图片生成仍服从 `art-director` / `design-foundation` / `art-asset-plan` 的固定输出路径、比例、尺寸、kind 和 label,禁止先删除正式图片;请求前记录旧文件 SHA-256,外部生成返回后在项目写锁内复核,旧图在网络请求期间变化即拒绝覆盖。授权替换先写私有临时文件,再以备份 / rename 切换;落盘或 manifest 登记失败时恢复旧图,不把新旧文件并存状态当作成功。
-- 在既有 16-task manifest 内固定正式视觉 DAG,不新增平行任务系统:`art-director` 用当前调用模式的图片生成 `kind=spec` 生成 `assets/art-spec.png` 并登记为 `assetKind=icon-spec`;`design-foundation` 使用该规范图的稳定资源 ID 作为视觉规范参考,用同模式图片生成 `kind=ui-design` 生成 `assets/ui-prototype.png`;`art-asset-plan` 以同一 resource ID 调用同模式图标 spritesheet 生成,产出透明 `assets/art-spritesheet.png`。普通模式使用内部 `/api/editor/*`,standalone/高级模式使用对应 `/api/external/v1/*`;业务请求、依赖和验收完全一致。规范图缺失、未登记或缺少稳定资源 ID 时,下游任务不得退回普通生图。图集 warning、透明像素与切片门禁保持不变。
+- 视觉 Agent 只负责指导 Codex 选择合适的图片/编辑/图集工具并提供项目上下文,不再固定图片数量、文件槽位、素材类别或 spritesheet 布局;请求可按玩法需要生成单图、多图或任意切片布局。普通模式使用内部 `/api/editor/*`,standalone/高级模式使用对应 `/api/external/v1/*`;权限、计费、幂等、资源登记和安全校验保持不变。
 - 旧项目已有同路径派生图但缺少上述 provenance 时,一律标记为 legacy,不得只因文件、kind 或通用视觉检查存在就完成。原位替换仍走显式 repair:`design-foundation` 与 `art-asset-plan` 先在同一 Supervisor 批次分别建立 owner 精确原合同并交付 `needs-repair`,父 run 认领后再在同一批次分别发起各自唯一 repair;两个 repair 合称一个显式视觉返工阶段。`art-director` 不得跨 owner 声明或替换 UI / spritesheet,Runtime 在委派落盘前就拒绝这类合同,不再等到生图阶段才失败。
 - 2026-07-27 新起的“16 任务正式产物 + 两张真实画布图片 + current revision 静态 / 双视口浏览器 / PNG 证据 + 受限 repair 替换”独立外部 Provider 验收,使用 `npm run agc:test:chat -- --timeout-minutes 75`,约 `59m50s` 后以退出码 `0` 完整 **PASS**。同一轮真实生成并登记 `assets/ui-prototype.png`(`2829418` bytes)与 `assets/art-spritesheet.png`(`1361906` bytes),固定 `16` 个 manifest task 均为当前父 Run 下唯一 logical run、一次 started、一次 completed、零 failed / cancelled 和一次 manifest projection;七份基础正式产物、两张 PNG、当前 revision 的 `game.static_smoke`、desktop / mobile `lane-defense-v1` playtest、浏览器报告与截图全部通过。`turn.report=settled` 且唯一 assistant,busy / pending / running / confirmation / user-input / reconciliation 均为 `0`;隔离 Runner、一次性项目和隔离 AppData 已自动清理。此前失败轮继续独立保留,不与本轮拼接;未来合同变化仍须新起完整轮次复验。
 - 2026-07-27 补充 tool-plan 成功响应交接的内容边界:Provider 的自然语言计划叙述,以及结构化 arguments 中 `body / code / content / css / html / newText / oldText / patch / script / text` 等源码内容字段,只检查真实密钥 token 形状、凭据头标记和不安全控制字符;仅仅提及 `.env` 或 `game-creator.config` 不能阻断已经计费的安全响应。结构化输入中的敏感 JSON key、非内容字段中的配置痕迹或绝对路径、真实 token、容量、thinking、身份、顺序和账本完整性门禁仍失败关闭。成功 handoff 失败进入 reconciliation 时,Runtime 额外只持久化受控 `failureKind`、脱敏错误 SHA-256 和字符数,不保存 Provider 正文、function arguments、密钥或绝对路径。定向回归覆盖叙述/源码字段放行、`.env.local` 路径和真实 token 拒绝、全部 tool-plan handoff 回归及诊断零正文。
@@ -1265,7 +1299,6 @@ game-project/
 本文早期关于“DirectProject 关闭通用 shell、原生网络和主动工具”的描述属于迁移前基线,现由以下覆盖规则取代:DirectProject 仅在真实 `game/` cwd 与 `workspaceWrite(writableRoots=[game])` 内恢复 Codex 原生文件/搜索/命令、图片查看和 Skill;其余 ToolHost/DirectHome 合同不变。客户端审核的 `agc_tools` MCP 继续承担平台美术、资源登记、去背景、浏览器试玩和受控搜索,并保留项目锁、幂等账本、下载校验、恢复与投影权威。
 
 DirectProject 使用 `approvalPolicy=never`,避免每次原生调用再经过泛化 ToolHost 包装;原生命令网络保持关闭,联网资料继续走受控 `agc_web_search`。多 Agent、Apps、完整插件 Runtime、hooks、Goals、Workspace Dependencies、Tool Suggestion 和原生浏览器/电脑控制仍关闭,避免绕过 AGC durable delegation、浏览器证据和副作用审计;图片生成通过客户端审核的 `agc_tools.agc_generate_image` 暴露普通单图、角色图、视觉规范图和 UI 设计图,完整游戏美术包继续使用 `agc_tools.taonier_prepare_game_art`,两者都复用同一客户端登录态、幂等账本、下载校验和 manifest/revision 投影,不开放 Codex 原生 image tool。app-server 使用隔离 `CODEX_HOME`:内置 `agc_tools` 由客户端启动参数注入,用户在客户端扩展列表启用的独立第三方 MCP 以原生配置写入该次隔离 home;全局 Codex MCP、禁用项、Plugin hooks/apps 和其它插件能力不进入 DirectProject。第三方项固定非 required,配置或启动失败只记录该项,不替换 `agc_tools`;provider session token、工具桥地址和受控搜索标记不得通过第三方 MCP 的环境转发字段泄露。配置了 AGC LLM Key 或可解析的 `OPENAI_API_KEY` 登录态时,真实 provider 凭据只由 AGC 本地 provider proxy 持有,Codex 仅使用连接级随机代理令牌;无法安全代理的 OAuth `auth.json` 继续关闭 native shell/unified exec。`agc_tools` 的平台授权由 AGC 客户端当前登录会话和受控后端完成,普通客户端不得把 DirectProject 请求改成外部 API Key 请求;401/403 只投影为客户端登录或权限异常,不向用户索要凭据或暴露内部 URL。shell 子进程采用 `shell_environment_policy` core 继承及 secret/proxy/bridge 排除,provider key 和桥接凭据不得进入命令环境。系统提示词不再预注入项目源码快照或 Skill 正文,Codex 按需读取当前 cwd 文件。
-
 ## 2026-08-24 AGC UI 原型桥接与自主 UI workflow
 
 - 2026-08-24 起,`ui-prototype` 与 UI 编辑器的 `UI` JSON 资源明确分离。设计图生成后必须由白名单 `ui.workflow.run` 按页面执行 `prepare → recognize → status → finalize`:为每个功能页面创建并关联 `UI` JSON,载入页面设计图和已登记图片/图标/字体,调用 UI Editor 的 provider-backed 结构识别、多树合并与分批组件绑定,持久化 State/revision,写入 `game/` 应用标记,并把 `reference-ready → structure-ready → merge-ready → binding-ready → application-ready → completed` 各阶段的 `generationKind` 和 manifest revision 投影给客户端。Provider 未配置、请求失败、工具调用缺失、结果不匹配、未知字体引用、未产出可渲染组件或仍有待审节点时保留最近真实阶段并返回 blocker,不得使用 deterministic seed 冒充完成。工作台点击 `ui-prototype` 时通过 `ensure_ui_design_resource_for_prototype` 幂等补齐关联资源;工作流完成后自动打开首个页面的 UI 编辑器 `visual-binding` 最终阶段,交给用户检查和手动调整。UI 编辑器独立的语义建议请求也必须复用统一 LLM 传输选择,`llm.stream=true` 时发送 `stream=true` 并聚合完整工具调用后再校验结果。只生成图片、登记空 JSON 或进入普通图片画布均不构成 UI 工作流完成,详见 [`【技术方案】UI工作流资源桥接与Runtime执行-2026-08-24.md`](../【技术方案】UI工作流资源桥接与Runtime执行-2026-08-24.md)。
@@ -1276,6 +1309,15 @@ DirectProject 使用 `approvalPolicy=never`,避免每次原生调用再经过
 - 该档位不把最终验收条件提前成启动条件,也不把平台画布、preview、static smoke、发布包或其它平台产物检查作为 child 或根 Supervisor 的完成门。缺少平台产物不会把已完成任务重置为 `Pending`;根 run 只等待任务图进入终态并交回结果。
 - 代码可先按约定的项目路径落地并完成自己的工作;后续任务状态变化只负责唤醒同一根 run 继续收束,不因 `art-polish`、`art-asset-plan` 等非代码任务失败而阻塞代码启动。平台产物和可玩性检查若需要,属于后续独立验收,不是本档位的运行前置条件。
 
+## 2026-09-04 AGC 项目聊天 Markdown 与流式回复渲染
+
+- 项目开发工作台的 `ProjectWorkspaceChatPane`、`ProjectSupervisorView` 与 `SupervisorChatOnlyView` 继续共享 `ChatMessage` / `visibleMessages` 数据结构;聊天 Markdown 只作为前端表现层能力,不新增消息字段、持久化格式、后端 DTO 或事件协议。
+- 三个视图统一复用 `apps/ai-game-creator-shell/src/components/ChatMarkdownMessage`。assistant 历史消息和流式临时回复使用 `react-markdown + remark-gfm` 渲染;用户消息与命令草稿保持纯文本。调用方仍先执行现有的 `projectSupervisorVisibleConversationText` 安全文案归一化,再交给展示组件。
+- 流式回复以事件中的 `accumulatedText` 作为当前完整草稿:每次更新替换上一版临时正文,不在渲染层自行拼接 `deltaText`。既有 `runId` / sequence 去重、最终 assistant 持久化和历史回放语义保持不变。
+- Markdown 禁止原始 HTML;链接和图片只显示普通文本,不产生可点击或可加载的外部资源。后续若开放安全外链,必须另行评估协议白名单、窗口策略和审计边界,并保留实现 TODO。
+- Markdown 元素样式使用 Tailwind 内联 class,限定在聊天消息组件内部,不改全局 `.message`、资源文档预览或启动器全局 Agent 聊天。组件异常按单条消息回退纯文本,不能使整个聊天面板崩溃。
+- 流式更新沿用“仅在用户接近底部时跟随”的滚动语义;用户主动查看历史时不得被实时 Markdown 高度变化强制拉回底部。实现验收需覆盖 GFM、未闭合 Markdown 中间态、HTML/链接/图片安全、三视图一致性、流式去重、历史回放和桌面/移动视口。
+
 ## 2026-08-29 DirectProject 受控联网搜索闭环
 
 - 本次正式产品范围只包含 `DirectProject` 单 Codex Agent;`Provider`、`ToolHost`、`DirectHome` 不新增联网工具桥,也不纳入本次联网路由覆盖。受控联网唯一实现为 `agc_tools.agc_web_search`:Codex app-server 通过审核的 STDIO MCP 工具目录发起调用,客户端 loopback 工具桥执行固定 Bing RSS HTTPS 请求,过滤非 HTTPS、凭据 URL、回环 / 私网 / 本地域名,返回有界标题、摘要和结果链接,并以“不可信网页内容”标签回传。
@@ -1289,6 +1331,26 @@ DirectProject 使用 `approvalPolicy=never`,避免每次原生调用再经过
 - `/api/llm/responses` 与 `/api/llm/chat/completions` 的正式请求体上限为 `32 MiB`。两个路由必须显式配置 Axum `DefaultBodyLimit::max(LLM_REQUEST_MAX_BODY_BYTES)`;不能依赖 handler 内的 `Bytes / Json` 后置检查,否则 Axum 默认 `2 MiB` 会先拒绝 Direct Codex 携带图片工具结果的大上下文请求。超过 `32 MiB` 仍返回 `413 PAYLOAD_TOO_LARGE`。
 - Codex app-server 的 failed turn 需要把上游 / 连接层 HTTP 413、`PAYLOAD_TOO_LARGE` 和 provider proxy 的 `provider request too large` 映射为稳定分类 `codex-app-server-error:request-too-large`;用户可见文案固定为“模型请求体过大,请减少参考图或上下文后重试”,不得落入 `other` 或泛化成权限 / 安全策略错误。
 
+## 2026-09-09 项目写锁残留回收与启动诊断
+
+- `.agent/project.lock` 新增 `processStartedAt`(持有进程启动时间,Unix 秒)。PID 仍存活时必须先核对启动身份:身份不一致即判定 PID 复用,可直接回收;旧锁没有该字段时退回“进程启动时间晚于锁 `createdAt` 加 5 秒容差”的推断,锁创建时间未知时不做该推断。崩溃停在 `create_new` 与落盘 payload 之间的空锁 / 坏锁宽限期从 600 秒收紧到 30 秒;无法判定持有者是否存活、或 mtime 不可读时保持保守(前者 600 秒、后者不回收),活持有者仍然不回收。
+- 回收判据与删除必须基于同一次读到的锁文件快照:payload 只解析一次,删除前重新核对字节,只有内容仍是判定时的内容才 unlink;文件已消失或被替换时重试 `create_new`,不把并发回收当成错误。
+- 启动诊断日志改为 `StartupLogSlot`:优先用已经生效的配置目录(含 `--config-dir`),否则退到平台配置根(Windows APPDATA、macOS Application Support、其它平台 `XDG_CONFIG_HOME` / `~/.config`),成功后再切换到真实配置目录。`startup.*.failed` 与 `show_startup_error_dialog` 不再是死分支;日志路径未知时同样给出用户可见提示。Windows 启动失败恢复系统消息框并附诊断日志路径,其它平台写 stderr,同一进程只提示一次。
+- 边界与验证:残留的 `agent-runner.lock` / `agent-runner.gui-owner.lock` 是 OS 独占句柄锁,进程退出即释放,文件本身不阻塞下次启动;真正阻塞启动的是仍有活进程持锁。验证覆盖 `project_lock_recovery` 11 条(死 PID、非法进程号、空锁宽限、PID 复用时间推断、PID 复用身份不一致、身份一致不抢锁、旧格式活持有者不抢锁、新鲜空锁不抢锁、存活未知保守回收、mtime 未知保守回收、并发替换或已消失时不删除)、`diagnostic_log` 7 条,以及真实二进制双实例:第二个实例写入 `startup.runner.owner-lock.failed` 并弹出可见提示。
+
+## 2026-09-10 Direct 写通道项目锁等待、持锁方可诊断与权限分类
+
+- `agc_write_file` 是用户直接触发、失败即整轮无法落盘的项目写入通道,原先却用零等待 `acquire_project_write_lock`:任何重叠都在 24-42ms 内被判成“项目正在被其他写操作占用”,而 `file.write / file.patch / file.delete` 等入口用的是约 10 秒有界等待。现统一为 `acquire_game_creator_agent_runtime_project_write_lock_with_wait`:短暂重叠排队等成功,只有预算耗尽才报出带持锁方身份的错误;同一轮并行写多个文件按同一把锁串行。这是 2026-07-22 同一形状修复在 Direct 通道上的补齐,与 2026-08-13 一节“这些结果统一投影为争用并进入既有有界等待”的口径一致。**失败耗时是判据**:几十毫秒说明该入口没等,不是锁没释放。
+- 这条等待是**同步轮询**(2_000 × 5ms,最多约 10 秒),而 `handle_direct_tool_bridge` 是 async handler:直接在 handler 里跑完整条写路径会占住一个 tokio worker,争用窗口内同一轮并行写多个文件时会有多个 worker 被占,而这条 bridge 与只读端点、UI 命令共享同一个 runtime——Issue #318 现场“只读工具全部正常”这条诊断特征会在争用窗口内失效。因此写路径经 `bridge_write_file_in_blocking_pool` 走 `tokio::task::spawn_blocking`(仓库既有模式,如 `codex_app_server.rs` 的 DirectProject 历史落盘),等待语义与错误文案不变;定向用例用默认 `current_thread` runtime 加心跳任务锁住“等待期间 runtime 仍在推进”。
+- 争用错误必须带持锁方身份才可行动:`项目正在被其他写操作占用:<锁路径>(持锁方 commandId=<命令> pid=<进程> createdAt=<创建时间> ownerIsSelf=<是否本进程>)`。锁文件处于 delete-pending 或尚未写完时读不到身份,也必须显式表达成“不可读”,不得默认成“没有持锁方”。前缀逐字不变:`project_gates.rs`、`provider_recovery.rs`、`planning_session_v2.rs`、`direct_runtime.rs` 和前端 `App.tsx` 都按它把争用识别成可等待的瞬时状态;这句话已是 `crate::project::PROJECT_WRITE_LOCK_CONTENTION_PREFIX` 单一真源,四个站点不再各自手写中文。
+- `create_new` 的失败必须分三类处置,不能再共用一句文案:可重试(目标已存在、Windows `sharing violation(32)` / `lock violation(33)` / `ACCESS_DENIED(5)`)进入有界等待;明确判定不是争用的权限 / ACL 拒绝(Unix `EACCES`)失败关闭且文案不含争用前缀;其它 I/O 错误原样上报。**重试性只能由错误码决定,不能用 `path.exists()` 这类一次 metadata 观察决定**:目标被删除时目录项先消失、删除挂起随后才结束,`create_new` 会在这个拆链窗口里返回 `ACCESS_DENIED(5)`,而 `exists()` 往往已经报 false(本机实测 6 万次建锁 / 删锁竞争里 396-538 例命中该组合)。按“目标不存在”当场判成权限拒绝,等待层就会立刻失败关闭——正是本次要消灭的“毫秒级直接失败”,只是换成更误导的 ACL 文案。平台判据以 `project_write_lock_open_failure_for(platform, error)` 保留、平台由参数传入而不是 `#[cfg]`:CI 只有 Linux runner,Windows 分支必须在 Linux 上也能断言。
+- Windows 上真实 ACL 拒绝与删除拆链窗口在错误码上不可区分,所以终态改判放到**等待预算耗尽之后**:`ProjectWriteLockFailure::exhausted_projection(waited)` 只在“真的等过预算 + 目标此刻仍不存在 + 错误码是 `ACCESS_DENIED(5)`”三个条件同时成立时才投影成权限拒绝;单次试探(`max_attempts == 1`,例如 hydrate 的 `try_acquire_...`)没有等待证据,保持争用语义。代价是 Windows 上真实 ACL 拒绝会先等满等待窗口(约 10 秒)才报权限错误;Unix 的 `EACCES` 立即判定、不等待。
+- 重试与否改由**类型**决定,不再解析错误文案:`acquire_project_write_lock_failure` 返回 `ProjectWriteLockFailure::{Retryable, Terminal}`,有界等待按 `is_retryable()` 分流,`acquire_project_write_lock` 只是它的文案包装。零等待入口前缀不变,只在“错误码不可区分且目标此刻不存在”时补一句“可能是删除挂起、删除拆链窗口或权限 / ACL 拒绝”,把两种处置都交给调用方,而不是替它猜一个。
+- 复用 2026-09-09 的回收机制,不新增第二套:`ProjectWriteLockSnapshot` 补 `commandId` 与 `describe_holder()`,争用错误、`project.write_lock.reclaim_stale`、`project.write_lock.wait_exhausted` 三处共用同一份身份描述。“活持有者始终不回收”的判据不变。
+- 等待预算耗尽时按 `project.write_lock.wait_exhausted` 记录 `commandId`、尝试次数、等待毫秒数、`projection=`(contention / permission_denied)与持锁方身份,Unix 上明确判定的权限拒绝按 `project.write_lock.permission_denied` 记录;争用不在零等待入口里逐次记账,避免有界等待的上千次重试淹没日志。这条日志正是 Issue #318 现场缺的“谁在持锁、是不是自己人”。**这条日志与终态改判都只在真的等过(`max_attempts > 1`)时发生**:单次试探(hydrate 的 `try_acquire_*`)不写 `wait_exhausted`(`waitedMs≈0` 会让“耗尽”失去意义,而 hydrate 每次状态变化都会撞一次锁,写成日志就是噪声),也不做终态改判。
+- 定向验收覆盖:同进程重叠写等待后成功、同一轮并行写多个文件、有界等待不占 runtime worker(`current_thread` + 心跳任务)、活外部进程持锁(错误带 `ownerIsSelf=false` 且锁文件不被回收)、ACL 拒绝不投影成争用,外加两条平台无关判据用例(重试性只由错误码决定、终态改判三条件)——后两条让 Linux CI 也能盯住 Windows 分支。对应 `project_lock_recovery`、`direct_tool_bridge` 与 `project/write_lock` 定向测试;`tests/project_tools.rs` 既有的 `runtime_project_write_lock_waits_for_delete_pending_target` 继续覆盖“带句柄的 delete-pending 必须等到成功”。
+- 仍待收口(后续事项):① 其余仍用零等待取锁的入口(`command.exec / project.verify / memory / conversation / task / checkpoint / 预览 / UI 编辑器 / 资源编辑器 / Tauri 命令`)本批不改,遇到同类争用仍会立刻失败;零等待入口无法区分“拆链窗口 / ACL 拒绝”,因此在前缀不变的前提下补一句“锁文件此刻不存在,可能是删除挂起、删除拆链窗口或权限 / ACL 拒绝”。② 锁策略已按“单一职责”收口到 `project/write_lock.rs`(887 行:取锁、等待分类、持锁方诊断、残留回收),`project/filesystem.rs` 回到项目文件 IO(680 行);仍待收口的是 Direct 锁用例,它们还留在 `direct_tool_bridge.rs`(3135 行,锁用例与桥实现混在一起),后续移到 `tests/project_lock_recovery.rs` 或独立测试文件。③ 行为级 Windows 用例(delete-pending 等)仍只在 Windows 本地执行,CI 没有 Windows runner;关键判据已参数化到 Linux 可覆盖,行为级覆盖仍需本地执行或后续补 runner。
+
 ## 2026-09-11 AGC 登录态自动续期
 
 - AGC 前端请求客户端配套后端的鉴权 API(包括 `/api/llm/models`)收到 `401` 时,共享进行中的 refresh 请求;确认当前用户并安装 Rust / Runner 会话后,用新 access token 最多重试原请求一次。`403` 权限拒绝不触发续期;续期失败保留原鉴权错误,账号切换或登出后不重发旧请求。
diff --git a/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md b/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md
new file mode 100644
index 000000000..de0c1b0a2
--- /dev/null
+++ b/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md	
@@ -0,0 +1,58 @@
+# DirectProject Codex 原始历史与异常恢复
+
+更新时间:`2026-09-07`
+
+## 目标
+
+DirectProject 只使用 `.agent/conversations/project.jsonl` 作为对话历史。历史保存 Codex Responses API 的完整 item,使聊天展示与新线程恢复使用同一份事实来源;两者只是不同读取动作。
+
+本方案只适用于 DirectProject,不改变 DirectHome、Agent session 历史或 `runtime/direct-codex/turns` 审计账本。
+
+## 文件格式
+
+每行采用 Codex CLI rollout 的最小事件外壳,不保存 AGC 自有的顺序号或运行环境字段:
+
+```json
+{"type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"你好"}]}}
+```
+
+`payload` 必须是未经改写的 Responses item。Direct 回合不由浏览器预写用户 message;Codex 返回的 `rawResponseItem/completed.params.item` 原样追加。显式的本地 user/assistant 补写只能通过受权限保护的 `append_direct_project_conversation_message` 命令完成。native 工具、MCP 工具、reasoning、调用参数和调用结果都保留完整内容,不截断、不摘要、不保存 delta/started 事件。
+
+DirectProject 不迁移旧 `{role,content}` 行;实现按新格式工作。
+
+## 正常回合
+
+1. 启动 `ephemeral: true` 线程,并启用 `experimentalRawEvents: true`。
+2. 新线程先把历史 item 数组一次注入;注入成功后执行新的 `turn/start`。本轮用户 item 只接受 Codex 回传的 `rawResponseItem/completed`,不由 AGC 预写。
+3. 收到 `rawResponseItem/completed` 后立即追加其 `params.item` 并 flush。
+4. 正常 `turn/completed: completed` 不生成额外记录。
+
+## 异常回合收尾
+
+AGC 判定本轮不会再产生新事件时收尾:用户中断、turn failed、无响应/idle timeout、硬超时、transport closed、stdout EOF 或 app-server 卡死终止均属于异常终态;正常 completed 不收尾。
+
+`item/agentMessage/delta` 正常带有 `itemId`;若协议异常缺失,AGC 记录 warning 并按当前 turn 生成稳定回退 id。AGC 在内存中按该 id 累计 assistant 文本,不实时写 delta。异常终态时,对仍有累计文本的 item 合成普通 Responses assistant `message` item:
+
+```json
+{"type":"response_item","payload":{"type":"message","role":"assistant","id":"msg_1","content":[{"type":"output_text","text":"已累计文本"}]}}
+```
+
+合成 item 在返回错误、销毁连接或启动恢复线程前追加并 flush。没有文本 delta 的半截工具/MCP 调用不合成,等待完整 `rawResponseItem/completed`。
+
+`rawResponseItem/completed` 缺少 `item`(包括 `null`)时视为反序列化错误;该回合按异常终态收尾,历史中不会写入非法空 item。
+
+Codex 启动时注入的 `host_skills.instructions`、`permissions.instructions` 和 `environments.environment_context` item 不属于项目对话历史;落盘时过滤,读取和线程注入时也过滤。过滤同时识别 role=user 的完整上下文标签包裹文本,即使该 item 没有 `internal_chat_message_metadata_passthrough` 元数据,也不能把它当成用户回合。
+
+## 恢复
+
+创建新的 ephemeral thread 后,读取 `project.jsonl` 中所有 `response_item.payload`,按文件行顺序一次调用 `thread/inject_items`,再执行新的 `turn/start`。Codex 负责上下文窗口管理;注入失败直接失败,AGC 不截断、摘要或改写历史。新 thread 已进入连接池但历史读取或注入失败时,必须先从池中淘汰并取消订阅该 thread,重试只能创建新 thread 并重新注入。
+
+`clientUserMessageId` 仅作为 Codex 用户消息的稳定标识随 `turn/start` 发送,不等价于 turn 级 exactly-once 幂等。断线后的重试仍须由项目侧持久化 turn ledger 或服务端去重合同决定,不能仅凭该字段再次执行。
+
+聊天界面只从 message item 提取 user/assistant 内容;工具 item 不再拼成 `tool: ...` 假文本。
+
+DirectProject 的浏览器层只负责显示和乐观状态,不再调用通用对话写入器;Rust 是该历史文件的唯一写入方。历史读写与回合累计分别位于 `agent/direct_project_history.rs` 和 `agent/direct_project_turn_history.rs`。
+
+## 写入与损坏边界
+
+写入使用 `write_all + flush`。读取时允许丢弃文件末尾一条不完整 JSON 行;中间坏行直接失败。不会对旧格式做迁移或兼容。
diff --git a/docs/technical/【技术方案】DirectProject客户端Skill与MCP扩展导入方案-2026-08-31.md b/docs/technical/【技术方案】DirectProject客户端Skill与MCP扩展导入方案-2026-08-31.md
index bc6aeeafe..cf52261c4 100644
--- a/docs/technical/【技术方案】DirectProject客户端Skill与MCP扩展导入方案-2026-08-31.md
+++ b/docs/technical/【技术方案】DirectProject客户端Skill与MCP扩展导入方案-2026-08-31.md
@@ -14,6 +14,10 @@
 
 ## 3. 已确定的产品边界
 
+### 3.0 客户端能力 MCP 暴露边界(2026-09-03)
+
+客户端仍由现有对话入口启动并驱动 Codex;MCP 只是把客户端已审核的项目、文件、资源、画布、生成和预览能力暴露给该 Codex 或其它 Host。客户端只负责账号、项目路径、权限、计费、幂等、锁和恢复等自身安全,不替 Codex 做高层意图/完成门禁。审核 Skill 的索引和正文可作为只读 MCP resource 提供,第三方扩展不得获得客户端会话凭据、内部路径或 bridge token;该能力与公网 `/api/external/v1/mcp` 保持独立。
+
 ### 3.1 客户端安装、运行时注入
 
 - 扩展内容保存在 AGC 客户端的扩展仓库。
diff --git a/docs/technical/【技术方案】Direct回合行为审计账本-2026-08-31.md b/docs/technical/【技术方案】Direct回合行为审计账本-2026-08-31.md
index 789c00173..d8a570436 100644
--- a/docs/technical/【技术方案】Direct回合行为审计账本-2026-08-31.md
+++ b/docs/technical/【技术方案】Direct回合行为审计账本-2026-08-31.md
@@ -89,6 +89,10 @@ Codex app-server 协议里,`commandExecution.commandActions` 已分类为 `Rea
 
 不升级 `GAME_CREATOR_AGENT_DB_SCHEMA_VERSION`;新 `recordType` 走 Ordinary 追加。`updatedAt` / `schemaVersion` 仍由 `serialize_agent_db_record` 写入。
 
+### 2026-09-03 客户端对话与 MCP 能力边界
+
+客户端对话仍由现有 Codex app-server 链路完成;MCP 只暴露客户端自身业务能力和审核 Skill 指导。客户端安全门禁限于账号、项目路径、权限、计费、幂等、锁、revision 与恢复,不根据 Codex 自然语言替代 Codex 决定业务动作。外部 Host 返回如需旁路归档,可使用显式记录工具,但不替代现有 conversation projection,也不触发资源、状态或完成判定。
+
 jsonl 每条自带 `recordedAtMs`(`unix_millis`)。同一 `clientTurnId` 若再次进入(当前 GUI 运行中互斥,结束后理论上可再来):只追加,不截断;后一次 `turn_start` 视为新 attempt。读摘要时按文件内最后一次 `turn_start` 到对应 `turn_end` 计算 `offeredRead`。`agent.db` 每次 `turn_end` 再追加一条摘要,分析取该 `clientTurnId` 最后一条。
 
 ## 5. 记录合同
diff --git a/docs/technical/【技术方案】GameAgent资源自由画板与快速编辑-2026-08-20.md b/docs/technical/【技术方案】GameAgent资源自由画板与快速编辑-2026-08-20.md
index 828bee880..4b543bef8 100644
--- a/docs/technical/【技术方案】GameAgent资源自由画板与快速编辑-2026-08-20.md
+++ b/docs/technical/【技术方案】GameAgent资源自由画板与快速编辑-2026-08-20.md
@@ -76,6 +76,7 @@
 ### 失败生成任务归档与任务侧栏
 
 - 用户界面的“删除失败任务”语义是归档,不物理销毁私有 generation ledger。只有平台明确失败的 `failed` 任务可归档;`reconciliation-required`、已受理、运行中和结果未知任务不得移出恢复队列。
+- 已受理 operation 在重试时若请求快照发生变化,客户端可先对原 operation 执行一次只读状态查询;仅当平台明确返回 `failed` 时才自动收口旧账本并允许新提交,排队、运行中、完成或未知状态继续保持原幂等身份并阻断替代请求。
 - 归档命令校验 project、draft、generation 与 expected draft revision,先把私有 ledger 写入可重放的 `archiving/archivedAt`,再从 `draft.generations` 移除公开投影并推进一次 revision;草稿删除成功并回读后才发布 `archived`。`archiving` 以及历史上已写 `archived` 但仍残留公开记录的状态都必须在恢复阶段幂等收敛,且不依赖图片生成服务凭证。
 - 失败占位和右上角任务项复用同一个归档动作,成功后两处同时消失,其它任务、图层和候选不受影响。
 - 任务侧栏折叠只属于当前会话 UI 状态,不写入 draft 或 manifest。视觉和交互复用现役美术画布:右上角独立“任务列表”图标按钮、20rem 白色模糊卡、总数徽标、`排队/生成中` 与 `已完成` 双 Tab、状态圆形图标、阶段进度和时间信息;折叠后只保留图标按钮,不显示摘要卡。用户显式新建 generation 时自动展开并切回活动 Tab,普通进度更新不得推翻用户已有折叠选择。Game Agent 的失败归档作为任务行扩展保留。
diff --git a/docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md b/docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md
index 7d599f4d2..5f231d263 100644
--- a/docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md	
+++ b/docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md	
@@ -1,6 +1,7 @@
 # 立项策划 Agent(Fast GDD)技术方案
 
 - 日期:2026-08-10
+- 状态:**已退役**。本文描述的 V1 策划链路(`project-supervisor-plan` 根 Run、`project-planning` 子 Agent、`plan.submit_gdd` 工具、Fast GDD 审批门禁与恢复机制)已由策划会话 Runtime V2 取代,源码已于 2026-09 按四不写原则整体删除;现行方案见 `【技术方案】策划会话RuntimeV2接入与旧链路退役-2026-09-03.md`。本文仅作为历史推导记录保留。
 - 适用范围:AI 游戏创作独立 App、Project Supervisor、Agent Runtime、本地项目策划 sidecar 与后续完整构建准入
 
 > 当前口径(2026-08-30):以本文件中标注的 D11 / 最新修订和当前 `apps/ai-game-creator-shell` 实现为准。D6~D9 等被明确标注为作废或被取代的段落仅保留推导背景,不得作为现行拓扑、入口或 Runtime 真相;产品入口与 DirectProject 总体口径见 `docs/README.md` 和 App 实施计划。
@@ -9,7 +10,7 @@
 
 当前普通完整构建会从简短需求直接进入 `autonomous-game-build`,用户在消耗完整构建成本前没有正式确认玩法方向、MVP 范围和原型验证项的环节。现有完整构建中的 `design-director` 是只读协调任务,`design-foundation` 又会自行补齐玩法定位;用户意图与后续实现之间缺少可版本化、可审批、可恢复的策划基线。
 
-本方案新增“立项策划”阶段:用户给出一句需求后,由 Project Supervisor 顶层 root run 通过 `agent.delegate` 发起一个独立 `agentId` 的静态委派子 Agent(`agentId=project-planning`;2026-08-13 起取代原“下游工作流节点由 manifest ready-task 调度器启动”的表述,见第 1.1 节「D11 新拓扑」与 D11),在最多 3 轮决策卡内形成 Fast GDD(该 3 轮上限依赖 WP1——静态委派澄清轮次与返工深度拆分——先落地,见第 1.1 节;落地前实际上限仍是 1 轮);Runtime 校验并提交不可变版本,用户通过审批卡批准、修改或退回。只有不可变 GDD 与对应 approve receipt 同时有效时,后续完整构建才能取得 `approvedGddRef`。
+本方案新增“立项策划”阶段:用户给出一句需求后,由 Project Supervisor 顶层 root run 通过 `agent.delegate` 发起一个独立 `agentId` 的静态委派子 Agent(`agentId=project-planning`;2026-08-13 起取代原“下游工作流节点由 manifest ready-task 调度器启动”的表述,见第 1.1 节「D11 新拓扑」与 D11),在最多 3 轮决策卡内形成 Fast GDD(该 3 轮上限依赖 WP1——静态委派澄清轮次与返工深度拆分——先落地,见第 1.1 节;落地前实际上限仍是 1 轮);Runtime 校验并提交不可变版本,用户通过审批卡批准、修改或退回。审批修改后的 continuation 沿用普通 Planning V2 回合协议,Provider 可以继续问询,也可以直接提交新的完整 GDD;既有问询计数和已确认问答继承,不因修改重置。只有不可变 GDD 与对应 approve receipt 同时有效时,后续完整构建才能取得 `approvedGddRef`。
 
 目标:
 
@@ -389,7 +390,7 @@ Runtime 注入并强校验以下精确结构:
 
 ### 5.1 对话循环
 
-- 最多 3 轮主动追问,每轮只问 1 个主要决定。
+- 最多 3 轮主动追问,每轮只问 1 个主要决定;审批修改后的 continuation 仍沿用同一问询预算,可以继续问询或直接提交新的完整 GDD,问询计数不重置。
 - 建议顺序:核心行为与本局目标 → 重玩动力 → 制作边界与 MVP。
 - 满足任一条件即出稿:用户明确说“直接出稿”;已经完成第 3 轮;剩余问题不影响首个可玩闭环;Runtime 注入 240 秒 Agent 活跃时间软提示。
 - `accumulatedAgentMillis` 只累计 Provider 活跃区间,不包含等待用户、等待审批、进程休眠或应用关闭时间。
@@ -790,7 +791,7 @@ input 是当前 GDD 的完整快照,不要求与 source session 的 `decisions
 | title                    | 1~80 scalar                                                                                                                                   |
 | genre.primary / fusion   | primary 1~40;fusion 为 `null` 或 1~40                                                                                                       |
 | artStyle                 | visualType 1~80;keywords 3~5 个且去重,每项 1~32;其余各 1~400                                                                            |
-| oneLiner                 | 45~90 scalar                                                                                                                                  |
+| oneLiner                 | Runtime 实际接受 10~160 scalar;模型 schema 提示 25~90 scalar(有意不对称,不是缺陷或 bug)                                      |
 | pillars                  | 2~4 条;name 1~40,其余文本各 1~240;name 唯一                                                                                              |
 | coreLoop                 | 4~8 步,每步 1~120                                                                                                                           |
 | targetUsers              | 三个主文本各 1~240;referenceGames 0~5 项,每项 1~80                                                                                        |
@@ -1895,7 +1896,7 @@ M0 文档 PR 本身最低验证:Markdown 结构与三张 Mermaid 图可解析
 | 现役 pending wire                         | `apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs:26-27`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_action_batch.rs:3-46,219-270`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/pending_confirmation_ledger.rs:290-520`                                                                                                                                                                                                                                                                    | 保持 `game-creator-pending-action.v5`;M1 另建 planning pending,不升级全局 wire                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
 | submit 专用提交/恢复分支                  | `apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_recovery.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/recovery_scan.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_action_batch.rs`                                                                                                                                                                                                               | **2026-08-14 按 M1B-2 收口**:在普通 dispatch 前处理 Runtime-owned submit;提交后终止策划子 run,并保留 generic v5 standalone pending + v4 batch anchors,不复用 `WaitingForUserInput`,不创建 planning pending。审批等待属于 M1C-1                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              |
 | JSON sidecar                              | `apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/json_sidecar.rs:44-104,122-250`                                                                                                                                                                                                                                                                                                                                                                                                                                                             | 现有 writer 可覆盖;不可变文件必须新增 no-replace helper                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                         |
-| 项目锁                                    | `apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs:85-138`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs:1467-1505`                                                                                                                                                                                                                                                                                                                                                                                         | 所有 planning mutation 在同一项目锁内重读事实                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    |
+| 项目锁                                    | `apps/ai-game-creator-shell/src-tauri/src/project/write_lock.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs:1467-1505`                                                                                                                                                                                                                                                                                                                                                                                         | 所有 planning mutation 在同一项目锁内重读事实                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    |
 | completion blocker                        | `apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_approval.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs`                                                                                                                                                                                                                                                                                                      | **M1C-1 隔离 worktree 已补齐**:仅对 exact `project-supervisor-plan` standard 顶层根 Run 生效,读取 GDD lineage、approval pending/receipt、generic submit anchors、terminal observation、decision audit、planning session 与 recovery,且只读不创建 pending;现役 collaboration blocker 对策划子 Agent 仍不适用                                                                                                                                                                                                                                                                                                                                                                                                                                  |
 | plan 根 run 子 Agent 创建面               | `apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delegation.rs`(`observe_agent_runtime_agent_delegate` / `observe_agent_runtime_agent_spawn_isolated`)、`agent/runtime_protocol/run_configuration.rs`(`validate_project_supervisor_plan_root_binding_at`)、`agent/prompt.rs`(`game_creator_project_supervisor_tool_plan_prompt`)                                                                                                                                                                                                          | **2026-08-14 `M1A-4` 已落地**:plan 根 run 只能委派 `project-planning`,`agent.spawn_isolated` 一律拒,两条通道共用 typed `kind=plan-root-child-target-unsupported`;plan source 下不拼 `supervisorIntro` 与 `$visualContract`。**已知残留(有意保留,见 decision-log 2026-08-14 `M1A-4` 条)**:`$base` 的 `$isolatedAgentTemplates` 段仍会向 plan 根 run 列出全部专业角色名——那是 `agent.spawn_isolated` 的模板目录,因执行层硬拒而成为死文本;因此**不得**写「plan 根 run 上下文不出现其它 Agent 名」这类验收句                                                                                                                                                                                                                               |
 | plan retry                                | `apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/lifecycle_control.rs`(`resolve_game_creator_agent_runtime_retry_configuration_at`)、`runtime_driver.rs`(`supervisor_plan_root_identity_holds_at`)                                                                                                                                                                                                                                                                                                                                         | **2026-08-13 `M1A-3` 已落地 source 保源**:`task.source == project-supervisor-plan` 时先走强判据,通过则保留该 source,失败 `kind=plan-root-retry-identity-unsupported`、不降级。gui/cli 仍走 `agent-background-task`。plan-session revision / `gddId` / 按 `gdd-approval` kind 禁 retry 仍属后续包(现役已拒 `waiting-*`)                                                                                                                                                                                                                                                                                                                                                                                                                      |
diff --git a/docs/technical/【技术方案】策划会话RuntimeV2接入与旧链路退役-2026-09-03.md b/docs/technical/【技术方案】策划会话RuntimeV2接入与旧链路退役-2026-09-03.md
new file mode 100644
index 000000000..48c2794ce
--- /dev/null
+++ b/docs/technical/【技术方案】策划会话RuntimeV2接入与旧链路退役-2026-09-03.md
@@ -0,0 +1,972 @@
+# 策划会话 Runtime V2 接入与旧链路退役方案
+
+- 日期:2026-09-03
+- 状态:P0 合同冻结、P1 内核、P2 产物闭环、P3 入口/UI 接入、P4 灰度回归验收与 P5 旧链路退役均已完成;本文是新生产实现的目标方案与阶段验收合同
+- 适用范围:AGC 桌面 App 的“做方案”入口、策划会话、GDD 产物与审批
+
+> 本文规定新策划 Agent 的生产接入和旧链路退役方式。P3 开始修改正式 AGC 入口与工作台,但旧 `project-supervisor-plan` / `project-planning` 源码仍保留,直到 P5 完成退役;V2 切换时旧链路直接封存,所有未完成旧会话强制失败,旧 Fast GDD 文档之后只作为历史记录依据。
+
+## 1. 决策摘要
+
+本次不在旧 Supervisor Runtime 上逐条删除门禁,而是新增一个单 Agent 的 `PlanningSessionRuntime`(下称 Runtime V2):
+
+```text
+做方案
+  → PlanningSessionRuntime
+  → 一个策划 Agent Session
+  → Provider(流式)
+  → question 或 GDD
+  → 用户回答 / 审批
+  → 同一 Session 继续
+```
+
+V2 复用底层能力,但不复用旧策划编排身份:
+
+- 复用 Provider 连接、Provider 流式传输能力、超时/瞬态重试、会话消息持久化、项目路径边界、单项目并发控制和原子文件写入。这里的“Provider 流式传输能力”只描述底层请求实现,不承诺把每个文本 delta 投影给前端或用户。
+- 不经过 Project Supervisor,不创建 `project-planning` 子 Run,不使用 `agent.delegate`、delivery、continuation、Acceptance Graph 或 acceptance evidence。
+- 当前只启用 `mode=gdd`、最多展示 8 个有效问题、GDD 审批和用户修改。
+- 问询和出稿通过两个协议 function tools(`plan_ask_question` / `plan_submit_gdd`)输出,`tool_choice=required`;Runtime 解析工具参数后归一为 Question/Artifact,不执行工具、不把 `tool_call` 写入会话消息。
+- 当前不启用 MCP、Skill、第三方工具或无限问询;`capabilities.tools/skills` 仍为空,预留未来能力快照。
+- 新旧会话分开持久化,不自动转换旧会话;切换时所有未完成旧会话强制失败;同一项目同一时间只允许一条策划权威会话推进。
+
+### 1.1 本次必须达到的结果
+
+1. 新的“做方案”入口不再创建 `project-supervisor-plan` 根 Run。
+2. 单个策划 Agent 能在同一会话中完成提问、回答、GDD 生成、审批、修改和退回。
+3. `PLAN_MAX_TURNS=8` 表示最多向用户展示 8 个有效问题;第 8 个问题允许展示,达到 8 后再次调用 `plan_ask_question` 不得展示,内部最多重试一次要求调用 `plan_submit_gdd`。
+4. GDD、非法输出、Provider 请求失败和用户修改不增加有效问题数。
+5. Provider 失败、进程重启或页面重新打开后,不重复已完成的 Provider 副作用,不丢失已经持久化的用户消息和 GDD 版本。
+6. V2 切换时旧链路直接退役;所有未完成旧会话进入明确的 `legacy_retired` 失败状态,旧产物仍可读取。
+
+### 1.2 明确不做
+
+- 本次不实现无限多轮产品能力;只保证会话计数和上下文接口不把未来轮次锁死。
+- 本次不接入 MCP、Skill、第三方工具、工具审批或工具恢复。
+- 本次不实现上下文自动摘要、向量检索或无限历史存储;只分离完整会话记录与 Provider 请求上下文。
+- 本次不绑定批准 GDD 与后续做游戏的 `approvedGddRef`。
+- 本次不改做游戏/做素材的 DirectProject 路由。
+- 本次不删除旧 Runtime 源码、旧测试或旧 `.agent/planning` 产物。
+- 本次不保证未完成旧会话继续运行、继续问询或继续审批;切换后它们只能查看历史记录。
+- 本次不把 V2 做成 Python 子进程;生产实现仍在 AGC 客户端 Rust/Tauri 侧。
+
+### 1.3 问询策略与 Runtime 门禁的不对称设计
+
+**模型提示最多 3 轮、Runtime `question_limit` 默认 8,是明确的分层设计,不是缺陷、bug、配置漂移或校验遗漏。** 两个数值约束不同层面的行为,不要求统一成单一配置来源。
+
+- **模型侧策略为最多 3 轮**:提示词明确要求 `question_count >= 3` 时整理已有信息并调用 `plan_submit_gdd`;信息足够时允许 0~2 轮提前出稿。第 3 轮后的出稿指令是有意的策略约束,不是应当放宽为“必要时继续问到 8 轮”的软目标。
+- **Runtime 侧门禁为最多 8 个有效问题**:`question_limit=8` 表示运行时最多接受并展示多少个问题。模型偏离 3 轮策略、继续返回 question 时,Runtime 不因超过 3 而直接拒绝;仍按现有格式和业务门禁判断,达到 8 后才拒绝新的 question。这是对模型输出保留的容忍空间。
+- **Session/UI 中的 8 表示容量,不是配额或出稿前置条件**:不承诺正常路径会问满 8 轮,也不要求模型必须能够按当前提示一路问到第 8 轮。按策略在 3 轮或更早生成 GDD,是正常行为;不能仅凭未问满 8 轮推断 GDD 质量不足。
+- **评审与自动评测口径**:应分别核对模型是否收到 3 轮收敛指令、Runtime 是否按 `question_limit` 执行门禁。两者数值不同本身不构成问题;不得据此把提示词改成由 `session.question_limit` 决定何时出稿,也不得把 Runtime 门禁收紧到 3。`questionCount=3` 的提示要求出稿与 Runtime 仍可接受合法 question 可以同时成立;`questionCount=8` 时 Runtime 拒绝新 question。
+
+以上说明解释现有实现,不修改提示词、条件分支、默认值、校验或测试逻辑。
+
+## 2. 当前生产链路与迁移原因
+
+当前“做方案”生产路径是旧 Supervisor 链路:
+
+```text
+planningStartMode
+  → project-supervisor-plan 根 Run
+  → agent.delegate
+  → project-planning 子 Run
+  → planning_coordinator
+  → plan.submit_gdd
+  → acceptance evidence / claim
+  → GDD approval pending
+```
+
+现役入口和身份绑定主要分布在:
+
+- `apps/ai-game-creator-shell/src/App.tsx`
+- `apps/ai-game-creator-shell/src/features/agent-runtime/model.ts`
+- `apps/ai-game-creator-shell/src-tauri/src/commands.rs`
+- `apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs`
+- `apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_coordinator.rs`
+- `apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_submit.rs`
+- `apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_approval.rs`
+
+旧链路的复杂度不是单一校验,而是多组相互依赖的身份与编排事实:
+
+- Supervisor 根 Run、`project-planning` 子 Run、父子 Run Profile;
+- `agent.delegate`、delivery、continuation lineage 和 claim journal;
+- `plan.submit_gdd` 的 child 身份限制;
+- GDD 审批前的 Acceptance Graph / acceptance evidence;
+- `project-supervisor-plan` source 与 active root 判定;
+- 前端运行态、子 Agent 状态和审批 pending 的联动。
+
+如果在旧 Runtime 内逐个放宽这些限制,容易出现“提示词已经单 Agent、执行层仍要求 Supervisor/child 身份”的半迁移状态,典型结果是身份不匹配、错误恢复或 `needs-reconciliation`。因此 V2 以新会话运行内核接入;旧链路不再作为可推进的运行时,只在切换时被封存并保留历史文件。
+
+## 3. V2 目标架构
+
+### 3.1 分层
+
+```text
+┌──────────────────────────────────────────┐
+│ PlanningSessionRuntime                    │
+│  - 会话状态 / 回合生命周期                 │
+│  - 单 Agent 调用                           │
+│  - question / artifact 结果分流            │
+│  - 恢复、计时、失败投影                     │
+└──────────────────────────────────────────┘
+                 │
+┌──────────────────────────────────────────┐
+│ PlanningPolicy                            │
+│  当前:GddPlanningPolicy                   │
+│  - 有效问题计数                            │
+│  - question 上限                           │
+│  - GDD 结构与版本                          │
+│  - 审批 / 修改 / 退回                       │
+└──────────────────────────────────────────┘
+                 │
+┌──────────────────────────────────────────┐
+│ Shared Provider / Session substrate        │
+│  - provider-neutral request/stream          │
+│  - 会话消息落盘                            │
+│  - 项目锁、原子写、超时、基础重试            │
+│  - 上下文构建接口                           │
+└──────────────────────────────────────────┘
+```
+
+Runtime V2 不负责解释 GDD 字段;`GddPlanningPolicy` 也不负责 Provider 连接、文件锁或未来 MCP 进程。
+
+### 3.2 当前与未来能力的边界
+
+当前配置快照:
+
+```json
+{
+  "mode": "gdd",
+  "questionLimit": 8,
+  "capabilities": {
+    "tools": [],
+    "skills": []
+  }
+}
+```
+
+未来可以在不改会话核心的情况下扩展为:
+
+```json
+{
+  "mode": "conversation",
+  "questionLimit": null,
+  "capabilities": {
+    "tools": ["mcp.example.search"],
+    "skills": ["planning-research.v1"]
+  }
+}
+```
+
+`questionLimit`、`mode` 和 `capabilities` 是策略/能力快照,不是 Runtime 的硬编码身份。当前空能力集合不意味着未来消息格式只能承载普通文本。
+
+### 3.3 一次回合的统一结果
+
+Provider 回合在 Runtime 内统一归一为以下结果之一:
+
+```text
+Question(question)
+Artifact(artifact)
+ToolCall(toolCall)       # 仅保留消息/事件类型;当前不写入 conversation
+AssistantText(text)      # 当前策略视为非法输出;未来可由 conversation 模式使用
+```
+
+Provider 请求携带 `plan_ask_question` 与 `plan_submit_gdd`,`tool_choice=required`。`GddPlanningPolicy` 只接受恰好一个已知工具,并将其参数归一为 `Question` 或 `Artifact(kind=gdd)`。正文 JSON、多个工具或未知工具不写成成功产物;按输出重试策略处理,超过重试上限后进入可恢复失败状态。
+
+## 4. 会话与状态合同
+
+### 4.1 V2 会话快照
+
+P0 冻结为 `planning-session.v2`:
+
+```json
+{
+  "schemaVersion": "planning-session.v2",
+  "engine": "planning-session-v2",
+  "sessionId": "ps-...",
+  "projectId": "...",
+  "mode": "gdd",
+  "status": "awaiting_approval",
+  "turnIndex": 3,
+  "questionCount": 2,
+  "questionLimit": 8,
+  "revisionCount": 0,
+  "currentArtifactVersion": 1,
+  "currentQuestion": null,
+  "capabilities": {
+    "tools": [],
+    "skills": []
+  },
+  "processingSeconds": 123.45,
+  "createdAtUtc": "2026-09-03T00:00:00Z",
+  "updatedAtUtc": "2026-09-03T00:02:03Z",
+  "lastError": null
+}
+```
+
+约束:
+
+- `turnIndex` 是会话回合序号,不限制为 8 或 3;未来无限对话仍可继续递增。
+- `questionCount` 只统计已经展示给用户的有效 question。
+- `questionLimit` 由策略读取;当前值为 8,未来无限模式可为 `null`。
+- `revisionCount` 统计用户对当前产物发起的修改次数,不并入 questionCount。
+- `capabilities` 记录本会话可用能力快照;当前 `tools` 和 `skills` 必须为空数组。
+- `sessionId`、`projectId`、`createdAtUtc` 和 `updatedAtUtc` 是必填身份/时间字段;`lastError` 只保存安全错误分类和短摘要,不保存 Provider 原文、凭据或本地绝对路径。
+
+V2 会话 `status` 枚举冻结为:
+
+```text
+idle | planning | awaiting_user | awaiting_approval |
+revision_requested | approved | rejected | provider_failed
+```
+
+`legacy_retired` 只属于旧链路封存投影,不写入 V2 Session。
+
+### 4.2.1 V2 消息记录
+
+完整会话记录使用 `planning-message.v2`,与 Provider 请求上下文分离:
+
+```json
+{
+  "schemaVersion": "planning-message.v2",
+  "messageId": "msg-...",
+  "clientTurnId": "turn-...",
+  "turnIndex": 3,
+  "atUtc": "2026-09-03T00:02:03Z",
+  "role": "assistant",
+  "kind": "question",
+  "payload": {}
+}
+```
+
+`role` 冻结为 `user | assistant | system | tool`;`kind` 冻结为 `text | question | artifact | tool_call | tool_result | skill_reference | error`。当前 GDD 策略只把成功结果写成 `question`、`artifact` 和失败 `error`;协议工具只存在于 Provider 请求/响应,不把 `tool_call`/`tool_result` 写入 `conversation.jsonl`。未来启用 MCP/Skill 时使用已有 kind,不把工具结果伪装成普通 assistant 文本。
+
+### 4.2.2 回合结果与能力快照
+
+Provider 适配层输出 `planning-turn-result.v2`:
+
+```json
+{
+  "schemaVersion": "planning-turn-result.v2",
+  "kind": "question",
+  "payload": {}
+}
+```
+
+`kind` 冻结为 `question | artifact | assistant_text | tool_call | error`。协议工具解析成功后,`GddPlanningPolicy` 只落盘 `question` 或 `artifact(kind=gdd)`;正文 JSON 和其它结果按非法输出处理。
+
+能力快照冻结为:
+
+```json
+{
+  "tools": [],
+  "skills": []
+}
+```
+
+数组元素为稳定能力标识,必须去重并保持稳定排序;当前不得由模型修改。未来启用能力时由客户端/宿主在新回合开始前注入并重新记录快照。
+
+### 4.2.3 当前策略的 payload 形状
+
+`planning-message.v2` 和 `planning-turn-result.v2` 中的 `question` payload 冻结为:
+
+```json
+{
+  "id": "core_loop",
+  "header": "当前要决定:核心回路",
+  "question": "玩家每一局主要反复做什么?",
+  "options": [
+    {
+      "label": "工作台设计迭代",
+      "description": "先设计,再验证并回到编辑器修改。"
+    },
+    {
+      "label": "直接战斗验证",
+      "description": "先进入战斗,再根据结果调整设计。"
+    }
+  ]
+}
+```
+
+当前策略只要求 `options` 为 2~4 项、label/description 非空;不要求 A/B/“需要原型验证”固定顺序,也不把 `answerSource` 作为阻断条件。`question.id` 只作为当前问题标识,用户回答必须绑定当前 question 的 id。
+
+V2 决定状态使用 `confirmed | assumption_pending | prototype_pending`:`assumption_pending` 专门表示 Agent 根据上下文补出的、尚未被用户明确决定的内容,来源使用 `answerSource=agent_inferred`;`prototype_pending` 表示需要通过原型验证的决定,通常来源为用户选项或用户修改。V1 的 `default_pending` / `answerSource=default` 不属于 V2 语义。
+
+`artifact` payload 冻结为通用产物包:
+
+```json
+{
+  "artifactId": "artifact-...",
+  "kind": "gdd",
+  "version": 1,
+  "status": "ready_for_approval",
+  "fingerprint": "sha256-...",
+  "payload": {
+    "schemaVersion": "plan-gdd.v2",
+    "game": {},
+    "decisions": [],
+    "prototypeValidationItems": []
+  }
+}
+```
+
+`kind` 当前只有 `gdd`;未来增加其它产物类型时沿用同一产物包,不把会话状态改造成某一种产物的字段集合。`version` 在同一 V2 Session 内严格递增,`status` 当前使用 `ready_for_approval | approved | revision_requested | rejected | superseded`。
+
+### 4.2.4 V2 command DTO
+
+四个 command 的输入/输出边界冻结如下:
+
+| command                        | 必要输入                                                                                                   | 返回                                                                         |
+| ------------------------------ | ---------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- |
+| `start_planning_session_v2`    | `projectPath`、`clientTurnId`、`prompt`、可选 `mode`                                                       | V2 Session 及本次回合结果                                                    |
+| `continue_planning_session_v2` | `projectPath`、`sessionId`、`clientTurnId`、`input`                                                        | 更新后的 Session 及本次回合结果                                              |
+| `decide_planning_artifact_v2`  | `projectPath`、`sessionId`、`artifactId`、`version`、`fingerprint`、`decisionId`、`action`、可选 `comment` | 审批结果和最新 Session 状态                                                  |
+| `hydrate_planning_session_v2`  | `projectPath`、可选 `sessionId`                                                                            | 只读 V2 Session、当前 question、当前产物、错误摘要和完整 `conversation` 消息 |
+
+`input` 冻结为 `option`、`freeform`、`direct_draft`、`revision` 四种用户意图:
+
+回答当前问题时,客户端必须携带被回答卡片的 `questionId`(自由文本回答同样携带),后端保留该身份,在既有回合项目锁内比较 Session ID 和当前 question ID,再开始新回合。过期或缺失的问题身份不推进回合;已完成的 `clientTurnId` 仍可重放,失败回合沿用原输入重试,修订等非问答意图不要求问题身份。此处只核对用户提交的目标,不增加恢复门禁、Provider 调用或模型输出字段。
+
+前端 hydrate 返回后,必须在任何成功或空结果状态写回前同时比较请求序列和当前项目路径;迟到结果直接丢弃,不等待或重试。历史对话探测沿用自己的加载版本与项目路径检查。
+
+```json
+{
+  "kind": "option",
+  "questionId": "core_loop",
+  "optionIndex": 1,
+  "optionLabel": "工作台设计迭代",
+  "text": "按 A 做"
+}
+```
+
+Runtime 可以把 `A/B/C/D`、`1/2/3/4`、完整 label、“按 A 做”和“按第一个选项做”归一为 `optionIndex + optionLabel`;无法确定时才保留 `freeform`,不由旧 Supervisor 规则阻断。
+
+`action` 冻结为 `approve | revise | reject`。所有 command 都只接受项目路径、V2 Session/turn/artifact 身份和用户输入,不接受 Supervisor root、child Run、delegation、claim 或 acceptance evidence 字段。
+
+### 4.3 状态
+
+```text
+idle
+  → planning
+  → awaiting_user
+  → planning
+  → awaiting_approval
+  → approved
+
+awaiting_approval
+  → revision_requested
+  → planning
+
+awaiting_approval
+  → rejected
+
+planning
+  → provider_failed
+```
+
+`provider_failed` 是可恢复的失败投影,不代表 GDD 被拒绝;重试时必须沿用当前 Session 和未完成的用户意图。`needs-reconciliation` 不作为 V2 的正常业务状态;只有发生不可判断的持久化冲突时,才进入单独的恢复错误并阻止自动覆盖。
+
+### 4.4 问题上限语义
+
+`PLAN_MAX_TURNS=8` 的准确语义:最多向用户展示 8 个有效问题,而不是最多调用 Provider 8 次。
+
+```text
+questionCount=7,本次返回 question
+→ 保存并展示
+→ questionCount=8
+
+questionCount=8,本次返回 GDD
+→ 正常接受,不增加 questionCount
+
+questionCount=8,本次返回 question
+→ 不保存、不展示、不进入 awaiting_user
+→ 追加内部提示“不能再提问,直接根据已有信息出 GDD”
+→ 最多重试 1 次
+```
+
+其它规则:
+
+- 非法工具输出/GDD 不增加 `questionCount`。
+- Provider 请求失败不增加 `questionCount`,也不创建 GDD 版本。
+- 用户修改不受 `questionLimit` 限制,但修改回合仍不能再次向用户展示 question;若 Provider 返回 question,按一次内部出稿重试处理。
+- 达到内部输出重试上限后,保留当前会话和错误摘要,允许用户再次提交或恢复,不伪造 GDD。
+- 瞬态 Provider 故障不直接判死:timeout、connectivity、transport、空响应、反序列化失败、流式中途断连和上游 408/429/5xx 统一按主 Agent Runtime 同款分类判定为瞬态,会话层沿用该 Agent 的 `maxRetries` / `retryBackoffMs` 预算做指数退避自动重试本回合;重试不增加 `questionCount`、不产生 GDD 版本,debug `attempt-N` 随每次物理尝试递增。耗尽后才投影 `provider_failed`,错误摘要附带已重试次数;上游 4xx 等硬错误不进入该重试,直接 `provider_failed`。
+- `tool_choice=required` 依赖端点支持:部分模型或端点可能不接受该取值(例如思考模式下的 DeepSeek 会以 400 “Thinking mode does not support this tool_choice” 拒绝);这类 4xx 硬错误不进入瞬态重试,会话直接 `provider_failed`。接入或切换模型时必须先在目标端点验证 `required` 可用,再接入策划会话。
+
+## 5. Provider、上下文与未来 MCP/Skill 兼容性
+
+### 5.1 Provider 适配边界
+
+`GddPlanningPolicy` 不直接构造 OpenAI/Anthropic 请求体。Runtime 只调用 provider-neutral 接口:
+
+```text
+build_request(context, capability_snapshot, policy_hint)
+start_stream(request)
+collect_stream_events()
+normalize_turn_result()
+```
+
+当前实际 Provider 配置、Responses 流式格式、超时、瞬态重试和凭据边界沿用 AGC 现有 Provider substrate;V2 不改变当前 Provider 路由,也不引入新的第三方模型协议。
+
+P0 冻结适配器的四个边界对象:
+
+| 对象                            | Runtime 可见字段                                                           | 约束                                                                                         |
+| ------------------------------- | -------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
+| `PlanningProviderRequestV2`     | `sessionId`、`turnIndex`、`mode`、`policyHint`、`messages`、`capabilities` | 不携带 Supervisor/child/delegation 身份;具体 API kind、URL 和凭据由 Provider substrate 持有 |
+| `PlanningProviderStreamEventV2` | `type=text_delta\|completed\|failed`、可选 `text`/`result`/`error`         | 仅为 adapter/Runtime 内部的 Provider-neutral 事件;Provider 原始协议在 adapter 内归一,Runtime 不解析 OpenAI/Anthropic 私有字段,也不把 `text_delta` 视为前端业务事件 |
+| `ContextBuildResultV2`          | `messages`、`estimatedTokens`、`overflow`                                  | 完整会话记录不等于请求上下文;`overflow=true` 时显式失败,不静默丢历史                       |
+| `CapabilitySnapshotV2`          | `tools`、`skills`                                                          | 稳定排序、去重;当前必须为空,由宿主注入,模型不能修改                                       |
+
+Provider adapter 只负责“请求、内部流式事件归一、稳定错误、用量/耗时”;是否接受 question/GDD、是否计入问题数和是否生成审批由 `PlanningPolicy` 决定。Adapter 的 `text_delta` 可以被 Runtime 收集、丢弃或作为诊断/兼容状态使用,不构成前端逐 delta 推送义务。
+
+### 5.2 完整会话与请求上下文分离
+
+```text
+conversation.jsonl(完整事实记录)
+        │
+        ▼
+ContextBuilder(本次请求上下文)
+        │
+        ├─ 当前策略提示
+        ├─ 关键决定/当前产物
+        ├─ 最近消息窗口
+        └─ 未来摘要/工具结果/Skill 引用
+```
+
+当前实现可以先按有界完整历史构建请求,但必须通过 `ContextBuilder` 接口进入 Provider,不能把磁盘 JSONL 直接当作永久请求格式。当前不实现摘要;如果配置的上下文预算不足,应返回明确的上下文超限错误,不静默丢弃历史。未来再增加摘要或分页时,不改变持久化消息事实。
+
+### 5.3 MCP / Skill 预留而不提前执行
+
+当前只做数据边界:
+
+- Session 保存 `capabilities.tools` 与 `capabilities.skills` 的快照,当前为空。
+- 消息模型预留 `tool_call`、`tool_result`、`skill_reference` 类型,当前不产生。
+- Provider 请求接受能力快照参数,当前不向模型广告工具。
+- 能力启用由客户端/宿主决定,模型不能自行开启 MCP 或加载 Skill。
+
+当前不做 MCP 进程管理、Skill 安装、工具权限、工具审批、外部副作用账本或工具恢复。
+
+## 6. 持久化与新旧并存
+
+### 6.1 V2 目录
+
+V2 使用独立目录,避免被旧 `planning_storage.rs` 的 Supervisor 身份校验读取:
+
+```text
+.agent/planning-v2/
+├─ session.json
+├─ conversation.jsonl
+├─ index.json
+├─ gdd.v1.json
+├─ gdd.v2.json
+├─ debug/
+│  ├─ /requests/attempt-*.json
+│  ├─ /responses/attempt-*.json
+│  └─ /events.jsonl
+└─ approvals/
+   ├─ v1.json
+   └─ v2.json
+```
+
+`debug//` 只保存每次 Provider 尝试的请求、原始响应和解析分类,供失败诊断使用;它不进入会话上下文,不参与恢复、重试或 GDD 业务判断。诊断产物写入失败不改变主流程结果。
+
+继续生成同一用户可见路径:
+
+```text
+game/fast_gdd.md
+```
+
+该路径是当前 UI 和后续“做成游戏”入口的稳定交付面;V2 写入时必须使用项目写锁、临时文件和原子替换。
+
+`gdd.v{N}.json` 的 create-only 写入是提交点。index、`game/fast_gdd.md`、conversation 和 session 指针都是投影:任一投影失败不得回滚已创建的 GDD,也不得用新的 UUID/时间戳重写同一版本。hydrate 与同一回合重试必须认领 session 指针的下一个连续版本并补投影;只有磁盘上还不存在该版本文件时,才根据本轮入参新建。
+
+### 6.1.1 正常 run 优先的恢复旁路原则
+
+崩溃恢复只补齐已经落盘的事实,不得干扰正常 run 的可推进性。恢复不得增加阻塞性校验、Provider 调用、自动重试、大量 CPU 工作、模型额外输出字段,或正常 run 依赖文件的额外锁与等待。正常 run 活跃或锁竞争时,恢复旁路立即退出,交给既有下一次 hydrate;无活跃 run 时才可做一次有限、幂等的本地投影。
+
+### 6.2 V2 GDD 与审批
+
+P0 冻结 V2 GDD 使用 `plan-gdd.v2`,只保存业务内容和 V2 自身身份:
+
+```json
+{
+  "schemaVersion": "plan-gdd.v2",
+  "version": 1,
+  "createdAtUtc": "...",
+  "game": {},
+  "decisions": [],
+  "prototypeValidationItems": [],
+  "fingerprint": "..."
+}
+```
+
+不写入旧链路字段:
+
+```text
+rootAgentId
+rootRunId
+delegationId
+runProfile
+runProfileBindingFingerprint
+sourceSessionRevision
+createdByRunId
+```
+
+P0 冻结审批记录绑定 `version + fingerprint`,并使用 `plan-approval.v2`:
+
+```json
+{
+  "schemaVersion": "plan-approval.v2",
+  "version": 1,
+  "gddFingerprint": "...",
+  "action": "approve",
+  "comment": "",
+  "atUtc": "..."
+}
+```
+
+### 6.3 新旧会话路由
+
+同一项目只允许一个活跃策划权威。V2 切换包含一次项目级“旧链路封存”操作,路由规则如下:
+
+| 项目状态                                  | 新请求路由                                                                    |
+| ----------------------------------------- | ----------------------------------------------------------------------------- |
+| 已有活跃 V2 会话                          | 继续 V2;同一会话只允许一个在途 Provider 回合                                 |
+| 只有旧 `project-supervisor-plan` 活跃会话 | 切换时将旧会话投影为 `legacy_retired` 失败;用户需重新创建 V2,不再继续旧链路 |
+| 没有活跃策划会话                          | 创建 V2                                                                       |
+| 旧会话已终态、用户明确重新做方案          | 创建 V2,不改写旧目录                                                         |
+| 同时发现旧/V2 活跃会话                    | 旧会话优先封存为 `legacy_retired`,只保留 V2 推进                             |
+
+不做旧 session → V2 的自动转换。原因是两套身份、计数、审批和 GDD schema 不同,自动转换会把旧 pending 或旧 approval receipt 混入 V2。
+
+### 6.4 旧链路一次性封存
+
+切换由客户端在项目写锁内执行一次:
+
+1. 读取旧 `.agent/planning/session.json` 的当前阶段;`collecting`、`awaiting_user_input`、`awaiting_gdd_approval`、`revision_requested` 和 `recovery_required` 均视为未完成。
+2. 不改写旧 `plan-session.v1` 的字段形状;在 `.agent/planning-v2/legacy-cutover.json` 写入旧 session 指纹、原阶段、切换时间和 `state=legacy_retired`。
+3. V2/前端 hydrate 看到该标记后,把旧会话显示为“旧策划链路已退役(失败)”,禁止继续问询、审批、恢复或创建旧 continuation。
+4. 已经在途的旧 Provider 结果只允许落诊断,不得写入新的旧 GDD、approval receipt 或 `game/fast_gdd.md`;切换后的新写入只由 V2 负责。
+5. 旧 `gdd.v*.json`、旧 `index.json`、旧 approval receipt 和旧 conversation 仍保留只读,不删除、不改写、不迁移。
+
+旧 session 缺失或损坏时,也不尝试修复后继续;写入 `legacy_retired` 封存标记并阻止旧入口,避免把不可判断的旧状态带入 V2。
+
+## 7. 前端与命令接入
+
+### 7.1 入口分流
+
+目标分流:
+
+```text
+做方案 → PlanningSessionRuntime V2
+做游戏 → DirectProject
+做素材 → DirectProject
+```
+
+`planningStartMode` 仍可作为首页到工作台的入口标记,但它不再映射到 `project-supervisor-plan`。首轮提交、后续回答、审批意见和恢复都调用 V2 命令。
+
+### 7.2 V2 命令边界
+
+P0 冻结新增以下独立命令:
+
+```text
+start_planning_session_v2
+continue_planning_session_v2
+decide_planning_artifact_v2
+hydrate_planning_session_v2
+```
+
+命令职责冻结为:
+
+- `start_planning_session_v2`:创建或幂等启动 V2 Session,并提交首条用户需求。
+- `continue_planning_session_v2`:提交用户对 question 的回答,或提交审批修改意见后的修订指令;重启恢复不单独创建 `resume` 命令。
+- `decide_planning_artifact_v2`:提交当前最新产物的批准、修改或退回决定。
+- `hydrate_planning_session_v2`:返回 V2 Session、当前产物和当前等待态;若发现已提交但未投影的连续 GDD 版本,在项目写锁内认领并补投影。
+
+命令只接收项目路径、Session 标识、稳定 client turn、用户文本/选项和 V2 产物身份;不接收或生成 Supervisor 根 Run、delegation、acceptance evidence 等字段。
+
+### 7.3 UI 复用边界
+
+第一版可复用现有:
+
+- `ProjectSupervisorView` 的聊天区域和工作台布局;
+- `GddApprovalCard` 的产物展示与审批交互;
+- 现有耗时展示和会话历史加载。
+
+但数据来源必须改为 V2 状态,不再把“页面组件叫 Supervisor”当作运行时身份。后续再把组件重命名为 `PlanningSessionView`,不作为本次切换前置。
+
+## 8. 最小安全与业务校验
+
+### 8.1 保留
+
+- 项目路径必须属于当前用户打开的项目。
+- 单项目单活跃策划回合;重复提交同一 client turn 必须幂等。
+- Provider 超时、瞬态失败和稳定错误摘要。
+- 输出 JSON 可解析;当前 GDD 必填业务字段、长度和基本类型合法。
+- GDD 版本严格递增,文件写入原子化。
+- 审批必须绑定当前最新 GDD 的版本和 fingerprint。
+- 旧审批不能覆盖新 GDD;Provider 失败不能伪造成成功。
+- 重启后可以恢复当前 Session,不重复已经提交成功的消息/产物。
+
+### 8.2 不迁移
+
+- Supervisor 根/子 Run 身份和 parent binding fingerprint。
+- `agent.delegate`、delivery、continuation、claim journal。
+- Acceptance Graph、acceptance evidence、Supervisor claim gate。
+- `project-supervisor-plan` source。
+- 固定 A/B/“需要原型验证”三选一硬协议。
+- `answerSource` 作为阻断条件。
+- 3 轮硬限制。
+
+未来 MCP/Skill 的工具权限校验属于能力执行层,不重新引入上述 Supervisor 身份模型。
+
+## 9. 阶段任务拆分
+
+阶段按“先冻结合同,再做内核,再切入口,最后退役”执行。每个阶段完成后才进入下一阶段;阶段之间不要求一次性重写旧链路。
+
+### P0:V2 合同冻结(文档与接口设计)
+
+目标:把 V2 与旧链路的边界写成开发可执行合同。
+
+状态:已完成(2026-09-03)。P1 可以按本节冻结内容开始编码。
+
+任务:
+
+| ID   | 任务                                         | 产出                                |
+| ---- | -------------------------------------------- | ----------------------------------- |
+| P0-1 | 冻结 Session、消息、回合结果、产物和审批 DTO | V2 schema、字段枚举和版本策略       |
+| P0-2 | 冻结状态机、`questionLimit` 语义和重试规则   | 状态转移表、错误边界                |
+| P0-3 | 冻结新旧并存与同项目单权威规则               | 路由/恢复决策表                     |
+| P0-4 | 冻结 Provider/ContextBuilder/Capability 插槽 | provider-neutral adapter 和模块边界 |
+
+阶段验收:
+
+- 产品、前端、Runtime 对“第 8 个问题”和“第 9 次 question”能按同一例子解释。
+- 文档中不再出现“V2 先复用旧 Supervisor 再逐项放宽”的实现路径。
+- 能明确区分完整会话记录、Provider 请求上下文、GDD 产物和审批记录。
+- 明确旧会话如何封存、何时创建 V2,以及如何拒绝旧/V2 双活。
+- 已冻结 `planning-session.v2`、`planning-message.v2`、`planning-turn-result.v2`、`plan-gdd.v2` 和 `plan-approval.v2` 的版本名及核心字段。
+- 已冻结 `start/continue/decide/hydrate` 四个 V2 command 的职责;恢复不另建 resume command。
+- 已冻结 `questionLimit=8`、内部 question 重试上限为 1、Provider 失败/非法输出不占 questionCount 的规则。
+- 已冻结 `.agent/planning-v2/legacy-cutover.json` 的旧链路封存边界和 `legacy_retired` 投影语义。
+- 已冻结当前 `capabilities.tools/skills=[]`,未来能力通过能力快照和消息 kind 扩展,不修改 Session 根结构。
+
+依赖:无。完成后才能开始 P1。
+
+### P1:PlanningSessionRuntime 内核
+
+状态:实现已落在 AGC Tauri Runtime(2026-09-03)。当前仅提供内核闭环,尚未接入 GDD 解析、审批或生产 UI。
+
+已落地的 P1 入口:`start_planning_session_v2`、`continue_planning_session_v2`、`hydrate_planning_session_v2`。会话快照和完整消息分别持久化到 `.agent/planning-v2/session.json` 与 `.agent/planning-v2/conversation.jsonl`;Provider 请求沿用现有 `platform-llm`,按配置执行流式或普通调用。流式传输只是 Provider 调用实现细节,不属于策划用户交互契约;用户可见结果以 `plan_ask_question` / `plan_submit_gdd` 工具调用及其结构化结果为准。
+
+当前 P1 的恢复语义是轻量且显式的:进程退出时若快照仍为 `planning`,hydrate 将其投影为 `provider_failed/RECOVERY_REQUIRED`,要求用户重新提交当前意图;不会伪造成功或自动制造 GDD。`clientTurnId` 命中已有成功 assistant 记录时直接等值重放;若只有 error 记录,则沿用原用户意图重试且不重复追加用户消息。
+
+目标:在不包含 GDD 业务规则的情况下,跑通单 Agent 会话、Provider 调用(兼容流式或普通模式)、持久化和恢复。用户可见的完成标准是结构化工具结果,不是中间文本 delta 的到达频率。
+
+任务:
+
+| ID   | 任务                                       | 产出                                    |
+| ---- | ------------------------------------------ | --------------------------------------- |
+| P1-1 | 新建 V2 Session 生命周期与单项目并发控制   | `planning_session_v2` Rust 模块         |
+| P1-2 | 接入现有 Provider substrate 和内部流式事件归一 | provider-neutral request/stream adapter;不产生前端逐 delta 契约 |
+| P1-3 | 实现消息 JSONL、回合身份和幂等写入         | `conversation.jsonl` 及 turn identity   |
+| P1-4 | 实现 ContextBuilder 初版                   | 有界历史构建;超限显式失败              |
+| P1-5 | 实现 Provider 失败/中断/重启恢复           | Session 不丢消息、不伪造成功            |
+| P1-6 | 记录 `turnIndex`、处理耗时和安全错误摘要   | Session 快照、诊断字段                  |
+
+阶段验收:
+
+- 真实 Provider 可以完成一轮工具调用,前端收到结构化 question 或 GDD 结果并在终态落盘。
+- 同一 `clientTurnId` 重试不会重复追加用户/助手消息。
+- 同一项目第二个在途回合被拒绝,原回合不受影响。
+- Provider 失败后 Session 保留,恢复不会自动制造新问题或 GDD。
+- 重启后能读取完整会话记录;请求上下文不依赖前端临时内存。
+- P1 不包含 `agent.delegate`、Supervisor root、GDD 校验或审批逻辑。
+
+依赖:P0。
+
+### P2:GddPlanningPolicy 与 V2 产物闭环
+
+状态:核心实现已落地(2026-09-03)。当前已支持 question/GDD 解析、最多 8 个有效问题、达到上限后的单次强制出稿、`plan-gdd.v2` 版本文件、`game/fast_gdd.md` 投影和 `plan-approval.v2` 审批记录;审批修改后的下一轮仍由同一 V2 Session 继续。策略校验完成前的流式内容只在回合成功后对外转发,达到上限而被丢弃的 question、非法输出和重试内容不会泄露给调用方。等待审批时不能直接提交新的策划输入。P3 仍需把正式“做方案”入口和现有 UI 切到这些 command。
+
+已落地入口:`decide_planning_artifact_v2`。`answerSource` 缺失或未知值按当前状态回退为 `user_freeform` / `user_option` / `agent_inferred`,不作为单独阻断项;GDD 结构、必填业务字段、版本、指纹和当前项目身份仍必须合法。V2 不接受或生成 V1 的 `default_pending` / `default` 语义。
+
+目标:把新版原型的策划行为落到生产 V2,不把旧 Supervisor 协议带回来。
+
+任务:
+
+| ID   | 任务                                       | 产出                                     |
+| ---- | ------------------------------------------ | ---------------------------------------- |
+| P2-1 | 实现 question / GDD 结果解析               | 只接受当前策略需要的结果                 |
+| P2-2 | 实现最多 8 个有效问题的策略计数            | `questionCount` 与 `turnIndex` 分离      |
+| P2-3 | 实现达到上限后的单次强制出稿重试           | 不保存/展示额外 question                 |
+| P2-4 | 实现 V2 GDD schema、版本链和 `fast_gdd.md` | `.agent/planning-v2/**` 与 Markdown      |
+| P2-5 | 实现审批、修改、退回                       | `plan-approval.v2` 与新版本生成          |
+| P2-6 | 实现轻量输入归一化                         | A/B/编号/完整 label/“按第一个选项做”映射 |
+
+阶段验收:
+
+- 0 轮直出 GDD 可保存并进入审批。
+- 第 8 个有效问题可展示;第 8 个问题后模型再次返回 question 时,用户看不到该问题,内部最多重试一次并要求出 GDD。
+- 非法 JSON/GDD、Provider 失败不增加 `questionCount`。
+- 审批“批准”产生 approved 状态;“修改”产生新 GDD 版本且旧版本只读;“退回”不伪造批准。
+- `answerSource` 即使缺失或使用等价值,也不会成为唯一阻断原因;结构和业务字段仍需合法。
+- V2 GDD 不包含旧 Supervisor 身份字段。
+- 不出现固定三选一或 `project-planning` child 合同。
+
+依赖:P1。
+
+### P3:生产入口与 UI 接入(实施中)
+
+目标:让用户从正式“做方案”入口使用 V2,同时保持现有页面可用。
+
+任务:
+
+| ID   | 任务                                         | 产出                           |
+| ---- | -------------------------------------------- | ------------------------------ |
+| P3-1 | 新增 V2 Tauri command 注册和前端 invoke 封装 | 命令可启动/恢复/审批           |
+| P3-2 | 将 `planningStartMode` 路由到 V2             | 新项目不创建旧 Supervisor root |
+| P3-3 | 复用审批卡并切换到 V2 hydrate 状态           | GDD 展示、版本和审批按钮正常   |
+| P3-4 | 加入当前运行态、流式回复和耗时展示           | 页面可见状态与 Session 一致    |
+| P3-5 | 识别旧项目并准备封存投影                     | 存量旧会话不被误路由到 V2      |
+
+阶段验收:
+
+- 新项目点击“做方案”后,持久化目录是 `.agent/planning-v2/`,不产生新的 `project-supervisor-plan` 或 `project-planning` Run。
+- 前端能展示 question、接收用户答案、展示 GDD 并完成审批。
+- 刷新页面/重启 App 后可以恢复 V2 当前等待态。
+- 做游戏、做素材入口行为与改造前一致。
+- 旧活跃策划项目不会与 V2 双活;切换封存后显示明确失败并要求重新创建 V2。
+
+依赖:P2。
+
+当前实现进度(2026-09-03):
+
+- 正式 `planningStartMode` 首轮提交已改走 `start_planning_session_v2`,后续回答改走 `continue_planning_session_v2`,审批改走 `decide_planning_artifact_v2`;不再为新策划回合创建 Supervisor Run。
+- 前端通过 V2 适配层复用现有聊天区、澄清卡、GDD 审批卡和阶段进度条;V2 Session 的 hydrate 结果额外携带 `conversation`,用于刷新/重启恢复历史消息。
+- V2 只将结构化工具调用结果投影到澄清选项卡和 GDD 审批卡;旧 Runtime 轮询、专业 Agent 轮询和旧 Runtime 事件在 V2 策划会话中关闭。Provider 中间文本不作为策划用户对话展示。
+- 对已存在 V2 Session 的项目,打开项目时先 hydrate V2;没有 V2 authority 的旧项目继续走旧读取路径,避免误把旧项目数据当成 V2。
+- P3 已完成;P4 的真实 Provider、前端工作台、失败恢复和安全门禁验收已通过。旧会话 `legacy_retired` 封存、旧入口关闭与旧 Runtime 源码删除均已在 P5 完成。
+
+P3 之后的协议修正:V2 不再用正文 JSON 输出问询/GDD;Provider 请求挂 `plan_ask_question` / `plan_submit_gdd`,`tool_choice=required`,形状由工具 schema 承担。system prompt 只保留三项核心闭环等策略和当前问询进度;数量和字数由既有校验器在失败时回灌。`plan_submit_gdd` 的工具参数只包含决定和原型验证内容,不包含任何 Runtime 分配的 ID;Runtime 在落盘前为决定分配首项 `initial-request`、后续 `decision-{序号}`,并按 `prototype_pending` 顺序绑定原型验证项。入参不必回声 `schemaVersion`,落盘 GDD 仍写 `plan-gdd.v2`。失败结果不重复渲染,严格解析和失败不落盘成功产物的规则保持不变。历史 V2 数据不做兼容转换,按现有恢复/失败策略处理。
+
+### P4:灰度、真实 Provider 与回归验收(已完成)
+
+目标:证明 V2 的正常路径和关键失败路径可用,再关闭旧入口新建能力。
+
+任务:
+
+| ID   | 任务                  | 产出                                                             |
+| ---- | --------------------- | ---------------------------------------------------------------- |
+| P4-1 | 离线状态/结构定向测试 | Session、策略、schema、审批测试                                  |
+| P4-2 | 真实 Provider 测试    | 流式、问题、GDD、失败恢复                                        |
+| P4-3 | 前端组件/工作台测试   | 路由、审批卡、恢复显示                                           |
+| P4-4 | 旧链路切换封存测试    | 未完成旧 session 强制失败、V2 不误读旧目录                       |
+| P4-5 | 安全与编码门禁        | `npm run check:encoding`、`git diff --check` 及相关 Rust/TS 检查 |
+
+阶段验收:
+
+- 真实 Provider 至少完成“提问 → 回答 → GDD → 批准”和“GDD → 修改 → 新版本 → 批准”两条链路。
+- 至少覆盖一次 Provider 请求失败、一次非法输出和一次重启恢复。
+- 证实旧目录中的 delivery/approval 不会被 V2 hydrate 或审批读取。
+- 证实同一项目不存在两个活跃策划权威;旧活跃会话在切换后不可继续。
+- 所有失败均保留可操作状态,不以成功文案掩盖 Provider/持久化错误。
+
+阶段结果:已完成人工灰度验收,包含正常提问/回答/GDD/批准链路、GDD 修改与再次批准链路、Provider 失败恢复、非法输出失败边界、重启恢复、前端展示和编码/差异门禁。P4 不包含旧链路退役;旧链路封存和入口关闭在 P5 执行。
+
+依赖:P3。
+
+### P5:旧链路退役(已完成)
+
+目标:停止新业务进入旧 Supervisor,并将所有未完成旧会话一次性封存为失败。
+
+执行情况(2026-09):在 P5-1~P5-4 的“入口关闭、会话封存、产物只读”之上,进一步按四不写原则删除了旧 V1 Runtime 源码本体,避免退役代码继续占用编译与维护成本:
+
+- 前端数据通路与命令入口:`planGddState/planGddDecision` 读模型、`--plan-gdd-status/--plan-gdd-decide` CLI 等旧 V1 投影入口。
+- Runtime 源码:`runtime_protocol` 下 `planning_storage/planning_submit/planning_approval/planning_coordinator/planning_hydrate/planning_provider_usage` 六个模块、prompt manifest 中 planning Agent 目录与 plan sections、四个 V1 提示词文件、`game-creator.config.json` 的 `capabilities.planning` 开关、CLI `--swarm-chat --plan` 入口、provider_retry 的 planning session binding、provider_action_batch 的 V1 提交批次校验、tool_policy/main_loop/pending_recovery/recovery_scan/acceptance_graph 中的 plan 根特判与恢复车道、agent_db 的三条 V1 专用持久车道(`agent.runtime.plan.provider_usage`、`agent.runtime.plan.gdd_decided`、`agent.runtime.plan_submit_gdd.committed`)及对应测试。
+- 测试脚本:`scripts/agent-swarm-test-chat.mjs` 的 `--plan` 模式与自动 GDD 审批回路、根/应用 `package.json` 的 `test:plan*` / `agc:test:plan*` 脚本。
+- 保留项:V2 与 V1 共用的 GDD 数据模型抽到 `planning_gdd_model.rs` 继续复用;`.agent/planning/` 旧文件保持原样只读(不迁移、不删除);`fast_gdd.md` 仍可通过既有命令打开查看;前端 `GddApprovalCard.tsx`(`PlanGddSurface`)是 V2 现役的 GDD 展示面,不属于退役对象。
+
+任务:
+
+| ID   | 任务                                                 | 产出                             |
+| ---- | ---------------------------------------------------- | -------------------------------- |
+| P5-1 | 关闭旧 `project-supervisor-plan` 新建/继续入口 | 入口统一返回退役错误 |
+| P5-2 | 旧活跃会话自动失败                         | 入口拒绝后不启动旧 Runtime/Provider |
+| P5-3 | 保留旧产物只读                         | 不迁移、不修复、不删除旧文件 |
+| P5-4 | 更新生产文档和最小回归测试                     | 旧入口拒绝、V2 目录隔离 |
+
+阶段验收:
+
+- 代码搜索和运行时审计均证明新“做方案”不再调用 `start_game_creator_supervisor_runtime_task`。
+- 新项目不会创建旧 Supervisor root、child Run、delivery 或 acceptance evidence。
+- 旧入口或旧 continuation 若被直接调用,返回稳定的“旧链路已退役”错误,不启动旧 Runtime 或 Provider。
+- 旧文件和已终态旧产物保持原样,继续只读查看;不引入旧 Session schema 迁移。
+- V2 hydrate 和审批只读取 `.agent/planning-v2`,不读取旧目录。
+- 做游戏 DirectProject 和其它现役 Agent Runtime 不受影响。
+
+依赖:P4 通过;确认切换窗口并完成一次性封存。
+
+## 10. BDD 验收场景
+
+### 功能:新项目走单 Agent 策划会话
+
+为了去掉不必要的 Supervisor 编排,作为创作者,我希望“做方案”直接进入一个策划会话。
+
+```gherkin
+场景: 新项目首次进入做方案
+  假如项目没有活跃的旧策划会话,也没有 V2 会话
+  当用户从首页进入“做方案”并提交初始需求
+  那么系统应创建一个 planning-session-v2 会话
+  而且该会话只有一个策划 Agent
+  而且不应创建 project-supervisor-plan 根 Run、project-planning 子 Run 或 agent.delegate delivery
+```
+
+### 功能:问询与问题上限
+
+```gherkin
+场景: 第 8 个问题仍然可以展示
+  假如 V2 会话已经展示 7 个有效问题
+  当 Provider 返回第 8 个合法 question
+  那么系统应保存并展示该问题
+  而且 questionCount 应为 8
+  而且 turnIndex 应按实际 Provider 回合递增
+
+场景: 达到问题上限后不再向用户展示问题
+  假如 V2 会话的 questionCount 已为 8
+  当 Provider 返回合法 question
+  那么系统不应保存或展示该 question
+  而且不应进入 awaiting_user
+  而且系统应追加内部出稿提示并最多重试一次
+  而且重试得到合法 GDD 时应进入 awaiting_approval
+
+场景: 达到问题上限时直接返回 GDD
+  假如 V2 会话的 questionCount 已为 8
+  当 Provider 直接返回合法 GDD
+  那么系统应正常保存 GDD
+  而且不应追加额外 question
+```
+
+### 功能:Provider 与非法输出失败边界
+
+```gherkin
+场景: Provider 请求失败后恢复
+  假如 V2 会话正在 planning 且尚未得到本次结果
+  当 Provider 请求超时或返回瞬态失败
+  那么系统应保留当前 Session 和已落盘消息
+  而且不应增加 questionCount
+  而且不应创建新的 GDD 版本
+  而且用户可以重试或恢复同一会话
+
+场景: 非法 GDD 不被伪装成成功
+  假如 Provider 返回无法解析或缺少必填字段的 GDD
+  当输出校验完成
+  那么系统应按当前重试上限请求修正
+  而且重试耗尽后应显示可操作失败
+  而且不得写入 approved GDD
+```
+
+### 功能:审批、修改与退回
+
+```gherkin
+场景: 用户批准当前 GDD
+  假如当前存在 V2 最新 GDD 且审批卡引用的 fingerprint 与文件一致
+  当用户选择批准
+  那么系统应写入 V2 approval receipt
+  而且会话状态应为 approved
+  而且旧 GDD 文件保持可读
+
+场景: 用户修改当前 GDD
+  假如当前 GDD 正在等待审批
+  当用户提交修改意见
+  那么系统应以当前 GDD 为基线启动同一 V2 Session 的修订回合
+  而且用户修改不应消耗 questionLimit
+  而且成功后应生成递增版本的新 GDD
+  而且旧版本不应被覆盖
+
+场景: 过期审批不能覆盖新版本
+  假如审批卡引用 v1,但当前最新 GDD 已经是 v2
+  当用户提交 v1 的批准或修改
+  那么系统应拒绝该决定
+  而且 v2 内容和状态不得改变
+```
+
+### 功能:恢复与新旧并存
+
+```gherkin
+场景: App 重启后恢复等待用户回答
+  假如 V2 会话已持久化一个合法 question 且状态为 awaiting_user
+  当 App 重启并重新打开项目
+  那么系统应恢复同一 question
+  而且不应再次调用 Provider 生成新 question
+
+场景: 旧活跃会话在切换时强制失败
+  假如项目已有活跃的 project-supervisor-plan 会话
+  当系统切换到 PlanningSessionRuntime V2
+  那么旧会话应被投影为 legacy_retired 失败
+  而且不得再接受旧问询、审批、恢复或 continuation
+  而且用户重新做方案时只能创建 V2 会话
+
+场景: 旧 Provider 迟到结果不能复活旧链路
+  假如旧会话在切换时已有一个 Provider 请求在途
+  当该请求在切换后返回 GDD 或 question
+  那么系统不得写入旧 GDD、approval receipt 或 game/fast_gdd.md
+  而且旧会话仍保持 legacy_retired 失败
+
+场景: V2 不读取旧 planning 目录
+  假如项目同时存在旧 .agent/planning 和 V2 .agent/planning-v2 目录
+  当系统 hydrate V2 会话
+  那么系统只能读取 V2 schema 和产物
+  而且旧 delivery、旧 approval receipt 和旧 Run 身份不得改变 V2 状态
+```
+
+### 功能:未来能力插槽的当前行为
+
+```gherkin
+场景: 当前 V2 会话不启用 MCP 或 Skill
+  假如用户创建新的 V2 策划会话
+  当 Runtime 构建 Provider 请求
+  那么能力快照中的 tools 和 skills 应为空数组
+  而且 Provider 请求不应广告 MCP/Skill 工具
+  而且会话 schema 应能保存该空能力快照
+```
+
+## 11. 测试映射
+
+| 场景/规则                                  | 测试层级              | 计划目标                                        |
+| ------------------------------------------ | --------------------- | ----------------------------------------------- |
+| Session 状态、questionCount/turnIndex 分离 | Rust unit             | `planning_session_v2`                           |
+| Provider 失败、幂等回合、恢复              | Rust integration      | V2 runtime/provider adapter tests               |
+| GDD schema、版本链、fingerprint            | Rust unit/integration | V2 artifact/approval tests                      |
+| 第 8 个问题与上限后重试                    | Rust unit             | `GddPlanningPolicy` tests                       |
+| 审批批准/修改/退回/过期审批                | Rust integration      | V2 approval tests                               |
+| 输入“按 A 做/按第一个选项做”               | Rust/TS unit          | input normalization tests                       |
+| 做方案入口路由                             | frontend integration  | `App`/planning lane tests                       |
+| 问题卡、GDD 卡和恢复态展示                 | component             | `ProjectSupervisorView`/`GddApprovalCard` tests |
+| 真实 Provider 流式链路                     | real provider smoke   | P4 独立脚本或现有 real-e2e harness              |
+| 旧会话切换强制失败、旧目录不被 V2 读取     | Rust integration      | legacy cutover/recovery tests                   |
+| 中文编码和文档 diff                        | repository gate       | `npm run check:encoding`、`git diff --check`    |
+
+未接入 Cucumber/Playwright runner 前,以上 BDD 先作为 Markdown 验收合同;不为本方案新增独立 BDD 测试框架。
+
+## 12. 风险与处理原则
+
+| 风险                                                     | 处理                                                              |
+| -------------------------------------------------------- | ----------------------------------------------------------------- |
+| 继续复用旧 `planning_submit.rs` 导致 Supervisor 身份回流 | V2 使用独立 artifact/approval 模块;只复用通用文件/锁能力         |
+| 新旧都写 `game/fast_gdd.md`                              | 同一项目单活跃策划权威;V2/旧路径均使用项目锁和原子写             |
+| 审批修改后立刻续跑与 hydrate 抢同一把项目锁              | 一次性用户意图走完整等待窗口,V2 hydrate 走短窗口,对齐 V1;不引入可重入锁 |
+| 无限会话导致上下文无限膨胀                               | 当前先分离完整记录和 ContextBuilder;超预算显式失败,后续再加摘要 |
+| 未来 MCP/Skill 侵入 GDD 策略                             | 能力快照和消息类型在 Runtime 层预留,当前策略不广告、不执行       |
+| 强制失败导致旧 pending/receipt 不再可继续                | 这是本次明确的退役语义;旧文件只读保留,不迁移、不删除            |
+| 前端组件名继续叫 Supervisor 造成误解                     | 第一阶段只切数据源;后续独立重命名,不把命名重构当接入前置        |
+
+## 13. 完成定义
+
+本方案对应的工程工作只有在以下条件全部满足后才可宣布完成:
+
+- P0~P4 阶段验收通过,真实 Provider 至少跑通一条审批链和一条修改链。
+- 新“做方案”入口的运行时审计中不再出现新的 Supervisor root/child/delivery。
+- V2 Session、GDD 和审批记录可在重启后恢复,且旧目录不会污染 V2。
+- P5 关闭旧新建/继续入口;所有未完成旧会话均为 `legacy_retired` 失败,历史产物仍可只读查看。
+- 做游戏/做素材 DirectProject 路径无回归。
+- 相关 Rust/TS 定向验证、`npm run check:encoding` 和 `git diff --check` 通过。
diff --git a/docs/technical/【技术说明】AGC接第三方Provider的兼容性缺陷-2026-08-19.md b/docs/technical/【技术说明】AGC接第三方Provider的兼容性缺陷-2026-08-19.md
index 521fdf34b..0bc7324d8 100644
--- a/docs/technical/【技术说明】AGC接第三方Provider的兼容性缺陷-2026-08-19.md
+++ b/docs/technical/【技术说明】AGC接第三方Provider的兼容性缺陷-2026-08-19.md
@@ -176,7 +176,9 @@ error=unable to locate image at `/memory/README.md`: (os error 2)
 
 ---
 
-## 4. 缺陷 4:普通 action 批次带 plan update 时预检自相矛盾(已修)
+## 4. 缺陷 4:普通 action 批次带 plan update 时预检自相矛盾(已修,2026-09 随策划 V1 退役删除相关代码)
+
+> 本节引用的 `plan.submit_gdd` 特判、`provider_batch_plan_update` 与 `planning_session_binding` 字段属于已退役的策划 V1 链路,2026-09 已按四不写原则整体删除;回归测试 `supervisor_collaboration_batch_keeps_plan_update_off_members` 保留并收敛为「非 planning 批次的 plan update 不落入成员」这一不变量。
 
 换成 `gpt-5.6-terra` 后 LLM 链路可用,但首次复现曾在第二步「委派 project-planning 产出 Fast GDD」失败。这一条与 Provider 无关,是当时实现引入、现已修复的回归。
 
diff --git a/docs/technical/【设计】UI编辑器工作流完成通知弹窗-2026-09-04.md b/docs/technical/【设计】UI编辑器工作流完成通知弹窗-2026-09-04.md
new file mode 100644
index 000000000..5cdc52832
--- /dev/null
+++ b/docs/technical/【设计】UI编辑器工作流完成通知弹窗-2026-09-04.md
@@ -0,0 +1,40 @@
+# UI 编辑器工作流完成通知弹窗
+
+## 目标
+
+UI 编辑器的“分析参考图”“识别界面结构”“绑定视觉素材”三个工作流动作在每次运行结束后,用独立的阻塞通知弹窗明确反馈结果,避免仅依赖卡片内一行状态文本而被忽略。
+
+## 交互约定
+
+- 三个动作的每次运行在终态(成功或失败)时自动弹出一次通知。
+- 弹窗打开期间遮挡并阻塞工作台底层交互;关闭后恢复当前步骤,不自动切换步骤、不自动重跑。
+- 使用现有 `ThemedModal` 的普通关闭行为(遮罩、Esc 和关闭按钮均可关闭)。
+- 弹窗仅承载通知,不提供“继续”“重试”或其他业务操作。
+- 关闭弹窗后,工作流卡片继续显示同一条结果状态;重新运行产生新的终态时再次通知。
+- 绑定动作包含多个批次时,只在最终批次结束后通知一次。
+
+## 文案
+
+弹窗标题由步骤名和结果态组成,例如“识别界面结构完成”或“绑定视觉素材失败”。正文复用卡片状态文本,并逐条扩展结果信息,统一以“请检查”收尾。
+
+成功状态的基线文案:
+
+- 分析参考图:保留已应用的语义建议数量;若现有状态可可靠取得问题/待确认数量,则一并展示。
+- 识别界面结构:保留替换的界面树数量,并展示识别结果中的待检查/必须修复数量(若可取得)。
+- 绑定视觉素材:保留现有 `B/B` 批次计数,改为用户可读的绑定结果。
+
+失败状态保留实际错误文本,仅在弹窗标题中补充步骤和失败上下文,正文同样以“请检查”收尾。
+
+## 实现边界
+
+- 新增独立的工作流通知弹窗组件文件,组件只负责展示和关闭,不包含工作流领域规则或后端副作用。
+- 在 UI 编辑器页面/会话投影中维护临时通知状态,并在三个异步动作的成功与失败终态写入。
+- 不新增后端字段或公开契约;数量只能使用当前前端已有且可靠的数据。
+
+## 验收
+
+1. 三个动作成功和失败终态各弹出一次通知;绑定批次只弹最终一次。
+2. 弹窗打开时底层工作台不可操作,且无继续/重试等业务按钮。
+3. 弹窗可通过标准关闭方式退出;关闭后卡片状态仍可见。
+4. 每条成功文案保留原有数量信息并增加可用的检查数量,所有文案包含“请检查”。
+5. 重新运行后新的终态会再次弹出通知。
diff --git a/docs/【UI编辑器】撤销重做规范-2026-09-03.md b/docs/【UI编辑器】撤销重做规范-2026-09-03.md
index f3912e000..86d7e3cd9 100644
--- a/docs/【UI编辑器】撤销重做规范-2026-09-03.md
+++ b/docs/【UI编辑器】撤销重做规范-2026-09-03.md
@@ -48,13 +48,19 @@ UI 编辑器支持撤销最近一次或多次作品编辑,并支持重做被
 
 - 桌面端页面工具栏提供撤销和重做按钮。
 - 非文本编辑目标聚焦编辑器时支持 `Cmd/Ctrl+Z` 撤销、`Cmd/Ctrl+Shift+Z` 和 `Ctrl+Y` 重做。
-- `input`、`textarea`、`select`、`contenteditable` 以及按钮/链接等控件交给浏览器原生行为,不拦截文本撤销。
+- `input`、`textarea`、`select`、`contenteditable` 等文本编辑控件交给浏览器原生行为,不拦截文本撤销;按钮/链接等非文本控件仍允许编辑器撤销/重做快捷键生效。
+- UI Editor 页面内选中节点后,支持不带修饰键的 `Delete` 删除节点及其子节点;快捷键复用 Inspector、树面板和右键菜单共用的 `deleteNode` 命令,因此沿用根节点/锁定禁删、单条历史记录、dirty 标记和后续保存语义。
+- `Delete` / `Backspace` 在 `input`、`textarea`、`select`、`contenteditable`、按钮和链接等交互控件聚焦时交给浏览器原生行为;无选中、根节点、锁定或目标不存在时不执行删除。长按重复事件不重复删除,成功处理后阻止默认行为和事件冒泡。
+- `Delete` / `Backspace` 在打开的对话框(`role="dialog"` 或 `aria-modal="true"`)内聚焦时交给对话框处理,不删除对话框背后的节点。
+- `Cmd/Ctrl+Z`、`Cmd/Ctrl+Shift+Z` 和 `Ctrl+Y` 在打开的对话框(`role="dialog"` 或 `aria-modal="true"`)内聚焦时暂停编辑器级撤销/重做,由对话框或浏览器原生行为处理。
 - 无可撤销或重做记录时按钮禁用,并提供可访问名称。
 
 ## 脏状态与选择
 
 撤销和重做恢复的 State 继续参与现有 dirty 判定、保存和后端持久化。历史快照不包含当前设计图、节点选择、隐藏集合或视口;恢复后若当前选择已不存在,页面清理无效选择并保持安全空态。
 
+删除节点后,若当前选中节点是被删除节点或其任意后代,页面将选区清空为 `null`;删除成功不强制抢占焦点。该选择清理属于 UI 临时状态,不写入撤销历史。
+
 ## 验收标准
 
 1. 单次字段编辑可撤销和重做。
@@ -67,3 +73,4 @@ UI 编辑器支持撤销最近一次或多次作品编辑,并支持重做被
 8. 工具栏按钮、禁用态和桌面快捷键可用,文本控件保留原生撤销。
 9. no-op、锁定、校验失败和不存在目标不进入历史。
 10. 颜色选择器和九宫格边界拖动不会按每个 pointer move 写入 State。
+11. `Delete` / `Backspace` 仅在非交互控件聚焦且存在可删除选中节点时生效;删除整棵子树、可撤销、长按只处理一次,并清理被删除子树内的无效选区。
diff --git a/docs/【交互设计】预览画布缩放滑杆-2026-09-05.md b/docs/【交互设计】预览画布缩放滑杆-2026-09-05.md
new file mode 100644
index 000000000..497fb7e85
--- /dev/null
+++ b/docs/【交互设计】预览画布缩放滑杆-2026-09-05.md
@@ -0,0 +1,27 @@
+# 预览画布缩放滑杆
+
+## 交付目标
+
+将 UI 编辑器预览区现有的缩放按钮替换为桌面端可拖动滑杆,同时保留快速缩小、放大和适配画布操作。
+
+## 交互约定
+
+- 滑杆选择范围为 25%–200%,步进 5%;快捷加减继续使用核心视口的现有缩放边界。
+- 滑杆位于预览区右下角固定工具栏;不提供移动端或窄窗口 fallback。
+- 保留 `−` 与 `+` 按钮。点击轨道可跳转,拖动滑块实时更新画布和百分比。
+- 缩放以视口中心为中心,继续支持 Ctrl/Cmd + 滚轮缩放。
+- 滑杆支持方向键、Home、End;百分比文本可点击编辑并在失焦时限制到有效范围。
+- 百分比文本使用数字输入框呈现;输入提交后同步视口缩放,适配画布保持独立按钮,点击百分比不会触发适配。
+- 缩放滑杆获得焦点时,Delete/Backspace 不触发 UI 节点快捷删除;缩放按钮鼠标点击不改变快捷键焦点。
+- Windows/Linux 使用 `Ctrl`,macOS 使用 `Cmd`;`+`、`=` 与 `NumpadAdd` 放大,`-` 与 `NumpadSubtract` 缩小,允许按键重复时连续缩放。
+- 快捷键直接复用现有 `+`/`−` 按钮动作:以视口中心为锚点,按 `×1.16`/`×0.86` 改变缩放。
+- 鼠标悬停在预览画布上,或预览画布已获得键盘焦点时,快捷键生效。预览画布需提供可聚焦语义与无障碍名称。
+- 事件来自输入框、文本编辑区、按钮、链接、缩放工具栏或其他可操作控件时不拦截;不向 iframe 子文档注入监听。
+- 仅在存在可缩放视口且确认命中快捷键时调用 `preventDefault()` 与 `stopPropagation()`,防止浏览器页面同时缩放。
+- 保留现有 `Ctrl/Cmd+0` 适配画布与 `Ctrl/Cmd+1` 恢复 100% 行为,不增加其他重置快捷键。缩放仍是当前预览实例的临时 UI 状态。
+- 快捷键和百分比使用逻辑 `viewport.scale` 作为缩放真相;共享 `CanvasWorld` 的渲染 transform 可能包含超采样换算,验证时不得直接把 CSS transform 值当作用户可见比例。
+- 保留快捷键与缩放焦点边界的组件级回归测试,不扩展端到端测试。
+
+## 验收
+
+桌面端可通过按钮、轨道点击、拖动和键盘改变缩放;百分比与画布同步,边界不会越界。组件级测试覆盖平台修饰键、主键盘与小键盘变体、按键重复、悬停/焦点作用域、可编辑与可操作目标放行、无内容放行、浏览器默认行为拦截和现有 `0`/`1` 回归;另运行前端定向类型检查、编码检查和 `git diff --check`。
diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md
index 8cae77d2d..becec30de 100644
--- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md
+++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md
@@ -251,6 +251,7 @@ npm run check:server-rs-ddd
 10. 已有静态图片的 `POST /api/editor/images/pixel-art-snaps` 是免费 inline 派生操作,不调用外部 provider、不创建 `external_generation_job`、不读写泥点 ledger,也不进入任务侧栏。免费不放宽 owner、稳定引用、输入上限、持久化或处理阶段零持久化门禁。
 11. 主站编辑器生成队列使用同一次前端请求稳定复用的 `x-request-id`,按 namespace + owner + job kind + request id 生成唯一 `dedupe_key`;首次请求已入队但响应丢失时,重试必须返回原任务。同一幂等键携带不同 payload 返回 `409`,不得创建第二个任务或串到旧结果。外部 v1 的 `Idempotency-Key` 使用独立 namespace,不能与主站请求标识碰撞。幂等 payload 比较只对本次已迁移 sanitizer 的图片生成、图片修改、去背景、图标图集和 UI 提取任务,兼容“升级前旧任务仍含客户端 `generationInputs.references`、当前请求已删除该字段”的单向形状;当前请求仍含 references,或 job kind 属于音频 / 视频 / 角色动作等未迁移任务时必须完整比较,其余请求字段始终完全一致。
 12. `generationInputs.references` 是最终资产的服务端权威行引用,不接受客户端自报 provenance。图片生成类请求入队、完美像素及直接创建资源 / 素材时删除客户端 references;worker 和 inline 路径按本次真实参考图、当前 owner 的项目资源 / 素材记录重建 `refType/refId` 后再持久化。仅能证明 owned objectKey、但找不到对应资源或素材行时可以参与生成,不得制造虚假行引用;`title/label` 只作为展示快照,不提升为资源身份。完美像素为兼容升级前的未知结果重放,可继续用旧版 canonical 客户端输入计算 operation fingerprint;新操作持久化元数据只能使用服务端重建值。owner-scoped 项目快照发现同一 operation 的稳定 result `resourceId` 时,HTTP 路径必须在来源解析、OSS 下载、规整、preflight 和 PUT 前直接返回 `409`,携带 `operationResultAlreadyExists=true` 与 `resultResourceId`,客户端仅以 GET-only 项目对账判定权威结果,不复用既存 metadata 作 exact compare-and-return。
+13. 资产操作扣费必须在既有 `profile_wallet_ledger.metadata_json` 中记录服务端确定的 `assetKind`;外部生成任务继续同时记录 `externalGenerationJobId` 与 `externalGenerationClaimAttempt`。公开 `GET /api/profile/wallet-ledger` 不返回原始 metadata、资源 ID 或任务 ID,只为 `asset_operation_consume` 下发可选的用户可见 `reason`:图片、图标图集、美术规范、UI 设计和发布素材等图片生成统一显示“生成美术素材”,图片修改显示“编辑美术素材”,UI 素材提取显示“提取美术素材”,角色动画、视频、音效和背景音乐分别显示对应生成原因。未知、空值和未携带新 metadata 的历史流水不猜测业务含义,不下发 `reason`,共享前端继续回退到来源类型文案“资产操作消耗”。该展示投影不得改变定价、扣费顺序、幂等 ledger、失败退款或余额结算语义。
 
 ## 外部服务与资产
 
@@ -660,7 +661,7 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复
 
 - 私有单例表,主键 `id=0`,保存 `catalog_json`、`revision`、`updated_at`;不存凭据。
 - `read_agc_model_catalog` / `save_agc_model_catalog` 只接受已登记的 runtime service identity,保存使用 revision 乐观锁。
-- 后台 owner 通过 `GET/PUT /admin/api/agc-models` 管理稳定标识、必填别名、实际模型名、启用状态和默认项;客户端 `GET /api/llm/models` 仅返回启用项的稳定标识和别名。
+- 后台 owner 通过 `GET/PUT /admin/api/agc-models` 管理稳定标识、必填别名、实际模型名、启用状态和默认项;客户端 `GET /api/llm/models` 仅返回启用项的稳定标识、别名和目录 `revision`(供条件刷新,不暴露实际模型名)。
 - Responses / Chat 请求按目录解析模型;未知或停用项拒绝。AGC 的 `platform-default` 请求标识使用目录默认项。详细契约见 `technical/【技术方案】AGC后台模型别名与对话选择-2026-09-05.md`。
 
 ### `error_report`
diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md
index 69b9c9325..a1bdae5e9 100644
--- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md
+++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md
@@ -62,9 +62,9 @@ Linux 本机多用户并发开发时,`npm run dev`、`npm run dev:*` 单模块
 
 后端日志默认写入 `logs/api-server/`,独立 BgFilter worker 日志默认写入 `logs/bgfilter-worker/`。后端 API smoke 使用 `npm run dev:api-server`,先检查 BgFilter worker `/readyz`,再检查 API `/healthz`;需要确认 API 实例可接生产流量时检查 API `/readyz`。不要使用旧 `api-server:maincloud` 或任何 `GENARRATIVE_SPACETIME_MAINCLOUD_*` 口径。
 
-AI 游戏创作客户端使用 `npm run agc`。该入口由 `apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs` 解析 AGC Vite 实际端口:Linux 默认取当前用户端口段的 `start + 5`,占用时只在本用户段内漂移;Windows / macOS 保留 `3080` 为兼容首选并允许统一漂移。最终端口通过 `GENARRATIVE_AGC_VITE_PORT` 传给 `beforeDevCommand` 和配套后端端口解析器,通过 Tauri CLI 动态 `build.devUrl` 配置传给 WebView,并通过 Vite CLI `--port` 启动严格监听;Vite 继续使用 `strictPort`,任何一层都不得自行改到另一个端口。AGC 配套后端的 `backend` 模式启动 SpacetimeDB、独立 `bgfilter-worker` 和 `api-server`,并在复用现有后端前同时检查三者状态及 `/v1/ping`、`/readyz`、`/healthz`;worker 缺失时不得把不完整的 API/数据库组合误判为 ready。启动器在创建原生窗口前预检最终地址;若竞态中该地址被 AGC Vite、无响应监听器或其它服务占用,一律失败关闭,不复用、也不擅自终止无法证明归属的进程。
+AI 游戏创作客户端使用 `npm run agc`。该入口由 `apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs` 解析 AGC Vite 实际端口:Linux 默认取当前用户端口段的 `start + 5`,占用时只在本用户段内漂移;Windows / macOS 保留 `3080` 为兼容首选并允许统一漂移。最终端口通过 `GENARRATIVE_AGC_VITE_PORT` 传给 `beforeDevCommand` 和配套后端端口解析器,通过 Tauri CLI 动态 `build.devUrl` 配置传给 WebView,并通过 Vite CLI `--port` 启动严格监听;Vite 继续使用 `strictPort`,任何一层都不得自行改到另一个端口。AGC 配套后端的 `backend` 模式启动 SpacetimeDB、独立 `bgfilter-worker` 和 `api-server`,并在复用现有后端前同时检查三者状态及 `/v1/ping`、`/readyz`、`/healthz`;worker 缺失时不得把不完整的 API/数据库组合误判为 ready。任一配套服务在启动阶段进入 `failed` 时,外层启动器必须立即报告具体服务和退出原因,不能继续等待前端地址超时。端口健康不等于归属正确:复用前还必须证明端口上的监听进程属于当前工作树(Windows 按 `server-rs/target/debug/api-server.exe` 绝对路径与 SpacetimeDB `--data-dir` 校验,探测不可用时退化为旧行为),无法证明归属时一律不复用,改为启动本工作树自己的后端并在需要时端口漂移;否则上个工作树 Ctrl+C 残留的后端会被当成自己的后端复用,改了数据库的工作树会连到旧库。启动器在创建原生窗口前预检最终地址;若竞态中该地址被 AGC Vite、无响应监听器或其它服务占用,一律失败关闭,不复用、也不擅自终止无法证明归属的进程。
 
-Tauri `beforeDevCommand` 默认与客户端构建并行,不能把上述检查只放在 `beforeDevCommand` 内:选定地址上若已有旧 Vite,Tauri 可能先创建加载旧前端的窗口,随后配套后端才因代理不匹配退出。外层启动器会把 Tauri CLI 放入受控进程树;CLI 正常退出、启动失败或收到终止信号后,POSIX 先向保留的 PGID 发送 `SIGTERM`、有界等待后升级 `SIGKILL`,Windows 使用 `taskkill /PID  /T /F`。Linux 容器中的孤儿后代退出后可能暂时保留为 zombie,`kill(-PGID, 0)` 仍会返回成功;启动器必须结合 `/proc//stat` 判断同组是否还存在非 zombie 成员,不能把等待 PID 1 回收误报为清理失败。配套后端和 Vite 仍由 `start-dev-stack.mjs` 各自持有,退出时同样有界收束,避免只剩客户端、Runner、Cargo 或旧订阅进程。排障时同时核对控制台输出的 AGC Vite 实际地址及其 marker、`.app/dev-stack.json` 的实际 API URL 和进程 cwd;不要把“终端已返回”当成客户端及其 Runner 已退出的证据。
+Tauri `beforeDevCommand` 默认与客户端构建并行,不能把上述检查只放在 `beforeDevCommand` 内:选定地址上若已有旧 Vite,Tauri 可能先创建加载旧前端的窗口,随后配套后端才因代理不匹配退出。外层启动器会把 Tauri CLI 放入受控进程树;CLI 正常退出、启动失败或收到终止信号后,POSIX 先向保留的 PGID 发送 `SIGTERM`、有界等待后升级 `SIGKILL`,Windows 使用 `taskkill /PID  /T /F`。Windows 下每个长驻服务都经 `cmd.exe /d /s /c` 包装层启动,Ctrl+C 会先杀掉包装层(退出码 `0xC000013A`),因此清理不能只看直接子进程是否存活:`taskkill` 对已退出的 PID 只会失败,必须继续按记录下来的根 PID 遍历,并在退出时按本工作树 `api-server.exe` 绝对路径(以及本次自己拉起的 SpacetimeDB `--data-dir`)做一次身份兜底清扫;`scripts/dev-windows-process.mjs` 是这套判定的唯一实现。Linux 容器中的孤儿后代退出后可能暂时保留为 zombie,`kill(-PGID, 0)` 仍会返回成功;启动器必须结合 `/proc//stat` 判断同组是否还存在非 zombie 成员,不能把等待 PID 1 回收误报为清理失败。配套后端和 Vite 仍由 `start-dev-stack.mjs` 各自持有,退出时同样有界收束,避免只剩客户端、Runner、Cargo 或旧订阅进程。排障时同时核对控制台输出的 AGC Vite 实际地址及其 marker、`.app/dev-stack.json` 的实际 API URL 和进程 cwd;不要把“终端已返回”当成客户端及其 Runner 已退出的证据。
 
 Windows 本地 `npm run dev` / `npm run dev:api-server` / `npm run dev:bgfilter-worker` 会用空的 `RUSTC_WRAPPER` / `CARGO_BUILD_RUSTC_WRAPPER` 覆盖 `server-rs/.cargo/config.toml` 里的 `sccache`,从而直连真实 `rustc`。完整栈和 `dev:api-server` 把 API 与 BgFilter worker 作为一个 Rust 重启单元:源码变化时先停两个进程,再先启动并验活 worker、最后启动并验活 API,避免两个 `cargo run` 并发链接同一个 Windows 可执行文件。不要把 wrapper 绕过值写成 `rustc`;Cargo 会按 wrapper 协议调用 `rustc <真实rustc路径> - ...`,最终报 `multiple input filenames provided` 并导致 api-server 无法启动。排查本地启动失败时,先看 dev 日志是否出现该错误,再确认脚本注入的 wrapper 为空。
 
diff --git a/local-docs/【修复记录】Issue226跨Origin重定向标记泄漏修复-2026-09-02.md b/local-docs/【修复记录】Issue226跨Origin重定向标记泄漏修复-2026-09-02.md
deleted file mode 100644
index ca6b18e5b..000000000
--- a/local-docs/【修复记录】Issue226跨Origin重定向标记泄漏修复-2026-09-02.md
+++ /dev/null
@@ -1,84 +0,0 @@
-# Issue #226:跨 origin 重定向标记泄漏复审修复
-
-更新时间:2026-09-02
-关联 Issue:`#226 添加客户端特殊标识`;交接 Issue:`#225 添加客户端埋点统计`
-
-## 1. 修复结论
-
-采纳评审意见:AGC 主站 Client 的默认重定向策略不能继续使用 reqwest 的“任意 origin 默认跟随”,否则 `default_headers` 中的 `X-Genarrative-Client: agc` 可能被复制到 OSS 或第三方 origin。
-
-本次修复将工厂默认策略调整为:
-
-- 目标 URL 与原始请求 URL 的完整 origin(scheme + host + effective port)相同:委托 reqwest 默认 redirect policy。
-- origin 不同:`stop`,返回当前 3xx,不发起下一跳请求。
-- 无法取得原始 URL 或 origin 无法确认:按 fail-closed 处理,停止重定向。
-
-这不是把 factory 改成全局 `Policy::none()`。同 origin 的 redirect 仍保留原有默认限制、方法和请求体处理;调用点显式设置的 `Policy::none()` 继续有效。
-
-另针对评审发现的同名 Header 覆盖缺口,补充了主站请求终结器:`default_headers` 继续负责普通请求的默认注入;所有已审计的 AGC 主站请求在发送前统一调用 `with_agc_main_site_marker`,使用 `RequestBuilder::headers` 替换调用方可能传入的同名 Header,确保最终值固定为 `agc`。
-
-## 2. 修改范围
-
-修改 AGC Rust HTTP Client factory、主站请求终结器、已审计主站直发调用点和定向测试:
-
-- `apps/ai-game-creator-shell/src-tauri/src/http_client.rs`
-- 已审计主站请求所在的 `assets.rs`、`commands.rs`、`canvas_generation.rs`、`direct_runtime.rs`、`direct_tool_bridge.rs`、`project/asset_canvas/generation.rs`、`project/resource_editor.rs`
-- 本地实施方案、阶段计划和本复审记录
-
-不修改:
-
-- #225 的主站 tracking、数据库和后台代码。
-- OSS/签名下载、Provider、受控搜索、loopback、更新下载等第三方 Client。
-- 各业务调用点的 timeout、connect timeout、认证、幂等或请求体逻辑。
-
-## 3. 重定向行为契约
-
-| 场景 | 处理 | 标记是否到达下一跳 |
-|---|---|---:|
-| 同 scheme、host、effective port | 继续按 reqwest 默认策略 | 是,仍是主站 origin |
-| scheme、host 或端口任一不同 | 返回当前 3xx,不 follow | 否,不发起请求 |
-| 调用点显式 `Policy::none()` | 继续不 follow | 否 |
-
-使用 `Url::origin()` 比较,不比较 URL 字符串前缀;路径、查询参数和 fragment 不参与 origin 判断。
-
-## 4. 同名 Header 覆盖契约
-
-`reqwest` 的 `ClientBuilder::default_headers` 只会为请求补充缺失字段,请求级同名 Header 默认优先。因此不能把 `default_headers` 单独当作“不可伪造”的约束。
-
-当前实现由两层组成:
-
-- factory 设置 `default_headers`,覆盖普通未显式设置 Header 的请求;
-- `with_agc_main_site_marker(request)` 在主站请求发送前用 `RequestBuilder::headers` 替换同名字段。`external_editor_json_request` 已内置该终结器,直接 `.send()` 的主站路径也显式经过该终结器。
-
-这样既保留方案一的 Client factory 形态,又满足“调用方传入同名 Header 时最终仍为 `X-Genarrative-Client: agc`”的冻结契约。
-
-## 5. 测试证据
-
-新增或调整 `http_client` 定向测试,覆盖:
-
-- 完整 origin 比较:默认端口等价,scheme/host/非默认端口变化视为跨 origin。
-- 同 origin 302:下一跳实际收到 `X-Genarrative-Client: agc`,最终响应成功。
-- 跨 origin 302:当前响应为 302,外部 listener 没有收到连接。
-- 显式 `Policy::none()`:仍然覆盖 factory 默认策略。
-- 请求级伪造同名 Header:终结器覆盖调用方值,服务端只收到 `agc`。
-
-验证命令及结果:
-
-```text
-cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml http_client -- --nocapture
-8 tests passed
-```
-
-## 6. 对 #225 的交接
-
-本修复不改变 Header 契约:
-
-```http
-X-Genarrative-Client: agc
-```
-
-主站只会收到客户端实际发出的主站请求;跨 origin 3xx 不会产生第二个第三方请求,因此不会出现客户端把该标记发送到 OSS/第三方 origin 的情况。#225 不需要回改 tracking、数据库或后台设计。
-
-## 7. 后续限制
-
-`agc_main_site_client_builder()` 仍返回原始 `reqwest::ClientBuilder`,理论上调用方可以再次覆盖 redirect policy,或新增请求时绕过终结器。当前已审计生产调用点均已经过终结器,只有显式 `Policy::none()` 的 redirect 覆盖,没有重新启用任意 origin follow 的调用。若未来需要类型级不可绕过,再单独评估 `AgcMainSiteClient` wrapper,不在本次复审修复中扩大范围。
diff --git a/local-docs/【实施方案】Issue225-AGC主站请求标记埋点统计-2026-09-02.md b/local-docs/【实施方案】Issue225-AGC主站请求标记埋点统计-2026-09-02.md
deleted file mode 100644
index 45311511f..000000000
--- a/local-docs/【实施方案】Issue225-AGC主站请求标记埋点统计-2026-09-02.md
+++ /dev/null
@@ -1,342 +0,0 @@
-# Issue #225:AGC 主站请求标记埋点统计实施方案
-
-更新时间:2026-09-02
-关联 Issue:
-
-- `#225 添加客户端埋点统计`:本文全部实施范围
-- `#226 添加客户端特殊标识`:客户端侧已完成;本文只接收其固定交接契约,不回改客户端
-
-当前状态:阶段 0~6 已完成。主站生产代码和定向测试已按阶段提交;阶段 6 仅补充最终门禁、交接与验收文档。未修改 SpacetimeDB schema、OpenAPI 或后台页面。
-
-## 1. 一句话交付结果
-
-主站 `api-server` 能识别 AGC 发来的 `X-Genarrative-Client: agc`,并在本期新增的 AGC 专用成功路由埋点写入 `tracking_event.metadata_json` 的 `client: "agc"`,同时按真实登录用户或 External API Key 的 `owner_user_id` 归属,后台可以通过现有 tracking 事件查询看到这类请求;既有 route tracking 和手工资产事件保持原有全客户端统计口径,不改变鉴权、计费、幂等和响应语义。
-
-## 2. Issue 边界
-
-### 2.1 本次只做 #225
-
-本次主改动限定在主站后端:
-
-- `server-rs/crates/api-server/src/app.rs` 的 tracking middleware 入口。
-- `server-rs/crates/api-server/src/tracking.rs` 的标记识别、主体归属、metadata 合并和 route tracking 覆盖。
-- 必要的 `api-server` 定向测试、External v1 路由测试和后台 tracking 读取验证。
-- 方案、验收、交接文档。
-
-### 2.2 本次不做 #226 的回改
-
-不修改:
-
-- AGC TS 的 `fetchClientHttp` 标记注入。
-- AGC Rust 主站 Client factory、请求终结器和 redirect policy。
-- AGC 主站/第三方请求边界、timeout、认证、幂等和请求体语义。
-
-`#226` 已冻结并合入的客户端契约直接作为本 Issue 的输入。
-
-### 2.3 明确不做项
-
-- 不新增 `tracking_event` 列、索引、migration 或生成 bindings;第一阶段复用已有 `metadata_json`。
-- 不新增平行的 `agc_client_request` 事件、平行表或平行统计口径。
-- 不改变 `/api/external/v1` 的路由、HTTP 方法、DTO、状态码、鉴权或异步语义,因此不改 OpenAPI 契约。
-- 不把 `generationInputs.source`、User-Agent、请求体中的 owner 字段当作客户端来源或主体。
-- 不记录 access token、External API Key 明文、Cookie、签名 URL、项目绝对路径或其他秘密。
-- 不把 OSS 上传、签名 URL 实际下载、LLM/Codex Provider、受控搜索、loopback、更新下载和任意外部网页请求当成主站业务 tracking。
-- 不默认把失败响应改造成新的 tracking 事实;先保持现有“成功响应才写 route tracking”语义。
-- 不默认新增后台筛选控件;后台先复用现有 tracking 原始事件查询展示 `metadata_json`。
-
-## 3. 当前实现基线与缺口
-
-### 3.1 现有 tracking 流程
-
-当前全局链路位于:
-
-- `server-rs/crates/api-server/src/app.rs`
-- `server-rs/crates/api-server/src/tracking.rs`
-
-流程为:
-
-```text
-请求进入
-  → tracking middleware 保存 method/path
-  → next.run(request)
-  → 从 response extensions 读取认证主体
-  → 仅对成功响应解析 RouteTrackingSpec
-  → 生成 TrackingEventDraft.metadata
-  → 本机 tracking outbox
-  → SpacetimeDB tracking_event / tracking_daily_stat
-```
-
-现有 `AuthenticatedAccessToken` 和 `ExternalApiPrincipal` 都会在认证 middleware 成功后写入 response extensions;但 tracking middleware 目前只消费前者。
-
-### 3.2 当前缺口
-
-- tracking middleware 尚未读取 `X-Genarrative-Client`。
-- route metadata 尚未写入 `client`。
-- External API Key 的 `ExternalApiPrincipal.owner_user_id` 尚未接入 route tracking 归属。
-- `/api/external/v1/*` 没有完整的 route tracking spec。
-- 账号态实际被 AGC 使用的 `/api/editor`、`/api/assets`、`/api/runtime` 路径也有未覆盖项。
-
-## 4. 冻结的 #226 交接契约
-
-### 4.1 Header 识别
-
-```http
-X-Genarrative-Client: agc
-```
-
-规则:
-
-- Header 名按 HTTP 规则大小写不敏感。
-- 值去除首尾空白后,精确等于小写 `agc` 才认定为 AGC。
-- 缺失、空值、`AGC`、其他未知值均按“未标记”处理。
-- 未标记请求不被拒绝,也不改变业务行为。
-- Header 只用于来源审计和统计,不参与鉴权、权限、计费、幂等或账号归属。
-
-### 4.2 metadata 形态
-
-第一阶段复用已有 `tracking_event.metadata_json`,在原对象上追加固定键:
-
-```json
-{
-  "route": "/api/editor/images/generations",
-  "method": "POST",
-  "status": 202,
-  "operation": "generateExternalEditorImage",
-  "client": "agc"
-}
-```
-
-约定:
-
-- JSON key 固定为 `client`,值固定为 `agc`。
-- 有效标记时追加 `client`;未标记时不写 `client`,不写空字符串或 `null`。
-- 已有 `route`、`method`、`status`、`operation` 以及资产类嵌套 metadata 必须保留。
-- `route` 使用主站实际收到的 method/path;External v1 不得只记录客户端账本中的内部映射路径。
-
-### 4.3 认证主体归属
-
-| 请求类型 | `user_id` | `owner_user_id` | `scope_kind/scope_id` | 说明 |
-|---|---|---|---|---|
-| 登录账号态 | 沿用 `AuthenticatedAccessToken.claims().user_id()` | 沿用现有行为,通常与 user_id 相同 | 沿用 route spec;User scope 使用真实用户 | Header 不能覆盖真实认证主体 |
-| External API Key 态 | 不伪造登录用户,可为空 | `ExternalApiPrincipal.owner_user_id()` | User scope 使用 owner_user_id | 归属 API Key 所属账号 |
-| 无认证的公开/站点请求 | 为空 | 为空 | 沿用 Site/公开 route spec | 只在已有公开 route spec 时记录 |
-
-第一阶段不把 API Key 明文写入 metadata。`key_id` 是安全的内部标识,但只有在后续明确需要按单个 Key 统计或审计时才增加 `externalApiKeyId`,不作为本期客户端交接前提。
-
-### 4.4 成功与失败语义
-
-本期先保留当前 route tracking 的成功响应语义:
-
-- 2xx 成功响应按 route spec 写入 tracking。
-- 3xx、4xx、5xx 和 transport failure 不因为 AGC 标记而自动新增 route 事件。
-- 生成提交、异步轮询、重试请求使用同一标记;是否落库仍由现有 route tracking 规则决定。
-
-如果产品后续要求统计失败调用,应另立失败事件的 event key、幂等键、容量和报表口径,不在本期隐式扩展。
-
-## 5. 推荐实现:扩展现有 route tracking
-
-本期采用方案一:在现有 route tracking 上追加来源和主体信息,不创建通用 AGC 请求事件。
-
-```text
-请求 Header
-  → tracking middleware 在 next.run 前白名单解析 marker
-  → next.run(request)
-  → 从 response extensions 读取 AuthenticatedAccessToken / ExternalApiPrincipal
-  → 按实际 method + path 解析 RouteTrackingSpec
-  → 计算 user/owner/scope
-  → 在现有 metadata 对象追加 client=agc
-  → 复用现有 outbox、tracking_event、tracking_daily_stat
-  → 现有后台 tracking 查询读取 metadata_json
-```
-
-### 5.1 为什么不新增通用 AGC 事件
-
-- 不会与现有 route event 重复计数。
-- 不会改变 `tracking_daily_stat` 的 event key 和历史统计口径。
-- 仍能看到实际 path、method、status、operation 和 client 的组合。
-- 继续复用现有 outbox、SpacetimeDB procedure 和后台原始事件查询。
-- 新增或确认 AGC 路由时只需补齐 route spec 和测试,不需要引入第二套事件系统。
-
-代价是 route coverage 必须显式审计;因此阶段 3 将以 AGC 客户端调用清单和 External v1 router/OpenAPI 交叉核对,禁止“标记已经发送但主站没有 route spec”的漏项。
-
-### 5.2 实现边界
-
-推荐保持最小抽象:
-
-- `app.rs` 在消费 request 之前解析 marker,并从 response extensions 取两类主体。
-- `tracking.rs` 提供小型白名单解析函数和主体归属逻辑。
-- `RouteTrackingSpec` 明确区分既有全客户端 route 与本期新增的 AGC-only route;新增 AGC route 只有在 marker 有效时才允许落库。
-- `build_route_tracking_metadata` 只在有效 marker 时追加 `client`,不重写既有字段。
-- `record_route_tracking_event_after_success` 继续负责 route spec、outbox 和失败日志策略。
-- 不把 marker 放进 `RequestContext`,除非阶段 1 证明同一请求的其他统一 tracking 入口确实需要它;避免扩大公共上下文结构。
-
-既有 route spec 默认保持全客户端语义;本期新增、仅为 AGC 调用清单补齐的 route spec 使用 AGC-only 策略。已有 `handled_by_existing_event` 的详细资产事件不改记录范围,只在有效 marker 时追加 `client`。
-
-## 6. Route 覆盖范围
-
-### 6.1 必须覆盖的账号态路径
-
-按 AGC 当前扫描清单,至少核对并覆盖:
-
-- `/api/auth/*`:当前登录用户查询、refresh、登录入口、发送验证码、手机号登录、logout 等实际调用。
-- `/api/profile/*`:dashboard、recharge center、recharge order/confirm、wallet ledger、API Key 管理。
-- `/api/assets/*`:direct-upload-tickets、objects/confirm、read-url、read-bytes 以及 AGC 实际使用的资产操作。
-- `/api/editor/*`:projects、assets/library、assets/folders、项目 resources、图片/编辑/去背景/图集/角色动画/视频/音效/BGM 生成和轮询相关内部路径。
-- `/api/runtime/external-generation/jobs/{operationId}`:账号态生成任务轮询。
-
-已有 route spec 的 event key 和 scope 语义保持不变;新增项使用稳定、可读、与路径/operation 对应的 event key。
-
-### 6.2 必须覆盖的 External API Key 业务路径
-
-按 `server-rs/crates/api-server/src/modules/external_api.rs` 和实际 AGC 使用情况核对:
-
-- `/api/external/v1/assets/direct-upload-tickets`
-- `/api/external/v1/assets/objects/confirm`
-- `/api/external/v1/assets/read-url`
-- `/api/external/v1/editor/projects` 及项目读取/资源登记相关路径
-- `/api/external/v1/editor/assets/library`、folders、asset CRUD 中实际被 AGC 使用的路径
-- `/api/external/v1/editor/images/generations`
-- `/api/external/v1/editor/images/edits`
-- `/api/external/v1/editor/images/background-removals`
-- `/api/external/v1/editor/icon-spritesheets/generations`
-- `/api/external/v1/editor/character-animations/generations`
-- `/api/external/v1/editor/videos/generations`
-- `/api/external/v1/editor/audios/sound-effects/generations`
-- `/api/external/v1/editor/audios/background-music/generations`
-- `/api/external/v1/generations/{operationId}`
-
-External v1 路由的 tracking 必须记录外部实际 path,不把它改写成 `/api/editor`、`/api/assets` 或 `/api/runtime`。
-
-### 6.3 明确排除的 External v1 路径
-
-以下是公开发现或集成协议入口,不属于普通 AGC 主站业务调用,本期不新增普通 route tracking spec:
-
-- `GET /api/external/v1/openapi.json`
-- `GET /api/external/v1/agent-integration.json`
-- `GET /api/external/v1/skill/SKILL.md`
-- `GET /api/external/v1/skill.zip`
-- `POST /api/external/v1/mcp`
-
-如果未来 AGC 明确接入远程 MCP/Skill,再单独定义集成流量的事件语义,不把它们隐式混入普通业务统计。
-
-## 7. 数据库与后台承接
-
-### 7.1 数据库
-
-沿用现有链路:
-
-```text
-TrackingEventDraft.metadata
-  → RuntimeTrackingEventInput.metadata_json
-  → tracking outbox
-  → record_tracking_event procedure
-  → tracking_event.metadata_json
-```
-
-不增加字段、不改 migration、不重新生成 bindings。现有 JSON object 校验足以容纳 `client` 键;`tracking_daily_stat` 继续按原 event key/scope/day 聚合,不按 client 另建统计表。
-
-### 7.2 后台
-
-现有后台 tracking 查询已经返回 `metadata_json`,第一阶段只做数据可见性验证:
-
-- 管理员能在原始事件详情中看到 `client: "agc"`。
-- 原有 event key、scope、user/owner、日期查询不回归。
-- 不新增客户端筛选器、导出列或新的后台路由。
-
-如果后续需要高频按 client 筛选,再评估新增结构化列和索引;那将是独立 schema 变更,不应在本期偷偷引入。
-
-## 8. 安全与兼容性约束
-
-- 来源 Header 是可伪造的审计标签,不能当作安全边界。
-- 主体必须来自已验证的 `AuthenticatedAccessToken` 或 `ExternalApiPrincipal`,不能信任请求体 owner、客户端账本或 Header 中的身份。
-- 不记录 Bearer token、API Key 原文、Cookie、签名 URL 或请求体中的敏感字段。
-- Header 缺失/未知时继续走原有业务和 tracking 逻辑,只是不追加 `client`。
-- metadata 合并必须保留既有资产嵌套字段,不得用新对象覆盖旧 metadata。
-- outbox 入队、SpacetimeDB 写入失败继续只记录 warning,不阻断主业务响应。
-- 不修改 route normalization、dynamic id 归一化、event id 幂等或 daily stat 聚合规则。
-
-## 9. 定向测试方案
-
-### 9.1 Marker 解析
-
-表驱动测试覆盖:
-
-- `X-Genarrative-Client: agc` → `Some("agc")`。
-- Header 名大小写变化 → 仍识别。
-- 缺失、空值、首尾空白、`AGC`、未知值 → 未标记。
-
-### 9.2 Metadata 与主体归属
-
-- 有效 marker 的账号态成功路由:metadata 含 `client: "agc"`,user_id/owner_user_id 保持真实用户。
-- 有效 marker 的 External v1 成功路由:metadata 含 `client: "agc"`,owner_user_id 等于 `ExternalApiPrincipal.owner_user_id()`,scope_id 不落到 `anonymous`。
-- 未标记请求:metadata 不含 `client`。
-- 请求体伪造 owner 或 marker 不改变主体归属。
-- metadata 原有 route/method/status/operation 和资产嵌套字段仍存在。
-
-### 9.3 AGC-only 记录门禁
-
-- 本期新增的账号态和 External v1 AGC route:带有效 marker 的 2xx 响应才记录。
-- 同一批新增 route:缺失、非法或未知 marker 时不记录,也不拒绝业务请求。
-- 既有 route spec 和已有详细资产事件保持原有全客户端记录语义。
-- 4xx/5xx 即使带有效 marker 也不新增成功 route event。
-- `build_router` middleware 集成测试验证 marker、账号认证主体、成功 route tracking 和隔离 outbox 可以串联,且 metadata 保留 `client`、实际 route 和 user/owner。
-
-### 9.4 Route 覆盖
-
-- 当前 AGC 调用清单中的账号态路径全部能解析到 route spec。
-- 当前 AGC 使用的 External v1 业务路径全部能解析到 route spec。
-- `/api/external/v1` 发现、Skill 和 MCP 路径不被普通业务 spec 误收录。
-- dynamic project/asset/operation ID 仍按现有 normalize 规则归一化。
-
-### 9.5 持久化与后台读取
-
-- 构造 tracking event input 后,`metadata_json` 是合法 JSON object 且含 `client`。
-- outbox 入队/回退直写路径不丢失 `client`。
-- tracking_event 读回及后台 tracking API 解析不丢失 metadata。
-- 现有 daily stat、event id 幂等和失败不阻断语义保持不变。
-
-### 9.6 状态码语义
-
-至少保留一组回归:
-
-- 2xx 成功响应写入 route tracking。
-- 4xx/5xx 不因为 marker 自动新增成功 route event。
-- 认证失败不伪造 ExternalApiPrincipal 或用户归属。
-- 轻量 middleware 成功链路测试通过;不依赖真实 SpacetimeDB,不纳入完整环境型 E2E。
-
-## 10. 实施顺序
-
-1. 阶段 0:确认基线、交接契约、路径边界和不做项。
-2. 阶段 1:加入 marker 白名单解析,并将解析结果传入现有 route tracking。
-3. 阶段 2:接入 `AuthenticatedAccessToken` / `ExternalApiPrincipal`,完成 owner/user/scope 归属和 metadata 合并。
-4. 阶段 3:按 AGC 调用清单补齐账号态与 External v1 业务 route spec,明确排除发现/MCP。
-5. 阶段 4:验证 outbox、SpacetimeDB `tracking_event` 和现有后台原始查询,无 schema 变更。
-6. 阶段 5:完成 marker、主体、route 覆盖、状态码、安全和持久化定向测试。
-7. 阶段 6:执行最终门禁,更新交接材料并把结果回传 Issue #225;不要求 #226 回改。
-
-## 11. 完成判据
-
-只有同时满足以下条件,#225 才算完成:
-
-- 主站精确识别 `X-Genarrative-Client: agc`,未知/缺失值不拒绝请求。
-- 有效标记进入现有 route tracking metadata,写入 `client: "agc"`。
-- 已有 route、method、status、operation、资产 metadata 和 event key 语义不丢失。
-- 登录账号态按真实用户归属,External API Key 态按 `owner_user_id` 归属。
-- 当前 AGC 实际使用的账号态和 External v1 业务路径均有 tracking spec。
-- 本期新增的 AGC route spec 未携带有效 marker 时不产生 route tracking event;既有 route spec 和手工资产事件保持原有全客户端统计语义。
-- External v1 discovery/Skill/MCP、OSS、签名下载、Provider、搜索、loopback、更新下载不被误纳入普通 AGC 业务统计。
-- outbox、SpacetimeDB 写入、daily stat、幂等和失败不阻断语义无回归。
-- 后台现有 tracking 查询可以看到 `metadata_json.client`,无需新增 schema 或页面。
-- 定向测试和必要的格式/编码/空白检查通过。
-
-## 12. 评审与提交节奏建议
-
-建议按阶段分组提交或至少在一个 PR 中保持以下逻辑顺序:
-
-1. 阶段 0 文档与契约冻结。
-2. marker 解析、metadata 合并和账号态主体接入。
-3. External API Key 主体接入与 External v1 route spec。
-4. 持久化/后台读取验证和定向测试。
-5. 最终文档、交接评论和门禁记录。
-
-每个阶段先通过自身验收,再进入下一阶段;不需要等待 #226 再次修改,也不需要先新增数据库字段。
diff --git a/local-docs/【实施方案】Issue226-AGC客户端主站请求统一标记-2026-09-01.md b/local-docs/【实施方案】Issue226-AGC客户端主站请求统一标记-2026-09-01.md
deleted file mode 100644
index 3cdbc576d..000000000
--- a/local-docs/【实施方案】Issue226-AGC客户端主站请求统一标记-2026-09-01.md
+++ /dev/null
@@ -1,470 +0,0 @@
-# Issue #226:AGC 客户端主站请求统一标记实施方案
-
-更新时间:2026-09-02
-关联 Issue: - -- `#226 添加客户端特殊标识`:本方案实际实施范围 -- `#225 添加客户端埋点统计`:主站接收、落库和后台统计,本文只冻结交接契约,不在本次实施 - -状态:阶段 6 已完成;已补齐跨 origin 重定向安全复审修复
-本方案包含 #226 客户端代码修改,不包含 #225 主站接收、落库和后台统计代码 - -## 1. 一句话交付结果 - -让 AGC 客户端发往当前选定 Genarrative 主站 origin 的业务 HTTP 请求统一携带 `X-Genarrative-Client: agc`,同时保持同源重定向及现有认证、幂等、timeout、connect timeout 和上传/下载安全边界;跨 origin 重定向不继续请求,第三方请求不携带该标记。 - -## 2. Issue 边界 - -### 2.1 本次只做 #226 - -本次只修改 AGC 客户端: - -- Web/TS 统一请求出口。 -- Tauri/Rust 主站专用 HTTP Client 构造方式。 -- 主站请求与第三方请求的 Client 边界。 -- 客户端侧定向测试。 - -### 2.2 本次不做 #225 - -本次不修改: - -- `server-rs` 请求上下文。 -- 主站 `tracking.rs`、tracking middleware 或 route tracking 规格。 -- SpacetimeDB `tracking_event` schema、migration、bindings 或索引。 -- 后台埋点查询、筛选和报表。 -- External v1 OpenAPI、请求 DTO 和响应 DTO。 - -### 2.3 不恢复或扩展其他旧标记 - -AGC 当前已有部分 body 内来源值: - -- `ai-game-creator-client` -- `game-creator-account-binding` -- `game-creator-resource-editor` - -本次不删除、不改写这些现有业务字段,也不把它们扩展成统一调用标记。它们继续服务各自的生成/资源登记语义;统一来源以 HTTP Header 为准。 - -## 3. 冻结的客户端标记契约 - -### 3.1 Header - -```http -X-Genarrative-Client: agc -``` - -约定: - -- Header 名按 HTTP 规则大小写不敏感;客户端发送时固定使用上面的拼写。 -- 值固定为小写 `agc`。 -- 客户端应覆盖调用方传入的同名 Header,避免业务层伪造其他值。 -- 不携带 token、用户 ID、API Key、版本号或项目 ID。 -- 不将该 Header 用作鉴权、权限、计费或账号归属证明。 - -### 3.2 发送范围 - -只要请求目标是当前选定的 Genarrative 主站 origin,并且属于主站业务/API 请求,就携带该 Header。 - -包括: - -- 登录、刷新、登出和用户查询。 -- 账户、钱包和充值接口。 -- 项目、画布、素材库和素材读取接口。 -- 上传凭证、对象确认和项目资源登记接口。 -- 图片、图集、去背景、角色动画、视频、音效和背景音乐生成接口。 -- 生成任务异步轮询接口。 -- 登录账号态映射后的 `/api/editor`、`/api/assets`、`/api/runtime` 请求。 -- 开发者 API Key 态的 `/api/external/v1` 请求。 -- 创建本机开发者 API Key 的 `/api/profile/api-keys` 请求。 - -不包括: - -- 更新清单和更新包下载。 -- OSS/对象存储 multipart 上传。 -- `read-url` 返回的签名地址下载。 -- LLM Provider 请求。 -- AGC 受控搜索。 -- loopback 工具桥。 -- 浏览器试玩页面和任意外部网页请求。 - -### 3.3 主站 origin - -主站 origin 必须来自当前选定的 `serverBaseUrl` / `apiBaseUrl`,包括: - -- 发布地址。 -- 开发地址。 -- 用户配置的合法自定义地址。 - -不能只根据硬编码域名判断,也不能因为 URL 路径包含 `/api` 就认定它是主站。 - -## 4. 方案一的落地结构 - -本次采用“统一 Client 工厂 + `default_headers` + origin-safe redirect policy”方案,不引入 origin-aware RequestBuilder 包装器。 - -总体结构: - -```text -TS 主站请求 - → fetchClientHttp - → 解析当前选定主站目标 - → 注入 X-Genarrative-Client: agc - → fetch / Tauri HTTP plugin - -Rust 主站请求 - → 主站 Client Builder factory - → default_headers 注入 X-Genarrative-Client: agc - → 请求发送前由主站请求终结器覆盖同名 Header - → 默认只跟随同 origin 重定向,跨 origin 返回当前 3xx - → 保留调用方原有 Client 配置 - -Rust 第三方请求 - → 独立 Client - → 不携带主站标记 -``` - -## 5. TS 实施方案 - -### 5.1 统一入口 - -文件: - -- [clientHttp.ts](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src/services/clientHttp.ts) - -在 `fetchClientHttp` 中: - -1. 按当前已有逻辑解析最终目标 URL。 -2. 保持现有的服务器地址校验和 transport 选择。 -3. 使用 `Headers` 合并 `init.headers`。 -4. 设置 `X-Genarrative-Client: agc`。 -5. 按现有逻辑调用浏览器 `fetch` 或 Tauri HTTP plugin。 - -### 5.2 需要保持的 Header - -不得覆盖或删除: - -- `Authorization` -- `Content-Type` -- `x-genarrative-response-envelope` -- 调用方已有的 `X-Request-ID` - -### 5.3 TS 侧不应修改的请求 - -[appUpdate.ts](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src/services/appUpdate.ts) 中直接访问更新清单的请求不经过 `fetchClientHttp`,继续保持现状,不纳入主站业务标记。 - -## 6. Rust 实施方案 - -### 6.1 主站 Client Builder factory - -在 AGC Tauri/Rust 侧增加一个小型、无业务语义的主站 Client Builder 入口。推荐职责只有: - -- 创建 `reqwest::ClientBuilder`。 -- 设置 `X-Genarrative-Client: agc` 默认 Header。 -- 设置只允许同 origin 的默认重定向策略;同源时委托 reqwest 默认策略,跨 origin 时停止。 -- 不负责 token、API Key、幂等键、请求体、重试或错误解析。 - -由于 `reqwest::ClientBuilder::default_headers` 只补充请求中缺失的字段,主站请求在发送前还必须经过 `with_agc_main_site_marker(request)`。该终结器使用 `RequestBuilder::headers` 替换调用方可能传入的同名 Header,保证最终值固定为 `agc`;`external_editor_json_request` 已内置该步骤,直接 `.send()` 的主站路径显式调用该终结器。 - -示意结构: - -```rust -fn agc_main_site_client_builder() -> reqwest::ClientBuilder { - let mut headers = reqwest::header::HeaderMap::new(); - headers.insert( - reqwest::header::HeaderName::from_static("x-genarrative-client"), - reqwest::header::HeaderValue::from_static("agc"), - ); - let default_policy = reqwest::redirect::Policy::default(); - let redirect_policy = reqwest::redirect::Policy::custom(move |attempt| { - let same_origin = attempt - .previous() - .first() - .map(|initial| initial.origin() == attempt.url().origin()) - .unwrap_or(false); - if same_origin { - default_policy.redirect(attempt) - } else { - attempt.stop() - } - }); - reqwest::Client::builder() - .default_headers(headers) - .redirect(redirect_policy) -} -``` - -实际实现可放在 AGC Rust 已有的客户端公共模块中;本 Issue 只增加轻量请求终结器,不新增完整 `AgcMainSiteClient` RequestBuilder 包装器,不提前实现方案二。 - -### 6.2 保留现有 Client 配置 - -主站请求原有 builder 配置必须在 factory 返回的 builder 上继续设置: - -```rust -let client = agc_main_site_client_builder() - .connect_timeout(Duration::from_secs(10)) - .timeout(Duration::from_secs(60)) - .redirect(reqwest::redirect::Policy::none()) - .build()?; -``` - -必须保留的语义包括: - -- connect timeout。 -- request timeout。 -- 调用方显式设置的 redirect policy;未显式设置时保留同源默认重定向、默认限制和方法处理,跨 origin 重定向由 factory 停止。 -- `no_proxy` 或其他已有网络策略(仅属于原请求的情况下保留)。 -- Bearer access token/API Key 的设置方式。 -- `Idempotency-Key` 的设置方式和原值。 -- Content-Type、multipart、JSON body 和响应解析方式。 - -`reqwest::Client::new()` 不能配置 `default_headers`。如果它被用于生产主站请求,需要改为从 builder 创建;不能为了少改一行而遗漏标记。 - -### 6.3 只替换主站请求的创建点 - -潜在涉及的生产文件: - -- [assets.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/assets.rs) -- [commands.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/commands.rs) -- [canvas_generation.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs) -- [direct_runtime.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs) -- [direct_tool_bridge.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs) -- [asset_canvas/generation.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas/generation.rs) -- [resource_editor.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs) - -替换原则: - -- 主站 API Client 的 `Client::builder()` 改用 AGC 主站 builder。 -- 生产主站 API 中使用 `Client::new()` 的位置改用 AGC 主站 builder,并保留原 timeout 等配置。 -- 只访问主站的专用 Client 仍使用 factory 的默认 Header,并在最终 `.send()` 前经过 `with_agc_main_site_marker`;不能依赖 `default_headers` 覆盖请求级同名 Header。 -- 一个 Client 如果同时访问主站和第三方地址,不得直接改成主站 Client;应拆分用途或保持第三方 Client 独立。 - -### 6.4 不应替换的 Client - -以下请求明确保持独立 Client,不加主站标记: - -- [codex_provider_proxy.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/agent/codex_provider_proxy.rs) 的 Provider 请求。 -- `build_external_asset_download_client` 创建的签名素材下载 Client。 -- OSS 表单直传 Client。 -- [direct_tool_bridge.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs) 的受控搜索 Client。 -- loopback 客户端工具桥 Client。 -- [main.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/main.rs) 的更新下载请求。 - -### 6.5 阶段 3 实施记录 - -- 已将生产代码中明确用于主站 API 的 Client 创建点切换为 `crate::http_client::agc_main_site_client_builder()`,覆盖 `assets.rs`、`commands.rs`、`canvas_generation.rs`、`direct_runtime.rs`、`direct_tool_bridge.rs`、`asset_canvas/generation.rs` 和 `resource_editor.rs`。 -- 主站项目、素材库、素材目录、上传凭证、对象确认、资源登记、图片生成/编辑/抠图、视频/动画/音频生成以及 generation 查询请求复用带 `X-Genarrative-Client: agc` 的 Client。 -- `assets/read-url` 换签请求使用主站 factory;签名 URL 媒体下载仍使用独立的 `build_external_asset_download_client`,不会携带主站标记。 -- OSS multipart 上传、Provider、受控搜索、loopback 工具桥和更新下载没有切换到主站 factory。 -- 保留原有 timeout、connect timeout、`no_proxy`、Bearer/API Key、Idempotency-Key、请求体和响应处理;同源重定向保持默认语义,跨 origin 重定向由 factory 阻断;未修改 `ExternalEditorBindingAccess` 的路由映射语义。 -- 阶段 3 请求捕获验证:`sync_canvas_project_assets_downloads_external_resources`(1 test passed)确认账号态项目请求和 `assets/read-url` 带标记,签名媒体下载不带标记。 -- 阶段 3 验证命令:`cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml http_client -- --nocapture`(2 tests passed);`cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml sync_canvas_project_assets_downloads_external_resources -- --nocapture`(1 test passed);`cargo fmt --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml -- --check`、`npm run check:encoding` 和 `git diff --check` 均通过。 - -### 6.6 阶段 4 实施记录 - -- 保持第三方请求的独立 Client 边界:`build_external_asset_download_client`、Codex Provider 代理、受控搜索、loopback 工具桥和更新下载均未接入主站 factory。 -- 为搜索、loopback 和更新下载提取独立的 Client 构造点,保留原有 `no_proxy`、timeout 和 redirect 配置,并明确验证这些 Client 不携带 `X-Genarrative-Client`。 -- 新增 OSS/签名传输 Client、受控搜索 Client、loopback 工具桥 Client 和更新下载 Client 的本地 TCP 负向测试;Provider 代理转发测试增加无标记断言。 -- 阶段 4 验证结果:`omits_agc_marker` 负向测试 4 个通过;Provider 代理测试 1 个通过;阶段 3 的画板同步请求捕获测试继续作为签名 URL 媒体下载无标记证据。 -- 未修改主站 API、`ExternalEditorBindingAccess` 路由映射、认证语义、OSS/Provider/搜索/loopback/更新的网络安全策略或 #225 代码。 - -### 6.7 阶段 5 实施记录 - -- 账号态回归使用真实平台会话 fixture,验证 `/api/editor`、`/api/assets` 和 `/api/runtime` 实际路径均携带 `X-Genarrative-Client: agc`。 -- 账号态生成请求继续携带 Bearer、UUID Idempotency-Key 和原有 `generationInputs.source = ai-game-creator-client`;排队、运行中和完成状态仍按原语义轮询,提交只发生一次。 -- External Key 态回归清除平台会话,使用 `tnr_sk_phase5_fixture` 和本地自定义 `apiBaseUrl`,验证 `/api/external/v1/editor/projects/*` 与 `/api/external/v1/assets/read-url` 携带相同标记和 API Key,签名媒体下载不带标记。 -- External v1 endpoint、账号态路由映射、请求体、认证、幂等、异步轮询和结果未知语义均未改变。 -- 阶段 5 验证:`background_agent_runtime_can_generate_platform_art_asset`、`sync_canvas_project_assets_with_developer_key_uses_external_route_and_marker` 和 `generation_submit_response_loss_is_not_retried` 均通过;Rust fmt、编码检查和 `git diff --check` 通过。 - -### 6.8 同名 Header 覆盖复审修复记录(2026-09-02) - -- 评审确认 `reqwest` 的 `default_headers` 只补充缺失字段,请求级同名 Header 会优先,不能单独满足“标记值固定为 `agc`”的契约。 -- 新增 `crate::http_client::with_agc_main_site_marker`,在主站请求发送前用 `RequestBuilder::headers` 替换 `X-Genarrative-Client`;`external_editor_json_request` 内置该终结器,直接发送的主站请求也显式接入。 -- 已补齐账号态、External Key 态、生成/轮询、资源编辑、素材读取、对象确认和抠图等主站直发路径;OSS/签名下载、Provider、搜索、loopback 和更新下载仍保持独立 Client。 -- 新增回归测试验证调用方传入伪造同名 Header 时最终只发送 `X-Genarrative-Client: agc`;`http_client` 定向测试共 8 个通过。 - -## 7. 调用语义和现有路由边界 - -本次不改变 `ExternalEditorBindingAccess` 的职责: - -- 继续负责平台账号态/开发者 Key 态的凭据访问。 -- 继续负责 `/api/external/v1` 到 `/api/editor`、`/api/assets`、`/api/runtime` 的路径映射。 -- 继续负责冻结会话和账号切换校验。 - -统一标记只附加在 HTTP Client 层,不改变: - -- External v1 语义 endpoint。 -- 账号态实际 endpoint。 -- 请求体中的 `generationInputs`。 -- operationId、Idempotency-Key 或本地账本。 - -主站后续记录时必须使用实际到达的 HTTP path;不能仅使用客户端账本中保存的 External v1 endpoint。 - -## 8. #225 的交接契约(本次不实现,但现在冻结) - -以下内容是为了让 `#225` 可以直接按固定契约接收,不需要反向修改 `#226` 的设计。 - -### 8.1 主站接收规则 - -主站读取: - -```http -X-Genarrative-Client: agc -``` - -规则: - -- Header 名大小写不敏感。 -- 精确值 `agc` 视为 AGC 客户端。 -- 缺失、空值或未知值视为“未标记”,不拒绝请求,也不改变业务行为。 -- Header 不参与权限和计费。 -- 不从请求体中的 `generationInputs.source` 推导统一客户端标记。 - -### 8.2 tracking metadata 形态 - -为避免 #225 重新设计字段,建议固定写入现有 tracking metadata JSON: - -```json -{ - "route": "/api/editor/images/generations", - "method": "POST", - "status": 202, - "operation": "generateExternalEditorImage", - "client": "agc" -} -``` - -约定: - -- JSON key 固定为 `client`。 -- AGC 值固定为 `agc`。 -- 缺失时不写 `client`,不写空字符串。 -- 第一阶段复用 `metadata_json`,不要求 `tracking_event` 新增列。 - -### 8.3 认证主体 - -主站记录标记时仍以真实认证主体为准: - -- 登录账号态:记录现有 `AuthenticatedAccessToken` 解析出的用户维度。 -- External API Key 态:记录 `ExternalApiPrincipal` 的 `owner_user_id`,必要时保留 `key_id` 维度。 -- 不记录 access token 或 API Key 明文。 - -### 8.4 路由覆盖 - -`#225` 应同时考虑: - -- `/api/auth/*` -- `/api/profile/*` -- `/api/assets/*` -- `/api/editor/*` -- `/api/runtime/*` -- `/api/external/v1/*` - -特别是 `/api/external/v1/*` 当前没有完整 route tracking spec,不能只依赖已有内部 `/api/editor` 路由规格。 - -### 8.5 成功与失败 - -`#226` 会给成功、失败、重试和轮询请求使用同一个来源标记。`#225` 是否继续只记录成功响应,或扩展为记录失败请求,应由 #225 按埋点目标决定,但不能要求 #226 为失败请求换另一种标记。 - -建议 #225 至少保留: - -- 实际 method/path。 -- response status。 -- request_id。 -- client=`agc`。 -- user/owner/key principal 的安全维度。 - -## 9. 定向测试方案 - -### 9.1 主站请求带标记 - -TS 层: - -- mock `fetchClientHttp` 的最终 transport。 -- 验证认证、素材和钱包请求均出现 `X-Genarrative-Client: agc`。 -- 验证已有 Authorization、envelope Header 和自定义 request ID 仍存在。 - -Rust 层: - -- 使用主站 mock server 接收项目查询、素材读取、生成提交和任务轮询。 -- 验证账号态映射路径携带标记。 -- 验证开发者 API Key 态 External v1 路径携带标记。 -- 验证长 timeout/短 timeout 的不同 Client profile 都携带标记。 - -### 9.2 第三方请求不带标记 - -使用 mock 或请求捕获断言以下请求没有 `X-Genarrative-Client`: - -- OSS multipart 上传。 -- 签名 URL 下载。 -- LLM Provider。 -- 受控网页搜索。 -- loopback 工具桥。 -- 更新清单/更新包下载。 - -### 9.3 语义保持 - -定向测试还应确认: - -- 原有 Authorization 仍被发送。 -- 原有 Idempotency-Key 仍被发送且值不变。 -- 请求体和 Content-Type 不变。 -- 同源 redirect policy 的默认跟随、限制和方法处理保持不变;跨 origin 3xx 不发起下一跳。 -- timeout 和 connect timeout 不被统一 factory 覆盖成单一值。 -- 第三方请求没有因共享 Client 改造而意外继承主站 Header。 - -## 10. 实施顺序 - -1. 固定 Header 常量和文档契约。 -2. 在 TS `fetchClientHttp` 加统一注入。 -3. 增加 Rust 主站 Client Builder factory。 -4. 按用途盘点并只替换主站 Client 创建点。 -5. 检查所有 `Client::new()` 主站生产调用是否已迁移到 builder。 -6. 检查 OSS、签名下载、Provider、搜索、loopback、更新下载仍使用独立 Client。 -7. 增加主站正向测试和第三方负向测试。 -8. 运行定向测试、类型检查、Rust check/test、`npm run check:encoding` 和 `git diff --check`。 -9. 将本方案中的第 8 节交给 `#225`,主站按该契约接收,不要求客户端二次改设计。 - -## 11. 完成判据 - -只有同时满足以下条件,`#226` 才算完成: - -- TS 主站业务请求统一带 `X-Genarrative-Client: agc`。 -- Rust 主站业务请求统一带同一 Header。 -- 账号态和开发者 API Key 态均覆盖。 -- 生成提交和异步轮询均覆盖。 -- 主站专用 Client 保留原有网络和业务语义。 -- OSS、签名 URL、Provider、搜索、loopback 和更新下载不带标记。 -- 客户端测试覆盖正向和负向边界。 -- 不修改 `#225` 所需的主站数据设计;`#225` 可直接读取 Header 并按本文约定写入 tracking metadata。 - -## 12. 阶段 6 实施记录与交接状态 - -更新时间:`2026-09-02` - -阶段 6 已完成,`#226` 客户端侧可以交付评审;`#225` 不需要因客户端实现回改设计。固定交接材料如下: - -- 请求 Header:`X-Genarrative-Client: agc`。 -- tracking metadata:复用现有 `metadata_json`,写入 `client: "agc"`;缺失时不写空字符串。 -- 主站记录实际收到的 method/path;不能只记录客户端账本中的 External v1 endpoint。 -- 登录账号态按真实用户维度记录;External API Key 态按 `ExternalApiPrincipal.owner_user_id` 记录,必要时保留 `key_id` 维度。 -- Header 只用于来源审计/统计,不参与鉴权、权限、计费或账号归属;不记录 token、API Key 明文或签名 URL。 -- `#225` 应同时覆盖账号态 `/api/auth/*`、`/api/profile/*`、`/api/assets/*`、`/api/editor/*`、`/api/runtime/*` 和 API Key 态 `/api/external/v1/*`。 -- Header 缺失、空值或未知值按未标记处理,不拒绝业务请求;客户端不需要为失败、重试或轮询请求更换标记。 - -最终门禁结果: - -| 门禁 | 结果 | -|---|---| -| TS `clientHttp` 定向测试 | 通过,14 tests passed | -| AGC TS 类型检查(含 skill-pack/config 检查) | 通过 | -| Rust `http_client` factory 测试 | 通过,7 tests passed;覆盖同源跟随、跨 origin 阻断、链式重定向和显式 `Policy::none()` | -| Rust OSS/签名 URL/Provider/搜索/loopback/更新下载负向测试 | 通过,`omits_agc_marker` 4 tests passed;loopback 认证边界 1 test passed | -| Rust 账号态主站 mock 捕获 | 通过,1 test passed | -| Rust External Key 态主站 mock 捕获 | 通过,1 test passed | -| Rust 签名 URL 下载边界 | 通过,1 test passed | -| Rust 提交响应丢失幂等回归 | 通过,1 test passed | -| Rust fmt、编码、diff 空白检查 | 通过 | - -本阶段未执行真实发布环境线上 smoke;账号态和 External Key 态证据来自本地 mock/custom `apiBaseUrl` fixture。真实发布环境 smoke 属于发布前或 `#225` 联调门禁,不改变本次 `#226` 客户端契约。跨 origin 重定向复审已通过本地双 listener 和链式重定向测试确认不会发起第三方下一跳。 - -可直接粘贴到 `#225` 的交接评论: - -> `#226` 客户端侧已完成并冻结交接契约:AGC 主站业务请求统一发送 `X-Genarrative-Client: agc`。账号态 `/api/auth/*`、`/api/profile/*`、`/api/assets/*`、`/api/editor/*`、`/api/runtime/*` 与 External API Key 态 `/api/external/v1/*` 均覆盖;OSS/签名下载、Provider、受控搜索、loopback、更新下载和外部网页请求不带该标记。主站可按实际 method/path 读取 Header,并在现有 tracking `metadata_json` 中写入 `client: "agc"`;Header 缺失/空值/未知值按未标记处理且不拒绝请求。登录态按真实用户维度记录,External Key 态按 `owner_user_id`(必要时 `key_id`)记录,不记录 token/API Key 明文或签名 URL。客户端正向、负向、账号态、External Key 态和幂等回归均已通过,`#225` 不需要让 `#226` 回改设计。` diff --git a/local-docs/【实施计划】Issue225-AGC主站请求标记埋点分阶段验收-2026-09-02.md b/local-docs/【实施计划】Issue225-AGC主站请求标记埋点分阶段验收-2026-09-02.md deleted file mode 100644 index 2acdebf32..000000000 --- a/local-docs/【实施计划】Issue225-AGC主站请求标记埋点分阶段验收-2026-09-02.md +++ /dev/null @@ -1,398 +0,0 @@ -# Issue #225:AGC 主站请求标记埋点分阶段实施与验收计划 - -更新时间:2026-09-02 -关联 Issue: - -- `#225 添加客户端埋点统计`:本计划全部实施范围 -- `#226 添加客户端特殊标识`:客户端已完成;仅作为固定交接输入 - -当前状态:阶段 0、阶段 1、阶段 2、阶段 3、阶段 4、阶段 5、阶段 6 已完成。阶段 1~5 的实现已提交:`2296f79fd`、`2a23ba657`、`01159a269`、`c75c61521`、`c5170669e`;日登录埋点构造器回归修复为 `0a06b4988`。阶段 6 仅补充最终验收、交接和门禁文档,不新增生产代码。 - -## 1. 交付目标 - -主站识别有效的: - -```http -X-Genarrative-Client: agc -``` - -并在已有 route tracking 的 `tracking_event.metadata_json` 中记录: - -```json -{ - "client": "agc" -} -``` - -同时按真实认证主体归属:登录账号态使用 `AuthenticatedAccessToken` 的用户,External API Key 态使用 `ExternalApiPrincipal.owner_user_id()`;不新增平行事件体系,不改鉴权、计费、幂等和 API 契约。 - -## 2. 固定不做项 - -本计划不修改: - -- AGC 客户端 Header 注入和 Rust Client factory。 -- `tracking_event` schema、migration、bindings 或索引。 -- `/api/external/v1` OpenAPI、DTO、HTTP 方法、状态码和鉴权。 -- 失败请求的全新 tracking 事实。 -- 公开 discovery、Skill、MCP、OSS、签名下载、Provider、搜索、loopback 和更新下载的普通 AGC 业务统计。 -- 后台新筛选器或报表页面;第一阶段只复用原始 metadata 查询。 - -## 3. 阶段总览 - -```text -阶段 0 现状基线与交接契约冻结(已完成) - ↓ -阶段 1 Marker 解析与 tracking 入口接入 - ↓ -阶段 2 认证主体归属与 metadata 合并 - ↓ -阶段 3 账号态 / External v1 route coverage - ↓ -阶段 4 tracking_event、outbox 与后台读取验证 - ↓ -阶段 5 定向测试、安全边界与语义回归 - ↓ -阶段 6 最终门禁与 #226 交接收口 -``` - -每阶段先通过本阶段验收,再进入下一阶段。阶段 1~6 均只改主站或相关测试/文档,不要求 #226 回改。 - -## 4. 阶段 0:现状基线与交接契约冻结 - -### 4.1 工作内容 - -- 确认当前分支、HEAD、工作树和 `origin/master` 关系。 -- 核对 `app.rs` tracking middleware、`tracking.rs` route spec、outbox 和 `tracking_event` 写入链路。 -- 核对 `AuthenticatedAccessToken` 和 `ExternalApiPrincipal` 的 response extension 传播。 -- 读取 AGC 调用清单和 External v1 router,冻结正向/排除路径。 -- 固定 Header、metadata、未知值行为、主体归属和成功响应语义。 - -### 4.2 阶段边界 - -- 不修改生产代码。 -- 不新增 schema 或后台代码。 -- 不把 Issue226 的客户端实现重新打开。 - -### 4.3 验收标准 - -- [x] 当前基线和代码缺口已记录。 -- [x] `X-Genarrative-Client: agc` 的识别规则已固定。 -- [x] `metadata_json.client = "agc"` 的落库形态已固定。 -- [x] 账号态与 External API Key 态的主体归属已固定。 -- [x] External v1 discovery/Skill/MCP 与第三方边界已列明。 -- [x] 未修改主站生产代码、schema、OpenAPI 或后台。 - -### 4.4 阶段产物 - -- [【阶段验收】Issue225阶段0现状基线与边界冻结-2026-09-02.md](C:/projects/narrative/Genarrative/local-docs/【阶段验收】Issue225阶段0现状基线与边界冻结-2026-09-02.md) -- 本实施方案。 -- 本分阶段验收计划。 - -## 5. 阶段 1:Marker 解析与 tracking 入口接入 - -### 5.1 工作内容 - -修改范围限定在 `api-server` tracking 入口及定向测试: - -- 在 `record_api_tracking_after_success` 的 `next.run(request)` 之前读取请求 Header。 -- 增加白名单解析:首尾空白可清理,值必须精确为小写 `agc`。 -- 将解析结果传入现有 `record_route_tracking_event_after_success`。 -- 不把原始未知值、完整 Header、token 或请求体写入日志/metadata。 - -### 5.2 阶段验收 - -定向单元测试必须证明: - -- 有效 `agc` 能被识别。 -- Header 名大小写变化仍能识别。 -- 缺失、空值、`AGC`、未知值均按未标记处理。 -- 未标记请求仍走原有 route tracking 逻辑,不被拒绝。 -- 认证、响应状态和现有 request context 行为不变。 - -### 5.3 阶段完成判据 - -- [x] marker 只在统一 tracking 入口解析一次。 -- [x] 业务 handler 无需逐个读取 Header。 -- [x] 尚未接入主体归属和 External v1 route 扩展之外,不发生无关改动。 - -### 5.4 阶段 1 实现与验证记录 - -- `app.rs` 在 `next.run(request)` 前调用统一 marker 解析函数,并把解析结果传入 route tracking。 -- `tracking.rs` 只接受 Header 名 `X-Genarrative-Client`(HTTP 名称大小写不敏感)和值 `trim` 后精确等于小写 `agc` 的请求。 -- 解析结果以内部 `TrackingClientMarker::Agc` 保存到 `TrackingEventDraft`,本阶段不改变 metadata 内容;metadata 合并由阶段 2 完成。 -- 未标记、未知值和无效 Header 值均得到 `None`,不会写入日志,也不会拒绝请求。 -- 定向测试:`tracking::tests::tracking_client_marker_accepts_only_trimmed_lowercase_agc` 通过。 -- `cargo fmt --manifest-path server-rs/crates/api-server/Cargo.toml`、`git diff --check` 通过。 -- 当前未运行完整 `api-server` 测试套件;现有编译过程已通过,完整测试按阶段 5/CI 统一执行。 - -## 6. 阶段 2:认证主体归属与 metadata 合并 - -### 6.1 工作内容 - -- 从最终 response extensions 读取 `AuthenticatedAccessToken`。 -- 同时读取 `ExternalApiPrincipal`。 -- 账号态沿用现有 user_id/owner_user_id 语义。 -- External API Key 态设置 `owner_user_id = principal.owner_user_id()`,User scope 的 scope_id 使用 owner,避免回退到 `anonymous`。 -- 在 `build_route_tracking_metadata` 原有 JSON object 上追加 `client: "agc"`,保留原字段和资产嵌套 metadata。 -- 第一阶段不写 API Key 明文;不新增 `externalApiKeyId`,除非评审明确要求单 Key 统计。 - -### 6.2 阶段验收 - -- 账号态成功请求的 user_id 与现有 access token 用户一致。 -- External v1 成功请求的 owner_user_id 与 `ExternalApiPrincipal.owner_user_id()` 一致。 -- Header 不能覆盖、伪造或替换认证主体。 -- 有效 marker 时 metadata 含 `client: "agc"`;无效/缺失时不含该键。 -- `route`、`method`、`status`、`operation` 和已有嵌套字段均保留。 -- token、API Key 明文和签名 URL 不进入 metadata 或日志。 - -### 6.3 阶段完成判据 - -- [x] route tracking draft 在两类主体下都能生成正确的结构化归属。 -- [x] metadata 合并逻辑有独立测试,不能只依赖端到端偶然覆盖。 -- [x] 不改变 event id、outbox 和 daily stat 逻辑。 - -### 6.4 阶段 2 实现与验证记录 - -- `app.rs` 从最终 response extensions 同时读取 `AuthenticatedAccessToken` 和 `ExternalApiPrincipal`。 -- `tracking.rs` 优先使用已验证的 External API Key owner;External API Key 请求不伪造 `user_id`,User scope 的 `scope_id` 使用 `owner_user_id`。 -- 账号态继续使用 access token claims 的 user id,同时写入 `user_id` 和 `owner_user_id`,保持原有语义。 -- `build_route_tracking_metadata` 只在有效 marker 时追加 `client: "agc"`,保留 route、method、status、operation 和 asset 嵌套 metadata;无 marker 不写 client。 -- 定向测试覆盖账号态主体、External API Key owner 优先级、User scope owner 和 metadata 字段保留。 -- 当前未修改 External v1 route spec、schema、OpenAPI、outbox 或后台页面。 - -## 7. 阶段 3:账号态与 External v1 Route Coverage - -### 7.1 工作内容 - -以 `local-docs/【扫描清单】AGC客户端主站调用与可标记点-2026-09-01.md` 和 `modules/external_api.rs` 为输入,补齐显式 route spec: - -- 账号态 `/api/auth/*`、`/api/profile/*`、`/api/assets/*`、`/api/editor/*`、`/api/runtime/external-generation/jobs/{id}` 的实际 AGC 路径。 -- External API Key 态 `/api/external/v1/assets/*`、`/editor/*`、`/generations/{id}` 的实际业务路径。 -- 生成提交、轮询、项目读取/资源登记、素材库、上传凭证、对象确认、换签读取等当前已确认调用。 -- 每个新增 spec 固定 event key、module key、scope kind 和动态路径归一化方式。 - -本阶段新增的 route spec 统一使用 AGC-only 记录策略:只有带有效 marker 的 2xx 请求才写入;既有 route spec 保持原有全客户端语义,上传票据/对象确认等 `handled_by_existing_event` 路径继续由手工资产事件负责。 - -同时明确不加入: - -- `/api/external/v1/openapi.json`。 -- `/api/external/v1/agent-integration.json`。 -- `/api/external/v1/skill/SKILL.md`、`skill.zip`。 -- `/api/external/v1/mcp`。 - -### 7.2 阶段验收 - -- AGC 当前实际调用清单中的每个 method + path 都能解析到 spec。 -- External v1 记录实际外部 path,不被映射为内部 `/api/editor` 等路径。 -- 发现/MCP 路径不会误进入普通业务 route tracking。 -- project/asset/operation 动态 ID 仍按现有规则归一化。 -- 原有 route spec 的 event key、scope 和统计口径不变。 -- 新增 AGC route 在未携带有效 marker 时不产生 route tracking event,且不影响业务响应。 - -### 7.3 阶段完成判据 - -- [x] 有一张可审计的“AGC 调用清单 → route spec → event key”矩阵。 -- [x] 新增路径均有 resolver 测试;未确认的 OpenAPI 潜在路径单独记录,不混入已实现范围。 -- [x] 没有用 catch-all `agc_client_request` 取代显式 route tracking。 - -### 7.4 阶段 3 实现与验证记录 - -- `tracking.rs` 补齐当前 AGC 实际使用的账号态 `/api/auth`、`/api/profile`、`/api/assets`、`/api/editor`、`/api/runtime/external-generation/jobs/{id}` 路径。 -- `tracking.rs` 补齐当前 AGC 实际使用的 External v1 资产、项目、素材库、生成提交和任务轮询路径;External v1 使用独立的实际外部 path 进入 metadata,不映射回账号态内部路径。 -- 上传票据和对象确认已有详细资产事件,route spec 以 `handled_by_existing_event` 标记为复用现有事件,统一 route tracking 不再重复写入;tracking middleware 将有效 marker 放入 request extensions,由共享资产 handler 将 `client: "agc"` 合并到既有资产 metadata,并补齐实际 route/method/status/operation。 -- 共享资产事件沿用真实主体:账号态写入 user/owner;External API Key 态只写 `owner_user_id`,不伪造登录 `user_id`,User scope 使用 owner。 -- 扩展动态路径归一化的静态段白名单,使 project、generation、asset 和 external v1 路径按既有 `{id}` 规则正确匹配;未引入 catch-all AGC 事件。 -- discovery、agent-integration、Skill、skill.zip 和 MCP 路径没有业务 route spec。 - -已执行定向验证: - -```text -cargo fmt --manifest-path server-rs/crates/api-server/Cargo.toml -- --check -cargo check --locked --manifest-path server-rs/Cargo.toml -p api-server -cargo test --locked --manifest-path server-rs/Cargo.toml -p api-server tracking::tests:: -- --nocapture -cargo test --locked --manifest-path server-rs/Cargo.toml -p api-server assets::tests::asset_tracking_metadata_receives_only_valid_agc_marker -- --nocapture -git diff --check -``` - -结果:tracking 定向测试 13 个通过,资产 marker/route metadata 与 External owner 归属测试 2 个通过,api-server 编译检查通过。 - -## 8. 阶段 4:tracking_event、outbox 与后台读取验证 - -### 8.1 工作内容 - -- 验证 `TrackingEventDraft.metadata` 经 `build_tracking_event_input` 后仍是合法 JSON object。 -- 验证本机 tracking outbox 入队和 SpacetimeDB 回退直写都保留 `client`。 -- 验证既有 `tracking_event` 行和 `tracking_daily_stat` 聚合未改变。 -- 验证现有后台 tracking API 返回 `metadata_json` 中的 `client`。 -- 不新增 schema/migration/bindings,不修改后台页面。 - -### 8.2 阶段验收 - -- AGC 成功事件可在 tracking_event 读回 `client: "agc"`。 -- unmarked 事件不会被补写 `client`。 -- owner_user_id、user_id、scope_id、event_key 和 occurred_at 不丢失。 -- 后台原始事件查询和已有 event key 查询不回归。 -- `npm run check:spacetime-schema` 不因本阶段产生 schema 变更;若未改 schema,可记录为无需运行。 - -### 8.3 阶段完成判据 - -- 至少有一条账号态和一条 External Key 态从 tracking draft 到后台读取的完整证据。 -- 已确认第一阶段不需要新增数据库字段。 - -### 8.4 阶段 4 实现与验证记录 - -- `api-server` tracking 测试分别构造账号态和 External API Key 态输入,验证 `TrackingEventDraft → build_tracking_event_input` 后 metadata 仍是合法 JSON object,`client: "agc"`、route、status、event key、module、scope 和 user/owner 字段均保留;未标记输入不补写 `client`。 -- tracking outbox 测试使用 External v1 AGC 事件完成 NDJSON 入队和读取 round-trip,验证 `metadata_json.client`、实际 External v1 route、`owner_user_id` 和“不伪造 `user_id`”均保留。 -- `spacetime-client` active mapper 测试验证送往生成绑定的 `RuntimeTrackingEventInput` 不丢失 `client`、External owner、scope、event key 和 module;module-runtime 的统一输入校验测试验证 metadata 必须是 JSON object,标记对象可正常通过。由于本地未启动 SpacetimeDB,未把确定性 input/mapper 验证扩大解释为真实远端库 E2E。 -- 后台 tracking SQL response parser 测试分别覆盖账号态与 External API Key 态,并增加 draft/input → SQL row → parser 组合回归,验证管理员现有原始事件读取能保留 `metadata_json.client`、实际 route 和 user/owner 归属。 -- 未修改 tracking schema、migration、bindings、outbox 失败回退策略、daily stat 聚合逻辑或后台页面;SpacetimeDB 的实际入库仍复用既有 `record_tracking_event` / `record_tracking_events` procedure,未新增字段。 - -已执行定向验证: - -```text -cargo fmt --manifest-path server-rs/Cargo.toml --all -- --check -cargo test --locked --manifest-path server-rs/Cargo.toml -p module-runtime tracking_input_ -- --nocapture -cargo test --locked --manifest-path server-rs/Cargo.toml -p api-server tracking::tests:: -- --nocapture -cargo test --locked --manifest-path server-rs/Cargo.toml -p api-server tracking_outbox::tests:: -- --nocapture -cargo test --locked --manifest-path server-rs/Cargo.toml -p api-server admin::tests::parse_admin_tracking_events_sql_response_ -- --nocapture -cargo test --locked --manifest-path server-rs/Cargo.toml -p spacetime-client tracking_input_mapper_preserves_agc_metadata_and_external_owner -- --nocapture -``` - -结果:module-runtime 2 个、api-server tracking 15 个、tracking outbox 8 个、后台 readback parser 5 个、spacetime-client mapper 1 个定向测试全部通过;格式检查通过。未运行完整工作区测试和 `npm run check:spacetime-schema`,因为本阶段未修改 schema。 - -## 9. 阶段 5:定向测试、安全边界与语义回归 - -### 9.1 工作内容 - -补齐并运行与改动直接相关的测试: - -- marker 解析表驱动测试。 -- route metadata 合并测试。 -- 账号态主体归属测试。 -- ExternalApiPrincipal owner 归属测试。 -- External v1 route resolver 覆盖测试。 -- discovery/MCP 排除测试。 -- 2xx 记录、4xx/5xx 不新增成功 route 事件测试。 -- `build_router` 轻量 middleware 成功链路测试:真实 HTTP request 经认证、tracking middleware 后写入隔离 outbox,并断言 `client`、route 和主体归属。 -- outbox/SpacetimeDB input 和后台 tracking readback 测试。 - -CI 已覆盖且与本改动无直接关系的全量测试可交给 CI;本阶段仍必须运行能直接证明本 Issue 契约的 targeted tests。 - -### 9.2 阶段验收 - -- 有效 marker 正向场景全部通过。 -- 缺失/空值/未知 marker 负向场景全部通过。 -- 登录账号态和 External Key 态主体归属均通过。 -- 未发生鉴权、状态码、幂等、请求体或异步轮询回归。 -- 测试输出不包含 token、API Key 明文、Cookie、签名 URL 或本地私密路径。 - -### 9.3 阶段完成判据 - -- 测试矩阵覆盖“标记/未标记 × 账号态/External Key 态 × 已覆盖/排除路由”。 -- 所有失败都能定位到 marker、主体、route、持久化或后台读取中的具体层。 - -### 9.4 阶段 5 实现与验证记录 - -- `tracking.rs` 抽出 `should_record_route_tracking` 判定,明确新增 AGC-only route 只有在带有效 marker 的 2xx 响应下才进入统一成功 route tracking;既有全客户端 route 保持原语义,4xx/5xx 和上传确认等复用事件均不新增重复成功事件。 -- 新增 2xx、4xx/5xx 状态矩阵测试,覆盖 `OK / CREATED / ACCEPTED / NO_CONTENT` 与认证、权限、客户端、服务端失败状态。 -- 新增 AGC-only route 正反向门禁测试:有效 marker 记录,缺失或无效 marker 不记录;既有 route 和手工资产事件策略保持不变。 -- 新增 `app::tests::agc_marker_flows_through_auth_tracking_middleware_to_outbox`,不启动真实 SpacetimeDB,仅验证 middleware 到 outbox 的最小闭环。 -- 新增 `app.rs` 路由回归:带有效 `X-Genarrative-Client: agc` 的未认证业务请求仍返回 `401`,marker 不绕过鉴权,也不改变失败状态。 -- 新增 route metadata 安全边界测试,确认统一 route metadata 只包含既有 route/method/status/operation 和可选 `client`/资产字段,不出现 authorization、token、API Key、Cookie、签名 URL 或 request body 字段。 -- 阶段 4 的账号态、External API Key、outbox、后台 readback 和 mapper 测试全部复跑;SpacetimeDB 既有 event-id 幂等回归也通过。未修改鉴权、请求体、异步轮询、event id、daily stat 或失败回退实现。 - -已执行定向验证: - -```text -cargo fmt --manifest-path server-rs/Cargo.toml --all -- --check -cargo test --locked --manifest-path server-rs/Cargo.toml -p module-runtime tracking_input_ -- --nocapture -cargo test --locked --manifest-path server-rs/Cargo.toml -p spacetime-client tracking_input_mapper_preserves_agc_metadata_and_external_owner -- --nocapture -cargo test --locked --manifest-path server-rs/Cargo.toml -p api-server tracking::tests:: -- --nocapture -cargo test --locked --manifest-path server-rs/Cargo.toml -p api-server app::tests::agc_marker_does_not_bypass_authentication_or_change_failure_status -- --nocapture -cargo test --locked --manifest-path server-rs/Cargo.toml -p api-server assets::tests::asset_tracking_metadata_receives_only_valid_agc_marker -- --nocapture -cargo test --locked --manifest-path server-rs/Cargo.toml -p api-server assets::tests::external_asset_tracking_keeps_owner_without_forging_user -- --nocapture -cargo test --locked --manifest-path server-rs/Cargo.toml -p api-server tracking_outbox::tests:: -- --nocapture -cargo test --locked --manifest-path server-rs/Cargo.toml -p api-server admin::tests::parse_admin_tracking_events_sql_response_ -- --nocapture -cargo test --locked --manifest-path server-rs/Cargo.toml -p api-server admin::tests::tracking_inputs_round_trip_to_admin_readback_for_both_subjects -- --nocapture -cargo test --locked --manifest-path server-rs/Cargo.toml -p spacetime-module duplicate_tracking_event_ids_are_treated_as_idempotent_replays -- --nocapture -``` - -结果:module-runtime 2 个、spacetime-client 1 个、api-server tracking 18 个、app 鉴权回归 1 个、资产 marker/owner 2 个、tracking outbox 8 个、后台 readback 5 个、spacetime-module 幂等 1 个定向测试全部通过;格式检查通过。未运行与本 Issue 无直接关系的完整工作区测试,按约定交给 CI。 - -## 10. 阶段 6:最终门禁与 #226 交接收口 - -### 10.1 工作内容 - -- 汇总阶段 1~5 的测试和 readback 证据。 -- 更新本方案、验收记录和 Issue #225 评论草案。 -- 复核 #226 交接契约不需要客户端回改。 -- 检查 diff、编码、格式、敏感信息和无关文件。 - -### 10.2 最终门禁 - -按实际改动范围运行: - -- `cargo fmt --manifest-path server-rs/Cargo.toml -- --check`。 -- `cargo test --locked -p api-server` 的 tracking/External v1 定向测试。 -- 必要时 `cargo check --locked -p api-server`。 -- `npm run check:encoding`。 -- `git diff --check`。 -- `git status --short`,确认没有构建产物、日志、凭据或无关文件。 - -未修改 schema 时不运行 schema 生成;若实现阶段意外需要 schema,必须停在阶段 4 重新确认迁移范围,不能顺手修改。 - -### 10.3 阶段完成判据 - -- 主站可以在现有 tracking_event metadata 中看到 `client: "agc"`。 -- 两类认证主体归属正确。 -- 当前 AGC 实际业务路径无漏记,发现/MCP/第三方边界无误记。 -- 后台现有查询可读,无需新 schema 或新页面。 -- 所有必要 targeted tests、格式/编码/空白检查通过。 -- 向 #225 交付固定 Header、metadata、主体归属、路由覆盖和验证证据;不要求 #226 回改。 - -### 10.4 阶段 6 实际执行与结果 - -阶段 6 以当前分支 `feat/agc_call_rec`、`HEAD=5aa38d9f3` 为基线;该提交已合并最新 `origin/master`(`8932f0b27`)。本阶段未修改 `server-rs`、SpacetimeDB schema、OpenAPI 或后台页面,只更新验收和交接文档。 - -最终门禁结果: - -| 验收项 | 命令/证据 | 结果 | -|---|---|---| -| api-server 编译 | `cargo check --locked --manifest-path server-rs/Cargo.toml -p api-server` | 通过;仅有仓库既有 dead-code warning | -| api-server tracking/资产/后台/outbox 定向测试 | `cargo test --locked --manifest-path server-rs/Cargo.toml -p api-server tracking -- --nocapture` | 通过,43 tests passed | -| marker 不绕过鉴权回归 | `cargo test --locked --manifest-path server-rs/Cargo.toml -p api-server app::tests::agc_marker_does_not_bypass_authentication_or_change_failure_status -- --nocapture` | 通过,1 test passed | -| module-runtime tracking input | `cargo test --locked --manifest-path server-rs/Cargo.toml -p module-runtime tracking_input_ -- --nocapture` | 通过,2 tests passed | -| spacetime-client mapper | `cargo test --locked --manifest-path server-rs/Cargo.toml -p spacetime-client tracking_input_mapper_preserves_agc_metadata_and_external_owner -- --nocapture` | 通过,1 test passed | -| SpacetimeDB event-id 幂等 | `cargo test --locked --manifest-path server-rs/Cargo.toml -p spacetime-module duplicate_tracking_event_ids_are_treated_as_idempotent_replays -- --nocapture` | 通过,1 test passed | -| Rust 格式 | `cargo fmt --manifest-path server-rs/Cargo.toml --all -- --check` | 通过 | -| 中文编码 | `npm run check:encoding` | 通过,5662 个文件 | -| Diff 空白与分支基线 | `git diff --check`、`git merge-base --is-ancestor origin/master HEAD` | 通过 | - -上述测试合计 48 个与本 Issue 直接相关的测试通过。完整工作区测试按用户约定交由 CI;本阶段未启动真实 SpacetimeDB,也未执行发布环境线上写入,因此最终证据是本地确定性 input/mapper、outbox、后台 parser 和 route tracking 回归,不把本地测试表述为线上 E2E。 - -### 10.5 阶段 6 交接结论 - -- 主站只对白名单值 `X-Genarrative-Client: agc` 追加 `metadata_json.client = "agc"`;缺失、空值、`AGC` 和未知值按未标记处理,不拒绝请求。 -- 本期新增的 AGC route spec 只有在带有效 marker 的 2xx 响应下才记录;既有 route spec 与手工资产事件保持原有全客户端统计语义。 -- 账号态沿用真实 access token 用户;External API Key 态使用 `ExternalApiPrincipal.owner_user_id()`,不伪造 `user_id`。 -- 当前 AGC 实际账号态和 External v1 业务路径均有显式 route spec;动态 ID 继续归一化,External v1 保留实际外部 path。 -- OSS、签名下载、Provider、受控搜索、loopback、更新下载、公开 discovery/Skill/MCP 不进入普通 AGC 业务 route tracking。 -- 最终记录仍落在现有 SpacetimeDB `tracking_event.metadata_json`,经过本机 `server-rs/.data/tracking-outbox/` 临时缓冲后由既有 tracking procedure 写入;不新增 schema、索引、后台页面或独立事件体系。 -- `#226` 的客户端标记注入和 origin-safe redirect 契约已满足本 Issue 输入要求,不需要回改 #226 设计。 -- 另有一个独立于 #225 的客户端后续项:自定义 origin 使用显式默认端口或大写主机名时,`clientHttp.ts` 的字符串与 `URL.origin` 比较可能误判为跨 origin;它只会拒绝合法请求,不会泄漏 marker,应单独在 #226 跟踪。 - -阶段 6 详细验收记录与可直接粘贴到 Issue #225 的交付评论见: - -- [【阶段验收】Issue225阶段6最终门禁与交接收口-2026-09-02.md](C:/projects/narrative/Genarrative/local-docs/【阶段验收】Issue225阶段6最终门禁与交接收口-2026-09-02.md) - -## 11. 建议提交分组 - -建议按以下逻辑分组提交,便于逐阶段验收: - -1. 阶段 1:marker 解析与 tracking 入口。 -2. 阶段 2:主体归属与 metadata 合并。 -3. 阶段 3:route spec 与 External v1 覆盖。 -4. 阶段 4~5:持久化/后台验证、定向测试和安全回归。 -5. 阶段 6:验收记录、Issue 交接评论和最终门禁。 - -如仓库要求单提交,也应在 PR 描述中按上述五组列出,确保每组都有独立验收标准。 diff --git a/local-docs/【实施计划】Issue226-AGC客户端主站请求标记分阶段验收-2026-09-01.md b/local-docs/【实施计划】Issue226-AGC客户端主站请求标记分阶段验收-2026-09-01.md deleted file mode 100644 index e0a9eb589..000000000 --- a/local-docs/【实施计划】Issue226-AGC客户端主站请求标记分阶段验收-2026-09-01.md +++ /dev/null @@ -1,408 +0,0 @@ -# Issue #226:AGC 客户端主站请求标记分阶段实施与验收计划 - -更新时间:2026-09-02
-关联 Issue: - -- `#226 添加客户端特殊标识`:本计划全部实施范围 -- `#225 添加客户端埋点统计`:只接收交接契约,不在本计划实现 - -当前状态:阶段 6 已完成;跨 origin 重定向复审修复已完成;阶段 0 的证据与验收记录见 -[【阶段验收】Issue226阶段0现状基线与契约冻结-2026-09-01.md](C:/projects/narrative/Genarrative/local-docs/【阶段验收】Issue226阶段0现状基线与契约冻结-2026-09-01.md)。 - -## 1. 交付目标 - -AGC 客户端发往当前选定 Genarrative 主站 origin 的业务 HTTP 请求统一携带: - -```http -X-Genarrative-Client: agc -``` - -同时保持同源重定向及现有认证、幂等、timeout、connect timeout、`no_proxy`、请求体和响应处理语义;跨 origin 3xx 不继续请求;OSS、签名下载、Provider、搜索、loopback 和更新下载请求不携带该标记。 - -## 2. 明确不做项 - -本计划不修改: - -- 主站 `server-rs`。 -- `/api/external/v1` OpenAPI 或 DTO。 -- SpacetimeDB schema、migration、bindings。 -- 主站 tracking middleware、route tracking 或后台查询。 -- `#225` 的数据库字段和管理页实现。 -- 现有 `generationInputs.source` 等业务 body 字段。 - -## 3. 阶段总览 - -```text -阶段 0 现状基线与契约冻结 - ↓ -阶段 1 TS 统一出口 - ↓ -阶段 2 Rust 主站 Client Builder factory - ↓ -阶段 3 主站 Client 创建点迁移 - ↓ -阶段 4 第三方请求隔离与负向验证 - ↓ -阶段 5 账号态/External 态和请求语义回归 - ↓ -阶段 6 #225 交接与最终门禁 -``` - -每个阶段都应先完成本阶段验收,再进入下一阶段;阶段之间不依赖 `#225` 已经实现。 - -## 4. 阶段 0:现状基线与契约冻结 - -### 4.1 工作内容 - -- 确认当前工作树无其他相关未提交修改。 -- 复核 AGC TS 主站统一出口:`fetchClientHttp`。 -- 复核 Rust 主站请求和第三方请求的 Client 创建点。 -- 固定 Header 名和值:`X-Genarrative-Client: agc`。 -- 固定主站 origin 判定依据:当前选定的 `serverBaseUrl` / `apiBaseUrl`。 -- 固定第三方排除列表。 -- 固定 #225 接收时使用的 metadata 约定:`metadata.client = "agc"`。 - -### 4.2 不应发生的改动 - -- 不修改生产代码。 -- 不修改主站代码或 OpenAPI。 -- 不新增数据库字段。 - -### 4.3 验收标准 - -- 已有调用清单和实施方案文档可定位到实际源码。 -- 正向范围和排除范围没有未决歧义。 -- Header 契约、metadata 交接字段和未知值行为已写入 Issue/方案文档。 -- `git status --short` 只反映预期的文档或无相关修改。 - -### 4.4 阶段产物 - -- 本方案文档。 -- Issue #226 的实现范围评论草案(正式评论待外部 Issue 操作确认)。 -- 交给 #225 的固定 Header/metadata 交接说明。 -- 阶段 0 基线与验收记录。 - -## 5. 阶段 1:TS 统一出口注入 - -### 5.1 工作内容 - -修改范围限定在: - -- [clientHttp.ts](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src/services/clientHttp.ts) -- 对应的 `clientHttp` 定向测试或现有测试 fixture - -在 `fetchClientHttp` 中: - -1. 保持现有目标 URL 解析和服务器地址校验。 -2. 使用 `Headers` 合并原有 `RequestInit.headers`。 -3. 注入 `X-Genarrative-Client: agc`。 -4. 保持浏览器 `fetch` 与 Tauri HTTP plugin 两条 transport 行为不变。 - -认证和业务函数不逐个添加 Header: - -- `clientAuth.ts` 继续调用 `fetchClientHttp`。 -- `clientApi.ts` 继续调用 `fetchClientHttp`。 - -更新清单请求 [appUpdate.ts](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src/services/appUpdate.ts) 不迁移到该统一出口,也不纳入主站业务标记。 - -### 5.2 阶段验收 - -TS 定向测试必须证明: - -- `/api/auth/*` 请求带 `X-Genarrative-Client: agc`。 -- `/api/profile/*` 请求带 `X-Genarrative-Client: agc`。 -- `/api/editor/*` 请求带 `X-Genarrative-Client: agc`。 -- `/api/assets/*` 请求带 `X-Genarrative-Client: agc`。 -- 原 `Authorization` 保留。 -- 原 envelope Header 保留。 -- 原 `X-Request-ID` 保留。 -- 调用方传入其他同名值时,最终值仍为 `agc`。 -- 更新清单请求不因本阶段被改为携带主站业务标记。 - -### 5.3 阶段完成判据 - -- TS 层所有已确认的主站业务请求经过 `fetchClientHttp` 并可观察到标记。 -- 没有在业务 API 文件中出现重复 Header 注入。 -- 相关 TS 定向测试通过。 - -### 5.4 阶段 1 实施记录 - -- `clientHttp.ts` 新增固定 Header 常量,并在 `fetchClientHttp` 内通过 `Headers` 合并并覆盖同名调用方 Header。 -- Web `fetch` 与 Tauri HTTP plugin 都接收同一份带标记的 `RequestInit`;原始 `RequestInit.headers` 不被修改。 -- `clientHttp.test.ts` 已覆盖 `/api/auth/*`、`/api/profile/*`、`/api/editor/*`、`/api/assets/*`,并分别验证 Web/Tauri transport、Authorization、envelope Header、`X-Request-ID` 和同名 Header 覆盖。 -- 阶段 1 验证命令:`npm exec vitest run apps/ai-game-creator-shell/tests/clientHttp.test.ts`(14 tests passed);`npm run ai-game-creator-shell:typecheck` 通过。 -- `appUpdate.ts` 未接入 `fetchClientHttp`,更新下载边界保持不变。 - -## 6. 阶段 2:Rust 主站 Client Builder factory - -### 6.1 工作内容 - -在 AGC Tauri/Rust 侧增加小型主站 Client Builder 入口,职责限定为: - -- 创建 `reqwest::ClientBuilder`。 -- 通过 `default_headers` 注入 `X-Genarrative-Client: agc`。 -- 不负责认证、幂等、重试、请求体、错误解析或账本。 - -建议抽象为 builder 工厂而不是完整 RequestBuilder facade,以保持当前方案一的低复杂度: - -```rust -fn agc_main_site_client_builder() -> reqwest::ClientBuilder; -``` - -工厂返回的 builder 允许调用方继续设置原有配置;未显式覆盖时,factory 默认只跟随同 origin 重定向: - -- connect timeout -- request timeout -- 显式 redirect policy(覆盖 factory 默认策略) -- 原有 `no_proxy` 或其他网络策略 - -### 6.2 阶段验收 - -增加最小单元测试或 mock 请求测试,证明: - -- 从 factory build 出的 Client 默认带 `X-Genarrative-Client: agc`。 -- 请求级 Authorization 和 Idempotency-Key 仍可正常设置。 -- factory 不改变 timeout 或 proxy 配置;默认 redirect 只跟随同 origin,调用方显式 `Policy::none()` 仍可覆盖。 -- 不新增完整 `AgcMainSiteClient` 类型。 -- 不把第三方下载或 Provider Client 接入该 factory。 - -### 6.3 阶段完成判据 - -- 主站 Client 的统一构造入口已经存在。 -- factory 的职责没有扩展到业务编排。 -- 现有测试或新增测试能锁定 Header 默认值。 - -### 6.4 阶段 2 实施记录 - -- 新增 `src-tauri/src/http_client.rs`,提供 `agc_main_site_client_builder()`。 -- factory 仅通过 `default_headers` 注入 `x-genarrative-client: agc`,不接管认证、幂等、请求体、重试或错误解析。 -- 调用方仍可在返回的 builder 上继续配置 connect timeout、request timeout、显式 redirect policy 和 `no_proxy`;未显式配置时跨 origin 重定向不会被跟随。 -- 新增两个本地 TCP fixture 单元测试,验证真实发送请求带标记、请求级 Authorization/Idempotency-Key 保留,且 factory 不自动注入认证信息。 -- 阶段 2 验证命令:`cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml http_client -- --nocapture`(2 tests passed);`cargo fmt --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml -- --check` 通过。 -- 阶段 2 未迁移任何业务 Client 创建点;迁移属于阶段 3。 - -## 7. 阶段 3:主站 Client 创建点迁移 - -### 7.1 工作内容 - -只替换生产代码中用于主站 API 的 Client 创建方式: - -- 主站用途的 `reqwest::Client::builder()` 改用 AGC 主站 builder factory。 -- 主站用途的 `reqwest::Client::new()` 改为 builder 创建,以便设置默认 Header。 -- 保留每个调用原有 timeout、connect timeout、认证和幂等设置;同源 redirect 语义保留,跨 origin redirect 统一阻断。 - -重点检查文件: - -- [assets.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/assets.rs) -- [commands.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/commands.rs) -- [canvas_generation.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs) -- [direct_runtime.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs) -- [direct_tool_bridge.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs) -- [asset_canvas/generation.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas/generation.rs) -- [resource_editor.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs) - -### 7.2 主站请求覆盖清单 - -迁移后应覆盖: - - -- `/api/profile/api-keys` -- `/api/editor/projects` -- `/api/editor/projects/{projectId}` -- `/api/editor/assets/library` -- `/api/editor/assets/folders` -- `/api/assets/direct-upload-tickets` -- `/api/assets/objects/confirm` -- `/api/assets/read-url` -- `/api/editor/projects/{projectId}/resources` -- `/api/editor/images/generations` -- `/api/editor/images/edits` -- `/api/editor/images/background-removals` -- `/api/editor/icon-spritesheets/generations` -- `/api/editor/character-animations/generations` -- `/api/editor/videos/generations` -- `/api/editor/audios/sound-effects/generations` -- `/api/editor/audios/background-music/generations` -- `/api/runtime/external-generation/jobs/{operationId}` -- 开发者 API Key 态对应的 `/api/external/v1/*` 路径 - -### 7.3 阶段验收 - -代码审查和请求捕获必须证明: - -- 上述主站请求均使用带默认 Header 的 Client。 -- 登录账号态路径带标记。 -- 开发者 API Key 态 External v1 路径带标记。 -- 生成提交和任务轮询都带标记。 -- 请求体、Bearer/API Key、Idempotency-Key、timeout 和同源 redirect 处理未变化;跨 origin redirect 不发起第二跳。 -- 没有为了加标记而修改 `ExternalEditorBindingAccess` 的路由映射语义。 - -### 7.4 阶段完成判据 - -- 主站生产请求的 Client 创建点完成迁移。 -- 没有把所有 Rust Client 粗暴替换成主站 Client。 -- 旧的第三方 Client 边界仍然清晰。 - -### 7.5 阶段 3 实施记录 - -- 已迁移 7 个生产 Rust 文件中的主站 Client 创建点:`assets.rs`、`commands.rs`、`agent/generation/canvas_generation.rs`、`agent/direct_runtime.rs`、`agent/direct_tool_bridge.rs`、`project/asset_canvas/generation.rs` 和 `project/resource_editor.rs`。 -- 迁移覆盖账号态实际 `/api/editor`、`/api/assets`、`/api/runtime` 路径,以及开发者 Key 态 `/api/external/v1` 路径;`assets/read-url` 的主站换签请求也通过 factory 创建。 -- 签名 URL 下载和 OSS multipart 上传仍由独立安全下载 Client 负责;Provider、搜索、loopback 和更新下载保持裸 Client。 -- 未修改 `ExternalEditorBindingAccess` 路由映射、凭据、请求体、operationId、Idempotency-Key 或本地账本语义。 -- 请求捕获测试已验证账号态画板同步的项目请求和换签请求带 `X-Genarrative-Client: agc`,签名媒体下载不带该 Header。 -- 阶段 3 验证:`http_client` 定向测试 2 个通过;`sync_canvas_project_assets_downloads_external_resources` 通过;Rust fmt、编码检查和 `git diff --check` 通过。 - -## 8. 阶段 4:第三方请求隔离与负向验证 - -### 8.1 工作内容 - -逐项确认以下 Client 不使用主站 factory: - -- [codex_provider_proxy.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/agent/codex_provider_proxy.rs) -- `build_external_asset_download_client`。 -- OSS multipart 上传 Client。 -- 受控搜索 Client。 -- loopback 工具桥 Client。 -- 更新清单/更新包下载 Client。 - -### 8.2 阶段验收 - -为每类请求配置 mock 或请求捕获,断言: - -- OSS multipart 上传没有 `X-Genarrative-Client`。 -- 签名 URL 下载没有 `X-Genarrative-Client`。 -- LLM Provider 没有 `X-Genarrative-Client`。 -- 搜索请求没有 `X-Genarrative-Client`。 -- loopback 工具桥没有 `X-Genarrative-Client`。 -- 更新下载没有 `X-Genarrative-Client`。 - -同时断言这些请求原有的 `no_proxy`、redirect、超时和安全校验仍生效。 - -### 8.3 阶段完成判据 - -- 所有明确排除项均通过负向测试。 -- 没有出现“为了复用 factory,把第三方请求也带上 Header”的情况。 -- 负向测试失败时能定位到具体请求类别,而不是只报告一个总失败。 - -### 8.4 阶段 4 实施记录 - -- `build_external_asset_download_client` 的 OSS/签名传输 Client 保持独立;新增 `external_asset_transfer_client_omits_agc_marker` 本地 TCP 测试。 -- 受控搜索 Client 提取为独立构造点,新增 `controlled_search_client_omits_agc_marker` 测试。 -- loopback 工具桥 Client 提取为独立构造点,新增 `loopback_tool_bridge_client_omits_agc_marker` 测试。 -- 更新下载保留独立裸 Client,新增 `update_download_client_omits_agc_marker` 测试。 -- Codex Provider 代理转发测试增加 `x-genarrative-client` 缺失断言;阶段 3 的画板同步测试继续确认签名媒体下载无标记。 -- 阶段 4 验证命令:`cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml omits_agc_marker -- --nocapture`(4 tests passed);`cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml loopback_proxy_strips_false_codex_limit_headers_and_requires_bearer -- --nocapture`(1 test passed);`cargo fmt --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml -- --check` 和 `git diff --check` 通过。 - -## 9. 阶段 5:账号态、External 态和语义回归 - -### 9.1 工作内容 - -覆盖以下身份和地址组合: - -| 场景 | 认证 | 路径形态 | -|---|---|---| -| 平台账号态 | access token | `/api/editor`、`/api/assets`、`/api/runtime` | -| 开发者 Key 态 | `tnr_sk_...` | `/api/external/v1` | -| 开发环境 | 当前开发主站 origin | 主站实际路径 | -| 发布环境 | 当前发布主站 origin | 主站实际路径 | -| 自定义服务器 | 合法自定义 `apiBaseUrl` | 自定义主站实际路径 | - -### 9.2 阶段验收 - -- 账号态和开发者 Key 态都收到相同 Header 值 `agc`。 -- External v1 语义 endpoint 不因加 Header 改变。 -- 账号态路由映射不因加 Header 改变。 -- 自定义合法主站地址仍能携带标记。 -- 非主站地址不会被主站 Client 使用。 -- 认证失败、幂等重试、异步轮询和结果未知语义不变。 -- 现有 body 内 `generationInputs.source` 值保持原样。 - -### 9.3 阶段完成判据 - -- 关键请求矩阵全部有正向或负向证据。 -- 没有发生 token、API Key、Cookie、签名 URL 或项目绝对路径泄漏到日志/测试输出。 -- 没有因为 Header 增加而改变业务状态码或重试策略。 - -### 9.4 阶段 5 实施记录 - -- 账号态生成回归:`background_agent_runtime_can_generate_platform_art_asset` 捕获并检查 `/api/editor`、`/api/assets`、`/api/runtime` 请求,确认统一标记、Bearer、UUID Idempotency-Key、单次提交和三阶段轮询;同时锁定 `generationInputs.source = ai-game-creator-client`。 -- External Key 态与自定义服务器回归:`sync_canvas_project_assets_with_developer_key_uses_external_route_and_marker` 在无平台会话下使用 `tnr_sk_phase5_fixture` 和自定义本地 `apiBaseUrl`,确认 External v1 项目/换签请求带标记,签名媒体下载不带标记。 -- 结果未知语义回归:`generation_submit_response_loss_is_not_retried` 通过,确认提交响应丢失时保留原 Idempotency-Key 且不重复提交。 -- 账号态画板同步测试继续覆盖主站项目请求、`assets/read-url` 和签名下载的正负边界。 -- 阶段 5 定向测试均通过;阶段 6 只剩 #225 交接材料复核与最终门禁,不在本阶段修改主站代码。 - -## 10. 阶段 6:#225 交接与最终门禁 - -### 10.1 交给 #225 的固定契约 - -`#225` 可以直接按以下约定实现,不需要要求 #226 回改客户端设计: - -```text -请求 Header:X-Genarrative-Client: agc -tracking metadata:client = "agc" -``` - -主站处理规则: - -- Header 名大小写不敏感。 -- 值为 `agc` 时识别为 AGC。 -- 缺失、空值或未知值按未标记处理,不拒绝业务请求。 -- 记录实际收到的 method/path,而不是客户端账本中的 External v1 endpoint。 -- 登录态使用真实登录用户维度。 -- External API Key 态使用 `ExternalApiPrincipal.owner_user_id`,必要时保留 `key_id`。 -- 不记录 token、API Key 明文或签名 URL。 -- External v1 不能因为当前没有 route spec 而漏记。 - -### 10.2 #226 最终门禁 - -完成 #226 前,不要求 #225 已经合并;但必须把以下材料交给 #225: - -- Header 名和值。 -- 主站/第三方请求边界。 -- 账号态和 External Key 态路径说明。 -- 正向测试结果摘要。 -- 负向测试结果摘要。 -- `metadata.client = "agc"` 的落库约定。 - -### 10.3 最终验证命令 - -按改动范围运行: - -- AGC TS 相关定向测试。 -- AGC Rust 相关定向测试或 `cargo test` 子集。 -- 必要的 mock HTTP 请求捕获测试。 -- `npm run check:encoding`。 -- `git diff --check`。 -- 检查 `git status --short`,确保没有构建产物、日志、凭据和无关文件。 - -### 10.4 阶段 6 实施记录 - -阶段 6 已完成,跨 origin 重定向复审修复已收口;交给 `#225` 的固定材料已在 -[【实施方案】Issue226-AGC客户端主站请求统一标记-2026-09-01.md](C:/projects/narrative/Genarrative/local-docs/【实施方案】Issue226-AGC客户端主站请求统一标记-2026-09-01.md) -第 12 节集中确认:Header 为 `X-Genarrative-Client: agc`,tracking metadata 使用 `client: "agc"`,主站按实际 method/path 和真实认证主体记录;缺失/空值/未知 Header 不拒绝请求。该交接不要求 `#226` 再回改客户端,也不要求本阶段实现 `#225` 的主站 tracking、数据库或后台代码。 - -最终门禁已通过: - -- TS `clientHttp` 定向测试:14 tests passed。 -- AGC TS 类型检查:通过。 -- Rust `http_client` factory:7 tests passed,覆盖同源跟随、跨 origin 阻断、链式重定向和显式 `Policy::none()`。 -- Rust `omits_agc_marker`:4 tests passed;loopback 认证边界:1 test passed。 -- 账号态主站 mock 捕获:1 test passed。 -- External Key 态主站 mock 捕获:1 test passed。 -- 签名 URL 下载边界:1 test passed。 -- 提交响应丢失幂等回归:1 test passed。 -- Rust fmt、`npm run check:encoding`、`git diff --check`:通过。 - -发布环境说明:本阶段没有执行真实发布环境线上 smoke;账号态/External Key 态使用本地 mock 和自定义 `apiBaseUrl` fixture 验证。真实发布环境 smoke 留给发布前或 `#225` 联调门禁,不影响 `#226` 客户端实现完成判定。 - -## 11. 建议提交/评审节奏 - -为方便阶段验收,建议在同一个 `#226` PR 内按以下逻辑组织提交或至少按阶段分组: - -1. 契约常量、TS 统一出口和 TS 测试。 -2. Rust 主站 Client Builder factory。 -3. Rust 主站 Client 创建点迁移。 -4. 第三方请求隔离和负向测试。 -5. 账号态/External 态回归、文档和最终门禁。 - -如果仓库提交策略要求单提交,也应在 PR 描述中按上述五组列出,确保每组都能单独 review 和验收。 diff --git a/local-docs/【扫描清单】AGC客户端主站调用与可标记点-2026-09-01.md b/local-docs/【扫描清单】AGC客户端主站调用与可标记点-2026-09-01.md deleted file mode 100644 index 5cbe08b61..000000000 --- a/local-docs/【扫描清单】AGC客户端主站调用与可标记点-2026-09-01.md +++ /dev/null @@ -1,228 +0,0 @@ -# AGC 客户端主站调用与可标记点清单 - -更新时间:2026-09-01
-范围:`apps/ai-game-creator-shell` 对 Genarrative 主站的调用扫描
-状态:只读盘点,未修改客户端、主站、OpenAPI 或数据库代码 - -## 1. 使用说明 - -本清单把调用分成三类: - -1. **已在 AGC 源码中发现的实际主站调用**:后续应纳入统一客户端标记范围。 -2. **主站 External v1 契约存在、但本次没有在 AGC 非测试源码中发现直接调用的接口**:作为潜在漏项保留,落地前需再次确认调用方。 -3. **明确不是主站的请求**:不应计入 AGC 主站调用统计,也不应加主站调用标记。 - -同一 AGC 能力可能有两套真实路径: - -- 登录账号态:使用平台账号 access token,External 语义路径会映射到站内 `/api/editor`、`/api/assets`、`/api/runtime`。 -- 开发者 API Key 态:使用 `tnr_sk_...`,保留 `/api/external/v1/...` 路径。 - -因此记录时应以主站实际收到的 `method + path + marker + request_id` 为准,不要只依赖客户端账本中的 External v1 endpoint。 - -## 2. 建议的统一标记 - -推荐先使用一个稳定请求头: - -```http -X-Genarrative-Client: agc -``` - -可继续复用已有 `X-Request-ID` 作为单次请求关联 ID。若后续需要区分一次客户端请求与重试,可另加每请求唯一的 `X-Genarrative-Client-Request-Id`,但不属于本次必须项。 - -标记只用于来源审计和统计,不得用于鉴权、权限、计费或账号归属判断。主站仍应以登录 access token 或 External API Key 的真实认证主体为准。 - -## 3. 已发现的实际调用:Web/TS 层 - -统一网络出口: - -- [clientHttp.ts](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src/services/clientHttp.ts) -- `fetchClientHttp` 根据环境使用浏览器 `fetch` 或 Tauri HTTP 插件。 -- 这一层适合统一注入标记,但不能覆盖 Tauri/Rust 的 `reqwest` 直连。 - -### 3.1 认证调用 - -统一封装:`clientAuth.ts` 的 `requestAuthJson`。 - -| 编号 | 方法 | 路径 | 用途 | 认证 | 标记建议 | 代码位置 | -|---|---|---|---|---|---|---| -| TS-A01 | GET | `/api/auth/me` | 获取当前用户 | Bearer,可选 | 应加 | [clientAuth.ts](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src/services/clientAuth.ts:169) | -| TS-A02 | POST | `/api/auth/refresh` | 刷新 access token | 无 Bearer | 应加 | [clientAuth.ts](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src/services/clientAuth.ts:183) | -| TS-A03 | POST | `/api/auth/entry` | 手机号+密码登录 | 无 Bearer | 应加 | [clientAuth.ts](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src/services/clientAuth.ts:210) | -| TS-A04 | POST | `/api/auth/phone/send-code` | 发送登录验证码 | 无 Bearer | 应加 | [clientAuth.ts](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src/services/clientAuth.ts:232) | -| TS-A05 | POST | `/api/auth/phone/login` | 手机号+验证码登录 | 无 Bearer | 应加 | [clientAuth.ts](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src/services/clientAuth.ts:253) | -| TS-A06 | POST | `/api/auth/logout` | 退出登录;失败时可能刷新后重试 | Bearer | 应加 | [clientAuth.ts](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src/services/clientAuth.ts:275) | - -### 3.2 素材、账户和钱包调用 - -统一封装:`clientApi.ts` 的 `requestClientApi` / `requestClientApiBytes`。 - -| 编号 | 方法 | 路径 | 用途 | 标记建议 | 代码位置 | -|---|---|---|---|---|---| -| TS-B01 | GET | `/api/editor/assets/library` | 读取平台素材库 | 应加 | [clientApi.ts](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src/services/clientApi.ts:164) | -| TS-B02 | GET | `/api/assets/read-url?objectKey=...` | 获取素材签名读取地址 | 应加 | [clientApi.ts](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src/services/clientApi.ts:176) | -| TS-B03 | GET | `/api/assets/read-bytes?objectKey=...` | 读取素材二进制 | 应加 | [clientApi.ts](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src/services/clientApi.ts:190) | -| TS-B04 | GET | `/api/profile/dashboard` | 读取余额/账户概览 | 应加 | [clientApi.ts](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src/services/clientApi.ts:198) | -| TS-B05 | GET | `/api/profile/recharge-center` | 读取充值中心 | 应加 | [clientApi.ts](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src/services/clientApi.ts:206) | -| TS-B06 | POST | `/api/profile/recharge/orders` | 创建充值订单 | 应加 | [clientApi.ts](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src/services/clientApi.ts:214) | -| TS-B07 | POST | `/api/profile/recharge/orders/{orderId}/wechat/confirm` | 确认微信支付订单 | 应加 | [clientApi.ts](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src/services/clientApi.ts:229) | -| TS-B08 | GET | `/api/profile/wallet-ledger` | 读取钱包流水 | 应加 | [clientApi.ts](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src/services/clientApi.ts:237) | - -## 4. 已发现的实际调用:Tauri/Rust 层 - -Rust 层直接使用 `reqwest`,不会经过 TS 的 `fetchClientHttp`,必须单独覆盖。 - -### 4.1 开发者 Key 与项目/素材上下文 - -核心路由分流在: - -- [external_editor_bindings.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/project/external_editor_bindings.rs:88) - -| 编号 | External v1 语义路径 | 账号态实际路径 | 用途 | 标记建议 | 代码位置 | -|---|---|---|---|---|---| -| RS-C01 | `POST /api/profile/api-keys` | 同路径 | 创建本机 AGC 开发者 API Key | 应加 | [assets.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/assets.rs:384) | -| RS-C02 | `GET /api/external/v1/editor/projects` | `GET /api/editor/projects` | 列出/查找画布项目 | 应加 | [canvas_generation.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs:1119) | -| RS-C03 | `POST /api/external/v1/editor/projects` | `POST /api/editor/projects` | 创建画布项目 | 应加 | [canvas_generation.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs:1151) | -| RS-C04 | `GET /api/external/v1/editor/projects/{projectId}` | `GET /api/editor/projects/{projectId}` | 读取项目、恢复或同步 | 应加 | [assets.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/assets.rs:717) | -| RS-C05 | `GET /api/external/v1/editor/assets/library` | `GET /api/editor/assets/library` | 读取账户素材库 | 应加 | [canvas_generation.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs:1196) | -| RS-C06 | `POST /api/external/v1/editor/assets/folders` | `POST /api/editor/assets/folders` | 创建素材文件夹 | 应加 | [canvas_generation.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs:1224) | -| RS-C07 | `POST /api/external/v1/assets/direct-upload-tickets` | `POST /api/assets/direct-upload-tickets` | 获取对象存储直传凭证 | 应加 | [canvas_generation.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs:1532) | -| RS-C08 | `POST /api/external/v1/assets/objects/confirm` | `POST /api/assets/objects/confirm` | 确认已上传对象 | 应加 | [canvas_generation.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs:1629) | -| RS-C09 | `GET /api/external/v1/assets/read-url` | `GET /api/assets/read-url` | 换签读取素材 | 应加 | [assets.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/assets.rs:1245) | -| RS-C10 | `POST /api/external/v1/editor/projects/{projectId}/resources` | `POST /api/editor/projects/{projectId}/resources` | 登记项目画布资源 | 应加 | [canvas_generation.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs:1659) | - -### 4.2 生成、去背景和异步轮询 - -| 编号 | 方法 | External v1 语义路径 | 账号态实际路径 | 用途 | 标记建议 | 代码位置 | -|---|---|---|---|---|---|---| -| RS-G01 | POST | `/api/external/v1/editor/images/generations` | `/api/editor/images/generations` | 图片生成 | 应加 | [canvas_generation.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs:941) | -| RS-G02 | POST | `/api/external/v1/editor/images/edits` | `/api/editor/images/edits` | 图片编辑/重绘 | 应加 | [generation.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas/generation.rs:2827) | -| RS-G03 | POST | `/api/external/v1/editor/images/background-removals` | `/api/editor/images/background-removals` | 图片去背景 | 应加 | [direct_tool_bridge.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs:1818) | -| RS-G04 | POST | `/api/external/v1/editor/icon-spritesheets/generations` | `/api/editor/icon-spritesheets/generations` | 图标图集/切片生成 | 应加 | [canvas_generation.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs:6290) | -| RS-G05 | POST | `/api/external/v1/editor/character-animations/generations` | `/api/editor/character-animations/generations` | 角色动画生成 | 应加 | [resource_editor.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs:2344) | -| RS-G06 | POST | `/api/external/v1/editor/videos/generations` | `/api/editor/videos/generations` | 视频生成 | 应加 | [resource_editor.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs:2373) | -| RS-G07 | POST | `/api/external/v1/editor/audios/sound-effects/generations` | `/api/editor/audios/sound-effects/generations` | 音效生成 | 应加 | [resource_editor.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs:2391) | -| RS-G08 | POST | `/api/external/v1/editor/audios/background-music/generations` | `/api/editor/audios/background-music/generations` | 背景音乐生成 | 应加 | [resource_editor.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs:2408) | -| RS-G09 | GET | `/api/external/v1/generations/{operationId}` | `/api/runtime/external-generation/jobs/{operationId}` | External Key/账号态生成任务轮询 | 应加 | [external_editor_bindings.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/project/external_editor_bindings.rs:100) | - -资源编辑器统一提交和轮询还位于: - -- [resource_editor.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs:2498) -- [resource_editor.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs:2614) - -### 4.3 Direct Runtime 恢复与账户素材导入 - -| 编号 | 方法 | 路径 | 用途 | 标记建议 | 代码位置 | -|---|---|---|---|---|---| -| RS-R01 | GET | `/api/external/v1/editor/projects/{projectId}` 或 `/api/editor/projects/{projectId}` | Direct Runtime 只读恢复项目资源 | 应加 | [direct_runtime.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs:2487) | -| RS-R02 | GET | `/api/external/v1/assets/read-url` 或 `/api/assets/read-url` | 账户素材导入前换签 | 应加 | [commands.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/commands.rs:3833) | -| RS-R03 | GET | `/api/external/v1/editor/assets/library` 或 `/api/editor/assets/library` | 查询当前账号素材并按 assetId 选择 | 应加 | [commands.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/commands.rs:3283) | -| RS-R04 | GET | `/api/external/v1/editor/projects/{projectId}` 或 `/api/editor/projects/{projectId}` | 读取项目画布资源清单 | 应加 | [commands.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/commands.rs:3327) | - -## 5. 已知的现有 body 内来源字段 - -这些字段不是统一 Header,且不会被全局 HTTP tracking middleware 自动读取,只能作为业务请求内部来源信息: - -| 来源值 | 出现位置 | 覆盖范围 | 说明 | -|---|---|---|---| -| `ai-game-creator-client` | [canvas_generation.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs:951) | 部分图片生成请求 | 账号态图片生成 helper 会写入 `generationInputs.source` | -| `game-creator-account-binding` | [generation.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas/generation.rs:2311) | 项目资源登记 | 表示 AGC 账户绑定上下文 | -| `game-creator-resource-editor` | [generation.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas/generation.rs:2796) | 资源编辑生成输入 | 表示 AGC 资源编辑链路 | - -这些 body 字段不能替代统一请求标记,因为它们不覆盖登录、列表、素材读取、钱包、上传凭证、对象确认和任务轮询等请求;并且主站 tracking 当前不会解析生成请求 body 中的 `generationInputs.source`。 - -## 6. External v1 契约中存在、但本次未发现 AGC 直接调用的潜在接口 - -这些接口在 [genarrative-external-v1.openapi.json](C:/projects/narrative/Genarrative/docs/openapi/genarrative-external-v1.openapi.json) 中存在。它们应保留在后续核对清单中,但本次不把它们误报为 AGC 已实际调用: - -### 6.1 项目管理 - -- `GET /api/external/v1/editor/projects/recent` -- `DELETE /api/external/v1/editor/projects/{projectId}` -- `PATCH /api/external/v1/editor/projects/{projectId}/metadata` -- `PATCH /api/external/v1/editor/projects/{projectId}/canvas` - -### 6.2 素材文件夹和素材记录管理 - -- `PATCH /api/external/v1/editor/assets/folders/{folderId}` -- `DELETE /api/external/v1/editor/assets/folders/{folderId}` -- `POST /api/external/v1/editor/assets` -- `PATCH /api/external/v1/editor/assets/{assetId}` -- `DELETE /api/external/v1/editor/assets/{assetId}` - -### 6.3 UI 设计图素材提取 - -- `POST /api/external/v1/editor/ui-designs/assets/extractions` - -当前 AGC Prompt 明确要求不要误用这个接口,而是通过图片生成链路生成 UI 原型,因此它目前属于契约潜在接口,不属于已确认实际调用。 - -### 6.4 External API 发现和协议接口 - -这些是公开契约/集成发现能力,不属于普通 AGC 主站业务调用;若未来 AGC 直接接入 MCP 或远程 Skill,再单独纳入: - -- `GET /api/external/v1/openapi.json` -- `GET /api/external/v1/agent-integration.json` -- `GET /api/external/v1/skill/SKILL.md` -- `GET /api/external/v1/skill.zip` -- `POST /api/external/v1/mcp` - -## 7. 明确排除的请求 - -以下请求不是 AGC 调用主站,不应进入主站调用标记统计: - -- LLM Provider:`POST {provider}/responses`,见 [codex_provider_proxy.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/agent/codex_provider_proxy.rs)。 -- OSS/对象存储上传:主站返回直传票据后,客户端向票据 host 发出的 multipart POST。 -- 签名素材下载:`read-url` 返回临时地址后,对签名地址发出的二进制 GET。 -- AGC 受控网页搜索:见 [direct_tool_bridge.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs:2201)。 -- 本地 Tauri IPC、文件读写、浏览器试玩页面请求。 - -## 8. 主站承接标记的现状 - -### 8.1 请求上下文 - -主站请求上下文位于: - -- [request_context.rs](C:/projects/narrative/Genarrative/server-rs/crates/api-server/src/request_context.rs) - -当前已有 `request_id`、operation 和计时信息,但没有 client/source/marker 字段。 - -### 8.2 全局 tracking - -主站全局 middleware 和 route tracking 位于: - -- [app.rs](C:/projects/narrative/Genarrative/server-rs/crates/api-server/src/app.rs) -- [tracking.rs](C:/projects/narrative/Genarrative/server-rs/crates/api-server/src/tracking.rs) - -当前特点: - -- 主要在成功响应后写 route tracking event。 -- `/api/auth`、`/api/profile`、`/api/assets`、`/api/editor`、`/api/runtime` 已有部分 route spec。 -- `/api/external/v1/*` 当前没有 route spec,所以 External API Key 模式的 AGC 调用不会自动进入现有 route tracking。 -- External API 鉴权中间件已有 `ExternalApiPrincipal`,可提供 `owner_user_id` 和 `key_id`,但现有成功 route tracking 只显式读取登录态 `AuthenticatedAccessToken`,需要单独确认 External principal 的接入方式。 - -### 8.3 数据库和后台 - -现有 SpacetimeDB `tracking_event` 已有 `metadata_json`,适合第一阶段记录: - -```json -{ - "client": "agc", - "route": "/api/editor/images/generations", - "method": "POST", - "status": 202 -} -``` - -后台当前 tracking 查询支持 event、user、scope、日期等结构化条件,不能直接按 `metadata_json.client` 高效筛选。若后续需要高频按客户端筛选,再考虑新增独立 `client_marker` 字段和索引;这会涉及 schema、migration、绑定和相关门禁。 - -## 9. 后续实现前的核对顺序 - -本文件只作为调用盘点,不代表已经授权改造。真正落地前建议按以下顺序重新核对: - -1. 以本清单的“实际调用”表为准,逐个确认非测试源码仍有调用。 -2. 先覆盖 TS `fetchClientHttp`。 -3. 再覆盖 Rust 的公共 External Editor 请求构造点和剩余直接 `reqwest` 调用。 -4. 确认账号态映射路径与开发者 Key External v1 路径都能携带同一标记。 -5. 主站解析 Header 时使用白名单值,未知值按未标记处理,不影响业务请求。 -6. route tracking 同时补充 External v1 路径和 External API principal 维度。 -7. 第一阶段优先写 `metadata_json`,暂不急于改 SpacetimeDB schema。 -8. 验证成功、失败、重试、异步轮询、401、上传凭证和钱包请求是否都能按预期区分。 diff --git a/local-docs/【技术方案】AGC主站请求标记统一注入两种方案-2026-09-01.md b/local-docs/【技术方案】AGC主站请求标记统一注入两种方案-2026-09-01.md deleted file mode 100644 index 7c938c0ab..000000000 --- a/local-docs/【技术方案】AGC主站请求标记统一注入两种方案-2026-09-01.md +++ /dev/null @@ -1,414 +0,0 @@ -# AGC 主站请求标记统一注入两种方案 - -更新时间:2026-09-01
-关联 Issue:`#226 添加客户端特殊标识`、`#225 添加客户端埋点统计`
-状态:方案说明,未实施代码修改 - -## 1. 背景 - -AGC 客户端同时存在两套 HTTP 出口: - -1. Web/TS 层,通过 `fetchClientHttp` 使用浏览器 `fetch` 或 Tauri HTTP 插件。 -2. Tauri/Rust 层,通过多个 `reqwest::Client` 直接调用主站、对象存储、签名下载地址、LLM Provider 和本地工具桥。 - -目标是让 AGC 发往主站的请求统一携带来源标记,而不是在每个业务 API 调用点重复写 Header。 - -推荐标记: - -```http -X-Genarrative-Client: agc -``` - -该标记只用于来源审计、埋点和统计,不参与鉴权、权限、计费或账号归属判断。 - -## 2. 目标与不做项 - -### 2.1 目标 - -- AGC 发往当前选定主站 origin 的请求统一携带客户端标记。 -- 同时覆盖登录账号态和开发者 API Key 态。 -- 同时覆盖普通业务请求、生成提交和异步任务轮询。 -- 避免在每个 `/api/...` 调用点重复设置 Header。 -- 不把标记发送给 OSS、签名资源地址、LLM Provider、网页搜索或本地工具桥。 -- 保持现有 access token、API Key、幂等键和请求体语义不变。 - -### 2.2 不做项 - -- 本方案不定义主站数据库字段和后台页面实现,主站接收与落库属于 `#225`。 -- 不使用客户端标记替代真实认证主体。 -- 不修改 External v1 请求 DTO 或 OpenAPI 请求体。 -- 不通过 DNS、系统代理或网络抓包层注入 Header。 -- 不给进程内所有 `reqwest` 请求无差别设置默认 Header。 - -## 3. 两种方案的共同部分 - -无论 Rust 层选择哪种方案,Web/TS 层都可以直接在统一出口注入。 - -### 3.1 Web/TS 统一出口 - -文件: - -- [clientHttp.ts](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src/services/clientHttp.ts) - -处理方式: - -1. `fetchClientHttp` 解析最终请求地址。 -2. 确认目标 origin 等于当前选定的 `serverBaseUrl`。 -3. 在传给浏览器 `fetch` 或 Tauri HTTP 插件前设置: - -```http -X-Genarrative-Client: agc -``` - -示意代码: - -```ts -const headers = new Headers(init.headers); -headers.set('X-Genarrative-Client', 'agc'); - -return transport(target.url, { - ...init, - headers, -}); -``` - -需要保留调用方原有 Header,尤其是: - -- `Authorization` -- `Content-Type` -- `x-genarrative-response-envelope` -- `X-Request-ID`,如果调用方已提供 - -### 3.2 主站 origin 的定义 - -不能只写死以下两个地址: - -```text -https://dev.genarrative.world -https://www.genarrative.world -``` - -AGC 还支持自定义服务器,判断依据应是当前请求绑定的权威 `serverBaseUrl` / `apiBaseUrl`,并比较规范化后的 origin: - -```text -scheme + host + effective port -``` - -路径、query 和 fragment 不属于 origin 判断条件。 - -### 3.3 必须排除的请求 - -以下请求不能携带主站客户端标记: - -- `direct-upload-tickets` 返回的对象存储 multipart 上传地址。 -- `read-url` 返回的临时签名下载地址。 -- LLM Provider 的 `/responses` 等请求。 -- AGC 受控网页搜索请求。 -- 本地 loopback 工具桥请求。 -- 游戏试玩页面、外部网页或用户输入的任意 URL。 - -### 3.4 CORS - -浏览器环境向不同 origin 的主站发送自定义 Header 时可能触发 CORS 预检。主站及自定义部署需要允许: - -```http -Access-Control-Allow-Headers: X-Genarrative-Client -``` - -Tauri HTTP 插件和 Rust `reqwest` 不受浏览器 CORS 限制,但仍应遵守相同的目标 origin 边界。 - -## 4. 方案一:专用主站 HTTP Client 工厂 - -### 4.1 核心思路 - -为 Rust 层建立一个只允许服务于主站 API 的 `reqwest::Client` 构造函数,并通过 `default_headers` 自动加入客户端标记;请求发送前再由轻量终结器覆盖同名 Header,确保调用方不能伪造标记值。 - -第三方上传、签名下载、Provider 和搜索继续使用现有独立 Client,不带标记。 - -示意代码: - -```rust -fn build_agc_main_site_client(timeout: Duration) -> Result { - let mut headers = reqwest::header::HeaderMap::new(); - headers.insert( - reqwest::header::HeaderName::from_static("x-genarrative-client"), - reqwest::header::HeaderValue::from_static("agc"), - ); - - let default_policy = reqwest::redirect::Policy::default(); - let redirect_policy = reqwest::redirect::Policy::custom(move |attempt| { - let same_origin = attempt - .previous() - .first() - .map(|initial| initial.origin() == attempt.url().origin()) - .unwrap_or(false); - if same_origin { - default_policy.redirect(attempt) - } else { - attempt.stop() - } - }); - - reqwest::Client::builder() - .default_headers(headers) - .connect_timeout(Duration::from_secs(10)) - .timeout(timeout) - .redirect(redirect_policy) - .build() - .map_err(|error| format!("创建 AGC 主站 HTTP 客户端失败:{error}")) -} -``` - -调用代码仍然保持普通 `reqwest` 写法: - -```rust -let client = build_agc_main_site_client(Duration::from_secs(60))?; - -let response = with_agc_main_site_marker( - client - .get(format!("{api_base_url}{route}")) - .bearer_auth(token), -) - .send() - .await?; -``` - -业务请求不再逐个调用 `.header("X-Genarrative-Client", "agc")`;由统一终结器在发送前覆盖调用方同名值。 - -### 4.2 预计改动范围 - -主要改动是把主站用途的 `reqwest::Client::new()` / `Client::builder()` 替换为统一工厂,而不是修改每个 API endpoint。 - -潜在涉及文件: - -- [assets.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/assets.rs) -- [commands.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/commands.rs) -- [canvas_generation.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs) -- [direct_runtime.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs) -- [direct_tool_bridge.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs) -- [generation.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas/generation.rs) -- [resource_editor.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs) - -不应迁移到主站 Client 的文件或请求: - -- [codex_provider_proxy.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/agent/codex_provider_proxy.rs) -- `build_external_asset_download_client` 创建的签名资源下载 Client。 -- OSS multipart 上传 Client。 -- 受控搜索和 loopback 工具桥 Client。 - -### 4.3 优点 - -- 实现简单,符合当前仓库优先简单设计的原则。 -- 业务请求代码基本不变。 -- Header 由 factory 默认注入,终结器在发送前统一覆盖同名值。 -- 不需要新增 HTTP middleware 框架或复杂泛型包装。 -- 便于为普通请求和长时间生成提交提供不同 timeout,但共享同一标记配置。 -- 可以通过单元测试检查 Client 发出的请求自动带标记。 - -### 4.4 缺点和风险 - -- `default_headers` 对该 Client 发出的所有请求生效,不会再次判断目标域名。 -- `default_headers` 不能覆盖请求级同名 Header;新增的主站直发路径若绕过请求终结器,仍可能发送错误值。 -- 如果后续有人误用主站 Client 请求 OSS、签名 URL 或 Provider,标记会被带到第三方。 -- 当前存在多个不同 timeout 的 Client,需要提供少量参数或几个明确的工厂函数。 -- 仍需要替换现有主站 Client 的创建点,无法做到完全零调用点改动。 - -### 4.5 风险控制 - -保持控制简单,不引入复杂网络状态机: - -- 主站 Client 只在持有权威 `apiBaseUrl` 的模块中创建。 -- 主站 Client 不传给通用下载函数。 -- 签名下载继续由 `build_external_asset_download_client` 单独创建。 -- OSS 上传继续使用单独 Client。 -- 工厂名称明确包含 `main_site`,避免误用。 -- 所有 AGC 主站请求的最终 `.send()` 前统一经过 `with_agc_main_site_marker`;新增直发路径必须同步接入该终结器。 -- 定向测试断言主站 mock 收到标记、第三方 mock 未收到标记。 - -## 5. 方案二:按目标 origin 的请求包装器 - -### 5.1 核心思路 - -不依赖 Client 的默认 Header,而是统一通过一个请求构造入口创建主站 RequestBuilder。 - -包装器在构造请求时: - -1. 解析目标 URL。 -2. 解析当前权威 `apiBaseUrl`。 -3. 比较两者 origin。 -4. 只有同源时加入 AGC 标记。 -5. 非同源时拒绝,或明确返回不带标记的普通请求。 - -更安全的做法是主站包装器直接拒绝非同源 URL。 - -示意代码: - -```rust -fn main_site_request( - client: &reqwest::Client, - method: reqwest::Method, - api_base_url: &str, - route: &str, -) -> Result { - let base = url::Url::parse(api_base_url) - .map_err(|_| "AGC 主站地址无效".to_string())?; - let target = base - .join(route) - .map_err(|_| "AGC 主站请求路径无效".to_string())?; - - if target.origin() != base.origin() { - return Err("AGC 主站请求越出当前服务器 origin".to_string()); - } - - Ok(client - .request(method, target) - .header("X-Genarrative-Client", "agc")) -} -``` - -调用示意: - -```rust -let response = main_site_request( - &client, - reqwest::Method::GET, - access.api_base_url(), - &route, -)? -.bearer_auth(access.bearer_token()) -.send() -.await?; -``` - -### 5.2 预计改动范围 - -所有主站 `.get(...)`、`.post(...)`、`.patch(...)`、`.delete(...)` 构造点需要切换为包装器,或统一封装为一个 `AgcMainSiteHttp` 类型。 - -例如: - -```rust -struct AgcMainSiteHttp { - client: reqwest::Client, - base_url: url::Url, -} - -impl AgcMainSiteHttp { - fn get(&self, route: &str) -> Result; - fn post(&self, route: &str) -> Result; -} -``` - -下载、OSS 和 Provider 请求继续直接使用普通 `reqwest::Client`。 - -### 5.3 优点 - -- 每次请求都会校验真实目标 origin。 -- 即使包装器被误用于第三方 URL,也可以失败关闭,不会泄漏标记。 -- 主站 URL 拼接和 origin 校验有单一权威实现。 -- 适合未来出现更多动态 URL、多个部署 origin 或更严格的请求来源策略。 -- 可以在同一个入口继续注入 request ID、客户端版本等非敏感请求元信息。 - -### 5.4 缺点和风险 - -- 需要修改更多请求构造点。 -- 容易把简单的 Header 注入扩大成新的 HTTP 抽象层。 -- 现有代码已经有 `ExternalEditorBindingAccess`、路由映射、冻结会话校验等概念,再新增完整 HTTP facade 会增加概念数量。 -- 如果包装器同时承接鉴权、重试、错误解析、幂等和下载,很容易过度设计。 -- 对只需要固定来源标记的当前需求,复杂度高于方案一。 - -### 5.5 风险控制 - -- 包装器只负责 URL 构造、origin 校验和固定 Header,不负责业务错误解析。 -- 不在包装器中自动重试 POST。 -- 不把 token、API Key 或幂等键保存到长生命周期对象。 -- `ExternalEditorBindingAccess` 继续负责账号态/开发者 Key 路由映射和冻结会话校验。 -- 业务层继续明确设置 Bearer、Idempotency-Key 和请求体。 - -## 6. 两种方案对比 - -| 对比项 | 方案一:专用主站 Client 工厂 | 方案二:origin 请求包装器 | -|---|---|---| -| Header 注入位置 | `reqwest::Client::default_headers` | 每次构造 RequestBuilder 时 | -| 业务调用点改动 | 较少,主要替换 Client 创建点 | 较多,需要替换请求构造点 | -| 第三方泄漏防护 | 依赖 Client 使用边界 | 每次请求显式 origin 校验 | -| 实现复杂度 | 低 | 中 | -| 新增概念数量 | 少 | 较多 | -| 支持不同 timeout | 通过工厂参数或少量变体 | 共用 Client 或包装器配置 | -| 对动态 URL 的安全性 | 一般 | 高 | -| 当前需求适配度 | 高 | 中 | -| 后续扩展请求元信息 | 可以,但仍是 Client 级别 | 更灵活,可按请求控制 | -| 误用后的行为 | 可能把标记发给非主站 | 可拒绝非同源请求 | - -## 7. 推荐选择 - -当前推荐 **方案一:专用主站 HTTP Client 工厂**。 - -理由: - -- 当前需求只是给主站请求增加稳定来源标记。 -- 主站、签名下载、OSS 和 Provider 已有相对明确的客户端边界。 -- 不需要为一个固定 Header 引入新的 HTTP facade。 -- 改动集中在 Client 创建点,业务请求路径和错误语义变化较小。 -- 更符合仓库“优先简单、避免过度设计”的约束。 - -方案一需要明确遵守:主站 Client 不能用于签名下载、OSS 上传和 Provider 请求。如果实施时发现多个模块无法可靠维持这条边界,或存在大量由外部数据生成的动态目标 URL,再改选方案二。 - -不建议一开始同时实现两种方案。二选一即可,避免形成“Client 默认 Header + RequestBuilder 再加一次 Header”的重复机制。 - -## 8. 建议的 PR 边界 - -### 8.1 `#226 添加客户端特殊标识` - -负责: - -- TS `fetchClientHttp` 统一注入标记。 -- Rust 按选定方案统一注入标记。 -- 主站请求携带标记。 -- 第三方请求不携带标记。 -- 必要的 CORS Header 配置验证。 - -不负责: - -- tracking event 落库。 -- External v1 route tracking。 -- 后台筛选和统计展示。 - -### 8.2 `#225 添加客户端埋点统计` - -负责: - -- 主站读取并校验客户端标记。 -- 结合登录用户或 `ExternalApiPrincipal` 记录认证主体。 -- 将标记写入 `tracking_event.metadata_json` 或后续确定的结构化字段。 -- 补齐 `/api/external/v1/*` tracking。 -- 后台查询、筛选或统计。 - -## 9. 验收清单 - -### 9.1 正向请求 - -- TS 登录请求带 `X-Genarrative-Client: agc`。 -- TS 素材、钱包请求带标记。 -- Rust 账号态 `/api/editor/*` 请求带标记。 -- Rust 账号态 `/api/assets/*` 请求带标记。 -- Rust 账号态 `/api/runtime/external-generation/jobs/*` 轮询带标记。 -- Rust API Key 态 `/api/external/v1/*` 请求带标记。 -- `/api/profile/api-keys` 请求带标记。 -- 自定义主站 origin 的请求带标记。 - -### 9.2 排除请求 - -- OSS multipart 上传不带标记。 -- 签名 URL 下载不带标记。 -- LLM Provider 请求不带标记。 -- AGC 受控搜索不带标记。 -- 本地工具桥请求不带标记。 - -### 9.3 兼容性 - -- 原 Authorization 不变。 -- 原 Idempotency-Key 不变。 -- 原请求 body 不变。 -- 原 timeout 不变;同源 redirect policy 语义不变,跨 origin redirect 由主站 Client factory 阻断。 -- 浏览器跨域预检允许 `X-Genarrative-Client`。 -- 未识别 marker 时主站业务请求不受影响。 diff --git a/local-docs/【阶段验收】Issue225阶段5定向测试与安全边界-2026-09-02.md b/local-docs/【阶段验收】Issue225阶段5定向测试与安全边界-2026-09-02.md deleted file mode 100644 index a2e7d1003..000000000 --- a/local-docs/【阶段验收】Issue225阶段5定向测试与安全边界-2026-09-02.md +++ /dev/null @@ -1,105 +0,0 @@ -# Issue225 阶段 5:定向测试与安全边界 - -更新时间:2026-09-02 -关联分支:`feat/agc_call_rec` - -## 1. 阶段交付结果 - -本阶段补齐 Issue #225 直接相关的状态码、鉴权、安全 metadata 和幂等回归,确认 `X-Genarrative-Client: agc` 只是来源审计标签,不改变认证、权限、计费、请求体、异步轮询或成功事件语义。 - -## 2. 新增回归 - -### 2.1 成功状态语义 - -`tracking.rs` 新增 `should_record_route_tracking` 判定测试: - -- `200 / 201 / 202 / 204` 的显式业务 route 可以进入成功 tracking; -- `400 / 401 / 403 / 404 / 500 / 502` 即使带 AGC marker,也不会新增成功 route event; -- 已由详细资产事件处理的上传票据和对象确认不重复生成统一 route event。 - -### 2.2 AGC-only 路由门禁 - -本期新增、仅为 AGC 调用清单补齐的账号态和 External v1 route spec 均要求有效 `X-Genarrative-Client: agc`: - -- 带有效 marker 的 2xx 响应才写入 route tracking; -- 缺失、非法、未知或大小写不符合契约的 marker 不写入这些新增 route event,也不拒绝业务请求; -- 既有 route spec 和已有详细资产事件保持原有全客户端记录语义。 - -### 2.3 鉴权边界 - -`app.rs` 通过真实 router 发起未认证的: - -```text -GET /api/editor/projects -X-Genarrative-Client: agc -``` - -响应仍为 `401 Unauthorized`。marker 不会获得权限,也不会改变失败状态。 - -### 2.4 metadata 安全边界 - -route metadata 测试确认不会写入: - -```text -authorization -accessToken -token -apiKey -cookie -signature -signedUrl -requestBody -``` - -有效 marker 只产生固定的 `client: "agc"`,并保留既有 route/method/status/operation 与资产嵌套字段。 - -### 2.5 既有链路复回归 - -阶段 4 的以下路径在阶段 5 再次顺序执行: - -- `TrackingEventDraft → RuntimeTrackingEventInput`; -- tracking outbox NDJSON round-trip; -- `spacetime-client` mapper; -- module-runtime metadata object 校验; -- 后台 tracking SQL response parser; -- 账号态 / External API Key owner 归属; -- SpacetimeDB tracking event-id 幂等回归。 - -## 3. 验收结果 - -- [x] marker 有效值、缺失值、空值、大小写和未知值测试通过。 -- [x] 账号态 `user_id = owner_user_id` 归属测试通过。 -- [x] External API Key 态只写真实 `owner_user_id`,不伪造 `user_id`,scope 使用 owner。 -- [x] 当前账号态和 External v1 业务 route coverage 测试通过。 -- [x] discovery、Skill、MCP 排除测试通过。 -- [x] 2xx 成功记录和 4xx/5xx 不新增成功 route event 测试通过。 -- [x] AGC marker 不绕过鉴权、不改变失败状态。 -- [x] route metadata 不包含 token、API Key、Cookie、签名 URL 或请求体字段。 -- [x] outbox、mapper、runtime input 和后台 readback 回归通过。 -- [x] event-id 幂等回归通过。 - -## 4. 定向验证结果 - -| 测试范围 | 通过数 | -|---|---:| -| `module-runtime` tracking input | 2 | -| `spacetime-client` tracking mapper | 1 | -| `api-server` tracking | 18 | -| `api-server` app 鉴权回归 | 1 | -| `api-server` 资产 marker / owner | 2 | -| `api-server` tracking outbox | 8 | -| `api-server` 后台 readback | 5 | -| `spacetime-module` event-id 幂等 | 1 | -| 合计 | 38 | - -另外通过: - -- `cargo fmt --manifest-path server-rs/Cargo.toml --all -- --check` -- `git diff --check` -- `npm run check:encoding` - -未运行与本 Issue 无直接关系的完整工作区测试,按约定交给 CI。没有启动真实 SpacetimeDB,因此没有把确定性 procedure input/mapper 和 admin parser 回归扩大解释为远端数据库 E2E。 - -## 5. 后续阶段 - -阶段 6 汇总阶段 1~5 的证据,复核 #226 交接契约、执行最终门禁并准备 Issue #225 交付说明。阶段 5 代码已提交为 `c5170669e`;日登录埋点构造器回归修复补充提交为 `0a06b4988`。 diff --git a/local-docs/【阶段验收】Issue225阶段6最终门禁与交接收口-2026-09-02.md b/local-docs/【阶段验收】Issue225阶段6最终门禁与交接收口-2026-09-02.md deleted file mode 100644 index a36a13acd..000000000 --- a/local-docs/【阶段验收】Issue225阶段6最终门禁与交接收口-2026-09-02.md +++ /dev/null @@ -1,148 +0,0 @@ -# Issue225 阶段6:最终门禁与 #226 交接收口 - -更新时间:`2026-09-02` -实施范围:`#225 添加客户端埋点统计` -交接输入:`#226 添加客户端特殊标识` - -执行结论:阶段 6 通过,Issue #225 的主站接收、tracking metadata、主体归属、路由覆盖、持久化/后台读取验证和安全语义回归已收口。本阶段没有新增生产功能,没有修改 #226 客户端实现,不需要 #226 回改设计。 - -## 1. 阶段边界 - -本阶段只完成: - -1. 汇总阶段 1~5 的实现提交、定向测试和 readback 证据。 -2. 复核 `#226` 交接的 Header、origin、认证和第三方边界。 -3. 执行与本 Issue 直接相关的最终编译、测试、格式、编码和空白门禁。 -4. 更新实施方案、分阶段计划和 Issue #225 交付评论草案。 - -本阶段明确不做: - -- 不修改 `AGC` 客户端的 `fetchClientHttp`、Rust Client factory、请求终结器或 redirect policy。 -- 不新增 `tracking_event` 列、索引、migration、生成 bindings 或新的统计表。 -- 不修改 `/api/external/v1` 的 OpenAPI、DTO、HTTP 方法、状态码、鉴权或异步语义。 -- 不新增后台筛选器、报表页面或真实发布环境线上写入。 - -## 2. 当前仓库基线 - -阶段 6 开始时仓库状态: - -| 项目 | 结果 | -|---|---| -| 分支 | `feat/agc_call_rec` | -| HEAD | `5aa38d9f3`(已合并最新 `origin/master`) | -| `origin/master` | `8932f0b27` | -| 工作树 | 开始阶段 6 时干净;本记录及计划/方案更新属于本阶段待提交文档变更 | - -阶段 1~5 的实现提交: - -| 阶段 | 提交 | -|---|---| -| 阶段 1:marker 解析与 tracking 入口 | `2296f79fd` | -| 阶段 2:主体归属与 metadata | `2a23ba657` | -| 阶段 3:路由覆盖 | `01159a269` | -| 阶段 4:持久化与后台读取 | `c75c61521` | -| 阶段 5:安全与语义回归 | `c5170669e` | -| 日登录埋点构造器回归修复 | `0a06b4988` | - -## 3. 最终交付行为 - -### 3.1 Marker 与 metadata - -主站识别: - -```http -X-Genarrative-Client: agc -``` - -规则: - -- Header 名按 HTTP 规则大小写不敏感。 -- Header 值去除首尾空白后,必须精确等于小写 `agc`。 -- 缺失、空值、`AGC` 或未知值按未标记处理,不拒绝请求。 -- 有效标记只追加到已有成功 route tracking 的 `tracking_event.metadata_json`: - -```json -{ - "route": "/api/editor/projects", - "method": "GET", - "status": 200, - "operation": "listEditorProjects", - "client": "agc" -} -``` - -不写入 Header 原文、token、API Key、Cookie、签名 URL、请求体或项目绝对路径。 - -本期新增的 AGC route spec 只有在请求带有效 marker 时才记录;既有 route spec 与手工资产事件沿用原有全客户端记录策略。 - -阶段 6 后续补充了一条轻量 middleware 成功链路回归:真实 `build_router` 请求经过账号认证和 tracking middleware 后写入隔离 outbox,并验证 `client`、实际 route、user/owner 归属;该测试不启动真实 SpacetimeDB,不改变完整环境型 E2E 仍作为发布前 smoke 的边界。 - -### 3.2 主体归属 - -- 登录账号态:使用已验证 access token 的真实用户,保留既有 `user_id`、`owner_user_id` 和 scope 语义。 -- External API Key 态:使用 `ExternalApiPrincipal.owner_user_id()`;不伪造登录 `user_id`,User scope 的 `scope_id` 使用 owner。 -- Header 只是来源审计标签,不能绕过认证、权限、计费或替换主体。 - -### 3.3 路由覆盖与排除 - -已覆盖的实际业务路径: - -- 账号态:`/api/auth/*`、`/api/profile/*`、`/api/assets/*`、`/api/editor/*`、`/api/runtime/*`。 -- External API Key 态:`/api/external/v1/*` 的当前资产、项目、素材库、生成提交和任务轮询业务路径。 -- 动态 project/generation/asset ID 继续按显式静态段规则归一化;External v1 metadata 保留实际外部 path。 -- 上传票据和对象确认沿用已有详细资产事件,不重复生成普通 route event,但会保留有效 AGC metadata。 - -明确排除: - -- OSS multipart 上传、签名 URL/OSS 媒体下载。 -- LLM/Codex Provider、AGC 受控搜索、loopback 工具桥。 -- 更新清单、更新包下载和任意外部网页请求。 -- `/api/external/v1/openapi.json`、`agent-integration.json`、Skill 文档/压缩包和 MCP 入口。 - -### 3.4 落库与读取位置 - -AGC 调用记录最终落在主站 SpacetimeDB 的 `tracking_event` 表,标识位于 `tracking_event.metadata_json.client`。普通 route tracking 默认先进入 api-server 本机: - -```text -server-rs/.data/tracking-outbox/active.ndjson -server-rs/.data/tracking-outbox/sealed-*.ndjson -``` - -worker 使用既有 `record_tracking_events_and_return` 批量 procedure 写入 SpacetimeDB;outbox 不可用时沿用同步 `record_tracking_event_and_return` 回退。后台 `GET /admin/api/tracking/events` 读取同一 `tracking_event` 的 `metadata_json`,当前没有单独的 `client=agc` 查询参数,需要从返回 JSON 中识别 `"client": "agc"`。 - -## 4. 最终门禁证据 - -| 验收项 | 命令 | 结果 | -|---|---|---| -| api-server 编译 | `cargo check --locked --manifest-path server-rs/Cargo.toml -p api-server` | 通过;仅仓库既有 warning | -| api-server tracking/资产/后台/outbox 定向测试 | `cargo test --locked --manifest-path server-rs/Cargo.toml -p api-server tracking -- --nocapture` | 43 passed,0 failed | -| marker 不绕过鉴权回归 | `cargo test --locked --manifest-path server-rs/Cargo.toml -p api-server app::tests::agc_marker_does_not_bypass_authentication_or_change_failure_status -- --nocapture` | 1 passed,0 failed | -| module-runtime tracking input | `cargo test --locked --manifest-path server-rs/Cargo.toml -p module-runtime tracking_input_ -- --nocapture` | 2 passed,0 failed | -| spacetime-client mapper | `cargo test --locked --manifest-path server-rs/Cargo.toml -p spacetime-client tracking_input_mapper_preserves_agc_metadata_and_external_owner -- --nocapture` | 1 passed,0 failed | -| SpacetimeDB event-id 幂等 | `cargo test --locked --manifest-path server-rs/Cargo.toml -p spacetime-module duplicate_tracking_event_ids_are_treated_as_idempotent_replays -- --nocapture` | 1 passed,0 failed | -| Rust 格式 | `cargo fmt --manifest-path server-rs/Cargo.toml --all -- --check` | 通过 | -| 中文编码 | `npm run check:encoding` | 通过,5662 个文件 | -| Diff 空白/分支基线 | `git diff --check`、`git merge-base --is-ancestor origin/master HEAD` | 通过 | - -与本 Issue 直接相关的本轮定向测试合计 48 个通过。完整工作区测试不在本地重复运行,按约定交给 CI;本地未启动真实 SpacetimeDB,因此没有把 input/mapper/readback parser 测试表述为线上 E2E。 - -## 5. #226 交接复核 - -`#226` 已提供并冻结以下输入: - -- 发往当前 Genarrative 主站 origin 的业务请求带 `X-Genarrative-Client: agc`。 -- OSS、签名下载、Provider、受控搜索、loopback、更新下载和外部网页请求不带该标记。 -- 同源重定向继续允许;跨 origin 重定向被阻断,避免标记泄漏到第三方 origin。 -- 请求级同名 Header 不能伪造最终值,主站业务请求最终仍为 `agc`。 - -主站 #225 已按上述契约消费 Header;不存在要求 #226 重新设计或回改的接口缺口。 - -补充记录一个不属于 #225 的客户端后续项:`clientHttp.ts` 当前把自定义服务器地址的规范化字符串直接与 `URL.origin` 比较;显式默认端口(例如 `https://example.com:443`)或主机名大小写可能导致合法自定义 origin 被误判为跨 origin。该问题表现为请求被客户端拒绝,不会造成 AGC 标记泄漏,也不影响当前 release/dev 默认地址;应作为 #226 的独立客户端修复跟踪,不能在 #225 中偷偷回改客户端设计。 - -## 6. 可直接粘贴到 Issue #225 的交付评论 - -> `#225` 主站侧已完成并收口:统一识别 `X-Genarrative-Client: agc`,本期新增的 AGC-only 成功 route 只有在带有效 marker 时才写入 `tracking_event.metadata_json.client = "agc"`;既有 route tracking 和手工资产事件保持原有全客户端统计语义。账号态按已验证 access token 的真实用户归属,External API Key 态按 `ExternalApiPrincipal.owner_user_id()` 归属,不伪造 `user_id`,不记录 token/API Key/Cookie/签名 URL。当前 AGC 实际使用的账号态 `/api/auth/*`、`/api/profile/*`、`/api/assets/*`、`/api/editor/*`、`/api/runtime/*` 与 External v1 业务路径均有显式 route spec;动态 ID 继续归一化,External v1 保留实际外部 path。OSS/签名下载、Provider、受控搜索、loopback、更新下载、公开 discovery/Skill/MCP 不进入普通 AGC 业务统计。记录最终复用既有 outbox、SpacetimeDB procedure、daily stat、event-id 幂等和后台 `GET /admin/api/tracking/events` 读取,不新增 schema、页面或独立事件体系。阶段 1~5 的定向实现和回归已提交,阶段 6 最终门禁通过:api-server tracking/资产/后台/outbox 43 个、鉴权回归 1 个、module-runtime 2 个、spacetime-client 1 个、spacetime-module 1 个定向测试全部通过,Rust 编译/格式、编码和 diff 检查通过;完整工作区测试按 CI 执行。`#226` 客户端标记、origin-safe redirect 和第三方边界契约无需回改。` - -## 7. 后续发布前事项 - -本 Issue 代码和本地确定性验证已完成;发布或联调时由主站环境补做一次真实链路 smoke:使用已部署的 AGC 客户端请求主站业务接口,确认 Header 被接收、`tracking_event.metadata_json.client` 可由后台原始查询读回。该 smoke 是环境验证,不改变本 PR 的设计或代码范围。 diff --git a/local-docs/【阶段验收】Issue226阶段0现状基线与契约冻结-2026-09-01.md b/local-docs/【阶段验收】Issue226阶段0现状基线与契约冻结-2026-09-01.md deleted file mode 100644 index 533a5f401..000000000 --- a/local-docs/【阶段验收】Issue226阶段0现状基线与契约冻结-2026-09-01.md +++ /dev/null @@ -1,184 +0,0 @@ -# Issue #226 阶段 0:现状基线与契约冻结验收记录 - -更新时间:2026-09-01
-关联 Issue:`#226 添加客户端特殊标识`;交接 Issue:`#225 添加客户端埋点统计`
-执行结论:阶段 0 本地基线与契约冻结通过;未修改生产代码、主站、OpenAPI 或数据库。
-远程 Issue 评论未直接提交,本文第 7 节提供可直接粘贴的评论草案。 - -## 1. 本阶段边界 - -阶段 0 只完成四件事: - -1. 记录当前工作树和源码基线。 -2. 核对 TS 统一请求出口,以及 Rust 主站/第三方 Client 创建边界。 -3. 冻结客户端 Header、主站 origin 和正负向范围。 -4. 冻结交给 `#225` 的 tracking metadata 约定。 - -本阶段明确不做: - -- 不改 `apps/ai-game-creator-shell` 生产实现。 -- 不改 `server-rs`、OpenAPI、SpacetimeDB schema、migration、bindings 或后台。 -- 不把现有 body 内 `generationInputs.source` 等字段升级成统一请求标记。 -- 不把所有 `reqwest::Client` 粗暴替换成主站 Client。 - -## 2. 当前仓库基线 - -| 项目 | 结果 | -|---|---| -| 工作目录 | `C:\projects\narrative\Genarrative` | -| 当前分支 | `feat/agc_call_header` | -| 当前 HEAD | `b14e42d9b` — `AGC支持安装扩展skill、MCP (#233)` | -| 阶段开始前工作树 | `git status --short` 为空 | -| 编码检查 | `npm run check:encoding` 通过,检查 5647 个文件 | -| Diff 空白检查 | `git diff --check` 通过 | -| 阶段 0 代码改动 | 无 | - -说明:本记录和相关方案/清单属于预期本地文档变更。当前仓库通过 `.git/info/exclude` 忽略整个 `local-docs/`,因此这些材料不会出现在 `git status` 或 `git diff` 中;生产代码工作树仍保持干净。 - -## 3. 源码边界核对 - -### 3.1 TS 统一出口已确认 - -AGC Web/TS 侧的统一网络入口是: - -- `apps/ai-game-creator-shell/src/services/clientHttp.ts:163` 的 `fetchClientHttp`。 -- `clientAuth.ts` 通过 `requestAuthJson` 调用该入口。 -- `clientApi.ts` 通过 `requestClientApi` / `requestClientApiBytes` 调用该入口。 - -因此阶段 1 只需要在 `fetchClientHttp` 统一注入 Header,即可覆盖认证、素材、账户和钱包等 TS 请求;更新下载 `clientHttp` 之外的独立路径仍需保持排除。 - -### 3.2 Rust 不是统一 Client - -Rust 侧已确认存在多个独立 `reqwest::Client::new()` / `Client::builder()` 创建点,不能依赖 TS 入口覆盖。主站相关的生产创建点主要分布在: - -- `assets.rs:377`、`:712`:开发者 Key、项目同步/恢复。 -- `commands.rs:3283`、`:3833`:账户素材库和素材下载前换签。 -- `agent/generation/canvas_generation.rs:2404`、`:2408`:External Editor 生成轮询/提交。 -- `project/asset_canvas/generation.rs:3729`、`:3733`:画布生成轮询/提交。 -- `project/resource_editor.rs:3101`:资源编辑生成/轮询公共入口。 -- `agent/direct_runtime.rs:2487`:Direct Runtime 只读恢复。 -- `agent/direct_tool_bridge.rs:1813`:受控去背景调用前的主站上下文准备。 - -公共路由与凭据映射抽象已确认存在于: - -- `project/external_editor_bindings.rs:40` 的 `ExternalEditorBindingAccess`。 -- `ExternalEditorBindingAccess::api_route(...)` 负责账号态 `/api/editor`、`/api/assets`、`/api/runtime` 与 API Key 态 `/api/external/v1` 的路径映射。 - -### 3.3 明确的第三方/非主站边界 - -以下调用保留原有 Client,不进入主站标记范围: - -- OSS/对象存储 multipart 上传。 -- `read-url` 返回的签名 URL 二进制下载。 -- LLM Provider `/responses`:`agent/codex_provider_proxy.rs:207`。 -- AGC 受控网页搜索:`agent/direct_tool_bridge.rs:2191`,带有独立 `no_proxy` 语义。 -- loopback 工具桥、本地 IPC、试玩页面请求。 -- 更新清单和更新包下载:`main.rs:120`。 - -完整的实际调用表、潜在 External v1 接口和排除项以 -[【扫描清单】AGC客户端主站调用与可标记点-2026-09-01.md](C:/projects/narrative/Genarrative/local-docs/【扫描清单】AGC客户端主站调用与可标记点-2026-09-01.md) -为准。 - -## 4. 冻结的客户端标记契约 - -### 4.1 Header - -```http -X-Genarrative-Client: agc -``` - -固定规则: - -- Header 名按 HTTP 规则大小写不敏感;客户端发送时固定使用 `X-Genarrative-Client`。 -- 值固定为小写 `agc`。 -- 统一出口/主站 Client 应覆盖调用方传入的同名 Header,避免业务层伪造其他值。 -- 不携带 token、API Key、用户 ID、项目 ID 或版本号。 -- 只用于来源审计和统计,不参与鉴权、权限、计费或账号归属判断。 - -### 4.2 Origin 与发送范围 - -主站判断依据固定为当前选定并已规范化的 `serverBaseUrl` / `apiBaseUrl`,比较完整 origin(scheme + host + port),而不是只看路径或字符串前缀。 - -应带标记的请求包括: - -- `/api/auth/*`、`/api/profile/*`、`/api/assets/*`、`/api/editor/*`、`/api/runtime/*`。 -- API Key 态 `/api/external/v1/*`。 -- 项目、画布、素材库、上传凭证、对象确认、资源登记、图片/图集/去背景/角色动画/视频/音频生成和异步轮询。 -- `/api/profile/api-keys`。 - -不得带标记的请求包括:OSS、签名下载、Provider、受控搜索、loopback、更新下载及任意外部网页请求。 - -### 4.3 保留的请求语义 - -后续实现必须保留每个调用点已有的: - -- `timeout`、`connect_timeout`、redirect policy 和 `no_proxy`。 -- Authorization/Bearer、External API Key、Idempotency-Key 和既有业务 Header。 -- Content-Type、请求体、响应状态处理、重试和异步轮询语义。 - -## 5. 冻结给 #225 的交接契约 - -主站接收同名 Header: - -```http -X-Genarrative-Client: agc -``` - -主站规则: - -- 精确值 `agc` 识别为 AGC。 -- Header 缺失、空值或未知值按“未标记”处理,不拒绝请求,不改变业务行为。 -- 不从 `generationInputs.source` 推导统一客户端标记。 -- 认证主体仍以真实账号态或 `ExternalApiPrincipal` 为准,不记录 token/API Key 明文。 - -第一阶段建议复用现有 `tracking_event.metadata_json`,固定 JSON key/value: - -```json -{ - "route": "/api/editor/images/generations", - "method": "POST", - "status": 202, - "operation": "generateExternalEditorImage", - "client": "agc" -} -``` - -交接要求: - -- 记录主站实际收到的 method/path,不只记录客户端账本中的 External v1 endpoint。 -- `/api/external/v1/*` 也要纳入 tracking 覆盖,不能只依赖内部 `/api/editor` 路由规格。 -- 缺失标记时不写 `client` 空字符串;未知值按未标记处理。 -- 成功、失败、重试和轮询请求沿用同一 Header;是否记录失败请求由 `#225` 的埋点目标决定,不要求 `#226` 回改标记设计。 - -## 6. 阶段 0 验收结果 - -| 验收项 | 结果 | 证据 | -|---|---|---| -| 当前工作树基线已确认 | 通过 | 本文第 2 节;阶段开始前 `git status --short` 为空 | -| TS 统一出口已定位 | 通过 | `clientHttp.ts:163`、`clientAuth.ts`、`clientApi.ts` | -| Rust 主站/第三方 Client 边界已定位 | 通过 | 本文第 3 节;完整清单见扫描文档 | -| 正向范围无未决歧义 | 通过 | 本文第 4.2 节 | -| 排除范围无未决歧义 | 通过 | 本文第 3.3、4.2 节 | -| Header、metadata、未知值行为已冻结 | 通过 | 本文第 4、5 节;实施方案第 3、8 节 | -| 未修改生产代码/主站/契约/数据库 | 通过 | `git diff --check`;本阶段仅新增/更新被本地忽略的 `local-docs` | - -阶段 0 可退出,允许进入阶段 1。阶段 1 的入口条件是直接按本文契约实现 TS `fetchClientHttp`,无需等待 `#225`。 - -## 7. Issue #226 实现范围评论草案 - -> 本次 #226 只做 AGC 客户端侧请求标记,不修改 #225 的主站 tracking、数据库或后台实现。 -> -> 冻结 Header:`X-Genarrative-Client: agc`。TS 在 `fetchClientHttp` 统一注入;Rust 增加主站专用 Client builder factory,通过 `default_headers` 注入,并只替换主站请求的 Client 创建点。账号态 `/api/editor`、`/api/assets`、`/api/runtime` 与 API Key 态 `/api/external/v1` 都发送同一标记。 -> -> 保留各调用点现有 timeout、connect timeout、redirect、no_proxy、Authorization/Bearer、API Key、Idempotency-Key、请求体和响应语义。OSS 上传、签名 URL 下载、LLM Provider、受控搜索、loopback、更新下载和外部网页请求不得带标记。 -> -> #225 接收约定:精确值 `agc` 识别为 AGC;缺失/空值/未知值按未标记处理且不拒绝请求。第一阶段复用 `tracking_event.metadata_json`,写入 `client: "agc"`,并按主站实际 method/path 记录;不要求 #226 为 #225 回改设计。 - -正式提交评论前,应将本文草案与 #226 当前讨论串核对一次;本阶段未直接执行远程 Issue 写操作。 - -## 8. 阶段 1 入口与风险提示 - -- 阶段 1 只改 TS `fetchClientHttp` 及其定向测试,先验证 Header 合并、同名 Header 覆盖和两条 transport 行为。 -- 阶段 2/3 再处理 Rust factory 与生产创建点;测试 fixture 中的裸 Client 不应被机械替换。 -- `default_headers` 只放稳定来源标记;Authorization、Idempotency-Key 和每请求动态 Header 继续保留在调用点。 -- 后续定向测试必须同时覆盖账号态和 API Key 态,以及主站正向和第三方负向请求。 diff --git a/local-docs/【阶段验收】Issue226阶段6交接与最终门禁-2026-09-02.md b/local-docs/【阶段验收】Issue226阶段6交接与最终门禁-2026-09-02.md deleted file mode 100644 index 1f879b1b8..000000000 --- a/local-docs/【阶段验收】Issue226阶段6交接与最终门禁-2026-09-02.md +++ /dev/null @@ -1,119 +0,0 @@ -# Issue #226 阶段 6:#225 交接与最终门禁验收记录 - -更新时间:`2026-09-02` -实施范围:`#226 添加客户端特殊标识` -交接范围:`#225 添加客户端埋点统计` -执行结论:阶段 6 通过;#226 客户端实现、跨 origin 重定向安全复审、边界回归、交接材料和最终门禁已收口。未修改 #225 主站代码、数据库、OpenAPI 或后台实现。 - -## 1. 阶段边界 - -本阶段只完成: - -1. 复核并固定交给 `#225` 的 Header、tracking metadata、认证主体和路径边界。 -2. 汇总阶段 1~5 的正向、负向和语义回归证据。 -3. 执行最终定向测试、编码/格式/空白检查和工作树检查。 -4. 更新实施方案与分阶段计划的完成状态。 - -本阶段明确不做: - -- 不修改 `server-rs`、主站 tracking middleware、route tracking 或后台。 -- 不修改 SpacetimeDB `tracking_event` schema、migration、bindings 或索引。 -- 不修改 External v1 OpenAPI、DTO 或请求响应语义。 -- 不执行真实发布环境线上写入或埋点验证。 - -## 2. 当前仓库状态 - -阶段 6 开始时: - -| 项目 | 结果 | -|---|---| -| 分支 | `feat/agc_call_header` | -| HEAD | `8059bbcb5`(已合并最新 `origin/master`) | -| 工作树 | 干净 | -| `origin/master` | `4a2f5be6c`,同步 AGC 更新下载域名门禁 | - -阶段 0~5 的提交保持不变,本阶段只补充交接/验收文档。 - -## 3. 给 #225 的固定交接契约 - -### 3.1 客户端请求标记 - -```http -X-Genarrative-Client: agc -``` - -- Header 名大小写不敏感;值精确为小写 `agc` 时识别为 AGC。 -- 缺失、空值或未知值按未标记处理,不拒绝请求,也不改变业务行为。 -- Header 只用于来源审计和统计,不参与鉴权、权限、计费或账号归属。 -- 不记录 access token、API Key 明文、签名 URL、项目绝对路径、用户隐私或客户端版本号。 - -### 3.2 tracking metadata - -第一阶段复用现有 tracking `metadata_json`,固定 JSON key/value: - -```json -{ - "route": "/api/editor/images/generations", - "method": "POST", - "status": 202, - "operation": "generateExternalEditorImage", - "client": "agc" -} -``` - -约定: - -- `client` key 固定;AGC 值固定为 `agc`。 -- Header 缺失时不写 `client` 空字符串。 -- 记录主站实际收到的 method/path,不只记录客户端账本中的 External v1 endpoint。 -- `/api/external/v1/*` 不能因为缺少完整 route spec 而漏记。 - -### 3.3 认证主体 - -- 登录账号态:沿用主站现有 access token 解析出的用户维度。 -- External API Key 态:使用 `ExternalApiPrincipal.owner_user_id`,必要时保留 `key_id` 维度。 -- Header 与认证主体独立处理;不能用 Header 代替认证,也不能从 `generationInputs.source` 推导客户端标记。 - -### 3.4 路由和边界 - -应识别的主站请求: - -- 账号态 `/api/auth/*`、`/api/profile/*`、`/api/assets/*`、`/api/editor/*`、`/api/runtime/*`。 -- External API Key 态 `/api/external/v1/*`。 -- 生成提交、异步轮询、项目/素材/资源登记和 `/api/assets/read-url` 换签。 - -明确不应识别为 AGC 主站业务请求: - -- OSS multipart 上传。 -- 签名 URL/OSS 媒体下载。 -- LLM/Codex Provider。 -- AGC 受控搜索。 -- loopback 工具桥。 -- 更新清单、更新包下载和任意外部网页请求。 - -## 4. 最终门禁结果 - -| 验收项 | 命令/证据 | 结果 | -|---|---|---| -| TS 统一出口正向测试 | `npm exec vitest run apps/ai-game-creator-shell/tests/clientHttp.test.ts` | 通过,14 tests passed | -| AGC TS 类型和配置检查 | `npm run ai-game-creator-shell:typecheck` | 通过;skill-pack/config 检查通过 | -| Rust 主站 Client factory、请求终结器与 origin-safe redirect policy | `cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml http_client -- --nocapture` | 通过,8 tests passed;同源跟随、跨 origin 阻断、链式重定向、显式 `Policy::none()` 和请求级同名 Header 覆盖均覆盖 | -| 第三方请求负向矩阵 | `cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml omits_agc_marker -- --nocapture` | 通过,4 tests passed | -| loopback 认证/请求边界 | `cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml loopback_proxy_strips_false_codex_limit_headers_and_requires_bearer -- --nocapture` | 通过,1 test passed | -| 账号态主站请求捕获 | `cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml background_agent_runtime_can_generate_platform_art_asset -- --nocapture` | 通过,1 test passed | -| External Key 态和自定义 origin | `cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml sync_canvas_project_assets_with_developer_key_uses_external_route_and_marker -- --nocapture` | 通过,1 test passed | -| `read-url` 与签名下载边界 | `cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml sync_canvas_project_assets_downloads_external_resources -- --nocapture` | 通过,1 test passed | -| 结果未知/幂等语义 | `cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml generation_submit_response_loss_is_not_retried -- --nocapture` | 通过,1 test passed | -| Rust 格式 | `cargo fmt --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml -- --check` | 通过 | -| 中文编码 | `npm run check:encoding` | 通过,5653 个文件 | -| Diff 空白 | `git diff --check` | 通过 | - -Rust 测试输出包含仓库既有的 unused/dead-code warning;本任务相关测试均无 error 或 failure。 - -## 5. 发布环境限制与后续交接 - -本阶段没有执行真实发布环境线上 smoke。账号态和 External Key 态的路径、Header、认证/幂等语义来自本地 mock/custom `apiBaseUrl` fixture;OSS/签名 URL/Provider/搜索/loopback/更新下载边界来自本地请求捕获;跨 origin 重定向来自双 listener 和链式重定向 fixture。发布前或 `#225` 联调时,应由主站侧补做真实环境 Header 接收、tracking metadata 写入和后台查询验证,不要求客户端回改本次设计。 - -## 6. 可直接粘贴到 #225 的评论 - -> `#226` 客户端侧已完成并冻结交接契约:AGC 主站业务请求统一发送 `X-Genarrative-Client: agc`。账号态 `/api/auth/*`、`/api/profile/*`、`/api/assets/*`、`/api/editor/*`、`/api/runtime/*` 与 External API Key 态 `/api/external/v1/*` 均覆盖;OSS/签名下载、Provider、受控搜索、loopback、更新下载和外部网页请求不带该标记。主站可按实际 method/path 读取 Header,并在现有 tracking `metadata_json` 中写入 `client: "agc"`;Header 缺失/空值/未知值按未标记处理且不拒绝请求。登录态按真实用户维度记录,External Key 态按 `owner_user_id`(必要时 `key_id`)记录,不记录 token/API Key 明文或签名 URL。客户端正向、负向、账号态、External Key 态和幂等回归均已通过,`#225` 不需要让 `#226` 回改设计。` diff --git a/package-lock.json b/package-lock.json index 47cf1217e..3b119a3f6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -110,6 +110,7 @@ "focus-trap-react": "^12.0.3", "lexical": "^0.47.0", "lucide-react": "^0.546.0", + "phaser": "^4.2.1", "react": "^19.0.0", "react-arborist": "^3.16.0", "react-colorful": "^5.8.0", @@ -11570,7 +11571,6 @@ "version": "5.0.4", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", - "dev": true, "license": "MIT" }, "node_modules/events-universal": { @@ -17706,6 +17706,15 @@ "node": "*" } }, + "node_modules/phaser": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/phaser/-/phaser-4.2.1.tgz", + "integrity": "sha512-WUNwCPJpdjvZiuT6SgCfYVW8Qw/3j0jJ4ws7P2QkhFLFu74sbGuyHJcbFueGkY/AYO4Pi47bNQXn1OCJeLX//w==", + "license": "MIT", + "dependencies": { + "eventemitter3": "^5.0.4" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -26309,6 +26318,7 @@ "focus-trap-react": "^12.0.3", "lexical": "^0.47.0", "lucide-react": "^0.546.0", + "phaser": "^4.2.1", "react": "^19.0.0", "react-arborist": "^3.16.0", "react-colorful": "^5.8.0", @@ -30478,8 +30488,7 @@ "eventemitter3": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", - "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", - "dev": true + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==" }, "events-universal": { "version": "1.0.1", @@ -34500,6 +34509,14 @@ "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==", "dev": true }, + "phaser": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/phaser/-/phaser-4.2.1.tgz", + "integrity": "sha512-WUNwCPJpdjvZiuT6SgCfYVW8Qw/3j0jJ4ws7P2QkhFLFu74sbGuyHJcbFueGkY/AYO4Pi47bNQXn1OCJeLX//w==", + "requires": { + "eventemitter3": "^5.0.4" + } + }, "picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", diff --git a/package.json b/package.json index 80aa34e94..e7ac811f0 100644 --- a/package.json +++ b/package.json @@ -166,8 +166,6 @@ "agc:test": "npm --prefix apps/ai-game-creator-shell run agent-runtime:supervisor-autonomous-playable-lane-defense-deterministic-e2e --", "agc:test:chat": "npm --prefix apps/ai-game-creator-shell run test:chat --", "agc:test:chat:manual": "npm --prefix apps/ai-game-creator-shell run test:chat:manual --", - "agc:test:plan": "npm --prefix apps/ai-game-creator-shell run test:plan --", - "agc:test:plan:manual": "npm --prefix apps/ai-game-creator-shell run test:plan:manual --", "ai-game-creator-shell:dev": "npm --prefix apps/ai-game-creator-shell run dev", "ai-game-creator-shell:dev-server": "npm --prefix apps/ai-game-creator-shell run dev-server", "ai-game-creator-shell:build": "npm --prefix apps/ai-game-creator-shell run build --", diff --git a/packages/image-canvas-core/src/model.ts b/packages/image-canvas-core/src/model.ts index 3deeac6f8..b6b6c3430 100644 --- a/packages/image-canvas-core/src/model.ts +++ b/packages/image-canvas-core/src/model.ts @@ -4,6 +4,8 @@ export const CANVAS_WORLD_SIZE = 12000; export const CANVAS_WORLD_ORIGIN = CANVAS_WORLD_SIZE / 2; export const MIN_SCALE = 0.025; export const MAX_SCALE = 3.2; +export const CANVAS_ZOOM_IN_FACTOR = 1.16; +export const CANVAS_ZOOM_OUT_FACTOR = 0.86; export const CANVAS_DISPLAY_SCALE_BASE = 0.5; export const DEFAULT_CANVAS_SIZE = { width: 900, height: 640 }; export const FIT_VIEW_PADDING = 10; diff --git a/packages/image-canvas-react/src/CanvasWorld.tsx b/packages/image-canvas-react/src/CanvasWorld.tsx index 3437360ab..bc089d1ce 100644 --- a/packages/image-canvas-react/src/CanvasWorld.tsx +++ b/packages/image-canvas-react/src/CanvasWorld.tsx @@ -2,7 +2,13 @@ import { CANVAS_WORLD_SIZE, type CanvasViewport, } from '@genarrative/image-canvas-core'; -import type { HTMLAttributes, ReactNode } from 'react'; +import type { CSSProperties, HTMLAttributes, ReactNode } from 'react'; + +import { + CANVAS_RENDER_SUPERSAMPLE, + canvasViewportToWorldTransform, + getSafeViewportScale, +} from './canvasWorldRendering'; export function CanvasWorld({ children, @@ -16,18 +22,33 @@ export function CanvasWorld({ viewport: CanvasViewport; worldSize?: number; }) { + const viewportScale = getSafeViewportScale(viewport); + return (
- {children} +
+ {children} +
); } diff --git a/packages/image-canvas-react/src/ZoomControls.tsx b/packages/image-canvas-react/src/ZoomControls.tsx index e9f853c26..53e88ccec 100644 --- a/packages/image-canvas-react/src/ZoomControls.tsx +++ b/packages/image-canvas-react/src/ZoomControls.tsx @@ -1,4 +1,6 @@ import { + CANVAS_ZOOM_IN_FACTOR, + CANVAS_ZOOM_OUT_FACTOR, canvasDisplayScaleToViewportScale, type CanvasViewport, formatCanvasDisplayScalePercent, @@ -27,8 +29,8 @@ export function ZoomControls({ const actions: CanvasZoomActions = { displayPercent: formatCanvasDisplayScalePercent(viewport.scale), fit: onFit, - zoomIn: () => onScaleFromCenter(viewport.scale * 1.16), - zoomOut: () => onScaleFromCenter(viewport.scale * 0.86), + zoomIn: () => onScaleFromCenter(viewport.scale * CANVAS_ZOOM_IN_FACTOR), + zoomOut: () => onScaleFromCenter(viewport.scale * CANVAS_ZOOM_OUT_FACTOR), zoomToDisplayScale: (displayScale) => onScaleFromCenter(canvasDisplayScaleToViewportScale(displayScale)), }; diff --git a/packages/image-canvas-react/src/canvasWorldRendering.ts b/packages/image-canvas-react/src/canvasWorldRendering.ts new file mode 100644 index 000000000..c298c716c --- /dev/null +++ b/packages/image-canvas-react/src/canvasWorldRendering.ts @@ -0,0 +1,16 @@ +import type { CanvasViewport } from '@genarrative/image-canvas-core'; + +export const CANVAS_RENDER_SUPERSAMPLE = 2; + +/** 将 viewport scale 规范化为可用于渲染和反向缩放的正有限值。 */ +export function getSafeViewportScale(viewport: CanvasViewport) { + return Number.isFinite(viewport.scale) && viewport.scale > 0 + ? viewport.scale + : 1; +} + +/** 将逻辑 viewport 转换为 world 的渲染 transform。 */ +export function canvasViewportToWorldTransform(viewport: CanvasViewport) { + const scale = getSafeViewportScale(viewport); + return `translate(${viewport.x}px, ${viewport.y}px) scale(${scale / CANVAS_RENDER_SUPERSAMPLE})`; +} diff --git a/packages/image-canvas-react/src/index.ts b/packages/image-canvas-react/src/index.ts index bb4023c2c..a1ea0ab9d 100644 --- a/packages/image-canvas-react/src/index.ts +++ b/packages/image-canvas-react/src/index.ts @@ -2,6 +2,10 @@ export * from './CanvasChrome'; export * from './CanvasPortal'; export * from './CanvasViewport'; export * from './CanvasWorld'; +export { + canvasViewportToWorldTransform, + getSafeViewportScale, +} from './canvasWorldRendering'; export * from './LayerRenderer'; export * from './Minimap'; export * from './SelectionOverlay'; diff --git a/packages/image-canvas-react/src/sharedCanvasReact.test.tsx b/packages/image-canvas-react/src/sharedCanvasReact.test.tsx index 81304b756..56ffd5145 100644 --- a/packages/image-canvas-react/src/sharedCanvasReact.test.tsx +++ b/packages/image-canvas-react/src/sharedCanvasReact.test.tsx @@ -1,10 +1,15 @@ /* @vitest-environment jsdom */ import type { CanvasLayer } from '@genarrative/image-canvas-core'; -import { act, renderHook } from '@testing-library/react'; +import { act, render, renderHook, screen } from '@testing-library/react'; import { createRef } from 'react'; import { afterEach, describe, expect, it, vi } from 'vitest'; +import { CanvasWorld } from './CanvasWorld'; +import { + CANVAS_RENDER_SUPERSAMPLE, + canvasViewportToWorldTransform, +} from './canvasWorldRendering'; import { useImageCanvasViewportControls } from './useImageCanvasViewportControls'; function layer(id: string, x: number): CanvasLayer { @@ -52,6 +57,26 @@ afterEach(() => { }); describe('shared canvas React lifecycle', () => { + it('renders a supersampled world while preserving the public viewport scale', () => { + render( + + content + , + ); + + const world = screen.getByText('content').parentElement?.parentElement; + const content = screen.getByText('content').parentElement; + expect(world?.style.width).toBe('12000px'); + expect(world?.style.height).toBe('12000px'); + expect(world?.style.transform).toBe( + canvasViewportToWorldTransform({ x: 12, y: 24, scale: 2 }), + ); + expect( + world?.style.getPropertyValue('--genarrative-image-canvas-inverse-scale'), + ).toBe('0.5'); + expect(content?.style.zoom).toBe(String(CANVAS_RENDER_SUPERSAMPLE)); + }); + it('removes every native listener across repeated mounts', () => { const viewport = createViewport(); const addListener = vi.spyOn(viewport, 'addEventListener'); diff --git a/packages/image-canvas-react/src/useImageCanvasStageInteractions.ts b/packages/image-canvas-react/src/useImageCanvasStageInteractions.ts index d5b3411cc..ce106893e 100644 --- a/packages/image-canvas-react/src/useImageCanvasStageInteractions.ts +++ b/packages/image-canvas-react/src/useImageCanvasStageInteractions.ts @@ -342,11 +342,12 @@ export function useImageCanvasStageInteractions({ event.preventDefault(); return; } - const target = event.target as HTMLElement; + const target = event.target; if ( effectiveTool === 'select' && (event.target === event.currentTarget || - target.classList.contains('genarrative-image-canvas__world')) + (target instanceof Element && + target.closest('.genarrative-image-canvas__world') !== null)) ) { event.preventDefault(); const rect = canvasViewportRef.current?.getBoundingClientRect(); diff --git a/packages/shared/src/components/PlatformProfileWalletLedgerModal/index.test.tsx b/packages/shared/src/components/PlatformProfileWalletLedgerModal/index.test.tsx index a2d15aea6..2c3369106 100644 --- a/packages/shared/src/components/PlatformProfileWalletLedgerModal/index.test.tsx +++ b/packages/shared/src/components/PlatformProfileWalletLedgerModal/index.test.tsx @@ -131,4 +131,45 @@ test('builds wallet ledger presentation with stable source fallbacks', () => { }, ], }); + + for (const reason of [ + '生成美术素材', + '编辑美术素材', + '提取美术素材', + '生成角色动画', + '生成视频素材', + '生成音效素材', + '生成背景音乐', + ]) { + expect( + buildWalletLedgerPresentation( + { + entries: [ + buildLedgerEntry({ + amountDelta: -12, + sourceType: 'asset_operation_consume', + reason, + }), + ], + }, + 12, + ).entries[0]?.sourceLabel, + `unexpected source label for ${reason}`, + ).toBe(reason); + } + + expect( + buildWalletLedgerPresentation( + { + entries: [ + buildLedgerEntry({ + amountDelta: -12, + sourceType: 'asset_operation_consume', + reason: ' ', + }), + ], + }, + 12, + ).entries[0]?.sourceLabel, + ).toBe('资产操作消耗'); }); diff --git a/packages/shared/src/components/PlatformProfileWalletLedgerModal/model.ts b/packages/shared/src/components/PlatformProfileWalletLedgerModal/model.ts index 40bd70e41..a18adce79 100644 --- a/packages/shared/src/components/PlatformProfileWalletLedgerModal/model.ts +++ b/packages/shared/src/components/PlatformProfileWalletLedgerModal/model.ts @@ -103,7 +103,8 @@ export function buildWalletLedgerPresentation( createdAtLabel: formatWalletLedgerDate(entry.createdAt), id: entry.id, isIncome: entry.amountDelta > 0, - sourceLabel: getWalletLedgerSourceLabel(entry.sourceType), + sourceLabel: + entry.reason?.trim() || getWalletLedgerSourceLabel(entry.sourceType), })), }; } diff --git a/packages/shared/src/contracts/runtime.ts b/packages/shared/src/contracts/runtime.ts index 1fa899e4d..eee7978ed 100644 --- a/packages/shared/src/contracts/runtime.ts +++ b/packages/shared/src/contracts/runtime.ts @@ -84,6 +84,7 @@ export type ProfileWalletLedgerEntry = { | 'puzzle_author_incentive_claim' | 'daily_task_reward'; createdAt: string; + reason?: string; }; export type ProfileWalletLedgerResponse = { diff --git a/scripts/dev-windows-process.mjs b/scripts/dev-windows-process.mjs new file mode 100644 index 000000000..ec53bc774 --- /dev/null +++ b/scripts/dev-windows-process.mjs @@ -0,0 +1,242 @@ +import { spawnSync } from 'node:child_process'; + +// Windows 开发栈清理工具。 +// +// 背景:Windows 下所有长驻服务都经 `cmd.exe /d /s /c` 包装层启动(Node 的 +// `shell: true`),而 Ctrl+C 会先让包装层退出。一旦中间层退出,按父进程链 +// 遍历就再也到不了更深的服务进程,`taskkill /T` 也会因为 PID 已消失而失效。 +// 因此这里同时提供两种定位方式: +// 1. `selectProcessTreeIds`:按记录下来的根 PID 做父子链遍历(能处理根已退出、 +// 但中间层仍留在快照里的情况)。 +// 2. `selectWorktreeOwnedProcessIds`:按身份匹配(api-server.exe 的绝对路径、 +// SpacetimeDB 的 --data-dir),不依赖任何仍然存活的包装层。 +// 两者结合后,即使 `npm run agc` 的 Ctrl+C 只杀掉了 shell 包装层,也不会留下 +// 属于本工作树的后端进程。 + +function normalizeWindowsPath(value) { + const raw = String(value ?? '') + .trim() + .replace(/^\\\\\?\\/u, ''); + if (!raw) { + return ''; + } + return raw.replace(/[\\/]+$/u, '').toLowerCase(); +} + +function parseWindowsProcessSnapshot(rawText) { + const raw = String(rawText ?? '').trim(); + if (!raw) { + return []; + } + + let parsed; + try { + parsed = JSON.parse(raw); + } catch { + return []; + } + if (!parsed) { + return []; + } + return Array.isArray(parsed) ? parsed : [parsed]; +} + +// 一次退出流程里会多次清理(每个服务的进程树 + 最后的身份兜底清扫), +// PowerShell 全量进程快照约 1 秒,短时间内复用同一份快照即可,避免 Ctrl+C +// 后清理被拖成十几秒。只在默认实现下缓存,注入实现(测试)始终重新读取。 +const PROCESS_SNAPSHOT_TTL_MS = 1000; +let cachedProcessSnapshot = null; +let cachedProcessSnapshotAt = 0; + +function readWindowsProcessSnapshot({ + spawnSyncImpl = spawnSync, + env = process.env, + now = Date.now, + ttlMs = PROCESS_SNAPSHOT_TTL_MS, +} = {}) { + const cacheable = spawnSyncImpl === spawnSync && env === process.env; + if ( + cacheable && + cachedProcessSnapshot && + now() - cachedProcessSnapshotAt < ttlMs + ) { + return cachedProcessSnapshot; + } + + const command = [ + '$ErrorActionPreference = "SilentlyContinue"', + 'Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,Name,ExecutablePath,CommandLine | ConvertTo-Json -Compress', + ].join('\n'); + const result = spawnSyncImpl( + 'powershell.exe', + ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', command], + { + encoding: 'utf8', + env, + maxBuffer: 32 * 1024 * 1024, + }, + ); + if (result?.error || result?.status !== 0) { + return []; + } + + const snapshot = parseWindowsProcessSnapshot(result.stdout); + if (cacheable) { + cachedProcessSnapshot = snapshot; + cachedProcessSnapshotAt = now(); + } + return snapshot; +} + +function selectProcessTreeIds(processes, rootPid) { + if (!Number.isInteger(rootPid)) { + return []; + } + + const childrenByParent = new Map(); + for (const processEntry of processes ?? []) { + const parentId = Number(processEntry?.ParentProcessId); + const processId = Number(processEntry?.ProcessId); + if (!Number.isInteger(parentId) || !Number.isInteger(processId)) { + continue; + } + if (!childrenByParent.has(parentId)) { + childrenByParent.set(parentId, []); + } + childrenByParent.get(parentId).push(processId); + } + + const collected = new Set(); + const queue = [rootPid]; + while (queue.length > 0) { + const current = queue.shift(); + if (collected.has(current)) { + continue; + } + collected.add(current); + for (const childId of childrenByParent.get(current) ?? []) { + queue.push(childId); + } + } + return [...collected]; +} + +function selectWorktreeOwnedProcessIds( + processes, + { apiServerExePath = '', spacetimeDataDir = '', selfPid = process.pid } = {}, +) { + const expectedExePath = normalizeWindowsPath(apiServerExePath); + const expectedDataDir = normalizeWindowsPath(spacetimeDataDir); + if (!expectedExePath && !expectedDataDir) { + return []; + } + + const matched = []; + for (const processEntry of processes ?? []) { + const processId = Number(processEntry?.ProcessId); + if (!Number.isInteger(processId) || processId === selfPid) { + continue; + } + + const executablePath = normalizeWindowsPath(processEntry?.ExecutablePath); + if (expectedExePath && executablePath === expectedExePath) { + matched.push(processId); + continue; + } + + if (!expectedDataDir) { + continue; + } + const name = String(processEntry?.Name ?? '').toLowerCase(); + if (!name.startsWith('spacetime')) { + continue; + } + if ( + normalizeWindowsPath(processEntry?.CommandLine).includes(expectedDataDir) + ) { + matched.push(processId); + } + } + return matched; +} + +function stopWindowsProcessIds( + processIds, + { spawnSyncImpl = spawnSync, env = process.env, waitForExitMs = 0 } = {}, +) { + const uniqueIds = [ + ...new Set((processIds ?? []).filter((value) => Number.isInteger(value))), + ]; + if (uniqueIds.length === 0) { + return []; + } + + const command = [ + '$ErrorActionPreference = "SilentlyContinue"', + '$ids = $env:GENARRATIVE_STOP_PIDS -split ","', + 'foreach ($id in $ids) {', + ' if ($id) { Stop-Process -Id ([int]$id) -Force -ErrorAction SilentlyContinue }', + '}', + ...(waitForExitMs > 0 + ? [ + // 启动前清理旧 api-server 时必须等它真正退出,否则 Windows 仍占用 + // target\debug\api-server.exe,cargo 会报 failed to remove file。 + `Wait-Process -Id $ids -Timeout ${Math.ceil(waitForExitMs / 1000)} -ErrorAction SilentlyContinue`, + ] + : []), + ].join('\n'); + spawnSyncImpl( + 'powershell.exe', + ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', command], + { + env: { ...env, GENARRATIVE_STOP_PIDS: uniqueIds.join(',') }, + stdio: 'ignore', + }, + ); + return uniqueIds; +} + +function stopWindowsProcessTree( + rootPid, + { snapshot = null, spawnSyncImpl = spawnSync, env = process.env } = {}, +) { + if (!Number.isInteger(rootPid)) { + return []; + } + + const processes = + snapshot ?? readWindowsProcessSnapshot({ spawnSyncImpl, env }); + return stopWindowsProcessIds(selectProcessTreeIds(processes, rootPid), { + spawnSyncImpl, + env, + }); +} + +function stopWindowsWorktreeProcesses({ + apiServerExePath = '', + spacetimeDataDir = '', + snapshot = null, + spawnSyncImpl = spawnSync, + env = process.env, +} = {}) { + const processes = + snapshot ?? readWindowsProcessSnapshot({ spawnSyncImpl, env }); + return stopWindowsProcessIds( + selectWorktreeOwnedProcessIds(processes, { + apiServerExePath, + spacetimeDataDir, + }), + { spawnSyncImpl, env }, + ); +} + +export { + normalizeWindowsPath, + parseWindowsProcessSnapshot, + readWindowsProcessSnapshot, + selectProcessTreeIds, + selectWorktreeOwnedProcessIds, + stopWindowsProcessIds, + stopWindowsProcessTree, + stopWindowsWorktreeProcesses, +}; diff --git a/scripts/dev-windows-process.test.ts b/scripts/dev-windows-process.test.ts new file mode 100644 index 000000000..57a5e11ca --- /dev/null +++ b/scripts/dev-windows-process.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, test } from 'vitest'; + +import { + normalizeWindowsPath, + parseWindowsProcessSnapshot, + selectProcessTreeIds, + selectWorktreeOwnedProcessIds, +} from './dev-windows-process.mjs'; + +function processEntry(overrides) { + return { + ProcessId: 1, + ParentProcessId: 0, + Name: 'node.exe', + ExecutablePath: null, + CommandLine: null, + ...overrides, + }; +} + +describe('Windows 进程快照解析', () => { + test('单个进程对象也归一为数组', () => { + const single = parseWindowsProcessSnapshot( + JSON.stringify({ ProcessId: 42, ParentProcessId: 1 }), + ); + expect(single).toHaveLength(1); + expect(single[0].ProcessId).toBe(42); + }); + + test('空输出和非法 JSON 返回空数组', () => { + expect(parseWindowsProcessSnapshot('')).toEqual([]); + expect(parseWindowsProcessSnapshot('null')).toEqual([]); + expect(parseWindowsProcessSnapshot('not json')).toEqual([]); + }); + + test('路径归一化去掉 \\\\?\\ 前缀、尾部分隔符并忽略大小写', () => { + expect( + normalizeWindowsPath('\\\\?\\C:\\Repo\\target\\debug\\api-server.exe'), + ).toBe('c:\\repo\\target\\debug\\api-server.exe'); + expect(normalizeWindowsPath('C:\\Repo\\data\\')).toBe('c:\\repo\\data'); + expect(normalizeWindowsPath(null)).toBe(''); + }); +}); + +describe('按根 PID 遍历进程树', () => { + test('中间层进程已从快照消失时无法再到达更深的后代', () => { + // cmd(10) -> wrapper(20) -> api-server(30)。Ctrl+C 先杀掉 10 和 20, + // 快照里只剩 30(ParentProcessId 仍指向已消失的 20),父链断开后 + // 按根 PID 遍历只能拿到根自己,这正是后端被漏杀的原因。 + const snapshot = [ + processEntry({ + ProcessId: 30, + ParentProcessId: 20, + Name: 'api-server.exe', + }), + ]; + expect(selectProcessTreeIds(snapshot, 10)).toEqual([10]); + }); + + test('根已退出但中间层仍在快照里时仍可收全后代', () => { + const snapshot = [ + processEntry({ ProcessId: 20, ParentProcessId: 10, Name: 'cargo.exe' }), + processEntry({ + ProcessId: 30, + ParentProcessId: 20, + Name: 'api-server.exe', + }), + ]; + expect(selectProcessTreeIds(snapshot, 10).sort((a, b) => a - b)).toEqual([ + 10, 20, 30, + ]); + }); + + test('父链完整时能收全后代', () => { + const snapshot = [ + processEntry({ ProcessId: 10, ParentProcessId: 1, Name: 'cmd.exe' }), + processEntry({ ProcessId: 20, ParentProcessId: 10, Name: 'cargo.exe' }), + processEntry({ + ProcessId: 30, + ParentProcessId: 20, + Name: 'api-server.exe', + }), + processEntry({ ProcessId: 40, ParentProcessId: 1, Name: 'other.exe' }), + ]; + expect(selectProcessTreeIds(snapshot, 10).sort((a, b) => a - b)).toEqual([ + 10, 20, 30, + ]); + }); +}); + +describe('按身份匹配本工作树后端进程', () => { + const apiServerExePath = 'C:\\Repo\\server-rs\\target\\debug\\api-server.exe'; + const spacetimeDataDir = + 'C:\\Repo\\server-rs\\.spacetimedb\\ai-game-creator\\data'; + + test('只收本工作树的 api-server.exe 与同一 data-dir 的 SpacetimeDB', () => { + const snapshot = [ + processEntry({ + ProcessId: 100, + Name: 'api-server.exe', + ExecutablePath: apiServerExePath, + CommandLine: '"server-rs\\target\\debug\\api-server.exe"', + }), + processEntry({ + ProcessId: 101, + Name: 'api-server.exe', + ExecutablePath: 'C:\\Other\\server-rs\\target\\debug\\api-server.exe', + }), + processEntry({ + ProcessId: 102, + Name: 'spacetimedb-standalone.exe', + ExecutablePath: + 'C:\\Users\\me\\SpacetimeDB\\spacetimedb-standalone.exe', + CommandLine: `spacetimedb-standalone.exe start --data-dir ${spacetimeDataDir} --listen-addr 127.0.0.1:3101`, + }), + processEntry({ + ProcessId: 103, + Name: 'spacetimedb-standalone.exe', + CommandLine: + 'spacetimedb-standalone.exe start --data-dir C:\\Other\\data --listen-addr 127.0.0.1:3101', + }), + processEntry({ + ProcessId: 104, + Name: 'node.exe', + CommandLine: `node dev.mjs --spacetime-data-dir ${spacetimeDataDir}`, + }), + ]; + + expect( + selectWorktreeOwnedProcessIds(snapshot, { + apiServerExePath, + spacetimeDataDir, + }).sort((a, b) => a - b), + ).toEqual([100, 102]); + }); + + test('只给 api-server 路径时不会误伤 SpacetimeDB', () => { + const snapshot = [ + processEntry({ + ProcessId: 100, + Name: 'api-server.exe', + ExecutablePath: apiServerExePath, + }), + processEntry({ + ProcessId: 102, + Name: 'spacetimedb-standalone.exe', + CommandLine: `--data-dir ${spacetimeDataDir}`, + }), + ]; + + expect( + selectWorktreeOwnedProcessIds(snapshot, { apiServerExePath }), + ).toEqual([100]); + }); + + test('排除自身进程且路径大小写不敏感', () => { + const snapshot = [ + processEntry({ + ProcessId: 200, + Name: 'api-server.exe', + ExecutablePath: apiServerExePath.toUpperCase(), + }), + ]; + + expect( + selectWorktreeOwnedProcessIds(snapshot, { + apiServerExePath, + selfPid: 200, + }), + ).toEqual([]); + expect( + selectWorktreeOwnedProcessIds(snapshot, { apiServerExePath }), + ).toEqual([200]); + }); + + test('没有可匹配身份时返回空数组', () => { + const snapshot = [processEntry({ ProcessId: 100, Name: 'api-server.exe' })]; + expect(selectWorktreeOwnedProcessIds(snapshot, {})).toEqual([]); + }); +}); diff --git a/scripts/dev.mjs b/scripts/dev.mjs index ef5fc75ea..237a31f31 100644 --- a/scripts/dev.mjs +++ b/scripts/dev.mjs @@ -36,6 +36,13 @@ import { resolveApiServerLogFile, resolveClientHost, } from './dev-utils.mjs'; +import { + readWindowsProcessSnapshot, + selectWorktreeOwnedProcessIds, + stopWindowsProcessIds, + stopWindowsProcessTree as stopWindowsProcessTreeById, + stopWindowsWorktreeProcesses, +} from './dev-windows-process.mjs'; // Resolve the workspace from this script's location, not the caller's cwd. // AGC starts this scheduler through `npm --prefix` from its own package; using @@ -917,7 +924,17 @@ class DevService { } async function stopProcess(child, label) { - if (!child || child.exitCode != null || child.signalCode != null) { + if (!child) { + return; + } + + // Windows 下直接子进程是 `cmd.exe /d /s /c` 包装层,Ctrl+C 会先让它退出。 + // 包装层退出不代表 cargo / api-server / spacetime 已经退出,所以这里不能像 + // 以前那样直接 return,必须继续按记录下来的根 PID 清理后代。 + if (child.exitCode != null || child.signalCode != null) { + if (process.platform === 'win32' && Number.isInteger(child.pid)) { + stopWindowsProcessTree(child.pid, label); + } return; } @@ -938,7 +955,7 @@ async function stopProcess(child, label) { try { if (process.platform === 'win32') { - stopWindowsProcessTree(child.pid); + stopWindowsProcessTree(child.pid, label); } else { child.kill('SIGTERM'); } @@ -1135,51 +1152,46 @@ async function stopStaleLocalExternalGenerationWorkers({ return stopped; } -function stopWindowsProcessTree(pid) { - if (!pid) { - return; +function resolveWindowsApiServerExePath(repoRootPath = repoRoot) { + return resolve(repoRootPath, 'server-rs/target/debug/api-server.exe'); +} + +function stopWindowsProcessTree(pid, label = '') { + if (!Number.isInteger(pid)) { + return []; } - spawnSync( - 'powershell.exe', - [ - '-NoProfile', - '-ExecutionPolicy', - 'Bypass', - '-Command', - [ - '$ErrorActionPreference = "SilentlyContinue"', - '$root = [int]$env:GENARRATIVE_STOP_PID', - '$all = Get-CimInstance Win32_Process', - '$childrenByParent = @{}', - 'foreach ($process in $all) {', - ' $parent = [int]$process.ParentProcessId', - ' if (-not $childrenByParent.ContainsKey($parent)) { $childrenByParent[$parent] = @() }', - ' $childrenByParent[$parent] += [int]$process.ProcessId', - '}', - '$toStop = New-Object System.Collections.Generic.List[int]', - '$queue = New-Object System.Collections.Generic.Queue[int]', - '$queue.Enqueue($root)', - 'while ($queue.Count -gt 0) {', - ' $current = $queue.Dequeue()', - ' $toStop.Add($current)', - ' if ($childrenByParent.ContainsKey($current)) {', - ' foreach ($child in $childrenByParent[$current]) { $queue.Enqueue($child) }', - ' }', - '}', - 'foreach ($id in ($toStop | Select-Object -Unique | Sort-Object -Descending)) {', - ' Stop-Process -Id $id -Force -ErrorAction SilentlyContinue', - '}', - ].join('\n'), - ], - { - env: { - ...process.env, - GENARRATIVE_STOP_PID: String(pid), - }, - stdio: 'ignore', - }, - ); + const stopped = stopWindowsProcessTreeById(pid); + if (stopped.length > 1) { + console.log( + `[dev${label ? `:${label}` : ''}] 已停止进程树: ${stopped.join(', ')}`, + ); + } + return stopped; +} + +// 兜底清扫:包装层(cmd.exe / cargo / npm)可能已经退出,父进程链断掉后按 PID +// 遍历再也找不到真正的服务进程,因此这里按身份再清一次本工作树的后端。 +function stopWindowsWorktreeBackendProcesses({ + spacetimeDataDir = '', + logStream = null, + snapshot = null, +} = {}) { + if (process.platform !== 'win32') { + return []; + } + + const stopped = stopWindowsWorktreeProcesses({ + apiServerExePath: resolveWindowsApiServerExePath(), + spacetimeDataDir, + snapshot, + }); + if (stopped.length > 0) { + const line = `[dev] 已清理本工作树残留后端进程: ${stopped.join(', ')}\n`; + process.stdout.write(line); + logStream?.write(line); + } + return stopped; } class DevRunner { @@ -2478,6 +2490,14 @@ class DevRunner { await this.services.get(serviceName)?.stop(); } + // 复用别人启动的 SpacetimeDB 时不能连带杀掉对方的 standalone;只有本进程 + // 自己拉起的 standalone 才属于本次退出的清理范围。 + stopWindowsWorktreeBackendProcesses({ + spacetimeDataDir: this.state.spacetimeReused + ? '' + : this.options.spacetimeDataDir, + }); + process.exit(code); } } @@ -2487,47 +2507,15 @@ function stopExistingWindowsApiServer(logStream) { return; } - const apiServerExePath = resolve( - repoRoot, - 'server-rs/target/debug/api-server.exe', - ); - const command = [ - '$ErrorActionPreference = "Continue"', - '$target = [System.IO.Path]::GetFullPath($env:GENARRATIVE_API_SERVER_EXE_TARGET)', - '$processes = Get-Process -Name api-server -ErrorAction SilentlyContinue | Where-Object {', - ' $_.Path -and ([System.IO.Path]::GetFullPath($_.Path) -ieq $target)', - '}', - 'foreach ($process in $processes) {', - ' try {', - ' Stop-Process -Id $process.Id -Force -ErrorAction Stop', - ' Wait-Process -Id $process.Id -Timeout 5 -ErrorAction SilentlyContinue', - ' Write-Output $process.Id', - ' } catch {', - ' Write-Error "[dev:api-server] 忽略旧进程清理瞬时失败 pid=$($process.Id): $($_.Exception.Message)"', - ' }', - '}', - 'exit 0', - ].join('\n'); + const apiServerExePath = resolveWindowsApiServerExePath(); + const snapshot = readWindowsProcessSnapshot(); + const processIds = selectWorktreeOwnedProcessIds(snapshot, { + apiServerExePath, + }); + const stopped = stopWindowsProcessIds(processIds, { waitForExitMs: 5000 }); - const result = spawnSync( - 'powershell.exe', - ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', command], - { - encoding: 'utf8', - env: { - ...process.env, - GENARRATIVE_API_SERVER_EXE_TARGET: apiServerExePath, - }, - }, - ); - - if (result.error) { - throw result.error; - } - - const output = String(result.stdout ?? '').trim(); - if (output) { - const line = `[dev:api-server] 已停止旧 api-server 进程: ${output}\n`; + if (stopped.length > 0) { + const line = `[dev:api-server] 已停止旧 api-server 进程: ${stopped.join(', ')}\n`; process.stdout.write(line); logStream?.write(line); } @@ -3505,8 +3493,10 @@ export { resolveDevStackStatePath, resolveLocalSpacetimeApiIdentityPath, resolveLocalSpacetimeRuntimeServiceBootstrapSecretPath, + resolveWindowsApiServerExePath, shouldAcceptWatchEvent, shouldTrustExistingSpacetimeToken, + stopWindowsWorktreeBackendProcesses, }; async function main() { diff --git a/server-rs/crates/api-server/src/asset_billing.rs b/server-rs/crates/api-server/src/asset_billing.rs index e0ce18d6d..f9eb99573 100644 --- a/server-rs/crates/api-server/src/asset_billing.rs +++ b/server-rs/crates/api-server/src/asset_billing.rs @@ -202,6 +202,7 @@ where points_cost, &billing_plan.current.consume_ledger_id, wallet_metadata_json( + asset_kind, billing_plan.external_generation_job_id.as_deref(), billing_plan.external_generation_claim_attempt, ), @@ -642,9 +643,10 @@ fn resolve_asset_operation_points_cost(configured_points_cost: u64) -> u64 { } #[cfg(test)] -fn current_wallet_metadata_json() -> String { +fn current_wallet_metadata_json(asset_kind: &str) -> String { let billing_context = current_external_generation_billing_context(); wallet_metadata_json( + asset_kind, billing_context .as_ref() .map(|context| context.job_id.as_str()), @@ -655,21 +657,21 @@ fn current_wallet_metadata_json() -> String { } fn wallet_metadata_json( + asset_kind: &str, external_generation_job_id: Option<&str>, external_generation_claim_attempt: Option, ) -> String { - let Some(external_generation_job_id) = external_generation_job_id + let mut metadata = json!({ + "assetKind": asset_kind.trim(), + }); + if let Some(external_generation_job_id) = external_generation_job_id .map(str::trim) .filter(|value| !value.is_empty()) - else { - return module_runtime::PROFILE_INVITE_CODE_METADATA_DEFAULT_JSON.to_string(); - }; - - json!({ - "externalGenerationJobId": external_generation_job_id, - "externalGenerationClaimAttempt": external_generation_claim_attempt, - }) - .to_string() + { + metadata["externalGenerationJobId"] = json!(external_generation_job_id); + metadata["externalGenerationClaimAttempt"] = json!(external_generation_claim_attempt); + } + metadata.to_string() } pub(crate) fn map_asset_operation_wallet_error(error: SpacetimeClientError) -> AppError { @@ -971,13 +973,20 @@ mod tests { async { ( resolve_asset_operation_points_cost(99), - current_wallet_metadata_json(), + current_wallet_metadata_json("editor_generated_image"), ) }, ) .await; assert_eq!(points_cost, 37); + assert_eq!( + serde_json::from_str::(&metadata_json) + .expect("worker metadata should be valid JSON") + .get("assetKind") + .and_then(serde_json::Value::as_str), + Some("editor_generated_image") + ); assert_eq!( serde_json::from_str::(&metadata_json) .expect("worker metadata should be valid JSON") @@ -994,8 +1003,11 @@ mod tests { ); assert_eq!(resolve_asset_operation_points_cost(99), 99); assert_eq!( - current_wallet_metadata_json(), - module_runtime::PROFILE_INVITE_CODE_METADATA_DEFAULT_JSON + serde_json::from_str::(¤t_wallet_metadata_json( + "editor_image_edit" + )) + .expect("ordinary metadata should be valid JSON"), + json!({"assetKind": "editor_image_edit"}) ); } diff --git a/server-rs/crates/api-server/src/editor_agent/tool.rs b/server-rs/crates/api-server/src/editor_agent/tool.rs index 198c31aef..949fadf99 100644 --- a/server-rs/crates/api-server/src/editor_agent/tool.rs +++ b/server-rs/crates/api-server/src/editor_agent/tool.rs @@ -863,6 +863,7 @@ impl EditorAgentTool for GenerateIconSpritesheetTool { reference_id, reference_image_srcs: Some(reference_image_srcs), icon_descriptions: args.icon_descriptions, + slice_count: None, slice_layout: None, style: None, model: Some(args.model), diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index d9f110905..b0663a4b1 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -8579,6 +8579,7 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( spritesheet_height: source_height, icon_image_srcs: Vec::new(), slice_layout: None, + slice_count: None, slice_warning: None, prompt, actual_prompt: generated.actual_prompt, @@ -8645,6 +8646,7 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( spritesheet_height: source_height, icon_image_srcs: Vec::new(), slice_layout: None, + slice_count: None, slice_warning: None, prompt, actual_prompt: generated.actual_prompt, @@ -8729,6 +8731,7 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( slice_source, request_context.external_call_deadline(), None, + None, ) .await { @@ -8891,6 +8894,7 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( spritesheet_height, icon_image_srcs, slice_layout: None, + slice_count: None, slice_warning, prompt, actual_prompt: generated.actual_prompt, @@ -19059,7 +19063,7 @@ mod tests { .checked_sub(Duration::from_millis(1)) .expect("expired deadline should be representable"); - let error = slice_editor_icon_spritesheet_all(source, Some(expired), None) + let error = slice_editor_icon_spritesheet_all(source, Some(expired), None, None) .await .err() .expect("expired CPU budget must fail before decoding"); @@ -19748,6 +19752,7 @@ mod tests { spritesheet_height: 512, icon_image_srcs: Vec::new(), slice_layout: None, + slice_count: None, slice_warning: Some(EditorIconSpritesheetSliceWarningResponse { code: EDITOR_ICON_SPRITESHEET_SLICE_WARNING_COMPONENTS, reason: "图集中未识别到可拆分的独立素材。".to_string(), diff --git a/server-rs/crates/api-server/src/editor_project_icon.rs b/server-rs/crates/api-server/src/editor_project_icon.rs index 9e8c307aa..f7f318b86 100644 --- a/server-rs/crates/api-server/src/editor_project_icon.rs +++ b/server-rs/crates/api-server/src/editor_project_icon.rs @@ -251,6 +251,9 @@ pub(crate) struct EditorIconSpritesheetGenerationRequest { pub(crate) reference_id: String, pub(crate) reference_image_srcs: Option>, pub(crate) icon_descriptions: Vec, + /// 用户要求的切片数量;未提供时按图像中的连通素材自动识别。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) slice_count: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub(crate) slice_layout: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -267,8 +270,7 @@ pub(crate) struct EditorIconSpritesheetGenerationRequest { pub(crate) canvas_completion: Option, } -/// Opt-in fixed atlas slicing. Existing callers remain on the default -/// connected-component path unless they explicitly request this layout. +/// Deprecated compatibility layout. New callers should use `sliceCount`。 #[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] pub(crate) enum EditorIconSpritesheetSliceLayout { #[serde(rename = "grid-2x2")] @@ -313,6 +315,8 @@ pub(crate) struct EditorIconSpritesheetGenerationResponse { #[serde(skip_serializing_if = "Option::is_none")] pub(crate) slice_layout: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) slice_count: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub(crate) slice_warning: Option, pub(crate) prompt: String, pub(crate) actual_prompt: Option, @@ -1783,6 +1787,7 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( spritesheet_height: source_height, icon_image_srcs: Vec::new(), slice_layout: payload.slice_layout, + slice_count: Some(0), slice_warning: None, prompt, actual_prompt: generated.actual_prompt, @@ -1864,6 +1869,7 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( spritesheet_height: source_height, icon_image_srcs: Vec::new(), slice_layout: payload.slice_layout, + slice_count: Some(0), slice_warning: None, prompt, actual_prompt: generated.actual_prompt, @@ -1961,6 +1967,7 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( slice_source, request_context.external_call_deadline(), payload.slice_layout, + payload.slice_count, ) .await { @@ -2053,6 +2060,7 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( "spritesheetHeight": spritesheet_height, "iconImageSrcs": &icon_image_srcs, "sliceLayout": payload.slice_layout, + "sliceCount": payload.slice_count, "sliceWarning": &slice_warning, "warning": &generation_warning, "prompt": user_prompt.clone(), @@ -2121,6 +2129,7 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( icon.asset = item.asset.map(editor_asset_payload_from_record); } + let slice_count = icon_image_srcs.len(); Ok(json_success_body( Some(&request_context), EditorIconSpritesheetGenerationResponse { @@ -2129,6 +2138,7 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( spritesheet_height, icon_image_srcs, slice_layout: payload.slice_layout, + slice_count: Some(slice_count), slice_warning, prompt, actual_prompt: generated.actual_prompt, @@ -2323,6 +2333,7 @@ pub async fn split_editor_icon_spritesheet( processing_deadline, memory_admission, None, + None, ) .await?; let prompt = source_resource @@ -2398,6 +2409,7 @@ pub(crate) async fn slice_editor_icon_spritesheet_all( source: DownloadedImage, request_deadline: Option, slice_layout: Option, + slice_count: Option, ) -> Result { let processing_deadline = resolve_editor_icon_spritesheet_processing_deadline(Instant::now(), request_deadline); @@ -2408,6 +2420,7 @@ pub(crate) async fn slice_editor_icon_spritesheet_all( processing_deadline, memory_admission, slice_layout, + slice_count, ) .await } @@ -2446,6 +2459,7 @@ async fn slice_editor_icon_spritesheet_all_with_memory_admission( processing_deadline: Instant, memory_admission: Arc, slice_layout: Option, + slice_count: Option, ) -> Result { if Instant::now() >= processing_deadline { return Err(editor_icon_spritesheet_processing_timeout_error()); @@ -2484,7 +2498,9 @@ async fn slice_editor_icon_spritesheet_all_with_memory_admission( } None => prepare_generated_icon_spritesheet_all_by_connected_components( &source, - EDITOR_ICON_SPRITESHEET_MAX_SLICES, + slice_count + .unwrap_or(EDITOR_ICON_SPRITESHEET_MAX_SLICES) + .min(EDITOR_ICON_SPRITESHEET_MAX_SLICES), EDITOR_ICON_SPRITESHEET_MAX_TOTAL_CROP_PIXELS, ), } @@ -2505,6 +2521,15 @@ async fn slice_editor_icon_spritesheet_all_with_memory_admission( } Err(_) => return Err(editor_icon_spritesheet_processing_timeout_error()), }; + if let Some(expected) = slice_count { + if expected == 0 || expected > EDITOR_ICON_SPRITESHEET_MAX_SLICES || plan.len() != expected + { + return Err(AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({ + "provider": "editor-icon-spritesheet-slicing", + "message": format!("请求切片数量为 {expected},实际识别到 {} 个。请调整 sliceCount 或素材排布。", plan.len()), + }))); + } + } if plan.is_empty() { return Err( AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({ @@ -2915,6 +2940,7 @@ mod tests { source, None, Some(EditorIconSpritesheetSliceLayout::Grid2x2), + None, ) .await .expect("declared 2x2 sheet should slice"); diff --git a/server-rs/crates/api-server/src/external_editor_api.rs b/server-rs/crates/api-server/src/external_editor_api.rs index 0a15d26ef..de48a2d15 100644 --- a/server-rs/crates/api-server/src/external_editor_api.rs +++ b/server-rs/crates/api-server/src/external_editor_api.rs @@ -2617,13 +2617,8 @@ mod tests { .is_some_and(|description| description.contains("同步返回 400")) ); assert_eq!( - icon_spritesheet_request["properties"]["sliceLayout"]["enum"], - json!(["grid-2x2"]) - ); - assert!( - icon_spritesheet_request["properties"]["sliceLayout"]["description"] - .as_str() - .is_some_and(|description| description.contains("固定图集切片合同")) + icon_spritesheet_request["properties"]["sliceCount"]["minimum"], + json!(1) ); let icon_style_schema = &parsed["components"]["schemas"]["EditorIconSpritesheetGenerationRequest"] ["properties"]["style"]; @@ -2635,11 +2630,7 @@ mod tests { ["sliceWarning"]["anyOf"][0]["$ref"], "#/components/schemas/EditorIconSpritesheetSliceWarning" ); - assert_eq!( - parsed["components"]["schemas"]["EditorIconSpritesheetGenerationResponse"]["properties"] - ["sliceLayout"]["enum"], - json!(["grid-2x2"]) - ); + assert!(parsed["components"]["schemas"]["EditorIconSpritesheetGenerationResponse"]["properties"]["sliceCount"].is_object()); assert_eq!( parsed["components"]["schemas"]["EditorImageGenerationResponse"]["properties"]["warning"] ["anyOf"][0]["$ref"], diff --git a/server-rs/crates/api-server/src/external_generation_worker.rs b/server-rs/crates/api-server/src/external_generation_worker.rs index fa80d9b32..722213247 100644 --- a/server-rs/crates/api-server/src/external_generation_worker.rs +++ b/server-rs/crates/api-server/src/external_generation_worker.rs @@ -1365,6 +1365,7 @@ fn compact_external_api_generation_result(result: Value) -> Value { | "spritesheetHeight" | "iconImageSrcs" | "sliceLayout" + | "sliceCount" | "frames" | "frameCount" | "frameWidth" diff --git a/server-rs/crates/api-server/src/llm/mod.rs b/server-rs/crates/api-server/src/llm/mod.rs index e9b526f2a..331ab840b 100644 --- a/server-rs/crates/api-server/src/llm/mod.rs +++ b/server-rs/crates/api-server/src/llm/mod.rs @@ -39,6 +39,7 @@ mod model_catalog_tests { #[test] fn public_catalog_only_exposes_alias_and_stable_id() { let mut catalog = module_runtime::AgcModelCatalog::default(); + catalog.revision = 7; catalog.models[1].enabled = false; let payload = serde_json::to_value(public_model_catalog(catalog)).unwrap(); assert_eq!( @@ -46,6 +47,7 @@ mod model_catalog_tests { json!([{"id": "quality", "displayName": "高质量"}]) ); assert_eq!(payload["defaultModelId"], "quality"); + assert_eq!(payload["revision"], json!(7)); assert!(!payload.to_string().contains("gpt-")); } } @@ -193,6 +195,7 @@ fn public_model_catalog(catalog: module_runtime::AgcModelCatalog) -> LlmModelsRe display_name: model.alias, }) .collect(), + revision: catalog.revision, } } diff --git a/server-rs/crates/api-server/src/runtime_profile.rs b/server-rs/crates/api-server/src/runtime_profile.rs index b7413f45a..db27bb20f 100644 --- a/server-rs/crates/api-server/src/runtime_profile.rs +++ b/server-rs/crates/api-server/src/runtime_profile.rs @@ -2064,15 +2064,46 @@ fn build_redeem_profile_reward_code_response( fn build_profile_wallet_ledger_entry_response( record: module_runtime::RuntimeProfileWalletLedgerEntryRecord, ) -> ProfileWalletLedgerEntryResponse { + let reason = profile_wallet_ledger_reason(record.source_type, &record.metadata_json); ProfileWalletLedgerEntryResponse { id: record.wallet_ledger_id, amount_delta: record.amount_delta, balance_after: record.balance_after, source_type: format_profile_wallet_ledger_source_type(record.source_type).to_string(), created_at: record.created_at, + reason, } } +fn profile_wallet_ledger_reason( + source_type: RuntimeProfileWalletLedgerSourceType, + metadata_json: &str, +) -> Option { + if source_type != RuntimeProfileWalletLedgerSourceType::AssetOperationConsume { + return None; + } + let metadata = serde_json::from_str::(metadata_json).ok()?; + let asset_kind = metadata.get("assetKind")?.as_str()?.trim(); + let reason = match asset_kind { + "editor_scene_image" + | "editor_character_image" + | "editor_spec_image" + | "editor_quick_edit_image" + | "editor_ui_design_image" + | "editor_publication_material" + | "editor_generated_image" + | "editor_icon_spritesheet" => "生成美术素材", + "editor_image_edit" => "编辑美术素材", + "editor_ui_design_asset_extraction" => "提取美术素材", + "editor_character_animation" => "生成角色动画", + "editor_video" => "生成视频素材", + "editor_sound_effect" => "生成音效素材", + "editor_background_music" => "生成背景音乐", + _ => return None, + }; + Some(reason.to_string()) +} + fn build_profile_task_center_response( record: RuntimeProfileTaskCenterRecord, ) -> ProfileTaskCenterResponse { @@ -2433,7 +2464,8 @@ mod tests { format_profile_wallet_ledger_source_type, is_wechat_profile_recharge_order_terminal_for_confirmation, map_runtime_profile_client_error, normalize_admin_invite_code_metadata, - parse_admin_profile_code_time_field, should_notify_virtual_payment_goods_delivery, + parse_admin_profile_code_time_field, profile_wallet_ledger_reason, + should_notify_virtual_payment_goods_delivery, }; #[test] @@ -2501,7 +2533,7 @@ mod tests { use platform_auth::{ AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus, sign_access_token, }; - use serde_json::Value; + use serde_json::{Value, json}; use spacetime_client::SpacetimeClientError; use std::time::Duration; use time::OffsetDateTime; @@ -2619,6 +2651,60 @@ mod tests { ); } + #[test] + fn profile_wallet_ledger_reason_only_exposes_known_asset_operations() { + for (asset_kind, expected) in [ + ("editor_scene_image", "生成美术素材"), + ("editor_character_image", "生成美术素材"), + ("editor_spec_image", "生成美术素材"), + ("editor_quick_edit_image", "生成美术素材"), + ("editor_ui_design_image", "生成美术素材"), + ("editor_publication_material", "生成美术素材"), + ("editor_generated_image", "生成美术素材"), + ("editor_icon_spritesheet", "生成美术素材"), + ("editor_image_edit", "编辑美术素材"), + ("editor_ui_design_asset_extraction", "提取美术素材"), + ("editor_character_animation", "生成角色动画"), + ("editor_video", "生成视频素材"), + ("editor_sound_effect", "生成音效素材"), + ("editor_background_music", "生成背景音乐"), + ] { + assert_eq!( + profile_wallet_ledger_reason( + RuntimeProfileWalletLedgerSourceType::AssetOperationConsume, + &json!({"assetKind": asset_kind}).to_string(), + ) + .as_deref(), + Some(expected), + "unexpected reason for {asset_kind}", + ); + } + + for metadata in [ + "{}", + "not-json", + r#"{"assetKind":""}"#, + r#"{"assetKind":" "}"#, + r#"{"assetKind":"unknown_internal_operation"}"#, + r#"{"assetKind":123}"#, + ] { + assert_eq!( + profile_wallet_ledger_reason( + RuntimeProfileWalletLedgerSourceType::AssetOperationConsume, + metadata, + ), + None, + ); + } + assert_eq!( + profile_wallet_ledger_reason( + RuntimeProfileWalletLedgerSourceType::AssetOperationRefund, + r#"{"assetKind":"editor_generated_image"}"#, + ), + None, + ); + } + #[tokio::test] async fn profile_dashboard_requires_authentication() { let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); diff --git a/server-rs/crates/shared-contracts/src/llm.rs b/server-rs/crates/shared-contracts/src/llm.rs index 5b882ae1f..0565fd25c 100644 --- a/server-rs/crates/shared-contracts/src/llm.rs +++ b/server-rs/crates/shared-contracts/src/llm.rs @@ -72,4 +72,6 @@ pub struct LlmModelSummary { pub struct LlmModelsResponse { pub default_model_id: String, pub models: Vec, + /// 模型目录版本,客户端据此做条件刷新。 + pub revision: u64, } diff --git a/server-rs/crates/shared-contracts/src/runtime.rs b/server-rs/crates/shared-contracts/src/runtime.rs index d46843ff3..704a5d444 100644 --- a/server-rs/crates/shared-contracts/src/runtime.rs +++ b/server-rs/crates/shared-contracts/src/runtime.rs @@ -225,6 +225,8 @@ pub struct ProfileWalletLedgerEntryResponse { pub balance_after: u64, pub source_type: String, pub created_at: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub reason: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] @@ -1430,6 +1432,7 @@ mod tests { source_type: PROFILE_WALLET_LEDGER_SOURCE_TYPE_NEW_USER_REGISTRATION_REWARD .to_string(), created_at: "2026-04-22T09:59:00Z".to_string(), + reason: None, }, ProfileWalletLedgerEntryResponse { id: "ledger-2".to_string(), @@ -1437,6 +1440,7 @@ mod tests { balance_after: 80, source_type: PROFILE_WALLET_LEDGER_SOURCE_TYPE_SNAPSHOT_SYNC.to_string(), created_at: "2026-04-22T10:00:00Z".to_string(), + reason: None, }, ProfileWalletLedgerEntryResponse { id: "ledger-3".to_string(), @@ -1445,6 +1449,7 @@ mod tests { source_type: PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITER_REWARD .to_string(), created_at: "2026-04-22T10:01:00Z".to_string(), + reason: None, }, ProfileWalletLedgerEntryResponse { id: "ledger-4".to_string(), @@ -1453,6 +1458,7 @@ mod tests { source_type: PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITEE_REWARD .to_string(), created_at: "2026-04-22T10:02:00Z".to_string(), + reason: None, }, ProfileWalletLedgerEntryResponse { id: "ledger-5".to_string(), @@ -1460,6 +1466,7 @@ mod tests { balance_after: 200, source_type: PROFILE_WALLET_LEDGER_SOURCE_TYPE_POINTS_RECHARGE.to_string(), created_at: "2026-04-22T10:03:00Z".to_string(), + reason: None, }, ProfileWalletLedgerEntryResponse { id: "ledger-6".to_string(), @@ -1468,6 +1475,7 @@ mod tests { source_type: PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_CONSUME .to_string(), created_at: "2026-04-22T10:04:00Z".to_string(), + reason: Some("生成美术素材".to_string()), }, ProfileWalletLedgerEntryResponse { id: "ledger-7".to_string(), @@ -1476,6 +1484,7 @@ mod tests { source_type: PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_REFUND .to_string(), created_at: "2026-04-22T10:05:00Z".to_string(), + reason: None, }, ProfileWalletLedgerEntryResponse { id: "ledger-8".to_string(), @@ -1484,6 +1493,7 @@ mod tests { source_type: PROFILE_WALLET_LEDGER_SOURCE_TYPE_PUZZLE_AUTHOR_INCENTIVE_CLAIM .to_string(), created_at: "2026-04-22T10:06:00Z".to_string(), + reason: None, }, ProfileWalletLedgerEntryResponse { id: "ledger-9".to_string(), @@ -1491,6 +1501,7 @@ mod tests { balance_after: 212, source_type: PROFILE_WALLET_LEDGER_SOURCE_TYPE_DAILY_TASK_REWARD.to_string(), created_at: "2026-04-22T10:07:00Z".to_string(), + reason: None, }, ], }) @@ -1522,6 +1533,8 @@ mod tests { payload["entries"][5]["sourceType"], json!(PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_CONSUME) ); + assert_eq!(payload["entries"][5]["reason"], json!("生成美术素材")); + assert!(payload["entries"][0].get("reason").is_none()); assert_eq!( payload["entries"][6]["sourceType"], json!(PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_REFUND) diff --git a/src/components/image-editor/ImageCanvasEditorView.test.tsx b/src/components/image-editor/ImageCanvasEditorView.test.tsx index 8012511cb..32d777569 100644 --- a/src/components/image-editor/ImageCanvasEditorView.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.test.tsx @@ -1,5 +1,6 @@ /* @vitest-environment jsdom */ +import { canvasViewportToWorldTransform } from '@genarrative/image-canvas-react'; import { act, fireEvent, @@ -2412,11 +2413,13 @@ describe('ImageCanvasEditorView', () => { act(() => rafCallbacks.shift()?.(210)); expect(world.style.transform).not.toBe(initialTransform); expect(world.style.transform).not.toBe( - 'translate(-840px, -246.5px) scale(1)', + canvasViewportToWorldTransform({ x: -840, y: -246.5, scale: 1 }), ); act(() => rafCallbacks.shift()?.(420)); - expect(world.style.transform).toBe('translate(-840px, -246.5px) scale(1)'); + expect(world.style.transform).toBe( + canvasViewportToWorldTransform({ x: -840, y: -246.5, scale: 1 }), + ); expect(screen.getByLabelText('发送给画布 Agent')).toBeTruthy(); expect( puzzleLayer.classList.contains('image-canvas-editor__layer--selected'), @@ -2532,7 +2535,9 @@ describe('ImageCanvasEditorView', () => { clientX: 400, clientY: 280, }); - expect(world.style.transform).toBe('translate(-80px, -120px) scale(0.5)'); + expect(world.style.transform).toBe( + canvasViewportToWorldTransform({ x: -80, y: -120, scale: 0.5 }), + ); expect( screen.getByRole('button', { name: '当前缩放比例 100%' }), ).toBeTruthy(); @@ -2567,7 +2572,7 @@ describe('ImageCanvasEditorView', () => { ).toBeTruthy(); expect( (screen.getByTestId('image-canvas-world') as HTMLElement).style.transform, - ).toBe('translate(0px, 0px) scale(0.5)'); + ).toBe(canvasViewportToWorldTransform({ x: 0, y: 0, scale: 0.5 })); }); it('selects multiple canvas layers with shift click', async () => { diff --git a/src/components/image-editor/ImageCanvasWorldView.test.tsx b/src/components/image-editor/ImageCanvasWorldView.test.tsx index cc98a733e..ebc0b3b75 100644 --- a/src/components/image-editor/ImageCanvasWorldView.test.tsx +++ b/src/components/image-editor/ImageCanvasWorldView.test.tsx @@ -1,5 +1,6 @@ /* @vitest-environment jsdom */ +import { canvasViewportToWorldTransform } from '@genarrative/image-canvas-react'; import { act, fireEvent, @@ -416,13 +417,15 @@ describe('ImageCanvasWorldView', () => { ).toBe('344px'); expect(screen.getByText('生成中')).toBeTruthy(); - const world = document.querySelector('.image-canvas-editor__world'); + const world = document.querySelector( + '.image-canvas-editor__world', + ); const marquee = world?.querySelector( '.image-canvas-editor__canvas-marquee', ); - expect(world?.getAttribute('style')).toContain( - 'transform: translate(10px, 20px) scale(1.5)', + expect(world?.style.transform).toBe( + canvasViewportToWorldTransform({ x: 10, y: 20, scale: 1.5 }), ); expect((marquee as HTMLElement | null)?.style.width).toBe('100px'); expect((marquee as HTMLElement | null)?.style.height).toBe('120px'); diff --git a/src/components/image-editor/useImageCanvasStageInteractions.test.tsx b/src/components/image-editor/useImageCanvasStageInteractions.test.tsx index d50e63623..fc510d559 100644 --- a/src/components/image-editor/useImageCanvasStageInteractions.test.tsx +++ b/src/components/image-editor/useImageCanvasStageInteractions.test.tsx @@ -1,5 +1,6 @@ /* @vitest-environment jsdom */ +import { CanvasWorld } from '@genarrative/image-canvas-react'; import { act, fireEvent, render, screen } from '@testing-library/react'; import { type PointerEvent as ReactPointerEvent, @@ -150,7 +151,6 @@ function StageInteractionsHarness({ ) => void; }) { const canvasViewportRef = useRef(null); - const worldRef = useRef(null); const [activeTool, setActiveTool] = useState<'select' | 'hand'>('select'); const [layers, setLayers] = useState(() => [ createLayer({ id: 'first', x: 40, y: 40, zIndex: 1 }), @@ -288,11 +288,9 @@ function StageInteractionsHarness({ onPointerMove={interaction.handlePointerMove} onPointerUp={interaction.finishDrag} > -
+ + {layers.map((layer) => (