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 02f23be24..59fbd3d93 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions.rs @@ -44,6 +44,7 @@ pub(in crate::agent) use run_status_observation::*; pub(in crate::agent) use structured_plan::*; pub(in crate::agent) use tool_plan_protocol::*; +pub(crate) use crate::agent::runtime_protocol::plan_gdd_completion_blocker_at_locked; #[cfg(test)] pub(crate) use action_audit::agent_runtime_action_receipt_public_safe_detail_for_test; #[cfg(test)] diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/autonomous_policy.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/autonomous_policy.rs index da06bd6a8..39cde5bbd 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/autonomous_policy.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/autonomous_policy.rs @@ -575,6 +575,7 @@ pub(in crate::agent) fn validate_agent_runtime_autonomous_plan_liveness_at( let barrier = static_delegate_completion_barrier_at(root, agent_id, run_id)?; if barrier.ready_unclaimed_count > 0 || barrier.unobserved_claim_count > 0 + || barrier.user_revision_pending_count > 0 || barrier.unknown_contract_status_count > 0 { let active_delegations = @@ -583,10 +584,11 @@ pub(in crate::agent) fn validate_agent_runtime_autonomous_plan_liveness_at( return Ok(()); } return Err(format!( - "{AGENT_RUNTIME_AUTONOMOUS_SUPERVISOR_DELIVERY_CONVERGENCE_LIVENESS_ERROR_PREFIX};当前父 run 在准备专业 repair 前仍有 activeDelegations={active_delegations}、readyUnclaimedReceipts={}、unobservedReceiptClaims={}、repairRequired={}、unknownContractStatus={}。必须先只调用 agent.run_status(agentId=null、scope=all、delegationId=null)原子认领并观察已有回执,再基于权威合同准备 repair", + "{AGENT_RUNTIME_AUTONOMOUS_SUPERVISOR_DELIVERY_CONVERGENCE_LIVENESS_ERROR_PREFIX};当前父 run 在准备专业 repair 前仍有 activeDelegations={active_delegations}、readyUnclaimedReceipts={}、unobservedReceiptClaims={}、repairRequired={}、userRevisionPending={}、unknownContractStatus={}。必须先只调用 agent.run_status(agentId=null、scope=all、delegationId=null)原子认领并观察已有回执,再基于权威合同准备 repair", barrier.ready_unclaimed_count, barrier.unobserved_claim_count, barrier.repair_required_count, + barrier.user_revision_pending_count, barrier.unknown_contract_status_count, )); } @@ -601,6 +603,7 @@ pub(in crate::agent) fn validate_agent_runtime_autonomous_plan_liveness_at( let active_delegations = active_static_delegate_delivery_count_at(root, agent_id, run_id)?; let must_claim_or_wait = barrier.ready_unclaimed_count > 0 || barrier.unobserved_claim_count > 0 + || barrier.user_revision_pending_count > 0 || barrier.unknown_contract_status_count > 0 || active_delegations >= 3; if must_claim_or_wait { @@ -608,10 +611,11 @@ pub(in crate::agent) fn validate_agent_runtime_autonomous_plan_liveness_at( return Ok(()); } return Err(format!( - "{AGENT_RUNTIME_AUTONOMOUS_SUPERVISOR_DELIVERY_CONVERGENCE_LIVENESS_ERROR_PREFIX};当前父 run 的 activeDelegations={active_delegations}、waitingDelegations={}、readyUnclaimedReceipts={}、unobservedReceiptClaims={}、unknownContractStatus={}。必须先只调用 agent.run_status(agentId=null、scope=all、delegationId=null)认领并观察 ready delivery,或等待已有委派推进;不得创建第四次 agent.delegate。收束后若项目 revision 已推进,再验证当前 revision", + "{AGENT_RUNTIME_AUTONOMOUS_SUPERVISOR_DELIVERY_CONVERGENCE_LIVENESS_ERROR_PREFIX};当前父 run 的 activeDelegations={active_delegations}、waitingDelegations={}、readyUnclaimedReceipts={}、unobservedReceiptClaims={}、userRevisionPending={}、unknownContractStatus={}。必须先只调用 agent.run_status(agentId=null、scope=all、delegationId=null)认领并观察 ready delivery,或等待已有委派推进;不得创建第四次 agent.delegate。收束后若项目 revision 已推进,再验证当前 revision", barrier.waiting_count, barrier.ready_unclaimed_count, barrier.unobserved_claim_count, + barrier.user_revision_pending_count, barrier.unknown_contract_status_count, )); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs index 6d6cdbc22..57c8d3bd1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs @@ -893,7 +893,12 @@ pub(in crate::agent) fn static_delegate_barrier_has_waiting_deliveries(detail: & .find_map(|part| part.strip_prefix("unknownContractStatus=")) .and_then(|value| value.parse::().ok()) .is_some_and(|count| count > 0); - waiting || unknown_contract_status + let user_revision_pending = detail + .split_whitespace() + .find_map(|part| part.strip_prefix("userRevisionPending=")) + .and_then(|value| value.parse::().ok()) + .is_some_and(|count| count > 0); + waiting || user_revision_pending || unknown_contract_status } pub(in crate::agent) fn static_delegate_barrier_requires_repair(detail: &str) -> bool { @@ -904,6 +909,14 @@ pub(in crate::agent) fn static_delegate_barrier_requires_repair(detail: &str) -> .is_some_and(|count| count > 0) } +pub(in crate::agent) fn static_delegate_barrier_requires_user_revision(detail: &str) -> bool { + detail + .split_whitespace() + .find_map(|part| part.strip_prefix("userRevisionPending=")) + .and_then(|value| value.parse::().ok()) + .is_some_and(|count| count > 0) +} + pub(in crate::agent) fn process_session_completion_blocker_at_locked( root: &Path, agent_id: &str, @@ -948,6 +961,7 @@ pub(in crate::agent) fn agent_runtime_non_verification_completion_blocker_at_loc run_id: &str, ) -> Option { provider_retry_completion_blocker_at_locked(root, agent_id, run_id) + .or_else(|| plan_gdd_completion_blocker_at_locked(root, agent_id, run_id)) .or_else(|| provider_action_batch_completion_blocker_at_locked(root, agent_id, run_id)) .or_else(|| { supervisor_collaboration_policy_completion_blocker_at_locked(root, agent_id, run_id) 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 bbd4b2596..a1657d25d 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 @@ -1897,6 +1897,9 @@ async fn run_game_creator_agent_background_task_pass_without_deadline( &runtime.run_id, ) }) + .or_else(|| { + plan_gdd_completion_blocker_at_locked(&root, &agent_id, &runtime.run_id) + }) .or_else(|| game_creator_agent_goal_completion_blocker_at_locked(&root, &runtime)) .or_else(|| goal_contract_acceptance_completion_blocker_at_locked(&root, &runtime)) .or_else(|| { @@ -1943,6 +1946,24 @@ async fn run_game_creator_agent_background_task_pass_without_deadline( runtime.current_action = "等待 Provider action 批次收束".to_string(); runtime.waiting_on = "持久批次完成确认、执行、投影与 cursor 清理".to_string(); runtime.next_step = "先恢复原批次,不能请求新计划或提交最终回复".to_string(); + } else if blocker.tool == "runtime.plan_gdd" { + runtime.status = "running".to_string(); + if blocker.status == "blocked" + && blocker.detail.as_deref().is_some_and(|detail| { + detail.contains("approvalPending=awaiting_decision") + }) + { + runtime.phase = "waiting-for-user-input".to_string(); + runtime.current_action = "等待 Fast GDD 审批决定".to_string(); + runtime.waiting_on = "用户在审批卡选择批准、修改或退回".to_string(); + runtime.next_step = "等待 decide_game_creator_plan_gdd;不得重新提交同一 GDD 或自行创建审批 pending".to_string(); + } else { + runtime.phase = "needs-reconciliation".to_string(); + runtime.current_action = "Fast GDD 审批投影需要人工核对".to_string(); + runtime.waiting_on = "planning pending、receipt、原提交锚点、terminal observation、audit 与 session 的精确身份".to_string(); + runtime.next_step = + "先恢复或核对现有 durable 事实,不能请求新 Provider 计划".to_string(); + } } else if blocker.tool == "runtime.collaboration_policy" { runtime.status = "running".to_string(); runtime.phase = "planning".to_string(); @@ -1977,6 +1998,10 @@ async fn run_game_creator_agent_background_task_pass_without_deadline( .detail .as_deref() .is_some_and(static_delegate_barrier_requires_repair); + let user_revision_pending = blocker + .detail + .as_deref() + .is_some_and(static_delegate_barrier_requires_user_revision); let user_input_required = blocker.detail.as_deref().is_some_and(|detail| { detail .split_whitespace() @@ -1985,7 +2010,15 @@ async fn run_game_creator_agent_background_task_pass_without_deadline( .is_some_and(|count| count > 0) }); runtime.status = "running".to_string(); - if user_input_required { + if user_revision_pending { + runtime.phase = "planning".to_string(); + runtime.current_action = + "等待 Project Supervisor 发起用户修订续跑".to_string(); + runtime.waiting_on = + "审批卡修改/退回对应的 UserRevisionRequested delivery".to_string(); + runtime.next_step = + "调用 agent.delegate,并把 repairOfDelegationId 指向原 delivery;不得把用户修订计入 repair_depth".to_string(); + } else if user_input_required { let deliveries = match claimed_static_delegate_deliveries_at( &root, &runtime.agent_id, @@ -2092,6 +2125,24 @@ async fn run_game_creator_agent_background_task_pass_without_deadline( "持久化专业 Agent 回执等待状态失败", AgentBackgroundTaskOutcome::WaitingForDelegateReceipts, )) + } else if observation.tool == "runtime.plan_gdd" { + if observation.status == "blocked" + && detail.contains("approvalPending=awaiting_decision") + { + Some(( + "agent.runtime.plan.gdd.waiting", + "waiting-for-user-input", + "Fast GDD 审批等待状态持久化失败", + AgentBackgroundTaskOutcome::WaitingForUserInput, + )) + } else { + Some(( + "agent.runtime.plan.gdd.reconciliation", + "needs-reconciliation", + "Fast GDD 审批投影需要人工核对", + AgentBackgroundTaskOutcome::NeedsReconciliation, + )) + } } else { None } 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 f5269fe38..5ab91750b 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 @@ -823,6 +823,8 @@ pub(in crate::agent) fn resume_game_creator_agent_background_tasks_unredacted_at root: &Path, ) -> Result, String> { validate_project_root(root)?; + reconcile_plan_gdd_approval_projections_at(root) + .map_err(|error| format!("恢复 GDD approval 投影失败:{error}"))?; if external_agent_runner_owns_background_execution() { resume_external_agent_runner(root)?; return read_game_creator_agent_runtimes_at(root); 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 b4f8e487e..9a724c449 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol.rs @@ -8,6 +8,7 @@ mod context_window; mod finalization; mod json_sidecar; mod models; +mod planning_approval; mod planning_storage; mod planning_submit; mod provider_control; @@ -23,6 +24,7 @@ pub(in crate::agent) use context_bundle::*; pub(in crate::agent) use finalization::*; pub(in crate::agent) use json_sidecar::*; pub(in crate::agent) use models::*; +pub(crate) use planning_approval::*; pub(crate) use planning_storage::*; pub(crate) use planning_submit::*; pub(in crate::agent) use provider_control::*; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_approval.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_approval.rs new file mode 100644 index 000000000..9d1d37865 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_approval.rs @@ -0,0 +1,1606 @@ +use super::*; + +use serde::{Deserialize, Serialize}; +use std::path::Path; + +/// Transport-independent input for the M1C-1 approval decision. The Tauri +/// command keeps `projectPath` outside this value; it is only used to locate a +/// project after the permission gate has run. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct DecidePlanGddInputV1 { + pub(crate) gdd_id: String, + pub(crate) version: u32, + pub(crate) fingerprint: String, + pub(crate) pending_action_id: String, + pub(crate) approval_request_id: String, + pub(crate) response_id: String, + pub(crate) action: String, + pub(crate) comment: Option, +} + +fn approval_error(code: &'static str, detail: impl Into) -> PlanningStorageError { + PlanningStorageError::new(code, detail) +} + +fn receipt_ref(receipt: &PlanGddApprovalV1) -> PlanGddDecisionRefV1 { + PlanGddDecisionRefV1 { + gdd_id: receipt.gdd_id.clone(), + version: receipt.version, + fingerprint: receipt.fingerprint.clone(), + approval_request_id: receipt.approval_request_id.clone(), + response_id: receipt.response_id.clone(), + action: receipt.action.clone(), + decision_fingerprint: receipt.decision_fingerprint.clone(), + receipt_fingerprint: receipt.receipt_fingerprint.clone(), + } +} + +fn receipt_plan_ref(receipt: &PlanGddApprovalV1) -> PlanGddRef { + PlanGddRef { + gdd_id: receipt.gdd_id.clone(), + version: receipt.version, + fingerprint: receipt.fingerprint.clone(), + } +} + +fn approval_observation(receipt: &PlanGddApprovalV1) -> AgentRuntimeToolObservation { + let (summary, detail) = match receipt.action.as_str() { + "approve" => ( + format!("Fast GDD v{} 已批准", receipt.version), + "用户已批准当前版本。".to_string(), + ), + "revise" => ( + format!("Fast GDD v{} 需要修改", receipt.version), + format!( + "用户修改意见:{}", + receipt.comment.as_deref().unwrap_or_default() + ), + ), + "reject" => ( + format!("Fast GDD v{} 已退回", receipt.version), + format!( + "用户退回原因:{}", + receipt.comment.as_deref().unwrap_or_default() + ), + ), + _ => unreachable!("validated receipt action"), + }; + AgentRuntimeToolObservation { + tool: PLAN_GDD_APPROVAL_TOOL.to_string(), + status: "ok".to_string(), + summary, + detail: Some(detail), + } +} + +fn receipt_decision_input( + gdd: &PlanGddV1, + input: &DecidePlanGddInputV1, + comment: Option, +) -> PlanGddApprovalDecisionInputV1 { + PlanGddApprovalDecisionInputV1 { + project_id: gdd.project_id.clone(), + gdd_id: gdd.gdd_id.clone(), + version: gdd.version, + fingerprint: gdd.fingerprint.clone(), + pending_action_id: gdd.submission_id.clone(), + action_fingerprint: gdd.action_fingerprint.clone(), + approval_request_id: gdd.approval_request_id.clone(), + response_id: input.response_id.clone(), + action: input.action.clone(), + comment, + } +} + +fn validate_decision_transport(input: &DecidePlanGddInputV1) -> Result<(), PlanningStorageError> { + validate_uuid_prefixed(&input.gdd_id, "gdd-", "decision.gddId")?; + if !(1..=PLAN_MAX_VERSIONS).contains(&input.version) { + return Err(approval_error( + "PLAN_INVALID_REQUEST", + "decision.version 越界", + )); + } + if !is_typed_fingerprint(&input.fingerprint) { + return Err(approval_error( + "PLAN_INVALID_REQUEST", + "decision.fingerprint 格式非法", + )); + } + validate_action_id(&input.pending_action_id, "decision.pendingActionId") + .map_err(|error| approval_error("PLAN_INVALID_REQUEST", error.to_string()))?; + validate_uuid_prefixed( + &input.approval_request_id, + "gdd-approval-", + "decision.approvalRequestId", + ) + .map_err(|error| approval_error("PLAN_INVALID_REQUEST", error.to_string()))?; + validate_uuid_prefixed(&input.response_id, "gdd-response-", "decision.responseId") + .map_err(|error| approval_error("PLAN_INVALID_REQUEST", error.to_string()))?; + if !matches!(input.action.as_str(), "approve" | "revise" | "reject") { + return Err(approval_error( + "PLAN_INVALID_REQUEST", + "decision.action 必须是 approve/revise/reject", + )); + } + Ok(()) +} + +fn pending_identity_matches_gdd(pending: &PlanGddApprovalPendingV1, gdd: &PlanGddV1) -> bool { + pending.project_id == gdd.project_id + && pending.agent_id == PLAN_GDD_APPROVAL_AGENT_ID + && pending.gdd_ref.gdd_id == gdd.gdd_id + && pending.gdd_ref.version == gdd.version + && pending.gdd_ref.fingerprint == gdd.fingerprint + && pending.submission.tool == PLAN_GDD_APPROVAL_TOOL + && pending.submission.pending_action_id == gdd.submission_id + && pending.submission.action_fingerprint == gdd.action_fingerprint + && pending.submission.approval_request_id == gdd.approval_request_id + && pending.run_identity.source == PLAN_GDD_APPROVAL_SOURCE + && pending.run_identity.run_profile == gdd.run_profile + && pending.run_identity.run_profile_binding_fingerprint + == gdd.run_profile_binding_fingerprint + && pending.run_identity.session_id == gdd.session_id + && pending.run_identity.run_id == gdd.created_by_run_id +} + +fn pending_matches_gdd(pending: &PlanGddApprovalPendingV1, gdd: &PlanGddV1) -> bool { + pending_identity_matches_gdd(pending, gdd) + && pending.status == "awaiting_decision" + && pending.observation.is_none() +} + +/// Construct the independent planning pending projection after an external +/// acceptance gate has succeeded. M1C-1 does not decide whether the gate +/// passed; the caller must supply that fact and the exact GDD identity. +pub(crate) fn create_plan_gdd_approval_pending_at( + root: &Path, + gdd: &PlanGddV1, +) -> Result<(), PlanningStorageError> { + let _lock = acquire_project_write_lock(root, "planning.approval-pending.create") + .map_err(|error| approval_error("PLAN_DURABILITY_FAILED", error))?; + create_plan_gdd_approval_pending_locked(root, gdd) +} + +pub(crate) fn create_plan_gdd_approval_pending_locked( + root: &Path, + gdd: &PlanGddV1, +) -> Result<(), PlanningStorageError> { + validate_plan_gdd(gdd)?; + let project_id = game_creator_agent_runtime_context_project_id(root) + .map_err(|error| approval_error("PLAN_PROJECT_ID_MISMATCH", error))?; + if project_id != gdd.project_id { + return Err(approval_error( + "PLAN_PROJECT_ID_MISMATCH", + "GDD projectId 与项目身份不一致", + )); + } + let gdds = read_plan_gdd_chain_locked(root)?; + let approvals = read_plan_gdd_approvals_locked(root)?; + validate_plan_gdd_approvals_against_gdds(&gdds, &approvals)?; + let Some(latest) = gdds.last() else { + return Err(approval_error( + "PLAN_STALE_APPROVAL", + "没有可供审批的 GDD 权威事实", + )); + }; + if latest.gdd_id != gdd.gdd_id + || latest.version != gdd.version + || latest.fingerprint != gdd.fingerprint + { + return Err(approval_error( + "PLAN_STALE_APPROVAL", + "审批 pending 只能绑定 lineage 最新且未决定的 GDD", + )); + } + if read_plan_gdd_approval_for_version_locked(root, gdd.version)?.is_some() { + return Err(approval_error( + "PLAN_STALE_APPROVAL", + "该 GDD 已有 receipt,不能重新创建 awaiting_decision pending", + )); + } + let pending = PlanGddApprovalPendingV1 { + schema_version: PLAN_GDD_APPROVAL_PENDING_SCHEMA_VERSION.to_string(), + kind: PLAN_GDD_APPROVAL_PENDING_KIND.to_string(), + project_id: gdd.project_id.clone(), + agent_id: PLAN_GDD_APPROVAL_AGENT_ID.to_string(), + gdd_ref: PlanGddRef { + gdd_id: gdd.gdd_id.clone(), + version: gdd.version, + fingerprint: gdd.fingerprint.clone(), + }, + submission: PlanGddApprovalPendingSubmission { + tool: PLAN_GDD_APPROVAL_TOOL.to_string(), + pending_action_id: gdd.submission_id.clone(), + action_fingerprint: gdd.action_fingerprint.clone(), + approval_request_id: gdd.approval_request_id.clone(), + }, + run_identity: PlanGddApprovalPendingRunIdentity { + source: PLAN_GDD_APPROVAL_SOURCE.to_string(), + run_profile: gdd.run_profile.clone(), + run_profile_binding_fingerprint: gdd.run_profile_binding_fingerprint.clone(), + session_id: gdd.session_id.clone(), + run_id: gdd.created_by_run_id.clone(), + }, + status: "awaiting_decision".to_string(), + observation: None, + pending_fingerprint: String::new(), + }; + let mut pending = pending; + pending.pending_fingerprint = plan_gdd_approval_pending_fingerprint(&pending)?; + if let Some(existing) = read_plan_gdd_approval_pending_locked(root)? { + if existing != pending { + return Err(approval_error( + "PLAN_NEEDS_RECONCILIATION", + "已有 planning approval pending 属于另一条 GDD identity", + )); + } + return Ok(()); + } + write_plan_gdd_approval_pending_atomic_locked(root, &pending) +} + +fn approval_terminal_observation_exists_locked( + root: &Path, + receipt: &PlanGddApprovalV1, +) -> Result { + // Identity alone is not a consumption proof. The summary is derived + // deterministically from the immutable receipt and must match exactly + // before any surviving submit anchor may be cleaned up. + let expected_summary = approval_observation(receipt).summary; + let (records, _) = crate::project::read_agent_db_records_bounded(root, 16 * 1024 * 1024)?; + Ok(records.iter().any(|record| { + record.get("recordType").and_then(serde_json::Value::as_str) + == Some("agent.runtime.tool_observation") + && record.get("agentId").and_then(serde_json::Value::as_str) + == Some(GAME_CREATOR_PROJECT_PLANNING_AGENT_ID) + && record.get("runId").and_then(serde_json::Value::as_str) + == Some(receipt.run_id.as_str()) + && record.get("actionId").and_then(serde_json::Value::as_str) + == Some(receipt.pending_action_id.as_str()) + && record + .get("actionFingerprint") + .and_then(serde_json::Value::as_str) + == Some(receipt.action_fingerprint.as_str()) + && record.get("tool").and_then(serde_json::Value::as_str) + == Some(PLAN_GDD_APPROVAL_TOOL) + && record.get("status").and_then(serde_json::Value::as_str) == Some("ok") + && record.get("decision").and_then(serde_json::Value::as_str) == Some("approval") + && record.get("summary").and_then(serde_json::Value::as_str) + == Some(expected_summary.as_str()) + })) +} + +fn project_generic_submit_runtime_observation_locked( + root: &Path, + pending: &AgentRuntimePendingToolAction, + observation: &AgentRuntimeToolObservation, +) -> Result<(), String> { + let mut runtime = read_game_creator_agent_runtime_at(root, &pending.agent_id)?.state; + if runtime.agent_id != pending.agent_id + || runtime.task_id != pending.task_id + || runtime.session_id != pending.session_id + || runtime.run_id != pending.run_id + || runtime.source != pending.source + || runtime.run_profile != pending.run_profile + || runtime.run_profile_binding_fingerprint != pending.run_profile_binding_fingerprint + || runtime.phase != "completed" + { + return Err( + "plan.submit_gdd receipt observation 与 terminal Runtime identity 不一致".to_string(), + ); + } + let summary = observation.summary(); + if runtime.observations.last() != Some(&summary) { + runtime.observations.push(summary.clone()); + } + runtime.pending_tool_action = Some(pending.summary()); + runtime.current_action = "Fast GDD 审批观察已落盘".to_string(); + runtime.next_step = "按审批结果等待下一步策划续跑".to_string(); + runtime.updated_at = unix_timestamp(); + append_game_creator_agent_runtime_task_projection_once(root, &runtime, &pending.action_id)?; + refresh_game_creator_agent_runtime_task_queue(root, &mut runtime)?; + write_game_creator_agent_runtime_state(root, &runtime)?; + append_game_creator_agent_runtime_action_event( + root, + &runtime, + "plan.submit_gdd.observed", + "idle", + "completed", + &summary, + Some(&pending.action_id), + &pending.action_id, + )?; + append_agent_runtime_action_receipt_with_project_revision_before( + root, + &runtime, + &pending.action_id, + &pending.action_fingerprint, + PLAN_GDD_APPROVAL_TOOL, + AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO, + pending.input_summary.as_deref(), + observation, + pending.project_revision_before.revision, + )?; + Ok(()) +} + +fn clear_generic_submit_runtime_observation_locked( + root: &Path, + pending: &AgentRuntimePendingToolAction, +) -> Result<(), String> { + let mut runtime = read_game_creator_agent_runtime_at(root, &pending.agent_id)?.state; + if runtime.run_id != pending.run_id || runtime.phase != "completed" { + return Err("清理 plan.submit_gdd observation 时 Runtime 已被其它 run 替换".to_string()); + } + runtime.pending_tool_action = None; + runtime.updated_at = unix_timestamp(); + append_game_creator_agent_runtime_task_projection_once(root, &runtime, &pending.action_id)?; + refresh_game_creator_agent_runtime_task_queue(root, &mut runtime)?; + write_game_creator_agent_runtime_state(root, &runtime)?; + append_game_creator_agent_runtime_action_event( + root, + &runtime, + "plan.submit_gdd.observation_consumed", + "idle", + "completed", + "plan.submit_gdd 审批观察已消费,原提交锚点已清理。", + Some(&pending.action_id), + &pending.action_id, + )?; + Ok(()) +} + +fn read_validated_generic_submit_batch_locked( + root: &Path, + receipt: &PlanGddApprovalV1, +) -> Result, String> { + if !game_creator_agent_runtime_provider_action_batch_exists( + root, + GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + &receipt.run_id, + ) { + return Ok(None); + } + let batch = read_game_creator_agent_runtime_provider_action_batch( + root, + GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + &receipt.run_id, + )?; + if !is_plan_submit_gdd_provider_action_batch(&batch) + || batch.project_id != receipt.project_id + || batch.agent_id != GAME_CREATOR_PROJECT_PLANNING_AGENT_ID + || batch.session_id != receipt.session_id + || batch.run_id != receipt.run_id + || batch.source != "agent-delegate" + || batch.run_profile != AGENT_RUNTIME_RUN_PROFILE_STANDARD + || batch.run_profile_binding_fingerprint != receipt.run_profile_binding_fingerprint + { + return Err( + "原 plan.submit_gdd Provider batch 不是 receipt 绑定的 exact identity".to_string(), + ); + } + let action = batch + .actions + .first() + .ok_or_else(|| "原 plan.submit_gdd Provider batch 缺少 action".to_string())?; + if action.action.tool != PLAN_GDD_APPROVAL_TOOL + || action.action_id != receipt.pending_action_id + || action.action_fingerprint != receipt.action_fingerprint + || action.agent_id != batch.agent_id + || action.session_id != batch.session_id + || action.run_id != batch.run_id + || action.run_profile_binding_fingerprint != batch.run_profile_binding_fingerprint + { + return Err( + "原 plan.submit_gdd Provider batch action identity 与 receipt 不一致".to_string(), + ); + } + Ok(Some(batch)) +} + +fn cleanup_completed_generic_submit_batch_after_pending_missing_locked( + root: &Path, + receipt: &PlanGddApprovalV1, +) -> Result { + let Some(batch) = read_validated_generic_submit_batch_locked(root, receipt)? else { + return Ok(true); + }; + let action = batch + .actions + .first() + .ok_or_else(|| "原 plan.submit_gdd Provider batch 缺少 action".to_string())?; + let expected_observation = approval_observation(receipt); + if batch.status != AGENT_RUNTIME_PROVIDER_ACTION_BATCH_STATUS_COMPLETED + || batch.next_action_index != 1 + || action.status != AGENT_RUNTIME_PENDING_ACTION_STATUS_OBSERVED_APPROVED + || action.observation.as_ref() != Some(&expected_observation) + { + // A terminal observation without an exact completed batch is still a + // recovery gap. Do not delete a surviving anchor based on the + // observation alone. + return Ok(false); + } + remove_game_creator_agent_runtime_provider_action_batch( + root, + GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + &receipt.run_id, + )?; + Ok(!game_creator_agent_runtime_provider_action_batch_exists( + root, + GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + &receipt.run_id, + )) +} + +fn project_generic_submit_observation_locked( + root: &Path, + receipt: &PlanGddApprovalV1, +) -> Result { + let pending_exists = game_creator_agent_runtime_pending_tool_action_exists( + root, + GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + &receipt.run_id, + ); + if !pending_exists { + if !approval_terminal_observation_exists_locked(root, receipt)? { + return Ok(false); + } + return cleanup_completed_generic_submit_batch_after_pending_missing_locked(root, receipt); + } + let terminal_observation_already_durable = + approval_terminal_observation_exists_locked(root, receipt)?; + let mut pending = read_game_creator_agent_runtime_pending_tool_action( + root, + GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + &receipt.run_id, + )?; + if pending.action_id != receipt.pending_action_id + || pending.action_fingerprint != receipt.action_fingerprint + || pending.action.tool != PLAN_GDD_APPROVAL_TOOL + { + return Err("原 plan.submit_gdd pending identity 与 approval receipt 不一致".to_string()); + } + let _batch = read_validated_generic_submit_batch_locked(root, receipt)?; + let observation = approval_observation(receipt); + pending.status = AGENT_RUNTIME_PENDING_ACTION_STATUS_OBSERVED_APPROVED.to_string(); + pending.observation = Some(observation.clone()); + pending.updated_at = unix_timestamp(); + write_game_creator_agent_runtime_pending_tool_action(root, &pending)?; + project_generic_submit_runtime_observation_locked(root, &pending, &observation)?; + append_agent_db_terminal_observation_if_missing_for_action( + root, + &pending.agent_id, + &pending.run_id, + &pending.action_id, + serde_json::json!({ + "recordType": "agent.runtime.tool_observation", + "agentId": pending.agent_id, + "taskId": pending.task_id, + "runId": pending.run_id, + "tool": observation.tool, + "status": observation.status, + "summary": observation.summary, + "actionId": pending.action_id, + "actionFingerprint": pending.action_fingerprint, + "decision": "approval", + }), + )?; + // The v4 batch is an independent recovery anchor. If it is present, + // advance its member after the terminal observation; if it is absent the + // receipt remains committed but the command reports recoveryPending. + let batch_exists = game_creator_agent_runtime_provider_action_batch_exists( + root, + &pending.agent_id, + &pending.run_id, + ); + let batch_completed = update_game_creator_agent_runtime_provider_batch_member(root, &pending)?; + // Once the terminal observation is durable, a missing batch is the normal + // post-consumption cleanup window (the batch is removed last). On the + // first attempt the observation was not present yet, so a missing batch + // remains a real recovery gap and must not be guessed through. + if batch_completed || (!batch_exists && terminal_observation_already_durable) { + clear_generic_submit_runtime_observation_locked(root, &pending)?; + remove_game_creator_agent_runtime_pending_tool_action( + root, + &pending.agent_id, + &pending.run_id, + )?; + remove_game_creator_agent_runtime_provider_action_batch( + root, + &pending.agent_id, + &pending.run_id, + )?; + return Ok(true); + } + // A missing v4 batch is a recovery gap on the first decision attempt. A + // later replay may observe the already durable terminal observation and + // safely finish cleanup; until then retain the standalone anchor. + Ok(false) +} + +fn project_plan_session_locked( + root: &Path, + gdd: &PlanGddV1, + receipt: &PlanGddApprovalV1, +) -> Result<(), PlanningStorageError> { + let Some(previous) = read_plan_session_with_recovery_locked(root)? else { + return Err(approval_error( + "PLAN_SESSION_RECOVERY_REQUIRED", + "approval receipt 已落盘,但 planning session 不存在", + )); + }; + if previous.project_id != gdd.project_id + || previous.gdd_id != gdd.gdd_id + || previous.session_id != gdd.session_id + || previous.run_profile_binding_fingerprint != gdd.run_profile_binding_fingerprint + || previous.root_run_id != gdd.root_run_id + { + return Err(approval_error( + "PLAN_SESSION_CAS_CONFLICT", + "approval receipt 与 planning session identity 不一致", + )); + } + let decision_ref = PlanDecisionRef { + version: receipt.version, + response_id: receipt.response_id.clone(), + action: receipt.action.clone(), + receipt_fingerprint: receipt.receipt_fingerprint.clone(), + }; + if previous.last_decision_ref.as_ref() == Some(&decision_ref) { + return Ok(()); + } + if previous.latest_submitted_ref.as_ref() != Some(&receipt_plan_ref(receipt)) + || previous.phase != "awaiting_gdd_approval" + || previous.active_run_id.is_some() + { + return Err(approval_error( + "PLAN_SESSION_CAS_CONFLICT", + "planning session 不是当前 GDD 的 awaiting_gdd_approval successor 起点", + )); + } + let mut next = previous.clone(); + next.session_revision = previous + .session_revision + .checked_add(1) + .ok_or_else(|| approval_error("PLAN_SESSION_CAS_CONFLICT", "sessionRevision 溢出"))?; + next.previous_fingerprint = Some(previous.session_fingerprint.clone()); + next.phase = match receipt.action.as_str() { + "approve" => "approved", + "revise" => "revision_requested", + "reject" => "rejected", + _ => unreachable!("validated receipt action"), + } + .to_string(); + next.active_run_id = None; + next.last_run_id = receipt.run_id.clone(); + next.last_decision_ref = Some(decision_ref); + next.updated_at_utc = receipt.decided_at_utc.clone(); + next.session_fingerprint = plan_session_fingerprint(&next)?; + validate_plan_session_successor(&previous, &next)?; + write_plan_session_atomic_locked(root, &next) +} + +fn project_receipt_locked( + root: &Path, + gdds: &[PlanGddV1], + receipt: &PlanGddApprovalV1, +) -> Result { + let mut recovery_pending = false; + let approvals = read_plan_gdd_approvals_locked(root)?; + let index = build_plan_gdd_index_with_approvals(gdds, &approvals, &receipt.decided_at_utc)?; + if write_plan_gdd_index_atomic_locked(root, &index).is_err() { + recovery_pending = true; + } + let latest = gdds.last().ok_or_else(|| { + approval_error( + "PLAN_CORRUPT_AUTHORITY", + "approval receipt 缺少 GDD lineage", + ) + })?; + let receipt_gdd = gdds + .iter() + .find(|gdd| { + gdd.gdd_id == receipt.gdd_id + && gdd.version == receipt.version + && gdd.fingerprint == receipt.fingerprint + }) + .ok_or_else(|| { + approval_error( + "PLAN_CORRUPT_AUTHORITY", + "approval receipt 找不到相符的 GDD 权威事实", + ) + })?; + // Markdown follows the highest valid approved version when one exists; + // a later revise/reject candidate must not replace the approved baseline. + let projection_gdd = index + .status_cache + .approved_version + .and_then(|version| gdds.iter().find(|gdd| gdd.version == version)) + .unwrap_or(latest); + let projection_status = index + .status_cache + .versions + .iter() + .find(|status| status.version == projection_gdd.version) + .map(|status| status.status.as_str()) + .unwrap_or("ready_for_approval"); + match render_plan_fast_gdd_markdown(projection_gdd, projection_status) { + Ok(markdown) => { + if write_plan_fast_gdd_markdown_atomic_locked(root, &markdown).is_err() { + recovery_pending = true; + } + } + Err(_) => recovery_pending = true, + } + + let comment_hash = plan_gdd_approval_comment_fingerprint(receipt.comment.as_deref())?; + let audit = serde_json::json!({ + "recordType": PLAN_GDD_APPROVAL_DECISION_AUDIT_RECORD_TYPE, + "auditSchemaVersion": PLAN_GDD_APPROVAL_DECISION_AUDIT_SCHEMA_VERSION, + "projectId": receipt.project_id, + "agentId": PLAN_GDD_APPROVAL_AGENT_ID, + "gddId": receipt.gdd_id, + "version": receipt.version, + "gddFingerprint": receipt.fingerprint, + "pendingActionId": receipt.pending_action_id, + "actionFingerprint": receipt.action_fingerprint, + "approvalRequestId": receipt.approval_request_id, + "responseId": receipt.response_id, + "source": receipt.source, + "runProfile": receipt.run_profile, + "runProfileBindingFingerprint": receipt.run_profile_binding_fingerprint, + "sessionId": receipt.session_id, + "runId": receipt.run_id, + "action": receipt.action, + "decisionFingerprint": receipt.decision_fingerprint, + "commentHash": comment_hash, + "commentLength": receipt.comment.as_deref().map_or(0, |value| value.chars().count()), + "receiptFingerprint": receipt.receipt_fingerprint, + "decidedAtUtc": receipt.decided_at_utc, + }); + if let Err(error) = crate::project::append_agent_db_plan_gdd_decision_if_missing(root, audit) { + if error.contains("PLAN_DECISION_IDENTITY_CONFLICT") { + return Err(approval_error( + "PLAN_DECISION_IDENTITY_CONFLICT", + "planning decision audit 与 receipt 幂等身份冲突", + )); + } + // Receipt is already the user-facing linearization point. Preserve + // the committed result and let a later retry repair ordinary audit + // I/O or capacity failures. + recovery_pending = true; + } + + let pending_observation = approval_observation(receipt); + let mut approval_pending_cleanup_eligible = false; + let approval_pending = match read_plan_gdd_approval_pending_locked(root) { + Ok(value) => value, + Err(_) => { + recovery_pending = true; + None + } + }; + match approval_pending { + Some(mut pending) => { + if !pending_identity_matches_gdd(&pending, receipt_gdd) { + recovery_pending = true; + } else { + let expected_status = format!("observed_{}", receipt.action); + if !matches!(pending.status.as_str(), "awaiting_decision") + && pending.status != expected_status + { + recovery_pending = true; + // Do not remove a projection whose durable state belongs + // to another decision action. + approval_pending_cleanup_eligible = false; + } else { + approval_pending_cleanup_eligible = true; + pending.status = format!("observed_{}", receipt.action); + pending.observation = Some(PlanGddApprovalObservationV1 { + tool: pending_observation.tool.clone(), + status: pending_observation.status.clone(), + summary: pending_observation.summary.clone(), + detail: pending_observation.detail.clone(), + }); + match plan_gdd_approval_pending_fingerprint(&pending) { + Ok(fingerprint) => { + pending.pending_fingerprint = fingerprint; + if write_plan_gdd_approval_pending_atomic_locked(&root, &pending) + .is_err() + { + recovery_pending = true; + } + } + Err(_) => recovery_pending = true, + } + } + } + } + // The approval pending projection is allowed to be absent after the + // original submit anchors have durably consumed the terminal + // observation. The generic-anchor reconciliation below decides + // whether this is a normal post-consumption state or a recovery gap. + None => {} + } + + match project_generic_submit_observation_locked(root, receipt) { + Ok(consumed) => { + if consumed { + if approval_pending_cleanup_eligible + && remove_plan_gdd_approval_pending_locked(root).is_err() + { + recovery_pending = true; + } + } else { + recovery_pending = true; + } + } + Err(error) => { + let _ = error; + recovery_pending = true; + } + } + if receipt.action != "approve" { + if mark_static_delegate_delivery_user_revision_requested_at( + root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &receipt_gdd.root_run_id, + &receipt_gdd.delegation_id, + ) + .is_err() + { + recovery_pending = true; + } + } + // A replay may target an older receipt after a newer GDD has already been + // submitted. The receipt still repairs its own audit/observation, but it + // must not try to roll the current session or delivery lineage backwards. + let session_points_to_receipt = match read_plan_session_with_recovery_locked(root) { + Ok(session) => session + .as_ref() + .and_then(|session| session.latest_submitted_ref.as_ref()) + .is_some_and(|reference| reference == &receipt_plan_ref(receipt)), + Err(_) => { + recovery_pending = true; + false + } + }; + if receipt.version == latest.version || session_points_to_receipt { + if let Err(error) = project_plan_session_locked(root, receipt_gdd, receipt) { + recovery_pending = true; + let _ = error; + } + } + Ok(recovery_pending) +} + +/// Reconcile receipt-derived projections after a Runner restart. Receipts +/// are the authority; this routine only repairs projections and never creates +/// an approval pending without an explicit upstream acceptance-gate caller. +pub(crate) fn reconcile_plan_gdd_approval_projections_at( + root: &Path, +) -> Result { + let _lock = acquire_project_write_lock(root, "planning.approval-recovery") + .map_err(|error| approval_error("PLAN_DURABILITY_FAILED", error))?; + let gdds = read_plan_gdd_chain_locked(root)?; + let approvals = read_plan_gdd_approvals_locked(root)?; + if approvals.is_empty() { + return Ok(false); + } + validate_plan_gdd_approvals_against_gdds(&gdds, &approvals)?; + let mut recovery_pending = false; + for receipt in &approvals { + recovery_pending |= project_receipt_locked(root, &gdds, receipt)?; + } + Ok(recovery_pending) +} + +fn validate_active_plan_root_for_decision_locked( + root: &Path, + gdd: &PlanGddV1, +) -> Result<(), PlanningStorageError> { + let root_binding = validate_project_supervisor_plan_root_binding_at( + root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &gdd.root_run_id, + ) + .map_err(|error| approval_error("PLAN_SOURCE_PROFILE_MISMATCH", error))?; + if root_binding.project_id != gdd.project_id + || root_binding.source != PLAN_GDD_APPROVAL_SOURCE + || root_binding.profile != AGENT_RUNTIME_RUN_PROFILE_STANDARD + { + return Err(approval_error( + "PLAN_SOURCE_PROFILE_MISMATCH", + "审批 GDD 的根 Run binding 与项目身份不一致", + )); + } + + let child_binding = validate_project_planning_child_binding_at( + root, + GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + &gdd.created_by_run_id, + ) + .map_err(|error| approval_error("PLAN_SOURCE_PROFILE_MISMATCH", error))?; + if child_binding.project_id != gdd.project_id + || child_binding.root_run_id != gdd.root_run_id + || child_binding.binding_fingerprint != gdd.run_profile_binding_fingerprint + { + return Err(approval_error( + "PLAN_SOURCE_PROFILE_MISMATCH", + "审批 GDD 的策划子 Run binding 与 GDD identity 不一致", + )); + } + let task_path = + game_creator_agent_runtime_task_path(root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID); + let records = read_all_game_creator_agent_runtime_tasks(&task_path) + .map_err(|error| approval_error("PLAN_SOURCE_PROFILE_MISMATCH", error))?; + let active_roots = latest_game_creator_agent_runtime_tasks(records) + .into_iter() + .filter(|task| { + task.agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID + && task.source == PLAN_GDD_APPROVAL_SOURCE + && task.run_profile == AGENT_RUNTIME_RUN_PROFILE_STANDARD + && task.parent_agent_id.is_none() + && task.parent_run_id.is_none() + && autonomous_game_build_root_task_is_active(task) + }) + .collect::>(); + if active_roots.len() != 1 || active_roots[0].run_id != gdd.root_run_id { + return Err(approval_error( + "PLAN_STALE_APPROVAL", + "审批 GDD 不再绑定当前唯一 active plan 根 Run", + )); + } + if active_roots[0].run_profile_binding_fingerprint != root_binding.binding_fingerprint { + return Err(approval_error( + "PLAN_SOURCE_PROFILE_MISMATCH", + "active plan 根 Run 的 binding fingerprint 已漂移", + )); + } + Ok(()) +} + +pub(crate) fn decide_plan_gdd_at( + root: &Path, + input: &DecidePlanGddInputV1, +) -> Result { + validate_decision_transport(input)?; + let _lock = acquire_project_write_lock(root, "planning.gdd-decision") + .map_err(|error| approval_error("PLAN_DURABILITY_FAILED", error))?; + let project_id = game_creator_agent_runtime_context_project_id(root) + .map_err(|error| approval_error("PLAN_PROJECT_ID_MISMATCH", error))?; + let gdds = read_plan_gdd_chain_locked(root)?; + let gdd = gdds + .iter() + .find(|gdd| gdd.gdd_id == input.gdd_id && gdd.version == input.version) + .ok_or_else(|| approval_error("PLAN_STALE_APPROVAL", "待审 GDD 已不存在"))?; + if gdd.project_id != project_id + || gdd.fingerprint != input.fingerprint + || gdd.submission_id != input.pending_action_id + || gdd.approval_request_id != input.approval_request_id + { + return Err(approval_error( + "PLAN_STALE_APPROVAL", + "审批卡引用的 GDD identity 已过期", + )); + } + let normalized_comment = + normalize_plan_gdd_approval_comment(&input.action, input.comment.as_deref()) + .map_err(|error| approval_error("PLAN_INVALID_REQUEST", error.to_string()))?; + let approvals = read_plan_gdd_approvals_locked(root)?; + validate_plan_gdd_approvals_against_gdds(&gdds, &approvals)?; + let existing = approvals + .iter() + .find(|receipt| receipt.version == gdd.version); + let (receipt, outcome) = if let Some(existing) = existing { + let decision_input = receipt_decision_input(gdd, input, normalized_comment.clone()); + let expected = plan_gdd_approval_decision_fingerprint_for_identity( + &decision_input, + &existing.source, + &existing.run_profile, + &existing.run_profile_binding_fingerprint, + &existing.session_id, + &existing.run_id, + normalized_comment.as_deref(), + )?; + if existing.response_id == input.response_id && expected != existing.decision_fingerprint { + return Err(approval_error( + "PLAN_DECISION_IDENTITY_CONFLICT", + "同 responseId 的审批意图不一致", + )); + } + ( + existing.clone(), + if existing.response_id == input.response_id { + "replayed" + } else { + "already-decided" + }, + ) + } else { + let Some(latest) = gdds.last() else { + return Err(approval_error( + "PLAN_STALE_APPROVAL", + "没有可供审批的 GDD 权威事实", + )); + }; + if latest.version != gdd.version || latest.fingerprint != gdd.fingerprint { + return Err(approval_error( + "PLAN_STALE_APPROVAL", + "只能决定 lineage 最新且尚未有 receipt 的 GDD", + )); + } + validate_active_plan_root_for_decision_locked(root, gdd)?; + let pending = read_plan_gdd_approval_pending_locked(root)?.ok_or_else(|| { + approval_error("PLAN_STALE_APPROVAL", "审批 pending 已过期或尚未建立") + })?; + if !pending_matches_gdd(&pending, gdd) + || pending.gdd_ref.gdd_id != input.gdd_id + || pending.gdd_ref.version != input.version + { + return Err(approval_error( + "PLAN_STALE_APPROVAL", + "审批 pending identity 与 GDD 不一致", + )); + } + let decision_input = receipt_decision_input(gdd, input, normalized_comment.clone()); + let decision_fingerprint = plan_gdd_approval_decision_fingerprint_for_identity( + &decision_input, + PLAN_GDD_APPROVAL_SOURCE, + &gdd.run_profile, + &gdd.run_profile_binding_fingerprint, + &gdd.session_id, + &gdd.created_by_run_id, + normalized_comment.as_deref(), + )?; + let mut receipt = PlanGddApprovalV1 { + schema_version: PLAN_GDD_APPROVAL_SCHEMA_VERSION.to_string(), + project_id: gdd.project_id.clone(), + gdd_id: gdd.gdd_id.clone(), + version: gdd.version, + fingerprint: gdd.fingerprint.clone(), + pending_action_id: gdd.submission_id.clone(), + action_fingerprint: gdd.action_fingerprint.clone(), + approval_request_id: gdd.approval_request_id.clone(), + response_id: input.response_id.clone(), + decision_fingerprint, + source: PLAN_GDD_APPROVAL_SOURCE.to_string(), + run_profile: gdd.run_profile.clone(), + run_profile_binding_fingerprint: gdd.run_profile_binding_fingerprint.clone(), + session_id: gdd.session_id.clone(), + run_id: gdd.created_by_run_id.clone(), + action: input.action.clone(), + comment: normalized_comment, + decided_at_utc: current_plan_timestamp_utc(), + receipt_fingerprint: String::new(), + }; + receipt.receipt_fingerprint = plan_gdd_approval_receipt_fingerprint(&receipt)?; + let bytes = canonical_plan_gdd_approval_bytes(&receipt)?; + let path = format!("{PLAN_GDD_APPROVAL_DIR}/v{}.json", receipt.version); + durable_create_json_no_replace_locked(root, &path, &bytes, "GDD approval receipt")?; + (receipt, "committed") + }; + let recovery_pending = project_receipt_locked(root, &gdds, &receipt)?; + Ok(PlanGddDecisionResultV1 { + outcome: outcome.to_string(), + requested_response_id: input.response_id.clone(), + decision_ref: receipt_ref(&receipt), + approved_gdd_ref: (receipt.action == "approve").then(|| receipt_plan_ref(&receipt)), + recovery_pending, + }) +} + +const PLAN_GDD_COMPLETION_BLOCKER_TOOL: &str = "runtime.plan_gdd"; + +fn plan_gdd_completion_blocker( + status: &str, + summary: impl Into, + detail: impl Into, +) -> AgentRuntimeToolObservation { + AgentRuntimeToolObservation { + tool: PLAN_GDD_COMPLETION_BLOCKER_TOOL.to_string(), + status: status.to_string(), + summary: summary.into(), + detail: Some(detail.into()), + } +} + +fn plan_gdd_decision_audit_value(receipt: &PlanGddApprovalV1) -> Result { + let comment_hash = plan_gdd_approval_comment_fingerprint(receipt.comment.as_deref()) + .map_err(|error| error.to_string())?; + Ok(serde_json::json!({ + "recordType": PLAN_GDD_APPROVAL_DECISION_AUDIT_RECORD_TYPE, + "auditSchemaVersion": PLAN_GDD_APPROVAL_DECISION_AUDIT_SCHEMA_VERSION, + "projectId": receipt.project_id, + "agentId": PLAN_GDD_APPROVAL_AGENT_ID, + "gddId": receipt.gdd_id, + "version": receipt.version, + "gddFingerprint": receipt.fingerprint, + "pendingActionId": receipt.pending_action_id, + "actionFingerprint": receipt.action_fingerprint, + "approvalRequestId": receipt.approval_request_id, + "responseId": receipt.response_id, + "source": receipt.source, + "runProfile": receipt.run_profile, + "runProfileBindingFingerprint": receipt.run_profile_binding_fingerprint, + "sessionId": receipt.session_id, + "runId": receipt.run_id, + "action": receipt.action, + "decisionFingerprint": receipt.decision_fingerprint, + "commentHash": comment_hash, + "commentLength": receipt.comment.as_deref().map_or(0, |value| value.chars().count()), + "receiptFingerprint": receipt.receipt_fingerprint, + "decidedAtUtc": receipt.decided_at_utc, + })) +} + +fn plan_gdd_decision_audit_state_locked( + root: &Path, + receipt: &PlanGddApprovalV1, +) -> Result { + let expected = plan_gdd_decision_audit_value(receipt)?; + let (records, scan_truncated) = + crate::project::read_agent_db_records_bounded(root, 16 * 1024 * 1024)?; + if scan_truncated { + return Err("Agent DB 扫描被截断,无法确认 plan GDD decision audit".to_string()); + } + let mut found = false; + for record in records { + if record.get("recordType").and_then(serde_json::Value::as_str) + != Some(PLAN_GDD_APPROVAL_DECISION_AUDIT_RECORD_TYPE) + || record.get("gddId").and_then(serde_json::Value::as_str) + != Some(receipt.gdd_id.as_str()) + || record.get("version").and_then(serde_json::Value::as_u64) + != Some(receipt.version as u64) + { + continue; + } + let mut comparable = record; + if let Some(object) = comparable.as_object_mut() { + object.remove("schemaVersion"); + object.remove("updatedAt"); + } + if comparable != expected { + return Err("plan GDD decision audit 与 receipt identity 不一致".to_string()); + } + found = true; + } + Ok(found) +} + +fn plan_gdd_pending_identity_matches( + pending: &AgentRuntimePendingToolAction, + gdd: &PlanGddV1, +) -> bool { + pending.agent_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID + && pending.session_id == gdd.session_id + && pending.run_id == gdd.created_by_run_id + && pending.source == gdd.source + && pending.run_profile == gdd.run_profile + && pending.run_profile_binding_fingerprint == gdd.run_profile_binding_fingerprint + && pending.action.tool == PLAN_GDD_APPROVAL_TOOL + && pending.action_id == gdd.submission_id + && pending.action_fingerprint == gdd.action_fingerprint + && pending + .planning_session_binding + .as_ref() + .is_some_and(|binding| { + binding.project_id == gdd.project_id + && binding.gdd_id == gdd.gdd_id + && binding.session_id == gdd.session_id + && binding.session_revision == gdd.source_session_revision + && binding.session_fingerprint == gdd.source_session_fingerprint + && binding.root_agent_id == gdd.root_agent_id + && binding.root_run_id == gdd.root_run_id + && binding.delegation_id == gdd.delegation_id + }) +} + +fn plan_gdd_batch_identity_matches( + batch: &AgentRuntimeProviderActionBatch, + gdd: &PlanGddV1, +) -> bool { + is_plan_submit_gdd_provider_action_batch(batch) + && batch.project_id == gdd.project_id + && batch.agent_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID + && batch.session_id == gdd.session_id + && batch.run_id == gdd.created_by_run_id + && batch.source == gdd.source + && batch.run_profile == gdd.run_profile + && batch.run_profile_binding_fingerprint == gdd.run_profile_binding_fingerprint + && batch + .planning_session_binding + .as_ref() + .is_some_and(|binding| { + binding.project_id == gdd.project_id + && binding.gdd_id == gdd.gdd_id + && binding.session_id == gdd.session_id + && binding.session_revision == gdd.source_session_revision + && binding.session_fingerprint == gdd.source_session_fingerprint + && binding.root_agent_id == gdd.root_agent_id + && binding.root_run_id == gdd.root_run_id + && binding.delegation_id == gdd.delegation_id + }) + && batch.actions.len() == 1 + && batch.actions[0].action_id == gdd.submission_id + && batch.actions[0].action_fingerprint == gdd.action_fingerprint + && plan_gdd_pending_identity_matches(&batch.actions[0], gdd) +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum PlanGddAnchorState { + Absent, + Present, +} + +fn plan_gdd_anchor_state_locked( + root: &Path, + gdd: &PlanGddV1, +) -> Result<(PlanGddAnchorState, PlanGddAnchorState), String> { + let pending_state = if game_creator_agent_runtime_pending_tool_action_exists( + root, + GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + &gdd.created_by_run_id, + ) { + let pending = read_game_creator_agent_runtime_pending_tool_action( + root, + GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + &gdd.created_by_run_id, + )?; + if !plan_gdd_pending_identity_matches(&pending, gdd) { + return Err("plan.submit_gdd standalone pending 与 GDD identity 不一致".to_string()); + } + PlanGddAnchorState::Present + } else { + PlanGddAnchorState::Absent + }; + let batch_state = if game_creator_agent_runtime_provider_action_batch_exists( + root, + GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + &gdd.created_by_run_id, + ) { + let batch = read_game_creator_agent_runtime_provider_action_batch( + root, + GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + &gdd.created_by_run_id, + )?; + if !plan_gdd_batch_identity_matches(&batch, gdd) { + return Err("plan.submit_gdd Provider batch 与 GDD identity 不一致".to_string()); + } + PlanGddAnchorState::Present + } else { + PlanGddAnchorState::Absent + }; + Ok((pending_state, batch_state)) +} + +fn plan_gdd_session_matches_submission(session: &PlanSessionV1, gdd: &PlanGddV1) -> bool { + session.project_id == gdd.project_id + && session.gdd_id == gdd.gdd_id + && session.agent_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID + && session.source == gdd.source + && session.run_profile == gdd.run_profile + && session.run_profile_binding_fingerprint == gdd.run_profile_binding_fingerprint + && session.root_agent_id == gdd.root_agent_id + && session.root_run_id == gdd.root_run_id + && session.latest_delegation_id == gdd.delegation_id + && session.session_id == gdd.session_id + && session.latest_submitted_ref.as_ref() == Some(&receipt_plan_ref_from_gdd(gdd)) + && session.active_run_id.is_none() +} + +fn receipt_plan_ref_from_gdd(gdd: &PlanGddV1) -> PlanGddRef { + PlanGddRef { + gdd_id: gdd.gdd_id.clone(), + version: gdd.version, + fingerprint: gdd.fingerprint.clone(), + } +} + +fn plan_gdd_session_matches_receipt( + session: &PlanSessionV1, + gdd: &PlanGddV1, + receipt: &PlanGddApprovalV1, +) -> bool { + if !plan_gdd_session_matches_submission(session, gdd) { + return false; + } + let expected_phase = match receipt.action.as_str() { + "approve" => "approved", + "revise" => "revision_requested", + "reject" => "rejected", + _ => return false, + }; + session.phase == expected_phase + && session.last_run_id == receipt.run_id + && session.last_decision_ref.as_ref().is_some_and(|reference| { + reference.version == receipt.version + && reference.response_id == receipt.response_id + && reference.action == receipt.action + && reference.receipt_fingerprint == receipt.receipt_fingerprint + }) +} + +fn plan_root_completion_identity_at( + root: &Path, + agent_id: &str, + run_id: &str, +) -> Result { + if agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID || run_id.trim().is_empty() { + return Ok(false); + } + let Some(binding) = + read_game_creator_agent_runtime_run_profile_binding(root, agent_id, run_id)? + else { + return Ok(false); + }; + if binding.source != PLAN_GDD_APPROVAL_SOURCE + || binding.profile != AGENT_RUNTIME_RUN_PROFILE_STANDARD + { + return Ok(false); + } + let binding = validate_project_supervisor_plan_root_binding_at(root, agent_id, run_id)?; + let runtime = read_game_creator_agent_runtime_at(root, agent_id)?.state; + if runtime.run_id != run_id + || runtime.agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID + || runtime.source != PLAN_GDD_APPROVAL_SOURCE + || runtime.run_profile != AGENT_RUNTIME_RUN_PROFILE_STANDARD + || runtime.run_profile_binding_fingerprint != binding.binding_fingerprint + || runtime.parent_agent_id.is_some() + || runtime.parent_run_id.is_some() + || runtime.delegation_id.is_some() + { + return Err("plan 根 Runtime state 与 durable root binding 不一致".to_string()); + } + let task_path = game_creator_agent_runtime_task_path(root, agent_id); + let records = read_all_game_creator_agent_runtime_tasks(&task_path)?; + let task = latest_game_creator_agent_runtime_tasks(records) + .into_iter() + .find(|task| task.run_id == run_id); + let Some(task) = task else { + return Err("plan 根 Runtime task 记录缺失".to_string()); + }; + if task.agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID + || task.source != PLAN_GDD_APPROVAL_SOURCE + || task.run_profile != AGENT_RUNTIME_RUN_PROFILE_STANDARD + || task.run_profile_binding_fingerprint != binding.binding_fingerprint + || task.parent_agent_id.is_some() + || task.parent_run_id.is_some() + || task.delegation_id.is_some() + { + return Err("plan 根 Runtime task 与 durable root binding 不一致".to_string()); + } + Ok(true) +} + +/// Dedicated completion gate for the top-level `project-supervisor-plan` run. +/// +/// This is deliberately read-only: M1C-1 may observe an approval pending +/// projection, but only the later acceptance-gate caller may create it. A +/// committed GDD therefore remains blocked until the pending/receipt, +/// generic child anchors, terminal observation, decision audit and planning +/// session all form one exact durable state. +pub(crate) fn plan_gdd_completion_blocker_at_locked( + root: &Path, + agent_id: &str, + run_id: &str, +) -> Option { + let is_plan_root = match plan_root_completion_identity_at(root, agent_id, run_id) { + Ok(value) => value, + Err(error) => { + return Some(plan_gdd_completion_blocker( + "needs-reconciliation", + "无法确认立项策划根 Run 身份,不能收束任务", + redact_agent_runtime_project_paths(root, &error, 500), + )); + } + }; + if !is_plan_root { + return None; + } + + let gdds = match read_plan_gdd_chain_locked(root) { + Ok(gdds) => gdds, + Err(error) => { + return Some(plan_gdd_completion_blocker( + "needs-reconciliation", + "Fast GDD lineage 无法读取,不能收束立项策划任务", + error.to_string(), + )); + } + }; + let plan_gdds = gdds + .iter() + .filter(|gdd| { + gdd.root_agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID + && gdd.root_run_id == run_id + && gdd.source == "agent-delegate" + && gdd.run_profile == AGENT_RUNTIME_RUN_PROFILE_STANDARD + }) + .collect::>(); + if plan_gdds.is_empty() { + return None; + } + let latest = *plan_gdds.last().expect("non-empty plan GDD lineage"); + + let approvals = match read_plan_gdd_approvals_locked(root) { + Ok(approvals) => approvals, + Err(error) => { + return Some(plan_gdd_completion_blocker( + "needs-reconciliation", + "Fast GDD approval receipt 无法读取,不能收束任务", + error.to_string(), + )); + } + }; + if let Err(error) = validate_plan_gdd_approvals_against_gdds(&gdds, &approvals) { + return Some(plan_gdd_completion_blocker( + "needs-reconciliation", + "Fast GDD approval receipt 与 lineage 无法对账,不能收束任务", + error.to_string(), + )); + } + let receipt = approvals.iter().find(|receipt| { + receipt.version == latest.version + && receipt.gdd_id == latest.gdd_id + && receipt.fingerprint == latest.fingerprint + }); + + let session = match read_plan_session_with_recovery_locked(root) { + Ok(Some(session)) => session, + Ok(None) => { + return Some(plan_gdd_completion_blocker( + "needs-reconciliation", + "Fast GDD 已提交但 planning session 缺失,不能收束任务", + format!("gddVersion={} · runId={run_id}", latest.version), + )); + } + Err(error) => { + return Some(plan_gdd_completion_blocker( + "needs-reconciliation", + "planning session 无法恢复,不能收束 Fast GDD 任务", + error.to_string(), + )); + } + }; + if !plan_gdd_session_matches_submission(&session, latest) { + return Some(plan_gdd_completion_blocker( + "needs-reconciliation", + "planning session 尚未收口到当前 Fast GDD 提交", + format!( + "gddVersion={} · phase={} · activeRunPresent={} · latestSubmittedMatches={}", + latest.version, + session.phase, + session.active_run_id.is_some(), + session.latest_submitted_ref.as_ref() == Some(&receipt_plan_ref_from_gdd(latest)), + ), + )); + } + + let pending = match read_plan_gdd_approval_pending_locked(root) { + Ok(pending) => pending, + Err(error) => { + return Some(plan_gdd_completion_blocker( + "needs-reconciliation", + "Fast GDD approval pending 无法读取,不能收束任务", + error.to_string(), + )); + } + }; + let (pending_anchor, batch_anchor) = match plan_gdd_anchor_state_locked(root, latest) { + Ok(state) => state, + Err(error) => { + return Some(plan_gdd_completion_blocker( + "needs-reconciliation", + "原 plan.submit_gdd 恢复锚点 identity 不一致,不能收束任务", + error, + )); + } + }; + let terminal_observation = match receipt { + Some(receipt) => match approval_terminal_observation_exists_locked(root, receipt) { + Ok(value) => value, + Err(error) => { + return Some(plan_gdd_completion_blocker( + "needs-reconciliation", + "无法确认 Fast GDD terminal observation,不能收束任务", + error, + )); + } + }, + None => false, + }; + + let Some(receipt) = receipt else { + let pending_matches = pending.as_ref().is_some_and(|pending| { + pending_identity_matches_gdd(pending, latest) + && pending.status == "awaiting_decision" + && pending.observation.is_none() + }); + if !pending_matches { + return Some(plan_gdd_completion_blocker( + "needs-reconciliation", + "Fast GDD 已提交但审批 pending 尚未建立或 identity 不一致", + format!( + "gddVersion={} · approvalPending={} · pendingIdentityMatches={} · childPending={} · childBatch={}", + latest.version, + pending.is_some(), + pending_matches, + matches!(pending_anchor, PlanGddAnchorState::Present), + matches!(batch_anchor, PlanGddAnchorState::Present), + ), + )); + } + return Some(plan_gdd_completion_blocker( + "blocked", + "Fast GDD 已提交,等待用户审批决定,不能收束任务", + format!( + "gddVersion={} · approvalPending=awaiting_decision · childPending={} · childBatch={};pending 只能由验收取证通过后的 acceptance-gate caller 创建", + latest.version, + matches!(pending_anchor, PlanGddAnchorState::Present), + matches!(batch_anchor, PlanGddAnchorState::Present), + ), + )); + }; + + let expected_pending_status = format!("observed_{}", receipt.action); + if let Some(pending) = pending { + let pending_matches = pending_identity_matches_gdd(&pending, latest) + && pending.status == expected_pending_status + && pending.observation.as_ref().is_some_and(|observation| { + observation.tool == PLAN_GDD_APPROVAL_TOOL + && observation.status == "ok" + && observation.summary == approval_observation(receipt).summary + && observation.detail == approval_observation(receipt).detail + }); + if !pending_matches { + return Some(plan_gdd_completion_blocker( + "needs-reconciliation", + "Fast GDD approval pending 尚未按 receipt 收口", + format!( + "gddVersion={} · expectedStatus={expected_pending_status}", + latest.version + ), + )); + } + } + if pending_anchor != PlanGddAnchorState::Absent || batch_anchor != PlanGddAnchorState::Absent { + return Some(plan_gdd_completion_blocker( + "blocked", + "Fast GDD receipt 已提交,但原 plan.submit_gdd 恢复锚点尚未清理", + format!( + "gddVersion={} · terminalObservation={} · childPending={} · childBatch={}", + latest.version, + terminal_observation, + matches!(pending_anchor, PlanGddAnchorState::Present), + matches!(batch_anchor, PlanGddAnchorState::Present), + ), + )); + } + if !terminal_observation { + return Some(plan_gdd_completion_blocker( + "needs-reconciliation", + "Fast GDD receipt 已提交但 terminal observation 缺失,不能收束任务", + format!( + "gddVersion={} · childPending=false · childBatch=false", + latest.version + ), + )); + } + match plan_gdd_decision_audit_state_locked(root, receipt) { + Ok(true) => {} + Ok(false) => { + return Some(plan_gdd_completion_blocker( + "needs-reconciliation", + "Fast GDD decision audit 尚未落盘,不能收束任务", + format!( + "gddVersion={} · receiptAction={}", + latest.version, receipt.action + ), + )); + } + Err(error) => { + return Some(plan_gdd_completion_blocker( + "needs-reconciliation", + "Fast GDD decision audit identity 不一致,不能收束任务", + error, + )); + } + } + if !plan_gdd_session_matches_receipt(&session, latest, receipt) { + return Some(plan_gdd_completion_blocker( + "needs-reconciliation", + "planning session 尚未收口到 Fast GDD receipt", + format!( + "gddVersion={} · phase={} · lastDecisionMatches={}", + latest.version, + session.phase, + session.last_decision_ref.is_some(), + ), + )); + } + if session.phase == "recovery_required" { + return Some(plan_gdd_completion_blocker( + "needs-reconciliation", + "planning session 仍处于 recovery_required,不能收束任务", + format!("gddVersion={}", latest.version), + )); + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + #[test] + fn forged_terminal_summary_cannot_consume_submit_anchor() { + let root = std::env::temp_dir().join(format!( + "genarrative-plan-approval-observation-{}", + uuid::Uuid::new_v4().simple() + )); + init_local_game_project_at(&root, "project-test-approval", "approval observation") + .expect("init project"); + let receipt = PlanGddApprovalV1 { + schema_version: PLAN_GDD_APPROVAL_SCHEMA_VERSION.to_string(), + project_id: "project-test-approval".to_string(), + gdd_id: "gdd-00000000-0000-4000-8000-000000000001".to_string(), + version: 1, + fingerprint: format!("sha256-serde-json-v2:{}", "1".repeat(64)), + pending_action_id: "action-0123456789abcdef01234567".to_string(), + action_fingerprint: "2".repeat(64), + approval_request_id: "gdd-approval-00000000-0000-4000-8000-000000000002".to_string(), + response_id: "gdd-response-00000000-0000-4000-8000-000000000003".to_string(), + decision_fingerprint: format!("sha256-serde-json-v2:{}", "3".repeat(64)), + source: PLAN_GDD_APPROVAL_SOURCE.to_string(), + run_profile: AGENT_RUNTIME_RUN_PROFILE_STANDARD.to_string(), + run_profile_binding_fingerprint: "4".repeat(64), + session_id: "session-approval-1".to_string(), + run_id: "run-planning-child-1".to_string(), + action: "approve".to_string(), + comment: None, + decided_at_utc: "2026-08-15T00:00:00.000Z".to_string(), + receipt_fingerprint: format!("sha256-serde-json-v2:{}", "5".repeat(64)), + }; + let batch_path = game_creator_agent_runtime_provider_action_batch_path( + &root, + GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + &receipt.run_id, + ); + fs::create_dir_all(batch_path.parent().expect("batch parent")).expect("batch parent"); + fs::write(&batch_path, b"{}\n").expect("seed batch anchor"); + append_agent_db_terminal_observation_if_missing_for_action( + &root, + GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + &receipt.run_id, + &receipt.pending_action_id, + serde_json::json!({ + "recordType": "agent.runtime.tool_observation", + "agentId": GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + "taskId": "task-planning-1", + "runId": receipt.run_id, + "actionId": receipt.pending_action_id, + "actionFingerprint": receipt.action_fingerprint, + "tool": PLAN_GDD_APPROVAL_TOOL, + "status": "ok", + "summary": "伪造的审批摘要", + "decision": "approval", + }), + ) + .expect("append forged terminal observation"); + + assert!( + !approval_terminal_observation_exists_locked(&root, &receipt) + .expect("read terminal observation") + ); + assert!(!project_generic_submit_observation_locked(&root, &receipt) + .expect("reconcile forged observation")); + assert!( + batch_path.is_file(), + "mismatched observation must retain anchor" + ); + let _ = fs::remove_dir_all(root); + } +} 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 6d397e7da..3d4d55378 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 @@ -116,6 +116,31 @@ 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; +pub(crate) const PLAN_GDD_APPROVAL_SCHEMA_VERSION: &str = "plan-gdd-approval.v1"; +pub(crate) const PLAN_GDD_APPROVAL_PENDING_SCHEMA_VERSION: &str = "plan-gdd-approval-pending.v1"; +pub(crate) const PLAN_GDD_APPROVAL_DIR: &str = ".agent/planning/approvals"; +pub(crate) const PLAN_GDD_APPROVAL_PENDING_PATH: &str = ".agent/planning/pending.json"; +pub(crate) const PLAN_GDD_APPROVAL_MAX_BYTES: usize = 16 * 1024; +pub(crate) const PLAN_GDD_APPROVAL_PENDING_MAX_BYTES: usize = 16 * 1024; +// The fixed Chinese action prefix is part of the durable observation detail; +// leave room for the full 1,000-scalar user comment without truncation. +pub(crate) const PLAN_GDD_APPROVAL_OBSERVATION_DETAIL_MAX_SCALARS: usize = 1_100; +pub(crate) const PLAN_GDD_APPROVAL_FINGERPRINT_DOMAIN: &str = + "genarrative.plan.gdd-approval-receipt.v1"; +pub(crate) const PLAN_GDD_APPROVAL_DECISION_FINGERPRINT_DOMAIN: &str = + "genarrative.plan.gdd-decision.v1"; +pub(crate) const PLAN_GDD_APPROVAL_PENDING_FINGERPRINT_DOMAIN: &str = + "genarrative.plan.gdd-approval-pending.v1"; +pub(crate) const PLAN_GDD_APPROVAL_COMMENT_FINGERPRINT_DOMAIN: &str = + "genarrative.plan.gdd-comment.v1"; +pub(crate) const PLAN_GDD_APPROVAL_DECISION_AUDIT_SCHEMA_VERSION: &str = + "agent-runtime-plan-gdd-decided.v1"; +pub(crate) const PLAN_GDD_APPROVAL_DECISION_AUDIT_RECORD_TYPE: &str = + "agent.runtime.plan.gdd_decided"; +pub(crate) const PLAN_GDD_APPROVAL_PENDING_KIND: &str = "gdd-approval"; +pub(crate) const PLAN_GDD_APPROVAL_SOURCE: &str = "project-supervisor-plan"; +pub(crate) const PLAN_GDD_APPROVAL_AGENT_ID: &str = "project-supervisor"; +pub(crate) const PLAN_GDD_APPROVAL_TOOL: &str = "plan.submit_gdd"; static PLANNING_TEMP_NONCE: AtomicU64 = AtomicU64::new(1); @@ -1223,6 +1248,548 @@ pub(crate) struct PlanDecisionRef { pub(crate) receipt_fingerprint: String, } +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct PlanGddApprovalV1 { + pub(crate) schema_version: String, + pub(crate) project_id: String, + pub(crate) gdd_id: String, + pub(crate) version: u32, + pub(crate) fingerprint: String, + pub(crate) pending_action_id: String, + pub(crate) action_fingerprint: String, + pub(crate) approval_request_id: String, + pub(crate) response_id: String, + pub(crate) decision_fingerprint: String, + pub(crate) source: String, + pub(crate) run_profile: String, + pub(crate) run_profile_binding_fingerprint: String, + pub(crate) session_id: String, + pub(crate) run_id: String, + pub(crate) action: String, + pub(crate) comment: Option, + pub(crate) decided_at_utc: String, + pub(crate) receipt_fingerprint: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct PlanGddApprovalPendingV1 { + pub(crate) schema_version: String, + pub(crate) kind: String, + pub(crate) project_id: String, + pub(crate) agent_id: String, + pub(crate) gdd_ref: PlanGddRef, + pub(crate) submission: PlanGddApprovalPendingSubmission, + pub(crate) run_identity: PlanGddApprovalPendingRunIdentity, + pub(crate) status: String, + pub(crate) observation: Option, + pub(crate) pending_fingerprint: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct PlanGddApprovalPendingSubmission { + pub(crate) tool: String, + pub(crate) pending_action_id: String, + pub(crate) action_fingerprint: String, + pub(crate) approval_request_id: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct PlanGddApprovalPendingRunIdentity { + pub(crate) source: String, + pub(crate) run_profile: String, + pub(crate) run_profile_binding_fingerprint: String, + pub(crate) session_id: String, + pub(crate) run_id: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct PlanGddApprovalObservationV1 { + pub(crate) tool: String, + pub(crate) status: String, + pub(crate) summary: String, + pub(crate) detail: Option, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct PlanGddApprovalDecisionInputV1 { + pub(crate) project_id: String, + pub(crate) gdd_id: String, + pub(crate) version: u32, + pub(crate) fingerprint: String, + pub(crate) pending_action_id: String, + pub(crate) action_fingerprint: String, + pub(crate) approval_request_id: String, + pub(crate) response_id: String, + pub(crate) action: String, + pub(crate) comment: Option, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct PlanGddDecisionRefV1 { + pub(crate) gdd_id: String, + pub(crate) version: u32, + pub(crate) fingerprint: String, + pub(crate) approval_request_id: String, + pub(crate) response_id: String, + pub(crate) action: String, + pub(crate) decision_fingerprint: String, + pub(crate) receipt_fingerprint: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct PlanGddDecisionResultV1 { + pub(crate) outcome: String, + pub(crate) requested_response_id: String, + pub(crate) decision_ref: PlanGddDecisionRefV1, + pub(crate) approved_gdd_ref: Option, + pub(crate) recovery_pending: bool, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct PlanGddApprovalDecisionFingerprintValue<'a> { + project_id: &'a str, + gdd_id: &'a str, + version: u32, + fingerprint: &'a str, + pending_action_id: &'a str, + action_fingerprint: &'a str, + approval_request_id: &'a str, + response_id: &'a str, + source: &'a str, + run_profile: &'a str, + run_profile_binding_fingerprint: &'a str, + session_id: &'a str, + run_id: &'a str, + action: &'a str, + normalized_comment: Option<&'a str>, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct PlanGddApprovalReceiptFingerprintValue<'a> { + schema_version: &'a str, + project_id: &'a str, + gdd_id: &'a str, + version: u32, + fingerprint: &'a str, + pending_action_id: &'a str, + action_fingerprint: &'a str, + approval_request_id: &'a str, + response_id: &'a str, + decision_fingerprint: &'a str, + source: &'a str, + run_profile: &'a str, + run_profile_binding_fingerprint: &'a str, + session_id: &'a str, + run_id: &'a str, + action: &'a str, + comment: Option<&'a str>, + decided_at_utc: &'a str, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct PlanGddApprovalPendingFingerprintValue<'a> { + schema_version: &'a str, + kind: &'a str, + project_id: &'a str, + agent_id: &'a str, + gdd_ref: &'a PlanGddRef, + submission: &'a PlanGddApprovalPendingSubmission, + run_identity: &'a PlanGddApprovalPendingRunIdentity, + status: &'a str, + observation: Option<&'a PlanGddApprovalObservationV1>, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct PlanGddApprovalCommentFingerprintValue<'a> { + comment: Option<&'a str>, +} + +pub(crate) fn normalize_plan_gdd_approval_comment( + action: &str, + comment: Option<&str>, +) -> Result, PlanningStorageError> { + if !matches!(action, "approve" | "revise" | "reject") { + return Err(invalid(format!("未知 GDD 审批动作:{action}"))); + } + let normalized = comment + .map(|value| { + value + .replace("\r\n", "\n") + .replace('\r', "\n") + .trim() + .to_string() + }) + .filter(|value| !value.is_empty()); + match action { + "approve" => { + if let Some(value) = normalized.as_deref() { + validate_text(value, "approval.comment", 1, 1000)?; + } + Ok(normalized) + } + "revise" | "reject" => { + let value = normalized.ok_or_else(|| { + invalid(format!("{action} 审批必须提供 1~1000 scalar 的 comment")) + })?; + validate_text(&value, "approval.comment", 1, 1000)?; + Ok(Some(value)) + } + _ => unreachable!(), + } +} + +fn validate_approval_response_id(value: &str) -> Result<(), PlanningStorageError> { + validate_uuid_prefixed(value, "gdd-response-", "responseId") +} + +pub(crate) fn plan_gdd_approval_decision_fingerprint( + input: &PlanGddApprovalDecisionInputV1, + normalized_comment: Option<&str>, +) -> Result { + typed_serde_fingerprint( + PLAN_GDD_APPROVAL_DECISION_FINGERPRINT_DOMAIN, + &PlanGddApprovalDecisionFingerprintValue { + project_id: &input.project_id, + gdd_id: &input.gdd_id, + version: input.version, + fingerprint: &input.fingerprint, + pending_action_id: &input.pending_action_id, + action_fingerprint: &input.action_fingerprint, + approval_request_id: &input.approval_request_id, + response_id: &input.response_id, + source: PLAN_GDD_APPROVAL_SOURCE, + run_profile: AGENT_RUNTIME_RUN_PROFILE_STANDARD, + run_profile_binding_fingerprint: "", + session_id: "", + run_id: "", + action: &input.action, + normalized_comment, + }, + ) +} + +pub(crate) fn plan_gdd_approval_decision_fingerprint_for_identity( + input: &PlanGddApprovalDecisionInputV1, + source: &str, + run_profile: &str, + run_profile_binding_fingerprint: &str, + session_id: &str, + run_id: &str, + normalized_comment: Option<&str>, +) -> Result { + typed_serde_fingerprint( + PLAN_GDD_APPROVAL_DECISION_FINGERPRINT_DOMAIN, + &PlanGddApprovalDecisionFingerprintValue { + project_id: &input.project_id, + gdd_id: &input.gdd_id, + version: input.version, + fingerprint: &input.fingerprint, + pending_action_id: &input.pending_action_id, + action_fingerprint: &input.action_fingerprint, + approval_request_id: &input.approval_request_id, + response_id: &input.response_id, + source, + run_profile, + run_profile_binding_fingerprint, + session_id, + run_id, + action: &input.action, + normalized_comment, + }, + ) +} + +pub(crate) fn plan_gdd_approval_receipt_fingerprint( + value: &PlanGddApprovalV1, +) -> Result { + typed_serde_fingerprint( + PLAN_GDD_APPROVAL_FINGERPRINT_DOMAIN, + &PlanGddApprovalReceiptFingerprintValue { + schema_version: &value.schema_version, + project_id: &value.project_id, + gdd_id: &value.gdd_id, + version: value.version, + fingerprint: &value.fingerprint, + pending_action_id: &value.pending_action_id, + action_fingerprint: &value.action_fingerprint, + approval_request_id: &value.approval_request_id, + response_id: &value.response_id, + decision_fingerprint: &value.decision_fingerprint, + source: &value.source, + run_profile: &value.run_profile, + run_profile_binding_fingerprint: &value.run_profile_binding_fingerprint, + session_id: &value.session_id, + run_id: &value.run_id, + action: &value.action, + comment: value.comment.as_deref(), + decided_at_utc: &value.decided_at_utc, + }, + ) +} + +pub(crate) fn plan_gdd_approval_comment_fingerprint( + comment: Option<&str>, +) -> Result { + typed_serde_fingerprint( + PLAN_GDD_APPROVAL_COMMENT_FINGERPRINT_DOMAIN, + &PlanGddApprovalCommentFingerprintValue { comment }, + ) +} + +pub(crate) fn plan_gdd_approval_pending_fingerprint( + value: &PlanGddApprovalPendingV1, +) -> Result { + typed_serde_fingerprint( + PLAN_GDD_APPROVAL_PENDING_FINGERPRINT_DOMAIN, + &PlanGddApprovalPendingFingerprintValue { + schema_version: &value.schema_version, + kind: &value.kind, + project_id: &value.project_id, + agent_id: &value.agent_id, + gdd_ref: &value.gdd_ref, + submission: &value.submission, + run_identity: &value.run_identity, + status: &value.status, + observation: value.observation.as_ref(), + }, + ) +} + +pub(crate) fn validate_plan_gdd_approval( + value: &PlanGddApprovalV1, +) -> Result<(), PlanningStorageError> { + if value.schema_version != PLAN_GDD_APPROVAL_SCHEMA_VERSION { + return Err(invalid("未知 plan-gdd-approval schemaVersion")); + } + validate_opaque_id(&value.project_id, "approval.projectId", false)?; + validate_uuid_prefixed(&value.gdd_id, "gdd-", "approval.gddId")?; + if !(1..=PLAN_MAX_VERSIONS).contains(&value.version) { + return Err(invalid("approval.version 越界")); + } + if !is_typed_fingerprint(&value.fingerprint) { + return Err(invalid("approval.fingerprint 非法")); + } + validate_action_id(&value.pending_action_id, "approval.pendingActionId")?; + if !is_bare_fingerprint(&value.action_fingerprint) { + return Err(invalid("approval.actionFingerprint 非法")); + } + validate_uuid_prefixed( + &value.approval_request_id, + "gdd-approval-", + "approval.approvalRequestId", + )?; + validate_approval_response_id(&value.response_id)?; + if !is_typed_fingerprint(&value.decision_fingerprint) + || !is_typed_fingerprint(&value.receipt_fingerprint) + { + return Err(invalid("approval decision/receipt fingerprint 非法")); + } + if value.source != PLAN_GDD_APPROVAL_SOURCE + || value.run_profile != AGENT_RUNTIME_RUN_PROFILE_STANDARD + { + return Err(invalid("approval source/runProfile 不匹配")); + } + if !is_bare_fingerprint(&value.run_profile_binding_fingerprint) { + return Err(invalid("approval.runProfileBindingFingerprint 非法")); + } + validate_opaque_id(&value.session_id, "approval.sessionId", false)?; + validate_opaque_id(&value.run_id, "approval.runId", false)?; + if !matches!(value.action.as_str(), "approve" | "revise" | "reject") { + return Err(invalid("未知 approval.action")); + } + let normalized = + normalize_plan_gdd_approval_comment(value.action.as_str(), value.comment.as_deref())?; + if normalized != value.comment { + return Err(invalid("approval.comment 未完成规范化")); + } + validate_timestamp(&value.decided_at_utc, "approval.decidedAtUtc")?; + let decision_input = PlanGddApprovalDecisionInputV1 { + project_id: value.project_id.clone(), + gdd_id: value.gdd_id.clone(), + version: value.version, + fingerprint: value.fingerprint.clone(), + pending_action_id: value.pending_action_id.clone(), + action_fingerprint: value.action_fingerprint.clone(), + approval_request_id: value.approval_request_id.clone(), + response_id: value.response_id.clone(), + action: value.action.clone(), + comment: value.comment.clone(), + }; + let normalized = normalize_plan_gdd_approval_comment(&value.action, value.comment.as_deref())?; + let expected_decision = plan_gdd_approval_decision_fingerprint_for_identity( + &decision_input, + &value.source, + &value.run_profile, + &value.run_profile_binding_fingerprint, + &value.session_id, + &value.run_id, + normalized.as_deref(), + )?; + if expected_decision != value.decision_fingerprint { + return Err(PlanningStorageError::new( + "PLAN_FINGERPRINT_MISMATCH", + "approval decisionFingerprint 与 canonical payload 不一致", + )); + } + let expected_receipt = plan_gdd_approval_receipt_fingerprint(value)?; + if expected_receipt != value.receipt_fingerprint { + return Err(PlanningStorageError::new( + "PLAN_FINGERPRINT_MISMATCH", + "approval receiptFingerprint 与 canonical payload 不一致", + )); + } + Ok(()) +} + +pub(crate) fn validate_plan_gdd_approval_pending( + value: &PlanGddApprovalPendingV1, +) -> Result<(), PlanningStorageError> { + if value.schema_version != PLAN_GDD_APPROVAL_PENDING_SCHEMA_VERSION + || value.kind != PLAN_GDD_APPROVAL_PENDING_KIND + { + return Err(invalid("未知 planning approval pending schema/kind")); + } + validate_opaque_id(&value.project_id, "pending.projectId", false)?; + if value.agent_id != PLAN_GDD_APPROVAL_AGENT_ID { + return Err(invalid( + "approval pending agentId 必须是 project-supervisor", + )); + } + validate_plan_gdd_ref(&value.gdd_ref, "pending.gddRef")?; + if value.submission.tool != PLAN_GDD_APPROVAL_TOOL { + return Err(invalid("approval pending submission.tool 不匹配")); + } + validate_action_id( + &value.submission.pending_action_id, + "pending.pendingActionId", + )?; + if !is_bare_fingerprint(&value.submission.action_fingerprint) { + return Err(invalid("pending.actionFingerprint 非法")); + } + validate_uuid_prefixed( + &value.submission.approval_request_id, + "gdd-approval-", + "pending.approvalRequestId", + )?; + if value.run_identity.source != PLAN_GDD_APPROVAL_SOURCE + || value.run_identity.run_profile != AGENT_RUNTIME_RUN_PROFILE_STANDARD + { + return Err(invalid("pending runIdentity source/runProfile 不匹配")); + } + if !is_bare_fingerprint(&value.run_identity.run_profile_binding_fingerprint) { + return Err(invalid( + "pending.runIdentity.runProfileBindingFingerprint 非法", + )); + } + validate_opaque_id(&value.run_identity.session_id, "pending.sessionId", false)?; + validate_opaque_id(&value.run_identity.run_id, "pending.runId", false)?; + if !matches!( + value.status.as_str(), + "awaiting_decision" | "observed_approve" | "observed_revise" | "observed_reject" + ) { + return Err(invalid("未知 approval pending status")); + } + match (value.status.as_str(), value.observation.as_ref()) { + ("awaiting_decision", None) => {} + ("awaiting_decision", Some(_)) => { + return Err(invalid("awaiting_decision pending 不能带 observation")) + } + ("observed_approve" | "observed_revise" | "observed_reject", Some(observation)) => { + validate_plan_gdd_approval_observation( + value.gdd_ref.version, + value.status.as_str(), + observation, + )?; + } + (_, None) => return Err(invalid("observed approval pending 缺少 observation")), + (_, Some(_)) => return Err(invalid("awaiting approval pending 状态非法")), + } + if !is_typed_fingerprint(&value.pending_fingerprint) { + return Err(invalid("pendingFingerprint 非法")); + } + let expected = plan_gdd_approval_pending_fingerprint(value)?; + if expected != value.pending_fingerprint { + return Err(PlanningStorageError::new( + "PLAN_FINGERPRINT_MISMATCH", + "approval pendingFingerprint 与 canonical payload 不一致", + )); + } + Ok(()) +} + +/// Validate the deterministic observation projection stored in an approval +/// pending. The receipt remains the authority for the exact comment; this +/// validator only proves that the replaceable projection has the fixed +/// action/version envelope and a normalized comment body. +pub(crate) fn validate_plan_gdd_approval_observation( + version: u32, + pending_status: &str, + observation: &PlanGddApprovalObservationV1, +) -> Result<(), PlanningStorageError> { + if !(1..=PLAN_MAX_VERSIONS).contains(&version) { + return Err(invalid("approval observation version 越界")); + } + if observation.tool != PLAN_GDD_APPROVAL_TOOL || observation.status != "ok" { + return Err(invalid("approval pending observation identity 不匹配")); + } + validate_text(&observation.summary, "pending.observation.summary", 1, 400)?; + let (action, expected_summary, detail_prefix) = match pending_status { + "observed_approve" => ("approve", format!("Fast GDD v{version} 已批准"), None), + "observed_revise" => ( + "revise", + format!("Fast GDD v{version} 需要修改"), + Some("用户修改意见:"), + ), + "observed_reject" => ( + "reject", + format!("Fast GDD v{version} 已退回"), + Some("用户退回原因:"), + ), + _ => return Err(invalid("未知 approval pending observation status")), + }; + if observation.summary != expected_summary { + return Err(invalid( + "approval pending observation summary 与 status/version 不匹配", + )); + } + let detail = observation + .detail + .as_deref() + .ok_or_else(|| invalid("approval pending observation 缺少 detail"))?; + validate_text( + detail, + "pending.observation.detail", + 1, + PLAN_GDD_APPROVAL_OBSERVATION_DETAIL_MAX_SCALARS, + )?; + match detail_prefix { + None if detail == "用户已批准当前版本。" => Ok(()), + None => Err(invalid("approve observation detail 不符合固定正文")), + Some(prefix) => { + let comment = detail + .strip_prefix(prefix) + .ok_or_else(|| invalid("审批 observation detail 缺少固定 comment 前缀"))?; + let normalized = normalize_plan_gdd_approval_comment(action, Some(comment))?; + if normalized.as_deref() != Some(comment) { + return Err(invalid("审批 observation detail comment 未完成规范化")); + } + Ok(()) + } + } +} + #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub(crate) struct PlanAppliedAnswer { @@ -1872,6 +2439,58 @@ pub(crate) fn parse_plan_index_bytes(bytes: &[u8]) -> Result Result, PlanningStorageError> { + validate_plan_gdd_approval(value)?; + let bytes = canonical_bytes(value)?; + if bytes.len() > PLAN_GDD_APPROVAL_MAX_BYTES { + return Err(PlanningStorageError::new( + "PLAN_SIZE_LIMIT", + "GDD approval receipt 超过 16 KiB 字节上限", + )); + } + Ok(bytes) +} + +pub(crate) fn parse_plan_gdd_approval_bytes( + bytes: &[u8], +) -> Result { + let value = parse_strict_canonical::( + bytes, + "GDD approval receipt", + PLAN_GDD_APPROVAL_MAX_BYTES, + )?; + validate_plan_gdd_approval(&value)?; + Ok(value) +} + +pub(crate) fn canonical_plan_gdd_approval_pending_bytes( + value: &PlanGddApprovalPendingV1, +) -> Result, PlanningStorageError> { + validate_plan_gdd_approval_pending(value)?; + let bytes = canonical_bytes(value)?; + if bytes.len() > PLAN_GDD_APPROVAL_PENDING_MAX_BYTES { + return Err(PlanningStorageError::new( + "PLAN_SIZE_LIMIT", + "GDD approval pending 超过 16 KiB 字节上限", + )); + } + Ok(bytes) +} + +pub(crate) fn parse_plan_gdd_approval_pending_bytes( + bytes: &[u8], +) -> Result { + let value = parse_strict_canonical::( + bytes, + "GDD approval pending", + PLAN_GDD_APPROVAL_PENDING_MAX_BYTES, + )?; + validate_plan_gdd_approval_pending(&value)?; + Ok(value) +} + pub(crate) fn validate_plan_submit_gdd_input( value: &PlanSubmitGddInputV1, ) -> Result<(), PlanningStorageError> { @@ -2074,6 +2693,140 @@ pub(crate) fn build_plan_gdd_index( }) } +pub(crate) fn validate_plan_gdd_approvals_against_gdds( + gdds: &[PlanGddV1], + approvals: &[PlanGddApprovalV1], +) -> Result<(), PlanningStorageError> { + validate_plan_gdd_chain(gdds)?; + let mut by_version = std::collections::BTreeSet::new(); + for receipt in approvals { + validate_plan_gdd_approval(receipt)?; + if !by_version.insert(receipt.version) { + return Err(PlanningStorageError::new( + "PLAN_CORRUPT_AUTHORITY", + format!("GDD v{} 存在多个 approval receipt", receipt.version), + )); + } + let Some(gdd) = gdds.iter().find(|gdd| gdd.version == receipt.version) else { + return Err(PlanningStorageError::new( + "PLAN_CORRUPT_AUTHORITY", + format!("approval receipt 引用了不存在的 GDD v{}", receipt.version), + )); + }; + if receipt.project_id != gdd.project_id + || receipt.gdd_id != gdd.gdd_id + || receipt.fingerprint != gdd.fingerprint + || receipt.pending_action_id != gdd.submission_id + || receipt.action_fingerprint != gdd.action_fingerprint + || receipt.approval_request_id != gdd.approval_request_id + || receipt.run_profile != gdd.run_profile + || receipt.run_profile_binding_fingerprint != gdd.run_profile_binding_fingerprint + || receipt.session_id != gdd.session_id + || receipt.run_id != gdd.created_by_run_id + { + return Err(PlanningStorageError::new( + "PLAN_CORRUPT_AUTHORITY", + format!( + "approval receipt v{} 与 GDD 权威身份不一致", + receipt.version + ), + )); + } + } + if approvals.len() > gdds.len() { + return Err(PlanningStorageError::new( + "PLAN_CORRUPT_AUTHORITY", + "approval receipt 数量超过 GDD lineage", + )); + } + let missing = gdds + .iter() + .filter(|gdd| !by_version.contains(&gdd.version)) + .count(); + if missing > 1 { + return Err(PlanningStorageError::new( + "PLAN_NEEDS_RECONCILIATION", + "同一 GDD lineage 不能同时存在多个无 receipt 版本", + )); + } + if let Some(last) = gdds.last() { + if missing == 1 && by_version.contains(&last.version) { + return Err(PlanningStorageError::new( + "PLAN_NEEDS_RECONCILIATION", + "无 receipt GDD 必须是 lineage 最新版本", + )); + } + } + Ok(()) +} + +pub(crate) fn build_plan_gdd_index_with_approvals( + gdds: &[PlanGddV1], + approvals: &[PlanGddApprovalV1], + rebuilt_at_utc: &str, +) -> Result { + validate_plan_gdd_approvals_against_gdds(gdds, approvals)?; + validate_timestamp(rebuilt_at_utc, "rebuiltAtUtc")?; + let Some(first) = gdds.first() else { + if approvals.is_empty() { + return Err(invalid("没有 GDD 权威事实时不能构造 plan-gdd-index.v1")); + } + return Err(PlanningStorageError::new( + "PLAN_CORRUPT_AUTHORITY", + "approval receipt 存在但没有 GDD 权威事实", + )); + }; + let entries = gdds + .iter() + .map(plan_gdd_index_entry_from_gdd) + .collect::>(); + let receipt_by_version = approvals + .iter() + .map(|receipt| (receipt.version, receipt)) + .collect::>(); + let approved_version = approvals + .iter() + .filter(|receipt| receipt.action == "approve") + .map(|receipt| receipt.version) + .max(); + let pending_version = gdds + .iter() + .find(|gdd| !receipt_by_version.contains_key(&gdd.version)) + .map(|gdd| gdd.version); + let statuses = gdds + .iter() + .map(|gdd| { + let status = match receipt_by_version.get(&gdd.version) { + None => "ready_for_approval", + Some(receipt) => match receipt.action.as_str() { + "approve" if Some(gdd.version) == approved_version => "approved", + "approve" => "superseded", + "revise" => "revision_requested", + "reject" => "rejected", + _ => unreachable!("validated approval action"), + }, + }; + PlanGddIndexVersionStatus { + version: gdd.version, + status: status.to_string(), + } + }) + .collect::>(); + Ok(PlanGddIndexV1 { + schema_version: PLAN_GDD_INDEX_SCHEMA_VERSION.to_string(), + project_id: first.project_id.clone(), + gdd_id: first.gdd_id.clone(), + entries, + status_cache: PlanGddIndexStatusCache { + latest_version: gdds.len() as u32, + pending_version, + approved_version, + versions: statuses, + }, + rebuilt_at_utc: rebuilt_at_utc.to_string(), + }) +} + fn plan_gdd_index_entry_from_gdd(gdd: &PlanGddV1) -> PlanGddIndexEntry { PlanGddIndexEntry { version: gdd.version, @@ -2127,6 +2880,68 @@ pub(crate) fn validate_plan_gdd_index_against_gdds( Ok(()) } +pub(crate) fn validate_plan_gdd_index_against_gdds_and_approvals( + index: &PlanGddIndexV1, + gdds: &[PlanGddV1], + approvals: &[PlanGddApprovalV1], +) -> Result<(), PlanningStorageError> { + validate_plan_gdd_chain(gdds)?; + validate_plan_gdd_index(index)?; + let Some(first) = gdds.first() else { + return Err(invalid("index 没有可对应的 GDD 权威事实")); + }; + if index.project_id != first.project_id || index.gdd_id != first.gdd_id { + return Err(conflict("index projectId/gddId 与 GDD 权威事实不一致")); + } + if index.entries.len() != gdds.len() + || index + .entries + .iter() + .zip(gdds) + .any(|(entry, gdd)| entry != &plan_gdd_index_entry_from_gdd(gdd)) + { + return Err(conflict("index entries 必须逐项等于对应 GDD 的权威字段")); + } + let expected_status_cache = + build_plan_gdd_index_with_approvals(gdds, approvals, &index.rebuilt_at_utc)?.status_cache; + if index.status_cache != expected_status_cache { + return Err(conflict( + "index statusCache 必须由 GDD/receipt 权威事实确定性重建", + )); + } + Ok(()) +} + +fn approval_directory_is_present(root: &Path) -> Result { + let path = resolve_local_project_path(root, PLAN_GDD_APPROVAL_DIR) + .map_err(|error| PlanningStorageError::new("PLAN_INVALID_PATH", error))?; + match fs::symlink_metadata(path) { + Ok(metadata) => { + if planning_metadata_is_link_or_reparse(&metadata) || !metadata.is_dir() { + return Err(PlanningStorageError::new( + "PLAN_UNTRUSTED_PATH", + "approval receipt 目录必须是可信普通目录", + )); + } + Ok(true) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(error) => Err(io_error("探测 approval receipt 目录失败", error)), + } +} + +pub(crate) fn build_plan_gdd_index_for_root_locked( + root: &Path, + gdds: &[PlanGddV1], + rebuilt_at_utc: &str, +) -> Result { + let approvals = read_plan_gdd_approvals_locked(root)?; + if approvals.is_empty() && !approval_directory_is_present(root)? { + return build_plan_gdd_index(gdds, rebuilt_at_utc); + } + build_plan_gdd_index_with_approvals(gdds, &approvals, rebuilt_at_utc) +} + fn is_recoverable_index_projection_error(error: &PlanningStorageError) -> bool { matches!( error.code(), @@ -2174,7 +2989,7 @@ pub(crate) fn read_plan_gdd_index_with_recovery_locked( } let rebuild = || { - let rebuilt = build_plan_gdd_index(&gdds, rebuilt_at_utc)?; + let rebuilt = build_plan_gdd_index_for_root_locked(root, &gdds, rebuilt_at_utc)?; write_plan_gdd_index_atomic_locked(root, &rebuilt)?; Ok(Some(rebuilt)) }; @@ -2193,7 +3008,13 @@ pub(crate) fn read_plan_gdd_index_with_recovery_locked( Err(error) if is_recoverable_index_projection_error(&error) => return rebuild(), Err(error) => return Err(error), }; - match validate_plan_gdd_index_against_gdds(&parsed, &gdds) { + let approvals = read_plan_gdd_approvals_locked(root)?; + let validation = if approvals.is_empty() && !approval_directory_is_present(root)? { + validate_plan_gdd_index_against_gdds(&parsed, &gdds) + } else { + validate_plan_gdd_index_against_gdds_and_approvals(&parsed, &gdds, &approvals) + }; + match validation { Ok(()) => Ok(Some(parsed)), Err(error) if is_recoverable_index_projection_error(&error) => rebuild(), Err(error) => Err(error), @@ -2234,6 +3055,43 @@ fn gdd_version_from_file_name(name: &str) -> Result, PlanningStorage Ok(Some(version)) } +fn approval_version_from_file_name(name: &str) -> Result, PlanningStorageError> { + if !name.starts_with('v') { + return Ok(None); + } + let Some(number) = name + .strip_prefix('v') + .and_then(|value| value.strip_suffix(".json")) + else { + return Err(PlanningStorageError::new( + "PLAN_INVALID_PATH", + format!("审批 receipt 文件名不符合 vN.json:{name}"), + )); + }; + if number.is_empty() + || number.starts_with('0') + || !number.bytes().all(|byte| byte.is_ascii_digit()) + { + return Err(PlanningStorageError::new( + "PLAN_INVALID_PATH", + format!("审批 receipt 文件名版本非法:{name}"), + )); + } + let version = number.parse::().map_err(|_| { + PlanningStorageError::new( + "PLAN_INVALID_PATH", + format!("审批 receipt 版本溢出:{name}"), + ) + })?; + if !(1..=PLAN_MAX_VERSIONS).contains(&version) { + return Err(PlanningStorageError::new( + "PLAN_VERSION_LIMIT_REACHED", + format!("审批 receipt 版本超出 1..=128:{name}"), + )); + } + Ok(Some(version)) +} + /// Enumerate only exact `gdd.vN.json` facts and validate the complete /// continuous lineage. Unrelated planning projections are ignored; a /// malformed file that claims to be a GDD is rejected instead of guessed. @@ -2293,6 +3151,103 @@ pub(crate) fn read_plan_gdd_chain_locked( Ok(values) } +/// Read the immutable approval receipts. Only exact `vN.json` entries are +/// facts; a malformed entry that claims the reserved prefix is rejected rather +/// than silently ignored. The caller is responsible for reconciling each +/// receipt against the corresponding GDD lineage. +pub(crate) fn read_plan_gdd_approvals( + root: &Path, +) -> Result, PlanningStorageError> { + let _lock = acquire_project_write_lock(root, "planning.read-approvals") + .map_err(|error| io_error("读取 planning approval receipts 时取得项目锁失败", error))?; + read_plan_gdd_approvals_locked(root) +} + +pub(crate) fn read_plan_gdd_approvals_locked( + root: &Path, +) -> Result, PlanningStorageError> { + let approvals_root = resolve_local_project_path(root, PLAN_GDD_APPROVAL_DIR) + .map_err(|error| PlanningStorageError::new("PLAN_INVALID_PATH", error))?; + let metadata = match fs::symlink_metadata(&approvals_root) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(error) => return Err(io_error("读取 approval receipt 目录失败", error)), + }; + if planning_metadata_is_link_or_reparse(&metadata) || !metadata.is_dir() { + return Err(PlanningStorageError::new( + "PLAN_UNTRUSTED_PATH", + "approval receipt 目录必须是可信普通目录", + )); + } + let mut receipts = Vec::<(u32, PlanGddApprovalV1)>::new(); + for entry in fs::read_dir(&approvals_root) + .map_err(|error| io_error("枚举 approval receipt 目录失败", error))? + { + let entry = entry.map_err(|error| io_error("读取 approval receipt 目录项失败", error))?; + let name = entry + .file_name() + .to_str() + .ok_or_else(|| { + PlanningStorageError::new( + "PLAN_INVALID_PATH", + "approval receipt 目录包含非 UTF-8 文件名", + ) + })? + .to_string(); + let Some(version) = approval_version_from_file_name(&name)? else { + continue; + }; + let path = entry.path(); + let bytes = read_regular_planning_file(&path, &format!("GDD approval receipt v{version}"))?; + let value = parse_plan_gdd_approval_bytes(&bytes)?; + if value.version != version { + return Err(conflict(format!( + "approval receipt 文件名版本 v{version} 与 payload version={} 不一致", + value.version + ))); + } + receipts.push((version, value)); + } + receipts.sort_by_key(|(version, _)| *version); + for pair in receipts.windows(2) { + if pair[0].0 == pair[1].0 { + return Err(conflict(format!( + "同一 GDD 版本存在多个 approval receipt:v{}", + pair[0].0 + ))); + } + } + Ok(receipts.into_iter().map(|(_, value)| value).collect()) +} + +pub(crate) fn read_plan_gdd_approval_for_version_locked( + root: &Path, + version: u32, +) -> Result, PlanningStorageError> { + if !(1..=PLAN_MAX_VERSIONS).contains(&version) { + return Err(invalid("approval receipt version 越界")); + } + let relative = format!("{PLAN_GDD_APPROVAL_DIR}/v{version}.json"); + let path = resolve_local_project_path(root, &relative) + .map_err(|error| PlanningStorageError::new("PLAN_INVALID_PATH", error))?; + match fs::symlink_metadata(&path) { + Ok(_) => { + let bytes = + read_regular_planning_file(&path, &format!("GDD approval receipt v{version}"))?; + let value = parse_plan_gdd_approval_bytes(&bytes)?; + if value.version != version { + return Err(conflict(format!( + "approval receipt 文件名版本 v{version} 与 payload version={} 不一致", + value.version + ))); + } + Ok(Some(value)) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(io_error("读取 GDD approval receipt 失败", error)), + } +} + #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) enum PlanningCreateOutcome { Created, @@ -2363,11 +3318,29 @@ fn validate_planning_payload_bytes( } return Ok(()); } - // Approval/pending schemas are deliberately owned by M1B-2. Do not let - // the generic writer smuggle arbitrary JSON into their reserved paths. + if relative_path == PLAN_GDD_APPROVAL_PENDING_PATH { + parse_plan_gdd_approval_pending_bytes(bytes)?; + return Ok(()); + } + if relative_path.starts_with(".agent/planning/approvals/v") { + let expected = relative_path + .strip_prefix(".agent/planning/approvals/") + .ok_or_else(|| invalid("approval receipt 路径前缀非法"))?; + let Some(version) = approval_version_from_file_name(expected)? else { + return Err(invalid("approval receipt 路径文件名非法")); + }; + let value = parse_plan_gdd_approval_bytes(bytes)?; + if value.version != version { + return Err(conflict(format!( + "{label} 文件名 version={version} 与 payload version={} 不一致", + value.version + ))); + } + return Ok(()); + } Err(PlanningStorageError::new( "PLAN_UNSUPPORTED_SCHEMA", - format!("{relative_path} 的 durable schema 尚未由 M1B-1 实现"), + format!("{relative_path} 的 durable schema 未知"), )) } @@ -2381,7 +3354,12 @@ fn validate_planning_payload_at_root( if relative_path == PLAN_GDD_INDEX_PATH { let index = parse_plan_index_bytes(bytes)?; let gdds = read_plan_gdd_chain_locked(root)?; - validate_plan_gdd_index_against_gdds(&index, &gdds)?; + let approvals = read_plan_gdd_approvals_locked(root)?; + if approvals.is_empty() && !approval_directory_is_present(root)? { + validate_plan_gdd_index_against_gdds(&index, &gdds)?; + } else { + validate_plan_gdd_index_against_gdds_and_approvals(&index, &gdds, &approvals)?; + } } Ok(()) } @@ -3043,6 +4021,104 @@ pub(crate) fn durable_create_json_no_replace_locked( Ok(outcome) } +pub(crate) fn read_plan_gdd_approval_pending( + root: &Path, +) -> Result, PlanningStorageError> { + let _lock = acquire_project_write_lock(root, "planning.approval-pending.read") + .map_err(|error| io_error("读取 GDD approval pending 时取得项目锁失败", error))?; + read_plan_gdd_approval_pending_locked(root) +} + +pub(crate) fn read_plan_gdd_approval_pending_locked( + root: &Path, +) -> Result, PlanningStorageError> { + let path = resolve_local_project_path(root, PLAN_GDD_APPROVAL_PENDING_PATH) + .map_err(|error| PlanningStorageError::new("PLAN_INVALID_PATH", error))?; + match fs::symlink_metadata(&path) { + Ok(_) => { + let bytes = read_regular_planning_file(&path, "GDD approval pending")?; + Ok(Some(parse_plan_gdd_approval_pending_bytes(&bytes)?)) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(io_error("读取 GDD approval pending 失败", error)), + } +} + +/// The pending file is a replaceable projection. It is written atomically, +/// but unlike a receipt it may be advanced from awaiting to observed or +/// rebuilt after a crash. Equal bytes are treated as an idempotent replay. +pub(crate) fn write_plan_gdd_approval_pending_atomic( + root: &Path, + value: &PlanGddApprovalPendingV1, +) -> Result<(), PlanningStorageError> { + let _lock = acquire_project_write_lock(root, "planning.approval-pending.write") + .map_err(|error| io_error("写入 GDD approval pending 时取得项目锁失败", error))?; + write_plan_gdd_approval_pending_atomic_locked(root, value) +} + +pub(crate) fn write_plan_gdd_approval_pending_atomic_locked( + root: &Path, + value: &PlanGddApprovalPendingV1, +) -> Result<(), PlanningStorageError> { + let bytes = canonical_plan_gdd_approval_pending_bytes(value)?; + let target = resolve_local_project_path(root, PLAN_GDD_APPROVAL_PENDING_PATH) + .map_err(|error| PlanningStorageError::new("PLAN_INVALID_PATH", error))?; + let parent = ensure_planning_parent(&target)?; + if let Ok(_) = fs::symlink_metadata(&target) { + verify_regular_planning_file(&target, "现有 GDD approval pending")?; + let existing = read_regular_planning_file(&target, "现有 GDD approval pending")?; + parse_plan_gdd_approval_pending_bytes(&existing)?; + if existing == bytes { + return Ok(()); + } + } + let temporary = temp_planning_path(parent, &target); + let result = (|| { + write_sync_new_file(&temporary, &bytes, "GDD approval pending")?; + verify_replace_target_is_safe(&target, "GDD approval pending")?; + replace_planning_file_atomically(&temporary, &target, "GDD approval pending")?; + let published = read_regular_planning_file(&target, "已发布 GDD approval pending")?; + if published != bytes { + return Err(PlanningStorageError::new( + "PLAN_RECONCILIATION_REQUIRED", + "GDD approval pending 发布后 canonical bytes 不一致", + )); + } + parse_plan_gdd_approval_pending_bytes(&published)?; + sync_planning_parent(parent) + })(); + let _ = fs::remove_file(&temporary); + result +} + +pub(crate) fn remove_plan_gdd_approval_pending_locked( + root: &Path, +) -> Result<(), PlanningStorageError> { + let path = resolve_local_project_path(root, PLAN_GDD_APPROVAL_PENDING_PATH) + .map_err(|error| PlanningStorageError::new("PLAN_INVALID_PATH", error))?; + match fs::symlink_metadata(&path) { + Ok(_) => { + verify_regular_planning_file(&path, "GDD approval pending")?; + let bytes = read_regular_planning_file(&path, "GDD approval pending")?; + parse_plan_gdd_approval_pending_bytes(&bytes)?; + fs::remove_file(&path) + .map_err(|error| io_error("清理 GDD approval pending 失败", error))?; + if let Some(parent) = path.parent() { + sync_planning_parent(parent)?; + } + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(io_error("读取 GDD approval pending 目标失败", error)), + } + Ok(()) +} + +pub(crate) fn remove_plan_gdd_approval_pending(root: &Path) -> Result<(), PlanningStorageError> { + let _lock = acquire_project_write_lock(root, "planning.approval-pending.remove") + .map_err(|error| io_error("清理 GDD approval pending 时取得项目锁失败", error))?; + remove_plan_gdd_approval_pending_locked(root) +} + /// Rebuild and atomically replace the derived GDD index. The index is not an /// immutable fact: a new GDD version must replace it, while a corrupt or /// missing index can always be rebuilt from the authoritative GDD chain. @@ -3061,7 +4137,12 @@ pub(crate) fn write_plan_gdd_index_atomic_locked( ) -> Result<(), PlanningStorageError> { let bytes = canonical_plan_index_bytes(value)?; let gdds = read_plan_gdd_chain_locked(root)?; - validate_plan_gdd_index_against_gdds(value, &gdds)?; + let approvals = read_plan_gdd_approvals_locked(root)?; + if approvals.is_empty() && !approval_directory_is_present(root)? { + validate_plan_gdd_index_against_gdds(value, &gdds)?; + } else { + validate_plan_gdd_index_against_gdds_and_approvals(value, &gdds, &approvals)?; + } let target = resolve_planning_path(root, PLAN_GDD_INDEX_PATH)?; let parent = ensure_planning_parent(&target)?; match fs::symlink_metadata(&target) { @@ -3080,7 +4161,16 @@ pub(crate) fn write_plan_gdd_index_atomic_locked( let published = read_regular_planning_file(&target, "已发布 GDD index")?; let parsed = parse_plan_index_bytes(&published)?; let current_gdds = read_plan_gdd_chain_locked(root)?; - validate_plan_gdd_index_against_gdds(&parsed, ¤t_gdds)?; + let current_approvals = read_plan_gdd_approvals_locked(root)?; + if current_approvals.is_empty() && !approval_directory_is_present(root)? { + validate_plan_gdd_index_against_gdds(&parsed, ¤t_gdds)?; + } else { + validate_plan_gdd_index_against_gdds_and_approvals( + &parsed, + ¤t_gdds, + ¤t_approvals, + )?; + } if published != bytes { return Err(PlanningStorageError::new( "PLAN_RECONCILIATION_REQUIRED", diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_submit.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_submit.rs index 0e33f9fa8..083967865 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_submit.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_submit.rs @@ -1613,19 +1613,38 @@ fn project_submit_successors_locked( 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) { + // A submit replay may be repairing a projection written for the previous + // lineage length. Rebuild from the complete current GDD/receipt facts on + // every committed submit so a newly created vN can never leave a vN-1 + // index behind. + let index = match build_plan_gdd_index_for_root_locked(root, &chain, &context.created_at_utc) { + Ok(index) => { if write_plan_gdd_index_atomic_locked(root, &index).is_err() { recovery_pending = true; } + Some(index) } - } + Err(_) => { + recovery_pending = true; + None + } + }; // Markdown is a projection of the current latest authority, not of the // action being replayed. Replaying an older submission must never roll // `game/fast_gdd.md` back over a newer immutable version. let projection_gdd = chain.last().unwrap_or(gdd); - if let Ok(markdown) = render_plan_fast_gdd_markdown(projection_gdd, "ready_for_approval") { + let projection_status = index + .as_ref() + .and_then(|index| { + index + .status_cache + .versions + .iter() + .find(|status| status.version == projection_gdd.version) + }) + .map(|status| status.status.as_str()) + .unwrap_or("ready_for_approval"); + if let Ok(markdown) = render_plan_fast_gdd_markdown(projection_gdd, projection_status) { if write_plan_fast_gdd_markdown_atomic_locked(root, &markdown).is_err() { recovery_pending = true; } @@ -1719,6 +1738,7 @@ pub(crate) fn execute_plan_submit_gdd( validate_durable_child_binding(root, context)?; let chain = read_plan_gdd_chain_locked(root)?; + let approvals = read_plan_gdd_approvals_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 @@ -1789,14 +1809,17 @@ pub(crate) fn execute_plan_submit_gdd( "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 重放", - )); + validate_plan_gdd_approvals_against_gdds(&chain, &approvals)?; + if let Some(latest) = chain.last() { + if !approvals + .iter() + .any(|receipt| receipt.version == latest.version) + { + return Err(submit_error( + "PLAN_PENDING_GDD_EXISTS", + "最新 GDD 尚未完成用户审批;只能重放原 submissionId", + )); + } } let Some(current_session) = current_session else { @@ -1807,13 +1830,23 @@ pub(crate) fn execute_plan_submit_gdd( }; validate_plan_session(current_session)?; validate_current_session_cas(current_session, context, input)?; - let version = 1; + let version = chain + .last() + .map(|latest| latest.version.saturating_add(1)) + .unwrap_or(1); + if version > PLAN_MAX_VERSIONS { + return Err(submit_error( + "PLAN_VERSION_LIMIT_REACHED", + "不能继续创建第 129 个 GDD 版本", + )); + } let approval_request_id = context .approval_request_id .clone() .unwrap_or_else(generated_approval_request_id); let candidate = build_plan_gdd_from_submit_input(input, context, version, &approval_request_id)?; + validate_next_plan_gdd_version(&chain, &candidate)?; let bytes = canonical_plan_gdd_bytes(&candidate)?; // Durable create is the submit point. Everything below is best-effort @@ -2073,6 +2106,16 @@ mod tests { None, ) .expect("bind plan root"); + start_game_creator_agent_runtime_task_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "收敛 Fast GDD", + "run-root-001", + AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE, + "等待 project-planning 提交 GDD", + vec!["等待策划子 Run 提交 Fast GDD".to_string()], + ) + .expect("start plan root task"); let child_binding = bind_game_creator_agent_runtime_run_profile_at( &root, GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, @@ -2960,6 +3003,353 @@ mod tests { cleanup_fixture(root); } + fn approval_input( + gdd: &PlanGddV1, + action: &str, + response_id: &str, + comment: Option, + ) -> DecidePlanGddInputV1 { + DecidePlanGddInputV1 { + gdd_id: gdd.gdd_id.clone(), + version: gdd.version, + fingerprint: gdd.fingerprint.clone(), + pending_action_id: gdd.submission_id.clone(), + approval_request_id: gdd.approval_request_id.clone(), + response_id: response_id.to_string(), + action: action.to_string(), + comment, + } + } + + #[test] + fn approval_pending_is_single_latest_unreceipted_projection() { + let (root, context, input) = submit_fixture(); + execute_plan_submit_gdd(&root, &context, &input).expect("submit v1"); + let gdd = read_plan_gdd_chain(&root) + .expect("read submitted GDD") + .pop() + .expect("GDD exists"); + + create_plan_gdd_approval_pending_at(&root, &gdd).expect("create approval pending"); + let pending = read_plan_gdd_approval_pending(&root) + .expect("read approval pending") + .expect("pending exists"); + assert_eq!(pending.status, "awaiting_decision"); + // Recreating the exact card is an idempotent replay. + create_plan_gdd_approval_pending_at(&root, &gdd).expect("replay approval pending"); + + let mut forged_next = gdd.clone(); + forged_next.version = 2; + forged_next.submission_id = "action-fedcba9876543210fedcba98".to_string(); + forged_next.approval_request_id = + "gdd-approval-00000000-0000-4000-8000-000000000003".to_string(); + forged_next.action_fingerprint = "4".repeat(64); + forged_next.fingerprint = plan_gdd_fingerprint(&forged_next).expect("next fingerprint"); + let stale = create_plan_gdd_approval_pending_at(&root, &forged_next) + .expect_err("a non-latest GDD cannot receive an approval card"); + assert_eq!(stale.code(), "PLAN_STALE_APPROVAL"); + + let decision = decide_plan_gdd_at( + &root, + &approval_input( + &gdd, + "approve", + "gdd-response-00000000-0000-4000-8000-000000000010", + None, + ), + ) + .expect("commit approval receipt"); + assert_eq!(decision.outcome, "committed"); + let after_receipt = create_plan_gdd_approval_pending_at(&root, &gdd) + .expect_err("a receipt must close awaiting_decision recreation"); + assert_eq!(after_receipt.code(), "PLAN_STALE_APPROVAL"); + cleanup_fixture(root); + } + + #[test] + fn plan_gdd_completion_blocker_requires_pending_and_terminal_observation() { + let (root, context, input) = submit_fixture(); + execute_plan_submit_gdd(&root, &context, &input).expect("submit v1"); + let gdd = read_plan_gdd_chain(&root) + .expect("read submitted GDD") + .pop() + .expect("GDD exists"); + + let blocker = plan_gdd_completion_blocker_at_locked( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &context.root_run_id, + ) + .expect("missing approval pending must block reconciliation"); + assert_eq!(blocker.status, "needs-reconciliation"); + assert!(blocker.summary.contains("审批 pending")); + + create_plan_gdd_approval_pending_at(&root, &gdd).expect("create approval pending"); + let blocker = plan_gdd_completion_blocker_at_locked( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &context.root_run_id, + ) + .expect("awaiting approval must block completion"); + assert_eq!(blocker.status, "blocked"); + assert!(blocker.summary.contains("等待用户审批")); + + let decision_input = approval_input( + &gdd, + "approve", + "gdd-response-00000000-0000-4000-8000-000000000030", + None, + ); + let decision = decide_plan_gdd_at(&root, &decision_input).expect("commit receipt"); + assert_eq!(decision.outcome, "committed"); + assert!(decision.recovery_pending); + let blocker = plan_gdd_completion_blocker_at_locked( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &context.root_run_id, + ) + .expect("receipt without terminal observation must reconcile"); + assert_eq!(blocker.status, "needs-reconciliation"); + assert!(blocker.summary.contains("terminal observation")); + + append_agent_db_terminal_observation_if_missing_for_action( + &root, + GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + &gdd.created_by_run_id, + &gdd.submission_id, + serde_json::json!({ + "recordType": "agent.runtime.tool_observation", + "agentId": GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + "taskId": "task-plan-001", + "runId": gdd.created_by_run_id, + "actionId": gdd.submission_id, + "actionFingerprint": gdd.action_fingerprint, + "tool": PLAN_GDD_APPROVAL_TOOL, + "status": "ok", + "summary": "Fast GDD v1 已批准", + "decision": "approval", + }), + ) + .expect("append exact terminal observation"); + assert!(!reconcile_plan_gdd_approval_projections_at(&root) + .expect("reconcile receipt projections")); + assert!(plan_gdd_completion_blocker_at_locked( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &context.root_run_id, + ) + .is_none()); + cleanup_fixture(root); + } + + #[test] + fn plan_gdd_completion_blocker_is_scoped_to_exact_plan_root() { + let (root, context, input) = submit_fixture(); + execute_plan_submit_gdd(&root, &context, &input).expect("submit v1"); + assert!(plan_gdd_completion_blocker_at_locked( + &root, + GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + "run-child-001", + ) + .is_none()); + assert!(plan_gdd_completion_blocker_at_locked( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "run-not-current", + ) + .is_none()); + cleanup_fixture(root); + } + + #[test] + fn plan_gdd_completion_blocker_rejects_mismatched_observed_pending() { + let (root, context, input) = submit_fixture(); + execute_plan_submit_gdd(&root, &context, &input).expect("submit v1"); + let gdd = read_plan_gdd_chain(&root) + .expect("read submitted GDD") + .pop() + .expect("GDD exists"); + create_plan_gdd_approval_pending_at(&root, &gdd).expect("create approval pending"); + decide_plan_gdd_at( + &root, + &approval_input( + &gdd, + "approve", + "gdd-response-00000000-0000-4000-8000-000000000031", + None, + ), + ) + .expect("commit receipt"); + + let mut pending = read_plan_gdd_approval_pending(&root) + .expect("read observed pending") + .expect("observed pending remains during recovery"); + pending.run_identity.run_id = "run-other-001".to_string(); + pending.pending_fingerprint = + plan_gdd_approval_pending_fingerprint(&pending).expect("recompute pending fingerprint"); + write_plan_gdd_approval_pending_atomic(&root, &pending) + .expect("write mismatched projection"); + + let blocker = plan_gdd_completion_blocker_at_locked( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &context.root_run_id, + ) + .expect("mismatched pending must require reconciliation"); + assert_eq!(blocker.status, "needs-reconciliation"); + assert!(blocker.summary.contains("pending 尚未按 receipt 收口")); + cleanup_fixture(root); + } + + #[test] + fn approval_comment_accepts_full_scalar_limit_and_observation_prefix() { + let (root, context, input) = submit_fixture(); + execute_plan_submit_gdd(&root, &context, &input).expect("submit v1"); + let gdd = read_plan_gdd_chain(&root) + .expect("read submitted GDD") + .pop() + .expect("GDD exists"); + let comment = "界".repeat(1_000); + let normalized = normalize_plan_gdd_approval_comment("revise", Some(&comment)) + .expect("1,000 scalar comment is valid") + .expect("comment remains present"); + assert_eq!(normalized.chars().count(), 1_000); + let mut pending = PlanGddApprovalPendingV1 { + schema_version: PLAN_GDD_APPROVAL_PENDING_SCHEMA_VERSION.to_string(), + kind: PLAN_GDD_APPROVAL_PENDING_KIND.to_string(), + project_id: gdd.project_id.clone(), + agent_id: PLAN_GDD_APPROVAL_AGENT_ID.to_string(), + gdd_ref: PlanGddRef { + gdd_id: gdd.gdd_id.clone(), + version: gdd.version, + fingerprint: gdd.fingerprint.clone(), + }, + submission: PlanGddApprovalPendingSubmission { + tool: PLAN_GDD_APPROVAL_TOOL.to_string(), + pending_action_id: gdd.submission_id.clone(), + action_fingerprint: gdd.action_fingerprint.clone(), + approval_request_id: gdd.approval_request_id.clone(), + }, + run_identity: PlanGddApprovalPendingRunIdentity { + source: PLAN_GDD_APPROVAL_SOURCE.to_string(), + run_profile: gdd.run_profile.clone(), + run_profile_binding_fingerprint: gdd.run_profile_binding_fingerprint.clone(), + session_id: gdd.session_id.clone(), + run_id: gdd.created_by_run_id.clone(), + }, + status: "observed_revise".to_string(), + observation: Some(PlanGddApprovalObservationV1 { + tool: PLAN_GDD_APPROVAL_TOOL.to_string(), + status: "ok".to_string(), + summary: format!("Fast GDD v{} 需要修改", gdd.version), + detail: Some(format!("用户修改意见:{normalized}")), + }), + pending_fingerprint: String::new(), + }; + pending.pending_fingerprint = + plan_gdd_approval_pending_fingerprint(&pending).expect("pending fingerprint"); + validate_plan_gdd_approval_pending(&pending) + .expect("observation prefix must leave room for the full comment"); + let mut tampered = pending.clone(); + tampered + .observation + .as_mut() + .expect("observation exists") + .summary = "伪造的审批摘要".to_string(); + tampered.pending_fingerprint = + plan_gdd_approval_pending_fingerprint(&tampered).expect("tampered fingerprint"); + assert!(validate_plan_gdd_approval_pending(&tampered).is_err()); + let too_long = format!("{comment}界"); + assert!(normalize_plan_gdd_approval_comment("revise", Some(&too_long)).is_err()); + let decision_error = decide_plan_gdd_at( + &root, + &approval_input( + &gdd, + "revise", + "gdd-response-00000000-0000-4000-8000-000000000011", + Some(too_long), + ), + ) + .expect_err("invalid command comment must be rejected at transport boundary"); + assert_eq!(decision_error.code(), "PLAN_INVALID_REQUEST"); + assert_eq!( + PLAN_GDD_APPROVAL_FINGERPRINT_DOMAIN, + "genarrative.plan.gdd-approval-receipt.v1" + ); + cleanup_fixture(root); + } + + #[test] + fn approval_actions_rebuild_receipt_aware_index_and_are_idempotent() { + for (action, comment, expected_status) in [ + ("approve", None, "approved"), + ("revise", Some("请收窄首局范围"), "revision_requested"), + ("reject", Some("当前方向需要重新梳理"), "rejected"), + ] { + let (root, context, input) = submit_fixture(); + execute_plan_submit_gdd(&root, &context, &input).expect("submit v1"); + let gdd = read_plan_gdd_chain(&root) + .expect("read submitted GDD") + .pop() + .expect("GDD exists"); + create_plan_gdd_approval_pending_at(&root, &gdd).expect("create approval pending"); + let first_input = approval_input( + &gdd, + action, + "gdd-response-00000000-0000-4000-8000-000000000020", + comment.map(str::to_string), + ); + let first = decide_plan_gdd_at(&root, &first_input).expect("commit receipt"); + assert_eq!(first.outcome, "committed"); + assert_eq!(first.decision_ref.action, action); + assert_eq!(first.approved_gdd_ref.is_some(), action == "approve"); + + let approvals = read_plan_gdd_approvals(&root).expect("read receipts"); + let index = build_plan_gdd_index_with_approvals( + &read_plan_gdd_chain(&root).expect("read GDD chain"), + &approvals, + "2026-08-15T00:00:00.000Z", + ) + .expect("receipt-aware index"); + assert_eq!(index.status_cache.versions[0].status, expected_status); + assert_eq!(index.status_cache.pending_version, None); + assert_eq!( + index.status_cache.approved_version, + (action == "approve").then_some(1) + ); + + let replay = decide_plan_gdd_at(&root, &first_input).expect("replay same response"); + assert_eq!(replay.outcome, "replayed"); + let already_decided = decide_plan_gdd_at( + &root, + &approval_input( + &gdd, + action, + "gdd-response-00000000-0000-4000-8000-000000000021", + comment.map(str::to_string), + ), + ) + .expect("different response returns existing decision"); + assert_eq!(already_decided.outcome, "already-decided"); + let conflicting = decide_plan_gdd_at( + &root, + &approval_input( + &gdd, + action, + "gdd-response-00000000-0000-4000-8000-000000000020", + if action == "approve" { + Some("changed intent".to_string()) + } else { + Some("changed intent".to_string()) + }, + ), + ) + .expect_err("same response with changed intent must conflict"); + assert_eq!(conflicting.code(), "PLAN_DECISION_IDENTITY_CONFLICT"); + cleanup_fixture(root); + } + } + #[test] fn projection_failure_after_gdd_create_returns_recovery_pending_and_replays() { let (root, context, input) = submit_fixture(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delivery.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delivery.rs index 064a4d6fd..603a0128d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delivery.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delivery.rs @@ -337,6 +337,11 @@ pub(in crate::agent) fn wake_waiting_static_delegate_parent_run_at( ensure_static_delegate_user_input_wait_at(root, &mut state, &deliveries)?; return Ok(true); } + if barrier.user_revision_pending_count > 0 { + // A user revision is an explicit Supervisor decision boundary. Do + // not auto-resume the parent before it dispatches the continuation. + return Ok(false); + } let state = advance_game_creator_agent_runtime_turn_at( root, state, diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index dacf1cb31..4816c145c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -1206,6 +1206,48 @@ pub(crate) fn answer_game_creator_agent_runtime_user_input( ) } +#[tauri::command] +pub(crate) fn decide_game_creator_plan_gdd( + project_path: String, + gdd_id: String, + version: u32, + fingerprint: String, + pending_action_id: String, + approval_request_id: String, + response_id: String, + action: String, + comment: Option, +) -> Result { + let root = validated_local_project_directory_path(project_path.trim())?; + enforce_project_permission_policy(&root, "conversation.read")?; + enforce_project_permission_policy(&root, "conversation.write")?; + enforce_project_permission_policy(&root, "agent.run_status")?; + enforce_project_permission_policy(&root, "agent.resume")?; + let mut result = decide_plan_gdd_at( + &root, + &DecidePlanGddInputV1 { + gdd_id, + version, + fingerprint, + pending_action_id, + approval_request_id, + response_id, + action, + comment, + }, + ) + .map_err(|error| error.to_string())?; + if !result.recovery_pending { + if wake_pending_game_creator_agent_background_tasks_at(&root).is_err() { + // The receipt is already the user-decision linearization point; + // surface a recoverable projection state instead of turning a + // durable approval into a false command failure. + result.recovery_pending = true; + } + } + Ok(result) +} + #[tauri::command] pub(crate) fn read_game_creator_agent_runtime( project_path: String, 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 48e8e60b8..3203c72c3 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/delegation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/delegation.rs @@ -216,6 +216,7 @@ pub(crate) struct StaticDelegateCompletionBarrier { pub(crate) unobserved_claim_count: usize, pub(crate) repair_required_count: usize, pub(crate) user_input_required_count: usize, + pub(crate) user_revision_pending_count: usize, pub(crate) unknown_contract_status_count: usize, } @@ -226,21 +227,25 @@ impl StaticDelegateCompletionBarrier { && self.unobserved_claim_count == 0 && self.repair_required_count == 0 && self.user_input_required_count == 0 + && self.user_revision_pending_count == 0 && self.unknown_contract_status_count == 0 } pub(crate) fn has_waiting(self) -> bool { - self.waiting_count > 0 || self.unknown_contract_status_count > 0 + self.waiting_count > 0 + || self.user_revision_pending_count > 0 + || self.unknown_contract_status_count > 0 } pub(crate) fn detail(self) -> String { format!( - "waitingDelegations={} · readyUnclaimedReceipts={} · unobservedReceiptClaims={} · repairRequired={} · userInputRequired={} · unknownContractStatus={} · 必须认领专业 Agent 回执,处理 needs-user-input/needs-repair,或升级客户端后再继续", + "waitingDelegations={} · readyUnclaimedReceipts={} · unobservedReceiptClaims={} · repairRequired={} · userInputRequired={} · userRevisionPending={} · unknownContractStatus={} · 必须认领专业 Agent 回执,处理 needs-user-input/needs-repair/user-revision-requested,或升级客户端后再继续", self.waiting_count, self.ready_unclaimed_count, self.unobserved_claim_count, self.repair_required_count, self.user_input_required_count, + self.user_revision_pending_count, self.unknown_contract_status_count ) } @@ -445,6 +450,42 @@ pub(crate) fn mark_static_delegate_delivery_ready_with_result_at( Ok(delivery) } +/// Mark an already claimed, evidence-ready planning delivery as waiting for a +/// user-requested revision. Approval is the only producer of this durable +/// status; keeping the transition here makes its evidence precondition and +/// idempotency explicit instead of allowing a generic delivery writer to +/// manufacture the state. +pub(crate) fn mark_static_delegate_delivery_user_revision_requested_at( + root: &Path, + parent_agent_id: &str, + parent_run_id: &str, + delegation_id: &str, +) -> Result { + validate_static_delegate_id(parent_agent_id, "parentAgentId", 96)?; + validate_static_delegate_id(parent_run_id, "parentRunId", 160)?; + validate_static_delegate_id(delegation_id, "delegationId", 160)?; + let mut delivery = read_static_delegate_delivery_at(root, delegation_id)? + .ok_or_else(|| format!("静态委派 delivery 不存在:{delegation_id}"))?; + if delivery.parent_agent_id != parent_agent_id || delivery.parent_run_id != parent_run_id { + return Err("用户修订只能改写同一 Supervisor 父 run 的 delivery".to_string()); + } + if delivery.status != StaticDelegateDeliveryStatus::ClaimedByParent { + return Err("用户修订只能改写已由 Supervisor 认领的 delivery".to_string()); + } + let Some(result) = delivery.structured_result.as_mut() else { + return Err("用户修订的原 delivery 缺少 structuredResult".to_string()); + }; + match result.contract_status { + StaticDelegateContractStatus::UserRevisionRequested => return Ok(delivery), + StaticDelegateContractStatus::EvidenceReady => {} + _ => return Err("用户修订只能从 EvidenceReady delivery 派生".to_string()), + } + result.contract_status = StaticDelegateContractStatus::UserRevisionRequested; + delivery.updated_at = unix_timestamp(); + write_static_delegate_delivery_at(root, &delivery)?; + Ok(delivery) +} + pub(crate) fn suppress_static_delegate_delivery_at( root: &Path, expected: &StaticDelegateDeliveryRecord, @@ -558,6 +599,25 @@ pub(crate) fn static_delegate_completion_barrier_at( }) }) .count(); + barrier.user_revision_pending_count = deliveries + .iter() + .filter(|delivery| { + delivery.status == StaticDelegateDeliveryStatus::ClaimedByParent + && delivery.structured_result.as_ref().is_some_and(|result| { + result.contract_status == StaticDelegateContractStatus::UserRevisionRequested + }) + && !deliveries.iter().any(|candidate| { + candidate.repair_of_delegation_id.as_deref() + == Some(delivery.delegation_id.as_str()) + && matches!( + candidate.status, + StaticDelegateDeliveryStatus::Dispatched + | StaticDelegateDeliveryStatus::Ready + | StaticDelegateDeliveryStatus::ClaimedByParent + ) + }) + }) + .count(); barrier.unknown_contract_status_count = deliveries .iter() .filter(|delivery| { @@ -2281,28 +2341,39 @@ fn validate_static_delegate_structured_result( if result.verified_revision == Some(0) { return Err("静态委派 verifiedRevision 必须大于 0".to_string()); } - if result.contract_status == StaticDelegateContractStatus::EvidenceReady - && (terminal_status != "completed" - || !result.missing_expected_artifacts.is_empty() - || (result.verification_required && result.verified_revision.is_none())) - { - return Err("静态委派 evidence-ready 与客观证据冲突".to_string()); + match &result.contract_status { + StaticDelegateContractStatus::EvidenceReady + | StaticDelegateContractStatus::UserRevisionRequested => { + if terminal_status != "completed" + || !result.missing_expected_artifacts.is_empty() + || (result.verification_required && result.verified_revision.is_none()) + { + return Err( + "静态委派 evidence-ready/user-revision-requested 与客观证据冲突".to_string(), + ); + } + } + StaticDelegateContractStatus::NeedsUserInput => { + if terminal_status != "completed" + || result.user_input_questions.is_empty() + || result.user_input_questions.len() > 3 + { + return Err("静态委派 needs-user-input 与终态或问题数量冲突".to_string()); + } + let expected_sha = serde_json::to_vec(&result.user_input_questions) + .map(|bytes| format!("{:x}", Sha256::digest(bytes))) + .map_err(|error| format!("序列化静态委派用户问题失败:{error}"))?; + if result.user_input_questions_sha256.as_deref() != Some(expected_sha.as_str()) { + return Err("静态委派 needs-user-input 问题指纹无效".to_string()); + } + } + StaticDelegateContractStatus::NeedsRepair | StaticDelegateContractStatus::Unknown(_) => {} } - if result.contract_status == StaticDelegateContractStatus::NeedsUserInput { - if terminal_status != "completed" - || result.user_input_questions.is_empty() - || result.user_input_questions.len() > 3 - { - return Err("静态委派 needs-user-input 与终态或问题数量冲突".to_string()); - } - let expected_sha = serde_json::to_vec(&result.user_input_questions) - .map(|bytes| format!("{:x}", Sha256::digest(bytes))) - .map_err(|error| format!("序列化静态委派用户问题失败:{error}"))?; - if result.user_input_questions_sha256.as_deref() != Some(expected_sha.as_str()) { - return Err("静态委派 needs-user-input 问题指纹无效".to_string()); - } - } else if !result.user_input_questions.is_empty() - || result.user_input_questions_sha256.is_some() + if !matches!( + result.contract_status, + StaticDelegateContractStatus::NeedsUserInput + ) && (!result.user_input_questions.is_empty() + || result.user_input_questions_sha256.is_some()) { return Err("非 needs-user-input 静态委派不能携带用户问题".to_string()); } @@ -2932,6 +3003,145 @@ mod tests { ); } + #[test] + fn static_delegate_user_revision_barrier_blocks_every_revision_until_continuation() { + let root = std::env::temp_dir().join(format!( + "genarrative-static-user-revision-barrier-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system time after unix epoch") + .as_nanos() + )); + init_local_game_project_at( + &root, + "m1c1-user-revision-barrier", + "M1C-1 用户修订 barrier 测试", + ) + .expect("project init"); + let parent_run_id = "m1c1-user-revision-barrier-parent-run"; + let first = claimed_static_delegate_for_lineage_test( + parent_run_id, + "m1c1-user-revision-barrier-first", + None, + StaticDelegateContractStatus::EvidenceReady, + ); + write_static_delegate_delivery_at(&root, &first).expect("write first delivery"); + mark_static_delegate_delivery_user_revision_requested_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + &first.delegation_id, + ) + .expect("mark first delivery for user revision"); + + let first_barrier = static_delegate_completion_barrier_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + ) + .expect("read first revision barrier"); + assert_eq!(first_barrier.user_revision_pending_count, 1); + assert!(!first_barrier.is_clear()); + assert!(first_barrier.has_waiting()); + assert!(first_barrier.detail().contains("userRevisionPending=1")); + + let mut continuation = claimed_static_delegate_for_lineage_test( + parent_run_id, + "m1c1-user-revision-barrier-continuation", + Some(&first.delegation_id), + StaticDelegateContractStatus::EvidenceReady, + ); + continuation.status = StaticDelegateDeliveryStatus::Dispatched; + continuation.terminal_status = None; + continuation.result_summary = None; + continuation.structured_result = None; + continuation.claimed_by_action_id = None; + write_static_delegate_delivery_at(&root, &continuation).expect("write continuation"); + let active_barrier = static_delegate_completion_barrier_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + ) + .expect("read active continuation barrier"); + assert_eq!(active_barrier.user_revision_pending_count, 0); + assert_eq!(active_barrier.waiting_count, 1); + + continuation.status = StaticDelegateDeliveryStatus::ClaimedByParent; + continuation.terminal_status = Some("completed".to_string()); + continuation.result_summary = Some("second revision candidate".to_string()); + continuation.structured_result = Some(StaticDelegateStructuredResult { + contract_status: StaticDelegateContractStatus::EvidenceReady, + ..StaticDelegateStructuredResult::default() + }); + continuation.claimed_by_action_id = + Some("m1c1-user-revision-barrier-continuation-claim".to_string()); + write_static_delegate_delivery_at(&root, &continuation) + .expect("complete continuation delivery"); + assert!( + static_delegate_completion_barrier_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + ) + .expect("read completed continuation barrier") + .is_clear(), + "the previous revision is satisfied once its continuation is claimed" + ); + + mark_static_delegate_delivery_user_revision_requested_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + &continuation.delegation_id, + ) + .expect("mark a repair-node delivery for the second revision"); + let second_barrier = static_delegate_completion_barrier_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + ) + .expect("read second revision barrier"); + assert_eq!( + second_barrier.user_revision_pending_count, 1, + "a revision whose parent delivery is itself a repair node must still block" + ); + assert!(!second_barrier.is_clear()); + + fs::remove_dir_all(root).ok(); + } + + #[test] + fn static_delegate_user_revision_reuses_evidence_ready_objective_constraints() { + let base = StaticDelegateStructuredResult { + contract_status: StaticDelegateContractStatus::UserRevisionRequested, + ..StaticDelegateStructuredResult::default() + }; + validate_static_delegate_structured_result(&base, "completed", &[]) + .expect("completed user revision with complete evidence is valid"); + + let failed = validate_static_delegate_structured_result(&base, "failed", &[]) + .expect_err("failed terminal status must reject a user revision result"); + assert!(failed.contains("客观证据冲突")); + + let mut missing = base.clone(); + missing.missing_expected_artifacts = vec!["game/fast_gdd.md".to_string()]; + let missing_error = validate_static_delegate_structured_result( + &missing, + "completed", + &["game/fast_gdd.md".to_string()], + ) + .expect_err("missing expected artifact must reject a user revision result"); + assert!(missing_error.contains("客观证据冲突")); + + let mut unverified = base; + unverified.verification_required = true; + let verification_error = + validate_static_delegate_structured_result(&unverified, "completed", &[]) + .expect_err("required verification without a revision must be rejected"); + assert!(verification_error.contains("客观证据冲突")); + } + #[test] fn static_delegate_lineage_boundary_and_status_deserialization_fail_closed() { let mut deliveries = Vec::new(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index bb23a8e46..d5e1e4856 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -2208,6 +2208,7 @@ fn main() { confirm_game_creator_agent_runtime_task, reject_game_creator_agent_runtime_task, answer_game_creator_agent_runtime_user_input, + decide_game_creator_plan_gdd, read_game_creator_agent_runtime, read_game_creator_agent_runtimes, resume_game_creator_agent_runtime_tasks, diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/agent_db.rs b/apps/ai-game-creator-shell/src-tauri/src/project/agent_db.rs index f554edb84..5cb177a76 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/agent_db.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/agent_db.rs @@ -6,6 +6,8 @@ use super::filesystem::PROJECT_FILE_FLAG_OPEN_REPARSE_POINT; const AGENT_DB_MAX_RECORD_BYTES: usize = 1024 * 1024; const AGENT_DB_ACTION_RECEIPT_RECORD_TYPE: &str = "agent.runtime.action_receipt"; +const AGENT_DB_PLAN_GDD_DECISION_RECORD_TYPE: &str = "agent.runtime.plan.gdd_decided"; +const AGENT_DB_PLAN_GDD_DECISION_AUDIT_SCHEMA_V1: &str = "agent-runtime-plan-gdd-decided.v1"; const AGENT_DB_PROVIDER_REQUEST_LIFECYCLE_RECORD_TYPE: &str = "agent.runtime.provider_request.lifecycle"; const AGENT_DB_FINALIZATION_LIFECYCLE_RECORD_TYPE: &str = "agent.runtime.finalization.lifecycle"; @@ -32,12 +34,22 @@ const AGENT_DB_LIFECYCLE_TERMINAL_RESERVE_RECORDS: u64 = 128; const AGENT_DB_LIFECYCLE_TERMINAL_RESERVE_BYTES: u64 = (AGENT_DB_FINALIZATION_CRITICAL_MAX_RECORD_BYTES as u64 + 1) * AGENT_DB_LIFECYCLE_TERMINAL_RESERVE_RECORDS; +// Planning decisions are a separate durability lane. They must retain a +// complete 128-version lineage even when ordinary/lifecycle records consume +// the rest of the Agent DB budget, so they cannot share either existing tail. +const AGENT_DB_PLAN_GDD_DECISION_MAX_RECORD_BYTES: usize = 16 * 1024; +const AGENT_DB_PLAN_GDD_DECISION_RESERVE_RECORDS: u64 = 128; +const AGENT_DB_PLAN_GDD_DECISION_RESERVE_BYTES: u64 = + (AGENT_DB_PLAN_GDD_DECISION_MAX_RECORD_BYTES as u64 + 1) + * AGENT_DB_PLAN_GDD_DECISION_RESERVE_RECORDS; const AGENT_DB_MAX_ORDINARY_APPEND_BYTES: u64 = AGENT_DB_MAX_ACTION_RECEIPT_SCAN_BYTES - AGENT_DB_TERMINAL_RESERVE_BYTES - - AGENT_DB_LIFECYCLE_TERMINAL_RESERVE_BYTES; + - AGENT_DB_LIFECYCLE_TERMINAL_RESERVE_BYTES + - AGENT_DB_PLAN_GDD_DECISION_RESERVE_BYTES; const AGENT_DB_MAX_ORDINARY_APPEND_RECORDS: usize = AGENT_DB_MAX_SCAN_RECORDS - AGENT_DB_TERMINAL_RESERVE_RECORDS as usize - - AGENT_DB_LIFECYCLE_TERMINAL_RESERVE_RECORDS as usize; + - AGENT_DB_LIFECYCLE_TERMINAL_RESERVE_RECORDS as usize + - AGENT_DB_PLAN_GDD_DECISION_RESERVE_RECORDS as usize; const AGENT_DB_FINALIZATION_CRITICAL_RECORDS_PER_SEQUENCE: usize = 7; const AGENT_DB_MAX_BOUNDED_READ_BYTES: u64 = 32 * 1024 * 1024; const AGENT_DB_MAX_BOUNDED_RECORDS: usize = 16_384; @@ -48,10 +60,14 @@ pub(super) enum AgentDbRecordAppendClass { ActionTerminal, LifecycleTerminal, FinalizationCritical, + PlanGddDecision, } pub(super) fn agent_db_record_append_class(record: &serde_json::Value) -> AgentDbRecordAppendClass { let record_type = record.get("recordType").and_then(serde_json::Value::as_str); + if record_type == Some(AGENT_DB_PLAN_GDD_DECISION_RECORD_TYPE) { + return AgentDbRecordAppendClass::PlanGddDecision; + } if record_type == Some(AGENT_DB_ACTION_RECEIPT_RECORD_TYPE) || agent_db_record_uses_terminal_reserve(record) { @@ -956,6 +972,9 @@ pub(crate) fn append_agent_db_record(root: &Path, record: serde_json::Value) -> Some(AGENT_DB_ACTION_RECEIPT_RECORD_TYPE) => { return Err("Agent 持久动作回执必须使用幂等终态 receipt 追加入口".to_string()) } + Some(AGENT_DB_PLAN_GDD_DECISION_RECORD_TYPE) => { + return Err("Agent DB planning decision 必须使用专用幂等追加入口".to_string()) + } Some( AGENT_DB_PROVIDER_REQUEST_LIFECYCLE_RECORD_TYPE | AGENT_DB_FINALIZATION_LIFECYCLE_RECORD_TYPE, @@ -1087,6 +1106,14 @@ fn validate_agent_db_append_class_record_size( AGENT_DB_LIFECYCLE_TERMINAL_MAX_RECORD_BYTES )); } + if record_type.as_deref() == Some(AGENT_DB_PLAN_GDD_DECISION_RECORD_TYPE) + && line.len() > AGENT_DB_PLAN_GDD_DECISION_MAX_RECORD_BYTES + { + return Err(format!( + "Agent DB planning decision 单条记录超过 {} 字节上限", + AGENT_DB_PLAN_GDD_DECISION_MAX_RECORD_BYTES + )); + } if append_class == AgentDbRecordAppendClass::FinalizationCritical && line.len() > AGENT_DB_FINALIZATION_CRITICAL_MAX_RECORD_BYTES { @@ -2264,6 +2291,233 @@ pub(crate) fn append_agent_db_plan_submit_gdd_committed_if_missing_for_action( ) } +/// Append the dedicated planning-decision audit lane. This intentionally +/// does not reuse action-id idempotency: a response id is scoped by +/// `(gddId, version)` and may be reused on another version, while two windows +/// deciding one version with different intent must fail closed. +pub(crate) fn append_agent_db_plan_gdd_decision_if_missing( + root: &Path, + record: serde_json::Value, +) -> Result { + const RECORD_TYPE: &str = AGENT_DB_PLAN_GDD_DECISION_RECORD_TYPE; + const FIELDS: &[&str] = &[ + "recordType", + "auditSchemaVersion", + "projectId", + "agentId", + "gddId", + "version", + "gddFingerprint", + "pendingActionId", + "actionFingerprint", + "approvalRequestId", + "responseId", + "source", + "runProfile", + "runProfileBindingFingerprint", + "sessionId", + "runId", + "action", + "decisionFingerprint", + "commentHash", + "commentLength", + "receiptFingerprint", + "decidedAtUtc", + ]; + if !agent_db_record_has_exact_payload_fields(&record, FIELDS) + || record.get("recordType").and_then(serde_json::Value::as_str) != Some(RECORD_TYPE) + || record + .get("auditSchemaVersion") + .and_then(serde_json::Value::as_str) + != Some(AGENT_DB_PLAN_GDD_DECISION_AUDIT_SCHEMA_V1) + || record.get("agentId").and_then(serde_json::Value::as_str) != Some("project-supervisor") + || record.get("source").and_then(serde_json::Value::as_str) + != Some("project-supervisor-plan") + || record.get("runProfile").and_then(serde_json::Value::as_str) != Some("standard") + || !matches!( + record.get("action").and_then(serde_json::Value::as_str), + Some("approve" | "revise" | "reject") + ) + { + return Err("Agent DB planning decision 字段集合或固定身份无效".to_string()); + } + let project_id = record + .get("projectId") + .and_then(serde_json::Value::as_str) + .unwrap_or_default(); + let gdd_id = record + .get("gddId") + .and_then(serde_json::Value::as_str) + .unwrap_or_default(); + let version = record + .get("version") + .and_then(serde_json::Value::as_u64) + .unwrap_or(0); + let gdd_fingerprint = record + .get("gddFingerprint") + .and_then(serde_json::Value::as_str) + .unwrap_or_default(); + let pending_action_id = record + .get("pendingActionId") + .and_then(serde_json::Value::as_str) + .unwrap_or_default(); + let action_fingerprint = record + .get("actionFingerprint") + .and_then(serde_json::Value::as_str) + .unwrap_or_default(); + let approval_request_id = record + .get("approvalRequestId") + .and_then(serde_json::Value::as_str) + .unwrap_or_default(); + let response_id = record + .get("responseId") + .and_then(serde_json::Value::as_str) + .unwrap_or_default(); + let binding_fingerprint = record + .get("runProfileBindingFingerprint") + .and_then(serde_json::Value::as_str) + .unwrap_or_default(); + let session_id = record + .get("sessionId") + .and_then(serde_json::Value::as_str) + .unwrap_or_default(); + let run_id = record + .get("runId") + .and_then(serde_json::Value::as_str) + .unwrap_or_default(); + let decision_fingerprint = record + .get("decisionFingerprint") + .and_then(serde_json::Value::as_str) + .unwrap_or_default(); + let comment_hash = record + .get("commentHash") + .and_then(serde_json::Value::as_str) + .unwrap_or_default(); + let receipt_fingerprint = record + .get("receiptFingerprint") + .and_then(serde_json::Value::as_str) + .unwrap_or_default(); + let comment_length = record + .get("commentLength") + .and_then(serde_json::Value::as_u64) + .unwrap_or(u64::MAX); + let valid_uuid_suffix = |value: &str, prefix: &str| { + value.strip_prefix(prefix).is_some_and(|suffix| { + uuid::Uuid::parse_str(suffix) + .ok() + .is_some_and(|parsed| parsed.hyphenated().to_string() == suffix) + }) + }; + let valid_typed = |value: &str| { + value + .strip_prefix("sha256-serde-json-v2:") + .is_some_and(|suffix| is_valid_agent_db_sha256(suffix)) + }; + if project_id.is_empty() + || !is_safe_agent_db_lifecycle_identity(project_id) + || !valid_uuid_suffix(gdd_id, "gdd-") + || !(1..=128).contains(&version) + || !valid_typed(gdd_fingerprint) + || !pending_action_id + .strip_prefix("action-") + .is_some_and(|suffix| { + suffix.len() == 24 + && suffix + .bytes() + .all(|byte| (b'a'..=b'f').contains(&byte) || byte.is_ascii_digit()) + }) + || !is_valid_agent_db_sha256(action_fingerprint) + || !valid_uuid_suffix(approval_request_id, "gdd-approval-") + || !valid_uuid_suffix(response_id, "gdd-response-") + || !is_valid_agent_db_sha256(binding_fingerprint) + || !is_safe_agent_db_lifecycle_identity(session_id) + || !is_safe_agent_db_lifecycle_identity(run_id) + || !valid_typed(decision_fingerprint) + || !valid_typed(comment_hash) + || comment_length > 1_000 + || !valid_typed(receipt_fingerprint) + { + return Err("Agent DB planning decision durable identity/fingerprint 无效".to_string()); + } + let decided_at = record + .get("decidedAtUtc") + .and_then(serde_json::Value::as_str) + .unwrap_or_default(); + crate::agent::validate_timestamp(decided_at, "agent.db.decidedAtUtc") + .map_err(|error| error.to_string())?; + + let path = root.join(".agent/agent.db"); + let directory = open_agent_db_directory(root, true)? + .ok_or_else(|| "创建项目 .agent 目录失败".to_string())?; + let append_lock = project_append_lock_for(&path)?; + let _append_guard = append_lock.lock_process("Agent 本地索引")?; + verify_agent_db_directory_current(&directory)?; + let mut storage = open_agent_db_storage(directory, true, true)? + .ok_or_else(|| "创建 Agent 本地索引失败".to_string())?; + verify_agent_db_storage_current(&storage)?; + repair_truncated_jsonl_tail_unlocked(&mut storage.file, &storage.path, "Agent 本地索引")?; + verify_agent_db_storage_current(&storage)?; + + let length = storage + .file + .metadata() + .map_err(|error| format!("读取 Agent 本地索引元数据失败:{error}"))? + .len(); + if length > AGENT_DB_MAX_ACTION_RECEIPT_SCAN_BYTES { + return Err("Agent 本地索引超过扫描上限,无法追加 planning decision".to_string()); + } + storage + .file + .seek(SeekFrom::Start(0)) + .map_err(|error| format!("定位 Agent 本地索引失败:{error}"))?; + let mut reader = BufReader::new(&mut storage.file); + let mut count = 0usize; + while let Some(line) = read_agent_db_jsonl_line_bounded(&mut reader, &storage.path)? { + if !line.complete { + break; + } + if line.content.iter().all(|byte| byte.is_ascii_whitespace()) { + continue; + } + count = count.saturating_add(1); + if count > AGENT_DB_MAX_SCAN_RECORDS { + return Err("Agent 本地索引超过记录扫描上限".to_string()); + } + let stored = serde_json::from_slice::(&line.content) + .map_err(|error| format!("解析 Agent 本地索引失败:{error}"))?; + if stored.get("recordType").and_then(serde_json::Value::as_str) == Some(RECORD_TYPE) + && stored.get("gddId").and_then(serde_json::Value::as_str) == Some(gdd_id) + && stored.get("version").and_then(serde_json::Value::as_u64) == Some(version) + && stored.get("responseId").and_then(serde_json::Value::as_str) == Some(response_id) + { + if !agent_db_record_has_exact_payload_fields(&stored, FIELDS) { + return Err("Agent DB planning decision 已有记录字段集合损坏".to_string()); + } + let mut comparable = stored.clone(); + if let Some(object) = comparable.as_object_mut() { + object.remove("schemaVersion"); + object.remove("updatedAt"); + } + if comparable == record { + return Ok(false); + } + return Err( + "PLAN_DECISION_IDENTITY_CONFLICT: planning decision 幂等键 payload 不一致" + .to_string(), + ); + } + } + drop(reader); + let line = serialize_agent_db_record(record)?; + validate_agent_db_append_class_record_size(AgentDbRecordAppendClass::PlanGddDecision, &line)?; + append_agent_db_classified_line_unlocked( + &mut storage, + &line, + AgentDbRecordAppendClass::PlanGddDecision, + )?; + Ok(true) +} + pub(crate) fn append_agent_db_record_if_missing_for_action_with_before_lock( root: &Path, record_type: &str, @@ -3937,6 +4191,8 @@ struct AgentDbReservedTailCapacity { action_tail_bytes: u64, lifecycle_unlinked_tail_records: usize, lifecycle_unlinked_tail_bytes: u64, + plan_decision_tail_records: usize, + plan_decision_tail_bytes: u64, finalizations: BTreeMap, } @@ -3959,6 +4215,13 @@ impl AgentDbReservedTailCapacity { AgentDbRecordAppendClass::LifecycleTerminal => { self.observe_unlinked_lifecycle(in_record_tail, tail_bytes); } + AgentDbRecordAppendClass::PlanGddDecision => { + self.plan_decision_tail_records = self + .plan_decision_tail_records + .saturating_add(usize::from(in_record_tail)); + self.plan_decision_tail_bytes = + self.plan_decision_tail_bytes.saturating_add(tail_bytes); + } AgentDbRecordAppendClass::FinalizationCritical => { if !self.observe_finalization_record(record, in_record_tail, tail_bytes) { self.observe_unlinked_lifecycle(in_record_tail, tail_bytes); @@ -4285,6 +4548,24 @@ fn ensure_agent_db_classified_capacity_unlocked( )); } } + AgentDbRecordAppendClass::PlanGddDecision => { + if capacity.plan_decision_tail_records + > AGENT_DB_PLAN_GDD_DECISION_RESERVE_RECORDS as usize + { + return Err(format!( + "Agent 本地索引已达到 {} 条 planning decision 尾部配额,无法继续追加:{}", + AGENT_DB_PLAN_GDD_DECISION_RESERVE_RECORDS, + path.display() + )); + } + if capacity.plan_decision_tail_bytes > AGENT_DB_PLAN_GDD_DECISION_RESERVE_BYTES { + return Err(format!( + "Agent 本地索引将超过 {} 字节 planning decision 尾部配额:{}", + AGENT_DB_PLAN_GDD_DECISION_RESERVE_BYTES, + path.display() + )); + } + } AgentDbRecordAppendClass::Ordinary => unreachable!(), } Ok(()) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 8822fdd69..97a866e02 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -1,5 +1,13 @@ # 决策记录 +## 2026-08-15 M1C-1 隔离工作树收口:审批核心与专用完成门已落地,生产前置门保持后置 + +- **本轮落地**:在 `planning_storage.rs` 增加 `plan-gdd-approval.v1` receipt、`plan-gdd-approval-pending.v1` projection、comment/decision/receipt/pending 的 typed fingerprint 与 strict canonical 校验;审批 observation 固定校验 tool/status、版本摘要、detail 前缀和规范化 comment。`planning_approval.rs` 增加 receipt create-only、三动作幂等(`committed / replayed / already-decided`)、版本/指纹竞态防护、receipt 后 index/Markdown/audit/terminal observation/session 投影与恢复,以及只读的 plan 根专用 completion blocker;`commands.rs` 暴露 `decide_game_creator_plan_gdd`。 +- **恢复边界**:generic `plan.submit_gdd` 的 v5 standalone pending 与 v4 batch 仍是独立恢复锚点。receipt 投影只在 exact planning-submit batch(v4、单 action、cursor 已到 1、completed、observation 与 receipt 逐字相等)时清理残留;pending 已缺失但 terminal observation 存在时仍校验/清理该 batch,形状不 exact 则保持 `recoveryPending`,不猜测删除。无 receipt 的 GDD 不由本包自行重建审批 pending。 +- **明确后置**:技术方案第 13.0 节要求的 acceptance-gate 取证成功后才创建生产 `gdd-approval` pending;当前创建 helper 仅由定向测试调用,真实 submit/recovery caller 留给 `M1C-2a` 的验收前置门接线。审批 UI、验收图接线和构建准入仍未完成,不能把当前隔离 WIP 宣称为完整产品交付。 +- **验证**:专用 target `target-m1c1-current` 下 `cargo test --all-targets planning_submit --no-fail-fast`(37 passed,含 completion blocker 正向/阻塞/作用域/identity 回归)、`cargo test --all-targets planning_storage --no-fail-fast`(11 passed)、`cargo check --all-targets` 通过;`npm run check:encoding`(7848 files)和 `git diff --check` 通过。编译仍有仓库既有 warnings,不作为本包缺陷。 +- **关联**:`docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md` 第 13、14、23.6、23.8 节;本条只记录隔离工作树状态,合回原分支前仍需按既定合并流程复核。 + ## 2026-08-15 CI 五条失败归并为三个根因:修位置,不修症状 栈溢出修复后的全量跑出 5 条失败(`2051 passed / 5 failed`,**零栈溢出**,栈修复站住)。逐条定位后归并为 3 个根因,**全部先于本轮工作**:与栈修复、`M1C-0b`、以及刚合入的 master 都无关,master(`9f5c84ee7`)自身全绿,`deae1e08c`(栈修复前、`M1C-0b` 前)已全挂。三者形状相同——**新增的检查被放到了链路更靠前的位置,改变的是作用域而非严格程度**,详见 pitfalls 同日条。 diff --git a/docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md b/docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md index 2816e2046..01b46c2bd 100644 --- a/docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md +++ b/docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md @@ -1,9 +1,9 @@ # 立项策划 Agent(Fast GDD)技术方案 - 日期:2026-08-10 -- 状态:2026-08-12 **M0 代码工作包全部完成**(`M0A-2`、`M0B-1`、`M0B-2` 已合入 M0 集成分支并通过各自门禁,见第 23.4 节);同日 **D6 作废、拓扑改变**,`M0A-1` 交付的文档基线随之失效,需以工作包 `M0A-3` 修订,**修订完成前 M0 不计完整完成**(见第 1.1 节)。2026-08-13 **D9 二次作废、D10 作废,由 D11 取代**:立项策划节点改为 Project Supervisor 通过 `agent.delegate` 发起的静态委派子 Agent,问询复用 PR #165 中转链路(见第 1.1 节「D11 新拓扑」);D11 依赖 WP1(静态委派澄清轮次与返工深度拆分)为强制前置,**该前置已于 2026-08-13 落地并合入**(`WP1` 生产代码 + `WP2` 回归,完成状态与门禁见第 23.5 节),澄清轮次上限现为 3(game-chat source 仍为 1)。随后 `M1A-1`、`M1A-2`、`M1A-3`、`M1A-4` 已分别落地:`M1A-2` 仅收口两层工具面、`project-planning` role brief 注入和 fail-closed 拒绝边界,`M1A-4` 收窄 plan 根 run 的子 Agent 创建面。**2026-08-14 `M1B-1` 已通过门禁并合入本分支**:已落地 `.agent/planning` storage module、strict schema/typed 指纹/canonical parser、GDD 版本链、session 原子恢复、Runtime 写入身份及只挡写门禁;golden vector 与 11 个定向 storage 测试通过,writer/index/recovery 门禁已完成。**2026-08-15 `M1B-2` 工作包已通过本包门禁并以 `27c3eb847` 合入本分支**:已落地 `plan.submit_gdd`、exact planning Provider binding/structured injection、专用提交点与崩溃恢复;本包不包含 `gdd-approval` planning pending、审批等待、receipt、审批命令或 UI。**2026-08-14 `M1C-0` 已合回本分支**:仅新增用户修订状态及 lineage 分类,不包含审批写入方。**2026-08-15 `M1C-0b` 已通过定向门禁并完成**:只改静态委派 durable status 的前向兼容读路径,未知字符串显式保留为 `Unknown(raw)` 并最大化阻塞;不含审批写入方。当前仍未实现完整审批闭环、UI、构建准入,见第 23.6、23.8 节。后续执行计划见第 23.6 节。 +- 状态:2026-08-12 **M0 代码工作包全部完成**(`M0A-2`、`M0B-1`、`M0B-2` 已合入 M0 集成分支并通过各自门禁,见第 23.4 节);同日 **D6 作废、拓扑改变**,`M0A-1` 交付的文档基线随之失效,需以工作包 `M0A-3` 修订,**修订完成前 M0 不计完整完成**(见第 1.1 节)。2026-08-13 **D9 二次作废、D10 作废,由 D11 取代**:立项策划节点改为 Project Supervisor 通过 `agent.delegate` 发起的静态委派子 Agent,问询复用 PR #165 中转链路(见第 1.1 节「D11 新拓扑」);D11 依赖 WP1(静态委派澄清轮次与返工深度拆分)为强制前置,**该前置已于 2026-08-13 落地并合入**(`WP1` 生产代码 + `WP2` 回归,完成状态与门禁见第 23.5 节),澄清轮次上限现为 3(game-chat source 仍为 1)。随后 `M1A-1`、`M1A-2`、`M1A-3`、`M1A-4` 已分别落地:`M1A-2` 仅收口两层工具面、`project-planning` role brief 注入和 fail-closed 拒绝边界,`M1A-4` 收窄 plan 根 run 的子 Agent 创建面。**2026-08-14 `M1B-1` 已通过门禁并合入本分支**:已落地 `.agent/planning` storage module、strict schema/typed 指纹/canonical parser、GDD 版本链、session 原子恢复、Runtime 写入身份及只挡写门禁;golden vector 与 11 个定向 storage 测试通过,writer/index/recovery 门禁已完成。**2026-08-15 `M1B-2` 工作包已通过本包门禁并以 `27c3eb847` 合入本分支**:已落地 `plan.submit_gdd`、exact planning Provider binding/structured injection、专用提交点与崩溃恢复;本包不包含 `gdd-approval` planning pending、审批等待、receipt、审批命令或 UI。**2026-08-14 `M1C-0` 已合回本分支**:仅新增用户修订状态及 lineage 分类,不包含审批写入方。**2026-08-15 `M1C-0b` 已通过定向门禁并完成**:只改静态委派 durable status 的前向兼容读路径,未知字符串显式保留为 `Unknown(raw)` 并最大化阻塞;不含审批写入方。**当前隔离 worktree 已完成 M1C-1 的审批核心、receipt 投影/恢复和 plan 根专用 completion blocker;生产 acceptance-gate pending caller、验收图接线、审批 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` 提供 storage 基础与写入隔离;`M1B-2` 工作包已提供提交点与恢复,`M1C-0b` 已补齐静态委派未知 durable status 的前向兼容读路径(包括既有 Provider/终态消费点的 fail-closed guard),但不构成完整可交付:审批 UI、receipt、审批等待、构建绑定和正式入口仍不可用 +- 当前实现边界:本文件是后续详细设计与实现的仓库内阶段基线;M0 工作包冻结 Fast GDD 合同并修复现有 owner 验证、game-chat retry 与前端投影边界,`M1A-1`~`M1A-4` 已提供 plan source、两层工具面、角色 brief 与子 Agent 创建面收窄的 Runtime 基础,`M1C-0` 已提供用户修订 lineage 分类,已合入的 `M1B-1` 提供 storage 基础与写入隔离;`M1B-2` 工作包已提供提交点与恢复,`M1C-0b` 已补齐静态委派未知 durable status 的前向兼容读路径,当前隔离 worktree 另已补齐 M1C-1 receipt/审批核心、投影恢复和 plan 根完成门;生产 acceptance-gate pending caller、验收图接线、审批 UI、构建绑定和正式入口仍不可用 ## 1. 背景与目标 @@ -1754,7 +1754,7 @@ M0 文档 PR 本身最低验证:Markdown 结构与三张 Mermaid 图可解析 | 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 状态 | +| completion blocker | `apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_approval.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs` | **M1C-1 隔离 worktree 已补齐**:仅对 exact `project-supervisor-plan` standard 顶层根 Run 生效,读取 GDD lineage、approval pending/receipt、generic submit anchors、terminal observation、decision audit、planning session 与 recovery,且只读不创建 pending;现役 collaboration blocker 对策划子 Agent 仍不适用 | | plan 根 run 子 Agent 创建面 | `apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delegation.rs`(`observe_agent_runtime_agent_delegate` / `observe_agent_runtime_agent_spawn_isolated`)、`agent/runtime_protocol/run_configuration.rs`(`validate_project_supervisor_plan_root_binding_at`)、`agent/prompt.rs`(`game_creator_project_supervisor_tool_plan_prompt`) | **2026-08-14 `M1A-4` 已落地**:plan 根 run 只能委派 `project-planning`,`agent.spawn_isolated` 一律拒,两条通道共用 typed `kind=plan-root-child-target-unsupported`;plan source 下不拼 `supervisorIntro` 与 `$visualContract`。**已知残留(有意保留,见 decision-log 2026-08-14 `M1A-4` 条)**:`$base` 的 `$isolatedAgentTemplates` 段仍会向 plan 根 run 列出全部专业角色名——那是 `agent.spawn_isolated` 的模板目录,因执行层硬拒而成为死文本;因此**不得**写「plan 根 run 上下文不出现其它 Agent 名」这类验收句 | | plan retry | `apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/lifecycle_control.rs`(`resolve_game_creator_agent_runtime_retry_configuration_at`)、`runtime_driver.rs`(`supervisor_plan_root_identity_holds_at`) | **2026-08-13 `M1A-3` 已落地 source 保源**:`task.source == project-supervisor-plan` 时先走强判据,通过则保留该 source,失败 `kind=plan-root-retry-identity-unsupported`、不降级。gui/cli 仍走 `agent-background-task`。plan-session revision / `gddId` / 按 `gdd-approval` kind 禁 retry 仍属后续包(现役已拒 `waiting-*`) | | planning hydrate command | `apps/ai-game-creator-shell/src-tauri/src/commands.rs:856-875`、`apps/ai-game-creator-shell/src-tauri/src/main.rs:2178-2180`、`apps/ai-game-creator-shell/src/App.tsx:1550,1650,2957,3364,3702` | 新增单一 hydrate command/注册与前端生命周期调用;不把 runtime polling 当 GDD authority | @@ -1921,7 +1921,7 @@ M0 完成不表示完整策划闭环已经上线。`M1A-1`~`M1A-4`、`M1B-1` | 三 | `project-planning` 的 agentCatalog 登记 | **已完成**(机制冻结见第 3.1 节;**代码亦已落地**,2026-08-13:manifest、prompt bundle、runtime adapter、`prompt.rs` 角色合成分支及四处 needs_change 全部合入) | | 四 | `M0A-3` 批二:拓扑与工具面部分 | **已完成**(2026-08-13),拆解见下 | | 四之余 | schema 与 golden vector 收口 | **已完成**(2026-08-13),拆解见下 | -| 五 | M1 本体:策划闭环功能实现 | **`M1A-1`、`M1A-2`、`M1A-3`、`M1A-4`、`M1B-1`、`M1B-2`、`M1C-0`、`M1C-0b` 已落地并合入**;其中 `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-0b` 工作包已通过定向门禁并合入**:未知 durable status 解析为 `Unknown(raw)`,进入 completion barrier/waiting blocker,返工/Provider/终态消费点 fail closed,读-改-写保留 raw,损坏 sidecar 仍整目录锁死。`M1C-1` 及之后仍未实现。合入门见第 23.8 节 | +| 五 | M1 本体:策划闭环功能实现 | **`M1A-1`、`M1A-2`、`M1A-3`、`M1A-4`、`M1B-1`、`M1B-2`、`M1C-0`、`M1C-0b` 已落地并合入**;当前隔离 worktree 已实现 M1C-1 审批核心、receipt 投影/恢复、terminal observation 完整性校验和 plan 根专用 completion blocker。**生产 acceptance-gate pending caller、验收图接线、审批 UI、M1C-2b 澄清中转与构建准入仍未完成**,因此 M1C-1 尚未合回、M1 整体不可交付。合入门见第 23.8 节 | 批二在 2026-08-13 拆成两半,因为其中一半在 M1 代码存在之前**做不完**: @@ -2009,7 +2009,7 @@ M0 完成不表示完整策划闭环已经上线。`M1A-1`~`M1A-4`、`M1B-1` | `M1B-2` | `plan.submit_gdd` 原生工具、exact planning Provider 请求绑定与 GDD 提交点 | `M1B-1` | **工作包已通过本包门禁并以 `27c3eb847` 合入本分支**。实现合同:四类 request kind 全部写 v3 lifecycle、required binding 与同一 dedicated structured-injection user message;只有 `tool-plan` 可生成 sole-submit v4 batch;提交点后只修复 index/Markdown/session successor、终止策划子 run,并保留 generic v5 standalone pending + v4 batch anchors,不创建 `gdd-approval` planning pending/审批等待。定向 Rust、`cargo check --offline`、格式、编码与 diff 门禁均已通过;完整审批链路与产品可交付仍留给后续工作包。**2026-08-15 订正:本包的定向门禁漏掉了两处跨包回归,全量 CI 才暴露**——① `validate_next_entry` 把 `same_tool_plan_repair_chain` 提到 `loop_iteration` 分支之外,使含本轮量(steer cursor / goal revision / planning binding)的判据作用于跨 loop 续跑,两条 `tests::goal` 与一条交接用例失败;② 新增的 `missing_plan_submit_anchor_candidate_at` 在 resume 最前面强读 runtime state,短路了下游对不可读 state 的 fail-closed 兜底。均已修,详见 `decision-log.md` 同日条裁决一/二。**教训已记入门禁**:新增或移动校验必须同时补一条「正向必须被接受」的回归,只钉拒绝挡不住作用域被放大 | | `M1C-0` | 新增 `StaticDelegateContractStatus::UserRevisionRequested` 与分类分支 | `M1A-1` | **已落地**:无审批写入方、是惰性路径;用户修订跳不增 `repair_depth` 也不重置 `clarification_round`,连续修订可通过;做游戏链路返工仍在 `depth=1` 被拒;原有三种状态与 `UserRevisionRequested` 的已知行为保持不变,未知 durable status 的前向兼容由已完成的 `M1C-0b` 显式承接,不在本包静默降级或改变 | | `M1C-0b` | 静态委派 durable enum 的前向兼容粒度:未知 `contract_status` 解析为显式 `Unknown` 并最大化阻塞 | `M1C-0` | **已完成**:纯读路径、无写入方、审批状态、pending、receipt 或 UI(不含 `M1C-1`)。四种已知 durable 值保持原 serde;未知字符串解析为 `Unknown(raw)`,非字符串仍拒绝,`Serialize` 及读-改-写均原样保留 raw。`Unknown` 计入 completion barrier 与 waiting blocker,返工入口无条件拒绝(含 `depth=0`),lineage 按“其它”最保守分类(`depth + 1`、`round = 0`);planning Provider、自治 liveness、终态扫描等既有读路径同步 fail closed。截断、非法 JSON、非 UTF-8、超过 128 KiB 的 sidecar 仍按整目录 fail closed,不做单条跳过。**不新增或改变 `M1B-*` 功能依赖(仅复核其既有读路径);不包含 `M1C-1` 的审批写入、receipt、UI 或构建准入** | -| `M1C-1` | `gdd-approval` pending、审批命令、receipt;receipt 写入上述 status | `M1B-2`、`M1C-0`(前向兼容粒度另见 `M1C-0b`) | 三动作全通;版本+指纹竞态防护;两窗口并发;**连续多次修订均可通过且 `repair_depth` 不变**。**`M1C-0` 合入复核留下的三条已于 2026-08-15 裁决**(全文见第 23.7 节「落地约束」裁决一/二/三与 `decision-log.md` 同日条):① 新增独立 barrier 计数 `user_revision_pending_count`,不并进现有两个,且**不带** `repair_of.is_none()`——否则第 2 次及以后的修订不阻塞;另有四处逐字段读 barrier 的调用点需逐处裁决;② `validate_static_delegate_structured_result` 复用 `EvidenceReady` 的三条客观证据约束,并把该处 if/else-if 链改成穷尽 `match`,但**不得更严**——该函数每次读取都跑,过严会把写入方 bug 变成 delivery 永久读不出;③ 前向兼容粒度移交 `M1C-0b`,本包二选一:`M1C-0b` 先落,或直接上线并在 release note 明写「用过策划审批后回滚旧版本会让该项目静态委派面不可用」 | +| `M1C-1` | `gdd-approval` pending、审批命令、receipt;receipt 写入上述 status 与 plan 根完成门 | `M1B-2`、`M1C-0`(前向兼容粒度另见 `M1C-0b`) | **隔离 worktree 已实现核心**:三动作幂等、版本/指纹竞态防护、receipt 后 index/Markdown/audit/terminal observation/session 投影与恢复、generic v5/v4 anchor 精确消费、terminal summary 完整性校验,以及仅作用于 exact plan 根的只读 completion blocker;已补无 pending、awaiting、receipt 后 observation 缺失、完整收口、非 plan root scope 与 pending identity 漂移回归。**生产 acceptance-gate pending caller 与验收前置取证门后置到 `M1C-2a`,审批 UI / 澄清中转 / 构建准入仍未完成;本包尚未合回原分支**。连续修订 barrier 与 `UserRevisionRequested` 规则按第 23.7 节执行 | | `M1C-2a` | Goal Contract 接线:turn 1 冻结、固定验收图、审批前置门取证 | `M1C-1`、`M1A-3` | turn 1 只能是一个 `agent.goal_contract`;`acceptanceNodes` 不接受自定义;**第 13.0 节审批前置门:取证未通过时不出现审批卡、而是产生返工委派**(取证顺序是协议时序问题,归本包而非 `M1C-1`) | | `M1C-2b` | 澄清中转接线、轮次派生、预算注入 | `M1C-2a` | 3 轮上限;continuation 重放幂等不增加轮次;答案绑定冲突被拒 | | `M1D-1` | 前端 hydrate 与 GDD 审批卡 | `M1C-2b` | 前端只经 `hydrate_game_creator_plan_gdd_state` 读权威状态,不在页面侧合成批准事实 |