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 79d7d2a51..d0082f2f5 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 @@ -500,6 +500,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/provider_request_builders.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs index fbfbc3654..b7b20f385 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 @@ -128,10 +128,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 @@ -139,6 +137,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()); @@ -301,7 +311,18 @@ pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request( "{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 root_control_authority { + 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,各页必须来自同一内容 SHA-256,并在 agent.acceptance_update.evidence 中提交全部分页 file.read 的 actionId。取证未通过时不得展示或创建审批卡,只能针对原策划 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 节点保持不变。" ) @@ -450,6 +471,9 @@ pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request( mcp_catalog, )?) .with_tool_choice(platform_llm::LlmToolChoice::Required); + if plan_root { + 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)?; } @@ -530,9 +554,16 @@ 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)); } let mut request = apply_game_creator_llm_reasoning_effort(request, &llm)?; request = if agent_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID { @@ -762,7 +793,9 @@ mod tests { AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE, AGENT_RUNTIME_UPDATE_PLAN_FUNCTION_NAME, GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, GAME_CREATOR_PROJECT_PLANNING_FINAL_REPLY_SYSTEM_PROMPT, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, RUNTIME_PROMPT_SUPERVISOR_CHAT_COMPOSITION, + 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, }; fn native_input_required_fields( @@ -1298,6 +1331,173 @@ 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 的 actionId")); + 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 binding = 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("bind deliberately drifted plan root"); + let state = start_game_creator_agent_runtime_task_at( + &root, + &binding.agent_id, + "形成 Fast GDD", + &binding.run_id, + &binding.source, + "冻结立项目标", + Vec::new(), + ) + .expect("start drifted plan root"); + let error = build_game_creator_agent_background_tool_plan_request( + &root, + &state.agent_id, + &state.session_id, + &state.run_id, + &state.current_task, + &[], + 0, + &GameCreatorMcpCatalog { + fingerprint: String::new(), + servers: Vec::new(), + tools: Vec::new(), + }, + ) + .expect_err("plan source must pass the shared strong predicate"); + assert!(error.contains("project-supervisor-plan standard 顶层根 Run")); + } + #[test] fn provider_request_source_does_not_patch_natural_language_with_replace_chains() { let source = include_str!("provider_request_builders.rs"); 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 b2ceddff4..5872d6d93 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,28 @@ 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 或代码围栏。" + ) + } +} + pub(in crate::agent) fn append_game_creator_agent_tool_plan_audit_idempotent( root: &Path, record: serde_json::Value, @@ -173,6 +195,15 @@ 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 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 @@ -1033,10 +1064,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() { @@ -1388,4 +1422,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_driver/finalization.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/finalization.rs index cdae6a7d8..350a90f46 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 @@ -390,6 +390,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/recovery_scan.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/recovery_scan.rs index 5ab91750b..155bf3571 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 @@ -1612,6 +1612,9 @@ mod orphaned_external_generation_recovery_tests { .error .as_deref() .is_some_and(|error| error.contains("恢复锚点同时缺失"))); + assert!(read_plan_gdd_approval_pending(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 @@ -1628,6 +1631,9 @@ mod orphaned_external_generation_recovery_tests { ); 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(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 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..a0f68a94c 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 { @@ -222,12 +224,195 @@ 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)); + 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 +449,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 +516,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 +877,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 +1042,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, @@ -1077,6 +1542,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_contract_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion_contract_tests.rs index 1804a4094..ffef15de5 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 @@ -224,25 +224,16 @@ fn plan_goal_contract_draft() -> AgentRuntimeGoalContractDraft { AgentRuntimeGoalContractDraft { outcome: "完成一份可审批的 Fast GDD".to_string(), non_negotiables: vec!["保留用户明确要求".to_string()], - preferences: vec!["优先复用现有资源".to_string()], + preferences: Vec::new(), forbidden_assumptions: vec!["不能把工具成功当作目标完成".to_string()], open_questions: vec!["最终视觉效果仍需观察".to_string()], - acceptance_nodes: vec![ - AgentRuntimeGoalContractAcceptanceNodeDraft { - criterion_id: "behavior".to_string(), - criterion: "核心交互可运行".to_string(), - required: true, - required_evidence: vec!["tool:project.verify".to_string()], - dependencies: Vec::new(), - }, - AgentRuntimeGoalContractAcceptanceNodeDraft { - criterion_id: "final-observation".to_string(), - criterion: "最终结果符合用户目标".to_string(), - required: true, - required_evidence: vec!["tool:preview.validate".to_string()], - dependencies: vec!["behavior".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(), + }], } } 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/planning_approval.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_approval.rs index 9d1d37865..078645d70 100644 --- 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 @@ -144,12 +144,33 @@ fn pending_identity_matches_gdd(pending: &PlanGddApprovalPendingV1, gdd: &PlanGd && pending.run_identity.run_id == gdd.created_by_run_id } -fn pending_matches_gdd(pending: &PlanGddApprovalPendingV1, gdd: &PlanGddV1) -> bool { +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. @@ -240,6 +261,249 @@ pub(crate) fn create_plan_gdd_approval_pending_locked( 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 agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID || run_id.trim().is_empty() { + return Ok(PlanGddAcceptanceGateOutcome::NotApplicable); + } + let Some(binding) = + read_game_creator_agent_runtime_run_profile_binding(root, agent_id, run_id)? + 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()); + } + 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 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, @@ -776,20 +1040,51 @@ fn project_receipt_locked( } /// Reconcile receipt-derived projections after a Runner restart. Receipts -/// are the authority; this routine only repairs projections and never creates -/// an approval pending without an explicit upstream acceptance-gate caller. +/// 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 { let _lock = acquire_project_write_lock(root, "planning.approval-recovery") .map_err(|error| approval_error("PLAN_DURABILITY_FAILED", error))?; 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(false); + return Ok(recovery_pending); } validate_plan_gdd_approvals_against_gdds(&gdds, &approvals)?; - let mut recovery_pending = false; for receipt in &approvals { recovery_pending |= project_receipt_locked(root, &gdds, receipt)?; } @@ -1175,7 +1470,7 @@ fn plan_gdd_anchor_state_locked( Ok((pending_state, batch_state)) } -fn plan_gdd_session_matches_submission(session: &PlanSessionV1, gdd: &PlanGddV1) -> bool { +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 @@ -1188,6 +1483,15 @@ fn plan_gdd_session_matches_submission(session: &PlanSessionV1, gdd: &PlanGddV1) && 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 { @@ -1203,7 +1507,7 @@ fn plan_gdd_session_matches_receipt( gdd: &PlanGddV1, receipt: &PlanGddApprovalV1, ) -> bool { - if !plan_gdd_session_matches_submission(session, gdd) { + if !plan_gdd_session_identity_matches_gdd(session, gdd) { return false; } let expected_phase = match receipt.action.as_str() { @@ -1320,7 +1624,25 @@ pub(crate) fn plan_gdd_completion_blocker_at_locked( }) .collect::>(); if plan_gdds.is_empty() { - return None; + return match read_game_creator_agent_runtime_goal_contract_at( + root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + ) { + Ok(Some(_)) => Some(plan_gdd_completion_blocker( + "blocked", + "当前立项策划根 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"); @@ -1347,6 +1669,80 @@ pub(crate) fn plan_gdd_completion_blocker_at_locked( && 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) => { @@ -1364,7 +1760,7 @@ pub(crate) fn plan_gdd_completion_blocker_at_locked( )); } }; - if !plan_gdd_session_matches_submission(&session, latest) { + if receipt.is_none() && !plan_gdd_session_matches_submission(&session, latest) { return Some(plan_gdd_completion_blocker( "needs-reconciliation", "planning session 尚未收口到当前 Fast GDD 提交", @@ -1378,16 +1774,6 @@ pub(crate) fn plan_gdd_completion_blocker_at_locked( )); } - 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(), - )); - } - }; let (pending_anchor, batch_anchor) = match plan_gdd_anchor_state_locked(root, latest) { Ok(state) => state, Err(error) => { 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 index 083967865..d8667748e 100644 --- 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 @@ -2184,6 +2184,180 @@ mod tests { 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(&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(&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(&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(&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( @@ -3110,7 +3284,11 @@ mod tests { ) .expect("receipt without terminal observation must reconcile"); assert_eq!(blocker.status, "needs-reconciliation"); - assert!(blocker.summary.contains("terminal observation")); + assert!( + blocker.summary.contains("terminal observation"), + "unexpected blocker: {}", + blocker.summary + ); append_agent_db_terminal_observation_if_missing_for_action( &root, @@ -3161,6 +3339,47 @@ mod tests { 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")); + cleanup_fixture(root); + } + #[test] fn plan_gdd_completion_blocker_rejects_mismatched_observed_pending() { let (root, context, input) = submit_fixture(); @@ -3197,7 +3416,11 @@ mod tests { ) .expect("mismatched pending must require reconciliation"); assert_eq!(blocker.status, "needs-reconciliation"); - assert!(blocker.summary.contains("pending 尚未按 receipt 收口")); + assert!( + blocker.summary.contains("pending 尚未按 receipt 收口"), + "unexpected blocker: {}", + blocker.summary + ); cleanup_fixture(root); } @@ -3365,4 +3588,841 @@ mod tests { 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(&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(&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(&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(&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(&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(&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(&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(&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(&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(&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(&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(&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(&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(&root) + .expect("read initial pending") + .expect("initial pending exists"); + remove_plan_gdd_approval_pending(&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(&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(&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(&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(&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_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/run_status.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/run_status.rs index f420a6218..2b23f9b8a 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 @@ -219,6 +219,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 agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { claimed_static_delegate_deliveries_at(root, agent_id, run_id)? } else { @@ -258,6 +300,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_native_tools.rs b/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs index d8f7c0f03..65569b2b1 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 @@ -13,7 +13,8 @@ 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, - PLAN_SUBMIT_GDD_INPUT_SCHEMA_VERSION, PLAN_SUBMIT_GDD_TOOL, + 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, @@ -359,6 +360,53 @@ pub(crate) fn build_agent_runtime_native_function_tools_for_agent( Ok(functions) } +/// 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 @@ -2068,6 +2116,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"], @@ -2098,6 +2147,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] diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 8d8691744..7477e6e5c 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -1,5 +1,14 @@ # 决策记录 +## 2026-08-15 M1C-2a 隔离工作树实现:固定 Goal Contract 与审批前置门 + +- **本轮范围**:只实现 Supervisor 根 run 的 Goal Contract / Acceptance Graph 与 Fast GDD 审批前置门;不接 `M1C-2b` 澄清中转、审批 UI、构建准入或下游完整构建。当前变更仍在隔离 worktree,尚未合回原分支。 +- **固定合同**:`project-supervisor-plan / standard` 的首轮与格式修复请求都必须且只能调用一次 `agent.goal_contract`。按项目变化的字段只有 `outcome / nonNegotiables / forbiddenAssumptions / openQuestions`;`preferences` 固定空数组,`acceptanceNodes` 固定为唯一 `fast-gdd-serves-intent` 节点,required evidence 固定 `tool:file.read`。其它 source 保持动态合同,即使使用同名 criterionId 也不套 Fast GDD 特例。 +- **证据合同**:Fast GDD evidence 只接受当前 Supervisor 根 run 自己读取 `game/fast_gdd.md` 的成功 action receipt。receipt 保存规范化路径、完整内容 SHA-256 与行覆盖摘要;多页必须从 `startLine=1` 无缺口、无重叠覆盖到 EOF,全部页同 hash/同总行数,`agent.acceptance_update.evidence` 必须列出所有分页 actionId。普通 Graph 读取只验 durable receipt 形状与完整覆盖;当前 Markdown hash 只在审批前且无 exact pending/receipt 时复核,审批后的状态投影不会让旧 Graph 损坏。 +- **取证与返工三态**:planning delivery 先由同一根 run 的 `agent.run_status` durable 认领。Graph 缺失、not-observed、project revision/hash 过期或证据不完整属于 `NeedsEvidence`,下一步是 `file.read`,不得提前返工;只有当前完整证据支撑的显式 failed 属 `RepairRequired`,才返回原 delivery 的 `repairOfDelegationId`;passed 才幂等创建 `gdd-approval` pending。`run_status` 在持有项目锁时调用 locked gate,并在 Ready 数为零时仍重放,封住旧 action 已认领但 gate 尚未落盘的崩溃窗口;GDD create 到 child/delivery 完成前保持惰性,不抢断 M1B-2 恢复。 +- **恢复与完成门**:acceptance update、delivery claim、Runner recovery、completion 与 finalization 都消费同一 gate。恢复不重新执行 Provider 或 `file.read` 动作,只复核 durable Graph、动作回执与当前 Markdown hash;missing pending 按原 approvalRequestId/identity 补建,exact pending 与 receipt 优先于 Graph/hash 复核,冲突则失败关闭。当前根没有自己的 GDD 时,上一根遗留 Markdown/Graph 不能绕过完成门。 +- **回归与门禁**:已新增真实 `file.read` receipt 的错路径、错误 actor、旧 hash、120/200 行部分覆盖、两页完整覆盖,claim 前后、零 Ready 重放、Dispatched 中间态、审批后 Markdown 改写、pending 二次 recovery 幂等、无本根 GDD、非 plan 同名 criterion 与 M1B-2 双锚缺失恢复顺序用例;终审另补 exact pending 优先于 stale Graph、exact receipt 优先于后来 failed Graph 及 pending/receipt 冲突仍失败关闭。`cargo fmt --check`、`cargo test goal_contract`(14 passed)、`cargo test m1c2a`(14 passed)、`cargo test acceptance_graph`(13 passed)、`cargo test planning_submit`(51 passed)、`cargo test planning_approval`(1 passed)、`cargo test finalization`(52 passed)及 M1B-2 双锚恢复回归(1 passed)均通过;`cargo check --all-targets`、`npm run check:encoding`(5403 files)与 `git diff --check` 通过。扩展 `cargo test autonomous_completion_contract` 在终审修复前曾整组 122 passed;最终代码下两次整组均为 121 passed,并由同一条无 plan 路径交集的 `game_chat_pure_continue_inherits_failed_root_semantics_and_manifest_progress` 停在 `planning` 超时,精确单测随即 1 passed(9.49s)。判为并行负载时序抖动,修复边界不明确且超出 M1C-2a,按本轮缺陷策略保留、不改 game-chat。门禁期间另发现 receipt 已存在时 completion gate 仍错误要求提交前 session 状态,导致审批后无法继续核对 terminal observation;已收窄为仅无 receipt 时校验提交态,有 receipt 时由既有 receipt session 强判据收口,整组 `planning_submit` 复跑通过。 + ## 2026-08-15 M1C-1 隔离工作树收口:审批核心与专用完成门已落地,生产前置门保持后置 - **本轮落地**:在 `planning_storage.rs` 增加 `plan-gdd-approval.v1` receipt、`plan-gdd-approval-pending.v1` projection、comment/decision/receipt/pending 的 typed fingerprint 与 strict canonical 校验;审批 observation 固定校验 tool/status、版本摘要、detail 前缀和规范化 comment。`planning_approval.rs` 增加 receipt create-only、三动作幂等(`committed / replayed / already-decided`)、版本/指纹竞态防护、receipt 后 index/Markdown/audit/terminal observation/session 投影与恢复,以及只读的 plan 根专用 completion blocker;`commands.rs` 暴露 `decide_game_creator_plan_gdd`。 diff --git a/docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md b/docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md index 01b46c2bd..751562f89 100644 --- a/docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md +++ b/docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md @@ -1,9 +1,9 @@ # 立项策划 Agent(Fast GDD)技术方案 - 日期:2026-08-10 -- 状态:2026-08-12 **M0 代码工作包全部完成**(`M0A-2`、`M0B-1`、`M0B-2` 已合入 M0 集成分支并通过各自门禁,见第 23.4 节);同日 **D6 作废、拓扑改变**,`M0A-1` 交付的文档基线随之失效,需以工作包 `M0A-3` 修订,**修订完成前 M0 不计完整完成**(见第 1.1 节)。2026-08-13 **D9 二次作废、D10 作废,由 D11 取代**:立项策划节点改为 Project Supervisor 通过 `agent.delegate` 发起的静态委派子 Agent,问询复用 PR #165 中转链路(见第 1.1 节「D11 新拓扑」);D11 依赖 WP1(静态委派澄清轮次与返工深度拆分)为强制前置,**该前置已于 2026-08-13 落地并合入**(`WP1` 生产代码 + `WP2` 回归,完成状态与门禁见第 23.5 节),澄清轮次上限现为 3(game-chat source 仍为 1)。随后 `M1A-1`、`M1A-2`、`M1A-3`、`M1A-4` 已分别落地:`M1A-2` 仅收口两层工具面、`project-planning` role brief 注入和 fail-closed 拒绝边界,`M1A-4` 收窄 plan 根 run 的子 Agent 创建面。**2026-08-14 `M1B-1` 已通过门禁并合入本分支**:已落地 `.agent/planning` storage module、strict schema/typed 指纹/canonical parser、GDD 版本链、session 原子恢复、Runtime 写入身份及只挡写门禁;golden vector 与 11 个定向 storage 测试通过,writer/index/recovery 门禁已完成。**2026-08-15 `M1B-2` 工作包已通过本包门禁并以 `27c3eb847` 合入本分支**:已落地 `plan.submit_gdd`、exact planning Provider binding/structured injection、专用提交点与崩溃恢复;本包不包含 `gdd-approval` planning pending、审批等待、receipt、审批命令或 UI。**2026-08-14 `M1C-0` 已合回本分支**:仅新增用户修订状态及 lineage 分类,不包含审批写入方。**2026-08-15 `M1C-0b` 已通过定向门禁并完成**:只改静态委派 durable status 的前向兼容读路径,未知字符串显式保留为 `Unknown(raw)` 并最大化阻塞;不含审批写入方。**当前隔离 worktree 已完成 M1C-1 的审批核心、receipt 投影/恢复和 plan 根专用 completion blocker;生产 acceptance-gate pending caller、验收图接线、审批 UI 与构建准入仍后置,完整交付未完成**(见第 23.6、23.8 节)。后续执行计划见第 23.6 节。 +- 状态:2026-08-12 **M0 代码工作包全部完成**(`M0A-2`、`M0B-1`、`M0B-2` 已合入 M0 集成分支并通过各自门禁,见第 23.4 节);同日 **D6 作废、拓扑改变**,`M0A-1` 交付的文档基线随之失效,需以工作包 `M0A-3` 修订,**修订完成前 M0 不计完整完成**(见第 1.1 节)。2026-08-13 **D9 二次作废、D10 作废,由 D11 取代**:立项策划节点改为 Project Supervisor 通过 `agent.delegate` 发起的静态委派子 Agent,问询复用 PR #165 中转链路(见第 1.1 节「D11 新拓扑」);D11 依赖 WP1(静态委派澄清轮次与返工深度拆分)为强制前置,**该前置已于 2026-08-13 落地并合入**(`WP1` 生产代码 + `WP2` 回归,完成状态与门禁见第 23.5 节),澄清轮次上限现为 3(game-chat source 仍为 1)。随后 `M1A-1`、`M1A-2`、`M1A-3`、`M1A-4` 已分别落地:`M1A-2` 仅收口两层工具面、`project-planning` role brief 注入和 fail-closed 拒绝边界,`M1A-4` 收窄 plan 根 run 的子 Agent 创建面。**2026-08-14 `M1B-1` 已通过门禁并合入本分支**:已落地 `.agent/planning` storage module、strict schema/typed 指纹/canonical parser、GDD 版本链、session 原子恢复、Runtime 写入身份及只挡写门禁;golden vector 与 11 个定向 storage 测试通过,writer/index/recovery 门禁已完成。**2026-08-15 `M1B-2` 工作包已通过本包门禁并以 `27c3eb847` 合入本分支**:已落地 `plan.submit_gdd`、exact planning Provider binding/structured injection、专用提交点与崩溃恢复;本包不包含 `gdd-approval` planning pending、审批等待、receipt、审批命令或 UI。**2026-08-14 `M1C-0` 已合回本分支**:仅新增用户修订状态及 lineage 分类,不包含审批写入方。**2026-08-15 `M1C-0b` 已通过定向门禁并完成**:只改静态委派 durable status 的前向兼容读路径,未知字符串显式保留为 `Unknown(raw)` 并最大化阻塞;不含审批写入方。**当前隔离 worktree 已完成并通过 `M1C-2a` 本包门禁**:已接通固定 Goal Contract、Supervisor 根 run 的完整分页 `file.read` evidence、claim 后三态 acceptance gate、审批 pending 恢复及 completion/finalization 门;改动尚未合回。**`M1C-2b`、审批 UI、构建准入与下游完整构建仍后置,M1 整体不可交付**(见第 23.6、23.8 节)。后续执行计划见第 23.6 节。 - 适用范围:AI 游戏创作独立 App、Project Supervisor、Agent Runtime、本地项目策划 sidecar 与后续完整构建准入 -- 当前实现边界:本文件是后续详细设计与实现的仓库内阶段基线;M0 工作包冻结 Fast GDD 合同并修复现有 owner 验证、game-chat retry 与前端投影边界,`M1A-1`~`M1A-4` 已提供 plan source、两层工具面、角色 brief 与子 Agent 创建面收窄的 Runtime 基础,`M1C-0` 已提供用户修订 lineage 分类,已合入的 `M1B-1` 提供 storage 基础与写入隔离;`M1B-2` 工作包已提供提交点与恢复,`M1C-0b` 已补齐静态委派未知 durable status 的前向兼容读路径,当前隔离 worktree 另已补齐 M1C-1 receipt/审批核心、投影恢复和 plan 根完成门;生产 acceptance-gate pending caller、验收图接线、审批 UI、构建绑定和正式入口仍不可用 +- 当前实现边界:本文件是后续详细设计与实现的仓库内阶段基线;M0 工作包冻结 Fast GDD 合同并修复现有 owner 验证、game-chat retry 与前端投影边界,`M1A-1`~`M1A-4` 已提供 plan source、两层工具面、角色 brief 与子 Agent 创建面收窄的 Runtime 基础,`M1C-0` 已提供用户修订 lineage 分类,已合入的 `M1B-1` 提供 storage 基础与写入隔离;`M1B-2` 已提供提交点与恢复,`M1C-0b` 已补齐静态委派未知 durable status 的前向兼容读路径,`M1C-1` 已提供 receipt/审批核心、投影恢复和 plan 根完成门;当前隔离 worktree 的 `M1C-2a` 已补齐固定 Goal Contract、验收图证据与生产 acceptance gate。`M1C-2b` 澄清中转、审批 UI、构建绑定和正式入口仍不可用 ## 1. 背景与目标 @@ -1206,7 +1206,11 @@ type PlanSubmitGddResult = { 此时用户以为已经定稿、系统却卡住,且 receipt 不可回滚(要改只能提交并决定新版本),这个状态对用户不可解释。 -**固定顺序**:Supervisor 收到 `plan.submit_gdd` 的 `Submitted` 回执后,**必须先**以 `tool:file.read` 对 `game/fast_gdd.md` 取证并确认合同的全部 required 验收节点,**通过后才允许把 `gdd-approval` pending 暴露给用户**。取证未通过时**不创建审批卡**,改为发起返工委派(`repair_of` 指向原 delivery),由策划子 Agent 修订后重新提交。 +**固定顺序**:Supervisor 收到 `plan.submit_gdd` 的 `Submitted` 回执后,**必须先**以 `tool:file.read` 对 `game/fast_gdd.md` 取证并确认合同的全部 required 验收节点,**通过后才允许把 `gdd-approval` pending 暴露给用户**。尚未取证、分页未覆盖全文或回执 SHA-256 已不是当前 Markdown 时,下一步仍是由当前 Supervisor 根 run 重新读取并更新验收图,不能提前返工;只有当前根、当前 project revision、当前 Markdown 全文证据支撑的显式 `failed` 才表示“取证未通过”,此时**不创建审批卡**,改为发起返工委派(`repair_of` 指向原 delivery),由策划子 Agent 修订后重新提交。 + +M1C-2a 将该取证合同进一步冻结为:evidence 必须来自当前 `project-supervisor-plan / standard` 根 run 自己的 `file.read`,策划子 run 或同根其它 child 的回执不能替代 Supervisor 的语义判断;路径必须精确为 `game/fast_gdd.md`。读取从 `startLine=1` 开始,按工具上限分页,无缺口、无重叠地覆盖到 EOF;所有页必须报告同一完整内容 SHA-256 和总行数,`agent.acceptance_update.evidence` 必须列出全部分页 actionId。普通 Acceptance Graph durable 读取只复核回执形状、身份与完整覆盖,不把审批决定后 Markdown 状态投影造成的 hash 变化误判为 Graph 损坏;当前 Markdown hash 只在尚无 receipt/pending 的审批前置门复核。 + +原 planning delivery 必须先由同一 Supervisor 根 run 通过 `agent.run_status` durable 认领。`Ready` 只返回认领动作,不给 `repairOfDelegationId`、也不建审批卡;`ClaimedByParent` 后才根据上述三态产生“继续取证 / 指向原 delivery 的返工 / 建卡”。`plan.submit_gdd` 已 create 但 child 或 delivery 尚未完成的恢复窗口保持惰性,由 M1B-2 原恢复链先收口,不能被审批门抢先判错。 **为什么必须是这个方向**:两道检查的性质不同,混序会让它们互相抵消。 @@ -1218,7 +1222,7 @@ type PlanSubmitGddResult = { 机器判合格在前、人判满意在后,三道门各自的失败路径互不干扰;反序则会出现「人已经批了、机器才说不合格」这种没有出路的组合。schema 那道天然满足本约束——校验失败不产生版本,不产生版本就没有审批卡,结构上不可能反序。 -**恢复语义**:取证已完成但 pending 尚未创建时,按既有 identity 幂等补建审批卡,不重新取证;pending 已创建则证明取证已通过,恢复路径不得再次要求取证。取证结果本身不落新的 durable 记录,它就是现役 Acceptance Graph 的 evaluation。 +**恢复语义**:取证已完成但 pending 尚未创建时,按既有 identity 幂等补建审批卡,不重新执行 Provider 或 `file.read` 动作;恢复只复核 durable Acceptance Graph、动作回执与当前 Markdown hash。pending 已创建则证明取证已通过,恢复路径不得再次要求取证,也不得因审批后的 Markdown 状态改写否定旧 Graph。取证结果本身不落新的平行记录,它就是现役 Acceptance Graph 的 evaluation。 ### 13.1 `gdd-approval` pending @@ -1827,7 +1831,7 @@ D9 之后,root 是未变的 Project Supervisor,它在 `standard` 下天然 仍然成立、且与本项无关的是**通用 handoff 安全边界**:任何 Provider 成功响应都必须先过现役 storage 的大小、控制字符、敏感键、绝对路径、容量与 durable identity 门并落盘,才允许被消费;storage 门拒绝或 handoff 无法 durable 提交时,禁止保存不安全正文、补 lifecycle completed 或自动重发。这是现役机制,策划链路照用,不需要额外裁决。 - ~~**plan source 与 Goal Contract 协议的关系**~~——**2026-08-13 已裁决**:`project-supervisor-plan` **进** `agent_runtime_supervisor_source_is_trusted`;做方案链路正常参与 Goal Contract 协议,不做豁免。原「裁决冻结前依赖 plan source 可信身份的 M1 代码不得合入」的硬门随本裁决解除。 - *原阻塞理由已失效*:2026-08-12 排除该方向的依据是「出口门在验收阶段照卡且更难救——合同强制至少一个带 `requiredEvidence` 的 required 节点,而**策划 Agent 按设计一项证据工具都不该有**,`.agent/planning/**` 由 Runtime 写、不产生 Agent 回执,必然留下永不 `passed` 的节点」。该推理写于 D6/D9 拓扑,当时「plan run」就是策划 Agent 本身。D11 拆成两层后两个前提都不成立:① 策划子 Agent 的 exact allowlist 恰好含 `file.read` / `file.list`,二者都在 `agent_runtime_acceptance_evidence_tools()` 白名单内;② 更根本的是证据**不必由它出**——`validate_acceptance_evidence_identity_at`(`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/acceptance_graph.rs:152`)对证据来源的唯一要求是 `binding.root_agent_id/root_run_id` 等于合同的根,**不要求是根 Agent 自己的回执**;委派子 Agent 的 binding 根就是 Supervisor,因此两层的 `file.read` 回执都是合法验收证据。 + *原阻塞理由已失效*:2026-08-12 排除该方向的依据是「出口门在验收阶段照卡且更难救——合同强制至少一个带 `requiredEvidence` 的 required 节点,而**策划 Agent 按设计一项证据工具都不该有**,`.agent/planning/**` 由 Runtime 写、不产生 Agent 回执,必然留下永不 `passed` 的节点」。该推理写于 D6/D9 拓扑,当时「plan run」就是策划 Agent 本身。D11 拆成两层后,Goal Contract 与最终语义验收都属于 Supervisor 根 run;策划子 Agent 负责提交结构化 GDD,不负责替根判断“是否服务用户意图”。M1C-2a 因此把 Fast GDD 特例收紧为**只接受当前 Supervisor 根 run 自己的 `file.read` 回执**,不再沿用通用 Acceptance Graph 对同根 child evidence 的宽松能力。Supervisor 的 standard 工具面已有 `file.read` / `agent.acceptance_update`,固定节点可正常收敛,同时不扩大策划子 Agent 的工具面。 *三道门逐一可满足*:入口门只规定时序——合同不存在时本轮必须且只能是一个 `agent.goal_contract`,因此做方案链路的 turn 1 冻结合同、turn 2 才发委派,多一次 Provider 调用而已,不是阻塞;内容门要求 ≥1 个 required 节点且 `requiredEvidence` 命中白名单,`tool:file.read` 满足;出口门要求合同已冻结且验收图收敛,两者都能达成。 @@ -1838,7 +1842,7 @@ D9 之后,root 是未变的 Project Supervisor,它在 `standard` 下天然 - **`plan.submit_gdd` 的 strict schema** 承担机器可判的字段存在性与字段约束(`oneLiner` 长度、`pillars` 条数、MVP 系统条数等,见第 8.2 / 8.3 节),这是硬校验; - **Goal Contract 验收图**承担「产物确实服务了用户这次的意图」,由 Supervisor 以 `tool:file.read` 对 `game/fast_gdd.md` 取证后确认节点。 - Goal Contract 中**按项目变化**的只有 `outcome` / `nonNegotiables` / `forbiddenAssumptions` / `openQuestions` 四项——它们承载入口门 Prompt 要求的「自行理解用户真正要做的事」;`acceptanceNodes` 近乎固定,不算「照抄固定信号代替理解」,因为「什么算一份合格 GDD」本就不是本轮需要理解的东西。 + Goal Contract 中**按项目变化**的只有 `outcome` / `nonNegotiables` / `forbiddenAssumptions` / `openQuestions` 四项——它们承载入口门 Prompt 要求的「自行理解用户真正要做的事」;`preferences` 在该 source 下固定为空数组,`acceptanceNodes` 固定为唯一 `fast-gdd-serves-intent` 节点,不算「照抄固定信号代替理解」,因为「什么算一份合格 GDD」本就不是本轮需要理解的东西。其它可信 source 继续使用现役动态 Goal Contract,不因 criterionId 恰好同名而套用 Fast GDD 的路径、actor 或分页特例。 本条**不改变** D1(游戏支柱按项目生成 2~4 条,四个候选不固定套用)与 D8(GDD 不含引擎字段,平台事实由 Runtime 注入)已冻结的口径;验收图据以生成的合格标准以本文档第 5.3、8.2、8.3 节为准。 - ~~**策划节点 `agentId` / source 命名**~~——**2026-08-13 已裁决冻结**:策划子 Agent `agentId=project-planning`;Supervisor 侧承载「做方案」入口的新可信 source 取全新常量名 `project-supervisor-plan`(不沿用历史 `project-supervisor-plan-chat` 字面,避免语义改变后接错旧代码路径)。第 1.1 节批二不再被本项阻塞,改由下方新增的 `build.rs` 一致性待裁决项阻塞。 @@ -1921,7 +1925,7 @@ M0 完成不表示完整策划闭环已经上线。`M1A-1`~`M1A-4`、`M1B-1` | 三 | `project-planning` 的 agentCatalog 登记 | **已完成**(机制冻结见第 3.1 节;**代码亦已落地**,2026-08-13:manifest、prompt bundle、runtime adapter、`prompt.rs` 角色合成分支及四处 needs_change 全部合入) | | 四 | `M0A-3` 批二:拓扑与工具面部分 | **已完成**(2026-08-13),拆解见下 | | 四之余 | schema 与 golden vector 收口 | **已完成**(2026-08-13),拆解见下 | -| 五 | M1 本体:策划闭环功能实现 | **`M1A-1`、`M1A-2`、`M1A-3`、`M1A-4`、`M1B-1`、`M1B-2`、`M1C-0`、`M1C-0b` 已落地并合入**;当前隔离 worktree 已实现 M1C-1 审批核心、receipt 投影/恢复、terminal observation 完整性校验和 plan 根专用 completion blocker。**生产 acceptance-gate pending caller、验收图接线、审批 UI、M1C-2b 澄清中转与构建准入仍未完成**,因此 M1C-1 尚未合回、M1 整体不可交付。合入门见第 23.8 节 | +| 五 | M1 本体:策划闭环功能实现 | **`M1A-1`、`M1A-2`、`M1A-3`、`M1A-4`、`M1B-1`、`M1B-2`、`M1C-0`、`M1C-0b`、`M1C-1` 已落地并合入**;当前隔离 worktree 的 `M1C-2a` 已完成固定 Goal Contract、完整分页 `file.read` evidence、claim 后三态 acceptance gate、pending exactly-once 恢复与 completion/finalization 接线,并通过本包定向门禁,尚未合回。**`M1C-2b` 澄清中转、审批 UI、构建准入与下游完整构建仍未完成**,因此 M1 整体仍不可交付。合入门见第 23.8 节 | 批二在 2026-08-13 拆成两半,因为其中一半在 M1 代码存在之前**做不完**: @@ -2009,8 +2013,8 @@ M0 完成不表示完整策划闭环已经上线。`M1A-1`~`M1A-4`、`M1B-1` | `M1B-2` | `plan.submit_gdd` 原生工具、exact planning Provider 请求绑定与 GDD 提交点 | `M1B-1` | **工作包已通过本包门禁并以 `27c3eb847` 合入本分支**。实现合同:四类 request kind 全部写 v3 lifecycle、required binding 与同一 dedicated structured-injection user message;只有 `tool-plan` 可生成 sole-submit v4 batch;提交点后只修复 index/Markdown/session successor、终止策划子 run,并保留 generic v5 standalone pending + v4 batch anchors,不创建 `gdd-approval` planning pending/审批等待。定向 Rust、`cargo check --offline`、格式、编码与 diff 门禁均已通过;完整审批链路与产品可交付仍留给后续工作包。**2026-08-15 订正:本包的定向门禁漏掉了两处跨包回归,全量 CI 才暴露**——① `validate_next_entry` 把 `same_tool_plan_repair_chain` 提到 `loop_iteration` 分支之外,使含本轮量(steer cursor / goal revision / planning binding)的判据作用于跨 loop 续跑,两条 `tests::goal` 与一条交接用例失败;② 新增的 `missing_plan_submit_anchor_candidate_at` 在 resume 最前面强读 runtime state,短路了下游对不可读 state 的 fail-closed 兜底。均已修,详见 `decision-log.md` 同日条裁决一/二。**教训已记入门禁**:新增或移动校验必须同时补一条「正向必须被接受」的回归,只钉拒绝挡不住作用域被放大 | | `M1C-0` | 新增 `StaticDelegateContractStatus::UserRevisionRequested` 与分类分支 | `M1A-1` | **已落地**:无审批写入方、是惰性路径;用户修订跳不增 `repair_depth` 也不重置 `clarification_round`,连续修订可通过;做游戏链路返工仍在 `depth=1` 被拒;原有三种状态与 `UserRevisionRequested` 的已知行为保持不变,未知 durable status 的前向兼容由已完成的 `M1C-0b` 显式承接,不在本包静默降级或改变 | | `M1C-0b` | 静态委派 durable enum 的前向兼容粒度:未知 `contract_status` 解析为显式 `Unknown` 并最大化阻塞 | `M1C-0` | **已完成**:纯读路径、无写入方、审批状态、pending、receipt 或 UI(不含 `M1C-1`)。四种已知 durable 值保持原 serde;未知字符串解析为 `Unknown(raw)`,非字符串仍拒绝,`Serialize` 及读-改-写均原样保留 raw。`Unknown` 计入 completion barrier 与 waiting blocker,返工入口无条件拒绝(含 `depth=0`),lineage 按“其它”最保守分类(`depth + 1`、`round = 0`);planning Provider、自治 liveness、终态扫描等既有读路径同步 fail closed。截断、非法 JSON、非 UTF-8、超过 128 KiB 的 sidecar 仍按整目录 fail closed,不做单条跳过。**不新增或改变 `M1B-*` 功能依赖(仅复核其既有读路径);不包含 `M1C-1` 的审批写入、receipt、UI 或构建准入** | -| `M1C-1` | `gdd-approval` pending、审批命令、receipt;receipt 写入上述 status 与 plan 根完成门 | `M1B-2`、`M1C-0`(前向兼容粒度另见 `M1C-0b`) | **隔离 worktree 已实现核心**:三动作幂等、版本/指纹竞态防护、receipt 后 index/Markdown/audit/terminal observation/session 投影与恢复、generic v5/v4 anchor 精确消费、terminal summary 完整性校验,以及仅作用于 exact plan 根的只读 completion blocker;已补无 pending、awaiting、receipt 后 observation 缺失、完整收口、非 plan root scope 与 pending identity 漂移回归。**生产 acceptance-gate pending caller 与验收前置取证门后置到 `M1C-2a`,审批 UI / 澄清中转 / 构建准入仍未完成;本包尚未合回原分支**。连续修订 barrier 与 `UserRevisionRequested` 规则按第 23.7 节执行 | -| `M1C-2a` | Goal Contract 接线:turn 1 冻结、固定验收图、审批前置门取证 | `M1C-1`、`M1A-3` | turn 1 只能是一个 `agent.goal_contract`;`acceptanceNodes` 不接受自定义;**第 13.0 节审批前置门:取证未通过时不出现审批卡、而是产生返工委派**(取证顺序是协议时序问题,归本包而非 `M1C-1`) | +| `M1C-1` | `gdd-approval` pending、审批命令、receipt;receipt 写入上述 status 与 plan 根完成门 | `M1B-2`、`M1C-0`(前向兼容粒度另见 `M1C-0b`) | **已落地并合入**:三动作幂等、版本/指纹竞态防护、receipt 后 index/Markdown/audit/terminal observation/session 投影与恢复、generic v5/v4 anchor 精确消费、terminal summary 完整性校验,以及仅作用于 exact plan 根的只读 completion blocker;生产 acceptance-gate pending caller 与验收前置取证门按拆包纪律由 `M1C-2a` 承接。审批 UI / 澄清中转 / 构建准入仍未完成。连续修订 barrier 与 `UserRevisionRequested` 规则按第 23.7 节执行 | +| `M1C-2a` | Goal Contract 接线:turn 1 冻结、固定验收图、审批前置门取证 | `M1C-1`、`M1A-3` | **当前隔离 worktree 已完成并通过本包门禁,尚未合回**:turn 1 的 request-scoped schema 与格式修复都只允许一个固定 `agent.goal_contract`;按项目变化的四项之外,`preferences=[]`、唯一验收节点及证据工具均冻结。Fast GDD evidence 只接受当前 Supervisor 根 run 对 `game/fast_gdd.md` 从第 1 行到 EOF 的同 hash 完整分页;无/旧证据先继续读取,显式 failed 才给原 delivery 的 `repairOfDelegationId`,passed 且 delivery 已认领才建 pending。pending/recovery/completion/finalization 均按同 identity 幂等,审批后 Markdown 改写不损坏 Graph。格式、Provider 强判据、M1C-2a、Acceptance Graph、planning submit/approval、finalization、all-targets、编码与 diff 门禁均通过;扩展 autonomous completion 整组的无关 game-chat 并行超时及精确复跑结果见 decision-log 同日条,不改该路径。不包含 `M1C-2b`、UI 或构建准入 | | `M1C-2b` | 澄清中转接线、轮次派生、预算注入 | `M1C-2a` | 3 轮上限;continuation 重放幂等不增加轮次;答案绑定冲突被拒 | | `M1D-1` | 前端 hydrate 与 GDD 审批卡 | `M1C-2b` | 前端只经 `hydrate_game_creator_plan_gdd_state` 读权威状态,不在页面侧合成批准事实 | | `M1D-2` | 入口分流与阶段进度 | `M1D-1` | 「直接开建」跳过路径与现状零差异 |