diff --git a/apps/ai-game-creator-shell/game-creator.config.json b/apps/ai-game-creator-shell/game-creator.config.json index 19e45914c..4dd14447d 100644 --- a/apps/ai-game-creator-shell/game-creator.config.json +++ b/apps/ai-game-creator-shell/game-creator.config.json @@ -16,5 +16,8 @@ "retryBackoffMs": 500 }, "agentLlm": {}, - "mcpServers": {} + "mcpServers": {}, + "planning": { + "capabilityEnabled": true + } } diff --git a/apps/ai-game-creator-shell/package.json b/apps/ai-game-creator-shell/package.json index 809ff111d..ca9a8c2a9 100644 --- a/apps/ai-game-creator-shell/package.json +++ b/apps/ai-game-creator-shell/package.json @@ -17,6 +17,8 @@ "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", 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 756e17422..97d19625f 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,6 +36,8 @@ 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'; @@ -138,7 +140,8 @@ export const usage = `用法: --keep-project 保留自动创建的一次性项目 --no-open 手工模式启动预览但不自动打开浏览器 --task <需求> 通过 manual 入口非交互提交自定义需求 - --timeout-minutes <分钟> 设置本次执行期限;自动任务默认 50 分钟,手工模式默认不限时 + --plan 走「做方案」立项策划入口,不做游戏,不做产物验收和试玩 + --timeout-minutes <分钟> 设置本次执行期限;自动任务默认 50 分钟,--plan 默认 6 分钟,手工模式默认不限时 --dry-run 只检查目录发现和项目准备,不启动 LLM -h, --help 显示帮助`; @@ -169,6 +172,7 @@ export function parseSwarmTestArguments(args) { keepProject: false, openBrowser: true, task: null, + plan: false, timeoutMinutes: null, dryRun: false, help: false, @@ -193,6 +197,8 @@ 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 只能指定一次'); @@ -211,11 +217,16 @@ export function parseSwarmTestArguments(args) { } export function shouldStartPersistentPreview(options) { - return !options.task; + // 立项策划链路只出 GDD,没有可试玩产物,任何模式都不该起预览。 + return !options.task && !options.plan; } export function resolveSwarmTestTimeoutMs(options) { - const minutes = options.timeoutMinutes ?? (options.task ? 50 : null); + // 立项策划的设计目标是五分钟出方案,给一分钟余量;再久就是卡住了,早失败 + // 比让 harness 空等更有用。做游戏那条链路的 50 分钟不变。 + const planMinutes = options.plan ? 6 : null; + const minutes = + options.timeoutMinutes ?? planMinutes ?? (options.task ? 50 : null); return minutes === null ? null : minutes * 60_000; } @@ -658,7 +669,18 @@ export async function cleanupSwarmTestProject(project) { } export function buildCargoCliArguments(cliArguments) { - return ['run', '--manifest-path', cargoManifestPath, '--', ...cliArguments]; + // `--quiet` only silences cargo's own build chatter; compiler errors and the + // CLI's stdout still come through. Without it the crate's several hundred + // dead-code warnings are reprinted on every spawn and bury the run output + // this script exists to show. + return [ + 'run', + '--quiet', + '--manifest-path', + cargoManifestPath, + '--', + ...cliArguments, + ]; } function spawnChild(command, args, options = {}) { @@ -923,13 +945,79 @@ async function runInteractiveCargo(cliArguments, setActiveChild) { return result; } -async function runTaskCargo(cliArguments, task, setActiveChild, timeoutMs) { +// 立项策划跑 standard 档,`agent.delegate` 这类动作按项目权限策略必须逐个确认, +// 而确认和问询都只从 CLI 的 stdin 读。自主构建档没有这一步,所以只有 --plan 需要 +// 一个把「人坐在终端前敲 approve」自动化掉的应答器;判据本身仍然走后端确认命令。 +const swarmConfirmationPromptPattern = /输入 approve 或 reject:$/u; +const swarmUserInputPromptPattern = /请选择 1-\d+,或直接输入其他答案:$/u; + +export function nextSwarmAutoPilotReply(output) { + if (swarmConfirmationPromptPattern.test(output)) return 'approve'; + if (swarmUserInputPromptPattern.test(output)) return '1'; + return null; +} + +// CLI 的 REPL 是「先打印提示符再读行」,所以第一个「你>」出现时本轮还没开始跑: +// 它就是用来读我们这条任务的。收 stdin 必须等到投递之后的下一个提示符——那才是 +// 本轮结束、CLI 回到待输入状态。绝大多数情况下此前已经打印过 turn 回执,但总控也 +// 可能判定直接回复而不起持久 Run,那条路径没有回执,只等回执会一直干等到超时。 +const swarmChatPromptPattern = /(^|\n)你> $/u; + +export function swarmAutoPilotSitsAtPrompt(output) { + return swarmChatPromptPattern.test(output); +} + +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'], }); setActiveChild(child); const reportLines = []; let pendingLine = ''; + let settled = false; + 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 审批位,正在提交 approve'); + 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); @@ -940,10 +1028,41 @@ async function runTaskCargo(cliArguments, task, setActiveChild, timeoutMs) { const normalizedLine = line.endsWith('\r') ? line.slice(0, -1) : line; if (normalizedLine.startsWith(swarmTurnReportPrefix)) { reportLines.push(normalizedLine); + settled = true; + } + if ( + onPlanGddApprovalWait && + !planGddApprovalStarted && + swarmOutputAwaitsPlanGddApproval(normalizedLine) + ) { + startPlanGddApproval(); } } + if (!autoPilot || child.stdin.writableEnded) return; + const atPrompt = swarmAutoPilotSitsAtPrompt(pendingLine); + if (atPrompt && !sittingAtPrompt) promptsSeen += 1; + sittingAtPrompt = atPrompt; + // turn 已给出回执、或 CLI 回到了投递之后的下一个提示符,都说明本轮结束。 + if ( + settled || + (taskSubmitted && + swarmAutoPilotShouldCloseInput(pendingLine, promptsSeen - 1)) + ) { + child.stdin.end(); + return; + } + const reply = nextSwarmAutoPilotReply(pendingLine); + if (reply === null) return; + console.log(`[自动应答] ${reply}`); + pendingLine = ''; + child.stdin.write(`${reply}\n`); }); - child.stdin.end(`${task}\n`); + if (autoPilot) { + child.stdin.write(`${task}\n`); + taskSubmitted = true; + } else { + child.stdin.end(`${task}\n`); + } try { const result = await childExitWithTimeout( child, @@ -956,7 +1075,13 @@ async function runTaskCargo(cliArguments, task, setActiveChild, timeoutMs) { if (normalizedPendingLine.startsWith(swarmTurnReportPrefix)) { reportLines.push(normalizedPendingLine); } - return { ...result, turnReportOutput: reportLines.join('\n') }; + await planGddApprovalPromise; + if (planGddApprovalError) throw planGddApprovalError; + return { + ...result, + turnReportOutput: reportLines.join('\n'), + planGddApproval, + }; } finally { setActiveChild(null); } @@ -1645,6 +1770,176 @@ 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 需要一段真实的修改意见,让机器编一段等于把判据 +// 换成噪声;要跑那两条分支就手工调 --plan-gdd-decide。 +export function planGddAutoApprovalIsPending(state) { + return Boolean(state?.pendingApproval); +} + +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 decision = await runCapturedCargo( + [ + '--config-dir', + runtimeConfigPath, + '--plan-gdd-decide', + projectPath, + 'approve', + ], + setActiveChild, + { + timeoutMs: planGddApprovalTimeoutMs, + label: 'Fast GDD 审批决定', + }, + ); + 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( + ` [已批准] 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]) { @@ -1797,17 +2092,29 @@ 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; receivedSignal ??= signal; - if (!activeChild) return; - void terminateChildTree(activeChild, signal, repeatedSignal).catch( - () => {}, - ); + const targets = [activeChild, ...concurrentChildren].filter(Boolean); + if (targets.length === 0) return; + for (const target of targets) { + void terminateChildTree(target, signal, repeatedSignal).catch(() => {}); + } if (repeatedSignal) return; forceTerminationHandle = setTimeout(() => { - void terminateChildTree(activeChild, 'SIGKILL', true).catch(() => {}); + for (const target of [activeChild, ...concurrentChildren].filter( + Boolean, + )) { + void terminateChildTree(target, 'SIGKILL', true).catch(() => {}); + } }, childTerminationGraceMs); forceTerminationHandle.unref(); }; @@ -1869,10 +2176,11 @@ export async function runSwarmTestChat(options) { ); } console.log('LLM 配置已就绪。'); + const requirementNoun = options.plan ? '立项策划需求' : '游戏需求'; console.log( options.task - ? '已提交一条非交互游戏需求,正在等待 Swarm 自主完成。\n' - : '输入一条游戏需求并回车;提交后按 Ctrl+D,让 Swarm 自主完成。\n', + ? `已提交一条非交互${requirementNoun},正在等待 Swarm 自主完成。\n` + : `输入一条${requirementNoun}并回车;提交后按 Ctrl+D,让 Swarm 自主完成。\n`, ); phase = 'chat'; @@ -1882,7 +2190,8 @@ export async function runSwarmTestChat(options) { runtimeConfig.path, '--swarm-chat', '--init', - '--autonomous-game-build', + // 做方案链路只能跑 standard 档,后端对 plan + autonomous 是硬否决。 + options.plan ? '--plan' : '--autonomous-game-build', project.path, ]; let chat; @@ -1895,6 +2204,15 @@ 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) { @@ -1910,6 +2228,31 @@ 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 9732b34b5..714c594b4 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -1548,11 +1548,13 @@ if (!viteConfigSource.includes('allow: [repoRoot]')) { ); } -if (!( - tauriConfig.build?.beforeDevCommand?.includes( - 'run ai-game-creator-shell:dev-server', - ) || tauriConfig.build?.beforeDevCommand?.includes('run agc:serve') -)) { +if ( + !( + tauriConfig.build?.beforeDevCommand?.includes( + 'run ai-game-creator-shell:dev-server', + ) || tauriConfig.build?.beforeDevCommand?.includes('run agc:serve') + ) +) { throw new Error( 'AI game creator shell beforeDevCommand must start the selected Vite dev server', ); 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 938945dcb..d04cd43b9 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 @@ -39,6 +39,11 @@ struct PromptBundleManifest { struct PromptCompositions { runtime: Vec, supervisor: Vec, + /// 立项策划根 run 的 Supervisor system prompt。它不是 `supervisor` 的差集, + /// 而是一份独立的完整清单:plan 根的工具面只有 7 个原生工具,专业组、 + /// isolated child、任务图与视觉产物合同在这条链路上全部不可执行,逐段 + /// 减法会把「plan 根到底看到什么」摊在两个函数的四个否定分支里。 + supervisor_plan: Vec, supervisor_chat: SupervisorChatComposition, } @@ -109,6 +114,10 @@ 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, } @@ -237,6 +246,12 @@ pub fn compile_manifest(manifest_path: &Path) -> Result Result Result>(); @@ -426,6 +443,14 @@ 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", @@ -454,9 +479,19 @@ 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:?}" @@ -693,11 +728,17 @@ 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(catalog.groups.iter()) { + for group in std::iter::once(&catalog.supervisor) + .chain(std::iter::once(&catalog.planning)) + .chain(catalog.groups.iter()) + { if !group_brief_names.insert(group.brief_path_name.as_str()) { return Err(format!( "agent group briefPathName 重复:{}", @@ -705,7 +746,10 @@ fn validate_agent_catalog(catalog: &AgentCatalog) -> Result<(), String> { )); } } - let mut generated_names = BTreeSet::from(["PROJECT_SUPERVISOR".to_string()]); + let mut generated_names = BTreeSet::from([ + "PROJECT_SUPERVISOR".to_string(), + "PROJECT_PLANNING".to_string(), + ]); for group in &catalog.groups { let generated = rust_identifier(&group.id); if !generated @@ -731,6 +775,12 @@ 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)?; } @@ -892,6 +942,10 @@ 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 5c95a838e..2b2b67b81 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 @@ -25,7 +25,11 @@ "supervisorVisualWithEditor": "supervisor/visual-contract-with-editor.md", "supervisorPlaybook": "supervisor/playbook.md", "supervisorClaimGate": "supervisor/claim-gate.md", - "supervisorRepair": "supervisor/repair.md" + "supervisorRepair": "supervisor/repair.md", + "projectPlanningRoleBrief": "roles/project-planning.md", + "planCommon": "plan/common.md", + "planSupervisorIdentity": "plan/supervisor-identity.md", + "planSupervisorPlaybook": "plan/supervisor-playbook.md" }, "compositions": { "runtime": [ @@ -45,6 +49,14 @@ "supervisorClaimGate", "supervisorRepair" ], + "supervisorPlan": [ + "$header", + "planCommon", + "isolatedAgentContract", + "planSupervisorIdentity", + "planSupervisorPlaybook", + "supervisorRepair" + ], "supervisorChat": { "identity": "supervisorIdentityContract", "finalReply": "supervisorFinalReplyContract" @@ -64,12 +76,22 @@ { "agentId": "project-supervisor", "rootSourceKind": "supervisorGameChat", - "sections": ["projectSupervisorGameChatRouting"] + "sections": [ + "projectSupervisorGameChatRouting" + ] }, { "agentId": "code-prototype", "rootSourceKind": "supervisorGameChat", - "sections": ["codePrototypeGameChat"] + "sections": [ + "codePrototypeGameChat" + ] + }, + { + "agentId": "project-planning", + "sections": [ + "projectPlanningRoleBrief" + ] } ], "providerFragments": { @@ -98,6 +120,21 @@ } ] }, + "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 new file mode 100644 index 000000000..d1029e423 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/plan/common.md @@ -0,0 +1,8 @@ +用户只描述玩法类型、机制或相似体验时,不代表授权复刻现有游戏。所有专业 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,不要泄露密钥。 \ No newline at end of file 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 new file mode 100644 index 000000000..d15e589f8 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/plan/supervisor-identity.md @@ -0,0 +1,7 @@ +你是 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 new file mode 100644 index 000000000..feef2a6a2 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/plan/supervisor-playbook.md @@ -0,0 +1,22 @@ +【固定动作顺序,不得跳步】 + +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 里;同一原委派只能返工一次。用户通过后只做一句简短收尾。 + +【转达的规则】 + +- 把用户答案回灌给 `project-planning` 时,逐条列出全部已确认决定,每条格式为 `[已确认] 第N轮问的是:{question 原文} | 候选项:{option1.label} / {option2.label} / {option3.label} → 用户答:{原文}`。**问题原文和三个选项标签必须带上**:`{header}` 恒为「第N轮·关键决定」,不含任何信息量;子 Agent 每轮都是全新 run,除了这段正文什么都看不到,只给它 header 和答案,「类似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 new file mode 100644 index 000000000..70204da00 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/roles/project-planning.md @@ -0,0 +1,33 @@ +你是“立项策划 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` 是决策卡标题,单行且不超过 12 字符;`question` 是决策卡正文,单行且不超过 400 字符;`options` 是 2~3 个 `{"label": ..., "description": ...}`,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`;只用于缩短对话,不覆盖用户明确输入):`genre.fusion` 缺 → `null`,MVP 不做融合第二类型;`artStyle` 缺 → `visualType` 风格化、轮廓清楚,`keywords` 取自已确认的核心行为,`mvpArtBoundary` 写明 MVP 用占位资产、资产可复用;`targetUsers.sessionLength` 缺 → 10~20 分钟一局;`targetUsers.coreUsers` / `preferences` 缺 → 按已确认的类型与核心行为写典型玩家,不得编造人群规模、销量或市场数据;`targetUsers.referenceGames` 缺 → 空数组;`outOfScope` 缺 → 多人、商城、服务器、开放世界、赛季、复杂社交、完整剧情、全量内容。**`pillars` 与 `coreLoop` 没有默认建议**:它们就是首个可玩闭环本身,空白时属于该问的空白,不得用默认值填掉。 +- 优先顺序:核心行为与本局目标 → 重玩动力 → 制作边界与 MVP。每轮最多问一个主要决定。**已确认决定关掉的那条轴不得重问。** 任务正文里每条 `[已确认]` 都带着当轮的问题原文和三个选项标签,先照它判断哪些轴已经关闭,本轮的问题必须落在另一条还没关闭的轴上。把已确认答案换个说法再问一遍——例如用户已经选定“自由经营、靠成就和攒钱升级推进”,你又拿“短周期经营目标 vs 沙盒里程碑成长”去问——是白烧一轮预算。所有轴都已关闭时按出稿触发器③直接出稿。 +- 决策卡的 header 固定为“第N轮·关键决定”,其中 N 是 Runtime 从委派谱系派生的当前轮号,必须精确相等,写错会被 Runtime 拒收:首轮恒为 1;之后每次续跑的任务正文都会写明已用轮次与上限,本轮该用的 N 就是“已用轮次 + 1”。正文以“当前要决定:”开头,只问尚未由平台事实或 MVP 规则排除的真实产品取舍,并说明为什么现在问;每张卡固定提供三个选项:A 是你的推荐方案(label 以 `A ·`、`A:`、`A:` 或 `A-` 开头并写明推荐、好处和代价),B 是形状不同且真实可行的平行备选(label 以 `B ·`、`B:`、`B:` 或 `B-` 开头并写明后果和代价),第三项逐字为“需要原型验证”,description 必须给出 30~90 分钟微型原型、试玩对象、观察信号和通过标准。自由输入按用户原话处理。 + +## 低幻觉与 GDD 约束 + +- 用户描述玩法类型、机制或“像某款游戏”时,不代表授权复刻该游戏。游戏名称、世界观、角色与单位名、阵营、资源、界面术语和视觉语言必须原创;不得沿用、翻译或近似改写现有游戏的专有名称、Logo、标志性布局与受保护视觉语言,也不得把它们写进 GDD 正文、决定台账或原型验证项。用户提到的相似作品只能作为抽象品类参考,`targetUsers.referenceGames` 同样不得填入受保护名称。你的工具面窄,但内容红线不因此放宽——GDD 是整条产线的上游。 +- 决定台账里,**事实归 Runtime、判断归你**。`decisions` 必须逐条包含 Runtime 已记录的全部决定(含首项 `initial-request`),id 用你提问时的 `id` 把下划线换成连字符;这些条目的 `answerSummary`、`answerSource`、`round` 由 Runtime 用用户的真实作答覆盖,你写占位值也会被替换,**不需要、也不要**为了抄准而改写或压缩用户原话。你真正决定的是 `topic` 和 `state`。 +- A、B 或自由填写得到的用户决定标 `confirmed`;用户选择“需要原型验证”标 `prototype_pending`,并保留同 id 的原型验证项——这两项是用户亲手选的,不得改判。只有未提问、由你按默认建议填写的字段才标 `default_pending`,其 `answerSource=default`、`round=0`。不要把用户选择的 B 当成默认项,也不要凭空把没问过的字段标成 `confirmed`——Runtime 会拒收任何没有对应用户作答的 `confirmed`。 +- 用户的自由填写没有回答你问的那道题时(他谈的是别的取舍,或者推翻了更早的决定),改这条决定的 `topic`,按他**实际说的内容**重新命名——这是你纠正错误绑定的唯一手段,Runtime 不会替你判断一句话答没答上一道题。若他对该题确实没有作出取舍,把该条降级为 `default_pending` + `answerSource=default` 并按默认建议写 `answerSummary`,再另起一条记录他实际确定下来的东西,在新条目的 `topic` 里写明与被推翻决定的关系。降级只能往这个方向;用户已作出的决定不得整条丢弃。 +- `prototypeValidationItems` 是必填字段(没有就传空数组),与 `prototype_pending` 决定**一一对应**:每条 `prototype_pending` 决定必须有一个同 id 的验证项,每个验证项也必须对应一条 `prototype_pending` 决定,最多 3 项。除了用户亲选“需要原型验证”之外,你自己也可以主动标:手感、节奏、可读性、难度曲线这类你没问过、但选错就做不出首个可玩闭环的判断,标 `prototype_pending`(`answerSource=default`、`round=0`)比标 `default_pending` 诚实——那不是一个默认值,是一个没人验证过的假设。每项写清 30~90 分钟微型原型做什么、让谁试玩、观察什么信号、什么算通过。 +- 只定义一个完整可玩闭环。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/prompts/runtime/supervisor/playbook.md b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/supervisor/playbook.md index 534b488d6..cb2123047 100644 --- a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/supervisor/playbook.md +++ b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/supervisor/playbook.md @@ -2,4 +2,8 @@ 需要等待专业 Agent 时不得调用 respond_to_user;Runtime 会通过 delegate/all-join 完成屏障保持同一父 run,取得 readyDelegateReceipts 或 readyIsolatedJoins 后直接整合结果。readyDelegateReceipts 中 contractStatus=evidence-ready 只说明终态、产物和验证等客观证据齐全,你仍须按 acceptanceCriteria 判断语义是否满足;needs-repair 不得当作成功。contractStatus=needs-user-input 时,Runtime 会按原 delivery 逐一发起 user.input_request;每个请求答案收齐后,为对应原 delivery 仅创建一次 continuation 委派,repairOfDelegationId 与 continuationOfDelegationId 都指向该原 delivery,并提交 observation 给出的 questionsSha256、answersSha256;Runtime 自动派生稳定 continuation identity,禁止跨 delivery 混用指纹。客观或语义不满足时可以发起一次新 agent.delegate,并把 repairOfDelegationId 指向已认领原 delivery;不得对返工再返工或为同一原 delivery 创建第二个返工。专业结果冲突且无法依据用户目标裁决时,合并问题后用一次 user.input_request 询问用户。只有实现路径、产品取舍或缺失事实会实质改变结果时才调用 user.input_request;项目内可读取事实、权限确认和工具失败不得伪装成用户问题。 +委派 `project-planning` 时,acceptanceCriteria 只写产物形状、覆盖范围与红线(例如必须交付 `game/fast_gdd.md`、必须原创、必须只定义一个 MVP 闭环),**不得替用户预先裁定产品取舍**。用户没有指定的玩法规则、数值、关卡量级、美术方向和目标人群,一律留给策划子 Agent 按其 3 轮问询预算决定是提问还是按默认建议填写;不要写“未指定的标注为立项假设”“自行假设后继续”这类指令,那会把问询预算作废。平台事实(自包含 Web、desktop/mobile 双视口、keyboard/touch 双输入、本地 HTTP 预览)由 Runtime 固定注入,属于已定事实,不得要求标为待定、建议或开放项。 + +对 `project-planning` 的澄清 continuation,必须按 A/B/“需要原型验证”三项合同原样转述;B 是用户确认的 `confirmed/user_option`,不能转成默认建议。若用户后续自由填写推翻已确认决定,保留用户答案原文逐字不改写、不拆分、不搬轮次,并在被推翻决定后注明“已被第 N 轮回答推翻,以后者为准”,在新决定 topic 中写明推翻关系。 + 只在所有必要回执已认领、manifest 正式任务图已经完成、所有必要返工也已认领、项目副作用已验证且没有待确认动作或待回答请求时给用户最终回复。不要向用户暴露内部 task/event、工具计划、动态 child ID 或调试状态。 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 ab38bcf1e..6d045dbd7 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 @@ -2619,6 +2619,8 @@ 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())?; @@ -2668,6 +2670,8 @@ 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())?; @@ -2789,6 +2793,7 @@ mod tests { request_slot: "slot-1".to_string(), web_search_enabled: false, allow_idle_context_compaction: false, + planning_session_binding: None, } } 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 a662ba9dc..bdc1046b5 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 @@ -336,6 +336,9 @@ 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/prompt.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/prompt.rs index a986b6be7..41e6e26b8 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,14 +488,14 @@ 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。不得创建、修改、删除或补丁 game/index.html,也不得改动任何其他程序实现、发布、音频或美术素材文件。可以调用 command.run_limited 的 game.static_smoke 提交当前 revision 的只读静态验证;不得调用 preview.start、preview.validate,也不得通过 command.exec、command.start 或其他工具启动本地预览服务、浏览器、Playwright,或执行任何桌面端、移动端试玩验证;这些集成验证必须交由程序或质量 Agent 完成。"; + let role_boundary = "角色边界:项目文件写入只允许 memory/project.md 与 game/game_design.md;配置 External Editor API Key 且任务要求界面原型时,可额外产出指定的 assets/ui-prototype.png。不得创建、修改、删除或补丁 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。"; 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。把界面结构、控件、状态和双视口要求写进玩法规格,供后续程序组直接实现;完成写入并通过当前 revision 的非浏览器验证后即可交付。{role_boundary}" + "{prompt}\n\n你负责玩法规格与界面原型基础交付。当前未配置 External Editor API Key,因此本轮必须完成 memory/project.md 与 game/game_design.md,不调用 canvas.asset_generate,也不伪造 assets/ui-prototype.png。把界面结构、控件、状态和双视口要求写进玩法规格,供后续程序组直接实现;完成写入后直接交付,不要自行运行任何验证命令。{role_boundary}" ); } format!( - "{prompt}\n\n你负责玩法规格与界面原型交付。玩法类型和机制描述不代表用户授权复刻现有游戏;必须先为项目创造原创标题、实体、资源、目标名称与视觉语言,并在 memory/project.md、game/game_design.md 和图片提示中保持一致。不得沿用或近似改写知名游戏单位、角色、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 成功动作本身就是当前 revision 的验证。已有同路径画布资产时先核对登记,再在当前 run 对且只对 assets/ui-prototype.png 调用 image.inspect;检查已通过时不得重复生成或再次扣费。只有 ui-prototype.v2 的 informationHud、gameplaySurface、objectiveEntities、primaryControls、failureRestartFlow、responsiveLayout、implementationClarity、originalTheme 八项检查全部通过才可完成。纯场景图、概念图、地图、海报或只有角色而没有可玩界面的画面都不是 UI 原型。视觉检查未通过时不得提交最终回复;只有任务正文明确标识这是带 repairOfDelegationId 的唯一返工轮时,才可使用固定输出合同和 replaceExisting=true 原位替换旧候选;不得先删除正式图片。图片生成未配置、待确认或失败时同样不得提交最终回复,也不得把计划写完当成 completed。{role_boundary}" + "{prompt}\n\n你负责玩法规格与界面原型交付。玩法类型和机制描述不代表用户授权复刻现有游戏;必须先为项目创造原创标题、实体、资源、目标名称与视觉语言,并在 memory/project.md、game/game_design.md 和图片提示中保持一致。不得沿用或近似改写知名游戏单位、角色、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 八项检查全部通过才可完成。八项视觉检查通过后直接交付,由 Runtime 在收束门内同时核对固定 owner 文档、当前 revision 与视觉证据。纯场景图、概念图、地图、海报或只有角色而没有可玩界面的画面都不是 UI 原型。视觉检查未通过时不得提交最终回复;只有任务正文明确标识这是带 repairOfDelegationId 的唯一返工轮时,才可使用固定输出合同和 replaceExisting=true 原位替换旧候选;不得先删除正式图片。图片生成未配置、待确认或失败时同样不得提交最终回复,也不得把计划写完当成 completed。{role_boundary}" ) } @@ -504,9 +504,9 @@ fn game_creator_art_director_tool_plan_prompt( editor_api_key_is_configured: bool, ) -> String { if !editor_api_key_is_configured { - return format!("{prompt}\n\n你负责确定原创视觉方向。当前未配置 External Editor API Key,只完成正式 director 文档,不调用 canvas.asset_generate,也不伪造 assets/art-spec.png。"); + 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、纯文本计划、完整游戏截图、海报或普通黑底图集冒充。已有有效同路径资产时不得重复生成或扣费;只有带 repairOfDelegationId 的唯一返工轮可设置 replaceExisting=true 原位替换。生成失败或缺少 resourceId 时不得提交最终回复,也不得把计划写完当成 completed。") + 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。") } fn game_creator_art_asset_plan_tool_plan_prompt( @@ -515,17 +515,21 @@ fn game_creator_art_asset_plan_tool_plan_prompt( ) -> String { if !editor_api_key_is_configured { return format!( - "{prompt}\n\n你负责首版美术资产清单交付。当前未配置 External Editor API Key,因此本轮必须写入可解析的 assets/manifest.art.json,记录所需素材、用途、推荐规格和当前未生成状态;不调用 canvas.asset_generate,也不伪造 assets/art-spritesheet.png。完成清单并通过当前 revision 的验证后即可交付,不得编辑 game/index.html。" + "{prompt}\n\n你负责首版美术资产清单交付。当前未配置 External Editor API Key,因此本轮必须写入可解析的 assets/manifest.art.json,记录所需素材、用途、推荐规格和当前未生成状态;不调用 canvas.asset_generate,也不伪造 assets/art-spritesheet.png。完成清单后直接交付,由 Runtime 验证本人固定 owner 产物;不得调用 project.verify、game.static_smoke 或 preview.validate,也不得编辑 game/index.html。" ); } 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 时可保留完整透明图集,但不得声称独立切片已生成。已有有效同路径资产时不得重复生成或扣费;只有带 repairOfDelegationId 的唯一返工轮可 replaceExisting=true 原位替换。不得运行 game.static_smoke 或 preview.validate,也不得编辑 game/index.html。图片生成未配置、待确认、失败或透明证据不足时不得提交最终回复。" + "{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。图片生成未配置、待确认、失败或透明证据不足时不得提交最终回复。" ) } 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( @@ -545,13 +549,46 @@ 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()) + game_creator_project_supervisor_tool_plan_prompt( + &prompt, + editor_api_key_is_configured(), + source, + ) } +/// 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/game-chat/未知)都必须逐字 +/// 保留既有合成结果,不能被这里的分支误伤。 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 { @@ -567,6 +604,35 @@ 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 与 MCP 工具在本 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() @@ -581,19 +647,25 @@ pub(crate) fn required_runtime_prompt_section(section_id: &str) -> &'static str .unwrap_or_else(|| panic!("生成的 Prompt Bundle 缺少 section:{section_id}")) } +/// 每个 composition item 先交给 `dynamic` 闭包,让调用方有机会按运行时条件 +/// (如 M1A-4 的 plan 根 source)覆盖或整段跳过(返回 `Some("")`,会在 +/// `render_runtime_prompt_sections` 里被 trim 后过滤掉)。`dynamic` 返回 +/// `None` 表示"这个 item 不关心":`$` 开头的动态 marker 必须有人接管,找不到 +/// 就是 Prompt Bundle 配置错误,直接 panic;普通 section id 则退回既有的 +/// `required_runtime_prompt_section` 查找。既有调用方的闭包对不认识的普通 +/// section id 一律返回 None,因此这次改动不改变它们的既有输出。 fn render_runtime_prompt_composition<'a>( composition: &[&str], mut dynamic: impl FnMut(&str) -> Option<&'a str>, ) -> String { let sections = composition .iter() - .map(|item| { - if item.starts_with('$') { - dynamic(item) - .unwrap_or_else(|| panic!("Prompt composition 缺少动态 marker:{item}")) - } else { - required_runtime_prompt_section(item) + .map(|item| match dynamic(item) { + Some(value) => value, + None if item.starts_with('$') => { + panic!("Prompt composition 缺少动态 marker:{item}") } + None => required_runtime_prompt_section(item), }) .collect::>(); render_runtime_prompt_sections(§ions) @@ -667,6 +739,16 @@ 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| { @@ -808,6 +890,192 @@ 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 [ + format!("不超过 {AGENT_RUNTIME_USER_INPUT_MAX_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} 字符"), + format!( + "{AGENT_RUNTIME_USER_INPUT_MIN_OPTIONS}~{AGENT_RUNTIME_USER_INPUT_MAX_OPTIONS} 个" + ), + ] { + assert!( + planning.contains(&stated), + "brief 与 schema 不一致:缺少「{stated}」" + ); + } + } + + #[test] + /// 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` 明确排除在清单外:它们就是首个可玩闭环本身, + /// 给它们配默认值等于把最该花提问预算的那两项默认掉。原型那份清单里的 + /// 成长 / 探索 / 构建三条落到本仓库的 schema 上正好落在这两个字段上,照抄 + /// 会和「提问顺序:核心行为与本局目标 → 重玩动力」的前两顺位直接打架。 + /// + /// 三、出稿触发器是闭集。生产实测过 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 [ + "`genre.fusion`", + "`artStyle`", + "`targetUsers.sessionLength`", + "`targetUsers.referenceGames`", + "`outOfScope`", + ] { + assert!(planning.contains(field), "默认建议清单缺少字段 {field}"); + } + 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(); @@ -1091,6 +1359,7 @@ 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", @@ -1115,8 +1384,16 @@ mod tests { #[test] fn supervisor_editor_prompt_has_one_art_asset_plan_owner_contract() { - let prompt = game_creator_project_supervisor_tool_plan_prompt("", true); - let without_editor = game_creator_project_supervisor_tool_plan_prompt("", false); + 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, + ); assert!(prompt.contains( "art-asset-plan 只声明 assets/manifest.art.json 与 assets/art-spritesheet.png" @@ -1130,12 +1407,375 @@ 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 选项几乎是用户原话的复述。 + #[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_GAME_CHAT_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/game-chat 等非 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, + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_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", @@ -1178,12 +1818,16 @@ mod tests { assert!(prompt.contains("项目文件写入只允许 memory/project.md 与 game/game_design.md")); assert!(prompt.contains("不得创建、修改、删除或补丁 game/index.html")); - assert!(prompt.contains("可以调用 command.run_limited 的 game.static_smoke")); - assert!(prompt.contains("不得调用 preview.start、preview.validate")); + 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("这些集成验证必须交由程序或质量 Agent 完成")); } } @@ -1202,6 +1846,9 @@ mod tests { 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("informationHud")); assert!(with_canvas.contains("failureRestartFlow")); assert!(with_canvas.contains("不得假设为塔防")); @@ -1211,6 +1858,24 @@ mod tests { .contains("不得误用 POST /api/external/v1/editor/ui-designs/assets/extractions")); } + #[test] + fn agent_prompt_art_director_switches_between_read_only_and_conditional_canvas_owner() { + 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 形成验证凭证")); + } + #[test] fn agent_prompt_art_asset_plan_uses_fixed_transparent_spritesheet_route_and_warnings() { let without_canvas = @@ -1234,13 +1899,16 @@ mod tests { 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 产物")); } #[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"); + let code_agent_prompt = game_creator_agent_runtime_tool_plan_system_prompt_for_agent( + "code-prototype", + AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, + ); 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 368af149b..c33708993 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,6 +44,7 @@ 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)] @@ -72,7 +73,9 @@ pub(crate) use autonomous_policy::{ 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_is_parallel_safe_read, game_creator_agent_runtime_parallel_read_batch_path, + 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, }; @@ -113,6 +116,7 @@ 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, }; @@ -131,17 +135,28 @@ pub(crate) use response_stream::filter_agent_runtime_response_stream_for_test; pub(crate) use structured_plan::{ activate_agent_runtime_plan_step, activate_agent_runtime_response_plan_step, apply_agent_runtime_plan_update, complete_agent_runtime_active_plan_step, - complete_agent_runtime_remaining_plan_steps, retry_agent_runtime_active_plan_step, - sanitize_agent_runtime_plan_update, + complete_agent_runtime_remaining_plan_steps, plan_update_idle_rounds_require_repair, + retry_agent_runtime_active_plan_step, sanitize_agent_runtime_plan_update, + AgentRuntimePlanUpdateOutcome, }; +// 不带 agentId 的三个解析入口走 `"__all_agents__"` 哨兵、跳过按身份的工具面 +// 复核,只对测试开放;生产代码必须用 `_for_agent`。 +#[cfg(test)] pub(crate) use tool_plan_protocol::{ parse_game_creator_agent_tool_plan_llm_response, parse_game_creator_agent_tool_plan_llm_response_with_catalog, parse_game_creator_agent_tool_plan_llm_response_with_catalog_classified, +}; +pub(crate) use tool_plan_protocol::{ + parse_game_creator_agent_tool_plan_llm_response_with_catalog_classified_for_agent, parse_game_creator_agent_tool_plan_response, }; pub(crate) use tool_policy_snapshot::{ - agent_runtime_acceptance_evidence_tools, agent_runtime_executable_tools, - agent_runtime_native_executable_tools, agent_runtime_tool_policy_snapshot_for_run_at, - AGENT_RUNTIME_CANVAS_ASSET_KINDS, + 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, + PlanRootSupervisorStage, AGENT_RUNTIME_CANVAS_ASSET_KINDS, + AGENT_RUNTIME_PROJECT_PLANNING_ACTION_TOOLS, }; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_audit.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_audit.rs index 2bddba426..2cecd843c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_audit.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_audit.rs @@ -573,6 +573,28 @@ fn agent_runtime_action_receipt_safe_detail_with_owner( })) .ok(); } + if observation.tool == "file.read" { + let first_line = observation.detail.as_deref()?.lines().next()?.trim(); + let mut fields = first_line.split('·').map(str::trim); + let path = normalize_relative_path(fields.next()?).ok()?; + let sha_field = fields.next()?; + let content_sha256 = sha_field.strip_prefix("sha256=")?; + if content_sha256.len() != 64 + || !content_sha256.bytes().all(|byte| byte.is_ascii_hexdigit()) + { + return None; + } + let lines = fields.next()?.strip_prefix("lines ")?; + if lines.is_empty() || path.is_empty() { + return None; + } + return serde_json::to_string(&serde_json::json!({ + "path": path, + "contentSha256": content_sha256, + "lines": lines, + })) + .ok(); + } if observation.tool == GAME_CREATOR_MCP_CALL_TOOL { return game_creator_mcp_public_result_metadata( observation.detail.as_deref().unwrap_or_default(), 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 71f601ca4..0c5d4467b 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 @@ -37,6 +37,29 @@ pub(crate) async fn execute_game_creator_agent_runtime_tool_action_with_pending_ pending_action: Option<&AgentRuntimePendingToolAction>, ) -> AgentRuntimeToolObservation { let tool = action.tool.trim(); + 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( @@ -260,16 +283,25 @@ pub(crate) async fn execute_game_creator_agent_runtime_tool_action_with_pending_ false, || observe_agent_runtime_file_list(root, &action.input), ), - "file.read" => observe_agent_runtime_project_snapshot_with_lock( - root, - agent_id, - run_id, - action, - &action_fingerprint, - pending_action, - false, - || observe_agent_runtime_file(root, &action.input), - ), + "file.read" => { + let mut observation = observe_agent_runtime_project_snapshot_with_lock( + root, + agent_id, + run_id, + action, + &action_fingerprint, + pending_action, + false, + || observe_agent_runtime_file(root, &action.input), + ); + append_agent_runtime_file_read_evidence_ref( + &mut observation, + agent_id, + run_id, + action_id, + ); + observation + } "file.write" => observe_agent_runtime_file_write( root, agent_id, @@ -406,7 +438,7 @@ pub(crate) async fn execute_game_creator_agent_runtime_tool_action_with_pending_ "agent.message" => { observe_agent_runtime_agent_message(root, agent_id, run_id, &action.input) } - "agent.delegate" => observe_agent_runtime_project_snapshot_with_lock( + "agent.delegate" => observe_agent_runtime_project_snapshot_with_lock_guard( root, agent_id, run_id, @@ -414,13 +446,14 @@ pub(crate) async fn execute_game_creator_agent_runtime_tool_action_with_pending_ &action_fingerprint, pending_action, true, - || { - observe_agent_runtime_agent_delegate( + |project_write_lock| { + observe_agent_runtime_agent_delegate_at_locked( root, agent_id, run_id, action_id, &action.input, + project_write_lock, ) }, ), @@ -489,6 +522,60 @@ pub(crate) async fn execute_game_creator_agent_runtime_tool_action_with_pending_ } } +/// 让 `file.read` 的 observation 自带 durable actionId。 +/// +/// §13.0 的审批前置取证要求把每一页 `file.read` 的 actionId 放进 +/// `agent.acceptance_update.evidence`,而 observation 结构里只有 +/// `{tool,status,summary,detail}`,模型没有第二条途径拿到它——只能回头查 +/// `agent.action_history`。实测它会为了一个 actionId 连查四次:拿到 1 条怀疑漏了 +/// 分页,拿到全量又怀疑混进了别的工具,而这些结果自始至终都在它上下文里 +/// (`agent.action_history` 的 observation detail 有 8000 字符的专属额度,不会被裁)。 +/// 加 prompt 约束对这种"不敢信"没用,把取 id 这件事从工具变成事实才有用。 +/// +/// `command.exec` 早就是这么做的:durable observation 直接返回可复用的 +/// `sourceActionId`,合同里明写「不要先猜 actionId 或为取得它额外查询动作历史」。 +/// 这里把同一条路铺给 `file.read`。 +/// +/// 追加在 detail 首行末尾是安全的:`agent_runtime_action_safe_detail_value` 解析 +/// `file.read` 时只取前三个 `·` 字段(path / sha256 / lines),多出来的字段不参与, +/// durable receipt 与既有的取证解析都不受影响。 +fn append_agent_runtime_file_read_evidence_ref( + observation: &mut AgentRuntimeToolObservation, + agent_id: &str, + run_id: &str, + action_id: Option<&str>, +) { + if observation.status != "ok" { + return; + } + let Some(action_id) = action_id.map(str::trim).filter(|value| !value.is_empty()) else { + return; + }; + if observation.summary.contains("sourceActionId=") { + return; + } + let agent_id = agent_id.trim(); + let run_id = run_id.trim(); + if agent_id.is_empty() || run_id.is_empty() { + return; + } + // 写进 **summary** 而不是 detail:`file.read` 的 detail 在投影给模型和事件流之前 + // 会被换成 durable receipt 的 safe_detail(只有 path / contentSha256 / lines 三个 + // 字段),追加在 detail 上的字段到不了模型手里。实测一次 run 里模型照着新指令 + // 满世界找 sourceActionId、一次也没看到,只能反复重读全文再猜 actionId,28 次 + // file.read、5 次 acceptance_update 才收敛。summary 是原样保留的,也没有任何 + // 解析方依赖它的形状。 + // 给的是**整个三元组**,不是一个碎片。`agent.acceptance_update` 的 evidence 引用 + // 要求 {agentId, runId, actionId} 三个字段,回执查找也按三元组整体做 key。早期 + // 只给 actionId,另外两个靠模型回忆——实测它第一次就把其中一个记错,白吃一次 + // 拒绝。反正 `validate_fast_gdd_evidence_identity` 只接受当前根 run 的回执, + // 合法取值唯一,本来就不该让它猜。 + observation.summary = format!( + "{} · sourceAgentId={agent_id} · sourceRunId={run_id} · sourceActionId={action_id}", + observation.summary + ); +} + pub(in crate::agent) fn observe_agent_runtime_project_snapshot_with_lock( root: &Path, agent_id: &str, @@ -501,6 +588,31 @@ pub(in crate::agent) fn observe_agent_runtime_project_snapshot_with_lock( ) -> AgentRuntimeToolObservation where F: FnOnce() -> AgentRuntimeToolObservation, +{ + observe_agent_runtime_project_snapshot_with_lock_guard( + root, + agent_id, + run_id, + action, + action_fingerprint, + pending_action, + validate_revision_gate, + |_| observe(), + ) +} + +pub(in crate::agent) fn observe_agent_runtime_project_snapshot_with_lock_guard( + root: &Path, + agent_id: &str, + run_id: &str, + action: &AgentRuntimeToolAction, + action_fingerprint: &str, + pending_action: Option<&AgentRuntimePendingToolAction>, + validate_revision_gate: bool, + observe: F, +) -> AgentRuntimeToolObservation +where + F: FnOnce(&ProjectWriteLock) -> AgentRuntimeToolObservation, { let tool = action.tool.trim(); let _lock = match acquire_game_creator_agent_runtime_project_write_lock_with_wait( @@ -528,7 +640,7 @@ where ) { return observation; } - observe() + observe(&_lock) } pub(in crate::agent) fn validate_agent_runtime_project_snapshot_action_after_lock( @@ -814,3 +926,93 @@ mod canvas_only_execution_tests { ); } } + +#[cfg(test)] +mod file_read_source_action_id_tests { + use super::*; + + fn file_read_observation() -> AgentRuntimeToolObservation { + AgentRuntimeToolObservation { + tool: "file.read".to_string(), + status: "ok".to_string(), + summary: "已读取 game/fast_gdd.md 第 1-134 行(共 134 行)".to_string(), + detail: Some(format!( + "game/fast_gdd.md · sha256={} · lines 1-134 of 134 +第一行内容", + "a".repeat(64) + )), + } + } + + /// 追加的 sourceActionId 必须能被模型看见,且不能破坏 durable receipt 的 + /// safe_detail 解析——取证门就是从那里读 path / contentSha256 / lines 的。 + #[test] + fn appending_the_source_action_id_keeps_the_receipt_safe_detail_parseable() { + let mut observation = file_read_observation(); + append_agent_runtime_file_read_evidence_ref( + &mut observation, + "project-supervisor", + "run-1", + Some("action-0123456789abcdef01234567"), + ); + assert!( + observation.summary.ends_with( + "· sourceAgentId=project-supervisor · sourceRunId=run-1 · sourceActionId=action-0123456789abcdef01234567" + ), + "evidence 三元组必须整份写在 summary 上——detail 会被换成 safe_detail,到不了模型手里;只给 actionId 则另外两个字段要靠模型回忆,实测会记错", + ); + let detail = observation.detail.clone().expect("detail"); + assert!(!detail.contains("sourceActionId="), "detail 保持原样"); + assert!(detail.contains("第一行内容"), "首行之后的内容必须保留"); + + let root = std::env::temp_dir(); + let safe_detail = crate::agent::runtime_actions::action_audit:: + agent_runtime_action_receipt_public_safe_detail_for_test(&root, &observation) + .expect("safe detail still parses"); + let value = serde_json::from_str::(&safe_detail).expect("json"); + assert_eq!(value["path"], "game/fast_gdd.md"); + assert_eq!(value["lines"], "1-134 of 134"); + assert_eq!(value["contentSha256"], "a".repeat(64)); + } + + /// 失败的读取、缺 actionId、以及重复调用都不得改写 detail。 + #[test] + fn appending_is_a_no_op_without_a_successful_read_or_an_action_id() { + let mut failed = file_read_observation(); + failed.status = "failed".to_string(); + let before = failed.summary.clone(); + append_agent_runtime_file_read_evidence_ref( + &mut failed, + "project-supervisor", + "run-1", + Some("action-1"), + ); + assert_eq!(failed.summary, before); + + let mut missing = file_read_observation(); + let before = missing.summary.clone(); + append_agent_runtime_file_read_evidence_ref( + &mut missing, + "project-supervisor", + "run-1", + None, + ); + assert_eq!(missing.summary, before); + + let mut twice = file_read_observation(); + append_agent_runtime_file_read_evidence_ref( + &mut twice, + "project-supervisor", + "run-1", + Some("action-1"), + ); + let once = twice.summary.clone(); + append_agent_runtime_file_read_evidence_ref( + &mut twice, + "project-supervisor", + "run-1", + Some("action-2"), + ); + assert_eq!(twice.summary, once, "已经带了 id 就不再追加第二个"); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_projection.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_projection.rs index 9410885e8..6bdfb94b0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_projection.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_projection.rs @@ -4,6 +4,32 @@ pub(in crate::agent) fn persist_game_creator_agent_user_input_wait_at( root: &Path, runtime: &mut AgentRuntimeState, pending: &mut AgentRuntimePendingToolAction, +) -> Result<(), String> { + persist_game_creator_agent_user_input_wait_with_project_lock_at(root, runtime, pending, None) +} + +pub(in crate::agent) fn persist_game_creator_agent_user_input_wait_at_locked( + root: &Path, + runtime: &mut AgentRuntimeState, + pending: &mut AgentRuntimePendingToolAction, + project_lock: &ProjectWriteLock, +) -> Result<(), String> { + if !project_lock.guards_project_root(root)? { + return Err("持久化 planning 用户输入等待缺少当前项目写锁".to_string()); + } + persist_game_creator_agent_user_input_wait_with_project_lock_at( + root, + runtime, + pending, + Some(project_lock), + ) +} + +fn persist_game_creator_agent_user_input_wait_with_project_lock_at( + root: &Path, + runtime: &mut AgentRuntimeState, + pending: &mut AgentRuntimePendingToolAction, + project_lock: Option<&ProjectWriteLock>, ) -> Result<(), String> { if runtime.run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD { return Err("自主构建 Run 禁止进入 waiting-for-user-input".to_string()); @@ -13,7 +39,13 @@ pub(in crate::agent) fn persist_game_creator_agent_user_input_wait_at( pending.observation = None; pending.updated_at = unix_timestamp(); write_game_creator_agent_runtime_pending_tool_action(root, pending)?; - let request = match prepare_game_creator_agent_user_input_request_at(root, pending)? { + let recovered_user_input = match project_lock { + 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 request = match recovered_user_input { AgentRuntimeUserInputRecovery::Waiting(request) => request, AgentRuntimeUserInputRecovery::Answered { .. } => { return Err("新建用户输入等待时 sidecar 已进入 answered,需由恢复路径继续".to_string()); 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 f0bc0f716..55c1e2a64 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 @@ -269,11 +269,20 @@ pub(super) const AGENT_RUNTIME_AUTONOMOUS_SPECIALIST_MUTATION_ONLY_REPAIR_ERROR_ "自主构建非只读专业 Agent 必须先完成本人 run 的项目修改"; pub(super) const AGENT_RUNTIME_AUTONOMOUS_SPECIALIST_VERIFICATION_ONLY_REPAIR_ERROR_PREFIX: &str = "自主构建非只读专业 Agent 必须先验证本人 run 的项目修改"; +pub(super) const AGENT_RUNTIME_AUTONOMOUS_OWNER_ARTIFACT_IDENTITY_ERROR_PREFIX: &str = + "自主构建固定 owner 的 Runtime 内部产物验证身份不可用"; pub(super) const AGENT_RUNTIME_AUTONOMOUS_READ_ONLY_MUTATION_ERROR_PREFIX: &str = "自主构建只读专业 Agent 禁止执行写入或副作用动作"; pub(in crate::agent) const AGENT_RUNTIME_AUTONOMOUS_ART_DIRECTOR_CANVAS_ONLY_TASK_MARKER: &str = "唯一允许的项目写动作是 canvas.asset_generate"; +pub(in crate::agent) fn agent_runtime_autonomous_uses_owner_artifact_validation( + agent_id: &str, +) -> bool { + !autonomous_manifest_owner_artifact_paths(agent_id).is_empty() + && !matches!(agent_id, "code-prototype" | "publish-package") +} + fn autonomous_initial_collaboration_contract_error(detail: impl AsRef) -> String { format!( "{AGENT_RUNTIME_SUPERVISOR_INITIAL_COLLABORATION_LIVENESS_ERROR_PREFIX};autonomous-game-build 首批协作合同无效:{}", @@ -606,7 +615,7 @@ pub(crate) fn refresh_agent_runtime_autonomous_convergence_snapshot_after_provid )) } -pub(super) fn validate_agent_runtime_autonomous_plan_liveness_at( +pub(in crate::agent) fn validate_agent_runtime_autonomous_plan_liveness_at( root: &Path, agent_id: &str, run_id: &str, @@ -639,17 +648,23 @@ pub(super) fn validate_agent_runtime_autonomous_plan_liveness_at( && agent_runtime_autonomous_supervisor_plan_prepares_repair(plan) { let barrier = static_delegate_completion_barrier_at(root, agent_id, run_id)?; - if barrier.ready_unclaimed_count > 0 || barrier.unobserved_claim_count > 0 { + if barrier.ready_unclaimed_count > 0 + || barrier.unobserved_claim_count > 0 + || barrier.user_revision_pending_count > 0 + || barrier.unknown_contract_status_count > 0 + { let active_delegations = active_static_delegate_delivery_count_at(root, agent_id, run_id)?; if is_agent_runtime_autonomous_supervisor_delivery_convergence_plan(plan) { return Ok(()); } return Err(format!( - "{AGENT_RUNTIME_AUTONOMOUS_SUPERVISOR_DELIVERY_CONVERGENCE_LIVENESS_ERROR_PREFIX};当前父 run 在准备专业 repair 前仍有 activeDelegations={active_delegations}、readyUnclaimedReceipts={}、unobservedReceiptClaims={}、repairRequired={}。必须先只调用 agent.run_status(agentId=null、scope=all、delegationId=null)原子认领并观察已有回执,再基于权威合同准备 repair", + "{AGENT_RUNTIME_AUTONOMOUS_SUPERVISOR_DELIVERY_CONVERGENCE_LIVENESS_ERROR_PREFIX};当前父 run 在准备专业 repair 前仍有 activeDelegations={active_delegations}、readyUnclaimedReceipts={}、unobservedReceiptClaims={}、repairRequired={}、userRevisionPending={}、unknownContractStatus={}。必须先只调用 agent.run_status(agentId=null、scope=all、delegationId=null)原子认领并观察已有回执,再基于权威合同准备 repair", barrier.ready_unclaimed_count, barrier.unobserved_claim_count, barrier.repair_required_count, + barrier.user_revision_pending_count, + barrier.unknown_contract_status_count, )); } } @@ -663,21 +678,34 @@ pub(super) fn validate_agent_runtime_autonomous_plan_liveness_at( let active_delegations = active_static_delegate_delivery_count_at(root, agent_id, run_id)?; let must_claim_or_wait = barrier.ready_unclaimed_count > 0 || barrier.unobserved_claim_count > 0 + || barrier.user_revision_pending_count > 0 + || barrier.unknown_contract_status_count > 0 || active_delegations >= 3; if must_claim_or_wait { if is_agent_runtime_autonomous_supervisor_delivery_convergence_plan(plan) { return Ok(()); } return Err(format!( - "{AGENT_RUNTIME_AUTONOMOUS_SUPERVISOR_DELIVERY_CONVERGENCE_LIVENESS_ERROR_PREFIX};当前父 run 的 activeDelegations={active_delegations}、waitingDelegations={}、readyUnclaimedReceipts={}、unobservedReceiptClaims={}。必须先只调用 agent.run_status(agentId=null、scope=all、delegationId=null)认领并观察 ready delivery,或等待已有委派推进;不得创建第四次 agent.delegate。收束后若项目 revision 已推进,再验证当前 revision", + "{AGENT_RUNTIME_AUTONOMOUS_SUPERVISOR_DELIVERY_CONVERGENCE_LIVENESS_ERROR_PREFIX};当前父 run 的 activeDelegations={active_delegations}、waitingDelegations={}、readyUnclaimedReceipts={}、unobservedReceiptClaims={}、userRevisionPending={}、unknownContractStatus={}。必须先只调用 agent.run_status(agentId=null、scope=all、delegationId=null)认领并观察 ready delivery,或等待已有委派推进;不得创建第四次 agent.delegate。收束后若项目 revision 已推进,再验证当前 revision", barrier.waiting_count, barrier.ready_unclaimed_count, barrier.unobserved_claim_count, + barrier.user_revision_pending_count, + barrier.unknown_contract_status_count, )); } } validate_game_chat_code_safe_default_repair_liveness_at(root, agent_id, run_id, plan)?; + if autonomous_owner_artifact_validation_available_for_run_at(root, agent_id, run_id)? + && !plan.response.trim().is_empty() + { + // 固定 pre-code 产物没有 Provider 可调用的验证动作。专业回复门确认 + // 当前 run 已有 mutation 后,即使此前累积了较长只读尾部,也必须让 + // finalization 进入 Runtime 确定性验证,不能再强迫模型伪造验证动作。 + return Ok(()); + } + let has_current_playtest_receipt = if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { let contract = read_autonomous_completion_contract(root, agent_id, run_id)?; match contract.as_ref() { @@ -1050,14 +1078,22 @@ pub(in crate::agent) fn agent_runtime_autonomous_verified_delivery_allows_plan_c agent_id: &str, verification_gate: &AgentRuntimeVerificationGate, ) -> bool { - agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID - && verification_gate - .mutation_revision - .is_some_and(|mutation_revision| { - verification_gate - .verified_revision - .is_some_and(|verified_revision| verified_revision >= mutation_revision) - }) + if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { + return false; + } + let Some(mutation_revision) = verification_gate.mutation_revision else { + return false; + }; + if agent_id == "code-prototype" + && !verification_gate + .static_smoke_verified_revision + .is_some_and(|revision| revision >= mutation_revision) + { + return false; + } + verification_gate + .verified_revision + .is_some_and(|verified_revision| verified_revision >= mutation_revision) && verification_gate.last_verification_status.as_deref() == Some(AGENT_RUNTIME_VERIFICATION_STATUS_PASSED) } @@ -1188,9 +1224,11 @@ pub(in crate::agent) fn agent_runtime_autonomous_art_director_canvas_only_action } pub(in crate::agent) fn validate_agent_runtime_autonomous_specialist_response_delivery( + root: &Path, agent_id: &str, run_id: &str, read_only_delivery: bool, + runtime_owner_artifact_validation_available: bool, verification_gate: &AgentRuntimeVerificationGate, plan: &AgentRuntimeToolPlan, ) -> Result<(), String> { @@ -1208,12 +1246,42 @@ pub(in crate::agent) fn validate_agent_runtime_autonomous_specialist_response_de "{AGENT_RUNTIME_AUTONOMOUS_SPECIALIST_MUTATION_ONLY_REPAIR_ERROR_PREFIX};当前 respond_to_user 没有本人 run 的 mutationRevision。必须先执行实际项目修改,不能以其它 Agent 的 revision、只读检查、空验证或任务文案代替" )); } + if runtime_owner_artifact_validation_available + && agent_runtime_autonomous_uses_owner_artifact_validation(agent_id) + { + return Ok(()); + } + let trusted_game_chat_canvas_delivery = agent_id == "art-asset-plan" + && game_chat_delegated_art_asset_plan_uses_canvas_verification_at(root, agent_id, run_id)?; + if agent_runtime_autonomous_uses_owner_artifact_validation(agent_id) + && !trusted_game_chat_canvas_delivery + { + return Err(format!( + "{AGENT_RUNTIME_AUTONOMOUS_OWNER_ARTIFACT_IDENTITY_ERROR_PREFIX};当前 {agent_id}/{run_id} 既不是可信完整 autonomous DAG 的当前固定 owner child,也不是可信 game-chat code-prototype 的当前动态美术委派,不能借用 Runtime 内部验证或普通 Canvas 凭证" + )); + } + if trusted_game_chat_canvas_delivery + && (verification_gate.last_verification_tool.as_deref() != Some("canvas.asset_generate") + || verification_gate.static_smoke_verified_revision.is_some()) + { + return Err(format!( + "{AGENT_RUNTIME_AUTONOMOUS_SPECIALIST_VERIFICATION_ONLY_REPAIR_ERROR_PREFIX};可信 game-chat 动态美术委派只接受本人 canvas.asset_generate 的通过凭证,不能借用 project.verify、game.static_smoke 或其它验证" + )); + } if !agent_runtime_autonomous_verified_delivery_allows_plan_completion( agent_id, verification_gate, ) { + let required_verification = if agent_id == "code-prototype" { + format!( + "code-prototype 必须由本人 run 执行 game.static_smoke,且 staticSmokeVerifiedRevision 覆盖 mutationRevision;当前 staticSmokeVerifiedRevision={:?}", + verification_gate.static_smoke_verified_revision + ) + } else { + "必须先只验证本人 run 的最新 mutation revision".to_string() + }; return Err(format!( - "{AGENT_RUNTIME_AUTONOMOUS_SPECIALIST_VERIFICATION_ONLY_REPAIR_ERROR_PREFIX};当前 mutationRevision={:?}、verifiedRevision={:?}、verificationStatus={}。必须先只验证本人 run 的最新 mutation revision,通过后才能 respond_to_user", + "{AGENT_RUNTIME_AUTONOMOUS_SPECIALIST_VERIFICATION_ONLY_REPAIR_ERROR_PREFIX};当前 mutationRevision={:?}、verifiedRevision={:?}、verificationStatus={}。{required_verification},通过后才能 respond_to_user", verification_gate.mutation_revision, verification_gate.verified_revision, verification_gate @@ -2139,6 +2207,119 @@ mod tests { } } + #[test] + fn autonomous_owner_delivery_uses_internal_validation_only_for_fixed_pre_code_roles() { + let temporary = tempfile::tempdir().expect("create owner delivery policy root"); + let root = temporary.path().join("project"); + init_local_game_project_at(&root, "owner-delivery-policy", "owner 收束策略") + .expect("init owner delivery policy project"); + let plan = AgentRuntimeToolPlan { + response: "玩法规格与界面基础已经完成。".to_string(), + ..AgentRuntimeToolPlan::default() + }; + let gate_for = |agent_id: &str, run_id: &str| AgentRuntimeVerificationGate { + schema_version: "test".to_string(), + project_id: "test".to_string(), + agent_id: agent_id.to_string(), + run_id: run_id.to_string(), + requires_verification: true, + mutation_revision: Some(7), + verified_revision: None, + last_mutation_tool: Some("file.write".to_string()), + last_verification_tool: None, + last_verification_status: None, + static_smoke_verified_revision: None, + static_smoke_verified_game_index_sha256: None, + failed_playtest_revision: None, + updated_at: 0, + }; + + for agent_id in [ + "design-foundation", + "balance-seed", + "art-asset-plan", + "audio-asset-plan", + ] { + assert!(agent_runtime_autonomous_uses_owner_artifact_validation( + agent_id + )); + let run_id = format!("{agent_id}-run"); + let gate = gate_for(agent_id, &run_id); + let unavailable_error = validate_agent_runtime_autonomous_specialist_response_delivery( + &root, agent_id, &run_id, false, false, &gate, &plan, + ) + .expect_err("fixed owner must not fall back to Provider-visible verification"); + assert!(unavailable_error + .contains(AGENT_RUNTIME_AUTONOMOUS_OWNER_ARTIFACT_IDENTITY_ERROR_PREFIX)); + assert!(!unavailable_error.starts_with( + AGENT_RUNTIME_AUTONOMOUS_SPECIALIST_VERIFICATION_ONLY_REPAIR_ERROR_PREFIX + )); + + validate_agent_runtime_autonomous_specialist_response_delivery( + &root, agent_id, &run_id, false, true, &gate, &plan, + ) + .expect("trusted Runtime owner-artifact validation may run inside finalization"); + + let mut manually_verified = gate.clone(); + manually_verified.verified_revision = Some(7); + manually_verified.last_verification_tool = Some("game.static_smoke".to_string()); + manually_verified.last_verification_status = + Some(AGENT_RUNTIME_VERIFICATION_STATUS_PASSED.to_string()); + manually_verified.static_smoke_verified_revision = Some(7); + assert!( + validate_agent_runtime_autonomous_specialist_response_delivery( + &root, + agent_id, + &run_id, + false, + false, + &manually_verified, + &plan, + ) + .expect_err("manual smoke must not replace fixed owner Runtime validation identity") + .contains(AGENT_RUNTIME_AUTONOMOUS_OWNER_ARTIFACT_IDENTITY_ERROR_PREFIX) + ); + + let mut wrong_run_gate = gate; + wrong_run_gate.run_id = "other-run".to_string(); + assert!( + validate_agent_runtime_autonomous_specialist_response_delivery( + &root, + agent_id, + &run_id, + false, + true, + &wrong_run_gate, + &plan, + ) + .expect_err("another run's gate must not authorize Runtime owner validation") + .contains("verification gate 与当前 run 身份不匹配") + ); + } + + for agent_id in ["code-prototype", "publish-package", "art-director"] { + assert!(!agent_runtime_autonomous_uses_owner_artifact_validation( + agent_id + )); + } + let publish_gate = gate_for("publish-package", "publish-package-run"); + assert!( + validate_agent_runtime_autonomous_specialist_response_delivery( + &root, + "publish-package", + "publish-package-run", + false, + true, + &publish_gate, + &plan, + ) + .expect_err( + "caller flag must not classify publish-package as an internal-validation owner" + ) + .contains(AGENT_RUNTIME_AUTONOMOUS_SPECIALIST_VERIFICATION_ONLY_REPAIR_ERROR_PREFIX) + ); + } + #[test] fn autonomous_read_only_validation_actions_are_role_scoped() { let plan_for = |tool: &str, input: serde_json::Value| AgentRuntimeToolPlan { 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 33788355e..72c7d2f73 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,6 +25,11 @@ 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, @@ -60,7 +65,20 @@ pub(in crate::agent) async fn compact_game_creator_agent_runtime_context_at( let app_config = load_game_creator_app_config()?; let llm = resolve_game_creator_llm_config_for_agent(&app_config, &template_agent_id); let config_path = format!("agentLlm.{template_agent_id}"); - let request = build_game_creator_agent_runtime_context_compaction_request(&source, &llm)?; + 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, @@ -88,6 +106,22 @@ 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 = @@ -154,6 +188,7 @@ 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); @@ -177,6 +212,7 @@ 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); @@ -210,6 +246,7 @@ 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 386077767..a7defc27e 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 @@ -41,6 +41,9 @@ pub(in crate::agent) fn agent_runtime_parallel_read_batch_is_auto_at( actions: &[AgentRuntimeToolAction], ) -> bool { actions.iter().all(|action| { + if !agent_runtime_tool_allowed_for_agent(agent_id, action.tool.trim()) { + return false; + } let Some(command_id) = game_creator_agent_runtime_tool_command_id(action.tool.trim()) else { return false; @@ -104,11 +107,125 @@ pub(in crate::agent) fn game_creator_agent_runtime_tool_command_id( "agent.route_manifest" => Some("agent.route_manifest"), "agent.action_history" => Some("agent.audit"), "agent.run_status" => Some("agent.run_status"), + PLAN_SUBMIT_GDD_TOOL => Some(PLAN_SUBMIT_GDD_TOOL), GAME_CREATOR_MCP_CALL_TOOL => Some(GAME_CREATOR_MCP_CALL_TOOL), _ => None, } } +/// Check the original provider/runtime tool identity before translating it to +/// a project permission command. Some tools intentionally share a command +/// 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; + } + 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 + // standard Agents and is separately denied for autonomous profiles. + return true; + } + 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" + )); + } +} + pub(crate) fn agent_runtime_confirmation_path_component(value: &str, fallback: &str) -> String { let normalized = value .trim() diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/parallel_read.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/parallel_read.rs index 505eea32c..e11fcd9e9 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/parallel_read.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/parallel_read.rs @@ -140,6 +140,12 @@ pub(in crate::agent) fn validate_game_creator_agent_runtime_parallel_read_pendin root, &error, ))); } + if !agent_runtime_tool_allowed_for_agent(&pending.agent_id, &pending.action.tool) { + return Ok(Some(agent_runtime_tool_policy_block_observation( + &pending.action.tool, + AgentRuntimeToolPolicyBlock::Denied("当前 Agent 身份不允许执行该原始工具".to_string()), + ))); + } if let Some(observation) = pending_repository_context_drift_observation(root, pending)? { return Ok(Some(observation)); } @@ -313,6 +319,12 @@ pub(in crate::agent) fn prepare_and_execute_game_creator_agent_runtime_parallel_ { return Ok(AgentRuntimeParallelReadBatchExecution::NotEligible); } + if !actions + .iter() + .all(|action| agent_runtime_tool_allowed_for_agent(&runtime.agent_id, &action.tool)) + { + return Ok(AgentRuntimeParallelReadBatchExecution::NotEligible); + } let _project_lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( &root, "runtime.parallel_read_batch", @@ -583,7 +595,7 @@ pub(crate) fn project_game_creator_agent_runtime_parallel_read_batch_for_test_at }); let plan = continuation.plan.clone(); let mut observations = continuation.observations.clone(); - let mut tracker = AgentRuntimeContextWindowTracker::from_continuation(&continuation); + let mut tracker = AgentRuntimeContextWindowTracker::from_continuation(&continuation, &runtime); project_game_creator_agent_runtime_parallel_read_batch( root, &mut runtime, 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 6de6a816e..b9c7a63cd 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 @@ -334,6 +334,32 @@ pub(in crate::agent) fn validate_agent_runtime_pending_tool_action_record( { return Err("Agent Runtime 待确认动作 Run Profile 绑定不匹配".to_string()); } + 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 b87a1e16a..db21fe5eb 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 @@ -430,6 +430,9 @@ pub(crate) fn finish_agent_runtime_project_verification_locked( gate.static_smoke_verified_revision = passed.then_some(current_revision.revision); gate.static_smoke_verified_game_index_sha256 = passed.then_some(static_smoke_game_index_sha256).flatten(); + } else if verification_tool.as_deref() == Some(AGENT_RUNTIME_OWNER_ARTIFACT_VERIFICATION_TOOL) { + gate.static_smoke_verified_revision = None; + gate.static_smoke_verified_game_index_sha256 = None; } gate.last_verification_status = Some( if passed { @@ -947,11 +950,22 @@ pub(in crate::agent) fn isolated_join_barrier_has_waiting_groups(detail: &str) - } pub(in crate::agent) fn static_delegate_barrier_has_waiting_deliveries(detail: &str) -> bool { - detail + let waiting = detail .split_whitespace() .find_map(|part| part.strip_prefix("waitingDelegations=")) .and_then(|value| value.parse::().ok()) - .is_some_and(|count| count > 0) + .is_some_and(|count| count > 0); + let unknown_contract_status = detail + .split_whitespace() + .find_map(|part| part.strip_prefix("unknownContractStatus=")) + .and_then(|value| value.parse::().ok()) + .is_some_and(|count| count > 0); + let user_revision_pending = detail + .split_whitespace() + .find_map(|part| part.strip_prefix("userRevisionPending=")) + .and_then(|value| value.parse::().ok()) + .is_some_and(|count| count > 0); + waiting || user_revision_pending || unknown_contract_status } pub(in crate::agent) fn static_delegate_barrier_requires_repair(detail: &str) -> bool { @@ -962,6 +976,22 @@ pub(in crate::agent) fn static_delegate_barrier_requires_repair(detail: &str) -> .is_some_and(|count| count > 0) } +pub(in crate::agent) fn static_delegate_barrier_requires_user_input(detail: &str) -> bool { + detail + .split_whitespace() + .find_map(|part| part.strip_prefix("userInputRequired=")) + .and_then(|value| value.parse::().ok()) + .is_some_and(|count| count > 0) +} + +pub(in crate::agent) fn static_delegate_barrier_requires_user_revision(detail: &str) -> bool { + detail + .split_whitespace() + .find_map(|part| part.strip_prefix("userRevisionPending=")) + .and_then(|value| value.parse::().ok()) + .is_some_and(|count| count > 0) +} + pub(in crate::agent) fn process_session_completion_blocker_at_locked( root: &Path, agent_id: &str, @@ -1006,6 +1036,7 @@ 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) @@ -1460,17 +1491,217 @@ pub(in crate::agent) fn project_verification_completion_blocker_at_locked( } } +fn owner_artifact_verification_is_current( + gate: &AgentRuntimeVerificationGate, + current_revision: u64, +) -> bool { + gate.last_verification_tool.as_deref() == Some(AGENT_RUNTIME_OWNER_ARTIFACT_VERIFICATION_TOOL) + && gate.last_verification_status.as_deref() + == Some(AGENT_RUNTIME_VERIFICATION_STATUS_PASSED) + && gate.static_smoke_verified_revision.is_none() + && gate.mutation_revision.is_some_and(|mutation_revision| { + gate.verified_revision.is_some_and(|verified_revision| { + verified_revision >= mutation_revision && verified_revision <= current_revision + }) + }) +} + +fn ensure_owner_artifact_validation_audit_at_locked( + root: &Path, + agent_id: &str, + run_id: &str, + revision: u64, + paths: &[&str], +) -> Result<(), String> { + let binding = read_game_creator_agent_runtime_run_profile_binding(root, agent_id, run_id)? + .ok_or_else(|| "owner-artifact 验证审计缺少 Run Profile 绑定".to_string())?; + let (records, _) = + read_agent_db_records_bounded(root, AGENT_RUNTIME_ACTION_HISTORY_MAX_DB_BYTES)?; + let audit_exists = records.iter().rev().any(|record| { + record.get("recordType").and_then(serde_json::Value::as_str) + == Some("agent.runtime.owner_artifacts.validated") + && 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("rootRunId").and_then(serde_json::Value::as_str) + == Some(binding.root_run_id.as_str()) + && record + .get("runProfileBindingFingerprint") + .and_then(serde_json::Value::as_str) + == Some(binding.binding_fingerprint.as_str()) + && record.get("revision").and_then(serde_json::Value::as_u64) == Some(revision) + && record + .get("verificationTool") + .and_then(serde_json::Value::as_str) + == Some(AGENT_RUNTIME_OWNER_ARTIFACT_VERIFICATION_TOOL) + && record + .get("paths") + .and_then(serde_json::Value::as_array) + .is_some_and(|items| { + items.len() == paths.len() + && items + .iter() + .zip(paths) + .all(|(item, path)| item.as_str().is_some_and(|value| value == *path)) + }) + }); + if audit_exists { + return Ok(()); + } + append_agent_db_record( + root, + serde_json::json!({ + "recordType": "agent.runtime.owner_artifacts.validated", + "agentId": agent_id, + "runId": run_id, + "rootAgentId": binding.root_agent_id, + "rootRunId": binding.root_run_id, + "parentAgentId": binding.parent_agent_id, + "parentRunId": binding.parent_run_id, + "source": binding.source, + "runProfile": binding.profile, + "runProfileBindingFingerprint": binding.binding_fingerprint, + "revision": revision, + "verificationTool": AGENT_RUNTIME_OWNER_ARTIFACT_VERIFICATION_TOOL, + "paths": paths, + }), + ) +} + pub(in crate::agent) fn evaluate_project_verification_completion_at_locked( root: &Path, agent_id: &str, run_id: &str, observations: &[AgentRuntimeToolObservation], ) -> Result, String> { - if let Some(blocker) = project_verification_completion_blocker(observations) { - return Ok(Some(blocker)); - } let revision = read_game_creator_agent_runtime_project_revision(root)?; - let gate = read_game_creator_agent_runtime_verification_gate(root, agent_id, run_id)?; + let runtime_owner_artifact_validation_available = + autonomous_owner_artifact_validation_available_for_run_at(root, agent_id, run_id)?; + let mut gate = read_game_creator_agent_runtime_verification_gate(root, agent_id, run_id)?; + let autonomous_owner_artifact_role = + if agent_runtime_autonomous_uses_owner_artifact_validation(agent_id) { + match read_game_creator_agent_runtime_run_profile_binding(root, agent_id, run_id)? { + Some(binding) => binding.profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD, + None => { + read_latest_game_creator_agent_runtime_task_by_run_id(root, agent_id, run_id)? + .is_some_and(|task| { + task.run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + }) + } + } + } else { + false + }; + let trusted_game_chat_canvas_delivery = autonomous_owner_artifact_role + && game_chat_delegated_art_asset_plan_uses_canvas_verification_at(root, agent_id, run_id)?; + if autonomous_owner_artifact_role + && !runtime_owner_artifact_validation_available + && !trusted_game_chat_canvas_delivery + { + return Ok(Some(agent_runtime_verification_blocker( + "当前 owner Run 不具备可用的验证身份,不能把任务标记为完成", + "只有完整 GUI/CLI autonomous DAG 的当前 fixed owner 可由 Runtime 内部验证;只有可信 game-chat code-prototype 的当前 art-asset-plan 动态委派可沿用普通 Canvas 验证。", + ))); + } + if trusted_game_chat_canvas_delivery + && (gate.last_verification_tool.as_deref() != Some("canvas.asset_generate") + || gate.last_verification_status.as_deref() + != Some(AGENT_RUNTIME_VERIFICATION_STATUS_PASSED) + || gate.static_smoke_verified_revision.is_some() + || !gate.mutation_revision.is_some_and(|mutation_revision| { + gate.verified_revision + .is_some_and(|verified_revision| verified_revision >= mutation_revision) + })) + { + return Ok(Some(agent_runtime_verification_blocker( + "game-chat 动态 art-asset-plan 尚未形成可信 Canvas 交付凭证", + "当前动态美术 child 必须由本人 canvas.asset_generate 生成正式素材并通过当前 mutation revision;project.verify、game.static_smoke 或其它 Agent 的凭证均不能替代。", + ))); + } + if runtime_owner_artifact_validation_available && gate.requires_verification { + let paths = + match validate_autonomous_owner_artifacts_for_run_at_locked(root, agent_id, run_id) { + Ok(paths) => paths, + Err(error) => { + return Ok(Some(agent_runtime_verification_blocker( + "当前 owner 正式产物尚未通过 Runtime 结构验证", + error, + ))); + } + }; + let owner_artifact_verification_current = + owner_artifact_verification_is_current(&gate, revision.revision); + let audit_revision = if owner_artifact_verification_current { + gate.verified_revision + .expect("current owner-artifact verification has a revision") + } else { + revision.revision + }; + ensure_owner_artifact_validation_audit_at_locked( + root, + agent_id, + run_id, + audit_revision, + paths, + )?; + if !owner_artifact_verification_current { + let (expected_revision, verification_gate) = + begin_agent_runtime_project_verification_locked( + root, + agent_id, + run_id, + AGENT_RUNTIME_OWNER_ARTIFACT_VERIFICATION_TOOL, + )?; + finish_agent_runtime_project_verification_locked( + root, + &expected_revision, + verification_gate, + true, + )?; + gate = read_game_creator_agent_runtime_verification_gate(root, agent_id, run_id)?; + } + } + let autonomous_code_prototype = if agent_id == "code-prototype" { + match read_game_creator_agent_runtime_run_profile_binding(root, agent_id, run_id)? { + Some(binding) => binding.profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD, + None => read_latest_game_creator_agent_runtime_task_by_run_id(root, agent_id, run_id)? + .is_some_and(|task| { + task.run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + }), + } + } else { + false + }; + if autonomous_code_prototype { + ensure_current_autonomous_ready_child_mutation_at_locked(root, agent_id, run_id)?; + let Some(mutation_revision) = gate.mutation_revision.filter(|revision| *revision > 0) + else { + return Ok(Some(agent_runtime_verification_blocker( + "code-prototype 尚未形成本人 run 的项目修改,不能把任务标记为完成", + "完整 autonomous DAG 的 code-prototype 必须先实际修改可玩入口,再由本人执行 game.static_smoke。", + ))); + }; + if !gate + .static_smoke_verified_revision + .is_some_and(|revision| revision >= mutation_revision) + { + return Ok(Some(agent_runtime_verification_blocker( + "code-prototype 尚未通过覆盖本人最后一次修改的 game.static_smoke", + format!( + "mutationRevision={mutation_revision}, staticSmokeVerifiedRevision={};project.verify 或其它 Agent 的验证凭证不能替代本人 run 的 game.static_smoke。", + gate.static_smoke_verified_revision + .map(|revision| revision.to_string()) + .unwrap_or_else(|| "none".to_string()) + ), + ))); + } + } + let owner_artifact_verification_current = runtime_owner_artifact_validation_available + && owner_artifact_verification_is_current(&gate, revision.revision); + if !owner_artifact_verification_current { + if let Some(blocker) = project_verification_completion_blocker(observations) { + return Ok(Some(blocker)); + } + } if let Some(status) = gate.last_verification_status.as_deref() { if status == AGENT_RUNTIME_VERIFICATION_STATUS_RUNNING { return Ok(Some(agent_runtime_verification_blocker( @@ -1548,18 +1779,31 @@ pub(crate) fn project_verification_completion_blocker_at( project_verification_completion_blocker_at_locked(root, agent_id, run_id, observations) } -pub(crate) fn acquire_game_creator_agent_runtime_project_write_lock_with_wait( +const AGENT_RUNTIME_PROJECT_WRITE_LOCK_RETRY_INTERVAL: Duration = Duration::from_millis(5); +const AGENT_RUNTIME_PROJECT_WRITE_LOCK_WAIT_ATTEMPTS: usize = 2_000; +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. +fn acquire_game_creator_agent_runtime_project_write_lock_within( root: &Path, command_id: &str, + max_attempts: usize, ) -> Result { - const MAX_ATTEMPTS: usize = 2_000; - for attempt in 0..MAX_ATTEMPTS { + let max_attempts = max_attempts.max(1); + for attempt in 0..max_attempts { match acquire_project_write_lock(root, command_id) { Err(error) if error.starts_with("项目正在被其他写操作占用:") - && attempt + 1 < MAX_ATTEMPTS => + && attempt + 1 < max_attempts => { - std::thread::sleep(Duration::from_millis(5)); + std::thread::sleep(AGENT_RUNTIME_PROJECT_WRITE_LOCK_RETRY_INTERVAL); } result => return result, } @@ -1567,9 +1811,134 @@ pub(crate) fn acquire_game_creator_agent_runtime_project_write_lock_with_wait( unreachable!("project write lock retry loop always returns") } +pub(crate) fn acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root: &Path, + command_id: &str, +) -> Result { + acquire_game_creator_agent_runtime_project_write_lock_within( + root, + command_id, + AGENT_RUNTIME_PROJECT_WRITE_LOCK_WAIT_ATTEMPTS, + ) +} + +/// Same wait, sized for a caller that re-runs on its own — a GUI refresh poll +/// rather than a user's one-shot decision. Blocking such a caller for the full +/// window would stall the panel it feeds; losing the race only costs it the +/// current tick. +pub(crate) fn acquire_game_creator_agent_runtime_project_write_lock_with_short_wait( + root: &Path, + command_id: &str, +) -> Result { + acquire_game_creator_agent_runtime_project_write_lock_within( + root, + command_id, + AGENT_RUNTIME_PROJECT_WRITE_LOCK_SHORT_WAIT_ATTEMPTS, + ) +} + pub(crate) fn acquire_game_creator_agent_provider_plan_project_write_lock_with_wait( root: &Path, command_id: &str, ) -> Result { acquire_game_creator_agent_runtime_project_write_lock_with_wait(root, command_id) } + +/// M1C-1 把 `userRevisionPending` 同时加进了 `StaticDelegateCompletionBarrier::detail()` +/// 的输出和本模块的三个解析门,两边靠一个字段名字符串隔空对齐,中间没有共享 schema。 +/// +/// 当时生产侧只有 `delegation.rs` 一条 `detail().contains("userRevisionPending=1")`, +/// 解析侧一条测试都没有,两者之间也没有任何东西相连。字段名一改,生产侧那条照过,而这 +/// 三个门会静默返回 false——父 run 于是越过用户修订边界收束。同样,往 `has_waiting()` +/// 里加一个计数而忘了加进解析器(或反之),也没有任何用例会报警。 +/// +/// 所以这里锁的不是拼写,是**等价关系**:对七个计数的全部组合,解析门的判定必须与 +/// barrier 自己的语义谓词逐一相等。 +#[cfg(test)] +mod static_delegate_barrier_detail_gate_tests { + use super::*; + use crate::delegation::StaticDelegateCompletionBarrier; + + fn assert_gates_agree_with_barrier(barrier: StaticDelegateCompletionBarrier) { + let detail = barrier.detail(); + assert_eq!( + static_delegate_barrier_has_waiting_deliveries(&detail), + barrier.has_waiting(), + "has_waiting() 与 detail 解析必须等价:{barrier:?}\ndetail={detail}" + ); + assert_eq!( + static_delegate_barrier_requires_repair(&detail), + barrier.repair_required_count > 0, + "repairRequired 往返失真:{barrier:?}\ndetail={detail}" + ); + assert_eq!( + static_delegate_barrier_requires_user_input(&detail), + barrier.user_input_required_count > 0, + "userInputRequired 往返失真:{barrier:?}\ndetail={detail}" + ); + assert_eq!( + static_delegate_barrier_requires_user_revision(&detail), + barrier.user_revision_pending_count > 0, + "userRevisionPending 往返失真:{barrier:?}\ndetail={detail}" + ); + } + + #[test] + fn barrier_detail_round_trips_through_every_gate_for_all_count_combinations() { + let mut checked = 0usize; + for bits in 0u32..(1 << 7) { + let present = |index: u32| usize::from(bits & (1 << index) != 0); + assert_gates_agree_with_barrier(StaticDelegateCompletionBarrier { + waiting_count: present(0), + ready_unclaimed_count: present(1), + unobserved_claim_count: present(2), + repair_required_count: present(3), + user_input_required_count: present(4), + user_revision_pending_count: present(5), + unknown_contract_status_count: present(6), + }); + checked += 1; + } + assert_eq!(checked, 128, "必须覆盖七个计数的全部 0/1 组合"); + } + + /// 门读的是计数而不是「等于 1」:`detail()` 里出现多位数时不能失配。 + #[test] + fn barrier_detail_gates_read_multi_digit_counts() { + assert_gates_agree_with_barrier(StaticDelegateCompletionBarrier { + waiting_count: 12, + ready_unclaimed_count: 34, + unobserved_claim_count: 56, + repair_required_count: 78, + user_input_required_count: 90, + user_revision_pending_count: 123, + unknown_contract_status_count: 456, + }); + } + + /// 没有任何键是另一个键的前缀——否则 `strip_prefix` 会读到隔壁字段的值。 + /// 这条是给未来改名加的护栏:等价关系测试能抓到读错值,但抓不到「读对了值却 + /// 是因为两个键碰巧不冲突」这层前提何时被打破。 + #[test] + fn barrier_detail_keys_are_prefix_free() { + let detail = StaticDelegateCompletionBarrier::default().detail(); + let keys = detail + .split_whitespace() + .filter_map(|part| part.split_once('=').map(|(key, _)| key)) + .collect::>(); + assert!( + keys.len() >= 7, + "detail 必须仍以 key=value 形式给出全部计数:{detail}" + ); + for (index, key) in keys.iter().enumerate() { + for (other_index, other) in keys.iter().enumerate() { + if index != other_index { + assert!( + !other.starts_with(key), + "detail 键 `{key}` 是 `{other}` 的前缀,strip_prefix 会读错字段" + ); + } + } + } + } +} 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 80108596a..23beaedf2 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,6 +14,18 @@ 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, @@ -78,6 +90,8 @@ 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, @@ -88,6 +102,8 @@ 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, @@ -107,6 +123,8 @@ 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, @@ -117,6 +135,8 @@ 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, @@ -140,6 +160,7 @@ 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, @@ -148,6 +169,7 @@ 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, @@ -195,7 +217,7 @@ impl AgentRuntimePendingToolAction { pub(in crate::agent) fn tool_plan(&self) -> AgentRuntimeToolPlan { AgentRuntimeToolPlan { thinking_summary: self.thinking_summary.clone(), - plan_update: None, + plan_update: self.provider_batch_plan_update.clone(), plan: self.plan.clone(), actions: Vec::new(), response: self.fallback_response.clone(), @@ -253,6 +275,8 @@ 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, @@ -288,6 +312,110 @@ 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 +} + +/// 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, @@ -297,6 +425,34 @@ 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 @@ -450,7 +606,11 @@ pub(crate) async fn prepare_game_creator_agent_runtime_provider_action_batch( } } } - if batch_plan.actions.len() < 2 && !collaboration_preflight.force_durable_batch { + if provider_action_batch_is_not_needed( + batch_plan.actions.len(), + collaboration_preflight.force_durable_batch, + is_plan_submit, + ) { return Ok(AgentRuntimeProviderActionBatchPreparation::NotNeeded); } @@ -472,7 +632,23 @@ pub(crate) async fn prepare_game_creator_agent_runtime_provider_action_batch( 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 art_director_canvas_only_block = agent_runtime_autonomous_art_director_canvas_only_action_block( &runtime.agent_id, @@ -500,7 +676,8 @@ pub(crate) async fn prepare_game_creator_agent_runtime_provider_action_batch( } else { None }; - let local_policy_block = art_director_canvas_only_block + let local_policy_block = identity_block + .or(art_director_canvas_only_block) .or(game_chat_art_scope_block) .or(isolated_scope_block) .or_else(|| { @@ -573,24 +750,71 @@ pub(crate) async fn prepare_game_creator_agent_runtime_provider_action_batch( } else { AGENT_RUNTIME_PROVIDER_ACTION_BATCH_STATUS_READY }; - 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 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 now = unix_timestamp(); let batch = AgentRuntimeProviderActionBatch { - schema_version: AGENT_RUNTIME_PROVIDER_ACTION_BATCH_SCHEMA_VERSION.to_string(), + 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() + }, 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(), @@ -599,6 +823,7 @@ pub(crate) async fn prepare_game_creator_agent_runtime_provider_action_batch( 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(), @@ -1075,3 +1300,93 @@ 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 f2c6710e0..c4a692e45 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,6 +110,48 @@ 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, @@ -126,7 +168,8 @@ pub(in crate::agent) fn validate_game_creator_agent_runtime_provider_action_batc ) -> Result<(), String> { if !matches!( batch.schema_version.as_str(), - AGENT_RUNTIME_PROVIDER_ACTION_BATCH_SCHEMA_VERSION + AGENT_RUNTIME_PLAN_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 ) { @@ -163,8 +206,48 @@ pub(in crate::agent) fn validate_game_creator_agent_runtime_provider_action_batc batch.status )); } - let minimum_action_count = if batch.schema_version - != AGENT_RUNTIME_PROVIDER_ACTION_BATCH_LEGACY_SCHEMA_VERSION + // `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 && batch.collaboration_contract.is_some() { 1 @@ -198,8 +281,23 @@ 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 @@ -214,6 +312,8 @@ 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] { @@ -242,6 +342,26 @@ 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 @@ -405,6 +525,26 @@ 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 1543951f7..b61b5c807 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,7 +116,32 @@ pub(in crate::agent) async fn request_game_creator_agent_background_final_reply_ root, "runtime.provider_request.capture.final_reply", )?; - capture_game_creator_agent_runtime_provider_request_snapshot_at_locked( + 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, session_id, @@ -124,8 +149,34 @@ pub(in crate::agent) async fn request_game_creator_agent_background_final_reply_ "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 + } }; + 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 d42d96c2a..7fdf1a632 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 @@ -1,8 +1,11 @@ use super::*; use crate::mcp::GAME_CREATOR_MCP_CALL_TOOL; +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, @@ -120,6 +123,36 @@ fn game_creator_agent_context_preload_notice(agent_id: &str) -> &'static str { } } +pub(in crate::agent) fn remove_autonomous_owner_manual_verification_tools( + tools: &mut Vec, +) -> Result<(), String> { + let project_verify = native_runtime_function_name("project.verify") + .ok_or_else(|| "无法生成 project.verify 原生函数名".to_string())?; + let limited_command = native_runtime_function_name("command.run_limited") + .ok_or_else(|| "无法生成 command.run_limited 原生函数名".to_string())?; + tools.retain(|tool| tool.name != project_verify && tool.name != limited_command); + Ok(()) +} + +pub(in crate::agent) fn remove_autonomous_art_director_non_canvas_validation_tools( + tools: &mut Vec, +) -> Result<(), String> { + let denied_function_names = [ + "project.verify", + "command.run_limited", + "preview.start", + "preview.validate", + ] + .into_iter() + .map(|tool| { + native_runtime_function_name(tool) + .ok_or_else(|| format!("无法生成 art-director 禁用工具函数名:{tool}")) + }) + .collect::, _>>()?; + tools.retain(|tool| !denied_function_names.contains(&tool.name)); + Ok(()) +} + pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request( root: &Path, agent_id: &str, @@ -139,6 +172,11 @@ pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request( ), 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( @@ -157,10 +195,8 @@ pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request( }; let tool_policy = agent_runtime_tool_policy_snapshot_for_run_at(root, agent_id, run_id, None, None)?; - let root_control_authority = read_game_creator_agent_runtime_run_profile_binding( - root, agent_id, run_id, - )? - .is_some_and(|binding| { + let root_binding = read_game_creator_agent_runtime_run_profile_binding(root, agent_id, run_id)?; + let root_control_authority = root_binding.as_ref().is_some_and(|binding| { binding.agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID && binding.root_agent_id == binding.agent_id && binding.root_run_id == binding.run_id @@ -168,6 +204,18 @@ pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request( && 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()); @@ -184,8 +232,10 @@ pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request( && agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID && prompt_observations_report_manifest_dag_in_progress(&prompt_observations), }; - let autonomous_project_verify_available = - !autonomous_game_build || agent_runtime_autonomous_project_verify_available(root); + let runtime_owner_artifact_validation_available = autonomous_game_build + && autonomous_owner_artifact_validation_available_for_run_at(root, agent_id, run_id)?; + let autonomous_project_verify_available = !runtime_owner_artifact_validation_available + && (!autonomous_game_build || agent_runtime_autonomous_project_verify_available(root)); let mut allowed_tools = tool_policy.allowed_tools.clone(); let mut auto_tools = tool_policy.auto_tools.clone(); let mut confirm_tools = tool_policy.confirm_tools.clone(); @@ -226,7 +276,9 @@ pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request( "deniedTools": denied_tools, })) .map_err(|error| format!("序列化 Agent 工具策略失败:{error}"))?; - let collaboration_policy_json = if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { + 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 { render_supervisor_collaboration_policy_for_prompt_at(root, agent_id, run_id)? } else { "null".to_string() @@ -262,10 +314,24 @@ pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request( }; let steers_json = render_game_creator_agent_runtime_steers_for_prompt(root, agent_id, session_id, run_id)?; - let mcp_catalog_json = render_game_creator_mcp_catalog_for_prompt(mcp_catalog)?; + let mcp_catalog_json = if agent_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID { + "[]".to_string() + } else { + render_game_creator_mcp_catalog_for_prompt(mcp_catalog)? + }; let loop_index = loop_index.saturating_add(1); let context_preload_notice = game_creator_agent_context_preload_notice(agent_id); let canvas_asset_kind_catalog = AGENT_RUNTIME_CANVAS_ASSET_KINDS.join("|"); + let limited_command_contract = if runtime_owner_artifact_validation_available { + "当前固定 owner 不得调用 command.run_limited 或 project.verify;完成固定正式产物后直接交付,由 Runtime 在收束门内执行结构验证。".to_string() + } else { + "command.run_limited 使用 {\"commandId\":\"game.static_smoke\"}。".to_string() + }; + let project_tools_contract = if runtime_owner_artifact_validation_available { + "project.search 使用 {\"query\":\"要查找的字面文本\",\"path\":\"\",\"maxResults\":20,\"caseSensitive\":false},path 为空字符串时搜索整个项目,返回 path:line 和匹配行;当前固定 owner 的函数目录不广告 project.verify;project.checkpoint 使用空对象,只用于多个 file.* 写动作前或需要独立回退点时创建本地 checkpoint;project.patchset 会自动创建 checkpoint,不要为同一批变更额外调用 project.checkpoint;project.restore 使用 {\"checkpointId\":\"checkpoint id\"};project.diff 使用 {\"checkpointId\":\"checkpoint id\",\"includeContent\":true,\"maxFiles\":20,\"maxChars\":24000};git.inspect 使用 {\"includeDiff\":true,\"maxFiles\":20,\"maxChars\":24000},只读项目根 Git 状态和有界 diff,不得用它提交、暂存、切分支、合并、重置、stash、worktree 或访问 remote。".to_string() + } else { + "project.search 使用 {\"query\":\"要查找的字面文本\",\"path\":\"\",\"maxResults\":20,\"caseSensitive\":false},path 为空字符串时搜索整个项目,返回 path:line 和匹配行;project.verify 使用 {\"script\":\"check|typecheck|test|lint|build\",\"expectedCommand\":\"从 package.json 读取的完整原始脚本\",\"timeoutSeconds\":120},只执行项目根 package.json 中同名 npm 脚本,expectedCommand 不一致时拒绝执行,确认策略以当前工具策略中 project.verify 的独立权限为准;project.checkpoint 使用空对象,只用于多个 file.* 写动作前或需要独立回退点时创建本地 checkpoint;project.patchset 会自动创建 checkpoint,不要为同一批变更额外调用 project.checkpoint;project.restore 使用 {\"checkpointId\":\"checkpoint id\"};project.diff 使用 {\"checkpointId\":\"checkpoint id\",\"includeContent\":true,\"maxFiles\":20,\"maxChars\":24000};git.inspect 使用 {\"includeDiff\":true,\"maxFiles\":20,\"maxChars\":24000},只读项目根 Git 状态和有界 diff,不得用它提交、暂存、切分支、合并、重置、stash、worktree 或访问 remote。".to_string() + }; let prompt = format!( concat!( "当前工具策略:\n{tool_policy_json}\n\n", @@ -281,10 +347,10 @@ pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request( "计划更新约定:复杂任务首次拆解、实际进度变化、steer 改变顺序或最终收束时调用 update_agent_plan,arguments 只提交 {{\"explanation\":\"本次计划变化\",\"steps\":[{{\"step\":\"步骤\",\"status\":\"pending|in_progress|completed\"}}]}};无需更新时不要调用 update_agent_plan。steps 最多 8 条且同时最多一个 in_progress,已完成步骤必须继续保留且不得回退。持久计划仍有 pending / in_progress 时不得调用 respond_to_user,Runtime 也不会按动作返回顺序自动完成步骤。\n\n", "工具 input 字段约定:下列每个示例对象都必须放入对应动作函数的 arguments.input;arguments 外层严格为 {{\"reason\":\"为什么需要\",\"input\":{{...}}}},禁止把 input 扁平到 arguments 顶层。\n", "memory.read 使用 {{\"scope\":\"session|project|blackboard|agent\"}};memory.write 使用 {{\"scope\":\"agent|project|session|blackboard\",\"title\":\"标题\",\"content\":\"要沉淀的稳定结论\",\"mode\":\"append|overwrite\"}},其中 agent scope 只能写当前 Agent 自己的私有记忆,跨 Agent 共享请用 blackboard.write 或 agent.message。\n", - "project.search 使用 {{\"query\":\"要查找的字面文本\",\"path\":\"\",\"maxResults\":20,\"caseSensitive\":false}},path 为空字符串时搜索整个项目,返回 path:line 和匹配行;project.verify 使用 {{\"script\":\"check|typecheck|test|lint|build\",\"expectedCommand\":\"从 package.json 读取的完整原始脚本\",\"timeoutSeconds\":120}},只执行项目根 package.json 中同名 npm 脚本,expectedCommand 不一致时拒绝执行,确认策略以当前工具策略中 project.verify 的独立权限为准;project.checkpoint 使用空对象,只用于多个 file.* 写动作前或需要独立回退点时创建本地 checkpoint;project.patchset 会自动创建 checkpoint,不要为同一批变更额外调用 project.checkpoint;project.restore 使用 {{\"checkpointId\":\"checkpoint id\"}};project.diff 使用 {{\"checkpointId\":\"checkpoint id\",\"includeContent\":true,\"maxFiles\":20,\"maxChars\":24000}};git.inspect 使用 {{\"includeDiff\":true,\"maxFiles\":20,\"maxChars\":24000}},只读项目根 Git 状态和有界 diff,不得用它提交、暂存、切分支、合并、重置、stash、worktree 或访问 remote。\n", + "{project_tools_contract}\n", "project.patchset 的每个 change 必须显式提交七个字段。create 使用 {{\"operation\":\"create\",\"path\":\"项目内相对文件\",\"content\":\"完整内容\",\"expectedSha256\":null,\"oldText\":null,\"newText\":null,\"expectedReplacements\":null}};update 使用 {{\"operation\":\"update\",\"path\":\"项目内相对文件\",\"content\":null,\"expectedSha256\":\"file.read 返回的 SHA-256\",\"oldText\":\"精确原文\",\"newText\":\"替换后文本\",\"expectedReplacements\":1}};delete 使用 {{\"operation\":\"delete\",\"path\":\"项目内相对文件\",\"content\":null,\"expectedSha256\":\"file.read 返回的 SHA-256\",\"oldText\":null,\"newText\":null,\"expectedReplacements\":null}}。成功后必须用返回的 checkpointId 调用 project.diff includeContent=true 审查整体变更。\n", "file.list 使用 {{\"path\":\"\"}},path 为空字符串时列出项目摘要;file.read 使用 {{\"path\":\"项目内相对路径\",\"startLine\":1,\"maxLines\":120}};file.write 使用 {{\"path\":\"项目内相对路径\",\"content\":\"完整文件内容\"}};file.patch 使用 {{\"path\":\"项目内相对路径\",\"oldText\":\"必须精确匹配的原文\",\"newText\":\"替换后的文本\",\"expectedReplacements\":1}};file.delete 使用 {{\"path\":\"项目内相对路径\"}},只删除项目内普通文件,不删除目录或任何 .agent 控制面文件。\n", - "task.create 使用 {{\"taskId\":null,\"title\":\"任务标题\",\"group\":\"design|art|code|balance|audio|publishing\",\"role\":\"角色名\",\"dependencies\":[],\"artifacts\":[],\"acceptanceCriteria\":[\"验收标准\"],\"status\":\"pending|running|waiting-for-confirmation|completed|failed\"}},需要自定义 taskId 时把 null 替换为合法 ID;task.update 使用 {{\"taskId\":\"manifest taskId\",\"status\":\"pending|running|waiting-for-confirmation|completed|failed\"}};command.run_limited 使用 {{\"commandId\":\"game.static_smoke\"}}。\n", + "task.create 使用 {{\"taskId\":null,\"title\":\"任务标题\",\"group\":\"design|art|code|balance|audio|publishing\",\"role\":\"角色名\",\"dependencies\":[],\"artifacts\":[],\"acceptanceCriteria\":[\"验收标准\"],\"status\":\"pending|running|waiting-for-confirmation|completed|failed\"}},需要自定义 taskId 时把 null 替换为合法 ID;task.update 使用 {{\"taskId\":\"manifest taskId\",\"status\":\"pending|running|waiting-for-confirmation|completed|failed\"}};{limited_command_contract}\n", "canvas.asset_generate 使用 {{\"prompt\":\"图片描述\",\"outputPath\":null,\"aspectRatio\":null,\"imageSize\":null,\"assetKind\":null,\"assetLabel\":null,\"replaceExisting\":false}};需要指定时,aspectRatio 只允许 1:1|2:3|3:2|9:16|16:9,imageSize 只允许 0.5K|1K|2K,assetKind 只允许 {canvas_asset_kind_catalog}。replaceExisting 只能在带 repairOfDelegationId 的唯一返工委派中设为 true,普通生成必须为 false,并通过配置的 External Editor API 同时写入画布、同名素材库目录和本地 assets。\n", "blackboard.write 使用 {{\"title\":\"标题\",\"content\":\"要共享给所有 Agent 的稳定结论\"}};agent.message 使用 {{\"agentId\":\"目标 taskId\",\"content\":\"给目标 Agent 的定向消息\"}};agent.delegate 使用 {{\"agentId\":\"目标 taskId\",\"task\":\"要委派的后台任务\",\"acceptanceCriteria\":[\"可核对的语义验收条件\"],\"expectedArtifacts\":[],\"repairOfDelegationId\":null,\"runId\":null}},expectedArtifacts 无产物时传空数组且不接受 glob;返工时 repairOfDelegationId 指向已认领原 delivery 且 runId 必须为 null;agent.schedule_ready 使用 {{\"limit\":1}};agent.route_manifest 使用 {{\"strategy\":\"audit-existing-first|use-existing-art|generate-missing-art\",\"intentSummary\":\"Supervisor 自行理解的用户意图,仅 Supervisor 提交\",\"missingAssetSlots\":[]}};Supervisor 必须自行概括非空 intentSummary,并以 audit-existing-first 提交执行安全策略;code-prototype 先 asset.list 后只能按权威缺口提交 use-existing-art 或 generate-missing-art,且无需提交 intentSummary;agent.run_status 使用 {{\"agentId\":null,\"scope\":\"all\",\"delegationId\":null}},指定目标 Agent 或已认领 delegation 时把对应 null 替换为实际 ID;当前可信父 Run 传 delegationId 时读取自己已认领的未截断权威返工合同。\n", "当前请求中的每个 MCP 工具都以单独的动态函数广告;必须从实际广告函数中选择,并严格按该函数的 input schema 提交 arguments.input。server、tool、catalogFingerprint 和 toolFingerprint 由 Runtime 注入,禁止构造目录外包装调用。\n", @@ -304,12 +370,31 @@ pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request( steers_json = steers_json, observations_json = observations_json, canvas_asset_kind_catalog = canvas_asset_kind_catalog, + limited_command_contract = limited_command_contract, + project_tools_contract = project_tools_contract, ); - let command_exec_contract = provider_command_exec_contract(); - let prompt = format!( - "{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,或成功执行 command.run_limited 的 game.static_smoke,才能调用 respond_to_user 收束。文件回读不能替代可执行验证,验证后再次修改必须重新验证。每 {AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT} 轮只是一次进度 checkpoint 与停滞检测,不是上下文压缩或 run 的终止上限;只要 observation 出现新的独立进展,就在同一 run 继续下一窗口,只有窗口没有新进展时才按停滞处理。真正的上下文压缩仅由 token 阈值或显式 compact 触发。" - ); - let prompt = if root_control_authority { + let prompt = if runtime_owner_artifact_validation_available { + format!( + "{prompt}\n\n固定 owner 收束协议:当前请求不广告 project.verify 或 command.run_limited。完成本人固定路径的正式产物后直接调用 respond_to_user;Runtime 会在同一活跃根、同一 Agent/run 和当前 mutation revision 上检查非空内容、JSON 解析、未完成标记与父完成合同 baseline 变化。不得为了取得验证凭证运行项目命令、静态 smoke、预览或提交 Git;文件回读也不能代替 Runtime 收束门。再次修改会使旧 owner 凭证失效,必须重新直接交付并接受收束检查。每 {AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT} 轮只是一次进度 checkpoint 与停滞检测,不是上下文压缩或 run 的终止上限;只要 observation 出现新的独立进展,就在同一 run 继续下一窗口,只有窗口没有新进展时才按停滞处理。真正的上下文压缩仅由 token 阈值或显式 compact 触发。" + ) + } else { + let command_exec_contract = provider_command_exec_contract(); + format!( + "{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 { 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 节点保持不变。" ) @@ -320,9 +405,13 @@ pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request( } else { prompt }; - let prompt = format!( - "{prompt}\n\n受控本地 Git 提交:git.inspect 会返回 commitSnapshotFingerprint;只有在完整审阅变更且最后一次源码修改已获得当前 revision 的 passed 验证后,才能调用 project.git_commit {{\"message\":\"提交标题和正文\",\"paths\":[\"显式相对路径\"],\"expectedHead\":\"git.inspect 返回的 head\",\"expectedSnapshotFingerprint\":\"git.inspect 返回的 commitSnapshotFingerprint\"}}。project.git_commit 最多提交 12 个显式安全路径,要求 attached branch 和空 staged index,只创建本地 commit;不得用它或 command.exec 执行 push、分支、merge、rebase、reset、stash、tag、submodule 或 worktree 写操作。" - ); + let prompt = if runtime_owner_artifact_validation_available { + prompt + } else { + format!( + "{prompt}\n\n受控本地 Git 提交:git.inspect 会返回 commitSnapshotFingerprint;只有在完整审阅变更且最后一次源码修改已获得当前 revision 的 passed 验证后,才能调用 project.git_commit {{\"message\":\"提交标题和正文\",\"paths\":[\"显式相对路径\"],\"expectedHead\":\"git.inspect 返回的 head\",\"expectedSnapshotFingerprint\":\"git.inspect 返回的 commitSnapshotFingerprint\"}}。project.git_commit 最多提交 12 个显式安全路径,要求 attached branch 和空 staged index,只创建本地 commit;不得用它或 command.exec 执行 push、分支、merge、rebase、reset、stash、tag、submodule 或 worktree 写操作。" + ) + }; let isolated_tool_contract = required_runtime_prompt_section(RUNTIME_PROMPT_PROVIDER_ISOLATED_TOOL_CONTRACT_SECTION); let prompt = format!( @@ -333,10 +422,72 @@ pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request( "{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 = 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 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!( + "必须直接调用当前请求提供的原生函数:需要更新持久计划时调用 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, + mcp_catalog, + )?) + .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( @@ -357,7 +508,25 @@ pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request( let playtest_contract = autonomous_playtest_contract_prompt(playtest_scenario); system_prompt.push_str("\n\n"); system_prompt.push_str(playtest_contract); - system_prompt.push_str(" 只有当前 revision 通过 game.static_smoke,并由 preview.validate 对上述固定状态面和控件完成真实浏览器动作后,Runtime 才允许最终回复;不要伪造已通过 observation。"); + let root_source = agent_runtime_root_source_at(root, agent_id, run_id)?; + let verification_contract = if runtime_owner_artifact_validation_available { + " 当前固定 owner 写入后直接交付;Runtime 会在收束门内检查本人正式产物,禁止调用 game.static_smoke、project.verify 或 preview.validate 冒充。" + } else if agent_id == "preview-readiness" { + " 当前只读静态验收任务必须对最终 revision 执行 game.static_smoke,不执行 preview.validate。" + } else if agent_id == "preview-playtest" { + " 当前只读试玩任务必须执行 preview.validate,不执行 game.static_smoke。" + } else if agent_id == "code-prototype" + && root_source == AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE + { + " 当前 game-chat 主 Agent 必须对本人最终 mutation revision 依次通过 game.static_smoke 与 preview.validate。" + } else if agent_id == "code-prototype" { + " 当前程序 owner 必须对可玩入口执行 game.static_smoke;完整 DAG 的最终静态与浏览器验收仍由后续质量任务负责。" + } else if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { + " 根 Supervisor 只按完成合同防御性复核最终 revision,不得把自身复核冒充专业 child 职责。" + } else { + " 当前专业任务只按自身完成合同收束,不得伪造 smoke 或 preview observation。" + }; + system_prompt.push_str(verification_contract); } if autonomous_game_build { let root_source = agent_runtime_root_source_at(root, agent_id, run_id)?; @@ -378,8 +547,25 @@ pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request( .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(mcp_catalog)?) + .with_function_tools(build_agent_runtime_native_function_tools_for_agent( + agent_id, + mcp_catalog, + )?) .with_tool_choice(platform_llm::LlmToolChoice::Required); + if plan_root { + let stage = 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)?; + } if !root_control_authority { let goal_contract_function = native_runtime_function_name("agent.goal_contract") .ok_or_else(|| "无法生成 Goal Contract 工具函数名".to_string())?; @@ -389,6 +575,9 @@ pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request( tool.name != goal_contract_function && tool.name != acceptance_update_function }); } + if autonomous_game_build && agent_id == "art-director" { + remove_autonomous_art_director_non_canvas_validation_tools(&mut request.function_tools)?; + } // A rejected structured-plan update is a request-scoped liveness signal. // The next Provider turn must perform the concrete mutation (or deliver a // read-only result) instead of entering another planning loop. @@ -438,6 +627,24 @@ pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request( "上一轮 runtime.plan_update 被拒绝。本轮必须立即提交当前 in_progress 步骤对应的实际项目 mutation,或在只读合同已满足时 respond_to_user;禁止再次规划、读取、搜索、验证、委派或普通文本解释。" })); } + // 计划空转和 plan_update 被拒绝一样,是 request-scoped 的 liveness 信号: + // 连续若干轮只改计划解释、没有任何动作也没有步骤推进时,本轮直接把 + // update_agent_plan 从工具目录里摘掉。判据是 Runtime 拥有的持久计数,不是 + // prompt 提醒——观察 detail 里那句「只有步骤或状态真实变化时才调用」拦不住 + // 任何东西。只摘这一个工具,其余工具面原样保留。 + let plan_update_idle_rounds = read_game_creator_agent_runtime_at(root, agent_id) + .ok() + .map(|result| result.state) + .filter(|state| state.run_id == run_id) + .map_or(0, |state| state.plan_update_idle_rounds); + if plan_update_idle_rounds_require_repair(plan_update_idle_rounds) { + request + .function_tools + .retain(|tool| tool.name != AGENT_RUNTIME_UPDATE_PLAN_FUNCTION_NAME); + request.messages.push(LlmMessage::user(format!( + "已连续 {plan_update_idle_rounds} 轮只改结构化计划解释,没有任何动作,步骤状态也没有变化。本轮 update_agent_plan 已从工具目录中移除:必须直接调用当前 in_progress 步骤对应的实际动作函数,或在证据已足够时 respond_to_user。" + ))); + } if autonomous_game_build && !editor_api_key_is_configured() { let canvas_function = native_runtime_function_name("canvas.asset_generate") .ok_or_else(|| "无法生成画布素材工具函数名".to_string())?; @@ -454,15 +661,23 @@ pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request( } if root_goal_contract_required { restrict_agent_runtime_root_goal_contract_tools(&mut request)?; - request.messages.push(LlmMessage::user( - "当前根 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 或任何其他动作,不得输出普通文本。", - )); + 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() + }; + request + .messages + .push(LlmMessage::user(goal_contract_instruction)); } - request = apply_game_creator_llm_web_search( - apply_game_creator_llm_reasoning_effort(request, &llm)?, - &llm, - true, - )?; + 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)? + }; Ok(( llm, config_path, @@ -504,15 +719,27 @@ 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 = 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 { + 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 { game_creator_project_supervisor_chat_system_prompt() } else { game_creator_role_agent_chat_system_prompt() @@ -527,6 +754,11 @@ 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)) } @@ -662,17 +894,25 @@ mod tests { build_game_creator_agent_background_final_reply_request, build_game_creator_agent_background_tool_plan_request, game_creator_agent_context_preload_notice, game_creator_agent_runtime_role_overlay_prompt, + game_creator_agent_runtime_run_profile_binding_path, game_creator_project_supervisor_chat_system_prompt, init_local_game_project_at, - provider_command_exec_contract, provider_command_start_contract, + new_game_creation_app_seed_tasks, provider_command_exec_contract, + provider_command_start_contract, render_autonomous_manifest_ready_task_background_prompt, required_runtime_prompt_section, resolve_agent_conversation_session_id_at, start_game_creator_agent_runtime_task_at, AgentRuntimeGoalContractAcceptanceNodeDraft, AgentRuntimeGoalContractDraft, AgentRuntimeTaskLink, AgentRuntimeToolObservation, AgentRuntimeToolPlan, GameCreatorMcpCatalog, GameCreatorMcpCatalogTool, AGENT_RUNTIME_AUTONOMOUS_PRE_MUTATION_LOOP_LIMIT, - AGENT_RUNTIME_COMPLETION_BLOCKER_TOOL_PLAN_PROTOCOL, AGENT_RUNTIME_RESPOND_FUNCTION_NAME, - AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD, AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, + 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_GAME_CHAT_SOURCE, AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, - AGENT_RUNTIME_UPDATE_PLAN_FUNCTION_NAME, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + 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, RUNTIME_PROMPT_SUPERVISOR_CHAT_COMPOSITION, }; @@ -780,6 +1020,105 @@ mod tests { .any(|message| message.content.contains("runtime.plan_update 被拒绝"))); } + #[test] + fn idle_plan_update_rounds_drop_the_plan_tool_from_the_request_catalog() { + let directory = crate::tests::canonical_test_tempdir("provider-plan-idle-repair-"); + let root = directory.path().join("project"); + init_local_game_project_at(&root, "plan-idle-repair", "修复现有游戏") + .expect("project init"); + let binding = bind_game_creator_agent_runtime_run_profile_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "plan-idle-repair-root", + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + None, + ) + .expect("bind root"); + let state = start_game_creator_agent_runtime_task_at( + &root, + &binding.agent_id, + "修复现有游戏", + &binding.run_id, + &binding.source, + "执行当前计划中的项目修改", + vec!["立即修改 game/index.html".to_string()], + ) + .expect("start task"); + crate::agent::create_game_creator_agent_runtime_goal_contract_at( + &root, + &binding.agent_id, + &binding.run_id, + &state.current_task, + &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: "repair-game".to_string(), + criterion: "完成项目修改".to_string(), + required: true, + required_evidence: vec!["file.patch".to_string()], + dependencies: Vec::new(), + }], + }, + ) + .expect("create goal contract"); + let catalog = GameCreatorMcpCatalog { + fingerprint: String::new(), + servers: Vec::new(), + tools: Vec::new(), + }; + + // 没有空转计数时 update_agent_plan 必须还在,否则这条判据就等于永远生效。 + let (_, _, baseline, _, _) = build_game_creator_agent_background_tool_plan_request( + &root, + &state.agent_id, + &state.session_id, + &state.run_id, + &state.current_task, + &[], + 1, + &catalog, + ) + .expect("build baseline request"); + assert!(baseline + .function_tools + .iter() + .any(|tool| tool.name == AGENT_RUNTIME_UPDATE_PLAN_FUNCTION_NAME)); + + let mut idle_state = state.clone(); + idle_state.plan_update_idle_rounds = 2; + crate::agent::write_game_creator_agent_runtime_state(&root, &idle_state) + .expect("persist idle rounds"); + + let (_, _, request, _, _) = build_game_creator_agent_background_tool_plan_request( + &root, + &state.agent_id, + &state.session_id, + &state.run_id, + &state.current_task, + &[], + 2, + &catalog, + ) + .expect("build idle-repair request"); + assert!(!request + .function_tools + .iter() + .any(|tool| tool.name == AGENT_RUNTIME_UPDATE_PLAN_FUNCTION_NAME)); + // 只摘这一个工具:真动作和收束都必须还在,否则模型无路可走。 + assert!(request + .function_tools + .iter() + .any(|tool| tool.name == AGENT_RUNTIME_RESPOND_FUNCTION_NAME)); + assert!(request.messages.iter().any(|message| message + .content + .contains("update_agent_plan 已从工具目录中移除"))); + } + fn build_request_system_prompt_for_root_source( agent_id: &str, root_source: &str, @@ -876,6 +1215,92 @@ mod tests { request.messages[0].content.clone() } + fn build_autonomous_ready_child_request( + agent_id: &str, + root_source: &str, + suffix: &str, + ) -> platform_llm::LlmRunRequest { + let temporary = + crate::tests::canonical_test_tempdir(&format!("provider-owner-artifact-{suffix}-")); + let root = temporary.path().join("project"); + init_local_game_project_at( + &root, + &format!("owner-artifact-{suffix}"), + "owner artifact provider contract", + ) + .expect("project init"); + let parent_run_id = format!("owner-artifact-parent-{suffix}"); + let parent = bind_game_creator_agent_runtime_run_profile_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &parent_run_id, + root_source, + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + None, + ) + .expect("bind autonomous parent profile"); + let child_run_id = format!("owner-artifact-child-{suffix}"); + let child_link = AgentRuntimeTaskLink { + parent_agent_id: Some(parent.agent_id), + parent_run_id: Some(parent.run_id), + delegation_id: None, + }; + bind_game_creator_agent_runtime_run_profile_at( + &root, + agent_id, + &child_run_id, + "agent-ready-task-scheduler", + None, + Some(&child_link), + ) + .expect("bind autonomous ready child profile"); + let seed_task = new_game_creation_app_seed_tasks() + .into_iter() + .find(|task| task.id == agent_id) + .unwrap_or_else(|| panic!("missing seed task {agent_id}")); + let task = render_autonomous_manifest_ready_task_background_prompt(&seed_task); + assert!( + task.contains("这是 autonomous-game-build"), + "ready task prompt lost autonomous overlay: {task}" + ); + let state = start_game_creator_agent_runtime_task_at( + &root, + agent_id, + &task, + &child_run_id, + "agent-ready-task-scheduler", + "构建 owner artifact planning request", + vec!["交付当前 manifest task".to_string()], + ) + .expect("start autonomous ready child task"); + let catalog = GameCreatorMcpCatalog { + fingerprint: String::new(), + servers: Vec::new(), + tools: Vec::new(), + }; + let (_, _, request, _, _) = build_game_creator_agent_background_tool_plan_request( + &root, + agent_id, + &state.session_id, + &state.run_id, + &state.current_task, + &[], + 0, + &catalog, + ) + .expect("build autonomous ready child request"); + request + } + + fn request_advertises_native_tool(request: &platform_llm::LlmRunRequest, tool: &str) -> bool { + let function_name = crate::agent_native_tools::native_runtime_function_name(tool) + .expect("native runtime function name"); + request + .function_tools + .iter() + .any(|function| function.name == function_name) + } + #[test] fn context_preload_notice_matches_agent_context() { assert_eq!( @@ -890,6 +1315,198 @@ mod tests { ); } + #[test] + fn full_dag_pre_code_owner_requests_do_not_advertise_manual_verification() { + let _config_guard = crate::tests::write_test_local_config("{}".to_string()); + for (index, agent_id) in [ + "design-foundation", + "balance-seed", + "art-asset-plan", + "audio-asset-plan", + ] + .into_iter() + .enumerate() + { + let root_source = if index % 2 == 0 { + AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE + } else { + AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE + }; + let request = build_autonomous_ready_child_request( + agent_id, + root_source, + &format!("owner-{index}"), + ); + assert!(!request_advertises_native_tool(&request, "project.verify")); + assert!(!request_advertises_native_tool( + &request, + "command.run_limited" + )); + let system_prompt = &request.messages[0].content; + let user_prompt = &request.messages[1].content; + assert!(system_prompt.contains("固定 owner 写入后直接交付")); + assert!(system_prompt.contains("Runtime 会在收束门内检查本人正式产物")); + assert!(user_prompt.contains("固定 owner 收束协议")); + assert!(user_prompt.contains("当前请求不广告 project.verify 或 command.run_limited")); + assert!(!user_prompt.contains("project.verify 使用")); + assert!(!user_prompt.contains("command.run_limited 使用")); + assert!(user_prompt.contains("完成本人固定路径的正式产物后直接调用 respond_to_user")); + } + } + + #[tokio::test] + async fn autonomous_art_director_advertises_canvas_without_smoke_or_preview_tools() { + // debug 构建下 editor_api_mode() 恒为 PlatformAccount,配置里的 + // editorApi.apiKey 会被 editor_api_key_is_configured 完全忽略;只有凭据 + // override 或平台会话才算「已配置」,Canvas 才会进工具目录。 + let request = crate::assets::with_external_editor_api_credentials( + crate::assets::external_editor_api_credentials_for_test( + "https://editor.test".to_string(), + "art-director-provider-key".to_string(), + ), + async { + build_autonomous_ready_child_request( + "art-director", + AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, + "art-director-canvas-only", + ) + }, + ) + .await; + assert!(request_advertises_native_tool( + &request, + "canvas.asset_generate" + )); + for tool in [ + "project.verify", + "command.run_limited", + "preview.start", + "preview.validate", + ] { + assert!( + !request_advertises_native_tool(&request, tool), + "art-director must not advertise non-Canvas validation tool {tool}" + ); + } + } + + #[test] + fn playable_and_late_stage_requests_keep_their_existing_verification_boundaries() { + let _config_guard = crate::tests::write_test_local_config("{}".to_string()); + + let code = build_autonomous_ready_child_request( + "code-prototype", + AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, + "code-prototype", + ); + assert!(request_advertises_native_tool(&code, "command.run_limited")); + assert!(code.messages[0] + .content + .contains("程序 owner 必须对可玩入口执行 game.static_smoke")); + assert!(code.messages[1] + .content + .contains("必须对可玩入口执行 game.static_smoke")); + + let readiness = build_autonomous_ready_child_request( + "preview-readiness", + AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, + "preview-readiness", + ); + assert!(request_advertises_native_tool( + &readiness, + "command.run_limited" + )); + assert!(readiness.messages[0] + .content + .contains("必须对最终 revision 执行 game.static_smoke,不执行 preview.validate")); + assert!(readiness.messages[1] + .content + .contains("且只能是 command.run_limited(commandId=game.static_smoke)")); + + let publish = build_autonomous_ready_child_request( + "publish-package", + AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, + "publish-package", + ); + assert!(!request_advertises_native_tool(&publish, "project.verify")); + assert!(request_advertises_native_tool( + &publish, + "command.run_limited" + )); + let publish_prompts = publish + .messages + .iter() + .map(|message| message.content.as_str()) + .collect::>() + .join("\n"); + assert!(publish_prompts.contains("不在前置固定 owner 的内部产物验证范围内")); + assert!(!publish_prompts.contains("当前固定 owner 写入后直接交付")); + assert!(!publish_prompts.contains("固定 owner 收束协议")); + } + + #[tokio::test] + async fn art_director_request_exposes_canvas_only_for_the_keyed_owner_route() { + { + let _config_guard = crate::tests::write_test_local_config("{}".to_string()); + let request = build_autonomous_ready_child_request( + "art-director", + AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, + "art-director-no-key", + ); + assert!(!request_advertises_native_tool( + &request, + "canvas.asset_generate" + )); + let prompts = request + .messages + .iter() + .map(|message| message.content.as_str()) + .collect::>() + .join("\n"); + assert!( + prompts.contains("无生图凭据只读协调任务"), + "unexpected no-key art-director prompts: {prompts}" + ); + assert!(prompts.contains("不调用 canvas.asset_generate")); + } + // debug 构建下 editor_api_mode() 恒为 PlatformAccount,配置里的 + // editorApi.apiKey 会被 editor_api_key_is_configured 完全忽略;只有凭据 + // override 或平台会话才算「已配置」。 + let request = crate::assets::with_external_editor_api_credentials( + crate::assets::external_editor_api_credentials_for_test( + "https://editor.test".to_string(), + "provider-art-director-key".to_string(), + ), + async { + build_autonomous_ready_child_request( + "art-director", + AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, + "art-director-keyed", + ) + }, + ) + .await; + assert!(request_advertises_native_tool( + &request, + "canvas.asset_generate" + )); + let prompts = request + .messages + .iter() + .map(|message| message.content.as_str()) + .collect::>() + .join( + " +", + ); + assert!(prompts.contains("非只读视觉规范生成任务")); + assert!(prompts + .contains(crate::agent::AGENT_RUNTIME_AUTONOMOUS_ART_DIRECTOR_CANVAS_ONLY_TASK_MARKER)); + assert!(prompts.contains("assets/art-spec.png")); + assert!(prompts.contains("会同时提交当前 run 的 mutation 与验证凭证")); + assert!(!prompts.contains("无生图凭据只读协调任务")); + } + #[test] fn trusted_root_supervisor_first_turn_only_receives_goal_contract_tool() { let directory = crate::tests::canonical_test_tempdir("provider-goal-control-"); @@ -952,6 +1569,159 @@ mod tests { .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 catalog = GameCreatorMcpCatalog { + fingerprint: String::new(), + servers: Vec::new(), + tools: Vec::new(), + }; + + let (_, _, first, _, _) = build_game_creator_agent_background_tool_plan_request( + &root, + &state.agent_id, + &state.session_id, + &state.run_id, + &state.current_task, + &[], + 0, + &catalog, + ) + .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( + &root, + &state.agent_id, + &state.session_id, + &state.run_id, + &state.current_task, + &[], + 1, + &catalog, + ) + .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"); @@ -1305,6 +2075,153 @@ mod tests { assert!(function.parameters.pointer("/properties/reason").is_some()); } + #[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 catalog = GameCreatorMcpCatalog { + fingerprint: String::new(), + servers: Vec::new(), + tools: Vec::new(), + }; + let (_, _, planning_request, _, _) = build_game_creator_agent_background_tool_plan_request( + &root, + GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + &planning_state.session_id, + &planning_state.run_id, + &planning_state.current_task, + &[], + 0, + &catalog, + ) + .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 使用", + "当前 MCP 动态工具目录", + "持久进程协议", + ] { + 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( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &supervisor_state.session_id, + &supervisor_state.run_id, + &supervisor_state.current_task, + &[], + 0, + &catalog, + ) + .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 2229daef8..fd1564dbb 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,6 +118,54 @@ 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)?; + } + 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 或代码围栏。" + ) + } +} + +/// 把上游的终态标记夹紧成可落审计的短标记。 +/// +/// tool-plan 这条链路此前把 finish_reason 整个丢掉了:真撞上 max_output_tokens 时, +/// platform-llm 只会在**存在工具调用**且上游明确给出未完成终态时拒收,其余情形一律 +/// 当作可用的降级结果放行,而这一层既不看也不记,审计里没有任何东西能把「上游说这 +/// 一轮没写完」和「模型自己写歪了」分开。信封退化的现场排查就卡在这里。 +/// +/// 只记录,不改判:是否因未完成终态拒收仍旧由 platform-llm 决定,做游戏与做素材的 +/// 行为逐字不变。 +fn agent_runtime_tool_plan_audit_finish_reason(finish_reason: Option<&str>) -> Option { + // 兼容网关常发自定义值甚至整段文案,所以按固定字符集丢弃而不是原样透传。 + let reason = finish_reason? + .trim() + .to_ascii_lowercase() + .chars() + .filter(|character| { + character.is_ascii_lowercase() + || character.is_ascii_digit() + || *character == '_' + || *character == '-' + }) + .take(32) + .collect::(); + (!reason.is_empty()).then_some(reason) +} + pub(in crate::agent) fn append_game_creator_agent_tool_plan_audit_idempotent( root: &Path, record: serde_json::Value, @@ -173,7 +221,27 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at let initial_request_slot = format!("loop-{loop_index}-repair-0"); let (run_profile, _) = agent_runtime_run_profile_identity_at(root, agent_id, run_id, None, None)?; - let mcp_catalog = read_game_creator_mcp_catalog_at(root).await?; + 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 mcp_catalog = if agent_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID { + // Planning has a structurally empty MCP surface. Do not even resolve + // the project MCP catalog here: doing so can start/connect required + // servers and make an unrelated MCP outage block an exact plan turn. + GameCreatorMcpCatalog { + fingerprint: String::new(), + servers: Vec::new(), + tools: Vec::new(), + } + } else { + read_game_creator_mcp_catalog_at(root).await? + }; 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, @@ -287,21 +355,78 @@ 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 = { + let (provider_snapshot, initial_planning_session_binding) = { let _lock = acquire_game_creator_agent_provider_plan_project_write_lock_with_wait( root, "runtime.provider_request.capture.tool_plan", )?; - 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 { + // 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( + root, + agent_id, + session_id, + run_id, + task, + observations, + loop_index, + &mcp_catalog, + )?; + } + 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) }; + 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 { @@ -316,20 +441,28 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at && agent_runtime_task_requires_read_only_delivery_at( root, agent_id, session_id, run_id, task, )?; + let runtime_owner_artifact_validation_available = run_profile + == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + && autonomous_owner_artifact_validation_available_for_run_at(root, agent_id, run_id)?; + let code_prototype_requires_static_smoke = run_profile + == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + && agent_id == "code-prototype"; let verified_delivery = if run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD { let verification_gate = read_game_creator_agent_runtime_verification_gate(root, agent_id, run_id)?; - agent_runtime_autonomous_verified_delivery_allows_plan_completion( - agent_id, - &verification_gate, - ) + runtime_owner_artifact_validation_available + || agent_runtime_autonomous_verified_delivery_allows_plan_completion( + agent_id, + &verification_gate, + ) } else { false }; let allow_runtime_plan_completion = read_only_delivery || verified_delivery; let autonomous_project_verify_available = run_profile != AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD - || agent_runtime_autonomous_project_verify_available(root); + || (!runtime_owner_artifact_validation_available + && agent_runtime_autonomous_project_verify_available(root)); let mut autonomous_scaffold_repair_active = false; let mut supervisor_collaboration_repair_active = false; let mut supervisor_collaboration_repair_actions = Vec::new(); @@ -356,6 +489,36 @@ 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, @@ -412,6 +575,16 @@ 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( @@ -427,7 +600,8 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at Sha256::digest(response_handoff.provider_request_id.as_bytes()) ); let mut supervisor_collaboration_candidate_actions = None; - let parsed = parse_game_creator_agent_tool_plan_llm_response_with_catalog_classified( + let parsed = parse_game_creator_agent_tool_plan_llm_response_with_catalog_classified_for_agent( + agent_id, &response, &mcp_catalog, ) @@ -468,9 +642,11 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at ) })?; validate_agent_runtime_autonomous_specialist_response_delivery( + root, agent_id, run_id, read_only_delivery, + runtime_owner_artifact_validation_available, &verification_gate, &parsed.plan, ) @@ -723,6 +899,9 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at "responseFingerprint": response_fingerprint, "providerRequestIdSha256": provider_request_id_sha256, "protocol": protocol, + "finishReason": agent_runtime_tool_plan_audit_finish_reason( + response.finish_reason.as_deref(), + ), "functionCallCount": call_ids.len(), "callIdSha256s": call_id_sha256s, "functionNames": function_names, @@ -741,6 +920,7 @@ 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, mcp_catalog_fingerprint: mcp_catalog.fingerprint.clone(), estimated_input_tokens, @@ -895,13 +1075,26 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at && protocol_error .starts_with(AGENT_RUNTIME_AUTONOMOUS_TRUNCATED_SCAFFOLD_ERROR_PREFIX) && !request.function_tools.is_empty(); + let force_autonomous_owner_artifact_delivery = + runtime_owner_artifact_validation_available + && (force_autonomous_specialist_verification_only + || force_autonomous_pending_verification + || force_autonomous_reverify_after_mutation); let force_root_goal_contract = protocol_error .starts_with(AGENT_RUNTIME_ROOT_GOAL_CONTRACT_REQUIRED_ERROR_PREFIX); let force_supervisor_initial_collaboration = agent_runtime_protocol_error_requires_supervisor_collaboration_repair( &protocol_error, ) && !request.function_tools.is_empty(); - if force_root_goal_contract + if agent_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID { + request.function_tools = build_agent_runtime_native_function_tools_for_agent( + agent_id, + &mcp_catalog, + )?; + 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 || force_supervisor_initial_collaboration || force_autonomous_specialist_mutation_only || force_autonomous_specialist_verification_only @@ -918,8 +1111,22 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at || force_autonomous_read_only_delivery || force_autonomous_pre_mutation { - request.function_tools = - build_agent_runtime_native_function_tools(&mcp_catalog)?; + request.function_tools = build_agent_runtime_native_function_tools_for_agent( + agent_id, + &mcp_catalog, + )?; + if runtime_owner_artifact_validation_available { + remove_autonomous_owner_manual_verification_tools( + &mut request.function_tools, + )?; + } + if run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + && agent_id == "art-director" + { + remove_autonomous_art_director_non_canvas_validation_tools( + &mut request.function_tools, + )?; + } if !autonomous_project_verify_available { let project_verify_function = native_runtime_function_name("project.verify") @@ -930,10 +1137,13 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at } } if force_root_goal_contract { - restrict_agent_runtime_root_goal_contract_tools(&mut request)?; - request.messages.push(LlmMessage::user(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 或代码围栏。" - ))); + restrict_root_goal_contract_repair_request(&mut request, plan_root)?; + 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; if let Some(actions) = supervisor_collaboration_candidate_actions.take() { @@ -974,12 +1184,20 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at request.messages.push(LlmMessage::user(format!( "上一条输出不符合工具计划协议:{protocol_error}\n当前是 autonomous-game-build 的非只读专业任务,本次修复的原生工具目录只保留项目 mutation 工具。必须立即完成本人 run 的实际项目修改;不得 respond_to_user、验证、更新计划、读取、搜索、查询状态或委派。首次源码字段不得超过 {AGENT_RUNTIME_AUTONOMOUS_SCAFFOLD_SOURCE_MAX_CHARS} 字符,完整写入 game/index.html 时必须保持 HTML 与 script 闭合。不要解释,不要 markdown,不要代码围栏。" ))); + } else if force_autonomous_owner_artifact_delivery { + restrict_agent_runtime_autonomous_verified_delivery_repair_tools(&mut request)?; + request.messages.push(LlmMessage::user(format!( + "上一条输出不符合工具计划协议:{protocol_error}\n当前是完整 autonomous DAG 的固定 owner 写入任务。若本人正式产物已经完整,立即 respond_to_user;Runtime 会在收束门内按固定路径和父完成合同 baseline 执行结构验证。不得调用 project.verify、command.run_limited、preview、继续读取或解释;产物仍有缺口时,本轮 finalization 会返回精确 blocker,再按该 blocker 修复。不要 markdown,不要代码围栏。" + ))); } else if force_autonomous_specialist_verification_only { restrict_agent_runtime_autonomous_reverification_repair_tools( &mut request, - autonomous_project_verify_available, + autonomous_project_verify_available + && !code_prototype_requires_static_smoke, )?; - let verification_instruction = if autonomous_project_verify_available { + let verification_instruction = if code_prototype_requires_static_smoke { + "本次修复的原生工具目录只保留 command.run_limited;必须立即调用 game.static_smoke 验证本人 run 的最新 mutation revision,project.verify 不能满足 code-prototype 的可玩入口交付合同" + } else if autonomous_project_verify_available { "本次修复的原生工具目录只保留 project.verify 与 command.run_limited;必须立即验证本人 run 的最新 mutation revision" } else { "当前项目没有可用的 package.json,本次修复的原生工具目录只保留 command.run_limited;必须立即调用 game.static_smoke 验证本人 run 的最新 mutation revision" @@ -1035,9 +1253,12 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at } else if force_autonomous_reverify_after_mutation { restrict_agent_runtime_autonomous_reverification_repair_tools( &mut request, - autonomous_project_verify_available, + autonomous_project_verify_available + && !code_prototype_requires_static_smoke, )?; - let verification_instruction = if autonomous_project_verify_available { + let verification_instruction = if code_prototype_requires_static_smoke { + "本次修复的原生工具目录只保留 command.run_limited;必须立即调用 game.static_smoke 验证当前 revision,project.verify 不能满足 code-prototype 的可玩入口交付合同" + } else if autonomous_project_verify_available { "本次修复的原生工具目录只保留 project.verify 与 command.run_limited;必须立即验证当前 revision" } else { "当前项目没有可用的 package.json,本次修复的原生工具目录只保留 command.run_limited;必须立即调用 game.static_smoke 验证当前 revision" @@ -1048,9 +1269,12 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at } else if force_autonomous_pending_verification { restrict_agent_runtime_autonomous_verification_repair_tools( &mut request, - autonomous_project_verify_available, + autonomous_project_verify_available + && !code_prototype_requires_static_smoke, )?; - let verification_instruction = if autonomous_project_verify_available { + let verification_instruction = if code_prototype_requires_static_smoke { + "或在项目已经满足要求时立即调用 command.run_limited 的 game.static_smoke;project.verify 只可作为早期诊断,不能满足 code-prototype 完成合同" + } else if autonomous_project_verify_available { "或在项目已经满足要求时立即调用 project.verify / command.run_limited" } else { "或在项目已经满足要求时立即调用 command.run_limited 的 game.static_smoke" @@ -1089,6 +1313,17 @@ 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) => { @@ -1174,6 +1409,47 @@ pub(crate) async fn request_game_creator_agent_background_tool_plan_for_test( } } +#[cfg(test)] +mod tool_plan_audit_finish_reason_tests { + use super::*; + + /// 正常终态原样落库,缺失记 null——这两个值就是「上游到底说没说这一轮写完了」 + /// 的全部答案,此前审计里一个都没有。 + #[test] + fn ordinary_finish_reasons_are_recorded_and_absence_stays_null() { + assert_eq!( + agent_runtime_tool_plan_audit_finish_reason(Some("completed")).as_deref(), + Some("completed") + ); + assert_eq!( + agent_runtime_tool_plan_audit_finish_reason(Some(" INCOMPLETE ")).as_deref(), + Some("incomplete") + ); + assert_eq!(agent_runtime_tool_plan_audit_finish_reason(None), None); + assert_eq!( + agent_runtime_tool_plan_audit_finish_reason(Some(" ")), + None + ); + } + + /// 兼容网关会在这个字段里发自定义值甚至整段文案。审计不是转发通道:越界字符 + /// 一律丢弃,长度夹到 32,夹空了记 null,绝不原样透传。 + #[test] + fn gateway_freeform_reasons_are_clamped_rather_than_relayed() { + assert_eq!( + agent_runtime_tool_plan_audit_finish_reason(Some("上游异常:截断了")), + None + ); + assert_eq!( + agent_runtime_tool_plan_audit_finish_reason(Some("stop\n\"};DROP")).as_deref(), + Some("stopdrop") + ); + let clamped = agent_runtime_tool_plan_audit_finish_reason(Some(&"a".repeat(200))) + .expect("a long ascii reason is still recorded"); + assert_eq!(clamped.chars().count(), 32); + } +} + #[cfg(test)] mod supervisor_collaboration_repair_tests { use super::*; @@ -1271,4 +1547,38 @@ mod supervisor_collaboration_repair_tests { assert_eq!(merged, vec![replacement]); } + + #[test] + fn plan_root_goal_contract_repair_keeps_the_fixed_schema_and_instruction() { + let catalog = GameCreatorMcpCatalog { + fingerprint: String::new(), + servers: Vec::new(), + tools: Vec::new(), + }; + let mut request = LlmRunRequest::new(Vec::new()) + .with_function_tools( + build_agent_runtime_native_function_tools(&catalog) + .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 e7597e6ad..860ec9622 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,6 +68,7 @@ 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/structured_plan.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/structured_plan.rs index 0617863b5..b3f28ee45 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/structured_plan.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/structured_plan.rs @@ -115,10 +115,41 @@ pub(crate) fn sanitize_agent_runtime_plan_update( Ok(AgentRuntimePlanUpdate { explanation, steps }) } +/// 只改计划解释、不落地任何动作的一轮是「计划空转」。第一轮可能只是模型把 +/// 思考和动作拆成了两步,第二轮就是模式了:从这一轮起把 update_agent_plan 从 +/// 工具目录里摘掉,逼它要么调真动作要么收束。 +const AGENT_RUNTIME_PLAN_UPDATE_IDLE_REPAIR_ROUNDS: u32 = 2; + +pub(crate) fn plan_update_idle_rounds_require_repair(idle_rounds: u32) -> bool { + idle_rounds >= AGENT_RUNTIME_PLAN_UPDATE_IDLE_REPAIR_ROUNDS +} + +/// 一次 `update_agent_plan` 究竟改动了什么。 +/// +/// 拆开的理由:计划解释是给人看的自由文本,只改解释不构成计划进展。把它算进 +/// 「有变化」里,模型每轮重写一遍解释就能无限续命,Runtime 还会给它发一个新 +/// planRevision 背书,看上去像在推进。 +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum AgentRuntimePlanUpdateOutcome { + /// 步骤集合、状态和解释都没变,Runtime 什么都没写。 + Unchanged, + /// 只有计划解释变了:新解释照旧落盘,但不算进展,也不 bump planRevision。 + ExplanationOnly, + /// 步骤集合或步骤状态真实变化。 + StepsChanged, +} + +impl AgentRuntimePlanUpdateOutcome { + /// 计划是否真的往前走了一格。空转守卫只认这一个判据。 + pub(crate) fn advanced_steps(self) -> bool { + matches!(self, Self::StepsChanged) + } +} + pub(crate) fn apply_agent_runtime_plan_update( runtime: &mut AgentRuntimeState, update: &AgentRuntimePlanUpdate, -) -> Result { +) -> Result { let update = sanitize_agent_runtime_plan_update(update)?; let had_structured_plan = agent_runtime_has_structured_plan(runtime); let mut terminal_steps = std::collections::BTreeMap::new(); @@ -178,18 +209,19 @@ pub(crate) fn apply_agent_runtime_plan_update( )); } - let unchanged = had_structured_plan - && runtime.plan_explanation == update.explanation - && runtime.plan_steps.len() == merged.len() - && runtime + let steps_changed = !had_structured_plan + || runtime.plan_steps.len() != merged.len() + || !runtime .plan_steps .iter() .zip(merged.iter()) .all(|(existing, (title, status))| { existing.title == *title && existing.status == *status }); - if unchanged { - return Ok(false); + let explanation_changed = + !had_structured_plan || runtime.plan_explanation != update.explanation; + if !steps_changed && !explanation_changed { + return Ok(AgentRuntimePlanUpdateOutcome::Unchanged); } let now = unix_timestamp(); @@ -232,8 +264,11 @@ pub(crate) fn apply_agent_runtime_plan_update( .find(|step| step.status == AGENT_RUNTIME_PLAN_STATUS_IN_PROGRESS) .map(|step| step.index); runtime.plan_explanation = update.explanation; + if !steps_changed { + return Ok(AgentRuntimePlanUpdateOutcome::ExplanationOnly); + } runtime.plan_revision = runtime.plan_revision.saturating_add(1).max(1); - Ok(true) + Ok(AgentRuntimePlanUpdateOutcome::StepsChanged) } pub(in crate::agent) fn update_agent_runtime_plan_steps( 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 60447414b..4ea242753 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 @@ -20,6 +20,8 @@ pub(in crate::agent) fn parse_game_creator_agent_tool_plan_response_classified( parse_game_creator_agent_tool_plan_payload(payload, false) } +/// 只允许测试使用(沿用下方 `_classified` 的哨兵约束)。 +#[cfg(test)] pub(crate) fn parse_game_creator_agent_tool_plan_llm_response( response: &platform_llm::LlmRunResponse, ) -> Result { @@ -33,6 +35,8 @@ pub(crate) fn parse_game_creator_agent_tool_plan_llm_response( ) } +/// 只允许测试使用(沿用下方 `_classified` 的哨兵约束)。 +#[cfg(test)] pub(crate) fn parse_game_creator_agent_tool_plan_llm_response_with_catalog( response: &platform_llm::LlmRunResponse, mcp_catalog: &GameCreatorMcpCatalog, @@ -41,25 +45,43 @@ pub(crate) fn parse_game_creator_agent_tool_plan_llm_response_with_catalog( .map_err(|error| error.to_string()) } +/// 不带身份的解析入口,**只允许测试使用**。 +/// +/// `"__all_agents__"` 哨兵会跳过按身份的工具面复核;生产代码必须走 +/// `_for_agent` 并传真实 `agentId`。`#[cfg(test)]` 让漏改在编译期就失败, +/// 而不是在运行时静默放行本该被收窄的调用。 +#[cfg(test)] pub(crate) fn parse_game_creator_agent_tool_plan_llm_response_with_catalog_classified( response: &platform_llm::LlmRunResponse, mcp_catalog: &GameCreatorMcpCatalog, +) -> Result { + parse_game_creator_agent_tool_plan_llm_response_with_catalog_classified_for_agent( + "__all_agents__", + response, + mcp_catalog, + ) +} + +pub(crate) fn parse_game_creator_agent_tool_plan_llm_response_with_catalog_classified_for_agent( + agent_id: &str, + response: &platform_llm::LlmRunResponse, + mcp_catalog: &GameCreatorMcpCatalog, ) -> Result { if response.tool_calls.is_empty() { - return parse_game_creator_agent_tool_plan_response_classified(response.text.as_str()).map( - |plan| ParsedAgentRuntimeToolPlan { - plan, - protocol: "text_json", - call_id: None, - function_name: None, - call_ids: Vec::new(), - function_names: Vec::new(), - normalization_kinds: Vec::new(), - normalization_count: 0, - normalized_text_chars: 0, - normalized_text_sha256: None, - }, - ); + 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", + call_id: None, + function_name: None, + call_ids: Vec::new(), + function_names: Vec::new(), + normalization_kinds: Vec::new(), + normalization_count: 0, + normalized_text_chars: 0, + normalized_text_sha256: None, + }); } let mut text_normalization = normalize_game_creator_agent_tool_plan_function_text(&response.text); @@ -81,6 +103,12 @@ pub(crate) fn parse_game_creator_agent_tool_plan_llm_response_with_catalog_class 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| { @@ -102,8 +130,13 @@ pub(crate) fn parse_game_creator_agent_tool_plan_llm_response_with_catalog_class normalized_text_sha256: text_normalization.source_text_sha256, }); } - let native = parse_agent_runtime_native_tool_calls(&response.tool_calls, mcp_catalog)?; + let native = parse_agent_runtime_native_tool_calls_for_agent( + agent_id, + &response.tool_calls, + mcp_catalog, + )?; 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", @@ -118,6 +151,46 @@ pub(crate) fn parse_game_creator_agent_tool_plan_llm_response_with_catalog_class }) } +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, @@ -306,5 +379,29 @@ 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 cab37fe83..51f7b29fd 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,6 +3,13 @@ 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::*; @@ -14,6 +21,45 @@ 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> { @@ -65,6 +111,136 @@ 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, + /// 已有委派:取证、返工与审批相关工具全部开放。 + 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"], + // 合同已冻结且不可重写,再广告 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::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 说了什么。 +pub(crate) fn plan_root_supervisor_stage_at( + root: &Path, + agent_id: &str, + run_id: &str, +) -> Result { + 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); + Ok(if delegated { + PlanRootSupervisorStage::Delegated + } else { + PlanRootSupervisorStage::Delegate + }) +} + pub(crate) fn agent_runtime_native_executable_tools() -> Vec<&'static str> { agent_runtime_executable_tools() .into_iter() @@ -103,6 +279,37 @@ fn autonomous_game_build_agent_can_generate_canvas_asset(agent_id: &str) -> bool ) } +pub(crate) fn agent_runtime_autonomous_design_foundation_command_is_allowed( + command_id: &str, +) -> bool { + matches!( + command_id, + "memory.read" + | "conversation.read" + | "asset.list" + | "project.index" + | "file.read" + | "project.diff" + | "project.git_inspect" + | "file.list" + | "file.write" + | "file.delete" + | "project.patchset" + | "task.list" + // master 允许它,靠它跑 game.static_smoke 完成手动验证。分支把它删掉的前提是 + // 「Runtime 内部产物验证」顶上,但那道兜底只在 + // `autonomous_owner_artifact_validation_available_for_run_at` 为真时才发放。 + // 删了它,非 scheduler 路径(如被 agent.delegate 另行委派的 design-foundation) + // 手动验证被拒、内部验证又拿不到,收束无路可走。留在这里,由上面那道按 run + // 身份判定的门禁在 scheduler 路径上单独摘除。 + | "command.run_limited" + | "image.inspect" + | "canvas.asset_generate" + | "agent.audit" + | "agent.run_status" + ) +} + pub(in crate::agent) fn agent_runtime_tool_policy_snapshot_at( root: &Path, agent_id: &str, @@ -140,6 +347,29 @@ 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(), @@ -171,6 +401,55 @@ 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); } @@ -208,6 +487,37 @@ pub(crate) fn agent_runtime_tool_policy_snapshot_for_run_at( .push("canvas.asset_generate".to_string()); } } + if agent_id == "design-foundation" { + for tool in agent_runtime_executable_tools() { + let allowed = game_creator_agent_runtime_tool_command_id(tool) + .is_some_and(agent_runtime_autonomous_design_foundation_command_is_allowed); + if allowed { + continue; + } + snapshot.auto_tools.retain(|candidate| candidate != tool); + snapshot.confirm_tools.retain(|candidate| candidate != tool); + if !snapshot + .denied_tools + .iter() + .any(|candidate| candidate == tool) + { + snapshot.denied_tools.push(tool.to_string()); + } + } + } + if autonomous_owner_artifact_validation_available_for_run_at(root, agent_id, run_id)? { + for tool in ["project.verify", "command.run_limited"] { + snapshot.auto_tools.retain(|candidate| candidate != tool); + snapshot.confirm_tools.retain(|candidate| candidate != tool); + if !snapshot + .denied_tools + .iter() + .any(|candidate| candidate == tool) + { + snapshot.denied_tools.push(tool.to_string()); + } + } + } for tool in agent_runtime_executable_tools() { let Some(command_id) = game_creator_agent_runtime_tool_command_id(tool) else { continue; 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 357aec35c..b7360c185 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,6 +16,25 @@ 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( @@ -91,10 +110,13 @@ mod tests { #[test] fn game_creator_runtime_agent_catalog_matches_the_existing_role_directory() { let catalog = game_creator_runtime_agent_catalog().expect("agent catalog"); - let mut expected = - std::collections::BTreeSet::from( - [GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string()], - ); + // 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(), + ]); 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 3d36dd892..9e8f3d965 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,6 +60,11 @@ 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 = @@ -107,26 +112,258 @@ 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_GAME_CHAT_SOURCE: &str = "project-supervisor-game-chat"; +pub(crate) const AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE: &str = "project-supervisor-plan"; +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"; pub(super) const GAME_CHAT_FIXED_TASK_GRAPH_STALLED_ERROR: &str = "game-chat 首版固定任务图无法继续推进,拒绝回退到普通 Provider 协作波"; +/// 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_GAME_CHAT_SOURCE + | AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE ) } + +pub(crate) fn agent_runtime_supervisor_source_is_autonomous_game_build(source: &str) -> bool { + matches!( + source.trim(), + AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE + | AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE + | AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_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 = "game-creator-autonomous-completion-contract.v2"; pub(super) const AGENT_RUNTIME_AUTONOMOUS_PLAYTEST_RECEIPT_SCHEMA_VERSION: &str = - "game-creator-autonomous-playtest-receipt.v1"; + "game-creator-autonomous-playtest-receipt.v2"; pub(super) const AGENT_RUNTIME_AUTONOMOUS_GAME_INDEX_MAX_BYTES: u64 = 4 * 1024 * 1024; pub(super) const AGENT_RUNTIME_AUTONOMOUS_BROWSER_EVIDENCE_MAX_BYTES: u64 = 16 * 1024 * 1024; pub(super) const AGENT_RUNTIME_GAME_INDEX_PATH: &str = "game/index.html"; +pub(crate) const AGENT_RUNTIME_OWNER_ARTIFACT_VERIFICATION_TOOL: &str = + "runtime.owner_artifacts_validate"; pub(super) const AGENT_RUNTIME_AUTONOMOUS_GAME_BUILD_AUTO_COMMAND_IDS: &[&str] = &[ "file.write", "file.delete", @@ -203,6 +440,14 @@ pub(super) struct AgentRuntimeAutonomousPlaytestReceipt { pub(super) agent_id: String, pub(super) run_id: String, pub(super) run_profile_binding_fingerprint: String, + #[serde(default)] + pub(super) executor_agent_id: String, + #[serde(default)] + pub(super) executor_run_id: String, + #[serde(default)] + pub(super) executor_source: String, + #[serde(default)] + pub(super) executor_run_profile_binding_fingerprint: String, pub(super) action_id: String, pub(super) action_fingerprint: String, pub(super) revision: u64, @@ -272,6 +517,8 @@ pub(crate) use entrypoints::{ #[cfg(test)] pub(crate) use finalization::resume_game_creator_agent_finalization_for_test_at; pub(crate) use finalization::AgentRuntimePendingActionResume; +#[cfg(test)] +pub(crate) use interaction::acquire_game_creator_agent_runtime_user_input_answer_locks_for_test; pub(crate) use interaction::{ agent_runtime_tool_requires_repository_context_fingerprint_gate, answer_game_creator_agent_runtime_user_input_at, confirm_game_creator_agent_runtime_task_at, @@ -317,6 +564,8 @@ pub(crate) use recovery_scan::{ wake_pending_game_creator_agent_background_tasks_at, }; #[cfg(test)] +pub(crate) use task_queue::agent_runtime_background_worker_threads_for_test; +#[cfg(test)] pub(crate) use task_queue::drain_next_game_creator_agent_background_tasks_for_test; pub(crate) use task_queue::{ run_game_creator_agent_background_task_with_context, @@ -353,6 +602,8 @@ pub(super) const AGENT_RUNTIME_FINALIZATION_STATUS_ASSISTANT_PERSISTED: &str = pub(super) const AGENT_RUNTIME_FINALIZATION_STATUS_RUNTIME_COMPLETED: &str = "runtime-completed"; pub(super) const AGENT_RUNTIME_PROVIDER_REQUEST_LIFECYCLE_SCHEMA_VERSION: &str = "game-creator-provider-request-lifecycle.v2"; +pub(super) const AGENT_RUNTIME_PLAN_PROVIDER_REQUEST_LIFECYCLE_SCHEMA_VERSION: &str = + "game-creator-provider-request-lifecycle.v3"; pub(super) const AGENT_RUNTIME_PROVIDER_REQUEST_LIFECYCLE_RECORD_TYPE: &str = "agent.runtime.provider_request.lifecycle"; pub(super) const AGENT_RUNTIME_PROVIDER_REQUEST_RECONCILIATION_PREFIX: &str = 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 cdae6a7d8..7cb0b3801 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 @@ -3,6 +3,11 @@ use super::*; pub(crate) enum AgentRuntimePendingActionResume { NotFound(AgentRuntimeTaskLock), Handled(AgentRuntimeResult), + /// 本轮无法在不破坏锁序的前提下推进:为了按 project -> execution 顺序取锁, + /// 执行锁已经被放掉,重取时又被别处占住。返回时不带锁——两把锁都已释放。 + /// 这不是失败:锁被占恰恰说明别处正在推进,调用方应跳过该 Agent 等下一轮, + /// 而不是把整轮恢复判失败。 + Deferred, } pub(in crate::agent) enum AgentRuntimeFinalizationResume { @@ -390,6 +395,10 @@ pub(in crate::agent) fn resume_game_creator_agent_finalization_at( let current_revision = read_game_creator_agent_runtime_project_revision(root)?; let blocker = 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/game_chat_fast_path.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/game_chat_fast_path.rs index dce612dea..d4d735612 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/game_chat_fast_path.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/game_chat_fast_path.rs @@ -1442,6 +1442,13 @@ pub(crate) fn game_chat_fast_path_scheduled_art_contract_repair_is_authorized_at .ok_or_else(|| "game-chat 美术 child 缺少 Run Profile 绑定".to_string())?; if task.agent_id != agent_id || task.run_id != run_id + || child_binding.agent_id != task.agent_id + || child_binding.run_id != task.run_id + || child_binding.source != task.source + || child_binding.profile != task.run_profile + || child_binding.binding_fingerprint != task.run_profile_binding_fingerprint + || child_binding.parent_agent_id != task.parent_agent_id + || child_binding.parent_run_id != task.parent_run_id || task.source != "agent-delegate" || task.run_profile != AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD || task.status != "running" @@ -1463,6 +1470,27 @@ pub(crate) fn game_chat_fast_path_scheduled_art_contract_repair_is_authorized_at else { return Ok(false); }; + let Some(parent_binding) = + read_game_creator_agent_runtime_run_profile_binding(root, parent_agent_id, parent_run_id)? + else { + return Ok(false); + }; + let Some(root_binding) = read_game_creator_agent_runtime_run_profile_binding( + root, + &child_binding.root_agent_id, + &child_binding.root_run_id, + )? + else { + return Ok(false); + }; + let Some(root_task) = read_latest_game_creator_agent_runtime_task_by_run_id( + root, + &root_binding.agent_id, + &root_binding.run_id, + )? + else { + return Ok(false); + }; let parent_is_main = parent.agent_id == "code-prototype" && parent.source == "agent-ready-task-scheduler" && parent.parent_agent_id.as_deref() == Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID) @@ -1470,6 +1498,38 @@ pub(crate) fn game_chat_fast_path_scheduled_art_contract_repair_is_authorized_at if !parent_is_main || parent.run_profile != AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD || parent.status != "running" + || parent.delegation_id.is_some() + || parent_binding.agent_id != parent.agent_id + || parent_binding.run_id != parent.run_id + || parent_binding.source != parent.source + || parent_binding.profile != parent.run_profile + || parent_binding.binding_fingerprint != parent.run_profile_binding_fingerprint + || parent_binding.parent_agent_id != parent.parent_agent_id + || parent_binding.parent_run_id != parent.parent_run_id + || parent_binding.project_id != child_binding.project_id + || parent_binding.root_agent_id != child_binding.root_agent_id + || parent_binding.root_run_id != child_binding.root_run_id + || child_binding.parent_binding_fingerprint.as_deref() + != Some(parent_binding.binding_fingerprint.as_str()) + || root_binding.project_id != child_binding.project_id + || root_binding.agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID + || root_binding.run_id != child_binding.root_run_id + || root_binding.source != AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE + || root_binding.profile != AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + || root_binding.root_agent_id != root_binding.agent_id + || root_binding.root_run_id != root_binding.run_id + || root_binding.parent_agent_id.is_some() + || root_binding.parent_run_id.is_some() + || parent_binding.parent_binding_fingerprint.as_deref() + != Some(root_binding.binding_fingerprint.as_str()) + || root_task.agent_id != root_binding.agent_id + || root_task.run_id != root_binding.run_id + || root_task.source != root_binding.source + || root_task.run_profile != root_binding.profile + || root_task.run_profile_binding_fingerprint != root_binding.binding_fingerprint + || root_task.parent_agent_id.is_some() + || root_task.parent_run_id.is_some() + || root_task.delegation_id.is_some() { return Ok(false); } @@ -1481,14 +1541,187 @@ pub(crate) fn game_chat_fast_path_scheduled_art_contract_repair_is_authorized_at { return Ok(false); } - Ok( - read_game_chat_asset_route_at(root, &child_binding.root_run_id)?.is_some_and(|route| { + let delegation_id = task + .delegation_id + .as_deref() + .expect("validated game-chat art child has delegation id"); + let Some(delivery) = read_static_delegate_delivery_at(root, delegation_id)? else { + return Ok(false); + }; + let expected_delegation_id = agent_runtime_delegation_id( + &parent.agent_id, + &parent.run_id, + &task.agent_id, + &delivery.parent_action_id, + ); + let route_authorizes_agent = read_game_chat_asset_route_at(root, &child_binding.root_run_id)? + .is_some_and(|route| { route .generated_task_ids .iter() .any(|task_id| task_id == agent_id) - }), - ) + }); + Ok(route_authorizes_agent + && delivery.delegation_id == delegation_id + && expected_delegation_id == delegation_id + && delivery.parent_agent_id == parent.agent_id + && delivery.parent_session_id == parent.session_id + && delivery.parent_run_id == parent.run_id + && delivery.target_agent_id == task.agent_id + && delivery.target_session_id == task.session_id + && delivery.target_run_id == task.run_id + && !delivery.acceptance_criteria.is_empty() + && delivery.expected_artifacts.len() == 1 + && delivery.expected_artifacts.first().map(String::as_str) == Some(output_path) + && delivery.repair_of_delegation_id.is_none() + && delivery.status == StaticDelegateDeliveryStatus::Dispatched) +} + +pub(in crate::agent) fn game_chat_delegated_art_asset_plan_uses_canvas_verification_at( + root: &Path, + agent_id: &str, + run_id: &str, +) -> Result { + if agent_id != "art-asset-plan" + || !game_chat_fast_path_scheduled_art_contract_repair_is_authorized_at( + root, + agent_id, + run_id, + AGENT_RUNTIME_ART_SPRITESHEET_PATH, + )? + { + return Ok(false); + } + let Some(task) = read_latest_game_creator_agent_runtime_task_by_run_id(root, agent_id, run_id)? + else { + return Ok(false); + }; + let Some(delegation_id) = task + .delegation_id + .as_deref() + .filter(|value| !value.trim().is_empty()) + else { + return Ok(false); + }; + let Some(binding) = + read_game_creator_agent_runtime_run_profile_binding(root, agent_id, run_id)? + else { + return Ok(false); + }; + if binding.agent_id != task.agent_id + || binding.run_id != task.run_id + || binding.source != task.source + || binding.profile != task.run_profile + || binding.binding_fingerprint != task.run_profile_binding_fingerprint + || binding.parent_agent_id != task.parent_agent_id + || binding.parent_run_id != task.parent_run_id + || binding.source != "agent-delegate" + || binding.profile != AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + || binding.parent_agent_id.as_deref() != Some("code-prototype") + || binding.root_agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID + || task.status != "running" + { + return Ok(false); + } + let parent_agent_id = binding + .parent_agent_id + .as_deref() + .expect("validated game-chat art child has parent agent"); + let parent_run_id = binding + .parent_run_id + .as_deref() + .expect("validated game-chat art child has parent run"); + let Some(parent) = read_latest_game_creator_agent_runtime_task_by_run_id( + root, + parent_agent_id, + parent_run_id, + )? + else { + return Ok(false); + }; + let Some(parent_binding) = + read_game_creator_agent_runtime_run_profile_binding(root, parent_agent_id, parent_run_id)? + else { + return Ok(false); + }; + let Some(root_binding) = read_game_creator_agent_runtime_run_profile_binding( + root, + &binding.root_agent_id, + &binding.root_run_id, + )? + else { + return Ok(false); + }; + let Some(root_task) = read_latest_game_creator_agent_runtime_task_by_run_id( + root, + &root_binding.agent_id, + &root_binding.run_id, + )? + else { + return Ok(false); + }; + if parent_binding.agent_id != parent.agent_id + || parent_binding.run_id != parent.run_id + || parent_binding.source != parent.source + || parent_binding.profile != parent.run_profile + || parent_binding.binding_fingerprint != parent.run_profile_binding_fingerprint + || parent_binding.parent_agent_id != parent.parent_agent_id + || parent_binding.parent_run_id != parent.parent_run_id + || parent_binding.source != "agent-ready-task-scheduler" + || parent_binding.profile != AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + || parent_binding.project_id != binding.project_id + || parent_binding.root_agent_id != binding.root_agent_id + || parent_binding.root_run_id != binding.root_run_id + || parent_binding.parent_agent_id.as_deref() != Some(root_binding.agent_id.as_str()) + || parent_binding.parent_run_id.as_deref() != Some(root_binding.run_id.as_str()) + || parent_binding.parent_binding_fingerprint.as_deref() + != Some(root_binding.binding_fingerprint.as_str()) + || binding.parent_binding_fingerprint.as_deref() + != Some(parent_binding.binding_fingerprint.as_str()) + || root_binding.project_id != binding.project_id + || root_binding.agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID + || root_binding.run_id != binding.root_run_id + || root_binding.source != AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE + || root_binding.profile != AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + || root_binding.root_agent_id != root_binding.agent_id + || root_binding.root_run_id != root_binding.run_id + || root_binding.parent_agent_id.is_some() + || root_binding.parent_run_id.is_some() + || root_task.agent_id != root_binding.agent_id + || root_task.run_id != root_binding.run_id + || root_task.source != root_binding.source + || root_task.run_profile != root_binding.profile + || root_task.run_profile_binding_fingerprint != root_binding.binding_fingerprint + || root_task.parent_agent_id.is_some() + || root_task.parent_run_id.is_some() + || root_task.delegation_id.is_some() + || parent.status != "running" + { + return Ok(false); + } + let Some(delivery) = read_static_delegate_delivery_at(root, delegation_id)? else { + return Ok(false); + }; + let expected_delegation_id = agent_runtime_delegation_id( + &parent.agent_id, + &parent.run_id, + &task.agent_id, + &delivery.parent_action_id, + ); + Ok(delivery.delegation_id == delegation_id + && expected_delegation_id == delegation_id + && delivery.parent_agent_id == parent.agent_id + && delivery.parent_session_id == parent.session_id + && delivery.parent_run_id == parent.run_id + && delivery.target_agent_id == task.agent_id + && delivery.target_session_id == task.session_id + && delivery.target_run_id == task.run_id + && !delivery.acceptance_criteria.is_empty() + && delivery.expected_artifacts.len() == 1 + && delivery.expected_artifacts.first().map(String::as_str) + == Some(AGENT_RUNTIME_ART_SPRITESHEET_PATH) + && delivery.repair_of_delegation_id.is_none() + && delivery.status == StaticDelegateDeliveryStatus::Dispatched) } pub(in crate::agent) fn game_chat_fast_path_art_slice_paths( 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 fd2d8c440..9e9a692ea 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 @@ -441,6 +441,79 @@ pub(in crate::agent) fn resolve_game_creator_agent_runtime_user_input_action( Ok((agent_id, task, runtime, pending)) } +type AgentRuntimeUserInputActionResolution = ( + String, + AgentRuntimeTaskRecord, + AgentRuntimeState, + AgentRuntimePendingToolAction, +); + +fn resolve_game_creator_agent_runtime_user_input_action_with_ordered_locks( + root: &Path, + agent_id: &str, + run_id: &str, + action_id: &str, +) -> Result< + ( + Option, + AgentRuntimeTaskLock, + AgentRuntimeUserInputActionResolution, + ), + 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)) + } +} + +#[cfg(test)] +pub(crate) fn acquire_game_creator_agent_runtime_user_input_answer_locks_for_test( + root: &Path, + agent_id: &str, + run_id: &str, + action_id: &str, +) -> Result<(Option, AgentRuntimeTaskLock), String> { + let (project_lock, runtime_lock, _) = + resolve_game_creator_agent_runtime_user_input_action_with_ordered_locks( + root, agent_id, run_id, action_id, + )?; + Ok((project_lock, runtime_lock)) +} + pub(crate) fn answer_game_creator_agent_runtime_user_input_at( root: &Path, agent_id: &str, @@ -452,17 +525,29 @@ pub(crate) fn answer_game_creator_agent_runtime_user_input_at( ) -> Result { let agent_id = normalize_game_creator_runtime_agent_id(agent_id)?; validate_project_root(root)?; - let runtime_lock = acquire_game_creator_agent_runtime_task_lock_with_wait(root, &agent_id)?; - let (agent_id, task, mut runtime, mut pending) = - resolve_game_creator_agent_runtime_user_input_action(root, &agent_id, run_id, action_id)?; + let (project_lock, runtime_lock, resolved) = + resolve_game_creator_agent_runtime_user_input_action_with_ordered_locks( + root, &agent_id, run_id, action_id, + )?; + let (agent_id, task, mut runtime, mut pending) = resolved; let already_observed = pending.status == AGENT_RUNTIME_PENDING_ACTION_STATUS_OBSERVED_APPROVED; - let (request, observation) = answer_game_creator_agent_user_input_request_for_pending_at( - root, - &pending, - request_id, - response_id, - answers, - )?; + let (request, observation) = match project_lock.as_ref() { + Some(project_lock) => answer_game_creator_agent_user_input_request_for_pending_at_locked( + root, + &pending, + request_id, + response_id, + answers, + project_lock, + )?, + None => answer_game_creator_agent_user_input_request_for_pending_at( + root, + &pending, + request_id, + response_id, + answers, + )?, + }; if already_observed { if pending.observation.as_ref() != Some(&observation) { return Err("用户输入回答与已持久化 observation 冲突".to_string()); @@ -522,6 +607,7 @@ pub(crate) fn answer_game_creator_agent_runtime_user_input_at( })?; } let result = read_game_creator_agent_runtime_at(root, &agent_id)?; + drop(project_lock); if external_agent_runner_owns_background_execution() { let answered_run_id = pending.run_id.clone(); let answered_action_id = pending.action_id.clone(); @@ -700,7 +786,8 @@ pub(in crate::agent) async fn continue_game_creator_agent_parallel_read_batch( let mut observations = first_pending.observations.clone(); let mut plan = first_pending.tool_plan(); let next_loop_index = usize::try_from(first_pending.loop_iteration).unwrap_or(usize::MAX); - let mut context_tracker = AgentRuntimeContextWindowTracker::from_continuation(&continuation); + let mut context_tracker = + AgentRuntimeContextWindowTracker::from_continuation(&continuation, &runtime); if let Err(error) = project_game_creator_agent_runtime_parallel_read_batch( &root, &mut runtime, 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 512271191..b1ba1c36b 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,6 +749,12 @@ 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 { @@ -765,13 +771,37 @@ 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() }; Ok((run_profile, source)) } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(in crate::agent) enum AgentRuntimeRetryErrorKind { + GameChatDynamicArtRetryUnsupported, +} + +impl AgentRuntimeRetryErrorKind { + pub(in crate::agent) const fn as_str(self) -> &'static str { + match self { + Self::GameChatDynamicArtRetryUnsupported => "game-chat-dynamic-art-retry-unsupported", + } + } + + fn wire_error(self, message: &str) -> String { + format!("kind={} {message}", self.as_str()) + } +} + pub(crate) fn retry_game_creator_agent_runtime_task_at( root: &Path, agent_id: &str, @@ -787,6 +817,13 @@ pub(crate) fn retry_game_creator_agent_runtime_task_at( let task = read_latest_game_creator_agent_runtime_task_by_run_id(root, &agent_id, &target_run_id)? .ok_or_else(|| format!("未找到 Agent Runtime 任务:{target_run_id}"))?; + if game_chat_dynamic_art_child_structural_identity_at(root, &task)? { + return Err( + AgentRuntimeRetryErrorKind::GameChatDynamicArtRetryUnsupported.wire_error( + "game-chat 动态美术 child 不支持通用 Agent Runtime retry;请继续 game-chat 对话,由下一轮 main code-prototype 重新完成 asset.list 审计,再按仍存在的缺口建立新的 durable 美术委派", + ), + ); + } if task.goal_id.is_some() { return Err("持久 Goal 任务不能使用普通 retry;请清理后创建新 Goal".to_string()); } @@ -888,23 +925,45 @@ pub(crate) fn retry_game_creator_agent_runtime_task_at( &task, retry_link.is_some(), )?; - 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 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 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(); @@ -932,12 +991,16 @@ pub(crate) fn retry_game_creator_agent_runtime_task_at( }), )?; result.accepted_run_id = Some(actual_retry_run_id.clone()); - notify_external_agent_runner_after_background_task_enqueue( - root, - &agent_id, - &task.session_id, - &actual_retry_run_id, - )?; + 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, + )?; + } 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 f868e2241..15c861adb 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,5 +1,135 @@ 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; @@ -79,12 +209,259 @@ pub(super) fn game_creator_agent_final_reply_error_allows_fallback(error: &str) matches!(kind.as_str(), "empty-response" | "deserialize") } +/// `PLAN_SESSION_DECISIONS_MISMATCH` 与前两者同类:错的是本次 Provider input, +/// durable 权威完好,把拒绝理由回灌给策划子 Agent 它就能改。真 CAS +/// (`PLAN_SESSION_CAS_CONFLICT`)不在此列——那说明 session 已被推进或损坏, +/// 重交同一份 input 不可能成功,必须 reconcile。 +fn plan_submit_error_is_business_rejection(error: &PlanningStorageError) -> bool { + matches!( + error.code(), + "PLAN_INVALID_REQUEST" | "PLAN_SIZE_LIMIT" | "PLAN_SESSION_DECISIONS_MISMATCH" + ) +} + +/// 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; + +/// 纯只读工具不算「推进」。 +/// +/// 空转计数只在**裸 `update_agent_plan` 且步骤没有真实变化**时累加,早期实现却让 +/// 任意非空 actions 都把它清零。于是「裸计划更新被 blocked → 调一次只读工具 → +/// 计数归零」构成一个闸门永远关不上的活锁:实测一次生产 run 24 轮里 58 次 +/// `agent.run_status`、0 次 `agent.delegate`,一路烧到超时。只读调用证明不了任何 +/// 推进,不该清零。 +/// +/// 纯只读调查不受影响:只要不发裸计划更新,计数根本不会累加,这里也就无事发生。 +fn agent_runtime_action_can_advance_progress(tool: &str) -> bool { + !matches!( + tool, + "agent.run_status" + | "agent.action_history" + | "file.read" + | "file.list" + | "project.index" + | "project.search" + | "project.diff" + | "git.inspect" + | "command.output_read" + ) +} + +fn plan_update_idle_limit_reached(idle_rounds: u32) -> bool { + idle_rounds >= AGENT_RUNTIME_PLAN_UPDATE_IDLE_LIMIT +} + +#[cfg(test)] +mod plan_update_idle_guard_tests { + use super::*; + + /// 只读工具不得把空转计数清零。这是 run 18 活锁的机制根因:Supervisor 冻结 + /// 合同后没有委派,改成「裸计划更新被 blocked → 调一次 agent.run_status → + /// 计数归零」,24 轮里 58 次 run_status、0 次 delegate,闸门一次没响。 + #[test] + fn read_only_tools_do_not_count_as_progress() { + for tool in [ + "agent.run_status", + "agent.action_history", + "file.read", + "file.list", + "project.index", + "project.search", + "project.diff", + "git.inspect", + "command.output_read", + ] { + assert!( + !agent_runtime_action_can_advance_progress(tool), + "{tool} 是只读工具,不该被当成推进" + ); + } + } + + /// 真正推进 durable 状态的动作照旧清零,否则合法的长链路会被误杀。 + #[test] + fn state_advancing_tools_still_count_as_progress() { + for tool in [ + "agent.delegate", + "agent.goal_contract", + "agent.acceptance_update", + "file.write", + "project.patchset", + "command.exec", + PLAN_SUBMIT_GDD_TOOL, + ] { + assert!( + agent_runtime_action_can_advance_progress(tool), + "{tool} 会推进 durable 状态,必须清零空转计数" + ); + } + } +} + +#[cfg(test)] +mod plan_update_idle_guard_threshold_tests { + use super::*; + + #[test] + fn idle_guard_always_tries_self_repair_before_killing_the_run() { + // 摘工具的阈值必须严格小于收束限额,否则 run 会在从没被逼过一次真动作 + // 的情况下直接失败,自愈这一级就等于不存在。 + assert!(plan_update_idle_rounds_require_repair( + AGENT_RUNTIME_PLAN_UPDATE_IDLE_LIMIT + )); + assert!(!plan_update_idle_limit_reached( + AGENT_RUNTIME_PLAN_UPDATE_IDLE_LIMIT - 1 + )); + let first_repair_round = (0..=AGENT_RUNTIME_PLAN_UPDATE_IDLE_LIMIT) + .find(|rounds| plan_update_idle_rounds_require_repair(*rounds)) + .expect("repair threshold within limit"); + assert!(first_repair_round < AGENT_RUNTIME_PLAN_UPDATE_IDLE_LIMIT); + } + + #[test] + fn idle_limit_is_reached_only_at_the_configured_round() { + assert!(!plan_update_idle_limit_reached(0)); + assert!(!plan_update_idle_limit_reached( + AGENT_RUNTIME_PLAN_UPDATE_IDLE_LIMIT - 1 + )); + assert!(plan_update_idle_limit_reached( + AGENT_RUNTIME_PLAN_UPDATE_IDLE_LIMIT + )); + assert!(plan_update_idle_limit_reached( + AGENT_RUNTIME_PLAN_UPDATE_IDLE_LIMIT + 1 + )); + } +} + +fn finish_plan_update_idle_limit_at( + root: &Path, + runtime: &AgentRuntimeState, +) -> Result { + let error = format!( + "结构化计划连续 {AGENT_RUNTIME_PLAN_UPDATE_IDLE_LIMIT} 轮只改解释、没有任何动作也没有步骤推进,已停止自动续跑;请检查最后一次 runtime.plan_update observation 后重新发起任务。" + ); + 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_UPDATE_IDLE_LIMIT, + ); + 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, + }) +} + fn requested_game_chat_fast_path_plan_at( root: &Path, plan: AgentRuntimeToolPlan, ) -> Result { Ok(RequestedAgentRuntimeToolPlan { plan, + planning_session_binding: None, repository_context_fingerprint: build_repository_startup_context_at(root)?.fingerprint, mcp_catalog_fingerprint: String::new(), estimated_input_tokens: 0, @@ -298,6 +675,10 @@ pub(in crate::agent) fn prepare_game_chat_single_round_convergence_at( } 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_BUDGET: &str = "loop-budget-exhausted"; const AGENT_RUNTIME_BACKGROUND_FAILURE_KIND_FINAL_REPLY: &str = "final-reply-failed"; const AGENT_RUNTIME_BACKGROUND_FAILURE_KIND_FINALIZATION: &str = "finalization-failed"; @@ -751,12 +1132,45 @@ async fn run_game_creator_agent_background_task_pass_without_deadline( let mut plan = continuation.plan.clone(); let mut observations = continuation.observations.clone(); let start_loop_index = continuation.next_loop_index; - let mut context_tracker = AgentRuntimeContextWindowTracker::from_continuation(&continuation); + let mut context_tracker = + AgentRuntimeContextWindowTracker::from_continuation(&continuation, &runtime); let mut final_reply = None; let mut final_reply_revision = None; let mut converged = false; let mut context_stalled = continuation.context_stalled; + // `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 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 plan_update_idle_limit_reached(runtime.plan_update_idle_rounds) { + return match finish_plan_update_idle_limit_at(&root, &runtime) { + Ok(outcome) => outcome, + Err(error) => fail_game_creator_agent_background_context_at( + &root, + &agent_id, + &session_id, + runtime, + &format!("收束已耗尽的结构化计划空转失败:{error}"), + ), + }; + } + if continuation.applied_steer_cursor < runtime.applied_steer_cursor { return fail_game_creator_agent_background_context_at( &root, @@ -907,10 +1321,10 @@ async fn run_game_creator_agent_background_task_pass_without_deadline( if let Some(blocker) = static_delegate_completion_blocker_at(&root, &agent_id, &runtime.run_id) { - let waits_for_delivery = blocker - .detail - .as_deref() - .is_some_and(static_delegate_barrier_has_waiting_deliveries); + let waits_for_delivery = blocker.detail.as_deref().is_some_and(|detail| { + static_delegate_barrier_has_waiting_deliveries(detail) + || static_delegate_barrier_requires_user_input(detail) + }); if waits_for_delivery { if let Err(error) = persist_waiting_static_delegate_parent_context_at( &root, @@ -1162,7 +1576,12 @@ 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 即可:那一支本来就带着 + // 真实动作,走不到空转分支。 + let mut plan_update_outcome = None; if let Some(batch) = resumed_provider_batch.as_ref() { let Some(first_pending) = batch.actions.first() else { return fail_game_creator_agent_background_context_at( @@ -1209,6 +1628,7 @@ 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( @@ -1272,6 +1692,7 @@ async fn run_game_creator_agent_background_task_pass_without_deadline( return AgentBackgroundTaskOutcome::NeedsReconciliation; } } else { + planning_session_binding = None; runtime.loop_iteration = (loop_index + 1) as u32; runtime.max_loop_iterations = u32::try_from( (loop_index / AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT + 1) @@ -1553,6 +1974,7 @@ 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(); let planning_mcp_catalog_fingerprint = requested_plan.mcp_catalog_fingerprint; plan = requested_plan.plan; match refresh_agent_runtime_autonomous_convergence_snapshot_after_provider_at( @@ -1697,7 +2119,8 @@ async fn run_game_creator_agent_background_task_pass_without_deadline( } if let Some(plan_update) = plan.plan_update.as_ref() { match apply_agent_runtime_plan_update(&mut runtime, plan_update) { - Ok(true) => { + Ok(outcome) if outcome.advanced_steps() => { + plan_update_outcome = Some(outcome); runtime.updated_at = unix_timestamp(); if let Err(error) = write_game_creator_agent_runtime_state(&root, &runtime) { @@ -1765,7 +2188,38 @@ async fn run_game_creator_agent_background_task_pass_without_deadline( }), ); } - Ok(false) => {} + Ok(outcome) => { + plan_update_outcome = Some(outcome); + if outcome == AgentRuntimePlanUpdateOutcome::ExplanationOnly { + // 解释照旧落盘,但不发 plan_update 事件、不 bump revision: + // 这一轮没有任何计划进展,事件流不该替它背书。 + runtime.updated_at = unix_timestamp(); + if let Err(error) = + write_game_creator_agent_runtime_state(&root, &runtime) + { + return fail_game_creator_agent_background_context_at( + &root, + &agent_id, + &session_id, + runtime, + &format!("持久化结构化计划解释 Runtime state 失败:{error}"), + ); + } + let _ = append_game_creator_agent_runtime_event( + &root, + &runtime, + "plan_update.explanation_only", + runtime.status.as_str(), + runtime.phase.as_str(), + "Agent 只改写了结构化计划解释,步骤与状态没有变化。", + Some(&format!( + "planRevision={} · explanationSha256={:x}", + runtime.plan_revision, + Sha256::digest(runtime.plan_explanation.as_bytes()) + )), + ); + } + } Err(error) => { let observation = AgentRuntimeToolObservation { tool: "runtime.plan_update".to_string(), @@ -1856,7 +2310,18 @@ async fn run_game_creator_agent_background_task_pass_without_deadline( action_start_index = 0; } + if plan + .actions + .iter() + .any(|action| agent_runtime_action_can_advance_progress(action.tool.trim())) + { + // 本轮至少有一个能推进 durable 状态的动作,计划没有空转。 + runtime.plan_update_idle_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 = structured_plan_completion_blocker(&runtime) .or_else(|| { provider_action_batch_completion_blocker_at_locked( @@ -1865,6 +2330,13 @@ 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)) .or_else(|| goal_contract_acceptance_completion_blocker_at_locked(&root, &runtime)) .or_else(|| { @@ -1900,6 +2372,15 @@ async fn run_game_creator_agent_background_task_pass_without_deadline( if let Some(blocker) = completion_blocker { let blocker_summary = blocker.summary(); if blocker.tool == "runtime.plan_update" { + // 走到这里说明本轮没有任何动作,而且未完成的原因就是计划自己。 + // 等委派回执、等 provider 批次、等用户问询都是别的 blocker 类型, + // 不会落到这一支,所以合法等待不会被算成空转。 + if plan_update_outcome.is_some_and(|outcome| outcome.advanced_steps()) { + runtime.plan_update_idle_rounds = 0; + } else { + runtime.plan_update_idle_rounds = + runtime.plan_update_idle_rounds.saturating_add(1); + } runtime.status = "running".to_string(); runtime.phase = "planning".to_string(); runtime.current_action = "等待结构化计划进度更新".to_string(); @@ -1911,6 +2392,13 @@ 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(); @@ -1945,55 +2433,39 @@ async fn run_game_creator_agent_background_task_pass_without_deadline( .detail .as_deref() .is_some_and(static_delegate_barrier_requires_repair); - let user_input_required = blocker.detail.as_deref().is_some_and(|detail| { - detail - .split_whitespace() - .find_map(|part| part.strip_prefix("userInputRequired=")) - .and_then(|value| value.parse::().ok()) - .is_some_and(|count| count > 0) - }); + let user_revision_pending = blocker + .detail + .as_deref() + .is_some_and(static_delegate_barrier_requires_user_revision); + let user_input_required = blocker + .detail + .as_deref() + .is_some_and(static_delegate_barrier_requires_user_input); runtime.status = "running".to_string(); - if user_input_required { - let deliveries = match claimed_static_delegate_deliveries_at( - &root, - &runtime.agent_id, - &runtime.run_id, - ) { - Ok(deliveries) => deliveries, - Err(error) => { - return fail_game_creator_agent_background_context_at( - &root, - &agent_id, - &session_id, - runtime, - &format!("读取 needs-user-input 回执失败:{error}"), - ); - } - }; - let waiting_for_user = match ensure_static_delegate_user_input_wait_at( - &root, - &mut runtime, - &deliveries, - ) { - Ok(waiting) => waiting, - Err(error) => { - return fail_game_creator_agent_background_context_at( - &root, - &agent_id, - &session_id, - runtime, - &format!("Supervisor 用户澄清请求无法安全进入等待态:{error}"), - ); - } - }; - if waiting_for_user { - return AgentBackgroundTaskOutcome::WaitingForUserInput; - } + if user_revision_pending { runtime.phase = "planning".to_string(); runtime.current_action = - "等待 Project Supervisor 采用安全默认值发起唯一返工".to_string(); - runtime.waiting_on = "已转换为 needs-repair 的自主构建专业回执".to_string(); - runtime.next_step = "调用 agent.delegate,并把 repairOfDelegationId 指向原 delivery;返工任务必须采用安全默认值继续,不能再询问用户".to_string(); + "等待 Project Supervisor 发起用户修订续跑".to_string(); + runtime.waiting_on = + "审批卡修改/退回对应的 UserRevisionRequested delivery".to_string(); + runtime.next_step = + "调用 agent.delegate,并把 repairOfDelegationId 指向原 delivery;不得把用户修订计入 repair_depth".to_string(); + } else if user_input_required { + // The background drain owns the Supervisor execution lane. Persist a + // receipt wait first, then let task_queue schedule parent-wake after this + // pass returns and the lane is released. Parent-wake acquires + // project -> execution and creates the unique clarification pending under + // both locks; doing that here would invert the M1C-2b planning projection + // order. + runtime.phase = "waiting-for-delegate-receipts".to_string(); + runtime.current_action = + "等待创建 Project Supervisor 用户澄清请求".to_string(); + runtime.waiting_on = + "释放当前 execution lane 后投影 planning session 与澄清 pending" + .to_string(); + runtime.next_step = + "由 lane 外 parent-wake 按 project → execution 锁序创建唯一澄清请求" + .to_string(); } else if repair_required { runtime.phase = "planning".to_string(); runtime.current_action = "等待 Project Supervisor 发起唯一返工".to_string(); @@ -2062,7 +2534,8 @@ async fn run_game_creator_agent_background_task_pass_without_deadline( AgentBackgroundTaskOutcome::WaitingForIsolatedJoin, )) } else if observation.tool == "runtime.delegate_receipts" - && static_delegate_barrier_has_waiting_deliveries(detail) + && (static_delegate_barrier_has_waiting_deliveries(detail) + || static_delegate_barrier_requires_user_input(detail)) { Some(( "agent.runtime.agent.delegate_receipts.waiting", @@ -2070,6 +2543,8 @@ 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 } @@ -2186,8 +2661,27 @@ async fn run_game_creator_agent_background_task_pass_without_deadline( "Agent 工具计划最终回复去除 thinking 后为空", ); } - final_reply = Some(response); - final_reply_revision = Some(planning_request_revision.revision); + // 策划子 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); + } + } } } observations = sanitize_game_creator_agent_runtime_context_observations_for_storage( @@ -2250,7 +2744,7 @@ async fn run_game_creator_agent_background_task_pass_without_deadline( &runtime.run_id, ) { - match prepare_game_creator_agent_runtime_provider_action_batch( + match prepare_game_creator_agent_runtime_provider_action_batch_with_planning_binding( &root, &runtime, &task, @@ -2258,6 +2752,7 @@ 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 { @@ -2719,12 +3214,19 @@ 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 { + 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 预检,已拒绝进入等待态" + }; let _ = mark_game_creator_agent_runtime_needs_reconciliation_at( &root, &mut runtime, &pending_action, - "自主构建 Run 的 user.input_request 绕过了 Provider action 预检,已拒绝进入等待态", + reason, ); return AgentBackgroundTaskOutcome::NeedsReconciliation; } @@ -2746,6 +3248,18 @@ 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() @@ -2760,30 +3274,40 @@ 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 = command_id.and_then(|command_id| { - if confirmation_approved { - match game_creator_agent_runtime_tool_policy_rule_for_run( - &root, - &agent_id, - &runtime.run_id, - Some(&runtime.run_profile), - Some(&runtime.run_profile_binding_fingerprint), - command_id, - ) { - Some(AgentRuntimeToolPolicyBlock::RequiresConfirmation(_)) => None, - blocked => blocked, + 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 { + command_id.and_then(|command_id| { + if confirmation_approved { + match game_creator_agent_runtime_tool_policy_rule_for_run( + &root, + &agent_id, + &runtime.run_id, + Some(&runtime.run_profile), + Some(&runtime.run_profile_binding_fingerprint), + command_id, + ) { + Some(AgentRuntimeToolPolicyBlock::RequiresConfirmation(_)) => None, + blocked => blocked, + } + } else { + game_creator_agent_runtime_tool_policy_block( + &root, + &agent_id, + runtime.run_id.as_str(), + command_id, + &action_fingerprint, + ) } - } else { - game_creator_agent_runtime_tool_policy_block( - &root, - &agent_id, - runtime.run_id.as_str(), - command_id, - &action_fingerprint, - ) - } - }); - let mcp_policy_block = if matches!( + }) + }; + let mcp_policy_block = if action.tool.trim() == PLAN_SUBMIT_GDD_TOOL { + None + } else if matches!( local_policy_block, Some(AgentRuntimeToolPolicyBlock::Denied(_)) ) { @@ -2797,14 +3321,152 @@ async fn run_game_creator_agent_background_task_pass_without_deadline( ) .await }; - 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), - strictest_agent_runtime_tool_policy_block(local_policy_block, mcp_policy_block), - ); + 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), + strictest_agent_runtime_tool_policy_block(local_policy_block, mcp_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 mut durable_action = None; let observation = if let Some(blocked) = policy_block { agent_runtime_tool_policy_block_observation(action.tool.trim(), blocked) @@ -3680,20 +4342,56 @@ async fn run_game_creator_agent_background_task_pass_without_deadline( &format!("持久化最终回复请求上下文失败:{error}"), ); } - 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; + // 坏信封在这里就地重取,不让它随 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; + } + }; if stop_game_creator_agent_runtime_if_cancel_requested(&root, &mut runtime) { return AgentBackgroundTaskOutcome::Finished; } @@ -3970,3 +4668,234 @@ 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":"当前要决定:自由经营农场的长期目标采用哪种组合?","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 不会改变它" + ); + } + + /// 台账逐项比对失败是本次 Provider input 写错,durable 权威完好,回灌理由后 + /// 策划子 Agent 能自行改稿;真 CAS 则说明 session 已被推进或损坏,重交同一份 + /// input 不可能成功。两者曾共用 `PLAN_SESSION_CAS_CONFLICT`,导致前者也被判成 + /// 硬阻断——实测中策划子 Agent 靠回灌连改三轮修好了形状层,紧接着撞上这一支 + /// 直接 needs-reconciliation,整条链路无产物收场。 + #[test] + fn session_ledger_mismatch_is_provider_feedback_but_a_real_cas_conflict_is_not() { + assert!( + plan_submit_error_is_business_rejection(&PlanningStorageError::new( + "PLAN_SESSION_DECISIONS_MISMATCH", + "submit input 未逐项匹配当前 planning session 决策摘要" + )), + "台账不匹配应回灌给 Provider 修正,受既有 5 次预算约束" + ); + assert!( + !plan_submit_error_is_business_rejection(&PlanningStorageError::new( + "PLAN_SESSION_CAS_CONFLICT", + "planning session 已被其它动作推进" + )), + "真 CAS 必须走 reconciliation,不得消耗 Provider 重试额度" + ); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_tests.rs index 8fa3b33e1..24fe547b7 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_tests.rs @@ -373,6 +373,853 @@ fn game_chat_main_art_child_fixture( (main, child, delegation_id) } +fn snapshot_agent_durable_files(root: &Path) -> std::collections::BTreeMap> { + fn visit( + base: &Path, + directory: &Path, + snapshot: &mut std::collections::BTreeMap>, + ) { + let Ok(entries) = fs::read_dir(directory) else { + return; + }; + for entry in entries { + let entry = entry.expect("read durable Agent entry"); + let path = entry.path(); + let file_type = entry.file_type().expect("read durable Agent file type"); + if file_type.is_dir() { + visit(base, &path, snapshot); + } else if file_type.is_file() { + if path + .file_name() + .and_then(|value| value.to_str()) + .is_some_and(|value| value.ends_with(".lock")) + { + continue; + } + let relative = path + .strip_prefix(base) + .expect("durable Agent file remains below project root") + .to_string_lossy() + .replace('\\', "/"); + snapshot.insert(relative, fs::read(&path).expect("read durable Agent file")); + } + } + } + + let mut snapshot = std::collections::BTreeMap::new(); + visit(root, &root.join(".agent"), &mut snapshot); + snapshot +} + +fn persist_legacy_game_chat_art_retry_run( + root: &Path, + original_child: &AgentRuntimeState, + retry_run_id: &str, +) -> AgentRuntimeState { + let parent_agent_id = original_child + .parent_agent_id + .as_deref() + .expect("game-chat art child has parent agent"); + let parent_run_id = original_child + .parent_run_id + .as_deref() + .expect("game-chat art child has parent run"); + let retry_link = AgentRuntimeTaskLink { + parent_agent_id: Some(parent_agent_id.to_string()), + parent_run_id: Some(parent_run_id.to_string()), + delegation_id: Some(format!("legacy-retry-{retry_run_id}")), + }; + let binding = bind_game_creator_agent_runtime_run_profile_at( + root, + &original_child.agent_id, + retry_run_id, + "agent-delegate-retry", + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + Some(&retry_link), + ) + .expect("bind legacy game-chat art retry run"); + let mut retry = read_latest_game_creator_agent_runtime_task_by_run_id( + root, + &original_child.agent_id, + &original_child.run_id, + ) + .expect("read original game-chat art child") + .expect("original game-chat art child exists"); + retry.run_id = retry_run_id.to_string(); + retry.source = "agent-delegate-retry".to_string(); + retry.run_profile = binding.profile; + retry.run_profile_binding_fingerprint = binding.binding_fingerprint; + retry.delegation_id = retry_link.delegation_id; + retry.status = "running".to_string(); + retry.phase = "planning".to_string(); + retry.current_action = "遗留 retry 等待执行".to_string(); + retry.terminal_detail = None; + retry.error = None; + retry.updated_at = unix_timestamp(); + let state = agent_runtime_state_from_task_record(&retry); + append_game_creator_agent_runtime_task(root, &state) + .expect("persist legacy game-chat art retry task"); + write_game_creator_agent_runtime_state(root, &state) + .expect("persist legacy game-chat art retry state"); + state +} + +#[test] +fn game_chat_dynamic_art_terminal_children_reject_generic_retry_without_durable_side_effects() { + for (case_index, (agent_id, missing_slots, terminal_status)) in [ + ( + "art-director", + vec!["art-spec", "core-spritesheet"], + "failed", + ), + ( + "art-director", + vec!["art-spec", "core-spritesheet"], + "cancelled", + ), + ("art-asset-plan", vec!["core-spritesheet"], "failed"), + ("art-asset-plan", vec!["core-spritesheet"], "cancelled"), + ] + .into_iter() + .enumerate() + { + let temporary = tempfile::tempdir().expect("create terminal art retry root"); + let root = temporary.path().join("project"); + let (_main, mut child, _) = + game_chat_main_art_child_fixture(&root, agent_id, &missing_slots); + child.status = terminal_status.to_string(); + child.phase = terminal_status.to_string(); + child.current_action = "等待用户恢复".to_string(); + child.error = (terminal_status == "failed").then(|| "kind=test-failure".to_string()); + append_game_creator_agent_runtime_task(&root, &child) + .expect("persist terminal game-chat art child task"); + write_game_creator_agent_runtime_state(&root, &child) + .expect("persist terminal game-chat art child state"); + fs::remove_file(game_creator_agent_runtime_task_path( + &root, + "code-prototype", + )) + .expect("remove parent task journal without removing immutable binding lineage"); + fs::remove_file(game_creator_agent_runtime_task_path( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + )) + .expect("remove root task journal without removing immutable binding lineage"); + let terminal_child = read_latest_game_creator_agent_runtime_task_by_run_id( + &root, + &child.agent_id, + &child.run_id, + ) + .expect("read terminal game-chat art child") + .expect("terminal game-chat art child exists"); + assert!( + game_chat_dynamic_art_child_structural_identity_at(&root, &terminal_child) + .expect("immutable binding lineage still identifies terminal art child") + ); + let durable_before = snapshot_agent_durable_files(&root); + let retry_run_id = format!("blocked-game-chat-art-retry-{case_index}"); + + let error = retry_game_creator_agent_runtime_task_at( + &root, + &child.agent_id, + &child.run_id, + &retry_run_id, + ) + .expect_err("game-chat dynamic art child retry must be rejected"); + + assert!( + error.contains("kind=game-chat-dynamic-art-retry-unsupported"), + "unexpected typed retry error: {error}" + ); + assert!(error.contains("继续 game-chat 对话"), "{error}"); + assert!(error.contains("下一轮 main code-prototype"), "{error}"); + assert!(error.contains("asset.list"), "{error}"); + assert_eq!( + snapshot_agent_durable_files(&root), + durable_before, + "blocked retry must not persist any successor state: {agent_id}/{terminal_status}" + ); + assert!(read_latest_game_creator_agent_runtime_task_by_run_id( + &root, + &child.agent_id, + &retry_run_id, + ) + .expect("inspect blocked retry successor") + .is_none()); + } +} + +#[tokio::test] +async fn legacy_game_chat_dynamic_art_retry_runs_are_read_only_and_fail_closed() { + let temporary = tempfile::tempdir().expect("create legacy art retry root"); + let root = temporary.path().join("project"); + let (_main, child, _) = + game_chat_main_art_child_fixture(&root, "art-asset-plan", &["core-spritesheet"]); + let retry = + persist_legacy_game_chat_art_retry_run(&root, &child, "legacy-game-chat-art-retry-run"); + let retry_task = read_latest_game_creator_agent_runtime_task_by_run_id( + &root, + &retry.agent_id, + &retry.run_id, + ) + .expect("read legacy art retry") + .expect("legacy art retry exists"); + assert!( + game_chat_dynamic_art_child_structural_identity_at(&root, &retry_task) + .expect("classify legacy art retry lineage") + ); + + for read_only_tool in ["asset.list", "file.read", "project.diff", "image.inspect"] { + assert!( + game_chat_delegated_art_agent_input_mutation_block( + &root, + &retry.agent_id, + &retry.run_id, + read_only_tool, + &serde_json::json!({}), + ) + .is_none(), + "diagnostic read-only tool must remain available: {read_only_tool}" + ); + } + + let actions = [ + AgentRuntimeToolAction { + tool: "file.write".to_string(), + reason: None, + input: serde_json::json!({"path":"assets/retry-must-not-write.txt","content":"blocked"}), + }, + AgentRuntimeToolAction { + tool: "project.patchset".to_string(), + reason: None, + input: serde_json::json!({"changes":[{"path":"assets/retry-patchset.txt","operation":"write","content":"blocked"}]}), + }, + AgentRuntimeToolAction { + tool: "canvas.asset_generate".to_string(), + reason: None, + input: serde_json::json!({"outputPath":"assets/art-spritesheet.png","assetKind":"art-spritesheet"}), + }, + AgentRuntimeToolAction { + tool: "memory.write".to_string(), + reason: None, + input: serde_json::json!({"scope":"project","title":"blocked","content":"blocked","mode":"append"}), + }, + AgentRuntimeToolAction { + tool: "task.create".to_string(), + reason: None, + input: serde_json::json!({"taskId":"blocked-retry-task"}), + }, + AgentRuntimeToolAction { + tool: "command.run_limited".to_string(), + reason: None, + input: serde_json::json!({"command":"npm test"}), + }, + AgentRuntimeToolAction { + tool: "preview.validate".to_string(), + reason: None, + input: serde_json::json!({}), + }, + AgentRuntimeToolAction { + tool: "agent.delegate".to_string(), + reason: None, + input: serde_json::json!({"agentId":"art-director","task":"blocked"}), + }, + AgentRuntimeToolAction { + tool: "agent.spawn_isolated".to_string(), + reason: None, + input: serde_json::json!({"children":[]}), + }, + ]; + for action in actions { + let observation = execute_game_creator_agent_runtime_tool_action( + &root, + &retry.agent_id, + &retry.run_id, + &retry.current_task, + &action, + ) + .await; + assert_eq!(observation.status, "blocked", "{action:?}: {observation:?}"); + assert!( + observation.summary.contains("不支持通用 retry"), + "{action:?}: {observation:?}" + ); + } + assert!(game_chat_delegated_art_agent_project_path_mutation_block( + &root, + &retry.agent_id, + &retry.run_id, + "file.write", + "assets/direct-path-bypass.txt", + ) + .is_some()); + assert!(!root.join("assets/retry-must-not-write.txt").exists()); + assert!(!root.join("assets/retry-patchset.txt").exists()); + + fs::remove_file(game_creator_agent_runtime_task_path( + &root, + "code-prototype", + )) + .expect("remove parent task journal for forged legacy fixture"); + let forged_task = read_latest_game_creator_agent_runtime_task_by_run_id( + &root, + &retry.agent_id, + &retry.run_id, + ) + .expect("read forged art retry") + .expect("forged art retry exists"); + assert!( + game_chat_dynamic_art_child_structural_identity_at(&root, &forged_task) + .expect("immutable bindings still prove structural identity") + ); + assert!( + !game_chat_fast_path_scheduled_art_contract_repair_is_authorized_at( + &root, + &retry.agent_id, + &retry.run_id, + AGENT_RUNTIME_ART_SPRITESHEET_PATH, + ) + .expect("missing parent task cannot prove strict authorization"), + "retry without a live parent task must not acquire strict authorization" + ); + let forged_action = AgentRuntimeToolAction { + tool: "file.write".to_string(), + reason: None, + input: serde_json::json!({ + "path":"assets/forged-retry-must-not-write.txt", + "content":"blocked", + }), + }; + let forged_observation = execute_game_creator_agent_runtime_tool_action( + &root, + &retry.agent_id, + &retry.run_id, + &retry.current_task, + &forged_action, + ) + .await; + assert_eq!( + forged_observation.status, "blocked", + "{forged_observation:?}" + ); + assert!( + forged_observation.summary.contains("不支持通用 retry"), + "{forged_observation:?}" + ); + assert!( + forged_observation + .detail + .as_deref() + .is_some_and(|detail| detail.contains("下一轮 main code-prototype")), + "{forged_observation:?}" + ); + assert!(!root.join("assets/forged-retry-must-not-write.txt").exists()); +} + +#[test] +fn non_delegated_art_sources_stay_out_of_game_chat_art_gate() { + for source in ["agent-ready-task-scheduler", "agent-background-task"] { + let temporary = tempfile::tempdir().expect("create non-delegated art root"); + let root = temporary.path().join("project"); + init_local_game_project_at(&root, "non-delegated-art", "非委派美术来源") + .expect("init non-delegated art project"); + let run_id = format!("non-delegated-art-{source}"); + start_game_creator_agent_runtime_task_at( + &root, + "art-director", + "非委派来源不得进入 game-chat 美术谱系门", + &run_id, + source, + "校验工具边界", + vec!["非委派来源不受美术门限制".to_string()], + ) + .expect("start non-delegated art runtime"); + for (tool, input) in [ + ( + "command.output_read", + serde_json::json!({"actionId":"history-probe","startLine":1,"maxLines":5}), + ), + ( + "canvas.asset_generate", + serde_json::json!({"prompt":"恢复视觉规范图","outputPath":AGENT_RUNTIME_ART_SPEC_PATH}), + ), + ( + "file.write", + serde_json::json!({"path":"game/notes.txt","content":"scoped by other policies"}), + ), + ] { + assert!( + game_chat_delegated_art_agent_input_mutation_block( + &root, + "art-director", + &run_id, + tool, + &input, + ) + .is_none(), + "{source}/{tool} 不应被 game-chat 美术门拦截" + ); + } + } +} + +#[test] +fn game_chat_dynamic_art_retry_guard_preserves_dag_art_and_non_art_delegated_retry() { + let dag_temporary = tempfile::tempdir().expect("create full DAG art retry root"); + let dag_root = dag_temporary.path().join("project"); + init_local_game_project_at(&dag_root, "full-dag-art-retry", "完整 DAG 美术重试") + .expect("init full DAG art retry project"); + let dag_root_session = resolve_agent_conversation_session_id_at( + &dag_root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + None, + true, + ) + .expect("resolve full DAG root session"); + let dag_root_record = append_unique_game_creator_agent_runtime_pending_task( + &dag_root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &dag_root_session, + "执行完整任务图", + "full-dag-art-retry-root", + AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + None, + ) + .expect("queue full DAG root"); + let mut dag_root_state = agent_runtime_state_from_task_record(&dag_root_record); + dag_root_state.status = "running".to_string(); + dag_root_state.phase = "planning".to_string(); + append_game_creator_agent_runtime_task(&dag_root, &dag_root_state) + .expect("persist running full DAG root"); + write_game_creator_agent_runtime_state(&dag_root, &dag_root_state) + .expect("persist full DAG root state"); + let dag_manifest = read_manifest_for_project(&dag_root).expect("read full DAG manifest"); + let dag_art_task = dag_manifest + .tasks + .iter() + .find(|task| task.id == "art-director") + .expect("full DAG art-director task"); + let dag_art_session = + resolve_agent_conversation_session_id_at(&dag_root, "art-director", None, true) + .expect("resolve full DAG art session"); + let dag_art_record = append_unique_game_creator_agent_runtime_pending_task( + &dag_root, + "art-director", + &dag_art_session, + &render_autonomous_manifest_ready_task_background_prompt(dag_art_task), + "full-dag-art-failed-run", + "agent-ready-task-scheduler", + None, + Some(&AgentRuntimeTaskLink { + parent_agent_id: Some(dag_root_state.agent_id.clone()), + parent_run_id: Some(dag_root_state.run_id.clone()), + delegation_id: None, + }), + ) + .expect("queue full DAG art task"); + let mut dag_art_state = agent_runtime_state_from_task_record(&dag_art_record); + dag_art_state.status = "failed".to_string(); + dag_art_state.phase = "failed".to_string(); + dag_art_state.error = Some("kind=test-failure".to_string()); + append_game_creator_agent_runtime_task(&dag_root, &dag_art_state) + .expect("persist failed full DAG art task"); + write_game_creator_agent_runtime_state(&dag_root, &dag_art_state) + .expect("persist failed full DAG art state"); + let dag_art_lane = try_acquire_game_creator_agent_runtime_task_lock(&dag_root, "art-director") + .expect("acquire full DAG art lane") + .expect("full DAG art lane is free"); + let dag_retry = retry_game_creator_agent_runtime_task_at( + &dag_root, + "art-director", + &dag_art_state.run_id, + "full-dag-art-retry-run", + ) + .expect("full DAG art retry semantics remain available"); + assert_eq!( + dag_retry.accepted_run_id.as_deref(), + Some("full-dag-art-retry-run") + ); + let dag_retry_task = read_latest_game_creator_agent_runtime_task_by_run_id( + &dag_root, + "art-director", + "full-dag-art-retry-run", + ) + .expect("read full DAG art retry") + .expect("full DAG art retry exists"); + assert_eq!(dag_retry_task.source, "agent-delegate-retry"); + assert_eq!( + dag_retry_task.parent_agent_id.as_deref(), + Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID) + ); + assert!( + !game_chat_dynamic_art_child_structural_identity_at(&dag_root, &dag_retry_task) + .expect("classify full DAG art retry") + ); + assert!(game_chat_delegated_art_agent_input_mutation_block( + &dag_root, + "art-director", + "full-dag-art-retry-run", + "file.write", + &serde_json::json!({"path":AGENT_RUNTIME_ART_SPEC_PATH,"content":"unchanged semantics"}), + ) + .is_none()); + + fs::remove_file(game_creator_agent_runtime_run_profile_binding_path( + &dag_root, + "art-director", + "full-dag-art-retry-run", + )) + .expect("remove full DAG retry binding for unproven-lineage fixture"); + let unproven_block = game_chat_delegated_art_agent_project_path_mutation_block( + &dag_root, + "art-director", + "full-dag-art-retry-run", + "file.write", + "assets/unproven-retry-must-not-write.txt", + ) + .expect("unproven retry-source art lineage must fail closed"); + assert!( + unproven_block.summary.contains("不支持通用 retry"), + "{unproven_block:?}" + ); + drop(dag_art_lane); + + let delegated_temporary = tempfile::tempdir().expect("create non-art delegated retry root"); + let delegated_root = delegated_temporary.path().join("project"); + init_local_game_project_at(&delegated_root, "non-art-delegated-retry", "非美术委派重试") + .expect("init non-art delegated retry project"); + let parent_session = resolve_agent_conversation_session_id_at( + &delegated_root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + None, + true, + ) + .expect("resolve non-art parent session"); + let parent_record = append_unique_game_creator_agent_runtime_pending_task( + &delegated_root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &parent_session, + "委派非美术任务", + "non-art-delegated-parent", + "agent-background-task", + Some(AGENT_RUNTIME_RUN_PROFILE_STANDARD), + None, + ) + .expect("queue non-art parent"); + let mut parent_state = agent_runtime_state_from_task_record(&parent_record); + parent_state.status = "running".to_string(); + parent_state.phase = "planning".to_string(); + append_game_creator_agent_runtime_task(&delegated_root, &parent_state) + .expect("persist running non-art parent"); + write_game_creator_agent_runtime_state(&delegated_root, &parent_state) + .expect("persist non-art parent state"); + let child_session = + resolve_agent_conversation_session_id_at(&delegated_root, "design-director", None, true) + .expect("resolve non-art child session"); + let child_record = append_unique_game_creator_agent_runtime_pending_task( + &delegated_root, + "design-director", + &child_session, + "完成非美术设计任务", + "non-art-delegated-failed-run", + "agent-delegate", + None, + Some(&AgentRuntimeTaskLink { + parent_agent_id: Some(parent_state.agent_id.clone()), + parent_run_id: Some(parent_state.run_id.clone()), + delegation_id: Some("non-art-original-delegation".to_string()), + }), + ) + .expect("queue non-art delegated child"); + let mut child_state = agent_runtime_state_from_task_record(&child_record); + child_state.status = "failed".to_string(); + child_state.phase = "failed".to_string(); + child_state.error = Some("kind=test-failure".to_string()); + append_game_creator_agent_runtime_task(&delegated_root, &child_state) + .expect("persist failed non-art child"); + write_game_creator_agent_runtime_state(&delegated_root, &child_state) + .expect("persist failed non-art child state"); + let non_art_lane = + try_acquire_game_creator_agent_runtime_task_lock(&delegated_root, "design-director") + .expect("acquire non-art child lane") + .expect("non-art child lane is free"); + let delegated_retry = retry_game_creator_agent_runtime_task_at( + &delegated_root, + "design-director", + &child_state.run_id, + "non-art-delegated-retry-run", + ) + .expect("non-art delegated retry semantics remain available"); + assert_eq!( + delegated_retry.accepted_run_id.as_deref(), + Some("non-art-delegated-retry-run") + ); + let delegated_retry_task = read_latest_game_creator_agent_runtime_task_by_run_id( + &delegated_root, + "design-director", + "non-art-delegated-retry-run", + ) + .expect("read non-art delegated retry") + .expect("non-art delegated retry exists"); + assert_eq!(delegated_retry_task.source, "agent-delegate-retry"); + drop(non_art_lane); +} + +#[test] +fn game_chat_failed_art_child_can_only_redelegate_in_the_next_main_round() { + for (case_index, terminal_status) in ["failed", "cancelled"].into_iter().enumerate() { + let temporary = tempfile::tempdir().expect("create cross-round art recovery root"); + let root = temporary.path().join("project"); + init_local_game_project_at(&root, "game-chat-art-child", "单主美术委派") + .expect("pre-initialize cross-round art recovery project"); + let _main_runtime_lane = + try_acquire_game_creator_agent_runtime_task_lock(&root, "code-prototype") + .expect("acquire cross-round main runtime lane") + .expect("cross-round main runtime lane is free"); + let _art_runtime_lane = + try_acquire_game_creator_agent_runtime_task_lock(&root, "art-asset-plan") + .expect("acquire cross-round art runtime lane") + .expect("cross-round art runtime lane is free"); + let (mut first_main, mut terminal_child, first_delegation_id) = + game_chat_main_art_child_fixture(&root, "art-asset-plan", &["core-spritesheet"]); + first_main.status = "running".to_string(); + first_main.phase = "waiting-for-delegate-receipts".to_string(); + first_main.current_action = "等待失败美术 child 回执".to_string(); + append_game_creator_agent_runtime_task(&root, &first_main) + .expect("persist first waiting main task"); + write_game_creator_agent_runtime_state(&root, &first_main) + .expect("persist first waiting main state"); + + terminal_child.status = terminal_status.to_string(); + terminal_child.phase = terminal_status.to_string(); + terminal_child.current_action = "美术 child 未能交付图集".to_string(); + terminal_child.error = + (terminal_status == "failed").then(|| "kind=cross-round-art-child-failure".to_string()); + append_game_creator_agent_runtime_task(&root, &terminal_child) + .expect("persist terminal first-round art child"); + write_game_creator_agent_runtime_state(&root, &terminal_child) + .expect("persist terminal first-round art child state"); + let terminal_task = read_latest_game_creator_agent_runtime_task_by_run_id( + &root, + &terminal_child.agent_id, + &terminal_child.run_id, + ) + .expect("read terminal first-round art child") + .expect("terminal first-round art child exists"); + publish_game_creator_agent_delegate_result( + &root, + &terminal_task, + Some("美术 child 未完成,等待主 Agent 认领失败回执"), + ); + let ready_delivery = read_static_delegate_delivery_at(&root, &first_delegation_id) + .expect("read ready failed art delivery") + .expect("failed art delivery exists"); + assert_eq!(ready_delivery.status, StaticDelegateDeliveryStatus::Ready); + assert_eq!( + ready_delivery.terminal_status.as_deref(), + Some(terminal_status) + ); + let claimed = claim_ready_static_delegate_receipts_at( + &root, + "code-prototype", + &first_main.run_id, + &format!("cross-round-art-claim-{case_index}"), + ) + .expect("claim failed art delivery"); + assert_eq!(claimed.len(), 1); + let claimed_delivery = read_static_delegate_delivery_at(&root, &first_delegation_id) + .expect("read claimed failed art delivery") + .expect("claimed failed art delivery exists"); + assert_eq!( + claimed_delivery.status, + StaticDelegateDeliveryStatus::ClaimedByParent + ); + + let same_run_action_id = format!("same-run-art-redelegate-{case_index}"); + let same_run = observe_agent_runtime_agent_delegate( + &root, + "code-prototype", + &first_main.run_id, + Some(&same_run_action_id), + &serde_json::json!({ + "agentId": "art-asset-plan", + "task": "同一 main run 不得再次尝试同一缺口", + "acceptanceCriteria": ["不应创建第二个 child"], + "expectedArtifacts": [AGENT_RUNTIME_ART_SPRITESHEET_PATH], + "runId": null + }), + ); + assert_eq!(same_run.status, "failed", "{same_run:?}"); + assert!(same_run.summary.contains("最多委派一次"), "{same_run:?}"); + let rejected_delegation_id = agent_runtime_delegation_id( + "code-prototype", + &first_main.run_id, + "art-asset-plan", + &same_run_action_id, + ); + assert!( + read_static_delegate_delivery_at(&root, &rejected_delegation_id) + .expect("inspect rejected same-run delivery") + .is_none(), + "same-run rejection must not create a second delivery" + ); + + first_main.status = "failed".to_string(); + first_main.phase = "failed".to_string(); + first_main.current_action = "本轮在失败美术回执后结束".to_string(); + append_game_creator_agent_runtime_task(&root, &first_main) + .expect("persist terminal first main task"); + write_game_creator_agent_runtime_state(&root, &first_main) + .expect("persist terminal first main state"); + let first_root_task = read_latest_game_creator_agent_runtime_task_by_run_id( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "game-chat-main-art-root", + ) + .expect("read first game-chat root") + .expect("first game-chat root exists"); + let mut first_root_state = agent_runtime_state_from_task_record(&first_root_task); + first_root_state.status = "failed".to_string(); + first_root_state.phase = "failed".to_string(); + first_root_state.current_action = "等待用户继续 game-chat 对话".to_string(); + append_game_creator_agent_runtime_task(&root, &first_root_state) + .expect("persist terminal first root task"); + write_game_creator_agent_runtime_state(&root, &first_root_state) + .expect("persist terminal first root state"); + + let next_root_run_id = format!("game-chat-cross-round-recovery-root-{case_index}"); + let (next_root, mut next_main) = queue_game_chat_fast_path_child( + &root, + &next_root_run_id, + "继续 game-chat 对话并重新审计仍存在的美术缺口", + "code-prototype", + ); + next_main.status = "running".to_string(); + next_main.phase = "planning".to_string(); + next_main.current_task = "下一轮重新审计并补齐仍存在的美术缺口".to_string(); + next_main.current_action = "重新执行 asset.list 审计".to_string(); + next_main + .recent_tool_calls + .push(AgentRuntimeToolCallRecord { + action_id: Some(format!("next-round-asset-list-{case_index}")), + tool: "asset.list".to_string(), + status: "ok".to_string(), + action_fingerprint: None, + input_summary: None, + reason: Some("下一轮重新审计当前项目资产".to_string()), + summary: "已确认核心图集仍缺失".to_string(), + detail: None, + updated_at: unix_timestamp(), + }); + append_game_creator_agent_runtime_task(&root, &next_main) + .expect("persist next-round main asset audit task"); + write_game_creator_agent_runtime_state(&root, &next_main) + .expect("persist next-round main asset audit state"); + let audited_runtime = read_game_creator_agent_runtime_for_session_at( + &root, + "code-prototype", + Some(&next_main.session_id), + ) + .expect("read next-round audited main runtime") + .state; + assert_eq!(audited_runtime.run_id, next_main.run_id); + persist_game_chat_supervisor_workflow_decision_at( + &root, + &next_root.agent_id, + &next_root.run_id, + GAME_CHAT_WORKFLOW_STRATEGY_AUDIT_EXISTING_FIRST, + "下一轮继续补齐仍存在的核心图集缺口", + ) + .expect("persist next-round Supervisor decision"); + let next_coverage = + game_chat_current_asset_coverage_at(&root, &next_root.run_id, &next_main.run_id) + .expect("calculate next-round asset coverage"); + assert_eq!(next_coverage.missing_slots, vec!["core-spritesheet"]); + persist_game_chat_code_asset_route_at( + &root, + &next_main.agent_id, + &next_main.run_id, + GAME_CHAT_ASSET_ROUTE_GENERATE_MISSING, + &next_coverage.missing_slots, + ) + .expect("persist next-round asset route"); + + let next_action_id = format!("next-round-art-delegate-{case_index}"); + let next_round = observe_agent_runtime_agent_delegate( + &root, + "code-prototype", + &next_main.run_id, + Some(&next_action_id), + &serde_json::json!({ + "agentId": "art-asset-plan", + "task": "补齐下一轮审计确认仍缺失的核心图集", + "acceptanceCriteria": ["只写入受委派的 assets 产物并回执"], + "expectedArtifacts": [AGENT_RUNTIME_ART_SPRITESHEET_PATH], + "runId": null + }), + ); + assert_eq!(next_round.status, "ok", "{next_round:?}"); + let next_delegation_id = agent_runtime_delegation_id( + "code-prototype", + &next_main.run_id, + "art-asset-plan", + &next_action_id, + ); + assert_ne!(next_delegation_id, first_delegation_id); + let next_delivery = read_static_delegate_delivery_at(&root, &next_delegation_id) + .expect("read next-round art delivery") + .expect("next-round art delivery exists"); + let next_child = read_latest_game_creator_agent_runtime_task_by_delegation_id( + &root, + "art-asset-plan", + &next_delegation_id, + ) + .expect("read next-round art child") + .expect("next-round art child exists"); + assert_eq!( + next_delivery.status, + StaticDelegateDeliveryStatus::Dispatched + ); + assert_eq!(next_delivery.parent_run_id, next_main.run_id); + assert_eq!(next_delivery.target_run_id, next_child.run_id); + assert_eq!(next_delivery.target_agent_id, "art-asset-plan"); + assert_eq!( + next_delivery.expected_artifacts, + [AGENT_RUNTIME_ART_SPRITESHEET_PATH.to_string()] + ); + assert!(next_delivery.terminal_status.is_none()); + + let mut next_child_state = agent_runtime_state_from_task_record(&next_child); + next_child_state.status = "running".to_string(); + next_child_state.phase = "planning".to_string(); + append_game_creator_agent_runtime_task(&root, &next_child_state) + .expect("persist running next-round art child"); + write_game_creator_agent_runtime_state(&root, &next_child_state) + .expect("persist running next-round art child state"); + assert!( + game_chat_fast_path_scheduled_art_contract_repair_is_authorized_at( + &root, + "art-asset-plan", + &next_child_state.run_id, + AGENT_RUNTIME_ART_SPRITESHEET_PATH, + ) + .expect("verify next-round art lineage") + ); + assert!( + game_chat_delegated_art_agent_project_path_mutation_block( + &root, + "art-asset-plan", + &next_child_state.run_id, + "file.write", + AGENT_RUNTIME_ART_SPRITESHEET_PATH, + ) + .is_none(), + "new parent run and new delivery must restore assets-only authorization" + ); + } +} + fn claim_game_chat_main_ready_deliveries_with_run_status( root: &Path, main: &AgentRuntimeState, @@ -499,7 +1346,10 @@ async fn game_chat_main_agent_delegates_only_real_missing_art_and_limits_childre _ => unreachable!(), }; assert_eq!(observation.status, "blocked", "{tool}: {observation:?}"); - assert!(observation.summary.contains("只能修改 assets/**")); + assert!( + observation.summary.contains("只能修改 assets/**"), + "{action:?}: {observation:?}" + ); } let patchset = observe_agent_runtime_project_patchset( @@ -557,6 +1407,18 @@ async fn game_chat_main_agent_delegates_only_real_missing_art_and_limits_childre }), }, ] { + let preflight = game_chat_delegated_art_agent_input_mutation_block( + &root, + &child.agent_id, + &child.run_id, + &action.tool, + &action.input, + ) + .unwrap_or_else(|| panic!("delegated art preflight must block {action:?}")); + assert!( + preflight.summary.contains("只能修改 assets/**"), + "{action:?}: {preflight:?}" + ); let observation = execute_game_creator_agent_runtime_tool_action( &root, &child.agent_id, @@ -566,7 +1428,10 @@ async fn game_chat_main_agent_delegates_only_real_missing_art_and_limits_childre ) .await; assert_eq!(observation.status, "blocked", "{action:?}: {observation:?}"); - assert!(observation.summary.contains("只能修改 assets/**")); + assert!( + observation.summary.contains("只能修改 assets/**"), + "{action:?}: {observation:?}" + ); } assert_eq!(fs::read(root.join("memory/project.md")).ok(), memory_before); assert_eq!( @@ -597,6 +1462,324 @@ async fn game_chat_main_agent_delegates_only_real_missing_art_and_limits_childre ); } +#[test] +fn game_chat_delegated_art_asset_plan_keeps_canvas_verification_and_rejects_borrowed_lineage() { + let temporary = tempfile::tempdir().expect("create game-chat Canvas delivery root"); + let root = temporary.path().join("project"); + init_local_game_project_at(&root, "game-chat-art-child", "单主美术委派") + .expect("pre-initialize game-chat Canvas delivery project"); + let _child_runtime_lane = + try_acquire_game_creator_agent_runtime_task_lock(&root, "art-asset-plan") + .expect("acquire game-chat Canvas art child runtime lane") + .expect("game-chat Canvas art child runtime lane is free"); + let (_main, mut child, delegation_id) = + game_chat_main_art_child_fixture(&root, "art-asset-plan", &["core-spritesheet"]); + child.status = "running".to_string(); + child.phase = "planning".to_string(); + append_game_creator_agent_runtime_task(&root, &child) + .expect("persist running game-chat art child"); + write_game_creator_agent_runtime_state(&root, &child) + .expect("persist running game-chat art runtime"); + + assert!(!autonomous_owner_artifact_validation_available_for_run_at( + &root, + &child.agent_id, + &child.run_id, + ) + .expect("evaluate fixed owner validation identity")); + assert!( + game_chat_delegated_art_asset_plan_uses_canvas_verification_at( + &root, + &child.agent_id, + &child.run_id, + ) + .expect("validate game-chat delegated art lineage") + ); + + { + let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + &root, + "test.game-chat-canvas-delivery", + ) + .expect("lock game-chat Canvas delivery root"); + prepare_agent_runtime_project_mutation_locked( + &root, + &child.agent_id, + &child.run_id, + "canvas.asset_generate", + ) + .expect("record Canvas mutation"); + let (expected_revision, gate) = begin_agent_runtime_project_verification_locked( + &root, + &child.agent_id, + &child.run_id, + "canvas.asset_generate", + ) + .expect("begin Canvas verification"); + finish_agent_runtime_project_verification_locked(&root, &expected_revision, gate, true) + .expect("finish Canvas verification"); + } + let plan = AgentRuntimeToolPlan { + response: "已按委派生成并登记图集素材。".to_string(), + ..AgentRuntimeToolPlan::default() + }; + let gate = + read_game_creator_agent_runtime_verification_gate(&root, &child.agent_id, &child.run_id) + .expect("read Canvas verification gate"); + assert_eq!( + gate.last_verification_tool.as_deref(), + Some("canvas.asset_generate") + ); + validate_agent_runtime_autonomous_specialist_response_delivery( + &root, + &child.agent_id, + &child.run_id, + false, + false, + &gate, + &plan, + ) + .expect("trusted game-chat art child may finalize with Canvas verification"); + + let mut borrowed_project_verify_gate = gate.clone(); + borrowed_project_verify_gate.last_verification_tool = Some("project.verify".to_string()); + write_game_creator_agent_runtime_verification_gate(&root, &borrowed_project_verify_gate) + .expect("persist deliberately borrowed project.verify gate"); + assert!( + validate_agent_runtime_autonomous_specialist_response_delivery( + &root, + &child.agent_id, + &child.run_id, + false, + false, + &borrowed_project_verify_gate, + &plan, + ) + .expect_err("trusted lineage must still require its own Canvas credential") + .contains("只接受本人 canvas.asset_generate") + ); + let borrowed_completion_blocker = + project_verification_completion_blocker_at(&root, &child.agent_id, &child.run_id, &[]) + .expect("borrowed project.verify must fail the completion gate"); + assert!(borrowed_completion_blocker + .summary + .contains("Canvas 交付凭证")); + write_game_creator_agent_runtime_verification_gate(&root, &gate) + .expect("restore Canvas verification gate"); + let restored_blocker = + project_verification_completion_blocker_at(&root, &child.agent_id, &child.run_id, &[]); + assert!( + restored_blocker.is_none(), + "restored Canvas credential must satisfy the dynamic art completion gate: {restored_blocker:?}" + ); + assert_eq!( + read_game_creator_agent_runtime_verification_gate(&root, &child.agent_id, &child.run_id,) + .expect("reread Canvas verification gate") + .last_verification_tool + .as_deref(), + Some("canvas.asset_generate") + ); + + let delivery = read_static_delegate_delivery_at(&root, &delegation_id) + .expect("read game-chat Canvas delivery") + .expect("game-chat Canvas delivery exists"); + let mut wrong_artifact_delivery = delivery.clone(); + wrong_artifact_delivery.expected_artifacts = vec!["assets/other.png".to_string()]; + write_agent_runtime_json_sidecar( + &root, + &format!(".agent/runtime/delegation-deliveries/{delegation_id}.json"), + "forged game-chat Canvas delivery", + &wrong_artifact_delivery, + ) + .expect("persist forged game-chat Canvas artifact contract"); + assert!( + !game_chat_delegated_art_asset_plan_uses_canvas_verification_at( + &root, + &child.agent_id, + &child.run_id, + ) + .expect("reject game-chat Canvas delivery with wrong expected artifact") + ); + write_agent_runtime_json_sidecar( + &root, + &format!(".agent/runtime/delegation-deliveries/{delegation_id}.json"), + "restored game-chat Canvas delivery", + &delivery, + ) + .expect("restore game-chat Canvas artifact contract"); + + // A task journal and binding that say `agent-delegate` are not sufficient: + // removing the Runtime-issued durable delivery makes the otherwise valid + // game-chat lineage ineligible for the narrow Canvas exception. + fs::remove_file(root.join(format!( + ".agent/runtime/delegation-deliveries/{delegation_id}.json" + ))) + .expect("remove durable delegation proof"); + let forged_asset_path = "assets/forged-without-delivery.txt"; + let forged_asset_write = observe_agent_runtime_file_write( + &root, + &child.agent_id, + &child.run_id, + &AgentRuntimeToolAction { + tool: "file.write".to_string(), + reason: Some("验证伪造 game-chat 美术 lineage 不能取得 assets 写权限".to_string()), + input: serde_json::json!({ + "path": forged_asset_path, + "content": "forged delegated art mutation", + }), + }, + &"0".repeat(64), + None, + ); + assert_eq!( + forged_asset_write.status, "blocked", + "{forged_asset_write:?}" + ); + assert!( + !root.join(forged_asset_path).exists(), + "missing durable delivery must be rejected before assets/** mutation" + ); + assert!( + !game_chat_delegated_art_asset_plan_uses_canvas_verification_at( + &root, + &child.agent_id, + &child.run_id, + ) + .expect("reject forged delegated source without durable delivery") + ); + let forged_completion_blocker = + project_verification_completion_blocker_at(&root, &child.agent_id, &child.run_id, &[]) + .expect("forged delegated source must also fail the durable completion gate"); + assert!(forged_completion_blocker + .summary + .contains("不具备可用的验证身份")); + assert!( + validate_agent_runtime_autonomous_specialist_response_delivery( + &root, + &child.agent_id, + &child.run_id, + false, + false, + &gate, + &plan, + ) + .expect_err("forged delegated source must not borrow Canvas finalization") + .contains("固定 owner 的 Runtime 内部产物验证身份不可用") + ); + + write_agent_runtime_json_sidecar( + &root, + &format!(".agent/runtime/delegation-deliveries/{delegation_id}.json"), + "restored game-chat Canvas delivery before root source forgery", + &delivery, + ) + .expect("restore durable delivery before root source forgery"); + assert!( + game_chat_delegated_art_asset_plan_uses_canvas_verification_at( + &root, + &child.agent_id, + &child.run_id, + ) + .expect("restored game-chat Canvas lineage remains valid") + ); + let root_run_id = "game-chat-main-art-root"; + let root_binding = read_game_creator_agent_runtime_run_profile_binding( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + root_run_id, + ) + .expect("read game-chat Canvas root binding") + .expect("game-chat Canvas root binding exists"); + assert_eq!( + root_binding.source, + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE + ); + let root_task = read_latest_game_creator_agent_runtime_task_by_run_id( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + root_run_id, + ) + .expect("read game-chat Canvas root task") + .expect("game-chat Canvas root task exists"); + let mut wrong_source_root = agent_runtime_state_from_task_record(&root_task); + // Keep the signed binding and route coherent so this reaches the + // task-versus-binding cross-check instead of failing on a broken parent chain. + wrong_source_root.source = AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE.to_string(); + append_game_creator_agent_runtime_task(&root, &wrong_source_root) + .expect("persist forged non-game-chat root task"); + assert!( + !game_chat_delegated_art_asset_plan_uses_canvas_verification_at( + &root, + &child.agent_id, + &child.run_id, + ) + .expect("reject Canvas exception outside a game-chat root") + ); + + let ordinary_temporary = tempfile::tempdir().expect("create ordinary delegated owner root"); + let ordinary_root = ordinary_temporary.path().join("project"); + init_local_game_project_at( + &ordinary_root, + "ordinary-delegated-owner", + "普通委派不能借 game-chat 例外", + ) + .expect("init ordinary delegated owner project"); + let supervisor_session = resolve_agent_conversation_session_id_at( + &ordinary_root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + None, + true, + ) + .expect("resolve ordinary Supervisor session"); + let supervisor_record = append_unique_game_creator_agent_runtime_pending_task( + &ordinary_root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &supervisor_session, + "普通 GUI 自主任务", + "ordinary-delegated-root-run", + AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + None, + ) + .expect("queue ordinary Supervisor"); + let mut supervisor_state = agent_runtime_state_from_task_record(&supervisor_record); + supervisor_state.status = "running".to_string(); + supervisor_state.phase = "planning".to_string(); + append_game_creator_agent_runtime_task(&ordinary_root, &supervisor_state) + .expect("persist running ordinary Supervisor"); + let ordinary_session = + resolve_agent_conversation_session_id_at(&ordinary_root, "art-asset-plan", None, true) + .expect("resolve ordinary art session"); + let ordinary_record = append_unique_game_creator_agent_runtime_pending_task( + &ordinary_root, + "art-asset-plan", + &ordinary_session, + "普通 delegated 美术任务", + "ordinary-delegated-art-run", + "agent-delegate", + None, + Some(&AgentRuntimeTaskLink { + parent_agent_id: Some(supervisor_state.agent_id.clone()), + parent_run_id: Some(supervisor_state.run_id.clone()), + delegation_id: Some("ordinary-delegation".to_string()), + }), + ) + .expect("queue ordinary delegated art task"); + let mut ordinary_child = agent_runtime_state_from_task_record(&ordinary_record); + ordinary_child.status = "running".to_string(); + ordinary_child.phase = "planning".to_string(); + append_game_creator_agent_runtime_task(&ordinary_root, &ordinary_child) + .expect("persist running ordinary art child"); + assert!( + !game_chat_delegated_art_asset_plan_uses_canvas_verification_at( + &ordinary_root, + &ordinary_child.agent_id, + &ordinary_child.run_id, + ) + .expect("ordinary delegated source is not game-chat lineage") + ); +} + #[test] fn game_chat_main_agent_rejects_unneeded_or_wrong_art_delegation() { let temporary = tempfile::tempdir().expect("create wrong art delegation root"); @@ -1382,6 +2565,49 @@ fn game_chat_code_prototype_rejects_tampered_art_delivery_identity_before_claim( drop(child_lane); } +#[test] +fn completed_run_exempts_the_clarification_envelope_from_the_free_text_cap() { + let temporary = tempfile::tempdir().expect("create clarification cap root"); + let root = temporary.path().join("project"); + let (_main, child, _delegation_id, _lane) = game_chat_main_art_child_fixture_with_lane( + &root, + "art-director", + &["art-spec", "core-spritesheet"], + false, + ); + + // 源头:last_response 的定界。这里被盲切,后面每一处修复都补不回来—— + // 现场就是子 Agent 输出 518 字符、切到 501 字符,父 run 在 1120 字节处 EOF。 + let envelope = schema_max_clarification_envelope(); + let completed = + prepare_game_creator_agent_runtime_completed_state(&root, child.clone(), &envelope) + .expect("prepare completed state carrying a clarification envelope"); + assert_eq!( + completed.last_response.as_deref(), + Some(envelope.as_str()), + "澄清信封是结构化协议载荷,不能按自由文本盲切" + ); + // terminal_detail 必须与 last_response 逐字相等,否则 finalization 幂等校验 + // 会判「completed task 与 finalization 回复不匹配」。两处上限规则必须同源。 + assert_eq!( + agent_runtime_terminal_detail(&completed), + completed.last_response + ); + + // 非信封的长自由文本仍然守着原来的 500 字符上限——这条豁免不是把定界取消掉。 + let free_text = "普通自由回复。".repeat(200); + let bounded = prepare_game_creator_agent_runtime_completed_state(&root, child, &free_text) + .expect("prepare completed state carrying ordinary free text"); + assert_eq!( + bounded + .last_response + .as_deref() + .map(|value| value.chars().count()), + Some(501), + "普通自由回复必须仍被截到 500 字符加省略号" + ); +} + #[test] fn game_chat_main_agent_allows_only_one_same_contract_safe_default_repair() { let temporary = tempfile::tempdir().expect("create game-chat safe-default repair root"); @@ -1440,7 +2666,7 @@ fn game_chat_main_agent_allows_only_one_same_contract_safe_default_repair() { original .structured_result .as_ref() - .map(|result| result.contract_status), + .map(|result| result.contract_status.clone()), Some(StaticDelegateContractStatus::NeedsRepair) ); assert!(original @@ -1635,7 +2861,8 @@ fn autonomous_parent_waits_for_active_child_while_registered_derived_visuals_nee ); let mut observations = Vec::new(); let continuation = AgentRuntimeContinuationContext::default(); - let mut context_tracker = AgentRuntimeContextWindowTracker::from_continuation(&continuation); + let mut context_tracker = + AgentRuntimeContextWindowTracker::from_continuation(&continuation, &parent_state); let blocker = autonomous_game_build_completion_blocker_at_locked(&root, &parent_state) .expect("active manifest child must block parent completion"); persist_waiting_autonomous_manifest_parent_context_at( @@ -1830,6 +3057,41 @@ fn prepare_autonomous_completion_evidence( prepare_autonomous_completion_evidence_for_actor(root, state, state, expect_complete) } +fn start_full_dag_playtest_child_for_completion_fixture( + root: &Path, + parent_state: &AgentRuntimeState, +) -> AgentRuntimeState { + let manifest = read_manifest_for_project(root).expect("read completion fixture manifest"); + let task = manifest + .tasks + .iter() + .find(|task| task.id == "preview-playtest") + .expect("completion fixture preview-playtest task"); + let session_id = resolve_agent_conversation_session_id_at(root, "preview-playtest", None, true) + .expect("resolve completion fixture preview-playtest session"); + let record = append_unique_game_creator_agent_runtime_pending_task( + root, + "preview-playtest", + &session_id, + &render_autonomous_manifest_ready_task_background_prompt(task), + &autonomous_manifest_ready_task_run_id(&parent_state.run_id, "preview-playtest"), + "agent-ready-task-scheduler", + None, + Some(&AgentRuntimeTaskLink { + parent_agent_id: Some(parent_state.agent_id.clone()), + parent_run_id: Some(parent_state.run_id.clone()), + delegation_id: None, + }), + ) + .expect("queue completion fixture preview-playtest child"); + let mut state = agent_runtime_state_from_task_record(&record); + state.status = "running".to_string(); + state.phase = "planning".to_string(); + append_game_creator_agent_runtime_task(root, &state) + .expect("persist running completion fixture preview-playtest child"); + state +} + fn prepare_autonomous_completion_evidence_for_actor( root: &Path, contract_state: &AgentRuntimeState, @@ -1840,6 +3102,19 @@ fn prepare_autonomous_completion_evidence_for_actor( read_autonomous_completion_contract(root, &contract_state.agent_id, &contract_state.run_id) .expect("read autonomous completion contract") .expect("autonomous completion contract exists"); + let root_binding = read_game_creator_agent_runtime_run_profile_binding( + root, + &contract_state.agent_id, + &contract_state.run_id, + ) + .expect("read completion fixture root binding") + .expect("completion fixture root binding exists"); + let full_dag_playtest_state = matches!( + root_binding.source.as_str(), + AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE | AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE + ) + .then(|| start_full_dag_playtest_child_for_completion_fixture(root, contract_state)); + let playtest_state = full_dag_playtest_state.as_ref().unwrap_or(actor_state); let revision = { let latest = read_latest_game_creator_agent_runtime_task_by_run_id( root, @@ -1935,11 +3210,11 @@ fn prepare_autonomous_completion_evidence_for_actor( let evidence_root = root .join(".agent/runtime/browser-validations") .join(agent_runtime_confirmation_path_component( - &contract_state.agent_id, + &playtest_state.agent_id, "agent", )) .join(agent_runtime_confirmation_path_component( - &contract_state.run_id, + &playtest_state.run_id, "run", )) .join(revision.to_string()); @@ -2037,17 +3312,27 @@ fn prepare_autonomous_completion_evidence_for_actor( input: serde_json::json!({}), }; let action_fingerprint = - agent_runtime_tool_action_fingerprint(&action, &actor_state.current_task); - let action_id = agent_runtime_tool_action_id(&actor_state.run_id, 1, 0, 1, &action_fingerprint); + agent_runtime_tool_action_fingerprint(&action, &playtest_state.current_task); + let action_id = + agent_runtime_tool_action_id(&playtest_state.run_id, 1, 0, 1, &action_fingerprint); write_autonomous_playtest_receipt_at( root, &contract, + playtest_state, &action_id, &action_fingerprint, revision, &result, ) .expect("write autonomous playtest receipt"); + if let Some(playtest_state) = full_dag_playtest_state.as_ref() { + let mut completed = playtest_state.clone(); + completed.status = "completed".to_string(); + completed.phase = "completed".to_string(); + completed.current_action = "已完成独立桌面与移动试玩".to_string(); + append_game_creator_agent_runtime_task(root, &completed) + .expect("complete fixture preview-playtest child after receipt"); + } if expect_complete { let blocker = autonomous_game_build_completion_blocker_at_locked(root, actor_state); assert!( @@ -2077,14 +3362,16 @@ fn prepare_autonomous_playtest_evidence_for_actor_at_revision( ) .expect("bind deterministic game-chat collaboration policy"); + // 回执现在冻结执行者身份,证据路径按 executor 的 agent/run 校验;夹具必须落在 + // 实际执行试玩的 actor 名下,而不是合同根。 let evidence_root = root .join(".agent/runtime/browser-validations") .join(agent_runtime_confirmation_path_component( - &contract_state.agent_id, + &actor_state.agent_id, "agent", )) .join(agent_runtime_confirmation_path_component( - &contract_state.run_id, + &actor_state.run_id, "run", )) .join(revision.to_string()); @@ -2193,6 +3480,7 @@ fn prepare_autonomous_playtest_evidence_for_actor_at_revision( write_autonomous_playtest_receipt_at( root, &contract, + actor_state, &action_id, &action_fingerprint, revision, @@ -2208,11 +3496,11 @@ fn game_chat_preview_playtest_migrates_legacy_generic_tetris_receipt_before_deli let root = temporary.path().join("project"); init_local_game_project_at(&root, "legacy-game-chat-tetris", "水晶俄罗斯方块") .expect("init project"); - let (root_state, preview_state) = queue_game_chat_fast_path_child( + let (root_state, mut main_state) = queue_game_chat_fast_path_child( &root, "legacy-game-chat-tetris-root", "做一个俄罗斯方块,包含旋转、重力下落、锁定和消行", - "preview-playtest", + "code-prototype", ); let mut legacy_contract = read_autonomous_completion_contract(&root, &root_state.agent_id, &root_state.run_id) @@ -2231,30 +3519,24 @@ fn game_chat_preview_playtest_migrates_legacy_generic_tetris_receipt_before_deli &legacy_contract, ) .expect("persist legacy Generic Tetris contract"); - prepare_autonomous_completion_evidence(&root, &root_state, false); + prepare_autonomous_completion_evidence_for_actor(&root, &root_state, &main_state, false); + main_state.status = "running".to_string(); + main_state.phase = "planning".to_string(); + persist_game_chat_main_asset_audit_and_route(&root, &root_state, &mut main_state); - let bound_at = read_game_creator_agent_runtime_run_profile_binding( - &root, - &root_state.agent_id, - &root_state.run_id, - ) - .expect("read root binding") - .expect("root binding exists") - .bound_at; - let plan = - game_chat_fast_path_plan_at(&root, &preview_state, &preview_state.current_task, bound_at) - .expect("evaluate migrated preview-playtest fast path") - .expect("preview-playtest emits a deterministic plan"); - assert_eq!(plan.actions.len(), 1, "unexpected plan: {plan:?}"); - assert_eq!(plan.actions[0].tool, "preview.validate"); - let migrated = - read_autonomous_completion_contract(&root, &root_state.agent_id, &root_state.run_id) - .expect("read migrated root contract") - .expect("migrated root contract exists"); + let migrated = autonomous_playtest_execution_contract_for_state_at(&root, &main_state) + .expect("evaluate migrated code-prototype preview execution contract") + .expect("code-prototype inherits the migrated root contract"); assert_eq!( migrated.playtest_scenario, BrowserPlaytestScenario::TetrisV1 ); + assert!( + read_autonomous_playtest_receipt(&root, &migrated) + .expect("legacy Generic receipt becomes recoverably stale") + .is_none(), + "the Generic receipt must not satisfy the migrated Tetris contract" + ); } #[test] @@ -4417,7 +5699,8 @@ fn autonomous_manifest_waiting_context_persists_without_finishing_parent_run() { let mut runtime = agent_runtime_state_from_task_record(&task_record); let mut observations = Vec::new(); let continuation = AgentRuntimeContinuationContext::default(); - let mut context_tracker = AgentRuntimeContextWindowTracker::from_continuation(&continuation); + let mut context_tracker = + AgentRuntimeContextWindowTracker::from_continuation(&continuation, &runtime); let blocker = autonomous_game_build_completion_blocker_at_locked(&root, &runtime) .expect("running manifest must block completion"); @@ -5250,7 +6533,7 @@ async fn autonomous_supervisor_converged_final_reply_deserialize_commits_fallbac read_game_creator_agent_runtime_at(&root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID) .expect("read autonomous Supervisor runtime") .state; - for _ in 0..500 { + for _ in 0..1_500 { if runtime.status == "idle" || runtime.status == "failed" { break; } @@ -5263,7 +6546,11 @@ async fn autonomous_supervisor_converged_final_reply_deserialize_commits_fallbac let fallback = format!( "项目已完成生成,并通过当前 revision {revision} 的静态检查和桌面、移动端交互试玩验证。" ); - assert_eq!(runtime.status, "idle"); + assert_eq!( + runtime.status, "idle", + "phase={}, currentAction={}, waitingOn={}, error={:?}", + runtime.phase, runtime.current_action, runtime.waiting_on, runtime.error + ); assert_eq!(runtime.phase, "completed"); assert_eq!(runtime.last_response.as_deref(), Some(fallback.as_str())); @@ -5383,3 +6670,311 @@ async fn autonomous_supervisor_converged_final_reply_deserialize_commits_fallbac .chunks_exact(2) .all(|pair| pair == ["started", "failed"])); } + +fn planning_submit_completion_fixture_at( + root: &Path, +) -> ( + AgentRuntimeState, + AgentRuntimePendingToolAction, + PlanSubmitGddResultV1, + String, +) { + init_local_game_project_at(root, "planning-submit-completion", "策划提交终态恢复") + .expect("initialize planning submit completion project"); + let parent_run_id = "planning-submit-completion-parent-run"; + start_game_creator_agent_runtime_task_at( + root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "收敛 Fast GDD", + parent_run_id, + AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE, + "准备委派立项策划 Agent", + vec!["委派 project-planning".to_string()], + ) + .expect("start planning Supervisor root"); + 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 planning Supervisor root"); + + let planning_lane = try_acquire_game_creator_agent_runtime_task_lock( + root, + GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + ) + .expect("acquire planning child lane") + .expect("planning child lane is available"); + let delegate_action_id = "action-111111111111111111111111"; + let observation = observe_agent_runtime_agent_delegate( + root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + Some(delegate_action_id), + &serde_json::json!({ + "agentId": GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + "task": "输出可审批的 Fast GDD", + "acceptanceCriteria": ["提交 strict Fast GDD"], + "expectedArtifacts": [], + "repairOfDelegationId": null, + "runId": null + }), + ); + assert_eq!(observation.status, "ok", "{observation:?}"); + let delegation_id = agent_runtime_delegation_id( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + delegate_action_id, + ); + let child_task = read_latest_game_creator_agent_runtime_task_by_delegation_id( + root, + GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + &delegation_id, + ) + .expect("read planning child task") + .expect("planning child task exists"); + drop(planning_lane); + + let mut runtime = agent_runtime_state_from_task_record(&child_task); + runtime.loop_iteration = 1; + let action = AgentRuntimeToolAction { + tool: PLAN_SUBMIT_GDD_TOOL.to_string(), + reason: Some("提交已校验的 Fast GDD".to_string()), + input: serde_json::json!({"schemaVersion": PLAN_SUBMIT_GDD_INPUT_SCHEMA_VERSION}), + }; + let action_fingerprint = agent_runtime_tool_action_fingerprint(&action, &runtime.current_task); + let occurrence_nonce = 42; + let action_id = agent_runtime_tool_action_id( + &runtime.run_id, + runtime.loop_iteration, + 0, + occurrence_nonce, + &action_fingerprint, + ); + let now = unix_timestamp(); + let pending = AgentRuntimePendingToolAction { + schema_version: AGENT_RUNTIME_PENDING_ACTION_SCHEMA_VERSION.to_string(), + fingerprint_version: AGENT_RUNTIME_ACTION_FINGERPRINT_VERSION.to_string(), + 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: None, + provider_batch_plan_update: None, + task: runtime.current_task.clone(), + goal_id: runtime.goal_id.clone(), + goal_revision: runtime.goal_revision, + goal_snapshot_fingerprint: agent_goal_snapshot_fingerprint_for_state_at(root, &runtime) + .expect("read planning child goal snapshot"), + loop_iteration: runtime.loop_iteration, + action_index: 0, + occurrence_nonce, + thinking_summary: "Fast GDD 已通过 strict 校验".to_string(), + plan: vec!["提交 Fast GDD".to_string()], + fallback_response: String::new(), + observations: Vec::new(), + project_revision_before: read_game_creator_agent_runtime_project_revision(root) + .expect("read planning submit project revision"), + verification_gate_before: read_game_creator_agent_runtime_verification_gate( + root, + &runtime.agent_id, + &runtime.run_id, + ) + .expect("read planning submit verification gate"), + planned_repository_context_fingerprint: build_repository_startup_context_at(root) + .expect("read planning submit repository context") + .fingerprint, + planned_steer_cursor: runtime.applied_steer_cursor, + action, + action_id: action_id.clone(), + action_fingerprint, + input_summary: None, + execution_mode: AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO.to_string(), + status: AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING.to_string(), + observation: None, + created_at: now, + updated_at: now, + }; + let result = PlanSubmitGddResultV1 { + outcome: "submitted".to_string(), + gdd_ref: PlanGddRef { + gdd_id: "gdd-00000000-0000-4000-8000-000000000001".to_string(), + version: 1, + fingerprint: format!("sha256-serde-json-v2:{}", "a".repeat(64)), + }, + pending_action_id: action_id, + approval_request_id: "gdd-approval-00000000-0000-4000-8000-000000000002".to_string(), + recovery_pending: false, + }; + (runtime, pending, result, delegation_id) +} + +fn read_planning_submit_completion_jsonl(path: &Path) -> Vec { + fs::read_to_string(path) + .ok() + .into_iter() + .flat_map(|content| { + content + .lines() + .filter(|line| !line.trim().is_empty()) + .map(|line| { + serde_json::from_str::(line) + .expect("parse planning submit completion JSONL") + }) + .collect::>() + }) + .collect() +} + +fn planning_submit_completion_audit_count( + root: &Path, + record_type: &str, + action_id: &str, +) -> usize { + read_planning_submit_completion_jsonl(&root.join(".agent/agent.db")) + .into_iter() + .filter(|record| { + record.get("recordType").and_then(serde_json::Value::as_str) == Some(record_type) + && record.get("actionId").and_then(serde_json::Value::as_str) == Some(action_id) + }) + .count() +} + +#[test] +fn planning_submit_child_completion_is_action_scoped_and_idempotent() { + let temporary = tempfile::tempdir().expect("create planning submit completion root"); + let root = temporary.path().join("project"); + let (mut runtime, pending, result, delegation_id) = + planning_submit_completion_fixture_at(&root); + + ensure_project_planning_submit_child_completion_at(&root, &mut runtime, &pending, &result) + .expect("complete planning child first time"); + ensure_project_planning_submit_child_completion_at(&root, &mut runtime, &pending, &result) + .expect("replay planning child completion"); + + let task_projection_count = read_planning_submit_completion_jsonl( + &game_creator_agent_runtime_task_path(&root, &runtime.agent_id), + ) + .into_iter() + .filter(|record| { + record.get("runId").and_then(serde_json::Value::as_str) == Some(runtime.run_id.as_str()) + && record.get("actionId").and_then(serde_json::Value::as_str) + == Some(pending.action_id.as_str()) + && record.get("phase").and_then(serde_json::Value::as_str) == Some("completed") + }) + .count(); + assert_eq!(task_projection_count, 1); + + let action_event_count = read_planning_submit_completion_jsonl( + &game_creator_agent_runtime_event_path(&root, &runtime.agent_id), + ) + .into_iter() + .filter(|record| { + record.get("eventType").and_then(serde_json::Value::as_str) + == Some("plan.submit_gdd.committed") + && record.get("actionId").and_then(serde_json::Value::as_str) + == Some(pending.action_id.as_str()) + }) + .count(); + assert_eq!(action_event_count, 1); + + let matching_deliveries = list_static_delegate_deliveries_at(&root) + .expect("list planning child deliveries") + .into_iter() + .filter(|delivery| delivery.delegation_id == delegation_id) + .collect::>(); + assert_eq!(matching_deliveries.len(), 1); + assert_eq!( + matching_deliveries[0].status, + StaticDelegateDeliveryStatus::Ready + ); + assert_eq!( + matching_deliveries[0].terminal_status.as_deref(), + Some("completed") + ); + assert_eq!( + planning_submit_completion_audit_count( + &root, + "agent.runtime.agent.delegate_receipt.ready", + "action-111111111111111111111111", + ), + 1 + ); + assert_eq!( + planning_submit_completion_audit_count( + &root, + "agent.runtime.plan_submit_gdd.committed", + &pending.action_id, + ), + 1 + ); +} + +#[test] +fn planning_submit_child_completion_waits_for_exact_delivery_before_committed_audit() { + let temporary = tempfile::tempdir().expect("create planning submit delivery recovery root"); + let root = temporary.path().join("project"); + let (mut runtime, pending, result, delegation_id) = + planning_submit_completion_fixture_at(&root); + let delivery = read_static_delegate_delivery_at(&root, &delegation_id) + .expect("read dispatched planning delivery") + .expect("dispatched planning delivery exists"); + fs::remove_file( + root.join(".agent/runtime/delegation-deliveries") + .join(format!("{delegation_id}.json")), + ) + .expect("remove planning delivery to model post-commit interruption"); + + let error = + ensure_project_planning_submit_child_completion_at(&root, &mut runtime, &pending, &result) + .expect_err("completion must wait for exact durable delivery"); + assert!(error.contains("durable delivery 不存在"), "{error}"); + assert_eq!( + planning_submit_completion_audit_count( + &root, + "agent.runtime.plan_submit_gdd.committed", + &pending.action_id, + ), + 0, + "delivery 未 durable 前不得写 recoveryPending=false committed audit" + ); + + create_or_read_static_delegate_delivery_at(&root, &delivery) + .expect("restore exact dispatched planning delivery"); + ensure_project_planning_submit_child_completion_at(&root, &mut runtime, &pending, &result) + .expect("resume same planning submit action after delivery repair"); + let recovered_delivery = read_static_delegate_delivery_at(&root, &delegation_id) + .expect("read recovered planning delivery") + .expect("recovered planning delivery exists"); + assert_eq!( + recovered_delivery.status, + StaticDelegateDeliveryStatus::Ready + ); + assert_eq!( + recovered_delivery.terminal_status.as_deref(), + Some("completed") + ); + let committed = read_planning_submit_completion_jsonl(&root.join(".agent/agent.db")) + .into_iter() + .filter(|record| { + record.get("recordType").and_then(serde_json::Value::as_str) + == Some("agent.runtime.plan_submit_gdd.committed") + && record.get("actionId").and_then(serde_json::Value::as_str) + == Some(pending.action_id.as_str()) + }) + .collect::>(); + assert_eq!(committed.len(), 1); + assert_eq!( + committed[0] + .get("recoveryPending") + .and_then(serde_json::Value::as_bool), + Some(false) + ); +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_execution.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_execution.rs index 14c2fabdb..589287d58 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_execution.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_execution.rs @@ -979,7 +979,8 @@ async fn continue_game_creator_agent_pending_tool_action_within_stack_boundary( usize::try_from(pending.loop_iteration).unwrap_or(usize::MAX), ) }; - let mut context_tracker = AgentRuntimeContextWindowTracker::from_continuation(&continuation); + let mut context_tracker = + AgentRuntimeContextWindowTracker::from_continuation(&continuation, &runtime); if let Some(observation) = observations.last() { context_tracker.record(observation); } 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 7e130e4a5..b3456076e 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 @@ -159,12 +159,13 @@ pub(in crate::agent) fn replay_supervisor_delivery_pending_action_at( } } match pending.action.tool.as_str() { - "agent.delegate" => observe_agent_runtime_agent_delegate( + "agent.delegate" => observe_agent_runtime_agent_delegate_at_locked( root, &pending.agent_id, &pending.run_id, Some(&pending.action_id), &pending.action.input, + &_project_lock, ), "agent.run_status" => observe_agent_runtime_run_status( root, @@ -620,6 +621,209 @@ 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, @@ -639,11 +843,33 @@ 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) { - if has_reconciliation_barrier { - return read_game_creator_agent_runtime_at(root, agent_id) - .map(AgentRuntimePendingActionResume::Handled); + 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)); } - return Ok(AgentRuntimePendingActionResume::NotFound(runtime_lock)); } let mut pending = match read_game_creator_agent_runtime_pending_tool_action( root, @@ -670,6 +896,30 @@ pub(crate) fn resume_game_creator_agent_pending_tool_action_at( .map(AgentRuntimePendingActionResume::Handled); } }; + 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) @@ -682,13 +932,11 @@ 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); } - 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); if session_mismatch { mark_game_creator_agent_runtime_needs_reconciliation_at( root, @@ -714,6 +962,34 @@ pub(crate) fn resume_game_creator_agent_pending_tool_action_at( can_repair_terminal_receipt = true; } } + 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" { @@ -722,8 +998,15 @@ 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)); } - let provider_batch_exists = - game_creator_agent_runtime_provider_action_batch_exists(root, agent_id, &runtime.run_id); + // `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, @@ -786,31 +1069,142 @@ 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 { - if runtime.run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD - && !static_delegate_clarification_pending_matches_delivery_at(root, &pending)? + 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)?) { 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, - "自主构建 Run 恢复到 legacy waiting-for-user-input,已拒绝继续等待", + if planning_agent { + "project-planning 子 Agent 恢复到 waiting-for-user-input,已拒绝继续等待" + } else { + "自主构建 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)?; - mark_game_creator_agent_runtime_cancelled_at( - root, - &mut runtime, - "Agent 后台任务已按开发者请求取消", - Some("Runtime 恢复用户输入等待时发现尚未完成的取消请求。"), - )?; + 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 恢复用户输入等待时发现尚未完成的取消请求。"), + )?, + } return Ok(AgentRuntimePendingActionResume::NotFound(runtime_lock)); } - match prepare_game_creator_agent_user_input_request_at(root, &pending) { + 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), + }; + match recovered_user_input { Ok(AgentRuntimeUserInputRecovery::Waiting(request)) => { runtime.pending_tool_action = Some(pending.summary()); runtime.status = "waiting-for-user-input".to_string(); @@ -852,12 +1246,20 @@ pub(crate) fn resume_game_creator_agent_pending_tool_action_at( can_repair_terminal_receipt = true; } Ok(AgentRuntimeUserInputRecovery::Cancelled) => { - mark_game_creator_agent_runtime_cancelled_at( - root, - &mut runtime, - "Agent 用户输入请求已取消", - Some("Runner 恢复时发现用户输入 sidecar 已取消。"), - )?; + 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 已取消。"), + )?, + } return Ok(AgentRuntimePendingActionResume::NotFound(runtime_lock)); } Err(error) => { @@ -872,6 +1274,7 @@ 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" { @@ -1131,12 +1534,116 @@ 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 { @@ -1240,9 +1747,10 @@ 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 runtime.applied_steer_cursor != batch.planned_steer_cursor - || queued_steer - || repository_context_drifted + if !exact_committed_plan_submit + && (runtime.applied_steer_cursor != batch.planned_steer_cursor + || queued_steer + || repository_context_drifted) { let current_pending = batch .actions @@ -1388,6 +1896,7 @@ pub(crate) fn resume_game_creator_agent_provider_action_batch_for_test_at( match resume_game_creator_agent_provider_action_batch_at(root, agent_id, runtime_lock)? { AgentRuntimePendingActionResume::Handled(_) => Ok("handled"), AgentRuntimePendingActionResume::NotFound(_) => Ok("not-found"), + AgentRuntimePendingActionResume::Deferred => Ok("deferred"), } } @@ -1395,6 +1904,729 @@ 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 6b0e60d62..321a6acfb 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 @@ -324,11 +324,28 @@ pub(crate) fn schedule_waiting_static_delegate_parent_wake_after_lane_release( /// Convert a claimed `needs-user-input` delivery into the Supervisor's own /// durable user-input action. The child never owns this action: it is tied to /// the parent run and therefore passes the normal user-input owner gate. +#[cfg(test)] pub(crate) fn ensure_static_delegate_user_input_wait_at( root: &Path, runtime: &mut AgentRuntimeState, deliveries: &[StaticDelegateDeliveryRecord], ) -> Result { + let project_lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "planning.clarification.pending", + )?; + ensure_static_delegate_user_input_wait_at_locked(root, runtime, deliveries, &project_lock) +} + +pub(crate) fn ensure_static_delegate_user_input_wait_at_locked( + root: &Path, + runtime: &mut AgentRuntimeState, + deliveries: &[StaticDelegateDeliveryRecord], + project_lock: &ProjectWriteLock, +) -> Result { + if !project_lock.guards_project_root(root)? { + return Err("Supervisor 澄清 pending 投影缺少当前项目写锁".to_string()); + } let parent_task = read_latest_game_creator_agent_runtime_task_by_run_id( root, &runtime.agent_id, @@ -355,6 +372,10 @@ pub(crate) fn ensure_static_delegate_user_input_wait_at( 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 @@ -370,7 +391,7 @@ pub(crate) fn ensure_static_delegate_user_input_wait_at( let question_binding = game_creator_agent_user_input_action_input_summary(&action.input) .unwrap_or_else(|| "questionsSha256=unavailable".to_string()); let task = format!( - "子 Agent 需要用户澄清后才能继续。delegationId={};{question_binding}。请回答以下问题;回答完成后只创建一次 agent.delegate continuation,并将 repairOfDelegationId 与 continuationOfDelegationId 指向该原 delegation,同时提交 questionsSha256/answersSha256。", + "{AGENT_RUNTIME_DELEGATE_CLARIFICATION_TASK_PREFIX}{};{question_binding}。请回答以下问题;回答完成后只创建一次 agent.delegate continuation,并将 repairOfDelegationId 与 continuationOfDelegationId 指向该原 delegation。questionsSha256/answersSha256 由 Runtime 从原 delivery 补齐,你不要自己填写。", delivery.delegation_id ); // Re-entry after a wake or restart may only reuse the exact request that @@ -421,7 +442,12 @@ pub(crate) fn ensure_static_delegate_user_input_wait_at( AGENT_RUNTIME_PENDING_ACTION_STATUS_WAITING_FOR_USER_INPUT, None, )?; - persist_game_creator_agent_user_input_wait_at(root, runtime, &mut pending)?; + persist_game_creator_agent_user_input_wait_at_locked( + root, + runtime, + &mut pending, + project_lock, + )?; Ok(true) } 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 045ed4484..fa4e87285 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,6 +51,208 @@ 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, @@ -122,7 +324,8 @@ pub(in crate::agent) fn ensure_waiting_provider_retry_projection_at( let bundle = read_game_creator_agent_runtime_context_bundle(root, &runtime)? .ok_or_else(|| "Provider retry 等待投影缺少 Runtime context bundle".to_string())?; let continuation = continuation_from_game_creator_agent_runtime_context_bundle(bundle); - let context_tracker = AgentRuntimeContextWindowTracker::from_continuation(&continuation); + let context_tracker = + AgentRuntimeContextWindowTracker::from_continuation(&continuation, &runtime); persist_waiting_provider_retry_context_at( root, &mut runtime, @@ -454,6 +657,10 @@ 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) => {} @@ -903,11 +1110,95 @@ 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); @@ -990,6 +1281,92 @@ pub(in crate::agent) fn resume_game_creator_agent_background_tasks_unredacted_at else { continue; }; + // A planning continuation can be durable while `session.json` still + // points at the answered/waiting round. Recovery used to enter the + // pending-action and Provider-batch paths below before repairing that + // 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 { @@ -1028,13 +1405,13 @@ pub(in crate::agent) fn resume_game_creator_agent_background_tasks_unredacted_at match resume_game_creator_agent_finalization_at(root, &agent_id, runtime_lock)? { AgentRuntimeFinalizationResume::Recovered(result, runtime_lock) => { resumed.push(result); - let root = root.to_path_buf(); - let background_agent_id = agent_id.clone(); - tauri::async_runtime::spawn(async move { - let _runtime_lock = runtime_lock; - drain_next_game_creator_agent_background_tasks(root, background_agent_id) - .await; - }); + // 与正常唤醒共用 `spawn_next_..._with_lock`:此处原本手写 spawn,绕过了 + // 该 helper 对专用 worker 栈的保证。 + spawn_next_game_creator_agent_background_task_drain_with_lock( + root, + &agent_id, + runtime_lock, + ); continue; } AgentRuntimeFinalizationResume::Blocked(result) => { @@ -1080,6 +1457,7 @@ pub(in crate::agent) fn resume_game_creator_agent_background_tasks_unredacted_at continue; } AgentRuntimePendingActionResume::NotFound(runtime_lock) => runtime_lock, + AgentRuntimePendingActionResume::Deferred => continue, }; let runtime_lock = match resume_game_creator_agent_pending_tool_action_at( root, @@ -1091,6 +1469,8 @@ pub(in crate::agent) fn resume_game_creator_agent_background_tasks_unredacted_at continue; } AgentRuntimePendingActionResume::NotFound(runtime_lock) => runtime_lock, + // 锁序重排窗口里没抢到锁。跳过该 Agent,本轮其余 Agent 照常恢复。 + AgentRuntimePendingActionResume::Deferred => continue, }; match resume_game_creator_agent_provider_action_batch_at(root, &agent_id, runtime_lock)? { @@ -1099,6 +1479,7 @@ pub(in crate::agent) fn resume_game_creator_agent_background_tasks_unredacted_at continue; } AgentRuntimePendingActionResume::NotFound(runtime_lock) => runtime_lock, + AgentRuntimePendingActionResume::Deferred => continue, } }; let Some(task) = @@ -1326,19 +1707,25 @@ pub(in crate::agent) fn resume_game_creator_agent_background_tasks_unredacted_at ); let _ = append_game_creator_agent_background_task_started_record(root, &state); let result = read_game_creator_agent_runtime_at(root, &agent_id)?; - let root = root.to_path_buf(); - let background_agent_id = agent_id.clone(); let background_task = task.task.clone(); - tauri::async_runtime::spawn(async move { - let _runtime_lock = runtime_lock; - drain_game_creator_agent_background_tasks( + // 恢复重启必须和正常启动共用同一条 16 MiB 专用 worker。此处原本手写 + // `tauri::async_runtime::spawn`,把与 started 入口同样深的 poll 链放到默认 2 MiB 的 + // tokio worker 上,是 2026-08-15 栈溢出的直接原因。helper 另外提供首轮轮询握手, + // 保证执行锁只在 future 确实开始轮询之后才交接;原写法把锁 move 进一个无人保证会被 + // 轮询的 future,运行时关停时 run 会永远停在 running 且无主。 + if let Err((error, _runtime_lock)) = + spawn_started_game_creator_agent_background_task_drain_with_lock( root, - background_agent_id, + &agent_id, background_task, - state, + state.clone(), + runtime_lock, ) - .await; - }); + { + let error = format!("Agent Runtime 后台执行 worker 启动失败:{error}"); + let _ = fail_game_creator_agent_runtime_turn_at(root, state, &error); + continue; + } resumed.push(result); } reconcile_game_creator_agent_delegate_receipts_at(root)?; @@ -1477,6 +1864,11 @@ pub(crate) fn resume_game_creator_agent_pending_action_for_agent_at( AgentRuntimePendingActionResume::NotFound(_runtime_lock) => { Err("Agent Runner 未找到可继续的精确待处理动作".to_string()) } + // 这条入口是「继续这一个动作」的定向请求,不是批量扫描:没抢到锁只能如实 + // 报错。文案沿用执行锁自己的措辞,让上游的 transient 判据仍能认出它。 + AgentRuntimePendingActionResume::Deferred => Err(format!( + "Agent Runtime 正在执行该 Agent 的其他任务:{agent_id}" + )), } } @@ -1792,6 +2184,245 @@ 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-"); @@ -1916,3 +2547,280 @@ 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 5d6667732..ccbbbdcb2 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 @@ -1,5 +1,47 @@ use super::*; +/// 所有会进入 Agent 主循环的 future 都必须在这个尺寸的专用线程上轮询。 +/// +/// debug 构建里主循环、pending continuation 与 Provider 分发组合出的 poll 链远超默认 +/// 2 MiB worker 栈(见 `pitfalls.md`「Runtime 后台执行不能让大型 async frame 共用默认 +/// worker 栈」)。历史上只有「首个任务」入口用了专用线程,队列 drain 与恢复重启留在默认 +/// 栈上;2026-08-15 实测两条路的栈需求只差一个 poll 帧,因此统一到同一个常量,避免再次 +/// 出现「一半入口有保护、另一半没有」。 +pub(crate) const AGENT_RUNTIME_BACKGROUND_WORKER_STACK_BYTES: usize = 16 * 1024 * 1024; + +/// 结构性回归钩子:记录主循环 drain 实际所在的线程名。 +/// +/// 只断言「默认栈下没崩」的用例在余量仅剩几百字节时依然是绿的——这正是恢复重启栈溢出 +/// 没被提前拦住的原因。测试改为断言线程名,钉的是不变量本身而不是当时的余量。 +#[cfg(test)] +pub(in crate::agent) fn record_agent_runtime_background_worker_thread_for_test(root: &Path) { + let name = std::thread::current() + .name() + .unwrap_or("") + .to_string(); + let path = root.join(AGENT_RUNTIME_BACKGROUND_WORKER_THREAD_LOG_FOR_TEST); + if let Some(parent) = path.parent() { + let _ = fs::create_dir_all(parent); + } + let mut content = fs::read_to_string(&path).unwrap_or_default(); + content.push_str(&name); + content.push('\n'); + let _ = fs::write(&path, content); +} + +#[cfg(test)] +pub(in crate::agent) const AGENT_RUNTIME_BACKGROUND_WORKER_THREAD_LOG_FOR_TEST: &str = + ".agent/runtime/test-background-worker-threads"; + +#[cfg(test)] +pub(crate) fn agent_runtime_background_worker_threads_for_test(root: &Path) -> Vec { + fs::read_to_string(root.join(AGENT_RUNTIME_BACKGROUND_WORKER_THREAD_LOG_FOR_TEST)) + .unwrap_or_default() + .lines() + .map(str::to_string) + .collect() +} + pub(in crate::agent) fn game_creator_agent_background_task_default_plan() -> Vec { vec![ "记录开发者投递的后台任务".to_string(), @@ -77,6 +119,8 @@ pub(in crate::agent) async fn drain_game_creator_agent_background_tasks( first_task: String, first_state: AgentRuntimeState, ) { + #[cfg(test)] + record_agent_runtime_background_worker_thread_for_test(&root); if !matches!( run_game_creator_agent_background_task( root.clone(), @@ -96,6 +140,8 @@ pub(in crate::agent) async fn drain_next_game_creator_agent_background_tasks( root: PathBuf, agent_id: String, ) { + #[cfg(test)] + record_agent_runtime_background_worker_thread_for_test(&root); loop { match game_creator_agent_runtime_has_reconciliation_barrier(&root, &agent_id) { Ok(true) | Err(_) => break, @@ -195,10 +241,36 @@ pub(crate) fn spawn_next_game_creator_agent_background_task_drain_with_lock( } let root = root.to_path_buf(); let agent_id = agent_id.to_string(); - tauri::async_runtime::spawn(async move { - let _runtime_lock = runtime_lock; - drain_next_game_creator_agent_background_tasks(root, agent_id).await; - }); + let worker_name = format!( + "agent-runtime-worker-{}", + sanitize_agent_runtime_text(&agent_id, 44) + ); + let worker_root = root.clone(); + let worker_agent_id = agent_id.clone(); + // 本入口是 best-effort:拿不到执行锁就直接返回、由后续 wake 重试(见上方 + // `spawn_next_game_creator_agent_background_task_drain`),因此建线程失败只需记录并释放 + // 锁——闭包连同 `runtime_lock` 一起被丢弃即完成释放,不必像 started 入口那样把锁交还 + // 调用方,也就不需要首轮轮询握手。 + if let Err(error) = std::thread::Builder::new() + .name(worker_name) + .stack_size(AGENT_RUNTIME_BACKGROUND_WORKER_STACK_BYTES) + .spawn(move || { + tauri::async_runtime::block_on(async move { + let _runtime_lock = runtime_lock; + drain_next_game_creator_agent_background_tasks(worker_root, worker_agent_id).await; + }); + }) + { + let _ = append_agent_db_record( + &root, + serde_json::json!({ + "recordType": "agent.runtime.background_worker_spawn_failed", + "agentId": agent_id, + "entry": "drain-next", + "error": sanitize_agent_runtime_text(&error.to_string(), 500), + }), + ); + } } pub(crate) fn spawn_started_game_creator_agent_background_task_drain_with_lock( @@ -232,7 +304,7 @@ pub(crate) fn spawn_started_game_creator_agent_background_task_drain_with_lock( ); if let Err(error) = std::thread::Builder::new() .name(worker_name) - .stack_size(16 * 1024 * 1024) + .stack_size(AGENT_RUNTIME_BACKGROUND_WORKER_STACK_BYTES) .spawn(move || { #[cfg(test)] if !first_poll_delay.is_zero() { @@ -404,7 +476,232 @@ pub(crate) async fn run_game_creator_agent_background_task_with_context( AgentBackgroundTaskOutcome::WaitingForProviderHandoff => { 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 0a9cd34ac..1858397e7 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,6 +6,10 @@ 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); @@ -122,7 +126,13 @@ pub(crate) fn start_game_creator_supervisor_background_task_for_session_at( if !agent_runtime_supervisor_source_is_trusted(source) { return Err("Project Supervisor 提交 source 不受信任".to_string()); } - normalize_agent_runtime_run_profile(Some(run_profile))?; + 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, @@ -130,7 +140,7 @@ pub(crate) fn start_game_creator_supervisor_background_task_for_session_at( task, run_id, source, - Some(run_profile), + Some(run_profile.as_str()), ) .map(|(mut result, accepted_run_id)| { result.accepted_run_id = Some(accepted_run_id); @@ -168,6 +178,79 @@ pub(in crate::agent) fn start_game_creator_agent_background_task_with_link_at( source: &str, 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, + session_id, + task, + run_id, + source, + run_profile, + task_link, + None, + ) +} + +pub(in crate::agent) fn start_game_creator_agent_background_task_with_link_locked_at( + root: &Path, + agent_id: &str, + session_id: Option<&str>, + task: &str, + run_id: &str, + source: &str, + run_profile: Option<&str>, + task_link: Option<&AgentRuntimeTaskLink>, + project_write_lock: &ProjectWriteLock, +) -> Result<(AgentRuntimeResult, String), String> { + if !project_write_lock.guards_project_root(root)? { + return Err("Agent 后台任务入队缺少当前项目写锁".to_string()); + } + start_game_creator_agent_background_task_with_link_with_project_lock_at( + root, + agent_id, + session_id, + task, + run_id, + source, + run_profile, + task_link, + Some(project_write_lock), + ) +} + +#[allow(clippy::too_many_arguments)] +fn start_game_creator_agent_background_task_with_link_with_project_lock_at( + root: &Path, + agent_id: &str, + session_id: Option<&str>, + task: &str, + run_id: &str, + source: &str, + run_profile: Option<&str>, + task_link: Option<&AgentRuntimeTaskLink>, + project_write_lock: Option<&ProjectWriteLock>, ) -> Result<(AgentRuntimeResult, String), String> { let agent_id = normalize_game_creator_runtime_agent_id(agent_id)?; validate_project_root(root)?; @@ -176,7 +259,7 @@ pub(in crate::agent) fn start_game_creator_agent_background_task_with_link_at( &agent_id, "Agent Session Runtime 入队", || { - start_game_creator_agent_background_task_with_link_in_session_lane_at( + start_game_creator_agent_background_task_with_link_in_session_lane_with_project_lock_at( root, &agent_id, session_id, @@ -185,6 +268,7 @@ pub(in crate::agent) fn start_game_creator_agent_background_task_with_link_at( source, run_profile, task_link, + project_write_lock, ) }, )?; @@ -249,6 +333,36 @@ pub(in crate::agent) fn start_game_creator_agent_background_task_with_link_in_se run_profile: Option<&str>, task_link: Option<&AgentRuntimeTaskLink>, ) -> Result<(AgentRuntimeResult, String), String> { + start_game_creator_agent_background_task_with_link_in_session_lane_with_project_lock_at( + root, + agent_id, + session_id, + task, + run_id, + source, + run_profile, + task_link, + None, + ) +} + +#[allow(clippy::too_many_arguments)] +fn start_game_creator_agent_background_task_with_link_in_session_lane_with_project_lock_at( + root: &Path, + agent_id: &str, + session_id: Option<&str>, + task: &str, + run_id: &str, + source: &str, + run_profile: Option<&str>, + 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)) @@ -393,6 +507,41 @@ pub(in crate::agent) fn start_game_creator_agent_background_task_with_link_in_se 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: "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) @@ -704,7 +853,7 @@ pub(crate) fn current_autonomous_game_build_root_task_at( && record.run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD && record.parent_agent_id.is_none() && record.parent_run_id.is_none() - && agent_runtime_supervisor_source_is_trusted(&record.source) + && agent_runtime_supervisor_source_is_autonomous_game_build(&record.source) && seen_run_ids.insert(record.run_id.clone()) { root_run_ids.push(record.run_id.clone()); @@ -1461,8 +1610,14 @@ fn render_autonomous_manifest_ready_task_owner_prompt(task: &GameCreationAppTask } else { "" }; + 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 产物验证。", + "publish-package" => "publish-package 不在前置固定 owner 的内部产物验证范围内;必须继续按现有发布完成合同和当前 run 的可用验证门收束,不得借用前置 owner 的验证凭证。", + _ => "完成修改后按当前任务的既有验证合同收束。", + }; format!( - "{base}\n\n这是 autonomous-game-build 的正式 owner 写入任务。必须实际生成并写入非空正式产物:{paths};JSON 文件必须是可解析 JSON,code-prototype 的 game/index.html 不能沿用初始化占位。{publish_package_requirement}任务声明中的视觉图片继续按现有 visual gate 生成、登记并验收。完成修改后按当前 run 的验证门完成验证并直接交付结论;不要调用 task.update,Runtime 会在子 Run 终态后幂等投影 manifest。" + "{base}\n\n这是 autonomous-game-build 的正式 owner 写入任务。必须实际生成并写入非空正式产物:{paths};JSON 文件必须是可解析 JSON,code-prototype 的 game/index.html 不能沿用初始化占位。{publish_package_requirement}任务声明中的视觉图片继续按现有 visual gate 生成、登记并验收。{verification_requirement}不要调用 task.update,Runtime 会在子 Run 终态后幂等投影 manifest。" ) } @@ -1492,9 +1647,14 @@ pub(in crate::agent) fn render_autonomous_manifest_ready_task_background_prompt( "{base}\n\n这是 autonomous-game-build 的只读试玩验收任务,不要修改项目文件。固定核心动作是且只能是 preview.validate;完成当前 revision 的桌面与移动试玩后直接交付验收结论,不要调用项目 mutation、其它预览动作或 task.update,Runtime 会在子 Run 终态后幂等投影 manifest。" ); } - if task.id == "art-director" && editor_api_key_is_configured() { + 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。" + ); + } 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 的无生图凭据只读协调任务。当前未配置 External Editor 生图凭据,上述 seed task 中 assets/art-spec.png 图片产物与生成验收条款在本轮不适用;只交付正式视觉方向结论,不要修改项目文件,不调用 canvas.asset_generate、game.static_smoke、project.verify、command.run_limited、preview 或 task.update。Runtime 会在子 Run 终态后幂等投影 manifest。" ); } if autonomous_manifest_task_requires_project_mutation(&task.id) { @@ -1541,3 +1701,99 @@ pub(in crate::agent) fn render_manifest_ready_task_background_prompt( acceptance ) } + +#[cfg(test)] +mod tests { + use super::*; + + fn seed_task(task_id: &str) -> GameCreationAppTaskState { + new_game_creation_app_seed_tasks() + .into_iter() + .find(|task| task.id == task_id) + .unwrap_or_else(|| panic!("missing seed task {task_id}")) + } + + #[test] + fn autonomous_ready_task_prompts_separate_owner_artifacts_from_playable_validation() { + let _config_guard = crate::tests::write_test_local_config("{}".to_string()); + + for task_id in [ + "design-foundation", + "balance-seed", + "art-asset-plan", + "audio-asset-plan", + ] { + let prompt = + render_autonomous_manifest_ready_task_background_prompt(&seed_task(task_id)); + assert!(prompt.contains("由 Runtime 在收束门内验证本人固定 owner 产物")); + assert!( + prompt.contains("禁止调用 game.static_smoke、project.verify、command.run_limited") + ); + } + + let code_prompt = + render_autonomous_manifest_ready_task_background_prompt(&seed_task("code-prototype")); + assert!(code_prompt.contains("必须对可玩入口执行 game.static_smoke")); + assert!(!code_prompt.contains("验证本人固定 owner 产物")); + + let readiness_prompt = render_autonomous_manifest_ready_task_background_prompt(&seed_task( + "preview-readiness", + )); + assert!( + readiness_prompt.contains("且只能是 command.run_limited(commandId=game.static_smoke)") + ); + assert!(!readiness_prompt.contains("preview.validate")); + + let publish_prompt = + render_autonomous_manifest_ready_task_background_prompt(&seed_task("publish-package")); + assert!(publish_prompt.contains("不在前置固定 owner 的内部产物验证范围内")); + assert!(!publish_prompt.contains("验证本人固定 owner 产物")); + } + + #[tokio::test] + async fn art_director_ready_task_is_read_only_without_key_and_canvas_owner_with_key() { + let task = seed_task("art-director"); + { + let _config_guard = crate::tests::write_test_local_config("{}".to_string()); + let prompt = render_autonomous_manifest_ready_task_background_prompt(&task); + assert!(prompt.contains("无生图凭据只读协调任务")); + assert!(prompt.contains("assets/art-spec.png 图片产物与生成验收条款在本轮不适用")); + assert!(prompt.contains("不要修改项目文件")); + assert!(!autonomous_manifest_ready_task_requires_visual_asset( + "art-director" + )); + assert!(agent_runtime_task_requires_read_only_delivery( + "art-director", + &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 + )); + }, + ) + .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 6c595a236..466d5d582 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 @@ -8,6 +8,12 @@ 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 provider_control; mod provider_retry; mod real_e2e_checkpoint; @@ -21,10 +27,17 @@ 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(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::*; @@ -40,7 +53,8 @@ pub(crate) use context_bundle::{ write_game_creator_agent_runtime_context_bundle, }; pub(crate) use context_window::{ - sanitize_agent_runtime_context_observation, AgentRuntimeContextCheckpoint, + agent_runtime_context_window_applies, sanitize_agent_runtime_context_observation, + AgentRuntimeContextCheckpoint, }; #[allow(unused_imports)] pub(crate) use finalization::{ 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 e59e42a88..62866db3f 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 @@ -124,6 +124,8 @@ struct AcceptanceEvidenceReceipt { tool: String, project_revision_before: u64, project_revision_after: u64, + input_summary: Option, + safe_detail: Option, } fn acceptance_evidence_tool_may_advance_project_revision(tool: &str) -> bool { @@ -196,11 +198,20 @@ fn acceptance_evidence_tools_at<'a>( for identity in identities { let record = receipts .get(&identity) - .ok_or_else(|| format!("Acceptance Graph evidence 缺少持久动作回执:{}", identity.2))?; + // 查找按 (agentId, runId, actionId) 整体做 key,三者任一对不上都会落空。 + // 早期这句只打印 actionId,于是一次「runId 写错」被报成「这个 actionId 没有 + // 回执」——实测把模型带进了「我的读取没落盘」的错误结论,重读全文再猜; + // 排查的人也会先去查时序和落盘顺序。报全三元组才指得对方向。 + .ok_or_else(|| { + format!( + "Acceptance Graph evidence 缺少持久动作回执:agentId={} · runId={} · actionId={}", + identity.0, identity.1, identity.2 + ) + })?; if agent_db_record_text(record, "status") != Some("ok") { return Err(format!( - "Acceptance Graph evidence 动作未成功:{}", - identity.2 + "Acceptance Graph evidence 动作未成功:agentId={} · runId={} · actionId={}", + identity.0, identity.1, identity.2 )); } let tool = agent_db_record_text(record, "tool") @@ -222,12 +233,206 @@ fn acceptance_evidence_tools_at<'a>( tool: tool.to_string(), project_revision_before, project_revision_after, + input_summary: record + .get("inputSummary") + .and_then(serde_json::Value::as_str) + .map(ToString::to_string), + safe_detail: record + .get("safeDetail") + .and_then(serde_json::Value::as_str) + .map(ToString::to_string), }, ); } Ok(tools) } +#[derive(Clone, Debug, Eq, PartialEq)] +struct FastGddFileReadCoverage { + content_sha256: String, + start_line: usize, + end_line: usize, + 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, @@ -264,6 +469,7 @@ 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() @@ -330,6 +536,25 @@ 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()); @@ -672,15 +897,157 @@ 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) { 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(), @@ -695,6 +1062,124 @@ 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, @@ -753,6 +1238,52 @@ 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"); @@ -1077,6 +1608,62 @@ 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/autonomous_completion.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs index 06825c6fc..580fcf120 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs @@ -38,7 +38,7 @@ pub(crate) fn autonomous_game_build_root_run_active_at(root: &Path) -> bool { if read_game_creator_agent_runtime_at(root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID).is_ok_and( |runtime| { runtime.state.run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD - && agent_runtime_supervisor_source_is_trusted(&runtime.state.source) + && agent_runtime_supervisor_source_is_autonomous_game_build(&runtime.state.source) && !matches!( runtime.state.phase.as_str(), "completed" | "failed" | "cancelled" | "budget-exhausted" @@ -55,7 +55,7 @@ pub(crate) fn autonomous_game_build_root_run_active_at(root: &Path) -> bool { tasks.into_iter().any(|task| { task.run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD && task.parent_run_id.is_none() - && agent_runtime_supervisor_source_is_trusted(&task.source) + && agent_runtime_supervisor_source_is_autonomous_game_build(&task.source) && matches!( task.status.as_str(), "pending" @@ -413,6 +413,114 @@ fn autonomous_manifest_owner_artifact_gaps_at( Ok(gaps) } +pub(in crate::agent) fn autonomous_owner_artifact_validation_available_for_run_at( + root: &Path, + agent_id: &str, + run_id: &str, +) -> Result { + if !matches!( + agent_id, + "design-foundation" | "balance-seed" | "art-asset-plan" | "audio-asset-plan" + ) { + return Ok(false); + } + let Some(binding) = + read_game_creator_agent_runtime_run_profile_binding(root, agent_id, run_id)? + else { + return Ok(false); + }; + if binding.profile != AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + || binding.source != "agent-ready-task-scheduler" + || binding.agent_id != agent_id + || binding.run_id != run_id + || binding.parent_agent_id.as_deref() != Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID) + || binding.parent_run_id.as_deref() != Some(binding.root_run_id.as_str()) + || binding.root_agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID + { + return Ok(false); + } + let Some(root_binding) = read_game_creator_agent_runtime_run_profile_binding( + root, + &binding.root_agent_id, + &binding.root_run_id, + )? + else { + return Err("owner-artifact 验证缺少根 Supervisor Run Profile 绑定".to_string()); + }; + Ok( + root_binding.profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + && matches!( + root_binding.source.as_str(), + AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE | AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE + ) + && root_binding.agent_id == binding.root_agent_id + && root_binding.run_id == binding.root_run_id + && root_binding.root_agent_id == root_binding.agent_id + && root_binding.root_run_id == root_binding.run_id + && root_binding.parent_agent_id.is_none() + && root_binding.parent_run_id.is_none() + && binding.parent_binding_fingerprint.as_deref() + == Some(root_binding.binding_fingerprint.as_str()), + ) +} + +pub(in crate::agent) fn validate_autonomous_owner_artifacts_for_run_at_locked( + root: &Path, + agent_id: &str, + run_id: &str, +) -> Result<&'static [&'static str], String> { + if !autonomous_owner_artifact_validation_available_for_run_at(root, agent_id, run_id)? { + return Err( + "owner-artifact 验证只允许完整 autonomous DAG 的固定 owner 写入任务".to_string(), + ); + } + ensure_current_autonomous_ready_child_mutation_at_locked(root, agent_id, run_id)?; + let revision = read_game_creator_agent_runtime_project_revision(root)?; + let gate = read_game_creator_agent_runtime_verification_gate(root, agent_id, run_id)?; + let mutation_revision = gate + .mutation_revision + .filter(|value| *value > 0) + .ok_or_else(|| "owner-artifact 验证要求当前 run 已形成 mutationRevision".to_string())?; + if !gate.requires_verification || mutation_revision > revision.revision { + return Err("owner-artifact 验证门与当前项目 revision 不一致".to_string()); + } + let binding = read_game_creator_agent_runtime_run_profile_binding(root, agent_id, run_id)? + .ok_or_else(|| "owner-artifact 验证缺少当前 Run Profile 绑定".to_string())?; + let parent_agent_id = binding + .parent_agent_id + .as_deref() + .ok_or_else(|| "owner-artifact 验证缺少 parentAgentId".to_string())?; + let parent_run_id = binding + .parent_run_id + .as_deref() + .ok_or_else(|| "owner-artifact 验证缺少 parentRunId".to_string())?; + let parent_contract = + read_autonomous_completion_contract(root, parent_agent_id, parent_run_id)? + .ok_or_else(|| "owner-artifact 验证缺少父 Supervisor 完成合同".to_string())?; + if binding.parent_binding_fingerprint.as_deref() + != Some(parent_contract.run_profile_binding_fingerprint.as_str()) + { + return Err("owner-artifact 验证与父 Supervisor 完成合同绑定不一致".to_string()); + } + let gaps = autonomous_manifest_owner_artifact_gaps_at( + root, + agent_id, + parent_contract.baseline_index_sha256.as_deref(), + &parent_contract.baseline_artifacts, + )?; + if !gaps.is_empty() { + return Err(format!( + "owner-artifact 验证未通过:{}", + autonomous_manifest_artifact_gaps_detail(&gaps) + )); + } + let paths = autonomous_manifest_owner_artifact_paths(agent_id); + if paths.is_empty() { + return Err("owner-artifact 验证合同没有固定产物路径".to_string()); + } + Ok(paths) +} + fn autonomous_code_prototype_art_asset_reference_gap_at( root: &Path, task_id: &str, @@ -5898,6 +6006,67 @@ pub(in crate::agent) fn game_creation_app_task_status_label( .unwrap_or_else(|| "unknown".to_string()) } +fn autonomous_manifest_completed_code_static_smoke_gap_at( + root: &Path, + contract: &AgentRuntimeAutonomousCompletionContract, + task_id: &str, +) -> Result, String> { + if task_id != "code-prototype" { + return Ok(None); + } + let child_run_id = autonomous_manifest_ready_task_run_id(&contract.run_id, task_id); + let Some(task) = + read_latest_game_creator_agent_runtime_task_by_run_id(root, task_id, &child_run_id)? + else { + return Ok(None); + }; + let invalid = |detail: &str| { + Some(AutonomousManifestArtifactGap::new(format!( + "{task_id}(verification-invalid:{detail})" + ))) + }; + if task.status != "completed" + || task.phase != "completed" + || task.source != "agent-ready-task-scheduler" + || task.run_profile != AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + || task.parent_agent_id.as_deref() != Some(contract.agent_id.as_str()) + || task.parent_run_id.as_deref() != Some(contract.run_id.as_str()) + || task.delegation_id.is_some() + { + return Ok(invalid("terminal-child-identity")); + } + let Some(binding) = + read_game_creator_agent_runtime_run_profile_binding(root, task_id, &child_run_id)? + else { + return Ok(invalid("missing-run-profile-binding")); + }; + if binding.agent_id != task.agent_id + || binding.run_id != task.run_id + || binding.source != task.source + || binding.profile != task.run_profile + || binding.binding_fingerprint != task.run_profile_binding_fingerprint + || binding.root_agent_id != contract.agent_id + || binding.root_run_id != contract.run_id + || binding.parent_agent_id.as_deref() != Some(contract.agent_id.as_str()) + || binding.parent_run_id.as_deref() != Some(contract.run_id.as_str()) + || binding.parent_binding_fingerprint.as_deref() + != Some(contract.run_profile_binding_fingerprint.as_str()) + { + return Ok(invalid("run-profile-binding")); + } + let gate = read_game_creator_agent_runtime_verification_gate(root, task_id, &child_run_id)?; + let Some(mutation_revision) = gate.mutation_revision.filter(|value| *value > 0) else { + return Ok(invalid("missing-code-mutation")); + }; + if !gate + .static_smoke_verified_revision + .is_some_and(|verified_revision| verified_revision >= mutation_revision) + { + return Ok(invalid("code-static-smoke")); + } + Ok(None) +} + fn autonomous_manifest_parent_completion_gaps_at( root: &Path, contract: &AgentRuntimeAutonomousCompletionContract, @@ -5957,6 +6126,11 @@ fn autonomous_manifest_parent_completion_gaps_at( if !completed { continue; } + if let Some(gap) = + autonomous_manifest_completed_code_static_smoke_gap_at(root, contract, &seed_task.id)? + { + missing_paths.push(gap); + } let mut owner_artifact_gaps = autonomous_manifest_owner_artifact_gaps_at( root, &seed_task.id, @@ -6359,6 +6533,47 @@ fn autonomous_manifest_ready_task_completion_blocker_at_locked( } } if gaps.is_empty() { + if state.agent_id == "code-prototype" + && matches!( + root_source.as_str(), + AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE | AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE + ) + { + let gate = match read_game_creator_agent_runtime_verification_gate( + root, + &state.agent_id, + &state.run_id, + ) { + Ok(gate) => gate, + Err(error) => { + return Some(autonomous_completion_blocker( + "code-prototype 静态验证凭证不可用", + error, + )); + } + }; + let Some(mutation_revision) = gate.mutation_revision.filter(|revision| *revision > 0) + else { + return Some(autonomous_completion_blocker( + "code-prototype 尚未形成本人 run 的项目修改", + "完整 GUI/CLI autonomous DAG 的 code-prototype 必须先实际修改可玩入口,再由本人执行 game.static_smoke。", + )); + }; + if !gate + .static_smoke_verified_revision + .is_some_and(|revision| revision >= mutation_revision) + { + return Some(autonomous_completion_blocker( + "code-prototype 尚未通过覆盖本人最后一次修改的 game.static_smoke", + format!( + "mutationRevision={mutation_revision}, staticSmokeVerifiedRevision={};project.verify 或其它 Agent 的验证凭证不能替代本人 run 的 game.static_smoke。", + gate.static_smoke_verified_revision + .map(|revision| revision.to_string()) + .unwrap_or_else(|| "none".to_string()) + ), + )); + } + } return None; } let next_required_action = autonomous_manifest_artifact_next_required_action(&gaps) @@ -6742,6 +6957,10 @@ pub(in crate::agent) fn autonomous_playtest_receipt_fingerprint( "agentId": receipt.agent_id, "runId": receipt.run_id, "runProfileBindingFingerprint": receipt.run_profile_binding_fingerprint, + "executorAgentId": receipt.executor_agent_id, + "executorRunId": receipt.executor_run_id, + "executorSource": receipt.executor_source, + "executorRunProfileBindingFingerprint": receipt.executor_run_profile_binding_fingerprint, "actionId": receipt.action_id, "actionFingerprint": receipt.action_fingerprint, "revision": receipt.revision, @@ -6896,7 +7115,7 @@ pub(in crate::agent) fn validate_autonomous_completion_contract( || binding.parent_run_id.is_some() || binding.root_agent_id != contract.agent_id || binding.root_run_id != contract.run_id - || !agent_runtime_supervisor_source_is_trusted(&binding.source) + || !agent_runtime_supervisor_source_is_autonomous_game_build(&binding.source) { return Err("自主构建完成合同与根 Supervisor Run 不匹配".to_string()); } @@ -7023,7 +7242,7 @@ fn failed_terminal_autonomous_root_contract_before_task_at( root: &Path, task: &AgentRuntimeTaskRecord, ) -> Result, String> { - if !agent_runtime_supervisor_source_is_trusted(&task.source) + if !agent_runtime_supervisor_source_is_autonomous_game_build(&task.source) || !is_pure_autonomous_continuation_intent(&task.task) { return Ok(None); @@ -7040,7 +7259,7 @@ fn failed_terminal_autonomous_root_contract_before_task_at( && record.run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD && record.parent_agent_id.is_none() && record.parent_run_id.is_none() - && agent_runtime_supervisor_source_is_trusted(&record.source) + && agent_runtime_supervisor_source_is_autonomous_game_build(&record.source) && seen_run_ids.insert(record.run_id.clone()) { ordered_run_ids.push(record.run_id.clone()); @@ -7198,7 +7417,7 @@ fn reset_cancelled_reconciliation_manifest_tasks_for_continuation_at( .into_iter() .map(|task| task.id) .collect::>(); - let _lock = acquire_project_write_lock( + let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( root, "runtime.autonomous.manifest.reconciliation_cancel_retry", )?; @@ -14496,7 +14715,7 @@ pub(in crate::agent) fn ensure_autonomous_completion_contract_for_task_at( return Ok(()); } if task.agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID - || !agent_runtime_supervisor_source_is_trusted(&task.source) + || !agent_runtime_supervisor_source_is_autonomous_game_build(&task.source) { return Err("自主构建完成合同只允许可信根 Supervisor Run".to_string()); } @@ -14603,7 +14822,10 @@ fn reset_autonomous_manifest_seed_tasks_at( root: &Path, task: &AgentRuntimeTaskRecord, ) -> Result<(), String> { - let _lock = acquire_project_write_lock(root, "runtime.autonomous.manifest.reset")?; + let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "runtime.autonomous.manifest.reset", + )?; let (manifest_path, mut manifest) = read_or_create_manifest(root)?; ensure_manifest_seed_tasks(root, &mut manifest); let seed_task_ids = new_game_creation_app_seed_tasks() @@ -14672,6 +14894,196 @@ pub(in crate::agent) fn validate_autonomous_evidence_digest( Ok(()) } +fn autonomous_playtest_executor_agent_id_for_root_source( + source: &str, +) -> Result<&'static str, String> { + match source { + AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE | AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE => { + Ok("preview-playtest") + } + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE => Ok("code-prototype"), + _ => Err("自主试玩根 Run 来源不受信任".to_string()), + } +} + +fn validate_autonomous_playtest_executor_state_lineage_at( + root: &Path, + state: &AgentRuntimeState, +) -> Result<(AgentRuntimeRunProfileBinding, AgentRuntimeRunProfileBinding), String> { + if state.run_profile != AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD { + return Err("自主试玩执行 Run Profile 无效".to_string()); + } + ensure_current_autonomous_ready_child_mutation_at_locked(root, &state.agent_id, &state.run_id) + .map_err(|error| format!("自主试玩执行 child 身份不可用:{error}"))?; + let binding = + read_game_creator_agent_runtime_run_profile_binding(root, &state.agent_id, &state.run_id)? + .ok_or_else(|| "自主试玩执行 child 缺少 Run Profile 绑定".to_string())?; + let root_binding = read_game_creator_agent_runtime_run_profile_binding( + root, + &binding.root_agent_id, + &binding.root_run_id, + )? + .ok_or_else(|| "自主试玩执行 child 缺少根 Supervisor Run Profile 绑定".to_string())?; + let expected_agent_id = + autonomous_playtest_executor_agent_id_for_root_source(&root_binding.source)?; + let expected_run_id = + autonomous_manifest_ready_task_run_id(&root_binding.run_id, expected_agent_id); + if root_binding.agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID + || root_binding.root_agent_id != root_binding.agent_id + || root_binding.root_run_id != root_binding.run_id + || root_binding.parent_agent_id.is_some() + || root_binding.parent_run_id.is_some() + || root_binding.profile != AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + || binding.agent_id != state.agent_id + || binding.run_id != state.run_id + || binding.binding_fingerprint != state.run_profile_binding_fingerprint + || binding.source != "agent-ready-task-scheduler" + || binding.profile != AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + || binding.root_agent_id != root_binding.agent_id + || binding.root_run_id != root_binding.run_id + || binding.parent_agent_id.as_deref() != Some(root_binding.agent_id.as_str()) + || binding.parent_run_id.as_deref() != Some(root_binding.run_id.as_str()) + || binding.parent_binding_fingerprint.as_deref() + != Some(root_binding.binding_fingerprint.as_str()) + || state.agent_id != expected_agent_id + || state.run_id != expected_run_id + || state.source != binding.source + || state.parent_agent_id != binding.parent_agent_id + || state.parent_run_id != binding.parent_run_id + || state.delegation_id.is_some() + { + return Err(format!( + "自主试玩必须由当前根 Run 的确定性 {expected_agent_id} child 独立执行" + )); + } + Ok((binding, root_binding)) +} + +fn validate_autonomous_playtest_executor_receipt_identity_at( + root: &Path, + contract: &AgentRuntimeAutonomousCompletionContract, + receipt: &AgentRuntimeAutonomousPlaytestReceipt, +) -> Result<(), String> { + let root_binding = read_game_creator_agent_runtime_run_profile_binding( + root, + &contract.agent_id, + &contract.run_id, + )? + .ok_or_else(|| "自主试玩回执缺少根 Supervisor Run Profile 绑定".to_string())?; + let expected_agent_id = + autonomous_playtest_executor_agent_id_for_root_source(&root_binding.source)?; + let expected_run_id = + autonomous_manifest_ready_task_run_id(&root_binding.run_id, expected_agent_id); + if root_binding.project_id != contract.project_id + || root_binding.agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID + || root_binding.run_id != contract.run_id + || root_binding.root_agent_id != root_binding.agent_id + || root_binding.root_run_id != root_binding.run_id + || root_binding.parent_agent_id.is_some() + || root_binding.parent_run_id.is_some() + || root_binding.profile != AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + || root_binding.binding_fingerprint != contract.run_profile_binding_fingerprint + || receipt.executor_agent_id != expected_agent_id + || receipt.executor_run_id != expected_run_id + || receipt.executor_source != "agent-ready-task-scheduler" + || !is_lowercase_sha256(&receipt.executor_run_profile_binding_fingerprint) + { + return Err("自主试玩回执的执行 child 身份无效".to_string()); + } + let root_task = read_latest_game_creator_agent_runtime_task_by_run_id( + root, + &root_binding.agent_id, + &root_binding.run_id, + )? + .ok_or_else(|| "自主试玩回执缺少根 Supervisor durable task journal".to_string())?; + let current_root = current_autonomous_game_build_root_task_at(root)? + .ok_or_else(|| "自主试玩回执对应的当前根 Supervisor Run 已不存在".to_string())?; + if root_task.agent_id != root_binding.agent_id + || root_task.run_id != root_binding.run_id + || root_task.source != root_binding.source + || root_task.run_profile != root_binding.profile + || root_task.run_profile_binding_fingerprint != root_binding.binding_fingerprint + || root_task.parent_agent_id.is_some() + || root_task.parent_run_id.is_some() + || root_task.delegation_id.is_some() + || current_root.run_id != root_task.run_id + || current_root.source != root_task.source + || current_root.run_profile_binding_fingerprint != root_task.run_profile_binding_fingerprint + || !autonomous_game_build_root_task_is_active(¤t_root) + { + return Err("自主试玩回执与当前根 Supervisor durable identity 不匹配".to_string()); + } + let binding = read_game_creator_agent_runtime_run_profile_binding( + root, + &receipt.executor_agent_id, + &receipt.executor_run_id, + )? + .ok_or_else(|| "自主试玩回执缺少执行 child Run Profile 绑定".to_string())?; + if binding.project_id != contract.project_id + || binding.agent_id != receipt.executor_agent_id + || binding.run_id != receipt.executor_run_id + || binding.source != receipt.executor_source + || binding.profile != AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + || binding.binding_fingerprint != receipt.executor_run_profile_binding_fingerprint + || binding.root_agent_id != root_binding.agent_id + || binding.root_run_id != root_binding.run_id + || binding.parent_agent_id.as_deref() != Some(root_binding.agent_id.as_str()) + || binding.parent_run_id.as_deref() != Some(root_binding.run_id.as_str()) + || binding.parent_binding_fingerprint.as_deref() + != Some(root_binding.binding_fingerprint.as_str()) + { + return Err("自主试玩回执与执行 child Run Profile 绑定不匹配".to_string()); + } + let task = read_latest_game_creator_agent_runtime_task_by_run_id( + root, + &receipt.executor_agent_id, + &receipt.executor_run_id, + )? + .ok_or_else(|| "自主试玩回执缺少执行 child durable task journal".to_string())?; + if task.agent_id != binding.agent_id + || task.task_id != expected_agent_id + || task.run_id != binding.run_id + || task.source != binding.source + || task.run_profile != binding.profile + || task.run_profile_binding_fingerprint != binding.binding_fingerprint + || task.parent_agent_id != binding.parent_agent_id + || task.parent_run_id != binding.parent_run_id + || task.delegation_id.is_some() + || matches!( + game_creator_agent_runtime_terminal_status(&task), + Some("failed" | "cancelled" | "budget-exhausted") + ) + { + return Err("自主试玩回执与执行 child durable task journal 不匹配".to_string()); + } + Ok(()) +} + +pub(in crate::agent) fn autonomous_playtest_execution_contract_for_state_at( + root: &Path, + state: &AgentRuntimeState, +) -> Result, String> { + if state.run_profile != AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD { + return Ok(None); + } + let (binding, root_binding) = + validate_autonomous_playtest_executor_state_lineage_at(root, state)?; + let contract = + read_autonomous_completion_contract(root, &root_binding.agent_id, &root_binding.run_id)? + .ok_or_else(|| "自主试玩执行 child 缺少根 Supervisor 完成合同".to_string())?; + let contract = migrate_legacy_tetris_completion_contract_for_run_at(root, contract)?; + if contract.project_id != root_binding.project_id + || contract.agent_id != root_binding.agent_id + || contract.run_id != root_binding.run_id + || contract.run_profile_binding_fingerprint != root_binding.binding_fingerprint + || binding.parent_binding_fingerprint.as_deref() + != Some(contract.run_profile_binding_fingerprint.as_str()) + { + return Err("自主试玩执行 child 与根 Supervisor 完成合同不匹配".to_string()); + } + Ok(Some(contract)) +} + fn validate_autonomous_playtest_receipt_integrity( root: &Path, contract: &AgentRuntimeAutonomousCompletionContract, @@ -14696,11 +15108,12 @@ fn validate_autonomous_playtest_receipt_integrity( { return Err("自主试玩回执身份无效".to_string()); } + validate_autonomous_playtest_executor_receipt_identity_at(root, contract, receipt)?; validate_autonomous_evidence_digest(&receipt.game_index, Some(AGENT_RUNTIME_GAME_INDEX_PATH))?; let evidence_prefix = format!( ".agent/runtime/browser-validations/{}/{}/{}/", - agent_runtime_confirmation_path_component(&receipt.agent_id, "agent"), - agent_runtime_confirmation_path_component(&receipt.run_id, "run"), + agent_runtime_confirmation_path_component(&receipt.executor_agent_id, "agent"), + agent_runtime_confirmation_path_component(&receipt.executor_run_id, "run"), receipt.revision ); validate_autonomous_evidence_digest( @@ -14769,6 +15182,9 @@ pub(in crate::agent) fn read_autonomous_playtest_receipt( "自主试玩回执", )?; if let Some(receipt) = receipt.as_ref() { + if receipt.schema_version == "game-creator-autonomous-playtest-receipt.v1" { + return Ok(None); + } if receipt.playtest_scenario != contract.playtest_scenario { return Ok(None); } @@ -14846,11 +15262,21 @@ pub(in crate::agent) fn verify_autonomous_playtest_evidence_files_at( pub(in crate::agent) fn write_autonomous_playtest_receipt_at( root: &Path, contract: &AgentRuntimeAutonomousCompletionContract, + executor_state: &AgentRuntimeState, action_id: &str, action_fingerprint: &str, revision: u64, result: &BrowserValidationResult, ) -> Result { + let (executor_binding, root_binding) = + validate_autonomous_playtest_executor_state_lineage_at(root, executor_state)?; + if contract.project_id != root_binding.project_id + || contract.agent_id != root_binding.agent_id + || contract.run_id != root_binding.run_id + || contract.run_profile_binding_fingerprint != root_binding.binding_fingerprint + { + return Err("自主试玩执行 child 与回执完成合同不匹配".to_string()); + } if revision <= contract.baseline_revision || !is_valid_agent_runtime_action_id(action_id) || !is_valid_agent_runtime_action_fingerprint(action_fingerprint) @@ -14932,6 +15358,10 @@ pub(in crate::agent) fn write_autonomous_playtest_receipt_at( agent_id: contract.agent_id.clone(), run_id: contract.run_id.clone(), run_profile_binding_fingerprint: contract.run_profile_binding_fingerprint.clone(), + executor_agent_id: executor_binding.agent_id.clone(), + executor_run_id: executor_binding.run_id.clone(), + executor_source: executor_binding.source.clone(), + executor_run_profile_binding_fingerprint: executor_binding.binding_fingerprint.clone(), action_id: action_id.to_string(), action_fingerprint: action_fingerprint.to_string(), revision, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion_contract_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion_contract_tests.rs index 374c8e7f4..bda8a298f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion_contract_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion_contract_tests.rs @@ -56,7 +56,7 @@ fn autonomous_fixture_with_source( } #[test] -fn autonomous_supervisor_source_allowlist_includes_game_chat_only() { +fn autonomous_supervisor_source_allowlist_includes_game_chat_and_plan() { assert!(agent_runtime_supervisor_source_is_trusted( AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE )); @@ -66,9 +66,453 @@ fn autonomous_supervisor_source_allowlist_includes_game_chat_only() { assert!(agent_runtime_supervisor_source_is_trusted( AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE )); + assert!(agent_runtime_supervisor_source_is_trusted( + AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE + )); assert!(!agent_runtime_supervisor_source_is_trusted( "project-supervisor-forged" )); + assert!(!agent_runtime_supervisor_source_is_trusted( + "project-supervisor-plan-chat" + )); +} + +#[test] +fn plan_source_rejects_autonomous_profile_and_accepts_standard() { + reject_supervisor_plan_autonomous_profile( + AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE, + AGENT_RUNTIME_RUN_PROFILE_STANDARD, + ) + .expect("plan + standard 应放行"); + let error = reject_supervisor_plan_autonomous_profile( + AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE, + AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD, + ) + .expect_err("plan + autonomous 应拒绝"); + assert!( + error.contains(AGENT_RUNTIME_PLAN_AUTONOMOUS_PROFILE_UNSUPPORTED_KIND), + "{error}" + ); + reject_supervisor_plan_autonomous_profile( + AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, + AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD, + ) + .expect("gui + autonomous 不受 plan 组合门影响"); +} + +#[test] +fn plan_root_steer_is_rejected_inside_and_outside_trusted_matcher() { + assert!(agent_runtime_supervisor_source_is_trusted( + AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE + )); + let in_matcher = reject_supervisor_plan_root_steer(AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE) + .expect_err("plan 在 matcher 内也必须拒绝 steer"); + assert!( + in_matcher.contains(AGENT_RUNTIME_PLAN_ROOT_STEER_UNSUPPORTED_KIND), + "{in_matcher}" + ); + + assert!(!agent_runtime_supervisor_source_is_trusted( + "project-supervisor-plan-chat" + )); + reject_supervisor_plan_root_steer("project-supervisor-plan-chat") + .expect("已作废的 plan-chat 字面不是现行 plan source,独立否决不得误伤"); + reject_supervisor_plan_root_steer(AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE) + .expect("gui 不受 plan steer 独立否决"); + reject_supervisor_plan_root_steer("project-supervisor-forged") + .expect("不可信非 plan source 不走 plan steer 错误,留给 trusted matcher"); +} + +#[test] +fn plan_supervisor_start_rejects_autonomous_and_allows_standard() { + let temporary = crate::tests::canonical_test_tempdir("plan-source-start-"); + let root = temporary.path().join("project"); + init_local_game_project_at(&root, "plan-source-start", "立项策划启动门").expect("init"); + let _runtime_lock = try_acquire_game_creator_agent_runtime_task_lock( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + ) + .expect("lock") + .expect("lock available"); + + let autonomous_error = start_game_creator_supervisor_background_task_for_session_at( + &root, + None, + "做一份 Fast GDD", + "plan-autonomous-forbidden", + AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE, + AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD, + ) + .expect_err("plan + autonomous 启动必须拒绝"); + assert!( + autonomous_error.contains(AGENT_RUNTIME_PLAN_AUTONOMOUS_PROFILE_UNSUPPORTED_KIND), + "{autonomous_error}" + ); + + let started = start_game_creator_supervisor_background_task_for_session_at( + &root, + None, + "做一份 Fast GDD", + "plan-standard-allowed", + AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE, + AGENT_RUNTIME_RUN_PROFILE_STANDARD, + ) + .expect("plan + standard 应能启动"); + assert_eq!( + started.accepted_run_id.as_deref(), + Some("plan-standard-allowed") + ); + let queued = read_latest_game_creator_agent_runtime_task_by_run_id( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "plan-standard-allowed", + ) + .expect("read queued plan task") + .expect("queued plan task exists"); + assert_eq!(queued.source, AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE); + assert_eq!(queued.run_profile, AGENT_RUNTIME_RUN_PROFILE_STANDARD); + + let steer_error = steer_game_creator_agent_runtime_task_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &queued.session_id, + "plan-standard-allowed", + "steer-plan-forbidden", + "改成另一套玩法", + "test", + ) + .expect_err("plan 根 run 的 steer 必须拒绝"); + assert!( + steer_error.contains(AGENT_RUNTIME_PLAN_ROOT_STEER_UNSUPPORTED_KIND), + "{steer_error}" + ); +} + +fn failed_supervisor_task( + run_id: &str, + source: &str, + run_profile: &str, + binding_fingerprint: &str, + session_id: &str, +) -> AgentRuntimeTaskRecord { + AgentRuntimeTaskRecord { + goal_id: None, + goal_revision: 0, + goal_status: None, + schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(), + agent_id: GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(), + task_id: GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(), + session_id: session_id.to_string(), + run_id: run_id.to_string(), + source: source.to_string(), + run_profile: run_profile.to_string(), + run_profile_binding_fingerprint: binding_fingerprint.to_string(), + parent_agent_id: None, + parent_run_id: None, + delegation_id: None, + task: "做一份 Fast GDD".to_string(), + status: "failed".to_string(), + phase: "failed".to_string(), + current_action: "测试失败".to_string(), + terminal_detail: Some("测试失败".to_string()), + error: Some("测试失败".to_string()), + updated_at: unix_timestamp(), + } +} + +fn plan_goal_contract_draft() -> AgentRuntimeGoalContractDraft { + AgentRuntimeGoalContractDraft { + outcome: "完成一份可审批的 Fast GDD".to_string(), + non_negotiables: vec!["保留用户明确要求".to_string()], + preferences: Vec::new(), + forbidden_assumptions: vec!["不能把工具成功当作目标完成".to_string()], + open_questions: vec!["最终视觉效果仍需观察".to_string()], + 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(), + }], + } +} + +#[test] +fn plan_root_identity_requires_durable_binding_not_runtime_source() { + let temporary = crate::tests::canonical_test_tempdir("plan-root-identity-"); + let root = temporary.path().join("project"); + init_local_game_project_at(&root, "plan-root-identity", "强判据").expect("init"); + let session_id = resolve_agent_conversation_session_id_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + None, + true, + ) + .expect("session"); + + let runtime_only = failed_supervisor_task( + "plan-runtime-only", + AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE, + AGENT_RUNTIME_RUN_PROFILE_STANDARD, + "not-a-binding-fingerprint", + &session_id, + ); + assert!( + !supervisor_plan_root_identity_holds_at(&root, &runtime_only).expect("identity"), + "只有 source 字符串不得授予 plan 根身份" + ); + + let binding = bind_game_creator_agent_runtime_run_profile_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "plan-identity-ok", + AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_STANDARD), + None, + ) + .expect("bind plan root"); + let held = failed_supervisor_task( + "plan-identity-ok", + AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE, + AGENT_RUNTIME_RUN_PROFILE_STANDARD, + &binding.binding_fingerprint, + &session_id, + ); + assert!(supervisor_plan_root_identity_holds_at(&root, &held).expect("held identity")); +} + +#[test] +fn plan_root_retry_rejects_identity_mismatch_instead_of_degrading() { + let temporary = crate::tests::canonical_test_tempdir("plan-root-retry-reject-"); + let root = temporary.path().join("project"); + init_local_game_project_at(&root, "plan-root-retry-reject", "重试拒降级").expect("init"); + let session_id = resolve_agent_conversation_session_id_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + None, + true, + ) + .expect("session"); + + let missing_binding = failed_supervisor_task( + "plan-retry-missing-binding", + AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE, + AGENT_RUNTIME_RUN_PROFILE_STANDARD, + "", + &session_id, + ); + let missing_error = + resolve_game_creator_agent_runtime_retry_configuration_at(&root, &missing_binding, false) + .expect_err("缺少 binding 必须拒绝而不是降级"); + assert!( + missing_error.contains(AGENT_RUNTIME_PLAN_ROOT_RETRY_IDENTITY_UNSUPPORTED_KIND), + "{missing_error}" + ); + + let binding = bind_game_creator_agent_runtime_run_profile_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "plan-retry-drift", + AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_STANDARD), + None, + ) + .expect("bind"); + let drifted = failed_supervisor_task( + "plan-retry-drift", + AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE, + AGENT_RUNTIME_RUN_PROFILE_STANDARD, + "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef", + &session_id, + ); + let drift_error = + resolve_game_creator_agent_runtime_retry_configuration_at(&root, &drifted, false) + .expect_err("指纹漂移必须拒绝而不是降级"); + assert!( + drift_error.contains(AGENT_RUNTIME_PLAN_ROOT_RETRY_IDENTITY_UNSUPPORTED_KIND) + || drift_error.contains("与持久绑定不一致"), + "{drift_error}" + ); + + let mut with_parent = failed_supervisor_task( + "plan-retry-drift", + AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE, + AGENT_RUNTIME_RUN_PROFILE_STANDARD, + &binding.binding_fingerprint, + &session_id, + ); + with_parent.parent_agent_id = Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string()); + with_parent.parent_run_id = Some("not-a-root".to_string()); + let parent_error = + resolve_game_creator_agent_runtime_retry_configuration_at(&root, &with_parent, false) + .expect_err("带 parent 的 plan 根候选必须拒绝"); + assert!( + parent_error.contains(AGENT_RUNTIME_PLAN_ROOT_RETRY_IDENTITY_UNSUPPORTED_KIND), + "{parent_error}" + ); + + bind_game_creator_agent_runtime_run_profile_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "plan-retry-source-mismatch", + AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_STANDARD), + None, + ) + .expect("bind gui"); + let mismatched = failed_supervisor_task( + "plan-retry-source-mismatch", + AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE, + AGENT_RUNTIME_RUN_PROFILE_STANDARD, + "", + &session_id, + ); + let mismatch_error = + resolve_game_creator_agent_runtime_retry_configuration_at(&root, &mismatched, false) + .expect_err("task.source 与 binding.source 不一致必须拒绝"); + assert!( + mismatch_error.contains(AGENT_RUNTIME_PLAN_ROOT_RETRY_IDENTITY_UNSUPPORTED_KIND), + "{mismatch_error}" + ); +} + +#[test] +fn plan_root_retry_keeps_plan_source_and_goal_contract_authority() { + let temporary = crate::tests::canonical_test_tempdir("plan-root-retry-keep-"); + let root = temporary.path().join("project"); + init_local_game_project_at(&root, "plan-root-retry-keep", "重试保源").expect("init"); + let session_id = resolve_agent_conversation_session_id_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + None, + true, + ) + .expect("session"); + let original_run_id = "plan-failed-original"; + let binding = bind_game_creator_agent_runtime_run_profile_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + original_run_id, + AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_STANDARD), + None, + ) + .expect("bind plan root"); + let failed = failed_supervisor_task( + original_run_id, + AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE, + AGENT_RUNTIME_RUN_PROFILE_STANDARD, + &binding.binding_fingerprint, + &session_id, + ); + let (profile, source) = + resolve_game_creator_agent_runtime_retry_configuration_at(&root, &failed, false) + .expect("plan 根 retry 应保源"); + assert_eq!(profile, AGENT_RUNTIME_RUN_PROFILE_STANDARD); + assert_eq!(source, AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE); + + append_game_creator_agent_runtime_task_record(&root, &failed).expect("append failed plan task"); + let _runtime_lock = try_acquire_game_creator_agent_runtime_task_lock( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + ) + .expect("lock") + .expect("lock available"); + let retried = retry_game_creator_agent_runtime_task_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + original_run_id, + "plan-failed-original-retry", + ) + .expect("retry plan root"); + let retry_run_id = retried + .accepted_run_id + .as_deref() + .expect("retry accepted run"); + let queued = read_latest_game_creator_agent_runtime_task_by_run_id( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + retry_run_id, + ) + .expect("read retry task") + .expect("retry task exists"); + assert_eq!(queued.source, AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE); + assert_eq!(queued.run_profile, AGENT_RUNTIME_RUN_PROFILE_STANDARD); + let retry_binding = read_game_creator_agent_runtime_run_profile_binding( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + retry_run_id, + ) + .expect("read retry binding") + .expect("retry binding exists"); + assert_eq!(retry_binding.source, AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE); + assert_eq!(retry_binding.profile, AGENT_RUNTIME_RUN_PROFILE_STANDARD); + assert!(retry_binding.parent_agent_id.is_none()); + assert!(agent_runtime_supervisor_source_is_trusted( + &retry_binding.source + )); + + let steer_error = steer_game_creator_agent_runtime_task_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &queued.session_id, + retry_run_id, + "steer-after-retry", + "改成另一套玩法", + "test", + ) + .expect_err("重试后 steer 仍须拒绝"); + assert!( + steer_error.contains(AGENT_RUNTIME_PLAN_ROOT_STEER_UNSUPPORTED_KIND), + "{steer_error}" + ); + + create_game_creator_agent_runtime_goal_contract_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + retry_run_id, + &queued.task, + &plan_goal_contract_draft(), + ) + .expect("重试后仍能创建 Goal Contract"); +} + +#[test] +fn gui_and_delegate_retry_sources_stay_on_existing_fallback() { + let temporary = crate::tests::canonical_test_tempdir("gui-retry-fallback-"); + let root = temporary.path().join("project"); + init_local_game_project_at(&root, "gui-retry-fallback", "对照兜底").expect("init"); + let session_id = resolve_agent_conversation_session_id_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + None, + true, + ) + .expect("session"); + let gui_binding = bind_game_creator_agent_runtime_run_profile_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "gui-standard-retry", + AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_STANDARD), + None, + ) + .expect("bind gui"); + let gui_task = failed_supervisor_task( + "gui-standard-retry", + AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, + AGENT_RUNTIME_RUN_PROFILE_STANDARD, + &gui_binding.binding_fingerprint, + &session_id, + ); + let (_, gui_source) = + resolve_game_creator_agent_runtime_retry_configuration_at(&root, &gui_task, false) + .expect("gui retry"); + assert_eq!(gui_source, "agent-background-task"); + let (_, delegated_source) = + resolve_game_creator_agent_runtime_retry_configuration_at(&root, &gui_task, true) + .expect("delegated retry"); + assert_eq!(delegated_source, "agent-delegate-retry"); } #[test] @@ -487,6 +931,24 @@ fn complete_and_claim_game_chat_art_delivery( .expect("read delegated art child") .expect("delegated art child exists"); let mut completed = agent_runtime_state_from_task_record(&child); + let revision = read_game_creator_agent_runtime_project_revision(root) + .expect("read revision for Canvas delivery fixture") + .revision; + assert!( + revision > 0, + "Canvas delivery fixture requires a project revision" + ); + let mut gate = + read_game_creator_agent_runtime_verification_gate(root, target_agent_id, &completed.run_id) + .expect("read Canvas delivery verification gate"); + gate.requires_verification = true; + gate.mutation_revision = Some(revision); + gate.verified_revision = Some(revision); + gate.last_mutation_tool = Some("canvas.asset_generate".to_string()); + gate.last_verification_tool = Some("canvas.asset_generate".to_string()); + gate.last_verification_status = Some(AGENT_RUNTIME_VERIFICATION_STATUS_PASSED.to_string()); + write_game_creator_agent_runtime_verification_gate(root, &gate) + .expect("persist Canvas delivery verification gate"); completed.status = "completed".to_string(); completed.phase = "completed".to_string(); completed.current_action = "已补齐受委派美术".to_string(); @@ -519,10 +981,9 @@ fn persist_game_chat_main_playtest_receipt( read_autonomous_completion_contract(root, &parent_state.agent_id, &parent_state.run_id) .expect("read game-chat root completion contract") .expect("game-chat root completion contract exists"); - // The durable receipt belongs to the root completion contract even though - // code-prototype performed the validation. Keep its evidence under the - // root identity so receipt integrity can bind it to that contract. - let result = browser_result_fixture(root, parent_state, revision, contract.playtest_scenario); + // The durable receipt is indexed by the root completion contract, while + // its evidence and executor fields remain bound to the single main child. + let result = browser_result_fixture(root, main_state, revision, contract.playtest_scenario); let action = AgentRuntimeToolAction { tool: "preview.validate".to_string(), reason: Some("game-chat code-prototype desktop/mobile playtest".to_string()), @@ -534,6 +995,7 @@ fn persist_game_chat_main_playtest_receipt( write_autonomous_playtest_receipt_at( root, &contract, + main_state, &action_id, &action_fingerprint, revision, @@ -571,6 +1033,76 @@ fn advance_game_index_revision(root: &Path, state: &AgentRuntimeState, html: &st revision } +fn advance_owner_artifact_revision( + root: &Path, + state: &AgentRuntimeState, + path: &str, + content: &str, +) -> u64 { + let latest = + read_latest_game_creator_agent_runtime_task_by_run_id(root, &state.agent_id, &state.run_id) + .expect("read owner run before project mutation") + .expect("owner run exists before project mutation"); + if latest.status != "running" { + let mut running = state.clone(); + running.status = "running".to_string(); + running.phase = "planning".to_string(); + append_game_creator_agent_runtime_task(root, &running) + .expect("append durable running owner run before project mutation"); + } + let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "test.autonomous.owner-artifact.mutate", + ) + .expect("acquire owner artifact mutation lock"); + let revision = prepare_agent_runtime_project_mutation_locked( + root, + &state.agent_id, + &state.run_id, + "file.write", + ) + .expect("advance owner artifact revision"); + write_local_project_file_at(root, path, content).expect("write owner artifact"); + revision +} + +fn start_autonomous_owner_child( + root: &Path, + parent_state: &AgentRuntimeState, + agent_id: &str, +) -> AgentRuntimeState { + update_manifest_task_status_at(root, agent_id, GameCreationAppTaskStatus::Running) + .expect("mark autonomous owner manifest task running"); + let mut state = agent_runtime_state_from_task_record(&queue_autonomous_manifest_child_fixture( + root, + parent_state, + agent_id, + )); + state.status = "running".to_string(); + state.phase = "planning".to_string(); + append_game_creator_agent_runtime_task(root, &state) + .expect("persist running autonomous owner child"); + state +} + +fn start_autonomous_playtest_child( + root: &Path, + parent_state: &AgentRuntimeState, +) -> AgentRuntimeState { + update_manifest_task_status_at(root, "preview-playtest", GameCreationAppTaskStatus::Running) + .expect("mark autonomous preview-playtest manifest task running"); + let mut state = agent_runtime_state_from_task_record(&queue_autonomous_manifest_child_fixture( + root, + parent_state, + "preview-playtest", + )); + state.status = "running".to_string(); + state.phase = "planning".to_string(); + append_game_creator_agent_runtime_task(root, &state) + .expect("persist running autonomous preview-playtest child"); + state +} + fn mark_verification_passed(root: &Path, state: &AgentRuntimeState, tool: &str) { let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( root, @@ -835,9 +1367,10 @@ fn preview_child_migrates_legacy_generic_tetris_contract_and_invalidates_its_rec &parent_state, "", ); + let child_state = start_autonomous_playtest_child(&root, &parent_state); let result = browser_result_fixture( &root, - &parent_state, + &child_state, revision, BrowserPlaytestScenario::GenericV1, ); @@ -847,12 +1380,12 @@ fn preview_child_migrates_legacy_generic_tetris_contract_and_invalidates_its_rec input: serde_json::json!({}), }; let action_fingerprint = - agent_runtime_tool_action_fingerprint(&action, &parent_state.current_task); - let action_id = - agent_runtime_tool_action_id(&parent_state.run_id, 1, 0, 1, &action_fingerprint); + agent_runtime_tool_action_fingerprint(&action, &child_state.current_task); + let action_id = agent_runtime_tool_action_id(&child_state.run_id, 1, 0, 1, &action_fingerprint); write_autonomous_playtest_receipt_at( &root, &legacy_contract, + &child_state, &action_id, &action_fingerprint, revision, @@ -860,9 +1393,6 @@ fn preview_child_migrates_legacy_generic_tetris_contract_and_invalidates_its_rec ) .expect("persist legacy Generic Tetris receipt"); - let child_record = - queue_autonomous_manifest_child_fixture(&root, &parent_state, "preview-readiness"); - let child_state = agent_runtime_state_from_task_record(&child_record); let migrated = autonomous_playtest_completion_contract_for_state_at(&root, &child_state) .expect("recover child playtest contract") .expect("preview child inherits the root contract"); @@ -1621,17 +2151,20 @@ fn inherited_tetris_contract_rejects_a_generic_collection_replacement() { .as_deref() .is_some_and(|detail| detail.contains("tetris-identity"))); - let result = browser_result_fixture(&root, &state, revision, contract.playtest_scenario); + let main_state = start_game_chat_main_agent(&root, &state); + let result = browser_result_fixture(&root, &main_state, revision, contract.playtest_scenario); let action = AgentRuntimeToolAction { tool: "preview.validate".to_string(), reason: Some("negative semantic continuity fixture".to_string()), input: serde_json::json!({}), }; - let action_fingerprint = agent_runtime_tool_action_fingerprint(&action, &state.current_task); - let action_id = agent_runtime_tool_action_id(&state.run_id, 1, 0, 1, &action_fingerprint); + let action_fingerprint = + agent_runtime_tool_action_fingerprint(&action, &main_state.current_task); + let action_id = agent_runtime_tool_action_id(&main_state.run_id, 1, 0, 1, &action_fingerprint); let receipt_error = write_autonomous_playtest_receipt_at( &root, &contract, + &main_state, &action_id, &action_fingerprint, revision, @@ -5400,15 +5933,7 @@ fn autonomous_preview_manifest_tasks_accept_bound_current_revision_receipts() { "做一个完整小游戏", "autonomous-preview-playtest-receipt-parent", ); - update_manifest_task_status_at( - &root, - "preview-playtest", - GameCreationAppTaskStatus::Running, - ) - .expect("mark preview playtest running"); - let playtest_child = - queue_autonomous_manifest_child_fixture(&root, &parent_state, "preview-playtest"); - let playtest_state = agent_runtime_state_from_task_record(&playtest_child); + let playtest_state = start_autonomous_playtest_child(&root, &parent_state); let revision = advance_game_index_revision( &root, &parent_state, @@ -5416,7 +5941,7 @@ fn autonomous_preview_manifest_tasks_accept_bound_current_revision_receipts() { ); let result = browser_result_fixture( &root, - &parent_state, + &playtest_state, revision, BrowserPlaytestScenario::GenericV1, ); @@ -5429,18 +5954,114 @@ fn autonomous_preview_manifest_tasks_accept_bound_current_revision_receipts() { agent_runtime_tool_action_fingerprint(&action, &playtest_state.current_task); let action_id = agent_runtime_tool_action_id(&playtest_state.run_id, 1, 0, 1, &action_fingerprint); - write_autonomous_playtest_receipt_at( + let receipt = write_autonomous_playtest_receipt_at( &root, &contract, + &playtest_state, &action_id, &action_fingerprint, revision, &result, ) .expect("persist child-bound autonomous playtest receipt"); + assert_eq!(receipt.executor_agent_id, "preview-playtest"); + assert_eq!(receipt.executor_run_id, playtest_state.run_id); + assert_eq!(receipt.executor_source, "agent-ready-task-scheduler"); + assert_eq!( + receipt.executor_run_profile_binding_fingerprint, + playtest_state.run_profile_binding_fingerprint + ); + assert!( + receipt.report.path.contains("/preview-playtest/") + || receipt.report.path.contains("/preview-playtest-") + ); + + let mut borrowed_root_receipt = receipt.clone(); + borrowed_root_receipt.executor_agent_id = contract.agent_id.clone(); + borrowed_root_receipt.executor_run_id = contract.run_id.clone(); + borrowed_root_receipt.executor_source = parent_state.source.clone(); + borrowed_root_receipt.executor_run_profile_binding_fingerprint = + contract.run_profile_binding_fingerprint.clone(); + borrowed_root_receipt.receipt_fingerprint = + autonomous_playtest_receipt_fingerprint(&borrowed_root_receipt); + let error = validate_autonomous_playtest_receipt(&root, &contract, &borrowed_root_receipt) + .expect_err( + "root-owned receipt must not replace the deterministic playtest child identity", + ); + assert!( + error.contains("执行 child 身份"), + "unexpected error: {error}" + ); assert!(autonomous_game_build_completion_blocker_at_locked(&root, &playtest_state).is_none()); } +#[test] +fn full_dag_preview_execution_rejects_upstream_owner_and_accepts_only_playtest_child() { + for source in [ + AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, + AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, + ] { + let (_temporary, root, parent_state, expected_contract) = autonomous_fixture_with_source( + "做一个完整小游戏", + &format!("autonomous-preview-owner-{source}"), + source, + ); + let code_state = start_autonomous_owner_child(&root, &parent_state, "code-prototype"); + let error = autonomous_playtest_execution_contract_for_state_at(&root, &code_state) + .expect_err("full DAG code-prototype must not execute preview.validate"); + assert!( + error.contains("确定性 preview-playtest child"), + "unexpected error for {source}: {error}" + ); + assert!( + autonomous_playtest_execution_contract_for_state_at(&root, &parent_state).is_err(), + "the full DAG root must not execute preview.validate for {source}" + ); + + let playtest_state = start_autonomous_playtest_child(&root, &parent_state); + let actual_contract = + autonomous_playtest_execution_contract_for_state_at(&root, &playtest_state) + .expect("validate deterministic preview-playtest child") + .expect("full DAG playtest child inherits completion contract"); + assert_eq!(actual_contract, expected_contract); + + let mut forged_source = playtest_state.clone(); + forged_source.source = "agent-delegate".to_string(); + assert!( + autonomous_playtest_execution_contract_for_state_at(&root, &forged_source).is_err(), + "a forged child source must fail closed for {source}" + ); + let mut forged_parent = playtest_state.clone(); + forged_parent.parent_run_id = Some("forged-preview-parent".to_string()); + assert!( + autonomous_playtest_execution_contract_for_state_at(&root, &forged_parent).is_err(), + "a forged child lineage must fail closed for {source}" + ); + } +} + +#[test] +fn game_chat_preview_execution_keeps_the_single_main_code_prototype_route() { + let (_temporary, root, parent_state, expected_contract) = autonomous_fixture_with_source( + "继续完善当前小游戏", + "game-chat-preview-single-main-owner", + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + ); + let main_state = start_game_chat_main_agent(&root, &parent_state); + let actual_contract = autonomous_playtest_execution_contract_for_state_at(&root, &main_state) + .expect("validate game-chat code-prototype playtest route") + .expect("game-chat main child inherits completion contract"); + assert_eq!(actual_contract, expected_contract); + + let playtest_state = start_autonomous_playtest_child(&root, &parent_state); + let error = autonomous_playtest_execution_contract_for_state_at(&root, &playtest_state) + .expect_err("game-chat must not create a parallel preview-playtest owner"); + assert!( + error.contains("确定性 code-prototype child"), + "unexpected error: {error}" + ); +} + #[test] fn autonomous_completion_rejects_formal_artifact_unchanged_from_run_baseline() { let baseline_bytes = @@ -5585,15 +6206,22 @@ fn autonomous_playtest_receipt_rejects_previous_scenario_fingerprint() { &state, "新游戏", ); - let result = - browser_result_fixture(&root, &state, revision, BrowserPlaytestScenario::GenericV1); + let playtest_state = start_autonomous_playtest_child(&root, &state); + let result = browser_result_fixture( + &root, + &playtest_state, + revision, + BrowserPlaytestScenario::GenericV1, + ); let action = AgentRuntimeToolAction { tool: "preview.validate".to_string(), reason: Some("验证真实可玩闭环".to_string()), input: serde_json::json!({}), }; - let action_fingerprint = agent_runtime_tool_action_fingerprint(&action, &state.current_task); - let action_id = agent_runtime_tool_action_id(&state.run_id, 1, 0, 1, &action_fingerprint); + let action_fingerprint = + agent_runtime_tool_action_fingerprint(&action, &playtest_state.current_task); + let action_id = + agent_runtime_tool_action_id(&playtest_state.run_id, 1, 0, 1, &action_fingerprint); let mut stale_result = result.clone(); stale_result .playtest @@ -5604,6 +6232,7 @@ fn autonomous_playtest_receipt_rejects_previous_scenario_fingerprint() { write_autonomous_playtest_receipt_at( &root, &contract, + &playtest_state, &action_id, &action_fingerprint, revision, @@ -5614,6 +6243,7 @@ fn autonomous_playtest_receipt_rejects_previous_scenario_fingerprint() { let mut receipt = write_autonomous_playtest_receipt_at( &root, &contract, + &playtest_state, &action_id, &action_fingerprint, revision, @@ -5689,6 +6319,7 @@ fn autonomous_playtest_receipt_requires_passed_desktop_and_mobile_viewports() { write_autonomous_playtest_receipt_at( &root, &contract, + &state, &action_id, &action_fingerprint, revision, @@ -5701,6 +6332,7 @@ fn autonomous_playtest_receipt_requires_passed_desktop_and_mobile_viewports() { write_autonomous_playtest_receipt_at( &root, &contract, + &state, &action_id, &action_fingerprint, revision, @@ -5720,18 +6352,26 @@ fn stale_scenario_receipt_reads_as_missing_and_can_be_replaced() { &state, "可恢复试玩", ); - let result = - browser_result_fixture(&root, &state, revision, BrowserPlaytestScenario::GenericV1); + let playtest_state = start_autonomous_playtest_child(&root, &state); + let result = browser_result_fixture( + &root, + &playtest_state, + revision, + BrowserPlaytestScenario::GenericV1, + ); let action = AgentRuntimeToolAction { tool: "preview.validate".to_string(), reason: Some("验证可恢复试玩回执".to_string()), input: serde_json::json!({}), }; - let action_fingerprint = agent_runtime_tool_action_fingerprint(&action, &state.current_task); - let action_id = agent_runtime_tool_action_id(&state.run_id, 1, 0, 1, &action_fingerprint); + let action_fingerprint = + agent_runtime_tool_action_fingerprint(&action, &playtest_state.current_task); + let action_id = + agent_runtime_tool_action_id(&playtest_state.run_id, 1, 0, 1, &action_fingerprint); let current = write_autonomous_playtest_receipt_at( &root, &contract, + &playtest_state, &action_id, &action_fingerprint, revision, @@ -5739,6 +6379,31 @@ fn stale_scenario_receipt_reads_as_missing_and_can_be_replaced() { ) .expect("persist current autonomous playtest receipt"); + let receipt_path = + autonomous_playtest_receipt_relative_path(&contract.agent_id, &contract.run_id); + let mut legacy_v1 = serde_json::to_value(¤t).expect("serialize legacy v1 fixture"); + let legacy_v1_object = legacy_v1.as_object_mut().expect("legacy v1 receipt object"); + legacy_v1_object.insert( + "schemaVersion".to_string(), + serde_json::Value::String("game-creator-autonomous-playtest-receipt.v1".to_string()), + ); + for field in [ + "executorAgentId", + "executorRunId", + "executorSource", + "executorRunProfileBindingFingerprint", + ] { + legacy_v1_object.remove(field); + } + write_agent_runtime_json_sidecar(&root, &receipt_path, "v1 自主试玩回执 fixture", &legacy_v1) + .expect("persist v1 autonomous playtest receipt fixture"); + assert!( + read_autonomous_playtest_receipt(&root, &contract) + .expect("v1 receipt must be recoverable as missing") + .is_none(), + "v1 receipt without executor identity must require a fresh playtest" + ); + let mut stale = current.clone(); stale.scenario_fingerprint = "b48e3189a0765d82b84b56ce88cff8d05db7d1c0cc218a5d068e6470b83010d3".to_string(); @@ -5758,8 +6423,6 @@ fn stale_scenario_receipt_reads_as_missing_and_can_be_replaced() { "stale scenario receipt must behave like missing evidence" ); - let receipt_path = - autonomous_playtest_receipt_relative_path(&contract.agent_id, &contract.run_id); let mut digest_tampered = stale.clone(); digest_tampered.report.sha256 = "0".repeat(64); write_agent_runtime_json_sidecar( @@ -5799,6 +6462,7 @@ fn stale_scenario_receipt_reads_as_missing_and_can_be_replaced() { let replacement = write_autonomous_playtest_receipt_at( &root, &contract, + &playtest_state, &action_id, &action_fingerprint, revision, @@ -6380,7 +7044,10 @@ fn canvas_visual_gate_resolves_numeric_constants_by_symbol_scope() { &queue_autonomous_manifest_child_fixture(&root, &parent_state, "code-prototype"), ); let html = ""; - advance_game_index_revision(&root, &code_state, html); + // 夹具只为数值常量作用域判据构造,缺完整 static-smoke 合同要的目标说明、 + // 主循环与输入监听;补上这层再落盘,被探测的常量引用形状不变。 + advance_game_index_revision(&root, &code_state, &with_static_smoke_contract(html)); + mark_verification_passed(&root, &code_state, "game.static_smoke"); assert!(autonomous_game_build_completion_blocker_at_locked(&root, &code_state).is_none()); } @@ -6438,6 +7105,7 @@ fn cli_code_prototype_keeps_registered_canvas_spritesheet_gate_when_editor_is_co assert!(autonomous_game_build_completion_blocker_at_locked(&root, &code_state).is_some()); advance_game_index_revision(&root, &code_state, cropped_spritesheet_game_html()); + mark_verification_passed(&root, &code_state, "game.static_smoke"); assert!(autonomous_game_build_completion_blocker_at_locked(&root, &code_state).is_none()); } @@ -6457,8 +7125,9 @@ fn cli_code_prototype_accepts_linked_inline_and_external_modules_for_canvas_atla advance_game_index_revision( &root, &code_state, - "", + &with_static_smoke_contract(""), ); + mark_verification_passed(&root, &code_state, "game.static_smoke"); assert!( autonomous_game_build_completion_blocker_at_locked(&root, &code_state).is_none(), "a reachable inline module atlas crop must satisfy the visual asset gate" @@ -6472,8 +7141,11 @@ fn cli_code_prototype_accepts_linked_inline_and_external_modules_for_canvas_atla advance_game_index_revision( &root, &code_state, - "", + &with_static_smoke_contract( + "", + ), ); + mark_verification_passed(&root, &code_state, "game.static_smoke"); assert!( autonomous_game_build_completion_blocker_at_locked(&root, &code_state).is_none(), "a reachable external module atlas crop must satisfy the visual asset gate" @@ -6698,9 +7370,15 @@ fn current_game_chat_code_child_survives_pending_manifest_drift_and_projects_com code_gate.last_verification_tool.as_deref(), Some("game.static_smoke") ); + let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + &root, + "test.game-chat-root-completion-after-main-terminal", + ) + .expect("wait for main-terminal next-wave scheduling to release the project lock"); + let root_blocker = autonomous_game_build_completion_blocker_at_locked(&root, &parent_state); assert!( - autonomous_game_build_completion_blocker_at_locked(&root, &parent_state).is_none(), - "the root must accept the same main Run's smoke and desktop/mobile playtest evidence" + root_blocker.is_none(), + "the root must accept the same main Run's smoke and desktop/mobile playtest evidence: {root_blocker:?}" ); } @@ -7214,6 +7892,97 @@ fn autonomous_root_creation_waits_for_the_project_write_lock() { assert_eq!(created.run_id, requested_run_id); } +fn assert_autonomous_contract_rebuild_waits_for_project_lock( + root: &Path, + record: &AgentRuntimeTaskRecord, + command_id: &str, +) { + let relative_path = + autonomous_completion_contract_relative_path(&record.agent_id, &record.run_id); + fs::remove_file( + resolve_local_project_path(root, &relative_path) + .expect("resolve autonomous completion contract path"), + ) + .expect("remove autonomous completion contract before deterministic rebuild"); + let project_lock = + acquire_game_creator_agent_runtime_project_write_lock_with_wait(root, command_id) + .expect("hold project lock before rebuilding completion contract"); + let root_for_thread = root.to_path_buf(); + let record_for_thread = record.clone(); + let (started_tx, started_rx) = std::sync::mpsc::channel(); + let (result_tx, result_rx) = std::sync::mpsc::channel(); + let rebuilder = std::thread::spawn(move || { + started_tx + .send(()) + .expect("announce completion contract rebuild attempt"); + let result = + ensure_autonomous_completion_contract_for_task_at(&root_for_thread, &record_for_thread); + result_tx + .send(result) + .expect("return completion contract rebuild result"); + }); + started_rx + .recv_timeout(std::time::Duration::from_secs(1)) + .expect("completion contract rebuilder started"); + std::thread::sleep(std::time::Duration::from_millis(50)); + assert!(matches!( + result_rx.try_recv(), + Err(std::sync::mpsc::TryRecvError::Empty) + )); + drop(project_lock); + result_rx + .recv_timeout(std::time::Duration::from_secs(2)) + .expect("completion contract rebuild finishes after lock release") + .expect("rebuild autonomous completion contract after lock release"); + rebuilder + .join() + .expect("join completion contract rebuilder"); + assert!( + read_autonomous_completion_contract(root, &record.agent_id, &record.run_id) + .expect("read rebuilt autonomous completion contract") + .is_some() + ); +} + +#[test] +fn autonomous_completion_contract_reset_waits_for_incidental_project_write_lock() { + let (_temporary, root, initial_state, _contract) = autonomous_fixture_with_source( + "创建一轮星空收集游戏", + "completion-contract-lock-initial", + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + ); + let initial_record = read_latest_game_creator_agent_runtime_task_by_run_id( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &initial_state.run_id, + ) + .expect("read initial root before contract lock regression") + .expect("initial root exists before contract lock regression"); + assert_autonomous_contract_rebuild_waits_for_project_lock( + &root, + &initial_record, + "test.hold-before-initial-contract-reset", + ); + + append_failed_autonomous_root_projection(&root, &initial_record, "failed"); + let continuation = append_unique_game_creator_agent_runtime_pending_task( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &initial_record.session_id, + "继续", + "completion-contract-lock-continuation", + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + None, + ) + .expect("create continuation before contract lock regression"); + assert_autonomous_contract_rebuild_waits_for_project_lock( + &root, + &continuation, + "test.hold-before-continuation-contract-reset", + ); +} + #[test] fn gui_ready_child_still_rejects_pending_manifest_status() { let (_temporary, root, parent_state, _contract) = @@ -7313,6 +8082,713 @@ fn autonomous_ready_child_missing_or_invalid_owner_artifact_is_blocked() { ); } +#[test] +fn autonomous_owner_artifact_runtime_validation_unblocks_real_new_project_without_smoke() { + let _config_guard = crate::tests::write_test_local_config("{}".to_string()); + let (_temporary, root, parent_state, _contract) = + autonomous_fixture("做一个完整小游戏", "owner-validation-real-project-parent"); + assert!(!root.join("package.json").exists()); + assert!(run_limited_local_command_at(&root, "game.static_smoke") + .expect_err("initial placeholder must fail real game smoke") + .contains("画布")); + + fs::remove_file(root.join("memory/project.md")).expect("remove prepared project memory"); + fs::remove_file(root.join("game/game_design.md")).expect("remove prepared game design"); + update_manifest_task_status_at( + &root, + "design-foundation", + GameCreationAppTaskStatus::Running, + ) + .expect("mark design foundation running"); + update_manifest_task_status_at(&root, "code-prototype", GameCreationAppTaskStatus::Pending) + .expect("keep downstream code prototype pending"); + let record = queue_autonomous_manifest_child_fixture(&root, &parent_state, "design-foundation"); + let mut state = agent_runtime_state_from_task_record(&record); + state.status = "running".to_string(); + state.phase = "planning".to_string(); + append_game_creator_agent_runtime_task(&root, &state) + .expect("persist running design foundation child"); + + // 上面刚断言过占位入口过不了真实 smoke。后面要伪造一次 static-smoke 通过来制造 + // 「过期凭证」,而收口时会按当前磁盘内容复核入口页——占位页必然被拒。先把入口 + // 换成合规页面,再走 owner 产物;verified_revision 仍取最后一次 owner 产物, + // 后续 revision 断言不受影响。 + advance_game_index_revision(&root, &state, cropped_spritesheet_game_html()); + + advance_owner_artifact_revision( + &root, + &state, + "memory/project.md", + "# 项目记忆\n\n核心目标与约束。\n", + ); + let missing = + project_verification_completion_blocker_at(&root, &state.agent_id, &state.run_id, &[]) + .expect("missing second owner artifact must block"); + assert!(missing + .detail + .as_deref() + .is_some_and(|detail| detail.contains("game/game_design.md"))); + + let verified_revision = advance_owner_artifact_revision( + &root, + &state, + "game/game_design.md", + "# 游戏设计\n\n核心循环、胜负条件与双视口交互。\n", + ); + let pending_owner_gate = + read_game_creator_agent_runtime_verification_gate(&root, &state.agent_id, &state.run_id) + .expect("read pending owner verification gate"); + let non_progress_observations = (0..AGENT_RUNTIME_AUTONOMOUS_LIVENESS_OBSERVATION_LIMIT) + .map(|index| AgentRuntimeToolObservation { + tool: "file.read".to_string(), + status: "ok".to_string(), + summary: format!("owner read tail {index}"), + detail: None, + }) + .collect::>(); + validate_agent_runtime_autonomous_plan_liveness_at( + &root, + &state.agent_id, + &state.run_id, + AGENT_RUNTIME_AUTONOMOUS_PRE_MUTATION_LOOP_LIMIT + 1, + verified_revision, + &pending_owner_gate, + &non_progress_observations, + &AgentRuntimeToolPlan { + response: "固定 owner 产物已经完成。".to_string(), + ..AgentRuntimeToolPlan::default() + }, + false, + false, + ) + .expect("owner response must reach Runtime validation after a long read tail"); + mark_verification_passed(&root, &state, "game.static_smoke"); + let obsolete_smoke_gate = + read_game_creator_agent_runtime_verification_gate(&root, &state.agent_id, &state.run_id) + .expect("read obsolete owner smoke credential"); + assert_eq!( + obsolete_smoke_gate.last_verification_tool.as_deref(), + Some("game.static_smoke") + ); + assert_eq!( + obsolete_smoke_gate.static_smoke_verified_revision, + Some(verified_revision) + ); + assert!( + project_verification_completion_blocker_at(&root, &state.agent_id, &state.run_id, &[],) + .is_none() + ); + let gate = + read_game_creator_agent_runtime_verification_gate(&root, &state.agent_id, &state.run_id) + .expect("read Runtime owner artifact verification gate"); + assert_eq!(gate.mutation_revision, Some(verified_revision)); + assert_eq!(gate.verified_revision, Some(verified_revision)); + assert_eq!( + gate.last_verification_tool.as_deref(), + Some(AGENT_RUNTIME_OWNER_ARTIFACT_VERIFICATION_TOOL) + ); + assert_eq!( + gate.last_verification_status.as_deref(), + Some(AGENT_RUNTIME_VERIFICATION_STATUS_PASSED) + ); + assert_eq!(gate.static_smoke_verified_revision, None); + let owner_audits = || { + read_agent_db_records_bounded(&root, AGENT_RUNTIME_ACTION_HISTORY_MAX_DB_BYTES) + .expect("read owner artifact validation audits") + .0 + .into_iter() + .filter(|record| { + record.get("recordType").and_then(serde_json::Value::as_str) + == Some("agent.runtime.owner_artifacts.validated") + && record.get("agentId").and_then(serde_json::Value::as_str) + == Some(state.agent_id.as_str()) + && record.get("runId").and_then(serde_json::Value::as_str) + == Some(state.run_id.as_str()) + && record.get("revision").and_then(serde_json::Value::as_u64) + == Some(verified_revision) + }) + .collect::>() + }; + assert_eq!(owner_audits().len(), 1); + assert!( + project_verification_completion_blocker_at(&root, &state.agent_id, &state.run_id, &[],) + .is_none() + ); + assert_eq!(owner_audits().len(), 1, "owner audit must be idempotent"); + + let mut missing_credential = gate.clone(); + missing_credential.verified_revision = None; + missing_credential.last_verification_tool = None; + missing_credential.last_verification_status = None; + missing_credential.static_smoke_verified_revision = None; + missing_credential.updated_at = unix_timestamp(); + write_game_creator_agent_runtime_verification_gate(&root, &missing_credential) + .expect("remove owner credential while preserving mutation identity"); + assert!( + project_verification_completion_blocker_at(&root, &state.agent_id, &state.run_id, &[],) + .is_none(), + "same active owner run must deterministically recover a missing credential" + ); + let recovered = + read_game_creator_agent_runtime_verification_gate(&root, &state.agent_id, &state.run_id) + .expect("read recovered owner credential"); + assert_eq!( + recovered.last_verification_tool.as_deref(), + Some(AGENT_RUNTIME_OWNER_ARTIFACT_VERIFICATION_TOOL) + ); + assert_eq!(recovered.verified_revision, Some(verified_revision)); + assert_eq!(owner_audits().len(), 1); + let manifest = read_manifest_for_project(&root).expect("read project manifest"); + assert!(manifest + .command_runs + .iter() + .all(|run| run.command_id != "game.static_smoke")); + assert!(!root.join(".agent/runtime/browser-validations").exists()); + assert_eq!( + manifest + .tasks + .iter() + .find(|task| task.id == "code-prototype") + .map(|task| &task.status), + Some(&GameCreationAppTaskStatus::Pending) + ); + + advance_owner_artifact_revision(&root, &state, "game/game_design.md", "# 游戏设计\n\nTODO\n"); + let incomplete = + project_verification_completion_blocker_at(&root, &state.agent_id, &state.run_id, &[]) + .expect("later incomplete mutation must invalidate owner credential"); + assert!(incomplete + .detail + .as_deref() + .is_some_and(|detail| detail.contains("incomplete-marker"))); + let invalidated = + read_game_creator_agent_runtime_verification_gate(&root, &state.agent_id, &state.run_id) + .expect("read invalidated owner artifact gate"); + assert_eq!(invalidated.verified_revision, None); + assert_eq!(invalidated.static_smoke_verified_revision, None); +} + +#[test] +fn autonomous_owner_artifact_validation_recovers_prepared_finalization_without_observations() { + let _config_guard = crate::tests::write_test_local_config("{}".to_string()); + let (_temporary, root, parent_state, _contract) = + autonomous_fixture("做一个完整小游戏", "owner-finalization-recovery-parent"); + let mut state = start_autonomous_owner_child(&root, &parent_state, "design-foundation"); + advance_owner_artifact_revision( + &root, + &state, + "memory/project.md", + "# 项目记忆\n\n恢复测试的正式项目约束。\n", + ); + let response_revision = advance_owner_artifact_revision( + &root, + &state, + "game/game_design.md", + "# 游戏设计\n\n恢复测试的完整玩法规格。\n", + ); + state.status = "running".to_string(); + state.phase = "finalizing".to_string(); + state.current_action = "恢复固定 owner prepared finalization".to_string(); + append_game_creator_agent_runtime_task(&root, &state).expect("append finalizing owner task"); + write_game_creator_agent_runtime_state(&root, &state).expect("persist finalizing owner state"); + + let response = "固定玩法规格已经完成。"; + let journal = build_game_creator_agent_runtime_finalization_journal( + &root, + &state, + response, + response_revision, + ) + .expect("build owner prepared finalization"); + write_game_creator_agent_runtime_finalization_journal(&root, &journal) + .expect("write owner prepared finalization"); + append_game_creator_agent_runtime_finalization_lifecycle_stage( + &root, + &journal, + "prepared", + journal.prepared_at, + ) + .expect("append owner prepared lifecycle"); + + assert_eq!( + resume_game_creator_agent_finalization_for_test_at(&root, &state.agent_id) + .expect("resume owner prepared finalization"), + "recovered" + ); + let gate = + read_game_creator_agent_runtime_verification_gate(&root, &state.agent_id, &state.run_id) + .expect("read recovered owner verification gate"); + assert_eq!( + gate.last_verification_tool.as_deref(), + Some(AGENT_RUNTIME_OWNER_ARTIFACT_VERIFICATION_TOOL) + ); + assert_eq!(gate.verified_revision, Some(response_revision)); + assert_eq!(gate.static_smoke_verified_revision, None); + assert!(read_game_creator_agent_runtime_finalization_journal( + &root, + &state.agent_id, + &state.run_id, + ) + .expect("read recovered owner finalization") + .is_none()); + let conversation = read_local_conversation_for_session_at( + &root, + Some(&state.agent_id), + Some(&state.session_id), + ) + .expect("read recovered owner conversation"); + assert!(conversation + .messages + .iter() + .any(|message| message.role == "assistant" && message.content == response)); + assert!(!root.join(".agent/runtime/browser-validations").exists()); +} + +#[test] +fn autonomous_owner_artifact_validation_and_path_matrix_is_role_scoped() { + let _config_guard = crate::tests::write_test_local_config("{}".to_string()); + let (_temporary, root, parent_state, _contract) = + autonomous_fixture("做一个完整小游戏", "owner-validation-matrix-parent"); + for (agent_id, allowed_path) in [ + ("design-foundation", "memory/project.md"), + ("balance-seed", "game/balance.json"), + ("art-asset-plan", "assets/manifest.art.json"), + ("audio-asset-plan", "assets/manifest.audio.json"), + ] { + let record = queue_autonomous_manifest_child_fixture(&root, &parent_state, agent_id); + assert!(autonomous_owner_artifact_validation_available_for_run_at( + &root, + agent_id, + &record.run_id, + ) + .expect("resolve owner artifact validation role")); + assert!(agent_role_project_path_mutation_block( + &root, + agent_id, + &record.run_id, + "file.write", + allowed_path, + ) + .is_none()); + let blocked = agent_role_project_path_mutation_block( + &root, + agent_id, + &record.run_id, + "file.write", + "game/index.html", + ); + if agent_id == "design-foundation" { + assert!(blocked + .as_ref() + .is_some_and(|value| value.summary.contains("design-foundation"))); + } else { + assert!(blocked + .as_ref() + .is_some_and(|value| value.summary.contains("固定正式产物"))); + } + } + + for agent_id in ["code-prototype", "publish-package"] { + let record = queue_autonomous_manifest_child_fixture(&root, &parent_state, agent_id); + assert!(!autonomous_owner_artifact_validation_available_for_run_at( + &root, + agent_id, + &record.run_id, + ) + .expect("resolve excluded owner artifact validation role")); + } +} + +#[test] +fn autonomous_gui_cli_code_prototype_terminal_projection_requires_own_static_smoke() { + let _config_guard = crate::tests::write_test_local_config("{}".to_string()); + + for (root_source, suffix) in [ + (AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, "gui"), + (AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, "cli"), + ] { + let (_temporary, root, parent_state, _contract) = autonomous_fixture_with_source( + "实现一个完整可玩小游戏", + &format!("code-terminal-smoke-{suffix}-parent"), + root_source, + ); + let _code_runtime_lane = + try_acquire_game_creator_agent_runtime_task_lock(&root, "code-prototype") + .expect("acquire code-prototype terminal projection runtime lane") + .expect("code-prototype terminal projection runtime lane is free"); + let state = start_autonomous_owner_child(&root, &parent_state, "code-prototype"); + let mutation_revision = advance_game_index_revision( + &root, + &state, + // 同一份入口后面要被记为 static-smoke 通过,落盘时就得满足完整合同; + // 此处只写一次、revision 不变,前面对 mutation/verified revision 的断言不受影响。 + &with_static_smoke_contract( + "", + ), + ); + mark_verification_passed(&root, &state, "project.verify"); + let project_verified_gate = read_game_creator_agent_runtime_verification_gate( + &root, + &state.agent_id, + &state.run_id, + ) + .expect("read project.verify-only code gate"); + assert_eq!( + project_verified_gate.mutation_revision, + Some(mutation_revision) + ); + assert_eq!( + project_verified_gate.verified_revision, + Some(mutation_revision) + ); + assert_eq!(project_verified_gate.static_smoke_verified_revision, None); + + let blocker = autonomous_game_build_completion_blocker_at_locked(&root, &state) + .expect("project.verify-only code child must remain blocked"); + assert!(blocker.summary.contains("game.static_smoke")); + + let mut completed = state.clone(); + completed.status = "completed".to_string(); + completed.phase = "completed".to_string(); + completed.current_action = "尝试投影 project.verify-only 终态".to_string(); + append_game_creator_agent_runtime_task(&root, &completed) + .expect("persist deliberately under-verified code terminal"); + let error = project_autonomous_manifest_ready_task_terminal_at_locked(&root, &completed) + .expect_err("terminal projection must defend the static smoke owner contract"); + assert!( + error.contains("game.static_smoke"), + "unexpected error: {error}" + ); + let manifest = read_manifest_for_project(&root) + .expect("read manifest after rejected code terminal projection"); + assert_eq!( + manifest + .tasks + .iter() + .find(|task| task.id == "code-prototype") + .expect("code-prototype seed task") + .status, + GameCreationAppTaskStatus::Running + ); + + mark_verification_passed(&root, &state, "game.static_smoke"); + let smoke_gate = read_game_creator_agent_runtime_verification_gate( + &root, + &state.agent_id, + &state.run_id, + ) + .expect("read code static smoke gate"); + assert_eq!( + smoke_gate.static_smoke_verified_revision, + Some(mutation_revision) + ); + assert!(autonomous_game_build_completion_blocker_at_locked(&root, &completed).is_none()); + let _project_lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + &root, + "test.code-prototype-terminal-root-revalidation", + ) + .expect("acquire code-prototype terminal root revalidation project lock"); + assert!( + project_autonomous_manifest_ready_task_terminal_at_locked(&root, &completed) + .expect("project code terminal after static smoke") + ); + let manifest = read_manifest_for_project(&root) + .expect("read manifest after accepted code terminal projection"); + assert_eq!( + manifest + .tasks + .iter() + .find(|task| task.id == "code-prototype") + .expect("completed code-prototype seed task") + .status, + GameCreationAppTaskStatus::Completed + ); + + let mut legacy_gate = smoke_gate; + legacy_gate.last_verification_tool = Some("project.verify".to_string()); + legacy_gate.last_verification_status = + Some(AGENT_RUNTIME_VERIFICATION_STATUS_PASSED.to_string()); + legacy_gate.static_smoke_verified_revision = None; + // 入口摘要与 revision 是一对:仿造 legacy gate 时必须一起清,否则 gate + // 自身的一致性校验会先于被测行为拒收。 + legacy_gate.static_smoke_verified_game_index_sha256 = None; + write_game_creator_agent_runtime_verification_gate(&root, &legacy_gate) + .expect("persist legacy project.verify-only completed code gate"); + let root_blocker = autonomous_game_build_completion_blocker_at_locked(&root, &parent_state) + .expect("root completion must revalidate a manifest-completed code child"); + assert!( + root_blocker + .detail + .as_deref() + .is_some_and(|detail| detail.contains("code-static-smoke")), + "unexpected root completion blocker: {root_blocker:?}" + ); + } +} + +#[test] +fn autonomous_owner_artifact_validation_rejects_noncanonical_or_inactive_identity() { + let _config_guard = crate::tests::write_test_local_config("{}".to_string()); + + { + let (_temporary, root, parent_state, _contract) = + autonomous_fixture("做一个完整小游戏", "owner-identity-wrong-run-parent"); + let state = start_autonomous_owner_child(&root, &parent_state, "design-foundation"); + assert!(validate_autonomous_owner_artifacts_for_run_at_locked( + &root, + &state.agent_id, + "owner-identity-other-run", + ) + .is_err()); + assert!(validate_autonomous_owner_artifacts_for_run_at_locked( + &root, + "balance-seed", + &state.run_id, + ) + .is_err()); + } + + { + let (_temporary, root, parent_state, _contract) = + autonomous_fixture("做一个完整小游戏", "owner-identity-delegated-parent"); + let session_id = + resolve_agent_conversation_session_id_at(&root, "design-foundation", None, true) + .expect("resolve delegated owner session"); + let delegated = append_unique_game_creator_agent_runtime_pending_task( + &root, + "design-foundation", + &session_id, + "伪造的 delegated owner", + "owner-identity-delegated-run", + "agent-delegate", + None, + Some(&AgentRuntimeTaskLink { + parent_agent_id: Some(parent_state.agent_id.clone()), + parent_run_id: Some(parent_state.run_id.clone()), + delegation_id: Some("owner-identity-delegation".to_string()), + }), + ) + .expect("queue delegated owner identity"); + assert!(!autonomous_owner_artifact_validation_available_for_run_at( + &root, + "design-foundation", + &delegated.run_id, + ) + .expect("resolve delegated owner route")); + assert!(validate_autonomous_owner_artifacts_for_run_at_locked( + &root, + "design-foundation", + &delegated.run_id, + ) + .is_err()); + let index_path = root.join(AGENT_RUNTIME_GAME_INDEX_PATH); + let index_before = + fs::read(&index_path).expect("read game index before forged owner write"); + let observation = observe_agent_runtime_file_write( + &root, + "design-foundation", + &delegated.run_id, + &AgentRuntimeToolAction { + tool: "file.write".to_string(), + reason: Some("验证错误 lineage 不能先越权写入".to_string()), + input: serde_json::json!({ + "path": AGENT_RUNTIME_GAME_INDEX_PATH, + "content": "forged owner mutation", + }), + }, + &"0".repeat(64), + None, + ); + assert_eq!(observation.status, "blocked"); + assert!(observation.summary.contains("lineage")); + assert_eq!( + fs::read(&index_path).expect("read game index after forged owner write"), + index_before, + "an untrusted autonomous fixed owner must be rejected before file mutation", + ); + } + + { + let (_temporary, root, parent_state, _contract) = + autonomous_fixture("做一个完整小游戏", "owner-identity-wrong-parent-root"); + let code_parent = + queue_autonomous_manifest_child_fixture(&root, &parent_state, "code-prototype"); + let session_id = + resolve_agent_conversation_session_id_at(&root, "design-foundation", None, true) + .expect("resolve wrong-parent owner session"); + let wrong_parent = append_unique_game_creator_agent_runtime_pending_task( + &root, + "design-foundation", + &session_id, + "错误父节点下的 owner", + "owner-identity-wrong-parent-run", + "agent-ready-task-scheduler", + None, + Some(&AgentRuntimeTaskLink { + parent_agent_id: Some(code_parent.agent_id.clone()), + parent_run_id: Some(code_parent.run_id.clone()), + delegation_id: None, + }), + ) + .expect("queue owner with wrong direct parent"); + assert!(!autonomous_owner_artifact_validation_available_for_run_at( + &root, + "design-foundation", + &wrong_parent.run_id, + ) + .expect("resolve wrong-parent owner route")); + assert!(validate_autonomous_owner_artifacts_for_run_at_locked( + &root, + "design-foundation", + &wrong_parent.run_id, + ) + .is_err()); + } + + { + let (_temporary, root, parent_state, _contract) = + autonomous_fixture("做一个完整小游戏", "owner-identity-old-root-parent"); + let state = start_autonomous_owner_child(&root, &parent_state, "design-foundation"); + append_unique_game_creator_agent_runtime_pending_task( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &parent_state.session_id, + "启动新的完整小游戏构建", + "owner-identity-new-root", + AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + None, + ) + .expect("create newer autonomous root"); + let error = validate_autonomous_owner_artifacts_for_run_at_locked( + &root, + &state.agent_id, + &state.run_id, + ) + .expect_err("old root owner must not validate"); + assert!( + error.contains("取代") || error.contains("当前根") || error.contains("活跃"), + "unexpected inactive-root error: {error}" + ); + } + + { + let (_temporary, root, parent_state, _contract) = + autonomous_fixture("做一个完整小游戏", "owner-identity-terminal-parent"); + let state = start_autonomous_owner_child(&root, &parent_state, "design-foundation"); + let latest = read_latest_game_creator_agent_runtime_task_by_run_id( + &root, + &state.agent_id, + &state.run_id, + ) + .expect("read running owner before terminal transition") + .expect("running owner exists"); + append_game_creator_agent_runtime_task_record( + &root, + &AgentRuntimeTaskRecord { + status: "completed".to_string(), + phase: "completed".to_string(), + current_action: "owner terminal fixture".to_string(), + ..latest + }, + ) + .expect("append terminal owner record"); + assert!(validate_autonomous_owner_artifacts_for_run_at_locked( + &root, + &state.agent_id, + &state.run_id, + ) + .is_err()); + } +} + +#[test] +fn autonomous_owner_artifact_credential_cannot_be_borrowed_across_agents() { + let _config_guard = crate::tests::write_test_local_config("{}".to_string()); + let (_temporary, root, parent_state, _contract) = + autonomous_fixture("做一个完整小游戏", "owner-credential-isolation-parent"); + let design = start_autonomous_owner_child(&root, &parent_state, "design-foundation"); + advance_owner_artifact_revision( + &root, + &design, + "memory/project.md", + "# 项目记忆\n\n隔离凭证测试。\n", + ); + advance_owner_artifact_revision( + &root, + &design, + "game/game_design.md", + "# 游戏设计\n\n隔离凭证测试。\n", + ); + assert!(project_verification_completion_blocker_at( + &root, + &design.agent_id, + &design.run_id, + &[], + ) + .is_none()); + let design_gate = + read_game_creator_agent_runtime_verification_gate(&root, &design.agent_id, &design.run_id) + .expect("read design owner credential"); + + let balance = start_autonomous_owner_child(&root, &parent_state, "balance-seed"); + fs::remove_file(root.join("game/balance.json")).expect("remove balance owner artifact"); + let mut borrowed = design_gate; + borrowed.agent_id = balance.agent_id.clone(); + borrowed.run_id = balance.run_id.clone(); + borrowed.updated_at = unix_timestamp(); + write_game_creator_agent_runtime_verification_gate(&root, &borrowed) + .expect("install structurally valid borrowed credential fixture"); + let blocker = + project_verification_completion_blocker_at(&root, &balance.agent_id, &balance.run_id, &[]) + .expect("borrowed owner credential must not pass"); + assert!(blocker + .detail + .as_deref() + .is_some_and(|detail| detail.contains("game/balance.json"))); +} + +#[test] +fn owner_artifact_verification_revision_drift_fails_closed() { + let _config_guard = crate::tests::write_test_local_config("{}".to_string()); + let (_temporary, root, parent_state, _contract) = + autonomous_fixture("做一个完整小游戏", "owner-revision-drift-parent"); + let state = start_autonomous_owner_child(&root, &parent_state, "design-foundation"); + advance_owner_artifact_revision( + &root, + &state, + "memory/project.md", + "# 项目记忆\n\n并发 revision 测试。\n", + ); + let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + &root, + "test.owner-verification-revision-drift", + ) + .expect("acquire owner verification drift lock"); + let (expected_revision, gate) = begin_agent_runtime_project_verification_locked( + &root, + &state.agent_id, + &state.run_id, + AGENT_RUNTIME_OWNER_ARTIFACT_VERIFICATION_TOOL, + ) + .expect("begin owner verification before drift"); + advance_agent_runtime_project_revision_locked(&root) + .expect("advance project revision during verification fixture"); + let error = + finish_agent_runtime_project_verification_locked(&root, &expected_revision, gate, true) + .expect_err("revision drift must invalidate owner verification"); + assert!(error.contains("结果不再有效")); + let failed = + read_game_creator_agent_runtime_verification_gate(&root, &state.agent_id, &state.run_id) + .expect("read failed owner verification after drift"); + assert_eq!(failed.verified_revision, None); + assert_eq!( + failed.last_verification_status.as_deref(), + Some(AGENT_RUNTIME_VERIFICATION_STATUS_FAILED) + ); + assert_eq!(failed.static_smoke_verified_revision, None); +} + #[test] fn autonomous_completion_blocks_outer_e2e_incomplete_markers_in_owner_markdown_and_html() { let (_temporary, root, parent_state, _contract) = autonomous_fixture( @@ -7975,9 +9451,10 @@ fn autonomous_completion_requires_changed_index_static_smoke_and_bound_playtest( .exists() ); + let playtest_state = start_autonomous_playtest_child(&root, &state); let result = browser_result_fixture( &root, - &state, + &playtest_state, revision, BrowserPlaytestScenario::LaneDefenseV1, ); @@ -7986,17 +9463,30 @@ fn autonomous_completion_requires_changed_index_static_smoke_and_bound_playtest( reason: Some("验证真实可玩闭环".to_string()), input: serde_json::json!({}), }; - let action_fingerprint = agent_runtime_tool_action_fingerprint(&action, &state.current_task); - let action_id = agent_runtime_tool_action_id(&state.run_id, 1, 0, 1, &action_fingerprint); + let action_fingerprint = + agent_runtime_tool_action_fingerprint(&action, &playtest_state.current_task); + let action_id = + agent_runtime_tool_action_id(&playtest_state.run_id, 1, 0, 1, &action_fingerprint); write_autonomous_playtest_receipt_at( &root, &contract, + &playtest_state, &action_id, &action_fingerprint, revision, &result, ) .expect("persist autonomous playtest receipt"); + let mut completed_playtest = playtest_state.clone(); + completed_playtest.status = "completed".to_string(); + completed_playtest.phase = "completed".to_string(); + completed_playtest.current_action = "已完成独立桌面与移动试玩".to_string(); + append_game_creator_agent_runtime_task(&root, &completed_playtest) + .expect("persist completed preview-playtest child"); + assert!( + project_autonomous_manifest_ready_task_terminal_at(&root, &completed_playtest) + .expect("project completed preview-playtest child") + ); assert!(autonomous_game_build_completion_blocker_at_locked(&root, &state).is_none()); let changed_game = format!("{valid_game}\n"); @@ -8143,3 +9633,92 @@ fn autonomous_playtest_liveness_only_enforces_the_latest_preview_result() { error.starts_with(AGENT_RUNTIME_AUTONOMOUS_DELEGATED_PLAYTEST_REPAIR_LIVENESS_ERROR_PREFIX) ); } + +/// `M1A-4` 收口:强判据必须排在 `delegated` 与 `autonomous-game-build` 两支之前。 +/// +/// 这两条路径原先都能绕开 `reject_supervisor_plan_root_retry_without_identity`: +/// 「plan source + 伪造 parent」落 delegated 支直接返回 `agent-delegate-retry`; +/// 「binding.source 是 plan + autonomous profile」落 autonomous 支,因为 plan 在 +/// 可信集合内而被原样取回,复活启动路径明令禁止的组合。两者都要 durable 状态先 +/// 畸变才可达,但强判据存在的意义正是对畸变状态 fail closed。 +#[test] +fn plan_root_retry_identity_guard_precedes_delegated_and_autonomous_branches() { + let temporary = crate::tests::canonical_test_tempdir("plan-root-retry-guard-"); + let root = temporary.path().join("project"); + init_local_game_project_at(&root, "plan-root-retry-guard", "守卫前置").expect("init"); + let session_id = resolve_agent_conversation_session_id_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + None, + true, + ) + .expect("session"); + + // ① delegated=true:伪造 parent 的 plan source task 不得静默变成 + // agent-delegate-retry,必须先被强判据拒绝。 + let binding = bind_game_creator_agent_runtime_run_profile_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "plan-retry-guard-delegated", + AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_STANDARD), + None, + ) + .expect("bind plan root"); + let mut forged_parent = failed_supervisor_task( + "plan-retry-guard-delegated", + AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE, + AGENT_RUNTIME_RUN_PROFILE_STANDARD, + &binding.binding_fingerprint, + &session_id, + ); + forged_parent.parent_agent_id = Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string()); + forged_parent.parent_run_id = Some("forged-parent-run".to_string()); + let delegated_error = + resolve_game_creator_agent_runtime_retry_configuration_at(&root, &forged_parent, true) + .expect_err("delegated 支不得绕开 plan 根强判据"); + assert!( + delegated_error.contains(AGENT_RUNTIME_PLAN_ROOT_RETRY_IDENTITY_UNSUPPORTED_KIND), + "{delegated_error}" + ); + + // ② autonomous 支:task.source 已损坏成非 plan,但 binding.source 是 plan。 + // 顶部守卫按 task.source 判定,挡不住这一种,必须由 autonomous 支内的 + // reject_supervisor_plan_autonomous_profile 兜住。 + let autonomous_error = bind_game_creator_agent_runtime_run_profile_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "plan-retry-guard-autonomous", + AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + None, + ) + .expect_err("durable binding 不得写入 plan+autonomous 非法组合"); + assert!( + autonomous_error.contains(AGENT_RUNTIME_PLAN_AUTONOMOUS_PROFILE_UNSUPPORTED_KIND), + "{autonomous_error}" + ); + assert!( + !game_creator_agent_runtime_run_profile_binding_path( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "plan-retry-guard-autonomous", + ) + .exists(), + "非法 binding 不得落盘" + ); + + // 对照:合法 plan 根 run 不受本守卫影响,仍然保源。 + let healthy = failed_supervisor_task( + "plan-retry-guard-delegated", + AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE, + AGENT_RUNTIME_RUN_PROFILE_STANDARD, + &binding.binding_fingerprint, + &session_id, + ); + let (profile, source) = + resolve_game_creator_agent_runtime_retry_configuration_at(&root, &healthy, false) + .expect("合法 plan 根 run 必须仍然保源"); + assert_eq!(profile, AGENT_RUNTIME_RUN_PROFILE_STANDARD); + assert_eq!(source, AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE); +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/context_bundle.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/context_bundle.rs index 9f2187da8..60f436634 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/context_bundle.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/context_bundle.rs @@ -173,7 +173,24 @@ pub(in crate::agent) fn validate_agent_runtime_pending_context( { return Err("Agent Runtime 待确认动作身份与当前状态不匹配".to_string()); } - validate_agent_runtime_context_task_parameter(root, runtime, &pending.task)?; + // 澄清转述是唯一一处 `task` 字段存的不是本 run 任务的场景:Runtime 代 + // Supervisor 汇总子 Agent 澄清问题时,把「回答后创建唯一 continuation 委派」 + // 那段指令连同 delegationId 写在这里(provider_recovery.rs),而 run 的 + // current_task 始终是用户原始请求。其余五个 pending 创建点传的都是 run 的真实 + // task,所以这条相等断言对它们成立,对转述则永不可能成立——D11 澄清路径实测 + // 每一次都会在 resume 时被判成 needs-reconciliation,用户答案已落盘却无法继续。 + // + // 豁免按 task 判、不按工具判:用户答完后整条 run 都改跑在转述任务上 + // (run_recovered_game_creator_context_on_fresh_task 传的就是 pending.task), + // 所以续跑里的 agent.run_status、agent.delegate 同样带着它。 + // + // 这里只豁免文本相等这一条。pending 与 runtime 的 agent/task_id/session/run/ + // source 五项身份检查在上面已经全部通过,轮次检查在下面继续执行,转述 pending + // 本身也只能由 Runtime 在本 run 内生成,所以豁免不放开任何跨 run 或跨身份的 + // 重放面。 + if !agent_runtime_task_is_delegate_clarification_relay(&pending.task) { + validate_agent_runtime_context_task_parameter(root, runtime, &pending.task)?; + } if pending.loop_iteration != runtime.loop_iteration { return Err(format!( "Agent Runtime 待确认动作轮次与当前状态不匹配:pending={} state={}", @@ -469,6 +486,21 @@ pub(in crate::agent) fn read_game_creator_agent_runtime_context_bundle_with_supe bundle.run_profile_binding_fingerprint = runtime.run_profile_binding_fingerprint.clone(); bundle = sanitize_game_creator_agent_runtime_context_bundle(root, &bundle); } + // `bundle.task` 存的是**写出这份 bundle 的那一轮**的任务,和 run 的持久 task 是两个 + // 独立参数(build_game_creator_agent_runtime_context_bundle 的 `task` 形参)。澄清转述 + // 是唯一一处二者必然不同的场景:用户答完后整条 run 改跑在转述任务上 + // (run_recovered_game_creator_context_on_fresh_task 传的就是 pending.task),那一轮 + // 写出的 bundle 因此带着转述文本,而 task record 与 current_task 始终是用户原始请求。 + // + // validate_agent_runtime_pending_context 已经为 pending 入口豁免过同一条文本相等断言, + // 但 bundle 这个入口漏了:D11 实测走过一轮澄清后,策划子 Agent 完成、父 run 醒来认领 + // 委派回执时必定在这里判失败,用户答案已落盘却整轮报「身份与当前任务不匹配」。 + // + // 豁免同样只放开文本相等这一条。project/agent/task_id/session/run/source/profile/绑定 + // 指纹和 verification_gate 三项在上下都照旧全等,转述文本本身也只能由 Runtime 在本 run + // 内生成,所以不放开任何跨 run 或跨身份的重放面。 + let bundle_task_is_clarification_relay = + agent_runtime_task_is_delegate_clarification_relay(&bundle.task); if bundle.project_id != game_creator_agent_runtime_context_project_id(root)? || bundle.agent_id != redact_agent_runtime_project_paths(root, &runtime.agent_id, 160) || bundle.task_id != redact_agent_runtime_project_paths(root, &runtime.task_id, 160) @@ -477,7 +509,8 @@ pub(in crate::agent) fn read_game_creator_agent_runtime_context_bundle_with_supe || bundle.source != redact_agent_runtime_project_paths(root, &runtime.source, 120) || bundle.run_profile != runtime.run_profile || bundle.run_profile_binding_fingerprint != runtime.run_profile_binding_fingerprint - || validate_agent_runtime_context_task_parameter(root, runtime, &bundle.task).is_err() + || (!bundle_task_is_clarification_relay + && validate_agent_runtime_context_task_parameter(root, runtime, &bundle.task).is_err()) || bundle.verification_gate.project_id != bundle.project_id || bundle.verification_gate.agent_id != bundle.agent_id || bundle.verification_gate.run_id != bundle.run_id @@ -820,6 +853,104 @@ mod tests { use super::*; use std::time::{SystemTime, UNIX_EPOCH}; + /// D11 转述路径的回归锚点。 + /// + /// Runtime 代 Supervisor 汇总子 Agent 澄清问题时,pending 的 `task` 存的是转述 + /// 指令(含 delegationId),而 run 的 current_task 始终是用户原始请求。此前这条 + /// 相等断言对转述 pending 永不可能成立:用户答完后 resume 立刻被判 + /// needs-reconciliation,答案已落盘却无法继续,整条立项策划链在第一次澄清就断。 + /// 生产 run 11/12/13 没暴露,只是因为澄清信封从来没触发过。 + #[test] + fn delegate_clarification_relay_pending_survives_the_task_identity_check() { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be after epoch") + .as_nanos(); + let temporary = crate::tests::canonical_test_tempdir(&format!( + "clarification-relay-task-{}-{unique}-", + std::process::id() + )); + let root = temporary.path().join("project"); + init_local_game_project_at(&root, "project-1", "澄清转述任务身份项目") + .expect("project init"); + let runtime = start_game_creator_agent_runtime_task_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "做个横版像素解谜小游戏", + "clarification-relay-run", + "agent-background-task", + "转述澄清问题", + vec!["等待用户回答后创建唯一 continuation 委派".to_string()], + ) + .expect("start supervisor runtime state"); + + let action = AgentRuntimeToolAction { + tool: GAME_CREATOR_USER_INPUT_REQUEST_TOOL.to_string(), + reason: Some("代 Supervisor 汇总子 Agent 的澄清问题".to_string()), + input: serde_json::json!({"questions": []}), + }; + let relay_task = format!( + "{AGENT_RUNTIME_DELEGATE_CLARIFICATION_TASK_PREFIX}delegation-abc;questionsSha256=fixture。请回答以下问题。" + ); + let build = |task: &str, action: &AgentRuntimeToolAction| { + build_game_creator_agent_runtime_pending_tool_action( + &root, + &runtime, + task, + &AgentRuntimeToolPlan::default(), + &[], + &read_game_creator_agent_runtime_project_revision(&root) + .expect("read project revision"), + &build_repository_startup_context_at(&root) + .expect("repository context") + .fingerprint, + action, + 0, + AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO, + AGENT_RUNTIME_PENDING_ACTION_STATUS_WAITING_FOR_USER_INPUT, + None, + ) + .expect("build pending action") + }; + + let relay = build(&relay_task, &action); + assert!(agent_runtime_task_is_delegate_clarification_relay( + &relay.task + )); + assert_ne!(relay.task, runtime.current_task); + validate_agent_runtime_pending_context(&root, &runtime, &relay) + .expect("转述 pending 必须能通过身份校验,否则用户答案落盘后无法续跑"); + + // 用户答完后整条 run 都改跑在转述任务上,后续动作同样带着它。按工具收窄 + // 豁免会让续跑第一步 agent.run_status 就被判身份变化——run 15 实测如此, + // 所以这里显式覆盖非 user.input_request 的后续动作。 + let follow_up = AgentRuntimeToolAction { + tool: "agent.run_status".to_string(), + reason: Some("读取原委派的权威合同,准备创建唯一一次续跑委派".to_string()), + input: serde_json::json!({}), + }; + let resumed = build(&relay_task, &follow_up); + validate_agent_runtime_pending_context(&root, &runtime, &resumed) + .expect("续跑动作同样跑在转述任务上,必须通过身份校验"); + + // 豁免只覆盖 Runtime 生成的转述任务。task 不是转述指令时,原有的相等断言 + // 必须照旧拒绝——否则这就是个洞而不是修复。 + let forged = build("被改写的任务", &action); + assert!(!agent_runtime_task_is_delegate_clarification_relay( + &forged.task + )); + assert!(validate_agent_runtime_pending_context(&root, &runtime, &forged).is_err()); + let forged_follow_up = build("被改写的任务", &follow_up); + assert!( + validate_agent_runtime_pending_context(&root, &runtime, &forged_follow_up).is_err() + ); + + // 身份五项仍然照查:换一个 run_id 的转述 pending 不得被放行。 + let mut cross_run = relay.clone(); + cross_run.run_id = format!("{}-other", cross_run.run_id); + assert!(validate_agent_runtime_pending_context(&root, &runtime, &cross_run).is_err()); + } + #[test] fn agent_runtime_context_bundle_restores_pre_checkpoint_window_boundary() { let unique = SystemTime::now() 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 1bff16163..31bc81698 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,7 +1,30 @@ 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 +} + impl AgentRuntimeContextWindowTracker { - pub(crate) fn from_continuation(continuation: &AgentRuntimeContinuationContext) -> Self { + pub(crate) fn from_continuation( + continuation: &AgentRuntimeContinuationContext, + runtime: &AgentRuntimeState, + ) -> Self { Self { completed_loops: continuation.window_completed_loops, observation_signatures: continuation @@ -12,6 +35,7 @@ impl AgentRuntimeContextWindowTracker { .collect(), last_window_fingerprint: continuation.last_window_fingerprint.clone(), stalled: continuation.context_stalled, + window_disabled: !agent_runtime_context_window_applies(runtime), } } @@ -45,6 +69,19 @@ impl AgentRuntimeContextWindowTracker { &mut self, next_loop_index: usize, ) -> AgentRuntimeContextCheckpoint { + // Stay at zero rather than counting into a window that will never + // close. A zero count also keeps the persisted bundle inside the + // context-bundle window validator for every `nextLoopIndex`, so a + // paused-and-resumed run on this lane can no longer be failed by a + // counter that drifted from the index it is validated against. + // Stay at zero rather than counting into a window that will never + // close. A zero count also keeps the persisted bundle inside the + // context-bundle window validator for every `nextLoopIndex`, so a + // paused-and-resumed run on this lane can no longer be failed by a + // counter that drifted from the index it is validated against. + if self.window_disabled { + return AgentRuntimeContextCheckpoint::Continue; + } if self.stalled { return AgentRuntimeContextCheckpoint::Stalled; } 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 abc25face..65a241127 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 @@ -10,6 +10,14 @@ const AGENT_RUNTIME_GOAL_CONTRACT_NODE_ID_MAX_CHARS: usize = 160; const AGENT_RUNTIME_GOAL_CONTRACT_NODE_EVIDENCE_LIMIT: usize = 32; 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")] pub(crate) struct AgentRuntimeGoalContractAcceptanceNode { @@ -271,6 +279,30 @@ 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, @@ -340,6 +372,15 @@ 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 @@ -406,6 +447,9 @@ 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)?, @@ -610,6 +654,23 @@ 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"); @@ -727,6 +788,56 @@ 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 3607bb842..78bce914c 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,6 +136,7 @@ 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) mcp_catalog_fingerprint: String, pub(in crate::agent) estimated_input_tokens: u64, @@ -245,6 +246,10 @@ 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 { @@ -265,6 +270,15 @@ 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)] @@ -424,6 +438,10 @@ pub(crate) struct AgentRuntimeContextWindowTracker { pub(in crate::agent) observation_signatures: std::collections::BTreeSet, pub(in crate::agent) last_window_fingerprint: Option, pub(in crate::agent) stalled: bool, + /// Set for runs the loop window does not apply to (see + /// `agent_runtime_context_window_applies`). Defaults to `false` so an + /// unmarked tracker keeps the full bookkeeping. + pub(in crate::agent) window_disabled: bool, } #[derive(Clone, Debug, Default)] 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 new file mode 100644 index 000000000..2102a6279 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_approval.rs @@ -0,0 +1,2419 @@ +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), + } +} + +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_at( + root: &Path, + gdd: &PlanGddV1, +) -> Result<(), PlanningStorageError> { + if !crate::config::game_creator_planning_capability_enabled() + .map_err(|error| approval_error("PLAN_CAPABILITY_DISABLED", error))? + { + return Err(approval_error( + "PLAN_CAPABILITY_DISABLED", + "立项策划能力当前已停用", + )); + } + let _lock = acquire_project_write_lock(root, "planning.approval-pending.create") + .map_err(|error| approval_error("PLAN_DURABILITY_FAILED", error))?; + create_plan_gdd_approval_pending_locked(root, gdd) +} + +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 + }) +} + +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; + let (records, _) = crate::project::read_agent_db_records_bounded(root, 16 * 1024 * 1024)?; + Ok(records.iter().any(|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()) + })) +} + +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; + 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; + } + } + // 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_points_to_receipt = match read_plan_session_with_recovery_locked(root) { + Ok(session) => session + .as_ref() + .and_then(|session| session.latest_submitted_ref.as_ref()) + .is_some_and(|reference| reference == &receipt_plan_ref(receipt)), + 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 receipt.version == latest.version || session_points_to_receipt { + 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)?; + let (records, scan_truncated) = + crate::project::read_agent_db_records_bounded(root, 16 * 1024 * 1024)?; + if scan_truncated { + return Err("Agent DB 扫描被截断,无法确认 plan GDD decision audit".to_string()); + } + let mut found = false; + for record in records { + if 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) + { + continue; + } + 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) => { + return Some(plan_gdd_completion_blocker( + "needs-reconciliation", + "Fast GDD decision audit identity 不一致,不能收束任务", + 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(), + ), + )); + } + if session.phase == "recovery_required" { + return Some(plan_gdd_completion_blocker( + "needs-reconciliation", + "planning session 仍处于 recovery_required,不能收束任务", + format!("gddVersion={}", latest.version), + )); + } + 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); + } + + /// 祖先绑定坏掉不能让一个跟立项策划无关的 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 new file mode 100644 index 000000000..4e6bf1374 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_coordinator.rs @@ -0,0 +1,790 @@ +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 = "需要原型验证"; +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 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))) +} + +fn plan_question_topic(question: &AgentRuntimeUserInputQuestion) -> Result { + let remainder = question + .question + .strip_prefix(PLAN_QUESTION_PREFIX) + .ok_or_else(|| { + plan_coordinator_error( + "PLAN_INVALID_CLARIFICATION", + "plan question 必须以“当前要决定:”开头", + ) + })?; + 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:方案名` 是高频输出, +/// 而不在集合里的后果是整个信封被拒、回灌重试,白吃一个未推进回合预算。 +const PLAN_OPTION_LABEL_DELIMITERS: [char; 4] = ['·', ':', ':', '-']; + +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` +/// 钉的就是这条不变量。 +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 expected_header = format!("第{round}轮·关键决定"); + if question.header != expected_header { + return Err(plan_coordinator_error( + "PLAN_INVALID_CLARIFICATION", + format!("plan question header 必须精确等于 {expected_header}"), + )); + } + let valid_shape = question.options.len() == 3 + && 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、需要原型验证三个选项", + )); + } + plan_question_topic(question)?; + 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)?; + 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); + } + let original_id = delivery.repair_of_delegation_id.as_deref().ok_or_else(|| { + plan_coordinator_error( + "PLAN_ACTIVE_RUN_EXISTS", + "已有 planning session 时不能创建第二条根 delegation", + ) + })?; + 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_hydrate.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_hydrate.rs new file mode 100644 index 000000000..3d541f687 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_hydrate.rs @@ -0,0 +1,779 @@ +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_provider_usage.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_provider_usage.rs new file mode 100644 index 000000000..d421901a0 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_provider_usage.rs @@ -0,0 +1,972 @@ +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_storage.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_storage.rs new file mode 100644 index 000000000..a7f483afe --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_storage.rs @@ -0,0 +1,5560 @@ +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" | "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"))); + } + // round=0 表示这条决定从未向用户提问过,因此它不能声称任何用户权威: + // answerSource 必须是 default。但它可以落在两种状态上——由 Agent 按默认 + // 建议填写(default_pending),或者 Agent 判定这项会实质影响首个可玩闭环、 + // 不该由它替用户拍板,需要一个 30~90 分钟微型原型来验证 + // (prototype_pending,并配同 id 的 prototypeValidationItems 项)。 + // + // 早期实现把 round=0 钉死成 default_pending。于是用户一次把需求说全、 + // 走 0 轮直出时,全部决定都是 round=0,没有任何决定可能成为 + // prototype_pending;而下面的双射又要求验证项逐项对应 prototype_pending + // 决定,结果是首次 plan.submit_gdd 必被预检拒收,且这份稿子永远不可能 + // 带上原型验证项。把一项未经验证的风险标成「默认,待确认」是在说谎: + // 那不是一个默认值,那是一个没人验证过的假设。 + if decision.round == 0 && decision.id != "initial-request" { + if decision.answer_source != "default" { + return Err(invalid(format!( + "decisions[{index}] round=0 未经提问,answerSource 只能是 default" + ))); + } + if !matches!( + decision.state.as_str(), + "default_pending" | "prototype_pending" + ) { + return Err(invalid(format!( + "decisions[{index}] round=0 只能是 default_pending 或 prototype_pending" + ))); + } + } + 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 + || !matches!(next.phase.as_str(), "collecting" | "revision_requested")) + { + 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 parse_plan_submit_gdd_input_bytes( + 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) +} + +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"); + assert_eq!( + parse_plan_submit_gdd_input_bytes(&bytes).expect("parse input"), + value + ); + let mut newline = bytes.clone(); + newline.push(b'\n'); + assert_eq!( + parse_plan_submit_gdd_input_bytes(&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_plan_submit_gdd_input_bytes(&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 new file mode 100644 index 000000000..e8dc24c0d --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_submit.rs @@ -0,0 +1,5382 @@ +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) +} + +/// 决定台账的权威归属:Runtime 拥有**事实**(用户在第几轮、对着哪道题、原样说了 +/// 什么),策划子 Agent 拥有**判断**(这句话是不是构成对该题的取舍、该记成什么 +/// topic)。 +/// +/// 早期实现要求 submit input 的前缀与 `session.decisionsSummary` 六个字段逐项相等。 +/// 那六个字段没有一个是子 Agent 生产的,它只能从 Supervisor 转述的委派 task 里回抄; +/// 而权威台账从不下发给它,拒绝理由也不含差异。于是「回抄」这件零信息量的动作成了 +/// 唯一的提交前提,用户只要自由填写过一次,逐字复现就依赖一条没有机制保证的 LLM +/// 转述链,抄歪即在 5 次盲重试后硬失败。同一条相等约束还顺带禁掉了子 Agent 纠正 +/// 错误绑定的能力——答非所问被 Runtime 投影成 confirmed 之后,改一个字都过不了。 +/// +/// 现在只守真正要守的那一条:**不能声称用户确认过他没确认的东西**。 +fn submit_decisions_respect_session_authority( + session: &PlanSessionV1, + input: &PlanSubmitGddInputV1, +) -> bool { + // 1. 不得凭空造出用户拍板:任何 confirmed 且非默认来源的决定,都必须命中一条 + // 同 id 的 confirmed session 决定。 + let no_forged_confirmation = input.decisions.iter().all(|decision| { + if decision.state != "confirmed" || decision.answer_source == "default" { + return true; + } + session + .decisions_summary + .iter() + .any(|recorded| recorded.id == decision.id && recorded.state == "confirmed") + }); + // 2. 不得丢弃用户已作出的决定,也不得篡改用户亲自选择的「需要原型验证」。 + // confirmed 允许降级为 default_pending(子 Agent 判定该轮回答并未回答所问 + // 时的唯一出口),但不能凭空消失。 + let no_dropped_authority = session.decisions_summary.iter().all(|recorded| { + let Some(decision) = input + .decisions + .iter() + .find(|decision| decision.id == recorded.id) + else { + return false; + }; + match recorded.state.as_str() { + "prototype_pending" => decision.state == "prototype_pending", + "confirmed" => matches!(decision.state.as_str(), "confirmed" | "default_pending"), + _ => true, + } + }); + // 3. Runtime 生成的原型验证项必须都在,内容由 `apply_plan_session_authority_to_ + // submit_input` 覆盖,不比较;子 Agent 可以另加自己的项,由 `validate_decisions` + // 的「逐项对应全部 prototype_pending 决定」双射约束兜底。 + let no_dropped_prototype_items = session.prototype_validation_items.iter().all(|recorded| { + input + .prototype_validation_items + .iter() + .any(|item| item.id == recorded.id) + }); + no_forged_confirmation && no_dropped_authority && no_dropped_prototype_items +} + +/// 把 Runtime 拥有的字段直接覆盖进 submit input,而不是要求子 Agent 回抄。 +/// +/// 覆盖对象只有「仍然挂着用户权威」的条目:保持 confirmed 的、以及用户亲选的 +/// prototype_pending。子 Agent 判定为未答而降级成 default_pending 的条目,其 +/// answerSummary 描述的是它自己填的默认值,归它所有,不覆盖。`topic` 任何情况下 +/// 都不覆盖——按答案真实内容重新命名决定,正是子 Agent 纠正错误绑定的手段。 +/// +/// 覆盖必须发生在 durable action identity 重放比对之前,且只依赖 session 里 +/// 跨 submit 不变的 `decisionsSummary` / `prototypeValidationItems` +/// (`build_submit_session_successor` 原样克隆这两项),这样同一个 actionId 重放 +/// 时归一化结果稳定,重放比对不会因为覆盖而错判成 payload 不一致。 +fn apply_plan_session_authority_to_submit_input( + session: &PlanSessionV1, + input: &mut PlanSubmitGddInputV1, +) { + for decision in &mut input.decisions { + let Some(recorded) = session + .decisions_summary + .iter() + .find(|recorded| recorded.id == decision.id) + else { + continue; + }; + let carries_user_authority = match recorded.state.as_str() { + "confirmed" => decision.state == "confirmed", + "prototype_pending" => decision.state == "prototype_pending", + _ => false, + }; + if !carries_user_authority { + continue; + } + decision.answer_source = recorded.answer_source.clone(); + decision.round = recorded.round; + decision.answer_summary = recorded.answer_summary.clone(); + } + for item in &mut input.prototype_validation_items { + if let Some(recorded) = session + .prototype_validation_items + .iter() + .find(|recorded| recorded.id == item.id) + { + *item = recorded.clone(); + } + } +} + +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, + input: &PlanSubmitGddInputV1, +) -> 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", + )); + } + // 这条 phase 判据实际只可能看到 `collecting`:上面的 activeRunId 判据要求 + // session 绑着当前策划子 run,而 schema 不变量禁止 `awaiting_user_input`、 + // `awaiting_gdd_approval`、`revision_requested`、`approved`、`rejected`、 + // `recovery_required` 保留 activeRunId(planning_storage.rs 的 + // 「session 进入审批/终态/recovery_required 后不得保留 activeRunId」)。 + // 因此 revise/reject 之后能不能重做,不由这条门决定,而由 M1C-2b 的 continuation + // 起点 writer 决定——它必须以新 activeRunId 写 revision+1 successor,phase 只能落回 + // `collecting`。这里保留 `revision_requested` 作为既有冗余,不再新增更多不可达分支。 + if !matches!(session.phase.as_str(), "collecting" | "revision_requested") { + return Err(submit_error( + "PLAN_PENDING_GDD_EXISTS", + "当前 planning session 仍有未决 GDD", + )); + } + // 这一支和上面三条 CAS 判据性质不同,因此不共用 `PLAN_SESSION_CAS_CONFLICT`。 + // 真 CAS(revision 溢出、session 已被其它动作推进、Runtime source + // revision/fingerprint 无效)说明 durable 权威变了或坏了,重交同一份 input 也 + // 没用,只能 reconcile;而台账逐项比对失败时权威完好,错的是本次 Provider + // input——策划子 Agent 把 session 决策摘要抄漏、抄错或多追加了一条非默认决定。 + // 这正是第 12 节划归「本次 Provider input」的那一类,应该走 rejected + // observation 回灌让它改,受既有 5 次预算约束,而不是硬阻断等人。 + // 不变量本身一个字没放松:不匹配照样拒,只是改了拒绝的后果。 + if !submit_decisions_respect_session_authority(session, input) { + return Err(submit_error( + "PLAN_SESSION_DECISIONS_MISMATCH", + "submit input 的决定台账越过了 planning session 的用户权威", + )); + } + if session.latest_delegation_id != context.delegation_id { + return Err(submit_error( + "PLAN_SOURCE_PROFILE_MISMATCH", + "submit action 的 delegation identity 未逐字匹配当前 session", + )); + } + Ok(()) +} + +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. + 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()) + && previous_session.latest_submitted_ref.is_none() + && matches!( + previous_session.phase.as_str(), + "collecting" | "revision_requested" + ); + 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); + + // Runtime 拥有的决定字段在这里一次性覆盖进 input,之后的重放比对、CAS 与 GDD + // 构建全部使用归一化后的值。放在重放分支之前是必需的:`submit_payload_matches_gdd` + // 拿 input 和已落库 GDD 反推出的 input 比对,只有两侧都归一化过才等价。归一化 + // 只读 `decisionsSummary` / `prototypeValidationItems`,二者跨 submit successor + // 原样保留,所以同一 actionId 重放的结果稳定。session 读不出来时保持原样,把 + // session 错误留给下面既有的分支处置。 + let normalized_input; + let input = match current_session { + Some(session) => { + let mut owned = input.clone(); + apply_plan_session_authority_to_submit_input(session, &mut owned); + normalized_input = owned; + &normalized_input + } + None => input, + }; + + // 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, 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 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:durable session 的 + /// `decisionsSummary` / `prototypeValidationItems` 直接镜像它,于是可以构造出 + /// 「用户已在第 N 轮拍板」「用户亲选了需要原型验证」这类前置台账。 + 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_an_extra_non_default_decision_not_present_in_session() { + 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_option".to_string(), + round: 1, + answer_summary: "伪造为用户已确认".to_string(), + }); + + let error = execute_plan_submit_gdd(&root, &context, &input) + .expect_err("a non-default decision outside the session prefix must fail"); + // 伪造用户确认照样被拒;只是错误码从 CAS 换成了可回灌的输入类, + // 让策划子 Agent 能按理由改稿而不是把整个 Agent 阻断到人工核对。 + assert_eq!(error.code(), "PLAN_SESSION_DECISIONS_MISMATCH"); + assert!(!root.join(".agent/planning/gdd.v1.json").exists()); + cleanup_fixture(root); + } + + /// 用户答案原文归 Runtime 所有:子 Agent 抄歪了直接被覆盖回去,而不是把整条 + /// 提交拒掉。真 CAS(durable 权威已变)仍然是另一回事,必须区分开。 + #[test] + fn a_rewritten_answer_summary_is_overwritten_while_a_stale_session_is_still_a_cas_conflict() { + // 抄错既有决定的正文(权威没变,错的是 input):落库的是权威原文。 + let (root, context, mut input) = submit_fixture(); + let authoritative = input.decisions[0].answer_summary.clone(); + input.decisions[0].answer_summary.push_str("(被改写)"); + execute_plan_submit_gdd(&root, &context, &input) + .expect("a rewritten answer summary is overwritten, not rejected"); + let chain = read_plan_gdd_chain(&root).expect("read submitted chain"); + assert_eq!(chain[0].decisions[0].answer_summary, authoritative); + 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, + "重玩动力(用户实际回答的是这个)" + ); + // 但答案原文仍然是 Runtime 的权威值。 + assert_eq!( + chain[0].decisions[1].answer_summary, + "不要那两个,我要玩家只能移动光源给守卫开路" + ); + cleanup_fixture(root); + } + + /// 降级(confirmed → default_pending)是允许的安全方向;整条丢掉不行——那会让 + /// 用户已经作出的决定从 GDD 里凭空消失。 + #[test] + fn a_confirmed_decision_may_be_downgraded_but_never_dropped() { + 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("downgrade is the safe direction"); + let chain = read_plan_gdd_chain(&root).expect("read submitted chain"); + // 降级之后这条不再声称用户拍过板,正文归子 Agent 所有,不被覆盖。 + 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); + let error = execute_plan_submit_gdd(&root, &context, &input) + .expect_err("dropping a user decision must fail"); + assert_eq!(error.code(), "PLAN_SESSION_DECISIONS_MISMATCH"); + cleanup_fixture(root); + } + + /// 用户亲手选的「需要原型验证」不是子 Agent 可以改判的东西。 + #[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(); + let error = execute_plan_submit_gdd(&root, &context, &input) + .expect_err("a user-picked prototype validation must survive"); + assert_eq!(error.code(), "PLAN_SESSION_DECISIONS_MISMATCH"); + 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 放开的只是状态,不是权威:从未提问过的决定仍然不许声称用户拍过板, + /// 也不许挂上任何 user_* 来源。 + #[test] + fn a_round_zero_decision_still_cannot_claim_any_user_authority() { + let mut confirmed = valid_input(); + confirmed.decisions.push(PlanSubmitDecision { + id: "invented".to_string(), + topic: "没问过却声称已确认".to_string(), + state: "confirmed".to_string(), + answer_source: "default".to_string(), + round: 0, + answer_summary: "伪造".to_string(), + }); + validate_plan_submit_gdd_input(&confirmed).expect_err("round=0 may not be confirmed"); + + let mut sourced = valid_input(); + sourced.decisions.push(PlanSubmitDecision { + id: "invented".to_string(), + topic: "没问过却挂上用户来源".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 user 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_at(&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_at(&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_at(&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_at(&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_at(&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_at(&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_at(&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_at(&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_at(&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); + } + + /// 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_at(&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, &input) + .expect("reject 之后的 continuation 必须能提交同一 lineage 的下一版本"); + 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_at(&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_at(&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_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 4d2311a19..1e061353a 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,6 +69,7 @@ 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, }) } @@ -122,6 +123,7 @@ 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, }) } @@ -319,15 +321,92 @@ pub(in crate::agent) fn append_game_creator_agent_runtime_provider_request_lifec request_id: &str, status: &str, ) -> Result { - append_agent_db_lifecycle_record_idempotent( - root, - "requestId", - request_id, - "status", - status, + append_game_creator_agent_runtime_provider_request_lifecycle_with_session_read( + root, snapshot, request_id, status, false, + ) +} + +/// 调用方已经持有项目写锁时的同一次 lifecycle 追加。 +/// +/// `started` + planning binding 这条分支会重新校验冻结的 planning session;那道 +/// 校验的不持锁版本会自己再去抢同一把项目写锁,在持锁上下文里必然失败,而失败会 +/// 被包成 reconciliation 前缀,主循环见到该前缀就静默返回,策划子 Run 于是停在 +/// running/planning 不动。持锁调用方必须用这一支。 +pub(in crate::agent) fn append_game_creator_agent_runtime_provider_request_lifecycle_at_locked( + root: &Path, + snapshot: &AgentRuntimeProviderRequestSnapshot, + request_id: &str, + status: &str, +) -> Result { + append_game_creator_agent_runtime_provider_request_lifecycle_with_session_read( + root, snapshot, request_id, status, true, + ) +} + +fn append_game_creator_agent_runtime_provider_request_lifecycle_with_session_read( + root: &Path, + snapshot: &AgentRuntimeProviderRequestSnapshot, + request_id: &str, + 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": AGENT_RUNTIME_PROVIDER_REQUEST_LIFECYCLE_SCHEMA_VERSION, + "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, @@ -338,7 +417,15 @@ pub(in crate::agent) fn append_game_creator_agent_runtime_provider_request_lifec "requestSlot": snapshot.request_slot, "webSearchEnabled": snapshot.web_search_enabled, "status": status, - }), + }) + }; + append_agent_db_lifecycle_record_idempotent( + root, + "requestId", + request_id, + "status", + status, + record, ) .map_err(|error| redact_agent_runtime_error(root, &error, 500)) } 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 0e898dc02..92a6d3926 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,6 +325,227 @@ 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, + mcp_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, + mcp_tools: Vec::new(), + 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 { @@ -443,6 +664,11 @@ 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)? + }; Ok(AgentRuntimeProviderRetryIdentity { project_id: snapshot.project_id.clone(), agent_id: snapshot.agent_id.clone(), @@ -456,7 +682,7 @@ pub(in crate::agent) fn game_creator_agent_runtime_provider_retry_identity_for_m applied_steer_cursor: snapshot.applied_steer_cursor, request_kind: snapshot.request_kind.clone(), base_request_slot: snapshot.request_slot.clone(), - request_fingerprint: game_creator_agent_runtime_llm_request_fingerprint(request)?, + request_fingerprint, provider_config_fingerprint: game_creator_agent_runtime_provider_config_fingerprint_for_mode( &agent_mode, @@ -465,6 +691,7 @@ 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(), }) } @@ -486,6 +713,7 @@ 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(), } } @@ -536,6 +764,9 @@ 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 } @@ -552,6 +783,7 @@ 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); @@ -1307,6 +1539,7 @@ 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); @@ -1585,6 +1818,19 @@ 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( @@ -1620,27 +1866,58 @@ pub(in crate::agent) fn game_creator_agent_runtime_provider_request_attempt_id( ) } +pub(in crate::agent) fn game_creator_agent_runtime_provider_request_slot_for_id( + snapshot: &AgentRuntimeProviderRequestSnapshot, + request_id: &str, +) -> 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) + }; + if candidate == request_id { + return Some(if attempt == 0 { + snapshot.request_slot.clone() + } else { + format!("{}-transient-{attempt}", snapshot.request_slot) + }); + } + } + None +} + 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 request_id = - game_creator_agent_runtime_provider_request_attempt_id(base_request_id, attempt); - let transitions = read_agent_db_lifecycle_transitions_at( - root, - AGENT_RUNTIME_PROVIDER_REQUEST_LIFECYCLE_RECORD_TYPE, - "requestId", - &request_id, - )?; - if transitions.is_empty() { - return Ok((request_id, false)); + 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, + )] + }; + for request_id in candidates { + let transitions = read_agent_db_lifecycle_transitions_at( + root, + AGENT_RUNTIME_PROVIDER_REQUEST_LIFECYCLE_RECORD_TYPE, + "requestId", + &request_id, + )?; + if transitions.is_empty() { + return Ok((request_id, false)); + } + if transitions == ["started", "interrupted"] && attempt < MAX_INTERRUPTED_ATTEMPTS { + continue; + } + return Ok((request_id, true)); } - if transitions == ["started", "interrupted"] && attempt < MAX_INTERRUPTED_ATTEMPTS { - continue; - } - return Ok((request_id, true)); } unreachable!("Provider interrupted attempt loop always returns") } @@ -1665,6 +1942,28 @@ 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(); @@ -1709,6 +2008,199 @@ 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 caa6910ae..16b519b90 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,6 +1032,7 @@ 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, @@ -1133,6 +1134,7 @@ 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) => { @@ -1153,7 +1155,8 @@ where "{AGENT_RUNTIME_PROVIDER_REQUEST_RECONCILIATION_PREFIX}: requestId={request_id}" )); } - match append_game_creator_agent_runtime_provider_request_lifecycle( + // 这里仍然持有上面的 control_lock,必须走持锁版本。 + match append_game_creator_agent_runtime_provider_request_lifecycle_at_locked( root, &snapshot, &request_id, @@ -1178,20 +1181,91 @@ 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 = if active.interrupted.load(Ordering::Acquire) { - Ok(None) + let (result, active_millis) = if active.interrupted.load(Ordering::Acquire) { + (Ok(None), 0) } else { - tokio::select! { + let active_started = tokio::time::Instant::now(); + let result = 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 7352ad379..1b371e3ba 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,6 +275,7 @@ 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 dac96b4e1..b235e7361 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,6 +99,7 @@ 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()); } @@ -111,7 +112,7 @@ pub(in crate::agent) fn validate_agent_runtime_run_profile_binding_record( } if binding.profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD && (binding.agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID - || !agent_runtime_supervisor_source_is_trusted(&binding.source)) + || !agent_runtime_supervisor_source_is_autonomous_game_build(&binding.source)) { return Err("自主构建 Run Profile 只允许可信 Supervisor 入口绑定".to_string()); } @@ -126,6 +127,83 @@ 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 003285533..6ec369a4e 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 @@ -760,10 +760,13 @@ fn goal_contract_root_steer_task_at( if task.session_id != session_id || task.parent_agent_id.is_some() || task.parent_run_id.is_some() - || !agent_runtime_supervisor_source_is_trusted(&task.source) { return Ok(None); } + reject_supervisor_plan_root_steer(&task.source)?; + if !agent_runtime_supervisor_source_is_trusted(&task.source) { + return Ok(None); + } let Some(binding) = read_game_creator_agent_runtime_run_profile_binding(root, agent_id, run_id)? else { @@ -1152,6 +1155,11 @@ 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( @@ -1174,6 +1182,7 @@ 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_protocol/verification.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/verification.rs index 30592a7d7..061510810 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/verification.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/verification.rs @@ -169,6 +169,7 @@ pub(in crate::agent) fn validate_agent_runtime_verification_gate( tool, "project.verify" | "game.static_smoke" + | AGENT_RUNTIME_OWNER_ARTIFACT_VERIFICATION_TOOL | "command.exec" | "preview.validate" | "canvas.asset_generate" 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 cd187fe46..c3ca53fbf 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 @@ -554,7 +554,10 @@ pub(super) fn prepare_game_creator_agent_runtime_completed_state( state.current_action = "等待下一轮输入".to_string(); state.waiting_on = "开发者下一轮输入".to_string(); state.next_step = "等待下一轮输入".to_string(); - state.last_response = Some(sanitize_agent_runtime_text(response, 500)); + state.last_response = Some(sanitize_agent_runtime_text( + response, + static_delegate_result_detail_max_chars(response, 500), + )); state.error = None; complete_agent_runtime_remaining_plan_steps(&mut state, "本轮 Agent 已生成最终回复。"); state @@ -1600,6 +1603,8 @@ 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, max_loop_iterations: AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT as u32, tool_action_budget: AGENT_RUNTIME_BACKGROUND_TOOL_ACTION_LIMIT as u32, plan_revision: 0, @@ -1615,7 +1620,7 @@ pub(crate) fn default_game_creator_agent_runtime_state( recent_tool_calls: Vec::new(), pending_tool_action: None, task_queue: AgentRuntimeTaskQueueSummary::default(), - allowed_tools: default_game_creator_agent_runtime_allowed_tools(), + allowed_tools: default_game_creator_agent_runtime_allowed_tools_for_agent(agent_id), tool_policy: AgentRuntimeToolPolicySnapshot::default(), applied_steer_cursor: 0, applied_steer_refs: Vec::new(), @@ -1662,6 +1667,68 @@ pub(crate) fn default_game_creator_agent_runtime_allowed_tools() -> Vec .collect() } +/// 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, +) -> 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)); + } +} + pub(super) fn normalize_game_creator_agent_runtime_state( state: &mut AgentRuntimeState, agent_id: &str, @@ -1772,7 +1839,16 @@ pub(super) fn normalize_game_creator_agent_runtime_state( state.next_step = "修复计划快照后恢复当前 run".to_string(); state.error = Some(sanitize_agent_runtime_text(&error, 500)); } - if state.allowed_tools.is_empty() { + 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() { state.allowed_tools = default_game_creator_agent_runtime_allowed_tools(); } else { for tool in default_game_creator_agent_runtime_allowed_tools() { @@ -1784,7 +1860,37 @@ pub(super) fn normalize_game_creator_agent_runtime_state( if state.updated_at == 0 { state.updated_at = unix_timestamp(); } - if state.tool_policy.allowed_tools.is_empty() { + 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() { state.tool_policy.allowed_tools = agent_runtime_executable_tools() .into_iter() .map(str::to_string) @@ -3313,7 +3419,10 @@ pub(super) fn agent_runtime_terminal_detail(state: &AgentRuntimeState) -> Option "cancelled" => Some(state.current_action.as_str()), _ => None, }?; - let detail = sanitize_agent_runtime_text(detail, 500); + // 必须与 last_response 用同一条上限规则:两者相等是 finalization 幂等校验的 + // 不变式(见 finish_game_creator_agent_background_runtime_turn_idempotently_at)。 + let detail = + sanitize_agent_runtime_text(detail, static_delegate_result_detail_max_chars(detail, 500)); (!detail.trim().is_empty()).then_some(detail) } 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 80db1ffa4..c37a10537 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 @@ -34,11 +34,14 @@ pub(in crate::agent) use project_ops::*; pub(in crate::agent) use run_status::*; pub(in crate::agent) use task_ops::*; +#[cfg(test)] +pub(crate) use delegation::observe_agent_runtime_agent_delegate_at_locked; #[cfg(test)] pub(crate) use delivery::{ build_static_delegate_result_for_child_at, convert_game_chat_child_user_input_to_safe_default_repair, trusted_game_chat_autonomous_root_parent_at, + wake_waiting_static_delegate_parent_run_for_test_at, }; #[cfg(test)] pub(crate) use media::validate_agent_runtime_canvas_replacement_authorization_at; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/command_ops.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/command_ops.rs index c86494dab..15e3382d1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/command_ops.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/command_ops.rs @@ -730,6 +730,28 @@ pub(crate) fn observe_agent_runtime_limited_command( detail: None, }; } + match autonomous_owner_artifact_validation_available_for_run_at(root, agent_id, run_id) { + Ok(true) => { + return AgentRuntimeToolObservation { + tool: "command.run_limited".to_string(), + status: "failed".to_string(), + summary: "固定 owner 产物不能使用 game.static_smoke 验证".to_string(), + detail: Some( + "Runtime 会在收束门内验证当前 Agent/run 的固定正式产物;game.static_smoke 只验证可玩入口" + .to_string(), + ), + }; + } + Ok(false) => {} + Err(error) => { + return AgentRuntimeToolObservation { + tool: "command.run_limited".to_string(), + status: "failed".to_string(), + summary: "无法核对 game.static_smoke 的当前 run 身份".to_string(), + detail: Some(redact_agent_runtime_project_paths(root, &error, 500)), + }; + } + } let _lock = match acquire_project_write_lock(root, "command.run_limited") { Ok(lock) => lock, Err(error) => { 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 af44bb3e1..4d18851aa 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 @@ -201,6 +201,68 @@ pub(crate) fn observe_agent_runtime_agent_message( } } +/// 委派 task 末尾那句「你在这条链路上的位置」。三种跳的语义互不相同,共用一句话 +/// 就会说谎,所以这里把它显式化。 +/// +/// - `Repair`:质量返工,`repair_depth` 上限 1,「唯一返工轮」是真的。而且这句话是 +/// 做游戏链路的**授权信号**——`design-foundation` / `art-director` / +/// `art-asset-plan` 的角色提示词都把「任务正文明确标识这是带 repairOfDelegationId +/// 的唯一返工轮」当作 `replaceExisting=true` 的唯一许可(见 agent/prompt.rs 的三处 +/// 角色 prompt)。这一支逐字不能动。 +/// - `PlanClarification`:澄清续跑不消耗 `repair_depth`,预算是 +/// `static_delegate_clarification_round_limit_at`(plan 链路 3 轮)。master 只有一道 +/// 平坦的 depth <= 1 门,那时「唯一返工轮」对澄清跳也成立;本仓库改成按谱系分类后 +/// 把预算抬到 3,这句话就变成了假天花板——生产实测 4 次澄清续跑全部命中它,命中后 +/// 全部直接出稿,没有任何一个 run 走到第 2 轮。 +/// - `None`:普通委派,不加这一段。 +pub(in crate::agent) enum StaticDelegateHopNote<'a> { + None, + Repair { + original_delegation_id: &'a str, + }, + PlanClarification { + original_delegation_id: &'a str, + rounds_used: u32, + rounds_limit: u32, + }, +} + +impl StaticDelegateHopNote<'_> { + fn is_none(&self) -> bool { + matches!(self, StaticDelegateHopNote::None) + } + + fn render(&self) -> String { + match self { + StaticDelegateHopNote::None => String::new(), + StaticDelegateHopNote::Repair { + original_delegation_id, + } => format!("\n\n这是对已认领委派 {original_delegation_id} 的唯一返工轮。"), + // 预算用尽:planning_coordinator 出卡时会用 + // `current_round >= 3` 直接拒掉第四张卡,所以这里不能再邀请提问, + // 只能要求收稿——语义上等价于原型的 INJ_MUST_DRAFT_ROUNDS。 + StaticDelegateHopNote::PlanClarification { + original_delegation_id, + rounds_used, + rounds_limit, + } if rounds_used >= rounds_limit => format!( + "\n\n这是对已认领委派 {original_delegation_id} 的澄清续跑,不是返工轮。已用澄清轮次 {rounds_used}/{rounds_limit},澄清预算已用尽:本轮不得再输出 AGC_NEEDS_USER_INPUT_V1 信封,剩余空白按默认建议补齐并标 default_pending,立即提交 GDD。" + ), + // 轮号必须和 planning_coordinator 出卡时的期望一致:那边用 + // `validate_exact_plan_clarification_question(.., current_round + 1)`, + // current_round 就是本 delivery 的谱系轮次,也就是这里的 rounds_used。 + StaticDelegateHopNote::PlanClarification { + original_delegation_id, + rounds_used, + rounds_limit, + } => format!( + "\n\n这是对已认领委派 {original_delegation_id} 的澄清续跑,不是返工轮,不消耗返工深度。已用澄清轮次 {rounds_used}/{rounds_limit}。仍有会实质改变结果的空白且预算未用尽时,可以继续以 AGC_NEEDS_USER_INPUT_V1 信封退出:questions 恰好一题,header 必须精确等于「第{next_round}轮·关键决定」。预算已用尽,或剩余空白能由默认建议覆盖且不影响首个可玩闭环时,立即提交 GDD。", + next_round = rounds_used.saturating_add(1), + ), + } + } +} + pub(in crate::agent) fn render_static_delegate_task_contract( task: &str, parent_agent_id: &str, @@ -208,12 +270,9 @@ pub(in crate::agent) fn render_static_delegate_task_contract( delegation_id: &str, acceptance_criteria: &[String], expected_artifacts: &[String], - repair_of_delegation_id: Option<&str>, + hop_note: StaticDelegateHopNote<'_>, ) -> Result { - if acceptance_criteria.is_empty() - && expected_artifacts.is_empty() - && repair_of_delegation_id.is_none() - { + if acceptance_criteria.is_empty() && expected_artifacts.is_empty() && hop_note.is_none() { return Ok(task.to_string()); } let criteria = if acceptance_criteria.is_empty() { @@ -234,11 +293,9 @@ pub(in crate::agent) fn render_static_delegate_task_contract( .collect::>() .join("\n") }; - let repair = repair_of_delegation_id - .map(|delegation_id| format!("\n\n这是对已认领委派 {delegation_id} 的唯一返工轮。")) - .unwrap_or_default(); + let hop_note_text = hop_note.render(); let rendered = format!( - "{task}\n\n委派验收合同:\n- parentAgentId: {parent_agent_id}\n- parentRunId: {parent_run_id}\n- delegationId: {delegation_id}\n验收标准:\n{criteria}\n预期产物:\n{artifacts}{repair}\n你只向父 Agent 提交内部回执和证据,不直接回答正式用户。交付前逐项核对;无法满足时明确说明缺口,不得假装完成。" + "{task}\n\n委派验收合同:\n- parentAgentId: {parent_agent_id}\n- parentRunId: {parent_run_id}\n- delegationId: {delegation_id}\n验收标准:\n{criteria}\n预期产物:\n{artifacts}{hop_note_text}\n你只向父 Agent 提交内部回执和证据,不直接回答正式用户。交付前逐项核对;无法满足时明确说明缺口,不得假装完成。" ); if rendered.chars().count() > AGENT_RUNTIME_TASK_MAX_CHARS { return Err(format!( @@ -434,6 +491,49 @@ pub(crate) fn observe_agent_runtime_agent_delegate( action_id: Option<&str>, input: &serde_json::Value, ) -> AgentRuntimeToolObservation { + let project_write_lock = match acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "runtime.snapshot.agent.delegate.direct", + ) { + Ok(lock) => lock, + Err(error) => { + return AgentRuntimeToolObservation { + tool: "agent.delegate".to_string(), + status: "failed".to_string(), + summary: "无法取得一致项目快照".to_string(), + detail: Some(redact_agent_runtime_project_paths(root, &error, 500)), + }; + } + }; + observe_agent_runtime_agent_delegate_at_locked( + root, + agent_id, + parent_run_id, + action_id, + input, + &project_write_lock, + ) +} + +pub(crate) fn observe_agent_runtime_agent_delegate_at_locked( + root: &Path, + agent_id: &str, + parent_run_id: &str, + action_id: Option<&str>, + input: &serde_json::Value, + project_write_lock: &ProjectWriteLock, +) -> AgentRuntimeToolObservation { + if !project_write_lock + .guards_project_root(root) + .unwrap_or(false) + { + return AgentRuntimeToolObservation { + tool: "agent.delegate".to_string(), + status: "failed".to_string(), + summary: "agent.delegate 缺少当前项目写锁".to_string(), + detail: None, + }; + } let target_agent_id = agent_runtime_tool_input_text(input, &["agentId", "targetAgentId"]); let target_agent_id = match normalize_game_creator_runtime_agent_id(target_agent_id.as_str()) { Ok(target_agent_id) => target_agent_id, @@ -478,6 +578,58 @@ pub(crate) fn observe_agent_runtime_agent_delegate( 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) @@ -494,14 +646,36 @@ pub(crate) fn observe_agent_runtime_agent_delegate( detail: None, }; } - let acceptance_criteria = agent_runtime_tool_input_string_list( + let mut acceptance_criteria = agent_runtime_tool_input_string_list( input, &["acceptanceCriteria", "acceptance_criteria", "criteria"], ); - let expected_artifacts = agent_runtime_tool_input_string_list( + let mut expected_artifacts = agent_runtime_tool_input_string_list( input, &["expectedArtifacts", "expected_artifacts", "artifacts"], ); + // 返工与澄清续跑必须逐字继承原委派的两个数组(下面 + // validate_static_delegate_repair_request_at 会逐项比对)。两个都空时由 Runtime + // 从原 delivery 补齐,省掉一次 agent.run_status 往返和一整轮手抄。只补齐"两个 + // 都空"这一种形态:只写了一半是有歧义的输入,仍旧交给既有比对报错。 + // 读不到原 delivery 时保持原样,让下游的既有错误如实说明问题。 + { + let original_delegation_id = agent_runtime_tool_input_text( + input, + &["repairOfDelegationId", "repair_of_delegation_id"], + ); + if !original_delegation_id.trim().is_empty() + && acceptance_criteria.is_empty() + && expected_artifacts.is_empty() + { + if let Ok(Some(original)) = + read_static_delegate_delivery_at(root, original_delegation_id.trim()) + { + acceptance_criteria = original.acceptance_criteria.clone(); + expected_artifacts = original.expected_artifacts.clone(); + } + } + } let explicit_contract = input.as_object().is_some_and(|object| { object.contains_key("acceptanceCriteria") || object.contains_key("acceptance_criteria") @@ -696,6 +870,74 @@ pub(crate) fn observe_agent_runtime_agent_delegate( let delegated_task_text = safe_default_repair_instruction .map(|instruction| format!("{instruction}\n\n{task}")) .unwrap_or(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 根下, + // 做游戏 / 做素材 / game-chat 的澄清续跑仍走 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 + }; + 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) => StaticDelegateHopNote::Repair { + original_delegation_id, + }, + (None, _) => StaticDelegateHopNote::None, + }; let delegated_task = match render_static_delegate_task_contract( &delegated_task_text, agent_id, @@ -703,7 +945,7 @@ pub(crate) fn observe_agent_runtime_agent_delegate( &delegation_id, &acceptance_criteria, &expected_artifacts, - repair_of_delegation_id.as_deref(), + hop_note, ) { Ok(task) => task, Err(error) => { @@ -958,21 +1200,25 @@ pub(crate) fn observe_agent_runtime_agent_delegate( } if let Some(terminal_status) = game_creator_agent_runtime_terminal_status(&existing) { - let result_summary = existing + let result_detail = existing .terminal_detail .as_deref() .or(existing.error.as_deref()) .unwrap_or(existing.current_action.as_str()); - let result_summary = truncate_agent_runtime_text( - &redact_agent_runtime_project_paths(root, result_summary, 240), - 140, + // 摘要给人看,明细给回执解析:两者不能共用一次截断,否则一行摘要 + // 的长度会决定结构化载荷能不能被父 run 解析。 + let result_detail = redact_agent_runtime_project_paths( + root, + result_detail, + static_delegate_result_detail_max_chars(result_detail, 240), ); + let result_summary = truncate_agent_runtime_text(&result_detail, 140); let structured_result = match build_static_delegate_result_for_child_at( root, &delivery, &existing, terminal_status, - &result_summary, + &result_detail, ) { Ok(result) => result, Err(error) => { @@ -1142,7 +1388,7 @@ pub(crate) fn observe_agent_runtime_agent_delegate( if parent_session_id.is_some() { drop(dispatch_lock.take()); } - match start_game_creator_agent_background_task_with_link_at( + match start_game_creator_agent_background_task_with_link_locked_at( root, &target_agent_id, requested_session_id, @@ -1151,6 +1397,7 @@ pub(crate) fn observe_agent_runtime_agent_delegate( "agent-delegate", None, Some(&task_link), + project_write_lock, ) { Ok((runtime, delegated_run_id)) => { if let Some((_, expected_run_id)) = target_session_id.as_ref() { @@ -1233,15 +1480,17 @@ pub(crate) fn observe_agent_runtime_agent_delegate( if let Some(terminal_status) = game_creator_agent_runtime_terminal_status(&existing) { - let result_summary = existing + let result_detail = existing .terminal_detail .as_deref() .or(existing.error.as_deref()) .unwrap_or(error.as_str()); - let result_summary = truncate_agent_runtime_text( - &redact_agent_runtime_project_paths(root, result_summary, 240), - 140, + let result_detail = redact_agent_runtime_project_paths( + root, + result_detail, + static_delegate_result_detail_max_chars(result_detail, 240), ); + let result_summary = truncate_agent_runtime_text(&result_detail, 140); let ready_result = (|| { let _delivery_lock = try_acquire_game_creator_agent_delegation_lock_with_wait( @@ -1262,7 +1511,7 @@ pub(crate) fn observe_agent_runtime_agent_delegate( &delivery, &existing, terminal_status, - &result_summary, + &result_detail, )?; mark_static_delegate_delivery_ready_with_result_at( root, @@ -1372,6 +1621,43 @@ 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, @@ -1483,9 +1769,14 @@ 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_SUPERVISOR_AGENT_ID + && template != GAME_CREATOR_PROJECT_PLANNING_AGENT_ID => { template } @@ -1680,3 +1971,88 @@ pub(crate) fn observe_agent_runtime_agent_spawn_isolated( .map(|value| redact_agent_runtime_project_paths(root, &value, 3_600)), } } + +#[cfg(test)] +mod tests { + use super::*; + + /// 委派 task 末尾那句话是两条链路的合同,不能共用一份文案。 + /// + /// 上半条钉做游戏链路:`design-foundation` / `art-director` / `art-asset-plan` + /// 的角色提示词把「任务正文明确标识这是带 repairOfDelegationId 的唯一返工轮」 + /// 当作 `replaceExisting=true` 的唯一授权信号,改一个字就会让返工轮拿不到许可。 + /// + /// 下半条钉立项策划链路:澄清续跑不是返工轮,套用返工文案等于告诉策划子 Agent + /// 「你只剩这一轮」——这正是生产上 4 次澄清续跑之后无一走到第 2 轮的原因。 + /// 同时钉住轮号:`planning_coordinator` 出卡时按 `rounds_used + 1` 校验 header, + /// 这里写进 task 的必须是同一个数,否则第 2 轮信封会当场被拒。 + #[test] + fn plan_clarification_hop_note_is_not_the_repair_round_note() { + let repair = render_static_delegate_task_contract( + "任务", + "project-supervisor", + "run-1", + "delegation-new", + &["交付产物".to_string()], + &["assets/art-spec.png".to_string()], + StaticDelegateHopNote::Repair { + original_delegation_id: "delegation-old", + }, + ) + .expect("render repair"); + assert!( + repair.contains("这是对已认领委派 delegation-old 的唯一返工轮。"), + "返工轮文案是做游戏链路 replaceExisting 的授权信号,必须逐字保留:{repair}" + ); + + let clarification = render_static_delegate_task_contract( + "任务", + "project-supervisor", + "run-1", + "delegation-new", + &["交付 game/fast_gdd.md".to_string()], + &["game/fast_gdd.md".to_string()], + StaticDelegateHopNote::PlanClarification { + original_delegation_id: "delegation-old", + rounds_used: 1, + rounds_limit: 3, + }, + ) + .expect("render clarification"); + assert!( + !clarification.contains("唯一返工轮"), + "澄清续跑不得复用返工文案,否则策划子 Agent 以为只剩这一轮:{clarification}" + ); + assert!( + clarification.contains("已用澄清轮次 1/3"), + "澄清续跑必须写明已用轮次与上限:{clarification}" + ); + assert!( + clarification.contains("第2轮·关键决定"), + "task 里的轮号必须等于 planning_coordinator 校验 header 时用的 rounds_used + 1:{clarification}" + ); + + let exhausted = render_static_delegate_task_contract( + "任务", + "project-supervisor", + "run-1", + "delegation-new", + &["交付 game/fast_gdd.md".to_string()], + &["game/fast_gdd.md".to_string()], + StaticDelegateHopNote::PlanClarification { + original_delegation_id: "delegation-old", + rounds_used: 3, + rounds_limit: 3, + }, + ) + .expect("render exhausted clarification"); + assert!( + exhausted.contains("澄清预算已用尽"), + "预算用尽时必须要求收稿,出卡侧会直接拒掉第四张卡:{exhausted}" + ); + assert!( + !exhausted.contains("第4轮·关键决定"), + "预算用尽时不得再给出下一轮 header,那是一张永远递不上去的卡:{exhausted}" + ); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delivery.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delivery.rs index ec82d5121..abc6f1d79 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delivery.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delivery.rs @@ -368,6 +368,20 @@ pub(in crate::agent) fn game_chat_code_parent_terminal_delivery_error_at( delivery.target_agent_id ))); } + // 用户修订只出现在做方案链路;出现在 game-chat 美术回执上即身份不一致。 + StaticDelegateContractStatus::UserRevisionRequested => { + return Ok(Some(format!( + "game-chat 专业美术子任务出现做方案链路的用户修订状态:{}", + delivery.target_agent_id + ))); + } + // M1C-0b:未知 durable status 一律最大化阻塞,只拒不放。 + StaticDelegateContractStatus::Unknown(_) => { + return Ok(Some(format!( + "game-chat 专业美术子任务的委派状态不被当前版本识别:{}", + delivery.target_agent_id + ))); + } } } Ok(None) @@ -708,7 +722,13 @@ pub(crate) fn build_static_delegate_result_for_child_at( .error .as_deref() .or((!result_detail.trim().is_empty()).then_some(result_detail)); - let result_detail = result_detail.map(|value| redact_agent_runtime_error(root, value, 500)); + let result_detail = result_detail.map(|value| { + redact_agent_runtime_error( + root, + value, + static_delegate_result_detail_max_chars(value, 500), + ) + }); let mut result = build_static_delegate_structured_result_at( root, terminal_status, @@ -888,6 +908,17 @@ pub(in crate::agent) fn wake_waiting_static_delegate_parent_run_at( )?; return Ok(true); } + // Parent wake follows project -> execution ordering. Do not hold the + // Supervisor execution lane while the planning/session projection reads + // or writes the project lock. + let project_lock = match acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "planning.parent-wake", + ) { + Ok(lock) => lock, + Err(error) if static_delegate_parent_wake_error_is_transient(&error) => return Ok(false), + Err(error) => return Err(error), + }; let Some(runtime_lock) = try_acquire_game_creator_agent_runtime_task_lock(root, &parent_task.agent_id)? else { @@ -909,6 +940,14 @@ pub(in crate::agent) fn wake_waiting_static_delegate_parent_run_at( if barrier.has_waiting() { return Ok(false); } + // 用户修订是一条显式的 Supervisor 决策边界:在它派出续作之前,父 run 绝不能 + // 被自动恢复。这条语义由 `has_waiting()` 承担(它把 userRevisionPending 计入 + // 等待),所以上面那道门已经覆盖。断言把这份跨文件依赖钉在使用现场——若哪天 + // `has_waiting()` 不再计入该计数,这里会立刻炸而不是静默跨过决策边界。 + debug_assert_eq!( + barrier.user_revision_pending_count, 0, + "userRevisionPending 必须已被 has_waiting() 拦下,否则父 run 会越过用户修订边界自动恢复" + ); let state = read_game_creator_agent_runtime_for_session_at( root, ¤t_task.agent_id, @@ -928,21 +967,38 @@ pub(in crate::agent) fn wake_waiting_static_delegate_parent_run_at( ¤t_task.run_id, )?; let mut state = state; - if !ensure_static_delegate_user_input_wait_at(root, &mut state, &deliveries)? { - let state = advance_game_creator_agent_runtime_turn_at( - root, - state, - "planning", - "自主构建澄清已转换为安全默认返工", - "专业 Agent 回执已按安全默认策略进入 needs-repair,恢复同一父 run。", - )?; - let root = root.to_path_buf(); - let agent_id = current_task.agent_id.clone(); - let task = current_task.task.clone(); - tauri::async_runtime::spawn(async move { - let _runtime_lock = runtime_lock; - drain_game_creator_agent_background_tasks(root, agent_id, task, state).await; - }); + // `_locked` 返回 false 有两种来源,必须分开处理:master 的 game-chat 自主链路 + // 会把澄清转换成安全默认返工(`convert_claimed_game_chat_user_input_deliveries_to_repair_at` + // 内部门的就是这个判据),此时父 run 必须继续跑完;其余链路的 false 表示 + // barrier 与 delivery 快照在两次读之间变了,要保持回执等待态由有界 parent-wake 重试。 + let game_chat_autonomous = + trusted_game_chat_autonomous_parent_chain_at(root, ¤t_task)?.is_some(); + if !ensure_static_delegate_user_input_wait_at_locked( + root, + &mut state, + &deliveries, + &project_lock, + )? { + if game_chat_autonomous { + let state = advance_game_creator_agent_runtime_turn_at( + root, + state, + "planning", + "自主构建澄清已转换为安全默认返工", + "专业 Agent 回执已按安全默认策略进入 needs-repair,恢复同一父 run。", + )?; + let root = root.to_path_buf(); + let agent_id = current_task.agent_id.clone(); + let task = current_task.task.clone(); + tauri::async_runtime::spawn(async move { + let _runtime_lock = runtime_lock; + drain_game_creator_agent_background_tasks(root, agent_id, task, state).await; + }); + } else { + // Claiming success here would strand the run without either a + // pending card or another wake. + return Ok(false); + } } return Ok(true); } @@ -953,6 +1009,7 @@ pub(in crate::agent) fn wake_waiting_static_delegate_parent_run_at( "专业 Agent 已完成,父 run 正在认领委派回执", "静态委派回执已就绪,恢复同一父 run。", )?; + drop(project_lock); let root = root.to_path_buf(); let agent_id = current_task.agent_id.clone(); let task = current_task.task.clone(); @@ -963,6 +1020,14 @@ pub(in crate::agent) fn wake_waiting_static_delegate_parent_run_at( Ok(true) } +#[cfg(test)] +pub(crate) fn wake_waiting_static_delegate_parent_run_for_test_at( + root: &Path, + parent_task: &AgentRuntimeTaskRecord, +) -> Result { + wake_waiting_static_delegate_parent_run_at(root, parent_task) +} + pub(in crate::agent) fn wake_waiting_autonomous_manifest_parent_run_at( root: &Path, parent_task: &AgentRuntimeTaskRecord, @@ -1430,7 +1495,11 @@ pub(crate) fn publish_game_creator_agent_delegate_result( .or(child_task.terminal_detail.as_deref()) .or(child_task.error.as_deref()) .unwrap_or(child_task.current_action.as_str()); - let result_detail = redact_agent_runtime_error(root, result_detail, 600); + let result_detail = redact_agent_runtime_error( + root, + result_detail, + static_delegate_result_detail_max_chars(result_detail, 600), + ); if uses_durable_delivery { let existing_delivery = match read_static_delegate_delivery_at(root, delegation_id) { Ok(Some(delivery)) => delivery, @@ -1587,7 +1656,7 @@ pub(crate) fn publish_game_creator_agent_delegate_result( "contractStatus": delivery .structured_result .as_ref() - .map(|result| result.contract_status), + .map(|result| result.contract_status.clone()), "artifactCount": delivery .structured_result .as_ref() 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 a68fe2100..8d35de0f7 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 @@ -1,42 +1,188 @@ use super::*; -fn design_foundation_owned_project_path(path: &str) -> bool { - matches!(path, "memory/project.md" | "game/game_design.md") +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum GameChatDelegatedArtAgentMutationScope { + Unrestricted, + AssetsOnly, + RetryLineageBlocked, } -fn game_chat_delegated_art_agent_is_assets_only_at( +pub(in crate::agent) fn game_chat_dynamic_art_child_structural_identity_at( root: &Path, - agent_id: &str, - run_id: &str, + task: &AgentRuntimeTaskRecord, ) -> Result { - if !matches!(agent_id, "art-director" | "art-asset-plan") { + if !matches!(task.agent_id.as_str(), "art-director" | "art-asset-plan") + || !matches!( + task.source.as_str(), + "agent-delegate" | "agent-delegate-retry" + ) + || task.parent_agent_id.as_deref() != Some("code-prototype") + { return Ok(false); } - let Some(binding) = - read_game_creator_agent_runtime_run_profile_binding(root, agent_id, run_id)? + let Some(parent_run_id) = task + .parent_run_id + .as_deref() + .filter(|value| !value.is_empty()) else { return Ok(false); }; - if binding.profile != AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD - || binding.source != "agent-delegate" - || binding.parent_agent_id.as_deref() != Some("code-prototype") - || binding.parent_run_id.as_deref().is_none_or(str::is_empty) - || binding.root_agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID - { + let Some(child_binding) = + read_game_creator_agent_runtime_run_profile_binding(root, &task.agent_id, &task.run_id)? + else { return Ok(false); - } - let root_binding = read_game_creator_agent_runtime_run_profile_binding( + }; + let Some(parent_binding) = + read_game_creator_agent_runtime_run_profile_binding(root, "code-prototype", parent_run_id)? + else { + return Ok(false); + }; + let Some(root_binding) = read_game_creator_agent_runtime_run_profile_binding( root, - &binding.root_agent_id, - &binding.root_run_id, + &child_binding.root_agent_id, + &child_binding.root_run_id, )? - .ok_or_else(|| "美术 Agent 写入路径校验缺少根 Run Profile 绑定".to_string())?; - if root_binding.source != AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE - || root_binding.profile != AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + else { + return Ok(false); + }; + + Ok(child_binding.agent_id == task.agent_id + && child_binding.run_id == task.run_id + && child_binding.source == task.source + && child_binding.profile == task.run_profile + && child_binding.binding_fingerprint == task.run_profile_binding_fingerprint + && child_binding.parent_agent_id == task.parent_agent_id + && child_binding.parent_run_id == task.parent_run_id + && child_binding.profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + && child_binding.root_agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID + && parent_binding.agent_id == "code-prototype" + && parent_binding.run_id == parent_run_id + && parent_binding.source == "agent-ready-task-scheduler" + && parent_binding.profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + && parent_binding.parent_agent_id.as_deref() + == Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID) + && parent_binding.parent_run_id.as_deref() == Some(child_binding.root_run_id.as_str()) + && parent_binding.project_id == child_binding.project_id + && parent_binding.root_agent_id == child_binding.root_agent_id + && parent_binding.root_run_id == child_binding.root_run_id + && child_binding.parent_binding_fingerprint.as_deref() + == Some(parent_binding.binding_fingerprint.as_str()) + && root_binding.project_id == child_binding.project_id + && root_binding.agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID + && root_binding.run_id == child_binding.root_run_id + && root_binding.source == AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE + && root_binding.profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + && root_binding.root_agent_id == root_binding.agent_id + && root_binding.root_run_id == root_binding.run_id + && root_binding.parent_agent_id.is_none() + && root_binding.parent_run_id.is_none() + && root_binding.parent_binding_fingerprint.is_none() + && parent_binding.parent_binding_fingerprint.as_deref() + == Some(root_binding.binding_fingerprint.as_str())) +} + +fn game_chat_dynamic_art_retry_claims_game_chat_lineage_at( + root: &Path, + task: &AgentRuntimeTaskRecord, +) -> Result { + if task.source != "agent-delegate-retry" + || !matches!(task.agent_id.as_str(), "art-director" | "art-asset-plan") { return Ok(false); } - Ok(true) + if task.parent_agent_id.as_deref() == Some("code-prototype") { + return Ok(true); + } + let child_binding = match read_game_creator_agent_runtime_run_profile_binding( + root, + &task.agent_id, + &task.run_id, + ) { + Ok(Some(binding)) => binding, + // A retry-source art task without a complete immutable binding chain + // cannot be downgraded to ordinary unrestricted art permissions. + Ok(None) | Err(_) => return Ok(true), + }; + if child_binding.agent_id != task.agent_id + || child_binding.run_id != task.run_id + || child_binding.source != task.source + || child_binding.profile != task.run_profile + || child_binding.binding_fingerprint != task.run_profile_binding_fingerprint + || child_binding.parent_agent_id != task.parent_agent_id + || child_binding.parent_run_id != task.parent_run_id + { + return Ok(true); + } + if child_binding.parent_agent_id.as_deref() == Some("code-prototype") { + return Ok(true); + } + let child_root_binding = match read_game_creator_agent_runtime_run_profile_binding( + root, + &child_binding.root_agent_id, + &child_binding.root_run_id, + ) { + Ok(Some(binding)) => binding, + Ok(None) | Err(_) => return Ok(true), + }; + Ok( + child_root_binding.agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID + && child_root_binding.source == AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + ) +} + +fn game_chat_delegated_art_agent_mutation_scope_at( + root: &Path, + agent_id: &str, + run_id: &str, +) -> Result { + let canonical_output = match agent_id { + "art-director" => AGENT_RUNTIME_ART_SPEC_PATH, + "art-asset-plan" => AGENT_RUNTIME_ART_SPRITESHEET_PATH, + _ => return Ok(GameChatDelegatedArtAgentMutationScope::Unrestricted), + }; + if let Some(task) = + read_latest_game_creator_agent_runtime_task_by_run_id(root, agent_id, run_id)? + { + if game_chat_dynamic_art_retry_claims_game_chat_lineage_at(root, &task)? { + return Ok(GameChatDelegatedArtAgentMutationScope::RetryLineageBlocked); + } + // Live game-chat dynamic art children only ever run with the + // `agent-delegate` source (retry lineage is classified above). + // Scheduler-seeded DAG tasks and plain background runs must not reach + // the strict predicate: it fails closed on a missing run-profile + // binding and would block even their read-only tools. + if task.source != "agent-delegate" { + return Ok(GameChatDelegatedArtAgentMutationScope::Unrestricted); + } + } + // `assets/**` is a broad write scope. Grant it only after the same + // canonical-output authorization used by Canvas replacement has verified + // the live root, main parent, child binding, durable delivery and audited + // generate-missing route. + Ok( + if game_chat_fast_path_scheduled_art_contract_repair_is_authorized_at( + root, + agent_id, + run_id, + canonical_output, + )? { + GameChatDelegatedArtAgentMutationScope::AssetsOnly + } else { + GameChatDelegatedArtAgentMutationScope::Unrestricted + }, + ) +} + +fn game_chat_dynamic_art_retry_mutation_block(tool: &str) -> AgentRuntimeToolObservation { + AgentRuntimeToolObservation { + tool: tool.to_string(), + status: "blocked".to_string(), + summary: "game-chat 动态美术 child 不支持通用 retry 写入".to_string(), + detail: Some( + "请继续 game-chat 对话,由下一轮 main code-prototype 重新完成 asset.list 审计,再按仍存在的缺口建立新的 durable 美术委派" + .to_string(), + ), + } } fn game_chat_art_child_tool_is_read_only(tool: &str) -> bool { @@ -65,17 +211,26 @@ pub(in crate::agent) fn game_chat_delegated_art_agent_project_path_mutation_bloc tool: &str, path: &str, ) -> Option { - match game_chat_delegated_art_agent_is_assets_only_at(root, agent_id, run_id) { - Ok(false) => None, - Ok(true) if path == "assets" || path.starts_with("assets/") => None, - Ok(true) => Some(AgentRuntimeToolObservation { - tool: tool.to_string(), - status: "blocked".to_string(), - summary: "game-chat 临时美术 Agent 只能修改 assets/**".to_string(), - detail: Some(format!( - "path={path} · allowed=assets/** · parentAgentId=code-prototype" - )), - }), + match game_chat_delegated_art_agent_mutation_scope_at(root, agent_id, run_id) { + Ok(GameChatDelegatedArtAgentMutationScope::Unrestricted) => None, + Ok(GameChatDelegatedArtAgentMutationScope::RetryLineageBlocked) => { + Some(game_chat_dynamic_art_retry_mutation_block(tool)) + } + Ok(GameChatDelegatedArtAgentMutationScope::AssetsOnly) + if path == "assets" || path.starts_with("assets/") => + { + None + } + Ok(GameChatDelegatedArtAgentMutationScope::AssetsOnly) => { + Some(AgentRuntimeToolObservation { + tool: tool.to_string(), + status: "blocked".to_string(), + summary: "game-chat 临时美术 Agent 只能修改 assets/**".to_string(), + detail: Some(format!( + "path={path} · allowed=assets/** · parentAgentId=code-prototype" + )), + }) + } Err(error) => Some(AgentRuntimeToolObservation { tool: tool.to_string(), status: "blocked".to_string(), @@ -117,21 +272,28 @@ pub(in crate::agent) fn game_chat_delegated_art_agent_input_mutation_block( }); } } - let assets_only = match game_chat_delegated_art_agent_is_assets_only_at(root, agent_id, run_id) - { - Ok(value) => value, - Err(error) => { - return Some(AgentRuntimeToolObservation { - tool: tool.to_string(), - status: "blocked".to_string(), - summary: "无法校验 game-chat 临时美术 Agent 的写入边界".to_string(), - detail: Some(sanitize_agent_runtime_text(&error, 240)), - }); - } - }; - if !assets_only || game_chat_art_child_tool_is_read_only(tool) { + let mutation_scope = + match game_chat_delegated_art_agent_mutation_scope_at(root, agent_id, run_id) { + Ok(value) => value, + Err(error) => { + return Some(AgentRuntimeToolObservation { + tool: tool.to_string(), + status: "blocked".to_string(), + summary: "无法校验 game-chat 临时美术 Agent 的写入边界".to_string(), + detail: Some(sanitize_agent_runtime_text(&error, 240)), + }); + } + }; + if game_chat_art_child_tool_is_read_only(tool) { return None; } + match mutation_scope { + GameChatDelegatedArtAgentMutationScope::Unrestricted => return None, + GameChatDelegatedArtAgentMutationScope::RetryLineageBlocked => { + return Some(game_chat_dynamic_art_retry_mutation_block(tool)); + } + GameChatDelegatedArtAgentMutationScope::AssetsOnly => {} + } let mut block_path = |path: &str| { game_chat_delegated_art_agent_project_path_mutation_block( root, agent_id, run_id, tool, path, @@ -161,21 +323,140 @@ pub(in crate::agent) fn game_chat_delegated_art_agent_input_mutation_block( } pub(in crate::agent) fn agent_role_project_path_mutation_block( + root: &Path, agent_id: &str, + run_id: &str, tool: &str, path: &str, ) -> Option { - if agent_id != "design-foundation" || design_foundation_owned_project_path(path) { - return None; + if is_agent_planning_storage_path(path) || is_plan_fast_gdd_projection_path(path) { + return Some(AgentRuntimeToolObservation { + tool: tool.to_string(), + status: "blocked".to_string(), + summary: if is_agent_planning_storage_path(path) { + "`.agent/planning/**` 只能由立项策划 Runtime 专用存储层写入".to_string() + } else { + "`game/fast_gdd.md` 只能由立项策划 Runtime renderer 写入".to_string() + }, + detail: Some(format!("agentId={agent_id} · runId={run_id} · path={path}")), + }); + } + match autonomous_owner_artifact_validation_available_for_run_at(root, agent_id, run_id) { + Ok(true) => { + let allowed = autonomous_manifest_owner_artifact_paths(agent_id); + if allowed.contains(&path) { + None + } else { + Some(AgentRuntimeToolObservation { + tool: tool.to_string(), + status: "blocked".to_string(), + summary: if agent_id == "design-foundation" { + "design-foundation 只能修改项目基础设计文档".to_string() + } else { + "autonomous owner 只能修改本人固定正式产物".to_string() + }, + detail: Some(format!("path={path} · allowed={}", allowed.join(","))), + }) + } + } + Ok(false) => { + match game_chat_delegated_art_agent_mutation_scope_at(root, agent_id, run_id) { + Ok(GameChatDelegatedArtAgentMutationScope::RetryLineageBlocked) => { + return Some(game_chat_dynamic_art_retry_mutation_block(tool)); + } + Ok(GameChatDelegatedArtAgentMutationScope::AssetsOnly) + if path == "assets" || path.starts_with("assets/") => + { + return None; + } + Ok(GameChatDelegatedArtAgentMutationScope::AssetsOnly) => { + return Some(AgentRuntimeToolObservation { + tool: tool.to_string(), + status: "blocked".to_string(), + summary: "game-chat 临时美术 Agent 只能修改 assets/**".to_string(), + detail: Some(format!( + "path={path} · allowed=assets/** · parentAgentId=code-prototype" + )), + }); + } + Ok(GameChatDelegatedArtAgentMutationScope::Unrestricted) => {} + Err(error) => { + return Some(AgentRuntimeToolObservation { + tool: tool.to_string(), + status: "blocked".to_string(), + summary: "无法校验 game-chat 临时美术 Agent 的写入边界".to_string(), + detail: Some(sanitize_agent_runtime_text(&error, 240)), + }); + } + } + + let autonomous_fixed_owner = + if agent_runtime_autonomous_uses_owner_artifact_validation(agent_id) { + match read_game_creator_agent_runtime_run_profile_binding( + root, agent_id, run_id, + ) { + Ok(Some(binding)) => { + binding.profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + } + Ok(None) => match read_latest_game_creator_agent_runtime_task_by_run_id( + root, agent_id, run_id, + ) { + Ok(Some(task)) => { + task.run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + } + Ok(None) => false, + Err(error) => { + return Some(AgentRuntimeToolObservation { + tool: tool.to_string(), + status: "blocked".to_string(), + summary: "无法核对 autonomous owner 写入边界".to_string(), + detail: Some(sanitize_agent_runtime_text(&error, 240)), + }); + } + }, + Err(error) => { + return Some(AgentRuntimeToolObservation { + tool: tool.to_string(), + status: "blocked".to_string(), + summary: "无法核对 autonomous owner 写入边界".to_string(), + detail: Some(sanitize_agent_runtime_text(&error, 240)), + }); + } + } + } else { + false + }; + if autonomous_fixed_owner { + Some(AgentRuntimeToolObservation { + tool: tool.to_string(), + status: "blocked".to_string(), + summary: "autonomous fixed owner 的当前 lineage 不受信任,禁止修改项目" + .to_string(), + detail: Some(format!("agentId={agent_id} · runId={run_id} · path={path}")), + }) + } else if agent_id == "design-foundation" { + let allowed = autonomous_manifest_owner_artifact_paths(agent_id); + if allowed.contains(&path) { + None + } else { + Some(AgentRuntimeToolObservation { + tool: tool.to_string(), + status: "blocked".to_string(), + summary: "design-foundation 只能修改项目基础设计文档".to_string(), + detail: Some(format!("path={path} · allowed={}", allowed.join(","))), + }) + } + } else { + None + } + } + Err(error) => Some(AgentRuntimeToolObservation { + tool: tool.to_string(), + status: "blocked".to_string(), + summary: "无法核对 autonomous owner 写入边界".to_string(), + detail: Some(sanitize_agent_runtime_text(&error, 240)), + }), } - Some(AgentRuntimeToolObservation { - tool: tool.to_string(), - status: "blocked".to_string(), - summary: "design-foundation 只能修改项目基础设计文档".to_string(), - detail: Some(format!( - "path={path} · allowed=memory/project.md,game/game_design.md" - )), - }) } pub(in crate::agent) fn observe_agent_runtime_file( @@ -337,7 +618,9 @@ pub(in crate::agent) fn observe_agent_runtime_file_write( }; } }; - if let Some(blocked) = agent_role_project_path_mutation_block(agent_id, "file.write", &path) { + if let Some(blocked) = + agent_role_project_path_mutation_block(root, agent_id, run_id, "file.write", &path) + { return blocked; } if let Some(blocked) = game_chat_delegated_art_agent_project_path_mutation_block( @@ -450,7 +733,9 @@ pub(in crate::agent) fn observe_agent_runtime_file_delete( }; } }; - if let Some(blocked) = agent_role_project_path_mutation_block(agent_id, "file.delete", &path) { + if let Some(blocked) = + agent_role_project_path_mutation_block(root, agent_id, run_id, "file.delete", &path) + { return blocked; } if let Some(blocked) = game_chat_delegated_art_agent_project_path_mutation_block( @@ -669,7 +954,9 @@ pub(in crate::agent) fn observe_agent_runtime_file_patch( }; } }; - if let Some(blocked) = agent_role_project_path_mutation_block(agent_id, "file.patch", &path) { + if let Some(blocked) = + agent_role_project_path_mutation_block(root, agent_id, run_id, "file.patch", &path) + { return blocked; } if let Some(blocked) = game_chat_delegated_art_agent_project_path_mutation_block( 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 40508f17a..9925c65de 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 @@ -148,23 +148,90 @@ pub(crate) fn observe_agent_runtime_acceptance_update( ) })(); match result { - Ok(state) => AgentRuntimeToolObservation { - tool: "agent.acceptance_update".to_string(), - status: "ok".to_string(), - summary: format!("Acceptance Graph 已更新到 revision {}", state.revision), - detail: Some( - serde_json::to_string(&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::>(), - })) - .unwrap_or_default(), - ), - }, + 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()), + } + } + } + } Err(error) => AgentRuntimeToolObservation { tool: "agent.acceptance_update".to_string(), status: "rejected".to_string(), 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 16720f2ca..37c8c3561 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 @@ -7,29 +7,23 @@ fn autonomous_game_build_agent_can_execute_canvas_asset_generate(agent_id: &str) ) } -fn autonomous_design_foundation_command_is_allowed(command_id: &str) -> bool { - matches!( - command_id, - "memory.read" - | "conversation.read" - | "asset.list" - | "project.index" - | "project.search" - | "file.read" - | "project.diff" - | "git.inspect" - | "file.list" - | "file.write" - | "file.delete" - | "project.patchset" - | "task.list" - | "command.run_limited" - | "image.inspect" - | "canvas.asset_generate" - | "agent.audit" - | "agent.action_history" - | "agent.run_status" - ) +fn autonomous_owner_manual_verification_command_is_denied( + agent_id: &str, + command_id: &str, +) -> bool { + agent_runtime_autonomous_uses_owner_artifact_validation(agent_id) + && matches!(command_id, "project.verify" | "command.run_limited") +} + +fn autonomous_art_director_non_canvas_validation_command_is_denied( + agent_id: &str, + command_id: &str, +) -> bool { + agent_id == "art-director" + && matches!( + command_id, + "project.verify" | "command.run_limited" | "preview.start" | "preview.validate" + ) } pub(in crate::agent) fn refresh_game_creator_agent_runtime_tool_policy( @@ -57,10 +51,90 @@ 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, @@ -71,6 +145,41 @@ pub(crate) fn game_creator_agent_runtime_tool_policy_rule_for_run( Ok(identity) => identity, Err(error) => return Some(AgentRuntimeToolPolicyBlock::Denied(error)), }; + // 只有 design-foundation 这一条经审查确认:master 明确允许它在委派路径上用 + // `command.run_limited` 跑 `game.static_smoke`(master 自身用例断言 `is_none`), + // 而兜底的「Runtime 内部产物验证」只在 + // `autonomous_owner_artifact_validation_available_for_run_at` 为真时才发放 + // (要求 source 是 agent-ready-task-scheduler、parent 是 Supervisor、根来自 gui/cli)。 + // 两头一堵,委派路径上的 design-foundation 就无从收束。 + // + // 其余三个固定 owner 的委派路径是否合法尚未核实,维持现状不一并放宽; + // 判据也只对 design-foundation 读一次绑定,不给其它 agent 的每次策略判定加磁盘读。 + let manual_verification_fallback_required = if run_profile + == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + && agent_id == "design-foundation" + { + match autonomous_owner_artifact_validation_available_for_run_at(root, agent_id, run_id) { + Ok(available) => !available, + Err(error) => return Some(AgentRuntimeToolPolicyBlock::Denied(error)), + } + } else { + false + }; + if run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + && !manual_verification_fallback_required + && autonomous_owner_manual_verification_command_is_denied(agent_id, command_id) + { + return Some(AgentRuntimeToolPolicyBlock::Denied(format!( + "自主构建固定 owner {agent_id} 的正式产物只允许由 Runtime 内部验证,拒绝回退执行:{command_id}" + ))); + } + if run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + && autonomous_art_director_non_canvas_validation_command_is_denied(agent_id, command_id) + { + return Some(AgentRuntimeToolPolicyBlock::Denied(format!( + "自主构建 art-director 只负责条件 Canvas 规范图或无 Key 只读结论,拒绝执行非 Canvas 验证:{command_id}" + ))); + } if run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD && command_id == "canvas.asset_generate" && !autonomous_game_build_agent_can_execute_canvas_asset_generate(agent_id) @@ -81,10 +190,10 @@ pub(crate) fn game_creator_agent_runtime_tool_policy_rule_for_run( } if run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD && agent_id == "design-foundation" - && !autonomous_design_foundation_command_is_allowed(command_id) + && !agent_runtime_autonomous_design_foundation_command_is_allowed(command_id) { return Some(AgentRuntimeToolPolicyBlock::Denied(format!( - "design-foundation 只允许只读工具、受限设计文档写入、UI 原型画布生成和 game.static_smoke:{command_id}" + "design-foundation 只允许只读工具、受限设计文档写入、UI 原型画布生成和 Runtime 内部产物验证:{command_id}" ))); } match blocked { @@ -180,6 +289,14 @@ 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)), @@ -293,6 +410,13 @@ 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, @@ -590,20 +714,17 @@ mod tests { "conversation.read", "asset.list", "project.index", - "project.search", "file.read", "project.diff", - "git.inspect", + "project.git_inspect", "file.list", "file.write", "file.delete", "project.patchset", "task.list", - "command.run_limited", "image.inspect", "canvas.asset_generate", "agent.audit", - "agent.action_history", "agent.run_status", ] { assert!(game_creator_agent_runtime_tool_policy_rule_for_run( @@ -617,6 +738,89 @@ mod tests { .is_none()); } + let snapshot = agent_runtime_tool_policy_snapshot_for_run_at( + &root, + "design-foundation", + "policy-design-foundation-run", + Some(&binding.profile), + Some(&binding.binding_fingerprint), + ) + .expect("read design-foundation policy snapshot"); + for (tool, command_id) in [ + ("project.search", "file.read"), + ("git.inspect", "project.git_inspect"), + ("file.patch", "file.write"), + ("agent.action_history", "agent.audit"), + ] { + assert!(game_creator_agent_runtime_tool_policy_rule_for_run( + &root, + "design-foundation", + "policy-design-foundation-run", + Some(&binding.profile), + Some(&binding.binding_fingerprint), + command_id, + ) + .is_none()); + assert!(snapshot + .auto_tools + .iter() + .any(|candidate| candidate == tool)); + assert!(!snapshot + .confirm_tools + .iter() + .any(|candidate| candidate == tool)); + assert!(!snapshot + .denied_tools + .iter() + .any(|candidate| candidate == tool)); + } + for (tool, command_id) in [ + ("memory.write", "memory.write"), + ("project.verify", "project.verify"), + ("project.restore", "project.restore"), + ("project.git_commit", "project.git_commit"), + ("command.exec", "command.exec"), + ("command.output_read", "command.output_read"), + ("command.start", "command.start"), + ("command.poll", "command.poll"), + ("command.stdin", "command.stdin"), + ("command.terminate", "command.terminate"), + ("preview.start", "preview.start"), + ("preview.validate", "preview.validate"), + ("agent.message", "conversation.write"), + ("agent.delegate", "agent.delegate"), + ("agent.spawn_isolated", "agent.spawn_isolated"), + ("agent.schedule_ready", "agent.schedule_ready"), + ("agent.route_manifest", "agent.route_manifest"), + ] { + assert!(matches!( + game_creator_agent_runtime_tool_policy_rule_for_run( + &root, + "design-foundation", + "policy-design-foundation-run", + Some(&binding.profile), + Some(&binding.binding_fingerprint), + command_id, + ), + Some(AgentRuntimeToolPolicyBlock::Denied(_)) + )); + assert!( + snapshot + .denied_tools + .iter() + .any(|candidate| candidate == tool), + "{tool} must be visible as denied for design-foundation" + ); + assert!(!snapshot + .auto_tools + .iter() + .any(|candidate| candidate == tool)); + assert!(!snapshot + .confirm_tools + .iter() + .any(|candidate| candidate == tool)); + } + let code_binding = bind_delegated_autonomous_run(&root, supervisor_run_id, "code-prototype"); assert!(game_creator_agent_runtime_tool_policy_rule_for_run( @@ -629,4 +833,154 @@ mod tests { ) .is_none()); } + + #[test] + fn autonomous_fixed_owners_manual_verification_without_trusted_dag_fallback() { + let temporary = tempfile::tempdir().expect("create owner verification policy root"); + let root = temporary.path().join("project"); + init_local_game_project_at( + &root, + "project-owner-verification-policy", + "固定 owner 验证执行边界测试", + ) + .expect("project init"); + let supervisor_run_id = "policy-owner-verification-parent-run"; + bind_game_creator_agent_runtime_run_profile_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + supervisor_run_id, + AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + None, + ) + .expect("bind supervisor autonomous run"); + + for agent_id in [ + "design-foundation", + "balance-seed", + "art-asset-plan", + "audio-asset-plan", + ] { + let binding = bind_delegated_autonomous_run(&root, supervisor_run_id, agent_id); + assert!(!autonomous_owner_artifact_validation_available_for_run_at( + &root, + agent_id, + &binding.run_id, + ) + .expect("evaluate deliberately non-scheduler owner binding")); + // 只有 design-foundation 经审查确认需要保留手动验证出路(master 允许它用 + // command.run_limited 跑 smoke),且仅限 command.run_limited——project.verify + // 在 master 的 design-foundation 角色白名单里本就没有。 + // 其余三个 owner 未经核实,维持本分支的拒绝现状。 + for command_id in ["project.verify", "command.run_limited"] { + let rule = game_creator_agent_runtime_tool_policy_rule_for_run( + &root, + agent_id, + &binding.run_id, + Some(&binding.profile), + Some(&binding.binding_fingerprint), + command_id, + ); + if agent_id == "design-foundation" { + if command_id == "command.run_limited" { + assert!( + rule.is_none(), + "design-foundation 在非 scheduler 路径必须保留 command.run_limited 出路" + ); + } else { + assert!(matches!( + rule, + Some(AgentRuntimeToolPolicyBlock::Denied(reason)) + if reason.contains("design-foundation") + )); + } + } else { + assert!(matches!( + rule, + Some(AgentRuntimeToolPolicyBlock::Denied(reason)) + if reason.contains("固定 owner") + && reason.contains(agent_id) + && reason.contains(command_id) + )); + } + } + + assert!(matches!( + game_creator_agent_runtime_tool_policy_rule_for_run( + &root, + agent_id, + &binding.run_id, + Some(&binding.profile), + Some("forged-recovery-fingerprint"), + "project.verify", + ), + Some(AgentRuntimeToolPolicyBlock::Denied(reason)) + if reason.contains("持久绑定不一致") + )); + } + + assert!(matches!( + game_creator_agent_runtime_tool_policy_rule_for_run( + &root, + "balance-seed", + "missing-recovered-owner-run", + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + Some("missing-binding-fingerprint"), + "command.run_limited", + ), + Some(AgentRuntimeToolPolicyBlock::Denied(reason)) + if reason.contains("绑定缺失") && reason.contains("拒绝降级") + )); + + for agent_id in ["code-prototype", "publish-package"] { + let binding = bind_delegated_autonomous_run(&root, supervisor_run_id, agent_id); + for command_id in ["project.verify", "command.run_limited"] { + assert!( + game_creator_agent_runtime_tool_policy_rule_for_run( + &root, + agent_id, + &binding.run_id, + Some(&binding.profile), + Some(&binding.binding_fingerprint), + command_id, + ) + .is_none(), + "{agent_id} must remain outside the fixed pre-code owner verification rule" + ); + } + } + + let art_director_binding = + bind_delegated_autonomous_run(&root, supervisor_run_id, "art-director"); + for command_id in [ + "project.verify", + "command.run_limited", + "preview.start", + "preview.validate", + ] { + assert!(matches!( + game_creator_agent_runtime_tool_policy_rule_for_run( + &root, + "art-director", + &art_director_binding.run_id, + Some(&art_director_binding.profile), + Some(&art_director_binding.binding_fingerprint), + command_id, + ), + Some(AgentRuntimeToolPolicyBlock::Denied(reason)) + if reason.contains("art-director") + && reason.contains("非 Canvas 验证") + && reason.contains(command_id) + )); + } + assert!(game_creator_agent_runtime_tool_policy_rule_for_run( + &root, + "art-director", + &art_director_binding.run_id, + Some(&art_director_binding.profile), + Some(&art_director_binding.binding_fingerprint), + "canvas.asset_generate", + ) + .is_none()); + } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/preview.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/preview.rs index 33c6cd649..a612dec3f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/preview.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/preview.rs @@ -175,13 +175,14 @@ pub(in crate::agent) async fn observe_agent_runtime_preview_validate( } }; let completion_contract = - match autonomous_playtest_completion_contract_for_state_at(root, &runtime) { + match autonomous_playtest_execution_contract_for_state_at(root, &runtime) { Ok(contract) => contract, Err(error) => { return AgentRuntimeToolObservation { tool: "preview.validate".to_string(), status: "failed".to_string(), - summary: "自主构建完成合同不可用,未执行浏览器试玩".to_string(), + summary: "preview.validate 执行身份或自主构建完成合同不可用,未执行浏览器试玩" + .to_string(), detail: Some(redact_agent_runtime_project_paths(root, &error, 500)), }; } @@ -248,14 +249,10 @@ pub(in crate::agent) async fn observe_agent_runtime_preview_validate( } }, }; - let (evidence_agent_id, evidence_run_id) = completion_contract - .as_ref() - .map(|contract| (contract.agent_id.as_str(), contract.run_id.as_str())) - .unwrap_or((agent_id, run_id)); let evidence_relative_root = format!( ".agent/runtime/browser-validations/{}/{}/{}", - agent_runtime_confirmation_path_component(evidence_agent_id, "agent"), - agent_runtime_confirmation_path_component(evidence_run_id, "run"), + agent_runtime_confirmation_path_component(agent_id, "agent"), + agent_runtime_confirmation_path_component(run_id, "run"), revision_before.revision, ); let evidence_root = match resolve_local_project_path(root, &evidence_relative_root) { @@ -379,6 +376,7 @@ pub(in crate::agent) async fn observe_agent_runtime_preview_validate( let receipt = match write_autonomous_playtest_receipt_at( root, contract, + &runtime, action_id, action_fingerprint, revision_after.revision, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/project_ops.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/project_ops.rs index 2817a85ae..d0c3a8d73 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/project_ops.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/project_ops.rs @@ -193,13 +193,10 @@ where }; } }; - if agent_id == "design-foundation" { - if let Some(summary) = prepared.summaries().iter().find(|summary| { - agent_role_project_path_mutation_block(agent_id, tool, summary.path()).is_some() - }) { - return agent_role_project_path_mutation_block(agent_id, tool, summary.path()) - .expect("design-foundation unauthorized patchset path must be blocked"); - } + if let Some(blocked) = prepared.summaries().iter().find_map(|summary| { + agent_role_project_path_mutation_block(root, agent_id, run_id, tool, summary.path()) + }) { + return blocked; } if let Some(summary) = prepared.summaries().iter().find(|summary| { game_chat_delegated_art_agent_project_path_mutation_block( 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 71cca9f61..1a5c0002c 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 @@ -26,7 +26,7 @@ pub(in crate::agent) fn observe_claimed_static_delegate_contract_at( "contractStatus": delivery .structured_result .as_ref() - .map(|result| result.contract_status), + .map(|result| result.contract_status.clone()), } })) .map_err(|error| format!("序列化已认领委派合同失败:{error}"))?; @@ -213,6 +213,48 @@ 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 { @@ -230,7 +272,7 @@ pub(crate) fn observe_agent_runtime_run_status( "contractStatus": delivery .structured_result .as_ref() - .map(|result| result.contract_status), + .map(|result| result.contract_status.clone()), "needsUserInput": delivery .structured_result .as_ref() @@ -252,6 +294,11 @@ 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 d91338b98..a24520aeb 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 @@ -333,6 +333,18 @@ 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_native_tools.rs b/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs index f373f8e80..9ead2654a 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 @@ -10,17 +10,25 @@ use serde_json::{json, Value}; use sha2::{Digest, Sha256}; use crate::agent::{ - agent_runtime_native_executable_tools, AgentRuntimePlanUpdate, AgentRuntimeToolAction, - AgentRuntimeToolPlan, AGENT_RUNTIME_BACKGROUND_TOOL_ACTION_LIMIT, - AGENT_RUNTIME_CANVAS_ASSET_KINDS, AGENT_RUNTIME_PLAN_STEP_LIMIT, + agent_runtime_native_executable_tools, agent_runtime_plan_root_supervisor_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, }; use crate::mcp::{ validate_game_creator_mcp_tool_arguments, GameCreatorMcpCatalog, GameCreatorMcpCatalogTool, GAME_CREATOR_MCP_CALL_TOOL, }; +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_"; const AGENT_RUNTIME_NATIVE_MCP_PREFIX: &str = "mcp_tool_"; @@ -224,6 +232,9 @@ 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) @@ -276,8 +287,30 @@ pub(crate) fn native_mcp_function_name(server_id: &str, tool_name: &str) -> Stri ) } +/// 不带身份的全量目录,**只允许测试使用**。 +/// +/// `"__all_agents__"` 是个不对应任何真实 Agent 的哨兵:走这条路径拿到的是 +/// 未按身份收窄的完整函数目录。生产代码必须调用 `_for_agent` 版本并传入真实 +/// `agentId`,否则按身份收窄的工具面(如 `project-planning` 的 exact +/// allowlist)会被静默绕开。这里用 `#[cfg(test)]` 把「忘记改用 `_for_agent`」 +/// 从运行时静默扩权变成编译期错误。 +#[cfg(test)] pub(crate) fn build_agent_runtime_native_function_tools( mcp_catalog: &GameCreatorMcpCatalog, +) -> Result, String> { + build_agent_runtime_native_function_tools_for_agent("__all_agents__", mcp_catalog) +} + +/// Build the function catalog for a specific Agent identity. +/// +/// `project-planning` is deliberately handled as an exact allowlist. The +/// planning-only `plan.submit_gdd` capability is appended below only for that +/// identity; it is intentionally absent from the global capability registry +/// and from every other Agent's function catalog. Protocol controls remain +/// available to every Agent. +pub(crate) fn build_agent_runtime_native_function_tools_for_agent( + agent_id: &str, + mcp_catalog: &GameCreatorMcpCatalog, ) -> Result, String> { let mut functions = vec![plan_update_function_tool(), response_function_tool()]; let mut names = BTreeSet::from([ @@ -285,7 +318,11 @@ pub(crate) fn build_agent_runtime_native_function_tools( 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}")); @@ -300,6 +337,17 @@ pub(crate) fn build_agent_runtime_native_function_tools( ); } + // Planning Agents never receive an MCP catalog, even if a caller passes + // one accidentally. This keeps the ad surface fail-closed by identity. + 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); + } for tool in &mcp_catalog.tools { let name = native_mcp_function_name(&tool.server_id, &tool.name); if !names.insert(name.clone()) { @@ -314,9 +362,159 @@ pub(crate) fn build_agent_runtime_native_function_tools( 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. MCP function tools carry a different prefix +/// and are dropped here too — the plan lane never calls MCP. +/// +/// 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: game-chat 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; + } + if tool.trim() == GAME_CREATOR_MCP_CALL_TOOL { + // MCP calls are bound and checked against the current catalog by the + // MCP policy path; they are not part of the native capability registry. + return true; + } + agent_runtime_native_capability_registry() + .ok() + .and_then(|registry| registry.get(tool.trim())) + .is_some() +} + +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) { + return Err(protocol_error( + AgentRuntimeToolPlanProtocolErrorKind::UnknownFunction, + format!( + "Agent 原生工具协议错误:Agent {} 不允许调用 {}", + agent_id.trim(), + tool + ), + )); + } + } + Ok(()) +} + +/// 不带身份的解析入口,**只允许测试使用**(理由同 +/// `build_agent_runtime_native_function_tools`:哨兵会跳过按身份的原始工具 +/// identity 复核)。 +#[cfg(test)] pub(crate) fn parse_agent_runtime_native_tool_calls( calls: &[LlmToolCall], mcp_catalog: &GameCreatorMcpCatalog, +) -> Result { + parse_agent_runtime_native_tool_calls_for_agent("__all_agents__", calls, mcp_catalog) +} + +pub(crate) fn parse_agent_runtime_native_tool_calls_for_agent( + agent_id: &str, + calls: &[LlmToolCall], + mcp_catalog: &GameCreatorMcpCatalog, ) -> Result { if calls.is_empty() { return Err(protocol_error( @@ -328,6 +526,7 @@ pub(crate) fn parse_agent_runtime_native_tool_calls( 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()); @@ -373,7 +572,14 @@ pub(crate) fn parse_agent_runtime_native_tool_calls( } let runtime_tool = runtime_tool_for_native_function(&call.name); + validate_native_tool_identity(agent_id, runtime_tool.as_deref())?; let mcp_tool = mcp_tool_for_native_function(&call.name, mcp_catalog)?; + if agent_id.trim() == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID && mcp_tool.is_some() { + return Err(protocol_error( + AgentRuntimeToolPlanProtocolErrorKind::UnknownFunction, + "Agent 原生工具协议错误:project-planning 不允许 MCP 工具", + )); + } if runtime_tool.is_none() && mcp_tool.is_none() { return Err(protocol_error( AgentRuntimeToolPlanProtocolErrorKind::UnknownFunction, @@ -398,6 +604,9 @@ pub(crate) fn parse_agent_runtime_native_tool_calls( input = normalize_native_project_patchset_input(input)?; } let action = if let Some(tool) = runtime_tool { + if tool == PLAN_SUBMIT_GDD_TOOL { + submit_gdd_action_count = submit_gdd_action_count.saturating_add(1); + } AgentRuntimeToolAction { tool, reason: Some(arguments.reason), @@ -442,6 +651,14 @@ pub(crate) fn parse_agent_runtime_native_tool_calls( "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()) @@ -518,20 +735,31 @@ fn validate_native_agent_delegate_input( } validate_native_delegate_string(object.get("agentId"), "agentId", 96, false)?; validate_native_delegate_string(object.get("task"), "task", 2_400, false)?; - validate_native_delegate_string_list( - object.get("acceptanceCriteria"), - "acceptanceCriteria", - 1, - 8, - 240, - )?; - validate_native_delegate_string_list( - object.get("expectedArtifacts"), - "expectedArtifacts", - 0, - 16, - 240, - )?; + // 返工/澄清续跑必须逐字继承原委派的两个数组,Runtime 从 repairOfDelegationId + // 指向的 delivery 直接读得到权威值。两个都传 null 时由 Runtime 补齐;手抄一遍 + // 不带来任何信息增益,只制造反复失败的返工委派。初次委派仍然必须自己写。 + let repair_hop = object + .get("repairOfDelegationId") + .is_some_and(Value::is_string); + let inherits_contract = repair_hop + && object.get("acceptanceCriteria").is_some_and(Value::is_null) + && object.get("expectedArtifacts").is_some_and(Value::is_null); + if !inherits_contract { + validate_native_delegate_string_list( + object.get("acceptanceCriteria"), + "acceptanceCriteria", + 1, + 8, + 240, + )?; + validate_native_delegate_string_list( + object.get("expectedArtifacts"), + "expectedArtifacts", + 0, + 16, + 240, + )?; + } validate_native_delegate_string( object.get("repairOfDelegationId"), "repairOfDelegationId", @@ -568,28 +796,27 @@ fn validate_native_agent_delegate_input( "Agent 原生工具协议错误:agent.delegate 返工委派时 runId 必须为 JSON null", )); } - let continuation_fields = [ - "continuationOfDelegationId", - "questionsSha256", - "answersSha256", - ] - .iter() - .filter(|field| object.get(**field).is_some_and(Value::is_string)) - .count(); - let continuation_present = [ - "continuationOfDelegationId", - "questionsSha256", - "answersSha256", - ] - .iter() - .filter(|field| object.contains_key(**field)) - .count(); - if (continuation_present != 0 && continuation_present != 3) - || (continuation_fields != 0 && continuation_fields != 3) - { + // 澄清 continuation 的锚点是 continuationOfDelegationId;两个指纹可以整体省略, + // 由 Runtime 从原 delivery 取权威值补齐。省略是为了不让 Supervisor 手抄 128 个 + // 十六进制字符——抄错会打到硬失败,而抄对也不带来任何 Runtime 不知道的信息。 + // 三个字段一律按“JSON null 等同于缺省”处理,与本函数其余可空字段一致。 + let continuation_anchor = object + .get("continuationOfDelegationId") + .is_some_and(Value::is_string); + let digest_strings = ["questionsSha256", "answersSha256"] + .iter() + .filter(|field| object.get(**field).is_some_and(Value::is_string)) + .count(); + if digest_strings == 1 { return Err(protocol_error( AgentRuntimeToolPlanProtocolErrorKind::ArgumentsSchema, - "Agent 原生工具协议错误:agent.delegate 澄清 continuation 字段必须同时提供", + "Agent 原生工具协议错误:agent.delegate 澄清 continuation 指纹必须成对提供,或整体省略交给 Runtime 补齐", + )); + } + if digest_strings == 2 && !continuation_anchor { + return Err(protocol_error( + AgentRuntimeToolPlanProtocolErrorKind::ArgumentsSchema, + "Agent 原生工具协议错误:agent.delegate 澄清 continuation 指纹必须同时提供 continuationOfDelegationId", )); } for field in ["questionsSha256", "answersSha256"] { @@ -810,6 +1037,9 @@ 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) @@ -859,6 +1089,15 @@ 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", @@ -887,6 +1126,196 @@ 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 +/// semantic/session equality 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", "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; @@ -1007,6 +1436,7 @@ 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 自己或项目范围的稳定记忆。", @@ -1045,7 +1475,7 @@ fn runtime_tool_description(tool: &str) -> &'static str { "blackboard.write" => "向项目级共享黑板追加稳定结论。", "agent.message" => "向一个目标 Agent 写入定向上下文消息。", "agent.delegate" => { - "用持久验收合同把边界清晰的后台任务委派给另一个 Agent;返工时 repairOfDelegationId 指向原 delivery,且 runId 必须为 null。" + "用持久验收合同把边界清晰的后台任务委派给另一个 Agent;返工时 repairOfDelegationId 指向原 delivery,runId 必须为 null,acceptanceCriteria 与 expectedArtifacts 一起传 null 由 Runtime 从原 delivery 继承。" } "agent.spawn_isolated" => "创建最多三个写范围互不重叠的隔离子 Agent。", "agent.goal_contract" => { @@ -1074,6 +1504,7 @@ fn mcp_tool_description(tool: &GameCreatorMcpCatalogTool) -> String { 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"], @@ -1291,8 +1722,8 @@ fn runtime_tool_input_schema(tool: &str) -> Value { "properties": { "agentId": { "type": "string", "minLength": 1 }, "task": { "type": "string", "minLength": 1, "maxLength": 2400 }, - "acceptanceCriteria": { "type": "array", "minItems": 1, "maxItems": 8, "items": { "type": "string", "minLength": 1, "maxLength": 240 } }, - "expectedArtifacts": { "type": "array", "maxItems": 16, "items": { "type": "string", "minLength": 1, "maxLength": 240 } }, + "acceptanceCriteria": { "type": ["array", "null"], "minItems": 1, "maxItems": 8, "items": { "type": "string", "minLength": 1, "maxLength": 240 }, "description": "初次委派必填。带 repairOfDelegationId 的返工或澄清续跑传 null:Runtime 会从原 delivery 继承权威合同,手抄一遍不增加任何信息,抄错会被直接拒收。" }, + "expectedArtifacts": { "type": ["array", "null"], "maxItems": 16, "items": { "type": "string", "minLength": 1, "maxLength": 240 }, "description": "与 acceptanceCriteria 同进同出:初次委派必填,返工与澄清续跑一起传 null 由 Runtime 继承。" }, "repairOfDelegationId": { "type": ["string", "null"] }, "runId": { "type": ["string", "null"] }, "continuationOfDelegationId": { "type": ["string", "null"] }, @@ -1504,6 +1935,42 @@ mod tests { } } + #[test] + fn project_planning_catalog_is_exact_and_mcp_free() { + let functions = build_agent_runtime_native_function_tools_for_agent( + GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + &native_mcp_catalog(empty_input_schema()), + ) + .expect("planning function catalog"); + let names = functions + .iter() + .map(|function| function.name.as_str()) + .collect::>(); + assert!(names.contains(AGENT_RUNTIME_UPDATE_PLAN_FUNCTION_NAME)); + assert!(names.contains(AGENT_RUNTIME_RESPOND_FUNCTION_NAME)); + assert!(names.contains("runtime_tool_file_read")); + assert!(names.contains("runtime_tool_file_list")); + assert!(names.contains(PLAN_SUBMIT_GDD_FUNCTION_NAME)); + assert_eq!(names.len(), 5); + assert!(!names.iter().any(|name| name.starts_with("mcp_tool_"))); + assert!(!agent_runtime_native_tool_allowed_for_agent( + GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + "user.input_request" + )); + assert!(!agent_runtime_native_tool_allowed_for_agent( + GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + "file.write" + )); + assert!(agent_runtime_native_tool_allowed_for_agent( + GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + PLAN_SUBMIT_GDD_TOOL + )); + assert!(!agent_runtime_native_tool_allowed_for_agent( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + PLAN_SUBMIT_GDD_TOOL + )); + } + fn native_mcp_catalog(input_schema: Value) -> GameCreatorMcpCatalog { GameCreatorMcpCatalog { fingerprint: "catalog-fingerprint".to_string(), @@ -1612,20 +2079,81 @@ mod tests { .expect("legacy delegate input without clarification fields"); } + /// 返工/续跑的两个数组必须逐字继承原委派,Runtime 读得到权威值。要求 Supervisor + /// 手抄它们只会反复失败:实测一次两轮澄清的 plan run 里, + /// 「静态委派返工必须完整继承原 acceptanceCriteria 和 expectedArtifacts」 + /// 出现 4 次,每建一条 continuation 先白跑两轮工具调用。 #[test] - fn native_agent_delegate_rejects_partial_or_invalid_clarification_binding() { - let mut partial = valid_delegate_input(json!("delegation-id"), Value::Null); - partial + fn native_agent_delegate_repair_may_inherit_the_original_contract() { + let mut inherit = valid_delegate_input(json!("delegation-id"), Value::Null); + let object = inherit.as_object_mut().expect("delegate input object"); + object.insert("acceptanceCriteria".to_string(), Value::Null); + object.insert("expectedArtifacts".to_string(), Value::Null); + + validate_native_agent_delegate_input(&inherit).expect("repair may inherit the contract"); + + // 初次委派没有可继承的原 delivery,两个数组仍然必须自己写。 + let mut initial = valid_delegate_input(Value::Null, json!("run-1")); + let object = initial.as_object_mut().expect("delegate input object"); + object.insert("acceptanceCriteria".to_string(), Value::Null); + object.insert("expectedArtifacts".to_string(), Value::Null); + assert!(validate_native_agent_delegate_input(&initial) + .expect_err("initial delegate must still carry its own contract") + .to_string() + .contains("acceptanceCriteria")); + + // 只省一半是有歧义的输入,不放行。 + let mut half = valid_delegate_input(json!("delegation-id"), Value::Null); + let object = half.as_object_mut().expect("delegate input object"); + object.insert("acceptanceCriteria".to_string(), Value::Null); + assert!(validate_native_agent_delegate_input(&half) + .expect_err("half-omitted contract must fail") + .to_string() + .contains("acceptanceCriteria")); + } + + #[test] + fn native_agent_delegate_accepts_clarification_continuation_without_fingerprints() { + // 指纹由 Runtime 从原 delivery 补齐,Supervisor 只需要指出续跑的是哪个委派。 + let mut anchor_only = valid_delegate_input(json!("delegation-id"), Value::Null); + anchor_only .as_object_mut() .expect("delegate input object") .insert( "continuationOfDelegationId".to_string(), json!("delegation-id"), ); - assert!(validate_native_agent_delegate_input(&partial) - .expect_err("partial continuation binding must fail") + + validate_native_agent_delegate_input(&anchor_only) + .expect("clarification continuation without fingerprints"); + } + + #[test] + fn native_agent_delegate_rejects_partial_or_invalid_clarification_binding() { + let mut single_digest = valid_delegate_input(json!("delegation-id"), Value::Null); + let object = single_digest + .as_object_mut() + .expect("delegate input object"); + object.insert( + "continuationOfDelegationId".to_string(), + json!("delegation-id"), + ); + object.insert("questionsSha256".to_string(), json!("a".repeat(64))); + assert!(validate_native_agent_delegate_input(&single_digest) + .expect_err("single continuation fingerprint must fail") .to_string() - .contains("必须同时提供")); + .contains("必须成对提供")); + + let mut orphan_digests = valid_delegate_input(json!("delegation-id"), Value::Null); + let object = orphan_digests + .as_object_mut() + .expect("delegate input object"); + object.insert("questionsSha256".to_string(), json!("a".repeat(64))); + object.insert("answersSha256".to_string(), json!("b".repeat(64))); + assert!(validate_native_agent_delegate_input(&orphan_digests) + .expect_err("continuation fingerprints without anchor must fail") + .to_string() + .contains("必须同时提供 continuationOfDelegationId")); let mut invalid_sha = valid_delegate_input(json!("delegation-id"), Value::Null); let object = invalid_sha.as_object_mut().expect("delegate input object"); @@ -1686,6 +2214,89 @@ mod tests { assert!(description.contains("repairOfDelegationId 指向原 delivery")); assert!(description.contains("runId 必须为 null")); + // 广告给模型的 schema 一度把这两个数组写死成非空数组,模型于是根本无法 + // 传 null,只能手抄——实测每条 continuation 固定先失败 2 次才抄对。 + assert!( + description.contains("acceptanceCriteria 与 expectedArtifacts 一起传 null"), + "{description}" + ); + let schema = runtime_tool_input_schema("agent.delegate"); + for field in ["acceptanceCriteria", "expectedArtifacts"] { + let types = schema["properties"][field]["type"] + .as_array() + .unwrap_or_else(|| panic!("{field} 必须允许 null")); + assert!( + types.iter().any(|value| value == "null"), + "{field} 的广告 schema 必须允许 null,否则 Runtime 侧的继承分支永远走不到" + ); + } + } + + /// plan 根 Supervisor 只广告 7 个原生工具 + 2 个协议控制。全量注册表里其余 + /// 约 36 个在这条链路上全部会被执行层拒绝,广告出去只会诱导 Supervisor 自己 + /// 下场写文件、跑命令、查任务图。断言写成精确集合而不是「不包含某几个」, + /// 这样将来往注册表里加工具不会静默漏进 plan 根。 + #[test] + fn plan_root_supervisor_tool_catalog_is_an_exact_allowlist() { + let mcp_catalog = + native_mcp_catalog(json!({"type": "object", "additionalProperties": false})); + let mut functions = build_agent_runtime_native_function_tools_for_agent( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &mcp_catalog, + ) + .expect("build supervisor catalog"); + let before = functions.len(); + // 逐个阶段都必须是精确集合:委派前那两档各自只剩一个推进动作,正是活锁 + // 的解药——模型在那一档连 agent.run_status 都调不出来。 + for stage in [ + PlanRootSupervisorStage::GoalContract, + PlanRootSupervisorStage::Delegate, + 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(), + "收窄必须真的裁掉工具,否则这条测试是空跑" + ); + // MCP 前缀的动态工具同样不得残留:plan 根整条链路不调 MCP。 + assert!(!staged + .iter() + .any(|function| function.name.starts_with(AGENT_RUNTIME_NATIVE_MCP_PREFIX))); + } + // 广告层不得再出现 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::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] @@ -1703,6 +2314,7 @@ mod tests { ]) ); assert_eq!(goal["properties"]["acceptanceNodes"]["minItems"], 1); + assert_eq!(goal["properties"]["acceptanceNodes"]["maxItems"], 32); assert_eq!( goal["properties"]["acceptanceNodes"]["items"]["properties"]["requiredEvidence"] ["items"]["pattern"], @@ -1733,6 +2345,23 @@ 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] @@ -1756,6 +2385,113 @@ 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(&empty_catalog()) + .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(), + }, + ], + &empty_catalog(), + ) + .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(), + }, + ], + &empty_catalog(), + ) + .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(), + }, + ], + &empty_catalog(), + ) + .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 = build_agent_runtime_native_function_tools(&empty_catalog()) 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 d957fc284..00ad60a35 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/cli.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/cli.rs @@ -113,6 +113,15 @@ 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 { @@ -148,6 +157,7 @@ impl CliCommand { | Self::AgentGoalResume { .. } | Self::AgentGoalClear { .. } | Self::AgentResume { .. } + | Self::PlanGddDecide { .. } | Self::RunnerShutdownIfIdle ) } @@ -155,7 +165,10 @@ impl CliCommand { pub(crate) fn is_read_only_status(&self) -> bool { matches!( self, - Self::AgentRuntimeStatus { .. } | Self::AgentGoalStatus { .. } | Self::RunnerStatus + Self::AgentRuntimeStatus { .. } + | Self::AgentGoalStatus { .. } + | Self::PlanGddStatus { .. } + | Self::RunnerStatus ) } @@ -203,6 +216,8 @@ 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, @@ -416,6 +431,56 @@ 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(); @@ -645,6 +710,53 @@ 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") { @@ -706,7 +818,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] [--game-chat-smoke] <本地项目绝对路径> [parentAgentId]"; + const USAGE: &str = "用法:--swarm-chat [--init] [--autonomous-game-build] [--game-chat-smoke] [--plan] <本地项目绝对路径> [parentAgentId]"; let mut rest = args[1..].to_vec(); let initialize = if let Some(index) = rest.iter().position(|arg| arg == "--init") { rest.remove(index); @@ -746,6 +858,18 @@ 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()); } @@ -760,6 +884,20 @@ pub(crate) fn parse_cli_command(args: &[String]) -> Result, S .to_string(), ); } + // 立项策划根 Run 只跑 standard 档(后端 reject_supervisor_plan_autonomous_profile + // 同样否决),且必须挂在总控上;这里先拦一道,免得建完项目才失败。 + if plan + && (autonomous_game_build + || game_chat_smoke + || rest.get(1).is_some_and(|parent| { + parent.trim() != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID + })) + { + return Err( + "--plan 仅允许 project-supervisor 的 standard 档,不能搭配 --autonomous-game-build / --game-chat-smoke" + .to_string(), + ); + } return Ok(Some(CliCommand::SwarmChat { project_path: PathBuf::from(&rest[0]), parent_agent_id: rest @@ -774,6 +912,8 @@ pub(crate) fn parse_cli_command(args: &[String]) -> Result, S }, supervisor_source: if game_chat_smoke { AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE + } else if plan { + AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE } else { AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE }, @@ -1395,6 +1535,63 @@ 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={}", @@ -1902,6 +2099,116 @@ 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( @@ -2110,6 +2417,58 @@ mod tests { .is_err()); } + #[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(), + "--autonomous-game-build".to_string(), + "--game-chat-smoke".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 01119ac2c..d41474155 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -230,6 +230,24 @@ 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) @@ -534,6 +552,104 @@ pub(crate) fn validated_local_project_directory_path( Ok(path.to_path_buf()) } +/// Open the approved Fast GDD Markdown in whatever application the OS has +/// registered for it. +/// +/// The GDD is the one product artifact the 立项策划 lane hands back, and it is +/// already on disk — `plan.submit_gdd` renders `game/fast_gdd.md` and the +/// approval receipt re-renders it with the approved header. This command only +/// hands that existing path to the shell; it never creates or rewrites it. +#[tauri::command] +pub(crate) fn open_local_project_plan_gdd_markdown( + app: tauri::AppHandle, + project_path: String, +) -> Result<(), String> { + let path = validated_local_project_plan_gdd_markdown_path(project_path.trim())?; + app.opener() + .open_path(path.to_string_lossy().into_owned(), None::<&str>) + .map_err(|error| format!("打开 Fast GDD 文件失败:{error}")) +} + +pub(crate) fn validated_local_project_plan_gdd_markdown_path( + project_path: &str, +) -> Result { + let root = validated_local_project_directory_path(project_path)?; + // `resolve_local_project_path` 是项目内路径的唯一安全入口:它做根校验、相对路径 + // 归一化,并逐段拒绝符号链接。这里的相对路径是常量,但仍然走它——GDD 的渲染侧 + // (`planning_storage`)用的也是同一个解析器,两边对「项目内的这个文件」必须是 + // 同一个判定,不能一边解析一边拼字符串。 + let path = resolve_local_project_path(&root, PLAN_FAST_GDD_PATH)?; + match fs::symlink_metadata(&path) { + Ok(metadata) if metadata.file_type().is_file() => Ok(path), + Ok(_) => Err("Fast GDD 产物不是普通文件".to_string()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + Err("Fast GDD 产物尚未生成,请先完成立项策划审批".to_string()) + } + Err(error) => Err(format!("读取 Fast GDD 产物失败:{error}")), + } +} + +#[cfg(test)] +mod plan_gdd_markdown_path_tests { + use super::*; + + fn fixture() -> tempfile::TempDir { + let temporary = tempfile::tempdir().expect("create GDD path fixture"); + crate::project::init_local_game_project_at( + &temporary.path().join("project"), + "gdd-open", + "打开 GDD 产物", + ) + .expect("initialize GDD path fixture"); + temporary + } + + #[test] + fn resolves_the_rendered_markdown_under_the_project_root() { + let temporary = fixture(); + let root = temporary.path().join("project"); + fs::create_dir_all(root.join("game")).expect("create game directory"); + fs::write(root.join(PLAN_FAST_GDD_PATH), "# Fast GDD").expect("render markdown"); + + let resolved = + validated_local_project_plan_gdd_markdown_path(&root.to_string_lossy().into_owned()) + .expect("resolve rendered markdown"); + + assert_eq!(resolved, root.join(PLAN_FAST_GDD_PATH)); + } + + #[test] + fn refuses_to_open_a_markdown_that_has_not_been_rendered_yet() { + // 恢复态下 `plan.submit_gdd` 的 Markdown 渲染可能还没落盘。这时按钮必须给出 + // 明确原因,而不是把一个不存在的路径丢给 shell 由系统弹一个无从解释的错误。 + let temporary = fixture(); + let root = temporary.path().join("project"); + + let error = + validated_local_project_plan_gdd_markdown_path(&root.to_string_lossy().into_owned()) + .expect_err("missing markdown must fail closed"); + + assert!(error.contains("尚未生成"), "unexpected error: {error}"); + } + + #[test] + fn refuses_a_project_path_that_is_not_an_initialized_project() { + let temporary = tempfile::tempdir().expect("create bare fixture"); + let error = validated_local_project_plan_gdd_markdown_path( + &temporary.path().to_string_lossy().into_owned(), + ) + .expect_err("a directory without .agent is not a project root"); + assert!(!error.is_empty()); + } + + #[test] + fn refuses_a_relative_project_path() { + let error = validated_local_project_plan_gdd_markdown_path("relative/project") + .expect_err("relative project path must fail"); + assert!(error.contains("绝对路径"), "unexpected error: {error}"); + } +} + #[tauri::command] pub(crate) fn get_local_game_manifest( project_path: String, @@ -1116,6 +1232,12 @@ 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(), @@ -1291,6 +1413,7 @@ 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()); } @@ -1527,6 +1650,67 @@ 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 = decide_plan_gdd_at( + &root, + &DecidePlanGddInputV1 { + gdd_id, + version, + fingerprint, + pending_action_id, + approval_request_id, + response_id, + action, + comment, + }, + ) + .map_err(|error| error.to_string())?; + if !result.recovery_pending { + if wake_pending_game_creator_agent_background_tasks_at(&root).is_err() { + // 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, 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 e298a6502..f052afd7f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/config.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/config.rs @@ -76,7 +76,7 @@ fn build_game_creator_platform_llm_config( .map_err(|error| format!("LLM 配置无效:{error}")) } -fn game_creator_supports_anthropic_strict_tools( +pub(crate) fn game_creator_supports_anthropic_strict_tools( api_kind: LlmApiKind, base_url: &str, model: &str, @@ -1462,9 +1462,18 @@ pub(crate) fn merge_game_creator_config_file( config.mcp_servers.insert(server_id, server); } } + if let Some(planning) = file_config.planning { + if let Some(capability_enabled) = planning.capability_enabled { + config.planning.capability_enabled = capability_enabled; + } + } 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", 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 4817ac216..ada7af6d5 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/delegation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/delegation.rs @@ -13,8 +13,64 @@ const STATIC_DELEGATE_ACCEPTANCE_CRITERION_MAX_CHARS: usize = 240; const STATIC_DELEGATE_MAX_EXPECTED_ARTIFACTS: usize = 16; const STATIC_DELEGATE_EXPECTED_ARTIFACT_MAX_CHARS: usize = 240; const STATIC_DELEGATE_MAX_EVIDENCE: usize = 16; -const STATIC_DELEGATE_USER_INPUT_PREFIX: &str = "AGC_NEEDS_USER_INPUT_V1\n"; -const STATIC_DELEGATE_USER_INPUT_MAX_RESPONSE_CHARS: usize = 500; +pub(crate) const STATIC_DELEGATE_USER_INPUT_PREFIX: &str = "AGC_NEEDS_USER_INPUT_V1\n"; +/// 中转通道的上限必须由澄清问询 schema 推导。写死 500 时,一问三选的正常中文 +/// 问询(501 字符)就会在父 run 认领回执时被拒收,整条委派链阻断;而 schema 本身 +/// 允许的最大合法问询比 500 大一个数量级。 +pub(crate) const STATIC_DELEGATE_USER_INPUT_MAX_RESPONSE_CHARS: usize = + STATIC_DELEGATE_USER_INPUT_PREFIX.len() + AGENT_RUNTIME_USER_INPUT_MAX_WIRE_CHARS; +/// 澄清信封是结构化协议载荷,只是恰好借用了「子 Agent 自由回复」这条文本通道。 +/// 通道上每一处按普通自由文本盲切字符的定界都会把 JSON 拦腰砍断,父 run 认领回执 +/// 时解析失败,整条委派链停在 needs-reconciliation。信封本身已由问询 schema 硬性 +/// 定界(问题数、选项数、各字段字符上限逐项校验),不需要再叠一层盲切;凡是可能 +/// 承载信封的定界点都从这里取上限,非信封文本仍走各自原有的上限。 +pub(crate) fn static_delegate_result_detail_max_chars( + value: &str, + default_max_chars: usize, +) -> usize { + if value + .trim_start() + .starts_with(STATIC_DELEGATE_USER_INPUT_PREFIX) + { + STATIC_DELEGATE_USER_INPUT_MAX_RESPONSE_CHARS + } else { + default_max_chars + } +} + +/// 构造一份贴着问询 schema 上限的合法澄清信封,供跨模块的通道用例复用。 +/// 通道必须容得下 schema 允许的最大合法问询,而不只是「碰巧短」的那一条。 +#[cfg(test)] +pub(crate) fn schema_max_clarification_envelope() -> String { + let questions = (0..3) + .map(|index| { + serde_json::json!({ + "id": format!("decision_{index}"), + "header": "关键决定", + "question": "当前要定的规则。".repeat(40), + "options": (0..3) + .map(|option| { + serde_json::json!({ + "label": format!("{} · 平行方案", char::from(b'A' + option as u8)), + "description": "该方案的边界与代价。".repeat(20), + }) + }) + .collect::>(), + }) + }) + .collect::>(); + format!( + "{STATIC_DELEGATE_USER_INPUT_PREFIX}{}", + serde_json::json!({ "questions": questions }) + ) +} + +// 澄清轮次上限按 source 区分:诉求只来自策划节点,game-chat 单主路径定位零打扰, +// 从未承诺给它 3 轮预算,因此取 1;其它 source(包括 Project Supervisor 常规协作)取 3。 +const STATIC_DELEGATE_CLARIFICATION_ROUND_LIMIT_DEFAULT: u32 = 3; +const STATIC_DELEGATE_CLARIFICATION_ROUND_LIMIT_GAME_CHAT: u32 = 1; +// 链上重放的防环 / 防越界上限,远大于设计允许的最大 7 跳,纯粹是安全阀。 +const STATIC_DELEGATE_LINEAGE_MAX_HOPS: usize = 32; #[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "kebab-case")] @@ -25,12 +81,58 @@ pub(crate) enum StaticDelegateDeliveryStatus { Suppressed, } -#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "kebab-case")] +#[derive(Clone, Debug, Eq, PartialEq)] pub(crate) enum StaticDelegateContractStatus { EvidenceReady, NeedsRepair, NeedsUserInput, + UserRevisionRequested, + /// A structurally valid durable status introduced by a newer client. + /// + /// The raw wire value is retained so an old client can safely read and + /// rewrite the record without silently changing the newer status. + Unknown(String), +} + +impl StaticDelegateContractStatus { + pub(crate) fn is_unknown(&self) -> bool { + matches!(self, Self::Unknown(_)) + } + + fn durable_value(&self) -> &str { + match self { + Self::EvidenceReady => "evidence-ready", + Self::NeedsRepair => "needs-repair", + Self::NeedsUserInput => "needs-user-input", + Self::UserRevisionRequested => "user-revision-requested", + Self::Unknown(value) => value, + } + } +} + +impl serde::Serialize for StaticDelegateContractStatus { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.durable_value()) + } +} + +impl<'de> serde::Deserialize<'de> for StaticDelegateContractStatus { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let value = ::deserialize(deserializer)?; + Ok(match value.as_str() { + "evidence-ready" => Self::EvidenceReady, + "needs-repair" => Self::NeedsRepair, + "needs-user-input" => Self::NeedsUserInput, + "user-revision-requested" => Self::UserRevisionRequested, + _ => Self::Unknown(value), + }) + } } #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] @@ -164,6 +266,8 @@ pub(crate) struct StaticDelegateCompletionBarrier { pub(crate) unobserved_claim_count: usize, pub(crate) repair_required_count: usize, pub(crate) user_input_required_count: usize, + pub(crate) user_revision_pending_count: usize, + pub(crate) unknown_contract_status_count: usize, } impl StaticDelegateCompletionBarrier { @@ -173,20 +277,26 @@ impl StaticDelegateCompletionBarrier { && self.unobserved_claim_count == 0 && self.repair_required_count == 0 && self.user_input_required_count == 0 + && self.user_revision_pending_count == 0 + && self.unknown_contract_status_count == 0 } pub(crate) fn has_waiting(self) -> bool { self.waiting_count > 0 + || self.user_revision_pending_count > 0 + || self.unknown_contract_status_count > 0 } pub(crate) fn detail(self) -> String { format!( - "waitingDelegations={} · readyUnclaimedReceipts={} · unobservedReceiptClaims={} · repairRequired={} · userInputRequired={} · 必须认领专业 Agent 回执,并处理 needs-user-input 或对 needs-repair 原委派发起唯一返工后再继续", + "waitingDelegations={} · readyUnclaimedReceipts={} · unobservedReceiptClaims={} · repairRequired={} · userInputRequired={} · userRevisionPending={} · unknownContractStatus={} · 必须认领专业 Agent 回执,处理 needs-user-input/needs-repair/user-revision-requested,或升级客户端后再继续", self.waiting_count, self.ready_unclaimed_count, self.unobserved_claim_count, self.repair_required_count, - self.user_input_required_count + self.user_input_required_count, + self.user_revision_pending_count, + self.unknown_contract_status_count ) } } @@ -390,6 +500,42 @@ pub(crate) fn mark_static_delegate_delivery_ready_with_result_at( Ok(delivery) } +/// Mark an already claimed, evidence-ready planning delivery as waiting for a +/// user-requested revision. Approval is the only producer of this durable +/// status; keeping the transition here makes its evidence precondition and +/// idempotency explicit instead of allowing a generic delivery writer to +/// manufacture the state. +pub(crate) fn mark_static_delegate_delivery_user_revision_requested_at( + root: &Path, + parent_agent_id: &str, + parent_run_id: &str, + delegation_id: &str, +) -> Result { + validate_static_delegate_id(parent_agent_id, "parentAgentId", 96)?; + validate_static_delegate_id(parent_run_id, "parentRunId", 160)?; + validate_static_delegate_id(delegation_id, "delegationId", 160)?; + let mut delivery = read_static_delegate_delivery_at(root, delegation_id)? + .ok_or_else(|| format!("静态委派 delivery 不存在:{delegation_id}"))?; + if delivery.parent_agent_id != parent_agent_id || delivery.parent_run_id != parent_run_id { + return Err("用户修订只能改写同一 Supervisor 父 run 的 delivery".to_string()); + } + if delivery.status != StaticDelegateDeliveryStatus::ClaimedByParent { + return Err("用户修订只能改写已由 Supervisor 认领的 delivery".to_string()); + } + let Some(result) = delivery.structured_result.as_mut() else { + return Err("用户修订的原 delivery 缺少 structuredResult".to_string()); + }; + match result.contract_status { + StaticDelegateContractStatus::UserRevisionRequested => return Ok(delivery), + StaticDelegateContractStatus::EvidenceReady => {} + _ => return Err("用户修订只能从 EvidenceReady delivery 派生".to_string()), + } + result.contract_status = StaticDelegateContractStatus::UserRevisionRequested; + delivery.updated_at = unix_timestamp(); + write_static_delegate_delivery_at(root, &delivery)?; + Ok(delivery) +} + pub(crate) fn suppress_static_delegate_delivery_at( root: &Path, expected: &StaticDelegateDeliveryRecord, @@ -504,6 +650,35 @@ pub(crate) fn static_delegate_completion_barrier_at( }) }) .count(); + barrier.user_revision_pending_count = deliveries + .iter() + .filter(|delivery| { + delivery.status == StaticDelegateDeliveryStatus::ClaimedByParent + && delivery.structured_result.as_ref().is_some_and(|result| { + result.contract_status == StaticDelegateContractStatus::UserRevisionRequested + }) + && !deliveries.iter().any(|candidate| { + candidate.repair_of_delegation_id.as_deref() + == Some(delivery.delegation_id.as_str()) + && matches!( + candidate.status, + StaticDelegateDeliveryStatus::Dispatched + | StaticDelegateDeliveryStatus::Ready + | StaticDelegateDeliveryStatus::ClaimedByParent + ) + }) + }) + .count(); + barrier.unknown_contract_status_count = deliveries + .iter() + .filter(|delivery| { + delivery.status == StaticDelegateDeliveryStatus::ClaimedByParent + && delivery + .structured_result + .as_ref() + .is_some_and(|result| result.contract_status.is_unknown()) + }) + .count(); Ok(barrier) } @@ -1075,6 +1250,143 @@ pub(crate) fn game_chat_art_delivery_gap_at( Ok(None) } +/// 唯一权威判据:某条 delivery 是否处于「等待用户澄清」状态——也就是说,从它出发的 +/// 下一跳(若存在)应当被归类为澄清 continuation,而不是质量返工。 +/// `validate_static_delegate_repair_request_at` 与 +/// `validate_static_delegate_clarification_continuation_at` 共用这一个判据源, +/// 避免两处口径漂移;用户修订状态另由下方判据单独识别。 +fn static_delegate_original_is_awaiting_clarification( + delivery: &StaticDelegateDeliveryRecord, +) -> bool { + delivery.structured_result.as_ref().is_some_and(|result| { + result.contract_status == StaticDelegateContractStatus::NeedsUserInput + }) +} + +/// 唯一权威判据:某条 delivery 是否由用户审批的「修改」动作标记为待修订。 +/// +/// 该状态只由后续审批工作包写入;本包只让 lineage 重放认识它,不能自行生成或 +/// 把其它状态静默映射成它。 +fn static_delegate_original_is_user_revision_requested( + delivery: &StaticDelegateDeliveryRecord, +) -> bool { + delivery.structured_result.as_ref().is_some_and(|result| { + result.contract_status == StaticDelegateContractStatus::UserRevisionRequested + }) +} + +/// A newer durable contract status is intentionally not a repairable contract. +/// The old client may preserve and report it, but must not manufacture a +/// mutation under semantics it does not understand. +fn static_delegate_original_has_unknown_contract_status( + delivery: &StaticDelegateDeliveryRecord, +) -> bool { + delivery + .structured_result + .as_ref() + .is_some_and(|result| result.contract_status.is_unknown()) +} + +/// 沿 repair_of_delegation_id 反向重放整条链,现算目标 delivery 的 +/// (repair_depth, clarification_round)。两个维度都是运行时派生值,故意不落盘: +/// - 链根(repair_of_delegation_id 为 None):depth = 0,round = 0。 +/// - 澄清跳(父节点处于 awaiting-clarification):round = parent.round + 1, +/// depth 原样继承(不重置),否则可以插一次澄清洗掉返工深度,变成无限返工。 +/// - 用户修订跳(父节点为 UserRevisionRequested):depth/round 都原样继承, +/// 因为该跳由用户显式触发,不属于 runaway-agent 质量返工。 +/// - 质量返工跳(父节点不处于上述两种状态):depth = parent.depth + 1, +/// round 重置为 0,因为返工后策划节点重新开工,不能因为返工吃掉预设的澄清轮次预算。 +/// +/// 防环 / 防越界:反向重放阶段一旦遇到重复 id、缺失的上游节点,或跳数超出 +/// STATIC_DELEGATE_LINEAGE_MAX_HOPS,立即 fail closed,返回 (u32::MAX, u32::MAX), +/// 使调用方的深度门 / 轮次门必然拒绝,而不是静默放行。 +pub(crate) fn static_delegate_lineage_counters( + deliveries: &[StaticDelegateDeliveryRecord], + delegation_id: &str, +) -> (u32, u32) { + let Some(mut chain) = static_delegate_lineage_nodes(deliveries, delegation_id) else { + return (u32::MAX, u32::MAX); + }; + // chain 目前是 [目标 .. 根],反转成 [根 .. 目标] 便于按 R1/R2/R3 正向传播。 + chain.reverse(); + let mut depth = 0u32; + let mut round = 0u32; + for parent in &chain[..chain.len().saturating_sub(1)] { + if static_delegate_original_is_awaiting_clarification(*parent) { + round += 1; + } else if static_delegate_original_is_user_revision_requested(*parent) { + // 用户明确触发的修订不是 runaway-agent 返工;保留两个运行时派生计数。 + } else { + depth += 1; + round = 0; + } + } + (depth, round) +} + +/// Return whether the target's durable lineage contains a status introduced by +/// a newer client. A malformed lineage is an error rather than a negative +/// answer: callers that use this as a mutation/Provider gate must fail closed. +pub(crate) fn static_delegate_lineage_contains_unknown_contract_status( + deliveries: &[StaticDelegateDeliveryRecord], + delegation_id: &str, +) -> Result { + let chain = static_delegate_lineage_nodes(deliveries, delegation_id) + .ok_or_else(|| "静态委派谱系无效,无法检查未知 contractStatus".to_string())?; + Ok(chain.iter().any(|delivery| { + delivery + .structured_result + .as_ref() + .is_some_and(|result| result.contract_status.is_unknown()) + })) +} + +fn static_delegate_lineage_nodes<'a>( + deliveries: &'a [StaticDelegateDeliveryRecord], + delegation_id: &str, +) -> Option> { + let mut chain: Vec<&StaticDelegateDeliveryRecord> = Vec::new(); + let mut seen_ids: std::collections::BTreeSet<&str> = std::collections::BTreeSet::new(); + let mut current_id = delegation_id; + loop { + if !seen_ids.insert(current_id) || chain.len() >= STATIC_DELEGATE_LINEAGE_MAX_HOPS { + return None; + } + let Some(node) = deliveries + .iter() + .find(|delivery| delivery.delegation_id == current_id) + else { + return None; + }; + chain.push(node); + match node.repair_of_delegation_id.as_deref() { + Some(parent_id) => current_id = parent_id, + None => break, + } + } + Some(chain) +} + +/// 澄清轮次上限只按发起请求所在 Run 的 source 区分(详见常量注释), +/// 与仓库已有的 game-chat 分支先例(见 agent/runtime_tools/delegation.rs 的 +/// may_be_game_chat 判定)同源:source 缺失 binding 时按非 game-chat 处理。 +pub(crate) fn static_delegate_clarification_round_limit_at( + root: &Path, + parent_agent_id: &str, + parent_run_id: &str, +) -> Result { + let is_game_chat = + read_game_creator_agent_runtime_run_profile_binding(root, parent_agent_id, parent_run_id)? + .is_some_and(|binding| { + binding.source.trim() == AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE + }); + Ok(if is_game_chat { + STATIC_DELEGATE_CLARIFICATION_ROUND_LIMIT_GAME_CHAT + } else { + STATIC_DELEGATE_CLARIFICATION_ROUND_LIMIT_DEFAULT + }) +} + pub(crate) fn validate_static_delegate_repair_request_at( root: &Path, parent_agent_id: &str, @@ -1111,9 +1423,38 @@ pub(crate) fn validate_static_delegate_repair_request_at( if original.target_agent_id != target_agent_id { return Err("静态委派返工必须交回原专业 Agent".to_string()); } - if original.repair_of_delegation_id.is_some() { + if static_delegate_original_has_unknown_contract_status(original) { + return Err( + "静态委派原 delivery 的 contractStatus 由更新版本写入,当前版本拒绝返工".to_string(), + ); + } + let (original_repair_depth, original_clarification_round) = + static_delegate_lineage_counters(&deliveries, &original.delegation_id); + if static_delegate_original_is_awaiting_clarification(original) { + let clarification_round_limit = + static_delegate_clarification_round_limit_at(root, parent_agent_id, parent_run_id)?; + if original_clarification_round >= clarification_round_limit { + return Err("静态委派澄清轮次已达上限".to_string()); + } + } else if static_delegate_original_is_user_revision_requested(original) { + // 用户修订不消耗 repair_depth/clarification_round,但链上重放的 fail-closed + // 哨兵仍不能被绕过;否则损坏或成环的 durable lineage 可能被误放行。 + if original_repair_depth == u32::MAX || original_clarification_round == u32::MAX { + return Err("静态委派谱系计数无效,拒绝继续".to_string()); + } + } else if original_repair_depth >= 1 { return Err("静态委派返工深度最多为 1".to_string()); } + if static_delegate_lineage_contains_unknown_contract_status( + &deliveries, + &original.delegation_id, + ) + .map_err(|_| "静态委派谱系计数无效,拒绝继续".to_string())? + { + return Err( + "静态委派原 delivery 所在谱系含更新版本 contractStatus,当前版本拒绝返工".to_string(), + ); + } if original.acceptance_criteria != acceptance_criteria || original.expected_artifacts != expected_artifacts { @@ -1175,9 +1516,7 @@ pub(crate) fn validate_static_delegate_clarification_continuation_at( read_static_delegate_delivery_at(root, repair_of_delegation_id)?.ok_or_else(|| { format!("澄清 continuation 引用的原 delivery 不存在:{repair_of_delegation_id}") })?; - let needs_user_input = original.structured_result.as_ref().is_some_and(|result| { - result.contract_status == StaticDelegateContractStatus::NeedsUserInput - }); + let needs_user_input = static_delegate_original_is_awaiting_clarification(&original); if !needs_user_input { if !continuation_of.is_empty() || !input_questions_sha.is_empty() @@ -1214,7 +1553,13 @@ pub(crate) fn validate_static_delegate_clarification_continuation_at( if continuation_of != repair_of_delegation_id { return Err("澄清 continuation 必须绑定原 delegationId".to_string()); } - if input_questions_sha != questions_sha256 || input_answers_sha != answers_sha256 { + // 两个指纹是可选的:原 delivery 已经唯一确定了它们,下面的 continuation identity + // 也只用这份权威值算,输入侧填了只是重复一遍。要求 Supervisor 手抄 128 个十六进制 + // 字符没有任何信息增益,抄错却会一路打到硬失败,所以缺省时由 Runtime 自己补齐。 + // 填了就仍然逐字校验——它能证明本轮续跑对应的确实是这次已回答的请求。 + if (!input_questions_sha.is_empty() && input_questions_sha != questions_sha256) + || (!input_answers_sha.is_empty() && input_answers_sha != answers_sha256) + { return Err("澄清 continuation 的问题或答案指纹与已回答请求不一致".to_string()); } let continuation_identity = format!( @@ -1292,12 +1637,7 @@ pub(crate) fn static_delegate_clarification_pending_matches_delivery_at( { return Ok(false); } - let Some(delegation_id) = pending - .task - .strip_prefix("子 Agent 需要用户澄清后才能继续。delegationId=") - .and_then(|value| value.split(';').next()) - .map(str::trim) - .filter(|value| !value.is_empty()) + let Some(delegation_id) = agent_runtime_delegate_clarification_delegation_id(&pending.task) else { return Ok(false); }; @@ -1391,11 +1731,24 @@ pub(crate) fn build_static_delegate_structured_result_at( let verification_passed = !verification_required || (verification_status == Some("passed") && verified_revision.is_some()); let completed = terminal_status == "completed"; - let (user_input_questions, user_input_questions_sha256) = - parse_static_delegate_user_input_request(error)?; + // 终态信封在这里解析,而这里是**投递回执时**——子 run 此刻已经终止,没有任何 + // 一轮可以把解析错误回灌给它。早期实现在这一步直接 `?`,于是一次格式手滑 + // (实测:option 对象里多写了一个 `id` 字段)就把整条委派判成投递失败,父 + // Supervisor 直接进 needs-reconciliation 停下等人。 + // + // 信封格式属于「本次 Provider 输出写错」,不是「durable 权威损坏」,和 + // plan.submit_gdd 的业务拒绝同类。降级成 needs-repair 并把解析错误当返工理由 + // 带上:Supervisor 用既有的一次返工额度就能让子 Agent 重写,不需要人工介入。 + let (user_input_questions, user_input_questions_sha256, user_input_parse_error) = + match parse_static_delegate_user_input_request(error) { + Ok((questions, sha256)) => (questions, sha256, None), + Err(parse_error) => (None, None, Some(parse_error)), + }; let needs_user_input = completed && user_input_questions.is_some(); let contract_status = if needs_user_input { StaticDelegateContractStatus::NeedsUserInput + } else if user_input_parse_error.is_some() { + StaticDelegateContractStatus::NeedsRepair } else if completed && missing_expected_artifacts.is_empty() && verification_passed { StaticDelegateContractStatus::EvidenceReady } else { @@ -1415,8 +1768,19 @@ pub(crate) fn build_static_delegate_structured_result_at( }); } let derived_error = if contract_status == StaticDelegateContractStatus::NeedsRepair { - error - .map(|value| redact_agent_runtime_error(root, value, 500)) + // 坏信封的返工理由必须是**解析失败在哪**,而不是把那段无法解析的原文照抄 + // 回去——后者对子 Agent 没有任何可操作信息。 + user_input_parse_error + .map(|parse_error| { + redact_agent_runtime_error( + root, + &format!( + "AGC_NEEDS_USER_INPUT_V1 信封无法解析:{parse_error}。请严格按信封契约重写问题(questions 恰好一题,元素只含 id/header/question/options,option 只含 label/description),或者直接完成交付收束。" + ), + 500, + ) + }) + .or_else(|| error.map(|value| redact_agent_runtime_error(root, value, 500))) .filter(|value| !value.trim().is_empty()) .or_else(|| { if !completed { @@ -1448,7 +1812,52 @@ pub(crate) fn build_static_delegate_structured_result_at( }) } -fn parse_static_delegate_user_input_request( +/// 从信封载荷里切出第一个括号配平的 JSON 对象。 +/// +/// serde 的 from_str 要求整段输入就是一个值,尾部多一个字节就整包拒收。而模型在长 +/// 嵌套 JSON 字符串的尾部会退化:27 个历史 run 的 68 条真实澄清信封里,有 2 条把信封 +/// 写完整之后继续吐垃圾(「 马会」「સwerhu рҭ. 北京赛车? тру. [ ]」),信封本身 +/// 一个字节都没坏,却和真正写坏的信封一样被判死,整条委派链停在 needs-repair。 +/// +/// 信封是终态协议载荷,配平的那个对象之后不存在任何协议内容,按配平定界即可。 +/// 反过来,少写闭合符的那一类(同一批里 5 条,结尾是 `}]}` 而非 `}]}]}`)在这里 +/// 仍然失败:那是模型真的没把结构写完,补括号只是替它猜一个它没表达的形状。 +fn static_delegate_user_input_balanced_object(payload: &str) -> Result<&str, String> { + if !payload.starts_with('{') { + return Err("信封标记后不是 JSON 对象".to_string()); + } + let mut depth = 0usize; + let mut in_string = false; + let mut escaped = false; + for (index, character) in payload.char_indices() { + if in_string { + if escaped { + escaped = false; + } else if character == '\\' { + escaped = true; + } else if character == '"' { + in_string = false; + } + continue; + } + match character { + '"' => in_string = true, + '{' | '[' => depth += 1, + '}' | ']' => { + depth = depth + .checked_sub(1) + .ok_or_else(|| "信封 JSON 括号不闭合".to_string())?; + if depth == 0 { + return Ok(&payload[..index + character.len_utf8()]); + } + } + _ => {} + } + } + Err("信封 JSON 括号不闭合".to_string()) +} + +pub(crate) fn parse_static_delegate_user_input_request( response: Option<&str>, ) -> Result<(Option>, Option), String> { let Some(response) = response.map(str::trim).filter(|value| !value.is_empty()) else { @@ -1457,10 +1866,16 @@ fn parse_static_delegate_user_input_request( if !response.starts_with(STATIC_DELEGATE_USER_INPUT_PREFIX) { return Ok((None, None)); } - if response.chars().count() > STATIC_DELEGATE_USER_INPUT_MAX_RESPONSE_CHARS { + let payload = response[STATIC_DELEGATE_USER_INPUT_PREFIX.len()..].trim(); + // 长度上限约束的是协议载荷本身,所以按配平后的切片算:退化尾巴既然不进解析器, + // 也就不该替一条合法信封把通道撑爆。 + let payload = static_delegate_user_input_balanced_object(payload) + .map_err(|reason| format!("子 Agent 用户澄清请求 JSON 无效:{reason}"))?; + if STATIC_DELEGATE_USER_INPUT_PREFIX.chars().count() + payload.chars().count() + > STATIC_DELEGATE_USER_INPUT_MAX_RESPONSE_CHARS + { return Err("子 Agent 用户澄清请求超过回执长度上限".to_string()); } - let payload = response[STATIC_DELEGATE_USER_INPUT_PREFIX.len()..].trim(); let value = serde_json::from_str::(payload) .map_err(|error| format!("子 Agent 用户澄清请求 JSON 无效:{error}"))?; let questions = parse_game_creator_agent_user_input_questions(&value)?; @@ -1578,7 +1993,7 @@ pub(crate) fn read_static_delegate_delivery_at( Ok(Some(record)) } -fn list_static_delegate_deliveries_at( +pub(crate) fn list_static_delegate_deliveries_at( root: &Path, ) -> Result, String> { let dir = resolve_local_project_path(root, STATIC_DELEGATE_DELIVERY_DIR)?; @@ -1675,7 +2090,7 @@ fn list_static_delegate_claims_at(root: &Path) -> Result Result<(), String> { @@ -2193,28 +2608,39 @@ fn validate_static_delegate_structured_result( if result.verified_revision == Some(0) { return Err("静态委派 verifiedRevision 必须大于 0".to_string()); } - if result.contract_status == StaticDelegateContractStatus::EvidenceReady - && (terminal_status != "completed" - || !result.missing_expected_artifacts.is_empty() - || (result.verification_required && result.verified_revision.is_none())) - { - return Err("静态委派 evidence-ready 与客观证据冲突".to_string()); + match &result.contract_status { + StaticDelegateContractStatus::EvidenceReady + | StaticDelegateContractStatus::UserRevisionRequested => { + if terminal_status != "completed" + || !result.missing_expected_artifacts.is_empty() + || (result.verification_required && result.verified_revision.is_none()) + { + return Err( + "静态委派 evidence-ready/user-revision-requested 与客观证据冲突".to_string(), + ); + } + } + StaticDelegateContractStatus::NeedsUserInput => { + if terminal_status != "completed" + || result.user_input_questions.is_empty() + || result.user_input_questions.len() > 3 + { + return Err("静态委派 needs-user-input 与终态或问题数量冲突".to_string()); + } + let expected_sha = serde_json::to_vec(&result.user_input_questions) + .map(|bytes| format!("{:x}", Sha256::digest(bytes))) + .map_err(|error| format!("序列化静态委派用户问题失败:{error}"))?; + if result.user_input_questions_sha256.as_deref() != Some(expected_sha.as_str()) { + return Err("静态委派 needs-user-input 问题指纹无效".to_string()); + } + } + StaticDelegateContractStatus::NeedsRepair | StaticDelegateContractStatus::Unknown(_) => {} } - if result.contract_status == StaticDelegateContractStatus::NeedsUserInput { - if terminal_status != "completed" - || result.user_input_questions.is_empty() - || result.user_input_questions.len() > 3 - { - return Err("静态委派 needs-user-input 与终态或问题数量冲突".to_string()); - } - let expected_sha = serde_json::to_vec(&result.user_input_questions) - .map(|bytes| format!("{:x}", Sha256::digest(bytes))) - .map_err(|error| format!("序列化静态委派用户问题失败:{error}"))?; - if result.user_input_questions_sha256.as_deref() != Some(expected_sha.as_str()) { - return Err("静态委派 needs-user-input 问题指纹无效".to_string()); - } - } else if !result.user_input_questions.is_empty() - || result.user_input_questions_sha256.is_some() + if !matches!( + result.contract_status, + StaticDelegateContractStatus::NeedsUserInput + ) && (!result.user_input_questions.is_empty() + || result.user_input_questions_sha256.is_some()) { return Err("非 needs-user-input 静态委派不能携带用户问题".to_string()); } @@ -2252,6 +2678,184 @@ fn validate_static_delegate_structured_result( mod tests { use super::*; + /// 中转通道和澄清问询 schema 之间隔着一个字符数上限,两边没有任何东西相连。 + /// 通道窄于 schema 的代价不是「问询被截断」:子 Agent 提了一个完全合法的问题, + /// Runtime 会在父 run 认领回执时整包拒收,委派链就此阻断——现场实测一问三选的 + /// 正常中文问询是 501 字符,而当时的上限恰好是 500。 + /// + /// 所以这里锁的是**包含关系**:schema 允许的最大合法问询,必须能过通道。 + #[test] + fn user_input_relay_channel_admits_the_largest_schema_legal_request() { + let long_text = |count: usize| "问".repeat(count); + let questions = (0..3) + .map(|index| { + serde_json::json!({ + "id": format!("q{index}{}", "a".repeat(60)), + "header": long_text(12), + "question": long_text(400), + "options": (0..3) + .map(|option| serde_json::json!({ + "label": format!("{option}{}", long_text(59)), + "description": long_text(240), + })) + .collect::>(), + }) + }) + .collect::>(); + let response = format!( + "{STATIC_DELEGATE_USER_INPUT_PREFIX}{}", + serde_json::json!({ "questions": questions }) + ); + assert!( + response.chars().count() <= STATIC_DELEGATE_USER_INPUT_MAX_RESPONSE_CHARS, + "schema 允许的最大问询 {} 字符超过了中转通道上限 {}", + response.chars().count(), + STATIC_DELEGATE_USER_INPUT_MAX_RESPONSE_CHARS + ); + let (parsed, sha256) = parse_static_delegate_user_input_request(Some(&response)) + .expect("largest schema-legal clarification must pass the relay channel"); + assert_eq!(parsed.expect("questions").len(), 3); + assert!(sha256.is_some_and(|value| value.len() == 64)); + } + + /// 澄清信封走的是 error 文本通道,会被按普通错误消息截断。现场子 Agent 的真实 + /// 输出 521 字符,截到 500 再补一个省略号正好 501——JSON 拦腰断在末尾,父 run + /// 解析失败停在 needs-reconciliation。放宽拒收上限治不了这个:载荷在到达解析器 + /// 之前就已经被切了。 + #[test] + fn truncating_a_clarification_envelope_makes_it_unparseable() { + let response = format!( + "{STATIC_DELEGATE_USER_INPUT_PREFIX}{}", + serde_json::json!({ + "questions": [{ + "id": "core_loop", + "header": "第1轮·核心", + "question": "影子能力在首个可玩闭环里承担什么作用?这决定关卡布局与原型优先级,也决定第一批谜题按什么规则组合。", + "options": [ + { "label": "A · 暗影分身", "description": "影子沿地面或墙面独立移动,可压机关、挡感应光、穿窄缝;规则直观,代价是要处理可达范围与回收。" }, + { "label": "B · 暗影桥梁", "description": "调整光源与站位让影子延展成短暂平台或连接导电点;偏空间构图,代价是碰撞与落脚可读性更严格。" }, + ], + }] + }) + ); + // 完整信封能过通道。 + parse_static_delegate_user_input_request(Some(&response)).expect("intact envelope parses"); + // 同一条信封被截断后必然解析失败——这正是现场那条 needs-reconciliation。 + let truncated: String = response + .chars() + .take(response.chars().count() - 20) + .collect(); + let error = parse_static_delegate_user_input_request(Some(&truncated)) + .expect_err("a truncated envelope must not parse"); + 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 现场原文,结尾是 `}]}` 而非 `}]}]}`。 + let response = concat!( + "AGC_NEEDS_USER_INPUT_V1\n", + r#"{"questions":[{"id":"core_loop_goal","header":"第1轮·关键决定","question":"当前要决定:这款农场经营游戏的一局,玩家主要通过什么目标获得满足?现在先定核心闭环,才能控制 MVP 范围。","options":[{"label":"A · 推荐:短周期订单经营","description":"围绕播种、收获、加工并完成限时订单推进;目标清晰、反馈快,代价是自由建造与长期规划较少。"},{"label":"B · 自主农场成长","description":"围绕规划田地、逐步扩建并达成阶段里程碑;沉浸和成长感更强,代价是前期目标反馈较慢、系统边界更难控。"},{"label":"需要原型验证","description":"制作 30~90 分钟微型原型,包含种植、收获和一种目标;让 2~3 名目标玩家试玩,观察是否理解目标、是否愿意继续一轮;通过标准是多数玩家无需讲解即可完成闭环并主动开始第二轮。"}]}"#, + ); + let error = parse_static_delegate_user_input_request(Some(response)) + .expect_err("an envelope that stops short of closing must not parse"); + assert!(error.contains("括号不闭合"), "unexpected error: {error}"); + } + + /// 定界只认字符串外的括号。信封正文里出现的括号字符必须被跳过,否则一条完全 + /// 合法的信封会因为问题文案里写了 `}` 而被提前切断。 + #[test] + fn balanced_object_scanning_ignores_brackets_inside_strings() { + let response = format!( + "{STATIC_DELEGATE_USER_INPUT_PREFIX}{}", + serde_json::json!({ + "questions": [{ + "id": "brace_heavy", + "header": "括号", + "question": "存档格式写成 {\"slot\": [1]} 还是二进制?", + "options": [ + {"label": "A · JSON", "description": "形如 {\"slot\": [1]} 的文本存档,可读但体积大。"}, + {"label": "B · 二进制", "description": "紧凑但要自己写工具才能看,形如 ]}]} 的字节序列。"} + ] + }] + }) + ); + let (questions, _) = parse_static_delegate_user_input_request(Some(&response)) + .expect("brackets inside strings must not terminate the scan"); + assert_eq!(questions.expect("questions present").len(), 1); + } + #[test] fn static_delegate_target_agent_ids_include_claimed_and_exclude_suppressed_or_repair() { let root = std::env::temp_dir().join(format!( @@ -2418,6 +3022,48 @@ mod tests { assert!(error.contains("user.input_request")); } + /// 坏信封是 Provider 输出质量问题,不是 durable 权威损坏。它必须变成一次可返工 + /// 的 needs-repair,而不是把整条委派判成投递失败、把父 Supervisor 推进 + /// needs-reconciliation——解析发生在子 run 终止之后,那条路上没有任何一轮能自愈。 + #[test] + fn a_malformed_user_input_envelope_degrades_to_needs_repair_with_the_parse_reason() { + // 实测形态:option 对象里多写了一个 `id` 字段。 + let response = concat!( + "AGC_NEEDS_USER_INPUT_V1\n", + "{\"questions\":[{\"id\":\"core_loop\",\"header\":\"第1轮·关键决定\",", + "\"question\":\"当前要决定:核心闭环形状。\",\"options\":[", + "{\"id\":\"a\",\"label\":\"A · 甲方案\",\"description\":\"甲方案的后果\"},", + "{\"id\":\"b\",\"label\":\"B · 乙方案\",\"description\":\"乙方案的后果\"},", + "{\"id\":\"c\",\"label\":\"需要原型验证\",\"description\":\"做个微型原型看看\"}]}]}" + ); + parse_static_delegate_user_input_request(Some(response)) + .expect_err("an option carrying an extra field is still a malformed envelope"); + + let result = build_static_delegate_structured_result_at( + &std::env::temp_dir(), + "completed", + &[], + false, + None, + None, + None, + Some(response), + ) + .expect("a malformed envelope must not fail the whole delivery"); + assert_eq!( + result.contract_status, + StaticDelegateContractStatus::NeedsRepair + ); + assert!(result.user_input_questions.is_empty()); + assert!(result.user_input_questions_sha256.is_none()); + let reason = result.error.as_deref().expect("a repair reason is derived"); + // 返工理由要说清错在哪,而不是把那段无法解析的原文照抄回去。 + assert!(reason.contains("AGC_NEEDS_USER_INPUT_V1 信封无法解析")); + assert!(reason.contains("label")); + validate_static_delegate_structured_result(&result, "completed", &[]) + .expect("the degraded result still validates"); + } + #[test] fn game_chat_safe_default_replacement_recovers_both_persisted_half_states() { let root = std::env::temp_dir().join(format!( @@ -2753,4 +3399,575 @@ mod tests { fs::remove_dir_all(root).ok(); } + + fn claimed_static_delegate_for_lineage_test( + parent_run_id: &str, + delegation_id: &str, + repair_of_delegation_id: Option<&str>, + contract_status: StaticDelegateContractStatus, + ) -> StaticDelegateDeliveryRecord { + let mut delivery = new_static_delegate_delivery_with_contract( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "m1c0-lineage-parent-session", + parent_run_id, + &format!("{delegation_id}-action"), + delegation_id, + "design-director", + &format!("{delegation_id}-session"), + &format!("{delegation_id}-run"), + &[], + &[], + repair_of_delegation_id, + ); + let mut result = StaticDelegateStructuredResult::default(); + result.contract_status = contract_status; + delivery.status = StaticDelegateDeliveryStatus::ClaimedByParent; + delivery.terminal_status = Some("completed".to_string()); + delivery.result_summary = Some("M1C-0 lineage test".to_string()); + delivery.structured_result = Some(result); + delivery.claimed_by_action_id = Some(format!("{delegation_id}-claim-action")); + delivery + } + + #[test] + fn static_delegate_user_revision_preserves_counters_and_bypasses_depth_gate() { + let root = std::env::temp_dir().join(format!( + "genarrative-static-user-revision-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system time after unix epoch") + .as_nanos() + )); + init_local_game_project_at(&root, "m1c0-user-revision", "M1C-0 用户修订分类测试") + .expect("project init"); + let parent_run_id = "m1c0-user-revision-parent-run"; + let root_delivery = claimed_static_delegate_for_lineage_test( + parent_run_id, + "m1c0-user-revision-d0", + None, + StaticDelegateContractStatus::NeedsRepair, + ); + write_static_delegate_delivery_at(&root, &root_delivery).expect("write root delivery"); + + // 先证明做游戏链路的普通返工仍然受 depth=1 门限制。 + let mut first_revision = claimed_static_delegate_for_lineage_test( + parent_run_id, + "m1c0-user-revision-d1", + Some(&root_delivery.delegation_id), + StaticDelegateContractStatus::NeedsRepair, + ); + write_static_delegate_delivery_at(&root, &first_revision) + .expect("write first repair delivery"); + let ordinary_repair_error = validate_static_delegate_repair_request_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + "m1c0-user-revision-d2-ordinary", + "design-director", + &[], + &[], + Some(&first_revision.delegation_id), + ) + .expect_err("ordinary repair at depth=1 must remain blocked"); + assert!(ordinary_repair_error.contains("深度最多为 1")); + + // 同一节点被用户审批标记为修订后,第一次用户修订不应再被当作质量返工。 + first_revision + .structured_result + .as_mut() + .expect("first revision structured result") + .contract_status = StaticDelegateContractStatus::UserRevisionRequested; + write_static_delegate_delivery_at(&root, &first_revision) + .expect("rewrite first delivery as user revision"); + let deliveries = list_static_delegate_deliveries_at(&root).expect("list deliveries"); + assert_eq!( + static_delegate_lineage_counters(&deliveries, &first_revision.delegation_id), + (1, 0), + "用户修订跳的父节点已有一次普通返工,两个计数仍应保持 (1,0)" + ); + validate_static_delegate_repair_request_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + "m1c0-user-revision-d2", + "design-director", + &[], + &[], + Some(&first_revision.delegation_id), + ) + .expect("user revision must bypass the depth=1 quality-repair gate"); + + // 连续第二次用户修订仍挂在同一 lineage 上,不能把任一计数重新解释成返工。 + let second_revision = claimed_static_delegate_for_lineage_test( + parent_run_id, + "m1c0-user-revision-d2", + Some(&first_revision.delegation_id), + StaticDelegateContractStatus::UserRevisionRequested, + ); + write_static_delegate_delivery_at(&root, &second_revision) + .expect("write second user revision delivery"); + let deliveries = list_static_delegate_deliveries_at(&root).expect("list deliveries"); + assert_eq!( + static_delegate_lineage_counters(&deliveries, &second_revision.delegation_id), + (1, 0), + "连续用户修订不能增加 repair_depth,也不能重置 clarification_round" + ); + validate_static_delegate_repair_request_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + "m1c0-user-revision-d3", + "design-director", + &[], + &[], + Some(&second_revision.delegation_id), + ) + .expect("a second consecutive user revision must remain admissible"); + + fs::remove_dir_all(root).ok(); + } + + #[test] + fn static_delegate_user_revision_parent_hop_preserves_depth_and_clarification_round() { + let mut quality_root = new_static_delegate_delivery( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "m1c0-counter-session", + "m1c0-counter-run", + "m1c0-counter-d0-action", + "m1c0-counter-d0", + "design-director", + "m1c0-counter-d0-session", + "m1c0-counter-d0-run", + ); + let mut quality_result = StaticDelegateStructuredResult::default(); + quality_result.contract_status = StaticDelegateContractStatus::NeedsRepair; + quality_root.structured_result = Some(quality_result); + + let mut clarification = new_static_delegate_delivery_with_contract( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "m1c0-counter-session", + "m1c0-counter-run", + "m1c0-counter-d1-action", + "m1c0-counter-d1", + "design-director", + "m1c0-counter-d1-session", + "m1c0-counter-d1-run", + &[], + &[], + Some("m1c0-counter-d0"), + ); + let mut clarification_result = StaticDelegateStructuredResult::default(); + clarification_result.contract_status = StaticDelegateContractStatus::NeedsUserInput; + clarification.structured_result = Some(clarification_result); + + let mut user_revision = new_static_delegate_delivery_with_contract( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "m1c0-counter-session", + "m1c0-counter-run", + "m1c0-counter-d2-action", + "m1c0-counter-d2", + "design-director", + "m1c0-counter-d2-session", + "m1c0-counter-d2-run", + &[], + &[], + Some("m1c0-counter-d1"), + ); + let mut user_revision_result = StaticDelegateStructuredResult::default(); + user_revision_result.contract_status = StaticDelegateContractStatus::UserRevisionRequested; + user_revision.structured_result = Some(user_revision_result); + + let mut deliveries = vec![quality_root, clarification, user_revision]; + // 对照组,不是主张:计数循环只遍历 chain[..len-1](父节点集合),目标节点自身的 + // status 从不参与判定。所以「目标是 UserRevisionRequested」这条断言与新分支无关, + // 单靠它证明不了用户修订分类。 + assert_eq!( + static_delegate_lineage_counters(&deliveries, "m1c0-counter-d2"), + (1, 1), + "目标自身是用户修订时,两个计数只由其父链决定" + ); + + // 真正打到新分支的是「父节点为 UserRevisionRequested」的下一跳。 + let continuation = new_static_delegate_delivery_with_contract( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "m1c0-counter-session", + "m1c0-counter-run", + "m1c0-counter-d3-action", + "m1c0-counter-d3", + "design-director", + "m1c0-counter-d3-session", + "m1c0-counter-d3-run", + &[], + &[], + Some("m1c0-counter-d2"), + ); + deliveries.push(continuation); + assert_eq!( + static_delegate_lineage_counters(&deliveries, "m1c0-counter-d3"), + (1, 1), + "父节点是用户修订时,depth 与 clarification_round 都必须原样继承" + ); + + // 反证:同一条链只把父节点改回普通质量返工,上一条必须变成 (2, 0)。缺了这条, + // 上一条断言在新分支被删掉后依然成立(走 else 支得到 (2, 0) 才会失败), + // 但没有对照就看不出它究竟钉住了什么。 + deliveries[2] + .structured_result + .as_mut() + .expect("d2 structured result") + .contract_status = StaticDelegateContractStatus::NeedsRepair; + assert_eq!( + static_delegate_lineage_counters(&deliveries, "m1c0-counter-d3"), + (2, 0), + "父节点不是用户修订时必须回到质量返工分类" + ); + } + + #[test] + fn static_delegate_user_revision_barrier_blocks_every_revision_until_continuation() { + let root = std::env::temp_dir().join(format!( + "genarrative-static-user-revision-barrier-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system time after unix epoch") + .as_nanos() + )); + init_local_game_project_at( + &root, + "m1c1-user-revision-barrier", + "M1C-1 用户修订 barrier 测试", + ) + .expect("project init"); + let parent_run_id = "m1c1-user-revision-barrier-parent-run"; + let first = claimed_static_delegate_for_lineage_test( + parent_run_id, + "m1c1-user-revision-barrier-first", + None, + StaticDelegateContractStatus::EvidenceReady, + ); + write_static_delegate_delivery_at(&root, &first).expect("write first delivery"); + mark_static_delegate_delivery_user_revision_requested_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + &first.delegation_id, + ) + .expect("mark first delivery for user revision"); + + let first_barrier = static_delegate_completion_barrier_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + ) + .expect("read first revision barrier"); + assert_eq!(first_barrier.user_revision_pending_count, 1); + assert!(!first_barrier.is_clear()); + assert!(first_barrier.has_waiting()); + assert!(first_barrier.detail().contains("userRevisionPending=1")); + + let mut continuation = claimed_static_delegate_for_lineage_test( + parent_run_id, + "m1c1-user-revision-barrier-continuation", + Some(&first.delegation_id), + StaticDelegateContractStatus::EvidenceReady, + ); + continuation.status = StaticDelegateDeliveryStatus::Dispatched; + continuation.terminal_status = None; + continuation.result_summary = None; + continuation.structured_result = None; + continuation.claimed_by_action_id = None; + write_static_delegate_delivery_at(&root, &continuation).expect("write continuation"); + let active_barrier = static_delegate_completion_barrier_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + ) + .expect("read active continuation barrier"); + assert_eq!(active_barrier.user_revision_pending_count, 0); + assert_eq!(active_barrier.waiting_count, 1); + + continuation.status = StaticDelegateDeliveryStatus::ClaimedByParent; + continuation.terminal_status = Some("completed".to_string()); + continuation.result_summary = Some("second revision candidate".to_string()); + continuation.structured_result = Some(StaticDelegateStructuredResult { + contract_status: StaticDelegateContractStatus::EvidenceReady, + ..StaticDelegateStructuredResult::default() + }); + continuation.claimed_by_action_id = + Some("m1c1-user-revision-barrier-continuation-claim".to_string()); + write_static_delegate_delivery_at(&root, &continuation) + .expect("complete continuation delivery"); + assert!( + static_delegate_completion_barrier_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + ) + .expect("read completed continuation barrier") + .is_clear(), + "the previous revision is satisfied once its continuation is claimed" + ); + + mark_static_delegate_delivery_user_revision_requested_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + &continuation.delegation_id, + ) + .expect("mark a repair-node delivery for the second revision"); + let second_barrier = static_delegate_completion_barrier_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + ) + .expect("read second revision barrier"); + assert_eq!( + second_barrier.user_revision_pending_count, 1, + "a revision whose parent delivery is itself a repair node must still block" + ); + assert!(!second_barrier.is_clear()); + + fs::remove_dir_all(root).ok(); + } + + #[test] + fn static_delegate_user_revision_reuses_evidence_ready_objective_constraints() { + let base = StaticDelegateStructuredResult { + contract_status: StaticDelegateContractStatus::UserRevisionRequested, + ..StaticDelegateStructuredResult::default() + }; + validate_static_delegate_structured_result(&base, "completed", &[]) + .expect("completed user revision with complete evidence is valid"); + + let failed = validate_static_delegate_structured_result(&base, "failed", &[]) + .expect_err("failed terminal status must reject a user revision result"); + assert!(failed.contains("客观证据冲突")); + + let mut missing = base.clone(); + missing.missing_expected_artifacts = vec!["game/fast_gdd.md".to_string()]; + let missing_error = validate_static_delegate_structured_result( + &missing, + "completed", + &["game/fast_gdd.md".to_string()], + ) + .expect_err("missing expected artifact must reject a user revision result"); + assert!(missing_error.contains("客观证据冲突")); + + let mut unverified = base; + unverified.verification_required = true; + let verification_error = + validate_static_delegate_structured_result(&unverified, "completed", &[]) + .expect_err("required verification without a revision must be rejected"); + assert!(verification_error.contains("客观证据冲突")); + } + + #[test] + fn static_delegate_lineage_boundary_and_status_deserialization_fail_closed() { + let mut deliveries = Vec::new(); + let mut ids = Vec::new(); + for index in 0..33 { + ids.push(format!("m1c0-lineage-boundary-{index}")); + } + for index in 0..33 { + let parent = (index > 0).then(|| ids[index - 1].as_str()); + let mut delivery = new_static_delegate_delivery( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "m1c0-boundary-session", + "m1c0-boundary-run", + &format!("{}-action", ids[index]), + &ids[index], + "design-director", + &format!("{}-session", ids[index]), + &format!("{}-run", ids[index]), + ); + delivery.repair_of_delegation_id = parent.map(str::to_string); + let mut result = StaticDelegateStructuredResult::default(); + result.contract_status = StaticDelegateContractStatus::UserRevisionRequested; + delivery.structured_result = Some(result); + deliveries.push(delivery); + } + assert_eq!( + static_delegate_lineage_counters(&deliveries[..32], &ids[31]), + (0, 0), + "32 条 delivery(31 跳)仍应在安全阀内" + ); + assert_eq!( + static_delegate_lineage_counters(&deliveries, &ids[32]), + (u32::MAX, u32::MAX), + "超过 32-hop 安全阀必须 fail closed" + ); + + for (status, wire) in [ + ( + StaticDelegateContractStatus::EvidenceReady, + "evidence-ready", + ), + (StaticDelegateContractStatus::NeedsRepair, "needs-repair"), + ( + StaticDelegateContractStatus::NeedsUserInput, + "needs-user-input", + ), + ( + StaticDelegateContractStatus::UserRevisionRequested, + "user-revision-requested", + ), + ] { + let encoded = serde_json::to_value(&status).expect("serialize known status"); + assert_eq!(encoded, serde_json::json!(wire)); + assert_eq!( + serde_json::from_value::(encoded) + .expect("round-trip known status"), + status + ); + } + let unknown_wire = "future-contract-status"; + let unknown = + serde_json::from_value::(serde_json::json!(unknown_wire)) + .expect("unknown durable contract status is preserved explicitly"); + assert_eq!( + unknown, + StaticDelegateContractStatus::Unknown(unknown_wire.to_string()) + ); + assert_eq!( + serde_json::to_value(&unknown).expect("serialize unknown status"), + serde_json::json!(unknown_wire) + ); + assert!( + serde_json::from_value::(serde_json::json!(42)).is_err(), + "non-string durable contract status must still fail closed" + ); + + // Durable sidecar 的未知变体可读,但必须进入 barrier、拒绝返工,并在读-改-写后 + // 保留原始 wire 字符串;不能吞掉或降级成 Default(NeedsRepair)。 + let root = std::env::temp_dir().join(format!( + "genarrative-static-unknown-status-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system time after unix epoch") + .as_nanos() + )); + init_local_game_project_at(&root, "m1c0-unknown-status", "未知静态委派状态解析测试") + .expect("project init"); + let acceptance_criteria = vec!["保留未知合同状态".to_string()]; + let mut record = new_static_delegate_delivery_with_contract( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "m1c0-unknown-status-session", + "m1c0-unknown-status-run", + "m1c0-unknown-status-action", + "m1c0-unknown-status-delivery", + "design-director", + "m1c0-unknown-status-target-session", + "m1c0-unknown-status-target-run", + &acceptance_criteria, + &[], + None, + ); + record.status = StaticDelegateDeliveryStatus::ClaimedByParent; + record.terminal_status = Some("completed".to_string()); + record.result_summary = Some("unknown status fixture".to_string()); + record.claimed_by_action_id = Some("m1c0-unknown-status-claim-action".to_string()); + let mut result = StaticDelegateStructuredResult::default(); + result.contract_status = StaticDelegateContractStatus::Unknown(unknown_wire.to_string()); + record.structured_result = Some(result); + write_static_delegate_delivery_at(&root, &record).expect("write unknown status fixture"); + let loaded = read_static_delegate_delivery_at(&root, &record.delegation_id) + .expect("read unknown status fixture") + .expect("unknown status delivery exists"); + assert_eq!(loaded, record); + + let barrier = static_delegate_completion_barrier_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "m1c0-unknown-status-run", + ) + .expect("read unknown status completion barrier"); + assert_eq!(barrier.unknown_contract_status_count, 1); + assert!(!barrier.is_clear()); + assert!(barrier.has_waiting()); + assert!(barrier.detail().contains("unknownContractStatus=1")); + + let repair_error = validate_static_delegate_repair_request_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "m1c0-unknown-status-run", + "m1c0-unknown-status-repair-candidate", + "design-director", + &acceptance_criteria, + &[], + Some(&record.delegation_id), + ) + .expect_err("unknown root status must reject the first repair unconditionally"); + assert!(repair_error.contains("更新版本") && repair_error.contains("拒绝返工")); + + // 读-改-写只改变外层字段时,未知 status 的 raw wire 值必须原样保留。 + let mut rewritten = loaded; + rewritten.result_summary = Some("unknown status rewritten summary".to_string()); + rewritten.updated_at = unix_timestamp(); + write_static_delegate_delivery_at(&root, &rewritten).expect("rewrite unknown status"); + let delivery_path = root + .join(STATIC_DELEGATE_DELIVERY_DIR) + .join(format!("{}.json", record.delegation_id)); + let rewritten_raw: serde_json::Value = serde_json::from_slice( + &fs::read(&delivery_path).expect("read rewritten unknown status sidecar"), + ) + .expect("parse rewritten unknown status sidecar"); + assert_eq!( + rewritten_raw["structuredResult"]["contractStatus"], + serde_json::json!(unknown_wire) + ); + + // lineage 中的 Unknown 按“其它”分支计数,不能被误识别成用户修订或澄清。 + let mut unknown_lineage = deliveries[..2].to_vec(); + unknown_lineage[0] + .structured_result + .as_mut() + .expect("lineage root structured result") + .contract_status = StaticDelegateContractStatus::Unknown(unknown_wire.to_string()); + assert_eq!( + static_delegate_lineage_counters(&unknown_lineage, &ids[1]), + (1, 0) + ); + assert!(static_delegate_lineage_contains_unknown_contract_status( + &unknown_lineage, + &ids[1] + ) + .expect("inspect unknown lineage")); + + // 损坏仍按原有整体 fail-closed 语义处理;即使目录里另有合法 delivery,也不能 + // 跳过坏记录后继续计算 barrier/lineage。 + let unrelated = new_static_delegate_delivery( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "m1c0-unrelated-session", + "m1c0-unrelated-run", + "m1c0-unrelated-action", + "m1c0-unrelated-delivery", + "art-director", + "m1c0-unrelated-target-session", + "m1c0-unrelated-target-run", + ); + write_static_delegate_delivery_at(&root, &unrelated) + .expect("write unrelated valid delivery"); + for (label, bytes) in [ + ("截断 JSON", b"{not-json".to_vec()), + ("非 UTF-8", vec![b'{', 0xff, b'}']), + ( + "超限 sidecar", + vec![b' '; STATIC_DELEGATE_DELIVERY_MAX_BYTES + 1], + ), + ] { + fs::write(&delivery_path, &bytes).expect("write damaged delivery sidecar"); + let error = list_static_delegate_deliveries_at(&root) + .expect_err("damaged sidecar must lock the whole delivery directory"); + assert!( + error.contains("静态委派 delivery"), + "{label} returned an unrelated error: {error}" + ); + write_static_delegate_delivery_at(&root, &rewritten) + .expect("restore valid unknown status sidecar"); + } + fs::remove_dir_all(root).ok(); + } } 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 eb3f8cf6c..e52c57200 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -295,6 +295,17 @@ 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 + /// Provider spend. + #[serde(default)] + plan_update_idle_rounds: u32, #[serde(default)] max_loop_iterations: u32, #[serde(default)] @@ -789,6 +800,14 @@ struct GameCreatorAppConfigFile { agent_llm: Option>, editor_api: Option, mcp_servers: Option>, + planning: Option, +} + +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +struct GameCreatorPlanningConfigFile { + #[serde(skip_serializing_if = "Option::is_none")] + capability_enabled: Option, } #[derive(Clone, Debug, Default, Deserialize, Serialize)] @@ -840,6 +859,15 @@ struct GameCreatorAppConfig { editor_api: GameCreatorEditorApiConfig, #[serde(default)] mcp_servers: BTreeMap, + #[serde(default)] + planning: GameCreatorPlanningConfig, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +struct GameCreatorPlanningConfig { + #[serde(default = "default_game_creator_planning_capability_enabled")] + capability_enabled: bool, } #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] @@ -1325,6 +1353,11 @@ fn default_game_creator_llm_auto_compact_token_limit() -> u64 { 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; @@ -1391,6 +1424,15 @@ impl Default for GameCreatorAppConfig { agent_llm: BTreeMap::new(), editor_api: GameCreatorEditorApiConfig::default(), mcp_servers: BTreeMap::new(), + planning: GameCreatorPlanningConfig::default(), + } + } +} + +impl Default for GameCreatorPlanningConfig { + fn default() -> Self { + Self { + capability_enabled: default_game_creator_planning_capability_enabled(), } } } @@ -1989,8 +2031,70 @@ mod game_chat_release_client_exit_tests { } } +/// Tauri 的全局异步 runtime 默认由 `TokioRuntime::new()` 建出来,worker 线程吃 +/// tokio 默认栈。Runtime 的 agent turn 调用链深到本仓库另一处专门给自己的后台 +/// 线程配了 AGENT_RUNTIME_BACKGROUND_WORKER_STACK_BYTES;但凡经 async_runtime::spawn +/// 派发的活(例如静态委派的父 run 唤醒)都落在这些默认栈的 worker 上,同一段代码 +/// 在那里直接 `thread 'tokio-rt-worker' has overflowed its stack` 把整个进程 abort +/// 掉——现场表现是父 run 认领委派回执那一刻 Agent Runner 无声消失,调用方只看到 +/// 连接超时。这里在任何异步派发之前把全局 runtime 换成同样栈尺寸的实例。 +/// +/// `async_runtime::set` 只接受 handle 且要求底层 Runtime 常驻,所以这里刻意泄漏。 +fn build_agent_runtime_async_runtime() -> Result { + tokio::runtime::Builder::new_multi_thread() + .enable_all() + .thread_stack_size(crate::agent::AGENT_RUNTIME_BACKGROUND_WORKER_STACK_BYTES) + .build() + .map_err(|error| format!("创建全局异步 runtime 失败:{error}")) +} + +#[cfg(not(test))] +fn install_agent_runtime_async_runtime_with_deep_stack() { + let runtime = match build_agent_runtime_async_runtime() { + Ok(runtime) => runtime, + Err(error) => { + eprintln!("agent.runner.failed: {error}"); + std::process::exit(1); + } + }; + tauri::async_runtime::set(runtime.handle().clone()); + // 进程生命周期内必须持有,否则 handle 立即失效。 + Box::leak(Box::new(runtime)); +} + +#[cfg(test)] +mod async_runtime_stack_tests { + /// 每帧固定占 16 KiB,用 black_box 挡住优化,让递归深度直接换算成栈用量。 + fn consume_stack(depth: usize) -> u64 { + let mut frame = [0_u8; 16 * 1024]; + frame[depth % frame.len()] = depth as u8; + let sum = std::hint::black_box(&frame) + .iter() + .map(|byte| u64::from(*byte)) + .sum::(); + if depth == 0 { + sum + } else { + sum + consume_stack(depth - 1) + } + } + + /// 全局异步 runtime 的 worker 必须和 Runtime 自己的后台线程用同一份栈预算。 + /// 掉了 thread_stack_size 时这条不是断言失败而是整个测试进程被 abort——这正是 + /// 线上的失效形态:Agent Runner 在父 run 认领委派回执时无声消失。 + #[test] + fn async_runtime_workers_hold_a_call_chain_that_overflows_the_default_stack() { + const FRAMES: usize = 192; // 192 × 16 KiB = 3 MiB,超出 tokio 默认栈,远低于 16 MiB + let runtime = super::build_agent_runtime_async_runtime().expect("build async runtime"); + let handled = + runtime.block_on(async { tokio::spawn(async { consume_stack(FRAMES - 1) }).await }); + assert!(handled.is_ok(), "{handled:?}"); + } +} + #[cfg(not(test))] fn main() { + install_agent_runtime_async_runtime_with_deep_stack(); let mut args = std::env::args().skip(1).collect::>(); if let Some(exit_code) = run_direct_tools_mcp_if_requested(&args) { std::process::exit(exit_code); @@ -2260,6 +2364,7 @@ fn main() { pick_local_project_directory, pick_local_file, open_local_project_directory, + open_local_project_plan_gdd_markdown, control_agent_run, generate_local_game_draft, chat_with_game_creator_agent, @@ -2282,6 +2387,8 @@ 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, diff --git a/apps/ai-game-creator-shell/src-tauri/src/mcp.rs b/apps/ai-game-creator-shell/src-tauri/src/mcp.rs index 28684775e..b76042922 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/mcp.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/mcp.rs @@ -2078,6 +2078,8 @@ mod tests { 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: None, goal_revision: 0, diff --git a/apps/ai-game-creator-shell/src-tauri/src/patchset.rs b/apps/ai-game-creator-shell/src-tauri/src/patchset.rs index dbe032e3e..ca3125e28 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/patchset.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/patchset.rs @@ -7,7 +7,9 @@ use std::io::{Read, Seek, SeekFrom, Write}; use std::path::{Path, PathBuf}; use unicode_normalization::UnicodeNormalization; -use crate::project::{normalize_relative_path, validate_project_root}; +use crate::project::{ + is_plan_fast_gdd_projection_path, normalize_relative_path, validate_project_root, +}; const PROJECT_PATCHSET_MAX_CHANGES: usize = 12; const PROJECT_PATCHSET_MAX_TOTAL_BODY_BYTES: usize = 256 * 1024; @@ -464,6 +466,11 @@ fn normalize_and_validate_patchset_inputs( let path = normalize_relative_path(change.path())?; reject_sensitive_patchset_path(&path)?; + if is_plan_fast_gdd_projection_path(&path) { + return Err( + "project.patchset 不得直接修改 Runtime-owned game/fast_gdd.md 投影".to_string(), + ); + } let change = match change { ParsedProjectPatchsetChange::Create { content, .. } => { validate_input_text(&content, &path)?; @@ -1365,6 +1372,11 @@ fn metadata_is_link_or_reparse(metadata: &Metadata) -> bool { } fn reject_sensitive_patchset_path(relative_path: &str) -> Result<(), String> { + if is_plan_fast_gdd_projection_path(relative_path) { + return Err( + "project.patchset 不得直接修改 Runtime-owned game/fast_gdd.md 投影".to_string(), + ); + } let components = relative_path .split('/') .map(str::to_ascii_lowercase) @@ -1694,6 +1706,8 @@ mod tests { for path in [ ".agent/runtime/state.json", + ".agent/planning/gdd.v1.json", + "game/fast_gdd.md", ".env.local", "config/private.pem", "data/runtime.sqlite", 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 1012d6881..7a26ef189 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 @@ -6,6 +6,9 @@ use super::filesystem::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"; @@ -13,6 +16,8 @@ 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"; @@ -30,12 +35,22 @@ 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_LIFECYCLE_TERMINAL_RESERVE_BYTES + - AGENT_DB_PLAN_GDD_DECISION_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_LIFECYCLE_TERMINAL_RESERVE_RECORDS as usize + - AGENT_DB_PLAN_GDD_DECISION_RESERVE_RECORDS as usize; const AGENT_DB_FINALIZATION_CRITICAL_RECORDS_PER_SEQUENCE: usize = 7; const AGENT_DB_MAX_BOUNDED_READ_BYTES: u64 = 32 * 1024 * 1024; const AGENT_DB_MAX_BOUNDED_RECORDS: usize = 16_384; @@ -46,10 +61,14 @@ 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) { @@ -69,6 +88,9 @@ 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 } @@ -954,6 +976,12 @@ 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, @@ -1108,6 +1136,7 @@ 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 { @@ -1116,6 +1145,14 @@ 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 { @@ -1176,6 +1213,260 @@ 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, @@ -1254,11 +1545,14 @@ 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 audit_schema == AGENT_DB_PROVIDER_REQUEST_LIFECYCLE_SCHEMA_V2 - && record - .get("webSearchEnabled") - .and_then(serde_json::Value::as_bool) - .is_none() + 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() { return Err("Agent DB Provider lifecycle webSearchEnabled 必须为 bool".to_string()); } @@ -1289,6 +1583,15 @@ 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) @@ -1337,6 +1640,21 @@ 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", @@ -1366,6 +1684,7 @@ 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"), } } @@ -1392,12 +1711,166 @@ 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_V2 + | AGENT_DB_PROVIDER_REQUEST_LIFECYCLE_SCHEMA_V3), ) => 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> { @@ -1665,6 +2138,7 @@ pub(crate) fn append_agent_db_tool_plan_audit_idempotent( "responseFingerprint", "providerRequestIdSha256", "protocol", + "finishReason", "functionCallCount", "callIdSha256s", "functionNames", @@ -1791,6 +2265,27 @@ pub(crate) fn append_agent_db_tool_plan_audit_idempotent( return Err(format!("Agent DB tool-plan 幂等审计字段无效:{field}")); } } + // Provider 终态标记是上游自由文本(兼容网关常发自定义值),写入侧已夹紧成 + // 固定字符集的短标记;这里只复核夹紧结果,不接受原样透传的自由文本。 + match record.get("finishReason") { + Some(serde_json::Value::Null) => {} + Some(serde_json::Value::String(value)) => { + if value.is_empty() + || value.chars().count() > 32 + || !value.chars().all(|character| { + character.is_ascii_lowercase() + || character.is_ascii_digit() + || character == '_' + || character == '-' + }) + { + return Err("Agent DB tool-plan 幂等审计字段无效:finishReason".to_string()); + } + } + _ => { + return Err("Agent DB tool-plan 幂等审计字段无效:finishReason".to_string()); + } + } let autonomous_source_payload_validated = record .get("autonomousSourcePayloadValidated") .and_then(serde_json::Value::as_bool) @@ -2020,6 +2515,324 @@ 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, @@ -3036,6 +3849,48 @@ pub(crate) fn read_agent_db_lifecycle_transitions_at( .unwrap_or_default()) } +pub(crate) fn read_agent_db_lifecycle_transitions_matching_at( + root: &Path, + record_type: &str, + identity_field: &str, + identity_value: &str, + expected_identity: &serde_json::Value, +) -> Result, String> { + let (expected_identity_field, _) = agent_db_lifecycle_key_fields(record_type)?; + if identity_field != expected_identity_field + || (record_type == AGENT_DB_PROVIDER_REQUEST_LIFECYCLE_RECORD_TYPE + && !is_valid_agent_db_provider_request_id(identity_value)) + || (record_type == AGENT_DB_FINALIZATION_LIFECYCLE_RECORD_TYPE + && !is_valid_agent_db_finalization_id(identity_value)) + { + return Err("Agent DB lifecycle 查询身份或 recordType 不受支持".to_string()); + } + validate_agent_db_lifecycle_record_semantics(record_type, expected_identity, false)?; + let path = root.join(".agent/agent.db"); + let Some(directory) = open_agent_db_directory(root, false)? else { + return Ok(Vec::new()); + }; + let append_lock = project_append_lock_for(&path)?; + let _append_guard = append_lock.lock_process("Agent 本地索引 lifecycle identity 查询")?; + verify_agent_db_directory_current(&directory)?; + let Some(mut storage) = open_agent_db_storage(directory, false, false)? else { + return Ok(Vec::new()); + }; + verify_agent_db_storage_current(&storage)?; + let scan = + scan_agent_db_lifecycle_records_unlocked(&mut storage.file, &storage.path, record_type)?; + verify_agent_db_storage_current(&storage)?; + let Some(sequence) = scan.sequences.get(identity_value) else { + return Ok(Vec::new()); + }; + validate_agent_db_lifecycle_record_identity( + &sequence.identity_record, + expected_identity, + record_type, + )?; + Ok(sequence.transitions_in_physical_order.clone()) +} + pub(crate) fn read_agent_db_incomplete_provider_request_ids_at( root: &Path, agent_id: &str, @@ -3094,6 +3949,7 @@ fn validate_agent_db_lifecycle_record_identity( "requestKind", "requestSlot", "webSearchEnabled", + "planningSessionBinding", ], AGENT_DB_FINALIZATION_LIFECYCLE_RECORD_TYPE => &[ "recordType", @@ -3692,6 +4548,8 @@ struct AgentDbReservedTailCapacity { action_tail_bytes: u64, lifecycle_unlinked_tail_records: usize, lifecycle_unlinked_tail_bytes: u64, + plan_decision_tail_records: usize, + plan_decision_tail_bytes: u64, finalizations: BTreeMap, } @@ -3714,6 +4572,13 @@ 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); @@ -4040,6 +4905,24 @@ 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 6d5d560f1..c7aa53808 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 @@ -52,6 +52,7 @@ fn tool_plan_protocol_audit_record( "responseFingerprint": "1".repeat(64), "providerRequestIdSha256": "2".repeat(64), "protocol": "native_runtime_tools", + "finishReason": "completed", "functionCallCount": 0, "callIdSha256s": [], "functionNames": [], @@ -333,6 +334,57 @@ 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, @@ -1730,6 +1782,55 @@ 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'); @@ -2365,13 +2466,19 @@ 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_TERMINAL_RESERVE_BYTES + + AGENT_DB_PLAN_GDD_DECISION_RESERVE_BYTES, AGENT_DB_MAX_ACTION_RECEIPT_SCAN_BYTES ); - assert_eq!(AGENT_DB_MAX_ORDINARY_APPEND_RECORDS, 999_808); + assert_eq!(AGENT_DB_MAX_ORDINARY_APPEND_RECORDS, 999_680); 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 571257861..66b6b142b 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 @@ -566,6 +566,7 @@ pub(crate) fn restore_local_project_checkpoint_at( pub(crate) fn should_skip_project_restore_path(relative_path: &str) -> bool { should_skip_project_snapshot_path(relative_path) + || is_plan_fast_gdd_projection_path(relative_path) || relative_path == ".agent/agent.db" || relative_path == PROJECT_PERMISSION_POLICY_PATH || relative_path == PROJECT_WRITE_LOCK_PATH 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 b25c26e32..ab5253287 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 @@ -307,6 +307,55 @@ pub(crate) fn reject_agent_runtime_private_control_path( Ok(()) } +/// `.agent/planning/**` is a Runtime-owned sidecar. It remains readable by +/// the narrow planning read tools, but generic project mutation helpers must +/// never be able to create, replace, patch, or delete it. Keeping this gate +/// separate from `reject_agent_runtime_private_control_path` is deliberate: +/// 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/") +} + +pub(crate) fn is_agent_planning_managed_write_path(normalized_path: &str) -> bool { + is_agent_planning_storage_path(normalized_path) + || is_plan_fast_gdd_projection_path(normalized_path) +} + +pub(crate) fn reject_agent_planning_storage_write_path( + normalized_path: &str, +) -> Result<(), String> { + if is_agent_planning_managed_write_path(normalized_path) { + return Err( + "`.agent/planning/**` 与 `game/fast_gdd.md` 只能由立项策划 Runtime 专用存储层写入,通用文件写入被拒绝" + .to_string(), + ); + } + Ok(()) +} + +/// `game/fast_gdd.md` is the human-readable planning projection. It lives +/// outside `.agent/planning`, but it is still Runtime-owned and must not be +/// mutated by generic file tools. Keep this predicate write-only so planning +/// observations can continue to read the projection. +pub(crate) fn is_plan_fast_gdd_projection_path(normalized_path: &str) -> bool { + normalized_path.eq_ignore_ascii_case(PLAN_FAST_GDD_PATH) +} + +pub(crate) fn reject_plan_projection_write_path(normalized_path: &str) -> Result<(), String> { + reject_agent_planning_storage_write_path(normalized_path)?; + if is_plan_fast_gdd_projection_path(normalized_path) { + return Err( + "`game/fast_gdd.md` 只能由立项策划 Runtime renderer 写入,通用文件写入被拒绝" + .to_string(), + ); + } + Ok(()) +} + fn reject_agent_control_path_delete(normalized_path: &str) -> Result<(), String> { if matches!( normalized_path.split('/').next(), @@ -343,6 +392,7 @@ pub(crate) fn write_local_project_file_at( ) -> Result { let normalized_path = normalize_relative_path(relative_path)?; reject_agent_runtime_private_control_path(&normalized_path)?; + reject_plan_projection_write_path(&normalized_path)?; let path = resolve_local_project_path(root, &normalized_path)?; if path.exists() && !path.is_file() { return Err("只能写入文件".to_string()); @@ -367,6 +417,7 @@ pub(crate) fn delete_local_project_file_at( ) -> Result { let normalized_path = normalize_relative_path(relative_path)?; reject_agent_runtime_private_control_path(&normalized_path)?; + reject_plan_projection_write_path(&normalized_path)?; reject_agent_control_path_delete(&normalized_path)?; let path = resolve_local_project_path(root, &normalized_path)?; if !path.exists() { 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 e738530c2..8c6d7c027 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 @@ -65,6 +65,8 @@ fn inspect_godot_project_marker(root: &Path) -> Result { GetFileInformationByHandle, BY_HANDLE_FILE_INFORMATION, }; + const FILE_ATTRIBUTE_DIRECTORY: u32 = 0x0000_0010; + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400; let file = fs::File::open(&project_file).map_err(|error| { format!( "打开 Godot 项目文件失败:{}: {error}", @@ -74,7 +76,11 @@ fn inspect_godot_project_marker(root: &Path) -> Result { // SAFETY: the structure is plain data initialized by GetFileInformationByHandle. let mut information = unsafe { std::mem::zeroed::() }; // SAFETY: file owns a live handle and information is a valid output pointer. + // 取不到句柄信息、目录与 reparse point 一律按拒绝处理,保持 fail-closed。 if unsafe { GetFileInformationByHandle(file.as_raw_handle().cast(), &mut information) } == 0 + || information.dwFileAttributes + & (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_REPARSE_POINT) + != 0 { return Err(format!( "读取 Godot 项目文件 Windows 身份失败:{}", 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 d8cd83355..f30dd9710 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 @@ -799,10 +799,15 @@ pub(crate) fn normalize_policy_command_ids(values: Vec) -> Result, } #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] @@ -374,7 +377,17 @@ pub(crate) fn validate_identity( return Err(format!("Provider 重试身份 {label} 不能为空")); } } - if !is_sha256(&identity.request_fingerprint) { + // 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 { return Err("Provider 重试身份的 requestFingerprint 无效".to_string()); } if !is_sha256(&identity.provider_config_fingerprint) { @@ -388,6 +401,26 @@ 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(()) } @@ -444,6 +477,12 @@ 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) } @@ -550,6 +589,62 @@ 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), } } @@ -626,6 +721,44 @@ 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 c87180438..afd5c224b 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 @@ -2698,6 +2698,7 @@ 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, @@ -2801,6 +2802,7 @@ 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 dd4c51ee4..8ecff389f 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 @@ -47,8 +47,11 @@ 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_GAME_CHAT_SOURCE => + if source == AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE + || source == AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE => { Some(source) } @@ -71,7 +74,9 @@ pub(super) fn resolve_swarm_new_run_launch<'a>( if parent_agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { if !matches!( supervisor_source, - AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE | AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE + AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE + | AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE + | AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE ) { return Err(format!( "不支持的 Project Supervisor source:{supervisor_source}" @@ -82,13 +87,18 @@ pub(super) fn resolve_swarm_new_run_launch<'a>( { return Err("game-chat smoke source 仅支持 autonomous-game-build".to_string()); } + // 与 GUI「做方案」入口同一条门禁;能力开关和 source 可信性由 task_start 统一判定, + // 这里只把档位冲突提前到起 Run 之前。 + reject_supervisor_plan_autonomous_profile(supervisor_source, run_profile)?; return Ok(SwarmNewRunLaunch::ProjectSupervisor { source: supervisor_source, run_profile, }); } if supervisor_source != AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE { - return Err("game-chat smoke source 仅支持 project-supervisor 总控入口".to_string()); + return Err(format!( + "受限 Supervisor source 仅支持 project-supervisor 总控入口:{supervisor_source}" + )); } if run_profile != AGENT_RUNTIME_RUN_PROFILE_STANDARD { return Err("--autonomous-game-build 仅支持 project-supervisor 总控入口".to_string()); diff --git a/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/terminal_classification.rs b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/terminal_classification.rs index 129d9a255..40129d9ec 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/terminal_classification.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/terminal_classification.rs @@ -360,6 +360,10 @@ pub(super) fn original_delivery_has_successful_repair( claimed_deliveries: &[StaticDelegateDeliveryRecord], ) -> bool { original.repair_of_delegation_id.is_none() + && !original + .structured_result + .as_ref() + .is_some_and(|result| result.contract_status.is_unknown()) && claimed_deliveries.iter().any(|candidate| { candidate.repair_of_delegation_id.as_deref() == Some(original.delegation_id.as_str()) && candidate.terminal_status.as_deref() == Some("completed") 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 ba88f0e55..695faa214 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 @@ -138,6 +138,65 @@ fn restricted_game_chat_smoke_launch_selects_trusted_single_main_source() { .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_GAME_CHAT_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!( @@ -1420,6 +1479,26 @@ fn original_specialist_failure_is_recoverable_but_repair_failure_closes() { classify_failed_specialist(&parent, &child, Some(&repair), false), SwarmSpecialistFailureDisposition::Failed ); + + let mut successful_repair = repair.clone(); + successful_repair.terminal_status = Some("completed".to_string()); + let mut evidence_ready = StaticDelegateStructuredResult::default(); + evidence_ready.contract_status = StaticDelegateContractStatus::EvidenceReady; + successful_repair.structured_result = Some(evidence_ready); + assert!(original_delivery_has_successful_repair( + &original, + &[successful_repair.clone()] + )); + + let mut unknown_original = original.clone(); + let mut unknown_result = StaticDelegateStructuredResult::default(); + unknown_result.contract_status = + StaticDelegateContractStatus::Unknown("future-contract-status".to_string()); + unknown_original.structured_result = Some(unknown_result); + assert!( + !original_delivery_has_successful_repair(&unknown_original, &[successful_repair]), + "a newer contract status must not be classified as already repaired" + ); } #[test] 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 7d98a5cd6..7e4359b37 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 @@ -171,7 +171,7 @@ pub(super) fn handle_swarm_user_turn( return Ok(SwarmChatFlow::Continue); } - let action = if game_creator_agent_uses_interaction_kernel(parent_agent_id) { + let action = if swarm_turn_uses_interaction_kernel(parent_agent_id, expected_parent_source) { let Some(runtime_lock) = try_acquire_game_creator_agent_runtime_task_lock(root, parent_agent_id)? else { @@ -261,6 +261,20 @@ 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>, +) -> 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) +} + pub(super) fn normalize_interaction_action_without_active_runtime( action: AgentInteractionAction, ) -> AgentInteractionAction { @@ -377,6 +391,12 @@ 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 的旧行为,免得给 game-chat / CLI 链路加新的一致性判据。 + let steer_source = expected_parent_source + .filter(|source| agent_runtime_supervisor_source_is_plan(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(), @@ -385,7 +405,7 @@ fn steer_and_wait_for_swarm_turn( steer_id.clone(), message.to_string(), Some(run_profile.to_string()), - None, + steer_source, ))?; writeln!( output, 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 4695fd7ab..2db89e0ba 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 @@ -2521,12 +2521,18 @@ fn autonomous_direct_child_collaboration_mutations_require_the_current_root() { ); let action_id = "stale-direct-child-delegate"; + // 本文件这四处「先持项目写锁、再调 agent.delegate」的用例走 `_at_locked`,不走同名薄壳。 + // 薄壳在 M1C-2b(`152cc40c7`)里被改成入口自取项目写锁——把锁的所有权上提,是为了让 + // planning 子 session 的投影按 project -> session 的顺序取锁。而 `.agent/project.lock` 是 + // `create_new(true)` 的文件锁、不可重入:调用方已持锁再进薄壳,会死等满 10s 预算然后报 + // 「无法取得一致项目快照」。生产侧两个调用方(`action_execution.rs`、`pending_recovery.rs`) + // 也都是持锁后调 `_at_locked`,薄壳如今已无生产调用方;用 `_at_locked` 钉的才是真实形状。 let project_lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( &root, "test.stale-direct-child-delegate", ) .expect("acquire delegate project lock"); - let stale_delegate = observe_agent_runtime_agent_delegate( + let stale_delegate = observe_agent_runtime_agent_delegate_at_locked( &root, child_agent_id, &child_run_id, @@ -2536,6 +2542,7 @@ fn autonomous_direct_child_collaboration_mutations_require_the_current_root() { "task": "旧根 child 不得创建委派", "runId": "stale-direct-child-target" }), + &project_lock, ); drop(project_lock); assert_eq!(stale_delegate.status, "failed", "{stale_delegate:?}"); @@ -2594,7 +2601,7 @@ fn autonomous_delegate_descendant_inherits_and_enforces_the_current_root_guard() "test.current-descendant-delegate", ) .expect("acquire current descendant delegate project lock"); - let delegated = observe_agent_runtime_agent_delegate( + let delegated = observe_agent_runtime_agent_delegate_at_locked( &root, ready_agent_id, &ready_run_id, @@ -2604,6 +2611,7 @@ fn autonomous_delegate_descendant_inherits_and_enforces_the_current_root_guard() "task": "在当前自主根下执行只读质量检查", "runId": descendant_run_id }), + &project_lock, ); drop(project_lock); assert_eq!(delegated.status, "ok", "{delegated:?}"); @@ -2687,7 +2695,7 @@ fn autonomous_delegate_descendant_inherits_and_enforces_the_current_root_guard() "test.stale-descendant-delegate", ) .expect("acquire descendant delegate project lock"); - let stale_delegate = observe_agent_runtime_agent_delegate( + let stale_delegate = observe_agent_runtime_agent_delegate_at_locked( &root, descendant_agent_id, descendant_run_id, @@ -2697,6 +2705,7 @@ fn autonomous_delegate_descendant_inherits_and_enforces_the_current_root_guard() "task": "旧根 descendant 不得继续派生", "runId": "stale-descendant-target" }), + &project_lock, ); drop(project_lock); assert_eq!(stale_delegate.status, "failed", "{stale_delegate:?}"); @@ -2748,7 +2757,7 @@ fn standard_delegate_collaboration_mutations_remain_compatible() { "test.standard-delegate", ) .expect("acquire standard delegate project lock"); - let delegated = observe_agent_runtime_agent_delegate( + let delegated = observe_agent_runtime_agent_delegate_at_locked( &root, parent_agent_id, parent_run_id, @@ -2758,6 +2767,7 @@ fn standard_delegate_collaboration_mutations_remain_compatible() { "task": "标准 Run 的兼容委派", "runId": "standard-delegate-child" }), + &project_lock, ); drop(project_lock); assert_eq!(delegated.status, "ok", "{delegated:?}"); 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 4921705a8..3ec30ca5a 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 @@ -1425,3 +1425,99 @@ async fn agent_runtime_file_tools_cannot_modify_collaboration_policy_or_advance_ } fs::remove_dir_all(root).ok(); } + +/// 回归:模型在同一轮里既调 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(); + init_local_game_project_at( + &root, + "project-collaboration-plan-update", + "协作批次携带 plan update 测试", + ) + .expect("project init"); + write_supervisor_collaboration_policy_at( + &root, + supervisor_collaboration_mixed_policy_for_test(), + ) + .expect("write mixed collaboration policy"); + write_project_permission_policy_at( + &root, + ProjectPermissionPolicy { + denied_commands: Vec::new(), + confirm_commands: Vec::new(), + agent_policies: BTreeMap::new(), + }, + ) + .expect("write permissive tool policy"); + let run_id = "supervisor-collaboration-plan-update-run"; + let runtime = start_game_creator_agent_runtime_task_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "边更新计划边编排协作", + run_id, + "agent-chat", + "编排协作并同步计划", + vec!["收齐专业交付".to_string()], + ) + .expect("start supervisor runtime"); + let revision = read_game_creator_agent_runtime_project_revision(&root) + .expect("read revision before plan update batch"); + let repository_fingerprint = "d".repeat(64); + let mut plan = supervisor_collaboration_plan_for_test(vec![ + supervisor_collaboration_delegate_action_for_test("design-director", None), + supervisor_collaboration_delegate_action_for_test("art-director", None), + supervisor_collaboration_spawn_action_for_test(2), + ]); + plan.plan_update = Some(AgentRuntimePlanUpdate { + explanation: "拆解协作波次".to_string(), + steps: vec![ + AgentRuntimePlanUpdateStep { + step: "冻结根 Goal Contract".to_string(), + status: "completed".to_string(), + }, + AgentRuntimePlanUpdateStep { + step: "委派专业 Agent".to_string(), + status: "in_progress".to_string(), + }, + ], + }); + + let preparation = prepare_game_creator_agent_runtime_provider_action_batch( + &root, + &runtime, + "边更新计划边编排协作", + &plan, + &[], + &revision, + &repository_fingerprint, + ) + .await + .expect("prepare batch carrying plan update"); + let AgentRuntimeProviderActionBatchPreparation::Ready(batch) = preparation else { + 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, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + ) + .expect("read back batch carrying plan update"); + assert_eq!(recovered.batch_id, batch.batch_id); + assert_eq!(recovered.plan.plan_update, batch.plan.plan_update); + fs::remove_dir_all(root).ok(); +} 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 b7bcbf461..501527c6c 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 @@ -1,5 +1,898 @@ use super::super::*; +// =========================================================================================== +// WP2 测试帮助函数:把「dispatch -> 标记终态 -> 认领 -> (可选)用户澄清问答 -> 真实 +// observe_agent_runtime_agent_delegate 派发」这套反复出现的仪式收敛成可组合的小函数, +// 供本文件下方的澄清轮次 / 返工深度链上推断用例复用。所有函数都不绕过生产校验,只是把 +// 已经在本文件其他用例里出现过的调用顺序打包起来。 +// =========================================================================================== + +/// 创建一条处于 Dispatched 状态的静态委派 delivery(链根或某一跳的占位),不做任何终态标记。 +#[allow(clippy::too_many_arguments)] +fn create_dispatched_static_delegate_delivery( + root: &Path, + parent_agent_id: &str, + parent_session_id: &str, + parent_run_id: &str, + parent_action_id: &str, + delegation_id: &str, + target_agent_id: &str, + target_session_id: &str, + target_run_id: &str, + acceptance_criteria: &[String], + expected_artifacts: &[String], + repair_of_delegation_id: Option<&str>, +) { + let delivery = new_static_delegate_delivery_with_contract( + parent_agent_id, + parent_session_id, + parent_run_id, + parent_action_id, + delegation_id, + target_agent_id, + target_session_id, + target_run_id, + acceptance_criteria, + expected_artifacts, + repair_of_delegation_id, + ); + create_or_read_static_delegate_delivery_at(root, &delivery) + .expect("create dispatched delivery"); +} + +/// 把一条已 dispatched 的 delivery 标记为「存在质量缺口」的 needs-repair 终态并认领—— +/// 依赖调用方传入的 expected_artifacts 里至少有一个从未真正写入项目的路径, +/// 确保 build_static_delegate_structured_result_at 必然推导出 NeedsRepair。 +#[allow(clippy::too_many_arguments)] +fn mark_and_claim_static_delegate_needs_repair( + root: &Path, + parent_agent_id: &str, + parent_run_id: &str, + target_agent_id: &str, + target_session_id: &str, + target_run_id: &str, + delegation_id: &str, + expected_artifacts: &[String], + claim_action_id: &str, +) { + let result = build_static_delegate_structured_result_at( + root, + "completed", + expected_artifacts, + false, + None, + None, + None, + None, + ) + .expect("build needs-repair result"); + assert_eq!( + result.contract_status, + StaticDelegateContractStatus::NeedsRepair, + "帮助函数要求 expected_artifacts 至少一项缺失,产出必须是 needs-repair" + ); + mark_static_delegate_delivery_ready_with_result_at( + root, + target_agent_id, + target_session_id, + target_run_id, + delegation_id, + "completed", + "存在质量缺口", + result, + ) + .expect("mark needs-repair ready"); + claim_ready_static_delegate_receipts_at(root, parent_agent_id, parent_run_id, claim_action_id) + .expect("claim needs-repair receipt"); +} + +/// 把一条已认领的 delivery 改写成「用户修订」形态,用于模拟审批卡上的 revise。 +/// +/// M1C-1 之后 `UserRevisionRequested` 与 `EvidenceReady` 共用同一套客观证据要求 +/// (terminal completed、无缺失产物、验证已满足),所以模拟修订不能只翻 +/// `contract_status`:沿用 needs-repair 的证据落盘时会被判成「evidence-ready/ +/// user-revision-requested 与客观证据冲突」。这里先让 expected_artifacts 真实落盘, +/// 再按真实证据重建 structuredResult——用户是在**已交付**的产物上要求修订。 +fn rewrite_claimed_static_delegate_as_user_revision_requested( + root: &Path, + delegation_id: &str, + expected_artifacts: &[String], +) { + for artifact in expected_artifacts { + let path = root.join(artifact); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).expect("create delivered artifact parent"); + } + fs::write(&path, b"# user revision evidence\n").expect("write delivered artifact"); + } + let mut result = build_static_delegate_structured_result_at( + root, + "completed", + expected_artifacts, + false, + None, + None, + None, + None, + ) + .expect("build delivered result"); + assert_eq!( + result.contract_status, + StaticDelegateContractStatus::EvidenceReady, + "模拟用户修订之前,客观证据必须先真实达到 evidence-ready" + ); + result.contract_status = StaticDelegateContractStatus::UserRevisionRequested; + let mut delivery = read_static_delegate_delivery_at(root, delegation_id) + .expect("read claimed delivery") + .expect("claimed delivery exists"); + delivery.structured_result = Some(result); + write_static_delegate_delivery_at(root, &delivery) + .expect("persist simulated approval revision status"); +} + +/// 把一条已 dispatched 的 delivery 标记为 needs-user-input 终态并认领。 +/// 注意:即便这一跳是纯粹的用户澄清、不产出文件,structuredResult 也必须完整覆盖 +/// delivery 记录自身声明的 expected_artifacts(校验在 write_static_delegate_delivery_at +/// 落盘时统一执行),所以这里必须把调用方真实的 expected_artifacts 传给 +/// build_static_delegate_structured_result_at——needs-user-input 分类优先级高于 +/// missing-artifacts,即便产物仍缺失也不影响 contract_status 推导。 +#[allow(clippy::too_many_arguments)] +fn mark_and_claim_static_delegate_needs_user_input( + root: &Path, + parent_agent_id: &str, + parent_run_id: &str, + target_agent_id: &str, + target_session_id: &str, + target_run_id: &str, + delegation_id: &str, + expected_artifacts: &[String], + questions_body: &str, + claim_action_id: &str, +) { + let result = build_static_delegate_structured_result_at( + root, + "completed", + expected_artifacts, + false, + None, + None, + None, + Some(&format!("AGC_NEEDS_USER_INPUT_V1\n{questions_body}")), + ) + .expect("build needs-user-input result"); + assert_eq!( + result.contract_status, + StaticDelegateContractStatus::NeedsUserInput + ); + mark_static_delegate_delivery_ready_with_result_at( + root, + target_agent_id, + target_session_id, + target_run_id, + delegation_id, + "completed", + "需要用户澄清", + result, + ) + .expect("mark needs-user-input ready"); + claim_ready_static_delegate_receipts_at(root, parent_agent_id, parent_run_id, claim_action_id) + .expect("claim needs-user-input receipt"); +} + +/// 认领已 ready 的 needs-user-input delivery 之后,走真实的 +/// ensure_static_delegate_user_input_wait_at -> prepare -> answer 链路取得该 delivery 的 +/// (questionsSha256, answersSha256),并清理已回答的 pending 动作文件。 +fn answer_static_delegate_clarification_wait( + root: &Path, + state: &mut AgentRuntimeState, + parent_agent_id: &str, + parent_run_id: &str, + delegation_id: &str, + answers: BTreeMap, + response_id: &str, +) -> (String, String) { + let deliveries = claimed_static_delegate_deliveries_at(root, parent_agent_id, parent_run_id) + .expect("read claimed deliveries"); + assert!( + ensure_static_delegate_user_input_wait_at(root, state, &deliveries) + .expect("create supervisor wait"), + "认领到的 needs-user-input delivery 必须触发一次 Supervisor 等待" + ); + let pending = + read_game_creator_agent_runtime_pending_tool_action(root, parent_agent_id, parent_run_id) + .expect("read pending action"); + assert!( + pending.task.contains(delegation_id), + "pending action 应引用 {delegation_id}:{}", + pending.task + ); + let request = match prepare_game_creator_agent_user_input_request_at(root, &pending) + .expect("prepare clarification request") + { + AgentRuntimeUserInputRecovery::Waiting(request) => request, + other => panic!("unexpected clarification recovery: {other:?}"), + }; + let (_, observation) = answer_game_creator_agent_user_input_request_for_pending_at( + root, + &pending, + &request.request_id, + response_id, + answers, + ) + .expect("answer clarification request"); + let detail = + serde_json::from_str::(observation.detail.as_deref().expect("answer detail")) + .expect("parse answer detail"); + let questions_sha256 = detail["questionsSha256"] + .as_str() + .expect("questions sha") + .to_string(); + let answers_sha256 = detail["answersSha256"] + .as_str() + .expect("answers sha") + .to_string(); + let pending_path = + game_creator_agent_runtime_pending_tool_action_path(root, parent_agent_id, parent_run_id); + fs::remove_file(&pending_path).expect("clear answered pending"); + (questions_sha256, answers_sha256) +} + +/// 走真实 agent.delegate 路径派发一次澄清 continuation(携带 continuationOfDelegationId / +/// questionsSha256 / answersSha256),不对结果做任何断言。 +#[allow(clippy::too_many_arguments)] +fn dispatch_static_delegate_clarification_continuation( + root: &Path, + parent_agent_id: &str, + parent_run_id: &str, + target_agent_id: &str, + task_text: &str, + acceptance_criteria: &[String], + expected_artifacts: &[String], + original_delegation_id: &str, + questions_sha256: &str, + answers_sha256: &str, + action_id: &str, +) -> AgentRuntimeToolObservation { + let delegate_input = serde_json::json!({ + "agentId": target_agent_id, + "task": task_text, + "acceptanceCriteria": acceptance_criteria, + "expectedArtifacts": expected_artifacts, + "repairOfDelegationId": original_delegation_id, + "runId": null, + "continuationOfDelegationId": original_delegation_id, + "questionsSha256": questions_sha256, + "answersSha256": answers_sha256, + }); + observe_agent_runtime_agent_delegate( + root, + parent_agent_id, + parent_run_id, + Some(action_id), + &delegate_input, + ) +} + +/// 只读地推导一次澄清 continuation 会得到的 delegationId——用于在真正派发前/后核对落盘身份, +/// 不产生任何副作用(validate_static_delegate_clarification_continuation_at 本身是只读校验)。 +fn clarification_continuation_delegation_id( + root: &Path, + parent_agent_id: &str, + parent_run_id: &str, + target_agent_id: &str, + original_delegation_id: &str, + questions_sha256: &str, + answers_sha256: &str, +) -> String { + let input = serde_json::json!({ + "continuationOfDelegationId": original_delegation_id, + "questionsSha256": questions_sha256, + "answersSha256": answers_sha256, + }); + let identity = validate_static_delegate_clarification_continuation_at( + root, + parent_agent_id, + parent_run_id, + &input, + Some(original_delegation_id), + ) + .expect("validate continuation binding") + .expect("derived continuation identity"); + // 省略两个指纹时 Runtime 必须从原 delivery 补齐权威值,并得到与手填时完全相同的身份。 + // 这条断言挂在共享 helper 上,所有澄清 continuation 用例都会顺带覆盖到。 + let identity_without_fingerprints = validate_static_delegate_clarification_continuation_at( + root, + parent_agent_id, + parent_run_id, + &serde_json::json!({ "continuationOfDelegationId": original_delegation_id }), + Some(original_delegation_id), + ) + .expect("validate continuation binding without fingerprints") + .expect("derived continuation identity without fingerprints"); + assert_eq!(identity, identity_without_fingerprints); + agent_runtime_delegation_id(parent_agent_id, parent_run_id, target_agent_id, &identity) +} + +/// 派发一次澄清 continuation 并断言必须成功、必须真正落盘,返回新 delivery 的 +/// (delegationId, targetSessionId, targetRunId),供下一跳复用。 +#[allow(clippy::too_many_arguments)] +fn clarification_round_must_succeed( + root: &Path, + parent_agent_id: &str, + parent_run_id: &str, + target_agent_id: &str, + task_text: &str, + acceptance_criteria: &[String], + expected_artifacts: &[String], + original_delegation_id: &str, + questions_sha256: &str, + answers_sha256: &str, + action_id: &str, +) -> (String, String, String) { + let new_delegation_id = clarification_continuation_delegation_id( + root, + parent_agent_id, + parent_run_id, + target_agent_id, + original_delegation_id, + questions_sha256, + answers_sha256, + ); + 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, + action_id, + ); + assert_eq!(observation.status, "ok", "{observation:?}"); + let new_delivery = read_static_delegate_delivery_at(root, &new_delegation_id) + .expect("read new delivery") + .expect("continuation 成功后必须真正落盘"); + ( + new_delegation_id, + new_delivery.target_session_id, + new_delivery.target_run_id, + ) +} + +/// 走完整「标记 needs-user-input -> 认领 -> 用户作答 -> 真实 continuation 派发」链路, +/// 断言这一轮必须成功,返回新 delivery 的 (delegationId, targetSessionId, targetRunId)。 +#[allow(clippy::too_many_arguments)] +fn drive_static_delegate_clarification_round( + 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, +) -> (String, String, String) { + 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, + ); + clarification_round_must_succeed( + 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, + ) +} + +/// 走到「标记 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}轮·关键决定"), + "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, + parent_run_id: &str, + target_agent_id: &str, + task_text: &str, + acceptance_criteria: &[String], + expected_artifacts: &[String], + original_delegation_id: &str, + action_id: &str, +) -> AgentRuntimeToolObservation { + let delegate_input = serde_json::json!({ + "agentId": target_agent_id, + "task": task_text, + "acceptanceCriteria": acceptance_criteria, + "expectedArtifacts": expected_artifacts, + "repairOfDelegationId": original_delegation_id, + "runId": null, + }); + observe_agent_runtime_agent_delegate( + root, + parent_agent_id, + parent_run_id, + Some(action_id), + &delegate_input, + ) +} + #[test] fn supervisor_chat_window_carries_encoded_project_path() { assert_eq!( @@ -46,8 +939,10 @@ 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"); + let design_prompt = game_creator_agent_runtime_tool_plan_system_prompt_for_agent( + "design-foundation", + AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, + ); for expected in [ "文本策划只是中间结果", "canvas.asset_generate", @@ -70,7 +965,8 @@ fn visual_specialist_prompts_require_real_registered_image_deliveries() { "referenceImageSrcs 第一项", "POST /api/external/v1/editor/images/generations(kind=ui-design)", "不得误用 POST /api/external/v1/editor/ui-designs/assets/extractions", - "canvas.asset_generate 成功动作本身就是当前 revision 的验证", + "canvas.asset_generate 成功只表示候选图片已生成并登记,不等于视觉验收完成", + "由 Runtime 在收束门内同时核对固定 owner 文档", "已有同路径画布资产时先核对登记", "检查已通过时不得重复生成或再次扣费", "纯场景图", @@ -82,14 +978,18 @@ fn visual_specialist_prompts_require_real_registered_image_deliveries() { ); } - let director_prompt = - game_creator_agent_runtime_tool_plan_system_prompt_for_agent("art-director"); + let director_prompt = game_creator_agent_runtime_tool_plan_system_prompt_for_agent( + "art-director", + AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, + ); 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 时不得提交最终回复", ] { assert!( @@ -98,7 +998,10 @@ 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"); + let art_prompt = game_creator_agent_runtime_tool_plan_system_prompt_for_agent( + "art-asset-plan", + AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, + ); for expected in [ "资产清单和美术计划只是中间结果", "canvas.asset_generate", @@ -129,8 +1032,10 @@ fn visual_specialist_prompts_require_real_registered_image_deliveries() { ); } - let ordinary_prompt = - game_creator_agent_runtime_tool_plan_system_prompt_for_agent("quality-review"); + let ordinary_prompt = game_creator_agent_runtime_tool_plan_system_prompt_for_agent( + "quality-review", + AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, + ); assert!(!ordinary_prompt.contains("assets/ui-prototype.png")); assert!(!ordinary_prompt.contains("assets/art-spritesheet.png")); } @@ -138,18 +1043,24 @@ 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"); + let design_prompt = game_creator_agent_runtime_tool_plan_system_prompt_for_agent( + "design-foundation", + AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, + ); 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"); + let art_prompt = game_creator_agent_runtime_tool_plan_system_prompt_for_agent( + "art-asset-plan", + AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, + ); 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")); @@ -169,6 +1080,7 @@ 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 [ @@ -1664,7 +2576,7 @@ fn claimed_needs_user_input_delivery_becomes_one_supervisor_wait() { ); let mut spoofed = first.clone(); spoofed.task = format!( - "子 Agent 需要用户澄清后才能继续。delegationId={delegation_id};questionsSha256={}。", + "{AGENT_RUNTIME_DELEGATE_CLARIFICATION_TASK_PREFIX}{delegation_id};questionsSha256={}。", "a".repeat(64) ); assert!( @@ -1820,6 +2732,85 @@ fn claimed_needs_user_input_delivery_becomes_one_supervisor_wait() { fs::remove_dir_all(root).ok(); } +#[test] +fn schema_max_clarification_envelope_survives_the_delegate_result_channel() { + let root = unique_project_path(); + init_local_game_project_at( + &root, + "project-child-max-clarification", + "满格澄清信封通道测试", + ) + .expect("project init"); + let delivery = new_static_delegate_delivery_with_contract( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "parent-session", + "parent-run", + "parent-action", + "child-max-clarification-delegation", + "design-director", + "child-session", + "child-run", + &["明确核心规则".to_string()], + &[], + None, + ); + let response = schema_max_clarification_envelope(); + // 通道上历史最紧的一处盲切是 500 字符;schema 允许的满格问询远超过它, + // 被切之后 JSON 只会在末尾 EOF,父 run 拿不到问题只能停在 needs-reconciliation。 + assert!( + response.chars().count() > 600, + "满格信封必须超过通道上所有旧盲切上限:{}", + response.chars().count() + ); + assert!( + response.chars().count() <= STATIC_DELEGATE_USER_INPUT_MAX_RESPONSE_CHARS, + "满格信封仍须落在 schema 推导的上限内:{}", + response.chars().count() + ); + let child_task = AgentRuntimeTaskRecord { + schema_version: "game-creator-agent-runtime-task.v1".to_string(), + task_id: "child-task".to_string(), + agent_id: delivery.target_agent_id.clone(), + session_id: delivery.target_session_id.clone(), + run_id: delivery.target_run_id.clone(), + source: "agent-delegate".to_string(), + parent_agent_id: Some(delivery.parent_agent_id.clone()), + parent_run_id: Some(delivery.parent_run_id.clone()), + delegation_id: Some(delivery.delegation_id.clone()), + run_profile: default_agent_runtime_run_profile(), + run_profile_binding_fingerprint: String::new(), + goal_id: None, + goal_revision: 0, + goal_status: None, + task: "明确核心规则".to_string(), + status: "completed".to_string(), + phase: "completed".to_string(), + current_action: "已完成澄清请求".to_string(), + terminal_detail: Some(response.clone()), + error: None, + updated_at: unix_timestamp(), + }; + let result = build_static_delegate_result_for_child_at( + &root, + &delivery, + &child_task, + "completed", + &response, + ) + .expect("满格澄清信封必须能被父 run 解析成结构化回执"); + assert_eq!( + result.contract_status, + StaticDelegateContractStatus::NeedsUserInput + ); + assert_eq!(result.user_input_questions.len(), 3); + assert!(result + .user_input_questions + .iter() + .all(|question| question.options.len() == 3)); + + fs::remove_dir_all(root).ok(); +} + #[test] fn completed_child_final_response_becomes_needs_user_input_delivery_result() { let root = unique_project_path(); @@ -3053,3 +4044,3136 @@ async fn project_supervisor_waiting_state_survives_agent_db_audit_failure() { fs::remove_dir_all(root).ok(); } + +#[test] +fn clarification_continuation_chain_supports_multiple_rounds() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-clarification-chain", "链式多轮澄清测试") + .expect("project init"); + let run_id = "project-supervisor-clarification-chain-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"; + + // ---------- 第 1 轮:D1(repair_of=None)-> 子 Agent 澄清终态 -> 认领 -> 用户回答 -> 真实 + // continuation 派发出 D2 ---------- + let d1_delegation_id = "clarification-chain-round1-delivery"; + let d1_delivery = new_static_delegate_delivery_with_contract( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &state.session_id, + run_id, + "clarification-chain-round1-delegate-action", + d1_delegation_id, + target_agent_id, + "clarification-chain-round1-child-session", + "clarification-chain-round1-child-run", + &acceptance_criteria, + &expected_artifacts, + None, + ); + create_or_read_static_delegate_delivery_at(&root, &d1_delivery).expect("create D1 delivery"); + let round1_result = build_static_delegate_structured_result_at( + &root, + "completed", + &expected_artifacts, + false, + None, + None, + None, + Some(concat!( + "AGC_NEEDS_USER_INPUT_V1\n", + r#"{"questions":[{"id":"target_platform","header":"首发平台","question":"首版优先发布到哪个平台?","options":[{"label":"Web","description":"优先浏览器交付。"},{"label":"桌面端","description":"优先桌面客户端交付。"}]}]}"# + )), + ) + .expect("build D1 needs-user-input result"); + mark_static_delegate_delivery_ready_with_result_at( + &root, + &d1_delivery.target_agent_id, + &d1_delivery.target_session_id, + &d1_delivery.target_run_id, + d1_delegation_id, + "completed", + "需要用户确认首发平台", + round1_result, + ) + .expect("mark D1 ready"); + claim_ready_static_delegate_receipts_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + "clarification-chain-round1-claim-action", + ) + .expect("claim D1 receipt"); + let round1_deliveries = claimed_static_delegate_deliveries_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + ) + .expect("read round1 claimed deliveries"); + assert!( + ensure_static_delegate_user_input_wait_at(&root, &mut state, &round1_deliveries) + .expect("create round1 supervisor wait") + ); + let round1_pending = read_game_creator_agent_runtime_pending_tool_action( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + ) + .expect("read round1 pending action"); + assert!(round1_pending.task.contains(d1_delegation_id)); + let round1_request = + match prepare_game_creator_agent_user_input_request_at(&root, &round1_pending) + .expect("prepare round1 clarification request") + { + AgentRuntimeUserInputRecovery::Waiting(request) => request, + other => panic!("unexpected round1 clarification recovery: {other:?}"), + }; + let (_, round1_observation) = answer_game_creator_agent_user_input_request_for_pending_at( + &root, + &round1_pending, + &round1_request.request_id, + "clarification-chain-round1-response", + BTreeMap::from([("target_platform".to_string(), "Web".to_string())]), + ) + .expect("answer round1 clarification request"); + let round1_detail = serde_json::from_str::( + round1_observation + .detail + .as_deref() + .expect("round1 answer detail"), + ) + .expect("parse round1 answer detail"); + let round1_questions_sha = round1_detail["questionsSha256"] + .as_str() + .expect("round1 questions sha") + .to_string(); + let round1_answers_sha = round1_detail["answersSha256"] + .as_str() + .expect("round1 answers sha") + .to_string(); + let pending_path = game_creator_agent_runtime_pending_tool_action_path( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + ); + fs::remove_file(&pending_path).expect("clear round1 answered pending before next planning"); + + let round1_continuation_input = serde_json::json!({ + "continuationOfDelegationId": d1_delegation_id, + "questionsSha256": round1_questions_sha, + "answersSha256": round1_answers_sha, + }); + let round1_identity = validate_static_delegate_clarification_continuation_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + &round1_continuation_input, + Some(d1_delegation_id), + ) + .expect("validate round1 continuation binding") + .expect("derived round1 continuation identity"); + + let target_lock = try_acquire_game_creator_agent_runtime_task_lock(&root, target_agent_id) + .expect("acquire clarification chain target lane") + .expect("clarification chain target lane available"); + + let round1_delegate_input = serde_json::json!({ + "agentId": target_agent_id, + "task": "根据用户确认的 Web 首发平台继续完成原方案", + "acceptanceCriteria": acceptance_criteria, + "expectedArtifacts": expected_artifacts, + "repairOfDelegationId": d1_delegation_id, + "runId": null, + "continuationOfDelegationId": d1_delegation_id, + "questionsSha256": round1_questions_sha, + "answersSha256": round1_answers_sha, + }); + let round1_continuation_observation = observe_agent_runtime_agent_delegate( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + Some("clarification-chain-round1-continuation-action"), + &round1_delegate_input, + ); + assert_eq!( + round1_continuation_observation.status, "ok", + "{round1_continuation_observation:?}" + ); + + let d2_delegation_id = agent_runtime_delegation_id( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + target_agent_id, + &round1_identity, + ); + let d2_delivery = read_static_delegate_delivery_at(&root, &d2_delegation_id) + .expect("read D2 delivery") + .expect("D2 delivery exists"); + assert_eq!( + d2_delivery.repair_of_delegation_id.as_deref(), + Some(d1_delegation_id), + "D2 必须记录自己续接自 D1,这正是第 2 轮返工深度校验会读到的字段" + ); + + // ---------- 第 2 轮:D2(repair_of=D1)-> 子 Agent 再次澄清终态 -> 认领 -> 用户回答 ---------- + let round2_result = build_static_delegate_structured_result_at( + &root, + "completed", + &expected_artifacts, + false, + None, + None, + None, + Some(concat!( + "AGC_NEEDS_USER_INPUT_V1\n", + r#"{"questions":[{"id":"art_style","header":"美术风格","question":"角色美术走哪种风格?","options":[{"label":"写实","description":"偏写实渲染。"},{"label":"卡通","description":"偏卡通渲染。"}]}]}"# + )), + ) + .expect("build D2 needs-user-input result"); + mark_static_delegate_delivery_ready_with_result_at( + &root, + &d2_delivery.target_agent_id, + &d2_delivery.target_session_id, + &d2_delivery.target_run_id, + &d2_delegation_id, + "completed", + "需要用户确认美术风格", + round2_result, + ) + .expect("mark D2 ready"); + claim_ready_static_delegate_receipts_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + "clarification-chain-round2-claim-action", + ) + .expect("claim D2 receipt"); + let round2_deliveries = claimed_static_delegate_deliveries_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + ) + .expect("read round2 claimed deliveries"); + assert!( + ensure_static_delegate_user_input_wait_at(&root, &mut state, &round2_deliveries) + .expect("create round2 supervisor wait") + ); + let round2_pending = read_game_creator_agent_runtime_pending_tool_action( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + ) + .expect("read round2 pending action"); + assert!(round2_pending.task.contains(&d2_delegation_id)); + let round2_request = + match prepare_game_creator_agent_user_input_request_at(&root, &round2_pending) + .expect("prepare round2 clarification request") + { + AgentRuntimeUserInputRecovery::Waiting(request) => request, + other => panic!("unexpected round2 clarification recovery: {other:?}"), + }; + let (_, round2_observation) = answer_game_creator_agent_user_input_request_for_pending_at( + &root, + &round2_pending, + &round2_request.request_id, + "clarification-chain-round2-response", + BTreeMap::from([("art_style".to_string(), "卡通".to_string())]), + ) + .expect("answer round2 clarification request"); + let round2_detail = serde_json::from_str::( + round2_observation + .detail + .as_deref() + .expect("round2 answer detail"), + ) + .expect("parse round2 answer detail"); + let round2_questions_sha = round2_detail["questionsSha256"] + .as_str() + .expect("round2 questions sha") + .to_string(); + let round2_answers_sha = round2_detail["answersSha256"] + .as_str() + .expect("round2 answers sha") + .to_string(); + fs::remove_file(&pending_path).expect("clear round2 answered pending before next planning"); + + // 澄清 continuation 自身的绑定校验(不含返工深度检查):应当通过——证明 D2 -> D3 的绑定 + // 本身是合法的,问题出在另一道独立的深度门上。 + let round2_continuation_input = serde_json::json!({ + "continuationOfDelegationId": d2_delegation_id, + "questionsSha256": round2_questions_sha, + "answersSha256": round2_answers_sha, + }); + let round2_identity = validate_static_delegate_clarification_continuation_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + &round2_continuation_input, + Some(&d2_delegation_id), + ) + .expect("validate round2 continuation binding") + .expect("derived round2 continuation identity"); + + let d3_delegation_id = agent_runtime_delegation_id( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + target_agent_id, + &round2_identity, + ); + + // ---------- 第 3 轮:对 D2 发起 continuation 以创建 D3,repair_of = D2 ---------- + // 【有意的行为变更】本测试原先在这里断言第 3 轮必须被拒绝——旧实现里返工深度门对 + // “质量返工”和“澄清 continuation”一视同仁,静态委派全链路只放行一跳,第 3 轮永远不可达。 + // 本轮设计冻结把返工深度(上限 1)与澄清轮次(上限 3)拆成两个独立的链上推断维度: + // D2 -> D3 的分类判据是 D2.structured_result.contract_status == NeedsUserInput(真), + // 因此它是澄清 continuation,只受澄清轮次上限约束,不再触碰返工深度门。 + // 第 3 轮现在必须成功、D3 必须真正落盘——这是设计冻结明确要求的新行为,不是回归。 + let pre_round3_deliveries = + list_static_delegate_deliveries_at(&root).expect("list deliveries before round3"); + let (pre_round3_depth, pre_round3_round) = + static_delegate_lineage_counters(&pre_round3_deliveries, &d2_delegation_id); + assert_eq!( + (pre_round3_depth, pre_round3_round), + (0, 1), + "D2 应为返工深度 0、澄清轮次 1(D1 是链根,D1 -> D2 是第 1 轮澄清)" + ); + + let round3_delegate_input = serde_json::json!({ + "agentId": target_agent_id, + "task": "根据用户确认的卡通美术风格继续完成原方案", + "acceptanceCriteria": acceptance_criteria, + "expectedArtifacts": expected_artifacts, + "repairOfDelegationId": d2_delegation_id, + "runId": null, + "continuationOfDelegationId": d2_delegation_id, + "questionsSha256": round2_questions_sha, + "answersSha256": round2_answers_sha, + }); + let round3_observation = observe_agent_runtime_agent_delegate( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + Some("clarification-chain-round3-continuation-action"), + &round3_delegate_input, + ); + assert_eq!( + round3_observation.status, "ok", + "第 3 轮 continuation 在真实 agent.delegate 路径上必须成功:{round3_observation:?}" + ); + + let d3_delivery = read_static_delegate_delivery_at(&root, &d3_delegation_id) + .expect("read D3 lookup") + .expect("第 3 轮成功后 D3 必须真正落盘"); + assert_eq!( + d3_delivery.repair_of_delegation_id.as_deref(), + Some(d2_delegation_id.as_str()), + "D3 必须记录自己续接自 D2" + ); + let post_round3_deliveries = + list_static_delegate_deliveries_at(&root).expect("list deliveries after round3"); + let (round3_depth, round3_round) = + static_delegate_lineage_counters(&post_round3_deliveries, &d3_delegation_id); + assert_eq!( + (round3_depth, round3_round), + (0, 2), + "D3 应继承返工深度 0(R1 澄清跳不重置、也不新增返工深度),澄清轮次推导为 2" + ); + + drop(target_lock); + fs::remove_dir_all(root).ok(); +} + +/// 返工/澄清续跑的 acceptanceCriteria 与 expectedArtifacts 由 Runtime 从原 delivery +/// 继承:四个字段全传 null 时续跑照样建得起来,落盘值与原委派逐字相同。 +/// +/// 生产实测里 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"); + 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 (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\":\"请确认继续?\",", + "\"options\":[", + "{\"label\":\"确认\",\"description\":\"继续下一步。\"},", + "{\"label\":\"暂缓\",\"description\":\"先不要继续。\"}", + "]}]}" +); + +#[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 + // -> 澄清 D3(depth 必须仍为 1、round=1) -> D3 转 NeedsRepair -> 对 D3 发起返工 D4 + // 必须被拒绝(因为 depth 会变成 2,超过上限 1)。 + // 这条同时证伪两种可能的失误实现: + // - “R1 误把 depth 清零”:如果 D3 的 depth 被错误算成 0,D4 就会被误放行。 + // - “R2 漏加 1”:如果 D2 的 depth 从未真正变成 1(例如实现里“有 parent 就继承 depth” + // 这种自然默认,从不区分澄清跳与返工跳),D4 同样会被误放行。 + // 现有的“纯返工链”和“纯澄清链”测试都无法同时抓住这两种失误,只有交替链能做到。 + let root = unique_project_path(); + init_local_game_project_at(&root, "project-alternating-chain", "交替返工澄清链钉子测试") + .expect("project init"); + let run_id = "project-supervisor-alternating-chain-run"; + let mut 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 alternating chain run"); + + let acceptance_criteria = vec!["必须交付缺失文件".to_string()]; + let expected_artifacts = vec!["game/alternating-missing.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 alternating chain target lane") + .expect("alternating chain target lane available"); + + // ---------- D1:链根,标记为质量问题(needs-repair) ---------- + let d1_id = "alternating-chain-d1"; + create_dispatched_static_delegate_delivery( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &state.session_id, + run_id, + "alternating-chain-d1-action", + d1_id, + target_agent_id, + "alternating-chain-d1-child-session", + "alternating-chain-d1-child-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, + "alternating-chain-d1-child-session", + "alternating-chain-d1-child-run", + d1_id, + &expected_artifacts, + "alternating-chain-d1-claim", + ); + + // ---------- D1 -> D2:质量返工(depth 必须变成 1) ---------- + let d2_id = agent_runtime_delegation_id( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + target_agent_id, + "alternating-chain-d2-repair-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, + "alternating-chain-d2-repair-action", + ); + assert_eq!(d2_observation.status, "ok", "{d2_observation:?}"); + let d2_delivery = read_static_delegate_delivery_at(&root, &d2_id) + .expect("read D2") + .expect("D2 必须真正落盘"); + let deliveries_after_d2 = + list_static_delegate_deliveries_at(&root).expect("list deliveries after D2"); + assert_eq!( + static_delegate_lineage_counters(&deliveries_after_d2, &d2_id), + (1, 0), + "D2 是质量返工跳,depth 必须变成 1,round 保持 0" + ); + + // ---------- D2 转 needs-user-input ---------- + mark_and_claim_static_delegate_needs_user_input( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + target_agent_id, + &d2_delivery.target_session_id, + &d2_delivery.target_run_id, + &d2_id, + &expected_artifacts, + CLARIFICATION_QUESTION_BODY, + "alternating-chain-d2-claim", + ); + + // ---------- D2 -> D3:澄清续接(depth 必须仍为 1,round 必须变成 1) ---------- + let (questions_sha256, answers_sha256) = answer_static_delegate_clarification_wait( + &root, + &mut state, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + &d2_id, + BTreeMap::from([("confirm".to_string(), "确认".to_string())]), + "alternating-chain-d3-response", + ); + let (d3_id, d3_session, d3_run) = clarification_round_must_succeed( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + target_agent_id, + "根据澄清结果继续补齐缺失文件", + &acceptance_criteria, + &expected_artifacts, + &d2_id, + &questions_sha256, + &answers_sha256, + "alternating-chain-d3-continuation-action", + ); + let deliveries_after_d3 = + list_static_delegate_deliveries_at(&root).expect("list deliveries after D3"); + assert_eq!( + static_delegate_lineage_counters(&deliveries_after_d3, &d3_id), + (1, 1), + "D3 是澄清跳:depth 必须原样继承自 D2(仍为 1,不清零),round 必须变成 1" + ); + + // ---------- D3 转 needs-repair ---------- + mark_and_claim_static_delegate_needs_repair( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + target_agent_id, + &d3_session, + &d3_run, + &d3_id, + &expected_artifacts, + "alternating-chain-d3-claim", + ); + + // ---------- 对 D3 发起返工 D4:必须被拒绝(depth 会变成 2,超过上限 1) ---------- + let d4_observation = dispatch_static_delegate_plain_repair( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + target_agent_id, + "再次补齐缺失文件", + &acceptance_criteria, + &expected_artifacts, + &d3_id, + "alternating-chain-d4-repair-action", + ); + assert_eq!( + d4_observation.status, "failed", + "对 D3 的返工必须被拒绝:depth 会变成 2,超过上限 1:{d4_observation:?}" + ); + assert!( + d4_observation.summary.contains("深度最多为 1"), + "拒绝理由必须是返工深度门文案,实际为:{d4_observation:?}" + ); + + drop(target_lock); + 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(); + init_local_game_project_at( + &root, + "project-concurrent-user-revision", + "用户修订并发派发测试", + ) + .expect("project init"); + let run_id = "project-supervisor-concurrent-user-revision-run"; + let state = start_game_creator_agent_runtime_task_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "并发发起同一用户修订", + run_id, + "agent-chat", + "等待用户修订", + vec!["只允许一条用户修订续跑".to_string()], + ) + .expect("start concurrent user revision parent"); + let acceptance_criteria = vec!["必须交付策划产物".to_string()]; + let expected_artifacts = vec!["game/concurrent-user-revision.md".to_string()]; + let target_agent_id = "design-director"; + // 车道锁必须在任何真实派发之前拿:下面 D1 走的是真实 observe 路径,会占用 + // 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 = "concurrent-user-revision-d0"; + create_dispatched_static_delegate_delivery( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &state.session_id, + run_id, + "concurrent-user-revision-d0-action", + d0_id, + target_agent_id, + "concurrent-user-revision-d0-session", + "concurrent-user-revision-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, + "concurrent-user-revision-d0-session", + "concurrent-user-revision-d0-run", + d0_id, + &expected_artifacts, + "concurrent-user-revision-d0-claim", + ); + + // 先用一次普通质量返工把链上 depth 顶到 1,之后才轮到新分支承重。 + let d1_action = "concurrent-user-revision-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, + "concurrent-user-revision-d1-claim", + ); + rewrite_claimed_static_delegate_as_user_revision_requested(&root, &d1_id, &expected_artifacts); + + let start = Arc::new(Barrier::new(3)); + let mut workers = Vec::new(); + for action_id in [ + "concurrent-user-revision-action-a", + "concurrent-user-revision-action-b", + ] { + let worker_root = root.clone(); + let worker_start = Arc::clone(&start); + let worker_criteria = acceptance_criteria.clone(); + let worker_artifacts = expected_artifacts.clone(); + let worker_original = d1_id.clone(); + workers.push(std::thread::spawn(move || { + worker_start.wait(); + let observation = observe_agent_runtime_agent_delegate( + &worker_root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + Some(action_id), + &serde_json::json!({ + "agentId": "design-director", + "task": "按用户审批修改方案", + "acceptanceCriteria": worker_criteria, + "expectedArtifacts": worker_artifacts, + "repairOfDelegationId": worker_original, + "runId": null + }), + ); + (action_id, observation) + })); + } + start.wait(); + let results = workers + .into_iter() + .map(|worker| worker.join().expect("join user revision worker")) + .collect::>(); + assert_eq!( + results + .iter() + .filter(|(_, observation)| observation.status == "ok") + .count(), + 1, + "并发用户修订必须恰好放行一条:{results:?}" + ); + assert!( + results.iter().any(|(_, observation)| { + observation.status == "failed" && observation.summary.contains("最多允许一轮返工") + }), + "被拒的那条必须止于兄弟检查:{results:?}" + ); + let persisted = results + .iter() + .filter_map(|(action_id, _)| { + read_static_delegate_delivery_at( + &root, + &agent_runtime_delegation_id( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + target_agent_id, + action_id, + ), + ) + .expect("read competing user revision delivery") + }) + .collect::>(); + assert_eq!(persisted.len(), 1, "只能落盘一条用户修订续跑"); + assert_eq!( + persisted[0].repair_of_delegation_id.as_deref(), + Some(d1_id.as_str()) + ); + + drop(target_lock); + fs::remove_dir_all(root).ok(); +} + +#[test] +fn quality_rework_resets_clarification_round_budget() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-round-reset", "返工重置澄清轮次预算测试") + .expect("project init"); + let run_id = "project-supervisor-round-reset-run"; + let mut 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 round-reset run"); + + let acceptance_criteria = vec!["必须交付缺失文件".to_string()]; + let expected_artifacts = vec!["game/round-reset-missing.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 round-reset target lane") + .expect("round-reset target lane available"); + + // ---------- D0 -> 两轮澄清 D1、D2 ---------- + let d0_id = "round-reset-d0"; + create_dispatched_static_delegate_delivery( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &state.session_id, + run_id, + "round-reset-d0-action", + d0_id, + target_agent_id, + "round-reset-d0-child-session", + "round-reset-d0-child-run", + &acceptance_criteria, + &expected_artifacts, + None, + ); + 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-reset-d0-child-session", + "round-reset-d0-child-run", + "继续推进玩法方案", + &acceptance_criteria, + &expected_artifacts, + d0_id, + CLARIFICATION_QUESTION_BODY, + "confirm", + "确认", + "round-reset-claim-1", + "round-reset-response-1", + "round-reset-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-reset-claim-2", + "round-reset-response-2", + "round-reset-delegate-2", + ); + let deliveries_after_d2 = + list_static_delegate_deliveries_at(&root).expect("list deliveries after D2"); + assert_eq!( + static_delegate_lineage_counters(&deliveries_after_d2, &d2_id), + (0, 2), + "D2 已经历 2 轮澄清" + ); + + // ---------- D2 转 needs-repair,对其发起一次真实质量返工 D3 ---------- + mark_and_claim_static_delegate_needs_repair( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + target_agent_id, + &d2_session, + &d2_run, + &d2_id, + &expected_artifacts, + "round-reset-d2-claim", + ); + let d3_id = agent_runtime_delegation_id( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + target_agent_id, + "round-reset-d3-repair-action", + ); + let d3_observation = dispatch_static_delegate_plain_repair( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + target_agent_id, + "补齐缺失文件", + &acceptance_criteria, + &expected_artifacts, + &d2_id, + "round-reset-d3-repair-action", + ); + assert_eq!(d3_observation.status, "ok", "{d3_observation:?}"); + let d3_delivery = read_static_delegate_delivery_at(&root, &d3_id) + .expect("read D3") + .expect("D3 必须真正落盘"); + let deliveries_after_d3 = + list_static_delegate_deliveries_at(&root).expect("list deliveries after D3"); + assert_eq!( + static_delegate_lineage_counters(&deliveries_after_d3, &d3_id), + (1, 0), + "D3 是质量返工跳:depth 变成 1,澄清轮次必须重置为 0——不能因为返工吃掉预设的澄清轮次预算" + ); + + // ---------- 对 D3 连续发起 3 轮澄清,全部必须放行 ---------- + let (d4_id, d4_session, d4_run) = drive_static_delegate_clarification_round( + &root, + &mut state, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + target_agent_id, + &d3_delivery.target_session_id, + &d3_delivery.target_run_id, + "继续推进玩法方案", + &acceptance_criteria, + &expected_artifacts, + &d3_id, + CLARIFICATION_QUESTION_BODY, + "confirm", + "确认", + "round-reset-claim-4", + "round-reset-response-4", + "round-reset-delegate-4", + ); + let (d5_id, d5_session, d5_run) = drive_static_delegate_clarification_round( + &root, + &mut state, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + target_agent_id, + &d4_session, + &d4_run, + "继续推进玩法方案", + &acceptance_criteria, + &expected_artifacts, + &d4_id, + CLARIFICATION_QUESTION_BODY, + "confirm", + "确认", + "round-reset-claim-5", + "round-reset-response-5", + "round-reset-delegate-5", + ); + let (d6_id, _d6_session, _d6_run) = drive_static_delegate_clarification_round( + &root, + &mut state, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + target_agent_id, + &d5_session, + &d5_run, + "继续推进玩法方案", + &acceptance_criteria, + &expected_artifacts, + &d5_id, + CLARIFICATION_QUESTION_BODY, + "confirm", + "确认", + "round-reset-claim-6", + "round-reset-response-6", + "round-reset-delegate-6", + ); + let deliveries_after_d6 = + list_static_delegate_deliveries_at(&root).expect("list deliveries after D6"); + assert_eq!( + static_delegate_lineage_counters(&deliveries_after_d6, &d6_id), + (1, 3), + "D3 返工重置澄清轮次预算后,D4/D5/D6 三轮澄清必须全部放行,round 推导到 3" + ); + + drop(target_lock); + fs::remove_dir_all(root).ok(); +} + +#[test] +fn clarification_hop_does_not_block_next_node_quality_rework() { + // 场景标题「澄清不占同级返工门配额」的可达实现:D0 完成后进入 needs-user-input, + // 对 D0 的唯一合法后续动作是澄清 continuation(生产代码里 + // validate_static_delegate_clarification_continuation_at 强制要求:一旦原 delivery + // 处于 needs-user-input,任何携带 repairOfDelegationId=D0 的请求都必须同时携带匹配的 + // continuationOfDelegationId/questionsSha256/answersSha256,否则在到达同级返工排它门之前 + // 就会先被“必须绑定原 delegationId”拒绝)——因此不存在“对同一个 D0 既发澄清续接、 + // 又发一次不带澄清字段的质量返工”这种可达状态。 + // 本用例改为验证这条设计真正保证的东西:D0 的一次澄清续接消费掉的是 D0 自己的“同级 + // 返工/续接排它配额”,而不会连带影响它产生的新节点 C1 自身的配额——C1 完成后如果是 + // 质量问题,仍然可以正常发起一次真实返工并被放行。 + let root = unique_project_path(); + init_local_game_project_at( + &root, + "project-sibling-quota", + "澄清跳不占用下一节点返工配额测试", + ) + .expect("project init"); + let run_id = "project-supervisor-sibling-quota-run"; + let mut 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 sibling-quota run"); + + let acceptance_criteria = vec!["必须交付缺失文件".to_string()]; + let expected_artifacts = vec!["game/sibling-quota-missing.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 sibling-quota target lane") + .expect("sibling-quota target lane available"); + + let d0_id = "sibling-quota-d0"; + create_dispatched_static_delegate_delivery( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &state.session_id, + run_id, + "sibling-quota-d0-action", + d0_id, + target_agent_id, + "sibling-quota-d0-child-session", + "sibling-quota-d0-child-run", + &acceptance_criteria, + &expected_artifacts, + None, + ); + // 先做一次澄清续接生成 C1(repair_of = D0),消费掉 D0 的同级续接配额。 + let (c1_id, c1_session, c1_run) = drive_static_delegate_clarification_round( + &root, + &mut state, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + target_agent_id, + "sibling-quota-d0-child-session", + "sibling-quota-d0-child-run", + "继续推进玩法方案", + &acceptance_criteria, + &expected_artifacts, + d0_id, + CLARIFICATION_QUESTION_BODY, + "confirm", + "确认", + "sibling-quota-claim-1", + "sibling-quota-response-1", + "sibling-quota-delegate-1", + ); + + // 直接对 D0 再发一次不带澄清字段的“真实质量返工”:必须被拒绝,且拒绝理由必须是 + // 澄清 continuation 校验器的“必须绑定原 delegationId”,而不是同级返工排它门的 + // “最多允许一轮返工”——证明挡住它的是分类规则本身,而不是 C1 消费掉了配额。 + let plain_repair_against_d0 = dispatch_static_delegate_plain_repair( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + target_agent_id, + "跳过澄清直接返工", + &acceptance_criteria, + &expected_artifacts, + d0_id, + "sibling-quota-plain-repair-against-d0-action", + ); + assert_eq!( + plain_repair_against_d0.status, "failed", + "{plain_repair_against_d0:?}" + ); + assert!( + plain_repair_against_d0 + .summary + .contains("必须绑定原 delegationId"), + "对处于 needs-user-input 的 D0 发起不带澄清字段的请求,必须被澄清校验器拒绝,\ + 而不是被同级返工排它门拒绝,实际为:{plain_repair_against_d0:?}" + ); + + // C1 完成后本身是质量问题:对 C1 发起一次真实返工,必须被放行——证明 D0 的续接配额 + // 与 C1 自己的返工配额相互独立,澄清跳不会连带占用后续节点的配额。 + mark_and_claim_static_delegate_needs_repair( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + target_agent_id, + &c1_session, + &c1_run, + &c1_id, + &expected_artifacts, + "sibling-quota-c1-claim", + ); + let repair_against_c1 = dispatch_static_delegate_plain_repair( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + target_agent_id, + "补齐缺失文件", + &acceptance_criteria, + &expected_artifacts, + &c1_id, + "sibling-quota-repair-against-c1-action", + ); + assert_eq!( + repair_against_c1.status, "ok", + "C1 自己的返工配额不应被 D0 的澄清续接连带消耗:{repair_against_c1:?}" + ); + + drop(target_lock); + fs::remove_dir_all(root).ok(); +} + +#[test] +fn legacy_pre_clarification_delivery_repair_counts_toward_depth() { + // 手工构造一份 #165(引入 NeedsUserInput)之前风格的旧 delivery:contract_status + // 只可能是 EvidenceReady/NeedsRepair,没有任何澄清相关字段被写入过。验证链上推断函数 + // 把它正确分类为“非澄清”,据此发起的返工正确计入返工深度,且第二次返工仍被深度门拦下。 + let root = unique_project_path(); + init_local_game_project_at(&root, "project-legacy-delivery", "历史记录分类回归测试") + .expect("project init"); + let run_id = "project-supervisor-legacy-delivery-run"; + let parent_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 legacy-delivery run"); + + let acceptance_criteria = vec!["必须交付缺失文件".to_string()]; + let expected_artifacts = vec!["game/legacy-missing.md".to_string()]; + let target_agent_id = "design-director"; + + let legacy_id = "legacy-delivery-original"; + create_dispatched_static_delegate_delivery( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &parent_state.session_id, + run_id, + "legacy-delivery-action", + legacy_id, + target_agent_id, + "legacy-delivery-child-session", + "legacy-delivery-child-run", + &acceptance_criteria, + &expected_artifacts, + None, + ); + // 手工写入一条 #165 之前风格的终态结果:没有 user_input_questions_sha256, + // 没有 clarification_request_id/clarification_answers_sha256。 + let mut legacy_delivery = read_static_delegate_delivery_at(&root, legacy_id) + .expect("read legacy delivery") + .expect("legacy delivery exists"); + legacy_delivery.status = StaticDelegateDeliveryStatus::ClaimedByParent; + legacy_delivery.terminal_status = Some("completed".to_string()); + legacy_delivery.result_summary = Some("旧版本质量缺口".to_string()); + legacy_delivery.structured_result = Some(StaticDelegateStructuredResult { + contract_status: StaticDelegateContractStatus::NeedsRepair, + artifacts: Vec::new(), + missing_expected_artifacts: expected_artifacts.clone(), + verification_required: false, + verified_revision: None, + evidence: Vec::new(), + error: Some("缺少 1 个预期产物".to_string()), + user_input_questions: Vec::new(), + user_input_questions_sha256: None, + }); + legacy_delivery.claimed_by_action_id = Some("legacy-delivery-claim-action".to_string()); + write_static_delegate_delivery_at(&root, &legacy_delivery).expect("persist legacy delivery"); + assert!(legacy_delivery.clarification_request_id.is_none()); + assert!(legacy_delivery.clarification_answers_sha256.is_none()); + + let deliveries_before_repair = + list_static_delegate_deliveries_at(&root).expect("list deliveries before repair"); + assert_eq!( + static_delegate_lineage_counters(&deliveries_before_repair, legacy_id), + (0, 0), + "旧记录自身是链根:depth=0,round=0" + ); + + let target_lock = try_acquire_game_creator_agent_runtime_task_lock(&root, target_agent_id) + .expect("acquire legacy-delivery target lane") + .expect("legacy-delivery target lane available"); + let repair_id = agent_runtime_delegation_id( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + target_agent_id, + "legacy-delivery-repair-action", + ); + let repair_observation = dispatch_static_delegate_plain_repair( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + target_agent_id, + "补齐缺失文件", + &acceptance_criteria, + &expected_artifacts, + legacy_id, + "legacy-delivery-repair-action", + ); + assert_eq!( + repair_observation.status, "ok", + "旧记录必须被分类为非澄清,正常放行返工:{repair_observation:?}" + ); + let deliveries_after_repair = + list_static_delegate_deliveries_at(&root).expect("list deliveries after repair"); + assert_eq!( + static_delegate_lineage_counters(&deliveries_after_repair, &repair_id), + (1, 0), + "返工必须正确计入深度:depth 变成 1" + ); + + // 返工的返工必须被深度门拦下:先让返工本身走到 ClaimedByParent(否则会先被 + // “只能引用已由父 Agent 认领的原回执”拦下,而不是深度门),再尝试对它发起第二次返工。 + let repair_delivery = read_static_delegate_delivery_at(&root, &repair_id) + .expect("read repair delivery") + .expect("repair delivery exists"); + mark_and_claim_static_delegate_needs_repair( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + target_agent_id, + &repair_delivery.target_session_id, + &repair_delivery.target_run_id, + &repair_id, + &expected_artifacts, + "legacy-delivery-repair-claim", + ); + + let nested_repair_error = validate_static_delegate_repair_request_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + "legacy-delivery-nested-repair", + target_agent_id, + &acceptance_criteria, + &expected_artifacts, + Some(&repair_id), + ) + .expect_err("返工的返工必须被深度门拒绝"); + assert!(nested_repair_error.contains("深度最多为 1")); + + drop(target_lock); + fs::remove_dir_all(root).ok(); +} + +#[test] +fn static_delegate_repair_request_rejects_cross_run_reference() { + // 跨 run 隔离:同一 Supervisor agent_id 下,换一个 parent_run_id 后引用另一个 run 里的 + // delivery 发起返工,必须被拒绝——静态委派的返工/续接关系不能跨 Supervisor 父 run。 + let root = unique_project_path(); + init_local_game_project_at(&root, "project-cross-run", "静态委派跨 run 隔离测试") + .expect("project init"); + let owning_run_id = "project-supervisor-cross-run-owner-run"; + let other_run_id = "project-supervisor-cross-run-other-run"; + let owner_state = start_game_creator_agent_runtime_task_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "验收专业 Agent 并按需返工", + owning_run_id, + "agent-chat", + "等待专业回执", + vec!["完成合同验收".to_string()], + ) + .expect("start owning run"); + start_game_creator_agent_runtime_task_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "另一个互不相关的 run", + other_run_id, + "agent-chat", + "等待专业回执", + vec!["完成另一份合同验收".to_string()], + ) + .expect("start other run"); + + let acceptance_criteria = vec!["必须交付缺失文件".to_string()]; + let expected_artifacts = vec!["game/cross-run-missing.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 cross-run target lane") + .expect("cross-run target lane available"); + + let original_id = "cross-run-original"; + create_dispatched_static_delegate_delivery( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &owner_state.session_id, + owning_run_id, + "cross-run-original-action", + original_id, + target_agent_id, + "cross-run-original-child-session", + "cross-run-original-child-run", + &acceptance_criteria, + &expected_artifacts, + None, + ); + mark_and_claim_static_delegate_needs_repair( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + owning_run_id, + target_agent_id, + "cross-run-original-child-session", + "cross-run-original-child-run", + original_id, + &expected_artifacts, + "cross-run-original-claim", + ); + + // 在属主 run 里发起返工必须放行——先证明这条 delivery 本身可以被正常返工。 + let same_run_repair = dispatch_static_delegate_plain_repair( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + owning_run_id, + target_agent_id, + "补齐缺失文件", + &acceptance_criteria, + &expected_artifacts, + original_id, + "cross-run-same-run-repair-action", + ); + assert_eq!(same_run_repair.status, "ok", "{same_run_repair:?}"); + + // 换一个互不相关的 parent_run_id,引用同一个 original_id 发起返工:必须被拒绝。 + let cross_run_repair = dispatch_static_delegate_plain_repair( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + other_run_id, + target_agent_id, + "跨 run 冒用返工", + &acceptance_criteria, + &expected_artifacts, + original_id, + "cross-run-cross-run-repair-action", + ); + assert_eq!( + cross_run_repair.status, "failed", + "跨 run 引用旧链节点的返工请求必须被拒绝:{cross_run_repair:?}" + ); + assert!( + cross_run_repair.summary.contains("同一 Supervisor 父 run"), + "拒绝理由必须是跨 run 隔离文案,实际为:{cross_run_repair:?}" + ); + + drop(target_lock); + fs::remove_dir_all(root).ok(); +} + +#[test] +fn game_chat_source_caps_clarification_round_at_one() { + // source 区分:game-chat source 下澄清上限为 1,第 2 轮就被拒绝; + // 非 game-chat source(本文件其余用例默认场景)下能走到 3——已由 + // clarification_round_limit_rejects_fourth_round 覆盖,这里只验证 game-chat 分支。 + let root = unique_project_path(); + init_local_game_project_at( + &root, + "project-game-chat-round-cap", + "game-chat 澄清上限测试", + ) + .expect("project init"); + let run_id = "project-supervisor-game-chat-round-cap-run"; + // 绑定 Run Profile 为 game-chat source;profile 保持默认 STANDARD(而非 + // AUTONOMOUS_GAME_BUILD),避免额外触发“game-chat 单主路径禁止 Supervisor 直接委派” + // 这条与本用例无关的美术委派专用门(那条门只在 profile=AUTONOMOUS_GAME_BUILD 时生效)。 + bind_game_creator_agent_runtime_run_profile_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + None, + None, + ) + .expect("bind game-chat run profile"); + let mut state = start_game_creator_agent_runtime_task_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "game-chat 单主路径协调专业 Agent", + run_id, + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + "协调专业 Agent", + vec!["取得用户澄清后继续专业委派".to_string()], + ) + .expect("start game-chat 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 game-chat target lane") + .expect("game-chat target lane available"); + + let d0_id = "game-chat-round-cap-d0"; + create_dispatched_static_delegate_delivery( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &state.session_id, + run_id, + "game-chat-round-cap-d0-action", + d0_id, + target_agent_id, + "game-chat-round-cap-d0-child-session", + "game-chat-round-cap-d0-child-run", + &acceptance_criteria, + &expected_artifacts, + None, + ); + + // 第 1 轮必须放行(round 推导为 1,未达 game-chat 上限 1?—— 判据是 + // original_clarification_round >= limit:D0 自身 round=0,0 >= 1 为假,放行)。 + 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, + "game-chat-round-cap-d0-child-session", + "game-chat-round-cap-d0-child-run", + "继续推进玩法方案", + &acceptance_criteria, + &expected_artifacts, + d0_id, + CLARIFICATION_QUESTION_BODY, + "confirm", + "确认", + "game-chat-round-cap-claim-1", + "game-chat-round-cap-response-1", + "game-chat-round-cap-delegate-1", + ); + let deliveries_after_d1 = + list_static_delegate_deliveries_at(&root).expect("list deliveries after D1"); + assert_eq!( + static_delegate_lineage_counters(&deliveries_after_d1, &d1_id), + (0, 1), + "game-chat 下第 1 轮澄清必须放行,round 推导为 1" + ); + + // 第 2 轮:D1 自身 round=1,1 >= game-chat 上限 1 为真,必须被拒绝。 + let rejection = drive_static_delegate_clarification_round_expect_rejection( + &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", + "确认", + "game-chat-round-cap-claim-2", + "game-chat-round-cap-response-2", + "game-chat-round-cap-delegate-2", + ); + assert!( + rejection.summary.contains("澄清轮次已达上限"), + "game-chat 下第 2 轮必须被澄清轮次上限拒绝:{rejection:?}" + ); + + drop(target_lock); + fs::remove_dir_all(root).ok(); +} + +/// 立项策划子 Agent 的身份登记(D11 / 技术方案第 3.1 节)。 +/// +/// 钉住四件事: +/// 1. `project-planning` 是 catalog 合法成员,能通过 `agent.delegate` 的目标校验; +/// 2. 它能合成出角色身份——这是原先的 blocking 缺口:`game_creator_agent_role_definition` +/// 只特判 Supervisor、其余遍历专业组,`project-planning` 会返回 `None`,而调用方用 +/// `.ok_or_else(...)?` 把它转成硬错误,导致委派第一轮就中断; +/// 3. 它**不进**种子 DAG——`build.rs` 的一致性校验只比对 `groups[].roles[]` 派生的集合, +/// 「做游戏」16 任务 DAG 一行不动; +/// 4. 登记不得带来隐性扩权:它只能被 Supervisor 静态委派,不能被当作 +/// `agent.spawn_isolated` 的动态孵生模板。 +#[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/configuration.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/configuration.rs index 8a98d7bb9..35784870b 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 @@ -340,6 +340,14 @@ 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" @@ -610,6 +618,7 @@ fn app_config_commands_write_runtime_config_file() { }, agent_llm, mcp_servers: BTreeMap::new(), + planning: GameCreatorPlanningConfig::default(), }) .expect("write runtime config"); @@ -694,6 +703,7 @@ fn app_config_write_rejects_invalid_api_kind() { editor_api: GameCreatorEditorApiConfig::default(), agent_llm: BTreeMap::new(), mcp_servers: BTreeMap::new(), + planning: GameCreatorPlanningConfig::default(), }); assert!(result @@ -718,6 +728,7 @@ fn app_config_write_rejects_invalid_reasoning_effort() { editor_api: GameCreatorEditorApiConfig::default(), agent_llm: BTreeMap::new(), mcp_servers: BTreeMap::new(), + planning: GameCreatorPlanningConfig::default(), }); assert!(result @@ -742,6 +753,7 @@ fn app_config_write_rejects_too_small_request_timeout() { editor_api: GameCreatorEditorApiConfig::default(), agent_llm: BTreeMap::new(), mcp_servers: BTreeMap::new(), + planning: GameCreatorPlanningConfig::default(), }); assert!(result 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 dfc64b43f..254e7dd22 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 @@ -918,6 +918,8 @@ 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, 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 e5a2a8f99..af8d52a4d 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 @@ -67,7 +67,7 @@ fn agent_runtime_context_window_restores_progress_and_counts_dynamic_detail_chan .expect("read mid-window context bundle") .expect("mid-window bundle exists"); let continuation = continuation_from_game_creator_agent_runtime_context_bundle(loaded); - let mut restored = AgentRuntimeContextWindowTracker::from_continuation(&continuation); + let mut restored = AgentRuntimeContextWindowTracker::from_continuation(&continuation, &state); for next_loop_index in 4..AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT { restored.record(&AgentRuntimeToolObservation { tool: "project.search".to_string(), @@ -162,7 +162,7 @@ fn agent_runtime_context_window_persists_stall_across_revision_drift_and_restart .expect("stalled context bundle exists"); assert!(loaded.context_stalled); let continuation = continuation_from_game_creator_agent_runtime_context_bundle(loaded); - let mut restored = AgentRuntimeContextWindowTracker::from_continuation(&continuation); + let mut restored = AgentRuntimeContextWindowTracker::from_continuation(&continuation, &state); assert_eq!( restored.complete_loop(AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT + 1), AgentRuntimeContextCheckpoint::Stalled, @@ -263,6 +263,143 @@ 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() { + 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", + "验证窗口分档", + "window-lane-run", + "agent-background-task", + "窗口分档测试", + vec!["跑满一个窗口".to_string()], + ) + .expect("start window lane runtime state"); + let lane_state = |agent_id: &str, source: &str| { + let mut state = started.clone(); + state.agent_id = agent_id.to_string(); + state.source = source.to_string(); + state + }; + + // 做游戏链路:窗口照常记账,跑满一轮窗口后给出 checkpoint。 + let game_state = lane_state("design-director", "agent-background-task"); + assert!(agent_runtime_context_window_applies(&game_state)); + let mut game_tracker = + AgentRuntimeContextWindowTracker::from_continuation(&Default::default(), &game_state); + for next_loop_index in 1..=AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT { + game_tracker.record(&AgentRuntimeToolObservation { + tool: "project.search".to_string(), + status: "ok".to_string(), + summary: format!("第 {next_loop_index} 轮"), + detail: None, + }); + let checkpoint = game_tracker.complete_loop(next_loop_index); + if next_loop_index == AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT { + assert_ne!( + checkpoint, + AgentRuntimeContextCheckpoint::Continue, + "做游戏链路必须保留窗口边界 checkpoint" + ); + } else { + 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] fn platform_art_asset_output_path_rejects_escape_overwrite_and_symlink() { let root = unique_project_path(); 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 aab8c3cbc..b287564d5 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 @@ -4115,6 +4115,7 @@ 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, @@ -7866,6 +7867,59 @@ fn agent_native_tool_parser_accepts_plan_with_reply_and_rejects_reply_with_actio assert!(error.contains("最终回复不能与动作工具同时提交")); } +#[test] +fn planning_agent_parser_rejects_text_and_legacy_tool_plan_bypasses() { + let catalog = GameCreatorMcpCatalog { + fingerprint: "planning-parser-empty-catalog".to_string(), + servers: Vec::new(), + tools: Vec::new(), + }; + let payload = serde_json::json!({ + "thinkingSummary": "不应执行搜索", + "planUpdate": null, + "plan": [], + "actions": [{ + "tool": "project.search", + "reason": "绕过 planning allowlist", + "input": {"query": "secret", "path": "", "maxResults": 20, "caseSensitive": false} + }], + "response": "" + }) + .to_string(); + let text_error = + parse_game_creator_agent_tool_plan_llm_response_with_catalog_classified_for_agent( + GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + &agent_tool_plan_llm_response(payload.clone(), Vec::new()), + &catalog, + ) + .expect_err("planning text JSON must not bypass the exact tool identity gate"); + assert_eq!( + text_error.kind(), + AgentRuntimeToolPlanProtocolErrorKind::UnknownFunction + ); + assert!(text_error.to_string().contains("project.search")); + + let legacy_error = + parse_game_creator_agent_tool_plan_llm_response_with_catalog_classified_for_agent( + GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + &agent_tool_plan_llm_response( + "", + vec![platform_llm::LlmToolCall { + id: "planning-legacy-wrapper".to_string(), + name: AGENT_RUNTIME_TOOL_PLAN_FUNCTION_NAME.to_string(), + arguments: payload, + }], + ), + &catalog, + ) + .expect_err("planning must reject the unadvertised legacy wrapper"); + assert_eq!( + legacy_error.kind(), + AgentRuntimeToolPlanProtocolErrorKind::UnknownFunction + ); + assert!(legacy_error.to_string().contains("submit_agent_tool_plan")); +} + #[test] fn agent_native_tool_parser_binds_dynamic_mcp_function_without_model_fingerprints() { let tool = GameCreatorMcpCatalogTool { 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 ddcc149de..accde9020 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 @@ -600,20 +600,23 @@ async fn background_agent_runtime_can_run_limited_static_smoke() { ) .expect("start background task"); - let plan_request = receiver - .recv_timeout(Duration::from_secs(2)) - .expect("plan llm request"); + let plan_request = wait_for_captured_mock_request(&receiver, "plan llm request").await; assert!(plan_request.contains("file.write")); assert!(plan_request.contains("command.run_limited")); - 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("game.static_smoke 已完成")); assert!(final_request.contains("通过:game/index.html")); let root_display = root.to_string_lossy(); assert!(!final_request.contains(root_display.as_ref())); - let runtime = wait_for_agent_runtime_idle(&root, "code-prototype"); + let runtime = wait_for_agent_runtime_terminal_and_lane_release( + &root, + "code-prototype", + "code-smoke-run", + "idle", + "completed", + ) + .state; assert_eq!(runtime.status, "idle"); assert_eq!(runtime.phase, "completed"); assert!(runtime diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/planning_strategy/autonomous_build.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/planning_strategy/autonomous_build.rs index e23ba5850..89082ecec 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/planning_strategy/autonomous_build.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/planning_strategy/autonomous_build.rs @@ -1,18 +1,21 @@ use super::super::support::*; use crate::{ - active_static_delegate_delivery_count_at, autonomous_game_build_root_run_active_at, - bind_supervisor_collaboration_policy_snapshot_at, build_static_delegate_structured_result_at, - claim_ready_static_delegate_receipts_at, create_or_read_static_delegate_delivery_at, - mark_static_delegate_claim_observed_at, mark_static_delegate_delivery_ready_at, - mark_static_delegate_delivery_ready_with_result_at, new_game_creation_app_seed_tasks, - new_static_delegate_delivery, new_static_delegate_delivery_with_contract, - observe_agent_runtime_run_status, record_command_run, record_preview_state, + active_static_delegate_delivery_count_at, + append_unique_game_creator_agent_runtime_pending_task, + autonomous_game_build_root_run_active_at, bind_supervisor_collaboration_policy_snapshot_at, + build_static_delegate_structured_result_at, claim_ready_static_delegate_receipts_at, + create_or_read_static_delegate_delivery_at, mark_static_delegate_claim_observed_at, + mark_static_delegate_delivery_ready_at, mark_static_delegate_delivery_ready_with_result_at, + new_game_creation_app_seed_tasks, new_static_delegate_delivery, + new_static_delegate_delivery_with_contract, observe_agent_runtime_run_status, + record_command_run, record_preview_state, refresh_agent_runtime_autonomous_convergence_snapshot_after_provider_at, start_game_creator_supervisor_background_task_for_session_at, static_delegate_completion_barrier_at, validate_agent_runtime_autonomous_plan_liveness, GameCreationAppCommandRunState, GameCreationAppCommandRunStatus, GameCreationAppPreviewStatus, StaticDelegateContractStatus, }; +use sha2::{Digest as _, Sha256}; fn write_autonomous_editor_api_config_for_test(config_dir: &Path, api_key: &str) { fs::create_dir_all(config_dir).expect("create autonomous runtime config dir"); @@ -609,6 +612,84 @@ fn bind_autonomous_specialist_runtime_for_test( runtime } +fn bind_autonomous_manifest_owner_runtime_for_test( + root: &Path, + parent_run_id: &str, + agent_id: &str, + task: &str, +) -> AgentRuntimeState { + let parent_session_id = resolve_agent_conversation_session_id_at( + root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + None, + true, + ) + .expect("resolve autonomous manifest parent session"); + append_unique_game_creator_agent_runtime_pending_task( + root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &parent_session_id, + task, + parent_run_id, + AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + None, + ) + .expect("queue autonomous manifest parent and freeze its completion baseline"); + start_game_creator_agent_runtime_task_at( + root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + task, + parent_run_id, + AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, + "调度固定 owner", + Vec::new(), + ) + .expect("start durable autonomous manifest parent"); + + update_manifest_task_status_at(root, agent_id, GameCreationAppTaskStatus::Running) + .expect("mark autonomous manifest owner running"); + let child_run_id = autonomous_manifest_ready_task_run_id_for_test(parent_run_id, agent_id); + let child_link = 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, + }; + let child_session_id = resolve_agent_conversation_session_id_at(root, agent_id, None, true) + .expect("resolve autonomous manifest owner session"); + append_unique_game_creator_agent_runtime_pending_task( + root, + agent_id, + &child_session_id, + task, + &child_run_id, + "agent-ready-task-scheduler", + None, + Some(&child_link), + ) + .expect("queue autonomous manifest owner"); + start_game_creator_agent_runtime_task_at( + root, + agent_id, + task, + &child_run_id, + "agent-ready-task-scheduler", + "执行固定 owner 交付", + Vec::new(), + ) + .expect("start autonomous manifest owner") +} + +fn autonomous_manifest_ready_task_run_id_for_test(parent_run_id: &str, task_id: &str) -> String { + let identity = format!("{parent_run_id}\n{task_id}\nagent-ready-task-scheduler"); + let fingerprint = format!("{:x}", Sha256::digest(identity.as_bytes())); + format!( + "autonomous-ready-{}-{}", + task_id, + fingerprint.chars().take(20).collect::() + ) +} + #[tokio::test] async fn autonomous_game_build_non_read_only_code_first_round_repairs_response_into_mutation_only() { @@ -842,6 +923,207 @@ async fn autonomous_game_build_unverified_mutation_immediately_repairs_into_veri fs::remove_dir_all(root).ok(); } +#[tokio::test] +async fn autonomous_manifest_code_prototype_requires_its_own_static_smoke_after_project_verify() { + let root = unique_project_path(); + init_local_game_project_at( + &root, + "project-autonomous-code-owner-smoke", + "自主构建程序 owner 静态验收测试", + ) + .expect("project init"); + fs::write( + root.join("package.json"), + br#"{"scripts":{"check":"node --check game/index.html"}}"#, + ) + .expect("write package manifest so project.verify is advertised diagnostically"); + let parent_run_id = "autonomous-code-owner-smoke-parent"; + let child_run_id = + autonomous_manifest_ready_task_run_id_for_test(parent_run_id, "code-prototype"); + let task = "实现可玩 game/index.html,并由 code-prototype 本人完成 game.static_smoke。"; + let response_arguments = serde_json::json!({ + "response": "可玩入口已经通过项目检查,可以交付。" + }) + .to_string(); + let smoke_arguments = serde_json::json!({ + "reason": "补齐 code-prototype 本人静态验收凭证", + "input": {"commandId": "game.static_smoke"} + }) + .to_string(); + let (sender, receiver) = mpsc::channel(); + let base_url = spawn_mock_llm_raw_responses_with_capture( + vec![ + native_agent_tool_plan_chat_response( + "call-autonomous-code-owner-project-verified-response", + AGENT_RUNTIME_RESPOND_FUNCTION_NAME, + response_arguments.clone(), + ), + native_agent_tool_plan_chat_response( + "call-autonomous-code-owner-static-smoke", + &native_runtime_function_name("command.run_limited") + .expect("limited command function"), + smoke_arguments, + ), + native_agent_tool_plan_chat_response( + "call-autonomous-code-owner-smoke-verified-response", + AGENT_RUNTIME_RESPOND_FUNCTION_NAME, + response_arguments, + ), + ], + Some(sender), + ); + let _config_guard = write_test_local_config(format!( + r#"{{ + "agentLlm": {{ + "code-prototype": {{ + "apiKey": "autonomous-code-owner-smoke-key", + "baseUrl": {base_url:?}, + "model": "autonomous-code-owner-smoke-model", + "apiKind": "openai_chat", + "maxRetries": 0 + }} + }} +}}"# + )); + let runtime = bind_autonomous_manifest_owner_runtime_for_test( + &root, + parent_run_id, + "code-prototype", + task, + ); + assert_eq!(runtime.run_id, child_run_id); + let mutation_revision = prepare_agent_runtime_project_mutation_locked( + &root, + "code-prototype", + &child_run_id, + "file.patch", + ) + .expect("record code-prototype mutation"); + persist_project_verification_for_test( + &root, + "code-prototype", + &child_run_id, + "project.verify", + true, + ); + let project_verified_gate = + read_game_creator_agent_runtime_verification_gate(&root, "code-prototype", &child_run_id) + .expect("read project.verify gate"); + assert_eq!( + project_verified_gate.mutation_revision, + Some(mutation_revision) + ); + assert_eq!( + project_verified_gate.verified_revision, + Some(mutation_revision) + ); + assert_eq!( + project_verified_gate.last_verification_tool.as_deref(), + Some("project.verify") + ); + assert_eq!(project_verified_gate.static_smoke_verified_revision, None); + let blocker = + project_verification_completion_blocker_at(&root, "code-prototype", &child_run_id, &[]) + .expect("project.verify alone must not complete code-prototype"); + assert!(blocker.summary.contains("game.static_smoke")); + + let repair_plan = request_game_creator_agent_background_tool_plan_for_test( + &root, + "code-prototype", + &runtime.session_id, + &child_run_id, + task, + &[], + 2, + 0, + ) + .await + .expect("repair project.verify-only delivery") + .expect("static-smoke repair plan"); + assert!(repair_plan.response.is_empty()); + assert_eq!(repair_plan.actions.len(), 1); + assert_eq!(repair_plan.actions[0].tool, "command.run_limited"); + assert_eq!( + repair_plan.actions[0].input["commandId"], + "game.static_smoke" + ); + + receiver + .recv_timeout(Duration::from_secs(2)) + .expect("initial project.verify-only response request"); + let repair_request = receiver + .recv_timeout(Duration::from_secs(2)) + .expect("static-smoke-only repair request"); + assert!(repair_request.contains("project.verify 不能满足 code-prototype")); + assert_eq!( + captured_native_function_names_for_test(&repair_request), + ["command.run_limited"] + .into_iter() + .map(|tool| native_runtime_function_name(tool).expect("static smoke function")) + .collect::>() + ); + + // 收口 static-smoke 时会按磁盘内容复核 game/index.html。该夹具只写了 + // package.json,入口仍是初始化留下的无画布占位页,复核必然拒收。落一份满足 + // 完整合同的入口;纯文件写入不触碰 revision 记账,上面对 mutation / verified + // revision 的断言不受影响。 + fs::write( + root.join("game/index.html"), + "

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

", + ) + .expect("write a smoke-contract compliant game entry before the static smoke credential"); + persist_project_verification_for_test( + &root, + "code-prototype", + &child_run_id, + "game.static_smoke", + true, + ); + let smoke_gate = + read_game_creator_agent_runtime_verification_gate(&root, "code-prototype", &child_run_id) + .expect("read static smoke gate"); + assert_eq!( + smoke_gate.static_smoke_verified_revision, + Some(mutation_revision) + ); + let completed_plan = request_game_creator_agent_background_tool_plan_for_test( + &root, + "code-prototype", + &runtime.session_id, + &child_run_id, + task, + &[AgentRuntimeToolObservation { + tool: "command.run_limited".to_string(), + status: "ok".to_string(), + summary: "game.static_smoke 已通过当前 revision".to_string(), + detail: None, + }], + 3, + 0, + ) + .await + .expect("request delivery after static smoke") + .expect("code-prototype may deliver after its own static smoke"); + assert_eq!( + completed_plan.response, + "可玩入口已经通过项目检查,可以交付。" + ); + assert!(completed_plan.actions.is_empty()); + assert!(project_verification_completion_blocker_at( + &root, + "code-prototype", + &child_run_id, + &[], + ) + .is_none()); + receiver + .recv_timeout(Duration::from_secs(2)) + .expect("post-smoke final response request"); + assert!(receiver.recv_timeout(Duration::from_millis(200)).is_err()); + + fs::remove_dir_all(root).ok(); +} + async fn assert_autonomous_repair_waits_for_receipt_observation_for_test(unobserved_claim: bool) { let root = unique_project_path(); let case_name = if unobserved_claim { @@ -3303,7 +3585,7 @@ async fn autonomous_game_build_verified_revision_forces_response_only_delivery() } #[tokio::test] -async fn autonomous_static_art_asset_verification_does_not_require_node_project() { +async fn autonomous_static_art_asset_delivery_uses_runtime_owner_validation_without_node_project() { let root = unique_project_path(); init_local_game_project_at( &root, @@ -3315,39 +3597,21 @@ async fn autonomous_static_art_asset_verification_does_not_require_node_project( !root.join("package.json").exists(), "static HTML fixture must not gain a Node project manifest" ); - fs::write( - root.join("assets/manifest.art.json"), - br#"{"assets":[{"path":"assets/art-spritesheet.png","kind":"art-spritesheet"}]}"#, - ) - .expect("write static art manifest"); - register_canvas_visual_asset_fixture(&root, "assets/art-spritesheet.png", "art-spritesheet"); - - let child_run_id = "autonomous-static-art-verification-child"; + let parent_run_id = "autonomous-static-art-verification-parent"; + let child_run_id = + autonomous_manifest_ready_task_run_id_for_test(parent_run_id, "art-asset-plan"); let task = "为纯静态 HTML 游戏生成并登记 assets/manifest.art.json 与 assets/art-spritesheet.png;项目不使用 Node 或 npm。"; let response_arguments = serde_json::json!({ "response": "美术清单和精灵图已经生成,可以交付。" }) .to_string(); - let smoke_arguments = serde_json::json!({ - "reason": "使用静态 smoke 验证当前纯静态项目 revision", - "input": {"commandId": "game.static_smoke"} - }) - .to_string(); let (sender, receiver) = mpsc::channel(); let base_url = spawn_mock_llm_raw_responses_with_capture( - vec![ - native_agent_tool_plan_chat_response( - "call-autonomous-static-art-response", - AGENT_RUNTIME_RESPOND_FUNCTION_NAME, - response_arguments, - ), - native_agent_tool_plan_chat_response( - "call-autonomous-static-art-smoke", - &native_runtime_function_name("command.run_limited") - .expect("limited command function"), - smoke_arguments, - ), - ], + vec![native_agent_tool_plan_chat_response( + "call-autonomous-static-art-response", + AGENT_RUNTIME_RESPOND_FUNCTION_NAME, + response_arguments, + )], Some(sender), ); let _config_guard = write_test_local_config(format!( @@ -3363,17 +3627,22 @@ async fn autonomous_static_art_asset_verification_does_not_require_node_project( }} }}"# )); - let runtime = bind_autonomous_specialist_runtime_for_test( + let runtime = bind_autonomous_manifest_owner_runtime_for_test( &root, - "autonomous-static-art-verification-parent", + parent_run_id, "art-asset-plan", - child_run_id, task, ); + fs::write( + root.join("assets/manifest.art.json"), + br#"{"assets":[{"path":"assets/art-spritesheet.png","kind":"art-spritesheet"}]}"#, + ) + .expect("write static art manifest after the parent completion baseline"); + register_canvas_visual_asset_fixture(&root, "assets/art-spritesheet.png", "art-spritesheet"); prepare_agent_runtime_project_mutation_locked( &root, "art-asset-plan", - child_run_id, + &child_run_id, "canvas.asset_generate", ) .expect("record generated art mutation"); @@ -3388,43 +3657,21 @@ async fn autonomous_static_art_asset_verification_does_not_require_node_project( &root, "art-asset-plan", &runtime.session_id, - child_run_id, + &child_run_id, task, &observations, 2, 0, ) .await - .expect("repair static art delivery into static verification") - .expect("static smoke verification plan"); - assert!(plan.response.is_empty()); - assert_eq!(plan.actions.len(), 1); - assert_eq!(plan.actions[0].tool, "command.run_limited"); - assert_eq!(plan.actions[0].input["commandId"], "game.static_smoke"); + .expect("accept static art delivery for runtime owner validation") + .expect("static art delivery plan"); + assert_eq!(plan.response, "美术清单和精灵图已经生成,可以交付。"); + assert!(plan.actions.is_empty()); receiver .recv_timeout(Duration::from_secs(2)) .expect("initial static art response request"); - let repair_request = receiver - .recv_timeout(Duration::from_secs(2)) - .expect("static art verification-only repair request"); - let repair_function_names = captured_native_function_names_for_test(&repair_request); - assert_eq!( - repair_function_names, - BTreeSet::from([ - native_runtime_function_name("command.run_limited").expect("static smoke function") - ]), - "pure static asset delivery must not expose package.json-backed project.verify" - ); - let repair_request_json = mock_http_request_json(&repair_request); - let repair_instruction = repair_request_json["messages"] - .as_array() - .and_then(|messages| messages.last()) - .and_then(|message| message.get("content")) - .and_then(serde_json::Value::as_str) - .expect("static art repair instruction"); - assert!(repair_instruction.contains("game.static_smoke")); - assert!(!repair_instruction.contains("project.verify")); assert!(receiver.recv_timeout(Duration::from_millis(200)).is_err()); fs::remove_dir_all(root).ok(); 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 09b5f0df0..7fea36247 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,4 +1,5 @@ use super::support::*; +use crate::PLAN_SUBMIT_GDD_TOOL; #[test] fn agent_runtime_default_allowed_tools_match_executable_whitelist() { @@ -17,6 +18,61 @@ 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(); @@ -210,6 +266,543 @@ 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(); @@ -1583,6 +2176,8 @@ async fn autonomous_design_foundation_denies_indirect_execution_before_side_effe let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("agent db"); assert!(!agent_db.contains("agent.runtime.project.verify")); assert!(!agent_db.contains("agent.runtime.mcp.call")); + // master 断言:委派路径的 design-foundation 仍可用 command.run_limited 跑手动验证。 + // 内部产物验证只在 scheduler 路径发放,这里删掉这条出路会让该 run 无从收束。 assert!(game_creator_agent_runtime_tool_policy_rule_for_run( &root, "design-foundation", @@ -1592,6 +2187,98 @@ async fn autonomous_design_foundation_denies_indirect_execution_before_side_effe "command.run_limited", ) .is_none()); + fs::remove_dir_all(root).ok(); +} + +#[tokio::test] +async fn autonomous_fixed_pre_code_owners_deny_manual_verification_before_side_effects() { + let root = unique_project_path(); + init_local_game_project_at( + &root, + "project-fixed-owner-manual-verification-policy", + "固定 owner 手工验证门禁测试", + ) + .expect("project init"); + let supervisor_run_id = "fixed-owner-manual-verification-parent-run"; + bind_game_creator_agent_runtime_run_profile_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + supervisor_run_id, + AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + None, + ) + .expect("bind supervisor autonomous run"); + let verify_command = + "node -e \"require('fs').writeFileSync('owner-project-verify-executed.txt','executed')\""; + fs::write( + root.join("package.json"), + serde_json::json!({ + "scripts": { + "check": verify_command, + } + }) + .to_string(), + ) + .expect("write project verify canary script"); + + for agent_id in ["balance-seed", "art-asset-plan", "audio-asset-plan"] { + let run_id = format!("fixed-owner-{agent_id}-run"); + bind_game_creator_agent_runtime_run_profile_at( + &root, + agent_id, + &run_id, + "agent-delegate", + None, + Some(&AgentRuntimeTaskLink { + parent_agent_id: Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string()), + parent_run_id: Some(supervisor_run_id.to_string()), + delegation_id: Some(format!("fixed-owner-{agent_id}-delegation")), + }), + ) + .expect("bind delegated fixed owner autonomous run"); + + let project_verify = execute_game_creator_agent_runtime_tool_action( + &root, + agent_id, + &run_id, + "不得回退执行项目脚本", + &AgentRuntimeToolAction { + tool: "project.verify".to_string(), + reason: Some("验证固定 owner 门禁先于项目命令执行".to_string()), + input: serde_json::json!({ + "script": "check", + "expectedCommand": verify_command, + "timeoutSeconds": 30, + }), + }, + ) + .await; + let smoke = execute_game_creator_agent_runtime_tool_action( + &root, + agent_id, + &run_id, + "不得借游戏 smoke 验证固定 owner 产物", + &AgentRuntimeToolAction { + tool: "command.run_limited".to_string(), + reason: Some("验证固定 owner 门禁先于静态检查".to_string()), + input: serde_json::json!({ "commandId": "game.static_smoke" }), + }, + ) + .await; + + for observation in [&project_verify, &smoke] { + assert_eq!(observation.status, "blocked"); + assert!(observation.summary.contains("固定 owner")); + assert!(observation.summary.contains(agent_id)); + } + } + + assert!(!root.join("owner-project-verify-executed.txt").exists()); + assert!(!root.join(".agent/logs/command.log").exists()); + let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("agent db"); + assert!(!agent_db.contains("agent.runtime.project.verify")); + assert!(!agent_db.contains("agent.runtime.command.run_limited")); fs::remove_dir_all(root).ok(); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/recovery.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/recovery.rs index d4c274484..0f81964c4 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/recovery.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/recovery.rs @@ -1482,6 +1482,17 @@ async fn background_agent_runtime_recovers_stale_running_before_pending_task() { .recent_tasks .iter() .any(|task| task.run_id == "design-pending-after-stale-run" && task.status == "completed")); + // 结构性断言:钉「主循环只在 16 MiB 专用 worker 上轮询」这个不变量本身。只断言「默认栈 + // 下没崩」是不够的——恢复重启曾长期把与 started 入口同样深的 poll 链放在默认 2 MiB 的 + // tokio worker 上,直到余量被吃穿才暴露,期间所有用例都是绿的。 + let worker_threads = agent_runtime_background_worker_threads_for_test(&root); + assert!( + !worker_threads.is_empty() + && worker_threads + .iter() + .all(|name| name.starts_with("agent-runtime-worker-")), + "恢复重启的后台主循环必须在专用 worker 线程上轮询,实际观察到:{worker_threads:?}" + ); fs::remove_dir_all(root).ok(); } 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 e3f8cf7fa..b99ff0052 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 @@ -34,9 +34,10 @@ pub(super) use crate::{ advance_game_creator_agent_runtime_turn_at, agent_runtime_action_receipt_public_safe_detail_for_test, agent_runtime_action_receipt_safe_detail_for_owner_for_test, - agent_runtime_contains_secret_key_prefix, agent_runtime_executable_tools, - agent_runtime_read_only_delivery_completion_plan_update, agent_runtime_run_profile_identity_at, - agent_runtime_tool_action_fingerprint, agent_runtime_tool_action_id, + agent_runtime_background_worker_threads_for_test, agent_runtime_contains_secret_key_prefix, + agent_runtime_executable_tools, agent_runtime_read_only_delivery_completion_plan_update, + agent_runtime_run_profile_identity_at, agent_runtime_tool_action_fingerprint, + agent_runtime_tool_action_id, agent_runtime_tool_allowed_for_agent, agent_runtime_tool_policy_snapshot_for_run_at, agent_runtime_tool_requires_pending_revision_gate, agent_runtime_tool_requires_repository_context_fingerprint_gate, @@ -66,7 +67,8 @@ pub(super) use crate::{ game_creator_agent_runtime_tool_policy_rule_for_run, init_local_game_project_at, invalidate_agent_runtime_project_verification_after_preview_failure_at, native_runtime_function_name, observe_agent_runtime_action_history, - observe_agent_runtime_agent_message, plan_game_creation_agent_pass, + observe_agent_runtime_agent_delegate, observe_agent_runtime_agent_message, + observe_agent_runtime_agent_spawn_isolated, plan_game_creation_agent_pass, prepare_agent_runtime_project_mutation_locked, prepare_game_creator_agent_runtime_provider_action_batch, project_verification_completion_blocker_at, read_all_game_creator_agent_runtime_tasks, @@ -107,14 +109,17 @@ 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_STATUS_COMPLETED, + AGENT_RUNTIME_PENDING_ACTION_STATUS_OBSERVED_APPROVED, + AGENT_RUNTIME_PLAN_ROOT_CHILD_TARGET_UNSUPPORTED_KIND, 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_SUPERVISOR_AGENT_ID, - GAME_CREATOR_USER_INPUT_REQUEST_TOOL, PROJECT_BLACKBOARD_MEMORY_PATH, + 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, }; diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/task_lifecycle.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/task_lifecycle.rs index f7b91b02b..6362eb2bd 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/task_lifecycle.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/task_lifecycle.rs @@ -527,6 +527,16 @@ async fn background_agent_runtime_cancel_waiting_task_drains_queued_task() { .recent_tasks .iter() .any(|task| { task.run_id == "design-after-cancel-run" && task.status == "completed" })); + // 结构性断言:队列 drain 与「首个任务」drain 只差一个 poll 帧,必须共用同一条 16 MiB + // 专用 worker,不能一个有保护、另一个留在默认 2 MiB 的 tokio worker 上。 + let worker_threads = agent_runtime_background_worker_threads_for_test(&root); + assert!( + !worker_threads.is_empty() + && worker_threads + .iter() + .all(|name| name.starts_with("agent-runtime-worker-")), + "队列 drain 的后台主循环必须在专用 worker 线程上轮询,实际观察到:{worker_threads:?}" + ); fs::remove_dir_all(root).ok(); } 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 114181cdf..647f2f8a6 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 @@ -516,17 +516,21 @@ fn structured_plan_update_validates_and_advances_monotonically() { &mut runtime, parsed.plan_update.as_ref().expect("plan update"), ) - .expect("apply first plan")); + .expect("apply first plan") + .advanced_steps()); assert_eq!(runtime.plan_revision, 1); assert_eq!(runtime.active_plan_step_index, Some(0)); assert_eq!(runtime.plan_steps[0].status, "in_progress"); assert_eq!(runtime.plan, vec!["读取项目", "验证结果"]); - assert!(!apply_agent_runtime_plan_update( - &mut runtime, - parsed.plan_update.as_ref().expect("same plan update"), - ) - .expect("idempotent plan update")); + assert_eq!( + apply_agent_runtime_plan_update( + &mut runtime, + parsed.plan_update.as_ref().expect("same plan update"), + ) + .expect("idempotent plan update"), + AgentRuntimePlanUpdateOutcome::Unchanged + ); assert_eq!(runtime.plan_revision, 1); let progressed = AgentRuntimePlanUpdate { @@ -542,7 +546,9 @@ fn structured_plan_update_validates_and_advances_monotonically() { }, ], }; - assert!(apply_agent_runtime_plan_update(&mut runtime, &progressed).expect("advance plan")); + assert!(apply_agent_runtime_plan_update(&mut runtime, &progressed) + .expect("advance plan") + .advanced_steps()); assert_eq!(runtime.plan_revision, 2); assert_eq!(runtime.plan_steps[0].status, "completed"); assert_eq!(runtime.active_plan_step_index, Some(1)); @@ -573,7 +579,8 @@ fn structured_plan_update_validates_and_advances_monotonically() { }], }; assert!(apply_agent_runtime_plan_update(&mut runtime, &completed) - .expect("terminal step is retained")); + .expect("terminal step is retained") + .advanced_steps()); assert_eq!(runtime.plan_revision, 3); assert_eq!(runtime.plan, vec!["读取项目", "验证结果"]); assert!(runtime @@ -629,6 +636,7 @@ fn first_structured_plan_replaces_terminal_legacy_scaffolding_steps() { assert!( apply_agent_runtime_plan_update(&mut runtime, &first_structured_update) .expect("first structured plan replaces legacy scaffolding") + .advanced_steps() ); assert_eq!(runtime.plan_revision, 1); assert_eq!(runtime.plan_steps.len(), AGENT_RUNTIME_PLAN_STEP_LIMIT); @@ -650,6 +658,71 @@ fn first_structured_plan_replaces_terminal_legacy_scaffolding_steps() { assert_ne!(same_title_step.updated_at, 1); } +#[test] +fn explanation_only_plan_update_is_not_plan_progress() { + let mut runtime = default_game_creator_agent_runtime_state("design-director", "plan-idle-run"); + let initial = AgentRuntimePlanUpdate { + explanation: "先读项目再验证".to_string(), + steps: vec![ + AgentRuntimePlanUpdateStep { + step: "读取项目".to_string(), + status: "in_progress".to_string(), + }, + AgentRuntimePlanUpdateStep { + step: "验证结果".to_string(), + status: "pending".to_string(), + }, + ], + }; + assert!(apply_agent_runtime_plan_update(&mut runtime, &initial) + .expect("apply first plan") + .advanced_steps()); + assert_eq!(runtime.plan_revision, 1); + + // 步骤集合与状态一字未动,只换了解释:这是活锁的形状,不能算计划进展, + // 也不能发一个新 planRevision 替它背书。 + let explanation_only = AgentRuntimePlanUpdate { + explanation: "换个说法解释同一个计划".to_string(), + steps: initial.steps.clone(), + }; + let outcome = apply_agent_runtime_plan_update(&mut runtime, &explanation_only) + .expect("apply explanation-only plan"); + assert_eq!(outcome, AgentRuntimePlanUpdateOutcome::ExplanationOnly); + assert!(!outcome.advanced_steps()); + assert_eq!(runtime.plan_revision, 1); + assert_eq!(runtime.plan_explanation, "换个说法解释同一个计划"); + assert_eq!(runtime.plan_steps[0].status, "in_progress"); + assert_eq!(runtime.plan_steps[1].status, "pending"); + + // 真把步骤推到下一格才重新计数。 + let progressed = AgentRuntimePlanUpdate { + explanation: "换个说法解释同一个计划".to_string(), + steps: vec![ + AgentRuntimePlanUpdateStep { + step: "读取项目".to_string(), + status: "completed".to_string(), + }, + AgentRuntimePlanUpdateStep { + step: "验证结果".to_string(), + status: "in_progress".to_string(), + }, + ], + }; + assert!(apply_agent_runtime_plan_update(&mut runtime, &progressed) + .expect("advance plan") + .advanced_steps()); + assert_eq!(runtime.plan_revision, 2); +} + +#[test] +fn plan_update_idle_repair_threshold_tolerates_exactly_one_split_round() { + // 第一轮只更新计划、下一轮才动手是正常的两步走,不该被当成活锁。 + assert!(!plan_update_idle_rounds_require_repair(0)); + assert!(!plan_update_idle_rounds_require_repair(1)); + assert!(plan_update_idle_rounds_require_repair(2)); + assert!(plan_update_idle_rounds_require_repair(3)); +} + #[test] fn structured_plan_failed_step_remains_immutable_after_migration() { let mut runtime = default_game_creator_agent_runtime_state("code-prototype", "failed-plan-run"); @@ -1898,6 +1971,59 @@ fn legacy_context_and_pending_records_fail_closed() { fs::remove_dir_all(root).ok(); } +/// 澄清转述那一轮写出的 bundle 必须能被父 run 唤醒时读回来。 +/// +/// 用户答完澄清后整条 run 改跑在转述任务上,那一轮的 bundle 因此带着转述文本,而 run 的 +/// 持久 task 始终是用户原始请求。validate_agent_runtime_pending_context 早就为 pending 入口 +/// 豁免过同一条文本相等断言,bundle 入口漏了——D11 走过一轮澄清后,策划子 Agent 完成、父 +/// run 认领委派回执时必定在这里失败。豁免只放开转述这一种 task,别的文本照旧 fail closed。 +#[test] +fn agent_runtime_context_bundle_accepts_only_the_clarification_relay_task_divergence() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "澄清转述上下文项目").expect("project init"); + let task = "贪吃蛇"; + let state = start_game_creator_agent_runtime_task_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + task, + "plan-clarification-relay-run", + AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE, + "等待澄清回答", + vec!["回答澄清后续跑".to_string()], + ) + .expect("start clarification runtime state"); + let context_tracker = AgentRuntimeContextWindowTracker::default(); + let relay_task = format!( + "{AGENT_RUNTIME_DELEGATE_CLARIFICATION_TASK_PREFIX}delegation-96db734b07f794a576ac5c99;questionCount=1 · questionsSha256=ef5b1201。请回答以下问题。" + ); + let relay_bundle = build_game_creator_agent_runtime_context_bundle( + &root, + &state, + &relay_task, + &AgentRuntimeToolPlan::default(), + &[], + 0, + &context_tracker, + ) + .expect("build clarification relay context bundle"); + write_game_creator_agent_runtime_context_bundle(&root, &relay_bundle) + .expect("write clarification relay bundle"); + let restored = read_game_creator_agent_runtime_context_bundle(&root, &state) + .expect("clarification relay bundle must restore") + .expect("clarification relay bundle present"); + assert_eq!(restored.task, relay_task); + + let mut unrelated_task = relay_bundle; + unrelated_task.task = "另一个不相干的任务".to_string(); + write_game_creator_agent_runtime_context_bundle(&root, &unrelated_task) + .expect("write unrelated-task bundle"); + let error = read_game_creator_agent_runtime_context_bundle(&root, &state) + .expect_err("非转述任务的文本分叉必须照旧 fail closed"); + assert!(error.contains("身份与当前任务不匹配"), "{error}"); + + fs::remove_dir_all(root).ok(); +} + #[test] fn agent_runtime_context_bundle_rejects_task_source_and_loop_identity_mismatches() { let root = unique_project_path(); @@ -4787,7 +4913,8 @@ fn structured_plan_same_run_steer_preserves_monotonic_runtime_and_v3_context() { ], }; assert!(apply_agent_runtime_plan_update(&mut state, &initial_update) - .expect("apply pre-steer structured plan")); + .expect("apply pre-steer structured plan") + .advanced_steps()); let pre_steer_revision = state.plan_revision; assert_eq!(pre_steer_revision, 1); write_game_creator_agent_runtime_state(&root, &state) @@ -4906,7 +5033,8 @@ fn structured_plan_same_run_steer_preserves_monotonic_runtime_and_v3_context() { ], }; assert!(apply_agent_runtime_plan_update(&mut state, &steered_update) - .expect("apply post-steer structured plan")); + .expect("apply post-steer structured plan") + .advanced_steps()); assert!(state.plan_revision > rejected_revision); assert!(state .plan_steps diff --git a/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/identity_order_validation.rs b/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/identity_order_validation.rs index 806ac6e8d..2bcfc549d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/identity_order_validation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/identity_order_validation.rs @@ -76,6 +76,10 @@ pub(super) fn validate_next_entry( return Err("tool-plan 成功响应交接 entry 的 durable run 身份冲突".to_string()); } if candidate.loop_iteration == previous.loop_iteration { + // repair 链判据包含 steer cursor、goal revision/快照与 planning session + // binding 这些**本轮量**,只有同 loop 的 repair 之间才必须逐位相等。跨 loop + // 续跑时它们本来就会前进,把这条判据提到 loop 分支之外会把正常续跑判成交接 + // 失败并打进 needs-reconciliation;跨 loop 的身份由 same_durable_tool_plan_run 守。 if !same_tool_plan_repair_chain(&previous.identity, &candidate.identity) { return Err("tool-plan 成功响应交接同 loop repair 链身份冲突".to_string()); } @@ -125,6 +129,36 @@ pub(super) fn same_tool_plan_repair_chain( current: &AgentRuntimeProviderRetryIdentity, candidate: &AgentRuntimeProviderRetryIdentity, ) -> bool { + let planning_binding_chain_matches = match ( + current.planning_session_binding.as_ref(), + candidate.planning_session_binding.as_ref(), + ) { + (None, None) => true, + (Some(left), Some(right)) => { + left.schema_version == right.schema_version + && left.project_id == right.project_id + && left.gdd_id == right.gdd_id + && left.agent_id == right.agent_id + && left.task_id == right.task_id + && left.session_id == right.session_id + && left.run_id == right.run_id + && left.root_agent_id == right.root_agent_id + && left.root_run_id == right.root_run_id + && left.delegation_id == right.delegation_id + && left.goal_id == right.goal_id + && left.goal_revision == right.goal_revision + && left.goal_snapshot_fingerprint == right.goal_snapshot_fingerprint + && left.source == right.source + && left.run_profile == right.run_profile + && left.run_profile_binding_fingerprint == right.run_profile_binding_fingerprint + && left.session_revision == right.session_revision + && left.session_fingerprint == right.session_fingerprint + && left.applied_steer_cursor == right.applied_steer_cursor + && left.request_kind == right.request_kind + && left.web_search_enabled == right.web_search_enabled + } + _ => false, + }; current.project_id == candidate.project_id && current.agent_id == candidate.agent_id && current.task_id == candidate.task_id @@ -138,6 +172,7 @@ pub(super) fn same_tool_plan_repair_chain( && current.request_kind == candidate.request_kind && current.provider_config_fingerprint == candidate.provider_config_fingerprint && current.allow_idle_context_compaction == candidate.allow_idle_context_compaction + && planning_binding_chain_matches } pub(super) fn request_slot_for_attempt( diff --git a/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/tests.rs index a8eb1ab98..5ea9ba07e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/tests.rs @@ -76,6 +76,7 @@ fn identity_for(slot: &str, agent_id: &str, run_id: &str) -> AgentRuntimeProvide provider_config_fingerprint: "b".repeat(64), web_search_enabled: repair_attempt == 0, allow_idle_context_compaction: false, + planning_session_binding: None, } } @@ -433,6 +434,26 @@ fn tool_plan_handoff_reports_identity_and_payload_conflicts() { assert!(error.contains("同一 slot 内容冲突")); } +#[test] +fn tool_plan_handoff_accepts_new_loop_after_steer_and_goal_revision_advance() { + let project = tempdir().expect("tool-plan handoff project"); + let base = identity("loop-0-repair-0"); + write(project.path(), &base, 0, &response("base", Vec::new())); + + // 进入新 loop 本身就意味着 steer cursor / goal revision 已经前进,跨 loop 不能套用 + // 同 loop 的 repair 链判据,否则正常续跑会被判成交接失败。 + let mut next_loop = identity("loop-1-repair-0"); + next_loop.applied_steer_cursor += 1; + next_loop.goal_revision += 1; + next_loop.goal_snapshot_fingerprint = "c".repeat(64); + write( + project.path(), + &next_loop, + 0, + &response("next loop", Vec::new()), + ); +} + #[test] fn tool_plan_handoff_rejects_out_of_order_entries() { let project = tempdir().expect("tool-plan handoff project"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/user_input.rs b/apps/ai-game-creator-shell/src-tauri/src/user_input.rs index 9e94618a5..017142cfb 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/user_input.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/user_input.rs @@ -2,6 +2,32 @@ use super::*; use sha2::{Digest, Sha256}; pub(crate) const GAME_CREATOR_USER_INPUT_REQUEST_TOOL: &str = "user.input_request"; +/// Runtime 代 Supervisor 汇总子 Agent 澄清问题时,pending 动作的 `task` 字段存的 +/// 不是本 run 的任务,而是这段由 Runtime 生成的转述指令,delegationId 编码在前缀 +/// 之后。系统里另外五处都要按这个前缀反解,此前各自抄了一份 `strip_prefix` 链; +/// 抄本之间一旦漂移,转述路径会静默失去与原 delivery 的绑定,所以收成一处。 +pub(crate) const AGENT_RUNTIME_DELEGATE_CLARIFICATION_TASK_PREFIX: &str = + "子 Agent 需要用户澄清后才能继续。delegationId="; + +/// 从转述 pending 的 `task` 里取回它绑定的原 delegationId;不是转述 task 返回 None。 +pub(crate) fn agent_runtime_delegate_clarification_delegation_id(task: &str) -> Option<&str> { + task.strip_prefix(AGENT_RUNTIME_DELEGATE_CLARIFICATION_TASK_PREFIX) + .and_then(|value| value.split(';').next()) + .map(str::trim) + .filter(|value| !value.is_empty()) +} + +/// 该任务是否是 Runtime 代 Supervisor 生成的子 Agent 澄清转述任务。 +/// +/// 判据只看 task,不看工具:用户答完后 +/// `run_recovered_game_creator_context_on_fresh_task` 会把 `pending.task` 当作同一 +/// run 后续每一轮的任务,所以转述任务会一路传播到 `agent.run_status`、 +/// `agent.delegate` 等动作上,而不是只停在那一次 `user.input_request`。按工具收窄 +/// 会让续跑第一步就被判身份变化——run 15 实测如此。 +pub(crate) fn agent_runtime_task_is_delegate_clarification_relay(task: &str) -> bool { + agent_runtime_delegate_clarification_delegation_id(task).is_some() +} + pub(crate) const AGENT_RUNTIME_USER_INPUT_SCHEMA_VERSION: &str = "game-creator-runtime-user-input.v1"; pub(crate) const AGENT_RUNTIME_USER_INPUT_STATUS_PENDING: &str = "pending"; @@ -11,14 +37,49 @@ pub(crate) const AGENT_RUNTIME_USER_INPUT_STATUS_CANCELLED: &str = "cancelled"; const AGENT_RUNTIME_USER_INPUT_SIDECAR_MAX_BYTES: usize = 128 * 1024; const AGENT_RUNTIME_USER_INPUT_MAX_QUESTIONS: usize = 3; +pub(crate) const AGENT_RUNTIME_USER_INPUT_MIN_OPTIONS: usize = 2; +pub(crate) const AGENT_RUNTIME_USER_INPUT_MAX_OPTIONS: usize = 3; const AGENT_RUNTIME_USER_INPUT_MAX_ID_CHARS: usize = 64; -const AGENT_RUNTIME_USER_INPUT_MAX_HEADER_CHARS: usize = 12; -const AGENT_RUNTIME_USER_INPUT_MAX_QUESTION_CHARS: usize = 400; -const AGENT_RUNTIME_USER_INPUT_MAX_OPTION_LABEL_CHARS: usize = 60; -const AGENT_RUNTIME_USER_INPUT_MAX_OPTION_DESCRIPTION_CHARS: usize = 240; +pub(crate) const AGENT_RUNTIME_USER_INPUT_MAX_HEADER_CHARS: usize = 12; +pub(crate) const AGENT_RUNTIME_USER_INPUT_MAX_QUESTION_CHARS: usize = 400; +pub(crate) const AGENT_RUNTIME_USER_INPUT_MAX_OPTION_LABEL_CHARS: usize = 60; +pub(crate) const AGENT_RUNTIME_USER_INPUT_MAX_OPTION_DESCRIPTION_CHARS: usize = 240; + +/// 一份 schema 合法的澄清问询在线上最多可能有多长(字符)。 +/// +/// 存在的意义是给中转通道一个由 schema 推导的上限,而不是让它自己拍一个数。 +/// 通道比 schema 窄的后果不是「模型写短一点」——子 Agent 提了一个完全合法的 +/// 问题,Runtime 会在父 run 认领回执时拒收,整条委派链就此阻断。真正的逐字段 +/// 复核仍在 `parse_game_creator_agent_user_input_questions`,这个上限只是粗筛。 +/// +/// JSON 语法开销按每个标量字段一对引号加冒号逗号、每层括号若干字符宽估。 +pub(crate) const AGENT_RUNTIME_USER_INPUT_MAX_WIRE_CHARS: usize = { + const OPTION_SYNTAX_CHARS: usize = 40; + const QUESTION_SYNTAX_CHARS: usize = 64; + const ENVELOPE_SYNTAX_CHARS: usize = 64; + let per_option = AGENT_RUNTIME_USER_INPUT_MAX_OPTION_LABEL_CHARS + + AGENT_RUNTIME_USER_INPUT_MAX_OPTION_DESCRIPTION_CHARS + + OPTION_SYNTAX_CHARS; + let per_question = AGENT_RUNTIME_USER_INPUT_MAX_ID_CHARS + + AGENT_RUNTIME_USER_INPUT_MAX_HEADER_CHARS + + AGENT_RUNTIME_USER_INPUT_MAX_QUESTION_CHARS + + AGENT_RUNTIME_USER_INPUT_MAX_OPTIONS * per_option + + QUESTION_SYNTAX_CHARS; + AGENT_RUNTIME_USER_INPUT_MAX_QUESTIONS * per_question + ENVELOPE_SYNTAX_CHARS +}; const AGENT_RUNTIME_USER_INPUT_MAX_ANSWER_CHARS: usize = 4_000; const AGENT_RUNTIME_USER_INPUT_MAX_TOTAL_ANSWER_CHARS: usize = 8_000; const AGENT_RUNTIME_USER_INPUT_MAX_RESPONSE_ID_CHARS: usize = 160; +#[cfg(test)] +pub(crate) const AGENT_RUNTIME_USER_INPUT_STOP_AFTER_PREPARED_FOR_TEST: &str = + ".agent/runtime/test-stop-user-input-after-answer-prepared"; + +fn planning_answer_requires_precheck(status: &str) -> bool { + matches!( + status, + AGENT_RUNTIME_USER_INPUT_STATUS_PENDING | AGENT_RUNTIME_USER_INPUT_STATUS_ANSWER_PREPARED + ) +} #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(deny_unknown_fields, rename_all = "camelCase")] @@ -60,6 +121,16 @@ pub(crate) struct AgentRuntimeUserInputRequestView { pub(crate) updated_at: u64, } +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct PlanStaticDelegateAnsweredInput { + pub(crate) request_id: String, + pub(crate) response_id: String, + pub(crate) question: AgentRuntimeUserInputQuestion, + pub(crate) answer: String, + pub(crate) questions_sha256: String, + pub(crate) answers_sha256: String, +} + #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(deny_unknown_fields, rename_all = "camelCase")] struct AgentRuntimeUserInputRecord { @@ -254,9 +325,11 @@ fn normalize_user_input_questions( question_index + 1 ), )?; - if question.options.len() < 2 || question.options.len() > 3 { + if question.options.len() < AGENT_RUNTIME_USER_INPUT_MIN_OPTIONS + || question.options.len() > AGENT_RUNTIME_USER_INPUT_MAX_OPTIONS + { return Err(format!( - "user.input_request question #{} 必须提供 2-3 个选项", + "user.input_request question #{} 必须提供 {AGENT_RUNTIME_USER_INPUT_MIN_OPTIONS}-{AGENT_RUNTIME_USER_INPUT_MAX_OPTIONS} 个选项", question_index + 1 )); } @@ -623,37 +696,35 @@ fn build_user_input_observation( }) } -fn validate_user_input_record( +fn validate_user_input_record_payload( root: &Path, - pending: &AgentRuntimePendingToolAction, record: &AgentRuntimeUserInputRecord, ) -> Result<(), String> { - let expected = build_new_user_input_record(root, pending)?; + let normalized_questions = normalize_user_input_questions(record.questions.clone())?; + let questions_sha256 = user_input_sha256_json(&normalized_questions)?; + let (question_count, option_count, question_chars) = + user_input_question_counts(&normalized_questions); if record.schema_version != AGENT_RUNTIME_USER_INPUT_SCHEMA_VERSION - || record.project_id != expected.project_id - || record.agent_id != expected.agent_id - || record.task_id != expected.task_id - || record.session_id != expected.session_id - || record.run_id != expected.run_id - || record.source != expected.source - || record.action_id != expected.action_id - || record.action_fingerprint != expected.action_fingerprint - || record.goal_id != expected.goal_id - || record.goal_revision != expected.goal_revision - || record.goal_snapshot_fingerprint != expected.goal_snapshot_fingerprint - || record.planned_steer_cursor != expected.planned_steer_cursor - || record.request_id != expected.request_id - || record.questions != expected.questions - || record.questions_sha256 != expected.questions_sha256 - || record.question_count != expected.question_count - || record.option_count != expected.option_count - || record.question_chars != expected.question_chars - || record.question_message_id != expected.question_message_id + || record.project_id != game_creator_agent_runtime_context_project_id(root)? + || record.questions != normalized_questions + || record.questions_sha256 != questions_sha256 + || record.question_count != question_count + || record.option_count != option_count + || record.question_chars != question_chars + || record.question_message_id != user_input_question_message_id(&record.request_id) + || record.agent_id.trim().is_empty() + || record.task_id.trim().is_empty() + || record.session_id.trim().is_empty() + || record.run_id.trim().is_empty() + || record.source.trim().is_empty() + || record.action_id.trim().is_empty() + || !valid_user_input_sha256(&record.action_fingerprint) + || record.request_id.trim().is_empty() || record.created_at == 0 || record.updated_at == 0 || !valid_user_input_sha256(&record.questions_sha256) { - return Err("用户输入请求 sidecar 身份或问题正文冲突".to_string()); + return Err("用户输入请求 sidecar payload 身份或问题正文冲突".to_string()); } if !matches!( record.status.as_str(), @@ -728,6 +799,37 @@ fn validate_user_input_record( Ok(()) } +fn validate_user_input_record( + root: &Path, + pending: &AgentRuntimePendingToolAction, + record: &AgentRuntimeUserInputRecord, +) -> Result<(), String> { + let expected = build_new_user_input_record(root, pending)?; + if record.project_id != expected.project_id + || record.agent_id != expected.agent_id + || record.task_id != expected.task_id + || record.session_id != expected.session_id + || record.run_id != expected.run_id + || record.source != expected.source + || record.action_id != expected.action_id + || record.action_fingerprint != expected.action_fingerprint + || record.goal_id != expected.goal_id + || record.goal_revision != expected.goal_revision + || record.goal_snapshot_fingerprint != expected.goal_snapshot_fingerprint + || record.planned_steer_cursor != expected.planned_steer_cursor + || record.request_id != expected.request_id + || record.questions != expected.questions + || record.questions_sha256 != expected.questions_sha256 + || record.question_count != expected.question_count + || record.option_count != expected.option_count + || record.question_chars != expected.question_chars + || record.question_message_id != expected.question_message_id + { + return Err("用户输入请求 sidecar 身份或问题正文冲突".to_string()); + } + validate_user_input_record_payload(root, record) +} + fn read_user_input_record( root: &Path, pending: &AgentRuntimePendingToolAction, @@ -746,6 +848,127 @@ fn read_user_input_record( Ok(record) } +fn validate_answered_plan_static_delegate_user_input_identity( + delivery: &StaticDelegateDeliveryRecord, + root_task: &AgentRuntimeTaskRecord, + record: &AgentRuntimeUserInputRecord, + request_id: &str, + expected_questions_sha256: &str, + expected_answers_sha256: &str, +) -> Result<(), String> { + // The task record, rather than the static Supervisor agent ID, is the + // durable source of `taskId`: task IDs are not a substitute identity for + // agent IDs. Keep every other sidecar field anchored to this same root + // task so a valid answer cannot be transplanted across root runs/sessions. + if root_task.agent_id != delivery.parent_agent_id + || root_task.task_id.trim().is_empty() + || root_task.session_id != delivery.parent_session_id + || root_task.run_id != delivery.parent_run_id + || root_task.source != AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE + || root_task.parent_agent_id.is_some() + || root_task.parent_run_id.is_some() + { + return Err("planning user-input sidecar 的 plan root task 身份冲突".to_string()); + } + if record.agent_id != root_task.agent_id + || record.task_id != root_task.task_id + || record.session_id != root_task.session_id + || record.run_id != root_task.run_id + || record.source != root_task.source + || record.request_id != request_id + || record.status != AGENT_RUNTIME_USER_INPUT_STATUS_ANSWERED + || record.questions_sha256 != expected_questions_sha256 + || record.answers_sha256.as_deref() != Some(expected_answers_sha256) + || record.questions.len() != 1 + || record.answers.len() != 1 + { + return Err("planning user-input sidecar 与 delivery 回答绑定冲突".to_string()); + } + Ok(()) +} + +/// Read the answered Supervisor sidecar that is already bound to one exact +/// planning delivery. Only the fields needed for the derived plan-session +/// projection escape this module; the complete private answer record remains +/// encapsulated here. +pub(crate) fn read_answered_plan_static_delegate_user_input_at( + root: &Path, + delivery: &StaticDelegateDeliveryRecord, +) -> Result { + if delivery.parent_agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID + || delivery.target_agent_id != GAME_CREATOR_PROJECT_PLANNING_AGENT_ID + || delivery.status != StaticDelegateDeliveryStatus::ClaimedByParent + || delivery.structured_result.as_ref().is_none_or(|result| { + result.contract_status != StaticDelegateContractStatus::NeedsUserInput + }) + { + return Err("planning answer 只能从已认领的 NeedsUserInput delivery 读取".to_string()); + } + validate_project_supervisor_plan_root_binding_for_crate_at( + root, + &delivery.parent_agent_id, + &delivery.parent_run_id, + )?; + let request_id = delivery + .clarification_request_id + .as_deref() + .ok_or_else(|| "planning delivery 尚未绑定 user-input requestId".to_string())?; + let expected_answers_sha256 = delivery + .clarification_answers_sha256 + .as_deref() + .ok_or_else(|| "planning delivery 尚未绑定 answersSha256".to_string())?; + let relative_path = user_input_relative_path( + &delivery.parent_agent_id, + &delivery.parent_run_id, + request_id, + ); + let record = read_agent_runtime_json_sidecar_with_max_bytes::( + root, + &relative_path, + "Fast GDD 澄清回答", + AGENT_RUNTIME_USER_INPUT_SIDECAR_MAX_BYTES, + )? + .ok_or_else(|| "planning delivery 已绑定答案但 user-input sidecar 缺失".to_string())?; + validate_user_input_record_payload(root, &record)?; + let expected_questions_sha256 = delivery + .structured_result + .as_ref() + .and_then(|result| result.user_input_questions_sha256.as_deref()) + .ok_or_else(|| "planning delivery 缺少 questionsSha256".to_string())?; + let root_task = read_latest_game_creator_agent_runtime_task_by_run_id( + root, + &delivery.parent_agent_id, + &delivery.parent_run_id, + )? + .ok_or_else(|| "planning user-input sidecar 缺少所属 plan root task".to_string())?; + validate_answered_plan_static_delegate_user_input_identity( + delivery, + &root_task, + &record, + request_id, + expected_questions_sha256, + expected_answers_sha256, + )?; + let question = record.questions[0].clone(); + let answer = record + .answers + .get(&question.id) + .cloned() + .ok_or_else(|| "planning user-input sidecar 缺少唯一答案".to_string())?; + Ok(PlanStaticDelegateAnsweredInput { + request_id: record.request_id, + response_id: record + .response_id + .ok_or_else(|| "planning user-input sidecar 缺少 responseId".to_string())?, + question, + answer, + questions_sha256: record.questions_sha256, + answers_sha256: record + .answers_sha256 + .ok_or_else(|| "planning user-input sidecar 缺少 answersSha256".to_string())?, + }) +} + fn write_user_input_record( root: &Path, pending: &AgentRuntimePendingToolAction, @@ -763,11 +986,27 @@ fn write_user_input_record( ) } -fn finish_prepared_user_input_answer( +fn finish_prepared_user_input_answer_with_project_lock( root: &Path, pending: &AgentRuntimePendingToolAction, mut record: AgentRuntimeUserInputRecord, + project_lock: Option<&ProjectWriteLock>, ) -> Result { + match project_lock { + Some(project_lock) => validate_plan_clarification_answer_for_pending_at_locked( + root, + pending, + &record.questions, + &record.answers, + project_lock, + )?, + None => validate_plan_clarification_answer_for_pending_at( + root, + pending, + &record.questions, + &record.answers, + )?, + } append_user_input_answer_message(root, &record)?; let observation = build_user_input_observation(&record)?; let now = unix_timestamp(); @@ -785,12 +1024,7 @@ fn bind_user_input_record_to_static_delegate_at( pending: &AgentRuntimePendingToolAction, record: &AgentRuntimeUserInputRecord, ) -> Result<(), String> { - let Some(delegation_id) = pending - .task - .strip_prefix("子 Agent 需要用户澄清后才能继续。delegationId=") - .and_then(|value| value.split(';').next()) - .map(str::trim) - .filter(|value| !value.is_empty()) + let Some(delegation_id) = agent_runtime_delegate_clarification_delegation_id(&pending.task) else { return Ok(()); }; @@ -811,6 +1045,29 @@ fn bind_user_input_record_to_static_delegate_at( pub(crate) fn prepare_game_creator_agent_user_input_request_at( root: &Path, pending: &AgentRuntimePendingToolAction, +) -> Result { + prepare_game_creator_agent_user_input_request_with_project_lock_at(root, pending, None) +} + +pub(crate) fn prepare_game_creator_agent_user_input_request_at_locked( + root: &Path, + pending: &AgentRuntimePendingToolAction, + project_lock: &ProjectWriteLock, +) -> Result { + if !project_lock.guards_project_root(root)? { + return Err("恢复 planning 用户输入缺少当前项目写锁".to_string()); + } + prepare_game_creator_agent_user_input_request_with_project_lock_at( + root, + pending, + Some(project_lock), + ) +} + +fn prepare_game_creator_agent_user_input_request_with_project_lock_at( + root: &Path, + pending: &AgentRuntimePendingToolAction, + project_lock: Option<&ProjectWriteLock>, ) -> Result { validate_user_input_action_owner(root, pending)?; let mut record = match read_user_input_record(root, pending)? { @@ -823,7 +1080,12 @@ pub(crate) fn prepare_game_creator_agent_user_input_request_at( }; append_user_input_question_message(root, &record)?; if record.status == AGENT_RUNTIME_USER_INPUT_STATUS_ANSWER_PREPARED { - record = finish_prepared_user_input_answer(root, pending, record)?; + record = finish_prepared_user_input_answer_with_project_lock( + root, + pending, + record, + project_lock, + )?; } match record.status.as_str() { AGENT_RUNTIME_USER_INPUT_STATUS_PENDING => Ok(AgentRuntimeUserInputRecovery::Waiting( @@ -875,6 +1137,57 @@ pub(crate) fn answer_game_creator_agent_user_input_request_for_pending_at( AgentRuntimeToolObservation, ), String, +> { + answer_game_creator_agent_user_input_request_for_pending_with_project_lock_at( + root, + pending, + request_id, + response_id, + answers, + None, + ) +} + +pub(crate) fn answer_game_creator_agent_user_input_request_for_pending_at_locked( + root: &Path, + pending: &AgentRuntimePendingToolAction, + request_id: &str, + response_id: &str, + answers: BTreeMap, + project_lock: &ProjectWriteLock, +) -> Result< + ( + AgentRuntimeUserInputRequestView, + AgentRuntimeToolObservation, + ), + String, +> { + if !project_lock.guards_project_root(root)? { + return Err("提交 planning 用户回答缺少当前项目写锁".to_string()); + } + answer_game_creator_agent_user_input_request_for_pending_with_project_lock_at( + root, + pending, + request_id, + response_id, + answers, + Some(project_lock), + ) +} + +fn answer_game_creator_agent_user_input_request_for_pending_with_project_lock_at( + root: &Path, + pending: &AgentRuntimePendingToolAction, + request_id: &str, + response_id: &str, + answers: BTreeMap, + project_lock: Option<&ProjectWriteLock>, +) -> Result< + ( + AgentRuntimeUserInputRequestView, + AgentRuntimeToolObservation, + ), + String, > { validate_user_input_action_owner(root, pending)?; let request_id = request_id.trim(); @@ -886,6 +1199,23 @@ pub(crate) fn answer_game_creator_agent_user_input_request_for_pending_at( } let (answers, answer_chars) = normalize_user_input_answers(&record.questions, answers)?; let answers_sha256 = user_input_sha256_json(&answers)?; + if planning_answer_requires_precheck(&record.status) { + match project_lock { + Some(project_lock) => validate_plan_clarification_answer_for_pending_at_locked( + root, + pending, + &record.questions, + &answers, + project_lock, + )?, + None => validate_plan_clarification_answer_for_pending_at( + root, + pending, + &record.questions, + &answers, + )?, + } + } match record.status.as_str() { AGENT_RUNTIME_USER_INPUT_STATUS_PENDING => { let now = unix_timestamp(); @@ -902,6 +1232,14 @@ pub(crate) fn answer_game_creator_agent_user_input_request_for_pending_at( record.answer_prepared_at = Some(now); record.updated_at = now; write_user_input_record(root, pending, &record)?; + #[cfg(test)] + if std::fs::remove_file( + root.join(AGENT_RUNTIME_USER_INPUT_STOP_AFTER_PREPARED_FOR_TEST), + ) + .is_ok() + { + return Err("测试注入:用户回答停在 answer-prepared".to_string()); + } } AGENT_RUNTIME_USER_INPUT_STATUS_ANSWER_PREPARED | AGENT_RUNTIME_USER_INPUT_STATUS_ANSWERED => { @@ -918,7 +1256,12 @@ pub(crate) fn answer_game_creator_agent_user_input_request_for_pending_at( _ => return Err("用户输入请求状态无效".to_string()), } if record.status == AGENT_RUNTIME_USER_INPUT_STATUS_ANSWER_PREPARED { - record = finish_prepared_user_input_answer(root, pending, record)?; + record = finish_prepared_user_input_answer_with_project_lock( + root, + pending, + record, + project_lock, + )?; } append_user_input_question_message(root, &record)?; append_user_input_answer_message(root, &record)?; @@ -1085,4 +1428,167 @@ mod tests { assert!(!metadata.contains("response-private")); assert!(metadata.contains("answerChars=12")); } + + #[test] + fn planning_answer_precheck_stops_after_answered_commit() { + assert!(planning_answer_requires_precheck( + AGENT_RUNTIME_USER_INPUT_STATUS_PENDING + )); + assert!(planning_answer_requires_precheck( + AGENT_RUNTIME_USER_INPUT_STATUS_ANSWER_PREPARED + )); + assert!(!planning_answer_requires_precheck( + AGENT_RUNTIME_USER_INPUT_STATUS_ANSWERED + )); + assert!(!planning_answer_requires_precheck( + AGENT_RUNTIME_USER_INPUT_STATUS_CANCELLED + )); + } + + #[test] + fn answered_plan_sidecar_requires_root_task_id_not_supervisor_agent_id() { + // Keep this at the pure identity seam: the production reader has + // already validated the sidecar payload, project identity and durable + // Run Profile binding before it reaches this check. + let mut root_task = AgentRuntimeTaskRecord { + schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(), + agent_id: GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(), + task_id: "plan-root-task-id".to_string(), + session_id: "plan-root-session".to_string(), + run_id: "plan-root-run".to_string(), + source: AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE.to_string(), + run_profile: AGENT_RUNTIME_RUN_PROFILE_STANDARD.to_string(), + run_profile_binding_fingerprint: "binding".to_string(), + parent_agent_id: None, + parent_run_id: None, + delegation_id: None, + goal_id: None, + goal_revision: 0, + goal_status: None, + task: "收敛 Fast GDD".to_string(), + status: "running".to_string(), + phase: "waiting-for-user-input".to_string(), + current_action: "等待用户回答".to_string(), + terminal_detail: None, + error: None, + updated_at: 1, + }; + let question = AgentRuntimeUserInputQuestion { + id: "route_choice".to_string(), + header: "第1轮·关键决定".to_string(), + question: "当前要决定:首版路线。".to_string(), + options: vec![ + AgentRuntimeUserInputOption { + label: "接受推荐".to_string(), + description: "采用推荐。".to_string(), + }, + AgentRuntimeUserInputOption { + label: "暂按推荐".to_string(), + description: "暂按推荐。".to_string(), + }, + AgentRuntimeUserInputOption { + label: "需要原型验证".to_string(), + description: "先验证。".to_string(), + }, + ], + }; + let questions = vec![question.clone()]; + let answers = BTreeMap::from([("route_choice".to_string(), "接受推荐".to_string())]); + let questions_sha256 = user_input_sha256_json(&questions).expect("questions fingerprint"); + let answers_sha256 = user_input_sha256_json(&answers).expect("answers fingerprint"); + let request_id = "user-input-plan-root-task-id"; + let response_id = "plan-root-answer"; + let record = AgentRuntimeUserInputRecord { + schema_version: AGENT_RUNTIME_USER_INPUT_SCHEMA_VERSION.to_string(), + project_id: "project-id".to_string(), + agent_id: root_task.agent_id.clone(), + task_id: root_task.task_id.clone(), + session_id: root_task.session_id.clone(), + run_id: root_task.run_id.clone(), + source: root_task.source.clone(), + action_id: "action-id".to_string(), + action_fingerprint: "a".repeat(64), + goal_id: None, + goal_revision: 0, + goal_snapshot_fingerprint: String::new(), + planned_steer_cursor: 0, + request_id: request_id.to_string(), + questions, + questions_sha256: questions_sha256.clone(), + question_count: 1, + option_count: 3, + question_chars: user_input_question_counts(&[question.clone()]).2, + question_message_id: user_input_question_message_id(request_id), + status: AGENT_RUNTIME_USER_INPUT_STATUS_ANSWERED.to_string(), + response_id: Some(response_id.to_string()), + answers, + answers_sha256: Some(answers_sha256.clone()), + answer_count: 1, + answer_chars: "接受推荐".chars().count() as u32, + answer_message_id: Some(user_input_answer_message_id(request_id, response_id)), + observation: None, + created_at: 1, + answer_prepared_at: Some(1), + answered_at: Some(1), + cancelled_at: None, + updated_at: 1, + }; + let delivery = StaticDelegateDeliveryRecord { + schema_version: "game-creator-static-delegate-delivery.v1".to_string(), + parent_agent_id: root_task.agent_id.clone(), + parent_session_id: root_task.session_id.clone(), + parent_run_id: root_task.run_id.clone(), + parent_action_id: "delegate-action".to_string(), + delegation_id: "delegation-id".to_string(), + target_agent_id: GAME_CREATOR_PROJECT_PLANNING_AGENT_ID.to_string(), + target_session_id: "planning-session".to_string(), + target_run_id: "planning-run".to_string(), + acceptance_criteria: Vec::new(), + expected_artifacts: Vec::new(), + repair_of_delegation_id: None, + clarification_request_id: Some(request_id.to_string()), + clarification_answers_sha256: Some(answers_sha256.clone()), + status: StaticDelegateDeliveryStatus::ClaimedByParent, + terminal_status: Some("completed".to_string()), + result_summary: Some("需要用户澄清".to_string()), + structured_result: None, + claimed_by_action_id: Some("claim-action".to_string()), + updated_at: 1, + }; + + validate_answered_plan_static_delegate_user_input_identity( + &delivery, + &root_task, + &record, + request_id, + &questions_sha256, + &answers_sha256, + ) + .expect("actual plan root taskId is accepted"); + + let mut wrong_task_id = record.clone(); + wrong_task_id.task_id = GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(); + assert!(validate_answered_plan_static_delegate_user_input_identity( + &delivery, + &root_task, + &wrong_task_id, + request_id, + &questions_sha256, + &answers_sha256, + ) + .expect_err("agentId must not be accepted as taskId") + .contains("delivery 回答绑定冲突")); + + root_task.parent_run_id = Some("unexpected-parent".to_string()); + assert!(validate_answered_plan_static_delegate_user_input_identity( + &delivery, + &root_task, + &record, + request_id, + &questions_sha256, + &answers_sha256, + ) + .expect_err("plan root task must not have a parent") + .contains("plan root task 身份冲突")); + } } diff --git a/apps/ai-game-creator-shell/src-tauri/tests/runtime_prompt_bundle_build.rs b/apps/ai-game-creator-shell/src-tauri/tests/runtime_prompt_bundle_build.rs index aa97ae22a..540193af2 100644 --- a/apps/ai-game-creator-shell/src-tauri/tests/runtime_prompt_bundle_build.rs +++ b/apps/ai-game-creator-shell/src-tauri/tests/runtime_prompt_bundle_build.rs @@ -28,6 +28,9 @@ const SECTION_FILES: &[(&str, &str)] = &[ ("supervisorPlaybook", "supervisor/playbook.md"), ("supervisorClaimGate", "supervisor/claim-gate.md"), ("supervisorRepair", "supervisor/repair.md"), + ("planCommon", "plan/common.md"), + ("planSupervisorIdentity", "plan/supervisor-identity.md"), + ("planSupervisorPlaybook", "plan/supervisor-playbook.md"), ("codePrototypeGameChat", "roles/code-prototype-game-chat.md"), ( "providerIsolatedToolContract", @@ -141,6 +144,14 @@ fn valid_manifest() -> Value { "supervisorClaimGate", "supervisorRepair" ], + "supervisorPlan": [ + "$header", + "planCommon", + "isolatedAgentContract", + "planSupervisorIdentity", + "planSupervisorPlaybook", + "supervisorRepair" + ], "supervisorChat": { "identity": "supervisorIdentityContract", "finalReply": "supervisorFinalReplyContract" @@ -189,6 +200,21 @@ fn valid_manifest() -> Value { } ] }, + "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": "code", diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index ddf21796e..9cf7ad4c0 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -32,7 +32,9 @@ import { AGENT_RUN_HISTORY_VISIBLE_STEP, CONVERSATION_INITIAL_VISIBLE_COUNT, CONVERSATION_VISIBLE_STEP, + createLocalProjectId, PROJECT_SUPERVISOR_AGENT_ID, + PROJECT_SUPERVISOR_PLAN_SOURCE, seedManifest, } from './app/constants'; import { useEscapeToClose } from './app/dialogs'; @@ -84,6 +86,8 @@ import type { OpenCanvasProjectResult, PendingCommand, PendingUiConfirmation, + PlanGddDecisionAction, + PlanGddStateViewV1, ProjectPermissionPolicy, ProjectPermissionPolicyView, SyncCanvasProjectAssetsResult, @@ -99,21 +103,30 @@ import { agentRuntimeStartStatus, agentRuntimeStateFromResult, agentRuntimeSteerStatus, + canArchiveGameChatStage, conversationContainsProjectSupervisorResponseStream, createAgentChatRunId, createDefaultChatMessages, createLocalConversationDraftMessage, ensureProjectSupervisorActiveSessionId, + type GameChatPlayableRevision, + gameChatRuntimeBelongsToLineage, + gameChatRuntimeIdentity, isAgentFinalizationMessageId, isAgentRuntimeTerminalState, + isGameChatManifestPrimaryTaskTerminal, + isGameChatRuntimeTerminalState, + isGameChatSupervisorRoot, isMissingAgentRuntimeResumeCommandError, isRuntimeConfigMissingError, + latestGameChatPlayableRevision, matchingAgentRuntimeForSteer, mergeAgentRuntimeStateIntoMap, mergeGameChatRuntimeResponseMessagesIntoHistory, mergeProjectSupervisorConversation, mergeProjectSupervisorResponseStream, normalizeAgentRuntimeState, + projectCurrentGameChatRuntimeLineage, projectNameFromPath, projectProfessionalAgentLabel, projectRuntimeVisibleError, @@ -220,6 +233,7 @@ import { parseRememberInput, } from './features/project-workspace/memoryCommands'; import { pendingCommandDetail } from './features/project-workspace/pendingCommandPresentation'; +import { planningStateNeedsRuntimeRefresh } from './features/project-workspace/planningLane'; import { isAgentTraceFilePath, isProjectPolicyConfirmableCommandId, @@ -383,44 +397,35 @@ type GameChatAutoPreviewAuthorization = { runId: string; }; -type GameChatPlayableRevision = { - runId: string; - revision: number; - validatedAt: number; +type PendingGameChatStageArchive = { + rootRuntime: AgentRuntimeState; + runtimeRecords: AgentRuntimeState[]; + manifestSnapshot: GameCreationAppManifest | null; + awaitingConversationSync: boolean; }; -type GameChatPreviewValidationCandidate = GameChatPlayableRevision & { - eventOrder: number; - playable: boolean; -}; - -const GAME_CHAT_STAGE_TASK_IDS = [ - 'design-director', - 'art-director', - 'art-asset-plan', - 'code-director', - 'code-prototype', - 'preview-readiness', - 'preview-playtest', -] as const; - -function gameChatManifestHasTerminalStageTasks( - manifest: GameCreationAppManifest | null, +function mergeGameChatRuntimeSnapshots( + previous: AgentRuntimeState[], + incoming: Iterable, ) { - if (!manifest) { - return false; + const merged = new Map( + previous.map((runtime) => [gameChatRuntimeIdentity(runtime), runtime]), + ); + for (const runtime of incoming) { + if (!runtime) { + continue; + } + const key = gameChatRuntimeIdentity(runtime); + const existing = merged.get(key); + if (!existing || runtime.updatedAt >= existing.updatedAt) { + merged.set(key, runtime); + } } - return GAME_CHAT_STAGE_TASK_IDS.every((taskId) => { - const status = manifest.tasks.find((task) => task.id === taskId)?.status; - return status === 'completed' || status === 'failed'; - }); + return Array.from(merged.values()); } -function gameChatRuntimeHasTerminalOutcome(runtime: AgentRuntimeState) { - return ( - ['completed', 'failed', 'cancelled'].includes(runtime.status) || - ['completed', 'failed', 'cancelled'].includes(runtime.phase) - ); +function gameChatStageRecordMessageId(runId: string) { + return `game-chat-stage-record:${encodeURIComponent(runId)}`; } function mergeGameChatHydratedConversationMessages( @@ -458,99 +463,6 @@ function gameChatPlayableRevisionIsAfterAuthorization( ); } -export function latestGameChatPlayableRevision( - runtime: AgentRuntimeState | null, - runtimeByAgentId: Record, -): GameChatPlayableRevision | null { - if (!runtime?.runId) { - return null; - } - const playtestChildren = new Map(); - for (const child of Object.values(runtimeByAgentId)) { - if ( - child?.agentId !== 'preview-playtest' || - child.taskId !== 'preview-playtest' || - child.source !== 'agent-ready-task-scheduler' || - child.parentAgentId !== PROJECT_SUPERVISOR_AGENT_ID || - child.parentRunId !== runtime.runId - ) { - continue; - } - playtestChildren.set( - `${child.agentId}\n${child.sessionId}\n${child.runId}`, - child, - ); - } - let latest: GameChatPreviewValidationCandidate | null = null; - let eventOrder = 0; - for (const child of playtestChildren.values()) { - for (const event of child.recentEvents ?? []) { - eventOrder += 1; - if ( - event.agentId !== child.agentId || - event.taskId !== child.taskId || - event.sessionId !== child.sessionId || - event.runId !== child.runId || - event.eventType !== 'observation' || - !Number.isSafeInteger(event.updatedAt) || - event.updatedAt < 0 || - !event.summary.startsWith('preview.validate:') || - !event.detail?.trim().startsWith('{') - ) { - continue; - } - try { - const detail = JSON.parse(event.detail) as { - passed?: unknown; - playtestPassed?: unknown; - revision?: unknown; - }; - const playable = - event.summary.startsWith('preview.validate:ok') && - detail.passed === true && - detail.playtestPassed === true; - if ( - typeof detail.passed !== 'boolean' || - typeof detail.revision !== 'number' || - !Number.isSafeInteger(detail.revision) || - detail.revision <= 0 - ) { - continue; - } - const shouldReplace = - !latest || - detail.revision > latest.revision || - (detail.revision === latest.revision && - event.updatedAt > latest.validatedAt) || - (detail.revision === latest.revision && - event.updatedAt === latest.validatedAt && - ((latest.playable && !playable) || - (latest.playable === playable && - eventOrder > latest.eventOrder))); - if (!shouldReplace) { - continue; - } - latest = { - eventOrder, - playable, - runId: runtime.runId, - revision: detail.revision, - validatedAt: event.updatedAt, - }; - } catch { - // Ignore malformed or truncated public evidence and wait for a valid revision. - } - } - } - return latest?.playable - ? { - runId: latest.runId, - revision: latest.revision, - validatedAt: latest.validatedAt, - } - : null; -} - function readStoredGameChatAutoPreviewAuthorization(): GameChatAutoPreviewAuthorization | null { try { window.localStorage.removeItem( @@ -696,6 +608,7 @@ type AppProps = { initialProjectKind?: LocalProjectKind; orchestrationMode?: 'single-supervisor' | 'professional-dag'; projectSupervisorOnly?: boolean; + planningStartMode?: boolean; supervisorChatOnly?: boolean; gameChatOnly?: boolean; /** @@ -728,6 +641,7 @@ export function App({ initialProjectKind = 'web', orchestrationMode = 'professional-dag', projectSupervisorOnly = false, + planningStartMode = false, supervisorChatOnly = false, gameChatOnly = false, directGameChatRuntime = false, @@ -741,11 +655,15 @@ export function App({ onAgentRuntimeSummariesChange, onAgentResultsChange, }: AppProps = {}) { + // 做方案入口独立成链:立项策划需要委派、澄清 pending 与 GDD 审批,这些只存在于 + // Supervisor Runtime;direct-codex 是单回合「生成→试玩→修」循环,没有对应机制。 + // 因此策划入口不走产品默认的 direct-codex,做游戏与做素材保持 master 的新默认。 const directCodexProductRuntime = DIRECT_CODEX_PRODUCT_RUNTIME && projectSupervisorOnly && !supervisorChatOnly && - (!gameChatOnly || directGameChatRuntime); + (!gameChatOnly || directGameChatRuntime) && + !planningStartMode; const [devMode] = useState(() => projectSupervisorOnly ? false : isDeveloperMode(), ); @@ -806,7 +724,7 @@ export function App({ const gameChatObservedRunKeysRef = useRef(new Set()); const gameChatArchivedRunKeysRef = useRef(new Set()); const gameChatPendingStageRuntimesRef = useRef( - new Map(), + new Map(), ); const gameChatCommittedResponseStreamKeysRef = useRef(new Set()); const initialSupervisorMessageLatchRef = useRef({ @@ -831,6 +749,7 @@ export function App({ : { status }, ); } + const [chatInput, setChatInput] = useState(() => (supervisorChatOnly || gameChatOnly) && initialProjectPath ? readSupervisorChatDraft(initialProjectPath) @@ -903,6 +822,189 @@ export function App({ useState(null); const [projectSupervisorRuntimeError, setProjectSupervisorRuntimeError] = useState(''); + const [planGddState, setPlanGddState] = useState( + null, + ); + const planGddStateRef = useRef(null); + planGddStateRef.current = planGddState; + const planGddHydrateSequenceRef = useRef(0); + const [planGddHydrateBusy, setPlanGddHydrateBusy] = useState(false); + const [planGddDecisionBusy, setPlanGddDecisionBusy] = useState(false); + const [planGddError, setPlanGddError] = useState(null); + const planGddDecisionResponseIdsRef = useRef( + new Map< + string, + { + action: PlanGddDecisionAction; + comment: string | null; + responseId: string; + } + >(), + ); + + const hydratePlanGddState = useCallback( + async (nextProjectPath?: string) => { + const targetProjectPath = + nextProjectPath?.trim() || localProjectPathRef.current || projectPath; + const invoke = resolveTauriInvoke(); + if (!invoke || !targetProjectPath.trim()) { + setPlanGddState(null); + return; + } + const requestSequence = ++planGddHydrateSequenceRef.current; + setPlanGddHydrateBusy(true); + setPlanGddError(null); + try { + const nextState = await invoke( + 'hydrate_game_creator_plan_gdd_state', + { projectPath: targetProjectPath }, + ); + if ( + requestSequence === planGddHydrateSequenceRef.current && + localProjectPathRef.current === targetProjectPath + ) { + setPlanGddState(nextState); + } + } catch (error) { + // 项目写锁争用是瞬时的:后端已经等过一个短窗口,仍然没抢到只说明此刻 + // 运行时正在写盘。这条 effect 每次监工状态变化都会再跑一次,下一拍就能 + // 拿到,所以保留上一份状态、不动错误位——否则一次刚落盘成功的审批会在 + // 卡里显示成失败(decide 成功后紧跟的这次 hydrate 正是最容易撞上的时刻)。 + const transientContention = + String(error).includes('项目正在被其他写操作占用:'); + if ( + !transientContention && + requestSequence === planGddHydrateSequenceRef.current && + localProjectPathRef.current === targetProjectPath + ) { + setPlanGddError(String(error)); + } + } finally { + if (requestSequence === planGddHydrateSequenceRef.current) { + setPlanGddHydrateBusy(false); + } + } + }, + [projectPath], + ); + + const decidePlanGdd = useCallback( + async (action: PlanGddDecisionAction, comment: string | null) => { + const current = planGddStateRef.current; + const pending = current?.pendingApproval; + const targetProjectPath = + resolveChatProjectPath(localProject) ?? projectPath; + const invoke = resolveTauriInvoke(); + if (!pending || !current?.displayGdd || !invoke || !targetProjectPath) { + throw new Error('当前没有可提交的 GDD 审批决定'); + } + // 方案 §13.2:busy、超时与网络重试复用同一 responseId,但用户改变 action 或 + // comment 后必须换新的。旧键只含 `approvalRequestId:action`,改写修改意见时会 + // 带着旧 responseId 提交,命中后端「同 responseId 的审批意图不一致」硬错误。 + // 判据方向是宁可多换不可少换:多换的最坏后果是 receipt 已存在时把 replayed 降级 + // 成 already-decided,两者都是 Ok;少换是硬错误。 + const previousDecision = planGddDecisionResponseIdsRef.current.get( + pending.approvalRequestId, + ); + const responseId = + previousDecision && + previousDecision.action === action && + previousDecision.comment === comment + ? previousDecision.responseId + : `gdd-response-${crypto.randomUUID()}`; + planGddDecisionResponseIdsRef.current.set(pending.approvalRequestId, { + action, + comment, + responseId, + }); + setPlanGddDecisionBusy(true); + setPlanGddError(null); + try { + await invoke('decide_game_creator_plan_gdd', { + projectPath: targetProjectPath, + gddId: pending.gddRef.gddId, + version: pending.gddRef.version, + fingerprint: pending.gddRef.fingerprint, + pendingActionId: pending.pendingActionId, + approvalRequestId: pending.approvalRequestId, + responseId, + action, + comment, + }); + await hydratePlanGddState(targetProjectPath); + } catch (error) { + // 方案 §18.3 要求 decision 返回后以 hydrate 对权威文件的重验为准,失败分支同样 + // 适用:不重灌就会让卡片停在已失效的 pending 身份上,三个决定按钮仍可点,且 + // `recoveryPending` 永远翻不成真、「重试恢复」入口不渲染,卡内没有出路。 + // 两句顺序不能反——`hydratePlanGddState` 入口会 `setPlanGddError(null)`, + // 先写错误再 hydrate 等于把这条错误擦掉。它自身从不抛出,不需要再包一层。 + await hydratePlanGddState(targetProjectPath); + setPlanGddError(String(error)); + throw error; + } finally { + setPlanGddDecisionBusy(false); + } + }, + [hydratePlanGddState, localProject, projectPath], + ); + + useEffect(() => { + const targetProjectPath = localProject?.projectPath; + if (!targetProjectPath) { + setPlanGddState(null); + setPlanGddError(null); + return; + } + void hydratePlanGddState(targetProjectPath); + }, [hydratePlanGddState, localProject?.projectPath]); + + useEffect(() => { + // 存在性判据故意走 `status`(必选字段,为 `undefined` 当且仅当 runtime 为 null)而不是整个 + // 对象:依赖里只挖 phase/status/updatedAt 三个标量,是为了只在监工状态真的动了时 + // 重灌。把 `projectSupervisorRuntime` 本体写进依赖会让每一轮轮询新建的对象身份都触发一次 + // hydrate,白烧 IPC。 + if ( + !localProject?.projectPath || + projectSupervisorRuntime?.status === undefined + ) { + return; + } + // 后端 hydrate 会抢项目写锁并扫 authority,不是纯内存读。没有这道门,做游戏和做 + // 素材链路的每一拍监工心跳都会去抢一次项目写锁——而那两条链路根本不产生策划状态。 + // 策划状态读 ref 而不进依赖:hydrate 成功就会换一个 `planGddState` 对象身份,写进 + // 依赖等于 hydrate 触发 hydrate。 + if ( + !planningStateNeedsRuntimeRefresh( + projectSupervisorRuntime?.source, + planGddStateRef.current, + ) + ) { + return; + } + void hydratePlanGddState(localProject.projectPath); + }, [ + hydratePlanGddState, + localProject?.projectPath, + projectSupervisorRuntime?.phase, + projectSupervisorRuntime?.source, + projectSupervisorRuntime?.status, + projectSupervisorRuntime?.updatedAt, + ]); + + useEffect(() => { + const hydrateOnResume = () => { + if (document.visibilityState === 'hidden' || !localProject?.projectPath) { + return; + } + void hydratePlanGddState(localProject.projectPath); + }; + window.addEventListener('focus', hydrateOnResume); + document.addEventListener('visibilitychange', hydrateOnResume); + return () => { + window.removeEventListener('focus', hydrateOnResume); + document.removeEventListener('visibilitychange', hydrateOnResume); + }; + }, [hydratePlanGddState, localProject?.projectPath]); const [projectSupervisorExpectedRunId, setProjectSupervisorExpectedRunId] = useState(null); const chatInputRef = useRef(null); @@ -1191,6 +1293,9 @@ export function App({ incoming, runtime, ); + if (gameChatOnly && !isGameChatSupervisorRoot(runtime)) { + nextStream = null; + } const candidateStream = nextStream; if (candidateStream?.status === 'ready' && gameChatOnly) { const text = candidateStream.accumulatedText.trim(); @@ -1271,7 +1376,20 @@ export function App({ return; } const syncKey = `${projectPath}\n${runtime.sessionId}\n${runtime.runId}`; - if (projectSupervisorRuntimeSyncingRef.current.has(syncKey)) { + const syncAlreadyStarted = + projectSupervisorRuntimeSyncingRef.current.has(syncKey); + if (gameChatOnly && isGameChatSupervisorRoot(runtime)) { + // Freeze the old root and its currently known descendants before the + // asynchronous conversation refresh. A fast next turn may replace the + // current-by-agent map while that refresh is still in flight. + appendGameChatStageRecord( + projectPath, + runtime, + false, + !syncAlreadyStarted, + ); + } + if (syncAlreadyStarted) { return; } const refreshConversation = projectSupervisorRefreshConversationRef.current; @@ -1279,57 +1397,124 @@ export function App({ return; } projectSupervisorRuntimeSyncingRef.current.add(syncKey); + if (gameChatOnly && isGameChatSupervisorRoot(runtime)) { + const archiveKey = `${projectPath}\n${runtime.runId}`; + void invoke('get_local_game_manifest', { + projectPath, + }) + .then((capturedManifest) => { + const pendingArchive = + gameChatPendingStageRuntimesRef.current.get(archiveKey); + // The manifest has no root/run binding, so a read that resolves after + // the next round took over may already describe that newer round. + // Only freeze it while this root is still the current one; otherwise + // the pre-next-run capture gate supplies the snapshot instead. + if ( + pendingArchive && + projectSupervisorRuntimeRef.current?.runId === runtime.runId && + isGameChatManifestPrimaryTaskTerminal(capturedManifest) + ) { + pendingArchive.manifestSnapshot = capturedManifest; + flushPendingGameChatStageRecords(); + } + }) + .catch(() => { + // The normal manifest refresh path can still supply the snapshot. + }); + } void refreshConversation( invoke, projectPath, runtime.sessionId, runtime.runId, - ).catch((error) => { - projectSupervisorRuntimeSyncingRef.current.delete(syncKey); - if ( - localProjectPathRef.current === projectPath && - projectSupervisorSessionIdRef.current === runtime.sessionId - ) { - setProjectSupervisorRuntimeError( - `项目总控 Agent 对话刷新失败:${ - error instanceof Error ? error.message : String(error) - }`, - ); - } - }); + ) + .then(() => { + const archiveKey = `${projectPath}\n${runtime.runId}`; + const pendingArchive = + gameChatPendingStageRuntimesRef.current.get(archiveKey); + if (pendingArchive) { + pendingArchive.awaitingConversationSync = false; + } + flushPendingGameChatStageRecords(); + }) + .catch((error) => { + projectSupervisorRuntimeSyncingRef.current.delete(syncKey); + if ( + localProjectPathRef.current === projectPath && + projectSupervisorSessionIdRef.current === runtime.sessionId + ) { + setProjectSupervisorRuntimeError( + `项目总控 Agent 对话刷新失败:${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + }); } function flushPendingGameChatStageRecords() { - if (!gameChatOnly || !gameChatManifestHasTerminalStageTasks(manifest)) { + if (!gameChatOnly) { return; } for (const [ archiveKey, - pendingRuntime, + pendingArchive, ] of gameChatPendingStageRuntimesRef.current) { if (gameChatArchivedRunKeysRef.current.has(archiveKey)) { gameChatPendingStageRuntimesRef.current.delete(archiveKey); continue; } + if (pendingArchive.awaitingConversationSync) { + continue; + } + pendingArchive.runtimeRecords = mergeGameChatRuntimeSnapshots( + pendingArchive.runtimeRecords, + Object.values(agentRuntimeByIdRef.current), + ); + if ( + projectSupervisorRuntimeRef.current?.runId === + pendingArchive.rootRuntime.runId && + isGameChatManifestPrimaryTaskTerminal(manifest) + ) { + pendingArchive.manifestSnapshot = manifest; + } + const lineage = projectCurrentGameChatRuntimeLineage( + pendingArchive.rootRuntime, + pendingArchive.runtimeRecords, + ); + if (!canArchiveGameChatStage(lineage, pendingArchive.manifestSnapshot)) { + continue; + } + const runtimeSnapshot = Object.fromEntries( + pendingArchive.runtimeRecords.map((runtime) => [ + gameChatRuntimeIdentity(runtime), + runtime, + ]), + ); const progress = buildGameChatProgressEvidence( - pendingRuntime, - agentRuntimeById, - manifest, + pendingArchive.rootRuntime, + runtimeSnapshot, + pendingArchive.manifestSnapshot, ); if (!progress) { continue; } const text = formatGameChatStageRecord( - pendingRuntime, + pendingArchive.rootRuntime, progress, - collectGameChatResultImages(manifest), + collectGameChatResultImages(pendingArchive.manifestSnapshot), + ); + const messageId = gameChatStageRecordMessageId( + pendingArchive.rootRuntime.runId, ); gameChatArchivedRunKeysRef.current.add(archiveKey); gameChatPendingStageRuntimesRef.current.delete(archiveKey); setMessages((current) => { if ( current.some( - (message) => message.role === 'assistant' && message.text === text, + (message) => + message.role === 'assistant' && + (message.messageId === messageId || message.text === text), ) ) { return current; @@ -1344,7 +1529,17 @@ export function App({ current.length, ); } - return [...current, { role: 'assistant', text }]; + const nextMessages: ChatMessage[] = [ + ...current, + { + role: 'assistant', + text, + messageId, + updatedAt: pendingArchive.rootRuntime.updatedAt, + }, + ]; + latestMessagesRef.current = nextMessages; + return nextMessages; }); } } @@ -1353,9 +1548,12 @@ export function App({ nextProjectPath: string, runtime: AgentRuntimeState, ) { + if (!isGameChatSupervisorRoot(runtime)) { + return; + } const eventMessages = gameChatRuntimeEventMessages( runtime, - agentRuntimeById, + agentRuntimeByIdRef.current, ); if (eventMessages.length === 0) { return; @@ -1388,27 +1586,36 @@ export function App({ if (!gameChatOnly || runtimeResults.length === 0) { return; } - // A professional Runtime is only part of the active game-chat turn when - // it was delegated by the current Project Supervisor run. This prevents - // a stale child Runtime (or a different app mode) from leaking into the - // project transcript after a restart. - const supervisorRunId = - projectSupervisorRuntimeRef.current?.runId ?? - runtimeResults.find( - (result) => result.state.agentId === PROJECT_SUPERVISOR_AGENT_ID, - )?.state.runId; - if (!supervisorRunId) { + const runtimeStates = runtimeResults.map((result) => + agentRuntimeStateFromResult(result), + ); + const root = + projectSupervisorRuntimeRef.current ?? + runtimeStates.find( + (candidate) => candidate.agentId === PROJECT_SUPERVISOR_AGENT_ID, + ) ?? + null; + const lineage = projectCurrentGameChatRuntimeLineage(root, [ + ...Object.values(agentRuntimeByIdRef.current), + ...runtimeStates, + ]); + if (!lineage?.main) { return; } - const messages = runtimeResults.flatMap((result) => { - const runtime = agentRuntimeStateFromResult(result); + const messages = runtimeResults.flatMap((result, index) => { + const runtime = runtimeStates[index]!; + const stream = result.responseStream; if ( - runtime.parentAgentId !== PROJECT_SUPERVISOR_AGENT_ID || - runtime.parentRunId !== supervisorRunId + !gameChatRuntimeBelongsToLineage(lineage, runtime) || + !stream || + stream.agentId !== runtime.agentId || + stream.taskId !== runtime.taskId || + stream.sessionId !== runtime.sessionId || + stream.runId !== runtime.runId ) { return []; } - return gameChatFinalReplyMessages([result.responseStream]); + return gameChatFinalReplyMessages([stream]); }); if (messages.length === 0) { return; @@ -1446,8 +1653,14 @@ export function App({ function appendGameChatStageRecord( nextProjectPath: string, runtime: AgentRuntimeState, + flush = true, + awaitingConversationSync = false, ) { - if (!gameChatOnly || !gameChatRuntimeHasTerminalOutcome(runtime)) { + if ( + !gameChatOnly || + !isGameChatSupervisorRoot(runtime) || + !isGameChatRuntimeTerminalState(runtime) + ) { return; } const archiveKey = `${nextProjectPath}\n${runtime.runId}`; @@ -1457,8 +1670,27 @@ export function App({ if (gameChatArchivedRunKeysRef.current.has(archiveKey)) { return; } - gameChatPendingStageRuntimesRef.current.set(archiveKey, runtime); - flushPendingGameChatStageRecords(); + const previous = gameChatPendingStageRuntimesRef.current.get(archiveKey); + gameChatPendingStageRuntimesRef.current.set(archiveKey, { + rootRuntime: + !previous || runtime.updatedAt >= previous.rootRuntime.updatedAt + ? runtime + : previous.rootRuntime, + runtimeRecords: mergeGameChatRuntimeSnapshots( + previous?.runtimeRecords ?? [], + Object.values(agentRuntimeByIdRef.current), + ), + manifestSnapshot: + isGameChatManifestPrimaryTaskTerminal(manifest) && + projectSupervisorRuntimeRef.current?.runId === runtime.runId + ? manifest + : (previous?.manifestSnapshot ?? null), + awaitingConversationSync: + awaitingConversationSync || previous?.awaitingConversationSync === true, + }); + if (flush) { + flushPendingGameChatStageRecords(); + } } useEffect(() => { @@ -1471,7 +1703,7 @@ export function App({ gameChatOnly && nextProjectPath && runtime && - gameChatRuntimeHasTerminalOutcome(runtime) + isGameChatRuntimeTerminalState(runtime) ) { // Hydration can restore a terminal root run without delivering a live // runtime-update event. Feed that snapshot through the same deferred @@ -1804,6 +2036,10 @@ export function App({ disposed = true; cleanup?.(); }; + // The subscription deliberately invokes the current render's helper; its + // mutable state is read through refs, so resubscribing for that helper is + // neither necessary nor safe. + // eslint-disable-next-line react-hooks/exhaustive-deps }, [ appendGameChatFinalReplyMessages, directCodexProductRuntime, @@ -2132,8 +2368,10 @@ export function App({ } const currentSupervisor = projectSupervisorRuntimeRef.current; let playableRevision = latestGameChatPlayableRevision( - currentSupervisor, - agentRuntimeByIdRef.current, + projectCurrentGameChatRuntimeLineage( + currentSupervisor, + agentRuntimeByIdRef.current, + ), ); if (playableRevision) { const currentRevision = await readCurrentProjectRevision(); @@ -3576,7 +3814,7 @@ export function App({ } : await invoke('init_local_game_project', { projectPath: trimmedProjectPath, - projectId: seedManifest.projectId, + projectId: createLocalProjectId(), name: gameChatOnly ? projectNameFromPath(trimmedProjectPath) : seedManifest.name, @@ -3714,7 +3952,7 @@ export function App({ if (directoryStatus.isGodotProject) { await invoke('import_local_godot_project', { projectPath: trimmedProjectPath, - projectId: seedManifest.projectId, + projectId: createLocalProjectId(), name: projectNameFromPath(trimmedProjectPath), }); await openWorkspace(trimmedProjectPath, false, 'open', 'godot'); @@ -6195,6 +6433,38 @@ export function App({ return sessionId; } + async function capturePendingGameChatStageManifestBeforeNextRun( + invoke: TauriInvoke, + projectPath: string, + ) { + const archivePrefix = `${projectPath}\n`; + const pendingArchives = Array.from( + gameChatPendingStageRuntimesRef.current.entries(), + ).filter( + ([archiveKey, pendingArchive]) => + archiveKey.startsWith(archivePrefix) && + !pendingArchive.manifestSnapshot, + ); + for (const [, pendingArchive] of pendingArchives) { + const currentRoot = projectSupervisorRuntimeRef.current; + if ( + !isGameChatSupervisorRoot(currentRoot) || + currentRoot.runId !== pendingArchive.rootRuntime.runId + ) { + throw new Error('上一轮阶段清单尚未冻结,请稍后重试'); + } + const capturedManifest = await invoke( + 'get_local_game_manifest', + { projectPath }, + ); + if (!isGameChatManifestPrimaryTaskTerminal(capturedManifest)) { + throw new Error('上一轮主阶段仍在收束,请稍后重试'); + } + pendingArchive.manifestSnapshot = capturedManifest; + } + flushPendingGameChatStageRecords(); + } + async function refreshDirectProjectManifest(nextProjectPath: string) { try { await refreshManifest(nextProjectPath); @@ -6481,26 +6751,59 @@ export function App({ if (!sessionId || localProjectPathRef.current !== nextProjectPath) { return; } + const runtimeAtSubmission = projectSupervisorRuntimeRef.current; + // 做方案入口独立成链。首次提交时本项目还没有任何 Supervisor run,只能靠首页 + // 带下来的 startMode 判断;之后由 plan 根 run 自己的 source 接管,做游戏与 + // 做素材两条链路不受影响。 + const planningEntry = + !gameChatOnly && + workspaceProjectKind === 'web' && + (runtimeAtSubmission?.source === PROJECT_SUPERVISOR_PLAN_SOURCE || + (planningStartMode && !runtimeAtSubmission)); + if ( + planningEntry && + runtimeAtSubmission && + runtimeAtSubmission.source === PROJECT_SUPERVISOR_PLAN_SOURCE && + !isAgentRuntimeTerminalState(runtimeAtSubmission) + ) { + setProjectSupervisorRuntimeError('请先完成当前立项策划步骤'); + return; + } const submissionRoute = resolveProjectSupervisorRuntimeSubmission({ workspaceProjectKind, orchestrationMode, supervisorChatOnly, gameChatOnly, + planningEntry, }); - const runtimeAtSubmission = projectSupervisorRuntimeRef.current; - const steerRuntime = matchingAgentRuntimeForSteer( - [runtimeAtSubmission], - PROJECT_SUPERVISOR_AGENT_ID, - sessionId, - submissionRoute.runProfile, - submissionRoute.source, - ); + // plan 根 run 不允许 steer(换根会作废旧委派链剩余的问询轮次),前端不发起, + // 后端另有独立否决。 + const steerRuntime = planningEntry + ? null + : matchingAgentRuntimeForSteer( + [runtimeAtSubmission], + PROJECT_SUPERVISOR_AGENT_ID, + sessionId, + submissionRoute.runProfile, + submissionRoute.source, + ); + if (gameChatOnly && !steerRuntime) { + await capturePendingGameChatStageManifestBeforeNextRun( + invoke, + nextProjectPath, + ); + if (localProjectPathRef.current !== nextProjectPath) { + return; + } + } let autoPreviewAfterRevision = 0; let autoPreviewAfterValidatedAt = 0; if (steerRuntime) { const playableAtSubmission = latestGameChatPlayableRevision( - runtimeAtSubmission, - agentRuntimeByIdRef.current, + projectCurrentGameChatRuntimeLineage( + runtimeAtSubmission, + agentRuntimeByIdRef.current, + ), ); autoPreviewAfterRevision = Math.max( gameChatPreviewRevisionRef.current ?? 0, @@ -6650,7 +6953,7 @@ export function App({ useEffect(() => { const latch = initialSupervisorMessageLatchRef.current; if ( - (!directCodexProductRuntime && !gameChatOnly) || + (!directCodexProductRuntime && !gameChatOnly && !planningStartMode) || !latch.prompt || !localProject ) { @@ -6699,6 +7002,7 @@ export function App({ initialCreationType, initialSupervisorMessage, localProject, + planningStartMode, ]); async function handleProjectSupervisorToolAction( @@ -11044,9 +11348,13 @@ export function App({ if (!runtime) { return; } - setAgentRuntimeById((current) => - mergeAgentRuntimeStateIntoMap(current, runtime, false), + const next = mergeAgentRuntimeStateIntoMap( + agentRuntimeByIdRef.current, + runtime, + false, ); + agentRuntimeByIdRef.current = next; + setAgentRuntimeById(next); } async function refreshAgentRuntimes( @@ -11896,6 +12204,11 @@ export function App({ !directCodexProductRuntime && orchestrationMode === 'professional-dag' } workspaceStatus={workspaceStatus} + planGddState={planGddState} + planGddHydrateBusy={planGddHydrateBusy || planGddDecisionBusy} + planGddError={planGddError} + onPlanGddRefresh={() => void hydratePlanGddState()} + onPlanGddDecision={decidePlanGdd} runtime={projectSupervisorRuntime} error={projectSupervisorRuntimeError} runtimeByAgentId={agentRuntimeById} @@ -11988,6 +12301,11 @@ export function App({ projectSupervisorRuntime={projectSupervisorRuntime} projectSupervisorRuntimeError={projectSupervisorRuntimeError} projectSupervisorTransientReply={projectSupervisorTransientReply} + planGddState={planGddState} + planGddHydrateBusy={planGddHydrateBusy || planGddDecisionBusy} + planGddError={planGddError} + onPlanGddRefresh={() => void hydratePlanGddState()} + onPlanGddDecision={decidePlanGdd} queueAgentRunControlFromPanel={queueAgentRunControlFromPanel} queueOrExecuteProjectIndex={queueOrExecuteProjectIndex} queuePendingCommand={queuePendingCommand} diff --git a/apps/ai-game-creator-shell/src/app/constants.ts b/apps/ai-game-creator-shell/src/app/constants.ts index 909379253..0a936c1fa 100644 --- a/apps/ai-game-creator-shell/src/app/constants.ts +++ b/apps/ai-game-creator-shell/src/app/constants.ts @@ -5,6 +5,10 @@ export const seedManifest = createGameCreationAppManifest( '未命名游戏原型', ); +export function createLocalProjectId(): string { + return `local-project-${crypto.randomUUID()}`; +} + export const AGENT_RUN_HISTORY_MAX_COUNT = 100; export const AGENT_RUN_HISTORY_INITIAL_VISIBLE_COUNT = 20; export const AGENT_RUN_HISTORY_VISIBLE_STEP = 20; @@ -12,6 +16,15 @@ export const CONVERSATION_INITIAL_VISIBLE_COUNT = 20; export const CONVERSATION_VISIBLE_STEP = 20; export const AGENT_CHAT_SCROLL_BOTTOM_THRESHOLD = 48; export const PROJECT_SUPERVISOR_AGENT_ID = 'project-supervisor'; +/** + * 立项策划链路的 run `source`。 + * + * 这是全前端唯一的字面量出处:`AgentRuntimeState.source` 在类型上只是 `string`, + * 改名不会有任何编译期提示,所以判据必须收敛到这一个常量上。`as const` 让它同时 + * 能充当 `ProjectSupervisorRuntimeSubmission['source']` 的成员。 + */ +export const PROJECT_SUPERVISOR_PLAN_SOURCE = + 'project-supervisor-plan' as const; export const launcherNotifications: Array<{ label: string; detail: string; diff --git a/apps/ai-game-creator-shell/src/app/types.ts b/apps/ai-game-creator-shell/src/app/types.ts index f86844dac..c816c7cfc 100644 --- a/apps/ai-game-creator-shell/src/app/types.ts +++ b/apps/ai-game-creator-shell/src/app/types.ts @@ -52,6 +52,8 @@ export type LauncherImportedAttachment = { export type LocalProjectKind = 'web' | 'godot'; +export type ProjectStartMode = 'planning' | 'direct-build'; + export type LauncherProjectContext = { projectPath: string; projectName: string; @@ -59,6 +61,7 @@ export type LauncherProjectContext = { manifest: GameCreationAppManifest; projectRevision: number | null; creationType: HomeCreationType | null; + startMode: ProjectStartMode | null; initialPrompt: string; attachments: LauncherImportedAttachment[]; recentRunStatus: string | null; @@ -75,6 +78,7 @@ export type PendingNonEmptyProject = | { kind: 'home-create'; projectPath: string; + startMode: ProjectStartMode; draft: HomeDraft; } | { @@ -128,6 +132,159 @@ export interface GenerateLocalGameDraftResult { manifest: GameCreationAppManifest; } +export type PlanGddDecisionAction = 'approve' | 'revise' | 'reject'; + +export interface PlanGddStateViewV1 { + schemaVersion: 'plan-gdd-state-view.v1'; + projectId: string; + gddId: string | null; + state: + | 'not_started' + | 'draft' + | 'ready_for_approval' + | 'revision_requested' + | 'approved' + | 'rejected'; + session: { + sessionId: string; + sessionRevision: number; + sessionFingerprint: string; + phase: + | 'collecting' + | 'awaiting_user_input' + | 'awaiting_gdd_approval' + | 'revision_requested' + | 'approved' + | 'rejected' + | 'recovery_required'; + clarificationRound: number; + repairDepth: number; + accumulatedAgentMillis: number; + activeRunId: string | null; + awaitingAnswerFor: { + delegationId: string; + requestId: string; + questionId: string; + round: number; + } | null; + decisionStateCounts: { + confirmed: number; + defaultPending: number; + prototypePending: number; + }; + } | null; + versions: Array<{ + gddRef: { gddId: string; version: number; fingerprint: string }; + status: + | 'ready_for_approval' + | 'revision_requested' + | 'approved' + | 'rejected' + | 'superseded'; + approvalRequestId: string; + createdAtUtc: string; + decision: { action: PlanGddDecisionAction; decidedAtUtc: string } | null; + }>; + displayGdd: { + schemaVersion: string; + projectId: string; + gddId: string; + version: number; + submissionId: string; + approvalRequestId: string; + actionFingerprint: string; + agentId: string; + source: string; + runProfile: string; + runProfileBindingFingerprint: string; + rootAgentId: string; + rootRunId: string; + delegationId: string; + sessionId: string; + sourceSessionRevision: number; + sourceSessionFingerprint: string; + createdByRunId: string; + createdAtUtc: string; + fingerprint: string; + game: { + title: string; + oneLiner: string; + genre: { primary: string; fusion: string | null }; + artStyle: { + visualType: string; + keywords: string[]; + moodAndColor: string; + mvpArtBoundary: string; + }; + pillars: Array<{ + name: string; + playerFeel: string; + mechanism: string; + decisionState: string; + basis: null; + }>; + coreLoop: string[]; + targetUsers: { + coreUsers: string; + preferences: string; + sessionLength: string; + referenceGames: string[]; + }; + platformFacts: { + runtime: string; + viewports: string[]; + inputs: string[]; + preview: string; + }; + mvpSystems: Array<{ + system: string; + minimalFunction: string; + whyRequired: string; + verifyMethod: string; + decisionState: string; + basis: null; + }>; + outOfScope: string[]; + creatorTips: { + doFirst: string; + deferForNow: string; + howToVerify: string; + expandWhen: string; + }; + }; + decisions: Array<{ + id: string; + topic: string; + state: 'confirmed' | 'default_pending' | 'prototype_pending'; + answerSource: 'user_option' | 'user_freeform' | 'default'; + round: number; + answerSummary: string; + basis: null; + }>; + prototypeValidationItems: Array<{ + id: string; + question: string; + microPrototype: string; + observation: string; + passCriterion: string; + }>; + } | null; + pendingApproval: { + gddRef: { gddId: string; version: number; fingerprint: string }; + pendingActionId: string; + actionFingerprint: string; + approvalRequestId: string; + sessionId: string; + runId: string; + } | null; + approvedGddRef: { + gddId: string; + version: number; + fingerprint: string; + } | null; + recoveryPending: boolean; +} + export interface GameCreatorChatAgentReply { replyText: string; } @@ -591,6 +748,9 @@ export interface GameCreatorAppConfig { apiKey: string; }; mcpServers: Record; + planning?: { + capabilityEnabled: boolean; + }; } export interface GameCreatorAppConfigView { diff --git a/apps/ai-game-creator-shell/src/features/agent-runtime/gameChatRuntimeProjection.ts b/apps/ai-game-creator-shell/src/features/agent-runtime/gameChatRuntimeProjection.ts new file mode 100644 index 000000000..67681490b --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/agent-runtime/gameChatRuntimeProjection.ts @@ -0,0 +1,472 @@ +import type { GameCreationAppManifest } from '../../../../../packages/shared/src/contracts/gameCreationApp'; +import { PROJECT_SUPERVISOR_AGENT_ID } from '../../app/constants'; +import type { + AgentRuntimeEventRecord, + AgentRuntimeState, +} from '../../app/types'; + +export const GAME_CHAT_SUPERVISOR_SOURCE = + 'project-supervisor-game-chat' as const; +export const GAME_CHAT_PRIMARY_TASK_ID = 'code-prototype' as const; +export const GAME_CHAT_DYNAMIC_ART_AGENT_IDS: ReadonlySet = new Set([ + 'art-director', + 'art-asset-plan', +]); + +const GAME_CHAT_MAIN_SOURCE = 'agent-ready-task-scheduler'; +const GAME_CHAT_DYNAMIC_ART_SOURCES = new Set([ + 'agent-delegate', + 'agent-delegate-retry', +]); +const GAME_CHAT_TERMINAL_STATES = new Set(['completed', 'failed', 'cancelled']); +const GAME_CHAT_RECONCILIATION_STATE = 'needs-reconciliation'; + +export type GameChatRuntimeLineage = { + root: AgentRuntimeState; + main: AgentRuntimeState | null; + dynamicArtChildren: AgentRuntimeState[]; + hasConflict: boolean; +}; + +export type GameChatPrimaryProgress = { + completed: 0 | 1; + total: 1; + status: + | 'pending' + | 'running' + | 'completed' + | 'failed' + | 'needs-reconciliation'; +}; + +export type GameChatPlayableRevision = { + runId: string; + mainRunId: string; + revision: number; + validatedAt: number; +}; + +type OrderedRuntimeEvent = { + event: AgentRuntimeEventRecord; + order: number; +}; + +type PreviewValidationCandidate = GameChatPlayableRevision & { + order: number; + playable: boolean; +}; + +function hasIdentity(value: string | null | undefined) { + return typeof value === 'string' && value.trim().length > 0; +} + +export function gameChatRuntimeIdentity(runtime: AgentRuntimeState) { + return [runtime.agentId, runtime.sessionId, runtime.runId].join('\u001f'); +} + +export function sameGameChatRuntimeIdentity( + left: AgentRuntimeState, + right: AgentRuntimeState, +) { + return gameChatRuntimeIdentity(left) === gameChatRuntimeIdentity(right); +} + +function distinctRuntimeRecords( + runtimeRecords: + | Iterable + | Record, +) { + const records = + Symbol.iterator in Object(runtimeRecords) + ? Array.from( + runtimeRecords as Iterable, + ) + : Object.values(runtimeRecords); + const distinct = new Map(); + for (const runtime of records) { + if (!runtime) { + continue; + } + const key = gameChatRuntimeIdentity(runtime); + const previous = distinct.get(key); + if (!previous || runtime.updatedAt >= previous.updatedAt) { + distinct.set(key, runtime); + } + } + return Array.from(distinct.values()); +} + +export function isGameChatSupervisorRoot( + runtime: AgentRuntimeState | null | undefined, +): runtime is AgentRuntimeState { + return Boolean( + runtime && + runtime.agentId === PROJECT_SUPERVISOR_AGENT_ID && + runtime.taskId === PROJECT_SUPERVISOR_AGENT_ID && + runtime.source === GAME_CHAT_SUPERVISOR_SOURCE && + hasIdentity(runtime.sessionId) && + hasIdentity(runtime.runId) && + !hasIdentity(runtime.parentAgentId) && + !hasIdentity(runtime.parentRunId), + ); +} + +export function isCurrentGameChatMainRuntime( + root: AgentRuntimeState | null | undefined, + candidate: AgentRuntimeState | null | undefined, +): boolean { + if (!root || !isGameChatSupervisorRoot(root) || !candidate) { + return false; + } + return Boolean( + candidate.agentId === GAME_CHAT_PRIMARY_TASK_ID && + candidate.taskId === GAME_CHAT_PRIMARY_TASK_ID && + candidate.source === GAME_CHAT_MAIN_SOURCE && + candidate.parentAgentId === PROJECT_SUPERVISOR_AGENT_ID && + candidate.parentRunId === root.runId && + hasIdentity(candidate.sessionId) && + hasIdentity(candidate.runId), + ); +} + +export function isCurrentGameChatDynamicArtRuntime( + main: AgentRuntimeState | null | undefined, + candidate: AgentRuntimeState | null | undefined, +): boolean { + return Boolean( + main && + main.agentId === GAME_CHAT_PRIMARY_TASK_ID && + main.taskId === GAME_CHAT_PRIMARY_TASK_ID && + main.source === GAME_CHAT_MAIN_SOURCE && + candidate && + gameChatRuntimeClaimsDynamicArtLineage(candidate) && + candidate.parentRunId === main.runId, + ); +} + +/** + * Fail-closed UI classification for views that only loaded the selected child + * Runtime and therefore cannot reconstruct its root binding. Exact lineage + * consumers must still use `isCurrentGameChatDynamicArtRuntime`. + */ +export function gameChatRuntimeClaimsDynamicArtLineage( + candidate: AgentRuntimeState | null | undefined, +): boolean { + return Boolean( + candidate && + GAME_CHAT_DYNAMIC_ART_AGENT_IDS.has(candidate.agentId) && + candidate.taskId === candidate.agentId && + GAME_CHAT_DYNAMIC_ART_SOURCES.has(candidate.source) && + candidate.parentAgentId === GAME_CHAT_PRIMARY_TASK_ID && + hasIdentity(candidate.parentRunId) && + hasIdentity(candidate.sessionId) && + hasIdentity(candidate.runId), + ); +} + +export function isGameChatRuntimeTerminalState( + runtime: AgentRuntimeState | null | undefined, +) { + return Boolean( + runtime && + (GAME_CHAT_TERMINAL_STATES.has(runtime.status) || + GAME_CHAT_TERMINAL_STATES.has(runtime.phase)), + ); +} + +export function isGameChatRuntimeInReconciliation( + runtime: AgentRuntimeState | null | undefined, +) { + return Boolean( + runtime && + (runtime.status === GAME_CHAT_RECONCILIATION_STATE || + runtime.phase === GAME_CHAT_RECONCILIATION_STATE), + ); +} + +export function projectCurrentGameChatRuntimeLineage( + root: AgentRuntimeState | null | undefined, + runtimeRecords: + | Iterable + | Record, +): GameChatRuntimeLineage | null { + if (!root || !isGameChatSupervisorRoot(root)) { + return null; + } + const records = distinctRuntimeRecords(runtimeRecords); + const mainCandidates = records.filter((candidate) => + isCurrentGameChatMainRuntime(root, candidate), + ); + const main = mainCandidates.length === 1 ? mainCandidates[0]! : null; + const dynamicArtChildren = main + ? records + .filter((candidate) => + isCurrentGameChatDynamicArtRuntime(main, candidate), + ) + .sort( + (left, right) => + left.updatedAt - right.updatedAt || + left.agentId.localeCompare(right.agentId) || + left.runId.localeCompare(right.runId), + ) + : []; + const activeArtChildren = dynamicArtChildren.filter( + (candidate) => !isGameChatRuntimeTerminalState(candidate), + ); + return { + root, + main, + dynamicArtChildren, + hasConflict: mainCandidates.length > 1 || activeArtChildren.length > 1, + }; +} + +export function gameChatLineageCollaboratingRuntimes( + lineage: GameChatRuntimeLineage | null, +) { + return lineage?.main ? [lineage.main, ...lineage.dynamicArtChildren] : []; +} + +export function gameChatRuntimeBelongsToLineage( + lineage: GameChatRuntimeLineage | null, + candidate: AgentRuntimeState | null | undefined, +) { + return Boolean( + candidate && + gameChatLineageCollaboratingRuntimes(lineage).some((runtime) => + sameGameChatRuntimeIdentity(runtime, candidate), + ), + ); +} + +function manifestPrimaryTaskStatus(manifest: GameCreationAppManifest | null) { + const tasks = + manifest?.tasks.filter((task) => task.id === GAME_CHAT_PRIMARY_TASK_ID) ?? + []; + return tasks.length === 1 ? tasks[0]!.status : null; +} + +/** + * 主任务是否已经落到终态。 + * + * 归档链路上有两处要问这个问题——先决定要不要把当前 manifest 冻成快照,再校验冻下来 + * 的那份快照能不能归档——两处必须是同一条判据,所以出口只留这一个。 + * 注意终态只有 completed / failed:manifest 任务没有 cancelled。 + */ +export function isGameChatManifestPrimaryTaskTerminal( + manifest: GameCreationAppManifest | null, +) { + const status = manifestPrimaryTaskStatus(manifest); + return status === 'completed' || status === 'failed'; +} + +export function projectGameChatPrimaryProgress( + manifest: GameCreationAppManifest | null, + lineage: GameChatRuntimeLineage | null, +): GameChatPrimaryProgress { + if (!lineage) { + return { completed: 0, total: 1, status: 'pending' }; + } + if (lineage.hasConflict || isGameChatRuntimeInReconciliation(lineage.root)) { + return { completed: 0, total: 1, status: 'needs-reconciliation' }; + } + if (!lineage.main) { + return { completed: 0, total: 1, status: 'pending' }; + } + const relatedRuntimes = gameChatLineageCollaboratingRuntimes(lineage); + if (relatedRuntimes.some(isGameChatRuntimeInReconciliation)) { + return { completed: 0, total: 1, status: 'needs-reconciliation' }; + } + const main = lineage.main; + if ( + main.status === 'failed' || + main.phase === 'failed' || + main.status === 'cancelled' || + main.phase === 'cancelled' + ) { + return { completed: 0, total: 1, status: 'failed' }; + } + if (!isGameChatRuntimeTerminalState(main)) { + return { completed: 0, total: 1, status: 'running' }; + } + const manifestStatus = manifestPrimaryTaskStatus(manifest); + if (manifestStatus === 'completed') { + return { completed: 1, total: 1, status: 'completed' }; + } + if (manifestStatus === 'failed') { + return { completed: 0, total: 1, status: 'failed' }; + } + return { completed: 0, total: 1, status: 'pending' }; +} + +function runtimeOwnOrderedEvents(runtime: AgentRuntimeState) { + return (runtime.recentEvents ?? []).flatMap((event, order) => + event.agentId === runtime.agentId && + event.taskId === runtime.taskId && + event.sessionId === runtime.sessionId && + event.runId === runtime.runId && + event.source === runtime.source && + Number.isSafeInteger(event.updatedAt) && + event.updatedAt >= 0 + ? [{ event, order }] + : [], + ); +} + +function staticSmokeObservationPassed(summary: string) { + if ( + !summary.startsWith('command.run_limited:') || + !/(?:^|[\s·])game\.static_smoke(?:$|[\s,。;])/u.test(summary) + ) { + return null; + } + return summary.startsWith('command.run_limited:ok') ? true : false; +} + +function orderedEventAtOrBefore( + left: OrderedRuntimeEvent, + right: OrderedRuntimeEvent, +) { + return ( + left.event.updatedAt < right.event.updatedAt || + (left.event.updatedAt === right.event.updatedAt && + left.order <= right.order) + ); +} + +function shouldReplacePreviewCandidate( + current: PreviewValidationCandidate | null, + candidate: PreviewValidationCandidate, +) { + if (!current) { + return true; + } + if (candidate.revision !== current.revision) { + return candidate.revision > current.revision; + } + if (candidate.validatedAt !== current.validatedAt) { + return candidate.validatedAt > current.validatedAt; + } + if (candidate.playable !== current.playable) { + return !candidate.playable; + } + return candidate.order > current.order; +} + +export function latestGameChatPlayableRevision( + lineage: GameChatRuntimeLineage | null, +): GameChatPlayableRevision | null { + if (!lineage?.main || lineage.hasConflict) { + return null; + } + const orderedEvents = runtimeOwnOrderedEvents(lineage.main); + const staticSmokeEvents = orderedEvents.filter( + ({ event }) => + event.eventType === 'observation' && + staticSmokeObservationPassed(event.summary) !== null, + ); + const latestStaticSmoke = + staticSmokeEvents.reduce( + (latest, candidate) => { + if ( + !latest || + candidate.event.updatedAt > latest.event.updatedAt || + (candidate.event.updatedAt === latest.event.updatedAt && + candidate.order > latest.order) + ) { + return candidate; + } + return latest; + }, + null, + ); + if ( + !latestStaticSmoke || + staticSmokeObservationPassed(latestStaticSmoke.event.summary) !== true + ) { + return null; + } + + let latestPreview: PreviewValidationCandidate | null = null; + for (const orderedEvent of orderedEvents) { + const { event, order } = orderedEvent; + if ( + event.eventType !== 'observation' || + !event.summary.startsWith('preview.validate:') || + !event.detail?.trim().startsWith('{') + ) { + continue; + } + try { + const detail = JSON.parse(event.detail) as { + passed?: unknown; + playtestPassed?: unknown; + revision?: unknown; + }; + if ( + typeof detail.revision !== 'number' || + !Number.isSafeInteger(detail.revision) || + detail.revision <= 0 + ) { + continue; + } + const candidate: PreviewValidationCandidate = { + runId: lineage.root.runId, + mainRunId: lineage.main.runId, + revision: detail.revision, + validatedAt: event.updatedAt, + order, + playable: + event.summary.startsWith('preview.validate:ok') && + detail.passed === true && + detail.playtestPassed === true, + }; + if (shouldReplacePreviewCandidate(latestPreview, candidate)) { + latestPreview = candidate; + } + } catch { + // Malformed public evidence cannot establish a playable revision. + } + } + if (!latestPreview?.playable) { + return null; + } + const previewEvent = orderedEvents.find( + ({ event, order }) => + event.updatedAt === latestPreview?.validatedAt && + order === latestPreview.order, + ); + if ( + !previewEvent || + !orderedEventAtOrBefore(latestStaticSmoke, previewEvent) + ) { + return null; + } + return { + runId: latestPreview.runId, + mainRunId: latestPreview.mainRunId, + revision: latestPreview.revision, + validatedAt: latestPreview.validatedAt, + }; +} + +export function canArchiveGameChatStage( + lineage: GameChatRuntimeLineage | null, + manifest: GameCreationAppManifest | null, +) { + if (!lineage?.main || lineage.hasConflict) { + return false; + } + const relatedRuntimes = [ + lineage.root, + lineage.main, + ...lineage.dynamicArtChildren, + ]; + if ( + relatedRuntimes.some(isGameChatRuntimeInReconciliation) || + relatedRuntimes.some((runtime) => !isGameChatRuntimeTerminalState(runtime)) + ) { + return false; + } + return isGameChatManifestPrimaryTaskTerminal(manifest); +} diff --git a/apps/ai-game-creator-shell/src/features/agent-runtime/index.ts b/apps/ai-game-creator-shell/src/features/agent-runtime/index.ts index 268e61dbf..870d429f9 100644 --- a/apps/ai-game-creator-shell/src/features/agent-runtime/index.ts +++ b/apps/ai-game-creator-shell/src/features/agent-runtime/index.ts @@ -1,2 +1,3 @@ +export * from './gameChatRuntimeProjection'; export * from './model'; export * from './panels'; diff --git a/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts b/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts index 9fbbb9059..2ab4f100f 100644 --- a/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts +++ b/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts @@ -2,7 +2,11 @@ import type { GameCreationAppManifest, GameCreationAppTaskState, } from '../../../../../packages/shared/src/contracts/gameCreationApp'; -import { PROJECT_SUPERVISOR_AGENT_ID, seedManifest } from '../../app/constants'; +import { + PROJECT_SUPERVISOR_AGENT_ID, + PROJECT_SUPERVISOR_PLAN_SOURCE, + seedManifest, +} from '../../app/constants'; import type { AgentConversationSessionListResult, AgentConversationSessionRecord, @@ -20,6 +24,12 @@ import type { LocalConversationMessageRecord, TauriInvoke, } from '../../app/types'; +import { + GAME_CHAT_SUPERVISOR_SOURCE, + gameChatLineageCollaboratingRuntimes, + isGameChatSupervisorRoot, + projectCurrentGameChatRuntimeLineage, +} from './gameChatRuntimeProjection'; const AGENT_RUNTIME_PUBLIC_STATUS_MESSAGE_ID_PREFIX = 'runtime-public-status-'; const AGENT_RUNTIME_TASK_MESSAGE_ID_PREFIX = 'runtime-task-'; @@ -28,7 +38,10 @@ const AGENT_RUNTIME_MESSAGE_CORRELATION_PATTERN = /^[0-9a-f]{32}$/; export type ProjectSupervisorRuntimeSubmission = { runProfile: 'standard' | 'autonomous-game-build'; - source: 'project-supervisor-gui' | 'project-supervisor-game-chat'; + source: + | 'project-supervisor-gui' + | 'project-supervisor-game-chat' + | typeof PROJECT_SUPERVISOR_PLAN_SOURCE; }; export function resolveProjectSupervisorRuntimeSubmission({ @@ -36,12 +49,22 @@ export function resolveProjectSupervisorRuntimeSubmission({ orchestrationMode, supervisorChatOnly, gameChatOnly, + planningEntry = false, }: { workspaceProjectKind: 'web' | 'godot'; orchestrationMode: 'single-supervisor' | 'professional-dag'; supervisorChatOnly: boolean; gameChatOnly: boolean; + planningEntry?: boolean; }): ProjectSupervisorRuntimeSubmission { + // 立项策划入口独立成链:命中时固定 standard + plan source,不参与下面按 + // godot / chat / DAG 的分流,也不改动做游戏与做素材的既有路由。 + if (planningEntry && !gameChatOnly && workspaceProjectKind === 'web') { + return { + runProfile: 'standard', + source: PROJECT_SUPERVISOR_PLAN_SOURCE, + }; + } if ( workspaceProjectKind === 'godot' || (supervisorChatOnly && !gameChatOnly) @@ -838,6 +861,37 @@ export function agentRuntimeNeedsUserInput( ); } +export const AGENT_RUNTIME_USER_INPUT_REQUEST_TOOL = 'user.input_request'; +export const AGENT_RUNTIME_PLAN_SUBMIT_GDD_TOOL = 'plan.submit_gdd'; + +// 有两个 pending 动作不是「等待批准的动作」,而是某张专用卡的载体:Runtime 把内容停在 +// pending 上,真正的交互面是另一个组件。 +// +// - `user.input_request`:子 Agent 以 needs-user-input 终态退出后,Runtime 在 parent-wake +// 屏障处按信封原文构造这个 pending(`ensure_static_delegate_user_input_wait_at_locked`), +// 同一份问题再投影成 `userInputRequest`,交互面是问答卡。 +// - `plan.submit_gdd`:策划子 Agent 提交 Fast GDD 后,`planning/pending.json` 的 +// `submission.pendingActionId` 指向的正是这个 pending,交互面是 `GddApprovalCard` +// 的批准 / 修改 / 退回。 +// +// 两者都没有「确认 / 拒绝」语义:`user.input_request` 从来不在 +// `GAME_CREATION_APP_COMMANDS` 的 confirm 集合里;`plan.submit_gdd` 更是被运行时策略明确 +// 拒绝转成通用确认 pending(「Runtime-owned create-only 提交」),点确认必然失败。 +// +// 通用待确认卡按 `tool` + `inputSummary` 渲染,套到它们身上就是把同一个请求画两遍——标题 +// 是原始工具名,副标题是 `questionCount=… · questionsSha256=…` 这种给日志看的取证摘要, +// 还会把真正该看的那张卡压在下面。所以这里统一把通用确认面判掉。 +const AGENT_RUNTIME_DEDICATED_CARD_TOOLS = new Set([ + AGENT_RUNTIME_USER_INPUT_REQUEST_TOOL, + AGENT_RUNTIME_PLAN_SUBMIT_GDD_TOOL, +]); + +export function agentRuntimePendingActionHasDedicatedCard( + action: AgentRuntimePendingToolActionSummary | null | undefined, +) { + return Boolean(action && AGENT_RUNTIME_DEDICATED_CARD_TOOLS.has(action.tool)); +} + export function matchingAgentRuntimeForSteer( runtimes: Array, agentId: string, @@ -1534,14 +1588,22 @@ export function projectSupervisorCollaboratingAgentRuntimes( if (!supervisorRuntime?.runId) { return []; } + if (supervisorRuntime.source === GAME_CHAT_SUPERVISOR_SOURCE) { + return isGameChatSupervisorRoot(supervisorRuntime) + ? gameChatLineageCollaboratingRuntimes( + projectCurrentGameChatRuntimeLineage( + supervisorRuntime, + runtimeByAgentId, + ), + ) + : []; + } const runtimesByAgentId = new Map(); for (const runtime of Object.values(runtimeByAgentId)) { - const isVisibleChildSource = - ['agent-delegate', 'agent-delegate-retry'].includes( - runtime?.source ?? '', - ) || - (supervisorRuntime.source === 'project-supervisor-game-chat' && - runtime?.source === 'agent-ready-task-scheduler'); + const isVisibleChildSource = [ + 'agent-delegate', + 'agent-delegate-retry', + ].includes(runtime?.source ?? ''); if ( !runtime || runtime.agentId === PROJECT_SUPERVISOR_AGENT_ID || @@ -1562,6 +1624,9 @@ export function projectSupervisorCollaboratingAgentRuntimes( } export function projectProfessionalAgentLabel(agentId: string) { + if (agentId === 'project-planning') { + return '立项策划 Agent'; + } if (agentId === 'design-foundation') { return '玩法策划 Agent'; } @@ -1575,7 +1640,7 @@ export function projectProfessionalAgentLabel(agentId: string) { return '数值 Agent'; } if (agentId.includes('design')) { - return '策划 Agent'; + return '设计实现 Agent'; } if (agentId.includes('art')) { return '美术 Agent'; diff --git a/apps/ai-game-creator-shell/src/features/agent-runtime/panels.tsx b/apps/ai-game-creator-shell/src/features/agent-runtime/panels.tsx index c6960f558..040b0b0dc 100644 --- a/apps/ai-game-creator-shell/src/features/agent-runtime/panels.tsx +++ b/apps/ai-game-creator-shell/src/features/agent-runtime/panels.tsx @@ -16,6 +16,11 @@ import type { AgentRuntimeUserInputRequest, } from '../../app/types'; import type { ProjectAgentResultSummary } from '../../view/project-development'; +import { + gameChatRuntimeClaimsDynamicArtLineage, + isCurrentGameChatDynamicArtRuntime, + projectCurrentGameChatRuntimeLineage, +} from './gameChatRuntimeProjection'; import { agentGoalStatusIsPaused, agentGoalStatusIsTerminal, @@ -24,6 +29,7 @@ import { agentRuntimeCanRetry, agentRuntimeNeedsUserInput, agentRuntimeNextStepFromPhase, + agentRuntimePendingActionHasDedicatedCard, agentRuntimePlanStepText, agentRuntimeWaitingOnFromPhase, createAgentRuntimeUserInputResponseId, @@ -245,12 +251,15 @@ export function AgentRuntimeUserInputCard({ placeholder="填写其他答案" rows={2} value={answer} - onChange={(event) => + onChange={(event) => { + // React 会在事件派发结束后把 currentTarget 置空,而 setState 的 + // updater 要等到渲染阶段才跑,所以必须在这里先取出值。 + const { value } = event.currentTarget; setAnswers((current) => ({ ...current, - [question.id]: event.currentTarget.value, - })) - } + [question.id]: value, + })); + }} /> ); @@ -387,19 +396,24 @@ export function AgentRuntimeStatusPanel({ const canRetry = Boolean(runtime.runId) && agentRuntimeCanRetry(runtime.status) && + !gameChatRuntimeClaimsDynamicArtLineage(runtime) && !agentGoalStatusIsPaused(runtime.goalStatus) && !agentGoalStatusIsPaused(runtime.status) && !agentGoalStatusIsPaused(runtime.phase) && !pendingToolAction && Boolean(onRetryRuntimeTask); + const pendingActionHasDedicatedCard = + agentRuntimePendingActionHasDedicatedCard(pendingToolAction); const canConfirm = Boolean(runtime.runId) && Boolean(pendingToolAction?.actionId) && + !pendingActionHasDedicatedCard && agentRuntimeCanConfirm(runtime.status) && Boolean(onConfirmRuntimeTask); const canReject = Boolean(runtime.runId) && Boolean(pendingToolAction?.actionId) && + !pendingActionHasDedicatedCard && agentRuntimeCanConfirm(runtime.status) && Boolean(onRejectRuntimeTask); const canCompact = @@ -664,6 +678,7 @@ export function ProjectSupervisorRuntimePanel({ runtimeByAgentId, controlBusy, readOnly = false, + planGddAwaitingDecision = false, professionalResultsByAgentId, onToolAction, onSupervisorRetry, @@ -676,6 +691,15 @@ export function ProjectSupervisorRuntimePanel({ runtimeByAgentId: Record; controlBusy: boolean; readOnly?: boolean; + /** + * `GddApprovalCard` 是否正在等用户做决定(`planning/pending.json` 有 + * `awaiting_decision` 的 pending)。 + * + * 立项策划的审批等待复用了 `waiting-for-user-input` 这个 phase,但它没有 + * `userInputRequest`——真正的交互面是审批卡。只看 phase 的话这里会退到「待回答问题未能 + * 读取」那句错误,把一次正常的等待报成读取失败,还压在审批卡上面。 + */ + planGddAwaitingDecision?: boolean; professionalResultsByAgentId: Record< string, ProjectAgentResultSummary | undefined @@ -723,6 +747,10 @@ export function ProjectSupervisorRuntimePanel({ runtime, runtimeByAgentId, ); + const gameChatLineage = projectCurrentGameChatRuntimeLineage( + runtime, + runtimeByAgentId, + ); const visibleSnapshotKey = [ runtime?.runId ?? '', runtime?.status ?? '', @@ -766,6 +794,8 @@ export function ProjectSupervisorRuntimePanel({ const pendingActionPresentation = pendingToolAction ? projectSupervisorPendingActionPresentation(pendingToolAction) : null; + const pendingActionHasDedicatedCard = + agentRuntimePendingActionHasDedicatedCard(pendingToolAction); const userInputRequest = runtime?.userInputRequest ?? null; const needsUserInput = agentRuntimeNeedsUserInput(runtime); const needsSupervisorReconciliation = Boolean( @@ -943,6 +973,7 @@ export function ProjectSupervisorRuntimePanel({ ) : null} {pendingToolAction && pendingActionPresentation && + !pendingActionHasDedicatedCard && !readOnly && !needsSupervisorReconciliation ? (
- 请先重试项目总控,再由新总控继续安排此任务 + {dynamicArtRetryUnsupported + ? '请继续 game-chat 对话,由下一轮程序原型 Agent 重新审计缺口后委派' + : '请先重试项目总控,再由新总控继续安排此任务'} ) : null} @@ -1204,7 +1247,7 @@ export function ProjectSupervisorRuntimePanel({ controlBusy={controlBusy} onSubmit={onUserInput} /> - ) : needsUserInput && !readOnly ? ( + ) : needsUserInput && !planGddAwaitingDecision && !readOnly ? (

待回答问题未能读取,请稍后重试。

@@ -1269,9 +1312,14 @@ export function ProjectSupervisorRuntimeControls({ ) => void | Promise; }) { const pendingToolAction = runtime?.pendingToolAction ?? null; + const confirmableToolAction = agentRuntimePendingActionHasDedicatedCard( + pendingToolAction, + ) + ? null + : pendingToolAction; const userInputRequest = runtime?.userInputRequest ?? null; const needsUserInput = agentRuntimeNeedsUserInput(runtime); - if (!pendingToolAction && !userInputRequest && !needsUserInput) { + if (!confirmableToolAction && !userInputRequest && !needsUserInput) { return null; } @@ -1289,12 +1337,12 @@ export function ProjectSupervisorRuntimeControls({ 待回答问题未能读取,请稍后重试。

) : null} - {pendingToolAction ? ( + {confirmableToolAction ? (
- {pendingToolAction.tool} - {pendingToolAction.inputSummary ? ( - {pendingToolAction.inputSummary} + {confirmableToolAction.tool} + {confirmableToolAction.inputSummary ? ( + {confirmableToolAction.inputSummary} ) : null} + +
+ {openError ? ( + + {openError} + + ) : null} +
+ ) : null} + {detailsOpen && deliveredGdd ? ( + setDetailsOpen(false)} + /> + ) : null} + + ); +} + +const decisionStateLabels = { + confirmed: '已确认', + default_pending: '待确认默认项', + prototype_pending: '待原型验证', +}; + +const visibleGddSections = ( + gdd: NonNullable, +) => [ + { + title: '类型与美术', + content: [ + `类型:${gdd.game.genre.primary}${gdd.game.genre.fusion ? ` / ${gdd.game.genre.fusion}` : ''}`, + `视觉:${gdd.game.artStyle.visualType}`, + `关键词:${gdd.game.artStyle.keywords.join('、')}`, + `氛围:${gdd.game.artStyle.moodAndColor}`, + `MVP 美术边界:${gdd.game.artStyle.mvpArtBoundary}`, + ].join('\n'), + }, + { + title: '游戏支柱', + content: gdd.game.pillars + .map( + (pillar) => `${pillar.name}:${pillar.playerFeel};${pillar.mechanism}`, + ) + .join('\n'), + }, + { title: '核心循环', content: gdd.game.coreLoop.join(' → ') }, + { + title: '目标用户', + content: [ + `核心用户:${gdd.game.targetUsers.coreUsers}`, + `偏好:${gdd.game.targetUsers.preferences}`, + `单局时长:${gdd.game.targetUsers.sessionLength}`, + `参考作品:${gdd.game.targetUsers.referenceGames.join('、') || '无'}`, + ].join('\n'), + }, + { + title: '平台事实', + content: [ + `运行时:${gdd.game.platformFacts.runtime}`, + `视口:${gdd.game.platformFacts.viewports.join('、')}`, + `输入:${gdd.game.platformFacts.inputs.join('、')}`, + `预览:${gdd.game.platformFacts.preview}`, + ].join('\n'), + }, + { + title: 'MVP 系统', + content: gdd.game.mvpSystems + .map( + (system) => + `${system.system}:${system.minimalFunction}\n为什么需要:${system.whyRequired}\n验证:${system.verifyMethod}`, + ) + .join('\n'), + }, + { title: '暂不纳入', content: gdd.game.outOfScope.join('、') }, + { + title: '创作者提示', + content: [ + `先做:${gdd.game.creatorTips.doFirst}`, + `暂缓:${gdd.game.creatorTips.deferForNow}`, + `验证:${gdd.game.creatorTips.howToVerify}`, + `扩展条件:${gdd.game.creatorTips.expandWhen}`, + ].join('\n'), + }, + { + title: '原型验证项', + content: gdd.prototypeValidationItems + .map( + (item) => + `${item.question}\n微型原型:${item.microPrototype}\n观察:${item.observation}\n通过标准:${item.passCriterion}`, + ) + .join('\n'), + }, +]; + +/** + * Fast GDD 的正文视图。 + * + * 审批时挂在审批卡上,批准之后审批卡收掉、改由阶段进度条那条交付行触发——同一份 + * 弹层两处共用,用户在批准前后看到的是同一个正文。 + */ +export function GddDetailsDialog({ + gdd, + onClose, +}: { + gdd: NonNullable; + onClose: () => void; +}) { + return ( +
{ + if (event.target === event.currentTarget) { + onClose(); + } + }} + > +
+
+

{gdd.game.title}

+

{gdd.game.oneLiner}

+
+ {visibleGddSections(gdd) + .filter((section) => section.content) + .map((section) => ( +
+ {section.title} +

{section.content}

+
+ ))} + + {`Fast GDD v${gdd.version} · ${gdd.fingerprint}`} + +
+ +
+
+
+ ); +} + +export function GddApprovalCard({ + state, + busy, + error, + onRefresh, + onDecision, +}: GddApprovalCardProps) { + const [commentAction, setCommentAction] = useState | null>(null); + const [comment, setComment] = useState(''); + const [gddDetailsOpen, setGddDetailsOpen] = useState(false); + + if (!state?.displayGdd || !approvalCardVisible(state)) { + return null; + } + + const gdd = state.displayGdd; + const pending = state.pendingApproval; + const canDecide = Boolean( + pending && state.state === 'ready_for_approval' && !state.recoveryPending, + ); + + const submitComment = () => { + // 方案 §18.2:`recoveryPending` 期间只允许重试同一 ID,不允许提交决定。触发按钮 + // 已经由 `canDecide` 门住,但弹层是打开后才可能被后台 hydrate 翻掉资格的, + // 所以提交口要自己再判一次,不能只靠按钮 disabled。 + if (!canDecide || !commentAction || !comment.trim()) { + return; + } + void onDecision(commentAction, comment.trim()) + .then(() => { + setCommentAction(null); + setComment(''); + }) + .catch(() => undefined); + }; + + return ( +
+
+

{gdd.game.title || `Fast GDD v${gdd.version}`}

+

{gdd.game.oneLiner}

+
+ + {gdd.decisions.length > 0 ? ( +
+ {gdd.decisions.map((decision) => ( +
+ {decision.topic} + {decisionStateLabels[decision.state]} + {decision.answerSummary} +
+ ))} +
+ ) : null} + + + + {state.recoveryPending ? ( +
+ 审批状态正在恢复,请保持当前审批版本不变。 + +
+ ) : null} + + {error ? ( +
+ {error} +
+ ) : null} + + {pending ? ( +
+ + + +
+ ) : null} + + {commentAction ? ( +
{ + if (event.target === event.currentTarget) { + setCommentAction(null); + setComment(''); + } + }} + > +
+

+ {commentAction === 'revise' ? '填写修改意见' : '填写退回原因'} +

+