diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs index ede836cf6..29ff244fe 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs @@ -1633,6 +1633,7 @@ mod tests { request_slot: "slot-1".to_string(), web_search_enabled: false, allow_idle_context_compaction: false, + planning_session_binding: None, } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/prompt.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/prompt.rs index bbfc7865d..145cf4256 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/prompt.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/prompt.rs @@ -549,7 +549,11 @@ pub(crate) fn game_creator_agent_runtime_tool_plan_system_prompt_for_agent( if agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { return prompt; } - game_creator_project_supervisor_tool_plan_prompt(&prompt, editor_api_key_is_configured(), source) + game_creator_project_supervisor_tool_plan_prompt( + &prompt, + editor_api_key_is_configured(), + source, + ) } /// The planning child has an exact native allowlist. Do not reuse the broad @@ -561,7 +565,7 @@ pub(crate) fn game_creator_agent_runtime_tool_plan_system_prompt_for_agent( /// Provider request builder after the identity binding has been checked. fn game_creator_project_planning_tool_plan_system_prompt() -> String { format!( - "你正在使用 Genarrative AI 游戏创作多智能体 Runtime。当前请求只广告以下原生函数:file.read、file.list、update_agent_plan、respond_to_user。只能直接调用这些函数;不得调用未广告的函数、动态工具或普通文本伪造工具调用。\n\n读取工具只用于获取项目内已有文本和文件摘要;不要把读取结果当作已经写入、提交、审批或构建完成。需要记录真实计划变化时调用 update_agent_plan,arguments 必须提交完整 steps;已有足够 observation、需要交付终态信封或当前轮次应收束时调用 respond_to_user。Runtime 身份、审批事实、项目版本和平台事实均由系统维护,不得自行生成或修改。" + "你正在使用 Genarrative AI 游戏创作多智能体 Runtime。当前请求只广告以下原生函数:file.read、file.list、plan.submit_gdd、update_agent_plan、respond_to_user。只能直接调用这些函数;不得调用未广告的函数、动态工具或普通文本伪造工具调用。\n\n读取工具只用于获取项目内已有文本和文件摘要;不要把读取结果当作已经写入、提交、审批或构建完成。需要记录真实计划变化时调用 update_agent_plan,arguments 必须提交完整 steps;成稿时调用 plan.submit_gdd,input 必须严格符合 plan-submit-gdd-input.v1,只提交 game、decisions、prototypeValidationItems,不得附加 platformFacts、身份、版本、时间或 fingerprint。plan.submit_gdd 必须是本轮唯一 action,可与 update_agent_plan 同响应,但不能与其它动作或 respond_to_user 混合;GDD 提交成功后再由 Runtime 负责 durable 写入和投影。已有足够 observation、需要交付终态信封或当前轮次应收束时调用 respond_to_user。Runtime 身份、审批事实、项目版本和平台事实均由系统维护,不得自行生成或修改。" ) } @@ -874,6 +878,19 @@ mod tests { ); } + #[test] + fn project_planning_prompt_advertises_submit_gdd_contract() { + let prompt = game_creator_agent_runtime_tool_plan_system_prompt_for_agent( + GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + "", + ); + assert!(prompt.contains("plan.submit_gdd")); + assert!(prompt.contains("plan-submit-gdd-input.v1")); + assert!(prompt.contains("唯一 action")); + assert!(prompt.contains("可与 update_agent_plan 同响应")); + assert!(!prompt.contains("user.input_request")); + } + #[test] fn runtime_prompt_tool_catalog_tracks_the_native_capability_registry() { let prompt = game_creator_agent_runtime_tool_plan_system_prompt(); @@ -1236,16 +1253,24 @@ mod tests { "plan 根 prompt 不应再包含 with-editor 视觉合同" ); assert!( - !plan_prompt.contains("当前未配置 External Editor API Key,art-director 只交付视觉方向文档"), + !plan_prompt.contains( + "当前未配置 External Editor API Key,art-director 只交付视觉方向文档" + ), "plan 根 prompt 不应再包含 without-editor 视觉合同" ); assert!( plan_prompt.contains("shared runtime contract"), "plan 根 prompt 必须保留 $base" ); - assert!(plan_prompt.contains(required_runtime_prompt_section("supervisorPlaybook").trim())); - assert!(plan_prompt.contains(required_runtime_prompt_section("supervisorClaimGate").trim())); - assert!(plan_prompt.contains(required_runtime_prompt_section("supervisorRepair").trim())); + assert!( + plan_prompt.contains(required_runtime_prompt_section("supervisorPlaybook").trim()) + ); + assert!( + plan_prompt.contains(required_runtime_prompt_section("supervisorClaimGate").trim()) + ); + assert!( + plan_prompt.contains(required_runtime_prompt_section("supervisorRepair").trim()) + ); } } @@ -1281,7 +1306,10 @@ mod tests { editor_api_key_is_configured, source, ); - assert_eq!(actual, expected, "source={source} 不应被 plan 根收窄逻辑改变"); + assert_eq!( + actual, expected, + "source={source} 不应被 plan 根收窄逻辑改变" + ); } } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions.rs index 54a598dcc..02f23be24 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions.rs @@ -115,6 +115,7 @@ pub(crate) use project_gates::{ }; pub(crate) use provider_action_batch::{ prepare_game_creator_agent_runtime_provider_action_batch, + prepare_game_creator_agent_runtime_provider_action_batch_with_planning_binding, update_game_creator_agent_runtime_provider_batch_member, AgentRuntimePendingToolAction, AgentRuntimeProviderActionBatch, }; @@ -152,5 +153,5 @@ pub(crate) use tool_policy_snapshot::{ agent_runtime_acceptance_evidence_tools, agent_runtime_autonomous_design_foundation_command_is_allowed, agent_runtime_executable_tools, agent_runtime_native_executable_tools, agent_runtime_tool_policy_snapshot_for_run_at, - AGENT_RUNTIME_CANVAS_ASSET_KINDS, + AGENT_RUNTIME_CANVAS_ASSET_KINDS, AGENT_RUNTIME_PROJECT_PLANNING_ACTION_TOOLS, }; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_execution.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_execution.rs index 528468fee..3b2465fb6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_execution.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_execution.rs @@ -37,9 +37,22 @@ pub(crate) async fn execute_game_creator_agent_runtime_tool_action_with_pending_ pending_action: Option<&AgentRuntimePendingToolAction>, ) -> AgentRuntimeToolObservation { let tool = action.tool.trim(); + if tool == PLAN_SUBMIT_GDD_TOOL && agent_id.trim() != GAME_CREATOR_PROJECT_PLANNING_AGENT_ID { + return AgentRuntimeToolObservation { + tool: tool.to_string(), + status: "rejected".to_string(), + summary: "plan.submit_gdd 仅允许 project-planning Agent".to_string(), + detail: None, + }; + } if agent_id.trim() == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID && !matches!(tool, "file.read" | "file.list") { + // `plan.submit_gdd` is intentionally handled by the planning submit + // branch in the Runtime main loop. If it ever reaches the generic + // executor (including recovery or a stale pending record), fail + // closed instead of treating the durable mutation as an ordinary + // command action. return AgentRuntimeToolObservation { tool: tool.to_string(), status: "rejected".to_string(), diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/context_compaction.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/context_compaction.rs index 33788355e..6721e21ef 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/context_compaction.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/context_compaction.rs @@ -60,7 +60,20 @@ pub(in crate::agent) async fn compact_game_creator_agent_runtime_context_at( let app_config = load_game_creator_app_config()?; let llm = resolve_game_creator_llm_config_for_agent(&app_config, &template_agent_id); let config_path = format!("agentLlm.{template_agent_id}"); - let request = build_game_creator_agent_runtime_context_compaction_request(&source, &llm)?; + let mut request = + build_game_creator_agent_runtime_context_compaction_request(&source, &llm)?; + let planning_agent = agent_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID; + if planning_agent { + if allow_idle_context_compaction { + return Err( + "project-planning 不支持脱离 active run 的 idle context compaction".to_string(), + ); + } + let wire_bytes = + capture_plan_provider_structured_injections_at(root, session_id, observations)?; + let message = render_plan_provider_structured_injections_message(&wire_bytes)?; + request.messages.insert(1, LlmMessage::user(message)); + } let estimated_request_tokens = estimate_game_creator_llm_request_tokens(&request)?; validate_game_creator_llm_request_context_budget( &llm, @@ -88,6 +101,22 @@ pub(in crate::agent) async fn compact_game_creator_agent_runtime_context_at( applied_steer_cursor, )? }; + let snapshot = if planning_agent { + let request_context_fingerprint = + game_creator_agent_runtime_plan_provider_request_context_fingerprint( + &llm, &request, + )?; + let runtime = read_game_creator_agent_runtime_at(root, agent_id)?.state; + let binding = capture_plan_provider_session_binding_for_snapshot( + root, + &runtime, + &snapshot, + &request_context_fingerprint, + )?; + snapshot.with_planning_session_binding(Some(binding)) + } else { + snapshot + }; (snapshot, source, llm, config_path, request) }; let handoff_identity = @@ -154,6 +183,7 @@ pub(in crate::agent) async fn compact_game_creator_agent_runtime_context_at( let request_id = resolve_game_creator_agent_runtime_provider_request_attempt_at_locked( root, &base_request_id, + snapshot.planning_session_binding.is_some(), ) .map(|value| value.0) .unwrap_or(base_request_id); @@ -177,6 +207,7 @@ pub(in crate::agent) async fn compact_game_creator_agent_runtime_context_at( let request_id = resolve_game_creator_agent_runtime_provider_request_attempt_at_locked( root, &base_request_id, + snapshot.planning_session_binding.is_some(), ) .map(|value| value.0) .unwrap_or(base_request_id); @@ -210,6 +241,7 @@ pub(in crate::agent) async fn compact_game_creator_agent_runtime_context_at( let request_id = resolve_game_creator_agent_runtime_provider_request_attempt_at_locked( root, &base_request_id, + snapshot.planning_session_binding.is_some(), ) .map(|value| value.0) .unwrap_or(base_request_id); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/parallel_ledger.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/parallel_ledger.rs index ca7de3013..a7defc27e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/parallel_ledger.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/parallel_ledger.rs @@ -107,6 +107,7 @@ pub(in crate::agent) fn game_creator_agent_runtime_tool_command_id( "agent.route_manifest" => Some("agent.route_manifest"), "agent.action_history" => Some("agent.audit"), "agent.run_status" => Some("agent.run_status"), + PLAN_SUBMIT_GDD_TOOL => Some(PLAN_SUBMIT_GDD_TOOL), GAME_CREATOR_MCP_CALL_TOOL => Some(GAME_CREATOR_MCP_CALL_TOOL), _ => None, } @@ -118,7 +119,13 @@ pub(in crate::agent) fn game_creator_agent_runtime_tool_command_id( /// not turn that aliasing into an identity escalation for a restricted Agent. pub(crate) fn agent_runtime_tool_allowed_for_agent(agent_id: &str, tool: &str) -> bool { if agent_id.trim() == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID { - return matches!(tool.trim(), "file.read" | "file.list"); + return matches!( + tool.trim(), + "file.read" | "file.list" | PLAN_SUBMIT_GDD_TOOL + ); + } + if tool.trim() == PLAN_SUBMIT_GDD_TOOL { + return false; } if tool.trim() == GAME_CREATOR_USER_INPUT_REQUEST_TOOL { // `user.input_request` is a protocol control handled by the main @@ -184,6 +191,10 @@ mod identity_tests { GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, "file.read" )); + assert!(!agent_runtime_tool_rejected_by_agent_identity( + GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + PLAN_SUBMIT_GDD_TOOL + )); } #[test] @@ -200,6 +211,14 @@ mod identity_tests { GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, "project.search" )); + assert!(agent_runtime_tool_allowed_for_agent( + GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + PLAN_SUBMIT_GDD_TOOL + )); + assert!(!agent_runtime_tool_allowed_for_agent( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + PLAN_SUBMIT_GDD_TOOL + )); assert!(agent_runtime_tool_allowed_for_agent( GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, "project.search" diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/parallel_read.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/parallel_read.rs index e2ba20fc1..d387eaa14 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/parallel_read.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/parallel_read.rs @@ -143,9 +143,7 @@ pub(in crate::agent) fn validate_game_creator_agent_runtime_parallel_read_pendin if !agent_runtime_tool_allowed_for_agent(&pending.agent_id, &pending.action.tool) { return Ok(Some(agent_runtime_tool_policy_block_observation( &pending.action.tool, - AgentRuntimeToolPolicyBlock::Denied( - "当前 Agent 身份不允许执行该原始工具".to_string(), - ), + AgentRuntimeToolPolicyBlock::Denied("当前 Agent 身份不允许执行该原始工具".to_string()), ))); } if let Some(observation) = pending_repository_context_drift_observation(root, pending)? { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/pending_confirmation_ledger.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/pending_confirmation_ledger.rs index 6de6a816e..b9c7a63cd 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/pending_confirmation_ledger.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/pending_confirmation_ledger.rs @@ -334,6 +334,32 @@ pub(in crate::agent) fn validate_agent_runtime_pending_tool_action_record( { return Err("Agent Runtime 待确认动作 Run Profile 绑定不匹配".to_string()); } + match pending.planning_session_binding.as_ref() { + Some(binding) => { + validate_plan_provider_session_binding(binding).map_err(|error| error.to_string())?; + if pending.action.tool.trim() != PLAN_SUBMIT_GDD_TOOL + || binding.agent_id != pending.agent_id + || binding.task_id != pending.task_id + || binding.session_id != pending.session_id + || binding.run_id != pending.run_id + || binding.source != pending.source + || binding.run_profile != pending.run_profile + || binding.run_profile_binding_fingerprint + != pending.run_profile_binding_fingerprint + || binding.applied_steer_cursor != pending.planned_steer_cursor + { + return Err( + "planning submit standalone pending 与 frozen binding 不一致".to_string(), + ); + } + } + None if pending.provider_batch_plan_update.is_none() => {} + None => { + return Err( + "非 planning standalone pending 不能携带 Provider batch planUpdate".to_string(), + ); + } + } validate_agent_runtime_project_revision(root, &pending.project_revision_before)?; if pending.verification_gate_before.project_id != game_creator_agent_runtime_context_project_id(root)? diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_action_batch.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_action_batch.rs index a0578184a..2425bb043 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_action_batch.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_action_batch.rs @@ -14,6 +14,18 @@ pub(crate) struct AgentRuntimePendingToolAction { pub(crate) run_profile: String, #[serde(default)] pub(crate) run_profile_binding_fingerprint: String, + /// Planning submit actions carry the exact source-session snapshot that + /// was captured before the Provider response was accepted. Other tools + /// leave this field absent and retain the v1-v3 batch semantics. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) planning_session_binding: Option, + /// The v4 planning batch identity covers the complete Provider plan, + /// including an optional structured plan update. Persist that one + /// batch-only field on the standalone submit anchor as recovery material; + /// otherwise a surviving pending action cannot reproduce the original + /// batch ID after the batch sidecar is lost. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) provider_batch_plan_update: Option, pub(crate) task: String, #[serde(default)] pub(crate) goal_id: Option, @@ -78,6 +90,8 @@ pub(in crate::agent) struct AgentRuntimeParallelReadBatch { pub(crate) struct AgentRuntimeProviderActionBatch { pub(crate) schema_version: String, pub(crate) batch_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) provider_request_id: Option, pub(crate) project_id: String, pub(crate) agent_id: String, pub(crate) task_id: String, @@ -88,6 +102,8 @@ pub(crate) struct AgentRuntimeProviderActionBatch { pub(crate) run_profile: String, #[serde(default)] pub(crate) run_profile_binding_fingerprint: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) planning_session_binding: Option, pub(crate) loop_iteration: u32, pub(crate) planned_steer_cursor: u64, pub(crate) status: String, @@ -107,6 +123,8 @@ pub(crate) struct AgentRuntimeProviderActionBatch { struct AgentRuntimeProviderActionBatchWire { schema_version: String, batch_id: String, + #[serde(default)] + provider_request_id: Option, project_id: String, agent_id: String, task_id: String, @@ -117,6 +135,8 @@ struct AgentRuntimeProviderActionBatchWire { run_profile: String, #[serde(default)] run_profile_binding_fingerprint: String, + #[serde(default)] + planning_session_binding: Option, loop_iteration: u32, planned_steer_cursor: u64, status: String, @@ -140,6 +160,7 @@ impl<'de> Deserialize<'de> for AgentRuntimeProviderActionBatch { let batch = Self { schema_version: wire.schema_version, batch_id: wire.batch_id, + provider_request_id: wire.provider_request_id, project_id: wire.project_id, agent_id: wire.agent_id, task_id: wire.task_id, @@ -148,6 +169,7 @@ impl<'de> Deserialize<'de> for AgentRuntimeProviderActionBatch { source: wire.source, run_profile: wire.run_profile, run_profile_binding_fingerprint: wire.run_profile_binding_fingerprint, + planning_session_binding: wire.planning_session_binding, loop_iteration: wire.loop_iteration, planned_steer_cursor: wire.planned_steer_cursor, status: wire.status, @@ -195,7 +217,7 @@ impl AgentRuntimePendingToolAction { pub(in crate::agent) fn tool_plan(&self) -> AgentRuntimeToolPlan { AgentRuntimeToolPlan { thinking_summary: self.thinking_summary.clone(), - plan_update: None, + plan_update: self.provider_batch_plan_update.clone(), plan: self.plan.clone(), actions: Vec::new(), response: self.fallback_response.clone(), @@ -253,6 +275,8 @@ pub(in crate::agent) fn build_game_creator_agent_runtime_pending_tool_action( source: runtime.source.clone(), run_profile: runtime.run_profile.clone(), run_profile_binding_fingerprint: runtime.run_profile_binding_fingerprint.clone(), + planning_session_binding: None, + provider_batch_plan_update: None, task, goal_id: runtime.goal_id.clone(), goal_revision: runtime.goal_revision, @@ -288,6 +312,110 @@ pub(in crate::agent) fn build_game_creator_agent_runtime_pending_tool_action( }) } +/// `plan.submit_gdd` is a transactional planning action rather than an +/// ordinary provider action. It must be represented by one (and only one) +/// durable batch member so that the main loop can establish the action +/// identity before handing control to the planning submit handler. +/// +/// Keep this check at the batch boundary as a second line of defence behind +/// the native-tool parser. In particular, a text/JSON tool-plan or a stale +/// caller must not be able to smuggle a submit action through the historical +/// `< 2 actions => NotNeeded` fast path. +fn validate_plan_submit_gdd_batch_shape_for_identity( + agent_id: &str, + source: &str, + run_profile: &str, + plan: &AgentRuntimeToolPlan, +) -> Result { + let submit_count = plan + .actions + .iter() + .filter(|action| action.tool.trim() == PLAN_SUBMIT_GDD_TOOL) + .count(); + if submit_count == 0 { + return Ok(false); + } + + if agent_id.trim() != GAME_CREATOR_PROJECT_PLANNING_AGENT_ID { + return Err("plan.submit_gdd 只能由 project-planning Agent 调用".to_string()); + } + // The planning child is created through the ordinary delegate path. Keep + // the source/profile check here even though the run-identity binder also + // checks it: this prevents a forged/stale RuntimeState from turning the + // sole-action exception into a generic batch. + if source.trim() != "agent-delegate" || run_profile.trim() != AGENT_RUNTIME_RUN_PROFILE_STANDARD + { + return Err( + "plan.submit_gdd 的 Runtime 身份必须是 source=agent-delegate、runProfile=standard" + .to_string(), + ); + } + if submit_count > 1 { + return Err("plan.submit_gdd 在同一 Provider 响应中只能出现一次".to_string()); + } + if plan.actions.len() != 1 { + return Err("plan.submit_gdd 必须是 Provider 响应中的唯一 action".to_string()); + } + if !plan.response.trim().is_empty() { + return Err("plan.submit_gdd 不得与 respond_to_user 混批".to_string()); + } + Ok(true) +} + +fn validate_plan_submit_gdd_batch_shape( + runtime: &AgentRuntimeState, + plan: &AgentRuntimeToolPlan, +) -> Result { + validate_plan_submit_gdd_batch_shape_for_identity( + &runtime.agent_id, + &runtime.source, + &runtime.run_profile, + plan, + ) +} + +/// Return whether a persisted v4 provider batch is the exact planning submit +/// shape that is allowed to contain one action. The provider-batch ledger +/// uses this narrow predicate when applying its normal two-action minimum; +/// all non-plan batches retain the historical minimum unchanged. +pub(in crate::agent) fn is_plan_submit_gdd_provider_action_batch( + batch: &AgentRuntimeProviderActionBatch, +) -> bool { + batch.schema_version == AGENT_RUNTIME_PLAN_PROVIDER_ACTION_BATCH_SCHEMA_VERSION + && batch.agent_id.trim() == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID + && batch.source.trim() == "agent-delegate" + && batch.run_profile.trim() == AGENT_RUNTIME_RUN_PROFILE_STANDARD + && batch.collaboration_contract.is_none() + && batch.actions.len() == 1 + && batch.plan.actions.len() == 1 + && batch.plan.actions[0].tool.trim() == PLAN_SUBMIT_GDD_TOOL + && batch.actions[0].action.tool.trim() == PLAN_SUBMIT_GDD_TOOL + && batch.plan.response.trim().is_empty() + && batch.actions[0].action == batch.plan.actions[0] + && batch.planning_session_binding.is_some() + && batch.provider_request_id.as_deref() + == batch + .planning_session_binding + .as_ref() + .map(|binding| binding.provider_request_id.as_str()) + && batch.actions[0].planning_session_binding == batch.planning_session_binding +} + +fn provider_action_batch_is_not_needed( + action_count: usize, + force_collaboration_batch: bool, + is_plan_submit: bool, +) -> bool { + action_count < 2 && !force_collaboration_batch && !is_plan_submit +} + +/// Backwards-compatible entry point for the historical provider-batch callers. +/// +/// Planning submit batches now need the frozen session binding captured while +/// building the provider request. Callers that do not build a planning +/// request (including the older test/support helpers) retain the old API and +/// therefore pass no binding; the planning path uses the `_with_planning_binding` +/// variant below. pub(crate) async fn prepare_game_creator_agent_runtime_provider_action_batch( root: &Path, runtime: &AgentRuntimeState, @@ -297,6 +425,34 @@ pub(crate) async fn prepare_game_creator_agent_runtime_provider_action_batch( project_revision_before: &AgentRuntimeProjectRevision, planned_repository_context_fingerprint: &str, ) -> Result { + prepare_game_creator_agent_runtime_provider_action_batch_with_planning_binding( + root, + runtime, + task, + plan, + observations, + project_revision_before, + planned_repository_context_fingerprint, + None, + ) + .await +} + +pub(crate) async fn prepare_game_creator_agent_runtime_provider_action_batch_with_planning_binding( + root: &Path, + runtime: &AgentRuntimeState, + task: &str, + plan: &AgentRuntimeToolPlan, + observations: &[AgentRuntimeToolObservation], + project_revision_before: &AgentRuntimeProjectRevision, + planned_repository_context_fingerprint: &str, + captured_planning_session_binding: Option<&PlanProviderSessionBindingV1>, +) -> Result { + // Validate against the complete provider plan before truncating the + // historical action budget. Otherwise a mixed submit batch could hide a + // `plan.submit_gdd` action beyond the truncation boundary and reach the + // generic executor without a durable identity. + let is_plan_submit = validate_plan_submit_gdd_batch_shape(runtime, plan)?; let mut batch_plan = plan.clone(); batch_plan .actions @@ -450,7 +606,11 @@ pub(crate) async fn prepare_game_creator_agent_runtime_provider_action_batch( } } } - if batch_plan.actions.len() < 2 && !collaboration_preflight.force_durable_batch { + if provider_action_batch_is_not_needed( + batch_plan.actions.len(), + collaboration_preflight.force_durable_batch, + is_plan_submit, + ) { return Ok(AgentRuntimeProviderActionBatchPreparation::NotNeeded); } @@ -472,16 +632,23 @@ pub(crate) async fn prepare_game_creator_agent_runtime_provider_action_batch( AGENT_RUNTIME_PENDING_ACTION_STATUS_APPROVED, None, )?; + if action.tool.trim() == PLAN_SUBMIT_GDD_TOOL { + pending.planning_session_binding = captured_planning_session_binding.cloned(); + pending.provider_batch_plan_update = batch_plan.plan_update.clone(); + if pending.planning_session_binding.is_none() { + return Err( + "planning submit action 缺少 Provider 请求前捕获的 session binding".to_string(), + ); + } + } let command_id = game_creator_agent_runtime_tool_command_id(action.tool.trim()); - let identity_block = agent_runtime_tool_rejected_by_agent_identity( - &runtime.agent_id, - action.tool.trim(), - ) - .then(|| { - AgentRuntimeToolPolicyBlock::Denied( - "当前 Agent 身份不允许执行该原始工具".to_string(), - ) - }); + let identity_block = + agent_runtime_tool_rejected_by_agent_identity(&runtime.agent_id, action.tool.trim()) + .then(|| { + AgentRuntimeToolPolicyBlock::Denied( + "当前 Agent 身份不允许执行该原始工具".to_string(), + ) + }); let game_chat_art_scope_block = game_chat_delegated_art_agent_input_mutation_block( root, &runtime.agent_id, @@ -575,24 +742,71 @@ pub(crate) async fn prepare_game_creator_agent_runtime_provider_action_batch( } else { AGENT_RUNTIME_PROVIDER_ACTION_BATCH_STATUS_READY }; - let batch_id = agent_runtime_provider_action_batch_id( - &project_id, - &runtime.agent_id, - &runtime.task_id, - &runtime.session_id, - &runtime.run_id, - runtime.loop_iteration, - runtime.applied_steer_cursor, - &batch_plan, - project_revision_before, - planned_repository_context_fingerprint, - &actions, - collaboration_preflight.contract.as_ref(), - )?; + let planning_session_binding = if is_plan_submit { + let binding = captured_planning_session_binding + .or_else(|| { + actions + .first() + .and_then(|pending| pending.planning_session_binding.as_ref()) + }) + .ok_or_else(|| "planning submit batch 缺少 frozen session binding".to_string())?; + validate_plan_provider_session_binding_current_at(root, binding)?; + if let Some(pending_binding) = actions + .first() + .and_then(|pending| pending.planning_session_binding.as_ref()) + { + if pending_binding != binding { + return Err( + "planning submit pending 与 captured session binding 不一致".to_string() + ); + } + } + Some(binding.clone()) + } else { + None + }; + let batch_id = if let Some(binding) = planning_session_binding.as_ref() { + agent_runtime_plan_provider_action_batch_id( + &project_id, + &runtime.agent_id, + &runtime.task_id, + &runtime.session_id, + &runtime.run_id, + runtime.loop_iteration, + runtime.applied_steer_cursor, + &batch_plan, + project_revision_before, + planned_repository_context_fingerprint, + &actions, + binding, + )? + } else { + agent_runtime_provider_action_batch_id( + &project_id, + &runtime.agent_id, + &runtime.task_id, + &runtime.session_id, + &runtime.run_id, + runtime.loop_iteration, + runtime.applied_steer_cursor, + &batch_plan, + project_revision_before, + planned_repository_context_fingerprint, + &actions, + collaboration_preflight.contract.as_ref(), + )? + }; let now = unix_timestamp(); let batch = AgentRuntimeProviderActionBatch { - schema_version: AGENT_RUNTIME_PROVIDER_ACTION_BATCH_SCHEMA_VERSION.to_string(), + schema_version: if is_plan_submit { + AGENT_RUNTIME_PLAN_PROVIDER_ACTION_BATCH_SCHEMA_VERSION.to_string() + } else { + AGENT_RUNTIME_PROVIDER_ACTION_BATCH_SCHEMA_VERSION.to_string() + }, batch_id, + provider_request_id: planning_session_binding + .as_ref() + .map(|binding| binding.provider_request_id.clone()), project_id, agent_id: runtime.agent_id.clone(), task_id: runtime.task_id.clone(), @@ -601,6 +815,7 @@ pub(crate) async fn prepare_game_creator_agent_runtime_provider_action_batch( source: runtime.source.clone(), run_profile: runtime.run_profile.clone(), run_profile_binding_fingerprint: runtime.run_profile_binding_fingerprint.clone(), + planning_session_binding, loop_iteration: runtime.loop_iteration, planned_steer_cursor: runtime.applied_steer_cursor, status: status.to_string(), @@ -1077,3 +1292,93 @@ pub(in crate::agent) fn update_game_creator_agent_runtime_provider_batch_paralle } Ok(()) } + +#[cfg(test)] +mod plan_submit_batch_shape_tests { + use super::*; + + fn action(tool: &str) -> AgentRuntimeToolAction { + AgentRuntimeToolAction { + tool: tool.to_string(), + reason: Some("测试动作".to_string()), + input: serde_json::json!({}), + } + } + + fn plan(actions: Vec, response: &str) -> AgentRuntimeToolPlan { + AgentRuntimeToolPlan { + thinking_summary: "测试 plan.submit_gdd 批次形状".to_string(), + plan_update: None, + plan: Vec::new(), + actions, + response: response.to_string(), + } + } + + fn validate(plan: &AgentRuntimeToolPlan) -> Result { + validate_plan_submit_gdd_batch_shape_for_identity( + GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + "agent-delegate", + AGENT_RUNTIME_RUN_PROFILE_STANDARD, + plan, + ) + } + + #[test] + fn planning_submit_is_the_only_durable_action_and_allows_plan_control() { + let mut submit = plan(vec![action(PLAN_SUBMIT_GDD_TOOL)], ""); + assert_eq!(validate(&submit), Ok(true)); + + submit.plan_update = Some(AgentRuntimePlanUpdate { + explanation: "同步计划进度".to_string(), + steps: Vec::new(), + }); + assert_eq!(validate(&submit), Ok(true)); + } + + #[test] + fn planning_submit_rejects_mixed_or_duplicate_actions() { + let mixed = plan(vec![action(PLAN_SUBMIT_GDD_TOOL), action("file.read")], ""); + let mixed_error = validate(&mixed).expect_err("submit + file.read must fail closed"); + assert!(mixed_error.contains("唯一 action"), "{mixed_error}"); + + let duplicate = plan( + vec![action(PLAN_SUBMIT_GDD_TOOL), action(PLAN_SUBMIT_GDD_TOOL)], + "", + ); + let duplicate_error = + validate(&duplicate).expect_err("duplicate submit actions must fail closed"); + assert!( + duplicate_error.contains("只能出现一次"), + "{duplicate_error}" + ); + } + + #[test] + fn planning_submit_rejects_final_response_and_wrong_identity() { + let with_response = plan(vec![action(PLAN_SUBMIT_GDD_TOOL)], "不能同时回复"); + let response_error = + validate(&with_response).expect_err("submit + respond_to_user must fail closed"); + assert!( + response_error.contains("respond_to_user"), + "{response_error}" + ); + + let wrong_agent = validate_plan_submit_gdd_batch_shape_for_identity( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "project-supervisor-plan", + AGENT_RUNTIME_RUN_PROFILE_STANDARD, + &plan(vec![action(PLAN_SUBMIT_GDD_TOOL)], ""), + ) + .expect_err("non-planning identity must not receive submit exception"); + assert!(wrong_agent.contains("project-planning"), "{wrong_agent}"); + } + + #[test] + fn ordinary_single_action_keeps_not_needed_eligibility() { + assert_eq!(validate(&plan(vec![action("file.read")], "")), Ok(false)); + assert!(provider_action_batch_is_not_needed(1, false, false)); + assert!(!provider_action_batch_is_not_needed(1, false, true)); + assert!(!provider_action_batch_is_not_needed(1, true, false)); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_batch_ledger.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_batch_ledger.rs index f2c6710e0..0fba3b479 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_batch_ledger.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_batch_ledger.rs @@ -110,6 +110,48 @@ pub(in crate::agent) fn agent_runtime_provider_action_batch_id( ) } +#[allow(clippy::too_many_arguments)] +pub(in crate::agent) fn agent_runtime_plan_provider_action_batch_id( + project_id: &str, + agent_id: &str, + task_id: &str, + session_id: &str, + run_id: &str, + loop_iteration: u32, + planned_steer_cursor: u64, + plan: &AgentRuntimeToolPlan, + project_revision_before: &AgentRuntimeProjectRevision, + planned_repository_context_fingerprint: &str, + actions: &[AgentRuntimePendingToolAction], + planning_session_binding: &PlanProviderSessionBindingV1, +) -> Result { + let action_ids = actions + .iter() + .map(|pending| pending.action_id.as_str()) + .collect::>(); + let identity = serde_json::to_vec(&serde_json::json!({ + "schemaVersion": AGENT_RUNTIME_PLAN_PROVIDER_ACTION_BATCH_SCHEMA_VERSION, + "projectId": project_id, + "agentId": agent_id, + "taskId": task_id, + "sessionId": session_id, + "runId": run_id, + "loopIteration": loop_iteration, + "plannedSteerCursor": planned_steer_cursor, + "plan": plan, + "projectRevisionBefore": project_revision_before, + "plannedRepositoryContextFingerprint": planned_repository_context_fingerprint, + "actionIds": action_ids, + "planningSessionBinding": planning_session_binding, + })) + .map_err(|error| format!("序列化 Provider action 批次 v4 身份失败:{error}"))?; + let fingerprint = format!("{:x}", Sha256::digest(identity)); + Ok(format!( + "provider-action-v4-{}", + fingerprint.chars().take(32).collect::() + )) +} + pub(in crate::agent) fn validate_game_creator_agent_runtime_provider_action_batch( root: &Path, batch: &AgentRuntimeProviderActionBatch, @@ -126,7 +168,8 @@ pub(in crate::agent) fn validate_game_creator_agent_runtime_provider_action_batc ) -> Result<(), String> { if !matches!( batch.schema_version.as_str(), - AGENT_RUNTIME_PROVIDER_ACTION_BATCH_SCHEMA_VERSION + AGENT_RUNTIME_PLAN_PROVIDER_ACTION_BATCH_SCHEMA_VERSION + | AGENT_RUNTIME_PROVIDER_ACTION_BATCH_SCHEMA_VERSION | AGENT_RUNTIME_PROVIDER_ACTION_BATCH_PREVIOUS_SCHEMA_VERSION | AGENT_RUNTIME_PROVIDER_ACTION_BATCH_LEGACY_SCHEMA_VERSION ) { @@ -163,8 +206,48 @@ pub(in crate::agent) fn validate_game_creator_agent_runtime_provider_action_batc batch.status )); } - let minimum_action_count = if batch.schema_version - != AGENT_RUNTIME_PROVIDER_ACTION_BATCH_LEGACY_SCHEMA_VERSION + // `plan.submit_gdd` is intentionally a sole-action durable batch. It is + // the only non-collaboration batch allowed to bypass the historical + // two-action minimum; keep the exception tied to the complete identity + // predicate so a forged one-action batch cannot widen the normal path. + let plan_schema = + batch.schema_version == AGENT_RUNTIME_PLAN_PROVIDER_ACTION_BATCH_SCHEMA_VERSION; + let plan_submit_batch = is_plan_submit_gdd_provider_action_batch(batch); + if plan_schema { + if !plan_submit_batch { + return Err( + "planning v4 Provider action 批次必须是唯一 plan.submit_gdd action 且无 collaboration 合同" + .to_string(), + ); + } + let binding = batch + .planning_session_binding + .as_ref() + .ok_or_else(|| "planning v4 Provider action 批次缺少 session binding".to_string())?; + validate_plan_provider_session_binding(binding).map_err(|error| error.to_string())?; + if batch.actions.len() != 1 + || binding.request_kind != "tool-plan" + || batch.actions[0].planning_session_binding.as_ref() != Some(binding) + || batch.provider_request_id.as_deref() != Some(binding.provider_request_id.as_str()) + || batch.project_id != binding.project_id + || batch.agent_id != binding.agent_id + || batch.task_id != binding.task_id + || batch.session_id != binding.session_id + || batch.run_id != binding.run_id + || batch.source != binding.source + || batch.run_profile != binding.run_profile + || batch.run_profile_binding_fingerprint != binding.run_profile_binding_fingerprint + || batch.planned_steer_cursor != binding.applied_steer_cursor + || batch.actions[0].provider_batch_plan_update != batch.plan.plan_update + { + return Err("planning v4 批次成员与 session binding 不一致".to_string()); + } + } else if batch.planning_session_binding.is_some() || batch.provider_request_id.is_some() { + return Err("非 planning v4 批次不能携带 planning session binding".to_string()); + } + let minimum_action_count = if plan_submit_batch { + 1 + } else if batch.schema_version != AGENT_RUNTIME_PROVIDER_ACTION_BATCH_LEGACY_SCHEMA_VERSION && batch.collaboration_contract.is_some() { 1 @@ -198,6 +281,13 @@ pub(in crate::agent) fn validate_game_creator_agent_runtime_provider_action_batc .actions .first() .ok_or_else(|| "Agent Runtime Provider action 批次缺少首个动作".to_string())?; + if plan_schema { + let mut recovered_plan = first_pending.tool_plan(); + recovered_plan.actions = vec![first_pending.action.clone()]; + if recovered_plan != batch.plan { + return Err("planning v4 批次无法从 standalone member 精确重建完整 plan".to_string()); + } + } let mut waiting_confirmation_count = 0_usize; let mut rejected_count = 0_usize; for (index, pending) in batch.actions.iter().enumerate() { @@ -214,6 +304,8 @@ pub(in crate::agent) fn validate_game_creator_agent_runtime_provider_action_batc || pending.project_revision_before != batch.project_revision_before || pending.planned_repository_context_fingerprint != batch.planned_repository_context_fingerprint + || pending.planning_session_binding != batch.planning_session_binding + || pending.provider_batch_plan_update != batch.plan.plan_update || usize::try_from(pending.action_index).unwrap_or(usize::MAX) != index || pending.action != batch.plan.actions[index] { @@ -242,6 +334,26 @@ pub(in crate::agent) fn validate_game_creator_agent_runtime_provider_action_batc if !action_ids.insert(pending.action_id.clone()) { return Err("Agent Runtime Provider action 批次包含重复 actionId".to_string()); } + if plan_schema { + let binding = batch + .planning_session_binding + .as_ref() + .ok_or_else(|| "planning v4 批次缺少 session binding".to_string())?; + if pending.planning_session_binding.as_ref() != Some(binding) + || pending.action.tool.trim() != PLAN_SUBMIT_GDD_TOOL + || pending.action_id.is_empty() + { + return Err(format!( + "planning v4 批次成员 frozen binding/action identity 不一致:index={index}" + )); + } + } else if pending.planning_session_binding.is_some() + || pending.provider_batch_plan_update.is_some() + { + return Err(format!( + "非 planning Provider action 批次成员不能携带 planning recovery material:index={index}" + )); + } match pending.status.as_str() { AGENT_RUNTIME_PENDING_ACTION_STATUS_PENDING => { if pending.execution_mode != AGENT_RUNTIME_ACTION_EXECUTION_MODE_CONFIRMATION @@ -405,6 +517,26 @@ pub(in crate::agent) fn validate_game_creator_agent_runtime_provider_action_batc batch.collaboration_contract.as_ref(), )? } + AGENT_RUNTIME_PLAN_PROVIDER_ACTION_BATCH_SCHEMA_VERSION => { + let binding = batch + .planning_session_binding + .as_ref() + .ok_or_else(|| "planning v4 批次缺少 session binding".to_string())?; + agent_runtime_plan_provider_action_batch_id( + &batch.project_id, + &batch.agent_id, + &batch.task_id, + &batch.session_id, + &batch.run_id, + batch.loop_iteration, + batch.planned_steer_cursor, + &batch.plan, + &batch.project_revision_before, + &batch.planned_repository_context_fingerprint, + &batch.actions, + binding, + )? + } AGENT_RUNTIME_PROVIDER_ACTION_BATCH_SCHEMA_VERSION => { agent_runtime_provider_action_batch_id( &batch.project_id, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_final_reply.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_final_reply.rs index 1543951f7..c4681bef4 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_final_reply.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_final_reply.rs @@ -116,7 +116,29 @@ pub(in crate::agent) async fn request_game_creator_agent_background_final_reply_ root, "runtime.provider_request.capture.final_reply", )?; - capture_game_creator_agent_runtime_provider_request_snapshot_at_locked( + if agent_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID { + // Freeze the concrete final-reply request and its Provider-facing + // planning injection under the same project lock as the durable + // session binding. This mirrors tool-plan and prevents an older + // message object from being stamped with a newer session primary. + built_request = build_game_creator_agent_background_final_reply_request( + root, + agent_id, + session_id, + run_id, + task, + plan, + observations, + )?; + let wire_bytes = + capture_plan_provider_structured_injections_at(root, session_id, observations)?; + let message = render_plan_provider_structured_injections_message(&wire_bytes)?; + built_request + .2 + .messages + .insert(1, LlmMessage::user(message)); + } + let snapshot = capture_game_creator_agent_runtime_provider_request_snapshot_at_locked( root, agent_id, session_id, @@ -124,8 +146,34 @@ pub(in crate::agent) async fn request_game_creator_agent_background_final_reply_ "final-reply", request_slot, applied_steer_cursor, - )? + )?; + if agent_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID { + let request_context_fingerprint = + game_creator_agent_runtime_plan_provider_request_context_fingerprint( + &built_request.0, + &built_request.2, + )?; + let runtime = read_game_creator_agent_runtime_at(root, agent_id)?.state; + let binding = capture_plan_provider_session_binding_for_snapshot( + root, + &runtime, + &snapshot, + &request_context_fingerprint, + )?; + snapshot.with_planning_session_binding(Some(binding)) + } else { + snapshot + } }; + if agent_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID { + estimated_input_tokens = estimate_game_creator_llm_request_tokens(&built_request.2)?; + validate_game_creator_llm_request_context_budget( + &built_request.0, + &built_request.2, + estimated_input_tokens, + "锁内冻结后的 final-reply 请求", + )?; + } let (llm, config_path, request) = built_request; let auto_compact_token_limit = llm.auto_compact_token_limit; let stream_snapshot = provider_snapshot.clone(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs index 5326110c6..fbfbc3654 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 @@ -4,7 +4,7 @@ use platform_llm::LlmFunctionTool; const AGENT_RUNTIME_COMPLETION_BLOCKER_TOOL_PLAN_PROTOCOL: &str = "通用完成阻断规则:如果最新 observation 的 tool 为 runtime.autonomous_completion 且 status 为 blocked,本轮禁止调用 respond_to_user;必须先读取该 observation.detail 的 nextRequiredAction,并据此调用合适的读取、修复和验证工具。只有完成要求的动作、取得后续可信 observation 且完成门禁不再阻断后,才能给最终回复;不得反复提交 final response,也不得按项目正文硬编码某一种 blocker 的处理方式。"; -const GAME_CREATOR_PROJECT_PLANNING_FINAL_REPLY_SYSTEM_PROMPT: &str = "你是 Genarrative 的立项策划 Agent final-reply 收束器。你只能依据当前请求中明确提供的后台任务、运行中用户追加指令、收束摘要和已获准工具 observation 作答;不得使用通用角色聊天人格,也不得补充这些材料之外的项目事实。不要声称已经写入文件、提交 GDD、获得审批、生成素材、构建或验证完成;不要声称调用了未出现在 observation 中的工具,也不要把建议当成用户确认。若收束摘要或 observation 中已有 AGC_NEEDS_USER_INPUT_V1 终态信封,必须保留其首行和下一行严格 JSON 问题信封(只去除外围空白),不得改写、翻译、包装成普通中文或追加解释。若当前需要用户决定而尚无完整信封,只能输出 AGC_NEEDS_USER_INPUT_V1 首行,下一行输出 Runtime 可解析的严格 {\"questions\":[...]} JSON;不得输出 markdown、代码围栏或第三行正文。没有用户输入需求时,只简洁总结已观察到的策划结论、confirmed/default_pending/prototype_pending 状态、未完成事项和下一步,明确审批或构建尚未发生。回复保持中文。"; +const GAME_CREATOR_PROJECT_PLANNING_FINAL_REPLY_SYSTEM_PROMPT: &str = "你是 Genarrative 的立项策划 Agent final-reply 收束器。你只能依据当前请求中明确提供的后台任务、运行中用户追加指令、收束摘要和已获准工具 observation 作答;不得使用通用角色聊天人格,也不得补充这些材料之外的项目事实。没有对应成功 observation 时,不要声称已经写入文件、提交 GDD、获得审批、生成素材、构建或验证完成;不要声称调用了未出现在 observation 中的工具,也不要把建议当成用户确认。若 observation 明确返回 plan.submit_gdd 成功,只能如实报告已提交的 GDD 版本、指纹摘要和待审批状态,不得把提交当成批准。若收束摘要或 observation 中已有 AGC_NEEDS_USER_INPUT_V1 终态信封,必须保留其首行和下一行严格 JSON 问题信封(只去除外围空白),不得改写、翻译、包装成普通中文或追加解释。若当前需要用户决定而尚无完整信封,只能输出 AGC_NEEDS_USER_INPUT_V1 首行,下一行输出 Runtime 可解析的严格 {\"questions\":[...]} JSON;不得输出 markdown、代码围栏或第三行正文。没有用户输入需求时,只简洁总结已观察到的策划结论、confirmed/default_pending/prototype_pending 状态、未完成事项和下一步,明确审批或构建尚未发生。回复保持中文。"; #[derive(Clone, Copy)] enum AgentBackgroundContextMode { @@ -330,7 +330,7 @@ pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request( ); let api_kind = parse_game_creator_llm_api_kind(&llm.api_kind)?; let protocol_prompt = if planning_agent { - "必须直接调用当前请求提供的原生函数:需要更新持久计划时调用 update_agent_plan,已有观察足够或需要交付终态信封时调用 respond_to_user。不要调用未广告的函数,也不要把计划、动作或回复放在普通文本中。" + "必须直接调用当前请求提供的原生函数:需要更新持久计划时调用 update_agent_plan,成稿时调用 plan.submit_gdd(input 严格为 plan-submit-gdd-input.v1,只提交 game、decisions、prototypeValidationItems;不得附加 Runtime 身份、版本、时间、平台事实或 fingerprint),已有观察足够或需要交付终态信封时调用 respond_to_user。plan.submit_gdd 必须是本轮唯一 action,可与 update_agent_plan 同响应,但不能与其它动作或 respond_to_user 混合。不要调用未广告的函数,也不要把计划、动作或回复放在普通文本中。" .to_string() } else { format!( @@ -339,7 +339,7 @@ pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request( }; let prompt = if planning_agent { format!( - "当前 planning 子 Agent 只可调用 file.read、file.list、update_agent_plan、respond_to_user;未广告的函数一律不可调用。读取工具只用于获取已有项目文本和文件摘要,不代表已经写入、提交、审批或构建完成。\n\n运行上下文如下。只能依据已获准工具返回的 observation 使用;未出现在 observation 里的项目事实不得自行假设。\n\n{context}\n\n后台任务:\n{effective_task}\n\n运行中用户追加指令:\n{steers_json}\n\n已有工具观察:\n{observations_json}\n\nfile.list 使用 {{\"path\":\"\"}};file.read 使用 {{\"path\":\"项目内相对路径\",\"startLine\":1,\"maxLines\":120}}。arguments 外层严格为 {{\"reason\":\"为什么需要\",\"input\":{{...}}}}。不要输出普通文本来代替函数调用。" + "当前 planning 子 Agent 只可调用 file.read、file.list、plan.submit_gdd、update_agent_plan、respond_to_user;未广告的函数一律不可调用。读取工具只用于获取已有项目文本和文件摘要,不代表已经写入、提交、审批或构建完成。成稿时 plan.submit_gdd 的 input 必须严格符合 plan-submit-gdd-input.v1,只提交 game、decisions、prototypeValidationItems;Runtime 会注入平台事实、身份、版本、时间和 fingerprint。plan.submit_gdd 必须是本轮唯一 action,可与 update_agent_plan 同响应,但不能与其它动作或 respond_to_user 混合。\n\n运行上下文如下。只能依据已获准工具返回的 observation 使用;未出现在 observation 里的项目事实不得自行假设。\n\n{context}\n\n后台任务:\n{effective_task}\n\n运行中用户追加指令:\n{steers_json}\n\n已有工具观察:\n{observations_json}\n\nfile.list 使用 {{\"path\":\"\"}};file.read 使用 {{\"path\":\"项目内相对路径\",\"startLine\":1,\"maxLines\":120}}。arguments 外层严格为 {{\"reason\":\"为什么需要\",\"input\":{{...}}}}。不要输出普通文本来代替函数调用。" ) } else { prompt @@ -367,8 +367,8 @@ pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request( mcp_catalog, )?) .with_tool_choice(platform_llm::LlmToolChoice::Required); - let request = apply_game_creator_llm_reasoning_effort(request, &llm)? - .with_web_search(false); + let request = + apply_game_creator_llm_reasoning_effort(request, &llm)?.with_web_search(false); return Ok((llm, config_path, request, repository_context_fingerprint)); } // M1A-4:Supervisor 的 plan 根 run 需要在合成 system prompt 时收窄 @@ -757,13 +757,12 @@ mod tests { AgentRuntimeGoalContractDraft, AgentRuntimeTaskLink, AgentRuntimeToolObservation, AgentRuntimeToolPlan, GameCreatorMcpCatalog, GameCreatorMcpCatalogTool, AGENT_RUNTIME_COMPLETION_BLOCKER_TOOL_PLAN_PROTOCOL, AGENT_RUNTIME_RESPOND_FUNCTION_NAME, - GAME_CREATOR_PROJECT_PLANNING_FINAL_REPLY_SYSTEM_PROMPT, - AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD, AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, - AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, - AGENT_RUNTIME_RUN_PROFILE_STANDARD, AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE, + AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD, AGENT_RUNTIME_RUN_PROFILE_STANDARD, + AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE, AGENT_RUNTIME_UPDATE_PLAN_FUNCTION_NAME, GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - RUNTIME_PROMPT_SUPERVISOR_CHAT_COMPOSITION, + GAME_CREATOR_PROJECT_PLANNING_FINAL_REPLY_SYSTEM_PROMPT, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, RUNTIME_PROMPT_SUPERVISOR_CHAT_COMPOSITION, }; fn native_input_required_fields( @@ -1733,24 +1732,28 @@ mod tests { } assert!(planning_prompt_text.contains("file.read 使用")); assert!(planning_prompt_text.contains("file.list 使用")); + assert!(planning_prompt_text.contains("plan.submit_gdd")); + assert!(planning_prompt_text.contains("plan-submit-gdd-input.v1")); let planning_plan = AgentRuntimeToolPlan { thinking_summary: "等待用户确认核心循环".to_string(), plan_update: None, plan: Vec::new(), actions: Vec::new(), - response: "AGC_NEEDS_USER_INPUT_V1\n{\"questions\":[{\"id\":\"core_loop\"}]}".to_string(), + response: "AGC_NEEDS_USER_INPUT_V1\n{\"questions\":[{\"id\":\"core_loop\"}]}" + .to_string(), }; - let (_, _, planning_final_request) = build_game_creator_agent_background_final_reply_request( - &root, - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - &planning_state.session_id, - &planning_state.run_id, - &planning_state.current_task, - &planning_plan, - &[], - ) - .expect("build planning final reply request"); + let (_, _, planning_final_request) = + build_game_creator_agent_background_final_reply_request( + &root, + GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + &planning_state.session_id, + &planning_state.run_id, + &planning_state.current_task, + &planning_plan, + &[], + ) + .expect("build planning final reply request"); assert_eq!( planning_final_request.messages[0].content, GAME_CREATOR_PROJECT_PLANNING_FINAL_REPLY_SYSTEM_PROMPT 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 945613b73..b2ceddff4 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 @@ -173,7 +173,18 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at let initial_request_slot = format!("loop-{loop_index}-repair-0"); let (run_profile, _) = agent_runtime_run_profile_identity_at(root, agent_id, run_id, None, None)?; - let mcp_catalog = read_game_creator_mcp_catalog_at(root).await?; + let mcp_catalog = if agent_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID { + // Planning has a structurally empty MCP surface. Do not even resolve + // the project MCP catalog here: doing so can start/connect required + // servers and make an unrelated MCP outage block an exact plan turn. + GameCreatorMcpCatalog { + fingerprint: String::new(), + servers: Vec::new(), + tools: Vec::new(), + } + } else { + read_game_creator_mcp_catalog_at(root).await? + }; let mut built_request = { let _lock = acquire_game_creator_agent_provider_plan_project_write_lock_with_wait( root, @@ -260,21 +271,73 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at .as_ref() .map(|sidecar| context_compaction_result(sidecar, true)); } - let provider_snapshot = { + let (provider_snapshot, initial_planning_session_binding) = { let _lock = acquire_game_creator_agent_provider_plan_project_write_lock_with_wait( root, "runtime.provider_request.capture.tool_plan", )?; - capture_game_creator_agent_runtime_provider_request_snapshot_at_locked( - root, - agent_id, - session_id, - run_id, - "tool-plan", - &initial_request_slot, - applied_steer_cursor, - )? + // Exact planning requests must freeze the session and the concrete + // request object under one project lock. Rebuild once while holding + // that lock so a session successor cannot be used to re-label an + // object assembled from an older session. + if agent_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID { + built_request = build_game_creator_agent_background_tool_plan_request( + root, + agent_id, + session_id, + run_id, + task, + observations, + loop_index, + &mcp_catalog, + )?; + } + if agent_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID { + let wire_bytes = + capture_plan_provider_structured_injections_at(root, session_id, observations)?; + let message = render_plan_provider_structured_injections_message(&wire_bytes)?; + built_request + .2 + .messages + .insert(1, LlmMessage::user(message)); + } + let provider_snapshot = + capture_game_creator_agent_runtime_provider_request_snapshot_at_locked( + root, + agent_id, + session_id, + run_id, + "tool-plan", + &initial_request_slot, + applied_steer_cursor, + )?; + let planning_session_binding = if agent_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID { + let request_context_fingerprint = + game_creator_agent_runtime_plan_provider_request_context_fingerprint( + &built_request.0, + &built_request.2, + )?; + let runtime = read_game_creator_agent_runtime_at(root, agent_id)?.state; + Some(capture_plan_provider_session_binding_for_snapshot( + root, + &runtime, + &provider_snapshot, + &request_context_fingerprint, + )?) + } else { + None + }; + (provider_snapshot, planning_session_binding) }; + if agent_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID { + estimated_input_tokens = estimate_game_creator_llm_request_tokens(&built_request.2)?; + validate_game_creator_llm_request_context_budget( + &built_request.0, + &built_request.2, + estimated_input_tokens, + "锁内冻结后的 tool-plan 请求", + )?; + } let (llm, config_path, mut request, repository_context_fingerprint) = built_request; let auto_compact_token_limit = llm.auto_compact_token_limit; let format_repair_attempts = if run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD { @@ -337,6 +400,36 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at let request_snapshot = provider_snapshot .with_request_slot(&request_slot) .with_web_search_enabled(request.enable_web_search); + let planning_session_binding = if agent_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID { + if repair_attempt == 0 { + initial_planning_session_binding.clone() + } else { + let request_context_fingerprint = + game_creator_agent_runtime_plan_provider_request_context_fingerprint( + &llm, &request, + )?; + let _lock = acquire_game_creator_agent_provider_plan_project_write_lock_with_wait( + root, + "runtime.provider_request.freeze.plan_binding", + )?; + let runtime = read_game_creator_agent_runtime_at(root, agent_id)?.state; + let candidate = capture_plan_provider_session_binding_for_snapshot( + root, + &runtime, + &request_snapshot, + &request_context_fingerprint, + )?; + let initial = initial_planning_session_binding.as_ref().ok_or_else(|| { + "planning Provider repair 缺少 repair-0 frozen session binding".to_string() + })?; + validate_plan_provider_session_binding_repair_lineage(initial, &candidate)?; + Some(candidate) + } + } else { + None + }; + let request_snapshot = + request_snapshot.with_planning_session_binding(planning_session_binding.clone()); let response = request_game_creator_agent_runtime_llm_with_persisted_transient_retry( root, &request_snapshot, @@ -393,6 +486,16 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at ); } }; + let effective_planning_session_binding = + if let Some(base_binding) = planning_session_binding.as_ref() { + Some(plan_provider_session_binding_for_attempt( + base_binding, + &response_handoff.request_slot, + &response_handoff.provider_request_id, + )?) + } else { + None + }; if response_handoff.to_llm_response() != response { return Err( game_creator_agent_runtime_provider_handoff_reconciliation_error( @@ -713,6 +816,7 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at return Ok(RequestedAgentRuntimeToolPlanOutcome::Ready(Some( RequestedAgentRuntimeToolPlan { plan, + planning_session_binding: effective_planning_session_binding, repository_context_fingerprint, mcp_catalog_fingerprint: mcp_catalog.fingerprint.clone(), estimated_input_tokens, @@ -879,13 +983,12 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at &protocol_error, ) && !request.function_tools.is_empty(); if agent_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID { - request.function_tools = - build_agent_runtime_native_function_tools_for_agent( - agent_id, - &mcp_catalog, - )?; + request.function_tools = build_agent_runtime_native_function_tools_for_agent( + agent_id, + &mcp_catalog, + )?; request.messages.push(LlmMessage::user(format!( - "上一条输出不符合 planning 工具计划协议:{protocol_error}\n本轮修复仍只允许调用 file.read、file.list、update_agent_plan、respond_to_user。不得调用或描述其它工具,不得输出普通文本来代替函数调用;需要用户决定时以 AGC_NEEDS_USER_INPUT_V1 终态信封收束。" + "上一条输出不符合 planning 工具计划协议:{protocol_error}\n本轮修复仍只允许调用 file.read、file.list、plan.submit_gdd、update_agent_plan、respond_to_user。plan.submit_gdd 的 input 必须严格符合 plan-submit-gdd-input.v1,只提交 game、decisions、prototypeValidationItems;它必须是唯一 action,可与 update_agent_plan 同响应,但不能与其它动作或 respond_to_user 混合。不得调用或描述其它工具,不得输出普通文本来代替函数调用;需要用户决定时以 AGC_NEEDS_USER_INPUT_V1 终态信封收束。" ))); } else if force_root_goal_contract || force_supervisor_initial_collaboration @@ -904,11 +1007,10 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at || force_autonomous_read_only_delivery || force_autonomous_pre_mutation { - request.function_tools = - build_agent_runtime_native_function_tools_for_agent( - agent_id, - &mcp_catalog, - )?; + request.function_tools = build_agent_runtime_native_function_tools_for_agent( + agent_id, + &mcp_catalog, + )?; if runtime_owner_artifact_validation_available { remove_autonomous_owner_manual_verification_tools( &mut request.function_tools, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/response_stream_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/response_stream_tests.rs index b6953e4a3..938455dd8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/response_stream_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/response_stream_tests.rs @@ -68,6 +68,7 @@ fn response_stream_fixture( ), web_search_enabled: false, allow_idle_context_compaction: false, + planning_session_binding: None, }; (project, state, response_revision, snapshot) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_plan_protocol.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_plan_protocol.rs index e93d9cb4a..4ea242753 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_plan_protocol.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_plan_protocol.rs @@ -155,6 +155,22 @@ fn validate_agent_runtime_tool_plan_identity( agent_id: &str, plan: &AgentRuntimeToolPlan, ) -> Result<(), AgentRuntimeToolPlanProtocolError> { + if agent_id.trim() != GAME_CREATOR_PROJECT_PLANNING_AGENT_ID + && agent_id.trim() != "__all_agents__" + && plan + .actions + .iter() + .any(|action| action.tool.trim() == PLAN_SUBMIT_GDD_TOOL) + { + return Err(AgentRuntimeToolPlanProtocolError::new( + AgentRuntimeToolPlanProtocolErrorKind::UnknownFunction, + format!( + "Agent 原生工具协议错误:Agent {} 不允许调用 {}", + agent_id.trim(), + PLAN_SUBMIT_GDD_TOOL + ), + )); + } if agent_id.trim() != GAME_CREATOR_PROJECT_PLANNING_AGENT_ID { return Ok(()); } @@ -363,5 +379,29 @@ pub(in crate::agent) fn normalize_game_creator_agent_tool_plan( error, ) })?; + validate_plan_submit_gdd_tool_plan(&plan).map_err(|error| { + AgentRuntimeToolPlanProtocolError::new( + AgentRuntimeToolPlanProtocolErrorKind::BatchConstraint, + error, + ) + })?; Ok(plan) } + +fn validate_plan_submit_gdd_tool_plan(plan: &AgentRuntimeToolPlan) -> Result<(), String> { + let submit_count = plan + .actions + .iter() + .filter(|action| action.tool.trim() == PLAN_SUBMIT_GDD_TOOL) + .count(); + if submit_count == 0 { + return Ok(()); + } + if submit_count != 1 || plan.actions.len() != 1 || !plan.response.trim().is_empty() { + return Err( + "Agent 工具计划协议错误:plan.submit_gdd 必须是本轮唯一 action,且不能与 respond_to_user 同响应(可与 update_agent_plan 同响应)" + .to_string(), + ); + } + Ok(()) +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_policy_snapshot.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_policy_snapshot.rs index 5db0ac7ee..3aaceb958 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_policy_snapshot.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_policy_snapshot.rs @@ -3,6 +3,13 @@ use super::*; pub(crate) const AGENT_RUNTIME_CANVAS_ASSET_KINDS: &[&str] = &["game-art", "icon-spec", "ui-prototype", "art-spritesheet"]; +/// Exact executable action surface for the delegated planning child. The +/// two read tools are ordinary Runtime capabilities; `plan.submit_gdd` is a +/// planning-only capability and therefore must not be added to the global +/// `agent_runtime_executable_tools()` catalog. +pub(crate) const AGENT_RUNTIME_PROJECT_PLANNING_ACTION_TOOLS: &[&str] = + &["file.read", "file.list", PLAN_SUBMIT_GDD_TOOL]; + #[cfg(test)] mod canvas_asset_kind_contract_tests { use super::*; @@ -14,6 +21,45 @@ mod canvas_asset_kind_contract_tests { &["game-art", "icon-spec", "ui-prototype", "art-spritesheet"] ); } + + #[test] + fn planning_submit_confirmation_is_classified_as_deny_without_a_generic_pending_mode() { + let temporary = crate::tests::canonical_test_tempdir("planning-submit-confirm-policy-"); + let root = temporary.path().join("project"); + init_local_game_project_at(&root, "planning-submit-confirm-policy", "submit policy") + .expect("init policy fixture"); + write_project_permission_policy_at( + &root, + ProjectPermissionPolicy { + denied_commands: Vec::new(), + confirm_commands: vec![PLAN_SUBMIT_GDD_TOOL.to_string()], + agent_policies: BTreeMap::new(), + }, + ) + .expect("write submit confirmation policy"); + + let snapshot = + agent_runtime_tool_policy_snapshot_at(&root, GAME_CREATOR_PROJECT_PLANNING_AGENT_ID) + .expect("read planning policy snapshot"); + assert!(snapshot + .denied_tools + .iter() + .any(|tool| tool == PLAN_SUBMIT_GDD_TOOL)); + assert!(!snapshot + .auto_tools + .iter() + .any(|tool| tool == PLAN_SUBMIT_GDD_TOOL)); + assert!(!snapshot + .confirm_tools + .iter() + .any(|tool| tool == PLAN_SUBMIT_GDD_TOOL)); + + // The M1B-2 submit state machine has no generic confirmation + // consumer. A confirmation rule must therefore never advertise a + // confirmation execution path or create a pending sidecar. + assert!(!root.join(".agent/runtime/pending-actions").exists()); + assert!(!root.join(".agent/planning/gdd.v1.json").exists()); + } } pub(crate) fn agent_runtime_executable_tools() -> Vec<&'static str> { @@ -164,6 +210,29 @@ pub(in crate::agent) fn agent_runtime_tool_policy_snapshot_at( auto_tools.push(tool.to_string()); } } + if agent_id.trim() == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID { + // `plan.submit_gdd` is intentionally not in the global catalog. + // Classify it against the same project/Agent permission policy so an + // explicit deny/confirm cannot be bypassed by the planning ceiling. + let planning_submit_command = PLAN_SUBMIT_GDD_TOOL; + let denied = policy + .denied_commands + .iter() + .any(|command| command == planning_submit_command); + let confirmation_requested = policy + .confirm_commands + .iter() + .any(|command| command == planning_submit_command); + if denied || confirmation_requested { + // M1B-2 has no generic user-confirmation state for the Runtime + // commit action. An explicit confirm rule therefore fails closed + // instead of creating a pending shape the submit state machine can + // never consume. + denied_tools.push(PLAN_SUBMIT_GDD_TOOL.to_string()); + } else { + auto_tools.push(PLAN_SUBMIT_GDD_TOOL.to_string()); + } + } Ok(AgentRuntimeToolPolicySnapshot { run_profile: default_agent_runtime_run_profile(), run_profile_binding_fingerprint: String::new(), @@ -199,10 +268,25 @@ pub(crate) fn agent_runtime_tool_policy_snapshot_for_run_at( validate_project_planning_child_binding_at(root, agent_id, run_id)?; // Planning is a delegated child. Never let normalization/recovery // repopulate the broad default policy for this identity. - let exact = ["file.read", "file.list"]; - snapshot.allowed_tools.retain(|tool| exact.contains(&tool.as_str())); - snapshot.auto_tools.retain(|tool| exact.contains(&tool.as_str())); - snapshot.confirm_tools.retain(|tool| exact.contains(&tool.as_str())); + let exact = AGENT_RUNTIME_PROJECT_PLANNING_ACTION_TOOLS; + if !snapshot + .allowed_tools + .iter() + .any(|tool| tool == PLAN_SUBMIT_GDD_TOOL) + { + snapshot + .allowed_tools + .push(PLAN_SUBMIT_GDD_TOOL.to_string()); + } + snapshot + .allowed_tools + .retain(|tool| exact.contains(&tool.as_str())); + snapshot + .auto_tools + .retain(|tool| exact.contains(&tool.as_str())); + snapshot + .confirm_tools + .retain(|tool| exact.contains(&tool.as_str())); // `snapshot_at` has already applied the project- and Agent-level // permission policy. Keep an exact-tool deny in that result instead // of replacing it with the ceiling's non-exact denies. Deny wins diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs index afc3c761a..6f656658b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs @@ -60,6 +60,11 @@ pub(super) const AGENT_RUNTIME_PARALLEL_READ_BATCH_STATUS_OBSERVED: &str = "obse pub(super) const AGENT_RUNTIME_PARALLEL_READ_BATCH_SIDECAR_MAX_BYTES: usize = 4 * 1024 * 1024; pub(crate) const AGENT_RUNTIME_PROVIDER_ACTION_BATCH_SCHEMA_VERSION: &str = "game-creator-provider-action-batch.v3"; +/// Exact planning batches carry the frozen provider/session binding. Keep +/// ordinary provider batches on v3 so existing recovery readers remain +/// byte-for-byte compatible. +pub(crate) const AGENT_RUNTIME_PLAN_PROVIDER_ACTION_BATCH_SCHEMA_VERSION: &str = + "game-creator-provider-action-batch.v4"; pub(super) const AGENT_RUNTIME_PROVIDER_ACTION_BATCH_PREVIOUS_SCHEMA_VERSION: &str = "game-creator-provider-action-batch.v2"; pub(super) const AGENT_RUNTIME_PROVIDER_ACTION_BATCH_LEGACY_SCHEMA_VERSION: &str = @@ -119,6 +124,97 @@ pub(crate) const AGENT_RUNTIME_PLAN_ROOT_CHILD_TARGET_UNSUPPORTED_KIND: &str = pub(super) const GAME_CHAT_FIXED_TASK_GRAPH_STALLED_ERROR: &str = "game-chat 首版固定任务图无法继续推进,拒绝回退到普通 Provider 协作波"; +/// Idempotently close the planning child after the immutable GDD submit point. +/// The original submit pending/batch remain live recovery anchors until M1C-1 +/// writes their terminal observation, so every other child projection must be +/// independently replayable across process kills. +pub(in crate::agent) fn ensure_project_planning_submit_child_completion_at( + root: &Path, + runtime: &mut AgentRuntimeState, + pending: &AgentRuntimePendingToolAction, + result: &PlanSubmitGddResultV1, +) -> Result<(), String> { + runtime.pending_tool_action = Some(pending.summary()); + runtime.status = "idle".to_string(); + runtime.phase = "completed".to_string(); + runtime.current_action = format!( + "Fast GDD v{} 已完成 create-only 提交", + result.gdd_ref.version + ); + runtime.waiting_on = "无".to_string(); + runtime.next_step = "策划子 Run 已完成".to_string(); + runtime.last_response = Some(format!("Fast GDD v{} 已提交。", result.gdd_ref.version)); + runtime.error = None; + complete_agent_runtime_remaining_plan_steps(runtime, "Fast GDD 已到达 create-only 提交点。"); + runtime.updated_at = unix_timestamp(); + + append_game_creator_agent_runtime_task_projection_once(root, runtime, &pending.action_id)?; + refresh_game_creator_agent_runtime_task_queue(root, runtime)?; + write_game_creator_agent_runtime_state(root, runtime)?; + append_game_creator_agent_runtime_action_event( + root, + runtime, + "plan.submit_gdd.committed", + "idle", + "completed", + "策划子 Run 已在 GDD 提交点终止;原 submit action 尚未 observed。", + Some(&format!( + "actionId={} · gddId={} · version={} · fingerprint={}", + pending.action_id, + result.gdd_ref.gdd_id, + result.gdd_ref.version, + result.gdd_ref.fingerprint + )), + &pending.action_id, + )?; + + publish_game_creator_agent_delegate_result_for_state( + root, + runtime, + runtime.last_response.as_deref(), + ); + let delegation_id = runtime + .delegation_id + .as_deref() + .ok_or_else(|| "策划子 Run 缺少 delegationId,无法验证提交回执".to_string())?; + let delivery = read_static_delegate_delivery_at(root, delegation_id)? + .ok_or_else(|| "策划子 Run 的 durable delivery 不存在".to_string())?; + if delivery.delegation_id != delegation_id + || delivery.target_agent_id != runtime.agent_id + || delivery.target_session_id != runtime.session_id + || delivery.target_run_id != runtime.run_id + || !matches!( + delivery.status, + StaticDelegateDeliveryStatus::Ready | StaticDelegateDeliveryStatus::ClaimedByParent + ) + || delivery.terminal_status.as_deref() != Some("completed") + { + return Err("策划子 Run 的 durable delivery 尚未收口为同 identity completed".to_string()); + } + + append_agent_db_plan_submit_gdd_committed_if_missing_for_action( + root, + &runtime.agent_id, + &runtime.run_id, + &pending.action_id, + serde_json::json!({ + "recordType": "agent.runtime.plan_submit_gdd.committed", + "agentId": runtime.agent_id, + "taskId": runtime.task_id, + "sessionId": runtime.session_id, + "runId": runtime.run_id, + "actionId": pending.action_id, + "actionFingerprint": pending.action_fingerprint, + "gddId": result.gdd_ref.gdd_id, + "version": result.gdd_ref.version, + "gddFingerprint": result.gdd_ref.fingerprint, + "approvalRequestId": result.approval_request_id, + "recoveryPending": false, + }), + )?; + Ok(()) +} + pub(crate) fn agent_runtime_supervisor_source_is_trusted(source: &str) -> bool { matches!( source.trim(), @@ -216,8 +312,7 @@ pub(crate) fn supervisor_plan_root_identity_holds_at( { return Ok(false); } - if game_creator_agent_runtime_provider_action_batch_exists(root, &task.agent_id, &task.run_id) - { + if game_creator_agent_runtime_provider_action_batch_exists(root, &task.agent_id, &task.run_id) { let batch = read_game_creator_agent_runtime_provider_action_batch( root, &task.agent_id, @@ -492,6 +587,8 @@ pub(super) const AGENT_RUNTIME_FINALIZATION_STATUS_ASSISTANT_PERSISTED: &str = pub(super) const AGENT_RUNTIME_FINALIZATION_STATUS_RUNTIME_COMPLETED: &str = "runtime-completed"; pub(super) const AGENT_RUNTIME_PROVIDER_REQUEST_LIFECYCLE_SCHEMA_VERSION: &str = "game-creator-provider-request-lifecycle.v2"; +pub(super) const AGENT_RUNTIME_PLAN_PROVIDER_REQUEST_LIFECYCLE_SCHEMA_VERSION: &str = + "game-creator-provider-request-lifecycle.v3"; pub(super) const AGENT_RUNTIME_PROVIDER_REQUEST_LIFECYCLE_RECORD_TYPE: &str = "agent.runtime.provider_request.lifecycle"; pub(super) const AGENT_RUNTIME_PROVIDER_REQUEST_RECONCILIATION_PREFIX: &str = diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs index 2612fd315..bbd4b2596 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs @@ -79,12 +79,87 @@ pub(super) fn game_creator_agent_final_reply_error_allows_fallback(error: &str) matches!(kind.as_str(), "empty-response" | "deserialize") } +fn plan_submit_error_is_business_rejection(error: &PlanningStorageError) -> bool { + matches!( + error.code(), + "PLAN_INVALID_REQUEST" | "PLAN_SIZE_LIMIT" | "PLAN_VERSION_LIMIT_REACHED" + ) +} + +/// A strict submit payload rejection is a normal planning observation, not a +/// Provider/lifecycle reconciliation failure. Close the exact sole-action +/// batch, persist the rejected observation, and return a same-run continuation +/// so the planning child can correct its payload in the next tool-plan turn. +fn project_plan_submit_business_rejection_at( + root: &Path, + runtime: &mut AgentRuntimeState, + task: &str, + plan: &AgentRuntimeToolPlan, + observations: &mut Vec, + loop_index: usize, + context_tracker: &mut AgentRuntimeContextWindowTracker, + pending: &AgentRuntimePendingToolAction, + error: &PlanningStorageError, +) -> Result { + let batch = read_game_creator_agent_runtime_provider_action_batch( + root, + &pending.agent_id, + &pending.run_id, + )?; + if !is_plan_submit_gdd_provider_action_batch(&batch) + || batch.actions.len() != 1 + || batch.actions[0].action_id != pending.action_id + || batch.actions[0].action_fingerprint != pending.action_fingerprint + || batch.actions[0].action != pending.action + { + return Err( + "plan.submit_gdd 业务拒绝时 Provider v4 batch/pending identity 不一致".to_string(), + ); + } + let public_error = redact_agent_runtime_error(root, &error.to_string(), 500); + let observation = AgentRuntimeToolObservation { + tool: PLAN_SUBMIT_GDD_TOOL.to_string(), + status: "rejected".to_string(), + summary: "Fast GDD 提交被 Runtime 拒绝,请根据 observation 修正后重新提交。".to_string(), + detail: Some(public_error), + }; + let mut rejected = pending.clone(); + rejected.execution_mode = AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO.to_string(); + rejected.status = AGENT_RUNTIME_PENDING_ACTION_STATUS_OBSERVED_REJECTED.to_string(); + rejected.observation = Some(observation.clone()); + rejected.updated_at = unix_timestamp(); + project_game_creator_agent_runtime_provider_batch_abort( + root, + runtime, + task, + plan, + observations, + loop_index, + context_tracker, + &batch, + &rejected, + &observation, + )?; + let continuation = continuation_for_game_creator_agent_runtime_steer( + runtime, + &AgentRuntimeToolPlan::default(), + observations, + loop_index.saturating_add(1), + context_tracker, + ); + Ok(AgentBackgroundTaskOutcome::ContinueSameRun { + state: runtime.clone(), + continuation, + }) +} + fn requested_game_chat_fast_path_plan_at( root: &Path, plan: AgentRuntimeToolPlan, ) -> Result { Ok(RequestedAgentRuntimeToolPlan { plan, + planning_session_binding: None, repository_context_fingerprint: build_repository_startup_context_at(root)?.fingerprint, mcp_catalog_fingerprint: String::new(), estimated_input_tokens: 0, @@ -1115,6 +1190,7 @@ async fn run_game_creator_agent_background_task_pass_without_deadline( }; let mut planning_request_revision: AgentRuntimeProjectRevision; let mut planning_repository_context_fingerprint: String; + let mut planning_session_binding: Option; let action_start_index: usize; if let Some(batch) = resumed_provider_batch.as_ref() { let Some(first_pending) = batch.actions.first() else { @@ -1162,6 +1238,7 @@ async fn run_game_creator_agent_background_task_pass_without_deadline( planning_request_revision = batch.project_revision_before.clone(); planning_repository_context_fingerprint = batch.planned_repository_context_fingerprint.clone(); + planning_session_binding = batch.planning_session_binding.clone(); action_start_index = usize::try_from(batch.next_action_index).unwrap_or(usize::MAX); if action_start_index >= plan.actions.len() { return fail_game_creator_agent_background_context_at( @@ -1225,6 +1302,7 @@ async fn run_game_creator_agent_background_task_pass_without_deadline( return AgentBackgroundTaskOutcome::NeedsReconciliation; } } else { + planning_session_binding = None; runtime.loop_iteration = (loop_index + 1) as u32; runtime.max_loop_iterations = u32::try_from( (loop_index / AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT + 1) @@ -1506,6 +1584,7 @@ async fn run_game_creator_agent_background_task_pass_without_deadline( ); } planning_repository_context_fingerprint = requested_plan.repository_context_fingerprint; + planning_session_binding = requested_plan.planning_session_binding.clone(); let planning_mcp_catalog_fingerprint = requested_plan.mcp_catalog_fingerprint; plan = requested_plan.plan; match refresh_agent_runtime_autonomous_convergence_snapshot_after_provider_at( @@ -2193,7 +2272,7 @@ async fn run_game_creator_agent_background_task_pass_without_deadline( &runtime.run_id, ) { - match prepare_game_creator_agent_runtime_provider_action_batch( + match prepare_game_creator_agent_runtime_provider_action_batch_with_planning_binding( &root, &runtime, &task, @@ -2201,6 +2280,7 @@ async fn run_game_creator_agent_background_task_pass_without_deadline( &observations, &planning_request_revision, &planning_repository_context_fingerprint, + planning_session_binding.as_ref(), ) .await { @@ -2722,30 +2802,40 @@ async fn run_game_creator_agent_background_task_pass_without_deadline( let confirmation_approved = prepared_action .as_ref() .is_some_and(|pending| !pending.is_auto() && pending.approved()); - let local_policy_block = command_id.and_then(|command_id| { - if confirmation_approved { - match game_creator_agent_runtime_tool_policy_rule_for_run( - &root, - &agent_id, - &runtime.run_id, - Some(&runtime.run_profile), - Some(&runtime.run_profile_binding_fingerprint), - command_id, - ) { - Some(AgentRuntimeToolPolicyBlock::RequiresConfirmation(_)) => None, - blocked => blocked, + let local_policy_block = if action.tool.trim() == PLAN_SUBMIT_GDD_TOOL { + // `plan.submit_gdd` is a Runtime-owned commit action. It is + // intentionally handled below before the generic policy / + // executor path; the planning-only catalog and batch shape + // checks are its authorization boundary. + None + } else { + command_id.and_then(|command_id| { + if confirmation_approved { + match game_creator_agent_runtime_tool_policy_rule_for_run( + &root, + &agent_id, + &runtime.run_id, + Some(&runtime.run_profile), + Some(&runtime.run_profile_binding_fingerprint), + command_id, + ) { + Some(AgentRuntimeToolPolicyBlock::RequiresConfirmation(_)) => None, + blocked => blocked, + } + } else { + game_creator_agent_runtime_tool_policy_block( + &root, + &agent_id, + runtime.run_id.as_str(), + command_id, + &action_fingerprint, + ) } - } else { - game_creator_agent_runtime_tool_policy_block( - &root, - &agent_id, - runtime.run_id.as_str(), - command_id, - &action_fingerprint, - ) - } - }); - let mcp_policy_block = if matches!( + }) + }; + let mcp_policy_block = if action.tool.trim() == PLAN_SUBMIT_GDD_TOOL { + None + } else if matches!( local_policy_block, Some(AgentRuntimeToolPolicyBlock::Denied(_)) ) { @@ -2759,14 +2849,152 @@ async fn run_game_creator_agent_background_task_pass_without_deadline( ) .await }; - let policy_block = fail_closed_agent_runtime_confirmation_for_run( - &root, - &agent_id, - &runtime.run_id, - Some(&runtime.run_profile), - Some(&runtime.run_profile_binding_fingerprint), - strictest_agent_runtime_tool_policy_block(local_policy_block, mcp_policy_block), - ); + let policy_block = if action.tool.trim() == PLAN_SUBMIT_GDD_TOOL { + None + } else { + fail_closed_agent_runtime_confirmation_for_run( + &root, + &agent_id, + &runtime.run_id, + Some(&runtime.run_profile), + Some(&runtime.run_profile_binding_fingerprint), + strictest_agent_runtime_tool_policy_block(local_policy_block, mcp_policy_block), + ) + }; + if action.tool.trim() == PLAN_SUBMIT_GDD_TOOL { + // `plan.submit_gdd` is a dedicated commit state machine. It + // must not be allowed to fall through the generic observation, + // terminal-receipt, batch-cursor or final-reply paths. + let Some(mut pending_action) = prepared_action.take() else { + return fail_game_creator_agent_background_context_at( + &root, + &agent_id, + &session_id, + runtime, + "plan.submit_gdd 缺少 durable pending action identity", + ); + }; + if pending_action.execution_mode != AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO + || !matches!( + pending_action.status.as_str(), + AGENT_RUNTIME_PENDING_ACTION_STATUS_APPROVED + | AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING + ) + { + let _ = mark_game_creator_agent_runtime_needs_reconciliation_at( + &root, + &mut runtime, + &pending_action, + "plan.submit_gdd pending action 不是严格 auto/approved(or executing) 形状", + ); + return AgentBackgroundTaskOutcome::NeedsReconciliation; + } + pending_action.updated_at = unix_timestamp(); + if let Err(error) = + write_game_creator_agent_runtime_pending_tool_action(&root, &pending_action) + { + let _ = mark_game_creator_agent_runtime_needs_reconciliation_at( + &root, + &mut runtime, + &pending_action, + &format!("plan.submit_gdd 执行前 pending 无法持久化:{error}"), + ); + return AgentBackgroundTaskOutcome::NeedsReconciliation; + } + let action_is_current = + match mark_game_creator_agent_runtime_auto_action_executing_if_current( + &root, + &mut pending_action, + ) { + Ok(current) => current, + Err(error) => { + let _ = mark_game_creator_agent_runtime_needs_reconciliation_at( + &root, + &mut runtime, + &pending_action, + &format!( + "plan.submit_gdd 尚未执行,但无法持久化 executing 状态:{error}" + ), + ); + return AgentBackgroundTaskOutcome::NeedsReconciliation; + } + }; + if !action_is_current { + let _ = mark_game_creator_agent_runtime_needs_reconciliation_at( + &root, + &mut runtime, + &pending_action, + "plan.submit_gdd action 在执行前被 steer cursor 作废", + ); + return AgentBackgroundTaskOutcome::NeedsReconciliation; + } + match execute_plan_submit_gdd_for_pending_action(&root, &runtime, &pending_action) { + Ok(result) if !result.recovery_pending => { + if let Err(error) = ensure_project_planning_submit_child_completion_at( + &root, + &mut runtime, + &pending_action, + &result, + ) { + let _ = mark_game_creator_agent_runtime_needs_reconciliation_at( + &root, + &mut runtime, + &pending_action, + &format!("GDD 提交后子 Run 收口失败:{error}"), + ); + return AgentBackgroundTaskOutcome::NeedsReconciliation; + } + return AgentBackgroundTaskOutcome::Finished; + } + Ok(result) => { + let _ = mark_game_creator_agent_runtime_needs_reconciliation_at( + &root, + &mut runtime, + &pending_action, + &format!( + "GDD v{} 已越过提交点但投影尚未收口(recoveryPending=true)", + result.gdd_ref.version + ), + ); + return AgentBackgroundTaskOutcome::NeedsReconciliation; + } + Err(error) => { + if plan_submit_error_is_business_rejection(&error) { + match project_plan_submit_business_rejection_at( + &root, + &mut runtime, + &task, + &plan, + &mut observations, + loop_index, + &mut context_tracker, + &pending_action, + &error, + ) { + Ok(outcome) => return outcome, + Err(projection_error) => { + let _ = mark_game_creator_agent_runtime_needs_reconciliation_at( + &root, + &mut runtime, + &pending_action, + &format!( + "Fast GDD 业务拒绝 observation 投影失败:{projection_error}" + ), + ); + return AgentBackgroundTaskOutcome::NeedsReconciliation; + } + } + } + let _ = mark_game_creator_agent_runtime_needs_reconciliation_at( + &root, + &mut runtime, + &pending_action, + &format!("Fast GDD 提交未收口:{error}"), + ); + return AgentBackgroundTaskOutcome::NeedsReconciliation; + } + } + } let mut durable_action = None; let observation = if let Some(blocked) = policy_block { agent_runtime_tool_policy_block_observation(action.tool.trim(), blocked) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_tests.rs index 57d1b3f51..e0bd9537a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_tests.rs @@ -4766,3 +4766,311 @@ async fn autonomous_supervisor_converged_final_reply_deserialize_commits_fallbac .chunks_exact(2) .all(|pair| pair == ["started", "failed"])); } + +fn planning_submit_completion_fixture_at( + root: &Path, +) -> ( + AgentRuntimeState, + AgentRuntimePendingToolAction, + PlanSubmitGddResultV1, + String, +) { + init_local_game_project_at(root, "planning-submit-completion", "策划提交终态恢复") + .expect("initialize planning submit completion project"); + let parent_run_id = "planning-submit-completion-parent-run"; + start_game_creator_agent_runtime_task_at( + root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "收敛 Fast GDD", + parent_run_id, + AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE, + "准备委派立项策划 Agent", + vec!["委派 project-planning".to_string()], + ) + .expect("start planning Supervisor root"); + bind_game_creator_agent_runtime_run_profile_at( + root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_STANDARD), + None, + ) + .expect("bind planning Supervisor root"); + + let planning_lane = try_acquire_game_creator_agent_runtime_task_lock( + root, + GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + ) + .expect("acquire planning child lane") + .expect("planning child lane is available"); + let delegate_action_id = "action-111111111111111111111111"; + let observation = observe_agent_runtime_agent_delegate( + root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + Some(delegate_action_id), + &serde_json::json!({ + "agentId": GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + "task": "输出可审批的 Fast GDD", + "acceptanceCriteria": ["提交 strict Fast GDD"], + "expectedArtifacts": [], + "repairOfDelegationId": null, + "runId": null + }), + ); + assert_eq!(observation.status, "ok", "{observation:?}"); + let delegation_id = agent_runtime_delegation_id( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + delegate_action_id, + ); + let child_task = read_latest_game_creator_agent_runtime_task_by_delegation_id( + root, + GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + &delegation_id, + ) + .expect("read planning child task") + .expect("planning child task exists"); + drop(planning_lane); + + let mut runtime = agent_runtime_state_from_task_record(&child_task); + runtime.loop_iteration = 1; + let action = AgentRuntimeToolAction { + tool: PLAN_SUBMIT_GDD_TOOL.to_string(), + reason: Some("提交已校验的 Fast GDD".to_string()), + input: serde_json::json!({"schemaVersion": PLAN_SUBMIT_GDD_INPUT_SCHEMA_VERSION}), + }; + let action_fingerprint = agent_runtime_tool_action_fingerprint(&action, &runtime.current_task); + let occurrence_nonce = 42; + let action_id = agent_runtime_tool_action_id( + &runtime.run_id, + runtime.loop_iteration, + 0, + occurrence_nonce, + &action_fingerprint, + ); + let now = unix_timestamp(); + let pending = AgentRuntimePendingToolAction { + schema_version: AGENT_RUNTIME_PENDING_ACTION_SCHEMA_VERSION.to_string(), + fingerprint_version: AGENT_RUNTIME_ACTION_FINGERPRINT_VERSION.to_string(), + agent_id: runtime.agent_id.clone(), + task_id: runtime.task_id.clone(), + session_id: runtime.session_id.clone(), + run_id: runtime.run_id.clone(), + source: runtime.source.clone(), + run_profile: runtime.run_profile.clone(), + run_profile_binding_fingerprint: runtime.run_profile_binding_fingerprint.clone(), + planning_session_binding: None, + provider_batch_plan_update: None, + task: runtime.current_task.clone(), + goal_id: runtime.goal_id.clone(), + goal_revision: runtime.goal_revision, + goal_snapshot_fingerprint: agent_goal_snapshot_fingerprint_for_state_at(root, &runtime) + .expect("read planning child goal snapshot"), + loop_iteration: runtime.loop_iteration, + action_index: 0, + occurrence_nonce, + thinking_summary: "Fast GDD 已通过 strict 校验".to_string(), + plan: vec!["提交 Fast GDD".to_string()], + fallback_response: String::new(), + observations: Vec::new(), + project_revision_before: read_game_creator_agent_runtime_project_revision(root) + .expect("read planning submit project revision"), + verification_gate_before: read_game_creator_agent_runtime_verification_gate( + root, + &runtime.agent_id, + &runtime.run_id, + ) + .expect("read planning submit verification gate"), + planned_repository_context_fingerprint: build_repository_startup_context_at(root) + .expect("read planning submit repository context") + .fingerprint, + planned_steer_cursor: runtime.applied_steer_cursor, + action, + action_id: action_id.clone(), + action_fingerprint, + input_summary: None, + execution_mode: AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO.to_string(), + status: AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING.to_string(), + observation: None, + created_at: now, + updated_at: now, + }; + let result = PlanSubmitGddResultV1 { + outcome: "submitted".to_string(), + gdd_ref: PlanGddRef { + gdd_id: "gdd-00000000-0000-4000-8000-000000000001".to_string(), + version: 1, + fingerprint: format!("sha256-serde-json-v2:{}", "a".repeat(64)), + }, + pending_action_id: action_id, + approval_request_id: "gdd-approval-00000000-0000-4000-8000-000000000002".to_string(), + recovery_pending: false, + }; + (runtime, pending, result, delegation_id) +} + +fn read_planning_submit_completion_jsonl(path: &Path) -> Vec { + fs::read_to_string(path) + .ok() + .into_iter() + .flat_map(|content| { + content + .lines() + .filter(|line| !line.trim().is_empty()) + .map(|line| { + serde_json::from_str::(line) + .expect("parse planning submit completion JSONL") + }) + .collect::>() + }) + .collect() +} + +fn planning_submit_completion_audit_count( + root: &Path, + record_type: &str, + action_id: &str, +) -> usize { + read_planning_submit_completion_jsonl(&root.join(".agent/agent.db")) + .into_iter() + .filter(|record| { + record.get("recordType").and_then(serde_json::Value::as_str) == Some(record_type) + && record.get("actionId").and_then(serde_json::Value::as_str) == Some(action_id) + }) + .count() +} + +#[test] +fn planning_submit_child_completion_is_action_scoped_and_idempotent() { + let temporary = tempfile::tempdir().expect("create planning submit completion root"); + let root = temporary.path().join("project"); + let (mut runtime, pending, result, delegation_id) = + planning_submit_completion_fixture_at(&root); + + ensure_project_planning_submit_child_completion_at(&root, &mut runtime, &pending, &result) + .expect("complete planning child first time"); + ensure_project_planning_submit_child_completion_at(&root, &mut runtime, &pending, &result) + .expect("replay planning child completion"); + + let task_projection_count = read_planning_submit_completion_jsonl( + &game_creator_agent_runtime_task_path(&root, &runtime.agent_id), + ) + .into_iter() + .filter(|record| { + record.get("runId").and_then(serde_json::Value::as_str) == Some(runtime.run_id.as_str()) + && record.get("actionId").and_then(serde_json::Value::as_str) + == Some(pending.action_id.as_str()) + && record.get("phase").and_then(serde_json::Value::as_str) == Some("completed") + }) + .count(); + assert_eq!(task_projection_count, 1); + + let action_event_count = read_planning_submit_completion_jsonl( + &game_creator_agent_runtime_event_path(&root, &runtime.agent_id), + ) + .into_iter() + .filter(|record| { + record.get("eventType").and_then(serde_json::Value::as_str) + == Some("plan.submit_gdd.committed") + && record.get("actionId").and_then(serde_json::Value::as_str) + == Some(pending.action_id.as_str()) + }) + .count(); + assert_eq!(action_event_count, 1); + + let matching_deliveries = list_static_delegate_deliveries_at(&root) + .expect("list planning child deliveries") + .into_iter() + .filter(|delivery| delivery.delegation_id == delegation_id) + .collect::>(); + assert_eq!(matching_deliveries.len(), 1); + assert_eq!( + matching_deliveries[0].status, + StaticDelegateDeliveryStatus::Ready + ); + assert_eq!( + matching_deliveries[0].terminal_status.as_deref(), + Some("completed") + ); + assert_eq!( + planning_submit_completion_audit_count( + &root, + "agent.runtime.agent.delegate_receipt.ready", + "action-111111111111111111111111", + ), + 1 + ); + assert_eq!( + planning_submit_completion_audit_count( + &root, + "agent.runtime.plan_submit_gdd.committed", + &pending.action_id, + ), + 1 + ); +} + +#[test] +fn planning_submit_child_completion_waits_for_exact_delivery_before_committed_audit() { + let temporary = tempfile::tempdir().expect("create planning submit delivery recovery root"); + let root = temporary.path().join("project"); + let (mut runtime, pending, result, delegation_id) = + planning_submit_completion_fixture_at(&root); + let delivery = read_static_delegate_delivery_at(&root, &delegation_id) + .expect("read dispatched planning delivery") + .expect("dispatched planning delivery exists"); + fs::remove_file( + root.join(".agent/runtime/delegation-deliveries") + .join(format!("{delegation_id}.json")), + ) + .expect("remove planning delivery to model post-commit interruption"); + + let error = + ensure_project_planning_submit_child_completion_at(&root, &mut runtime, &pending, &result) + .expect_err("completion must wait for exact durable delivery"); + assert!(error.contains("durable delivery 不存在"), "{error}"); + assert_eq!( + planning_submit_completion_audit_count( + &root, + "agent.runtime.plan_submit_gdd.committed", + &pending.action_id, + ), + 0, + "delivery 未 durable 前不得写 recoveryPending=false committed audit" + ); + + create_or_read_static_delegate_delivery_at(&root, &delivery) + .expect("restore exact dispatched planning delivery"); + ensure_project_planning_submit_child_completion_at(&root, &mut runtime, &pending, &result) + .expect("resume same planning submit action after delivery repair"); + let recovered_delivery = read_static_delegate_delivery_at(&root, &delegation_id) + .expect("read recovered planning delivery") + .expect("recovered planning delivery exists"); + assert_eq!( + recovered_delivery.status, + StaticDelegateDeliveryStatus::Ready + ); + assert_eq!( + recovered_delivery.terminal_status.as_deref(), + Some("completed") + ); + let committed = read_planning_submit_completion_jsonl(&root.join(".agent/agent.db")) + .into_iter() + .filter(|record| { + record.get("recordType").and_then(serde_json::Value::as_str) + == Some("agent.runtime.plan_submit_gdd.committed") + && record.get("actionId").and_then(serde_json::Value::as_str) + == Some(pending.action_id.as_str()) + }) + .collect::>(); + assert_eq!(committed.len(), 1); + assert_eq!( + committed[0] + .get("recoveryPending") + .and_then(serde_json::Value::as_bool), + Some(false) + ); +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_recovery.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_recovery.rs index 7d16acd07..0d45d3432 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_recovery.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_recovery.rs @@ -620,6 +620,209 @@ pub(in crate::agent) fn resume_game_creator_agent_parallel_read_batch_at( Ok(AgentRuntimePendingActionResume::Handled(result)) } +/// A successful planning submit intentionally leaves its pending action and +/// v4 batch in place until the later receipt/observation consumer closes the +/// original action. Once the planning child has been projected terminal, +/// those sidecars are therefore recovery anchors, not ordinary terminal +/// garbage. Verify the immutable GDD against the frozen action identity +/// before treating the anchor as consumed; any mismatch must remain visible +/// as reconciliation rather than being silently deleted or replayed. +fn planning_submit_gdd_committed_for_pending_at( + root: &Path, + pending: &AgentRuntimePendingToolAction, +) -> Result { + if pending.action.tool.trim() != PLAN_SUBMIT_GDD_TOOL { + return Ok(false); + } + let binding = pending + .planning_session_binding + .as_ref() + .ok_or_else(|| "planning submit pending 缺少 frozen session binding".to_string())?; + validate_plan_provider_session_binding(binding).map_err(|error| error.to_string())?; + let chain = read_plan_gdd_chain(root).map_err(|error| error.to_string())?; + Ok(chain.iter().any(|gdd| { + gdd.submission_id == pending.action_id + && gdd.action_fingerprint == pending.action_fingerprint + && gdd.project_id == binding.project_id + && gdd.gdd_id == binding.gdd_id + && gdd.agent_id == pending.agent_id + && gdd.source == pending.source + && gdd.run_profile == pending.run_profile + && gdd.run_profile_binding_fingerprint == pending.run_profile_binding_fingerprint + && gdd.root_agent_id == binding.root_agent_id + && gdd.root_run_id == binding.root_run_id + && gdd.delegation_id == binding.delegation_id + && gdd.session_id == pending.session_id + && gdd.source_session_revision == binding.session_revision + && gdd.source_session_fingerprint == binding.session_fingerprint + && gdd.created_by_run_id == pending.run_id + })) +} + +fn planning_submit_pending_has_exact_committed_shape( + pending: &AgentRuntimePendingToolAction, +) -> bool { + pending.schema_version == AGENT_RUNTIME_PENDING_ACTION_SCHEMA_VERSION + && pending.action.tool.trim() == PLAN_SUBMIT_GDD_TOOL + && pending.action_index == 0 + && pending.execution_mode == AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO + && pending.status == AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING + && pending.observation.is_none() + && pending.planning_session_binding.is_some() +} + +fn planning_submit_batch_has_exact_committed_shape( + batch: &AgentRuntimeProviderActionBatch, +) -> bool { + is_plan_submit_gdd_provider_action_batch(batch) + && batch.status == AGENT_RUNTIME_PROVIDER_ACTION_BATCH_STATUS_READY + && batch.next_action_index == 0 + && batch.actions.len() == 1 + && planning_submit_pending_has_exact_committed_shape(&batch.actions[0]) +} + +fn planning_submit_batch_matches_pending( + batch: &AgentRuntimeProviderActionBatch, + pending: &AgentRuntimePendingToolAction, +) -> bool { + planning_submit_batch_has_exact_committed_shape(batch) + && planning_submit_pending_has_exact_committed_shape(pending) + && batch.actions[0] == *pending +} + +fn rebuild_missing_committed_plan_submit_batch_at( + root: &Path, + pending: &AgentRuntimePendingToolAction, +) -> Result { + if !planning_submit_pending_has_exact_committed_shape(pending) { + return Err("planning submit pending 不是 exact auto/executing 提交锚点".to_string()); + } + let binding = pending + .planning_session_binding + .as_ref() + .ok_or_else(|| "planning submit pending 缺少 frozen session binding".to_string())?; + validate_plan_provider_session_binding(binding).map_err(|error| error.to_string())?; + let mut plan = pending.tool_plan(); + plan.actions = vec![pending.action.clone()]; + if !plan.response.trim().is_empty() { + return Err("planning submit pending 不能携带 final response".to_string()); + } + let actions = vec![pending.clone()]; + let batch_id = agent_runtime_plan_provider_action_batch_id( + &binding.project_id, + &pending.agent_id, + &pending.task_id, + &pending.session_id, + &pending.run_id, + pending.loop_iteration, + pending.planned_steer_cursor, + &plan, + &pending.project_revision_before, + &pending.planned_repository_context_fingerprint, + &actions, + binding, + )?; + let batch = AgentRuntimeProviderActionBatch { + schema_version: AGENT_RUNTIME_PLAN_PROVIDER_ACTION_BATCH_SCHEMA_VERSION.to_string(), + batch_id, + provider_request_id: Some(binding.provider_request_id.clone()), + project_id: binding.project_id.clone(), + agent_id: pending.agent_id.clone(), + task_id: pending.task_id.clone(), + session_id: pending.session_id.clone(), + run_id: pending.run_id.clone(), + source: pending.source.clone(), + run_profile: pending.run_profile.clone(), + run_profile_binding_fingerprint: pending.run_profile_binding_fingerprint.clone(), + planning_session_binding: Some(binding.clone()), + loop_iteration: pending.loop_iteration, + planned_steer_cursor: pending.planned_steer_cursor, + status: AGENT_RUNTIME_PROVIDER_ACTION_BATCH_STATUS_READY.to_string(), + next_action_index: 0, + plan, + actions, + collaboration_contract: None, + project_revision_before: pending.project_revision_before.clone(), + planned_repository_context_fingerprint: pending + .planned_repository_context_fingerprint + .clone(), + created_at: pending.created_at, + updated_at: pending.updated_at, + }; + write_game_creator_agent_runtime_provider_action_batch(root, &batch)?; + Ok(batch) +} + +fn ensure_committed_plan_submit_anchor_pair_for_pending_at( + root: &Path, + pending: &AgentRuntimePendingToolAction, +) -> Result { + if !planning_submit_pending_has_exact_committed_shape(pending) + || !planning_submit_gdd_committed_for_pending_at(root, pending)? + { + return Ok(false); + } + let batch = if game_creator_agent_runtime_provider_action_batch_exists( + root, + &pending.agent_id, + &pending.run_id, + ) { + read_game_creator_agent_runtime_provider_action_batch( + root, + &pending.agent_id, + &pending.run_id, + )? + } else { + rebuild_missing_committed_plan_submit_batch_at(root, pending)? + }; + Ok(planning_submit_batch_matches_pending(&batch, pending)) +} + +fn restore_missing_committed_plan_submit_pending_from_batch_at( + root: &Path, + runtime: &AgentRuntimeState, +) -> Result { + if !game_creator_agent_runtime_provider_action_batch_exists( + root, + &runtime.agent_id, + &runtime.run_id, + ) { + return Ok(false); + } + let batch = read_game_creator_agent_runtime_provider_action_batch( + root, + &runtime.agent_id, + &runtime.run_id, + )?; + if !planning_submit_batch_has_exact_committed_shape(&batch) { + return Ok(false); + } + let pending = &batch.actions[0]; + if validate_agent_runtime_pending_context(root, runtime, pending).is_err() + || !planning_submit_gdd_committed_for_pending_at(root, pending)? + { + return Ok(false); + } + write_game_creator_agent_runtime_pending_tool_action(root, pending)?; + Ok(true) +} + +fn ensure_recovered_project_planning_submit_child_at( + root: &Path, + runtime: &mut AgentRuntimeState, + pending: &AgentRuntimePendingToolAction, +) -> Result<(), String> { + let result = execute_plan_submit_gdd_for_pending_action(root, runtime, pending) + .map_err(|error| format!("已提交 GDD 的同 action 重放失败:{error}"))?; + if result.recovery_pending { + return Err(format!( + "GDD v{} 同 action 恢复后仍有投影未收口(recoveryPending=true)", + result.gdd_ref.version + )); + } + ensure_project_planning_submit_child_completion_at(root, runtime, pending, &result) +} + pub(crate) fn resume_game_creator_agent_pending_tool_action_at( root: &Path, agent_id: &str, @@ -639,11 +842,33 @@ pub(crate) fn resume_game_creator_agent_pending_tool_action_at( return Ok(AgentRuntimePendingActionResume::NotFound(runtime_lock)); } if !game_creator_agent_runtime_pending_tool_action_exists(root, agent_id, &runtime.run_id) { - if has_reconciliation_barrier { - return read_game_creator_agent_runtime_at(root, agent_id) - .map(AgentRuntimePendingActionResume::Handled); + let restored = if game_creator_agent_runtime_provider_action_batch_exists( + root, + agent_id, + &runtime.run_id, + ) { + match restore_missing_committed_plan_submit_pending_from_batch_at(root, &runtime) { + Ok(restored) => restored, + Err(error) => { + mark_game_creator_agent_runtime_provider_batch_needs_reconciliation_at( + root, + &mut runtime, + &format!("恢复 committed planning submit 的 pending anchor 失败:{error}"), + )?; + return read_game_creator_agent_runtime_at(root, agent_id) + .map(AgentRuntimePendingActionResume::Handled); + } + } + } else { + false + }; + if !restored { + if has_reconciliation_barrier { + return read_game_creator_agent_runtime_at(root, agent_id) + .map(AgentRuntimePendingActionResume::Handled); + } + return Ok(AgentRuntimePendingActionResume::NotFound(runtime_lock)); } - return Ok(AgentRuntimePendingActionResume::NotFound(runtime_lock)); } let mut pending = match read_game_creator_agent_runtime_pending_tool_action( root, @@ -670,6 +895,30 @@ pub(crate) fn resume_game_creator_agent_pending_tool_action_at( .map(AgentRuntimePendingActionResume::Handled); } }; + let session_mismatch = pending.session_id != runtime.session_id + || read_latest_game_creator_agent_runtime_task_by_run_id(root, agent_id, &runtime.run_id)? + .is_some_and(|task| task.session_id != pending.session_id); + let planning_submit = pending.action.tool.trim() == PLAN_SUBMIT_GDD_TOOL; + let exact_committed_plan_submit = if planning_submit + && !session_mismatch + && validate_agent_runtime_pending_context(root, &runtime, &pending).is_ok() + { + match ensure_committed_plan_submit_anchor_pair_for_pending_at(root, &pending) { + Ok(exact) => exact, + Err(error) => { + mark_game_creator_agent_runtime_needs_reconciliation_at( + root, + &mut runtime, + &pending, + &format!("planning submit committed anchor 恢复失败:{error}"), + )?; + return read_game_creator_agent_runtime_at(root, agent_id) + .map(AgentRuntimePendingActionResume::Handled); + } + } + } else { + false + }; let mut can_repair_terminal_receipt = agent_runtime_pending_has_persisted_terminal_observation(&pending) || agent_runtime_pending_is_replayable_supervisor_delivery_action(&pending) @@ -682,13 +931,11 @@ pub(crate) fn resume_game_creator_agent_pending_tool_action_at( if has_reconciliation_barrier && !can_repair_terminal_receipt && !resumes_durable_external_generation + && !exact_committed_plan_submit { return read_game_creator_agent_runtime_at(root, agent_id) .map(AgentRuntimePendingActionResume::Handled); } - let session_mismatch = pending.session_id != runtime.session_id - || read_latest_game_creator_agent_runtime_task_by_run_id(root, agent_id, &runtime.run_id)? - .is_some_and(|task| task.session_id != pending.session_id); if session_mismatch { mark_game_creator_agent_runtime_needs_reconciliation_at( root, @@ -714,6 +961,34 @@ pub(crate) fn resume_game_creator_agent_pending_tool_action_at( can_repair_terminal_receipt = true; } } + let provider_batch_exists = + game_creator_agent_runtime_provider_action_batch_exists(root, agent_id, &runtime.run_id); + if planning_submit + && matches!(runtime.phase.as_str(), "completed" | "cancelled" | "failed") + && runtime.phase != "needs-reconciliation" + { + // A planning child is projected terminal at the GDD commit point, but + // its pending/batch sidecars remain the receipt consumer's anchor. + // Never run the generic terminal cleanup on this state. + if exact_committed_plan_submit && provider_batch_exists { + // Keep both exact sidecars in place and replay every terminal + // child projection. A process may have died after the Runtime + // state became completed but before event/audit/delivery landed. + return resume_game_creator_agent_provider_action_batch_at( + root, + agent_id, + runtime_lock, + ); + } + mark_game_creator_agent_runtime_needs_reconciliation_at( + root, + &mut runtime, + &pending, + "planning submit terminal anchor 不是 exact ready/0 + auto/executing 形状,或 immutable GDD 无法对账", + )?; + return read_game_creator_agent_runtime_at(root, agent_id) + .map(AgentRuntimePendingActionResume::Handled); + } if matches!(runtime.phase.as_str(), "completed" | "cancelled" | "failed") && runtime.phase != "needs-reconciliation" { @@ -722,8 +997,15 @@ pub(crate) fn resume_game_creator_agent_pending_tool_action_at( remove_game_creator_agent_runtime_confirmations(root, agent_id, &runtime.run_id)?; return Ok(AgentRuntimePendingActionResume::NotFound(runtime_lock)); } - let provider_batch_exists = - game_creator_agent_runtime_provider_action_batch_exists(root, agent_id, &runtime.run_id); + // `plan.submit_gdd` is a create-only Runtime commit whose durable batch is + // the recovery anchor. Route it through the batch state machine before + // the generic pending-action recovery reaches the fail-closed branch for + // an `executing` action; otherwise a crash between the action marker and + // the GDD commit would be classified as an unknown generic side effect + // and the idempotent submit replay could never run. + if provider_batch_exists && planning_submit { + return resume_game_creator_agent_provider_action_batch_at(root, agent_id, runtime_lock); + } if provider_batch_exists { let batch = match read_game_creator_agent_runtime_provider_action_batch( root, @@ -1137,12 +1419,116 @@ pub(in crate::agent) fn resume_game_creator_agent_provider_action_batch_at( return read_game_creator_agent_runtime_at(root, agent_id) .map(AgentRuntimePendingActionResume::Handled); } + let plan_submit_batch = is_plan_submit_gdd_provider_action_batch(&batch); + let plan_submit_gdd_committed = if plan_submit_batch { + match planning_submit_gdd_committed_for_pending_at(root, first_pending) { + Ok(committed) => committed, + Err(error) => { + mark_game_creator_agent_runtime_provider_batch_needs_reconciliation_at( + root, + &mut runtime, + &format!("planning submit commit fact 校验失败:{error}"), + )?; + return read_game_creator_agent_runtime_at(root, agent_id) + .map(AgentRuntimePendingActionResume::Handled); + } + } + } else { + false + }; + let exact_committed_plan_submit = if plan_submit_gdd_committed { + if !planning_submit_batch_has_exact_committed_shape(&batch) { + mark_game_creator_agent_runtime_provider_batch_needs_reconciliation_at( + root, + &mut runtime, + "已提交 GDD 的 planning v4 batch 不是 exact ready/0 + auto/executing 形状", + )?; + return read_game_creator_agent_runtime_at(root, agent_id) + .map(AgentRuntimePendingActionResume::Handled); + } + if game_creator_agent_runtime_pending_tool_action_exists(root, agent_id, &runtime.run_id) { + match read_game_creator_agent_runtime_pending_tool_action( + root, + agent_id, + &runtime.run_id, + ) { + Ok(pending) if planning_submit_batch_matches_pending(&batch, &pending) => {} + Ok(_) => { + mark_game_creator_agent_runtime_provider_batch_needs_reconciliation_at( + root, + &mut runtime, + "已提交 GDD 的 planning batch 与 standalone pending snapshot 不一致", + )?; + return read_game_creator_agent_runtime_at(root, agent_id) + .map(AgentRuntimePendingActionResume::Handled); + } + Err(error) => { + mark_game_creator_agent_runtime_provider_batch_needs_reconciliation_at( + root, + &mut runtime, + &format!("读取 planning submit standalone pending 失败:{error}"), + )?; + return read_game_creator_agent_runtime_at(root, agent_id) + .map(AgentRuntimePendingActionResume::Handled); + } + } + } else { + write_game_creator_agent_runtime_pending_tool_action(root, first_pending)?; + } + true + } else { + false + }; if matches!(runtime.phase.as_str(), "completed" | "cancelled" | "failed") && runtime.phase != "needs-reconciliation" { + if plan_submit_batch { + if exact_committed_plan_submit { + if let Err(error) = ensure_recovered_project_planning_submit_child_at( + root, + &mut runtime, + first_pending, + ) { + mark_game_creator_agent_runtime_needs_reconciliation_at( + root, + &mut runtime, + first_pending, + &format!("GDD 提交终态投影恢复失败:{error}"), + )?; + } + return read_game_creator_agent_runtime_at(root, agent_id) + .map(AgentRuntimePendingActionResume::Handled); + } + mark_game_creator_agent_runtime_provider_batch_needs_reconciliation_at( + root, + &mut runtime, + "planning submit child 已终态,但 exact anchors 无法与 immutable GDD 对账", + )?; + return read_game_creator_agent_runtime_at(root, agent_id) + .map(AgentRuntimePendingActionResume::Handled); + } remove_game_creator_agent_runtime_provider_action_batch(root, agent_id, &runtime.run_id)?; return Ok(AgentRuntimePendingActionResume::NotFound(runtime_lock)); } + if exact_committed_plan_submit { + // The immutable GDD is the business-consumption proof. Replaying via + // the generic main loop would consume queued steer before reaching + // the submit action and would re-apply its repository drift gate. + // Repair the same create-only action directly and publish only the + // specialized planning-child completion. + if let Err(error) = + ensure_recovered_project_planning_submit_child_at(root, &mut runtime, first_pending) + { + mark_game_creator_agent_runtime_needs_reconciliation_at( + root, + &mut runtime, + first_pending, + &format!("GDD 提交恢复后子 Run 收口失败:{error}"), + )?; + } + return read_game_creator_agent_runtime_at(root, agent_id) + .map(AgentRuntimePendingActionResume::Handled); + } if batch.status == AGENT_RUNTIME_PROVIDER_ACTION_BATCH_STATUS_WAITING_CONFIRMATION && batch.run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD { @@ -1246,9 +1632,10 @@ pub(in crate::agent) fn resume_game_creator_agent_provider_action_batch_at( )?; let repository_context_drifted = build_repository_startup_context_at(root)?.fingerprint != batch.planned_repository_context_fingerprint; - if runtime.applied_steer_cursor != batch.planned_steer_cursor - || queued_steer - || repository_context_drifted + if !exact_committed_plan_submit + && (runtime.applied_steer_cursor != batch.planned_steer_cursor + || queued_steer + || repository_context_drifted) { let current_pending = batch .actions @@ -1401,6 +1788,729 @@ pub(crate) fn resume_game_creator_agent_provider_action_batch_for_test_at( mod pending_recovery_tests { use super::*; + fn valid_plan_submit_input_for_anchor_recovery() -> PlanSubmitGddInputV1 { + serde_json::from_value(serde_json::json!({ + "schemaVersion": PLAN_SUBMIT_GDD_INPUT_SCHEMA_VERSION, + "game": { + "title": "萤火守夜者", + "genre": {"primary": "轻策略", "fusion": null}, + "artStyle": { + "visualType": "手绘平面", + "keywords": ["暖色", "剪影", "纸感"], + "moodAndColor": "夜色中的暖黄灯火", + "mvpArtBoundary": "仅制作可复用的角色、灯火和地块素材" + }, + "oneLiner": "玩家在一局十分钟的守夜旅程中分配有限灯火、判断风险并选择路线,守住营地后寻找下一处安全落脚点", + "pillars": [ + { + "name": "取舍", + "playerFeel": "每次选择都有代价", + "mechanism": "有限灯火在路线与营地之间分配", + "decisionState": "confirmed" + }, + { + "name": "重玩", + "playerFeel": "想再试一次更优路线", + "mechanism": "不同路线组合产生不同风险", + "decisionState": "confirmed" + } + ], + "coreLoop": ["观察地图", "分配灯火", "选择路线", "处理事件"], + "targetUsers": { + "coreUsers": "喜欢短局策略的玩家", + "preferences": "偏好清晰反馈和轻量决策", + "sessionLength": "10至20分钟", + "referenceGames": [] + }, + "mvpSystems": [ + { + "system": "地图", + "minimalFunction": "展示当前营地与可选路线", + "whyRequired": "让玩家理解空间选择", + "verifyMethod": "能完成一局并看懂下一步", + "decisionState": "confirmed" + }, + { + "system": "灯火", + "minimalFunction": "消耗灯火换取安全或探索", + "whyRequired": "承载核心取舍", + "verifyMethod": "两种分配策略结果可区分", + "decisionState": "confirmed" + }, + { + "system": "事件", + "minimalFunction": "路线途中触发一项选择", + "whyRequired": "提供短局变化", + "verifyMethod": "重玩时可遇到不同事件", + "decisionState": "confirmed" + } + ], + "outOfScope": ["多人联机"], + "creatorTips": { + "doFirst": "先做一张可走完的地图", + "deferForNow": "暂缓复杂成长线", + "howToVerify": "观察玩家是否能说出每次选择的后果", + "expandWhen": "核心循环连续三局都可理解后再扩展" + } + }, + "decisions": [{ + "id": "initial-request", + "topic": "初始需求", + "state": "confirmed", + "answerSource": "user_freeform", + "round": 0, + "answerSummary": "做一个短局守夜策略游戏" + }], + "prototypeValidationItems": [] + })) + .expect("valid plan submit anchor recovery input") + } + + struct CommittedPlanSubmitAnchorFixture { + runtime: AgentRuntimeState, + pending: AgentRuntimePendingToolAction, + batch: AgentRuntimeProviderActionBatch, + gdd_chain: Vec, + } + + fn committed_plan_submit_anchor_fixture_at( + root: &Path, + identity: &str, + ) -> CommittedPlanSubmitAnchorFixture { + let project_id = format!("plan-submit-{identity}"); + let root_run_id = format!("plan-submit-{identity}-root"); + let child_run_id = format!("plan-submit-{identity}-child"); + let parent_action_id = format!("plan-submit-{identity}-parent-action"); + let delegation_id = agent_runtime_delegation_id( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &root_run_id, + GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + &parent_action_id, + ); + init_local_game_project_at(root, &project_id, "策划提交单锚恢复测试") + .expect("init anchor recovery project"); + bind_game_creator_agent_runtime_run_profile_at( + root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &root_run_id, + AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_STANDARD), + None, + ) + .expect("bind planning root"); + let root_runtime = start_game_creator_agent_runtime_task_at( + root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "发起 Fast GDD 立项策划", + &root_run_id, + AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE, + "等待策划子 Agent 提交 Fast GDD", + vec!["获取 Fast GDD 提交结果".to_string()], + ) + .expect("start planning root"); + let link = AgentRuntimeTaskLink { + parent_agent_id: Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string()), + parent_run_id: Some(root_run_id.clone()), + delegation_id: Some(delegation_id.clone()), + }; + let child_session_id = resolve_agent_conversation_session_id_at( + root, + GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + None, + true, + ) + .expect("resolve planning child session"); + let queued = append_unique_game_creator_agent_runtime_pending_task( + root, + GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + &child_session_id, + "提交 Fast GDD", + &child_run_id, + "agent-delegate", + Some(AGENT_RUNTIME_RUN_PROFILE_STANDARD), + Some(&link), + ) + .expect("queue planning child with exact link"); + assert_eq!(queued.run_id, child_run_id); + let runtime = start_game_creator_agent_runtime_task_for_session_at( + root, + GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + Some(&child_session_id), + "提交 Fast GDD", + &child_run_id, + "agent-delegate", + "提交 Fast GDD", + vec!["提交 Fast GDD".to_string()], + ) + .expect("start planning child"); + + let input = valid_plan_submit_input_for_anchor_recovery(); + let decisions = input + .decisions + .iter() + .map(|decision| PlanDecisionSummary { + id: decision.id.clone(), + topic: decision.topic.clone(), + state: decision.state.clone(), + answer_source: decision.answer_source.clone(), + round: decision.round, + answer_summary: decision.answer_summary.clone(), + }) + .collect(); + let gdd_id = "gdd-00000000-0000-4000-8000-000000000001".to_string(); + let mut session = PlanSessionV1 { + schema_version: PLAN_SESSION_SCHEMA_VERSION.to_string(), + project_id: project_id.clone(), + gdd_id: gdd_id.clone(), + session_revision: 1, + previous_fingerprint: None, + session_fingerprint: format!("sha256-serde-json-v2:{}", "0".repeat(64)), + agent_id: runtime.agent_id.clone(), + source: runtime.source.clone(), + run_profile: runtime.run_profile.clone(), + run_profile_binding_fingerprint: runtime.run_profile_binding_fingerprint.clone(), + root_agent_id: GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(), + root_run_id: root_run_id.clone(), + latest_delegation_id: delegation_id.clone(), + session_id: runtime.session_id.clone(), + active_run_id: Some(runtime.run_id.clone()), + last_run_id: runtime.run_id.clone(), + phase: "collecting".to_string(), + accumulated_agent_millis: 0, + applied_steer_cursor: runtime.applied_steer_cursor, + decisions_summary: decisions, + prototype_validation_items: input.prototype_validation_items.clone(), + applied_answers: Vec::new(), + latest_submitted_ref: None, + last_decision_ref: None, + updated_at_utc: "2026-08-14T00:00:00.000Z".to_string(), + }; + session.session_fingerprint = plan_session_fingerprint(&session).expect("session fp"); + write_plan_session_atomic(root, &session).expect("write planning session"); + + let action = AgentRuntimeToolAction { + tool: PLAN_SUBMIT_GDD_TOOL.to_string(), + reason: Some("提交测试 GDD".to_string()), + input: serde_json::to_value(&input).expect("serialize submit input"), + }; + let plan = AgentRuntimeToolPlan { + thinking_summary: "提交 Fast GDD".to_string(), + plan_update: Some(AgentRuntimePlanUpdate { + explanation: "完成 Fast GDD 提交步骤".to_string(), + steps: vec![AgentRuntimePlanUpdateStep { + step: "提交 Fast GDD".to_string(), + status: AGENT_RUNTIME_PLAN_STATUS_COMPLETED.to_string(), + }], + }), + plan: vec!["提交 Fast GDD".to_string()], + actions: vec![action.clone()], + response: String::new(), + }; + let revision = + read_game_creator_agent_runtime_project_revision(root).expect("read project revision"); + let repository_fingerprint = build_repository_startup_context_at(root) + .expect("read repository context") + .fingerprint; + let mut pending = build_game_creator_agent_runtime_pending_tool_action( + root, + &runtime, + &runtime.current_task, + &plan, + &[], + &revision, + &repository_fingerprint, + &action, + 0, + AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO, + AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING, + None, + ) + .expect("build pending action"); + let mut binding = PlanProviderSessionBindingV1 { + schema_version: PLAN_PROVIDER_SESSION_BINDING_SCHEMA_VERSION.to_string(), + project_id: project_id.clone(), + gdd_id, + agent_id: runtime.agent_id.clone(), + task_id: runtime.task_id.clone(), + provider_request_id: String::new(), + session_id: runtime.session_id.clone(), + run_id: runtime.run_id.clone(), + root_agent_id: GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(), + root_run_id: root_run_id.clone(), + delegation_id: delegation_id.clone(), + goal_id: pending.goal_id.clone(), + goal_revision: pending.goal_revision, + goal_snapshot_fingerprint: pending.goal_snapshot_fingerprint.clone(), + source: runtime.source.clone(), + run_profile: runtime.run_profile.clone(), + run_profile_binding_fingerprint: runtime.run_profile_binding_fingerprint.clone(), + session_revision: session.session_revision, + session_fingerprint: session.session_fingerprint.clone(), + applied_steer_cursor: runtime.applied_steer_cursor, + request_kind: "tool-plan".to_string(), + request_slot: format!("loop-{}-repair-0", runtime.loop_iteration), + web_search_enabled: false, + request_context_fingerprint: format!("sha256-serde-json-v2:{}", "2".repeat(64)), + fingerprint: String::new(), + }; + binding.provider_request_id = + plan_provider_session_binding_base_request_id(&binding).expect("provider request id"); + binding.fingerprint = + plan_provider_session_binding_fingerprint(&binding).expect("binding fingerprint"); + pending.planning_session_binding = Some(binding.clone()); + pending.provider_batch_plan_update = plan.plan_update.clone(); + let batch_id = agent_runtime_plan_provider_action_batch_id( + &project_id, + &runtime.agent_id, + &runtime.task_id, + &runtime.session_id, + &runtime.run_id, + runtime.loop_iteration, + runtime.applied_steer_cursor, + &plan, + &revision, + &repository_fingerprint, + &[pending.clone()], + &binding, + ) + .expect("planning batch id"); + let batch = AgentRuntimeProviderActionBatch { + schema_version: AGENT_RUNTIME_PLAN_PROVIDER_ACTION_BATCH_SCHEMA_VERSION.to_string(), + batch_id, + provider_request_id: Some(binding.provider_request_id.clone()), + project_id, + agent_id: runtime.agent_id.clone(), + task_id: runtime.task_id.clone(), + session_id: runtime.session_id.clone(), + run_id: runtime.run_id.clone(), + source: runtime.source.clone(), + run_profile: runtime.run_profile.clone(), + run_profile_binding_fingerprint: runtime.run_profile_binding_fingerprint.clone(), + planning_session_binding: Some(binding), + loop_iteration: runtime.loop_iteration, + planned_steer_cursor: runtime.applied_steer_cursor, + status: AGENT_RUNTIME_PROVIDER_ACTION_BATCH_STATUS_READY.to_string(), + next_action_index: 0, + plan, + actions: vec![pending.clone()], + collaboration_contract: None, + project_revision_before: revision, + planned_repository_context_fingerprint: repository_fingerprint, + created_at: pending.created_at, + updated_at: pending.updated_at, + }; + write_game_creator_agent_runtime_pending_tool_action(root, &pending) + .expect("write submit pending anchor"); + write_game_creator_agent_runtime_provider_action_batch(root, &batch) + .expect("write submit batch anchor"); + let delivery = new_static_delegate_delivery( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &root_runtime.session_id, + &root_run_id, + &parent_action_id, + &delegation_id, + GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + &runtime.session_id, + &runtime.run_id, + ); + create_or_read_static_delegate_delivery_at(root, &delivery) + .expect("write planning child delivery"); + + let submitted = execute_plan_submit_gdd_for_pending_action(root, &runtime, &pending) + .expect("commit immutable Fast GDD"); + assert_eq!(submitted.outcome, "submitted"); + assert_eq!(submitted.gdd_ref.version, 1); + assert!(!submitted.recovery_pending); + let gdd_chain = read_plan_gdd_chain(root).expect("read committed GDD chain"); + assert_eq!(gdd_chain.len(), 1); + CommittedPlanSubmitAnchorFixture { + runtime, + pending, + batch, + gdd_chain, + } + } + + fn resume_committed_plan_submit_anchor_at(root: &Path, runtime: &AgentRuntimeState) { + let runtime_lock = + try_acquire_game_creator_agent_runtime_task_lock(root, &runtime.agent_id) + .expect("acquire planning runtime lock") + .expect("planning runtime lock available"); + let resumed = + resume_game_creator_agent_pending_tool_action_at(root, &runtime.agent_id, runtime_lock) + .expect("resume committed planning submit"); + assert!(matches!( + resumed, + AgentRuntimePendingActionResume::Handled(_) + )); + } + + fn assert_exact_plan_submit_anchor_pair_at( + root: &Path, + expected_pending: &AgentRuntimePendingToolAction, + expected_batch: &AgentRuntimeProviderActionBatch, + ) { + let pending = read_game_creator_agent_runtime_pending_tool_action( + root, + &expected_pending.agent_id, + &expected_pending.run_id, + ) + .expect("read recovered standalone pending"); + let batch = read_game_creator_agent_runtime_provider_action_batch( + root, + &expected_pending.agent_id, + &expected_pending.run_id, + ) + .expect("read recovered provider batch"); + assert_eq!( + pending.schema_version, + AGENT_RUNTIME_PENDING_ACTION_SCHEMA_VERSION + ); + assert_eq!(pending.action_id, expected_pending.action_id); + assert_eq!( + pending.action_fingerprint, + expected_pending.action_fingerprint + ); + assert_eq!( + pending.planning_session_binding, + expected_pending.planning_session_binding + ); + assert_eq!( + pending.provider_batch_plan_update, + expected_pending.provider_batch_plan_update + ); + assert_eq!(batch.batch_id, expected_batch.batch_id); + assert_eq!( + batch.provider_request_id, + expected_batch.provider_request_id + ); + assert_eq!( + batch.planning_session_binding, + expected_batch.planning_session_binding + ); + assert_eq!(batch.plan.plan_update, expected_batch.plan.plan_update); + assert_eq!(batch.actions[0].action_id, expected_pending.action_id); + assert_eq!( + batch.actions[0].action_fingerprint, + expected_pending.action_fingerprint + ); + assert_eq!( + batch.actions[0].provider_batch_plan_update, + batch.plan.plan_update + ); + assert_eq!(pending, *expected_pending); + assert_eq!(batch, *expected_batch); + } + + #[test] + fn committed_plan_submit_pending_only_restores_exact_v4_batch_without_new_gdd() { + let temporary = crate::tests::canonical_test_tempdir("plan-submit-pending-only-"); + let root = temporary.path(); + let fixture = committed_plan_submit_anchor_fixture_at(root, "pending-only"); + remove_game_creator_agent_runtime_provider_action_batch( + root, + &fixture.runtime.agent_id, + &fixture.runtime.run_id, + ) + .expect("remove provider batch anchor"); + assert!(game_creator_agent_runtime_pending_tool_action_exists( + root, + &fixture.runtime.agent_id, + &fixture.runtime.run_id + )); + assert!(!game_creator_agent_runtime_provider_action_batch_exists( + root, + &fixture.runtime.agent_id, + &fixture.runtime.run_id + )); + + resume_committed_plan_submit_anchor_at(root, &fixture.runtime); + + assert_exact_plan_submit_anchor_pair_at(root, &fixture.pending, &fixture.batch); + assert_eq!( + read_plan_gdd_chain(root).expect("reread GDD chain after pending-only recovery"), + fixture.gdd_chain + ); + assert!(!root.join(".agent/planning/gdd.v2.json").exists()); + } + + #[test] + fn committed_plan_submit_batch_only_restores_exact_v5_pending_without_new_gdd() { + let temporary = crate::tests::canonical_test_tempdir("plan-submit-batch-only-"); + let root = temporary.path(); + let fixture = committed_plan_submit_anchor_fixture_at(root, "batch-only"); + remove_game_creator_agent_runtime_pending_tool_action( + root, + &fixture.runtime.agent_id, + &fixture.runtime.run_id, + ) + .expect("remove standalone pending anchor"); + assert!(!game_creator_agent_runtime_pending_tool_action_exists( + root, + &fixture.runtime.agent_id, + &fixture.runtime.run_id + )); + assert!(game_creator_agent_runtime_provider_action_batch_exists( + root, + &fixture.runtime.agent_id, + &fixture.runtime.run_id + )); + + resume_committed_plan_submit_anchor_at(root, &fixture.runtime); + + assert_exact_plan_submit_anchor_pair_at(root, &fixture.pending, &fixture.batch); + assert_eq!( + read_plan_gdd_chain(root).expect("reread GDD chain after batch-only recovery"), + fixture.gdd_chain + ); + assert!(!root.join(".agent/planning/gdd.v2.json").exists()); + } + + #[test] + fn committed_plan_submit_surviving_pending_binding_drift_fails_closed() { + let temporary = crate::tests::canonical_test_tempdir("plan-submit-anchor-drift-"); + let root = temporary.path(); + let fixture = committed_plan_submit_anchor_fixture_at(root, "binding-drift"); + remove_game_creator_agent_runtime_provider_action_batch( + root, + &fixture.runtime.agent_id, + &fixture.runtime.run_id, + ) + .expect("remove provider batch anchor"); + let mut drifted = fixture.pending.clone(); + let binding = drifted + .planning_session_binding + .as_mut() + .expect("planning binding"); + binding.gdd_id = "gdd-00000000-0000-4000-8000-000000000099".to_string(); + binding.provider_request_id.clear(); + binding.fingerprint.clear(); + binding.provider_request_id = plan_provider_session_binding_base_request_id(binding) + .expect("recompute drifted provider request id"); + binding.fingerprint = plan_provider_session_binding_fingerprint(binding) + .expect("recompute drifted binding fingerprint"); + write_game_creator_agent_runtime_pending_tool_action(root, &drifted) + .expect("write self-consistent drifted surviving pending"); + assert_eq!(drifted.action_id, fixture.pending.action_id); + assert_eq!( + drifted.action_fingerprint, + fixture.pending.action_fingerprint + ); + assert!( + !ensure_committed_plan_submit_anchor_pair_for_pending_at(root, &drifted) + .expect("compare drifted pending with immutable GDD") + ); + + resume_committed_plan_submit_anchor_at(root, &fixture.runtime); + + let recovered = read_game_creator_agent_runtime_at(root, &fixture.runtime.agent_id) + .expect("read fail-closed planning runtime"); + assert_eq!(recovered.state.phase, "needs-reconciliation"); + assert!(recovered + .state + .error + .as_deref() + .is_some_and(|error| error.contains("不会自动重放"))); + assert!(!game_creator_agent_runtime_provider_action_batch_exists( + root, + &fixture.runtime.agent_id, + &fixture.runtime.run_id + )); + assert_eq!( + read_plan_gdd_chain(root).expect("reread GDD chain after drift rejection"), + fixture.gdd_chain + ); + assert!(!root.join(".agent/planning/gdd.v2.json").exists()); + } + + #[test] + fn completed_plan_submit_anchor_match_requires_exact_ready_executing_snapshots() { + let temporary = crate::tests::canonical_test_tempdir("plan-submit-anchor-shape-"); + let root = temporary.path(); + init_local_game_project_at(root, "plan-submit-anchor-shape", "提交锚点形状测试") + .expect("init project"); + bind_game_creator_agent_runtime_run_profile_at( + root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "plan-submit-anchor-shape-root", + AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_STANDARD), + None, + ) + .expect("bind planning root"); + let link = AgentRuntimeTaskLink { + parent_agent_id: Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string()), + parent_run_id: Some("plan-submit-anchor-shape-root".to_string()), + delegation_id: Some("plan-submit-anchor-shape-delegation".to_string()), + }; + bind_game_creator_agent_runtime_run_profile_at( + root, + GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + "plan-submit-anchor-shape-child", + "agent-delegate", + Some(AGENT_RUNTIME_RUN_PROFILE_STANDARD), + Some(&link), + ) + .expect("bind planning child"); + let mut runtime = start_game_creator_agent_runtime_task_at( + root, + GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + "提交 Fast GDD", + "plan-submit-anchor-shape-child", + "agent-delegate", + "提交 Fast GDD", + vec!["提交 Fast GDD".to_string()], + ) + .expect("start planning child"); + runtime.loop_iteration = 1; + let action = AgentRuntimeToolAction { + tool: PLAN_SUBMIT_GDD_TOOL.to_string(), + reason: Some("提交测试 GDD".to_string()), + input: serde_json::json!({"schemaVersion": PLAN_SUBMIT_GDD_INPUT_SCHEMA_VERSION}), + }; + let plan = AgentRuntimeToolPlan { + thinking_summary: "提交 Fast GDD".to_string(), + plan_update: Some(AgentRuntimePlanUpdate { + explanation: "完成 Fast GDD 提交步骤".to_string(), + steps: vec![AgentRuntimePlanUpdateStep { + step: "提交 Fast GDD".to_string(), + status: AGENT_RUNTIME_PLAN_STATUS_COMPLETED.to_string(), + }], + }), + plan: vec!["提交 Fast GDD".to_string()], + actions: vec![action.clone()], + response: String::new(), + }; + let revision = + read_game_creator_agent_runtime_project_revision(root).expect("read project revision"); + let repository_fingerprint = build_repository_startup_context_at(root) + .expect("read repository context") + .fingerprint; + let mut pending = build_game_creator_agent_runtime_pending_tool_action( + root, + &runtime, + &runtime.current_task, + &plan, + &[], + &revision, + &repository_fingerprint, + &action, + 0, + AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO, + AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING, + None, + ) + .expect("build pending action"); + let mut binding = PlanProviderSessionBindingV1 { + schema_version: PLAN_PROVIDER_SESSION_BINDING_SCHEMA_VERSION.to_string(), + project_id: "plan-submit-anchor-shape".to_string(), + gdd_id: "gdd-00000000-0000-4000-8000-000000000001".to_string(), + agent_id: runtime.agent_id.clone(), + task_id: runtime.task_id.clone(), + provider_request_id: String::new(), + session_id: runtime.session_id.clone(), + run_id: runtime.run_id.clone(), + root_agent_id: GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(), + root_run_id: "plan-submit-anchor-shape-root".to_string(), + delegation_id: "plan-submit-anchor-shape-delegation".to_string(), + goal_id: pending.goal_id.clone(), + goal_revision: pending.goal_revision, + goal_snapshot_fingerprint: pending.goal_snapshot_fingerprint.clone(), + source: runtime.source.clone(), + run_profile: runtime.run_profile.clone(), + run_profile_binding_fingerprint: runtime.run_profile_binding_fingerprint.clone(), + session_revision: 1, + session_fingerprint: format!("sha256-serde-json-v2:{}", "1".repeat(64)), + applied_steer_cursor: runtime.applied_steer_cursor, + request_kind: "tool-plan".to_string(), + request_slot: "loop-1-repair-0".to_string(), + web_search_enabled: false, + request_context_fingerprint: format!("sha256-serde-json-v2:{}", "2".repeat(64)), + fingerprint: String::new(), + }; + binding.provider_request_id = + plan_provider_session_binding_base_request_id(&binding).expect("base provider request"); + binding.fingerprint = + plan_provider_session_binding_fingerprint(&binding).expect("binding fingerprint"); + pending.planning_session_binding = Some(binding.clone()); + pending.provider_batch_plan_update = plan.plan_update.clone(); + let batch_id = agent_runtime_plan_provider_action_batch_id( + &binding.project_id, + &runtime.agent_id, + &runtime.task_id, + &runtime.session_id, + &runtime.run_id, + runtime.loop_iteration, + runtime.applied_steer_cursor, + &plan, + &revision, + &repository_fingerprint, + &[pending.clone()], + &binding, + ) + .expect("planning batch id"); + let batch = AgentRuntimeProviderActionBatch { + schema_version: AGENT_RUNTIME_PLAN_PROVIDER_ACTION_BATCH_SCHEMA_VERSION.to_string(), + batch_id, + provider_request_id: Some(binding.provider_request_id.clone()), + project_id: binding.project_id.clone(), + agent_id: runtime.agent_id.clone(), + task_id: runtime.task_id.clone(), + session_id: runtime.session_id.clone(), + run_id: runtime.run_id.clone(), + source: runtime.source.clone(), + run_profile: runtime.run_profile.clone(), + run_profile_binding_fingerprint: runtime.run_profile_binding_fingerprint.clone(), + planning_session_binding: Some(binding), + loop_iteration: runtime.loop_iteration, + planned_steer_cursor: runtime.applied_steer_cursor, + status: AGENT_RUNTIME_PROVIDER_ACTION_BATCH_STATUS_READY.to_string(), + next_action_index: 0, + plan, + actions: vec![pending.clone()], + collaboration_contract: None, + project_revision_before: revision, + planned_repository_context_fingerprint: repository_fingerprint, + created_at: pending.created_at, + updated_at: pending.updated_at, + }; + + assert!(planning_submit_batch_matches_pending(&batch, &pending)); + + let mut wrong_status = batch.clone(); + wrong_status.status = AGENT_RUNTIME_PROVIDER_ACTION_BATCH_STATUS_COMPLETED.to_string(); + assert!(!planning_submit_batch_matches_pending( + &wrong_status, + &pending + )); + let mut wrong_cursor = batch.clone(); + wrong_cursor.next_action_index = 1; + assert!(!planning_submit_batch_matches_pending( + &wrong_cursor, + &pending + )); + let mut approved_pending = pending.clone(); + approved_pending.status = AGENT_RUNTIME_PENDING_ACTION_STATUS_APPROVED.to_string(); + let mut approved_batch = batch.clone(); + approved_batch.actions[0] = approved_pending.clone(); + assert!(!planning_submit_batch_matches_pending( + &approved_batch, + &approved_pending + )); + let mut different_standalone = pending.clone(); + different_standalone.updated_at = different_standalone.updated_at.saturating_add(1); + assert!(!planning_submit_batch_matches_pending( + &batch, + &different_standalone + )); + + write_game_creator_agent_runtime_pending_tool_action(root, &pending) + .expect("write surviving standalone pending"); + let rebuilt = rebuild_missing_committed_plan_submit_batch_at(root, &pending) + .expect("rebuild exact v4 batch from standalone recovery material"); + assert_eq!(rebuilt, batch); + } + #[test] fn observed_unknown_canvas_generation_returns_to_same_approved_action() { let temporary = crate::tests::canonical_test_tempdir("prepared-pending-"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/recovery_scan.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/recovery_scan.rs index c15a2e2fc..506a4e6e6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/recovery_scan.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/recovery_scan.rs @@ -51,6 +51,153 @@ pub(in crate::agent) fn mark_waiting_provider_retry_needs_reconciliation_at( read_game_creator_agent_runtime_at(root, agent_id) } +struct MissingPlanSubmitAnchorCandidate { + runtime: AgentRuntimeState, + action_id: String, + action_fingerprint: String, + commit_matches: bool, +} + +/// Detect a planning submit whose immutable GDD or Runtime action summary +/// survived while both generic recovery anchors vanished. The GDD is needed +/// for the real commit-point/child-finish gap because Runtime does not publish +/// `pending_tool_action` into state until child finish. Once this detector has +/// projected its own reconciliation state, do not append it again. +fn missing_plan_submit_anchor_candidate_at( + root: &Path, + agent_id: &str, +) -> Result, String> { + let runtime = read_game_creator_agent_runtime_at(root, agent_id)?.state; + if runtime.phase == "needs-reconciliation" + && runtime.current_action == "Fast GDD 提交恢复锚点需要人工核对" + { + return Ok(None); + } + if runtime.agent_id != GAME_CREATOR_PROJECT_PLANNING_AGENT_ID + || runtime.source != "agent-delegate" + || runtime.run_profile != AGENT_RUNTIME_RUN_PROFILE_STANDARD + || runtime.run_id.trim().is_empty() + || runtime.session_id.trim().is_empty() + { + return Ok(None); + } + let pending_exists = + game_creator_agent_runtime_pending_tool_action_exists(root, agent_id, &runtime.run_id); + let batch_exists = + game_creator_agent_runtime_provider_action_batch_exists(root, agent_id, &runtime.run_id); + if pending_exists || batch_exists { + return Ok(None); + } + let chain = read_plan_gdd_chain(root).map_err(|error| error.to_string())?; + let matching_gdds = chain + .iter() + .filter(|gdd| { + gdd.agent_id == runtime.agent_id + && gdd.source == runtime.source + && gdd.run_profile == runtime.run_profile + && gdd.run_profile_binding_fingerprint == runtime.run_profile_binding_fingerprint + && gdd.session_id == runtime.session_id + && gdd.created_by_run_id == runtime.run_id + }) + .collect::>(); + let (action_id, action_fingerprint, commit_matches) = + if let Some(summary) = runtime.pending_tool_action.as_ref() { + if summary.tool.trim() != PLAN_SUBMIT_GDD_TOOL + || summary.action_id.trim().is_empty() + || summary.action_fingerprint.trim().is_empty() + { + if runtime.phase == "completed" || matching_gdds.len() != 1 { + return Ok(None); + } + let gdd = matching_gdds[0]; + ( + gdd.submission_id.clone(), + gdd.action_fingerprint.clone(), + false, + ) + } else { + let commit_matches = matching_gdds.iter().any(|gdd| { + gdd.submission_id == summary.action_id + && gdd.action_fingerprint == summary.action_fingerprint + }); + ( + summary.action_id.clone(), + summary.action_fingerprint.clone(), + commit_matches, + ) + } + } else if runtime.phase != "completed" && matching_gdds.len() == 1 { + let gdd = matching_gdds[0]; + ( + gdd.submission_id.clone(), + gdd.action_fingerprint.clone(), + true, + ) + } else { + return Ok(None); + }; + Ok(Some(MissingPlanSubmitAnchorCandidate { + runtime, + action_id, + action_fingerprint, + commit_matches, + })) +} + +fn reconcile_missing_plan_submit_anchors_at( + root: &Path, + agent_id: &str, +) -> Result, String> { + let Some(candidate) = missing_plan_submit_anchor_candidate_at(root, agent_id)? else { + return Ok(None); + }; + let MissingPlanSubmitAnchorCandidate { + mut runtime, + action_id, + action_fingerprint, + commit_matches, + } = candidate; + let error = if commit_matches { + "Fast GDD 已提交,但原 plan.submit_gdd 的 pending/batch 恢复锚点同时缺失" + } else { + "策划子 Run 声称 Fast GDD 已提交,但 immutable GDD 与 Runtime action identity 无法对账" + }; + runtime.status = "failed".to_string(); + runtime.phase = "needs-reconciliation".to_string(); + runtime.current_action = "Fast GDD 提交恢复锚点需要人工核对".to_string(); + runtime.waiting_on = "开发者核对 immutable GDD 与原 submit action identity".to_string(); + runtime.next_step = "核实并恢复原精确 pending/batch 锚点后再继续".to_string(); + runtime.error = Some(error.to_string()); + runtime.updated_at = unix_timestamp(); + append_game_creator_agent_runtime_task(root, &runtime)?; + refresh_game_creator_agent_runtime_task_queue(root, &mut runtime)?; + write_game_creator_agent_runtime_state(root, &runtime)?; + append_game_creator_agent_runtime_event( + root, + &runtime, + "plan.submit_gdd.anchor_missing", + "failed", + "needs-reconciliation", + "Runner 检测到已完成策划提交缺少恢复锚点,已停止自动清理与续跑。", + Some(error), + )?; + append_agent_db_record( + root, + serde_json::json!({ + "recordType": "agent.runtime.plan_submit_gdd.anchor_missing", + "agentId": runtime.agent_id, + "taskId": runtime.task_id, + "sessionId": runtime.session_id, + "runId": runtime.run_id, + "actionId": action_id, + "actionFingerprint": action_fingerprint, + "commitMatches": commit_matches, + }), + )?; + emit_game_creator_agent_runtime_update(root, agent_id); + read_game_creator_agent_runtime_at(root, agent_id).map(Some) +} + pub(in crate::agent) fn ensure_waiting_provider_retry_projection_at( root: &Path, retry: &AgentRuntimeProviderRetryRecord, @@ -446,6 +593,10 @@ pub(crate) fn has_recoverable_game_creator_agent_background_tasks_at( } for agent_id in collect_game_creator_agent_runtime_agent_ids(root)? { + match missing_plan_submit_anchor_candidate_at(root, &agent_id) { + Ok(Some(_)) | Err(_) => return Ok(true), + Ok(None) => {} + } match read_recoverable_game_creator_agent_runtime_task(root, &agent_id) { Ok(Some(_)) | Err(_) => return Ok(true), Ok(None) => {} @@ -748,6 +899,11 @@ pub(in crate::agent) fn resume_game_creator_agent_background_tasks_unredacted_at else { continue; }; + if let Some(result) = reconcile_missing_plan_submit_anchors_at(root, &agent_id)? { + resumed.push(result); + drop(runtime_lock); + continue; + } let mut retry_projection_blocked = false; if let Some(retries) = retry_records_by_agent.remove(&agent_id) { for retry in retries { @@ -1242,6 +1398,234 @@ pub(crate) fn resume_game_creator_agent_pending_action_for_agent_at( mod orphaned_external_generation_recovery_tests { use super::*; + fn valid_plan_submit_input_for_recovery() -> PlanSubmitGddInputV1 { + serde_json::from_value(serde_json::json!({ + "schemaVersion": PLAN_SUBMIT_GDD_INPUT_SCHEMA_VERSION, + "game": { + "title": "萤火守夜者", + "genre": {"primary": "轻策略", "fusion": null}, + "artStyle": { + "visualType": "手绘平面", + "keywords": ["暖色", "剪影", "纸感"], + "moodAndColor": "夜色中的暖黄灯火", + "mvpArtBoundary": "仅制作可复用的角色、灯火和地块素材" + }, + "oneLiner": "玩家在一局十分钟的守夜旅程中分配有限灯火、判断风险并选择路线,守住营地后寻找下一处安全落脚点", + "pillars": [ + { + "name": "取舍", + "playerFeel": "每次选择都有代价", + "mechanism": "有限灯火在路线与营地之间分配", + "decisionState": "confirmed" + }, + { + "name": "重玩", + "playerFeel": "想再试一次更优路线", + "mechanism": "不同路线组合产生不同风险", + "decisionState": "confirmed" + } + ], + "coreLoop": ["观察地图", "分配灯火", "选择路线", "处理事件"], + "targetUsers": { + "coreUsers": "喜欢短局策略的玩家", + "preferences": "偏好清晰反馈和轻量决策", + "sessionLength": "10至20分钟", + "referenceGames": [] + }, + "mvpSystems": [ + { + "system": "地图", + "minimalFunction": "展示当前营地与可选路线", + "whyRequired": "让玩家理解空间选择", + "verifyMethod": "能完成一局并看懂下一步", + "decisionState": "confirmed" + }, + { + "system": "灯火", + "minimalFunction": "消耗灯火换取安全或探索", + "whyRequired": "承载核心取舍", + "verifyMethod": "两种分配策略结果可区分", + "decisionState": "confirmed" + }, + { + "system": "事件", + "minimalFunction": "路线途中触发一项选择", + "whyRequired": "提供短局变化", + "verifyMethod": "重玩时可遇到不同事件", + "decisionState": "confirmed" + } + ], + "outOfScope": ["多人联机"], + "creatorTips": { + "doFirst": "先做一张可走完的地图", + "deferForNow": "暂缓复杂成长线", + "howToVerify": "观察玩家是否能说出每次选择的后果", + "expandWhen": "核心循环连续三局都可理解后再扩展" + } + }, + "decisions": [{ + "id": "initial-request", + "topic": "初始需求", + "state": "confirmed", + "answerSource": "user_freeform", + "round": 0, + "answerSummary": "做一个短局守夜策略游戏" + }], + "prototypeValidationItems": [] + })) + .expect("valid plan submit recovery input") + } + + #[test] + fn committed_plan_submit_without_either_anchor_is_publicly_recoverable_and_fails_closed() { + let temporary = crate::tests::canonical_test_tempdir("plan-submit-double-anchor-"); + let root = temporary.path(); + init_local_game_project_at( + root, + "plan-submit-double-anchor", + "策划提交双锚缺失恢复测试", + ) + .expect("init project"); + bind_game_creator_agent_runtime_run_profile_at( + root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "plan-submit-double-anchor-root", + AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_STANDARD), + None, + ) + .expect("bind planning root"); + let link = AgentRuntimeTaskLink { + parent_agent_id: Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string()), + parent_run_id: Some("plan-submit-double-anchor-root".to_string()), + delegation_id: Some("plan-submit-double-anchor-delegation".to_string()), + }; + bind_game_creator_agent_runtime_run_profile_at( + root, + GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + "plan-submit-double-anchor-child", + "agent-delegate", + Some(AGENT_RUNTIME_RUN_PROFILE_STANDARD), + Some(&link), + ) + .expect("bind planning child"); + let mut runtime = start_game_creator_agent_runtime_task_at( + root, + GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + "提交 Fast GDD", + "plan-submit-double-anchor-child", + "agent-delegate", + "提交 Fast GDD", + vec!["提交 Fast GDD".to_string()], + ) + .expect("start planning child"); + let action_id = "action-0123456789abcdef01234567"; + let action_fingerprint = "a".repeat(64); + let context = PlanSubmitGddRuntimeContext { + project_id: "plan-submit-double-anchor".to_string(), + gdd_id: "gdd-00000000-0000-4000-8000-000000000001".to_string(), + action_id: action_id.to_string(), + action_fingerprint: action_fingerprint.clone(), + agent_id: runtime.agent_id.clone(), + source: runtime.source.clone(), + run_profile: runtime.run_profile.clone(), + run_profile_binding_fingerprint: runtime.run_profile_binding_fingerprint.clone(), + root_agent_id: GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(), + root_run_id: "plan-submit-double-anchor-root".to_string(), + parent_agent_id: Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string()), + parent_run_id: Some("plan-submit-double-anchor-root".to_string()), + delegation_id: "plan-submit-double-anchor-delegation".to_string(), + session_id: runtime.session_id.clone(), + source_session_revision: 1, + source_session_fingerprint: format!("sha256-serde-json-v2:{}", "b".repeat(64)), + created_by_run_id: runtime.run_id.clone(), + created_at_utc: "2026-08-14T00:00:00.000Z".to_string(), + approval_request_id: Some( + "gdd-approval-00000000-0000-4000-8000-000000000002".to_string(), + ), + }; + let gdd = build_plan_gdd_from_submit_input( + &valid_plan_submit_input_for_recovery(), + &context, + 1, + context + .approval_request_id + .as_deref() + .expect("approval request id"), + ) + .expect("build committed GDD"); + let gdd_bytes = canonical_plan_gdd_bytes(&gdd).expect("canonical committed GDD"); + durable_create_json_no_replace(root, ".agent/planning/gdd.v1.json", &gdd_bytes, "GDD") + .expect("persist committed GDD"); + assert_eq!( + read_plan_gdd_chain(root) + .expect("read committed GDD chain") + .len(), + 1 + ); + + // Model the commit-point/post-finish gap: the GDD fact is durable, + // both generic anchors are gone, but the child has not reached its + // terminal projection yet. + runtime.status = "running".to_string(); + runtime.phase = "provider-action-batch".to_string(); + runtime.pending_tool_action = None; + runtime.updated_at = unix_timestamp(); + append_game_creator_agent_runtime_task(root, &runtime).expect("append pre-finish task"); + refresh_game_creator_agent_runtime_task_queue(root, &mut runtime) + .expect("refresh pre-finish task queue"); + write_game_creator_agent_runtime_state(root, &runtime).expect("write pre-finish state"); + + assert!( + missing_plan_submit_anchor_candidate_at(root, &runtime.agent_id) + .expect("detect missing pre-finish anchors") + .is_some() + ); + assert!(has_recoverable_game_creator_agent_background_tasks_at(root) + .expect("public preflight must expose double-anchor loss")); + + let resumed = resume_game_creator_agent_background_tasks_at(root) + .expect("public recovery must fail closed in runtime state"); + assert!(resumed + .iter() + .any(|result| result.state.agent_id == runtime.agent_id + && result.state.phase == "needs-reconciliation")); + let reconciled = read_game_creator_agent_runtime_at(root, &runtime.agent_id) + .expect("read reconciled planning child"); + assert_eq!(reconciled.state.phase, "needs-reconciliation"); + assert!(reconciled + .state + .error + .as_deref() + .is_some_and(|error| error.contains("恢复锚点同时缺失"))); + let audit_count = read_agent_db_records_bounded(root, 1024 * 1024) + .expect("read anchor-missing audit") + .0 + .iter() + .filter(|record| { + record.get("recordType").and_then(|value| value.as_str()) + == Some("agent.runtime.plan_submit_gdd.anchor_missing") + }) + .count(); + assert_eq!(audit_count, 1); + assert!( + !has_recoverable_game_creator_agent_background_tasks_at(root) + .expect("the detector must not rediscover its own reconciliation") + ); + let _ = resume_game_creator_agent_background_tasks_at(root) + .expect("second recovery scan may surface the existing reconciliation state"); + let audit_count_after_second_scan = read_agent_db_records_bounded(root, 1024 * 1024) + .expect("reread anchor-missing audit") + .0 + .iter() + .filter(|record| { + record.get("recordType").and_then(|value| value.as_str()) + == Some("agent.runtime.plan_submit_gdd.anchor_missing") + }) + .count(); + assert_eq!(audit_count_after_second_scan, audit_count); + } + #[test] fn recovery_scan_preserves_active_generation_orphan_then_cleans_terminal_legacy_orphan() { let temporary = crate::tests::canonical_test_tempdir("orphan-generation-recovery-"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol.rs index def206f66..b4f8e487e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol.rs @@ -9,6 +9,7 @@ mod finalization; mod json_sidecar; mod models; mod planning_storage; +mod planning_submit; mod provider_control; mod provider_retry; mod real_e2e_checkpoint; @@ -22,7 +23,8 @@ pub(in crate::agent) use context_bundle::*; pub(in crate::agent) use finalization::*; pub(in crate::agent) use json_sidecar::*; pub(in crate::agent) use models::*; -pub(in crate::agent) use planning_storage::*; +pub(crate) use planning_storage::*; +pub(crate) use planning_submit::*; pub(in crate::agent) use provider_control::*; pub(in crate::agent) use provider_retry::*; pub(in crate::agent) use real_e2e_checkpoint::*; 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 d732f5b3f..1804a4094 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 @@ -310,12 +310,9 @@ fn plan_root_retry_rejects_identity_mismatch_instead_of_degrading() { "", &session_id, ); - let missing_error = resolve_game_creator_agent_runtime_retry_configuration_at( - &root, - &missing_binding, - false, - ) - .expect_err("缺少 binding 必须拒绝而不是降级"); + let missing_error = + resolve_game_creator_agent_runtime_retry_configuration_at(&root, &missing_binding, false) + .expect_err("缺少 binding 必须拒绝而不是降级"); assert!( missing_error.contains(AGENT_RUNTIME_PLAN_ROOT_RETRY_IDENTITY_UNSUPPORTED_KIND), "{missing_error}" @@ -460,7 +457,9 @@ fn plan_root_retry_keeps_plan_source_and_goal_contract_authority() { assert_eq!(retry_binding.source, AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE); assert_eq!(retry_binding.profile, AGENT_RUNTIME_RUN_PROFILE_STANDARD); assert!(retry_binding.parent_agent_id.is_none()); - assert!(agent_runtime_supervisor_source_is_trusted(&retry_binding.source)); + assert!(agent_runtime_supervisor_source_is_trusted( + &retry_binding.source + )); let steer_error = steer_game_creator_agent_runtime_task_at( &root, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/models.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/models.rs index 5f5b26ccf..8d0738c71 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/models.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/models.rs @@ -136,6 +136,7 @@ pub(crate) struct ParsedAgentRuntimeToolPlan { pub(in crate::agent) struct RequestedAgentRuntimeToolPlan { pub(in crate::agent) plan: AgentRuntimeToolPlan, + pub(in crate::agent) planning_session_binding: Option, pub(in crate::agent) repository_context_fingerprint: String, pub(in crate::agent) mcp_catalog_fingerprint: String, pub(in crate::agent) estimated_input_tokens: u64, @@ -245,6 +246,10 @@ pub(crate) struct AgentRuntimeProviderRequestSnapshot { pub(in crate::agent) request_slot: String, pub(in crate::agent) web_search_enabled: bool, pub(in crate::agent) allow_idle_context_compaction: bool, + /// Exact-plan requests carry the source session captured before the + /// Provider call. Ordinary requests keep this `None` and retain the + /// legacy lifecycle/batch identity path. + pub(in crate::agent) planning_session_binding: Option, } impl AgentRuntimeProviderRequestSnapshot { @@ -265,6 +270,15 @@ impl AgentRuntimeProviderRequestSnapshot { snapshot.allow_idle_context_compaction = allow; snapshot } + + pub(in crate::agent) fn with_planning_session_binding( + &self, + binding: Option, + ) -> Self { + let mut snapshot = self.clone(); + snapshot.planning_session_binding = binding; + snapshot + } } #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_storage.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_storage.rs index 49cd1d8c3..853c10f8b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_storage.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_storage.rs @@ -114,6 +114,8 @@ pub(crate) const PLAN_STORAGE_ROOT: &str = ".agent/planning"; pub(crate) const PLAN_GDD_INDEX_PATH: &str = ".agent/planning/index.json"; pub(crate) const PLAN_SESSION_PATH: &str = ".agent/planning/session.json"; pub(crate) const PLAN_SESSION_PREVIOUS_PATH: &str = ".agent/planning/.session.json.previous"; +pub(crate) const PLAN_FAST_GDD_PATH: &str = "game/fast_gdd.md"; +pub(crate) const PLAN_FAST_GDD_MAX_BYTES: usize = 128 * 1024; static PLANNING_TEMP_NONCE: AtomicU64 = AtomicU64::new(1); @@ -124,7 +126,7 @@ pub(crate) struct PlanningStorageError { } impl PlanningStorageError { - fn new(code: &'static str, detail: impl Into) -> Self { + pub(crate) fn new(code: &'static str, detail: impl Into) -> Self { Self { code, detail: detail.into(), @@ -208,17 +210,17 @@ fn is_lower_hex(value: &str, length: usize) -> bool { is_hex(value, length) && value.bytes().all(|byte| !byte.is_ascii_uppercase()) } -fn is_typed_fingerprint(value: &str) -> bool { +pub(crate) fn is_typed_fingerprint(value: &str) -> bool { value .strip_prefix("sha256-serde-json-v2:") .is_some_and(|digest| is_lower_hex(digest, 64)) } -fn is_bare_fingerprint(value: &str) -> bool { +pub(crate) fn is_bare_fingerprint(value: &str) -> bool { is_lower_hex(value, 64) } -fn validate_text( +pub(crate) fn validate_text( value: &str, label: &str, min: usize, @@ -259,7 +261,7 @@ pub(crate) fn normalize_plan_text( Ok(normalized) } -fn validate_opaque_id( +pub(crate) fn validate_opaque_id( value: &str, label: &str, allow_empty: bool, @@ -282,7 +284,7 @@ fn validate_opaque_id( Ok(()) } -fn validate_uuid_prefixed( +pub(crate) fn validate_uuid_prefixed( value: &str, prefix: &str, label: &str, @@ -305,7 +307,7 @@ fn validate_uuid_prefixed( Ok(()) } -fn validate_action_id(value: &str, label: &str) -> Result<(), PlanningStorageError> { +pub(crate) fn validate_action_id(value: &str, label: &str) -> Result<(), PlanningStorageError> { let Some(digest) = value.strip_prefix("action-") else { return Err(invalid(format!("{label} 必须以 action- 开头"))); }; @@ -315,7 +317,7 @@ fn validate_action_id(value: &str, label: &str) -> Result<(), PlanningStorageErr Ok(()) } -fn validate_timestamp(value: &str, label: &str) -> Result<(), PlanningStorageError> { +pub(crate) fn validate_timestamp(value: &str, label: &str) -> Result<(), PlanningStorageError> { validate_text(value, label, 24, 24)?; if value.len() != 24 || !value.is_ascii() { return Err(invalid(format!("{label} 必须是 ASCII UTC 毫秒时间"))); @@ -3002,7 +3004,16 @@ pub(crate) fn durable_create_json_no_replace_locked( return Err(io_error(&format!("发布 {label} 失败"), error)); } }; - sync_planning_parent(parent)?; + sync_planning_parent(parent).map_err(|error| { + // The target may already have been atomically published when the + // directory flush fails. This is an unknown commit-point result, + // not a normal rejection: callers must reconcile the target before + // claiming committed/replayed semantics. + PlanningStorageError::new( + "PLAN_COMMIT_UNKNOWN", + format!("{label} 已发布但父目录同步结果未知:{error}"), + ) + })?; Ok(outcome) } @@ -3063,6 +3074,127 @@ pub(crate) fn write_plan_gdd_index_atomic_locked( result } +/// Atomically publish the human-readable Fast GDD projection. This is a +/// Runtime-owned projection writer, deliberately separate from the generic +/// `file.write` gate (which rejects this path for planning Agents). The +/// caller must hold the project write lock when using the `_locked` variant. +pub(crate) fn write_plan_fast_gdd_markdown_atomic( + root: &Path, + markdown: &str, +) -> Result<(), PlanningStorageError> { + let _lock = acquire_project_write_lock(root, "planning.fast-gdd") + .map_err(|error| io_error("取得 Fast GDD 投影项目锁失败", error))?; + write_plan_fast_gdd_markdown_atomic_locked(root, markdown) +} + +pub(crate) fn write_plan_fast_gdd_markdown_atomic_locked( + root: &Path, + markdown: &str, +) -> Result<(), PlanningStorageError> { + let bytes = markdown.as_bytes(); + if bytes.is_empty() { + return Err(invalid("Fast GDD Markdown 不能为空")); + } + if bytes.len() > PLAN_FAST_GDD_MAX_BYTES { + return Err(PlanningStorageError::new( + "PLAN_SIZE_LIMIT", + format!( + "Fast GDD Markdown 超过 {} 字节上限", + PLAN_FAST_GDD_MAX_BYTES + ), + )); + } + if bytes.contains(&0) || !std::str::from_utf8(bytes).is_ok() { + return Err(invalid("Fast GDD Markdown 必须是无 NUL 的 UTF-8 文本")); + } + + let target = resolve_local_project_path(root, PLAN_FAST_GDD_PATH) + .map_err(|error| PlanningStorageError::new("PLAN_INVALID_PATH", error))?; + let parent = target + .parent() + .ok_or_else(|| PlanningStorageError::new("PLAN_INVALID_PATH", "Fast GDD 缺少父目录"))?; + + // The projection lives outside `.agent/planning`, so it cannot use the + // planning-only parent helper. Build the relative `game/` directory one + // component at a time and reject links/reparse points at every step. + let root_metadata = + fs::symlink_metadata(root).map_err(|error| io_error("读取项目根目录失败", error))?; + if planning_metadata_is_link_or_reparse(&root_metadata) || !root_metadata.is_dir() { + return Err(PlanningStorageError::new( + "PLAN_UNTRUSTED_PATH", + "项目根目录必须是可信普通目录", + )); + } + let mut cursor = root.to_path_buf(); + let relative_parent = parent + .strip_prefix(root) + .map_err(|_| PlanningStorageError::new("PLAN_INVALID_PATH", "Fast GDD 父目录越出项目根"))?; + for component in relative_parent.components() { + use std::path::Component; + let Component::Normal(component) = component else { + return Err(PlanningStorageError::new( + "PLAN_INVALID_PATH", + "Fast GDD 父目录组件非法", + )); + }; + cursor.push(component); + match fs::symlink_metadata(&cursor) { + Ok(metadata) => { + if planning_metadata_is_link_or_reparse(&metadata) || !metadata.is_dir() { + return Err(PlanningStorageError::new( + "PLAN_UNTRUSTED_PATH", + "Fast GDD 父目录必须是可信普通目录", + )); + } + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + fs::create_dir(&cursor) + .map_err(|error| io_error("创建 Fast GDD 父目录失败", error))?; + let metadata = fs::symlink_metadata(&cursor) + .map_err(|error| io_error("复核 Fast GDD 父目录失败", error))?; + if planning_metadata_is_link_or_reparse(&metadata) || !metadata.is_dir() { + return Err(PlanningStorageError::new( + "PLAN_UNTRUSTED_PATH", + "新建 Fast GDD 父目录不是可信普通目录", + )); + } + } + Err(error) => return Err(io_error("读取 Fast GDD 父目录失败", error)), + } + } + + match fs::symlink_metadata(&target) { + Ok(_) => { + verify_regular_planning_file(&target, "现有 Fast GDD Markdown")?; + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(io_error("读取现有 Fast GDD Markdown 失败", error)), + } + + let temporary = temp_planning_path(parent, &target); + let result = (|| { + write_sync_new_file(&temporary, bytes, "Fast GDD Markdown")?; + verify_replace_target_is_safe(&target, "Fast GDD Markdown")?; + replace_planning_file_atomically(&temporary, &target, "Fast GDD Markdown")?; + let published = read_regular_planning_file(&target, "已发布 Fast GDD Markdown")?; + if published != bytes { + return Err(PlanningStorageError::new( + "PLAN_RECONCILIATION_REQUIRED", + "Fast GDD Markdown 发布后内容不一致", + )); + } + sync_planning_parent(parent) + })(); + if temporary.exists() { + let cleanup = fs::remove_file(&temporary) + .map_err(|error| io_error("清理 Fast GDD 临时文件失败", error)); + if result.is_ok() { + cleanup?; + } + } + result +} + fn replace_planning_file_atomically( temporary: &Path, target: &Path, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_submit.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_submit.rs new file mode 100644 index 000000000..6cfb34285 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_submit.rs @@ -0,0 +1,2936 @@ +use super::planning_storage::PLAN_SUBMIT_GDD_INPUT_SCHEMA_VERSION as PLAN_SUBMIT_INPUT_SCHEMA; +use super::*; + +use sha2::{Digest, Sha256}; +use std::time::{SystemTime, UNIX_EPOCH}; +use uuid::Uuid; + +/// Native planning action name. The catalog/policy registration lives in the +/// Runtime action modules; the storage handler keeps the durable boundary in a +/// separate module so it can be called before the generic executor. +pub(crate) const PLAN_SUBMIT_GDD_TOOL: &str = "plan.submit_gdd"; + +/// The small, Runtime-owned identity envelope that travels with a planning +/// submit action. A Provider response is asynchronous: by the time the +/// action is resumed the planning session may have advanced. Keeping the +/// source snapshot next to the durable action makes that distinction +/// explicit and prevents the executor from silently rebinding an old +/// response to a newer session. +pub(crate) const PLAN_PROVIDER_SESSION_BINDING_SCHEMA_VERSION: &str = + "plan-provider-session-binding.v1"; +pub(crate) const PLAN_PROVIDER_SESSION_BINDING_FINGERPRINT_DOMAIN: &str = + "genarrative.plan.provider-session-binding.v1"; +pub(crate) const PLAN_PROVIDER_REQUEST_ID_DOMAIN: &str = "genarrative.plan.provider-request-id.v1"; +pub(crate) const PLAN_PROVIDER_STRUCTURED_INJECTIONS_SCHEMA_VERSION: &str = + "plan-provider-structured-injections.v1"; +pub(crate) const PLAN_PROVIDER_STRUCTURED_INJECTIONS_MESSAGE_HEADER: &str = + "AGC_PLAN_PROVIDER_STRUCTURED_INJECTIONS_V1"; +pub(crate) const PLAN_PROVIDER_STRUCTURED_INJECTIONS_MAX_BYTES: usize = 64 * 1024; + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub(in crate::agent) struct PlanProviderFacingSessionV1 { + pub(in crate::agent) phase: String, + pub(in crate::agent) decisions_summary: Vec, + pub(in crate::agent) prototype_validation_items: Vec, + pub(in crate::agent) latest_submitted_ref: Option, + pub(in crate::agent) last_decision_ref: Option, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub(in crate::agent) struct PlanProviderApprovalObservationV1 { + pub(in crate::agent) tool: String, + pub(in crate::agent) status: String, + pub(in crate::agent) summary: String, + pub(in crate::agent) detail: Option, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub(in crate::agent) struct PlanProviderStructuredInjectionsV1 { + pub(in crate::agent) schema_version: String, + pub(in crate::agent) clarification_round: u32, + pub(in crate::agent) accumulated_agent_millis: u64, + pub(in crate::agent) session: PlanProviderFacingSessionV1, + pub(in crate::agent) platform_facts: PlanPlatformFacts, + pub(in crate::agent) approval_observation: Option, +} + +pub(in crate::agent) fn fixed_plan_platform_facts() -> PlanPlatformFacts { + PlanPlatformFacts { + runtime: "self-contained-web".to_string(), + viewports: vec!["desktop".to_string(), "mobile".to_string()], + inputs: vec!["keyboard".to_string(), "touch".to_string()], + preview: "local-http".to_string(), + } +} + +/// Capture the only Provider-visible planning sidecar. The compact bytes +/// returned here are used twice without rebuilding: once as the dedicated +/// request message and once as request-context fingerprint material. +pub(crate) fn capture_plan_provider_structured_injections_at( + root: &Path, + session_id: &str, + observations: &[AgentRuntimeToolObservation], +) -> Result, String> { + let session = read_plan_session_with_recovery_locked(root) + .map_err(|error| error.to_string())? + .ok_or_else(|| "planning Provider 请求缺少 plan session primary".to_string())?; + if session.session_id != session_id { + return Err("planning Provider 注入的 sessionId 与当前请求不一致".to_string()); + } + let deliveries = list_static_delegate_deliveries_at(root)?; + let (_, clarification_round) = + static_delegate_lineage_counters(&deliveries, &session.latest_delegation_id); + if clarification_round == u32::MAX { + return Err("planning Provider 无法从委派链推导 clarificationRound".to_string()); + } + validate_plan_session_for_clarification_round(&session, clarification_round) + .map_err(|error| error.to_string())?; + let approval_observation = observations + .iter() + .rev() + .find(|observation| observation.tool == PLAN_SUBMIT_GDD_TOOL && observation.status == "ok") + .map(|observation| PlanProviderApprovalObservationV1 { + tool: observation.tool.clone(), + status: observation.status.clone(), + summary: observation.summary.clone(), + detail: observation.detail.clone(), + }); + let value = PlanProviderStructuredInjectionsV1 { + schema_version: PLAN_PROVIDER_STRUCTURED_INJECTIONS_SCHEMA_VERSION.to_string(), + clarification_round, + accumulated_agent_millis: session.accumulated_agent_millis, + session: PlanProviderFacingSessionV1 { + phase: session.phase, + decisions_summary: session.decisions_summary, + prototype_validation_items: session.prototype_validation_items, + latest_submitted_ref: session.latest_submitted_ref, + last_decision_ref: session.last_decision_ref, + }, + platform_facts: fixed_plan_platform_facts(), + approval_observation, + }; + let bytes = serde_json::to_vec(&value) + .map_err(|error| format!("序列化 planning Provider structured injections 失败:{error}"))?; + if bytes.len() > PLAN_PROVIDER_STRUCTURED_INJECTIONS_MAX_BYTES { + return Err("planning Provider structured injections 超出 64 KiB".to_string()); + } + Ok(bytes) +} + +pub(crate) fn render_plan_provider_structured_injections_message( + wire_bytes: &[u8], +) -> Result { + if wire_bytes.is_empty() || wire_bytes.len() > PLAN_PROVIDER_STRUCTURED_INJECTIONS_MAX_BYTES { + return Err("planning Provider structured injections wire bytes 非法".to_string()); + } + let parsed = serde_json::from_slice::(wire_bytes) + .map_err(|error| format!("解析 planning Provider structured injections 失败:{error}"))?; + let canonical = serde_json::to_vec(&parsed) + .map_err(|error| format!("重算 planning Provider structured injections 失败:{error}"))?; + if canonical != wire_bytes { + return Err( + "planning Provider structured injections 不是 canonical compact JSON".to_string(), + ); + } + let json = std::str::from_utf8(wire_bytes) + .map_err(|_| "planning Provider structured injections 不是 UTF-8".to_string())?; + Ok(format!( + "{PLAN_PROVIDER_STRUCTURED_INJECTIONS_MESSAGE_HEADER}\n{json}" + )) +} +const PLAN_PROVIDER_REQUEST_ATTEMPT_ID_DOMAIN: &str = + "genarrative.plan.provider-request-attempt.v1"; + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub(crate) struct PlanProviderSessionBindingV1 { + pub(crate) schema_version: String, + pub(crate) project_id: String, + pub(crate) gdd_id: String, + pub(crate) agent_id: String, + pub(crate) task_id: String, + pub(crate) provider_request_id: String, + pub(crate) session_id: String, + pub(crate) run_id: String, + pub(crate) root_agent_id: String, + pub(crate) root_run_id: String, + pub(crate) delegation_id: String, + pub(crate) goal_id: Option, + pub(crate) goal_revision: u64, + pub(crate) goal_snapshot_fingerprint: String, + pub(crate) source: String, + pub(crate) run_profile: String, + pub(crate) run_profile_binding_fingerprint: String, + pub(crate) session_revision: u32, + pub(crate) session_fingerprint: String, + pub(crate) applied_steer_cursor: u64, + pub(crate) request_kind: String, + pub(crate) request_slot: String, + pub(crate) web_search_enabled: bool, + pub(crate) request_context_fingerprint: String, + pub(crate) fingerprint: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct PlanProviderSessionBindingFingerprintValue<'a> { + schema_version: &'a str, + project_id: &'a str, + gdd_id: &'a str, + agent_id: &'a str, + task_id: &'a str, + provider_request_id: &'a str, + session_id: &'a str, + run_id: &'a str, + root_agent_id: &'a str, + root_run_id: &'a str, + delegation_id: &'a str, + goal_id: Option<&'a str>, + goal_revision: u64, + goal_snapshot_fingerprint: &'a str, + source: &'a str, + run_profile: &'a str, + run_profile_binding_fingerprint: &'a str, + session_revision: u32, + session_fingerprint: &'a str, + applied_steer_cursor: u64, + request_kind: &'a str, + request_slot: &'a str, + web_search_enabled: bool, + request_context_fingerprint: &'a str, +} + +impl<'a> From<&'a PlanProviderSessionBindingV1> for PlanProviderSessionBindingFingerprintValue<'a> { + fn from(value: &'a PlanProviderSessionBindingV1) -> Self { + Self { + schema_version: &value.schema_version, + project_id: &value.project_id, + gdd_id: &value.gdd_id, + agent_id: &value.agent_id, + task_id: &value.task_id, + provider_request_id: &value.provider_request_id, + session_id: &value.session_id, + run_id: &value.run_id, + root_agent_id: &value.root_agent_id, + root_run_id: &value.root_run_id, + delegation_id: &value.delegation_id, + goal_id: value.goal_id.as_deref(), + goal_revision: value.goal_revision, + goal_snapshot_fingerprint: &value.goal_snapshot_fingerprint, + source: &value.source, + run_profile: &value.run_profile, + run_profile_binding_fingerprint: &value.run_profile_binding_fingerprint, + session_revision: value.session_revision, + session_fingerprint: &value.session_fingerprint, + applied_steer_cursor: value.applied_steer_cursor, + request_kind: &value.request_kind, + request_slot: &value.request_slot, + web_search_enabled: value.web_search_enabled, + request_context_fingerprint: &value.request_context_fingerprint, + } + } +} + +pub(crate) fn plan_provider_session_binding_fingerprint( + value: &PlanProviderSessionBindingV1, +) -> Result { + typed_serde_fingerprint( + PLAN_PROVIDER_SESSION_BINDING_FINGERPRINT_DOMAIN, + &PlanProviderSessionBindingFingerprintValue::from(value), + ) +} + +fn deterministic_uuid_prefixed(prefix: &str, material: &str) -> String { + let digest = Sha256::digest(material.as_bytes()); + let mut bytes = [0_u8; 16]; + bytes.copy_from_slice(&digest[..16]); + // RFC 4122 version 4 / variant bits keep the generated value compatible + // with the existing opaque UUID-prefixed validators while remaining + // deterministic across a retry of the same durable action. + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + format!("{prefix}{}", Uuid::from_bytes(bytes).hyphenated()) +} + +pub(crate) fn plan_provider_approval_request_id( + action_id: &str, + action_fingerprint: &str, + session_fingerprint: &str, +) -> String { + deterministic_uuid_prefixed( + "gdd-approval-", + &format!("{action_id}\n{action_fingerprint}\n{session_fingerprint}"), + ) +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct PlanProviderRequestIdentityValue<'a> { + project_id: &'a str, + gdd_id: &'a str, + agent_id: &'a str, + task_id: &'a str, + session_id: &'a str, + run_id: &'a str, + root_agent_id: &'a str, + root_run_id: &'a str, + delegation_id: &'a str, + source: &'a str, + run_profile: &'a str, + run_profile_binding_fingerprint: &'a str, + goal_id: Option<&'a str>, + goal_revision: u64, + goal_snapshot_fingerprint: &'a str, + session_revision: u32, + session_fingerprint: &'a str, + applied_steer_cursor: u64, + request_kind: &'a str, + request_slot: &'a str, + web_search_enabled: bool, + request_context_fingerprint: &'a str, +} + +fn plan_provider_request_id( + value: &PlanProviderRequestIdentityValue<'_>, +) -> Result { + let bytes = typed_serde_canonical_bytes(PLAN_PROVIDER_REQUEST_ID_DOMAIN, value)?; + Ok(format!("provider-request-{:x}", Sha256::digest(bytes))) +} + +/// Recompute the base Provider request identity from a frozen binding. The +/// binding fingerprint protects the envelope bytes, while this independent +/// derivation protects the request ID algorithm itself; otherwise a forged +/// binding could choose an arbitrary providerRequestId and still pass a +/// self-consistent fingerprint check. +pub(crate) fn plan_provider_session_binding_base_request_id( + binding: &PlanProviderSessionBindingV1, +) -> Result { + let request_slot = binding + .request_slot + .split_once("-transient-") + .map(|(base, _)| base) + .unwrap_or(binding.request_slot.as_str()); + plan_provider_request_id(&PlanProviderRequestIdentityValue { + project_id: &binding.project_id, + gdd_id: &binding.gdd_id, + agent_id: &binding.agent_id, + task_id: &binding.task_id, + session_id: &binding.session_id, + run_id: &binding.run_id, + root_agent_id: &binding.root_agent_id, + root_run_id: &binding.root_run_id, + delegation_id: &binding.delegation_id, + source: &binding.source, + run_profile: &binding.run_profile, + run_profile_binding_fingerprint: &binding.run_profile_binding_fingerprint, + goal_id: binding.goal_id.as_deref(), + goal_revision: binding.goal_revision, + goal_snapshot_fingerprint: &binding.goal_snapshot_fingerprint, + session_revision: binding.session_revision, + session_fingerprint: &binding.session_fingerprint, + applied_steer_cursor: binding.applied_steer_cursor, + request_kind: &binding.request_kind, + request_slot, + web_search_enabled: binding.web_search_enabled, + request_context_fingerprint: &binding.request_context_fingerprint, + }) +} + +fn plan_provider_session_binding_expected_request_id( + binding: &PlanProviderSessionBindingV1, +) -> Result { + let base_request_id = plan_provider_session_binding_base_request_id(binding)?; + let Some((base_slot, attempt_text)) = binding.request_slot.split_once("-transient-") else { + return Ok(base_request_id); + }; + if base_slot.is_empty() { + return Err(submit_error( + "PLAN_NEEDS_RECONCILIATION", + "planning Provider attempt requestSlot 缺少 base slot", + )); + } + let attempt = attempt_text.parse::().map_err(|_| { + submit_error( + "PLAN_NEEDS_RECONCILIATION", + "planning Provider attempt requestSlot 的 attempt 无效", + ) + })?; + if attempt == 0 || attempt > 64 { + return Err(submit_error( + "PLAN_NEEDS_RECONCILIATION", + "planning Provider attempt 超出允许范围", + )); + } + Ok(plan_provider_request_attempt_id(&base_request_id, attempt)) +} + +fn is_provider_request_id(value: &str) -> bool { + value + .strip_prefix("provider-request-") + .is_some_and(is_bare_fingerprint) +} + +pub(crate) fn validate_plan_provider_session_binding( + binding: &PlanProviderSessionBindingV1, +) -> Result<(), PlanningStorageError> { + if binding.schema_version != PLAN_PROVIDER_SESSION_BINDING_SCHEMA_VERSION { + return Err(submit_error( + "PLAN_NEEDS_RECONCILIATION", + "planning provider session binding schema 不受支持", + )); + } + validate_opaque_id(&binding.project_id, "binding.projectId", false)?; + validate_uuid_prefixed(&binding.gdd_id, "gdd-", "binding.gddId")?; + if binding.agent_id != GAME_CREATOR_PROJECT_PLANNING_AGENT_ID { + return Err(submit_error( + "PLAN_NEEDS_RECONCILIATION", + "planning provider session binding agentId 非法", + )); + } + validate_opaque_id(&binding.task_id, "binding.taskId", false)?; + if !is_provider_request_id(&binding.provider_request_id) { + return Err(submit_error( + "PLAN_NEEDS_RECONCILIATION", + "planning provider session binding providerRequestId 非法", + )); + } + validate_opaque_id(&binding.session_id, "binding.sessionId", false)?; + validate_opaque_id(&binding.run_id, "binding.runId", false)?; + if binding.root_agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { + return Err(submit_error( + "PLAN_NEEDS_RECONCILIATION", + "planning provider session binding rootAgentId 非法", + )); + } + validate_opaque_id(&binding.root_run_id, "binding.rootRunId", false)?; + validate_opaque_id(&binding.delegation_id, "binding.delegationId", false)?; + if binding.source != "agent-delegate" + || binding.run_profile != AGENT_RUNTIME_RUN_PROFILE_STANDARD + || !is_bare_fingerprint(&binding.run_profile_binding_fingerprint) + || binding.session_revision == 0 + || !is_typed_fingerprint(&binding.session_fingerprint) + || !matches!( + binding.request_kind.as_str(), + "tool-plan" | "final-reply" | "context-compaction" | "final-reply-context-compaction" + ) + || binding.request_slot.trim().is_empty() + || binding.web_search_enabled + || !is_typed_fingerprint(&binding.request_context_fingerprint) + { + return Err(submit_error( + "PLAN_NEEDS_RECONCILIATION", + "planning provider session binding 字段不满足 strict identity 合同", + )); + } + match binding.goal_id.as_deref() { + Some(goal_id) + if !goal_id.trim().is_empty() + && binding.goal_revision > 0 + && is_bare_fingerprint(&binding.goal_snapshot_fingerprint) => {} + None if binding.goal_revision == 0 && binding.goal_snapshot_fingerprint.is_empty() => {} + _ => { + return Err(submit_error( + "PLAN_NEEDS_RECONCILIATION", + "planning provider session binding Goal 三元组无效", + )); + } + } + let expected_provider_request_id = plan_provider_session_binding_expected_request_id(binding)?; + if binding.provider_request_id != expected_provider_request_id { + return Err(submit_error( + "PLAN_NEEDS_RECONCILIATION", + "planning provider session binding providerRequestId 与 canonical identity 不一致", + )); + } + if !is_typed_fingerprint(&binding.fingerprint) + || binding.fingerprint != plan_provider_session_binding_fingerprint(binding)? + { + return Err(submit_error( + "PLAN_NEEDS_RECONCILIATION", + "planning provider session binding fingerprint 与 canonical identity 不一致", + )); + } + Ok(()) +} + +/// Capture the source session before a Provider action becomes durable. The +/// action sidecar stores this value, so execution can reject a stale response +/// instead of reading and trusting whatever session happens to be current. +pub(crate) fn capture_plan_provider_session_binding( + root: &std::path::Path, + runtime: &AgentRuntimeState, +) -> Result { + let session = read_plan_session_with_recovery(root) + .map_err(|error| error.to_string())? + .ok_or_else(|| "planning submit 建立前缺少 durable session".to_string())?; + if session.project_id != game_creator_agent_runtime_context_project_id(root)? + || session.agent_id != runtime.agent_id + || session.source != runtime.source + || session.run_profile != runtime.run_profile + || session.run_profile_binding_fingerprint != runtime.run_profile_binding_fingerprint + || session.session_id != runtime.session_id + || session.active_run_id.as_deref() != Some(runtime.run_id.as_str()) + || session.root_agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID + || session.applied_steer_cursor != runtime.applied_steer_cursor + { + return Err("planning submit action 建立时 session identity 不匹配".to_string()); + } + let delegation_id = runtime + .delegation_id + .clone() + .ok_or_else(|| "planning submit action 建立时缺少 delegationId".to_string())?; + if !session.latest_delegation_id.is_empty() && session.latest_delegation_id != delegation_id { + return Err("planning submit action 建立时 delegation identity 已漂移".to_string()); + } + let goal_snapshot_fingerprint = agent_goal_snapshot_fingerprint_for_state_at(root, runtime)?; + let request_context_fingerprint = format!( + "sha256-serde-json-v2:{:x}", + Sha256::digest( + format!( + "plan-provider-context\n{}\n{}\n{}\n{}\n{}", + runtime.agent_id, + runtime.run_id, + runtime.loop_iteration, + session.session_revision, + session.session_fingerprint + ) + .as_bytes(), + ) + ); + let request_kind = "tool-plan"; + let request_slot = format!("loop-{}-plan-submit", runtime.loop_iteration); + let provider_request_id = plan_provider_request_id(&PlanProviderRequestIdentityValue { + project_id: &session.project_id, + gdd_id: &session.gdd_id, + agent_id: &runtime.agent_id, + task_id: &runtime.task_id, + session_id: &session.session_id, + run_id: &runtime.run_id, + root_agent_id: GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + root_run_id: &session.root_run_id, + delegation_id: &delegation_id, + source: &runtime.source, + run_profile: &runtime.run_profile, + run_profile_binding_fingerprint: &runtime.run_profile_binding_fingerprint, + goal_id: runtime.goal_id.as_deref(), + goal_revision: runtime.goal_revision, + goal_snapshot_fingerprint: &goal_snapshot_fingerprint, + session_revision: session.session_revision, + session_fingerprint: &session.session_fingerprint, + applied_steer_cursor: runtime.applied_steer_cursor, + request_kind, + request_slot: &request_slot, + web_search_enabled: false, + request_context_fingerprint: &request_context_fingerprint, + }) + .map_err(|error| error.to_string())?; + let binding = PlanProviderSessionBindingV1 { + schema_version: PLAN_PROVIDER_SESSION_BINDING_SCHEMA_VERSION.to_string(), + project_id: session.project_id, + gdd_id: session.gdd_id, + agent_id: runtime.agent_id.clone(), + task_id: runtime.task_id.clone(), + provider_request_id, + session_id: session.session_id, + run_id: runtime.run_id.clone(), + root_agent_id: GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(), + root_run_id: session.root_run_id, + delegation_id, + goal_id: runtime.goal_id.clone(), + goal_revision: runtime.goal_revision, + goal_snapshot_fingerprint, + source: runtime.source.clone(), + run_profile: runtime.run_profile.clone(), + run_profile_binding_fingerprint: runtime.run_profile_binding_fingerprint.clone(), + session_revision: session.session_revision, + session_fingerprint: session.session_fingerprint, + applied_steer_cursor: runtime.applied_steer_cursor, + request_kind: request_kind.to_string(), + request_slot, + web_search_enabled: false, + request_context_fingerprint, + fingerprint: String::new(), + }; + let mut binding = binding; + binding.fingerprint = + plan_provider_session_binding_fingerprint(&binding).map_err(|error| error.to_string())?; + validate_plan_provider_session_binding(&binding).map_err(|error| error.to_string())?; + Ok(binding) +} + +/// Capture the same planning source/session identity that was used to build a +/// concrete Provider request. Unlike the batch-only helper above, this +/// variant takes the immutable request snapshot, so retry attempts and repair +/// slots cannot silently acquire a different provider request identity. +pub(crate) fn capture_plan_provider_session_binding_for_snapshot( + root: &std::path::Path, + runtime: &AgentRuntimeState, + snapshot: &AgentRuntimeProviderRequestSnapshot, + request_context_fingerprint: &str, +) -> Result { + let session = read_plan_session_with_recovery_locked(root) + .map_err(|error| error.to_string())? + .ok_or_else(|| "planning provider request 建立前缺少 durable session".to_string())?; + if snapshot.project_id != session.project_id + || snapshot.agent_id != runtime.agent_id + || snapshot.task_id != runtime.task_id + || snapshot.session_id != runtime.session_id + || snapshot.run_id != runtime.run_id + || snapshot.source != runtime.source + || snapshot.applied_steer_cursor != runtime.applied_steer_cursor + || !matches!( + snapshot.request_kind.as_str(), + "tool-plan" | "final-reply" | "context-compaction" | "final-reply-context-compaction" + ) + || (snapshot.agent_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID + && snapshot.web_search_enabled) + || session.agent_id != runtime.agent_id + || session.source != runtime.source + || session.run_profile != runtime.run_profile + || session.run_profile_binding_fingerprint != runtime.run_profile_binding_fingerprint + || session.session_id != runtime.session_id + || session.active_run_id.as_deref() != Some(runtime.run_id.as_str()) + || session.root_agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID + || session.applied_steer_cursor != runtime.applied_steer_cursor + { + return Err( + "planning Provider request snapshot 与当前 session/runtime identity 不匹配".to_string(), + ); + } + let delegation_id = runtime + .delegation_id + .clone() + .ok_or_else(|| "planning provider request 建立时缺少 delegationId".to_string())?; + if session.latest_delegation_id != delegation_id { + return Err("planning provider request 建立时 delegation identity 已漂移".to_string()); + } + if !is_typed_fingerprint(request_context_fingerprint) { + return Err("planning provider requestContextFingerprint 非法".to_string()); + } + if runtime.goal_id != snapshot.goal_id + || runtime.goal_revision != snapshot.goal_revision + || agent_goal_snapshot_fingerprint_for_state_at(root, runtime) + .map_err(|error| error.to_string())? + != snapshot.goal_snapshot_fingerprint + { + return Err("planning Provider request snapshot Goal identity 不匹配".to_string()); + } + let provider_request_id = plan_provider_request_id(&PlanProviderRequestIdentityValue { + project_id: &session.project_id, + gdd_id: &session.gdd_id, + agent_id: &snapshot.agent_id, + task_id: &snapshot.task_id, + session_id: &snapshot.session_id, + run_id: &snapshot.run_id, + root_agent_id: GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + root_run_id: &session.root_run_id, + delegation_id: &delegation_id, + source: &snapshot.source, + run_profile: &runtime.run_profile, + run_profile_binding_fingerprint: &runtime.run_profile_binding_fingerprint, + goal_id: snapshot.goal_id.as_deref(), + goal_revision: snapshot.goal_revision, + goal_snapshot_fingerprint: &snapshot.goal_snapshot_fingerprint, + session_revision: session.session_revision, + session_fingerprint: &session.session_fingerprint, + applied_steer_cursor: snapshot.applied_steer_cursor, + request_kind: &snapshot.request_kind, + request_slot: &snapshot.request_slot, + web_search_enabled: snapshot.web_search_enabled, + request_context_fingerprint, + }) + .map_err(|error| error.to_string())?; + let mut binding = PlanProviderSessionBindingV1 { + schema_version: PLAN_PROVIDER_SESSION_BINDING_SCHEMA_VERSION.to_string(), + project_id: session.project_id, + gdd_id: session.gdd_id, + agent_id: snapshot.agent_id.clone(), + task_id: snapshot.task_id.clone(), + provider_request_id, + session_id: snapshot.session_id.clone(), + run_id: snapshot.run_id.clone(), + root_agent_id: GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(), + root_run_id: session.root_run_id, + delegation_id, + goal_id: snapshot.goal_id.clone(), + goal_revision: snapshot.goal_revision, + goal_snapshot_fingerprint: snapshot.goal_snapshot_fingerprint.clone(), + source: snapshot.source.clone(), + run_profile: runtime.run_profile.clone(), + run_profile_binding_fingerprint: runtime.run_profile_binding_fingerprint.clone(), + session_revision: session.session_revision, + session_fingerprint: session.session_fingerprint, + applied_steer_cursor: snapshot.applied_steer_cursor, + request_kind: snapshot.request_kind.clone(), + request_slot: snapshot.request_slot.clone(), + web_search_enabled: snapshot.web_search_enabled, + request_context_fingerprint: request_context_fingerprint.to_string(), + fingerprint: String::new(), + }; + binding.fingerprint = + plan_provider_session_binding_fingerprint(&binding).map_err(|error| error.to_string())?; + validate_plan_provider_session_binding(&binding).map_err(|error| error.to_string())?; + Ok(binding) +} + +pub(crate) fn validate_plan_provider_session_binding_current_at( + root: &std::path::Path, + binding: &PlanProviderSessionBindingV1, +) -> Result<(), String> { + validate_plan_provider_session_binding(binding).map_err(|error| error.to_string())?; + let session = read_plan_session_with_recovery(root) + .map_err(|error| error.to_string())? + .ok_or_else(|| "planning provider session 已丢失,不能创建 durable batch".to_string())?; + if session.project_id != binding.project_id + || session.gdd_id != binding.gdd_id + || session.agent_id != binding.agent_id + || session.source != binding.source + || session.run_profile != binding.run_profile + || session.run_profile_binding_fingerprint != binding.run_profile_binding_fingerprint + || session.root_agent_id != binding.root_agent_id + || session.root_run_id != binding.root_run_id + || session.session_id != binding.session_id + || session.session_revision != binding.session_revision + || session.session_fingerprint != binding.session_fingerprint + || session.latest_delegation_id != binding.delegation_id + || session.applied_steer_cursor != binding.applied_steer_cursor + { + return Err("planning provider session 在 batch 持久化前已漂移".to_string()); + } + let runtime = read_game_creator_agent_runtime_at(root, &binding.agent_id) + .map_err(|error| error.to_string())? + .state; + if runtime.agent_id != binding.agent_id + || runtime.task_id != binding.task_id + || runtime.session_id != binding.session_id + || runtime.run_id != binding.run_id + || runtime.source != binding.source + || runtime.run_profile != binding.run_profile + || runtime.run_profile_binding_fingerprint != binding.run_profile_binding_fingerprint + || runtime.parent_agent_id.as_deref() != Some(binding.root_agent_id.as_str()) + || runtime.parent_run_id.as_deref() != Some(binding.root_run_id.as_str()) + || runtime.delegation_id.as_deref() != Some(binding.delegation_id.as_str()) + || runtime.goal_id != binding.goal_id + || runtime.goal_revision != binding.goal_revision + || runtime.applied_steer_cursor != binding.applied_steer_cursor + { + return Err("planning provider session binding 与当前 Runtime/Goal 身份不一致".to_string()); + } + let child_binding = + validate_project_planning_child_binding_at(root, &binding.agent_id, &binding.run_id)?; + if child_binding.root_agent_id != binding.root_agent_id + || child_binding.root_run_id != binding.root_run_id + || child_binding.parent_agent_id.as_deref() != Some(binding.root_agent_id.as_str()) + || child_binding.parent_run_id.as_deref() != Some(binding.root_run_id.as_str()) + { + return Err("planning provider frozen binding 与当前委派根身份不一致".to_string()); + } + let current_goal_snapshot_fingerprint = + agent_goal_snapshot_fingerprint_for_state_at(root, &runtime) + .map_err(|error| error.to_string())?; + if current_goal_snapshot_fingerprint != binding.goal_snapshot_fingerprint { + return Err( + "planning provider session binding Goal snapshot 在 batch 持久化前已漂移".to_string(), + ); + } + Ok(()) +} + +/// Materialize the binding for one concrete Provider attempt. Retry identity +/// keeps the base binding, while lifecycle/batch records must point at the +/// actual attempt request ID and slot. This helper is the only place allowed +/// to derive that per-attempt identity. +pub(crate) fn plan_provider_session_binding_for_attempt( + base: &PlanProviderSessionBindingV1, + request_slot: &str, + provider_request_id: &str, +) -> Result { + validate_plan_provider_session_binding(base).map_err(|error| error.to_string())?; + let expected = if request_slot == base.request_slot { + base.provider_request_id.clone() + } else if let Some(attempt) = request_slot + .strip_prefix(base.request_slot.as_str()) + .and_then(|suffix| suffix.strip_prefix("-transient-")) + { + let attempt = attempt + .parse::() + .map_err(|_| "planning Provider retry requestSlot 的 attempt 无效".to_string())?; + if attempt == 0 || attempt > 64 { + return Err("planning Provider retry attempt 超出允许范围".to_string()); + } + plan_provider_request_attempt_id(&base.provider_request_id, attempt) + } else { + return Err("planning Provider attempt requestSlot 不是 base/transient 形状".to_string()); + }; + if expected != provider_request_id { + return Err("planning Provider attempt providerRequestId 与 binding 不一致".to_string()); + } + let mut binding = base.clone(); + binding.request_slot = request_slot.to_string(); + binding.provider_request_id = provider_request_id.to_string(); + binding.fingerprint = String::new(); + binding.fingerprint = + plan_provider_session_binding_fingerprint(&binding).map_err(|error| error.to_string())?; + validate_plan_provider_session_binding(&binding).map_err(|error| error.to_string())?; + Ok(binding) +} + +/// A protocol-repair request may change its request slot and request-context +/// fingerprint, but it must remain on the exact source session/Run/Goal +/// lineage captured for repair-0. Checking this before a later repair is +/// sent prevents a newer session from being attached to an object derived +/// from the older request. +pub(crate) fn validate_plan_provider_session_binding_repair_lineage( + initial: &PlanProviderSessionBindingV1, + candidate: &PlanProviderSessionBindingV1, +) -> Result<(), String> { + validate_plan_provider_session_binding(initial).map_err(|error| error.to_string())?; + validate_plan_provider_session_binding(candidate).map_err(|error| error.to_string())?; + if initial.schema_version != candidate.schema_version + || initial.project_id != candidate.project_id + || initial.gdd_id != candidate.gdd_id + || initial.agent_id != candidate.agent_id + || initial.task_id != candidate.task_id + || initial.session_id != candidate.session_id + || initial.run_id != candidate.run_id + || initial.root_agent_id != candidate.root_agent_id + || initial.root_run_id != candidate.root_run_id + || initial.delegation_id != candidate.delegation_id + || initial.goal_id != candidate.goal_id + || initial.goal_revision != candidate.goal_revision + || initial.goal_snapshot_fingerprint != candidate.goal_snapshot_fingerprint + || initial.source != candidate.source + || initial.run_profile != candidate.run_profile + || initial.run_profile_binding_fingerprint != candidate.run_profile_binding_fingerprint + || initial.session_revision != candidate.session_revision + || initial.session_fingerprint != candidate.session_fingerprint + || initial.applied_steer_cursor != candidate.applied_steer_cursor + || initial.request_kind != candidate.request_kind + || initial.web_search_enabled != candidate.web_search_enabled + { + return Err( + "planning Provider repair 请求与 repair-0 的 source session lineage 不一致".to_string(), + ); + } + Ok(()) +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct PlanProviderRequestAttemptIdentityValue<'a> { + base_provider_request_id: &'a str, + attempt: usize, +} + +pub(crate) fn plan_provider_request_attempt_id(base_request_id: &str, attempt: usize) -> String { + if attempt == 0 { + return base_request_id.to_string(); + } + let value = PlanProviderRequestAttemptIdentityValue { + base_provider_request_id: base_request_id, + attempt, + }; + let bytes = typed_serde_canonical_bytes(PLAN_PROVIDER_REQUEST_ATTEMPT_ID_DOMAIN, &value) + .expect("serializing a typed Provider request attempt identity cannot fail"); + format!("provider-request-{:x}", Sha256::digest(bytes)) +} +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct PlanSubmitGddRuntimeContext { + pub(crate) project_id: String, + pub(crate) gdd_id: String, + pub(crate) action_id: String, + pub(crate) action_fingerprint: String, + pub(crate) agent_id: String, + pub(crate) source: String, + pub(crate) run_profile: String, + pub(crate) run_profile_binding_fingerprint: String, + pub(crate) root_agent_id: String, + pub(crate) root_run_id: String, + pub(crate) parent_agent_id: Option, + pub(crate) parent_run_id: Option, + pub(crate) delegation_id: String, + pub(crate) session_id: String, + pub(crate) source_session_revision: u32, + pub(crate) source_session_fingerprint: String, + pub(crate) created_by_run_id: String, + pub(crate) created_at_utc: String, + /// Runtime may provide a preallocated approval request identity. If it + /// is absent, the handler allocates one exactly once before the durable + /// GDD create; replay reads the existing identity and never regenerates a + /// version. + pub(crate) approval_request_id: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct PlanSubmitGddResultV1 { + /// `submitted` means this call created the immutable GDD fact; + /// `replayed` means the same durable action identity was already created. + pub(crate) outcome: String, + pub(crate) gdd_ref: PlanGddRef, + pub(crate) pending_action_id: String, + pub(crate) approval_request_id: String, + pub(crate) recovery_pending: bool, +} + +impl PlanSubmitGddResultV1 { + fn from_gdd(gdd: &PlanGddV1, replayed: bool, recovery_pending: bool) -> Self { + Self { + outcome: if replayed { + "replayed".to_string() + } else { + "submitted".to_string() + }, + gdd_ref: PlanGddRef { + gdd_id: gdd.gdd_id.clone(), + version: gdd.version, + fingerprint: gdd.fingerprint.clone(), + }, + pending_action_id: gdd.submission_id.clone(), + approval_request_id: gdd.approval_request_id.clone(), + recovery_pending, + } + } +} + +fn submit_error(code: &'static str, detail: impl Into) -> PlanningStorageError { + PlanningStorageError::new(code, detail) +} + +fn session_recovery_error(error: &PlanningStorageError) -> PlanningStorageError { + submit_error( + "PLAN_SESSION_RECOVERY_REQUIRED", + format!( + "planning session 需要恢复后才能继续(kind={})", + error.code() + ), + ) +} + +fn validate_runtime_context( + context: &PlanSubmitGddRuntimeContext, +) -> Result<(), PlanningStorageError> { + validate_opaque_id(&context.project_id, "runtime.projectId", false)?; + validate_uuid_prefixed(&context.gdd_id, "gdd-", "runtime.gddId")?; + validate_action_id(&context.action_id, "runtime.actionId")?; + if !is_bare_fingerprint(&context.action_fingerprint) { + return Err(submit_error( + "PLAN_INVALID_REQUEST", + "Runtime actionFingerprint 必须是 64 位小写裸 digest", + )); + } + if context.agent_id != GAME_CREATOR_PROJECT_PLANNING_AGENT_ID + || context.source != "agent-delegate" + || context.run_profile != AGENT_RUNTIME_RUN_PROFILE_STANDARD + || context.root_agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID + || context.parent_agent_id.as_deref() != Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID) + || context.parent_run_id.as_deref() != Some(context.root_run_id.as_str()) + { + return Err(submit_error( + "PLAN_SOURCE_PROFILE_MISMATCH", + "plan.submit_gdd 只能由 project-planning 的 agent-delegate standard 子 Run 调用", + )); + } + if !is_bare_fingerprint(&context.run_profile_binding_fingerprint) { + return Err(submit_error( + "PLAN_SOURCE_PROFILE_MISMATCH", + "Runtime run-profile binding fingerprint 非法", + )); + } + validate_opaque_id(&context.root_run_id, "runtime.rootRunId", false)?; + validate_opaque_id( + &context.parent_run_id.clone().unwrap_or_default(), + "runtime.parentRunId", + false, + )?; + validate_opaque_id(&context.delegation_id, "runtime.delegationId", false)?; + validate_opaque_id(&context.session_id, "runtime.sessionId", false)?; + validate_opaque_id(&context.created_by_run_id, "runtime.createdByRunId", false)?; + if context.source_session_revision == 0 + || !is_typed_fingerprint(&context.source_session_fingerprint) + { + return Err(submit_error( + "PLAN_SESSION_CAS_CONFLICT", + "Runtime source session revision/fingerprint 无效", + )); + } + validate_timestamp(&context.created_at_utc, "runtime.createdAtUtc")?; + if let Some(approval_request_id) = context.approval_request_id.as_deref() { + validate_uuid_prefixed( + approval_request_id, + "gdd-approval-", + "runtime.approvalRequestId", + )?; + } + Ok(()) +} + +/// Convert a strict Provider payload into the durable GDD envelope. All +/// fields not present in the Provider payload (platform facts, identity, +/// version, timestamp and fingerprints) are supplied by the Runtime context. +pub(crate) fn build_plan_gdd_from_submit_input( + input: &PlanSubmitGddInputV1, + context: &PlanSubmitGddRuntimeContext, + version: u32, + approval_request_id: &str, +) -> Result { + validate_plan_submit_gdd_input(input) + .map_err(|error| submit_error("PLAN_INVALID_REQUEST", error.to_string()))?; + validate_runtime_context(context)?; + validate_uuid_prefixed(approval_request_id, "gdd-approval-", "approvalRequestId")?; + if !(1..=PLAN_MAX_VERSIONS).contains(&version) { + return Err(submit_error( + "PLAN_VERSION_LIMIT_REACHED", + "GDD version 超出 1..=128", + )); + } + + let game = PlanGddGame { + title: input.game.title.clone(), + genre: input.game.genre.clone(), + art_style: input.game.art_style.clone(), + one_liner: input.game.one_liner.clone(), + pillars: input + .game + .pillars + .iter() + .map(|pillar| PlanPillar { + name: pillar.name.clone(), + player_feel: pillar.player_feel.clone(), + mechanism: pillar.mechanism.clone(), + decision_state: pillar.decision_state.clone(), + basis: None, + }) + .collect(), + core_loop: input.game.core_loop.clone(), + target_users: input.game.target_users.clone(), + platform_facts: fixed_plan_platform_facts(), + mvp_systems: input + .game + .mvp_systems + .iter() + .map(|system| PlanMvpSystem { + system: system.system.clone(), + minimal_function: system.minimal_function.clone(), + why_required: system.why_required.clone(), + verify_method: system.verify_method.clone(), + decision_state: system.decision_state.clone(), + basis: None, + }) + .collect(), + out_of_scope: input.game.out_of_scope.clone(), + creator_tips: input.game.creator_tips.clone(), + }; + let decisions = input + .decisions + .iter() + .map(|decision| PlanDecision { + id: decision.id.clone(), + topic: decision.topic.clone(), + state: decision.state.clone(), + answer_source: decision.answer_source.clone(), + round: decision.round, + answer_summary: decision.answer_summary.clone(), + basis: None, + }) + .collect(); + let mut gdd = PlanGddV1 { + schema_version: PLAN_GDD_SCHEMA_VERSION.to_string(), + project_id: context.project_id.clone(), + gdd_id: context.gdd_id.clone(), + version, + submission_id: context.action_id.clone(), + approval_request_id: approval_request_id.to_string(), + action_fingerprint: context.action_fingerprint.clone(), + agent_id: context.agent_id.clone(), + source: context.source.clone(), + run_profile: context.run_profile.clone(), + run_profile_binding_fingerprint: context.run_profile_binding_fingerprint.clone(), + root_agent_id: context.root_agent_id.clone(), + root_run_id: context.root_run_id.clone(), + delegation_id: context.delegation_id.clone(), + session_id: context.session_id.clone(), + source_session_revision: context.source_session_revision, + source_session_fingerprint: context.source_session_fingerprint.clone(), + created_by_run_id: context.created_by_run_id.clone(), + created_at_utc: context.created_at_utc.clone(), + game, + decisions, + prototype_validation_items: input.prototype_validation_items.clone(), + // The shape validator intentionally requires a typed fingerprint even + // while computing the canonical digest. Seed a non-semantic + // placeholder; `PlanGddFingerprintValue` excludes this field from the + // hashed payload and the computed value replaces it immediately. + fingerprint: format!("sha256-serde-json-v2:{}", "0".repeat(64)), + }; + gdd.fingerprint = plan_gdd_fingerprint(&gdd)?; + Ok(gdd) +} + +fn submit_input_from_gdd(gdd: &PlanGddV1) -> PlanSubmitGddInputV1 { + PlanSubmitGddInputV1 { + schema_version: PLAN_SUBMIT_INPUT_SCHEMA.to_string(), + game: PlanSubmitGame { + title: gdd.game.title.clone(), + genre: gdd.game.genre.clone(), + art_style: gdd.game.art_style.clone(), + one_liner: gdd.game.one_liner.clone(), + pillars: gdd + .game + .pillars + .iter() + .map(|pillar| PlanSubmitPillar { + name: pillar.name.clone(), + player_feel: pillar.player_feel.clone(), + mechanism: pillar.mechanism.clone(), + decision_state: pillar.decision_state.clone(), + }) + .collect(), + core_loop: gdd.game.core_loop.clone(), + target_users: gdd.game.target_users.clone(), + mvp_systems: gdd + .game + .mvp_systems + .iter() + .map(|system| PlanSubmitMvpSystem { + system: system.system.clone(), + minimal_function: system.minimal_function.clone(), + why_required: system.why_required.clone(), + verify_method: system.verify_method.clone(), + decision_state: system.decision_state.clone(), + }) + .collect(), + out_of_scope: gdd.game.out_of_scope.clone(), + creator_tips: gdd.game.creator_tips.clone(), + }, + decisions: gdd + .decisions + .iter() + .map(|decision| PlanSubmitDecision { + id: decision.id.clone(), + topic: decision.topic.clone(), + state: decision.state.clone(), + answer_source: decision.answer_source.clone(), + round: decision.round, + answer_summary: decision.answer_summary.clone(), + }) + .collect(), + prototype_validation_items: gdd.prototype_validation_items.clone(), + } +} + +fn submit_payload_matches_gdd( + input: &PlanSubmitGddInputV1, + gdd: &PlanGddV1, +) -> Result { + validate_plan_submit_gdd_input(input)?; + Ok(*input == submit_input_from_gdd(gdd)) +} + +fn gdd_submit_identity_matches(gdd: &PlanGddV1, context: &PlanSubmitGddRuntimeContext) -> bool { + gdd.submission_id == context.action_id + && gdd.action_fingerprint == context.action_fingerprint + && gdd.project_id == context.project_id + && gdd.agent_id == context.agent_id + && gdd.source == context.source + && gdd.run_profile == context.run_profile + && gdd.run_profile_binding_fingerprint == context.run_profile_binding_fingerprint + && gdd.root_agent_id == context.root_agent_id + && gdd.root_run_id == context.root_run_id + && gdd.delegation_id == context.delegation_id + && gdd.session_id == context.session_id + && gdd.source_session_revision == context.source_session_revision + && gdd.source_session_fingerprint == context.source_session_fingerprint + && gdd.created_by_run_id == context.created_by_run_id + && context + .approval_request_id + .as_deref() + .is_none_or(|approval_request_id| gdd.approval_request_id == approval_request_id) +} + +fn session_decisions_match_input(session: &PlanSessionV1, input: &PlanSubmitGddInputV1) -> bool { + session.decisions_summary.len() <= input.decisions.len() + && session + .decisions_summary + .iter() + .zip(&input.decisions) + .all(|(session, input)| { + session.id == input.id + && session.topic == input.topic + && session.state == input.state + && session.answer_source == input.answer_source + && session.round == input.round + && session.answer_summary == input.answer_summary + }) + && input.decisions[session.decisions_summary.len()..] + .iter() + .all(|decision| { + decision.state == "default_pending" + && decision.answer_source == "default" + && decision.round == 0 + }) + && session.prototype_validation_items == input.prototype_validation_items +} + +fn session_identity_matches_context( + session: &PlanSessionV1, + context: &PlanSubmitGddRuntimeContext, +) -> bool { + session.project_id == context.project_id + && session.gdd_id == context.gdd_id + && session.agent_id == context.agent_id + && session.source == context.source + && session.run_profile == context.run_profile + && session.run_profile_binding_fingerprint == context.run_profile_binding_fingerprint + && session.root_agent_id == context.root_agent_id + && session.root_run_id == context.root_run_id + && session.session_id == context.session_id +} + +fn build_submit_session_successor( + previous: &PlanSessionV1, + context: &PlanSubmitGddRuntimeContext, + gdd: &PlanGddV1, +) -> Result { + let mut next = previous.clone(); + next.session_revision = previous + .session_revision + .checked_add(1) + .ok_or_else(|| submit_error("PLAN_SESSION_CAS_CONFLICT", "sessionRevision 溢出"))?; + next.previous_fingerprint = Some(previous.session_fingerprint.clone()); + next.active_run_id = None; + next.last_run_id = context.created_by_run_id.clone(); + next.latest_delegation_id = context.delegation_id.clone(); + next.phase = "awaiting_gdd_approval".to_string(); + next.latest_submitted_ref = Some(PlanGddRef { + gdd_id: gdd.gdd_id.clone(), + version: gdd.version, + fingerprint: gdd.fingerprint.clone(), + }); + next.last_decision_ref = None; + next.updated_at_utc = context.created_at_utc.clone(); + next.session_fingerprint = plan_session_fingerprint(&next)?; + validate_plan_session_successor(previous, &next)?; + Ok(next) +} + +fn validate_current_session_cas( + session: &PlanSessionV1, + context: &PlanSubmitGddRuntimeContext, + input: &PlanSubmitGddInputV1, +) -> Result<(), PlanningStorageError> { + if !session_identity_matches_context(session, context) { + return Err(submit_error( + "PLAN_SOURCE_PROFILE_MISMATCH", + "当前 planning session 身份与 Runtime 不一致", + )); + } + if session.session_revision != context.source_session_revision + || session.session_fingerprint != context.source_session_fingerprint + { + return Err(submit_error( + "PLAN_SESSION_CAS_CONFLICT", + "planning session 已被其它动作推进", + )); + } + if session.active_run_id.as_deref() != Some(context.created_by_run_id.as_str()) { + return Err(submit_error( + "PLAN_SOURCE_PROFILE_MISMATCH", + "plan.submit_gdd 必须绑定当前活跃策划子 Run", + )); + } + if !matches!(session.phase.as_str(), "collecting" | "revision_requested") { + return Err(submit_error( + "PLAN_PENDING_GDD_EXISTS", + "当前 planning session 仍有未决 GDD", + )); + } + if !session_decisions_match_input(session, input) { + return Err(submit_error( + "PLAN_SESSION_CAS_CONFLICT", + "submit input 未逐项匹配当前 planning session 决策摘要", + )); + } + if session.latest_delegation_id != context.delegation_id { + return Err(submit_error( + "PLAN_SOURCE_PROFILE_MISMATCH", + "submit action 的 delegation identity 未逐字匹配当前 session", + )); + } + Ok(()) +} + +fn validate_durable_child_binding( + root: &std::path::Path, + context: &PlanSubmitGddRuntimeContext, +) -> Result<(), PlanningStorageError> { + let binding = validate_project_planning_child_binding_at( + root, + &context.agent_id, + &context.created_by_run_id, + ) + .map_err(|error| submit_error("PLAN_SOURCE_PROFILE_MISMATCH", error))?; + if binding.project_id != context.project_id + || binding.root_agent_id != context.root_agent_id + || binding.root_run_id != context.root_run_id + || binding.parent_agent_id != context.parent_agent_id + || binding.parent_run_id != context.parent_run_id + || binding.source != context.source + || binding.profile != context.run_profile + || binding.binding_fingerprint != context.run_profile_binding_fingerprint + { + return Err(submit_error( + "PLAN_SOURCE_PROFILE_MISMATCH", + "当前策划子 Run 的 durable run-profile binding 与 action identity 不一致", + )); + } + Ok(()) +} + +fn generated_approval_request_id() -> String { + format!("gdd-approval-{}", Uuid::new_v4().hyphenated()) +} + +/// Return the fixed UTC millisecond timestamp used by Runtime-owned planning +/// projections. Keeping this helper here makes tests able to inject a fixed +/// timestamp while production callers can use the same formatting contract. +pub(crate) fn current_plan_timestamp_utc() -> String { + let millis = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis(); + let seconds = millis / 1_000; + let millis_part = millis % 1_000; + let days = seconds / 86_400; + let day_seconds = seconds % 86_400; + let hour = day_seconds / 3_600; + let minute = (day_seconds % 3_600) / 60; + let second = day_seconds % 60; + + // Civil-from-days, Gregorian calendar (Howard Hinnant algorithm). + let z = days as i64 + 719_468; + let era = if z >= 0 { z } else { z - 146_096 } / 146_097; + let doe = z - era * 146_097; + let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; + let year = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let day = doy - (153 * mp + 2) / 5 + 1; + let month = mp + if mp < 10 { 3 } else { -9 }; + let year = year + i64::from(month <= 2); + format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}.{millis_part:03}Z") +} + +/// Main-loop adapter: build the durable submit context only from the current +/// Runtime, its already-persisted action identity and the current plan session. +/// The core handler re-reads the session under the commit lock, so the +/// optimistic snapshot taken here is a CAS input rather than a second source +/// of truth. +pub(crate) fn execute_plan_submit_gdd_for_pending_action( + root: &std::path::Path, + runtime: &AgentRuntimeState, + pending: &AgentRuntimePendingToolAction, +) -> Result { + if pending.action.tool.trim() != PLAN_SUBMIT_GDD_TOOL + || pending.agent_id != runtime.agent_id + || pending.task_id != runtime.task_id + || pending.session_id != runtime.session_id + || pending.run_id != runtime.run_id + || pending.source != runtime.source + || pending.run_profile != runtime.run_profile + || pending.run_profile_binding_fingerprint != runtime.run_profile_binding_fingerprint + { + return Err(submit_error( + "PLAN_SUBMISSION_IDENTITY_CONFLICT", + "prepared action 与当前 Runtime identity 不一致", + )); + } + let input = serde_json::from_value::(pending.action.input.clone()) + .map_err(|error| submit_error("PLAN_INVALID_REQUEST", error.to_string()))?; + // This both validates the complete nested shape and enforces the 64 KiB + // canonical payload limit. The Provider parser has already rejected + // duplicate JSON keys before the action becomes a `Value`. + canonical_plan_submit_gdd_input_bytes(&input) + .map_err(|error| submit_error("PLAN_INVALID_REQUEST", error.to_string()))?; + + let frozen_binding = pending.planning_session_binding.as_ref().ok_or_else(|| { + submit_error( + "PLAN_NEEDS_RECONCILIATION", + "plan.submit_gdd pending action 缺少 frozen provider session binding", + ) + })?; + validate_plan_provider_session_binding(frozen_binding)?; + if frozen_binding.project_id + != game_creator_agent_runtime_context_project_id(root) + .map_err(|error| submit_error("PLAN_SOURCE_PROFILE_MISMATCH", error))? + || frozen_binding.agent_id != pending.agent_id + || frozen_binding.task_id != pending.task_id + || frozen_binding.session_id != pending.session_id + || frozen_binding.run_id != pending.run_id + || frozen_binding.source != pending.source + || frozen_binding.run_profile != pending.run_profile + || frozen_binding.run_profile_binding_fingerprint != pending.run_profile_binding_fingerprint + || frozen_binding.applied_steer_cursor != pending.planned_steer_cursor + || frozen_binding.root_agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID + || frozen_binding.request_kind != "tool-plan" + { + return Err(submit_error( + "PLAN_SUBMISSION_IDENTITY_CONFLICT", + "pending action 与 frozen provider session binding 不一致", + )); + } + + let child_binding = + validate_project_planning_child_binding_at(root, &runtime.agent_id, &runtime.run_id) + .map_err(|error| submit_error("PLAN_SOURCE_PROFILE_MISMATCH", error))?; + let durable_task = read_latest_game_creator_agent_runtime_task_by_run_id( + root, + &runtime.agent_id, + &runtime.run_id, + ) + .map_err(|error| submit_error("PLAN_SOURCE_PROFILE_MISMATCH", error))? + .ok_or_else(|| { + submit_error( + "PLAN_SOURCE_PROFILE_MISMATCH", + "project-planning 子 Run 缺少 durable task identity", + ) + })?; + if child_binding.project_id != frozen_binding.project_id + || child_binding.root_run_id != frozen_binding.root_run_id + || durable_task.task_id != frozen_binding.task_id + || durable_task.session_id != frozen_binding.session_id + || durable_task.run_id != frozen_binding.run_id + || durable_task.delegation_id.as_deref() != Some(frozen_binding.delegation_id.as_str()) + { + return Err(submit_error( + "PLAN_SOURCE_PROFILE_MISMATCH", + "planning child durable binding/task 与 frozen provider identity 不一致", + )); + } + // For a new submission, all source identity comes from the frozen + // binding. Current session is only a CAS check in the core handler; it + // must never be used to reinterpret an older Provider response. + let gdd_id = frozen_binding.gdd_id.clone(); + let delegation_id = frozen_binding.delegation_id.clone(); + let context = PlanSubmitGddRuntimeContext { + project_id: frozen_binding.project_id.clone(), + gdd_id, + action_id: pending.action_id.clone(), + action_fingerprint: pending.action_fingerprint.clone(), + agent_id: runtime.agent_id.clone(), + source: runtime.source.clone(), + run_profile: runtime.run_profile.clone(), + run_profile_binding_fingerprint: runtime.run_profile_binding_fingerprint.clone(), + root_agent_id: frozen_binding.root_agent_id.clone(), + root_run_id: frozen_binding.root_run_id.clone(), + parent_agent_id: runtime.parent_agent_id.clone(), + parent_run_id: runtime.parent_run_id.clone(), + delegation_id, + session_id: frozen_binding.session_id.clone(), + source_session_revision: frozen_binding.session_revision, + source_session_fingerprint: frozen_binding.session_fingerprint.clone(), + created_by_run_id: runtime.run_id.clone(), + created_at_utc: current_plan_timestamp_utc(), + approval_request_id: Some(plan_provider_approval_request_id( + &pending.action_id, + &pending.action_fingerprint, + &frozen_binding.session_fingerprint, + )), + }; + execute_plan_submit_gdd(root, &context, &input) +} + +fn markdown_escape(value: &str) -> String { + value + .replace('\\', "\\\\") + .replace('`', "\\`") + .replace('*', "\\*") + .replace('_', "\\_") + .replace('[', "\\[") + .replace(']', "\\]") + .replace('<', "\\<") + .replace('>', "\\>") + .replace('|', "\\|") +} + +fn markdown_bullets(values: &[String]) -> String { + values + .iter() + .map(|value| format!("- {}", markdown_escape(value))) + .collect::>() + .join("\n") +} + +/// Render a deterministic, human-readable projection. The JSON GDD remains +/// the authority; this function never parses Markdown back into facts. +pub(crate) fn render_plan_fast_gdd_markdown( + gdd: &PlanGddV1, + status: &str, +) -> Result { + validate_plan_gdd(gdd)?; + validate_text(status, "GDD status", 1, 64)?; + let mut markdown = String::new(); + markdown.push_str("# "); + markdown.push_str(&markdown_escape(&gdd.game.title)); + markdown.push_str("\n\n"); + markdown.push_str(&format!( + "> Fast GDD v{} · 状态:{}\n> gddId:`{}`\n> fingerprint:`{}`\n> approvalRequestId:`{}`\n\n", + gdd.version, + markdown_escape(status), + gdd.gdd_id, + gdd.fingerprint, + gdd.approval_request_id + )); + + markdown.push_str("## 决定状态\n\n"); + for decision in &gdd.decisions { + markdown.push_str(&format!( + "- **{}**({},{},第 {} 轮):{}\n", + markdown_escape(&decision.topic), + markdown_escape(&decision.state), + markdown_escape(&decision.answer_source), + decision.round, + markdown_escape(&decision.answer_summary) + )); + } + markdown.push_str("\n## 一句话描述\n\n"); + markdown.push_str(&markdown_escape(&gdd.game.one_liner)); + markdown.push_str("\n\n## 游戏分类与美术\n\n"); + markdown.push_str(&format!( + "- 主类型:{}\n- 融合类型:{}\n- 视觉类型:{}\n- 关键词:{}\n- 色彩氛围:{}\n- MVP 美术边界:{}\n", + markdown_escape(&gdd.game.genre.primary), + gdd.game + .genre + .fusion + .as_deref() + .map(markdown_escape) + .unwrap_or_else(|| "无".to_string()), + markdown_escape(&gdd.game.art_style.visual_type), + markdown_escape(&gdd.game.art_style.keywords.join("、")), + markdown_escape(&gdd.game.art_style.mood_and_color), + markdown_escape(&gdd.game.art_style.mvp_art_boundary) + )); + markdown.push_str("\n## 游戏支柱\n\n"); + for pillar in &gdd.game.pillars { + markdown.push_str(&format!( + "### {}\n\n- 玩家感受:{}\n- 机制:{}\n- 决定状态:{}\n\n", + markdown_escape(&pillar.name), + markdown_escape(&pillar.player_feel), + markdown_escape(&pillar.mechanism), + markdown_escape(&pillar.decision_state) + )); + } + markdown.push_str("## 核心循环\n\n"); + for (index, step) in gdd.game.core_loop.iter().enumerate() { + markdown.push_str(&format!("{}. {}\n", index + 1, markdown_escape(step))); + } + markdown.push_str("\n## 目标用户\n\n"); + markdown.push_str(&format!( + "- 核心用户:{}\n- 偏好:{}\n- 单局时长:{}\n- 参考游戏:{}\n", + markdown_escape(&gdd.game.target_users.core_users), + markdown_escape(&gdd.game.target_users.preferences), + markdown_escape(&gdd.game.target_users.session_length), + if gdd.game.target_users.reference_games.is_empty() { + "无".to_string() + } else { + markdown_escape(&gdd.game.target_users.reference_games.join("、")) + } + )); + markdown.push_str("\n## Runtime 平台事实\n\n"); + markdown.push_str(&format!( + "- Runtime:{}\n- 视口:{}\n- 输入:{}\n- 预览:{}\n", + markdown_escape(&gdd.game.platform_facts.runtime), + markdown_escape(&gdd.game.platform_facts.viewports.join(" / ")), + markdown_escape(&gdd.game.platform_facts.inputs.join(" / ")), + markdown_escape(&gdd.game.platform_facts.preview) + )); + markdown.push_str("\n## MVP 系统\n\n"); + for system in &gdd.game.mvp_systems { + markdown.push_str(&format!( + "### {}\n\n- 最小功能:{}\n- 必要原因:{}\n- 验证方式:{}\n- 决定状态:{}\n\n", + markdown_escape(&system.system), + markdown_escape(&system.minimal_function), + markdown_escape(&system.why_required), + markdown_escape(&system.verify_method), + markdown_escape(&system.decision_state) + )); + } + markdown.push_str("## 制作边界\n\n"); + markdown.push_str(&markdown_bullets(&gdd.game.out_of_scope)); + markdown.push_str("\n\n## 创作者提示\n\n"); + markdown.push_str(&format!( + "- 先做:{}\n- 暂缓:{}\n- 如何验证:{}\n- 何时扩展:{}\n", + markdown_escape(&gdd.game.creator_tips.do_first), + markdown_escape(&gdd.game.creator_tips.defer_for_now), + markdown_escape(&gdd.game.creator_tips.how_to_verify), + markdown_escape(&gdd.game.creator_tips.expand_when) + )); + if !gdd.prototype_validation_items.is_empty() { + markdown.push_str("\n## 原型验证项\n\n"); + for item in &gdd.prototype_validation_items { + markdown.push_str(&format!( + "### {}\n\n- 问题:{}\n- 微型原型:{}\n- 观察:{}\n- 通过标准:{}\n\n", + markdown_escape(&item.id), + markdown_escape(&item.question), + markdown_escape(&item.micro_prototype), + markdown_escape(&item.observation), + markdown_escape(&item.pass_criterion) + )); + } + } + if markdown.as_bytes().len() > PLAN_FAST_GDD_MAX_BYTES { + return Err(submit_error( + "PLAN_SIZE_LIMIT", + "渲染后的 Fast GDD Markdown 超出大小上限", + )); + } + Ok(markdown) +} + +fn project_submit_successors_locked( + root: &std::path::Path, + context: &PlanSubmitGddRuntimeContext, + gdd: &PlanGddV1, + previous_session: Option<&PlanSessionV1>, +) -> bool { + let mut recovery_pending = false; + let chain = match read_plan_gdd_chain_locked(root) { + Ok(chain) => chain, + Err(_) => { + return true; + } + }; + if read_plan_gdd_index_with_recovery_locked(root, &context.created_at_utc).is_err() { + recovery_pending = true; + if let Ok(index) = build_plan_gdd_index(&chain, &context.created_at_utc) { + if write_plan_gdd_index_atomic_locked(root, &index).is_err() { + recovery_pending = true; + } + } + } + // Markdown is a projection of the current latest authority, not of the + // action being replayed. Replaying an older submission must never roll + // `game/fast_gdd.md` back over a newer immutable version. + let projection_gdd = chain.last().unwrap_or(gdd); + if let Ok(markdown) = render_plan_fast_gdd_markdown(projection_gdd, "ready_for_approval") { + if write_plan_fast_gdd_markdown_atomic_locked(root, &markdown).is_err() { + recovery_pending = true; + } + } else { + recovery_pending = true; + } + if let Some(previous_session) = previous_session { + let session_identity_matches_gdd = previous_session.project_id == gdd.project_id + && previous_session.gdd_id == gdd.gdd_id + && previous_session.agent_id == gdd.agent_id + && previous_session.source == gdd.source + && previous_session.run_profile == gdd.run_profile + && previous_session.run_profile_binding_fingerprint + == gdd.run_profile_binding_fingerprint + && previous_session.root_agent_id == gdd.root_agent_id + && previous_session.root_run_id == gdd.root_run_id + && previous_session.session_id == gdd.session_id; + let same_ref = session_identity_matches_gdd + && previous_session + .latest_submitted_ref + .as_ref() + .is_some_and(|reference| { + reference.gdd_id == gdd.gdd_id + && reference.version == gdd.version + && reference.fingerprint == gdd.fingerprint + }) + && previous_session.session_revision == gdd.source_session_revision.saturating_add(1) + && previous_session.previous_fingerprint.as_deref() + == Some(gdd.source_session_fingerprint.as_str()) + && previous_session.active_run_id.is_none() + && previous_session.last_run_id == gdd.created_by_run_id + && previous_session.latest_delegation_id == gdd.delegation_id + && previous_session.phase == "awaiting_gdd_approval" + && previous_session.last_decision_ref.is_none(); + // A replay may repair a session projection that was interrupted after + // the GDD create, but it must never overwrite a different legal + // successor. The only safe forward path is an exact source-session + // snapshot (including the still-active child run) or an already + // projected session pointing at this immutable ref. + let source_session_matches_gdd = session_identity_matches_gdd + && previous_session.session_revision == gdd.source_session_revision + && previous_session.session_fingerprint == gdd.source_session_fingerprint + && previous_session.active_run_id.as_deref() == Some(gdd.created_by_run_id.as_str()) + && previous_session.latest_submitted_ref.is_none() + && matches!( + previous_session.phase.as_str(), + "collecting" | "revision_requested" + ); + if !session_identity_matches_gdd { + recovery_pending = true; + } else if same_ref { + // The ref is already installed. Only repair derived projections; + // do not create another session revision on replay. + } else if !source_session_matches_gdd { + // Never regress a newer session projection to an older replay. + recovery_pending = true; + } else { + match build_submit_session_successor(previous_session, context, gdd) + .and_then(|next| write_plan_session_atomic_locked(root, &next)) + { + Ok(()) => {} + Err(_) => recovery_pending = true, + } + } + } else { + recovery_pending = true; + } + recovery_pending +} + +/// Execute the dedicated submit point. All reads and mutation occur under a +/// single project lock. The immutable GDD create is the linearization point; +/// failures after it are reported as a successful result with +/// `recoveryPending=true` so a retry can repair projections without allocating +/// another version. +pub(crate) fn execute_plan_submit_gdd( + root: &std::path::Path, + context: &PlanSubmitGddRuntimeContext, + input: &PlanSubmitGddInputV1, +) -> Result { + validate_runtime_context(context)?; + validate_plan_submit_gdd_input(input) + .map_err(|error| submit_error("PLAN_INVALID_REQUEST", error.to_string()))?; + validate_durable_child_binding(root, context)?; + let _lock = acquire_project_write_lock(root, "planning.submit_gdd") + .map_err(|error| submit_error("PLAN_DURABILITY_FAILED", error))?; + // The optimistic pre-check only avoids entering the handler with an + // obviously forged identity. Re-read the durable child binding after the + // lock is held so a binding rotation/replacement cannot race the GDD + // commit point. + validate_durable_child_binding(root, context)?; + + let chain = read_plan_gdd_chain_locked(root)?; + // Keep a session read error until after durable action identity replay is + // resolved. The GDD create is the commit point: if session projection was + // lost/corrupted after that point, a retry must still return the committed + // GDD with recoveryPending instead of pretending the action never ran. + let session_read = read_plan_session_with_recovery_locked(root); + let current_session = session_read.as_ref().ok().and_then(Option::as_ref); + + // First resolve the durable action identity. This branch intentionally + // runs before pending/version checks: replay must be idempotent even when a + // previous attempt already advanced the session or projections. + if let Some(existing) = chain + .iter() + .find(|gdd| gdd.submission_id == context.action_id) + { + if !gdd_submit_identity_matches(existing, context) || existing.gdd_id != context.gdd_id { + return Err(submit_error( + "PLAN_SUBMISSION_IDENTITY_CONFLICT", + "同一 submissionId 已绑定不同的 Runtime identity", + )); + } + if !submit_payload_matches_gdd(input, existing)? { + return Err(submit_error( + "PLAN_SUBMISSION_IDENTITY_CONFLICT", + "同一 submissionId 的 submit payload 不一致", + )); + } + if let Some(session) = current_session { + if !session_identity_matches_context(session, context) { + return Err(submit_error( + "PLAN_NEEDS_RECONCILIATION", + "同 submission replay 命中的 planning session 属于另一条 identity lineage", + )); + } + } + if session_read.is_err() { + let _ = project_submit_successors_locked(root, context, existing, None); + return Ok(PlanSubmitGddResultV1::from_gdd(existing, true, true)); + } + let recovery_pending = + project_submit_successors_locked(root, context, existing, current_session); + return Ok(PlanSubmitGddResultV1::from_gdd( + existing, + true, + recovery_pending, + )); + } + + // A new mutation cannot proceed against a missing/corrupt session. Do + // this before pending/version checks so a broken authority is not hidden + // behind the generic "pending GDD" response. + if let Err(error) = &session_read { + return Err(session_recovery_error(error)); + } + + // Before allocating the first version, reconcile the derived index with + // the authoritative GDD chain while the project lock is still held. In + // particular, an index left behind without any GDD fact is not a clean + // empty-project state: allowing a new v1 would silently overwrite an + // unexplained durable identity and make the orphan impossible to audit. + read_plan_gdd_index_with_recovery_locked(root, &context.created_at_utc)?; + + if chain + .iter() + .any(|gdd| gdd.action_fingerprint == context.action_fingerprint) + { + return Err(submit_error( + "PLAN_SUBMISSION_IDENTITY_CONFLICT", + "actionFingerprint 已被其它 submissionId 使用", + )); + } + if !chain.is_empty() { + // M1B-2 has no approval receipt writer yet. Consequently every + // existing candidate is still pending; M1C-1 will refine this check + // from receipt facts instead of weakening the submit boundary. + return Err(submit_error( + "PLAN_PENDING_GDD_EXISTS", + "已有已提交 GDD;M1B-2 只允许同 submissionId 重放", + )); + } + + let Some(current_session) = current_session else { + return Err(submit_error( + "PLAN_SESSION_RECOVERY_REQUIRED", + "planning session 不存在,不能提交 GDD", + )); + }; + validate_plan_session(current_session)?; + validate_current_session_cas(current_session, context, input)?; + let version = 1; + let approval_request_id = context + .approval_request_id + .clone() + .unwrap_or_else(generated_approval_request_id); + let candidate = + build_plan_gdd_from_submit_input(input, context, version, &approval_request_id)?; + let bytes = canonical_plan_gdd_bytes(&candidate)?; + + // Durable create is the submit point. Everything below is best-effort + // projection/recovery and must not turn a committed GDD into a new version. + let create_outcome = match durable_create_json_no_replace_locked( + root, + &format!("{PLAN_STORAGE_ROOT}/gdd.v{}.json", candidate.version), + &bytes, + "GDD", + ) { + Ok(outcome) => outcome, + Err(error) => { + if error.code() == "PLAN_COMMIT_UNKNOWN" { + // The immutable target may have been published but the + // parent-directory flush was not confirmed. Do not turn + // this unknown result into a committed replay. + return Err(error); + } + // The no-replace writer may have published the immutable target + // and then failed while syncing or cleaning up. Re-read the + // authority before surfacing an error so a post-create failure + // is reported as a replay with recoveryPending rather than as a + // false rejection that could invite a new version. + if let Ok(chain_after) = read_plan_gdd_chain_locked(root) { + if let Some(existing) = chain_after + .iter() + .find(|gdd| gdd.submission_id == context.action_id) + { + if gdd_submit_identity_matches(existing, context) + && existing.gdd_id == context.gdd_id + && submit_payload_matches_gdd(input, existing).unwrap_or(false) + { + let _projection_recovery_pending = project_submit_successors_locked( + root, + context, + existing, + Some(current_session), + ); + return Ok(PlanSubmitGddResultV1::from_gdd(existing, true, true)); + } + } + } + return Err(error); + } + }; + match create_outcome { + PlanningCreateOutcome::Replayed => { + // A race can publish the same target between the chain scan and + // create. Re-read the authority and return the exact existing GDD. + let chain = read_plan_gdd_chain_locked(root)?; + let existing = chain + .iter() + .find(|gdd| gdd.submission_id == context.action_id) + .ok_or_else(|| { + submit_error("PLAN_NEEDS_RECONCILIATION", "GDD 并发发布后无法回读") + })?; + let recovery_pending = + project_submit_successors_locked(root, context, existing, Some(current_session)); + Ok(PlanSubmitGddResultV1::from_gdd( + existing, + true, + recovery_pending, + )) + } + PlanningCreateOutcome::Created => { + let recovery_pending = + project_submit_successors_locked(root, context, &candidate, Some(current_session)); + Ok(PlanSubmitGddResultV1::from_gdd( + &candidate, + false, + recovery_pending, + )) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::path::PathBuf; + + fn valid_input() -> PlanSubmitGddInputV1 { + PlanSubmitGddInputV1 { + schema_version: PLAN_SUBMIT_INPUT_SCHEMA.to_string(), + game: PlanSubmitGame { + title: "萤火守夜者".to_string(), + genre: PlanGenre { + primary: "轻策略".to_string(), + fusion: None, + }, + art_style: PlanArtStyle { + visual_type: "手绘平面".to_string(), + keywords: vec!["暖色".to_string(), "剪影".to_string(), "纸感".to_string()], + mood_and_color: "夜色中的暖黄灯火".to_string(), + mvp_art_boundary: "仅制作可复用的角色、灯火和地块素材".to_string(), + }, + one_liner: "玩家在一局十分钟的守夜旅程中分配有限灯火、判断风险并选择路线,守住营地后寻找下一处安全落脚点" + .to_string(), + pillars: vec![ + PlanSubmitPillar { + name: "取舍".to_string(), + player_feel: "每次选择都有代价".to_string(), + mechanism: "有限灯火在路线与营地之间分配".to_string(), + decision_state: "confirmed".to_string(), + }, + PlanSubmitPillar { + name: "重玩".to_string(), + player_feel: "想再试一次更优路线".to_string(), + mechanism: "不同路线组合产生不同风险".to_string(), + decision_state: "confirmed".to_string(), + }, + ], + core_loop: vec![ + "观察地图".to_string(), + "分配灯火".to_string(), + "选择路线".to_string(), + "处理事件".to_string(), + ], + target_users: PlanTargetUsers { + core_users: "喜欢短局策略的玩家".to_string(), + preferences: "偏好清晰反馈和轻量决策".to_string(), + session_length: "10至20分钟".to_string(), + reference_games: vec![], + }, + mvp_systems: vec![ + PlanSubmitMvpSystem { + system: "地图".to_string(), + minimal_function: "展示当前营地与可选路线".to_string(), + why_required: "让玩家理解空间选择".to_string(), + verify_method: "能完成一局并看懂下一步".to_string(), + decision_state: "confirmed".to_string(), + }, + PlanSubmitMvpSystem { + system: "灯火".to_string(), + minimal_function: "消耗灯火换取安全或探索".to_string(), + why_required: "承载核心取舍".to_string(), + verify_method: "两种分配策略结果可区分".to_string(), + decision_state: "confirmed".to_string(), + }, + PlanSubmitMvpSystem { + system: "事件".to_string(), + minimal_function: "路线途中触发一项选择".to_string(), + why_required: "提供短局变化".to_string(), + verify_method: "重玩时可遇到不同事件".to_string(), + decision_state: "confirmed".to_string(), + }, + ], + out_of_scope: vec!["多人联机".to_string()], + creator_tips: PlanCreatorTips { + do_first: "先做一张可走完的地图".to_string(), + defer_for_now: "暂缓复杂成长线".to_string(), + how_to_verify: "观察玩家是否能说出每次选择的后果".to_string(), + expand_when: "核心循环连续三局都可理解后再扩展".to_string(), + }, + }, + decisions: vec![PlanSubmitDecision { + id: "initial-request".to_string(), + topic: "初始需求".to_string(), + state: "confirmed".to_string(), + answer_source: "user_freeform".to_string(), + round: 0, + answer_summary: "做一个短局守夜策略游戏".to_string(), + }], + prototype_validation_items: vec![], + } + } + + fn context() -> PlanSubmitGddRuntimeContext { + PlanSubmitGddRuntimeContext { + project_id: "project-test-001".to_string(), + gdd_id: "gdd-00000000-0000-4000-8000-000000000001".to_string(), + action_id: "action-0123456789abcdef01234567".to_string(), + action_fingerprint: "1".repeat(64), + agent_id: GAME_CREATOR_PROJECT_PLANNING_AGENT_ID.to_string(), + source: "agent-delegate".to_string(), + run_profile: AGENT_RUNTIME_RUN_PROFILE_STANDARD.to_string(), + run_profile_binding_fingerprint: "2".repeat(64), + root_agent_id: GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(), + root_run_id: "run-root-001".to_string(), + parent_agent_id: Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string()), + parent_run_id: Some("run-root-001".to_string()), + delegation_id: "delegation-001".to_string(), + session_id: "session-001".to_string(), + source_session_revision: 1, + source_session_fingerprint: "sha256-serde-json-v2:".to_string() + &"3".repeat(64), + created_by_run_id: "run-child-001".to_string(), + created_at_utc: "2026-08-14T00:00:00.000Z".to_string(), + approval_request_id: Some( + "gdd-approval-00000000-0000-4000-8000-000000000002".to_string(), + ), + } + } + + fn provider_binding() -> PlanProviderSessionBindingV1 { + let mut binding = PlanProviderSessionBindingV1 { + schema_version: PLAN_PROVIDER_SESSION_BINDING_SCHEMA_VERSION.to_string(), + project_id: "project-test-001".to_string(), + gdd_id: "gdd-00000000-0000-4000-8000-000000000001".to_string(), + agent_id: GAME_CREATOR_PROJECT_PLANNING_AGENT_ID.to_string(), + task_id: "task-plan-001".to_string(), + provider_request_id: String::new(), + session_id: "session-001".to_string(), + run_id: "run-child-001".to_string(), + root_agent_id: GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(), + root_run_id: "run-root-001".to_string(), + delegation_id: "delegation-001".to_string(), + goal_id: None, + goal_revision: 0, + goal_snapshot_fingerprint: String::new(), + source: "agent-delegate".to_string(), + run_profile: AGENT_RUNTIME_RUN_PROFILE_STANDARD.to_string(), + run_profile_binding_fingerprint: "2".repeat(64), + session_revision: 1, + session_fingerprint: format!("sha256-serde-json-v2:{}", "3".repeat(64)), + applied_steer_cursor: 0, + request_kind: "tool-plan".to_string(), + request_slot: "loop-2-repair-0".to_string(), + web_search_enabled: false, + request_context_fingerprint: format!("sha256-serde-json-v2:{}", "4".repeat(64)), + fingerprint: String::new(), + }; + binding.provider_request_id = + plan_provider_session_binding_base_request_id(&binding).expect("base request id"); + binding.fingerprint = + plan_provider_session_binding_fingerprint(&binding).expect("binding fingerprint"); + validate_plan_provider_session_binding(&binding).expect("valid planning binding"); + binding + } + + fn next_repair_binding(initial: &PlanProviderSessionBindingV1) -> PlanProviderSessionBindingV1 { + let mut binding = initial.clone(); + binding.request_slot = "loop-2-repair-1".to_string(); + binding.request_context_fingerprint = format!("sha256-serde-json-v2:{}", "5".repeat(64)); + binding.provider_request_id = + plan_provider_session_binding_base_request_id(&binding).expect("repair request id"); + binding.fingerprint = String::new(); + binding.fingerprint = plan_provider_session_binding_fingerprint(&binding) + .expect("repair binding fingerprint"); + validate_plan_provider_session_binding(&binding).expect("valid repair binding"); + binding + } + + fn submit_fixture() -> (PathBuf, PlanSubmitGddRuntimeContext, PlanSubmitGddInputV1) { + let root = std::env::temp_dir().join(format!( + "genarrative-planning-submit-{}", + Uuid::new_v4().simple() + )); + init_local_game_project_at(&root, "project-test-001", "M1B-2 submit fixture") + .expect("project init"); + bind_game_creator_agent_runtime_run_profile_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "run-root-001", + AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_STANDARD), + None, + ) + .expect("bind plan root"); + let child_binding = bind_game_creator_agent_runtime_run_profile_at( + &root, + GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + "run-child-001", + "agent-delegate", + Some(AGENT_RUNTIME_RUN_PROFILE_STANDARD), + Some(&AgentRuntimeTaskLink { + parent_agent_id: Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string()), + parent_run_id: Some("run-root-001".to_string()), + delegation_id: Some("delegation-001".to_string()), + }), + ) + .expect("bind planning child"); + + let mut context = context(); + context.project_id = "project-test-001".to_string(); + context.run_profile_binding_fingerprint = child_binding.binding_fingerprint; + let input = valid_input(); + let decisions = input + .decisions + .iter() + .map(|decision| PlanDecisionSummary { + id: decision.id.clone(), + topic: decision.topic.clone(), + state: decision.state.clone(), + answer_source: decision.answer_source.clone(), + round: decision.round, + answer_summary: decision.answer_summary.clone(), + }) + .collect(); + let mut session = PlanSessionV1 { + schema_version: PLAN_SESSION_SCHEMA_VERSION.to_string(), + project_id: context.project_id.clone(), + gdd_id: context.gdd_id.clone(), + session_revision: 1, + previous_fingerprint: None, + session_fingerprint: format!("sha256-serde-json-v2:{}", "0".repeat(64)), + agent_id: context.agent_id.clone(), + source: context.source.clone(), + run_profile: context.run_profile.clone(), + run_profile_binding_fingerprint: context.run_profile_binding_fingerprint.clone(), + root_agent_id: context.root_agent_id.clone(), + root_run_id: context.root_run_id.clone(), + latest_delegation_id: context.delegation_id.clone(), + session_id: context.session_id.clone(), + active_run_id: Some(context.created_by_run_id.clone()), + last_run_id: context.created_by_run_id.clone(), + phase: "collecting".to_string(), + accumulated_agent_millis: 0, + applied_steer_cursor: 0, + decisions_summary: decisions, + prototype_validation_items: input.prototype_validation_items.clone(), + applied_answers: Vec::new(), + latest_submitted_ref: None, + last_decision_ref: None, + updated_at_utc: context.created_at_utc.clone(), + }; + session.session_fingerprint = plan_session_fingerprint(&session).expect("session fp"); + context.source_session_revision = session.session_revision; + context.source_session_fingerprint = session.session_fingerprint.clone(); + write_plan_session_atomic(&root, &session).expect("write session fixture"); + (root, context, input) + } + + fn cleanup_fixture(root: PathBuf) { + let _ = fs::remove_dir_all(root); + } + + #[test] + fn runtime_injection_adds_fixed_platform_facts_and_identity() { + let gdd = build_plan_gdd_from_submit_input( + &valid_input(), + &context(), + 1, + "gdd-approval-00000000-0000-4000-8000-000000000002", + ) + .expect("build GDD"); + assert_eq!(gdd.game.platform_facts.runtime, "self-contained-web"); + assert_eq!(gdd.game.platform_facts.viewports, ["desktop", "mobile"]); + assert_eq!(gdd.source, "agent-delegate"); + assert!(validate_plan_gdd(&gdd).is_ok()); + } + + #[test] + fn provider_binding_is_strict_and_recomputes_canonical_request_identity() { + let binding = provider_binding(); + let round_trip = serde_json::from_value::( + serde_json::to_value(&binding).expect("serialize binding"), + ) + .expect("round-trip binding"); + assert_eq!(round_trip, binding); + + let mut with_unknown = serde_json::to_value(&binding).expect("serialize binding"); + with_unknown + .as_object_mut() + .expect("binding object") + .insert("unexpected".to_string(), serde_json::json!(true)); + assert!( + serde_json::from_value::(with_unknown).is_err(), + "unknown binding fields must fail closed" + ); + + let mut forged_request_id = binding.clone(); + forged_request_id.provider_request_id = format!("provider-request-{}", "f".repeat(64)); + forged_request_id.fingerprint = String::new(); + forged_request_id.fingerprint = + plan_provider_session_binding_fingerprint(&forged_request_id) + .expect("forge self-consistent binding fingerprint"); + assert_eq!( + validate_plan_provider_session_binding(&forged_request_id) + .expect_err("arbitrary providerRequestId must fail") + .code(), + "PLAN_NEEDS_RECONCILIATION" + ); + + let mut forged_context = binding.clone(); + forged_context.request_context_fingerprint = + format!("sha256-serde-json-v2:{}", "6".repeat(64)); + forged_context.fingerprint = String::new(); + forged_context.fingerprint = plan_provider_session_binding_fingerprint(&forged_context) + .expect("forge context binding fingerprint"); + assert!(validate_plan_provider_session_binding(&forged_context).is_err()); + } + + #[test] + fn provider_request_attempt_id_matches_typed_canonical_golden_vector() { + const BASE_REQUEST_ID: &str = + "provider-request-0000000000000000000000000000000000000000000000000000000000000000"; + const CANONICAL_ENVELOPE: &str = concat!( + "{\"domain\":\"genarrative.plan.provider-request-attempt.v1\",", + "\"value\":{\"baseProviderRequestId\":\"", + "provider-request-0000000000000000000000000000000000000000000000000000000000000000", + "\",\"attempt\":2}}" + ); + + assert_eq!( + plan_provider_request_attempt_id(BASE_REQUEST_ID, 0), + BASE_REQUEST_ID + ); + + let value = PlanProviderRequestAttemptIdentityValue { + base_provider_request_id: BASE_REQUEST_ID, + attempt: 2, + }; + assert_eq!( + typed_serde_canonical_bytes(PLAN_PROVIDER_REQUEST_ATTEMPT_ID_DOMAIN, &value) + .expect("serialize attempt identity"), + CANONICAL_ENVELOPE.as_bytes() + ); + assert_eq!( + plan_provider_request_attempt_id(BASE_REQUEST_ID, 2), + "provider-request-89e5d07c6761b6521ef77dc5f477854fd820b98b20b4e7f56cd80ef3464574a8" + ); + } + + #[test] + fn provider_binding_attempt_and_repair_identity_stay_on_the_frozen_lineage() { + let base = provider_binding(); + let attempt_id = plan_provider_request_attempt_id(&base.provider_request_id, 2); + let attempt = plan_provider_session_binding_for_attempt( + &base, + "loop-2-repair-0-transient-2", + &attempt_id, + ) + .expect("derive planning transient attempt"); + assert_eq!(attempt.provider_request_id, attempt_id); + assert_eq!( + plan_provider_session_binding_base_request_id(&attempt) + .expect("recover base request id"), + base.provider_request_id + ); + validate_plan_provider_session_binding(&attempt).expect("attempt binding validates"); + assert!(plan_provider_session_binding_for_attempt( + &base, + "loop-2-repair-0-transient-2", + &format!("provider-request-{}", "e".repeat(64)), + ) + .is_err()); + + let repair = next_repair_binding(&base); + validate_plan_provider_session_binding_repair_lineage(&base, &repair) + .expect("repair request may change slot/context only"); + let mut advanced_session = repair; + advanced_session.session_revision = 2; + advanced_session.session_fingerprint = format!("sha256-serde-json-v2:{}", "7".repeat(64)); + advanced_session.provider_request_id = + plan_provider_session_binding_base_request_id(&advanced_session) + .expect("advanced session request id"); + advanced_session.fingerprint = String::new(); + advanced_session.fingerprint = plan_provider_session_binding_fingerprint(&advanced_session) + .expect("advanced session binding fingerprint"); + validate_plan_provider_session_binding(&advanced_session) + .expect("advanced session is independently valid"); + assert!( + validate_plan_provider_session_binding_repair_lineage(&base, &advanced_session) + .is_err(), + "one repair chain must not jump to a newer session" + ); + } + + #[test] + fn planning_provider_four_request_kinds_share_v3_lifecycle_and_one_user_injection() { + let (root, context, _) = submit_fixture(); + ensure_agent_conversation_session_at( + &root, + GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + &context.session_id, + "M1B-2 planning Provider 请求矩阵", + ) + .expect("ensure planning Provider matrix conversation session"); + let mut runtime = start_game_creator_agent_runtime_task_for_session_at( + &root, + GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + Some(&context.session_id), + "形成并提交 Fast GDD", + &context.created_by_run_id, + "agent-delegate", + "构建 planning Provider 请求矩阵", + vec!["核对四类 planning Provider 请求".to_string()], + ) + .expect("start planning Provider matrix runtime"); + assert_eq!(runtime.session_id, context.session_id); + assert_eq!(runtime.run_id, context.created_by_run_id); + runtime.parent_agent_id = context.parent_agent_id.clone(); + runtime.parent_run_id = context.parent_run_id.clone(); + runtime.delegation_id = Some(context.delegation_id.clone()); + runtime.updated_at = unix_timestamp(); + append_game_creator_agent_runtime_task(&root, &runtime) + .expect("append planning Provider matrix task link"); + refresh_game_creator_agent_runtime_task_queue(&root, &mut runtime) + .expect("refresh planning Provider matrix task queue"); + write_game_creator_agent_runtime_state(&root, &runtime) + .expect("persist planning Provider matrix task link"); + let delivery = new_static_delegate_delivery( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "planning-provider-root-session", + &context.root_run_id, + "planning-provider-parent-action", + &context.delegation_id, + GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + &runtime.session_id, + &runtime.run_id, + ); + write_static_delegate_delivery_at(&root, &delivery) + .expect("write planning Provider matrix delivery"); + + let structured_wire = + capture_plan_provider_structured_injections_at(&root, &runtime.session_id, &[]) + .expect("capture planning structured injections"); + let structured_message = + render_plan_provider_structured_injections_message(&structured_wire) + .expect("render planning structured injections"); + let structured_prefix = format!("{PLAN_PROVIDER_STRUCTURED_INJECTIONS_MESSAGE_HEADER}\n"); + let request_kinds = [ + "tool-plan", + "final-reply", + "context-compaction", + "final-reply-context-compaction", + ]; + let mut request_ids = Vec::new(); + + for (index, request_kind) in request_kinds.into_iter().enumerate() { + let request_slot = format!("m1b2-{request_kind}-{index}"); + let snapshot = capture_game_creator_agent_runtime_provider_request_snapshot( + &root, + &runtime.agent_id, + &runtime.session_id, + &runtime.run_id, + request_kind, + &request_slot, + runtime.applied_steer_cursor, + ) + .expect("capture planning Provider request snapshot"); + let request = platform_llm::LlmRunRequest::new(vec![ + platform_llm::LlmMessage::system("planning request matrix"), + platform_llm::LlmMessage::user(structured_message.clone()), + platform_llm::LlmMessage::user(format!("requestKind={request_kind}")), + ]) + .with_model("planning-request-matrix") + .with_api_kind(platform_llm::LlmApiKind::OpenAiResponses) + .with_max_output_tokens(512) + .with_web_search(false); + let matching_injections = request + .messages + .iter() + .filter(|message| message.content.starts_with(&structured_prefix)) + .collect::>(); + assert_eq!(matching_injections.len(), 1); + assert_eq!( + matching_injections[0].role, + platform_llm::LlmMessageRole::User + ); + let request_context_fingerprint = + game_creator_agent_runtime_plan_provider_request_context_fingerprint( + &GameCreatorLlmConfig::default(), + &request, + ) + .expect("fingerprint planning Provider request wire"); + let binding = { + let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + &root, + "test.planning_provider_four_request_kinds", + ) + .expect("lock planning Provider request binding"); + let current = read_game_creator_agent_runtime_at(&root, &runtime.agent_id) + .expect("read current planning runtime") + .state; + capture_plan_provider_session_binding_for_snapshot( + &root, + ¤t, + &snapshot, + &request_context_fingerprint, + ) + .expect("capture planning Provider session binding") + }; + assert_eq!(binding.request_kind, request_kind); + assert_eq!(binding.request_slot, request_slot); + validate_plan_provider_session_binding_current_at(&root, &binding) + .expect("validate current planning Provider binding"); + let request_id = binding.provider_request_id.clone(); + let snapshot = snapshot.with_planning_session_binding(Some(binding)); + assert!( + append_game_creator_agent_runtime_provider_request_lifecycle( + &root, + &snapshot, + &request_id, + "started", + ) + .expect("append planning Provider started lifecycle") + ); + assert!( + append_game_creator_agent_runtime_provider_request_lifecycle( + &root, + &snapshot, + &request_id, + "completed", + ) + .expect("append planning Provider completed lifecycle") + ); + assert_eq!( + read_agent_db_lifecycle_transitions_at( + &root, + AGENT_RUNTIME_PROVIDER_REQUEST_LIFECYCLE_RECORD_TYPE, + "requestId", + &request_id, + ) + .expect("read planning Provider lifecycle"), + vec!["started", "completed"] + ); + if request_kind != "tool-plan" { + assert!(!game_creator_agent_runtime_provider_action_batch_exists( + &root, + &runtime.agent_id, + &runtime.run_id, + )); + } + request_ids.push((request_kind, request_id)); + } + + let records = read_agent_db_records_bounded(&root, 1024 * 1024) + .expect("read planning Provider lifecycle records") + .0; + for (request_kind, request_id) in request_ids { + let lifecycle = records + .iter() + .filter(|record| { + record.get("recordType").and_then(serde_json::Value::as_str) + == Some(AGENT_RUNTIME_PROVIDER_REQUEST_LIFECYCLE_RECORD_TYPE) + && record.get("requestId").and_then(serde_json::Value::as_str) + == Some(request_id.as_str()) + }) + .collect::>(); + assert_eq!(lifecycle.len(), 2); + for record in lifecycle { + assert_eq!( + record + .get("auditSchemaVersion") + .and_then(serde_json::Value::as_str), + Some(AGENT_RUNTIME_PLAN_PROVIDER_REQUEST_LIFECYCLE_SCHEMA_VERSION) + ); + assert_eq!( + record + .get("requestKind") + .and_then(serde_json::Value::as_str), + Some(request_kind) + ); + assert_eq!( + record + .pointer("/planningSessionBinding/requestKind") + .and_then(serde_json::Value::as_str), + Some(request_kind) + ); + } + } + cleanup_fixture(root); + } + + #[test] + fn planning_structured_injection_wire_enforces_the_64_kib_boundary() { + let mut value = PlanProviderStructuredInjectionsV1 { + schema_version: PLAN_PROVIDER_STRUCTURED_INJECTIONS_SCHEMA_VERSION.to_string(), + clarification_round: 0, + accumulated_agent_millis: 0, + session: PlanProviderFacingSessionV1 { + phase: "collecting".to_string(), + decisions_summary: Vec::new(), + prototype_validation_items: Vec::new(), + latest_submitted_ref: None, + last_decision_ref: None, + }, + platform_facts: fixed_plan_platform_facts(), + approval_observation: Some(PlanProviderApprovalObservationV1 { + tool: PLAN_SUBMIT_GDD_TOOL.to_string(), + status: "ok".to_string(), + summary: "提交完成".to_string(), + detail: Some(String::new()), + }), + }; + let target = PLAN_PROVIDER_STRUCTURED_INJECTIONS_MAX_BYTES; + let mut padding = 0usize; + loop { + value + .approval_observation + .as_mut() + .expect("approval observation") + .detail = Some("x".repeat(padding)); + let bytes = serde_json::to_vec(&value).expect("serialize boundary fixture"); + if bytes.len() >= target { + assert_eq!(bytes.len(), target, "ASCII detail padding is byte-linear"); + assert!(render_plan_provider_structured_injections_message(&bytes).is_ok()); + let mut oversized = bytes.clone(); + oversized.push(b' '); + assert!(render_plan_provider_structured_injections_message(&oversized).is_err()); + break; + } + padding = padding.saturating_add(target - bytes.len()); + } + } + + #[test] + fn planning_provider_started_rechecks_parent_and_delegation_binding() { + let (root, context, _) = submit_fixture(); + ensure_agent_conversation_session_at( + &root, + GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + &context.session_id, + "planning Provider binding tamper", + ) + .expect("ensure planning Provider tamper conversation session"); + let mut runtime = start_game_creator_agent_runtime_task_for_session_at( + &root, + GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + Some(&context.session_id), + "形成 Fast GDD", + &context.created_by_run_id, + "agent-delegate", + "验证 planning Provider binding", + vec!["验证 parent/delegation binding".to_string()], + ) + .expect("start planning Provider tamper runtime"); + runtime.parent_agent_id = context.parent_agent_id.clone(); + runtime.parent_run_id = context.parent_run_id.clone(); + runtime.delegation_id = Some(context.delegation_id.clone()); + runtime.updated_at = unix_timestamp(); + append_game_creator_agent_runtime_task(&root, &runtime) + .expect("append planning Provider tamper task"); + refresh_game_creator_agent_runtime_task_queue(&root, &mut runtime) + .expect("refresh planning Provider tamper queue"); + write_game_creator_agent_runtime_state(&root, &runtime) + .expect("persist planning Provider tamper runtime"); + let snapshot = capture_game_creator_agent_runtime_provider_request_snapshot( + &root, + &runtime.agent_id, + &runtime.session_id, + &runtime.run_id, + "tool-plan", + "m1b2-binding-tamper", + runtime.applied_steer_cursor, + ) + .expect("capture planning Provider tamper snapshot"); + let binding = { + let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + &root, + "test.planning_provider_started_rechecks_binding", + ) + .expect("lock planning Provider tamper binding"); + capture_plan_provider_session_binding_for_snapshot( + &root, + &runtime, + &snapshot, + &format!("sha256-serde-json-v2:{}", "9".repeat(64)), + ) + .expect("capture planning Provider tamper binding") + }; + let request_id = binding.provider_request_id.clone(); + let snapshot = snapshot.with_planning_session_binding(Some(binding)); + + // Tamper after capture but before lifecycle started. The durable + // started record must never be emitted for the stale parent/delegation + // identity. + runtime.parent_run_id = Some("tampered-root-run".to_string()); + runtime.delegation_id = Some("tampered-delegation".to_string()); + runtime.updated_at = unix_timestamp(); + append_game_creator_agent_runtime_task(&root, &runtime) + .expect("append tampered planning runtime"); + refresh_game_creator_agent_runtime_task_queue(&root, &mut runtime) + .expect("refresh tampered planning runtime queue"); + write_game_creator_agent_runtime_state(&root, &runtime) + .expect("persist tampered planning runtime"); + + let error = append_game_creator_agent_runtime_provider_request_lifecycle( + &root, + &snapshot, + &request_id, + "started", + ) + .expect_err("tampered parent/delegation binding must fail before started"); + assert!(error.contains("reconciliation") || error.contains("binding")); + assert!(read_agent_db_lifecycle_transitions_at( + &root, + AGENT_RUNTIME_PROVIDER_REQUEST_LIFECYCLE_RECORD_TYPE, + "requestId", + &request_id, + ) + .expect("read tamper lifecycle") + .is_empty()); + cleanup_fixture(root); + } + + #[test] + fn planning_provider_lifecycle_started_rejects_parent_or_delegation_drift_after_capture() { + for drift_kind in ["parent", "delegation"] { + let (root, context, _) = submit_fixture(); + ensure_agent_conversation_session_at( + &root, + GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + &context.session_id, + "M1B-2 planning identity drift", + ) + .expect("ensure planning identity-drift conversation session"); + let mut runtime = start_game_creator_agent_runtime_task_for_session_at( + &root, + GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + Some(&context.session_id), + "形成并提交 Fast GDD", + &context.created_by_run_id, + "agent-delegate", + "构建 planning identity-drift request", + vec!["核对 captured binding 身份".to_string()], + ) + .expect("start planning identity-drift runtime"); + assert_eq!(runtime.session_id, context.session_id); + assert_eq!(runtime.run_id, context.created_by_run_id); + runtime.parent_agent_id = context.parent_agent_id.clone(); + runtime.parent_run_id = context.parent_run_id.clone(); + runtime.delegation_id = Some(context.delegation_id.clone()); + runtime.updated_at = unix_timestamp(); + append_game_creator_agent_runtime_task(&root, &runtime) + .expect("append planning identity-drift task link"); + refresh_game_creator_agent_runtime_task_queue(&root, &mut runtime) + .expect("refresh planning identity-drift task queue"); + write_game_creator_agent_runtime_state(&root, &runtime) + .expect("persist planning identity-drift runtime"); + + let snapshot = capture_game_creator_agent_runtime_provider_request_snapshot( + &root, + &runtime.agent_id, + &runtime.session_id, + &runtime.run_id, + "tool-plan", + "m1b2-identity-drift-0", + runtime.applied_steer_cursor, + ) + .expect("capture planning request before identity drift"); + let binding = { + let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + &root, + "test.planning_identity_drift_binding", + ) + .expect("lock planning identity-drift binding"); + let current = read_game_creator_agent_runtime_at(&root, &runtime.agent_id) + .expect("read current planning identity-drift runtime") + .state; + capture_plan_provider_session_binding_for_snapshot( + &root, + ¤t, + &snapshot, + &format!("sha256-serde-json-v2:{}", "f".repeat(64)), + ) + .expect("capture planning identity-drift binding") + }; + let request_id = binding.provider_request_id.clone(); + let snapshot = snapshot.with_planning_session_binding(Some(binding)); + + match drift_kind { + "parent" => runtime.parent_run_id = Some("drifted-parent-run".to_string()), + "delegation" => runtime.delegation_id = Some("drifted-delegation".to_string()), + _ => unreachable!(), + } + runtime.updated_at = unix_timestamp(); + append_game_creator_agent_runtime_task(&root, &runtime) + .expect("append drifted planning runtime task"); + refresh_game_creator_agent_runtime_task_queue(&root, &mut runtime) + .expect("refresh drifted planning runtime task queue"); + write_game_creator_agent_runtime_state(&root, &runtime) + .expect("write drifted planning runtime state"); + + let error = append_game_creator_agent_runtime_provider_request_lifecycle( + &root, + &snapshot, + &request_id, + "started", + ) + .expect_err("captured planning binding drift must reject lifecycle started"); + assert!(error.starts_with(AGENT_RUNTIME_PROVIDER_REQUEST_RECONCILIATION_PREFIX)); + let records = read_agent_db_records_bounded(&root, 1024 * 1024) + .expect("read identity-drift lifecycle records") + .0; + assert!(!records.iter().any(|record| { + record.get("recordType").and_then(serde_json::Value::as_str) + == Some(AGENT_RUNTIME_PROVIDER_REQUEST_LIFECYCLE_RECORD_TYPE) + && record.get("requestId").and_then(serde_json::Value::as_str) + == Some(request_id.as_str()) + && record.get("status").and_then(serde_json::Value::as_str) == Some("started") + })); + assert!(!game_creator_agent_runtime_provider_action_batch_exists( + &root, + &runtime.agent_id, + &runtime.run_id + )); + cleanup_fixture(root); + } + } + + #[test] + fn replay_payload_comparison_is_strict() { + let input = valid_input(); + let gdd = build_plan_gdd_from_submit_input( + &input, + &context(), + 1, + "gdd-approval-00000000-0000-4000-8000-000000000002", + ) + .expect("build GDD"); + assert!(submit_payload_matches_gdd(&input, &gdd).expect("match")); + let mut changed = input; + changed.game.title.push('改'); + assert!(!submit_payload_matches_gdd(&changed, &gdd).expect("mismatch")); + } + + #[test] + fn markdown_renderer_is_deterministic_and_contains_authority_metadata() { + let gdd = build_plan_gdd_from_submit_input( + &valid_input(), + &context(), + 1, + "gdd-approval-00000000-0000-4000-8000-000000000002", + ) + .expect("build GDD"); + let first = render_plan_fast_gdd_markdown(&gdd, "ready_for_approval").expect("render"); + let second = render_plan_fast_gdd_markdown(&gdd, "ready_for_approval").expect("render"); + assert_eq!(first, second); + assert!(first.contains("Fast GDD v1")); + assert!(first.contains(&gdd.gdd_id)); + assert!(first.contains(&gdd.fingerprint)); + assert!(first.contains("## Runtime 平台事实")); + } + + #[test] + fn timestamp_helper_matches_fixed_millisecond_contract() { + let timestamp = current_plan_timestamp_utc(); + assert_eq!(timestamp.len(), 24); + validate_timestamp(×tamp, "now").expect("valid UTC timestamp"); + } + + #[test] + fn submit_handler_creates_projection_and_replays_without_new_version() { + let (root, context, input) = submit_fixture(); + let first = execute_plan_submit_gdd(&root, &context, &input).expect("submit v1"); + assert_eq!(first.outcome, "submitted"); + assert_eq!(first.gdd_ref.version, 1); + assert!(!first.recovery_pending); + assert!(root.join(".agent/planning/gdd.v1.json").is_file()); + assert!(root.join(".agent/planning/index.json").is_file()); + assert!(root.join("game/fast_gdd.md").is_file()); + + let replay = execute_plan_submit_gdd(&root, &context, &input).expect("replay v1"); + assert_eq!(replay.outcome, "replayed"); + assert_eq!(replay.gdd_ref, first.gdd_ref); + assert!(!replay.recovery_pending); + assert_eq!(read_plan_gdd_chain(&root).expect("read chain").len(), 1); + + let mut changed = input.clone(); + changed.game.title.push('改'); + let conflict = execute_plan_submit_gdd(&root, &context, &changed) + .expect_err("same submission different payload must conflict"); + assert_eq!(conflict.code(), "PLAN_SUBMISSION_IDENTITY_CONFLICT"); + cleanup_fixture(root); + } + + #[test] + fn committed_submit_rebuilds_each_derived_projection_without_allocating_v2() { + let (root, context, input) = submit_fixture(); + let source_session_bytes = + fs::read(root.join(".agent/planning/session.json")).expect("read source session"); + let first = execute_plan_submit_gdd(&root, &context, &input).expect("submit v1"); + assert_eq!(first.gdd_ref.version, 1); + + fs::remove_file(root.join(".agent/planning/index.json")) + .expect("remove derived index projection"); + let index_replay = + execute_plan_submit_gdd(&root, &context, &input).expect("rebuild index projection"); + assert_eq!(index_replay.outcome, "replayed"); + assert!(!index_replay.recovery_pending); + assert!(root.join(".agent/planning/index.json").is_file()); + + fs::remove_file(root.join("game/fast_gdd.md")).expect("remove derived markdown projection"); + let markdown_replay = + execute_plan_submit_gdd(&root, &context, &input).expect("rebuild markdown projection"); + assert_eq!(markdown_replay.outcome, "replayed"); + assert!(!markdown_replay.recovery_pending); + assert!(root.join("game/fast_gdd.md").is_file()); + + let previous = root.join(".agent/planning/.session.json.previous"); + if previous.exists() { + fs::remove_file(&previous).expect("remove successor recovery copy"); + } + fs::write( + root.join(".agent/planning/session.json"), + source_session_bytes, + ) + .expect("restore source session crash snapshot"); + let session_replay = execute_plan_submit_gdd(&root, &context, &input) + .expect("rebuild session successor projection"); + assert_eq!(session_replay.outcome, "replayed"); + assert!(!session_replay.recovery_pending); + let session = read_plan_session_with_recovery(&root) + .expect("read repaired session") + .expect("repaired session exists"); + assert_eq!( + session.session_revision, + context.source_session_revision + 1 + ); + assert_eq!(session.phase, "awaiting_gdd_approval"); + assert_eq!(session.latest_submitted_ref.as_ref(), Some(&first.gdd_ref)); + + assert_eq!( + read_plan_gdd_chain(&root).expect("read final chain").len(), + 1 + ); + assert!(!root.join(".agent/planning/gdd.v2.json").exists()); + cleanup_fixture(root); + } + + #[test] + fn submit_handler_rejects_session_cas_before_creating_any_fact() { + let (root, mut context, input) = submit_fixture(); + context.source_session_revision = 2; + let error = execute_plan_submit_gdd(&root, &context, &input) + .expect_err("stale session must be rejected"); + assert_eq!(error.code(), "PLAN_SESSION_CAS_CONFLICT"); + assert!(!root.join(".agent/planning/gdd.v1.json").exists()); + assert!(!root.join(".agent/planning/index.json").exists()); + assert!(!root.join("game/fast_gdd.md").exists()); + cleanup_fixture(root); + } + + #[test] + fn submit_rejects_orphan_index_before_allocating_first_version() { + let (root, context, input) = submit_fixture(); + fs::write(root.join(".agent/planning/index.json"), b"{}\n") + .expect("seed orphan planning index"); + + let error = execute_plan_submit_gdd(&root, &context, &input) + .expect_err("orphan index must block a new v1 submission"); + assert_eq!(error.code(), "PLAN_IDENTITY_CONFLICT"); + assert!(!root.join(".agent/planning/gdd.v1.json").exists()); + assert!(!root.join("game/fast_gdd.md").exists()); + cleanup_fixture(root); + } + + #[test] + fn submit_handler_rejects_second_submission_while_first_is_pending() { + let (root, context, input) = submit_fixture(); + let first = execute_plan_submit_gdd(&root, &context, &input).expect("submit v1"); + let mut second_context = context.clone(); + second_context.action_id = "action-abcdefabcdefabcdefabcdef".to_string(); + second_context.action_fingerprint = "4".repeat(64); + let error = execute_plan_submit_gdd(&root, &second_context, &input) + .expect_err("second pending GDD must be rejected"); + assert_eq!(error.code(), "PLAN_PENDING_GDD_EXISTS"); + assert_eq!(first.gdd_ref.version, 1); + assert_eq!(read_plan_gdd_chain(&root).expect("read chain").len(), 1); + cleanup_fixture(root); + } + + #[test] + fn submit_allows_unasked_default_decisions_after_the_exact_session_prefix() { + let (root, context, mut input) = submit_fixture(); + input.decisions.push(PlanSubmitDecision { + id: "default-session-length".to_string(), + topic: "单局时长".to_string(), + state: "default_pending".to_string(), + answer_source: "default".to_string(), + round: 0, + answer_summary: "默认按十到二十分钟一局设计".to_string(), + }); + + let result = execute_plan_submit_gdd(&root, &context, &input) + .expect("an unasked default may follow the exact session decisions"); + let chain = read_plan_gdd_chain(&root).expect("read submitted chain"); + assert_eq!(result.gdd_ref.version, 1); + assert_eq!(chain[0].decisions.len(), input.decisions.len()); + assert_eq!(chain[0].decisions.last().unwrap().basis, None); + cleanup_fixture(root); + } + + #[test] + fn submit_rejects_an_extra_non_default_decision_not_present_in_session() { + let (root, context, mut input) = submit_fixture(); + input.decisions.push(PlanSubmitDecision { + id: "invented-confirmation".to_string(), + topic: "未提问决定".to_string(), + state: "confirmed".to_string(), + answer_source: "user_option".to_string(), + round: 1, + answer_summary: "伪造为用户已确认".to_string(), + }); + + let error = execute_plan_submit_gdd(&root, &context, &input) + .expect_err("a non-default decision outside the session prefix must fail"); + assert_eq!(error.code(), "PLAN_SESSION_CAS_CONFLICT"); + assert!(!root.join(".agent/planning/gdd.v1.json").exists()); + cleanup_fixture(root); + } + + #[test] + fn submit_replay_reports_missing_session_without_guessing_or_allocating_version() { + let (root, context, input) = submit_fixture(); + let first = execute_plan_submit_gdd(&root, &context, &input).expect("submit v1"); + fs::remove_file(root.join(".agent/planning/session.json")).expect("remove session"); + fs::remove_file(root.join(".agent/planning/.session.json.previous")) + .expect("remove session recovery copy"); + let replay = + execute_plan_submit_gdd(&root, &context, &input).expect("replay committed GDD"); + assert_eq!(replay.outcome, "replayed"); + assert_eq!(replay.gdd_ref, first.gdd_ref); + assert!(replay.recovery_pending); + assert_eq!(read_plan_gdd_chain(&root).expect("read chain").len(), 1); + assert!(!root.join(".agent/planning/session.json").exists()); + assert!(!root.join(".agent/planning/.session.json.previous").exists()); + assert!(!root.join(".agent/planning/gdd.v2.json").exists()); + cleanup_fixture(root); + } + + #[test] + fn projection_failure_after_gdd_create_returns_recovery_pending_and_replays() { + let (root, context, input) = submit_fixture(); + fs::create_dir(root.join("game/fast_gdd.md")).expect("block markdown target"); + let first = execute_plan_submit_gdd(&root, &context, &input).expect("submit fact"); + assert_eq!(first.outcome, "submitted"); + assert!(first.recovery_pending); + assert!(root.join(".agent/planning/gdd.v1.json").is_file()); + fs::remove_dir(root.join("game/fast_gdd.md")).expect("remove blocking directory"); + let replay = execute_plan_submit_gdd(&root, &context, &input).expect("repair projections"); + assert_eq!(replay.outcome, "replayed"); + assert!(!replay.recovery_pending); + assert!(root.join("game/fast_gdd.md").is_file()); + cleanup_fixture(root); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/provider_control.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/provider_control.rs index 4d2311a19..230d496f4 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/provider_control.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/provider_control.rs @@ -69,6 +69,7 @@ pub(in crate::agent) fn capture_game_creator_agent_runtime_provider_request_snap request_slot: request_slot.to_string(), web_search_enabled: false, allow_idle_context_compaction: false, + planning_session_binding: None, }) } @@ -122,6 +123,7 @@ pub(in crate::agent) fn capture_idle_game_creator_agent_runtime_context_compacti request_slot: request_slot.to_string(), web_search_enabled: false, allow_idle_context_compaction: true, + planning_session_binding: None, }) } @@ -319,15 +321,58 @@ pub(in crate::agent) fn append_game_creator_agent_runtime_provider_request_lifec request_id: &str, status: &str, ) -> Result { - append_agent_db_lifecycle_record_idempotent( - root, - "requestId", - request_id, - "status", - status, + let snapshot = if let Some(binding) = snapshot.planning_session_binding.as_ref() { + let request_slot = game_creator_agent_runtime_provider_request_slot_for_id( + snapshot, + request_id, + ) + .ok_or_else(|| { + format!( + "{AGENT_RUNTIME_PROVIDER_REQUEST_RECONCILIATION_PREFIX}: planning requestId 不属于 binding" + ) + })?; + let attempt_binding = + plan_provider_session_binding_for_attempt(binding, &request_slot, request_id)?; + snapshot + .with_request_slot(request_slot) + .with_planning_session_binding(Some(attempt_binding)) + } else { + snapshot.clone() + }; + if snapshot.planning_session_binding.is_some() && status == "started" { + let binding = snapshot + .planning_session_binding + .as_ref() + .expect("planning binding checked above"); + validate_plan_provider_session_binding_current_at(root, binding).map_err(|error| { + format!("{AGENT_RUNTIME_PROVIDER_REQUEST_RECONCILIATION_PREFIX}: {error}") + })?; + } + let schema_version = if snapshot.planning_session_binding.is_some() { + AGENT_RUNTIME_PLAN_PROVIDER_REQUEST_LIFECYCLE_SCHEMA_VERSION + } else { + AGENT_RUNTIME_PROVIDER_REQUEST_LIFECYCLE_SCHEMA_VERSION + }; + let record = if let Some(binding) = snapshot.planning_session_binding.as_ref() { serde_json::json!({ "recordType": AGENT_RUNTIME_PROVIDER_REQUEST_LIFECYCLE_RECORD_TYPE, - "auditSchemaVersion": AGENT_RUNTIME_PROVIDER_REQUEST_LIFECYCLE_SCHEMA_VERSION, + "auditSchemaVersion": schema_version, + "agentId": snapshot.agent_id, + "taskId": snapshot.task_id, + "sessionId": snapshot.session_id, + "runId": snapshot.run_id, + "source": snapshot.source, + "requestId": request_id, + "requestKind": snapshot.request_kind, + "requestSlot": snapshot.request_slot, + "webSearchEnabled": snapshot.web_search_enabled, + "planningSessionBinding": binding, + "status": status, + }) + } else { + serde_json::json!({ + "recordType": AGENT_RUNTIME_PROVIDER_REQUEST_LIFECYCLE_RECORD_TYPE, + "auditSchemaVersion": schema_version, "agentId": snapshot.agent_id, "taskId": snapshot.task_id, "sessionId": snapshot.session_id, @@ -338,7 +383,15 @@ pub(in crate::agent) fn append_game_creator_agent_runtime_provider_request_lifec "requestSlot": snapshot.request_slot, "webSearchEnabled": snapshot.web_search_enabled, "status": status, - }), + }) + }; + append_agent_db_lifecycle_record_idempotent( + root, + "requestId", + request_id, + "status", + status, + record, ) .map_err(|error| redact_agent_runtime_error(root, &error, 500)) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/provider_retry.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/provider_retry.rs index ef3faa7bc..c1ac898db 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/provider_retry.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/provider_retry.rs @@ -324,6 +324,225 @@ pub(in crate::agent) fn game_creator_agent_runtime_llm_request_fingerprint( Ok(format!("{:x}", Sha256::digest(serialized))) } +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct PlanProviderWireDigest { + role: String, + wire_bytes: u32, + wire_sha256: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct PlanProviderToolWireDigest { + name: String, + kind: String, + wire_bytes: u32, + wire_sha256: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct PlanProviderStructuredInjectionDigest { + wire_bytes: u32, + wire_sha256: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct PlanProviderRequestContextValue { + effective_model: String, + api_kind: String, + stream: bool, + official_fallback: Option, + anthropic_strict_tool_support: Option, + open_ai_chat_token_budget_field: Option, + max_output_tokens: Option, + response_reasoning_effort: Option, + response_text_verbosity: Option, + tool_choice: Option, + composition: String, + source_kind: String, + web_search_enabled: bool, + messages: Vec, + native_tools: Vec, + mcp_tools: Vec, + structured_injections: PlanProviderStructuredInjectionDigest, +} + +fn plan_provider_api_kind_name(api_kind: platform_llm::LlmApiKind) -> &'static str { + match api_kind { + platform_llm::LlmApiKind::OpenAiResponses => "openai_responses", + platform_llm::LlmApiKind::OpenAiChat => "openai_chat", + platform_llm::LlmApiKind::Anthropic => "anthropic", + } +} + +fn plan_provider_message_role_name(role: platform_llm::LlmMessageRole) -> &'static str { + match role { + platform_llm::LlmMessageRole::System => "system", + platform_llm::LlmMessageRole::User => "user", + platform_llm::LlmMessageRole::Assistant => "assistant", + } +} + +fn plan_provider_tool_choice_name(choice: platform_llm::LlmToolChoice) -> &'static str { + match choice { + platform_llm::LlmToolChoice::Auto => "auto", + platform_llm::LlmToolChoice::Required => "required", + } +} + +fn plan_provider_reasoning_name(effort: platform_llm::LlmResponseReasoningEffort) -> &'static str { + match effort { + platform_llm::LlmResponseReasoningEffort::Low => "low", + platform_llm::LlmResponseReasoningEffort::Medium => "medium", + platform_llm::LlmResponseReasoningEffort::High => "high", + } +} + +fn plan_provider_verbosity_name(verbosity: platform_llm::LlmResponseTextVerbosity) -> &'static str { + match verbosity { + platform_llm::LlmResponseTextVerbosity::Low => "low", + platform_llm::LlmResponseTextVerbosity::Medium => "medium", + platform_llm::LlmResponseTextVerbosity::High => "high", + } +} + +fn plan_provider_wire_digest(value: &T) -> Result<(u32, String), String> { + let bytes = serde_json::to_vec(value) + .map_err(|error| format!("序列化 plan Provider wire DTO 失败:{error}"))?; + let byte_len = u32::try_from(bytes.len()) + .map_err(|_| "plan Provider wire DTO 超出 u32 字节上限".to_string())?; + Ok((byte_len, format!("{:x}", Sha256::digest(bytes)))) +} + +/// Typed, request-semantic fingerprint for exact planning requests. The +/// generic runtime identity intentionally keeps its historical hash contract; +/// only planning requests use this strict v1 context payload. +pub(in crate::agent) fn game_creator_agent_runtime_plan_provider_request_context_fingerprint( + llm: &GameCreatorLlmConfig, + request: &LlmRunRequest, +) -> Result { + let api_kind = request.api_kind; + let effective_model = request + .model + .as_deref() + .filter(|model| !model.trim().is_empty()) + .unwrap_or(llm.model.as_str()) + .trim() + .to_string(); + if effective_model.is_empty() { + return Err("plan Provider request effectiveModel 不能为空".to_string()); + } + let messages = request + .messages + .iter() + .map(|message| { + let (wire_bytes, wire_sha256) = plan_provider_wire_digest(message)?; + Ok(PlanProviderWireDigest { + role: plan_provider_message_role_name(message.role).to_string(), + wire_bytes, + wire_sha256, + }) + }) + .collect::, String>>()?; + let native_tools = request + .function_tools + .iter() + .map(|tool| { + let (wire_bytes, wire_sha256) = plan_provider_wire_digest(tool)?; + let kind = if tool.name.contains("update_agent_plan") + || tool.name.contains("respond_to_user") + { + "control" + } else { + "action" + }; + Ok(PlanProviderToolWireDigest { + name: tool.name.clone(), + kind: kind.to_string(), + wire_bytes, + wire_sha256, + }) + }) + .collect::, String>>()?; + let structured_prefix = format!("{PLAN_PROVIDER_STRUCTURED_INJECTIONS_MESSAGE_HEADER}\n"); + let mut structured_messages = request.messages.iter().filter_map(|message| { + message + .content + .strip_prefix(&structured_prefix) + .map(|json| (message.role, json)) + }); + let (structured_role, structured_json) = structured_messages + .next() + .ok_or_else(|| "plan Provider request 缺少 structured injections message".to_string())?; + if structured_role != platform_llm::LlmMessageRole::User { + return Err( + "plan Provider structured injections 必须是 dedicated user message".to_string(), + ); + } + if structured_messages.next().is_some() { + return Err("plan Provider request 包含重复 structured injections message".to_string()); + } + let structured_injection_wire_bytes = structured_json.as_bytes(); + let expected_message = + render_plan_provider_structured_injections_message(structured_injection_wire_bytes)?; + if expected_message != format!("{structured_prefix}{structured_json}") { + return Err("plan Provider structured injections message 不是 canonical wire".to_string()); + } + let structured_bytes = u32::try_from(structured_injection_wire_bytes.len()) + .map_err(|_| "plan Provider structured injections 超出 u32 字节上限".to_string())?; + let structured_sha256 = format!("{:x}", Sha256::digest(structured_injection_wire_bytes)); + let configured_api_kind = parse_game_creator_llm_api_kind(&llm.api_kind).ok(); + let anthropic_strict_tool_support = if api_kind == platform_llm::LlmApiKind::Anthropic { + configured_api_kind == Some(platform_llm::LlmApiKind::Anthropic) + && effective_model == llm.model.trim() + && game_creator_supports_anthropic_strict_tools( + platform_llm::LlmApiKind::Anthropic, + llm.base_url.as_str(), + llm.model.as_str(), + ) + } else { + false + }; + let context = PlanProviderRequestContextValue { + effective_model, + api_kind: plan_provider_api_kind_name(api_kind).to_string(), + stream: llm.stream, + official_fallback: (api_kind != platform_llm::LlmApiKind::Anthropic).then_some(false), + anthropic_strict_tool_support: (api_kind == platform_llm::LlmApiKind::Anthropic) + .then_some(anthropic_strict_tool_support), + open_ai_chat_token_budget_field: (api_kind == platform_llm::LlmApiKind::OpenAiChat) + .then_some("legacy_max_tokens".to_string()), + max_output_tokens: request.max_output_tokens, + response_reasoning_effort: request + .response_reasoning_effort + .map(plan_provider_reasoning_name) + .map(str::to_string), + response_text_verbosity: request + .response_text_verbosity + .map(plan_provider_verbosity_name) + .map(str::to_string), + tool_choice: request + .tool_choice + .map(plan_provider_tool_choice_name) + .map(str::to_string), + composition: "runtime".to_string(), + source_kind: "runtime".to_string(), + web_search_enabled: request.enable_web_search, + messages, + native_tools, + mcp_tools: Vec::new(), + structured_injections: PlanProviderStructuredInjectionDigest { + wire_bytes: structured_bytes, + wire_sha256: structured_sha256, + }, + }; + typed_serde_fingerprint("genarrative.plan.provider-request-context.v1", &context) + .map_err(|error| error.to_string()) +} + pub(in crate::agent) fn game_creator_agent_runtime_provider_config_fingerprint( llm: &GameCreatorLlmConfig, ) -> Result { @@ -442,6 +661,11 @@ pub(in crate::agent) fn game_creator_agent_runtime_provider_retry_identity_for_m } else { None }; + let request_fingerprint = if snapshot.planning_session_binding.is_some() { + game_creator_agent_runtime_plan_provider_request_context_fingerprint(llm, request)? + } else { + game_creator_agent_runtime_llm_request_fingerprint(request)? + }; Ok(AgentRuntimeProviderRetryIdentity { project_id: snapshot.project_id.clone(), agent_id: snapshot.agent_id.clone(), @@ -455,7 +679,7 @@ pub(in crate::agent) fn game_creator_agent_runtime_provider_retry_identity_for_m applied_steer_cursor: snapshot.applied_steer_cursor, request_kind: snapshot.request_kind.clone(), base_request_slot: snapshot.request_slot.clone(), - request_fingerprint: game_creator_agent_runtime_llm_request_fingerprint(request)?, + request_fingerprint, provider_config_fingerprint: game_creator_agent_runtime_provider_config_fingerprint_for_mode( &agent_mode, @@ -464,6 +688,7 @@ pub(in crate::agent) fn game_creator_agent_runtime_provider_retry_identity_for_m )?, web_search_enabled: snapshot.web_search_enabled, allow_idle_context_compaction: snapshot.allow_idle_context_compaction, + planning_session_binding: snapshot.planning_session_binding.clone(), }) } @@ -485,6 +710,7 @@ pub(in crate::agent) fn game_creator_agent_runtime_provider_snapshot_from_retry_ request_slot: identity.base_request_slot.clone(), web_search_enabled: identity.web_search_enabled, allow_idle_context_compaction: identity.allow_idle_context_compaction, + planning_session_binding: identity.planning_session_binding.clone(), } } @@ -535,6 +761,9 @@ pub(in crate::agent) fn game_creator_agent_runtime_provider_retry_drift_fields( if persisted.allow_idle_context_compaction != rebuilt.allow_idle_context_compaction { fields.push("allowIdleContextCompaction"); } + if persisted.planning_session_binding != rebuilt.planning_session_binding { + fields.push("planningSessionBinding"); + } fields } @@ -551,6 +780,7 @@ pub(in crate::agent) fn game_creator_agent_runtime_provider_handoff_reconciliati let request_id = resolve_game_creator_agent_runtime_provider_request_attempt_at_locked( root, &base_request_id, + snapshot.planning_session_binding.is_some(), ) .map(|value| value.0) .unwrap_or(base_request_id); @@ -1306,6 +1536,7 @@ where resolve_game_creator_agent_runtime_provider_request_attempt_at_locked( root, &base_request_id, + attempt_snapshot.planning_session_binding.is_some(), ) .map(|value| value.0) .unwrap_or(base_request_id); @@ -1584,6 +1815,19 @@ pub(in crate::agent) async fn request_game_creator_agent_runtime_llm_with_transi pub(crate) fn game_creator_agent_runtime_provider_request_id( snapshot: &AgentRuntimeProviderRequestSnapshot, ) -> String { + if let Some(binding) = snapshot.planning_session_binding.as_ref() { + if snapshot.request_slot == binding.request_slot { + return binding.provider_request_id.clone(); + } + if let Some(attempt) = snapshot + .request_slot + .strip_prefix(binding.request_slot.as_str()) + .and_then(|suffix| suffix.strip_prefix("-transient-")) + .and_then(|value| value.parse::().ok()) + { + return plan_provider_request_attempt_id(&binding.provider_request_id, attempt); + } + } format!( "provider-request-{:x}", Sha256::digest( @@ -1619,27 +1863,58 @@ pub(in crate::agent) fn game_creator_agent_runtime_provider_request_attempt_id( ) } +pub(in crate::agent) fn game_creator_agent_runtime_provider_request_slot_for_id( + snapshot: &AgentRuntimeProviderRequestSnapshot, + request_id: &str, +) -> Option { + let base_request_id = game_creator_agent_runtime_provider_request_id(snapshot); + for attempt in 0..=64_usize { + let candidate = if snapshot.planning_session_binding.is_some() { + plan_provider_request_attempt_id(&base_request_id, attempt) + } else { + game_creator_agent_runtime_provider_request_attempt_id(&base_request_id, attempt) + }; + if candidate == request_id { + return Some(if attempt == 0 { + snapshot.request_slot.clone() + } else { + format!("{}-transient-{attempt}", snapshot.request_slot) + }); + } + } + None +} + pub(in crate::agent) fn resolve_game_creator_agent_runtime_provider_request_attempt_at_locked( root: &Path, base_request_id: &str, + planning: bool, ) -> Result<(String, bool), String> { const MAX_INTERRUPTED_ATTEMPTS: usize = 64; for attempt in 0..=MAX_INTERRUPTED_ATTEMPTS { - let request_id = - game_creator_agent_runtime_provider_request_attempt_id(base_request_id, attempt); - let transitions = read_agent_db_lifecycle_transitions_at( - root, - AGENT_RUNTIME_PROVIDER_REQUEST_LIFECYCLE_RECORD_TYPE, - "requestId", - &request_id, - )?; - if transitions.is_empty() { - return Ok((request_id, false)); + let candidates = if planning { + vec![plan_provider_request_attempt_id(base_request_id, attempt)] + } else { + vec![game_creator_agent_runtime_provider_request_attempt_id( + base_request_id, + attempt, + )] + }; + for request_id in candidates { + let transitions = read_agent_db_lifecycle_transitions_at( + root, + AGENT_RUNTIME_PROVIDER_REQUEST_LIFECYCLE_RECORD_TYPE, + "requestId", + &request_id, + )?; + if transitions.is_empty() { + return Ok((request_id, false)); + } + if transitions == ["started", "interrupted"] && attempt < MAX_INTERRUPTED_ATTEMPTS { + continue; + } + return Ok((request_id, true)); } - if transitions == ["started", "interrupted"] && attempt < MAX_INTERRUPTED_ATTEMPTS { - continue; - } - return Ok((request_id, true)); } unreachable!("Provider interrupted attempt loop always returns") } @@ -1664,6 +1939,28 @@ pub(crate) fn append_game_creator_agent_runtime_provider_lifecycle_for_test( mod tests { use super::*; + fn planning_structured_injections_message(accumulated_agent_millis: u64) -> LlmMessage { + let value = PlanProviderStructuredInjectionsV1 { + schema_version: PLAN_PROVIDER_STRUCTURED_INJECTIONS_SCHEMA_VERSION.to_string(), + clarification_round: 0, + accumulated_agent_millis, + session: PlanProviderFacingSessionV1 { + phase: "collecting".to_string(), + decisions_summary: Vec::new(), + prototype_validation_items: Vec::new(), + latest_submitted_ref: None, + last_decision_ref: None, + }, + platform_facts: fixed_plan_platform_facts(), + approval_observation: None, + }; + let bytes = serde_json::to_vec(&value).expect("serialize structured injections fixture"); + LlmMessage::user( + render_plan_provider_structured_injections_message(&bytes) + .expect("render structured injections fixture"), + ) + } + #[test] fn provider_config_fingerprint_separates_all_agent_modes() { let llm = GameCreatorLlmConfig::default(); @@ -1708,6 +2005,199 @@ mod tests { ); } + #[test] + fn planning_request_context_fingerprint_tracks_provider_wire_semantics() { + let llm = GameCreatorLlmConfig::default(); + let request = platform_llm::LlmRunRequest::new(vec![ + platform_llm::LlmMessage::system("策划系统提示"), + planning_structured_injections_message(10), + platform_llm::LlmMessage::user("策划请求"), + ]) + .with_model("planning-model") + .with_api_kind(platform_llm::LlmApiKind::OpenAiResponses) + .with_max_output_tokens(4_000) + .with_response_reasoning_effort(platform_llm::LlmResponseReasoningEffort::High) + .with_response_text_verbosity(platform_llm::LlmResponseTextVerbosity::Low) + .with_function_tools(vec![platform_llm::LlmFunctionTool::new( + "runtime_tool_plan_submit_gdd", + "提交 GDD", + serde_json::json!({"type": "object", "additionalProperties": false}), + ) + .with_strict(true)]) + .with_tool_choice(platform_llm::LlmToolChoice::Required) + .with_web_search(false); + let fingerprint = + game_creator_agent_runtime_plan_provider_request_context_fingerprint(&llm, &request) + .expect("planning request context fingerprint"); + assert!(fingerprint.starts_with("sha256-serde-json-v2:")); + + let mut wrong_structured_role = request.clone(); + wrong_structured_role.messages[1] = + platform_llm::LlmMessage::system(wrong_structured_role.messages[1].content.clone()); + let wrong_role_error = + game_creator_agent_runtime_plan_provider_request_context_fingerprint( + &llm, + &wrong_structured_role, + ) + .expect_err("structured injections under a non-user role must fail closed"); + assert!(wrong_role_error.contains("dedicated user message")); + + let mut assistant_structured_role = request.clone(); + assistant_structured_role.messages[1] = platform_llm::LlmMessage::assistant( + assistant_structured_role.messages[1].content.clone(), + ); + let assistant_role_error = + game_creator_agent_runtime_plan_provider_request_context_fingerprint( + &llm, + &assistant_structured_role, + ) + .expect_err("structured injections under the assistant role must fail closed"); + assert!(assistant_role_error.contains("dedicated user message")); + + let mut duplicate_structured_message = request.clone(); + duplicate_structured_message + .messages + .insert(2, duplicate_structured_message.messages[1].clone()); + let duplicate_error = game_creator_agent_runtime_plan_provider_request_context_fingerprint( + &llm, + &duplicate_structured_message, + ) + .expect_err("duplicate structured injections must fail closed"); + assert!(duplicate_error.contains("重复 structured injections")); + + let mut missing_structured_message = request.clone(); + missing_structured_message.messages.remove(1); + let missing_error = game_creator_agent_runtime_plan_provider_request_context_fingerprint( + &llm, + &missing_structured_message, + ) + .expect_err("missing structured injections must fail closed"); + assert!(missing_error.contains("缺少 structured injections")); + + let mut changed_message = request.clone(); + changed_message.messages[2] = platform_llm::LlmMessage::user("策划请求已变化"); + let mut changed_injection = request.clone(); + changed_injection.messages[1] = planning_structured_injections_message(11); + let mut changed_tool = request.clone(); + changed_tool.function_tools[0].description = "提交另一份 GDD".to_string(); + let changed_tokens = request.clone().with_max_output_tokens(4_001); + let changed_api = request + .clone() + .with_api_kind(platform_llm::LlmApiKind::OpenAiChat); + for changed in [ + changed_message, + changed_injection, + changed_tool, + changed_tokens, + changed_api, + ] { + assert_ne!( + game_creator_agent_runtime_plan_provider_request_context_fingerprint( + &llm, &changed, + ) + .expect("changed planning request context fingerprint"), + fingerprint + ); + } + + let mut transport_only = llm.clone(); + transport_only.request_timeout_ms = transport_only.request_timeout_ms.saturating_add(1); + transport_only.retry_backoff_ms = transport_only.retry_backoff_ms.saturating_add(1); + assert_eq!( + game_creator_agent_runtime_plan_provider_request_context_fingerprint( + &transport_only, + &request, + ) + .expect("transport-only planning request context fingerprint"), + fingerprint, + "request timeout/backoff are explicitly outside Provider request semantics" + ); + } + + #[test] + fn planning_structured_injections_reject_wire_over_64_kib() { + let value = PlanProviderStructuredInjectionsV1 { + schema_version: PLAN_PROVIDER_STRUCTURED_INJECTIONS_SCHEMA_VERSION.to_string(), + clarification_round: 0, + accumulated_agent_millis: 0, + session: PlanProviderFacingSessionV1 { + phase: "collecting".to_string(), + decisions_summary: Vec::new(), + prototype_validation_items: Vec::new(), + latest_submitted_ref: None, + last_decision_ref: None, + }, + platform_facts: fixed_plan_platform_facts(), + approval_observation: Some(PlanProviderApprovalObservationV1 { + tool: PLAN_SUBMIT_GDD_TOOL.to_string(), + status: "ok".to_string(), + summary: "x".repeat(64 * 1024), + detail: None, + }), + }; + let bytes = serde_json::to_vec(&value).expect("serialize oversized planning injection"); + assert!(bytes.len() > 64 * 1024); + let error = render_plan_provider_structured_injections_message(&bytes) + .expect_err("oversized planning injection must fail closed"); + assert!(error.contains("wire bytes 非法")); + } + + #[test] + fn planning_structured_injections_accept_exact_64_kib_and_reject_one_byte_over() { + const MAX_BYTES: usize = 64 * 1024; + let mut value = PlanProviderStructuredInjectionsV1 { + schema_version: PLAN_PROVIDER_STRUCTURED_INJECTIONS_SCHEMA_VERSION.to_string(), + clarification_round: 0, + accumulated_agent_millis: 0, + session: PlanProviderFacingSessionV1 { + phase: "collecting".to_string(), + decisions_summary: Vec::new(), + prototype_validation_items: Vec::new(), + latest_submitted_ref: None, + last_decision_ref: None, + }, + platform_facts: fixed_plan_platform_facts(), + approval_observation: Some(PlanProviderApprovalObservationV1 { + tool: PLAN_SUBMIT_GDD_TOOL.to_string(), + status: "ok".to_string(), + summary: String::new(), + detail: None, + }), + }; + let empty_bytes = serde_json::to_vec(&value).expect("serialize empty structured input"); + assert!(empty_bytes.len() < MAX_BYTES); + value + .approval_observation + .as_mut() + .expect("approval observation") + .summary = "x".repeat(MAX_BYTES - empty_bytes.len()); + let exact_bytes = serde_json::to_vec(&value).expect("serialize exact structured input"); + assert_eq!(exact_bytes.len(), MAX_BYTES); + let exact_message = render_plan_provider_structured_injections_message(&exact_bytes) + .expect("exactly 64 KiB structured injection must be accepted"); + let prefix = format!("{PLAN_PROVIDER_STRUCTURED_INJECTIONS_MESSAGE_HEADER}\n"); + assert!(exact_message.starts_with(&prefix)); + assert_eq!( + exact_message + .strip_prefix(&prefix) + .expect("structured injection prefix") + .as_bytes(), + exact_bytes.as_slice() + ); + + value + .approval_observation + .as_mut() + .expect("approval observation") + .summary + .push('x'); + let over_bytes = serde_json::to_vec(&value).expect("serialize oversized structured input"); + assert_eq!(over_bytes.len(), MAX_BYTES + 1); + let error = render_plan_provider_structured_injections_message(&over_bytes) + .expect_err("one byte over 64 KiB must fail closed"); + assert!(error.contains("wire bytes 非法")); + } + #[test] fn codex_app_server_unknown_terminal_enters_reconciliation_without_retry() { let error = platform_llm::LlmError::Transport(format!( diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/real_e2e_checkpoint.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/real_e2e_checkpoint.rs index caa6910ae..b1c87f8a9 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/real_e2e_checkpoint.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/real_e2e_checkpoint.rs @@ -1032,6 +1032,7 @@ pub(crate) async fn await_game_creator_agent_runtime_tool_plan_checkpoint_for_te ), web_search_enabled: snapshot.web_search_enabled, allow_idle_context_compaction: snapshot.allow_idle_context_compaction, + planning_session_binding: snapshot.planning_session_binding.clone(), }; await_game_creator_agent_runtime_provider_request_with_snapshot_and_control_recheck( root, @@ -1133,6 +1134,7 @@ where match resolve_game_creator_agent_runtime_provider_request_attempt_at_locked( root, &base_request_id, + snapshot.planning_session_binding.is_some(), ) { Ok(resolution) => resolution, Err(error) => { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/response_stream.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/response_stream.rs index 7352ad379..1b371e3ba 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/response_stream.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/response_stream.rs @@ -275,6 +275,7 @@ pub(in crate::agent) fn mark_game_creator_agent_runtime_response_stream_committe request_slot: request_slot.clone(), web_search_enabled: false, allow_idle_context_compaction: false, + planning_session_binding: None, }; write_game_creator_agent_runtime_response_stream_ready_at( root, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/run_configuration.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/run_configuration.rs index ced05c998..d1ea7b777 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/run_configuration.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/run_configuration.rs @@ -154,7 +154,8 @@ pub(in crate::agent) fn validate_project_supervisor_plan_root_binding_at( || binding.profile != AGENT_RUNTIME_RUN_PROFILE_STANDARD { return Err( - "project-planning 父 Run 必须是 project-supervisor-plan standard 顶层根 Run".to_string(), + "project-planning 父 Run 必须是 project-supervisor-plan standard 顶层根 Run" + .to_string(), ); } Ok(binding) @@ -188,17 +189,16 @@ pub(in crate::agent) fn validate_project_planning_child_binding_at( } let parent_agent_id = binding.parent_agent_id.as_deref().unwrap_or_default(); let parent_run_id = binding.parent_run_id.as_deref().unwrap_or_default(); - let parent = validate_project_supervisor_plan_root_binding_at( - root, - parent_agent_id, - parent_run_id, - )?; + let parent = + validate_project_supervisor_plan_root_binding_at(root, parent_agent_id, parent_run_id)?; if binding.root_agent_id != parent.root_agent_id || binding.root_run_id != parent.root_run_id || binding.parent_binding_fingerprint.as_deref() != Some(parent.binding_fingerprint.as_str()) { - return Err("project-planning child 与 project-supervisor-plan 根 Run 身份不一致".to_string()); + return Err( + "project-planning child 与 project-supervisor-plan 根 Run 身份不一致".to_string(), + ); } Ok(binding) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_state.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_state.rs index 1193a2e3a..4c68c1d0b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_state.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_state.rs @@ -1672,7 +1672,10 @@ pub(crate) fn default_game_creator_agent_runtime_allowed_tools_for_agent( agent_id: &str, ) -> Vec { if agent_id.trim() == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID { - return vec!["file.read".to_string(), "file.list".to_string()]; + return AGENT_RUNTIME_PROJECT_PLANNING_ACTION_TOOLS + .iter() + .map(|tool| (*tool).to_string()) + .collect(); } default_game_creator_agent_runtime_allowed_tools() } @@ -1696,7 +1699,11 @@ mod planning_state_tests { ); assert_eq!( state.allowed_tools, - vec!["file.read".to_string(), "file.list".to_string()] + vec![ + "file.read".to_string(), + "file.list".to_string(), + PLAN_SUBMIT_GDD_TOOL.to_string() + ] ); assert_eq!(state.tool_policy.allowed_tools, state.allowed_tools); assert!(state @@ -1709,6 +1716,11 @@ mod planning_state_tests { .denied_tools .iter() .any(|tool| tool == "file.write")); + assert!(!state + .tool_policy + .denied_tools + .iter() + .any(|tool| tool == PLAN_SUBMIT_GDD_TOOL)); } } @@ -1858,20 +1870,14 @@ pub(super) fn normalize_game_creator_agent_runtime_state( .filter(|tool| exact.iter().any(|allowed| allowed == *tool)) .cloned() .collect::>(); - state - .tool_policy - .auto_tools - .retain(|tool| { - exact.iter().any(|allowed| allowed == tool) - && !exact_denied.iter().any(|denied| denied == tool) - }); - state - .tool_policy - .confirm_tools - .retain(|tool| { - exact.iter().any(|allowed| allowed == tool) - && !exact_denied.iter().any(|denied| denied == tool) - }); + state.tool_policy.auto_tools.retain(|tool| { + exact.iter().any(|allowed| allowed == tool) + && !exact_denied.iter().any(|denied| denied == tool) + }); + state.tool_policy.confirm_tools.retain(|tool| { + exact.iter().any(|allowed| allowed == tool) + && !exact_denied.iter().any(|denied| denied == tool) + }); state.tool_policy.denied_tools = exact_denied; state.tool_policy.denied_tools.extend( agent_runtime_executable_tools() diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delegation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delegation.rs index 45c293b83..7897149e6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delegation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delegation.rs @@ -463,11 +463,9 @@ pub(crate) fn observe_agent_runtime_agent_delegate( // combinations cannot create a child that later looks like a valid plan // continuation. if target_agent_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID { - if let Err(error) = validate_project_supervisor_plan_root_binding_at( - root, - agent_id, - parent_run_id, - ) { + if let Err(error) = + validate_project_supervisor_plan_root_binding_at(root, agent_id, parent_run_id) + { return AgentRuntimeToolObservation { tool: "agent.delegate".to_string(), status: "failed".to_string(), @@ -500,7 +498,8 @@ pub(crate) fn observe_agent_runtime_agent_delegate( { let plan_root_binding_validated = validate_project_supervisor_plan_root_binding_at(root, agent_id, parent_run_id).is_ok(); - if !plan_root_binding_validated || target_agent_id != GAME_CREATOR_PROJECT_PLANNING_AGENT_ID { + if !plan_root_binding_validated || target_agent_id != GAME_CREATOR_PROJECT_PLANNING_AGENT_ID + { return AgentRuntimeToolObservation { tool: "agent.delegate".to_string(), status: "failed".to_string(), diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/policy.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/policy.rs index 9db10241c..790c76ece 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/policy.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/policy.rs @@ -51,6 +51,14 @@ pub(crate) fn game_creator_agent_runtime_tool_policy_rule_for_run( stored_binding_fingerprint: Option<&str>, command_id: &str, ) -> Option { + if command_id == PLAN_SUBMIT_GDD_TOOL + && agent_id.trim() != GAME_CREATOR_PROJECT_PLANNING_AGENT_ID + { + return Some(AgentRuntimeToolPolicyBlock::Denied(format!( + "plan.submit_gdd 仅允许 project-planning Agent:{}", + agent_id.trim() + ))); + } if agent_id.trim() == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID { if let Err(error) = agent_runtime_run_profile_identity_at( root, @@ -68,11 +76,26 @@ pub(crate) fn game_creator_agent_runtime_tool_policy_rule_for_run( // narrower planning ceiling. Evaluate it before applying the exact // allowlist so a denied read cannot be turned into an auto action and // a confirmed read remains pending confirmation. - let permission_block = game_creator_agent_runtime_tool_policy_rule(root, agent_id, command_id); - if matches!(permission_block, Some(AgentRuntimeToolPolicyBlock::Denied(_))) { + let permission_block = + game_creator_agent_runtime_tool_policy_rule(root, agent_id, command_id); + if matches!( + permission_block, + Some(AgentRuntimeToolPolicyBlock::Denied(_)) + ) { return permission_block; } - if !matches!(command_id, "file.read" | "file.list") { + if command_id == PLAN_SUBMIT_GDD_TOOL + && matches!( + permission_block, + Some(AgentRuntimeToolPolicyBlock::RequiresConfirmation(_)) + ) + { + return Some(AgentRuntimeToolPolicyBlock::Denied( + "plan.submit_gdd 是 Runtime-owned create-only 提交,不支持转成通用确认 pending" + .to_string(), + )); + } + if !matches!(command_id, "file.read" | "file.list" | PLAN_SUBMIT_GDD_TOOL) { return Some(AgentRuntimeToolPolicyBlock::Denied(format!( "project-planning exact 工具面拒绝:{command_id}" ))); @@ -216,6 +239,14 @@ pub(in crate::agent) fn game_creator_agent_runtime_tool_policy_rule( agent_id: &str, command_id: &str, ) -> Option { + if command_id == PLAN_SUBMIT_GDD_TOOL + && agent_id.trim() != GAME_CREATOR_PROJECT_PLANNING_AGENT_ID + { + return Some(AgentRuntimeToolPolicyBlock::Denied(format!( + "plan.submit_gdd 仅允许 project-planning Agent:{}", + agent_id.trim() + ))); + } let view = match read_project_permission_policy_at(root) { Ok(view) => view, Err(error) => return Some(AgentRuntimeToolPolicyBlock::Denied(error)), 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 43b7c1855..d8f7c0f03 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,15 +13,19 @@ 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, }; -use crate::GAME_CREATOR_PROJECT_PLANNING_AGENT_ID; use crate::mcp::{ validate_game_creator_mcp_tool_arguments, GameCreatorMcpCatalog, GameCreatorMcpCatalogTool, GAME_CREATOR_MCP_CALL_TOOL, }; +use crate::GAME_CREATOR_PROJECT_PLANNING_AGENT_ID; +#[cfg(test)] +use crate::GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID; pub(crate) const AGENT_RUNTIME_UPDATE_PLAN_FUNCTION_NAME: &str = "update_agent_plan"; pub(crate) const AGENT_RUNTIME_RESPOND_FUNCTION_NAME: &str = "respond_to_user"; +pub(crate) const PLAN_SUBMIT_GDD_FUNCTION_NAME: &str = "runtime_tool_plan_submit_gdd"; const AGENT_RUNTIME_NATIVE_TOOL_PREFIX: &str = "runtime_tool_"; const AGENT_RUNTIME_NATIVE_MCP_PREFIX: &str = "mcp_tool_"; @@ -225,6 +229,9 @@ struct NativeResponseArguments { } pub(crate) fn native_runtime_function_name(tool: &str) -> Option { + if tool.trim() == PLAN_SUBMIT_GDD_TOOL { + return Some(PLAN_SUBMIT_GDD_FUNCTION_NAME.to_string()); + } agent_runtime_native_capability_registry() .ok()? .get(tool) @@ -288,18 +295,16 @@ pub(crate) fn native_mcp_function_name(server_id: &str, tool_name: &str) -> Stri pub(crate) fn build_agent_runtime_native_function_tools( mcp_catalog: &GameCreatorMcpCatalog, ) -> Result, String> { - build_agent_runtime_native_function_tools_for_agent( - "__all_agents__", - mcp_catalog, - ) + build_agent_runtime_native_function_tools_for_agent("__all_agents__", mcp_catalog) } /// Build the function catalog for a specific Agent identity. /// /// `project-planning` is deliberately handled as an exact allowlist. The -/// `plan.submit_gdd` capability is not registered yet (it belongs to M1B-2), -/// so it must not be advertised here or added to the global capability -/// registry prematurely. Protocol controls remain available to every Agent. +/// planning-only `plan.submit_gdd` capability is appended below only for that +/// identity; it is intentionally absent from the global capability registry +/// and from every other Agent's function catalog. Protocol controls remain +/// available to every Agent. pub(crate) fn build_agent_runtime_native_function_tools_for_agent( agent_id: &str, mcp_catalog: &GameCreatorMcpCatalog, @@ -312,9 +317,7 @@ pub(crate) fn build_agent_runtime_native_function_tools_for_agent( let planning_agent = agent_id.trim() == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID; for definition in agent_runtime_native_capability_registry()?.iter() { - if planning_agent - && !matches!(definition.dispatch().as_str(), "file.read" | "file.list") - { + if planning_agent && !matches!(definition.dispatch().as_str(), "file.read" | "file.list") { continue; } let name = definition.function_name().to_string(); @@ -334,6 +337,12 @@ pub(crate) fn build_agent_runtime_native_function_tools_for_agent( // Planning Agents never receive an MCP catalog, even if a caller passes // one accidentally. This keeps the ad surface fail-closed by identity. if planning_agent { + if !names.insert(PLAN_SUBMIT_GDD_FUNCTION_NAME.to_string()) { + return Err(format!( + "Runtime 原生函数名重复:{PLAN_SUBMIT_GDD_FUNCTION_NAME}" + )); + } + functions.push(plan_submit_gdd_function_tool()); return Ok(functions); } for tool in &mcp_catalog.tools { @@ -350,19 +359,25 @@ pub(crate) fn build_agent_runtime_native_function_tools_for_agent( Ok(functions) } -pub(crate) fn agent_runtime_native_tool_allowed_for_agent( - agent_id: &str, - tool: &str, -) -> bool { +pub(crate) fn agent_runtime_native_tool_allowed_for_agent(agent_id: &str, tool: &str) -> bool { if agent_id.trim() == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID { // update_agent_plan/respond_to_user are protocol controls and are // validated outside the action capability registry. return matches!( tool.trim(), - "file.read" | "file.list" | AGENT_RUNTIME_UPDATE_PLAN_FUNCTION_NAME + "file.read" + | "file.list" + | PLAN_SUBMIT_GDD_TOOL + | AGENT_RUNTIME_UPDATE_PLAN_FUNCTION_NAME | AGENT_RUNTIME_RESPOND_FUNCTION_NAME ); } + // This capability is planning-only. Do not let the global registry + // lookup (or a stale ordinary Agent snapshot) turn it into an executable + // action for Supervisor or a specialist. + if tool.trim() == PLAN_SUBMIT_GDD_TOOL { + return false; + } if tool.trim() == GAME_CREATOR_MCP_CALL_TOOL { // MCP calls are bound and checked against the current catalog by the // MCP policy path; they are not part of the native capability registry. @@ -401,11 +416,7 @@ pub(crate) fn parse_agent_runtime_native_tool_calls( calls: &[LlmToolCall], mcp_catalog: &GameCreatorMcpCatalog, ) -> Result { - parse_agent_runtime_native_tool_calls_for_agent( - "__all_agents__", - calls, - mcp_catalog, - ) + parse_agent_runtime_native_tool_calls_for_agent("__all_agents__", calls, mcp_catalog) } pub(crate) fn parse_agent_runtime_native_tool_calls_for_agent( @@ -423,6 +434,7 @@ pub(crate) fn parse_agent_runtime_native_tool_calls_for_agent( let mut plan_update = None; let mut response = None; let mut actions = Vec::new(); + let mut submit_gdd_action_count = 0usize; let mut call_ids = Vec::with_capacity(calls.len()); let mut function_names = Vec::with_capacity(calls.len()); @@ -500,6 +512,9 @@ pub(crate) fn parse_agent_runtime_native_tool_calls_for_agent( input = normalize_native_project_patchset_input(input)?; } let action = if let Some(tool) = runtime_tool { + if tool == PLAN_SUBMIT_GDD_TOOL { + submit_gdd_action_count = submit_gdd_action_count.saturating_add(1); + } AgentRuntimeToolAction { tool, reason: Some(arguments.reason), @@ -544,6 +559,14 @@ pub(crate) fn parse_agent_runtime_native_tool_calls_for_agent( "Agent 原生工具协议错误:最终回复不能与动作工具同时提交", )); } + if submit_gdd_action_count > 0 + && (submit_gdd_action_count != 1 || actions.len() != 1 || response.is_some()) + { + return Err(protocol_error( + AgentRuntimeToolPlanProtocolErrorKind::BatchConstraint, + "Agent 原生工具协议错误:plan.submit_gdd 必须是唯一 action,且不能与 respond_to_user 同响应(可与 update_agent_plan 同响应)", + )); + } if response .as_deref() .is_some_and(|value| value.trim().is_empty()) @@ -912,6 +935,9 @@ fn validate_native_delegate_string_list( } fn runtime_tool_for_native_function(name: &str) -> Option { + if name == PLAN_SUBMIT_GDD_FUNCTION_NAME { + return Some(PLAN_SUBMIT_GDD_TOOL.to_string()); + } agent_runtime_native_capability_registry() .ok()? .get_by_function_name(name) @@ -961,6 +987,15 @@ fn response_function_tool() -> LlmFunctionTool { .with_strict(true) } +fn plan_submit_gdd_function_tool() -> LlmFunctionTool { + LlmFunctionTool::new( + PLAN_SUBMIT_GDD_FUNCTION_NAME, + "提交当前立项策划 Session 的 Fast GDD。只能提交设计字段;Runtime 会注入项目、版本、时间、平台事实和指纹,并以 create-only durable GDD 作为提交点。该动作必须是本轮唯一 action,可与 update_agent_plan 同响应,但不能与 respond_to_user 或其它动作混合。", + action_function_parameters(plan_submit_gdd_input_schema()), + ) + .with_strict(true) +} + fn plan_update_schema() -> Value { json!({ "type": "object", @@ -989,6 +1024,196 @@ fn plan_update_schema() -> Value { }) } +fn bounded_plan_string_schema(max_length: usize) -> Value { + json!({ + "type": "string", + "minLength": 1, + "maxLength": max_length + }) +} + +fn nullable_plan_string_schema(max_length: usize) -> Value { + json!({ + "type": ["string", "null"], + "minLength": 1, + "maxLength": max_length + }) +} + +fn plan_string_array_schema(min_items: usize, max_items: usize, item_max_length: usize) -> Value { + json!({ + "type": "array", + "minItems": min_items, + "maxItems": max_items, + "items": bounded_plan_string_schema(item_max_length) + }) +} + +/// Strict provider-facing shape for `plan-submit-gdd-input.v1`. +/// +/// Runtime-injected identity, platform facts, version and fingerprint fields +/// deliberately do not appear here. The durable handler performs the +/// semantic/session equality checks after parsing this wire shape. +fn plan_submit_gdd_input_schema() -> Value { + let decision_state = json!({ + "type": "string", + "enum": ["confirmed", "default_pending", "prototype_pending"] + }); + let answer_source = json!({ + "type": "string", + "enum": ["user_freeform", "user_option", "default"] + }); + let pillar = json!({ + "type": "object", + "required": ["name", "playerFeel", "mechanism", "decisionState"], + "additionalProperties": false, + "properties": { + "name": bounded_plan_string_schema(40), + "playerFeel": bounded_plan_string_schema(240), + "mechanism": bounded_plan_string_schema(240), + "decisionState": decision_state.clone() + } + }); + let mvp_system = json!({ + "type": "object", + "required": ["system", "minimalFunction", "whyRequired", "verifyMethod", "decisionState"], + "additionalProperties": false, + "properties": { + "system": bounded_plan_string_schema(40), + "minimalFunction": bounded_plan_string_schema(240), + "whyRequired": bounded_plan_string_schema(240), + "verifyMethod": bounded_plan_string_schema(240), + "decisionState": decision_state.clone() + } + }); + let decisions = json!({ + "type": "object", + "required": ["id", "topic", "state", "answerSource", "round", "answerSummary"], + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "minLength": 1, + "maxLength": 32, + "pattern": "^[a-z][a-z0-9-]{0,31}$" + }, + "topic": bounded_plan_string_schema(80), + "state": decision_state.clone(), + "answerSource": answer_source.clone(), + "round": { "type": "integer", "minimum": 0, "maximum": 3 }, + "answerSummary": bounded_plan_string_schema(400) + } + }); + let prototype_item = json!({ + "type": "object", + "required": ["id", "question", "microPrototype", "observation", "passCriterion"], + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "minLength": 1, + "maxLength": 32, + "pattern": "^[a-z][a-z0-9-]{0,31}$" + }, + "question": bounded_plan_string_schema(400), + "microPrototype": bounded_plan_string_schema(400), + "observation": bounded_plan_string_schema(400), + "passCriterion": bounded_plan_string_schema(400) + } + }); + json!({ + "type": "object", + "required": ["schemaVersion", "game", "decisions", "prototypeValidationItems"], + "additionalProperties": false, + "properties": { + "schemaVersion": { + "type": "string", + "enum": [PLAN_SUBMIT_GDD_INPUT_SCHEMA_VERSION] + }, + "game": { + "type": "object", + "required": ["title", "genre", "artStyle", "oneLiner", "pillars", "coreLoop", "targetUsers", "mvpSystems", "outOfScope", "creatorTips"], + "additionalProperties": false, + "properties": { + "title": bounded_plan_string_schema(80), + "genre": { + "type": "object", + "required": ["primary", "fusion"], + "additionalProperties": false, + "properties": { + "primary": bounded_plan_string_schema(40), + "fusion": nullable_plan_string_schema(40) + } + }, + "artStyle": { + "type": "object", + "required": ["visualType", "keywords", "moodAndColor", "mvpArtBoundary"], + "additionalProperties": false, + "properties": { + "visualType": bounded_plan_string_schema(80), + "keywords": plan_string_array_schema(3, 5, 32), + "moodAndColor": bounded_plan_string_schema(400), + "mvpArtBoundary": bounded_plan_string_schema(400) + } + }, + "oneLiner": { + "type": "string", + "minLength": 45, + "maxLength": 90 + }, + "pillars": { + "type": "array", + "minItems": 2, + "maxItems": 4, + "items": pillar + }, + "coreLoop": plan_string_array_schema(4, 8, 120), + "targetUsers": { + "type": "object", + "required": ["coreUsers", "preferences", "sessionLength", "referenceGames"], + "additionalProperties": false, + "properties": { + "coreUsers": bounded_plan_string_schema(240), + "preferences": bounded_plan_string_schema(240), + "sessionLength": bounded_plan_string_schema(240), + "referenceGames": plan_string_array_schema(0, 5, 80) + } + }, + "mvpSystems": { + "type": "array", + "minItems": 3, + "maxItems": 6, + "items": mvp_system + }, + "outOfScope": plan_string_array_schema(1, 12, 80), + "creatorTips": { + "type": "object", + "required": ["doFirst", "deferForNow", "howToVerify", "expandWhen"], + "additionalProperties": false, + "properties": { + "doFirst": bounded_plan_string_schema(400), + "deferForNow": bounded_plan_string_schema(400), + "howToVerify": bounded_plan_string_schema(400), + "expandWhen": bounded_plan_string_schema(400) + } + } + } + }, + "decisions": { + "type": "array", + "minItems": 1, + "maxItems": 32, + "items": decisions + }, + "prototypeValidationItems": { + "type": "array", + "maxItems": 3, + "items": prototype_item + } + } + }) +} + fn rebase_action_input_schema_refs_in_scope(value: &mut Value, has_local_resource_id: bool) { let Value::Object(object) = value else { return; @@ -1109,6 +1334,7 @@ fn string_array_schema(max_items: usize) -> Value { fn runtime_tool_description(tool: &str) -> &'static str { match tool { + PLAN_SUBMIT_GDD_TOOL => "提交当前立项策划 Session 的 Fast GDD;只能提交 plan-submit-gdd-input.v1 设计字段,Runtime 注入身份、版本、时间、平台事实和指纹。", "user.input_request" => "向用户提出一至三个结构化问题,并暂停当前 run 等待回答。", "memory.read" => "读取当前 Agent、Session、项目或黑板记忆。", "memory.write" => "写入当前 Agent 自己或项目范围的稳定记忆。", @@ -1176,6 +1402,7 @@ fn mcp_tool_description(tool: &GameCreatorMcpCatalogTool) -> String { fn runtime_tool_input_schema(tool: &str) -> Value { match tool { + PLAN_SUBMIT_GDD_TOOL => plan_submit_gdd_input_schema(), "user.input_request" => json!({ "type": "object", "required": ["questions"], @@ -1621,7 +1848,8 @@ mod tests { assert!(names.contains(AGENT_RUNTIME_RESPOND_FUNCTION_NAME)); assert!(names.contains("runtime_tool_file_read")); assert!(names.contains("runtime_tool_file_list")); - assert_eq!(names.len(), 4); + assert!(names.contains(PLAN_SUBMIT_GDD_FUNCTION_NAME)); + assert_eq!(names.len(), 5); assert!(!names.iter().any(|name| name.starts_with("mcp_tool_"))); assert!(!agent_runtime_native_tool_allowed_for_agent( GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, @@ -1631,6 +1859,14 @@ mod tests { GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, "file.write" )); + assert!(agent_runtime_native_tool_allowed_for_agent( + GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + PLAN_SUBMIT_GDD_TOOL + )); + assert!(!agent_runtime_native_tool_allowed_for_agent( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + PLAN_SUBMIT_GDD_TOOL + )); } fn native_mcp_catalog(input_schema: Value) -> GameCreatorMcpCatalog { @@ -1885,6 +2121,113 @@ mod tests { } } + #[test] + fn planning_submit_gdd_schema_is_strict_and_runtime_identity_free() { + let schema = runtime_tool_input_schema(PLAN_SUBMIT_GDD_TOOL); + assert_eq!( + schema["properties"]["schemaVersion"]["enum"], + json!([PLAN_SUBMIT_GDD_INPUT_SCHEMA_VERSION]) + ); + assert!(schema["properties"]["game"]["properties"] + .get("platformFacts") + .is_none()); + assert!(schema["properties"]["game"]["properties"] + .get("projectId") + .is_none()); + let wrapped = action_function_parameters(schema); + let mut issues = Vec::new(); + collect_openai_strict_schema_issues(&wrapped, "plan.submit_gdd", &mut issues); + assert!(issues.is_empty(), "{}", issues.join("\n")); + } + + #[test] + fn planning_submit_gdd_is_not_in_global_catalog() { + let functions = build_agent_runtime_native_function_tools(&empty_catalog()) + .expect("global native catalog"); + assert!(!functions + .iter() + .any(|function| function.name == PLAN_SUBMIT_GDD_FUNCTION_NAME)); + } + + fn submit_call(id: &str) -> LlmToolCall { + LlmToolCall { + id: id.to_string(), + name: PLAN_SUBMIT_GDD_FUNCTION_NAME.to_string(), + arguments: json!({ + "reason": "提交完整 Fast GDD", + "input": {} + }) + .to_string(), + } + } + + #[test] + fn planning_submit_gdd_native_batch_rejects_mixed_actions_and_response() { + let mixed = parse_agent_runtime_native_tool_calls_for_agent( + GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + &[ + submit_call("submit-mixed"), + LlmToolCall { + id: "read-mixed".to_string(), + name: native_runtime_function_name("file.read").expect("file.read name"), + arguments: json!({ + "reason": "读取", + "input": {"path": "README.md", "startLine": 1, "maxLines": 1} + }) + .to_string(), + }, + ], + &empty_catalog(), + ) + .expect_err("submit must not mix with another action"); + assert_eq!( + mixed.kind(), + AgentRuntimeToolPlanProtocolErrorKind::BatchConstraint + ); + + let with_response = parse_agent_runtime_native_tool_calls_for_agent( + GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + &[ + submit_call("submit-response"), + LlmToolCall { + id: "response".to_string(), + name: AGENT_RUNTIME_RESPOND_FUNCTION_NAME.to_string(), + arguments: json!({"response": "已提交"}).to_string(), + }, + ], + &empty_catalog(), + ) + .expect_err("submit must not mix with final response"); + assert_eq!( + with_response.kind(), + AgentRuntimeToolPlanProtocolErrorKind::BatchConstraint + ); + } + + #[test] + fn planning_submit_gdd_native_batch_allows_plan_update_control() { + let parsed = parse_agent_runtime_native_tool_calls_for_agent( + GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + &[ + submit_call("submit-plan-update"), + LlmToolCall { + id: "plan-update".to_string(), + name: AGENT_RUNTIME_UPDATE_PLAN_FUNCTION_NAME.to_string(), + arguments: json!({ + "explanation": "提交 GDD", + "steps": [{"step": "提交", "status": "in_progress"}] + }) + .to_string(), + }, + ], + &empty_catalog(), + ) + .expect("submit may share a response with plan control"); + assert_eq!(parsed.plan.actions.len(), 1); + assert_eq!(parsed.plan.actions[0].tool, PLAN_SUBMIT_GDD_TOOL); + assert!(parsed.plan.plan_update.is_some()); + } + #[test] fn strict_native_function_schemas_match_openai_subset() { let functions = build_agent_runtime_native_function_tools(&empty_catalog()) diff --git a/apps/ai-game-creator-shell/src-tauri/src/config.rs b/apps/ai-game-creator-shell/src-tauri/src/config.rs index 4404da0e7..424174564 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/config.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/config.rs @@ -76,7 +76,7 @@ fn build_game_creator_platform_llm_config( .map_err(|error| format!("LLM 配置无效:{error}")) } -fn game_creator_supports_anthropic_strict_tools( +pub(crate) fn game_creator_supports_anthropic_strict_tools( api_kind: LlmApiKind, base_url: &str, model: &str, diff --git a/apps/ai-game-creator-shell/src-tauri/src/delegation.rs b/apps/ai-game-creator-shell/src-tauri/src/delegation.rs index 857ad465e..565421a7f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/delegation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/delegation.rs @@ -1173,14 +1173,11 @@ fn static_delegate_clarification_round_limit_at( parent_agent_id: &str, parent_run_id: &str, ) -> Result { - let is_game_chat = read_game_creator_agent_runtime_run_profile_binding( - root, - parent_agent_id, - parent_run_id, - )? - .is_some_and(|binding| { - binding.source.trim() == AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE - }); + let is_game_chat = + read_game_creator_agent_runtime_run_profile_binding(root, parent_agent_id, parent_run_id)? + .is_some_and(|binding| { + binding.source.trim() == AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE + }); Ok(if is_game_chat { STATIC_DELEGATE_CLARIFICATION_ROUND_LIMIT_GAME_CHAT } else { diff --git a/apps/ai-game-creator-shell/src-tauri/src/mcp.rs b/apps/ai-game-creator-shell/src-tauri/src/mcp.rs index 28684775e..b76042922 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/mcp.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/mcp.rs @@ -2078,6 +2078,8 @@ mod tests { source: state.source.clone(), run_profile: default_agent_runtime_run_profile(), run_profile_binding_fingerprint: String::new(), + planning_session_binding: None, + provider_batch_plan_update: None, task: state.current_task.clone(), goal_id: None, goal_revision: 0, diff --git a/apps/ai-game-creator-shell/src-tauri/src/process_session/lifecycle.rs b/apps/ai-game-creator-shell/src-tauri/src/process_session/lifecycle.rs index 8a5e21147..ec5882bd2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/process_session/lifecycle.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/process_session/lifecycle.rs @@ -145,7 +145,9 @@ pub(super) fn process_session_command_builder( #[cfg(windows)] if is_npm_launch { let node_executable = launch.executable.to_string_lossy(); - let node_executable = node_executable.strip_prefix(r"\\?\").unwrap_or(&node_executable); + let node_executable = node_executable + .strip_prefix(r"\\?\") + .unwrap_or(&node_executable); command.env("npm_node_execpath", node_executable); command.env("NODE", node_executable); command.env("npm_config_node_gyp", ""); @@ -1187,9 +1189,7 @@ fn drain_process_session_output( let before = pending.len(); ansi.push(*byte, &mut pending); let visible_bytes = pending.len().saturating_sub(before); - if visible_bytes > 0 - && !matches!(pending.last(), Some(b'\n' | b'\r')) - { + if visible_bytes > 0 && !matches!(pending.last(), Some(b'\n' | b'\r')) { pending_logical_line_bytes = pending_logical_line_bytes.saturating_add(visible_bytes); if pending_logical_line_bytes > PROCESS_SESSION_MAX_PENDING_LINE_BYTES { @@ -1225,8 +1225,8 @@ fn drain_process_session_output( const PROCESS_SESSION_PTY_COLS: usize = 120; match pending.last() { Some(b'\r') => { - conpty_soft_wrap = pending_logical_line_bytes - >= PROCESS_SESSION_PTY_COLS; + conpty_soft_wrap = + pending_logical_line_bytes >= PROCESS_SESSION_PTY_COLS; if !conpty_soft_wrap { pending_logical_line_bytes = 0; } diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/agent_db.rs b/apps/ai-game-creator-shell/src-tauri/src/project/agent_db.rs index 56a9cc65e..f554edb84 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/agent_db.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/agent_db.rs @@ -13,6 +13,8 @@ const AGENT_DB_PROVIDER_REQUEST_LIFECYCLE_SCHEMA_V1: &str = "game-creator-provider-request-lifecycle.v1"; const AGENT_DB_PROVIDER_REQUEST_LIFECYCLE_SCHEMA_V2: &str = "game-creator-provider-request-lifecycle.v2"; +const AGENT_DB_PROVIDER_REQUEST_LIFECYCLE_SCHEMA_V3: &str = + "game-creator-provider-request-lifecycle.v3"; const AGENT_DB_FINALIZATION_LIFECYCLE_SCHEMA_VERSION: &str = "game-creator-finalization-lifecycle.v1"; const AGENT_DB_FINALIZATION_JOURNAL_SCHEMA_V1: &str = "game-creator-runtime-finalization.v1"; @@ -1223,11 +1225,14 @@ fn validate_agent_db_lifecycle_record_semantics( match record_type { AGENT_DB_PROVIDER_REQUEST_LIFECYCLE_RECORD_TYPE => { let audit_schema = agent_db_provider_lifecycle_schema_version(record)?; - if audit_schema == AGENT_DB_PROVIDER_REQUEST_LIFECYCLE_SCHEMA_V2 - && record - .get("webSearchEnabled") - .and_then(serde_json::Value::as_bool) - .is_none() + if matches!( + audit_schema, + AGENT_DB_PROVIDER_REQUEST_LIFECYCLE_SCHEMA_V2 + | AGENT_DB_PROVIDER_REQUEST_LIFECYCLE_SCHEMA_V3 + ) && record + .get("webSearchEnabled") + .and_then(serde_json::Value::as_bool) + .is_none() { return Err("Agent DB Provider lifecycle webSearchEnabled 必须为 bool".to_string()); } @@ -1258,6 +1263,15 @@ fn validate_agent_db_lifecycle_record_semantics( if !is_safe_agent_db_lifecycle_identity(request_slot) { return Err("Agent DB Provider lifecycle requestSlot 安全形状无效".to_string()); } + if audit_schema == AGENT_DB_PROVIDER_REQUEST_LIFECYCLE_SCHEMA_V3 { + validate_agent_db_plan_provider_binding( + record.get("planningSessionBinding").ok_or_else(|| { + "Agent DB planning Provider lifecycle 缺少 planningSessionBinding" + .to_string() + })?, + record, + )?; + } let status = record .get("status") .and_then(serde_json::Value::as_str) @@ -1306,6 +1320,21 @@ fn validate_agent_db_lifecycle_record_fields( "webSearchEnabled", "status", ]; + const PROVIDER_FIELDS_V3: &[&str] = &[ + "recordType", + "auditSchemaVersion", + "agentId", + "taskId", + "sessionId", + "runId", + "source", + "requestId", + "requestKind", + "requestSlot", + "webSearchEnabled", + "planningSessionBinding", + "status", + ]; const FINALIZATION_FIELDS: &[&str] = &[ "recordType", "auditSchemaVersion", @@ -1335,6 +1364,7 @@ fn validate_agent_db_lifecycle_record_fields( match agent_db_provider_lifecycle_schema_version(record)? { AGENT_DB_PROVIDER_REQUEST_LIFECYCLE_SCHEMA_V1 => PROVIDER_FIELDS_V1, AGENT_DB_PROVIDER_REQUEST_LIFECYCLE_SCHEMA_V2 => PROVIDER_FIELDS_V2, + AGENT_DB_PROVIDER_REQUEST_LIFECYCLE_SCHEMA_V3 => PROVIDER_FIELDS_V3, _ => unreachable!("provider lifecycle schema was validated"), } } @@ -1361,12 +1391,166 @@ fn agent_db_provider_lifecycle_schema_version(record: &serde_json::Value) -> Res { Some( schema @ (AGENT_DB_PROVIDER_REQUEST_LIFECYCLE_SCHEMA_V1 - | AGENT_DB_PROVIDER_REQUEST_LIFECYCLE_SCHEMA_V2), + | AGENT_DB_PROVIDER_REQUEST_LIFECYCLE_SCHEMA_V2 + | AGENT_DB_PROVIDER_REQUEST_LIFECYCLE_SCHEMA_V3), ) => Ok(schema), _ => Err("Agent DB Provider lifecycle audit schema 无效".to_string()), } } +fn validate_agent_db_plan_provider_binding( + binding: &serde_json::Value, + outer: &serde_json::Value, +) -> Result<(), String> { + const FIELDS: &[&str] = &[ + "schemaVersion", + "projectId", + "gddId", + "agentId", + "taskId", + "providerRequestId", + "sessionId", + "runId", + "rootAgentId", + "rootRunId", + "delegationId", + "goalId", + "goalRevision", + "goalSnapshotFingerprint", + "source", + "runProfile", + "runProfileBindingFingerprint", + "sessionRevision", + "sessionFingerprint", + "appliedSteerCursor", + "requestKind", + "requestSlot", + "webSearchEnabled", + "requestContextFingerprint", + "fingerprint", + ]; + let object = binding + .as_object() + .ok_or_else(|| "Agent DB planning binding 必须是 object".to_string())?; + if object.len() != FIELDS.len() || !FIELDS.iter().all(|field| object.contains_key(*field)) { + return Err("Agent DB planning binding 字段集合无效".to_string()); + } + let string_field = |field: &str| { + object + .get(field) + .and_then(serde_json::Value::as_str) + .ok_or_else(|| format!("Agent DB planning binding 缺少合法字段:{field}")) + }; + for field in [ + "schemaVersion", + "projectId", + "gddId", + "agentId", + "taskId", + "providerRequestId", + "sessionId", + "runId", + "rootAgentId", + "rootRunId", + "delegationId", + "source", + "runProfile", + "runProfileBindingFingerprint", + "sessionFingerprint", + "requestKind", + "requestSlot", + "requestContextFingerprint", + "fingerprint", + ] { + if !is_safe_agent_db_lifecycle_identity(string_field(field)?) { + return Err(format!( + "Agent DB planning binding 字段安全形状无效:{field}" + )); + } + } + if string_field("providerRequestId")? + != outer + .get("requestId") + .and_then(serde_json::Value::as_str) + .unwrap_or_default() + { + return Err( + "Agent DB planning binding providerRequestId 与外层 requestId 不一致".to_string(), + ); + } + match object.get("goalId").and_then(serde_json::Value::as_str) { + Some(goal_id) + if is_safe_agent_db_lifecycle_identity(goal_id) + && object + .get("goalRevision") + .and_then(serde_json::Value::as_u64) + .is_some_and(|value| value > 0) + && object + .get("goalSnapshotFingerprint") + .and_then(serde_json::Value::as_str) + .is_some_and(is_safe_agent_db_lifecycle_identity) => {} + None if object + .get("goalRevision") + .and_then(serde_json::Value::as_u64) + == Some(0) + && object + .get("goalSnapshotFingerprint") + .and_then(serde_json::Value::as_str) + == Some("") => {} + _ => return Err("Agent DB planning binding Goal 三元组无效".to_string()), + } + for (binding_field, outer_field) in [ + ("agentId", "agentId"), + ("taskId", "taskId"), + ("sessionId", "sessionId"), + ("runId", "runId"), + ("source", "source"), + ("requestKind", "requestKind"), + ("requestSlot", "requestSlot"), + ] { + if string_field(binding_field)? + != outer + .get(outer_field) + .and_then(serde_json::Value::as_str) + .unwrap_or_default() + { + return Err(format!( + "Agent DB planning binding {binding_field} 与外层字段不一致" + )); + } + } + if object + .get("schemaVersion") + .and_then(serde_json::Value::as_str) + != Some("plan-provider-session-binding.v1") + || object + .get("sessionRevision") + .and_then(serde_json::Value::as_u64) + .is_none_or(|value| value == 0) + || object + .get("appliedSteerCursor") + .and_then(serde_json::Value::as_u64) + .is_none() + || object + .get("webSearchEnabled") + .and_then(serde_json::Value::as_bool) + != Some(false) + { + return Err("Agent DB planning binding 基础字段无效".to_string()); + } + let typed = + serde_json::from_value::(binding.clone()) + .map_err(|error| format!("Agent DB planning binding strict 解析失败:{error}"))?; + crate::agent::validate_plan_provider_session_binding(&typed) + .map_err(|error| format!("Agent DB planning binding 语义无效:{error}"))?; + let expected_fingerprint = crate::agent::plan_provider_session_binding_fingerprint(&typed) + .map_err(|error| format!("Agent DB planning binding fingerprint 计算失败:{error}"))?; + if typed.fingerprint != expected_fingerprint { + return Err("Agent DB planning binding fingerprint 不匹配".to_string()); + } + Ok(()) +} + fn validate_agent_db_finalization_lifecycle_semantics( record: &serde_json::Value, ) -> Result<(), String> { @@ -1989,6 +2173,97 @@ pub(crate) fn append_agent_db_process_reconciliation_if_missing_for_action( ) } +pub(crate) fn append_agent_db_plan_submit_gdd_committed_if_missing_for_action( + root: &Path, + agent_id: &str, + run_id: &str, + action_id: &str, + record: serde_json::Value, +) -> Result { + const RECORD_TYPE: &str = "agent.runtime.plan_submit_gdd.committed"; + const FIELDS: &[&str] = &[ + "recordType", + "agentId", + "taskId", + "sessionId", + "runId", + "actionId", + "actionFingerprint", + "gddId", + "version", + "gddFingerprint", + "approvalRequestId", + "recoveryPending", + ]; + if !agent_db_record_has_exact_payload_fields(&record, FIELDS) + || record.get("recordType").and_then(serde_json::Value::as_str) != Some(RECORD_TYPE) + || record.get("agentId").and_then(serde_json::Value::as_str) != Some(agent_id) + || record.get("runId").and_then(serde_json::Value::as_str) != Some(run_id) + || record.get("actionId").and_then(serde_json::Value::as_str) != Some(action_id) + || record + .get("recoveryPending") + .and_then(serde_json::Value::as_bool) + != Some(false) + { + return Err("Agent DB planning submit committed 幂等记录身份或字段集合无效".to_string()); + } + for field in ["taskId", "sessionId"] { + if record + .get(field) + .and_then(serde_json::Value::as_str) + .is_none_or(|value| value.trim().is_empty() || value.chars().any(char::is_control)) + { + return Err(format!( + "Agent DB planning submit committed 缺少合法字段:{field}" + )); + } + } + let action_fingerprint = record + .get("actionFingerprint") + .and_then(serde_json::Value::as_str) + .unwrap_or_default(); + let gdd_id = record + .get("gddId") + .and_then(serde_json::Value::as_str) + .unwrap_or_default(); + let gdd_fingerprint = record + .get("gddFingerprint") + .and_then(serde_json::Value::as_str) + .unwrap_or_default(); + let approval_request_id = record + .get("approvalRequestId") + .and_then(serde_json::Value::as_str) + .unwrap_or_default(); + if !action_id.strip_prefix("action-").is_some_and(|suffix| { + suffix.len() == 24 && suffix.bytes().all(|byte| byte.is_ascii_hexdigit()) + }) || !is_valid_agent_db_sha256(action_fingerprint) + || !gdd_id + .strip_prefix("gdd-") + .is_some_and(|suffix| uuid::Uuid::parse_str(suffix).is_ok()) + || !gdd_fingerprint + .strip_prefix("sha256-serde-json-v2:") + .is_some_and(is_valid_agent_db_sha256) + || !approval_request_id + .strip_prefix("gdd-approval-") + .is_some_and(|suffix| uuid::Uuid::parse_str(suffix).is_ok()) + || record + .get("version") + .and_then(serde_json::Value::as_u64) + .is_none_or(|version| !(1..=128).contains(&version)) + { + return Err("Agent DB planning submit committed durable identity 无效".to_string()); + } + append_agent_db_record_if_missing_for_action_internal( + root, + RECORD_TYPE, + agent_id, + run_id, + action_id, + record, + || {}, + ) +} + pub(crate) fn append_agent_db_record_if_missing_for_action_with_before_lock( root: &Path, record_type: &str, @@ -3063,6 +3338,7 @@ fn validate_agent_db_lifecycle_record_identity( "requestKind", "requestSlot", "webSearchEnabled", + "planningSessionBinding", ], AGENT_DB_FINALIZATION_LIFECYCLE_RECORD_TYPE => &[ "recordType", diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/agent_db/security_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/project/agent_db/security_tests.rs index 6d5d560f1..345d00975 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/agent_db/security_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/agent_db/security_tests.rs @@ -333,6 +333,57 @@ fn provider_lifecycle_record_with_schema( record } +fn planning_provider_lifecycle_record_without_goal(status: &str) -> serde_json::Value { + let mut binding = crate::agent::PlanProviderSessionBindingV1 { + schema_version: crate::agent::PLAN_PROVIDER_SESSION_BINDING_SCHEMA_VERSION.to_string(), + project_id: "planning-provider-project".to_string(), + gdd_id: "gdd-00000000-0000-4000-8000-000000000001".to_string(), + agent_id: crate::GAME_CREATOR_PROJECT_PLANNING_AGENT_ID.to_string(), + task_id: "planning-provider-task".to_string(), + provider_request_id: String::new(), + session_id: "planning-provider-session".to_string(), + run_id: "planning-provider-run".to_string(), + root_agent_id: crate::GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(), + root_run_id: "planning-provider-root-run".to_string(), + delegation_id: "planning-provider-delegation".to_string(), + goal_id: None, + goal_revision: 0, + goal_snapshot_fingerprint: String::new(), + source: "agent-delegate".to_string(), + run_profile: crate::AGENT_RUNTIME_RUN_PROFILE_STANDARD.to_string(), + run_profile_binding_fingerprint: "1".repeat(64), + session_revision: 1, + session_fingerprint: format!("sha256-serde-json-v2:{}", "2".repeat(64)), + applied_steer_cursor: 0, + request_kind: "tool-plan".to_string(), + request_slot: "loop-0-plan-submit".to_string(), + web_search_enabled: false, + request_context_fingerprint: format!("sha256-serde-json-v2:{}", "3".repeat(64)), + fingerprint: String::new(), + }; + binding.provider_request_id = + crate::agent::plan_provider_session_binding_base_request_id(&binding) + .expect("compute planning Provider request id"); + binding.fingerprint = crate::agent::plan_provider_session_binding_fingerprint(&binding) + .expect("compute planning Provider binding fingerprint"); + let request_id = binding.provider_request_id.clone(); + serde_json::json!({ + "recordType": AGENT_DB_PROVIDER_REQUEST_LIFECYCLE_RECORD_TYPE, + "auditSchemaVersion": AGENT_DB_PROVIDER_REQUEST_LIFECYCLE_SCHEMA_V3, + "agentId": binding.agent_id, + "taskId": binding.task_id, + "sessionId": binding.session_id, + "runId": binding.run_id, + "source": binding.source, + "requestId": request_id, + "requestKind": binding.request_kind, + "requestSlot": binding.request_slot, + "webSearchEnabled": false, + "planningSessionBinding": binding, + "status": status, + }) +} + fn finalization_lifecycle_record(finalization_id: &str, stage: &str) -> serde_json::Value { finalization_lifecycle_record_with_schema( finalization_id, @@ -1730,6 +1781,55 @@ fn provider_lifecycle_accepts_v1_and_strict_v2_without_changing_request_identity fs::remove_dir_all(root).ok(); } +#[test] +fn planning_provider_lifecycle_v3_accepts_the_explicit_no_goal_triple() { + let root = unique_agent_db_test_root("planning-provider-lifecycle-v3-no-goal"); + let started = planning_provider_lifecycle_record_without_goal("started"); + let request_id = started["requestId"] + .as_str() + .expect("planning Provider request id") + .to_string(); + assert_eq!( + started["planningSessionBinding"]["goalId"], + serde_json::Value::Null + ); + assert_eq!(started["planningSessionBinding"]["goalRevision"], 0); + assert_eq!( + started["planningSessionBinding"]["goalSnapshotFingerprint"], + "" + ); + + assert!(append_agent_db_lifecycle_record_idempotent( + &root, + "requestId", + &request_id, + "status", + "started", + started, + ) + .expect("append no-Goal planning Provider started lifecycle")); + assert!(append_agent_db_lifecycle_record_idempotent( + &root, + "requestId", + &request_id, + "status", + "completed", + planning_provider_lifecycle_record_without_goal("completed"), + ) + .expect("append no-Goal planning Provider completed lifecycle")); + assert_eq!( + read_agent_db_lifecycle_transitions_at( + &root, + AGENT_DB_PROVIDER_REQUEST_LIFECYCLE_RECORD_TYPE, + "requestId", + &request_id, + ) + .expect("read no-Goal planning Provider lifecycle"), + vec!["started", "completed"] + ); + fs::remove_dir_all(root).ok(); +} + #[test] fn provider_lifecycle_rejects_schema_specific_web_search_shape_and_identity_conflicts() { let request_id = provider_request_id('8'); diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/verification.rs b/apps/ai-game-creator-shell/src-tauri/src/project/verification.rs index d8cd83355..f30dd9710 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/verification.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/verification.rs @@ -799,10 +799,15 @@ pub(crate) fn normalize_policy_command_ids(values: Vec) -> Result, } #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] @@ -374,7 +377,17 @@ pub(crate) fn validate_identity( return Err(format!("Provider 重试身份 {label} 不能为空")); } } - if !is_sha256(&identity.request_fingerprint) { + // Exact planning requests persist the typed request-context fingerprint; + // historical/non-planning requests retain the bare 64-hex fingerprint. + // Do not apply the legacy bare-only check to a planning retry identity or + // every transient planning failure would fail closed before its retry + // sidecar can be written. + let request_fingerprint_valid = if identity.planning_session_binding.is_some() { + is_typed_sha256(&identity.request_fingerprint) + } else { + is_sha256(&identity.request_fingerprint) + }; + if !request_fingerprint_valid { return Err("Provider 重试身份的 requestFingerprint 无效".to_string()); } if !is_sha256(&identity.provider_config_fingerprint) { @@ -388,6 +401,26 @@ pub(crate) fn validate_identity( None if identity.goal_revision == 0 && identity.goal_snapshot_fingerprint.is_empty() => {} _ => return Err("Provider 重试身份的 Goal 绑定无效".to_string()), } + if let Some(binding) = identity.planning_session_binding.as_ref() { + validate_plan_provider_session_binding(binding) + .map_err(|error| format!("Provider 重试身份的 planning binding 无效:{error}"))?; + if binding.project_id != identity.project_id + || binding.agent_id != identity.agent_id + || binding.task_id != identity.task_id + || binding.session_id != identity.session_id + || binding.run_id != identity.run_id + || binding.source != identity.source + || binding.request_kind != identity.request_kind + || binding.request_slot != identity.base_request_slot + || binding.web_search_enabled != identity.web_search_enabled + || binding.goal_id != identity.goal_id + || binding.goal_revision != identity.goal_revision + || binding.goal_snapshot_fingerprint != identity.goal_snapshot_fingerprint + || binding.request_context_fingerprint != identity.request_fingerprint + { + return Err("Provider 重试身份与 planning binding 外层字段不一致".to_string()); + } + } Ok(()) } @@ -444,6 +477,12 @@ fn is_sha256(value: &str) -> bool { value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit()) } +fn is_typed_sha256(value: &str) -> bool { + value + .strip_prefix("sha256-serde-json-v2:") + .is_some_and(is_sha256) +} + fn remaining_ms_at(retry_at_ms: u64, at_ms: u64) -> u64 { retry_at_ms.saturating_sub(at_ms) } @@ -550,6 +589,62 @@ mod tests { provider_config_fingerprint: "e".repeat(64), web_search_enabled: true, allow_idle_context_compaction: false, + planning_session_binding: None, + } + } + + fn planning_identity(run_id: &str) -> AgentRuntimeProviderRetryIdentity { + let request_context_fingerprint = format!("sha256-serde-json-v2:{}", "d".repeat(64)); + let mut binding = PlanProviderSessionBindingV1 { + schema_version: crate::PLAN_PROVIDER_SESSION_BINDING_SCHEMA_VERSION.to_string(), + project_id: "project-provider-retry".to_string(), + gdd_id: "gdd-00000000-0000-4000-8000-000000000001".to_string(), + agent_id: crate::GAME_CREATOR_PROJECT_PLANNING_AGENT_ID.to_string(), + task_id: "task-provider-retry".to_string(), + provider_request_id: String::new(), + session_id: "session-provider-retry".to_string(), + run_id: run_id.to_string(), + root_agent_id: crate::GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(), + root_run_id: "run-provider-retry-root".to_string(), + delegation_id: "delegation-provider-retry".to_string(), + goal_id: None, + goal_revision: 0, + goal_snapshot_fingerprint: String::new(), + source: "agent-delegate".to_string(), + run_profile: crate::AGENT_RUNTIME_RUN_PROFILE_STANDARD.to_string(), + run_profile_binding_fingerprint: "a".repeat(64), + session_revision: 1, + session_fingerprint: format!("sha256-serde-json-v2:{}", "b".repeat(64)), + applied_steer_cursor: 2, + request_kind: "tool-plan".to_string(), + request_slot: "loop-2-repair-0".to_string(), + web_search_enabled: false, + request_context_fingerprint: request_context_fingerprint.clone(), + fingerprint: String::new(), + }; + binding.provider_request_id = + crate::agent::plan_provider_session_binding_base_request_id(&binding) + .expect("planning base request id"); + binding.fingerprint = crate::agent::plan_provider_session_binding_fingerprint(&binding) + .expect("planning binding fingerprint"); + AgentRuntimeProviderRetryIdentity { + project_id: binding.project_id.clone(), + agent_id: binding.agent_id.clone(), + task_id: binding.task_id.clone(), + session_id: binding.session_id.clone(), + run_id: binding.run_id.clone(), + source: binding.source.clone(), + goal_id: binding.goal_id.clone(), + goal_revision: binding.goal_revision, + goal_snapshot_fingerprint: binding.goal_snapshot_fingerprint.clone(), + applied_steer_cursor: binding.applied_steer_cursor, + request_kind: binding.request_kind.clone(), + base_request_slot: binding.request_slot.clone(), + request_fingerprint: request_context_fingerprint, + provider_config_fingerprint: "e".repeat(64), + web_search_enabled: false, + allow_idle_context_compaction: false, + planning_session_binding: Some(binding), } } @@ -626,6 +721,44 @@ mod tests { assert!(error.contains("unknown field")); } + #[test] + fn planning_provider_retry_round_trips_typed_request_identity() { + let directory = tempdir().expect("create temp directory"); + let identity = planning_identity("run-planning-retry"); + validate_identity(&identity).expect("planning retry identity validates"); + let record = write_first(directory.path(), &identity); + assert_eq!( + read_matching_at(directory.path(), &identity) + .expect("read planning retry") + .expect("planning retry exists"), + record + ); + assert!(record + .identity + .request_fingerprint + .starts_with("sha256-serde-json-v2:")); + assert_eq!( + record + .identity + .planning_session_binding + .as_ref() + .map(|binding| binding.request_context_fingerprint.as_str()), + Some(record.identity.request_fingerprint.as_str()) + ); + + let mut bare_request_fingerprint = identity.clone(); + bare_request_fingerprint.request_fingerprint = "d".repeat(64); + assert!(validate_identity(&bare_request_fingerprint).is_err()); + + let mut mismatched_binding = identity; + mismatched_binding + .planning_session_binding + .as_mut() + .expect("planning binding") + .request_context_fingerprint = format!("sha256-serde-json-v2:{}", "c".repeat(64)); + assert!(validate_identity(&mismatched_binding).is_err()); + } + #[test] fn provider_retry_reads_atomic_previous_when_primary_is_missing() { let directory = tempdir().expect("create temp directory"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs index c09ee7e49..ae5c57438 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs @@ -2151,6 +2151,7 @@ fn durable_provider_retry_prevents_shutdown_and_reopens_writes() { provider_config_fingerprint: "b".repeat(64), web_search_enabled: false, allow_idle_context_compaction: false, + planning_session_binding: None, }; let retry = crate::provider_retry::write_next_at( &root, @@ -2254,6 +2255,7 @@ fn durable_provider_handoff_prevents_shutdown_even_when_corrupt() { provider_config_fingerprint: "e".repeat(64), web_search_enabled: false, allow_idle_context_compaction: false, + planning_session_binding: None, }; let response = platform_llm::LlmRunResponse { provider: platform_llm::LlmProvider::OpenAiCompatible, diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/static_deliveries.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/static_deliveries.rs index 01eefedc1..e5f0f4829 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/static_deliveries.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/static_deliveries.rs @@ -36,7 +36,8 @@ fn create_dispatched_static_delegate_delivery( expected_artifacts, repair_of_delegation_id, ); - create_or_read_static_delegate_delivery_at(root, &delivery).expect("create dispatched delivery"); + create_or_read_static_delegate_delivery_at(root, &delivery) + .expect("create dispatched delivery"); } /// 把一条已 dispatched 的 delivery 标记为「存在质量缺口」的 needs-repair 终态并认领—— @@ -175,10 +176,9 @@ fn answer_static_delegate_clarification_wait( answers, ) .expect("answer clarification request"); - let detail = serde_json::from_str::( - observation.detail.as_deref().expect("answer detail"), - ) - .expect("parse answer detail"); + let detail = + serde_json::from_str::(observation.detail.as_deref().expect("answer detail")) + .expect("parse answer detail"); let questions_sha256 = detail["questionsSha256"] .as_str() .expect("questions sha") @@ -3487,8 +3487,12 @@ const CLARIFICATION_QUESTION_BODY: &str = concat!( #[test] fn clarification_round_limit_rejects_fourth_round() { let root = unique_project_path(); - init_local_game_project_at(&root, "project-clarification-round-limit", "澄清轮次上限边界测试") - .expect("project init"); + init_local_game_project_at( + &root, + "project-clarification-round-limit", + "澄清轮次上限边界测试", + ) + .expect("project init"); let run_id = "project-supervisor-clarification-round-limit-run"; let mut state = start_game_creator_agent_runtime_task_at( &root, @@ -3797,7 +3801,8 @@ fn alternating_repair_and_clarification_hop_blocks_second_repair_at_depth_two() } #[test] -fn static_delegate_user_revision_requested_continuation_passes_real_delegate_gate_after_depth_one() { +fn static_delegate_user_revision_requested_continuation_passes_real_delegate_gate_after_depth_one() +{ // 这里手工把 delivery 状态切到 UserRevisionRequested,是对 M1C-1 未来审批写入的 // durable fixture;委派本身仍走真实 observe_agent_runtime_agent_delegate 生产路径。 let root = unique_project_path(); @@ -4517,8 +4522,12 @@ fn game_chat_source_caps_clarification_round_at_one() { // 非 game-chat source(本文件其余用例默认场景)下能走到 3——已由 // clarification_round_limit_rejects_fourth_round 覆盖,这里只验证 game-chat 分支。 let root = unique_project_path(); - init_local_game_project_at(&root, "project-game-chat-round-cap", "game-chat 澄清上限测试") - .expect("project init"); + init_local_game_project_at( + &root, + "project-game-chat-round-cap", + "game-chat 澄清上限测试", + ) + .expect("project init"); let run_id = "project-supervisor-game-chat-round-cap-run"; // 绑定 Run Profile 为 game-chat source;profile 保持默认 STANDARD(而非 // AUTONOMOUS_GAME_BUILD),避免额外触发“game-chat 单主路径禁止 Supervisor 直接委派” diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs index 0d66111ed..3c3c80b4f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs @@ -891,6 +891,8 @@ fn pending_tool_action_for_test( source: state.source.clone(), run_profile: default_agent_runtime_run_profile(), run_profile_binding_fingerprint: String::new(), + planning_session_binding: None, + provider_batch_plan_update: None, task: state.current_task.clone(), goal_id: state.goal_id.clone(), goal_revision: state.goal_revision, diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs index 7a8b75405..39fb4ea1c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs @@ -585,12 +585,8 @@ async fn background_agent_runtime_preview_start_respects_project_policy() { ))); assert!(!root.join(".agent/logs/preview.log").exists()); - cancel_game_creator_agent_runtime_task_at( - &root, - "code-prototype", - "code-preview-policy-run", - ) - .expect("cancel waiting preview policy task"); + cancel_game_creator_agent_runtime_task_at(&root, "code-prototype", "code-preview-policy-run") + .expect("cancel waiting preview policy task"); wait_for_agent_runtime_terminal_and_lane_release( &root, "code-prototype", diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs index 1768d462d..e7f0cf67d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs @@ -4111,6 +4111,7 @@ fn provider_retry_waiting_same_attempt_for_distinct_requests_does_not_conflict() provider_config_fingerprint: "b".repeat(64), web_search_enabled: false, allow_idle_context_compaction: false, + planning_session_binding: None, }, next_request_slot: format!("{base_request_slot}-transient-1"), next_attempt: 1, diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/policy.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/policy.rs index cfa3b85d8..c852594b6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/policy.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/policy.rs @@ -1,4 +1,5 @@ use super::support::*; +use crate::PLAN_SUBMIT_GDD_TOOL; #[test] fn agent_runtime_default_allowed_tools_match_executable_whitelist() { @@ -40,7 +41,8 @@ fn planning_agent_original_tool_identity_is_not_widened_by_command_aliases() { #[tokio::test] async fn planning_runtime_rejects_search_alias_and_user_input_before_execution() { let root = unique_project_path(); - init_local_game_project_at(&root, "planning-boundary", "策划 Agent 运行时边界").expect("project init"); + init_local_game_project_at(&root, "planning-boundary", "策划 Agent 运行时边界") + .expect("project init"); for (tool, input) in [ ( @@ -267,12 +269,13 @@ fn agent_runtime_tool_policy_snapshot_reflects_project_policy() { #[test] fn planning_tool_policy_snapshot_keeps_exact_permission_decisions() { let root = unique_project_path(); - init_local_game_project_at(&root, "planning-policy-snapshot", "策划工具策略快照").expect("project init"); + init_local_game_project_at(&root, "planning-policy-snapshot", "策划工具策略快照") + .expect("project init"); write_project_permission_policy_at( &root, ProjectPermissionPolicy { denied_commands: vec!["file.read".to_string()], - confirm_commands: vec!["file.list".to_string()], + confirm_commands: vec!["file.list".to_string(), PLAN_SUBMIT_GDD_TOOL.to_string()], agent_policies: BTreeMap::new(), }, ) @@ -311,12 +314,36 @@ fn planning_tool_policy_snapshot_keeps_exact_permission_decisions() { None, ) .expect("read planning policy snapshot"); - assert_eq!(snapshot.allowed_tools, vec!["file.list", "file.read"]); + assert_eq!( + snapshot.allowed_tools, + vec!["file.list", "file.read", PLAN_SUBMIT_GDD_TOOL] + ); assert!(snapshot.denied_tools.iter().any(|tool| tool == "file.read")); assert!(!snapshot.auto_tools.iter().any(|tool| tool == "file.read")); - assert!(!snapshot.confirm_tools.iter().any(|tool| tool == "file.read")); - assert!(snapshot.confirm_tools.iter().any(|tool| tool == "file.list")); - assert!(snapshot.denied_tools.iter().any(|tool| tool == "project.search")); + assert!(!snapshot + .confirm_tools + .iter() + .any(|tool| tool == "file.read")); + assert!(snapshot + .confirm_tools + .iter() + .any(|tool| tool == "file.list")); + assert!(!snapshot + .auto_tools + .iter() + .any(|tool| tool == PLAN_SUBMIT_GDD_TOOL)); + assert!(snapshot + .denied_tools + .iter() + .any(|tool| tool == PLAN_SUBMIT_GDD_TOOL)); + assert!(!snapshot + .confirm_tools + .iter() + .any(|tool| tool == PLAN_SUBMIT_GDD_TOOL)); + assert!(snapshot + .denied_tools + .iter() + .any(|tool| tool == "project.search")); assert!(matches!( game_creator_agent_runtime_tool_policy_rule_for_run( @@ -342,6 +369,30 @@ fn planning_tool_policy_snapshot_keeps_exact_permission_decisions() { Some(AgentRuntimeToolPolicyBlock::RequiresConfirmation(reason)) if reason.contains("项目权限策略要求用户确认") )); + assert!(matches!( + game_creator_agent_runtime_tool_policy_rule_for_run( + &root, + GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + child_run_id, + None, + None, + PLAN_SUBMIT_GDD_TOOL, + ), + Some(AgentRuntimeToolPolicyBlock::Denied(reason)) + if reason.contains("不支持转成通用确认 pending") + )); + assert!(matches!( + game_creator_agent_runtime_tool_policy_rule_for_run( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + None, + None, + PLAN_SUBMIT_GDD_TOOL, + ), + Some(AgentRuntimeToolPolicyBlock::Denied(reason)) + if reason.contains("仅允许 project-planning") + )); assert!(matches!( game_creator_agent_runtime_tool_policy_rule_for_run( &root, @@ -428,7 +479,10 @@ fn plan_root_delegate_rejects_non_planning_targets_but_keeps_planning_path() { "joinMode": "all" }), ); - assert_eq!(isolated_observation.status, "failed", "{isolated_observation:?}"); + assert_eq!( + isolated_observation.status, "failed", + "{isolated_observation:?}" + ); assert!( isolated_observation .summary @@ -436,10 +490,12 @@ fn plan_root_delegate_rejects_non_planning_targets_but_keeps_planning_path() { "{isolated_observation:?}" ); - let planning_lock = - try_acquire_game_creator_agent_runtime_task_lock(&root, GAME_CREATOR_PROJECT_PLANNING_AGENT_ID) - .expect("acquire planning lane") - .expect("planning lane available"); + let planning_lock = try_acquire_game_creator_agent_runtime_task_lock( + &root, + GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + ) + .expect("acquire planning lane") + .expect("planning lane available"); let planning_observation = observe_agent_runtime_agent_delegate( &root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, @@ -454,7 +510,10 @@ fn plan_root_delegate_rejects_non_planning_targets_but_keeps_planning_path() { "runId": null }), ); - assert_eq!(planning_observation.status, "ok", "{planning_observation:?}"); + assert_eq!( + planning_observation.status, "ok", + "{planning_observation:?}" + ); drop(planning_lock); fs::remove_dir_all(root).ok(); @@ -647,7 +706,10 @@ fn gui_root_delegate_and_spawn_isolated_are_unaffected_by_plan_root_symmetry() { "joinMode": "all" }), ); - assert_eq!(isolated_observation.status, "ok", "{isolated_observation:?}"); + assert_eq!( + isolated_observation.status, "ok", + "{isolated_observation:?}" + ); fs::remove_dir_all(root).ok(); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/support.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/support.rs index 31933366c..7bb01951e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/support.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/support.rs @@ -37,8 +37,7 @@ pub(super) use crate::{ agent_runtime_contains_secret_key_prefix, agent_runtime_executable_tools, agent_runtime_read_only_delivery_completion_plan_update, agent_runtime_run_profile_identity_at, agent_runtime_tool_action_fingerprint, agent_runtime_tool_action_id, - agent_runtime_tool_policy_snapshot_for_run_at, - agent_runtime_tool_allowed_for_agent, + agent_runtime_tool_allowed_for_agent, agent_runtime_tool_policy_snapshot_for_run_at, agent_runtime_tool_requires_pending_revision_gate, agent_runtime_tool_requires_repository_context_fingerprint_gate, agent_runtime_verified_delivery_completion_plan_update, append_agent_db_record, @@ -107,9 +106,9 @@ pub(super) use crate::{ AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT, AGENT_RUNTIME_LOCK_STALE_AFTER_SECONDS, AGENT_RUNTIME_PENDING_ACTION_SCHEMA_VERSION, AGENT_RUNTIME_PENDING_ACTION_STATUS_APPROVED, AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING, - AGENT_RUNTIME_PENDING_ACTION_STATUS_OBSERVED_APPROVED, AGENT_RUNTIME_PLAN_STATUS_COMPLETED, - AGENT_RUNTIME_PLAN_ROOT_CHILD_TARGET_UNSUPPORTED_KIND, AGENT_RUNTIME_RESPOND_FUNCTION_NAME, - AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD, + AGENT_RUNTIME_PENDING_ACTION_STATUS_OBSERVED_APPROVED, + AGENT_RUNTIME_PLAN_ROOT_CHILD_TARGET_UNSUPPORTED_KIND, AGENT_RUNTIME_PLAN_STATUS_COMPLETED, + AGENT_RUNTIME_RESPOND_FUNCTION_NAME, AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD, AGENT_RUNTIME_RUN_PROFILE_STANDARD, AGENT_RUNTIME_SCHEMA_VERSION, AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE, @@ -117,7 +116,7 @@ pub(super) use crate::{ AGENT_RUNTIME_UI_PROTOTYPE_INSPECTION_KIND, AGENT_RUNTIME_UI_PROTOTYPE_LEGACY_VALIDATION_PROFILE, AGENT_RUNTIME_UI_PROTOTYPE_PATH, AGENT_RUNTIME_UI_PROTOTYPE_VALIDATION_PROFILE, AGENT_RUNTIME_UPDATE_PLAN_FUNCTION_NAME, - GAME_CREATOR_CONFIG_FILE_NAME, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, - GAME_CREATOR_USER_INPUT_REQUEST_TOOL, PROJECT_BLACKBOARD_MEMORY_PATH, + GAME_CREATOR_CONFIG_FILE_NAME, GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, GAME_CREATOR_USER_INPUT_REQUEST_TOOL, + PROJECT_BLACKBOARD_MEMORY_PATH, }; diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/sessions.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/sessions.rs index 2b6dc6663..06d3209ce 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/sessions.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/sessions.rs @@ -1247,9 +1247,7 @@ fn agent_conversation_sessions_preserve_legacy_and_isolate_new_history() { let legacy_history_path = legacy_history.path.replace('\\', "/"); let new_history_path = new_history.path.replace('\\', "/"); assert!(legacy_history_path.ends_with("design-director.jsonl")); - assert!(new_history_path.ends_with(&format!( - "design-director/sessions/{new_session_id}.jsonl" - ))); + assert!(new_history_path.ends_with(&format!("design-director/sessions/{new_session_id}.jsonl"))); let legacy_context = render_local_conversation_prompt_context_for_session( &root, Some("design-director"), diff --git a/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/identity_order_validation.rs b/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/identity_order_validation.rs index 806ac6e8d..cd62b4e6a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/identity_order_validation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/identity_order_validation.rs @@ -75,6 +75,11 @@ pub(super) fn validate_next_entry( if !same_durable_tool_plan_run(&previous.identity, &candidate.identity) { return Err("tool-plan 成功响应交接 entry 的 durable run 身份冲突".to_string()); } + if !same_tool_plan_repair_chain(&previous.identity, &candidate.identity) { + return Err( + "tool-plan 成功响应交接 entry 的 Provider/session binding 链身份冲突".to_string(), + ); + } if candidate.loop_iteration == previous.loop_iteration { if !same_tool_plan_repair_chain(&previous.identity, &candidate.identity) { return Err("tool-plan 成功响应交接同 loop repair 链身份冲突".to_string()); @@ -125,6 +130,36 @@ pub(super) fn same_tool_plan_repair_chain( current: &AgentRuntimeProviderRetryIdentity, candidate: &AgentRuntimeProviderRetryIdentity, ) -> bool { + let planning_binding_chain_matches = match ( + current.planning_session_binding.as_ref(), + candidate.planning_session_binding.as_ref(), + ) { + (None, None) => true, + (Some(left), Some(right)) => { + left.schema_version == right.schema_version + && left.project_id == right.project_id + && left.gdd_id == right.gdd_id + && left.agent_id == right.agent_id + && left.task_id == right.task_id + && left.session_id == right.session_id + && left.run_id == right.run_id + && left.root_agent_id == right.root_agent_id + && left.root_run_id == right.root_run_id + && left.delegation_id == right.delegation_id + && left.goal_id == right.goal_id + && left.goal_revision == right.goal_revision + && left.goal_snapshot_fingerprint == right.goal_snapshot_fingerprint + && left.source == right.source + && left.run_profile == right.run_profile + && left.run_profile_binding_fingerprint == right.run_profile_binding_fingerprint + && left.session_revision == right.session_revision + && left.session_fingerprint == right.session_fingerprint + && left.applied_steer_cursor == right.applied_steer_cursor + && left.request_kind == right.request_kind + && left.web_search_enabled == right.web_search_enabled + } + _ => false, + }; current.project_id == candidate.project_id && current.agent_id == candidate.agent_id && current.task_id == candidate.task_id @@ -138,6 +173,7 @@ pub(super) fn same_tool_plan_repair_chain( && current.request_kind == candidate.request_kind && current.provider_config_fingerprint == candidate.provider_config_fingerprint && current.allow_idle_context_compaction == candidate.allow_idle_context_compaction + && planning_binding_chain_matches } pub(super) fn request_slot_for_attempt( diff --git a/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/tests.rs index a8eb1ab98..c47495ccd 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/tests.rs @@ -76,6 +76,7 @@ fn identity_for(slot: &str, agent_id: &str, run_id: &str) -> AgentRuntimeProvide provider_config_fingerprint: "b".repeat(64), web_search_enabled: repair_attempt == 0, allow_idle_context_compaction: false, + planning_session_binding: None, } } diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 15f7b9f57..d33f4589b 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -1,5 +1,16 @@ # 决策记录 +## 2026-08-14 M1B-2 实现合同收口:Provider binding、结构化注入与提交恢复边界 + +- **状态与基线**:`M1B-1` 已通过门禁并合入,作为 `.agent/planning/` storage 基线;`M1B-2` 工作包已在隔离分支通过本包门禁,当前待合回原分支。当前包接 `plan.submit_gdd`、exact planning Provider 请求身份、提交点和恢复;`gdd-approval` planning pending、审批等待、receipt、审批命令与 UI 继续属于 `M1C-1` 及之后。本状态只表示 M1B-2 工作包完成,不表示完整产品可交付。 +- **binding 是有自指纹的 exact v1,不是可扩展 map**:`plan-provider-session-binding.v1` 在 `runId` 后固定包含 `rootAgentId`,并在 `requestContextFingerprint` 后以 required typed `fingerprint` 收尾;typed value 排除自身 fingerprint 但覆盖 `rootAgentId`,base Provider request ID value 同样在 `runId` 后覆盖 `rootAgentId`。`rootAgentId` 与末尾 fingerprint 是本次实现对委派根身份和 binding 自完整性的有意加固,不再称“额外字段”;缺失、重排、未知字段、重算不等或从当前状态补默认值都失败关闭。无 Goal 的唯一合法三元组是 `goalId=null / goalRevision=0 / goalSnapshotFingerprint=""`;Agent DB validator 不能先用通用非空 identity 门把这个合法空 fingerprint 拒掉。 +- **四类请求共用同一 captured context**:exact planning 的 `tool-plan | final-reply | context-compaction | final-reply-context-compaction` 全部写 `game-creator-provider-request-lifecycle.v3`、同一 required binding 和同一 structured injection;只有 `tool-plan` 能产生 action、`game-creator-provider-action-batch.v4` 与 `plan.submit_gdd`,其余三类无 action batch。planning idle context compaction 因没有 active run/session captured context 而明确不支持、fail-closed。request-context 的 `composition` 与 `sourceKind` 都固定为现役 Prompt Bundle 值 `runtime`,不得把 durable source `agent-delegate` 复制进 `sourceKind`;MCP 固定为空且 planning builder 不读取项目 MCP catalog,避免“先读后清空”制造额外依赖或漂移。 +- **structured injection 的 wire 冻结**:`plan-provider-structured-injections.v1` 顶层顺序为 `schemaVersion, clarificationRound, accumulatedAgentMillis, session, platformFacts, approvalObservation`;session 顺序为 `phase, decisionsSummary, prototypeValidationItems, latestSubmittedRef, lastDecisionRef`;平台事实复用固定 `PlanPlatformFacts`;approval observation 只能为 `null` 或现役 `tool, status, summary, detail` 四字段 strict 对象。canonical compact JSON 最大 64 KiB,以 dedicated user message 真正进入最终 `LlmRunRequest`:第一行 `AGC_PLAN_PROVIDER_STRUCTURED_INJECTIONS_V1`,第二行 JSON,无第三行与尾换行。同一第二行 JSON bytes 同时生成 `structuredInjections.wireBytes/wireSha256`,禁止重建另一份“语义相同”对象再摘要。 +- **submit 的包内 policy 与 session 真相**:`plan.submit_gdd` 配为 `confirm` 时按 deny 失败关闭,不创建 M1B-2 无法消费的 generic confirmation;显式 deny 同样拒绝。输入的 decisions 先逐项严格匹配 source session 的完整前缀,之后只允许追加 `default_pending/default/round=0` 的默认决定,不能把未提问项伪造成用户已确认。 +- **提交点不等于审批等待**:M1B-2 在 create-only GDD 提交点之后只重建 index、`game/fast_gdd.md` 与 session successor,再终止策划子 run/delivery;不创建 planning pending 或 waiting 投影。child terminal ensure 按原 action identity 可重入,task/event/delivery/Agent DB audit 各自幂等;delivery 必须发布后 exact 回读,未 durable 前不能先写 `recoveryPending=false` 的 committed audit。原 submit 在提交前已经建立的 generic `game-creator-pending-action.v5` standalone pending 与 v4 action batch 继续保留,供 M1C-1 receipt/terminal observation 按同 action identity 消费。standalone pending 保存 `providerBatchPlanUpdate`,使 pending-only 能精确重建完整 plan/planUpdate/batchId;batch-only 则补回同 identity pending。两枚 anchor 都在时严格对账;恰缺一枚且另一枚与 immutable GDD/frozen binding 严格匹配时确定性重建缺失投影;两枚都缺失、任一损坏或 identity 漂移时进入 reconciliation,不能只凭 GDD 猜完整 action/batch wire。双缺扫描覆盖 GDD commit 后、child finish 前且 Runtime `pendingToolAction=null` 的真实窗口,重复扫描不重复写 audit;已提交事实优先于其后的 repository/steer 漂移,同 action 只收口原投影、不生成新版本。 +- **本包门禁结果**:四类 request 的 binding/lifecycle/wire、structured DTO 字段与 64 KiB 边界、`composition/sourceKind=runtime`、idle compaction 拒绝、submit 业务/身份拒绝、提交点前后恢复、同 submission replay 不增版本、index/Markdown/session/child terminal 断点,以及 generic anchors 双在/单缺/双缺/漂移矩阵均已有定向证据;范围匹配的 Rust 门禁、`cargo check --offline`、`cargo fmt --check`、`npm run check:encoding` 与 `git diff --check` 已通过。隔离分支尚待合入;审批 pending/receipt/UI/构建准入仍不在本包。 +- 关联文档:`docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md` 第 3、8.1、9、12、21、23.6、23.8 节。 + ## 2026-08-14 M1C-0:用户修订状态进入静态委派 lineage 分类 - 落地:`StaticDelegateContractStatus` 新增 `UserRevisionRequested`(serde durable 值为 `user-revision-requested`)。`static_delegate_lineage_counters` 现在按三类传播:`NeedsUserInput` 只增加 `clarification_round`;`UserRevisionRequested` 原样继承 `repair_depth` 与 `clarification_round`;其它状态继续按质量返工增加 `repair_depth` 并重置 `clarification_round`。 diff --git a/docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md b/docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md index 2f0fd5eb0..c79d29b46 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 已在隔离 worktree 完成并通过本包验收门禁,已提交于隔离分支 `f453c2ca2`,待合回原分支**:已落地 `.agent/planning` storage module、strict schema/typed 指纹/canonical parser、GDD 版本链、session 原子恢复、Runtime 写入身份及只挡写门禁;golden vector 与 11 个定向 storage 测试通过,writer/index/recovery 及全仓库门禁已完成。**2026-08-14 `M1C-0` 亦已在隔离 worktree 完成并合回本分支**:仅新增用户修订状态及 lineage 分类,不包含审批写入方。`plan.submit_gdd`、审批闭环和 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-14 `M1B-2` 工作包已在隔离分支通过本包门禁,待合回原分支**:已落地 `plan.submit_gdd`、exact planning Provider binding/structured injection、专用提交点与崩溃恢复;本包不包含 `gdd-approval` planning pending、审批等待、receipt、审批命令或 UI。**2026-08-14 `M1C-0` 已合回本分支**:仅新增用户修订状态及 lineage 分类,不包含审批写入方。当前仅表示 M1B-2 工作包完成,完整审批闭环、UI、构建准入仍未实现,见第 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` 当前 worktree 已提供 storage 基础与写入隔离,但尚未宣称最终合入完成;立项策划入口、`plan.submit_gdd` 提交、审批 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` 工作包已在隔离分支通过本包门禁、待合入,但不构成完整可交付:审批 UI、receipt、审批等待、构建绑定和正式入口仍不可用 ## 1. 背景与目标 @@ -157,7 +157,7 @@ WP1 定稿的澄清轮次/返工深度语义(供 D11 依赖,权威定义见 | Rust source 常量 | `AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE` | | run profile | `standard`(Supervisor 根 run 与策划子 Agent 必须同为此值。**2026-08-13 按 D11 更正成立理由**:不是 ready-task 调度的约定,而是委派通用的父子继承——`bind_game_creator_agent_runtime_run_profile_at` 强制子 Run 等于父 Run 已绑定的 profile,「子 Run 不能切换父 Run 的 Run Profile」) | | Prompt composition | **不新增**。策划子 Agent 复用现役 `runtime` composition(委派/孤立子 Agent 通用模板),Supervisor 入口沿用现役 supervisor composition。**2026-08-13 按 D11 取代原冻结值 `projectPlanning`**:`manifest.json` 的 `compositions` 只有 `runtime`/`supervisor`/`supervisorChat` 三个固定字段,校验不遍历 `agentCatalog`,新增子 Agent 不需要也不能配第四套(依据见第 3.1 节) | -| Prompt source kind | `ProjectPlanning` | +| Prompt source kind | **不新增**;策划子 Agent 的 exact planning Provider request 固定使用现役 `runtime`,不得把 durable source `agent-delegate` 复制进 sourceKind | | 原生 action tool | `plan.submit_gdd`。**2026-08-13 按 D11 删除 `plan.request_decision`**:该工具是 D10「Runtime 直投」的配套,其存在的唯一理由是「standard ready-task 节点无 parent、Runtime 拦不住直接提问,只能另造工具绕开」;D11 下策划 Agent 是委派子 Agent、天然带 parent,问询改走 `AGC_NEEDS_USER_INPUT_V1` 终态信封 + Supervisor 中转,该工具连同 D10 一并作废,从未实现,全仓库零命中 | | 问询载体 | `AGC_NEEDS_USER_INPUT_V1` 终态信封(复用 PR #165 已实现的静态委派澄清中转,非本方案新增合同),单次信封 1~3 题 | | pending kind | `gdd-approval` | @@ -166,7 +166,8 @@ WP1 定稿的澄清轮次/返工深度语义(供 D11 依赖,权威定义见 | submit input schema | `plan-submit-gdd-input.v1` | | ~~回答解释 checkpoint schema~~ | **2026-08-13 随 D10 删除**(原值 `plan-decision-checkpoint.v1`)。D11 下解释由 continuation 子 Agent 的第一个普通 tool-plan turn 产生,走现役 handoff 通道,不需要专用 schema | | Provider session binding schema | `plan-provider-session-binding.v1`(durable lifecycle/batch 嵌套对象) | -| plan Provider request kinds | `tool-plan \| final-reply`(**2026-08-13 按 D11 移除 `plan-decision-checkpoint`**) | +| Provider structured injections schema | `plan-provider-structured-injections.v1`(作为 dedicated user message 进入实际 `LlmRunRequest`,精确 wire 见第 12 节) | +| plan Provider request kinds | `tool-plan \| final-reply \| context-compaction \| final-reply-context-compaction`(**2026-08-13 按 D11 移除 `plan-decision-checkpoint`**;2026-08-14 将 exact planning 的两类 context compaction 纳入同一 v3 binding) | | plan Provider request lifecycle schema | `game-creator-provider-request-lifecycle.v3` | | plan Provider action batch schema | `game-creator-provider-action-batch.v4` | | GDD schema | `plan-gdd.v1` | @@ -453,7 +454,7 @@ exact plan source 的 `user.input_request` 仍是四个 action tool 之一,不 } ``` -三个 option 的标签与顺序必须逐字等于上表;plan question ID 在现役 snake_case 规则上进一步限制为最多 32 个 ASCII 字符,并且不能映射成 `initial-request`。Runtime 确定性令 `decisionId = questionId.replace('_', '-')`,因此该 ID 必然满足第 8.3 节 GDD decision ID 合同,Provider 不能另选身份。Provider batch 仍须满足 `user.input_request` sole-action 规则;exact plan 的普通 tool-plan 只要产生 action,即使仅一项也必须强制 durable 写 v4 batch,不能走现役“少于两项则 NotNeeded”的优化。`user.input_request` 与 `plan.submit_gdd` 因此都有唯一 v4 member;decision-checkpoint/final-reply 无 action 才不创建 batch。非 plan source 的 input wire、数量和校验保持现状,任何额外 plan metadata 都因 unknown field 失败。 +三个 option 的标签与顺序必须逐字等于上表;plan question ID 在现役 snake_case 规则上进一步限制为最多 32 个 ASCII 字符,并且不能映射成 `initial-request`。Runtime 确定性令 `decisionId = questionId.replace('_', '-')`,因此该 ID 必然满足第 8.3 节 GDD decision ID 合同,Provider 不能另选身份。Provider batch 仍须满足 `user.input_request` sole-action 规则;exact plan 的普通 tool-plan 只要产生 action,即使仅一项也必须强制 durable 写 v4 batch,不能走现役“少于两项则 NotNeeded”的优化。Supervisor 的 `user.input_request` 与策划子 Agent 的 `plan.submit_gdd` 因此各有唯一 v4 member;exact planning 的 `final-reply`、`context-compaction`、`final-reply-context-compaction` 无 action、不创建 batch,但仍写第 12 节 v3 lifecycle/binding。非 plan source 的 input wire、数量和校验保持现状,任何额外 plan metadata 都因 unknown field 失败。 > **2026-08-13 按 D11 重写本节后半(原 D10「Runtime 直投」状态机整段作废)。** 原文描述的链路是:策划节点持续存活于同一 run,自己调 `user.input_request`,Runtime 在同一 run 内截获、写 `activeQuestion` session checkpoint,回答后再发一次 `plan-decision-checkpoint` 专用 Provider 请求取得设计解释,并以新 session primary 作为线性化点。D11 下这条链路的每一环都换了承载物,且**不是换实现是换机制**——用的是 PR #165 已发布、已有回归覆盖的静态委派澄清中转,不再自造状态机。 @@ -478,7 +479,7 @@ exact plan source 的 `user.input_request` 仍是四个 action tool 之一,不 **轮次计数。** 本轮是第几轮由委派链上的 `clarification_round` 派生值决定(沿 `repair_of_delegation_id` 上溯推断,语义见第 23.5 节),上限 3;session 不再自累加 `roundsUsed`。达到上限后 Runtime 在下一轮子 run 的上下文里注入「必须出稿」,若子 Agent 仍输出信封则拒绝该信封并要求改为 `plan.submit_gdd`。 -**Provider batch 规则。** exact plan 的普通 tool-plan 只要产生 action,即使仅一项也必须强制 durable 写 v4 batch,不走现役「少于两项则 NotNeeded」的优化。Supervisor 侧的 `user.input_request` 与策划子 Agent 侧的 `plan.submit_gdd` 因此各有唯一 v4 member;final-reply 无 action 才不创建 batch。非 plan source 的 input wire、数量和校验保持现状,任何额外 plan metadata 都因 unknown field 失败。 +**Provider batch 规则。** exact plan 的普通 tool-plan 只要产生 action,即使仅一项也必须强制 durable 写 v4 batch,不走现役「少于两项则 NotNeeded」的优化。Supervisor 侧的 `user.input_request` 与策划子 Agent 侧的 `plan.submit_gdd` 因此各有唯一 v4 member;exact planning 的 `final-reply`、`context-compaction`、`final-reply-context-compaction` 均无 action batch但仍写 v3 lifecycle/binding,planning idle compaction 不支持。非 plan source 的 input wire、数量和校验保持现状,任何额外 plan metadata 都因 unknown field 失败。 ### 5.3 Fast GDD 固定内容 @@ -539,7 +540,7 @@ MVP 明确排除多人、商城、服务器、开放世界、赛季、复杂社 【输出模式】 - 澄清模式:只提交当前决策卡,不输出完整 GDD。 -- 成稿模式:调用 plan.submit_gdd,等待审批;不展示内部思考过程。 +- 成稿模式:调用 plan.submit_gdd 后结束当前策划子 Run;审批由 Supervisor 承接,不展示内部思考过程。 - 收到 revise/reject observation 后,在同一 gddId 下修订并提交下一版本;收到 approve 后只做一句收尾确认。 ``` @@ -580,8 +581,8 @@ Runtime 还必须提供三类结构化注入,而不是让 Prompt 猜测:当 - 拒绝 NUL、DEL,以及除 LF/TAB 外的 C0 控制字符。长度按 Unicode scalar count;另执行 serialized UTF-8 byte 上限。不得静默截断。 - 一般 opaque Runtime ID 满足 `[A-Za-z0-9][A-Za-z0-9._:-]{0,127}`;现役 actionId 另固定为 `action-[0-9a-f]{24}`。`gddId` 为 `gdd-` 加小写 RFC 4122 UUID;`approvalRequestId` 为 `gdd-approval-` 加小写 UUID;approval command/receipt 的 responseId 为 `gdd-response-` 加小写 UUID。user-input answerResponseId 是明确例外:继续按现役 trim 后 1~160 scalar、无控制字符合同读取,UI 继续生成 `app-user-input-*`,不套用 GDD 审批前缀或一般 opaque ID 的 128 字符上限。 - 时间统一为 UTC、固定毫秒精度 `YYYY-MM-DDTHH:mm:ss.SSSZ`,由 Runtime 生成;输入时间不接受时区偏移或更高精度。 -- planning typed fingerprint(GDD、decision、receipt、session、pending、comment)完整匹配 `sha256-serde-json-v2:[0-9a-f]{64}`。现役 `actionFingerprint` 与 `runProfileBindingFingerprint` 保持已有 `[0-9a-f]{64}` 裸 digest,M1 不做全局格式迁移;两类字段不得互相比较。 -- submit input 与 GDD 最大 64 KiB,单个 plan decision checkpoint 4 KiB,Provider session binding 8 KiB,session 64 KiB,approval/pending 各 16 KiB,index 256 KiB,Markdown 128 KiB,hydrate view 512 KiB;限制按最终 UTF-8 bytes 计算。 +- planning typed fingerprint(GDD、decision、receipt、session、pending、comment、Provider session binding)完整匹配 `sha256-serde-json-v2:[0-9a-f]{64}`。现役 `actionFingerprint` 与 `runProfileBindingFingerprint` 保持已有 `[0-9a-f]{64}` 裸 digest,M1 不做全局格式迁移;两类字段不得互相比较。 +- submit input 与 GDD 最大 64 KiB,Provider session binding 8 KiB,Provider structured injections 64 KiB,session 64 KiB,approval/pending 各 16 KiB,index 256 KiB,Markdown 128 KiB,hydrate view 512 KiB;限制按最终 UTF-8 bytes 计算。(随 D10 作废的 plan decision checkpoint 不再分配容量。) - 一个 lineage 最多 128 个版本;版本是 `u32` 且只能为 `1..=128`。到达上限返回 `PLAN_VERSION_LIMIT_REACHED`,不能绕回、删除或另建 lineage。 - v1 所有 `basis` 必须为 `null`;非空值返回 `PLAN_UNSUPPORTED_KNOWLEDGE_BASIS`。 @@ -969,6 +970,7 @@ domain 固定为: | session | `genarrative.plan.session.v1` | `sessionFingerprint` | | approval pending | `genarrative.plan.gdd-approval-pending.v1` | `pendingFingerprint` | | decision comment | `genarrative.plan.gdd-comment.v1` | 无 | +| Provider session binding | `genarrative.plan.provider-session-binding.v1` | 末尾 `fingerprint` | | Provider request context | `genarrative.plan.provider-request-context.v1` | 无 | (**2026-08-13 删除**:原此处规定「回答解释 checkpoint 的字段声明顺序固定为第 5.2 节 `decisionCheckpoint` 内显示顺序」。该 payload 随 D10 的 `plan-decision-checkpoint` 请求 kind 一并作废,其 domain 也已从上表移除。D11 下这一轮的设计解释由 continuation 子 Agent 的第一个普通 tool-plan turn 产生,走现役 `tool_plan_handoff` 通道,不需要专用 typed 指纹。) @@ -977,7 +979,9 @@ domain 固定为: receipt fingerprint payload 的字段声明顺序固定为第 8.5 节除 `receiptFingerprint` 外的显示顺序。它包含 Runtime 生成的 `decidedAtUtc`,读取、恢复和构建准入时必须重算。 -exact plan 的 base Provider request ID 不复用现役换行拼接算法,也不改变非 plan request identity。它对 domain `genarrative.plan.provider-request-id.v1` 的 canonical envelope bytes 直接取 SHA-256,输出仍保持 `provider-request-<64 位小写 hex>`。value 字段声明顺序固定为 `projectId, gddId, agentId, taskId, sessionId, runId, rootRunId, delegationId, source, runProfile, runProfileBindingFingerprint, goalId, goalRevision, goalSnapshotFingerprint, sessionRevision, sessionFingerprint, appliedSteerCursor, requestKind, requestSlot, webSearchEnabled, requestContextFingerprint`;所有字段来自同一 captured context/lifecycle binding。(**2026-08-13 按 D11 更正**:移除随 D10 作废的 `supersededCheckpointProviderRequestIds`——它只服务 checkpoint 协议修复的传递闭包;补入 `rootRunId` / `delegationId`,因为 D11 下同一 gddId 会跨多个策划子 run,request identity 必须能定位到具体是哪一跳。)attempt 0 等于 base ID;attempt N>0 对 domain `genarrative.plan.provider-request-attempt.v1` 与 strict value `{baseProviderRequestId, attempt}` 的 canonical envelope bytes 取 SHA-256,并保持相同外形。合法 session/context 前滚会改变 base ID 并从 attempt 0 开始;只有同一 session/context 的 transient retry 或物理 interrupted retry 可增加 attempt。 +`plan-provider-session-binding.v1` 的 typed fingerprint value 字段声明顺序固定为 `schemaVersion, projectId, gddId, agentId, taskId, providerRequestId, sessionId, runId, rootAgentId, rootRunId, delegationId, goalId, goalRevision, goalSnapshotFingerprint, source, runProfile, runProfileBindingFingerprint, sessionRevision, sessionFingerprint, appliedSteerCursor, requestKind, requestSlot, webSearchEnabled, requestContextFingerprint`。durable JSON 在这些字段之后还必须以 `fingerprint` 收尾;该字段排除在自身 typed value 之外,但读取时必须按上述 domain 重算并逐字相等。`rootAgentId` 与末尾 required `fingerprint` 是 M1B-2 实现对委派根身份和 binding 自完整性的有意加固,属于 v1 当前 exact 合同,不得再称为“额外字段”或在恢复时补默认值;缺失、重排或夹带其它字段都失败关闭。 + +exact plan 的 base Provider request ID 不复用现役换行拼接算法,也不改变非 plan request identity。它对 domain `genarrative.plan.provider-request-id.v1` 的 canonical envelope bytes 直接取 SHA-256,输出仍保持 `provider-request-<64 位小写 hex>`。value 字段声明顺序固定为 `projectId, gddId, agentId, taskId, sessionId, runId, rootAgentId, rootRunId, delegationId, source, runProfile, runProfileBindingFingerprint, goalId, goalRevision, goalSnapshotFingerprint, sessionRevision, sessionFingerprint, appliedSteerCursor, requestKind, requestSlot, webSearchEnabled, requestContextFingerprint`;所有字段来自同一 captured context/lifecycle binding。(**2026-08-14 按当前实现收口**:在 D11 已补入的 `rootRunId` / `delegationId` 前进一步补入 `rootAgentId`,避免只凭 run 字符串猜根 Agent;随 D10 作废的 `supersededCheckpointProviderRequestIds` 继续不存在。)attempt 0 等于 base ID;attempt N>0 对 domain `genarrative.plan.provider-request-attempt.v1` 与 strict value `{baseProviderRequestId, attempt}` 的 canonical envelope bytes 取 SHA-256,并保持相同外形。合法 session/context 前滚会改变 base ID 并从 attempt 0 开始;只有同一 session/context 的 transient retry 或物理 interrupted retry 可增加 attempt。 ### 9.1 GDD golden vector @@ -1049,11 +1053,11 @@ flowchart LR ## 12. GDD 提交合同 -> **2026-08-13 按 D11 更正。** 提交者由「唯一那个 plan run」改为**策划子 Agent**(`agentId=project-planning`,`source=agent-delegate`);binding 补 `rootRunId` / `delegationId`;随 D10 作废的 `plan-decision-checkpoint` request kind 与 `supersededCheckpointProviderRequestIds` 数组一并移除。提交点、幂等域与 create-only 发布语义**未变**。 +> **2026-08-13 按 D11 更正。** 提交者由「唯一那个 plan run」改为**策划子 Agent**(`agentId=project-planning`,`source=agent-delegate`);binding 补 `rootRunId` / `delegationId`;随 D10 作废的 `plan-decision-checkpoint` request kind 与 `supersededCheckpointProviderRequestIds` 数组一并移除。**2026-08-14 再按 M1B-2 实现加固**:binding 与 typed/base request identity 补 `rootAgentId`,binding 末尾加入 required typed `fingerprint`。提交点、幂等域与 create-only 发布语义**未变**。 -`plan.submit_gdd` 由策划子 Agent 调用,但只有 Runtime 写文件。它必须是一次 Provider 响应中的唯一 action tool;同一响应可以更新 `update_agent_plan`,但不得把 submit 与 `file.read`、`file.list` 或第二次 submit 混入同一 action batch。(`user.input_request` 不在策划子 Agent 的工具面内,故不存在与它混批的情形;Supervisor 侧则不持有 `plan.submit_gdd`。)Runtime 在 batch 建立 durable actionId 之前拒绝混批,避免审批等待落在 generic multi-action cursor 中间。 +`plan.submit_gdd` 由策划子 Agent 调用,但只有 Runtime 写文件。它必须是一次 Provider 响应中的唯一 action tool;同一响应可以更新 `update_agent_plan`,但不得把 submit 与 `file.read`、`file.list` 或第二次 submit 混入同一 action batch。(`user.input_request` 不在策划子 Agent 的工具面内,故不存在与它混批的情形;Supervisor 侧则不持有 `plan.submit_gdd`。)Runtime 在 batch 建立 durable actionId 之前拒绝混批,避免 Runtime-owned commit 落在 generic multi-action cursor 中间。 -产生 submit 的 Provider request 必须先冻结 durable source-session binding,不能只放在内存: +每个 exact planning Provider request 都必须先冻结 durable source-session binding,不能只放在内存: ```jsonc { @@ -1065,6 +1069,7 @@ flowchart LR "providerRequestId": "provider-request-<64 位小写 hex>", "sessionId": "...", "runId": "...", + "rootAgentId": "project-supervisor", "rootRunId": "...", "delegationId": "...", "goalId": "...", @@ -1076,18 +1081,49 @@ flowchart LR "sessionRevision": 3, "sessionFingerprint": "sha256-serde-json-v2:", "appliedSteerCursor": 0, - "requestKind": "tool-plan", + "requestKind": "", "requestSlot": "loop-2-repair-0", "webSearchEnabled": false, - "requestContextFingerprint": "sha256-serde-json-v2:" + "requestContextFingerprint": "sha256-serde-json-v2:", + "fingerprint": "sha256-serde-json-v2:" } ``` -`requestContextFingerprint` 使用第 9 节 helper 与 domain `genarrative.plan.provider-request-context.v1`。canonical value 的字段声明顺序固定为 `effectiveModel, apiKind, stream, officialFallback, anthropicStrictToolSupport, openAiChatTokenBudgetField, maxOutputTokens, responseReasoningEffort, responseTextVerbosity, toolChoice, composition, sourceKind, webSearchEnabled, messages, nativeTools, mcpTools, structuredInjections`。前十项必须取最终不可变 `LlmRunRequest` 与同一 captured `LlmConfig`/adapter wire 配置的实际语义值:effectiveModel 是显式 model 或 Provider 默认值解析后的非空模型名;apiKind 只取 `openai_responses | openai_chat | anthropic`;stream 是实际请求体 boolean;officialFallback 对 `openai_chat/openai_responses` 为实际 boolean、对 anthropic 必须为 `null`;anthropicStrictToolSupport 只在 anthropic 为实际 boolean、其它 apiKind 必须为 `null`;openAiChatTokenBudgetField 只在 `openai_chat` 取 `max_completion_tokens | legacy_max_tokens`、其它 apiKind 必须为 `null`;maxOutputTokens 为 `null` 或正 `u32`;reasoning/verbosity 为 `null | low | medium | high`;toolChoice 为 `null | auto | required`。只有会改变当前 apiKind 实际 wire 的配置进入非空值,避免无关 adapter 配置制造假 context/base 漂移。composition/sourceKind 按第 4.2 节:策划子 Agent 复用现役 `runtime` composition,Supervisor 沿用现役 supervisor composition,**不新增第四套**(原此处写死的 `supervisorPlanChat` / `SupervisorPlanChat` 随 D6/D11 作废);webSearchEnabled 固定为 false;mcpTools 必须是显式空数组;structuredInjections 只含链路现算的澄清轮次/活跃毫秒、上述 Provider-facing session 语义投影、平台事实,以及存在时的审批业务 observation。`providerRequestId`、lifecycle/handoff ID、action/binding fingerprint 及其它 Provider/Runtime 控制元数据不得进入 messages、工具描述或 structured injection;它们只存在于 durable binding/base identity、session 私有 checkpoint 与恢复校验。 +binding 的 durable 字段顺序与上方 JSON 完全一致;第 9 节另行冻结排除末尾 `fingerprint` 后的 typed value 顺序,以及不含 `schemaVersion/providerRequestId/fingerprint` 的 base request ID value 顺序。`rootAgentId` 与末尾 required `fingerprint` 都是 v1 当前合同的一部分,不是 reader 可忽略的扩展字段。 + +每个 exact planning Provider request 都必须携带同一个 `plan-provider-structured-injections.v1` strict DTO;顶层字段顺序固定为 `schemaVersion, clarificationRound, accumulatedAgentMillis, session, platformFacts, approvalObservation`: + +```jsonc +{ + "schemaVersion": "plan-provider-structured-injections.v1", + "clarificationRound": 0, + "accumulatedAgentMillis": 0, + "session": { + "phase": "collecting", + "decisionsSummary": [], + "prototypeValidationItems": [], + "latestSubmittedRef": null, + "lastDecisionRef": null + }, + "platformFacts": { + "runtime": "self-contained-web", + "viewports": ["desktop", "mobile"], + "inputs": ["keyboard", "touch"], + "preview": "local-http" + }, + "approvalObservation": null +} +``` + +`session` 的字段顺序固定为 `phase, decisionsSummary, prototypeValidationItems, latestSubmittedRef, lastDecisionRef`,逐项复用当前 `plan-session.v1` 的 Provider-facing 语义类型;不得把 session identity、revision、fingerprint 或 Runtime 控制元数据带入。`platformFacts` 直接复用固定 `PlanPlatformFacts`,字段顺序与值均为上方所示。`approvalObservation` 只能是 `null`,或当前 observations 中最后一条 `tool=plan.submit_gdd + status=ok` 的现役 strict 四字段对象,字段顺序固定为 `tool, status, summary, detail`,其中 `detail` 必须显式为字符串或 `null`。DTO 的 canonical compact UTF-8 JSON 最大 64 KiB,不得 pretty print、追加空白、静默截断或省略 `null`/空数组。 + +上述 canonical JSON bytes 必须以一条 dedicated `user` message **实际进入最终 `LlmRunRequest`**,wire 精确为:第一行 `AGC_PLAN_PROVIDER_STRUCTURED_INJECTIONS_V1`,第二行该 compact JSON;两行之间只有一个 LF,不存在第三行,也没有尾换行。同一份第二行 JSON bytes 不经重建地同时生成 request-context 中 `structuredInjections.wireBytes` 与 `structuredInjections.wireSha256`;前者是该 JSON 的 `u32` UTF-8 byte 长度,后者是同一 bytes 的裸 64 位小写 SHA-256。消息摘要则继续覆盖实际发送的完整 user message,因此 header、LF 或 JSON 任一字节变化也会改变 `messages` 摘要。 + +`requestContextFingerprint` 使用第 9 节 helper 与 domain `genarrative.plan.provider-request-context.v1`。canonical value 的字段声明顺序固定为 `effectiveModel, apiKind, stream, officialFallback, anthropicStrictToolSupport, openAiChatTokenBudgetField, maxOutputTokens, responseReasoningEffort, responseTextVerbosity, toolChoice, composition, sourceKind, webSearchEnabled, messages, nativeTools, mcpTools, structuredInjections`。前十项必须取最终不可变 `LlmRunRequest` 与同一 captured `LlmConfig`/adapter wire 配置的实际语义值:effectiveModel 是显式 model 或 Provider 默认值解析后的非空模型名;apiKind 只取 `openai_responses | openai_chat | anthropic`;stream 是实际请求体 boolean;officialFallback 对 `openai_chat/openai_responses` 为实际 boolean、对 anthropic 必须为 `null`;anthropicStrictToolSupport 只在 anthropic 为实际 boolean、其它 apiKind 必须为 `null`;`openAiChatTokenBudgetField` 只在 `openai_chat` 取 `max_completion_tokens | legacy_max_tokens`、其它 apiKind 必须为 `null`;maxOutputTokens 为 `null` 或正 `u32`;reasoning/verbosity 为 `null | low | medium | high`;toolChoice 为 `null | auto | required`。只有会改变当前 apiKind 实际 wire 的配置进入非空值,避免无关 adapter 配置制造假 context/base 漂移。exact planning 四类请求的 `composition` 与 `sourceKind` 都固定为现役 Prompt Bundle 值 `runtime`,**不得把 durable Runtime source `agent-delegate` 复制进 `sourceKind`**,也不新增第四套 composition/source kind(原 `supervisorPlanChat` / `SupervisorPlanChat` 随 D6/D11 作废);webSearchEnabled 固定为 false;mcpTools 必须是显式空数组,且 `project-planning` 请求构建器直接使用空 catalog,**不得先读取项目 MCP catalog 再清空**;structuredInjections 只使用上方冻结的 DTO。`providerRequestId`、lifecycle/handoff ID、action/binding fingerprint 及其它 Provider/Runtime 控制元数据不得进入 messages、工具描述或 structured injection;它们只存在于 durable binding/base identity、session 私有 checkpoint 与恢复校验。 为遵守第 9 节“canonical payload 禁止 map/任意 Value”,messages 不是原始 JSON map,而是按实际发送顺序排列的 strict `{role, wireBytes, wireSha256}`;nativeTools 是按实际广告顺序排列的 strict `{name, kind, wireBytes, wireSha256}`,kind 只取 `action | control`;structuredInjections 固定为单个 strict `{wireBytes, wireSha256}`。`wireBytes` 是最终交给 provider-independent client 的单项 compact UTF-8 DTO `u32` byte 长度,`wireSha256` 是同一字节串的裸 64-hex SHA-256;消息顺序/正文、当前请求实际广告的工具名称/描述/参数 schema,以及注入对象的字段/null/空数组/正文任一字节变化都会改变它。策划子 Agent 的 tool-plan 按第 4.3 节广告恰好三个 action tool(`file.read` / `file.list` / `plan.submit_gdd`)与两个 control function;Supervisor 侧按现役 standard 工具面广告。(原此处的「四 action tool」计入了 `user.input_request`,随 D11 该工具不再属于策划子 Agent;「checkpoint 请求只广告 plan 专用 strict `update_agent_plan`」随 D10 作废。)Runtime 在内存中先完成 model/default、stream mode、adapter wire 配置与全部输出参数解析,再对实际 DTO 生成这些摘要并计算外层 typed fingerprint;lifecycle/batch 只持久化外层 fingerprint,不写 messages、Prompt、工具 schema 或注入正文。`requestTimeoutMs`、maxRetries、retryBackoffMs、rawLogDir、API Key、base URL 与 transport header 属于传输/诊断配置,不进入 fingerprint,也不得被用来改变请求正文、模型或输出语义;除这些明确排除项外,Provider client 实际收到的任一请求语义变化都必须改变 fingerprint 和 base providerRequestId。 -Goal 三元组沿用现役精确空值合同:没有 Goal 时必须是 `goalId=null + goalRevision=0 + goalSnapshotFingerprint=""`;存在 Goal 时 goalId 为合法 opaque ID、goalRevision 大于 0、goalSnapshotFingerprint 为该 durable Goal strict snapshot 的裸 64-hex SHA-256。其它组合失败关闭。该三元组进入 binding 与 base request ID,恢复不能从当前 Goal 补写旧请求。 +Goal 三元组沿用现役精确空值合同:没有 Goal 时必须是 `goalId=null + goalRevision=0 + goalSnapshotFingerprint=""`;存在 Goal 时 goalId 为合法 opaque ID、goalRevision 大于 0、goalSnapshotFingerprint 为该 durable Goal strict snapshot 的裸 64-hex SHA-256。其它组合失败关闭。Agent DB v3 validator 必须把空 fingerprint 只留给前一种显式无 Goal 组合,不能先用通用“非空 identity”校验把合法无 Goal binding 拒掉。该三元组进入 binding 与 base request ID,恢复不能从当前 Goal 补写旧请求。 同快照构建算法固定如下: @@ -1096,9 +1132,9 @@ Goal 三元组沿用现役精确空值合同:没有 Goal 时必须是 `goalId= 3. 发送前重新取得项目锁,逐项重读 session revision/fingerprint、appliedSteerCursor、run/profile/Goal identity 与 captured context。任一变化都丢弃整个已构建 object,回到第 1 步;不能局部替换 binding 或 messages。 4. 仍持锁时,从第 9 节规定的 plan request identity 生成稳定 base providerRequestId,解析本次 attempt,把上述 strict binding 先 durable 写入 lifecycle `started`;释放锁后发送的必须是第 2 步同一个 object,不能再次调用 builder。 -成功 response handoff 创建 provider batch 时,batch 必须持久化完全相同的 `providerRequestId + planningSessionBinding`,且 batch identity 计算覆盖两者;batch 自身的 runId 与 actionId/actionFingerprint 完成 request → context → session → run → action 关联。exact plan 的 final-reply 等无 action batch 请求仍写 v3 lifecycle 和同一 binding。(原此处描述的 `plan-decision-checkpoint` 特例随 D10 作废:D11 下不存在这种「有 handoff 无 batch」的专用请求 kind。)非 plan lifecycle/batch 不伪造该 binding。 +四种 exact planning request kind 都写 `game-creator-provider-request-lifecycle.v3`、同一 strict binding 与同一 structured-injection message。只有 `tool-plan` 能产生 action,因而也只有它能产生 `game-creator-provider-action-batch.v4` 或执行 `plan.submit_gdd`;`final-reply`、`context-compaction`、`final-reply-context-compaction` 一律没有 action batch。planning 的 idle context compaction 不具备 active run/session captured context,必须 fail-closed,不得借通用 idle compaction 伪造 binding。成功 `tool-plan` response handoff 创建 provider batch 时,batch 必须持久化完全相同的 `providerRequestId + planningSessionBinding`,且 batch identity 计算覆盖两者;batch 自身的 runId 与 actionId/actionFingerprint 完成 request → context → session → run → action 关联。若项目或 Agent policy 把 `plan.submit_gdd` 配为 `confirm`,M1B-2 必须把它按 deny 失败关闭,不能创建本包没有消费路径的 generic confirmation sidecar;显式 deny 同样拒绝。(原此处描述的 `plan-decision-checkpoint` 特例随 D10 作废:D11 下不存在这种「有 handoff 无 batch」的专用请求 kind。)非 plan lifecycle/batch 不伪造该 binding。 -外层 wire 同时冻结版本与兼容边界:exact plan 的新 lifecycle 必须使用 `game-creator-provider-request-lifecycle.v3`,在 v2 字段白名单后唯一增加 required `planningSessionBinding`;exact plan 的新 batch 必须使用 `game-creator-provider-action-batch.v4`,在 v3 字段白名单后唯一增加 required `providerRequestId` 与 `planningSessionBinding`,batch ID v4 覆盖新增字段。binding.providerRequestId 必须精确等于 lifecycle/batch 外层 request ID;agent/task/session/run/source/requestKind/requestSlot/webSearchEnabled 与存在于外层的同名字段也必须逐项相等。(**2026-08-13 删除** binding v1 的 `supersededCheckpointProviderRequestIds` 数组:它唯一的用途是维护「同一 session 内多次 checkpoint 协议修复」的传递闭包,而 D11 下每轮都是独立的新 run、上一轮已终态,不存在需要跨请求累积 superseded 闭包的情形。binding 改为携带 `rootRunId` 与 `delegationId`,把请求锚到具体委派跳。)非 plan 路径继续写现役 lifecycle v2 / batch v3。reader 继续按原合同双读既有 lifecycle v1/v2 与 batch v1/v2/v3,不原地升级或重写;这些旧记录只能按非 plan 语义恢复。任何记录声称 `source=project-supervisor-plan-chat`(作废字面量,全仓库零命中)或 `composition=supervisorPlanChat`,以及新 plan schema 缺 binding/夹带额外字段,都失败关闭并进入 reconciliation,不能用默认值补齐。 +外层 wire 同时冻结版本与兼容边界:exact plan 的新 lifecycle 必须使用 `game-creator-provider-request-lifecycle.v3`,在 v2 字段白名单后唯一增加 required `planningSessionBinding`;exact plan 的新 batch 必须使用 `game-creator-provider-action-batch.v4`,在 v3 字段白名单后唯一增加 required `providerRequestId` 与 `planningSessionBinding`,batch ID v4 覆盖新增字段。binding.providerRequestId 必须精确等于 lifecycle/batch 外层 request ID;agent/task/session/run/source/requestKind/requestSlot/webSearchEnabled 与存在于外层的同名字段也必须逐项相等。(**2026-08-14 按当前实现收口**:binding v1 携带 `rootAgentId` / `rootRunId` / `delegationId` 与末尾 required `fingerprint`,共同把请求锚到具体委派根和委派跳;随 D10 作废的 `supersededCheckpointProviderRequestIds` 数组继续不存在。)非 plan 路径继续写现役 lifecycle v2 / batch v3。reader 继续按原合同双读既有 lifecycle v1/v2 与 batch v1/v2/v3,不原地升级或重写;这些旧记录只能按非 plan 语义恢复。任何记录声称 `source=project-supervisor-plan-chat`(作废字面量,全仓库零命中)或 `composition=supervisorPlanChat`,以及新 plan schema 缺 binding/夹带合同外字段,都失败关闭并进入 reconciliation,不能用默认值补齐。 GDD handler 只能从已验证 batch binding 复制 `sourceSessionRevision/sourceSessionFingerprint`,并要求它与 started lifecycle 记录、request context 和 current session primary 逐项相等。GDD 已存在的同 submission replay 才按第 8.3/10.1 节复用既有 historical binding;已提交事实不因 session 后续前滚而被判 stale。 @@ -1124,18 +1160,18 @@ GDD handler 只能从已验证 batch binding 复制 `sourceSessionRevision/sourc **(2026-08-13 删除)**原此处一整段规定 `plan-decision-checkpoint` 的 stale/repair/superseded 状态机(`repair-{K+1}` 转换、传递闭包数组、handoff 与 successor 的应用边界)。该请求 kind 随 D10 作废,整段无对应物:D11 下续跑是「新 run、同 session」,其 stale 判据就是普通 tool-plan 的那一套(本节第 1~5 项),不需要第二套。 -main loop 不能把 submit 当成普通 action dispatch:在 durable action identity 建立后、生成普通 command ID 或进入 action executor 前,必须进入 `plan.submit_gdd` 专用分支。该分支重验 exact plan identity,执行下列提交与投影。**(2026-08-13 按 D11 与第 13.0 节更正)** 提交分支只负责校验、定版、写不可变 GDD、追加 index 与渲染 `game/fast_gdd.md`,**不创建 `gdd-approval` pending、也不把任何 run 投影为审批等待**——原文写的「随后把顶层 run 投影为 `waiting-for-user-input`」是 D9/D10 单 run 拓扑的残留:D11 下调用 submit 的是**策划子 run**,它提交完即终态结束;审批等待属于 Supervisor 根 run,且必须等第 13.0 节的验收取证通过后才创建。planning pending 的 `kind=gdd-approval` 仍用于区分审批等待,不新增第二个 queue outcome 或 run status。GDD create 成功不等于该 action 已 observed;只有 receipt 产生的确定性 terminal observation 才能完成原 action并让精确原 run 继续。 +main loop 不能把 submit 当成普通 action dispatch:在 durable action identity 建立后、生成普通 command ID 或进入 action executor 前,必须进入 `plan.submit_gdd` 专用分支。该分支重验 exact plan identity,执行下列提交与投影。**(2026-08-14 按 M1B-2 实现边界收口)** 本包只负责校验、定版、写不可变 GDD、重建 index、渲染 `game/fast_gdd.md`、安装 session successor 并终止策划子 run;**不创建 `.agent/planning/pending.json` / `gdd-approval` planning pending,不创建审批卡,也不把 Supervisor 或策划子 run 投影为审批等待**。`gdd-approval` pending 与 Supervisor 等待态属于 `M1C-1`,还要受第 13.0 节 `M1C-2a` 验收取证门约束。原 submit 在进入专用分支前已经建立的 generic `game-creator-pending-action.v5` standalone pending 与 `game-creator-provider-action-batch.v4` action batch 必须原样保留,作为后续 receipt/terminal observation 的同 action 恢复锚点;GDD create 成功不等于该 action 已 observed。 -1. 解析第 8.2 节 strict input;在项目锁内重读 project identity、active plan run、Provider request 所绑定的 session CAS、canonical GDD/receipt 与独立 planning pending。不信任 Provider payload 中不存在也不允许出现的版本、时间、平台事实或身份。 -2. 验证文本上限、轮次、决定状态和 prototype item 一一对应;Runtime 注入固定 platformFacts 和所有 `basis:null`,以当前 durable actionId/裸 action fingerprint 作为 submission identity。 -3. 若已有无 receipt 的 GDD,新的不同 submissionId 返回 `PLAN_PENDING_GDD_EXISTS`。最新 GDD 已有任一有效 approve/revise/reject receipt 后才允许分配下一版本:revise/reject 由精确原 run 的 observation continuation 继续;approve 后只允许用户显式发起第 14 节恢复矩阵所述新 plan continuation。旧 approved 在新版本提交和 revise/reject 期间仍有效,只有新版本 approve 才被 supersede。 -4. 版本取最后一个连续有效版本加一;版本 1~128,不允许缺号或扫描任意文件补号。 +1. 解析第 8.2 节 strict input;在项目锁内重读 project identity、策划子 run 与委派根身份、Provider request 所绑定的 session CAS、canonical GDD 链及原 submit 的 generic v5 standalone pending / v4 batch anchors。不信任 Provider payload 中不存在也不允许出现的版本、时间、平台事实或身份;M1B-2 不读取或创建尚未实现的 approval receipt / planning pending。 +2. 验证文本上限、轮次、决定状态和 prototype item 一一对应;`decisions` 必须先逐项等于 source session 的完整决定前缀,前缀之后只允许追加 `state=default_pending + answerSource=default + round=0` 的未提问默认决定,任何伪造为用户已确认的额外决定都按 session CAS 冲突拒绝。Runtime 注入固定 platformFacts 和所有 `basis:null`,以当前 durable actionId/裸 action fingerprint 作为 submission identity。 +3. M1B-2 尚无 receipt writer:只要已有任一 GDD,新的不同 submissionId 就返回 `PLAN_PENDING_GDD_EXISTS`;同 submissionId 只允许按历史 binding replay。`M1C-1` 接入有效 approve/revise/reject receipt 后,才把边界扩为“最新版本已有 receipt 才允许下一版本”。 +4. 当前 M1B-2 的首次版本固定为 1;未来版本仍只能取最后一个连续有效版本加一,范围 1~128,不允许缺号或扫描任意文件补号。 5. 新提交由 Runtime 生成并冻结 `approvalRequestId/createdAtUtc`,填充全部 durable identity、source session binding 和时间,计算 GDD fingerprint,以第 10.1 节算法 create-only 发布 `gdd.v{N}.json`。同 submissionId replay 必须先找到并严格读取既有 GDD,复用其中 Runtime 生成的版本、request/time 与 identity 后再比较,不能用新时间制造假冲突。 6. GDD target 完整发布并完成父目录同步是唯一提交点。提交点前失败不产生版本;提交点后任何投影失败都仍是已提交。 -7. 提交点后按固定顺序修复:重建 index/status → 按第 7.1 节渲染 Markdown → 创建/恢复独立 planning pending 与稳定 `approvalRequestId` → checkpoint session → task/state/event 与 waiting 投影。 +7. 提交点后按固定顺序修复:从严格 GDD 链重建 index/status → 按第 7.1 节渲染 Markdown → 以原 source session CAS 安装或确认精确的 `awaiting_gdd_approval` session successor → 将策划子 run 与 delivery 投影为已提交终态。child terminal ensure 必须可重入:task projection、action-scoped event、action-scoped Agent DB audit 和 delivery 都按原 action identity 幂等;delivery 发布后必须 exact 回读同 agent/session/run/delegation 的 `completed` 终态,未 durable 前不得写 `recoveryPending=false` 的 committed audit,重复恢复不得增加第二条逻辑投影。不得在这一步创建 planning pending 或任何 waiting 投影;completed 子 run 的 `waitingOn/nextStep/lastResponse` 也不得伪装成审批等待。原 generic v5 standalone pending 与 v4 batch anchors 也不得被 terminal cleanup 删除、标为 observed 或推进 cursor。 8. 同 submissionId + 同 canonical payload 返回同一 ref 并补投影;同 ID + 不同 payload 返回 `PLAN_SUBMISSION_IDENTITY_CONFLICT`,不得生成 vN+1。 -等待审批由 planning pending 专用恢复路径负责,不复用通用确认 pending 的 action re-execution。main loop 恢复时若 GDD 已提交而 batch sidecar 缺失,仍从不可变 GDD和独立 pending 恢复同一 waiting action;若遗留 batch 存在,只能核对其 actionId/fingerprint/source/profile 与 GDD 相等后作为普通投影清理,不能让 submit 再次落入 generic dispatch。receipt 后,专用 continuation 把第 13.1 节 observation 幂等写回原 run/action:approve 回到 final-reply,只允许一句收尾;revise/reject 回到 tool-plan/成稿循环并继续提交同一 gddId 的下一版本。receipt、observation、session 或 recovery 状态未收口,以及 revise/reject 后尚无下一待审版本时,`plan_gdd_completion_blocker` 必须阻止最终回复。 +M1B-2 的恢复只处理提交事实及其现役 generic anchors,不恢复审批等待。若 v5 standalone pending 与 v4 batch 都存在,必须逐项核对两者、binding 与 immutable GDD 的 `submissionId/actionFingerprint/agent/task/session/run/source/profile/root/delegation` 等身份并保持原状;standalone pending 还必须携带 v4 batch 独有但重建必需的 `providerBatchPlanUpdate` recovery material,使 pending-only 能逐字恢复原完整 plan、planUpdate 与 batchId。若恰有一个缺失,而另一个与 immutable GDD 严格匹配,则从“存活 anchor + immutable GDD + frozen binding”确定性重建缺失投影,重建后回读全等,不能重新调用 Provider 或重新分配 action/GDD identity;pending-only 补原 v4 batch,batch-only 补原 standalone pending。**两者同时缺失时即使 GDD 已提交也必须 fail-closed 进入 reconciliation**,因为只靠 GDD 不能无歧义恢复完整 action/batch wire;恢复扫描必须覆盖真实的“GDD 已 create、child 尚未 finish、Runtime `pendingToolAction=null`”窗口,并且重复扫描不得重复写 reconciliation audit。任一存活 anchor 损坏或与 GDD 不匹配同样 reconciliation,不得拿另一个覆盖。已提交 GDD 是先于当前 repository/steer 漂移的业务线性化证明,同 action replay 的 `recoveryPending` 必须能穿过通用 drift barrier 收口原投影,不能误开新请求或新 GDD 版本。`M1C-1` 后续才创建 Supervisor 的 planning pending,并在 receipt 后把第 13.1 节 terminal observation 幂等写回这些原 submit anchors,再按固定顺序完成与清理;M1B-2 不提前实现该消费路径。 结果 DTO: @@ -1149,12 +1185,14 @@ type PlanSubmitGddResult = { } ``` -只有所有提交后投影都 durable 时 `recoveryPending=false`。已越过提交点但投影未齐时仍按首次提交或重放返回 `submitted/replayed`,并以 `recoveryPending=true` 表示未收口;页面不得重新提交新版本,只能用同 action identity 触发恢复。outcome 只表达权威事实是首次创建还是重放,投影恢复状态只由 boolean 表达。 +只有 index、Markdown、session successor、策划子 run 终态及两枚 generic anchors 全部 durable 且 identity 对账通过时 `recoveryPending=false`。已越过提交点但这些投影未齐时仍按首次提交或重放返回 `submitted/replayed`,并以 `recoveryPending=true` 表示未收口;页面不得重新提交新版本,只能用同 action identity 触发恢复。outcome 只表达权威事实是首次创建还是重放,投影恢复状态只由 boolean 表达。 ## 13. 审批 pending、提交点与幂等 > **2026-08-13 按 D11 更正。** `gdd-approval` pending 建在 **Supervisor 根 run** 上(`source=project-supervisor-plan`),因为审批卡是给用户看的、而用户只跟 Supervisor 对话;提交 GDD 的策划子 run 在 `plan.submit_gdd` 返回后即终态,不等待审批。receipt、幂等域、`decisionFingerprint` / `receiptFingerprint` 与投影顺序**未变**。 +> **分包边界(2026-08-14)**:本节的 planning pending、审批等待、command 与 receipt 均从 `M1C-1` 起实现,不属于当前 `M1B-2`。M1B-2 只保留原 submit 的 generic v5 standalone pending + v4 batch anchors,二者不是审批 pending,也不得被改名或迁移为审批 pending。 + ### 13.0 审批前置门:验收取证必须先于审批卡(2026-08-13 补充) 原文只规定了「取证必须在 GDD 落盘之后」,**没有规定取证在用户审批之前还是之后**,第 13.3 节的 receipt 后投影顺序里也没有验收图这一步。该空白必须补上,因为反序会产生一个无回退路径的死角: @@ -1661,16 +1699,16 @@ Canvas 是条件外部能力,不是 M0-3 四个固定 owner 的通用前置: | 层 | 必测合同 | | --- | --- | -| schema | submit input + Provider session binding + hydrate view + 五个 durable/projection strict v1 struct;binding required superseded ID 数组与 session superseded handoff 摘要的 0/1/16/17 边界及逐项映射;unknown field/schema/enum;Agent 注入 Runtime 字段拒绝;ID/time/各类指纹形状;所有长度、数量、bytes 与 128 版本上限 | -| fingerprint | 本文 golden;中文/null/空数组/数组顺序;domain separation;任一 durable identity 变化;GDD/decision/receipt/session/pending/comment/request context;裸 action/profile/answers digest 不得带 typed 前缀 | +| schema | submit input + Provider session binding + Provider structured injections + hydrate view + 五个 durable/projection strict v1 struct;binding 的 `rootAgentId` 与末尾 `fingerprint` required,缺失/未知/重排失败关闭;structured injections 顶层、session、固定 `PlanPlatformFacts` 与四字段 observation 的 exact 顺序,`null`/空数组不省略,64 KiB 边界;unknown field/schema/enum;Agent 注入 Runtime 字段拒绝;ID/time/各类指纹形状;所有长度、数量、bytes 与 128 版本上限 | +| fingerprint | 本文 golden;中文/null/空数组/数组顺序;domain separation;任一 durable identity 变化;GDD/decision/receipt/session/pending/comment/Provider session binding/request context;typed binding 与 base request ID 都覆盖 `rootAgentId`;裸 action/profile/answers digest 不得带 typed 前缀 | | create-only storage | temp 强杀无半 primary;no-replace 竞态;父目录同步;existing same replay/different conflict;symlink/reparse/目录/硬链接/路径逃逸 | | session | revision/hash 合法 successor;missing primary 提升;primary corrupt fail closed;分叉 reconciliation;plan question strict mapping;sidecar absent/exact pending 的前置窗口;**delivery 问题落盘后才展示**;session `appliedAnswers` 与链路现算 `clarification_round` 不一致时以 delivery 为准并进 reconciliation、不得反写链路;pre-wait 的 v4 ready/0/单 approved member + sidecar pending + standalone absent 与 waiting 的 standalone exact 状态;answer-prepared 必须重验 waiting anchor;其它状态 fail closed;delivery 问题落盘 + waiting successor 不判 stale;答案绑回 delivery 即线性化(`answersSha256` 首次或逐字相同幂等、不同答案拒绝);不同 request 合法复用 answerResponseId;prototypeValidationItems/appliedAnswers 同异 replay;session 已落而 sidecar/observation 落后的恢复 | -| Provider binding | effective model/api kind/stream/当前 apiKind 生效的 official fallback/Anthropic strict/OpenAI Chat token field/output tokens/reasoning/verbosity/tool choice、messages/structured injection/实际工具目录与 binding 来自同 captured session;不适用 adapter 字段为 null;Provider 元数据不得注入;任一实际语义字段变化均改变 requestContextFingerprint/base ID;plan base ID/attempt vector;旧 started 必须先 failed/interrupted 再开下一 attempt;普通 tool-plan 的 started/ready/superseded;started + ready 先补 completed;GDD/activeQuestion/appliedAnswers 三类消费证明先于 stale;checkpoint same-session repair 原样继承数组、successor replacement 传递闭包、session 摘要在 handoff 清理后的 H1…Hn 稳定收口与分叉拒绝;checkpoint raw handoff 的超限/敏感键/绝对路径/容量/identity/durable write failure 只写安全诊断并 reconciliation、零自动 repair/retry;损坏 binding 只 reconciliation | -| submit | durable request/context/session/batch/action binding;缺失 binding 时丢弃旧响应;sole-action batch;main-loop 专用 waiting 分支;同 submission + 同/异 payload;复用既有 Runtime ID/time;session CAS;决定元数据与 session 真相逐项一致;已有未决版本;并发版本分配;版本到顶;GDD 提交点前后强杀 | +| Provider binding | effective model/api kind/stream/当前 apiKind 生效的 official fallback/Anthropic strict/OpenAI Chat token field/output tokens/reasoning/verbosity/tool choice、messages/实际工具目录与 binding 来自同 captured session;`composition=runtime`、`sourceKind=runtime`,不得复制 `agent-delegate`;dedicated user message 精确两行且无尾换行,同一第二行 JSON bytes 生成 structuredInjections wire 摘要;tool-plan/final-reply/context-compaction/final-reply-context-compaction 均写 v3 binding,只有 tool-plan 可写 v4 batch,planning idle compaction fail-closed;不适用 adapter 字段为 null;任一实际语义字段变化均改变 requestContextFingerprint/base ID;plan base ID/attempt vector;旧 started 必须先 failed/interrupted 再开下一 attempt;损坏 binding 只 reconciliation | +| submit | durable request/context/session/batch/action binding;缺失 binding 时丢弃旧响应;sole-action v4 batch;main-loop 专用 commit 分支且零 approval waiting;同 submission + 同/异 payload;复用既有 Runtime ID/time;session CAS;决定元数据与 session 真相逐项一致;M1B-2 已有 GDD 时拒绝不同 submission;GDD 提交点前后强杀;提交后 index/Markdown/session successor/child terminal;generic v5/v4 anchors 都在时严格对账、单缺时确定性重建、双缺或漂移时 reconciliation | | approval | 同 response 同/异决定;不同 response 同版本;不同版本合法复用 responseId;两个窗口并发;旧卡决定新版本;approve/revise/reject comment;approve 后显式 continuation 提交并批准新版本;响应丢失重试 | -| projection recovery | index/Markdown/audit/observation/session/独立 pending 每个断点恢复;现役 global pending v5 零迁移;恰一逻辑 audit/observation;无永久 stale 卡 | +| projection recovery | M1B-2 覆盖 index/Markdown/session successor/child terminal/generic v5+v4 anchors 每个断点恢复且零 planning pending;M1C-1 才覆盖独立 planning pending/audit/terminal observation/receipt 后 cleanup;现役 global pending v5 零迁移;恰一逻辑 audit/observation;无永久 stale 卡 | | agent.db | 专用幂等 helper;same key conflict;日志达到普通容量、尾部截断与压缩后仍能补齐并保留决定记录 | -| source/security | durable exact identity;四 action tool 广告与执行;MCP 空且 webSearchEnabled=false;control functions 单列;tool-plan/batch/ledger/context/repair/completion 全部跳过 collaboration;plan retry 保留 source/profile;所有副作用工具拒绝 | +| source/security | durable exact identity;三个 action tool(`file.read` / `file.list` / `plan.submit_gdd`)广告与执行;MCP 空且 webSearchEnabled=false;control functions 单列;tool-plan/batch/ledger/context/repair/completion 全部跳过 collaboration;plan retry 保留 source/profile;除 Runtime-owned submit 外的副作用工具拒绝 | | Prompt | **2026-08-13 按 D11 改写**:不新增 composition,Supervisor 根 run 沿用现役 supervisor composition、策划子 Agent 复用 `runtime` composition(见第 4.2 节);`decision-checkpoint` 请求 kind 随 D10 作废(见第 5.1 节)。仍冻结:3 轮/单题/固定选项;回答后设计解释与 prototype item;平台事实;恢复摘要;direct-out 条件 | | frontend | 默认入口与直接开建;stable approvalRequestId/responseId;busy;stale card;hydrate strict input/view;无目录空态;receipt 隐藏 stale pending;corrupt authority typed error;project open/reload/resume/submit/decision 刷新;recovery pending;批准并开建两命令顺序 | | M2 integration | explicit approved/direct mode;锁内重验 receipt;ref 贯穿 task/run/completion/context;无 ref 非回归;恢复不换稿 | @@ -1713,7 +1751,7 @@ M0 文档 PR 本身最低验证:Markdown 结构与三张 Mermaid 图可解析 | provider batch ledger | `apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_batch_ledger.rs:130-230,319-372,432-464,545-598` | validate/write/recovery 都不得为 plan 绑定或恢复 collaboration snapshot;submit sole action | | user.input_request / 静态委派澄清中转 | `apps/ai-game-creator-shell/src-tauri/src/user_input.rs:21-43,209-307,534-623,765-778,835-898`、`apps/ai-game-creator-shell/src/features/agent-runtime/model.ts:1164-1169`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/interaction.rs:444-548`、`apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/model.rs:86-157`、`apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/ledger.rs:126-205` | 现役 input 只有 questions;M1 保持 wire 与 answer responseId 兼容合同,plan 额外限制单题/固定选项/32 字符 ID,非 plan 行为不变。**2026-08-13 作废后半段**:「answer-prepared 后发起只含 strict `update_agent_plan` 的 checkpoint turn,success handoff 后 session CAS」是 D10「Runtime 直投」机制,D11 下该请求 kind 不存在——问题落 delivery、线性化点是答案绑回 delivery、解释与下一步由 continuation 子 Agent 的第一个普通 tool-plan turn 完成(见第 5.1 节);其专用 handoff 契约随之作废(见第 23.1 节) | | 现役 pending wire | `apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs:26-27`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_action_batch.rs:3-46,219-270`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/pending_confirmation_ledger.rs:290-520` | 保持 `game-creator-pending-action.v5`;M1 另建 planning pending,不升级全局 wire | -| submit 等待分支 | `apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/models.rs:5-21`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs:2547-2626`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_queue.rs:347-408`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_projection.rs:3-90` | 参照 user-input special case,在普通 dispatch 前处理 submit,并复用现有 WaitingForUserInput outcome/queue 消费语义 | +| submit 专用提交/恢复分支 | `apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_recovery.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/recovery_scan.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_action_batch.rs` | **2026-08-14 按 M1B-2 收口**:在普通 dispatch 前处理 Runtime-owned submit;提交后终止策划子 run,并保留 generic v5 standalone pending + v4 batch anchors,不复用 `WaitingForUserInput`,不创建 planning pending。审批等待属于 M1C-1 | | JSON sidecar | `apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/json_sidecar.rs:44-104,122-250` | 现有 writer 可覆盖;不可变文件必须新增 no-replace helper | | 项目锁 | `apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs:85-138`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs:1467-1505` | 所有 planning mutation 在同一项目锁内重读事实 | | completion blocker | `apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs:808-860,934-948`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs:1792-1832` | **2026-08-13 收窄**:现役 collaboration blocker 对**策划子 Agent**返回不适用;Supervisor 根 run 侧不再整体豁免(见第 4.3 节)。仍需新增 plan GDD 专用完成门,检查提问/审批等待、receipt、observation、session 与 recovery 状态 | @@ -1843,7 +1881,7 @@ M0 收口时的三项已知残留,均已定性且不阻塞后续阶段: - `set_task_status` 无秩序守卫的无条件覆盖,属 master 既有问题,另行提 issue,不在 M0 范围内修。 - 第 23.1 节的 M1 入口前置决策:**2026-08-13 起为空**。三项当日全部关闭——「plan source 与 Goal Contract 协议的关系」裁决为进可信 matcher;「plan run 是否允许 steer」裁决为不允许;「checkpoint handoff 私有持久化」随 D10 一并作废、无需裁决。M1 不再有未决的合入前置决策。 -M0 完成不表示完整策划闭环已经上线。`M1A-1`、`M1A-2`、`M1A-3` 已落地 plan source、两层工具面/角色 brief 以及根 run retry 身份保源;`plan.submit_gdd`、`.agent/planning` strict schema/版本链、审批 pending/receipt、前端入口和构建准入仍待后续 M1B~M1E 工作包。第 19 节第 2 条中关于工具面与执行拒绝的目标不变量已由 M1A-2 覆盖,其余 GDD 事实与审批不变量仍是后续实现目标。 +M0 完成不表示完整策划闭环已经上线。`M1A-1`~`M1A-4`、`M1B-1` 与 `M1C-0` 已合入,其中 `.agent/planning` strict schema/版本链已由 M1B-1 提供;`M1B-2` 工作包已在隔离分支通过本包门禁、待合入,审批 pending/receipt、前端入口和构建准入仍待后续 M1C~M1E 工作包。第 19 节第 2 条中关于工具面与执行拒绝的目标不变量已由 M1A-2/M1A-4 覆盖,其余 GDD 审批不变量仍按第 23.8 节推进。 ### 23.5 `WP1` / `WP2`:静态委派澄清轮次与返工深度拆分(2026-08-13 完成) @@ -1883,7 +1921,7 @@ M0 完成不表示完整策划闭环已经上线。`M1A-1`、`M1A-2`、`M1A-3` | 三 | `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`、`M1C-0` 已落地**;`M1B-1` 已于 2026-08-14 在隔离 worktree 完成并通过本包验收门禁,已提交于隔离分支 `f453c2ca2`,待合回原分支:storage 基础、strict schema、typed 指纹、版本链、session 原子恢复、只挡写门禁及 writer/index/recovery 验证均已完成,golden vector 与 11 个定向 storage 测试通过。`M1A-2` 只交付工具面、brief 注入和拒绝边界,`M1C-0` 只交付用户修订 lineage 分类,均不包含 GDD 提交/审批;`M1B-2` 及之后未开始。合入门见第 23.8 节 | +| 五 | M1 本体:策划闭环功能实现 | **`M1A-1`、`M1A-2`、`M1A-3`、`M1A-4`、`M1B-1`、`M1C-0` 已落地并合入**;其中 `M1B-1` 的 storage 基础、strict schema、typed 指纹、版本链、session 原子恢复、只挡写门禁及 writer/index/recovery 验证均已完成,golden vector 与 11 个定向 storage 测试通过。**`M1B-2` 工作包已在隔离分支通过本包门禁、待合入**:已接入 `plan.submit_gdd`、四类 exact planning v3 binding/structured injection、v4 sole-submit batch、create-only 提交点、index/Markdown/session successor、策划子 run 终止及 generic v5/v4 anchor 恢复;不创建 `gdd-approval` planning pending 或审批等待。`M1C-1` 及之后仍未实现。合入门见第 23.8 节 | 批二在 2026-08-13 拆成两半,因为其中一半在 M1 代码存在之前**做不完**: @@ -1963,8 +2001,8 @@ M0 完成不表示完整策划闭环已经上线。`M1A-1`、`M1A-2`、`M1A-3` | `M1A-2` | 两层工具面 + `project-planning` 角色 brief | `M1A-1` | **已落地**:Supervisor 根 run 保持 standard 工具面;planning 子 Agent 按 `agent-delegate`/`standard`/Supervisor parent 身份构建 exact native allowlist(`file.read`、`file.list` 与两个协议控制函数),MCP 为空、web search 关闭、collaboration policy 为 `null`;Prompt Bundle brief 只注入 `project-planning`,repair/rebuild 与初始请求一致;伪造写入、命令、MCP、`project.search` alias、`user.input_request` 等原始工具在广告/解析/执行/恢复路径均 fail-closed。**不包含 `plan.submit_gdd` 注册或 GDD 提交/审批。** | | `M1A-3` | plan 根 run 强判据函数 + retry 保源(本节下方「`M1A-3` 的由来」,规格见第 4.1 节第 5、7 段) | `M1A-1` | **已落地**(source 保源与强判据)。`kind=plan-root-retry-identity-unsupported`;gui/cli 兜底不变。第 4.1 节第 7 段里依赖 plan-session / `gddId` / `gdd-approval` kind 的部分仍后置。必须早于 `M1C-2a` | | `M1A-4` | plan 根 run 的子 Agent 创建面收窄:`agent.delegate` 目标必须是 `project-planning`、`agent.spawn_isolated` 一律拒;plan source 下不拼 `supervisorIntro` 与 `$visualContract` | `M1A-2` | 执行层与上下文层**都要做且不可互相替代**(同第 19 节第 2 条纪律);`agent.spawn_isolated` 是第二条造子 Agent 的通道,只堵 `agent.delegate` 视为未完成;强判据 `Err` 必须落进拒绝分支而非当作「不是 plan 根」;`project-supervisor-gui/cli` 的委派与 spawn 行为正常路径不变。**必须早于 `M1D-2` 与 `M1E`** | -| `M1B-1` | `.agent/planning/` 存储层、strict schema、typed 指纹、版本链;含 `.agent/planning/**` 只挡写判据 | `M1A-2` | **2026-08-14 已在隔离 worktree 完成并通过本包验收门禁,已提交于隔离分支 `f453c2ca2`,待合回原分支**:GDD / index / session strict DTO、canonical bytes、duplicate-key 与后缀门、typed 指纹、连续版本链、session 原子写入/恢复、Runtime writer identity 及 `game/fast_gdd.md` / `.agent/planning/**` 通用写入拒绝;第 9.1 节 golden vector(3857 bytes)与 11 个定向 storage 测试通过,writer 目标 schema 重验、index 权威对账与锁内 index recovery(缺失/损坏/陈旧从严格 GDD 链重建)、恢复故障矩阵、`cargo check --offline`、`npm run check:encoding` 与 `git diff --check` 均通过。当前无 approval receipt schema,多版本 `statusCache` 只是无 receipt 的预审批投影(最新版本 `ready_for_approval`、旧版本 `superseded`);M1C-1 接入 receipt 后必须重建 `approved/revise/reject/superseded` 状态。`plan.submit_gdd`、审批闭环仍留给 `M1B-2` 及后续包;create-only 与等前缀不可变 | -| `M1B-2` | `plan.submit_gdd` 原生工具与提交点 | `M1B-1` | 全部拒绝分支;提交点前后强杀恢复;同 submissionId replay 不产生 vN+1 | +| `M1B-1` | `.agent/planning/` 存储层、strict schema、typed 指纹、版本链;含 `.agent/planning/**` 只挡写判据 | `M1A-2` | **2026-08-14 已通过门禁并合入本分支**:GDD / index / session strict DTO、canonical bytes、duplicate-key 与后缀门、typed 指纹、连续版本链、session 原子写入/恢复、Runtime writer identity 及 `game/fast_gdd.md` / `.agent/planning/**` 通用写入拒绝;第 9.1 节 golden vector(3857 bytes)与 11 个定向 storage 测试通过,writer 目标 schema 重验、index 权威对账与锁内 index recovery(缺失/损坏/陈旧从严格 GDD 链重建)、恢复故障矩阵、`cargo check --offline`、`npm run check:encoding` 与 `git diff --check` 均通过。合入时无 approval receipt schema,多版本 `statusCache` 只是无 receipt 的预审批投影;M1C-1 接入 receipt 后必须重建 `approved/revise/reject/superseded` 状态。create-only 与等前缀不可变 | +| `M1B-2` | `plan.submit_gdd` 原生工具、exact planning Provider 请求绑定与 GDD 提交点 | `M1B-1` | **工作包已在隔离分支通过本包门禁、待合入**。实现合同:四类 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 门禁均已通过;完整审批链路与产品可交付仍留给后续工作包 | | `M1C-0` | 新增 `StaticDelegateContractStatus::UserRevisionRequested` 与分类分支 | `M1A-1` | **已落地**:无审批写入方、是惰性路径;用户修订跳不增 `repair_depth` 也不重置 `clarification_round`,连续修订可通过;做游戏链路返工仍在 `depth=1` 被拒;历史记录与未知状态均保持 fail-closed | | `M1C-1` | `gdd-approval` pending、审批命令、receipt;receipt 写入上述 status | `M1B-2`、`M1C-0` | 三动作全通;版本+指纹竞态防护;两窗口并发;**连续多次修订均可通过且 `repair_depth` 不变** | | `M1C-2a` | Goal Contract 接线:turn 1 冻结、固定验收图、审批前置门取证 | `M1C-1`、`M1A-3` | turn 1 只能是一个 `agent.goal_contract`;`acceptanceNodes` 不接受自定义;**第 13.0 节审批前置门:取证未通过时不出现审批卡、而是产生返工委派**(取证顺序是协议时序问题,归本包而非 `M1C-1`) | @@ -1973,9 +2011,11 @@ M0 完成不表示完整策划闭环已经上线。`M1A-1`、`M1A-2`、`M1A-3` | `M1D-2` | 入口分流与阶段进度 | `M1D-1` | 「直接开建」跳过路径与现状零差异 | | `M1E` | 端到端与故障注入收口 | `M1D-2` | 第 21 节测试矩阵中跨层场景 | -**2026-08-14 `M1B-1` 实现验收快照(已提交,待合回原分支)**:当前隔离 worktree 的实现集中在 `apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_storage.rs`,并由 `runtime_protocol.rs` 注册。已具备 `plan-gdd.v1`、`plan-gdd-index.v1`、`plan-session.v1` 及 `plan.submit_gdd` input 的 strict serde 形状校验、文本/ID/时间/枚举边界、typed serde fingerprint、canonical JSON(重复键、BOM、尾空白、字段顺序)解析、GDD 连续版本链、session `revision + 1` / `previousFingerprint` 链、create-only durable writer、session 原子替换与受限 recovery,以及 `project-planning / agent-delegate / standard / project-supervisor` writer identity。通用 `file.write`、`file.patch`、`file.delete`、`project.patchset` 与 checkpoint restore 对 `.agent/planning/**` 和 `game/fast_gdd.md` 只挡写,planning 的 `file.read` / `file.list` 仍可读;本包没有注册或执行 `plan.submit_gdd`,也没有实现 approval pending、receipt、UI 或构建准入。 +**2026-08-14 `M1B-1` 合入验收快照**:已合入实现集中在 `apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_storage.rs`,并由 `runtime_protocol.rs` 注册。已具备 `plan-gdd.v1`、`plan-gdd-index.v1`、`plan-session.v1` 及 `plan.submit_gdd` input 的 strict serde 形状校验、文本/ID/时间/枚举边界、typed serde fingerprint、canonical JSON(重复键、BOM、尾空白、字段顺序)解析、GDD 连续版本链、session `revision + 1` / `previousFingerprint` 链、create-only durable writer、session 原子替换与受限 recovery,以及 `project-planning / agent-delegate / standard / project-supervisor` writer identity。通用 `file.write`、`file.patch`、`file.delete`、`project.patchset` 与 checkpoint restore 对 `.agent/planning/**` 和 `game/fast_gdd.md` 只挡写,planning 的 `file.read` / `file.list` 仍可读;M1B-1 合入时没有注册或执行 `plan.submit_gdd`,也没有实现 approval pending、receipt、UI 或构建准入。 -当前已验证第 9.1 节 golden vector(canonical envelope 3857 bytes 与指纹 `sha256-serde-json-v2:a59856de7ef134cf2f49c4dedd2ba10ae4ab2340a9634d402eb792b6ee5458f0`)及 11 个定向 storage 测试;durable writer 的目标 schema 重解析与文件名/版本核对、index 与权威 GDD 对账、缺失/损坏/陈旧 index 的锁内链重建、`(requestId,responseId)` / decision/ref / active run/phase 等 session 约束、GDD 文件枚举/链读取及 primary 损坏/previous 提升/分叉 recovery 矩阵均已覆盖。M1B-1 尚无 approval receipt schema,因此多版本 `statusCache` 只表达无 receipt 的预审批投影;M1C-1 接入 receipt 后重建真实 approved/revise/reject/superseded 状态。`clarification_round` 与完整 root/session identity 绑定留给后续 `M1B-2` / `M1C-2b` 接线。`cargo check --offline`、`npm run check:encoding`、`git diff --check` 均通过;本包已提交于隔离分支 `f453c2ca2`,待合回原分支,不把 `plan.submit_gdd`、审批闭环、UI 或 renderer 误报为已实现。 +已验证第 9.1 节 golden vector(canonical envelope 3857 bytes 与指纹 `sha256-serde-json-v2:a59856de7ef134cf2f49c4dedd2ba10ae4ab2340a9634d402eb792b6ee5458f0`)及 11 个定向 storage 测试;durable writer 的目标 schema 重解析与文件名/版本核对、index 与权威 GDD 对账、缺失/损坏/陈旧 index 的锁内链重建、`(requestId,responseId)` / decision/ref / active run/phase 等 session 约束、GDD 文件枚举/链读取及 primary 损坏/previous 提升/分叉 recovery 矩阵均已覆盖。M1B-1 尚无 approval receipt schema,因此多版本 `statusCache` 只表达无 receipt 的预审批投影;M1C-1 接入 receipt 后重建真实 approved/revise/reject/superseded 状态。`cargo check --offline`、`npm run check:encoding`、`git diff --check` 均通过;该包现已合入本分支,这一历史快照不把后续 `plan.submit_gdd`、审批闭环、UI 或 renderer 误报为 M1B-1 已实现。 + +**2026-08-14 `M1B-2` 本包验收快照(隔离分支,待合入)**:代码已把 `plan.submit_gdd` 接到原生工具目录、Provider 请求、v3 lifecycle/v4 batch、main-loop 专用提交分支与恢复扫描;同时以 required `rootAgentId + fingerprint` 加固 `plan-provider-session-binding.v1`,冻结并实际注入 `plan-provider-structured-injections.v1` dedicated user message。tool-plan、final-reply、context-compaction、final-reply-context-compaction 四类 exact planning 请求共享 v3 binding 和 structured injection,只有 tool-plan 能生成 v4 batch,planning idle compaction 失败关闭。提交点后的 M1B-2 投影仅为 index、Markdown、session successor、策划子 run 终态以及原 generic v5/v4 anchors 的保留/重建;`gdd-approval` planning pending、审批等待、receipt、审批命令和 UI 明确留给 `M1C-1` 及之后。本包定向 Rust、`cargo check --offline`、格式、编码与 diff 门禁已通过;这只表示 M1B-2 工作包完成,不表示完整产品或审批闭环可交付。 **拆包纪律**: