diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_execution.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_execution.rs index 3b2465fb6..582e2f20c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_execution.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_execution.rs @@ -424,7 +424,7 @@ pub(crate) async fn execute_game_creator_agent_runtime_tool_action_with_pending_ "agent.message" => { observe_agent_runtime_agent_message(root, agent_id, run_id, &action.input) } - "agent.delegate" => observe_agent_runtime_project_snapshot_with_lock( + "agent.delegate" => observe_agent_runtime_project_snapshot_with_lock_guard( root, agent_id, run_id, @@ -432,13 +432,14 @@ pub(crate) async fn execute_game_creator_agent_runtime_tool_action_with_pending_ &action_fingerprint, pending_action, true, - || { - observe_agent_runtime_agent_delegate( + |project_write_lock| { + observe_agent_runtime_agent_delegate_at_locked( root, agent_id, run_id, action_id, &action.input, + project_write_lock, ) }, ), @@ -519,6 +520,31 @@ pub(in crate::agent) fn observe_agent_runtime_project_snapshot_with_lock( ) -> AgentRuntimeToolObservation where F: FnOnce() -> AgentRuntimeToolObservation, +{ + observe_agent_runtime_project_snapshot_with_lock_guard( + root, + agent_id, + run_id, + action, + action_fingerprint, + pending_action, + validate_revision_gate, + |_| observe(), + ) +} + +pub(in crate::agent) fn observe_agent_runtime_project_snapshot_with_lock_guard( + root: &Path, + agent_id: &str, + run_id: &str, + action: &AgentRuntimeToolAction, + action_fingerprint: &str, + pending_action: Option<&AgentRuntimePendingToolAction>, + validate_revision_gate: bool, + observe: F, +) -> AgentRuntimeToolObservation +where + F: FnOnce(&ProjectWriteLock) -> AgentRuntimeToolObservation, { let tool = action.tool.trim(); let _lock = match acquire_game_creator_agent_runtime_project_write_lock_with_wait( @@ -546,7 +572,7 @@ where ) { return observation; } - observe() + observe(&_lock) } pub(in crate::agent) fn validate_agent_runtime_project_snapshot_action_after_lock( diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_projection.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_projection.rs index 9410885e8..6bdfb94b0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_projection.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_projection.rs @@ -4,6 +4,32 @@ pub(in crate::agent) fn persist_game_creator_agent_user_input_wait_at( root: &Path, runtime: &mut AgentRuntimeState, pending: &mut AgentRuntimePendingToolAction, +) -> Result<(), String> { + persist_game_creator_agent_user_input_wait_with_project_lock_at(root, runtime, pending, None) +} + +pub(in crate::agent) fn persist_game_creator_agent_user_input_wait_at_locked( + root: &Path, + runtime: &mut AgentRuntimeState, + pending: &mut AgentRuntimePendingToolAction, + project_lock: &ProjectWriteLock, +) -> Result<(), String> { + if !project_lock.guards_project_root(root)? { + return Err("持久化 planning 用户输入等待缺少当前项目写锁".to_string()); + } + persist_game_creator_agent_user_input_wait_with_project_lock_at( + root, + runtime, + pending, + Some(project_lock), + ) +} + +fn persist_game_creator_agent_user_input_wait_with_project_lock_at( + root: &Path, + runtime: &mut AgentRuntimeState, + pending: &mut AgentRuntimePendingToolAction, + project_lock: Option<&ProjectWriteLock>, ) -> Result<(), String> { if runtime.run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD { return Err("自主构建 Run 禁止进入 waiting-for-user-input".to_string()); @@ -13,7 +39,13 @@ pub(in crate::agent) fn persist_game_creator_agent_user_input_wait_at( pending.observation = None; pending.updated_at = unix_timestamp(); write_game_creator_agent_runtime_pending_tool_action(root, pending)?; - let request = match prepare_game_creator_agent_user_input_request_at(root, pending)? { + let recovered_user_input = match project_lock { + Some(project_lock) => { + prepare_game_creator_agent_user_input_request_at_locked(root, pending, project_lock) + } + None => prepare_game_creator_agent_user_input_request_at(root, pending), + }?; + let request = match recovered_user_input { AgentRuntimeUserInputRecovery::Waiting(request) => request, AgentRuntimeUserInputRecovery::Answered { .. } => { return Err("新建用户输入等待时 sidecar 已进入 answered,需由恢复路径继续".to_string()); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/context_compaction.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/context_compaction.rs index 6721e21ef..d3f8416cf 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/context_compaction.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/context_compaction.rs @@ -25,6 +25,11 @@ pub(in crate::agent) async fn compact_game_creator_agent_runtime_context_at( root, "runtime.context_compaction.build", )?; + if agent_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID { + // Advance the immutable Provider-usage projection before any + // request/source bytes are rebuilt from the plan session. + fold_plan_provider_usage_before_new_request_at_locked(root)?; + } let source = build_game_creator_agent_runtime_context_compaction_source( root, agent_id, 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 5058c504b..ac9852d4b 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 @@ -909,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_input(detail: &str) -> bool { + detail + .split_whitespace() + .find_map(|part| part.strip_prefix("userInputRequired=")) + .and_then(|value| value.parse::().ok()) + .is_some_and(|count| count > 0) +} + pub(in crate::agent) fn static_delegate_barrier_requires_user_revision(detail: &str) -> bool { detail .split_whitespace() @@ -1757,6 +1765,11 @@ mod static_delegate_barrier_detail_gate_tests { barrier.repair_required_count > 0, "repairRequired 往返失真:{barrier:?}\ndetail={detail}" ); + assert_eq!( + static_delegate_barrier_requires_user_input(&detail), + barrier.user_input_required_count > 0, + "userInputRequired 往返失真:{barrier:?}\ndetail={detail}" + ); assert_eq!( static_delegate_barrier_requires_user_revision(&detail), barrier.user_revision_pending_count > 0, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_final_reply.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_final_reply.rs index c4681bef4..738cdd58c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_final_reply.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_final_reply.rs @@ -117,6 +117,9 @@ pub(in crate::agent) async fn request_game_creator_agent_background_final_reply_ "runtime.provider_request.capture.final_reply", )?; if agent_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID { + // Keep every rebuilt request field on the same budget successor + // that will be exposed by the structured injection and binding. + fold_plan_provider_usage_before_new_request_at_locked(root)?; // Freeze the concrete final-reply request and its Provider-facing // planning injection under the same project lock as the durable // session binding. This mirrors tool-plan and prevents an older diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_tool_plan.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_tool_plan.rs index 5872d6d93..98df90b05 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_tool_plan.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_tool_plan.rs @@ -307,6 +307,11 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at root, "runtime.provider_request.capture.tool_plan", )?; + if agent_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID { + // Fold first so the request rebuild, structured injection and + // frozen session binding all observe one budget successor. + fold_plan_provider_usage_before_new_request_at_locked(root)?; + } // Exact planning requests must freeze the session and the concrete // request object under one project lock. Rebuild once while holding // that lock so a session successor cannot be used to re-label an diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs index 21ee8d6a2..54f28ac82 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs @@ -506,6 +506,8 @@ pub(crate) use entrypoints::{ #[cfg(test)] pub(crate) use finalization::resume_game_creator_agent_finalization_for_test_at; pub(crate) use finalization::AgentRuntimePendingActionResume; +#[cfg(test)] +pub(crate) use interaction::acquire_game_creator_agent_runtime_user_input_answer_locks_for_test; pub(crate) use interaction::{ agent_runtime_tool_requires_repository_context_fingerprint_gate, answer_game_creator_agent_runtime_user_input_at, confirm_game_creator_agent_runtime_task_at, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/interaction.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/interaction.rs index fd2d8c440..92ea19715 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/interaction.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/interaction.rs @@ -441,6 +441,79 @@ pub(in crate::agent) fn resolve_game_creator_agent_runtime_user_input_action( Ok((agent_id, task, runtime, pending)) } +type AgentRuntimeUserInputActionResolution = ( + String, + AgentRuntimeTaskRecord, + AgentRuntimeState, + AgentRuntimePendingToolAction, +); + +fn resolve_game_creator_agent_runtime_user_input_action_with_ordered_locks( + root: &Path, + agent_id: &str, + run_id: &str, + action_id: &str, +) -> Result< + ( + Option, + AgentRuntimeTaskLock, + AgentRuntimeUserInputActionResolution, + ), + String, +> { + // Ordinary user-input keeps the existing execution-only path. Fast GDD + // answer validation may repair/read `session.json`, so route that exact + // pending through project -> execution and re-read its identity under the + // selected lock set before writing answer-prepared or binding delivery. + let (_, _, _, optimistic_pending) = + resolve_game_creator_agent_runtime_user_input_action(root, agent_id, run_id, action_id)?; + let optimistic_planning = + plan_clarification_pending_requires_project_lock_at(root, &optimistic_pending)?; + if optimistic_planning { + let project_lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "planning.clarification.answer.command", + )?; + let runtime_lock = acquire_game_creator_agent_runtime_task_lock_with_wait(root, agent_id)?; + let resolved = resolve_game_creator_agent_runtime_user_input_action( + root, agent_id, run_id, action_id, + )?; + return Ok((Some(project_lock), runtime_lock, resolved)); + } + + let runtime_lock = acquire_game_creator_agent_runtime_task_lock_with_wait(root, agent_id)?; + let resolved = + resolve_game_creator_agent_runtime_user_input_action(root, agent_id, run_id, action_id)?; + if plan_clarification_pending_requires_project_lock_at(root, &resolved.3)? { + drop(runtime_lock); + let project_lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "planning.clarification.answer.command", + )?; + let runtime_lock = acquire_game_creator_agent_runtime_task_lock_with_wait(root, agent_id)?; + let resolved = resolve_game_creator_agent_runtime_user_input_action( + root, agent_id, run_id, action_id, + )?; + Ok((Some(project_lock), runtime_lock, resolved)) + } else { + Ok((None, runtime_lock, resolved)) + } +} + +#[cfg(test)] +pub(crate) fn acquire_game_creator_agent_runtime_user_input_answer_locks_for_test( + root: &Path, + agent_id: &str, + run_id: &str, + action_id: &str, +) -> Result<(Option, AgentRuntimeTaskLock), String> { + let (project_lock, runtime_lock, _) = + resolve_game_creator_agent_runtime_user_input_action_with_ordered_locks( + root, agent_id, run_id, action_id, + )?; + Ok((project_lock, runtime_lock)) +} + pub(crate) fn answer_game_creator_agent_runtime_user_input_at( root: &Path, agent_id: &str, @@ -452,17 +525,29 @@ pub(crate) fn answer_game_creator_agent_runtime_user_input_at( ) -> Result { let agent_id = normalize_game_creator_runtime_agent_id(agent_id)?; validate_project_root(root)?; - let runtime_lock = acquire_game_creator_agent_runtime_task_lock_with_wait(root, &agent_id)?; - let (agent_id, task, mut runtime, mut pending) = - resolve_game_creator_agent_runtime_user_input_action(root, &agent_id, run_id, action_id)?; + let (project_lock, runtime_lock, resolved) = + resolve_game_creator_agent_runtime_user_input_action_with_ordered_locks( + root, &agent_id, run_id, action_id, + )?; + let (agent_id, task, mut runtime, mut pending) = resolved; let already_observed = pending.status == AGENT_RUNTIME_PENDING_ACTION_STATUS_OBSERVED_APPROVED; - let (request, observation) = answer_game_creator_agent_user_input_request_for_pending_at( - root, - &pending, - request_id, - response_id, - answers, - )?; + let (request, observation) = match project_lock.as_ref() { + Some(project_lock) => answer_game_creator_agent_user_input_request_for_pending_at_locked( + root, + &pending, + request_id, + response_id, + answers, + project_lock, + )?, + None => answer_game_creator_agent_user_input_request_for_pending_at( + root, + &pending, + request_id, + response_id, + answers, + )?, + }; if already_observed { if pending.observation.as_ref() != Some(&observation) { return Err("用户输入回答与已持久化 observation 冲突".to_string()); @@ -522,6 +607,7 @@ pub(crate) fn answer_game_creator_agent_runtime_user_input_at( })?; } let result = read_game_creator_agent_runtime_at(root, &agent_id)?; + drop(project_lock); if external_agent_runner_owns_background_execution() { let answered_run_id = pending.run_id.clone(); let answered_action_id = pending.action_id.clone(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/lifecycle_control.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/lifecycle_control.rs index 28a53a560..b1ba1c36b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/lifecycle_control.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/lifecycle_control.rs @@ -925,23 +925,45 @@ pub(crate) fn retry_game_creator_agent_runtime_task_at( &task, retry_link.is_some(), )?; - let (mut result, actual_retry_run_id) = with_agent_conversation_session_lane_at( - root, - &agent_id, - "Agent Runtime 重试入队", - || { - start_game_creator_agent_background_task_with_link_in_session_lane_at( - root, - &agent_id, - Some(&task.session_id), - &task.task, - &retry_run_id, - &retry_source, - Some(&retry_run_profile), - retry_link.as_ref(), - ) - }, - )?; + let planning_retry = agent_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID; + let (mut result, actual_retry_run_id) = if planning_retry { + // Planning enqueue owns both locks. Keep the global order identical + // to ordinary/delegated starts: project first, Session lane second. + // The locked entry also projects the retry child before it can start. + let project_lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "planning.child-session.retry", + )?; + start_game_creator_agent_background_task_with_link_locked_at( + root, + &agent_id, + Some(&task.session_id), + &task.task, + &retry_run_id, + &retry_source, + Some(&retry_run_profile), + retry_link.as_ref(), + &project_lock, + )? + } else { + with_agent_conversation_session_lane_at( + root, + &agent_id, + "Agent Runtime 重试入队", + || { + start_game_creator_agent_background_task_with_link_in_session_lane_at( + root, + &agent_id, + Some(&task.session_id), + &task.task, + &retry_run_id, + &retry_source, + Some(&retry_run_profile), + retry_link.as_ref(), + ) + }, + )? + }; let retry_task_sha256 = format!("{:x}", Sha256::digest(task.task.as_bytes())); let retry_task_chars = task.task.chars().count(); let retry_goal_bound = task.goal_id.is_some(); @@ -969,12 +991,16 @@ pub(crate) fn retry_game_creator_agent_runtime_task_at( }), )?; result.accepted_run_id = Some(actual_retry_run_id.clone()); - notify_external_agent_runner_after_background_task_enqueue( - root, - &agent_id, - &task.session_id, - &actual_retry_run_id, - )?; + if !planning_retry { + // The planning locked entry performs this notification after releasing + // its Session lane; the legacy in-lane entry deliberately does not. + notify_external_agent_runner_after_background_task_enqueue( + root, + &agent_id, + &task.session_id, + &actual_retry_run_id, + )?; + } Ok(result) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs index acd19576d..d185e2aa4 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 @@ -1063,10 +1063,10 @@ async fn run_game_creator_agent_background_task_pass_without_deadline( if let Some(blocker) = static_delegate_completion_blocker_at(&root, &agent_id, &runtime.run_id) { - let waits_for_delivery = blocker - .detail - .as_deref() - .is_some_and(static_delegate_barrier_has_waiting_deliveries); + let waits_for_delivery = blocker.detail.as_deref().is_some_and(|detail| { + static_delegate_barrier_has_waiting_deliveries(detail) + || static_delegate_barrier_requires_user_input(detail) + }); if waits_for_delivery { if let Err(error) = persist_waiting_static_delegate_parent_context_at( &root, @@ -2079,13 +2079,10 @@ async fn run_game_creator_agent_background_task_pass_without_deadline( .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() - .find_map(|part| part.strip_prefix("userInputRequired=")) - .and_then(|value| value.parse::().ok()) - .is_some_and(|count| count > 0) - }); + let user_input_required = blocker + .detail + .as_deref() + .is_some_and(static_delegate_barrier_requires_user_input); runtime.status = "running".to_string(); if user_revision_pending { runtime.phase = "planning".to_string(); @@ -2096,36 +2093,21 @@ async fn run_game_creator_agent_background_task_pass_without_deadline( 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, - &runtime.run_id, - ) { - Ok(deliveries) => deliveries, - Err(error) => { - return fail_game_creator_agent_background_context_at( - &root, - &agent_id, - &session_id, - runtime, - &format!("读取 needs-user-input 回执失败:{error}"), - ); - } - }; - if let Err(error) = ensure_static_delegate_user_input_wait_at( - &root, - &mut runtime, - &deliveries, - ) { - return fail_game_creator_agent_background_context_at( - &root, - &agent_id, - &session_id, - runtime, - &format!("Supervisor 用户澄清请求无法安全进入等待态:{error}"), - ); - } - return AgentBackgroundTaskOutcome::WaitingForUserInput; + // The background drain owns the Supervisor execution lane. Persist a + // receipt wait first, then let task_queue schedule parent-wake after this + // pass returns and the lane is released. Parent-wake acquires + // project -> execution and creates the unique clarification pending under + // both locks; doing that here would invert the M1C-2b planning projection + // order. + runtime.phase = "waiting-for-delegate-receipts".to_string(); + runtime.current_action = + "等待创建 Project Supervisor 用户澄清请求".to_string(); + runtime.waiting_on = + "释放当前 execution lane 后投影 planning session 与澄清 pending" + .to_string(); + runtime.next_step = + "由 lane 外 parent-wake 按 project → execution 锁序创建唯一澄清请求" + .to_string(); } else if repair_required { runtime.phase = "planning".to_string(); runtime.current_action = "等待 Project Supervisor 发起唯一返工".to_string(); @@ -2194,7 +2176,8 @@ async fn run_game_creator_agent_background_task_pass_without_deadline( AgentBackgroundTaskOutcome::WaitingForIsolatedJoin, )) } else if observation.tool == "runtime.delegate_receipts" - && static_delegate_barrier_has_waiting_deliveries(detail) + && (static_delegate_barrier_has_waiting_deliveries(detail) + || static_delegate_barrier_requires_user_input(detail)) { Some(( "agent.runtime.agent.delegate_receipts.waiting", diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_recovery.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_recovery.rs index 0d45d3432..a557c9295 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_recovery.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_recovery.rs @@ -159,12 +159,13 @@ pub(in crate::agent) fn replay_supervisor_delivery_pending_action_at( } } match pending.action.tool.as_str() { - "agent.delegate" => observe_agent_runtime_agent_delegate( + "agent.delegate" => observe_agent_runtime_agent_delegate_at_locked( root, &pending.agent_id, &pending.run_id, Some(&pending.action_id), &pending.action.input, + &_project_lock, ), "agent.run_status" => observe_agent_runtime_run_status( root, @@ -1068,6 +1069,74 @@ pub(crate) fn resume_game_creator_agent_pending_tool_action_at( if migrate_legacy_autonomous_confirmation_at(root, &runtime, &mut pending)? { can_repair_terminal_receipt = true; } + let (runtime_lock, planning_user_input_project_lock) = if pending.status + == AGENT_RUNTIME_PENDING_ACTION_STATUS_WAITING_FOR_USER_INPUT + && plan_clarification_pending_requires_project_lock_at(root, &pending)? + { + let expected_run_id = runtime.run_id.clone(); + let expected_session_id = runtime.session_id.clone(); + let expected_action_id = pending.action_id.clone(); + let expected_action_fingerprint = pending.action_fingerprint.clone(); + drop(runtime_lock); + let project_lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "planning.clarification.answer-recovery", + )?; + let runtime_lock = acquire_game_creator_agent_runtime_task_lock_with_wait(root, agent_id)?; + runtime = read_game_creator_agent_runtime_at(root, agent_id)?.state; + if runtime.run_id != expected_run_id + || runtime.session_id != expected_session_id + || runtime.status != "waiting-for-user-input" + || runtime.phase != "waiting-for-user-input" + || game_creator_agent_runtime_has_reconciliation_barrier(root, agent_id)? + { + return Ok(AgentRuntimePendingActionResume::NotFound(runtime_lock)); + } + let Some(current_task) = + read_latest_game_creator_agent_runtime_task_by_run_id(root, agent_id, &runtime.run_id)? + else { + return Ok(AgentRuntimePendingActionResume::NotFound(runtime_lock)); + }; + if current_task.session_id != expected_session_id + || current_task.status != "waiting-for-user-input" + || current_task.phase != "waiting-for-user-input" + || !game_creator_agent_runtime_pending_tool_action_exists( + root, + agent_id, + &runtime.run_id, + ) + { + return Ok(AgentRuntimePendingActionResume::NotFound(runtime_lock)); + } + pending = + read_game_creator_agent_runtime_pending_tool_action(root, agent_id, &runtime.run_id)?; + if pending.action_id != expected_action_id + || pending.status != AGENT_RUNTIME_PENDING_ACTION_STATUS_WAITING_FOR_USER_INPUT + { + // The old recovery candidate became obsolete while its execution + // lane was released. A concurrent answer may legitimately have + // advanced the exact pending to observed-approved, or another + // current action may now own the run. Do not overwrite that newer + // state with a reconciliation projection; let the caller inspect + // the re-read runtime on its next pass. + return Ok(AgentRuntimePendingActionResume::NotFound(runtime_lock)); + } + if pending.session_id != expected_session_id + || pending.action_fingerprint != expected_action_fingerprint + { + mark_game_creator_agent_runtime_needs_reconciliation_at( + root, + &mut runtime, + &pending, + "planning 用户回答恢复发现同一 pending 的 immutable identity 漂移", + )?; + return read_game_creator_agent_runtime_at(root, agent_id) + .map(AgentRuntimePendingActionResume::Handled); + } + (runtime_lock, Some(project_lock)) + } else { + (runtime_lock, None) + }; if pending.status == AGENT_RUNTIME_PENDING_ACTION_STATUS_WAITING_FOR_USER_INPUT { let planning_agent = runtime.agent_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID; if planning_agent @@ -1090,15 +1159,31 @@ pub(crate) fn resume_game_creator_agent_pending_tool_action_at( } if game_creator_agent_runtime_cancel_requested(root, &runtime) { cancel_game_creator_agent_user_input_request_for_pending_at(root, &pending)?; - mark_game_creator_agent_runtime_cancelled_at( - root, - &mut runtime, - "Agent 后台任务已按开发者请求取消", - Some("Runtime 恢复用户输入等待时发现尚未完成的取消请求。"), - )?; + match planning_user_input_project_lock.as_ref() { + Some(_) => mark_game_creator_agent_runtime_cancelled_at_locked( + root, + &mut runtime, + "Agent 后台任务已按开发者请求取消", + Some("Runtime 恢复用户输入等待时发现尚未完成的取消请求。"), + )?, + None => mark_game_creator_agent_runtime_cancelled_at( + root, + &mut runtime, + "Agent 后台任务已按开发者请求取消", + Some("Runtime 恢复用户输入等待时发现尚未完成的取消请求。"), + )?, + } return Ok(AgentRuntimePendingActionResume::NotFound(runtime_lock)); } - match prepare_game_creator_agent_user_input_request_at(root, &pending) { + let recovered_user_input = match planning_user_input_project_lock.as_ref() { + Some(project_lock) => prepare_game_creator_agent_user_input_request_at_locked( + root, + &pending, + project_lock, + ), + None => prepare_game_creator_agent_user_input_request_at(root, &pending), + }; + match recovered_user_input { Ok(AgentRuntimeUserInputRecovery::Waiting(request)) => { runtime.pending_tool_action = Some(pending.summary()); runtime.status = "waiting-for-user-input".to_string(); @@ -1140,12 +1225,20 @@ pub(crate) fn resume_game_creator_agent_pending_tool_action_at( can_repair_terminal_receipt = true; } Ok(AgentRuntimeUserInputRecovery::Cancelled) => { - mark_game_creator_agent_runtime_cancelled_at( - root, - &mut runtime, - "Agent 用户输入请求已取消", - Some("Runner 恢复时发现用户输入 sidecar 已取消。"), - )?; + match planning_user_input_project_lock.as_ref() { + Some(_) => mark_game_creator_agent_runtime_cancelled_at_locked( + root, + &mut runtime, + "Agent 用户输入请求已取消", + Some("Runner 恢复时发现用户输入 sidecar 已取消。"), + )?, + None => mark_game_creator_agent_runtime_cancelled_at( + root, + &mut runtime, + "Agent 用户输入请求已取消", + Some("Runner 恢复时发现用户输入 sidecar 已取消。"), + )?, + } return Ok(AgentRuntimePendingActionResume::NotFound(runtime_lock)); } Err(error) => { @@ -1160,6 +1253,7 @@ pub(crate) fn resume_game_creator_agent_pending_tool_action_at( } } } + drop(planning_user_input_project_lock); if pending.status == AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING && pending.action.tool == "canvas.asset_generate" { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/provider_recovery.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/provider_recovery.rs index 3b6af0f9d..9180657d5 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/provider_recovery.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/provider_recovery.rs @@ -324,11 +324,28 @@ pub(crate) fn schedule_waiting_static_delegate_parent_wake_after_lane_release( /// Convert a claimed `needs-user-input` delivery into the Supervisor's own /// durable user-input action. The child never owns this action: it is tied to /// the parent run and therefore passes the normal user-input owner gate. +#[cfg(test)] pub(crate) fn ensure_static_delegate_user_input_wait_at( root: &Path, runtime: &mut AgentRuntimeState, deliveries: &[StaticDelegateDeliveryRecord], ) -> Result { + let project_lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "planning.clarification.pending", + )?; + ensure_static_delegate_user_input_wait_at_locked(root, runtime, deliveries, &project_lock) +} + +pub(crate) fn ensure_static_delegate_user_input_wait_at_locked( + root: &Path, + runtime: &mut AgentRuntimeState, + deliveries: &[StaticDelegateDeliveryRecord], + project_lock: &ProjectWriteLock, +) -> Result { + if !project_lock.guards_project_root(root)? { + return Err("Supervisor 澄清 pending 投影缺少当前项目写锁".to_string()); + } let mut pending_deliveries = deliveries.iter().filter(|delivery| { delivery.structured_result.as_ref().is_some_and(|result| { result.contract_status == StaticDelegateContractStatus::NeedsUserInput @@ -337,6 +354,10 @@ pub(crate) fn ensure_static_delegate_user_input_wait_at( let Some(delivery) = pending_deliveries.next() else { return Ok(false); }; + // Fast GDD additionally projects the completed planning child into its + // derived session before the user can see or answer the card. Ordinary + // static-delegate questions remain byte-for-byte on the existing path. + project_plan_session_awaiting_user_input_at_locked(root, runtime, delivery, project_lock)?; // Each durable request belongs to exactly one original delivery. Other // deliveries remain behind the completion barrier and are asked next. let result = delivery @@ -403,7 +424,12 @@ pub(crate) fn ensure_static_delegate_user_input_wait_at( AGENT_RUNTIME_PENDING_ACTION_STATUS_WAITING_FOR_USER_INPUT, None, )?; - persist_game_creator_agent_user_input_wait_at(root, runtime, &mut pending)?; + persist_game_creator_agent_user_input_wait_at_locked( + root, + runtime, + &mut pending, + project_lock, + )?; Ok(true) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/recovery_scan.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/recovery_scan.rs index 13e7d63a6..80d5a519f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/recovery_scan.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/recovery_scan.rs @@ -51,6 +51,55 @@ pub(in crate::agent) fn mark_waiting_provider_retry_needs_reconciliation_at( read_game_creator_agent_runtime_at(root, agent_id) } +fn mark_plan_session_projection_needs_reconciliation_at( + root: &Path, + task: &AgentRuntimeTaskRecord, + error: &str, +) -> Result { + let mut runtime = match read_game_creator_agent_runtime_at(root, &task.agent_id) { + Ok(result) if result.state.run_id == task.run_id => result.state, + Ok(_) | Err(_) => agent_runtime_state_from_task_record(task), + }; + if runtime.phase == "needs-reconciliation" { + return read_game_creator_agent_runtime_at(root, &task.agent_id); + } + let error = redact_agent_runtime_error(root, error, 500); + runtime.status = "failed".to_string(); + runtime.phase = "needs-reconciliation".to_string(); + runtime.current_action = "Fast GDD session 恢复需要人工核对".to_string(); + runtime.waiting_on = "开发者核对 planning session、delivery 与 continuation 身份".to_string(); + runtime.next_step = "修复冲突的持久投影后显式恢复或取消当前 run".to_string(); + runtime.pending_tool_action = None; + runtime.error = Some(error.clone()); + runtime.updated_at = unix_timestamp(); + append_game_creator_agent_runtime_task(root, &runtime)?; + refresh_game_creator_agent_runtime_task_queue(root, &mut runtime)?; + write_game_creator_agent_runtime_state(root, &runtime)?; + append_game_creator_agent_runtime_event( + root, + &runtime, + "plan.session_recovery.needs_reconciliation", + "failed", + "needs-reconciliation", + "Fast GDD session 无法在 Provider 恢复前安全投影,Runtime 已停止自动请求。", + Some(&error), + )?; + let _ = append_agent_db_record( + root, + serde_json::json!({ + "recordType": "agent.runtime.plan.session_recovery.needs_reconciliation", + "agentId": runtime.agent_id, + "taskId": runtime.task_id, + "sessionId": runtime.session_id, + "runId": runtime.run_id, + "source": runtime.source, + "error": error, + }), + ); + emit_game_creator_agent_runtime_update(root, &task.agent_id); + read_game_creator_agent_runtime_at(root, &task.agent_id) +} + struct MissingPlanSubmitAnchorCandidate { runtime: AgentRuntimeState, action_id: String, @@ -989,6 +1038,77 @@ pub(in crate::agent) fn resume_game_creator_agent_background_tasks_unredacted_at else { continue; }; + // A planning continuation can be durable while `session.json` still + // points at the answered/waiting round. Recovery used to enter the + // pending-action and Provider-batch paths below before repairing that + // projection, so the continuation could issue its first request with + // stale decisions/appliedAnswers. + // + // Never acquire the project lock while retaining the Agent execution + // lock: normal enqueue owns project -> Session lane -> execution. Drop + // and reacquire in project -> execution order, re-read the task under + // the new lock set, project exactly once, then release the project lock + // before any later path can enter the Session lane. + let runtime_lock = if agent_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID + && read_recoverable_runnable_game_creator_agent_runtime_task(root, &agent_id)?.is_some() + { + drop(runtime_lock); + let project_lock = match acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "planning.child-session.recovery", + ) { + Ok(lock) => lock, + Err(error) if static_delegate_parent_wake_error_is_transient(&error) => continue, + Err(error) => { + return Err(format!("恢复 Fast GDD session 前取得项目锁失败:{error}")); + } + }; + let Some(runtime_lock) = + try_acquire_game_creator_agent_runtime_task_lock(root, &agent_id)? + else { + continue; + }; + let Some(task) = + read_recoverable_runnable_game_creator_agent_runtime_task(root, &agent_id)? + else { + continue; + }; + let projection = match plan_session_already_projects_planning_child_task_at_locked( + root, + &task, + &project_lock, + ) { + Ok(true) => Ok(true), + Ok(false) => ensure_plan_session_for_planning_child_task_at_locked( + root, + &task, + &project_lock, + ), + Err(error) => Err(error), + }; + match projection { + Ok(true) => {} + Ok(false) => { + resumed.push(mark_plan_session_projection_needs_reconciliation_at( + root, + &task, + "PLAN_NEEDS_RECONCILIATION: project-planning 恢复任务未命中 planning session 协调器", + )?); + continue; + } + Err(error) if static_delegate_parent_wake_error_is_transient(&error) => continue, + Err(error) => { + resumed.push(mark_plan_session_projection_needs_reconciliation_at( + root, &task, &error, + )?); + continue; + } + } + drop(project_lock); + runtime_lock + } else { + runtime_lock + }; if let Some(result) = reconcile_missing_plan_submit_anchors_at(root, &agent_id)? { resumed.push(result); drop(runtime_lock); @@ -2032,3 +2152,48 @@ mod plan_gdd_approval_wait_recovery_tests { )); } } + +#[cfg(test)] +mod plan_session_recovery_gate_tests { + use super::*; + + #[test] + fn planning_recovery_contains_session_identity_conflict_before_provider() { + let temporary = + crate::tests::canonical_test_tempdir("plan-session-recovery-gate-conflict-"); + let root = temporary.path(); + init_local_game_project_at(root, "plan-recovery-gate", "策划恢复门冲突收敛") + .expect("init project"); + start_game_creator_agent_runtime_task_at( + root, + GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + "不具备 Supervisor 委派身份的伪造策划任务", + "plan-session-recovery-conflict-run", + "agent-background-task", + "准备执行伪造策划任务", + vec!["不得发起 Provider 请求".to_string()], + ) + .expect("persist recoverable planning task"); + + let resumed = resume_game_creator_agent_background_tasks_unredacted_at(root) + .expect("identity conflict is contained to the planning task"); + let contained = resumed + .iter() + .find(|result| result.state.agent_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID) + .expect("planning task is returned as contained"); + assert_eq!(contained.state.status, "failed"); + assert_eq!(contained.state.phase, "needs-reconciliation"); + assert!(contained + .state + .error + .as_deref() + .is_some_and(|error| error.contains("PLAN_SOURCE_PROFILE_MISMATCH"))); + + let public_audit = fs::read_to_string(root.join(".agent/agent.db")).unwrap_or_default(); + assert!(public_audit.contains("agent.runtime.plan.session_recovery.needs_reconciliation")); + assert!( + !public_audit.contains(AGENT_RUNTIME_PROVIDER_REQUEST_LIFECYCLE_RECORD_TYPE), + "session identity 冲突必须在任何 Provider lifecycle 之前停止" + ); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs index 75d72e1b8..cffb133b0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs @@ -173,6 +173,79 @@ pub(in crate::agent) fn start_game_creator_agent_background_task_with_link_at( source: &str, run_profile: Option<&str>, task_link: Option<&AgentRuntimeTaskLink>, +) -> Result<(AgentRuntimeResult, String), String> { + // Planning session projection needs both the project lock and the target + // session lane. Always acquire them in project -> session order. The + // in-session entry below therefore rejects an unguarded planning child + // instead of trying to acquire the project lock while holding the lane. + if agent_id.trim() == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID { + let project_write_lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "planning.child-session.enqueue", + )?; + return start_game_creator_agent_background_task_with_link_locked_at( + root, + agent_id, + session_id, + task, + run_id, + source, + run_profile, + task_link, + &project_write_lock, + ); + } + start_game_creator_agent_background_task_with_link_with_project_lock_at( + root, + agent_id, + session_id, + task, + run_id, + source, + run_profile, + task_link, + None, + ) +} + +pub(in crate::agent) fn start_game_creator_agent_background_task_with_link_locked_at( + root: &Path, + agent_id: &str, + session_id: Option<&str>, + task: &str, + run_id: &str, + source: &str, + run_profile: Option<&str>, + task_link: Option<&AgentRuntimeTaskLink>, + project_write_lock: &ProjectWriteLock, +) -> Result<(AgentRuntimeResult, String), String> { + if !project_write_lock.guards_project_root(root)? { + return Err("Agent 后台任务入队缺少当前项目写锁".to_string()); + } + start_game_creator_agent_background_task_with_link_with_project_lock_at( + root, + agent_id, + session_id, + task, + run_id, + source, + run_profile, + task_link, + Some(project_write_lock), + ) +} + +#[allow(clippy::too_many_arguments)] +fn start_game_creator_agent_background_task_with_link_with_project_lock_at( + root: &Path, + agent_id: &str, + session_id: Option<&str>, + task: &str, + run_id: &str, + source: &str, + run_profile: Option<&str>, + task_link: Option<&AgentRuntimeTaskLink>, + project_write_lock: Option<&ProjectWriteLock>, ) -> Result<(AgentRuntimeResult, String), String> { let agent_id = normalize_game_creator_runtime_agent_id(agent_id)?; validate_project_root(root)?; @@ -181,7 +254,7 @@ pub(in crate::agent) fn start_game_creator_agent_background_task_with_link_at( &agent_id, "Agent Session Runtime 入队", || { - start_game_creator_agent_background_task_with_link_in_session_lane_at( + start_game_creator_agent_background_task_with_link_in_session_lane_with_project_lock_at( root, &agent_id, session_id, @@ -190,6 +263,7 @@ pub(in crate::agent) fn start_game_creator_agent_background_task_with_link_at( source, run_profile, task_link, + project_write_lock, ) }, )?; @@ -254,6 +328,36 @@ pub(in crate::agent) fn start_game_creator_agent_background_task_with_link_in_se run_profile: Option<&str>, task_link: Option<&AgentRuntimeTaskLink>, ) -> Result<(AgentRuntimeResult, String), String> { + start_game_creator_agent_background_task_with_link_in_session_lane_with_project_lock_at( + root, + agent_id, + session_id, + task, + run_id, + source, + run_profile, + task_link, + None, + ) +} + +#[allow(clippy::too_many_arguments)] +fn start_game_creator_agent_background_task_with_link_in_session_lane_with_project_lock_at( + root: &Path, + agent_id: &str, + session_id: Option<&str>, + task: &str, + run_id: &str, + source: &str, + run_profile: Option<&str>, + task_link: Option<&AgentRuntimeTaskLink>, + project_write_lock: Option<&ProjectWriteLock>, +) -> Result<(AgentRuntimeResult, String), String> { + if agent_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID && project_write_lock.is_none() { + return Err( + "project-planning 入队必须在取得项目写锁后再进入 Agent Session lane".to_string(), + ); + } let isolated_instance = agent_id .starts_with("child-") .then(|| resolve_isolated_agent_instance_at(root, &agent_id)) @@ -398,6 +502,41 @@ pub(in crate::agent) fn start_game_creator_agent_background_task_with_link_in_se return Err(format!("后台任务用户消息落盘失败,任务未执行:{error}")); } } + if game_creator_agent_runtime_terminal_status(&pending_task).is_none() { + let projection = project_write_lock.map_or(Ok(false), |project_write_lock| { + ensure_plan_session_for_planning_child_task_at_locked( + root, + &pending_task, + project_write_lock, + ) + }); + if let Err(error) = projection { + let error = redact_agent_runtime_project_paths(root, &error, 500); + let failed_task = AgentRuntimeTaskRecord { + status: "failed".to_string(), + phase: "planning-session-projection-failed".to_string(), + current_action: "Fast GDD session 未能安全绑定,后台任务未执行".to_string(), + terminal_detail: Some(error.clone()), + error: Some(error.clone()), + updated_at: unix_timestamp(), + ..pending_task.clone() + }; + append_game_creator_agent_runtime_task_record(root, &failed_task)?; + publish_game_creator_agent_delegate_result(root, &failed_task, Some(&error)); + let _ = append_agent_db_record( + root, + serde_json::json!({ + "recordType": "agent.runtime.background_task.queue_warning", + "agentId": pending_task.agent_id, + "sessionId": pending_task.session_id, + "runId": pending_task.run_id, + "warningKind": "planning-session-projection-failed", + "error": sanitize_agent_runtime_text(&error, 240), + }), + ); + return Err(format!("Fast GDD session 投影失败,任务未执行:{error}")); + } + } if requires_public_start_status { if let Err(error) = ensure_game_creator_agent_runtime_accepted_public_status_at(root, &pending_task) 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 9a724c449..5d7797c64 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol.rs @@ -9,6 +9,8 @@ mod finalization; mod json_sidecar; mod models; mod planning_approval; +mod planning_coordinator; +mod planning_provider_usage; mod planning_storage; mod planning_submit; mod provider_control; @@ -25,12 +27,15 @@ pub(in crate::agent) use finalization::*; pub(in crate::agent) use json_sidecar::*; pub(in crate::agent) use models::*; pub(crate) use planning_approval::*; +pub(crate) use planning_coordinator::*; +pub(crate) use planning_provider_usage::*; pub(crate) use planning_storage::*; pub(crate) use planning_submit::*; pub(in crate::agent) use provider_control::*; pub(in crate::agent) use provider_retry::*; pub(in crate::agent) use real_e2e_checkpoint::*; pub(in crate::agent) use response_stream::*; +pub(crate) use run_configuration::validate_project_supervisor_plan_root_binding_at as validate_project_supervisor_plan_root_binding_for_crate_at; pub(in crate::agent) use run_configuration::*; pub(in crate::agent) use steering::*; pub(in crate::agent) use verification::*; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_approval.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_approval.rs index d25a6db2b..82f852bea 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_approval.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_approval.rs @@ -988,7 +988,7 @@ fn project_receipt_locked( None => {} } - match project_generic_submit_observation_locked(root, receipt) { + let generic_submit_consumed = match project_generic_submit_observation_locked(root, receipt) { Ok(consumed) => { if consumed { if approval_pending_cleanup_eligible @@ -999,12 +999,14 @@ fn project_receipt_locked( } else { recovery_pending = true; } + consumed } Err(error) => { let _ = error; recovery_pending = true; + false } - } + }; if receipt.action != "approve" { if mark_static_delegate_delivery_user_revision_requested_at( root, @@ -1030,10 +1032,32 @@ fn project_receipt_locked( false } }; + let mut session_projection_ready = false; if receipt.version == latest.version || session_points_to_receipt { if let Err(error) = project_plan_session_locked(root, receipt_gdd, receipt) { recovery_pending = true; let _ = error; + } else { + session_projection_ready = true; + } + } + // Provider usage is an immutable fact, while the session value is only a + // projection. The final `plan.submit_gdd` request can finish immediately + // before the approval receipt is written; its v4 submit batch then keeps + // the fold deferred until this receipt consumes both generic anchors. + // Fold only after the receipt/session successor is durable and the batch + // cleanup returned an exact success. Deferred or malformed facts remain + // a recovery barrier instead of being force-written into the session. + if generic_submit_consumed && session_projection_ready { + match fold_plan_provider_usage_into_session_at_locked(root) { + Ok( + PlanProviderUsageFoldOutcome::NoSession + | PlanProviderUsageFoldOutcome::Unchanged + | PlanProviderUsageFoldOutcome::Advanced, + ) => {} + Ok(PlanProviderUsageFoldOutcome::Deferred) | Err(_) => { + recovery_pending = true; + } } } Ok(recovery_pending) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_coordinator.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_coordinator.rs new file mode 100644 index 000000000..7a4992bcc --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_coordinator.rs @@ -0,0 +1,789 @@ +use super::*; + +use uuid::Uuid; + +const PLAN_OPTION_ACCEPT_RECOMMENDATION: &str = "接受推荐"; +const PLAN_OPTION_TEMPORARY_RECOMMENDATION: &str = "暂按推荐"; +const PLAN_OPTION_PROTOTYPE_VALIDATION: &str = "需要原型验证"; +const PLAN_QUESTION_PREFIX: &str = "当前要决定:"; + +#[derive(Clone, Debug, Eq, PartialEq)] +struct PlanClarificationDecisionProjection { + decision: PlanDecisionSummary, + prototype_validation_item: Option, + applied_answer: PlanAppliedAnswer, +} + +fn plan_coordinator_error(kind: &str, detail: impl AsRef) -> String { + format!("{kind}: {}", detail.as_ref()) +} + +fn plan_session_successor_base(previous: &PlanSessionV1) -> Result { + let mut next = previous.clone(); + next.session_revision = previous.session_revision.checked_add(1).ok_or_else(|| { + plan_coordinator_error("PLAN_SESSION_CAS_CONFLICT", "sessionRevision 溢出") + })?; + next.previous_fingerprint = Some(previous.session_fingerprint.clone()); + next.updated_at_utc = current_plan_timestamp_utc(); + Ok(next) +} + +fn finalize_plan_session_successor( + previous: &PlanSessionV1, + mut next: PlanSessionV1, +) -> Result { + next.session_fingerprint = format!("sha256-serde-json-v2:{}", "0".repeat(64)); + next.session_fingerprint = + plan_session_fingerprint(&next).map_err(|error| error.to_string())?; + validate_plan_session_successor(previous, &next).map_err(|error| error.to_string())?; + Ok(next) +} + +fn exact_plan_child_identity_at( + root: &Path, + task: &AgentRuntimeTaskRecord, +) -> Result, String> { + if task.agent_id != GAME_CREATOR_PROJECT_PLANNING_AGENT_ID { + return Ok(None); + } + if task.source != "agent-delegate" + || task.run_profile != AGENT_RUNTIME_RUN_PROFILE_STANDARD + || task.parent_agent_id.as_deref() != Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID) + { + return Err(plan_coordinator_error( + "PLAN_SOURCE_PROFILE_MISMATCH", + "project-planning task 不是 Supervisor 的 agent-delegate/standard 子 Run", + )); + } + let parent_run_id = task + .parent_run_id + .as_deref() + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| { + plan_coordinator_error( + "PLAN_SOURCE_PROFILE_MISMATCH", + "planning child 缺少 parentRunId", + ) + })?; + let delegation_id = task + .delegation_id + .as_deref() + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| { + plan_coordinator_error( + "PLAN_SOURCE_PROFILE_MISMATCH", + "planning child 缺少 delegationId", + ) + })?; + validate_project_supervisor_plan_root_binding_at( + root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + )?; + let binding = validate_project_planning_child_binding_at(root, &task.agent_id, &task.run_id)?; + if binding.binding_fingerprint != task.run_profile_binding_fingerprint + || binding.root_agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID + || binding.root_run_id != parent_run_id + || binding.parent_agent_id.as_deref() != Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID) + || binding.parent_run_id.as_deref() != Some(parent_run_id) + { + return Err(plan_coordinator_error( + "PLAN_SOURCE_PROFILE_MISMATCH", + "planning child task 与 durable Run Profile binding 不一致", + )); + } + let delivery = read_static_delegate_delivery_at(root, delegation_id)?.ok_or_else(|| { + plan_coordinator_error("PLAN_NEEDS_RECONCILIATION", "planning delivery 缺失") + })?; + if delivery.parent_agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID + || delivery.parent_run_id != parent_run_id + || delivery.delegation_id != delegation_id + || delivery.target_agent_id != GAME_CREATOR_PROJECT_PLANNING_AGENT_ID + || delivery.target_session_id != task.session_id + || delivery.target_run_id != task.run_id + { + return Err(plan_coordinator_error( + "PLAN_NEEDS_RECONCILIATION", + "planning child task 与 static delivery 身份不一致", + )); + } + Ok(Some((binding, delivery))) +} + +fn plan_question_topic(question: &AgentRuntimeUserInputQuestion) -> Result { + let remainder = question + .question + .strip_prefix(PLAN_QUESTION_PREFIX) + .ok_or_else(|| { + plan_coordinator_error( + "PLAN_INVALID_CLARIFICATION", + "plan question 必须以“当前要决定:”开头", + ) + })?; + let topic = remainder + .split(['。', ';', ',', '?', '?']) + .next() + .unwrap_or_default(); + normalize_plan_text(topic, "plan question topic", 1, 80).map_err(|error| error.to_string()) +} + +pub(crate) fn validate_exact_plan_clarification_question( + questions: &[AgentRuntimeUserInputQuestion], + round: u32, +) -> Result<(), String> { + if !(1..=3).contains(&round) || questions.len() != 1 { + return Err(plan_coordinator_error( + "PLAN_INVALID_CLARIFICATION", + "Fast GDD 每轮必须且只能包含一题,轮次必须在 1..=3", + )); + } + let question = &questions[0]; + if question.id.len() > 32 + || !question.id.is_ascii() + || question.id.replace('_', "-") == "initial-request" + { + return Err(plan_coordinator_error( + "PLAN_INVALID_CLARIFICATION", + "plan questionId 必须是最多 32 个 ASCII 字符且不能映射为 initial-request", + )); + } + let expected_header = format!("第{round}轮·关键决定"); + if question.header != expected_header { + return Err(plan_coordinator_error( + "PLAN_INVALID_CLARIFICATION", + format!("plan question header 必须精确等于 {expected_header}"), + )); + } + let expected_labels = [ + PLAN_OPTION_ACCEPT_RECOMMENDATION, + PLAN_OPTION_TEMPORARY_RECOMMENDATION, + PLAN_OPTION_PROTOTYPE_VALIDATION, + ]; + if question.options.len() != expected_labels.len() + || question + .options + .iter() + .zip(expected_labels) + .any(|(option, expected)| option.label != expected) + { + return Err(plan_coordinator_error( + "PLAN_INVALID_CLARIFICATION", + "plan question 的三个选项标签或顺序不符合固定合同", + )); + } + plan_question_topic(question)?; + Ok(()) +} + +fn build_plan_clarification_decision_projection( + root_run_id: &str, + delegation_id: &str, + continuation_delegation_id: &str, + round: u32, + answer: &PlanStaticDelegateAnsweredInput, +) -> Result { + validate_exact_plan_clarification_question(std::slice::from_ref(&answer.question), round)?; + let normalized_answer = normalize_plan_text(&answer.answer, "plan answer", 1, 400) + .map_err(|error| error.to_string())?; + let question = &answer.question; + let topic = plan_question_topic(question)?; + let decision_id = question.id.replace('_', "-"); + let (state, answer_source, answer_summary) = match normalized_answer.as_str() { + PLAN_OPTION_ACCEPT_RECOMMENDATION => ( + "confirmed", + "user_option", + format!( + "{PLAN_OPTION_ACCEPT_RECOMMENDATION}:{}", + question.options[0].description + ), + ), + PLAN_OPTION_TEMPORARY_RECOMMENDATION => ( + "default_pending", + "default", + format!( + "{PLAN_OPTION_TEMPORARY_RECOMMENDATION}:{}", + question.options[1].description + ), + ), + PLAN_OPTION_PROTOTYPE_VALIDATION => ( + "prototype_pending", + "user_option", + format!( + "{PLAN_OPTION_PROTOTYPE_VALIDATION}:{}", + question.options[2].description + ), + ), + _ => ("confirmed", "user_freeform", normalized_answer), + }; + let decision = PlanDecisionSummary { + id: decision_id.clone(), + topic: topic.clone(), + state: state.to_string(), + answer_source: answer_source.to_string(), + round, + answer_summary, + }; + let prototype_validation_item = + (state == "prototype_pending").then(|| PlanPrototypeValidationItem { + id: decision_id.clone(), + question: format!("验证“{topic}”是否成立"), + micro_prototype: format!("用 30~90 分钟制作只覆盖“{topic}”的最小可交互原型"), + observation: format!("记录玩家在无额外提示时的行为与对“{topic}”的口头解释"), + pass_criterion: "至少 3 次独立试玩中有 2 次出现预期行为,且测试者能说明对应取舍" + .to_string(), + }); + let expected_continuation = derive_plan_continuation_delegation_id( + root_run_id, + delegation_id, + &answer.questions_sha256, + &answer.answers_sha256, + ) + .map_err(|error| error.to_string())?; + if expected_continuation != continuation_delegation_id { + return Err(plan_coordinator_error( + "PLAN_ANSWER_IDENTITY_CONFLICT", + "continuation deliveryId 与问答确定性派生值不一致", + )); + } + Ok(PlanClarificationDecisionProjection { + applied_answer: PlanAppliedAnswer { + delegation_id: delegation_id.to_string(), + continuation_delegation_id: continuation_delegation_id.to_string(), + request_id: answer.request_id.clone(), + question_id: question.id.clone(), + response_id: answer.response_id.clone(), + questions_sha256: answer.questions_sha256.clone(), + answers_sha256: answer.answers_sha256.clone(), + decision_id, + round, + }, + decision, + prototype_validation_item, + }) +} + +fn apply_plan_clarification_projection( + session: &mut PlanSessionV1, + projection: PlanClarificationDecisionProjection, +) -> Result<(), String> { + if let Some(existing) = session + .decisions_summary + .iter() + .find(|decision| decision.id == projection.decision.id) + { + if existing != &projection.decision { + return Err(plan_coordinator_error( + "PLAN_ANSWER_IDENTITY_CONFLICT", + "同 decisionId 已存在不同 decisionsSummary", + )); + } + } else { + session.decisions_summary.push(projection.decision.clone()); + } + match projection.prototype_validation_item { + Some(item) => { + if let Some(existing) = session + .prototype_validation_items + .iter() + .find(|existing| existing.id == item.id) + { + if existing != &item { + return Err(plan_coordinator_error( + "PLAN_ANSWER_IDENTITY_CONFLICT", + "同 decisionId 已存在不同 prototypeValidationItem", + )); + } + } else { + session.prototype_validation_items.push(item); + } + } + None => { + if session + .prototype_validation_items + .iter() + .any(|item| item.id == projection.applied_answer.decision_id) + { + return Err(plan_coordinator_error( + "PLAN_ANSWER_IDENTITY_CONFLICT", + "非 prototype_pending 回答携带了同 ID prototypeValidationItem", + )); + } + } + } + if let Some(existing) = session + .applied_answers + .iter() + .find(|item| item.round == projection.applied_answer.round) + { + if existing != &projection.applied_answer { + return Err(plan_coordinator_error( + "PLAN_ANSWER_IDENTITY_CONFLICT", + "同一 clarification round 已绑定不同 appliedAnswer", + )); + } + } else { + session.applied_answers.push(projection.applied_answer); + } + Ok(()) +} + +fn ensure_existing_plan_session_identity( + session: &PlanSessionV1, + task: &AgentRuntimeTaskRecord, + delivery: &StaticDelegateDeliveryRecord, +) -> Result<(), String> { + if session.agent_id != GAME_CREATOR_PROJECT_PLANNING_AGENT_ID + || session.source != "agent-delegate" + || session.run_profile != AGENT_RUNTIME_RUN_PROFILE_STANDARD + || session.root_agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID + || session.root_run_id != task.parent_run_id.clone().unwrap_or_default() + || session.session_id != task.session_id + || delivery.parent_run_id != session.root_run_id + { + return Err(plan_coordinator_error( + "PLAN_ACTIVE_RUN_EXISTS", + "已有 planning session 属于不同 project/root/session lineage", + )); + } + Ok(()) +} + +/// Recovery may encounter an already-started planning request with frozen +/// retry/handoff bytes. In that case folding is intentionally deferred and +/// the coordinator must only prove that this exact run was projected earlier. +/// A false result is not permission to start: callers must invoke the full +/// `ensure_*_locked` path, which folds prior usage before deriving a successor. +pub(crate) fn plan_session_already_projects_planning_child_task_at_locked( + root: &Path, + task: &AgentRuntimeTaskRecord, + project_lock: &ProjectWriteLock, +) -> Result { + if !project_lock.guards_project_root(root)? { + return Err(plan_coordinator_error( + "PLAN_NEEDS_RECONCILIATION", + "planning child session 恢复检查缺少当前项目写锁", + )); + } + let Some((binding, delivery)) = exact_plan_child_identity_at(root, task)? else { + return Ok(false); + }; + let Some(session) = + read_plan_session_with_recovery_locked(root).map_err(|error| error.to_string())? + else { + return Ok(false); + }; + ensure_existing_plan_session_identity(&session, task, &delivery)?; + Ok( + session.active_run_id.as_deref() == Some(task.run_id.as_str()) + && session.last_run_id == task.run_id + && session.latest_delegation_id == delivery.delegation_id + && session.run_profile_binding_fingerprint == binding.binding_fingerprint + && session.phase == "collecting", + ) +} + +/// Acquire the cross-process project write lock before projecting a planning +/// child. Callers that already hold the lock must use the `_locked` entry and +/// pass their guard instead of recursively acquiring `.agent/project.lock`. +#[cfg(test)] +pub(crate) fn ensure_plan_session_for_planning_child_task_at( + root: &Path, + task: &AgentRuntimeTaskRecord, +) -> Result { + if task.agent_id != GAME_CREATOR_PROJECT_PLANNING_AGENT_ID { + return Ok(false); + } + let project_lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "planning.child-session.project", + )?; + ensure_plan_session_for_planning_child_task_at_locked(root, task, &project_lock) +} + +/// Project the exact planning child task into the durable plan session before +/// that task is eligible to issue its first Provider request. The guard is an +/// explicit proof that the caller owns the cross-process project write lock. +pub(crate) fn ensure_plan_session_for_planning_child_task_at_locked( + root: &Path, + task: &AgentRuntimeTaskRecord, + project_lock: &ProjectWriteLock, +) -> Result { + if !project_lock.guards_project_root(root)? { + return Err(plan_coordinator_error( + "PLAN_NEEDS_RECONCILIATION", + "planning child session 投影缺少当前项目写锁", + )); + } + let Some((binding, delivery)) = exact_plan_child_identity_at(root, task)? else { + return Ok(false); + }; + let project_id = game_creator_agent_runtime_context_project_id(root)?; + // A continuation/recovery must observe all terminal Provider intervals + // before deriving its successor session. The fold owns no second lock; + // it advances the same session while this caller retains `project_lock`. + // On an initial child this is a no-op (`NoSession`), and root usage is + // folded immediately after revision 1 has established the lineage below. + fold_plan_provider_usage_before_new_request_at_locked(root)?; + let current = + read_plan_session_with_recovery_locked(root).map_err(|error| error.to_string())?; + let Some(previous) = current else { + if delivery.repair_of_delegation_id.is_some() { + return Err(plan_coordinator_error( + "PLAN_SESSION_RECOVERY_REQUIRED", + "continuation planning child 存在但 plan session 缺失", + )); + } + let root_task = read_latest_game_creator_agent_runtime_task_by_run_id( + root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &delivery.parent_run_id, + )? + .ok_or_else(|| { + plan_coordinator_error("PLAN_SESSION_RECOVERY_REQUIRED", "plan root task 缺失") + })?; + let initial_request = + normalize_plan_text(&root_task.task, "plan session initial request", 1, 400) + .map_err(|error| error.to_string())?; + let mut session = PlanSessionV1 { + schema_version: PLAN_SESSION_SCHEMA_VERSION.to_string(), + project_id, + gdd_id: format!("gdd-{}", Uuid::new_v4().hyphenated()), + session_revision: 1, + previous_fingerprint: None, + session_fingerprint: format!("sha256-serde-json-v2:{}", "0".repeat(64)), + agent_id: GAME_CREATOR_PROJECT_PLANNING_AGENT_ID.to_string(), + source: "agent-delegate".to_string(), + run_profile: AGENT_RUNTIME_RUN_PROFILE_STANDARD.to_string(), + run_profile_binding_fingerprint: binding.binding_fingerprint, + root_agent_id: GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(), + root_run_id: delivery.parent_run_id, + latest_delegation_id: delivery.delegation_id, + session_id: task.session_id.clone(), + active_run_id: Some(task.run_id.clone()), + last_run_id: task.run_id.clone(), + phase: "collecting".to_string(), + accumulated_agent_millis: 0, + applied_steer_cursor: 0, + decisions_summary: vec![PlanDecisionSummary { + id: "initial-request".to_string(), + topic: "初始需求".to_string(), + state: "confirmed".to_string(), + answer_source: "user_freeform".to_string(), + round: 0, + answer_summary: initial_request, + }], + prototype_validation_items: Vec::new(), + applied_answers: Vec::new(), + latest_submitted_ref: None, + last_decision_ref: None, + updated_at_utc: current_plan_timestamp_utc(), + }; + session.session_fingerprint = + plan_session_fingerprint(&session).map_err(|error| error.to_string())?; + write_plan_session_atomic_locked(root, &session).map_err(|error| error.to_string())?; + // Root plan Provider facts predate the first planning-child session. + // Establish revision 1 first, then fold those immutable facts under + // the same project lock so the child never starts from budget zero. + fold_plan_provider_usage_before_new_request_at_locked(root)?; + return Ok(true); + }; + if previous.project_id != project_id { + return Err(plan_coordinator_error( + "PLAN_ACTIVE_RUN_EXISTS", + "已有 planning session 的 projectId 与当前项目不一致", + )); + } + ensure_existing_plan_session_identity(&previous, task, &delivery)?; + if previous.active_run_id.as_deref() == Some(task.run_id.as_str()) + && previous.last_run_id == task.run_id + && previous.latest_delegation_id == delivery.delegation_id + && previous.run_profile_binding_fingerprint == binding.binding_fingerprint + && previous.phase == "collecting" + { + return Ok(true); + } + let original_id = delivery.repair_of_delegation_id.as_deref().ok_or_else(|| { + plan_coordinator_error( + "PLAN_ACTIVE_RUN_EXISTS", + "已有 planning session 时不能创建第二条根 delegation", + ) + })?; + let deliveries = list_static_delegate_deliveries_at(root)?; + if static_delegate_lineage_contains_unknown_contract_status( + &deliveries, + &delivery.delegation_id, + )? { + return Err(plan_coordinator_error( + "PLAN_NEEDS_RECONCILIATION", + "planning lineage 含未知 contractStatus", + )); + } + let original = deliveries + .iter() + .find(|candidate| candidate.delegation_id == original_id) + .ok_or_else(|| { + plan_coordinator_error("PLAN_NEEDS_RECONCILIATION", "continuation 原 delivery 缺失") + })?; + let (_, clarification_round) = + static_delegate_lineage_counters(&deliveries, &delivery.delegation_id); + if clarification_round == u32::MAX || clarification_round > 3 { + return Err(plan_coordinator_error( + "PLAN_NEEDS_RECONCILIATION", + "continuation clarification round 无效", + )); + } + let mut next = plan_session_successor_base(&previous)?; + if original.structured_result.as_ref().is_some_and(|result| { + result.contract_status == StaticDelegateContractStatus::NeedsUserInput + }) { + if clarification_round == 0 + || previous.applied_answers.len() as u32 + 1 != clarification_round + { + return Err(plan_coordinator_error( + "PLAN_NEEDS_RECONCILIATION", + "answer projection 与 lineage clarification round 不连续", + )); + } + let answered = read_answered_plan_static_delegate_user_input_at(root, original)?; + let projection = build_plan_clarification_decision_projection( + &previous.root_run_id, + &original.delegation_id, + &delivery.delegation_id, + clarification_round, + &answered, + )?; + apply_plan_clarification_projection(&mut next, projection)?; + } else if original.structured_result.as_ref().is_some_and(|result| { + result.contract_status == StaticDelegateContractStatus::UserRevisionRequested + }) { + validate_plan_session_for_clarification_round(&previous, clarification_round) + .map_err(|error| error.to_string())?; + } else { + // Quality repair starts a fresh clarification segment. Previously + // confirmed decisions stay authoritative, while transport bindings + // belong to the old segment and must not become a second round truth. + if clarification_round != 0 { + return Err(plan_coordinator_error( + "PLAN_NEEDS_RECONCILIATION", + "quality repair 必须把 clarification round 重置为 0", + )); + } + next.applied_answers.clear(); + } + next.run_profile_binding_fingerprint = binding.binding_fingerprint; + next.latest_delegation_id = delivery.delegation_id; + next.active_run_id = Some(task.run_id.clone()); + next.last_run_id = task.run_id.clone(); + next.phase = "collecting".to_string(); + let next = finalize_plan_session_successor(&previous, next)?; + write_plan_session_atomic_locked(root, &next).map_err(|error| error.to_string())?; + Ok(true) +} + +/// Project the planning session while the caller already owns the project +/// lock. M1C-2b planning clarification paths that also need a Runtime lane +/// must acquire this project lock before the session/execution lane; this is +/// deliberately not a repository-wide lock-order claim. +pub(crate) fn project_plan_session_awaiting_user_input_at_locked( + root: &Path, + runtime: &AgentRuntimeState, + delivery: &StaticDelegateDeliveryRecord, + project_lock: &ProjectWriteLock, +) -> Result<(), String> { + if delivery.target_agent_id != GAME_CREATOR_PROJECT_PLANNING_AGENT_ID { + return Ok(()); + } + if !project_lock.guards_project_root(root)? { + return Err(plan_coordinator_error( + "PLAN_NEEDS_RECONCILIATION", + "planning clarification 投影缺少当前项目写锁", + )); + } + validate_project_supervisor_plan_root_binding_at(root, &runtime.agent_id, &runtime.run_id)?; + fold_plan_provider_usage_before_new_request_at_locked(root)?; + let current_delivery = read_static_delegate_delivery_at(root, &delivery.delegation_id)? + .ok_or_else(|| { + plan_coordinator_error("PLAN_NEEDS_RECONCILIATION", "planning delivery 缺失") + })?; + if current_delivery != *delivery || current_delivery.clarification_answers_sha256.is_some() { + return Err(plan_coordinator_error( + "PLAN_NEEDS_RECONCILIATION", + "展示 planning 澄清卡前 delivery 已漂移或已绑定答案", + )); + } + let deliveries = list_static_delegate_deliveries_at(root)?; + let (_, current_round) = + static_delegate_lineage_counters(&deliveries, ¤t_delivery.delegation_id); + if current_round == u32::MAX || current_round >= 3 { + return Err(plan_coordinator_error( + "PLAN_CLARIFICATION_LIMIT_REACHED", + "Fast GDD 已完成三轮澄清,不能展示第四张卡", + )); + } + let result = current_delivery.structured_result.as_ref().ok_or_else(|| { + plan_coordinator_error("PLAN_NEEDS_RECONCILIATION", "planning delivery 缺少问题") + })?; + validate_exact_plan_clarification_question(&result.user_input_questions, current_round + 1)?; + let previous = read_plan_session_with_recovery_locked(root) + .map_err(|error| error.to_string())? + .ok_or_else(|| { + plan_coordinator_error("PLAN_SESSION_RECOVERY_REQUIRED", "plan session 缺失") + })?; + if previous.root_run_id != runtime.run_id + || previous.session_id != current_delivery.target_session_id + || previous.latest_delegation_id != current_delivery.delegation_id + || previous.last_run_id != current_delivery.target_run_id + { + return Err(plan_coordinator_error( + "PLAN_NEEDS_RECONCILIATION", + "awaiting_user_input 投影与 session/delivery lineage 不一致", + )); + } + validate_plan_session_for_clarification_round(&previous, current_round) + .map_err(|error| error.to_string())?; + if previous.phase == "awaiting_user_input" && previous.active_run_id.is_none() { + return Ok(()); + } + if previous.phase != "collecting" + || previous.active_run_id.as_deref() != Some(current_delivery.target_run_id.as_str()) + { + return Err(plan_coordinator_error( + "PLAN_NEEDS_RECONCILIATION", + "planning child 终态前 session 不在对应 collecting/active 状态", + )); + } + let mut next = plan_session_successor_base(&previous)?; + next.active_run_id = None; + next.last_run_id = current_delivery.target_run_id; + next.latest_delegation_id = current_delivery.delegation_id; + next.phase = "awaiting_user_input".to_string(); + let next = finalize_plan_session_successor(&previous, next)?; + write_plan_session_atomic_locked(root, &next).map_err(|error| error.to_string()) +} + +pub(crate) fn validate_plan_clarification_answer_for_pending_at( + root: &Path, + pending: &AgentRuntimePendingToolAction, + questions: &[AgentRuntimeUserInputQuestion], + answers: &BTreeMap, +) -> Result<(), String> { + if !plan_clarification_pending_requires_project_lock_at(root, pending)? { + return Ok(()); + } + let lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "planning.clarification.answer", + )?; + validate_plan_clarification_answer_for_pending_at_locked( + root, pending, questions, answers, &lock, + ) +} + +pub(crate) fn validate_plan_clarification_answer_for_pending_at_locked( + root: &Path, + pending: &AgentRuntimePendingToolAction, + questions: &[AgentRuntimeUserInputQuestion], + answers: &BTreeMap, + project_lock: &ProjectWriteLock, +) -> Result<(), String> { + if !project_lock.guards_project_root(root)? { + return Err(plan_coordinator_error( + "PLAN_NEEDS_RECONCILIATION", + "planning answer 校验缺少当前项目写锁", + )); + } + validate_plan_clarification_answer_for_pending_at_unlocked(root, pending, questions, answers) +} + +/// Read-only routing probe for lock ordering. It deliberately does not read +/// or repair `session.json`: command and recovery entrypoints call this before +/// taking the Agent execution lock, then re-read the pending action after +/// acquiring project -> execution in that order. +pub(crate) fn plan_clarification_pending_requires_project_lock_at( + root: &Path, + pending: &AgentRuntimePendingToolAction, +) -> Result { + let Some(delegation_id) = pending + .task + .strip_prefix("子 Agent 需要用户澄清后才能继续。delegationId=") + .and_then(|value| value.split(';').next()) + .map(str::trim) + .filter(|value| !value.is_empty()) + else { + return Ok(false); + }; + let delivery = read_static_delegate_delivery_at(root, delegation_id)? + .ok_or_else(|| plan_coordinator_error("PLAN_NEEDS_RECONCILIATION", "澄清 delivery 缺失"))?; + Ok(delivery.target_agent_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID) +} + +fn validate_plan_clarification_answer_for_pending_at_unlocked( + root: &Path, + pending: &AgentRuntimePendingToolAction, + questions: &[AgentRuntimeUserInputQuestion], + answers: &BTreeMap, +) -> Result<(), String> { + let Some(delegation_id) = pending + .task + .strip_prefix("子 Agent 需要用户澄清后才能继续。delegationId=") + .and_then(|value| value.split(';').next()) + .map(str::trim) + .filter(|value| !value.is_empty()) + else { + return Ok(()); + }; + let delivery = read_static_delegate_delivery_at(root, delegation_id)? + .ok_or_else(|| plan_coordinator_error("PLAN_NEEDS_RECONCILIATION", "澄清 delivery 缺失"))?; + if delivery.target_agent_id != GAME_CREATOR_PROJECT_PLANNING_AGENT_ID { + return Ok(()); + } + validate_project_supervisor_plan_root_binding_at(root, &pending.agent_id, &pending.run_id)?; + if delivery.parent_agent_id != pending.agent_id + || delivery.parent_run_id != pending.run_id + || delivery.status != StaticDelegateDeliveryStatus::ClaimedByParent + || delivery.clarification_answers_sha256.is_some() + { + return Err(plan_coordinator_error( + "PLAN_NEEDS_RECONCILIATION", + "planning answer 与未回答的 claimed delivery 身份不一致", + )); + } + let deliveries = list_static_delegate_deliveries_at(root)?; + let (_, current_round) = static_delegate_lineage_counters(&deliveries, delegation_id); + if current_round == u32::MAX || current_round >= 3 { + return Err(plan_coordinator_error( + "PLAN_CLARIFICATION_LIMIT_REACHED", + "Fast GDD 已达三轮澄清上限", + )); + } + validate_exact_plan_clarification_question(questions, current_round + 1)?; + let question_id = &questions[0].id; + let answer = answers.get(question_id).ok_or_else(|| { + plan_coordinator_error( + "PLAN_INVALID_CLARIFICATION", + "planning answer 缺少唯一 questionId", + ) + })?; + normalize_plan_text(answer, "plan answer", 1, 400).map_err(|error| error.to_string())?; + // The user-input owner already holds the project write lock when it calls + // this precheck. Do not acquire the non-reentrant lock here; the answer + // bind is not a fresh Provider boundary and usage was folded when this + // pending card was projected. + let session = read_plan_session_with_recovery_locked(root) + .map_err(|error| error.to_string())? + .ok_or_else(|| { + plan_coordinator_error("PLAN_SESSION_RECOVERY_REQUIRED", "plan session 缺失") + })?; + if session.root_run_id != pending.run_id + || session.latest_delegation_id != delegation_id + || session.phase != "awaiting_user_input" + || session.active_run_id.is_some() + { + return Err(plan_coordinator_error( + "PLAN_NEEDS_RECONCILIATION", + "planning answer 的 session pre-wait anchor 不成立", + )); + } + validate_plan_session_for_clarification_round(&session, current_round) + .map_err(|error| error.to_string()) +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_provider_usage.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_provider_usage.rs new file mode 100644 index 000000000..a39002b64 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_provider_usage.rs @@ -0,0 +1,886 @@ +use super::*; + +use std::collections::BTreeMap; + +const PLAN_PROVIDER_USAGE_RECORD_TYPE: &str = "agent.runtime.plan.provider_usage"; +const PLAN_PROVIDER_USAGE_SCHEMA_VERSION: &str = "plan-provider-usage.v1"; + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub(crate) struct PlanProviderUsageFactV1 { + record_type: String, + usage_schema_version: String, + project_id: String, + root_agent_id: String, + root_run_id: String, + root_run_profile_binding_fingerprint: String, + agent_id: String, + task_id: String, + session_id: String, + run_id: String, + source: String, + request_id: String, + request_kind: String, + request_slot: String, + web_search_enabled: bool, + planning_session_binding: Option, + outcome: String, + active_millis: u64, +} + +#[derive(Clone, Debug)] +pub(crate) struct PlanProviderUsageScope { + root_run_id: String, + root_run_profile_binding_fingerprint: String, + planning_session_binding: Option, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum PlanProviderUsageFoldOutcome { + NoSession, + Deferred, + Unchanged, + Advanced, +} + +fn is_provider_request_id(value: &str) -> bool { + value + .strip_prefix("provider-request-") + .is_some_and(|suffix| { + suffix.len() == 64 + && suffix + .bytes() + .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()) + }) +} + +fn is_bare_sha256(value: &str) -> bool { + value.len() == 64 + && value + .bytes() + .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()) +} + +fn validate_plan_provider_usage_fact_shape(fact: &PlanProviderUsageFactV1) -> Result<(), String> { + if fact.record_type != PLAN_PROVIDER_USAGE_RECORD_TYPE + || fact.usage_schema_version != PLAN_PROVIDER_USAGE_SCHEMA_VERSION + || fact.root_agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID + || fact.project_id.trim().is_empty() + || fact.root_run_id.trim().is_empty() + || fact.agent_id.trim().is_empty() + || fact.task_id.trim().is_empty() + || fact.session_id.trim().is_empty() + || fact.run_id.trim().is_empty() + || fact.source.trim().is_empty() + || fact.request_slot.trim().is_empty() + || !is_provider_request_id(&fact.request_id) + || !is_bare_sha256(&fact.root_run_profile_binding_fingerprint) + || !matches!( + fact.request_kind.as_str(), + "tool-plan" | "final-reply" | "context-compaction" | "final-reply-context-compaction" + ) + || !matches!( + fact.outcome.as_str(), + "completed" | "failed" | "interrupted" + ) + { + return Err("PLAN_PROVIDER_USAGE_INVALID: Provider usage fact 基础字段无效".to_string()); + } + match fact.planning_session_binding.as_ref() { + Some(binding) => { + validate_plan_provider_session_binding(binding).map_err(|error| { + format!("PLAN_PROVIDER_USAGE_INVALID: planning binding 无效:{error}") + })?; + if fact.agent_id != GAME_CREATOR_PROJECT_PLANNING_AGENT_ID + || fact.source != "agent-delegate" + || binding.project_id != fact.project_id + || binding.agent_id != fact.agent_id + || binding.task_id != fact.task_id + || binding.session_id != fact.session_id + || binding.run_id != fact.run_id + || binding.root_agent_id != fact.root_agent_id + || binding.root_run_id != fact.root_run_id + || binding.provider_request_id != fact.request_id + || binding.request_kind != fact.request_kind + || binding.request_slot != fact.request_slot + || binding.web_search_enabled != fact.web_search_enabled + { + return Err( + "PLAN_PROVIDER_USAGE_IDENTITY_CONFLICT: usage fact 与 planning binding 不一致" + .to_string(), + ); + } + } + None => { + if fact.agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID + || fact.run_id != fact.root_run_id + || fact.source != AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE + { + return Err( + "PLAN_PROVIDER_USAGE_IDENTITY_CONFLICT: root usage fact 身份不一致".to_string(), + ); + } + } + } + Ok(()) +} + +/// Capture the exact planning budget scope while the Provider start lock is +/// still held. Ordinary Runtime requests return `None`; an apparent planning +/// request with a broken durable identity fails closed instead of silently +/// escaping the budget. +pub(crate) fn capture_plan_provider_usage_scope_at_locked( + root: &Path, + snapshot: &AgentRuntimeProviderRequestSnapshot, + request_id: &str, +) -> Result, String> { + if let Some(base_binding) = snapshot.planning_session_binding.as_ref() { + if snapshot.agent_id != GAME_CREATOR_PROJECT_PLANNING_AGENT_ID { + return Err( + "PLAN_PROVIDER_USAGE_IDENTITY_CONFLICT: 非 planning Agent 携带 session binding" + .to_string(), + ); + } + let attempt_binding = plan_provider_session_binding_for_attempt( + base_binding, + &snapshot.request_slot, + request_id, + )?; + let child = + validate_project_planning_child_binding_at(root, &snapshot.agent_id, &snapshot.run_id)?; + let parent = validate_project_supervisor_plan_root_binding_at( + root, + &attempt_binding.root_agent_id, + &attempt_binding.root_run_id, + )?; + if child.binding_fingerprint != attempt_binding.run_profile_binding_fingerprint + || child.root_run_id != attempt_binding.root_run_id + || parent.binding_fingerprint + != child.parent_binding_fingerprint.clone().unwrap_or_default() + || parent.project_id != snapshot.project_id + { + return Err( + "PLAN_PROVIDER_USAGE_IDENTITY_CONFLICT: planning usage scope 与 Run Profile 绑定不一致" + .to_string(), + ); + } + return Ok(Some(PlanProviderUsageScope { + root_run_id: parent.run_id, + root_run_profile_binding_fingerprint: parent.binding_fingerprint, + planning_session_binding: Some(attempt_binding), + })); + } + + if snapshot.agent_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID { + return Err( + "PLAN_PROVIDER_USAGE_IDENTITY_CONFLICT: planning Provider 请求缺少 session binding" + .to_string(), + ); + } + if snapshot.source != AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE { + return Ok(None); + } + let binding = validate_project_supervisor_plan_root_binding_at( + root, + &snapshot.agent_id, + &snapshot.run_id, + )?; + if snapshot.source != binding.source + || snapshot.project_id != binding.project_id + || snapshot.run_id != binding.root_run_id + { + return Err( + "PLAN_PROVIDER_USAGE_IDENTITY_CONFLICT: root Provider snapshot 与绑定不一致" + .to_string(), + ); + } + Ok(Some(PlanProviderUsageScope { + root_run_id: binding.root_run_id, + root_run_profile_binding_fingerprint: binding.binding_fingerprint, + planning_session_binding: None, + })) +} + +/// Persist one immutable live-process interval. The caller stops its monotonic +/// clock before entering this function, so Agent DB locking, response handoff, +/// retry backoff and later recovery are never included in `activeMillis`. +pub(crate) fn persist_plan_provider_usage_fact_at( + root: &Path, + snapshot: &AgentRuntimeProviderRequestSnapshot, + request_id: &str, + scope: Option<&PlanProviderUsageScope>, + outcome: &str, + active_millis: u64, +) -> Result { + let Some(scope) = scope else { + return Ok(false); + }; + let fact = PlanProviderUsageFactV1 { + record_type: PLAN_PROVIDER_USAGE_RECORD_TYPE.to_string(), + usage_schema_version: PLAN_PROVIDER_USAGE_SCHEMA_VERSION.to_string(), + project_id: snapshot.project_id.clone(), + root_agent_id: GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(), + root_run_id: scope.root_run_id.clone(), + root_run_profile_binding_fingerprint: scope.root_run_profile_binding_fingerprint.clone(), + agent_id: snapshot.agent_id.clone(), + task_id: snapshot.task_id.clone(), + session_id: snapshot.session_id.clone(), + run_id: snapshot.run_id.clone(), + source: snapshot.source.clone(), + request_id: request_id.to_string(), + request_kind: snapshot.request_kind.clone(), + request_slot: snapshot.request_slot.clone(), + web_search_enabled: snapshot.web_search_enabled, + planning_session_binding: scope.planning_session_binding.clone(), + outcome: outcome.to_string(), + active_millis, + }; + validate_plan_provider_usage_fact_shape(&fact)?; + let record = serde_json::to_value(&fact) + .map_err(|error| format!("序列化 planning Provider usage 失败:{error}"))?; + append_agent_db_plan_provider_usage_idempotent(root, record) +} + +fn read_plan_provider_usage_facts_at(root: &Path) -> Result, String> { + read_agent_db_plan_provider_usage_records_at(root)? + .into_iter() + .map(|mut record| { + let object = record.as_object_mut().ok_or_else(|| { + "PLAN_PROVIDER_USAGE_INVALID: Agent DB usage record 不是 object".to_string() + })?; + object.remove("schemaVersion"); + object.remove("updatedAt"); + let fact = + serde_json::from_value::(record).map_err(|error| { + format!("PLAN_PROVIDER_USAGE_INVALID: 解析 usage fact 失败:{error}") + })?; + validate_plan_provider_usage_fact_shape(&fact)?; + Ok(fact) + }) + .collect() +} + +fn planning_child_usage_projection_is_deferred_at( + root: &Path, + facts: &[PlanProviderUsageFactV1], +) -> Result { + let mut runs = BTreeMap::<(String, String), ()>::new(); + for fact in facts { + if fact.planning_session_binding.is_some() { + runs.insert((fact.agent_id.clone(), fact.run_id.clone()), ()); + } + } + for ((agent_id, run_id), ()) in runs { + if crate::provider_retry::read_for_run_at(root, &agent_id, &run_id)?.is_some() + || crate::provider_handoff::read_for_run_at(root, &agent_id, &run_id)?.is_some() + || game_creator_agent_runtime_provider_action_batch_exists(root, &agent_id, &run_id) + { + return Ok(true); + } + } + Ok(false) +} + +fn plan_provider_usage_fact_matches_session( + root: &Path, + session: &PlanSessionV1, + fact: &PlanProviderUsageFactV1, +) -> Result { + if fact.project_id != session.project_id || fact.root_run_id != session.root_run_id { + return Ok(false); + } + let root_binding = validate_project_supervisor_plan_root_binding_at( + root, + &fact.root_agent_id, + &fact.root_run_id, + )?; + if root_binding.binding_fingerprint != fact.root_run_profile_binding_fingerprint { + return Err( + "PLAN_PROVIDER_USAGE_IDENTITY_CONFLICT: usage fact 的 root binding 已漂移".to_string(), + ); + } + let Some(binding) = fact.planning_session_binding.as_ref() else { + return Ok(true); + }; + if binding.gdd_id != session.gdd_id + || binding.session_id != session.session_id + || binding.root_run_id != session.root_run_id + { + return Err( + "PLAN_PROVIDER_USAGE_IDENTITY_CONFLICT: 同 plan root usage fact 跨越 GDD/session" + .to_string(), + ); + } + let child = validate_project_planning_child_binding_at(root, &fact.agent_id, &fact.run_id)?; + if child.binding_fingerprint != binding.run_profile_binding_fingerprint + || child.root_run_id != session.root_run_id + { + return Err( + "PLAN_PROVIDER_USAGE_IDENTITY_CONFLICT: child usage fact 的 Run Profile 绑定已漂移" + .to_string(), + ); + } + Ok(true) +} + +/// Fold terminal request facts into the current plan session. Facts are the +/// immutable source; the session value is rebuilt as a checked sum rather than +/// incremented from an event, making crash replay naturally idempotent. +/// +/// Callers must already hold the cross-process project write lock and must use +/// this only at a new-request or domain-successor boundary. A persisted retry, +/// response handoff or Provider batch for a planning child defers the fold so +/// the response's frozen session binding cannot be invalidated mid-exchange. +/// A tool-plan handoff is deliberately not a blocker: that ledger remains as +/// historical execution evidence until finalization. +pub(crate) fn fold_plan_provider_usage_into_session_at_locked( + root: &Path, +) -> Result { + let Some(previous) = + read_plan_session_with_recovery_locked(root).map_err(|error| error.to_string())? + else { + return Ok(PlanProviderUsageFoldOutcome::NoSession); + }; + let all_facts = read_plan_provider_usage_facts_at(root)?; + let mut unique = BTreeMap::::new(); + for fact in all_facts { + match unique.get(&fact.request_id) { + Some(existing) if existing == &fact => continue, + Some(_) => { + return Err(format!( + "PLAN_PROVIDER_USAGE_IDENTITY_CONFLICT: 同 requestId 存在不同 usage fact:{}", + fact.request_id + )); + } + None => { + unique.insert(fact.request_id.clone(), fact); + } + } + } + let mut matching = Vec::new(); + for fact in unique.into_values() { + if plan_provider_usage_fact_matches_session(root, &previous, &fact)? { + matching.push(fact); + } + } + if planning_child_usage_projection_is_deferred_at(root, &matching)? { + return Ok(PlanProviderUsageFoldOutcome::Deferred); + } + + let mut total = 0_u64; + for fact in &matching { + let expected_lifecycle = if let Some(binding) = fact.planning_session_binding.as_ref() { + serde_json::json!({ + "recordType": AGENT_RUNTIME_PROVIDER_REQUEST_LIFECYCLE_RECORD_TYPE, + "auditSchemaVersion": AGENT_RUNTIME_PLAN_PROVIDER_REQUEST_LIFECYCLE_SCHEMA_VERSION, + "agentId": fact.agent_id, + "taskId": fact.task_id, + "sessionId": fact.session_id, + "runId": fact.run_id, + "source": fact.source, + "requestId": fact.request_id, + "requestKind": fact.request_kind, + "requestSlot": fact.request_slot, + "webSearchEnabled": fact.web_search_enabled, + "planningSessionBinding": binding, + "status": "started", + }) + } else { + serde_json::json!({ + "recordType": AGENT_RUNTIME_PROVIDER_REQUEST_LIFECYCLE_RECORD_TYPE, + "auditSchemaVersion": AGENT_RUNTIME_PROVIDER_REQUEST_LIFECYCLE_SCHEMA_VERSION, + "agentId": fact.agent_id, + "taskId": fact.task_id, + "sessionId": fact.session_id, + "runId": fact.run_id, + "source": fact.source, + "requestId": fact.request_id, + "requestKind": fact.request_kind, + "requestSlot": fact.request_slot, + "webSearchEnabled": fact.web_search_enabled, + "status": "started", + }) + }; + let transitions = read_agent_db_lifecycle_transitions_matching_at( + root, + AGENT_RUNTIME_PROVIDER_REQUEST_LIFECYCLE_RECORD_TYPE, + "requestId", + &fact.request_id, + &expected_lifecycle, + )?; + let expected = ["started".to_string(), fact.outcome.clone()]; + if transitions != ["started"] && transitions != expected { + return Err(format!( + "PLAN_PROVIDER_USAGE_LIFECYCLE_CONFLICT: requestId={} usage/lifecycle 不一致", + fact.request_id + )); + } + total = total.checked_add(fact.active_millis).ok_or_else(|| { + "PLAN_PROVIDER_USAGE_OVERFLOW: accumulatedAgentMillis 溢出".to_string() + })?; + } + if previous.accumulated_agent_millis > total { + return Err( + "PLAN_PROVIDER_USAGE_IDENTITY_CONFLICT: session 累计值大于 immutable facts 总和" + .to_string(), + ); + } + if previous.accumulated_agent_millis == total { + return Ok(PlanProviderUsageFoldOutcome::Unchanged); + } + let mut next = previous.clone(); + next.session_revision = previous + .session_revision + .checked_add(1) + .ok_or_else(|| "PLAN_SESSION_CAS_CONFLICT: sessionRevision 溢出".to_string())?; + next.previous_fingerprint = Some(previous.session_fingerprint.clone()); + next.accumulated_agent_millis = total; + next.updated_at_utc = current_plan_timestamp_utc(); + next.session_fingerprint = format!("sha256-serde-json-v2:{}", "0".repeat(64)); + next.session_fingerprint = + plan_session_fingerprint(&next).map_err(|error| error.to_string())?; + validate_plan_session_successor(&previous, &next).map_err(|error| error.to_string())?; + write_plan_session_atomic_locked(root, &next).map_err(|error| error.to_string())?; + Ok(PlanProviderUsageFoldOutcome::Advanced) +} + +/// A fresh Provider request may only freeze bytes after all prior planning +/// exchange state has cleared. Retry replay keeps using its already-frozen +/// bytes elsewhere; reaching this boundary while folding is deferred must +/// block instead of quietly issuing a request from the older session. +pub(crate) fn fold_plan_provider_usage_before_new_request_at_locked( + root: &Path, +) -> Result<(), String> { + match fold_plan_provider_usage_into_session_at_locked(root)? { + PlanProviderUsageFoldOutcome::Deferred => Err( + "PLAN_PROVIDER_USAGE_DEFERRED: 上一条 planning Provider exchange 尚未收口".to_string(), + ), + PlanProviderUsageFoldOutcome::NoSession + | PlanProviderUsageFoldOutcome::Unchanged + | PlanProviderUsageFoldOutcome::Advanced => Ok(()), + } +} + +#[cfg(test)] +pub(crate) fn append_plan_provider_usage_fact_for_test( + root: &Path, + fact: &PlanProviderUsageFactV1, +) -> Result { + validate_plan_provider_usage_fact_shape(fact)?; + append_agent_db_plan_provider_usage_idempotent( + root, + serde_json::to_value(fact).map_err(|error| error.to_string())?, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + struct UsageFixture { + _temporary: tempfile::TempDir, + root: PathBuf, + project_id: String, + root_runtime: AgentRuntimeState, + child_runtime: AgentRuntimeState, + session: PlanSessionV1, + } + + fn usage_fixture() -> UsageFixture { + let temporary = crate::tests::canonical_test_tempdir("planning-provider-usage-"); + let root = temporary.path().join("project"); + init_local_game_project_at(&root, "planning-provider-usage", "统计 Fast GDD 活跃时间") + .expect("init usage fixture"); + let project_id = game_creator_agent_runtime_context_project_id(&root) + .expect("read usage fixture project id"); + bind_game_creator_agent_runtime_run_profile_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "usage-root-run", + AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_STANDARD), + None, + ) + .expect("bind usage plan root"); + let root_runtime = start_game_creator_agent_runtime_task_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "收敛 Fast GDD", + "usage-root-run", + AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE, + "委派策划子 Agent", + vec!["等待 Fast GDD".to_string()], + ) + .expect("start usage plan root"); + let child_binding = bind_game_creator_agent_runtime_run_profile_at( + &root, + GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + "usage-child-run", + "agent-delegate", + Some(AGENT_RUNTIME_RUN_PROFILE_STANDARD), + Some(&AgentRuntimeTaskLink { + parent_agent_id: Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string()), + parent_run_id: Some("usage-root-run".to_string()), + delegation_id: Some("usage-delegation".to_string()), + }), + ) + .expect("bind usage planning child"); + let mut child_runtime = start_game_creator_agent_runtime_task_at( + &root, + GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + "形成 Fast GDD", + "usage-child-run", + "agent-delegate", + "读取项目事实", + vec!["收敛设计决定".to_string()], + ) + .expect("start usage planning child"); + child_runtime.parent_agent_id = Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string()); + child_runtime.parent_run_id = Some("usage-root-run".to_string()); + child_runtime.delegation_id = Some("usage-delegation".to_string()); + child_runtime.run_profile = AGENT_RUNTIME_RUN_PROFILE_STANDARD.to_string(); + child_runtime.run_profile_binding_fingerprint = child_binding.binding_fingerprint; + append_game_creator_agent_runtime_task(&root, &child_runtime) + .expect("persist usage planning child identity"); + write_game_creator_agent_runtime_state(&root, &child_runtime) + .expect("persist current usage planning child identity"); + + let mut session = PlanSessionV1 { + schema_version: PLAN_SESSION_SCHEMA_VERSION.to_string(), + project_id: project_id.clone(), + gdd_id: "gdd-00000000-0000-4000-8000-000000000901".to_string(), + session_revision: 1, + previous_fingerprint: None, + session_fingerprint: format!("sha256-serde-json-v2:{}", "0".repeat(64)), + agent_id: GAME_CREATOR_PROJECT_PLANNING_AGENT_ID.to_string(), + source: "agent-delegate".to_string(), + run_profile: AGENT_RUNTIME_RUN_PROFILE_STANDARD.to_string(), + run_profile_binding_fingerprint: child_runtime.run_profile_binding_fingerprint.clone(), + root_agent_id: GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(), + root_run_id: "usage-root-run".to_string(), + latest_delegation_id: "usage-delegation".to_string(), + session_id: child_runtime.session_id.clone(), + active_run_id: Some(child_runtime.run_id.clone()), + last_run_id: child_runtime.run_id.clone(), + phase: "collecting".to_string(), + accumulated_agent_millis: 0, + applied_steer_cursor: 0, + decisions_summary: vec![PlanDecisionSummary { + id: "initial-request".to_string(), + topic: "初始需求".to_string(), + state: "confirmed".to_string(), + answer_source: "user_freeform".to_string(), + round: 0, + answer_summary: "统计 Fast GDD 活跃时间".to_string(), + }], + prototype_validation_items: Vec::new(), + applied_answers: Vec::new(), + latest_submitted_ref: None, + last_decision_ref: None, + updated_at_utc: current_plan_timestamp_utc(), + }; + session.session_fingerprint = plan_session_fingerprint(&session).expect("session fp"); + write_plan_session_atomic(&root, &session).expect("write usage session"); + UsageFixture { + _temporary: temporary, + root, + project_id, + root_runtime, + child_runtime, + session, + } + } + + fn request_snapshot( + fixture: &UsageFixture, + runtime: &AgentRuntimeState, + request_kind: &str, + request_slot: &str, + ) -> AgentRuntimeProviderRequestSnapshot { + AgentRuntimeProviderRequestSnapshot { + project_id: fixture.project_id.clone(), + agent_id: runtime.agent_id.clone(), + task_id: runtime.task_id.clone(), + session_id: runtime.session_id.clone(), + run_id: runtime.run_id.clone(), + source: runtime.source.clone(), + goal_id: runtime.goal_id.clone(), + goal_revision: runtime.goal_revision, + goal_snapshot_fingerprint: agent_goal_snapshot_fingerprint_for_state_at( + &fixture.root, + runtime, + ) + .expect("goal snapshot fingerprint"), + applied_steer_cursor: runtime.applied_steer_cursor, + request_kind: request_kind.to_string(), + request_slot: request_slot.to_string(), + web_search_enabled: false, + allow_idle_context_compaction: false, + planning_session_binding: None, + } + } + + fn planning_snapshot( + fixture: &UsageFixture, + request_kind: &str, + request_slot: &str, + ) -> AgentRuntimeProviderRequestSnapshot { + let snapshot = + request_snapshot(fixture, &fixture.child_runtime, request_kind, request_slot); + let binding = capture_plan_provider_session_binding_for_snapshot( + &fixture.root, + &fixture.child_runtime, + &snapshot, + &format!("sha256-serde-json-v2:{}", "7".repeat(64)), + ) + .expect("capture planning usage binding"); + snapshot.with_planning_session_binding(Some(binding)) + } + + fn persist_usage( + fixture: &UsageFixture, + snapshot: &AgentRuntimeProviderRequestSnapshot, + outcome: &str, + active_millis: u64, + append_terminal: bool, + ) -> String { + let request_id = game_creator_agent_runtime_provider_request_id(snapshot); + assert!( + append_game_creator_agent_runtime_provider_request_lifecycle( + &fixture.root, + snapshot, + &request_id, + "started", + ) + .expect("append usage started lifecycle") + ); + let scope = + capture_plan_provider_usage_scope_at_locked(&fixture.root, snapshot, &request_id) + .expect("capture usage scope"); + assert!(persist_plan_provider_usage_fact_at( + &fixture.root, + snapshot, + &request_id, + scope.as_ref(), + outcome, + active_millis, + ) + .expect("persist usage fact")); + if append_terminal { + assert!( + append_game_creator_agent_runtime_provider_request_lifecycle( + &fixture.root, + snapshot, + &request_id, + outcome, + ) + .expect("append usage terminal lifecycle") + ); + } + request_id + } + + #[test] + fn usage_fact_append_is_idempotent_and_conflicts_fail_closed() { + let fixture = usage_fixture(); + let snapshot = request_snapshot( + &fixture, + &fixture.root_runtime, + "tool-plan", + "usage-idempotent", + ); + let request_id = game_creator_agent_runtime_provider_request_id(&snapshot); + let scope = + capture_plan_provider_usage_scope_at_locked(&fixture.root, &snapshot, &request_id) + .expect("capture root usage scope"); + assert!(persist_plan_provider_usage_fact_at( + &fixture.root, + &snapshot, + &request_id, + scope.as_ref(), + "completed", + 19, + ) + .expect("append first usage fact")); + assert!(!persist_plan_provider_usage_fact_at( + &fixture.root, + &snapshot, + &request_id, + scope.as_ref(), + "completed", + 19, + ) + .expect("replay identical usage fact")); + let error = persist_plan_provider_usage_fact_at( + &fixture.root, + &snapshot, + &request_id, + scope.as_ref(), + "completed", + 20, + ) + .expect_err("same request id with a different interval must fail"); + assert!(error.contains("同 requestId 内容冲突"), "{error}"); + } + + #[test] + fn fold_counts_root_child_and_started_only_facts_once() { + let fixture = usage_fixture(); + let root_snapshot = request_snapshot( + &fixture, + &fixture.root_runtime, + "tool-plan", + "usage-root-completed", + ); + persist_usage(&fixture, &root_snapshot, "completed", 11, true); + let failed_child = planning_snapshot(&fixture, "tool-plan", "usage-child-failed"); + persist_usage(&fixture, &failed_child, "failed", 13, true); + let interrupted_child = + planning_snapshot(&fixture, "final-reply", "usage-child-interrupted"); + persist_usage(&fixture, &interrupted_child, "interrupted", 17, false); + + let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + &fixture.root, + "test.planning_provider_usage.fold", + ) + .expect("usage fold lock"); + assert_eq!( + fold_plan_provider_usage_into_session_at_locked(&fixture.root) + .expect("fold usage facts"), + PlanProviderUsageFoldOutcome::Advanced + ); + let advanced = read_plan_session_with_recovery_locked(&fixture.root) + .expect("read advanced usage session") + .expect("advanced usage session exists"); + assert_eq!(advanced.accumulated_agent_millis, 41); + assert_eq!(advanced.session_revision, 2); + assert_eq!( + advanced.previous_fingerprint.as_deref(), + Some(fixture.session.session_fingerprint.as_str()) + ); + assert_eq!(advanced.phase, fixture.session.phase); + assert_eq!( + advanced.decisions_summary, + fixture.session.decisions_summary + ); + assert_eq!( + fold_plan_provider_usage_into_session_at_locked(&fixture.root) + .expect("replay usage fold"), + PlanProviderUsageFoldOutcome::Unchanged + ); + // The next Provider request constructs its structured injection from + // this successor. This unit fixture deliberately omits a real + // delegation delivery, so assert the exact injected source field + // directly rather than manufacturing unrelated clarification lineage. + assert_eq!(advanced.accumulated_agent_millis, 41); + } + + #[test] + fn fold_waits_for_submit_anchor_cleanup_then_advances() { + let fixture = usage_fixture(); + let snapshot = planning_snapshot(&fixture, "tool-plan", "usage-submit-final"); + persist_usage(&fixture, &snapshot, "completed", 23, true); + + let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + &fixture.root, + "test.planning_provider_usage.submit_cleanup_boundary", + ) + .expect("usage cleanup-boundary lock"); + let batch_path = game_creator_agent_runtime_provider_action_batch_path( + &fixture.root, + &snapshot.agent_id, + &snapshot.run_id, + ); + std::fs::create_dir_all(batch_path.parent().expect("batch parent")) + .expect("create batch parent"); + std::fs::write(&batch_path, b"submit-anchor").expect("create submit anchor sentinel"); + assert_eq!( + fold_plan_provider_usage_into_session_at_locked(&fixture.root) + .expect("defer while submit anchor exists"), + PlanProviderUsageFoldOutcome::Deferred + ); + + std::fs::remove_file(&batch_path).expect("remove consumed submit anchor"); + assert_eq!( + fold_plan_provider_usage_into_session_at_locked(&fixture.root) + .expect("fold after submit anchor cleanup"), + PlanProviderUsageFoldOutcome::Advanced + ); + let session = read_plan_session_with_recovery_locked(&fixture.root) + .expect("read folded session") + .expect("folded session exists"); + assert_eq!(session.accumulated_agent_millis, 23); + } + + #[test] + fn fold_rejects_lifecycle_outcome_or_identity_drift() { + let outcome_fixture = usage_fixture(); + let outcome_snapshot = request_snapshot( + &outcome_fixture, + &outcome_fixture.root_runtime, + "tool-plan", + "usage-outcome-conflict", + ); + let request_id = persist_usage(&outcome_fixture, &outcome_snapshot, "failed", 23, false); + assert!( + append_game_creator_agent_runtime_provider_request_lifecycle( + &outcome_fixture.root, + &outcome_snapshot, + &request_id, + "completed", + ) + .expect("append conflicting terminal lifecycle") + ); + let _outcome_lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + &outcome_fixture.root, + "test.planning_provider_usage.outcome_conflict", + ) + .expect("outcome conflict lock"); + let error = fold_plan_provider_usage_into_session_at_locked(&outcome_fixture.root) + .expect_err("usage/lifecycle terminal mismatch must fail"); + assert!(error.contains("LIFECYCLE_CONFLICT"), "{error}"); + drop(_outcome_lock); + + let identity_fixture = usage_fixture(); + let lifecycle_snapshot = request_snapshot( + &identity_fixture, + &identity_fixture.root_runtime, + "tool-plan", + "usage-identity-conflict", + ); + let request_id = game_creator_agent_runtime_provider_request_id(&lifecycle_snapshot); + assert!( + append_game_creator_agent_runtime_provider_request_lifecycle( + &identity_fixture.root, + &lifecycle_snapshot, + &request_id, + "started", + ) + .expect("append identity lifecycle") + ); + let fact_snapshot = lifecycle_snapshot.with_web_search_enabled(true); + let scope = capture_plan_provider_usage_scope_at_locked( + &identity_fixture.root, + &fact_snapshot, + &request_id, + ) + .expect("capture drifted usage scope"); + assert!(persist_plan_provider_usage_fact_at( + &identity_fixture.root, + &fact_snapshot, + &request_id, + scope.as_ref(), + "interrupted", + 29, + ) + .expect("persist drifted usage fact")); + let _identity_lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + &identity_fixture.root, + "test.planning_provider_usage.identity_conflict", + ) + .expect("identity conflict lock"); + let error = fold_plan_provider_usage_into_session_at_locked(&identity_fixture.root) + .expect_err("usage/lifecycle identity mismatch must fail"); + assert!(error.contains("内容冲突"), "{error}"); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_storage.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_storage.rs index 29f3d1b99..ad6074171 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 @@ -2100,12 +2100,20 @@ fn validate_plan_session_shape(value: &PlanSessionV1) -> Result<(), PlanningStor "awaiting_user_input 必须有 latestDelegationId 以定位问题 delivery", )); } - if let Some(last_answer) = value.applied_answers.last() { - if value.latest_delegation_id != last_answer.continuation_delegation_id { - return Err(conflict( - "latestDelegationId 必须等于最后一个 appliedAnswers 的 continuationDelegationId", - )); - } + if value + .applied_answers + .iter() + .any(|answer| value.latest_delegation_id == answer.delegation_id) + { + // Once an answer has been applied, the session may point at its + // deterministic continuation or at a later user-revision descendant, + // but it must never rewind to an already answered question delivery. + // Exact descendant proof depends on the delivery sidecars and is + // enforced by `validate_plan_session_latest_delegation_lineage_at` at + // every runtime read/write boundary rather than guessed from phase. + return Err(conflict( + "latestDelegationId 不能回退到已消费回答的 delegationId", + )); } if matches!( value.phase.as_str(), @@ -2221,6 +2229,94 @@ pub(crate) fn validate_plan_session(value: &PlanSessionV1) -> Result<(), Plannin Ok(()) } +/// Prove the dynamic part of `latestDelegationId` that the standalone session +/// schema cannot establish. After a clarification answer, a later id is only +/// valid when the durable static-delivery chain reaches the last deterministic +/// continuation and every crossed edge is classified by a +/// `UserRevisionRequested` parent. +/// This keeps revise/reject continuations valid without accepting an arbitrary +/// recomputed session fingerprint that points at an unrelated delivery. +fn validate_plan_session_latest_delegation_lineage_at( + root: &Path, + value: &PlanSessionV1, +) -> Result<(), PlanningStorageError> { + let Some(last_answer) = value.applied_answers.last() else { + return Ok(()); + }; + if value.latest_delegation_id == last_answer.continuation_delegation_id { + return Ok(()); + } + + let deliveries = list_static_delegate_deliveries_at(root).map_err(|error| { + PlanningStorageError::new( + "PLAN_NEEDS_RECONCILIATION", + format!("读取 session latest delegation 谱系失败:{error}"), + ) + })?; + if static_delegate_lineage_contains_unknown_contract_status( + &deliveries, + &value.latest_delegation_id, + ) + .map_err(|error| { + PlanningStorageError::new( + "PLAN_NEEDS_RECONCILIATION", + format!("检查 session latest delegation 谱系失败:{error}"), + ) + })? { + return Err(PlanningStorageError::new( + "PLAN_NEEDS_RECONCILIATION", + "session latest delegation 谱系含未知 contractStatus", + )); + } + + let by_id = deliveries + .iter() + .map(|delivery| (delivery.delegation_id.as_str(), delivery)) + .collect::>(); + let mut cursor = value.latest_delegation_id.as_str(); + let mut visited = std::collections::BTreeSet::new(); + loop { + if !visited.insert(cursor) { + return Err(conflict( + "session latest delegation 谱系形成循环,不能证明回答后继关系", + )); + } + let delivery = by_id + .get(cursor) + .copied() + .ok_or_else(|| conflict(format!("session latest delegation 谱系缺少节点:{cursor}")))?; + if delivery.parent_agent_id != value.root_agent_id + || delivery.parent_run_id != value.root_run_id + || delivery.target_agent_id != value.agent_id + || delivery.target_session_id != value.session_id + { + return Err(conflict( + "session latest delegation 谱系跨越了 root/agent/session identity", + )); + } + if delivery.delegation_id == last_answer.continuation_delegation_id { + break; + } + let parent_id = delivery.repair_of_delegation_id.as_deref().ok_or_else(|| { + conflict("session latest delegation 不是最后一次回答 continuation 的后继") + })?; + let parent = by_id.get(parent_id).copied().ok_or_else(|| { + conflict(format!( + "session latest delegation 谱系缺少节点:{parent_id}" + )) + })?; + if !parent.structured_result.as_ref().is_some_and(|result| { + result.contract_status == StaticDelegateContractStatus::UserRevisionRequested + }) { + return Err(conflict( + "session latest delegation 含非 UserRevisionRequested 父边,不能保留 appliedAnswers", + )); + } + cursor = parent_id; + } + Ok(()) +} + /// Validate the session projection against the clarification round derived /// from the static-delegate lineage. The lineage counter intentionally stays /// outside this storage module; callers must supply the independently read @@ -2255,7 +2351,6 @@ pub(crate) fn validate_plan_session_successor( || next.agent_id != previous.agent_id || next.source != previous.source || next.run_profile != previous.run_profile - || next.run_profile_binding_fingerprint != previous.run_profile_binding_fingerprint || next.root_agent_id != previous.root_agent_id || next.root_run_id != previous.root_run_id { @@ -2281,6 +2376,17 @@ pub(crate) fn validate_plan_session_successor( "session successor 的累计运行时间和 steer cursor 只能单调增加", )); } + let active_run_changed = next.active_run_id != previous.active_run_id + && next.active_run_id.is_some() + && next.last_run_id == next.active_run_id.clone().unwrap_or_default(); + if next.run_profile_binding_fingerprint != previous.run_profile_binding_fingerprint + && (!active_run_changed + || !matches!(next.phase.as_str(), "collecting" | "revision_requested")) + { + return Err(conflict( + "session 只有在绑定新的 active planning child 时才能更换 Run Profile binding fingerprint", + )); + } Ok(()) } @@ -2305,11 +2411,17 @@ pub(crate) fn derive_plan_continuation_delegation_id( "continuation questionsSha256/answersSha256 必须是裸 64 位小写 digest", )); } - Ok(format!( + let continuation_action_identity = format!( "clarification-continuation-{:x}", Sha256::digest(format!( "{parent_run_id}\n{repair_of_delegation_id}\n{questions_sha256}\n{answers_sha256}" )) + ); + Ok(agent_runtime_delegation_id( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + &continuation_action_identity, )) } @@ -4378,6 +4490,7 @@ pub(crate) fn write_plan_session_atomic_locked( value: &PlanSessionV1, ) -> Result<(), PlanningStorageError> { let bytes = canonical_plan_session_bytes(value)?; + validate_plan_session_latest_delegation_lineage_at(root, value)?; let target = resolve_planning_path(root, PLAN_SESSION_PATH)?; let previous = resolve_planning_path(root, PLAN_SESSION_PREVIOUS_PATH)?; let parent = ensure_planning_parent(&target)?; @@ -4386,6 +4499,7 @@ pub(crate) fn write_plan_session_atomic_locked( verify_regular_planning_file(&target, "现有 plan session")?; let old_bytes = read_regular_planning_file(&target, "现有 plan session")?; let old = parse_plan_session_bytes(&old_bytes)?; + validate_plan_session_latest_delegation_lineage_at(root, &old)?; Some((old, old_bytes)) } Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, @@ -4395,7 +4509,9 @@ pub(crate) fn write_plan_session_atomic_locked( Ok(_) => { verify_regular_planning_file(&previous, "现有 plan session previous")?; let old_bytes = read_regular_planning_file(&previous, "现有 plan session previous")?; - Some(parse_plan_session_bytes(&old_bytes)?) + let old = parse_plan_session_bytes(&old_bytes)?; + validate_plan_session_latest_delegation_lineage_at(root, &old)?; + Some(old) } Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, Err(error) => return Err(io_error("读取现有 plan session previous 失败", error)), @@ -4450,7 +4566,8 @@ pub(crate) fn write_plan_session_atomic_locked( "plan session 发布后 canonical bytes 不一致", )); } - parse_plan_session_bytes(&published)?; + let published = parse_plan_session_bytes(&published)?; + validate_plan_session_latest_delegation_lineage_at(root, &published)?; sync_planning_parent(parent) })(); let _ = fs::remove_file(&temporary); @@ -4462,13 +4579,16 @@ pub(crate) fn write_plan_session_atomic_locked( /// revision/hash-chain rules in §10.2. A corrupt primary is never silently /// replaced by a valid previous copy. fn read_optional_plan_session_file( + root: &Path, path: &Path, label: &str, ) -> Result, PlanningStorageError> { match fs::symlink_metadata(path) { Ok(_) => { let bytes = read_regular_planning_file(path, label)?; - Ok(Some(parse_plan_session_bytes(&bytes)?)) + let value = parse_plan_session_bytes(&bytes)?; + validate_plan_session_latest_delegation_lineage_at(root, &value)?; + Ok(Some(value)) } Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), Err(error) => Err(io_error(&format!("读取 {label} 失败"), error)), @@ -4513,8 +4633,10 @@ pub(crate) fn read_plan_session_with_recovery_locked( ) -> Result, PlanningStorageError> { let primary_path = resolve_planning_path(root, PLAN_SESSION_PATH)?; let previous_path = resolve_planning_path(root, PLAN_SESSION_PREVIOUS_PATH)?; - let primary_state = read_optional_plan_session_file(&primary_path, "plan session primary")?; - let previous_state = read_optional_plan_session_file(&previous_path, "plan session previous")?; + let primary_state = + read_optional_plan_session_file(root, &primary_path, "plan session primary")?; + let previous_state = + read_optional_plan_session_file(root, &previous_path, "plan session previous")?; match (primary_state, previous_state) { (None, None) => Ok(None), (Some(primary), None) => Ok(Some(primary)), @@ -4525,10 +4647,13 @@ pub(crate) fn read_plan_session_with_recovery_locked( // published a new primary between the optimistic read and lock // acquisition; never overwrite that newer fact. if let Some(current_primary) = - read_optional_plan_session_file(&primary_path, "锁内 plan session primary")? + read_optional_plan_session_file(root, &primary_path, "锁内 plan session primary")? { - let current_previous = - read_optional_plan_session_file(&previous_path, "锁内 plan session previous")?; + let current_previous = read_optional_plan_session_file( + root, + &previous_path, + "锁内 plan session previous", + )?; return match current_previous { Some(current_previous) => { if current_primary != current_previous { @@ -4539,14 +4664,17 @@ pub(crate) fn read_plan_session_with_recovery_locked( None => Ok(Some(current_primary)), }; } - let current_previous = - read_optional_plan_session_file(&previous_path, "锁内 plan session previous")? - .ok_or_else(|| { - PlanningStorageError::new( - "PLAN_RECONCILIATION_REQUIRED", - "提升 session previous 时 recovery 文件已消失", - ) - })?; + let current_previous = read_optional_plan_session_file( + root, + &previous_path, + "锁内 plan session previous", + )? + .ok_or_else(|| { + PlanningStorageError::new( + "PLAN_RECONCILIATION_REQUIRED", + "提升 session previous 时 recovery 文件已消失", + ) + })?; if current_previous != previous { return Err(conflict( "提升 session previous 前 recovery 文件发生身份漂移", @@ -4559,14 +4687,17 @@ pub(crate) fn read_plan_session_with_recovery_locked( "plan session previous 提升", )?; sync_planning_parent(primary_path.parent().expect("session has parent"))?; - let promoted = - read_optional_plan_session_file(&primary_path, "提升后的 plan session primary")? - .ok_or_else(|| { - PlanningStorageError::new( - "PLAN_RECONCILIATION_REQUIRED", - "plan session previous 提升后 primary 缺失", - ) - })?; + let promoted = read_optional_plan_session_file( + root, + &primary_path, + "提升后的 plan session primary", + )? + .ok_or_else(|| { + PlanningStorageError::new( + "PLAN_RECONCILIATION_REQUIRED", + "plan session previous 提升后 primary 缺失", + ) + })?; if promoted != previous { return Err(PlanningStorageError::new( "PLAN_RECONCILIATION_REQUIRED", @@ -4577,22 +4708,28 @@ pub(crate) fn read_plan_session_with_recovery_locked( } (Some(primary_value), Some(previous_value)) => { if primary_value == previous_value { - let current_primary = - read_optional_plan_session_file(&primary_path, "锁内 plan session primary")? - .ok_or_else(|| { - PlanningStorageError::new( - "PLAN_RECONCILIATION_REQUIRED", - "清理 session previous 时 primary 缺失", - ) - })?; - let current_previous = - read_optional_plan_session_file(&previous_path, "锁内 plan session previous")? - .ok_or_else(|| { - PlanningStorageError::new( - "PLAN_RECONCILIATION_REQUIRED", - "清理 session previous 时 recovery 文件缺失", - ) - })?; + let current_primary = read_optional_plan_session_file( + root, + &primary_path, + "锁内 plan session primary", + )? + .ok_or_else(|| { + PlanningStorageError::new( + "PLAN_RECONCILIATION_REQUIRED", + "清理 session previous 时 primary 缺失", + ) + })?; + let current_previous = read_optional_plan_session_file( + root, + &previous_path, + "锁内 plan session previous", + )? + .ok_or_else(|| { + PlanningStorageError::new( + "PLAN_RECONCILIATION_REQUIRED", + "清理 session previous 时 recovery 文件缺失", + ) + })?; if current_primary != primary_value || current_previous != previous_value { return Err(conflict("清理 session previous 前文件发生身份漂移")); } @@ -4603,21 +4740,24 @@ pub(crate) fn read_plan_session_with_recovery_locked( } validate_plan_session_successor(&previous_value, &primary_value)?; let current_primary = - read_optional_plan_session_file(&primary_path, "锁内 plan session primary")? + read_optional_plan_session_file(root, &primary_path, "锁内 plan session primary")? .ok_or_else(|| { PlanningStorageError::new( "PLAN_RECONCILIATION_REQUIRED", "清理 session previous 时 primary 缺失", ) })?; - let current_previous = - read_optional_plan_session_file(&previous_path, "锁内 plan session previous")? - .ok_or_else(|| { - PlanningStorageError::new( - "PLAN_RECONCILIATION_REQUIRED", - "清理 session previous 时 recovery 文件缺失", - ) - })?; + let current_previous = read_optional_plan_session_file( + root, + &previous_path, + "锁内 plan session previous", + )? + .ok_or_else(|| { + PlanningStorageError::new( + "PLAN_RECONCILIATION_REQUIRED", + "清理 session previous 时 recovery 文件缺失", + ) + })?; validate_plan_session_successor(¤t_previous, ¤t_primary)?; fs::remove_file(&previous_path) .map_err(|error| io_error("清理 plan session previous 失败", error))?; @@ -5190,6 +5330,8 @@ mod tests { #[test] fn session_applied_answers_bind_unique_round_and_continuation() { + let directory = tempfile::tempdir().expect("temp root"); + let root = directory.path(); let mut session = golden_session(); session.phase = "awaiting_user_input".to_string(); session.active_run_id = None; @@ -5230,6 +5372,25 @@ mod tests { .clone(); session.session_fingerprint = plan_session_fingerprint(&session).expect("answer fp"); validate_plan_session(&session).expect("valid applied answer"); + + let mut next_question = session.clone(); + next_question.latest_delegation_id = "delegation-next-question-002".to_string(); + next_question.session_fingerprint = + plan_session_fingerprint(&next_question).expect("next question fp"); + validate_plan_session(&next_question) + .expect("awaiting_user_input may anchor the new question delivery"); + + let mut forged_collecting = next_question.clone(); + forged_collecting.phase = "collecting".to_string(); + forged_collecting.session_fingerprint = + plan_session_fingerprint(&forged_collecting).expect("recompute forged fingerprint"); + assert_eq!( + write_plan_session_atomic(root, &forged_collecting) + .expect_err("runtime boundary must reject an unrelated latest delegation") + .code(), + "PLAN_NEEDS_RECONCILIATION" + ); + let mut duplicate = session.clone(); duplicate .applied_answers @@ -5238,6 +5399,101 @@ mod tests { assert!(validate_plan_session(&duplicate).is_err()); } + #[test] + fn session_runtime_lineage_rejects_mixed_quality_repair_with_recomputed_fingerprint() { + let directory = tempfile::tempdir().expect("temp root"); + let root = directory.path(); + let mut session = golden_session(); + session.decisions_summary.push(PlanDecisionSummary { + id: "route-replay".to_string(), + topic: "路线重玩".to_string(), + state: "confirmed".to_string(), + answer_source: "user_option".to_string(), + round: 1, + answer_summary: "验证分支是否驱动重玩".to_string(), + }); + let questions_sha256 = "a".repeat(64); + let answers_sha256 = "b".repeat(64); + let question_delegation_id = "delegation-question-mixed-001".to_string(); + let continuation_id = derive_plan_continuation_delegation_id( + &session.root_run_id, + &question_delegation_id, + &questions_sha256, + &answers_sha256, + ) + .expect("continuation id"); + session.applied_answers.push(PlanAppliedAnswer { + delegation_id: question_delegation_id, + continuation_delegation_id: continuation_id.clone(), + request_id: "request-question-mixed-001".to_string(), + question_id: "route_replay".to_string(), + response_id: "app-user-input-mixed-001".to_string(), + questions_sha256, + answers_sha256, + decision_id: "route-replay".to_string(), + round: 1, + }); + + let quality_repair_id = "delegation-quality-repair-mixed-002"; + let latest_id = "delegation-latest-mixed-003"; + let build_delivery = + |delegation_id: &str, + repair_of: Option<&str>, + contract_status: crate::delegation::StaticDelegateContractStatus| { + let mut delivery = crate::delegation::new_static_delegate_delivery_with_contract( + &session.root_agent_id, + "session-golden-parent-001", + &session.root_run_id, + &format!("{delegation_id}-action"), + delegation_id, + &session.agent_id, + &session.session_id, + &format!("{delegation_id}-run"), + &[], + &[], + repair_of, + ); + let mut result = crate::delegation::StaticDelegateStructuredResult::default(); + result.contract_status = contract_status; + delivery.status = crate::delegation::StaticDelegateDeliveryStatus::ClaimedByParent; + delivery.terminal_status = Some("completed".to_string()); + delivery.result_summary = Some("planning lineage test".to_string()); + delivery.structured_result = Some(result); + delivery.claimed_by_action_id = Some(format!("{delegation_id}-claim")); + delivery + }; + let continuation = build_delivery( + &continuation_id, + None, + crate::delegation::StaticDelegateContractStatus::UserRevisionRequested, + ); + let quality_repair = build_delivery( + quality_repair_id, + Some(&continuation_id), + crate::delegation::StaticDelegateContractStatus::NeedsRepair, + ); + let latest = build_delivery( + latest_id, + Some(quality_repair_id), + crate::delegation::StaticDelegateContractStatus::UserRevisionRequested, + ); + for delivery in [&continuation, &quality_repair, &latest] { + crate::delegation::write_static_delegate_delivery_at(root, delivery) + .expect("write mixed lineage delivery"); + } + + session.latest_delegation_id = latest_id.to_string(); + session.session_fingerprint = + plan_session_fingerprint(&session).expect("recompute forged fingerprint"); + validate_plan_session(&session).expect("standalone session shape remains valid"); + assert_eq!( + write_plan_session_atomic(root, &session) + .expect_err("quality repair edge must clear old applied answers") + .code(), + "PLAN_IDENTITY_CONFLICT" + ); + } + #[test] fn session_recovery_rejects_corrupt_primary_and_forked_previous() { let directory = tempfile::tempdir().expect("temp root"); 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 07f17d13a..1ccbff9dc 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 @@ -3674,6 +3674,191 @@ mod tests { } } + #[tokio::test] + async fn approval_receipt_consumes_submit_batch_and_folds_final_provider_usage_once() { + let (root, mut context, input) = submit_fixture(); + let mut child_runtime = start_game_creator_agent_runtime_task_at( + &root, + GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + "提交 Fast GDD", + &context.created_by_run_id, + "agent-delegate", + "提交一份可审批的 Fast GDD", + vec!["读取项目事实".to_string(), "提交 GDD".to_string()], + ) + .expect("start planning child task"); + child_runtime.session_id = context.session_id.clone(); + child_runtime.parent_agent_id = Some(context.root_agent_id.clone()); + child_runtime.parent_run_id = Some(context.root_run_id.clone()); + child_runtime.delegation_id = Some(context.delegation_id.clone()); + child_runtime.run_profile_binding_fingerprint = + context.run_profile_binding_fingerprint.clone(); + append_game_creator_agent_runtime_task(&root, &child_runtime) + .expect("persist planning child identity"); + write_game_creator_agent_runtime_state(&root, &child_runtime) + .expect("persist current planning child identity"); + + let mut snapshot = AgentRuntimeProviderRequestSnapshot { + project_id: context.project_id.clone(), + agent_id: child_runtime.agent_id.clone(), + task_id: child_runtime.task_id.clone(), + session_id: child_runtime.session_id.clone(), + run_id: child_runtime.run_id.clone(), + source: child_runtime.source.clone(), + goal_id: child_runtime.goal_id.clone(), + goal_revision: child_runtime.goal_revision, + goal_snapshot_fingerprint: agent_goal_snapshot_fingerprint_for_state_at( + &root, + &child_runtime, + ) + .expect("planning goal snapshot fingerprint"), + applied_steer_cursor: child_runtime.applied_steer_cursor, + request_kind: "tool-plan".to_string(), + request_slot: "loop-1-repair-0".to_string(), + web_search_enabled: false, + allow_idle_context_compaction: false, + planning_session_binding: None, + }; + let binding = capture_plan_provider_session_binding_for_snapshot( + &root, + &child_runtime, + &snapshot, + &format!("sha256-serde-json-v2:{}", "7".repeat(64)), + ) + .expect("capture submit Provider binding"); + snapshot.planning_session_binding = Some(binding.clone()); + let request_id = game_creator_agent_runtime_provider_request_id(&snapshot); + assert_eq!(request_id, binding.provider_request_id); + assert!( + append_game_creator_agent_runtime_provider_request_lifecycle( + &root, + &snapshot, + &request_id, + "started", + ) + .expect("append final submit request start") + ); + let usage_scope = + capture_plan_provider_usage_scope_at_locked(&root, &snapshot, &request_id) + .expect("capture final submit usage scope"); + assert!(persist_plan_provider_usage_fact_at( + &root, + &snapshot, + &request_id, + usage_scope.as_ref(), + "completed", + 23, + ) + .expect("persist final submit usage fact")); + assert!( + append_game_creator_agent_runtime_provider_request_lifecycle( + &root, + &snapshot, + &request_id, + "completed", + ) + .expect("append final submit request completion") + ); + + let plan = AgentRuntimeToolPlan { + thinking_summary: "Fast GDD 已收敛,提交审批".to_string(), + plan_update: None, + plan: vec!["提交 Fast GDD".to_string()], + actions: vec![AgentRuntimeToolAction { + tool: PLAN_SUBMIT_GDD_TOOL.to_string(), + reason: Some("提交当前策划版本".to_string()), + input: serde_json::to_value(&input).expect("serialize plan.submit_gdd input"), + }], + response: String::new(), + }; + let project_revision = + read_game_creator_agent_runtime_project_revision(&root).expect("read revision"); + let repository_fingerprint = build_repository_startup_context_at(&root) + .expect("build repository context") + .fingerprint; + let batch = + match prepare_game_creator_agent_runtime_provider_action_batch_with_planning_binding( + &root, + &child_runtime, + "提交一份可审批的 Fast GDD", + &plan, + &[], + &project_revision, + &repository_fingerprint, + Some(&binding), + ) + .await + .expect("prepare exact plan.submit_gdd batch") + { + AgentRuntimeProviderActionBatchPreparation::Ready(batch) => batch, + other => panic!("plan.submit_gdd batch must be ready: {other:?}"), + }; + let pending = batch.actions[0].clone(); + write_game_creator_agent_runtime_pending_tool_action(&root, &pending) + .expect("persist standalone submit anchor"); + context.action_id = pending.action_id.clone(); + context.action_fingerprint = pending.action_fingerprint.clone(); + + execute_plan_submit_gdd(&root, &context, &input).expect("commit GDD"); + assert_eq!( + fold_plan_provider_usage_into_session_at_locked(&root) + .expect("submit batch must defer final usage"), + PlanProviderUsageFoldOutcome::Deferred + ); + let mut child_completed = child_runtime.clone(); + child_completed.status = "completed".to_string(); + child_completed.phase = "completed".to_string(); + child_completed.current_action = "Fast GDD 已提交".to_string(); + child_completed.pending_tool_action = Some(pending.summary()); + append_game_creator_agent_runtime_task(&root, &child_completed) + .expect("append completed planning child task"); + write_game_creator_agent_runtime_state(&root, &child_completed) + .expect("persist completed planning child state"); + + let gdd = read_plan_gdd_chain(&root) + .expect("read submitted GDD") + .pop() + .expect("GDD exists"); + create_plan_gdd_approval_pending_at(&root, &gdd).expect("create approval pending"); + let decision_input = approval_input( + &gdd, + "approve", + "gdd-response-00000000-0000-4000-8000-000000000022", + None, + ); + let first = decide_plan_gdd_at(&root, &decision_input).expect("commit approval receipt"); + assert_eq!(first.outcome, "committed"); + assert!(!first.recovery_pending); + assert!(!game_creator_agent_runtime_pending_tool_action_exists( + &root, + GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + &context.created_by_run_id, + )); + assert!(!game_creator_agent_runtime_provider_action_batch_exists( + &root, + GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + &context.created_by_run_id, + )); + let folded = read_plan_session_with_recovery(&root) + .expect("read folded receipt session") + .expect("receipt session exists"); + assert_eq!(folded.phase, "approved"); + assert_eq!(folded.accumulated_agent_millis, 23); + let folded_revision = folded.session_revision; + + let replay = decide_plan_gdd_at(&root, &decision_input).expect("replay approval decision"); + assert_eq!(replay.outcome, "replayed"); + assert!(!replay.recovery_pending); + assert!(!reconcile_plan_gdd_approval_projections_at(&root) + .expect("replay receipt recovery projections")); + let replayed = read_plan_session_with_recovery(&root) + .expect("read replayed receipt session") + .expect("replayed receipt session exists"); + assert_eq!(replayed.session_revision, folded_revision); + assert_eq!(replayed.accumulated_agent_millis, 23); + cleanup_fixture(root); + } + #[test] fn projection_failure_after_gdd_create_returns_recovery_pending_and_replays() { let (root, context, input) = submit_fixture(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/real_e2e_checkpoint.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/real_e2e_checkpoint.rs index b1c87f8a9..5f96e2c24 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/real_e2e_checkpoint.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/real_e2e_checkpoint.rs @@ -1180,20 +1180,91 @@ where return Err(error); } } + let plan_usage_scope = match capture_plan_provider_usage_scope_at_locked( + root, + &snapshot, + &request_id, + ) { + Ok(scope) => scope, + Err(error) => { + let _ = mark_game_creator_agent_runtime_provider_request_needs_reconciliation_at_locked( + root, + &snapshot, + &request_id, + ); + unregister_game_creator_agent_runtime_provider_request(&key, &active); + return Err(format!( + "{AGENT_RUNTIME_PROVIDER_REQUEST_RECONCILIATION_PREFIX}: requestId={request_id} · planningUsageScope={error}" + )); + } + }; drop(control_lock); let notified = active.notify.notified(); tokio::pin!(notified); tokio::pin!(request); - let result = if active.interrupted.load(Ordering::Acquire) { - Ok(None) + let (result, active_millis) = if active.interrupted.load(Ordering::Acquire) { + (Ok(None), 0) } else { - tokio::select! { + let active_started = tokio::time::Instant::now(); + let result = tokio::select! { biased; _ = &mut notified => Ok(None), result = &mut request => result.map(Some), - } + }; + let elapsed = active_started.elapsed().as_millis(); + let active_millis = match u64::try_from(elapsed) { + Ok(value) => value, + Err(_) => { + unregister_game_creator_agent_runtime_provider_request(&key, &active); + if let Ok(_control_lock) = + acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "runtime.provider_request.planning_usage_overflow_reconciliation", + ) + { + let _ = + mark_game_creator_agent_runtime_provider_request_needs_reconciliation_at_locked( + root, + &snapshot, + &request_id, + ); + } + return Err(format!( + "{AGENT_RUNTIME_PROVIDER_REQUEST_RECONCILIATION_PREFIX}: requestId={request_id} · planningUsage=activeMillis overflow" + )); + } + }; + (result, active_millis) }; let result = result.map_err(|error| redact_agent_runtime_error(root, &error, 500)); + let usage_outcome = match &result { + Ok(Some(_)) => "completed", + Ok(None) => "interrupted", + Err(_) => "failed", + }; + if let Err(error) = persist_plan_provider_usage_fact_at( + root, + &snapshot, + &request_id, + plan_usage_scope.as_ref(), + usage_outcome, + active_millis, + ) { + unregister_game_creator_agent_runtime_provider_request(&key, &active); + if let Ok(_control_lock) = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "runtime.provider_request.planning_usage_reconciliation", + ) { + let _ = mark_game_creator_agent_runtime_provider_request_needs_reconciliation_at_locked( + root, + &snapshot, + &request_id, + ); + } + return Err(format!( + "{AGENT_RUNTIME_PROVIDER_REQUEST_RECONCILIATION_PREFIX}: requestId={request_id} · planningUsage={error}" + )); + } if let Ok(Some(response)) = result.as_ref() { if let Err(error) = success_commit(&request_id, response) { unregister_game_creator_agent_runtime_provider_request(&key, &active); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/run_configuration.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/run_configuration.rs index d1ea7b777..dca6c2e36 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/run_configuration.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/run_configuration.rs @@ -132,7 +132,7 @@ pub(in crate::agent) fn validate_agent_runtime_run_profile_binding_record( /// source string alone. The durable binding must describe a top-level /// `project-supervisor-plan` standard run whose root fields point back to itself /// and which has no parent link. -pub(in crate::agent) fn validate_project_supervisor_plan_root_binding_at( +pub(crate) fn validate_project_supervisor_plan_root_binding_at( root: &Path, agent_id: &str, run_id: &str, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools.rs index c8327c490..a40e23674 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools.rs @@ -35,7 +35,9 @@ pub(in crate::agent) use run_status::*; pub(in crate::agent) use task_ops::*; #[cfg(test)] -pub(crate) use delivery::build_static_delegate_result_for_child_at; +pub(crate) use delivery::{ + build_static_delegate_result_for_child_at, wake_waiting_static_delegate_parent_run_for_test_at, +}; #[cfg(test)] pub(crate) use media::validate_agent_runtime_canvas_replacement_authorization_at; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delegation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delegation.rs index 7897149e6..26ba39782 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delegation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delegation.rs @@ -414,6 +414,49 @@ pub(crate) fn observe_agent_runtime_agent_delegate( action_id: Option<&str>, input: &serde_json::Value, ) -> AgentRuntimeToolObservation { + let project_write_lock = match acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "runtime.snapshot.agent.delegate.direct", + ) { + Ok(lock) => lock, + Err(error) => { + return AgentRuntimeToolObservation { + tool: "agent.delegate".to_string(), + status: "failed".to_string(), + summary: "无法取得一致项目快照".to_string(), + detail: Some(redact_agent_runtime_project_paths(root, &error, 500)), + }; + } + }; + observe_agent_runtime_agent_delegate_at_locked( + root, + agent_id, + parent_run_id, + action_id, + input, + &project_write_lock, + ) +} + +pub(crate) fn observe_agent_runtime_agent_delegate_at_locked( + root: &Path, + agent_id: &str, + parent_run_id: &str, + action_id: Option<&str>, + input: &serde_json::Value, + project_write_lock: &ProjectWriteLock, +) -> AgentRuntimeToolObservation { + if !project_write_lock + .guards_project_root(root) + .unwrap_or(false) + { + return AgentRuntimeToolObservation { + tool: "agent.delegate".to_string(), + status: "failed".to_string(), + summary: "agent.delegate 缺少当前项目写锁".to_string(), + detail: None, + }; + } let target_agent_id = agent_runtime_tool_input_text(input, &["agentId", "targetAgentId"]); let target_agent_id = match normalize_game_creator_runtime_agent_id(target_agent_id.as_str()) { Ok(target_agent_id) => target_agent_id, @@ -1106,7 +1149,7 @@ pub(crate) fn observe_agent_runtime_agent_delegate( if parent_session_id.is_some() { drop(dispatch_lock.take()); } - match start_game_creator_agent_background_task_with_link_at( + match start_game_creator_agent_background_task_with_link_locked_at( root, &target_agent_id, requested_session_id, @@ -1115,6 +1158,7 @@ pub(crate) fn observe_agent_runtime_agent_delegate( "agent-delegate", None, Some(&task_link), + project_write_lock, ) { Ok((runtime, delegated_run_id)) => { if let Some((_, expected_run_id)) = target_session_id.as_ref() { 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 1a63dcad1..92f338623 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 @@ -294,6 +294,17 @@ pub(in crate::agent) fn wake_waiting_static_delegate_parent_run_at( )?; return Ok(true); } + // Parent wake follows project -> execution ordering. Do not hold the + // Supervisor execution lane while the planning/session projection reads + // or writes the project lock. + let project_lock = match acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "planning.parent-wake", + ) { + Ok(lock) => lock, + Err(error) if static_delegate_parent_wake_error_is_transient(&error) => return Ok(false), + Err(error) => return Err(error), + }; let Some(runtime_lock) = try_acquire_game_creator_agent_runtime_task_lock(root, &parent_task.agent_id)? else { @@ -342,7 +353,18 @@ pub(in crate::agent) fn wake_waiting_static_delegate_parent_run_at( ¤t_task.run_id, )?; let mut state = state; - ensure_static_delegate_user_input_wait_at(root, &mut state, &deliveries)?; + if !ensure_static_delegate_user_input_wait_at_locked( + root, + &mut state, + &deliveries, + &project_lock, + )? { + // The barrier and delivery snapshot changed between reads. Keep + // the parent in its durable receipt-wait state and let the bounded + // parent-wake loop retry; claiming success here would strand the + // run without either a pending card or another wake. + return Ok(false); + } return Ok(true); } let state = advance_game_creator_agent_runtime_turn_at( @@ -352,6 +374,7 @@ pub(in crate::agent) fn wake_waiting_static_delegate_parent_run_at( "专业 Agent 已完成,父 run 正在认领委派回执", "静态委派回执已就绪,恢复同一父 run。", )?; + drop(project_lock); let root = root.to_path_buf(); let agent_id = current_task.agent_id.clone(); let task = current_task.task.clone(); @@ -362,6 +385,14 @@ pub(in crate::agent) fn wake_waiting_static_delegate_parent_run_at( Ok(true) } +#[cfg(test)] +pub(crate) fn wake_waiting_static_delegate_parent_run_for_test_at( + root: &Path, + parent_task: &AgentRuntimeTaskRecord, +) -> Result { + wake_waiting_static_delegate_parent_run_at(root, parent_task) +} + pub(in crate::agent) fn wake_waiting_autonomous_manifest_parent_run_at( root: &Path, parent_task: &AgentRuntimeTaskRecord, 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 5cb177a76..58719777c 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 @@ -7,6 +7,7 @@ use super::filesystem::PROJECT_FILE_FLAG_OPEN_REPARSE_POINT; const AGENT_DB_MAX_RECORD_BYTES: usize = 1024 * 1024; const AGENT_DB_ACTION_RECEIPT_RECORD_TYPE: &str = "agent.runtime.action_receipt"; const AGENT_DB_PLAN_GDD_DECISION_RECORD_TYPE: &str = "agent.runtime.plan.gdd_decided"; +const AGENT_DB_PLAN_PROVIDER_USAGE_RECORD_TYPE: &str = "agent.runtime.plan.provider_usage"; const AGENT_DB_PLAN_GDD_DECISION_AUDIT_SCHEMA_V1: &str = "agent-runtime-plan-gdd-decided.v1"; const AGENT_DB_PROVIDER_REQUEST_LIFECYCLE_RECORD_TYPE: &str = "agent.runtime.provider_request.lifecycle"; @@ -87,6 +88,9 @@ pub(super) fn agent_db_record_append_class(record: &serde_json::Value) -> AgentD { return AgentDbRecordAppendClass::LifecycleTerminal; } + if record_type == Some(AGENT_DB_PLAN_PROVIDER_USAGE_RECORD_TYPE) { + return AgentDbRecordAppendClass::LifecycleTerminal; + } AgentDbRecordAppendClass::Ordinary } @@ -975,6 +979,9 @@ pub(crate) fn append_agent_db_record(root: &Path, record: serde_json::Value) -> Some(AGENT_DB_PLAN_GDD_DECISION_RECORD_TYPE) => { return Err("Agent DB planning decision 必须使用专用幂等追加入口".to_string()) } + Some(AGENT_DB_PLAN_PROVIDER_USAGE_RECORD_TYPE) => { + return Err("Agent DB planning Provider usage 必须使用专用幂等追加入口".to_string()) + } Some( AGENT_DB_PROVIDER_REQUEST_LIFECYCLE_RECORD_TYPE | AGENT_DB_FINALIZATION_LIFECYCLE_RECORD_TYPE, @@ -1098,6 +1105,7 @@ fn validate_agent_db_append_class_record_size( Some( AGENT_DB_PROVIDER_REQUEST_LIFECYCLE_RECORD_TYPE | AGENT_DB_FINALIZATION_LIFECYCLE_RECORD_TYPE + | AGENT_DB_PLAN_PROVIDER_USAGE_RECORD_TYPE ) ) && line.len() > AGENT_DB_LIFECYCLE_TERMINAL_MAX_RECORD_BYTES { @@ -1174,6 +1182,260 @@ pub(crate) fn append_agent_db_lifecycle_record_idempotent( Ok(true) } +fn validate_agent_db_plan_provider_usage_record( + record: &serde_json::Value, + stored: bool, +) -> Result<(), String> { + const FIELDS: &[&str] = &[ + "recordType", + "usageSchemaVersion", + "projectId", + "rootAgentId", + "rootRunId", + "rootRunProfileBindingFingerprint", + "agentId", + "taskId", + "sessionId", + "runId", + "source", + "requestId", + "requestKind", + "requestSlot", + "webSearchEnabled", + "planningSessionBinding", + "outcome", + "activeMillis", + ]; + let object = record + .as_object() + .ok_or_else(|| "Agent DB planning Provider usage 必须是 object".to_string())?; + let expected_len = FIELDS.len().saturating_add(if stored { 2 } else { 0 }); + if object.len() != expected_len + || FIELDS.iter().any(|field| !object.contains_key(*field)) + || (stored && (!object.contains_key("schemaVersion") || !object.contains_key("updatedAt"))) + || (!stored && (object.contains_key("schemaVersion") || object.contains_key("updatedAt"))) + { + return Err("Agent DB planning Provider usage 字段集合无效".to_string()); + } + if object.get("recordType").and_then(serde_json::Value::as_str) + != Some(AGENT_DB_PLAN_PROVIDER_USAGE_RECORD_TYPE) + || object + .get("usageSchemaVersion") + .and_then(serde_json::Value::as_str) + != Some("plan-provider-usage.v1") + { + return Err("Agent DB planning Provider usage schema 无效".to_string()); + } + if stored + && (object + .get("schemaVersion") + .and_then(serde_json::Value::as_str) + != Some(GAME_CREATOR_AGENT_DB_SCHEMA_VERSION) + || object + .get("updatedAt") + .and_then(serde_json::Value::as_u64) + .is_none_or(|value| value == 0)) + { + return Err("Agent DB planning Provider usage 持久化 envelope 无效".to_string()); + } + for field in [ + "projectId", + "rootAgentId", + "rootRunId", + "agentId", + "taskId", + "sessionId", + "runId", + "source", + "requestSlot", + ] { + if object + .get(field) + .and_then(serde_json::Value::as_str) + .is_none_or(|value| !is_safe_agent_db_lifecycle_identity(value)) + { + return Err(format!( + "Agent DB planning Provider usage 字段安全形状无效:{field}" + )); + } + } + if object + .get("requestId") + .and_then(serde_json::Value::as_str) + .is_none_or(|value| !is_valid_agent_db_provider_request_id(value)) + { + return Err("Agent DB planning Provider usage requestId 无效".to_string()); + } + if object + .get("rootRunProfileBindingFingerprint") + .and_then(serde_json::Value::as_str) + .is_none_or(|value| !is_valid_agent_db_sha256(value)) + { + return Err("Agent DB planning Provider usage root binding fingerprint 无效".to_string()); + } + if !matches!( + object + .get("requestKind") + .and_then(serde_json::Value::as_str), + Some("tool-plan" | "final-reply" | "context-compaction" | "final-reply-context-compaction") + ) { + return Err("Agent DB planning Provider usage requestKind 无效".to_string()); + } + if object + .get("webSearchEnabled") + .and_then(serde_json::Value::as_bool) + .is_none() + { + return Err("Agent DB planning Provider usage webSearchEnabled 无效".to_string()); + } + if !matches!( + object.get("outcome").and_then(serde_json::Value::as_str), + Some("completed" | "failed" | "interrupted") + ) || object + .get("activeMillis") + .and_then(serde_json::Value::as_u64) + .is_none() + || !matches!( + object.get("planningSessionBinding"), + Some(serde_json::Value::Null | serde_json::Value::Object(_)) + ) + { + return Err("Agent DB planning Provider usage terminal payload 无效".to_string()); + } + Ok(()) +} + +fn scan_agent_db_plan_provider_usage_records_unlocked( + file: &mut File, + path: &Path, +) -> Result, String> { + let length = file + .metadata() + .map_err(|error| format!("读取 Agent 本地索引元数据失败:{}: {error}", path.display()))? + .len(); + if length > AGENT_DB_MAX_ACTION_RECEIPT_SCAN_BYTES { + return Err(format!( + "Agent 本地索引超过 {} 字节 planning Provider usage 扫描上限:{}", + AGENT_DB_MAX_ACTION_RECEIPT_SCAN_BYTES, + path.display() + )); + } + file.seek(SeekFrom::Start(0)) + .map_err(|error| format!("定位 Agent 本地索引失败:{}: {error}", path.display()))?; + let mut reader = BufReader::new(file); + let mut record_count = 0_usize; + let mut usage_records = Vec::new(); + while let Some(line) = read_agent_db_jsonl_line_bounded(&mut reader, path)? { + if !line.complete { + return Err(format!( + "Agent 本地索引 planning Provider usage 扫描发现不完整 JSONL 尾记录:{}", + path.display() + )); + } + if line.content.iter().all(|byte| byte.is_ascii_whitespace()) { + continue; + } + record_count = record_count.saturating_add(1); + if record_count > AGENT_DB_MAX_SCAN_RECORDS { + return Err(format!( + "Agent 本地索引超过 {} 条 planning Provider usage 扫描上限:{}", + AGENT_DB_MAX_SCAN_RECORDS, + path.display() + )); + } + let record = serde_json::from_slice::(&line.content) + .map_err(|error| format!("解析 Agent 本地索引失败:{}: {error}", path.display()))?; + if record.get("recordType").and_then(serde_json::Value::as_str) + != Some(AGENT_DB_PLAN_PROVIDER_USAGE_RECORD_TYPE) + { + continue; + } + validate_agent_db_plan_provider_usage_record(&record, true)?; + usage_records.push(record); + } + Ok(usage_records) +} + +pub(crate) fn append_agent_db_plan_provider_usage_idempotent( + root: &Path, + record: serde_json::Value, +) -> Result { + validate_agent_db_plan_provider_usage_record(&record, false)?; + let request_id = record + .get("requestId") + .and_then(serde_json::Value::as_str) + .expect("validated planning Provider usage requestId") + .to_string(); + #[cfg(test)] + take_agent_db_record_failure_injection(root, Some(AGENT_DB_PLAN_PROVIDER_USAGE_RECORD_TYPE))?; + + let path = root.join(".agent/agent.db"); + let directory = open_agent_db_directory(root, true)? + .ok_or_else(|| "创建项目 .agent 目录失败".to_string())?; + let append_lock = project_append_lock_for(&path)?; + let _append_guard = append_lock.lock_process("Agent 本地索引")?; + verify_agent_db_directory_current(&directory)?; + let mut storage = open_agent_db_storage(directory, true, true)? + .ok_or_else(|| "创建 Agent 本地索引失败".to_string())?; + verify_agent_db_storage_current(&storage)?; + repair_truncated_jsonl_tail_unlocked(&mut storage.file, &storage.path, "Agent 本地索引")?; + verify_agent_db_storage_current(&storage)?; + + let mut matches = 0_usize; + for existing in + scan_agent_db_plan_provider_usage_records_unlocked(&mut storage.file, &storage.path)? + { + if existing + .get("requestId") + .and_then(serde_json::Value::as_str) + != Some(request_id.as_str()) + { + continue; + } + if !agent_db_stored_record_matches_expected_payload(&existing, &record) { + return Err(format!( + "Agent DB planning Provider usage 同 requestId 内容冲突:{request_id}" + )); + } + matches = matches.saturating_add(1); + if matches > 1 { + return Err(format!( + "Agent DB planning Provider usage 同 requestId 存在重复事实:{request_id}" + )); + } + } + if matches == 1 { + return Ok(false); + } + let line = serialize_agent_db_record(record)?; + validate_agent_db_append_class_record_size(AgentDbRecordAppendClass::LifecycleTerminal, &line)?; + append_agent_db_classified_line_unlocked( + &mut storage, + &line, + AgentDbRecordAppendClass::LifecycleTerminal, + )?; + Ok(true) +} + +pub(crate) fn read_agent_db_plan_provider_usage_records_at( + root: &Path, +) -> Result, String> { + let path = root.join(".agent/agent.db"); + let Some(directory) = open_agent_db_directory(root, false)? else { + return Ok(Vec::new()); + }; + let append_lock = project_append_lock_for(&path)?; + let _append_guard = append_lock.lock_process("Agent 本地索引 planning Provider usage 查询")?; + verify_agent_db_directory_current(&directory)?; + let Some(mut storage) = open_agent_db_storage(directory, true, false)? else { + return Ok(Vec::new()); + }; + verify_agent_db_storage_current(&storage)?; + repair_truncated_jsonl_tail_unlocked(&mut storage.file, &storage.path, "Agent 本地索引")?; + verify_agent_db_storage_current(&storage)?; + scan_agent_db_plan_provider_usage_records_unlocked(&mut storage.file, &storage.path) +} + fn validate_agent_db_lifecycle_record_input<'a>( identity_field: &str, identity_value: &str, @@ -3534,6 +3796,48 @@ pub(crate) fn read_agent_db_lifecycle_transitions_at( .unwrap_or_default()) } +pub(crate) fn read_agent_db_lifecycle_transitions_matching_at( + root: &Path, + record_type: &str, + identity_field: &str, + identity_value: &str, + expected_identity: &serde_json::Value, +) -> Result, String> { + let (expected_identity_field, _) = agent_db_lifecycle_key_fields(record_type)?; + if identity_field != expected_identity_field + || (record_type == AGENT_DB_PROVIDER_REQUEST_LIFECYCLE_RECORD_TYPE + && !is_valid_agent_db_provider_request_id(identity_value)) + || (record_type == AGENT_DB_FINALIZATION_LIFECYCLE_RECORD_TYPE + && !is_valid_agent_db_finalization_id(identity_value)) + { + return Err("Agent DB lifecycle 查询身份或 recordType 不受支持".to_string()); + } + validate_agent_db_lifecycle_record_semantics(record_type, expected_identity, false)?; + let path = root.join(".agent/agent.db"); + let Some(directory) = open_agent_db_directory(root, false)? else { + return Ok(Vec::new()); + }; + let append_lock = project_append_lock_for(&path)?; + let _append_guard = append_lock.lock_process("Agent 本地索引 lifecycle identity 查询")?; + verify_agent_db_directory_current(&directory)?; + let Some(mut storage) = open_agent_db_storage(directory, false, false)? else { + return Ok(Vec::new()); + }; + verify_agent_db_storage_current(&storage)?; + let scan = + scan_agent_db_lifecycle_records_unlocked(&mut storage.file, &storage.path, record_type)?; + verify_agent_db_storage_current(&storage)?; + let Some(sequence) = scan.sequences.get(identity_value) else { + return Ok(Vec::new()); + }; + validate_agent_db_lifecycle_record_identity( + &sequence.identity_record, + expected_identity, + record_type, + )?; + Ok(sequence.transitions_in_physical_order.clone()) +} + pub(crate) fn read_agent_db_incomplete_provider_request_ids_at( root: &Path, agent_id: &str, diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/static_deliveries.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/static_deliveries.rs index 03b38f715..347c6d5b3 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/static_deliveries.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/static_deliveries.rs @@ -468,6 +468,358 @@ fn drive_static_delegate_clarification_round_expect_rejection( observation } +struct PlanningClarificationFixture { + root: PathBuf, + supervisor: AgentRuntimeState, + current_delivery: StaticDelegateDeliveryRecord, + planning_lock: AgentRuntimeTaskLock, + acceptance_criteria: Vec, + expected_artifacts: Vec, +} + +struct AnsweredPlanningClarification { + original_delivery: StaticDelegateDeliveryRecord, + question_id: String, + response_id: String, + questions_sha256: String, + answers_sha256: String, + awaiting_session: PlanSessionV1, +} + +fn planning_clarification_question_body(round: u32) -> (String, String) { + let question_id = format!("round_{round}_decision"); + let body = serde_json::json!({ + "questions": [{ + "id": question_id, + "header": format!("第{round}轮·关键决定"), + "question": format!("当前要决定:第{round}轮核心取舍。现在确认后才能继续收敛 Fast GDD。"), + "options": [ + { + "label": "接受推荐", + "description": "采用当前推荐方案继续收敛。" + }, + { + "label": "暂按推荐", + "description": "先按推荐推进,后续仍可调整。" + }, + { + "label": "需要原型验证", + "description": "用小型原型验证后再定稿。" + } + ] + }] + }) + .to_string(); + (question_id, body) +} + +fn planning_clarification_fixture(tag: &str) -> PlanningClarificationFixture { + let root = unique_project_path(); + init_local_game_project_at( + &root, + &format!("project-planning-clarification-{tag}"), + "Fast GDD planning 澄清投影测试", + ) + .expect("project init"); + let root_run_id = format!("planning-clarification-{tag}-root-run"); + bind_game_creator_agent_runtime_run_profile_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &root_run_id, + AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_STANDARD), + None, + ) + .expect("bind planning root"); + let supervisor = start_game_creator_agent_runtime_task_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "做一款需要三轮关键决定的短局游戏", + &root_run_id, + AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE, + "委派 project-planning 收敛 Fast GDD", + vec!["取得可审批的 Fast GDD".to_string()], + ) + .expect("start planning root"); + let planning_lock = try_acquire_game_creator_agent_runtime_task_lock( + &root, + GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + ) + .expect("acquire planning target lane") + .expect("planning target lane available"); + let acceptance_criteria = vec!["形成可审批且保留用户决定来源的 Fast GDD".to_string()]; + let expected_artifacts = Vec::::new(); + let action_id = format!("planning-clarification-{tag}-initial-delegate"); + let observation = observe_agent_runtime_agent_delegate( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &root_run_id, + Some(&action_id), + &serde_json::json!({ + "agentId": GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + "task": "根据用户初始需求形成 Fast GDD", + "acceptanceCriteria": acceptance_criteria, + "expectedArtifacts": expected_artifacts, + "repairOfDelegationId": null, + "runId": null + }), + ); + assert_eq!(observation.status, "ok", "{observation:?}"); + let delegation_id = agent_runtime_delegation_id( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &root_run_id, + GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + &action_id, + ); + let current_delivery = read_static_delegate_delivery_at(&root, &delegation_id) + .expect("read initial planning delivery") + .expect("initial planning delivery exists"); + let initial_session = read_plan_session_with_recovery(&root) + .expect("read initial planning session") + .expect("initial planning session exists"); + assert_eq!(initial_session.session_revision, 1); + assert_eq!(initial_session.phase, "collecting"); + assert_eq!( + initial_session.active_run_id.as_deref(), + Some(current_delivery.target_run_id.as_str()) + ); + assert_eq!( + initial_session.latest_delegation_id, + current_delivery.delegation_id + ); + assert!(initial_session.applied_answers.is_empty()); + + let initial_task = read_latest_game_creator_agent_runtime_task_by_delegation_id( + &root, + GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + ¤t_delivery.delegation_id, + ) + .expect("read initial planning child task") + .expect("initial planning child task exists"); + assert!( + ensure_plan_session_for_planning_child_task_at(&root, &initial_task) + .expect("replay initial planning projection") + ); + assert_eq!( + read_plan_session_with_recovery(&root) + .expect("reread replayed initial session") + .expect("replayed initial session exists"), + initial_session, + "同一初始 child task 重放不得增加 session revision" + ); + + PlanningClarificationFixture { + root, + supervisor, + current_delivery, + planning_lock, + acceptance_criteria, + expected_artifacts, + } +} + +fn answer_planning_clarification_round( + fixture: &mut PlanningClarificationFixture, + round: u32, + answer: &str, + tag: &str, +) -> AnsweredPlanningClarification { + let original_delivery = fixture.current_delivery.clone(); + let (question_id, questions_body) = planning_clarification_question_body(round); + mark_and_claim_static_delegate_needs_user_input( + &fixture.root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &fixture.supervisor.run_id, + GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + &original_delivery.target_session_id, + &original_delivery.target_run_id, + &original_delivery.delegation_id, + &fixture.expected_artifacts, + &questions_body, + &format!("planning-{tag}-round-{round}-claim"), + ); + let deliveries = claimed_static_delegate_deliveries_at( + &fixture.root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &fixture.supervisor.run_id, + ) + .expect("read planning claimed deliveries"); + assert!(ensure_static_delegate_user_input_wait_at( + &fixture.root, + &mut fixture.supervisor, + &deliveries, + ) + .expect("project planning clarification wait")); + let first_pending = read_game_creator_agent_runtime_pending_tool_action( + &fixture.root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &fixture.supervisor.run_id, + ) + .expect("read first planning clarification pending"); + let awaiting_session = read_plan_session_with_recovery(&fixture.root) + .expect("read awaiting planning session") + .expect("awaiting planning session exists"); + assert_eq!(awaiting_session.phase, "awaiting_user_input"); + assert!(awaiting_session.active_run_id.is_none()); + assert_eq!( + awaiting_session.latest_delegation_id, + original_delivery.delegation_id + ); + assert_eq!(awaiting_session.applied_answers.len(), (round - 1) as usize); + + assert!(ensure_static_delegate_user_input_wait_at( + &fixture.root, + &mut fixture.supervisor, + &deliveries, + ) + .expect("replay planning clarification wait")); + let replayed_pending = read_game_creator_agent_runtime_pending_tool_action( + &fixture.root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &fixture.supervisor.run_id, + ) + .expect("read replayed planning clarification pending"); + assert_eq!(replayed_pending.action_id, first_pending.action_id); + assert_eq!( + read_plan_session_with_recovery(&fixture.root) + .expect("reread replayed awaiting session") + .expect("replayed awaiting session exists"), + awaiting_session, + "重复 wake 不得增加 planning session revision" + ); + + let response_id = format!("planning-{tag}-round-{round}-response"); + let supervisor_run_id = fixture.supervisor.run_id.clone(); + let (questions_sha256, answers_sha256) = answer_static_delegate_clarification_wait( + &fixture.root, + &mut fixture.supervisor, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &supervisor_run_id, + &original_delivery.delegation_id, + BTreeMap::from([(question_id.clone(), answer.to_string())]), + &response_id, + ); + let original_delivery = + read_static_delegate_delivery_at(&fixture.root, &original_delivery.delegation_id) + .expect("reread answered planning delivery") + .expect("answered planning delivery exists"); + assert_eq!( + original_delivery.clarification_answers_sha256.as_deref(), + Some(answers_sha256.as_str()) + ); + + AnsweredPlanningClarification { + original_delivery, + question_id, + response_id, + questions_sha256, + answers_sha256, + awaiting_session, + } +} + +fn dispatch_answered_planning_continuation( + fixture: &mut PlanningClarificationFixture, + answered: &AnsweredPlanningClarification, + tag: &str, +) -> StaticDelegateDeliveryRecord { + let continuation_id = clarification_continuation_delegation_id( + &fixture.root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &fixture.supervisor.run_id, + GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + &answered.original_delivery.delegation_id, + &answered.questions_sha256, + &answered.answers_sha256, + ); + let observation = dispatch_static_delegate_clarification_continuation( + &fixture.root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &fixture.supervisor.run_id, + GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + "根据用户回答继续收敛 Fast GDD", + &fixture.acceptance_criteria, + &fixture.expected_artifacts, + &answered.original_delivery.delegation_id, + &answered.questions_sha256, + &answered.answers_sha256, + &format!("planning-{tag}-continuation"), + ); + assert_eq!(observation.status, "ok", "{observation:?}"); + let continuation = read_static_delegate_delivery_at(&fixture.root, &continuation_id) + .expect("read planning continuation delivery") + .expect("planning continuation delivery exists"); + fixture.current_delivery = continuation.clone(); + continuation +} + +fn prepare_first_planning_clarification_wait( + fixture: &mut PlanningClarificationFixture, + tag: &str, +) -> ( + AgentRuntimePendingToolAction, + AgentRuntimeUserInputRequestView, + String, +) { + let current = fixture.current_delivery.clone(); + let (question_id, questions_body) = planning_clarification_question_body(1); + mark_and_claim_static_delegate_needs_user_input( + &fixture.root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &fixture.supervisor.run_id, + GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + ¤t.target_session_id, + ¤t.target_run_id, + ¤t.delegation_id, + &fixture.expected_artifacts, + &questions_body, + &format!("planning-{tag}-claim"), + ); + let deliveries = claimed_static_delegate_deliveries_at( + &fixture.root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &fixture.supervisor.run_id, + ) + .expect("read claimed planning clarification"); + assert!(ensure_static_delegate_user_input_wait_at( + &fixture.root, + &mut fixture.supervisor, + &deliveries, + ) + .expect("create planning clarification wait")); + let pending = read_game_creator_agent_runtime_pending_tool_action( + &fixture.root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &fixture.supervisor.run_id, + ) + .expect("read planning clarification pending"); + let request = match prepare_game_creator_agent_user_input_request_at(&fixture.root, &pending) + .expect("prepare planning clarification request") + { + AgentRuntimeUserInputRecovery::Waiting(request) => request, + other => panic!("unexpected planning clarification recovery: {other:?}"), + }; + (pending, request, question_id) +} + +fn planning_provider_lifecycle_count(root: &Path) -> usize { + read_agent_db_records_bounded(root, 1024 * 1024) + .expect("read Agent DB lifecycle records") + .0 + .iter() + .filter(|record| { + record.get("recordType").and_then(Value::as_str) + == Some("agent.runtime.provider_request.lifecycle") + }) + .count() +} + +fn cleanup_planning_clarification_fixture(fixture: PlanningClarificationFixture) { + let root = fixture.root.clone(); + drop(fixture.planning_lock); + fs::remove_dir_all(root).ok(); +} + /// 走真实 agent.delegate 路径派发一次不携带任何澄清字段的「质量返工」,不对结果做断言。 #[allow(clippy::too_many_arguments)] fn dispatch_static_delegate_plain_repair( @@ -3519,6 +3871,1069 @@ fn clarification_continuation_chain_supports_multiple_rounds() { fs::remove_dir_all(root).ok(); } +#[test] +fn planning_clarification_three_rounds_project_session_and_structured_injection() { + let mut fixture = planning_clarification_fixture("three-rounds"); + let expected_decisions = [ + ("接受推荐", "confirmed", "user_option"), + ("暂按推荐", "default_pending", "default"), + ("需要原型验证", "prototype_pending", "user_option"), + ]; + + for (index, (answer, expected_state, expected_source)) in + expected_decisions.into_iter().enumerate() + { + let round = u32::try_from(index + 1).expect("round fits u32"); + let answered = + answer_planning_clarification_round(&mut fixture, round, answer, "three-rounds"); + assert_eq!( + answered.awaiting_session.session_revision, + round.saturating_mul(2), + "每轮展示卡前应只增加一个 awaiting successor" + ); + let continuation = dispatch_answered_planning_continuation( + &mut fixture, + &answered, + &format!("three-rounds-{round}"), + ); + let session = read_plan_session_with_recovery(&fixture.root) + .expect("read collecting planning session") + .expect("collecting planning session exists"); + assert_eq!(session.session_revision, round.saturating_mul(2) + 1); + assert_eq!(session.phase, "collecting"); + assert_eq!( + session.active_run_id.as_deref(), + Some(continuation.target_run_id.as_str()) + ); + assert_eq!(session.last_run_id, continuation.target_run_id); + assert_eq!(session.latest_delegation_id, continuation.delegation_id); + assert_eq!(session.applied_answers.len(), round as usize); + validate_plan_session_for_clarification_round(&session, round) + .expect("session projection matches durable clarification lineage"); + + let projected_answer = session + .applied_answers + .last() + .expect("current round applied answer"); + let decision_id = answered.question_id.replace('_', "-"); + assert_eq!( + projected_answer.delegation_id, + answered.original_delivery.delegation_id + ); + assert_eq!( + projected_answer.continuation_delegation_id, + continuation.delegation_id + ); + assert_eq!( + projected_answer.request_id, + answered + .original_delivery + .clarification_request_id + .clone() + .expect("answered delivery request id") + ); + assert_eq!(projected_answer.question_id, answered.question_id); + assert_eq!(projected_answer.response_id, answered.response_id); + assert_eq!(projected_answer.questions_sha256, answered.questions_sha256); + assert_eq!(projected_answer.answers_sha256, answered.answers_sha256); + assert_eq!(projected_answer.decision_id, decision_id); + assert_eq!(projected_answer.round, round); + + let decision = session + .decisions_summary + .iter() + .find(|decision| decision.id == decision_id) + .expect("projected planning decision"); + assert_eq!(decision.state, expected_state); + assert_eq!(decision.answer_source, expected_source); + assert_eq!(decision.round, round); + if round == 3 { + assert!(session + .prototype_validation_items + .iter() + .any(|item| item.id == decision_id)); + } + } + + let final_session = read_plan_session_with_recovery(&fixture.root) + .expect("read final three-round session") + .expect("final three-round session exists"); + let injection = capture_plan_provider_structured_injections_at( + &fixture.root, + &fixture.current_delivery.target_session_id, + &[], + ) + .expect("capture round-three planning injection"); + let injection = + serde_json::from_slice::(&injection).expect("parse round-three planning injection"); + assert_eq!(injection["clarificationRound"], 3); + assert_eq!( + injection["accumulatedAgentMillis"], + final_session.accumulated_agent_millis + ); + assert_eq!(injection["session"]["phase"], "collecting"); + + cleanup_planning_clarification_fixture(fixture); +} + +#[test] +fn planning_clarification_user_revision_after_answer_preserves_round_for_revise_and_reject() { + for action in ["revise", "reject"] { + let mut fixture = planning_clarification_fixture(&format!("user-{action}")); + let answered = answer_planning_clarification_round( + &mut fixture, + 1, + "接受推荐", + &format!("user-{action}"), + ); + let submitted_delivery = dispatch_answered_planning_continuation( + &mut fixture, + &answered, + &format!("user-{action}"), + ); + + let evidence = build_static_delegate_structured_result_at( + &fixture.root, + "completed", + &fixture.expected_artifacts, + false, + None, + None, + None, + None, + ) + .expect("build user-revision evidence-ready result"); + assert_eq!( + evidence.contract_status, + StaticDelegateContractStatus::EvidenceReady + ); + mark_static_delegate_delivery_ready_with_result_at( + &fixture.root, + GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + &submitted_delivery.target_session_id, + &submitted_delivery.target_run_id, + &submitted_delivery.delegation_id, + "completed", + "Fast GDD 已提交并等待用户决定", + evidence, + ) + .expect("mark submitted planning delivery ready"); + claim_ready_static_delegate_receipts_at( + &fixture.root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &fixture.supervisor.run_id, + &format!("planning-user-{action}-claim"), + ) + .expect("claim submitted planning delivery"); + + let collecting = read_plan_session_with_recovery(&fixture.root) + .expect("read answered collecting session") + .expect("answered collecting session exists"); + assert_eq!(collecting.applied_answers.len(), 1); + assert_eq!( + collecting.latest_delegation_id, + submitted_delivery.delegation_id + ); + + let submitted_ref = PlanGddRef { + gdd_id: collecting.gdd_id.clone(), + version: 1, + fingerprint: format!("sha256-serde-json-v2:{}", "a".repeat(64)), + }; + let mut awaiting_approval = collecting.clone(); + awaiting_approval.session_revision += 1; + awaiting_approval.previous_fingerprint = Some(collecting.session_fingerprint.clone()); + awaiting_approval.active_run_id = None; + awaiting_approval.phase = "awaiting_gdd_approval".to_string(); + awaiting_approval.latest_submitted_ref = Some(submitted_ref); + awaiting_approval.last_decision_ref = None; + awaiting_approval.updated_at_utc = "2026-08-17T01:00:00.000Z".to_string(); + awaiting_approval.session_fingerprint = format!("sha256-serde-json-v2:{}", "0".repeat(64)); + awaiting_approval.session_fingerprint = + plan_session_fingerprint(&awaiting_approval).expect("approval-wait fingerprint"); + validate_plan_session_successor(&collecting, &awaiting_approval) + .expect("project approval-wait successor"); + write_plan_session_atomic(&fixture.root, &awaiting_approval) + .expect("persist approval-wait session"); + + let mut decided = awaiting_approval.clone(); + decided.session_revision += 1; + decided.previous_fingerprint = Some(awaiting_approval.session_fingerprint.clone()); + decided.phase = if action == "revise" { + "revision_requested" + } else { + "rejected" + } + .to_string(); + decided.last_decision_ref = Some(PlanDecisionRef { + version: 1, + response_id: if action == "revise" { + "gdd-response-00000000-0000-4000-8000-000000000001" + } else { + "gdd-response-00000000-0000-4000-8000-000000000002" + } + .to_string(), + action: action.to_string(), + receipt_fingerprint: format!("sha256-serde-json-v2:{}", "b".repeat(64)), + }); + decided.updated_at_utc = "2026-08-17T01:00:01.000Z".to_string(); + decided.session_fingerprint = format!("sha256-serde-json-v2:{}", "0".repeat(64)); + decided.session_fingerprint = + plan_session_fingerprint(&decided).expect("decision fingerprint"); + validate_plan_session_successor(&awaiting_approval, &decided) + .expect("project revise/reject successor"); + write_plan_session_atomic(&fixture.root, &decided).expect("persist revise/reject session"); + + mark_static_delegate_delivery_user_revision_requested_at( + &fixture.root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &fixture.supervisor.run_id, + &submitted_delivery.delegation_id, + ) + .expect("mark submitted delivery user-revision-requested"); + let revision_action_id = format!("planning-user-{action}-continuation"); + let revision = dispatch_static_delegate_plain_repair( + &fixture.root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &fixture.supervisor.run_id, + GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + "按用户审批意见修订 Fast GDD", + &fixture.acceptance_criteria, + &fixture.expected_artifacts, + &submitted_delivery.delegation_id, + &revision_action_id, + ); + assert_eq!(revision.status, "ok", "{revision:?}"); + let revision_delegation_id = agent_runtime_delegation_id( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &fixture.supervisor.run_id, + GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + &revision_action_id, + ); + let revised_session = read_plan_session_with_recovery(&fixture.root) + .expect("read user-revision continuation session") + .expect("user-revision continuation session exists"); + assert_eq!(revised_session.phase, "collecting"); + assert_eq!(revised_session.latest_delegation_id, revision_delegation_id); + assert_eq!(revised_session.applied_answers, collecting.applied_answers); + assert_eq!( + revised_session.decisions_summary, + collecting.decisions_summary + ); + assert_eq!( + revised_session + .last_decision_ref + .as_ref() + .map(|reference| reference.action.as_str()), + Some(action) + ); + + let injection = capture_plan_provider_structured_injections_at( + &fixture.root, + &revised_session.session_id, + &[], + ) + .expect("capture user-revision planning injection"); + let injection = serde_json::from_slice::(&injection) + .expect("parse user-revision planning injection"); + assert_eq!( + injection.get("clarificationRound").and_then(Value::as_u64), + Some(1) + ); + let expected_decisions = serde_json::to_value(&collecting.decisions_summary) + .expect("serialize expected decisionsSummary"); + assert_eq!( + injection.pointer("/session/decisionsSummary"), + Some(&expected_decisions) + ); + + cleanup_planning_clarification_fixture(fixture); + } +} + +#[test] +fn planning_clarification_answer_and_continuation_replay_are_idempotent() { + let mut fixture = planning_clarification_fixture("replay"); + let answered = answer_planning_clarification_round(&mut fixture, 1, "接受推荐", "replay"); + let request_id = answered + .original_delivery + .clarification_request_id + .as_deref() + .expect("answered request id"); + let delivery_before_bind_replay = answered.original_delivery.clone(); + let session_before_bind_replay = read_plan_session_with_recovery(&fixture.root) + .expect("read session before bind replay") + .expect("session before bind replay exists"); + bind_static_delegate_clarification_answer_at( + &fixture.root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &fixture.supervisor.run_id, + &answered.original_delivery.delegation_id, + request_id, + &answered.questions_sha256, + &answered.answers_sha256, + ) + .expect("identical answer binding replay"); + assert_eq!( + read_static_delegate_delivery_at(&fixture.root, &answered.original_delivery.delegation_id,) + .expect("read replayed answer delivery") + .expect("replayed answer delivery exists"), + delivery_before_bind_replay + ); + assert_eq!( + read_plan_session_with_recovery(&fixture.root) + .expect("read session after bind replay") + .expect("session after bind replay exists"), + session_before_bind_replay + ); + + let continuation = + dispatch_answered_planning_continuation(&mut fixture, &answered, "replay-first"); + let session_after_first = read_plan_session_with_recovery(&fixture.root) + .expect("read session after first continuation") + .expect("session after first continuation exists"); + let deliveries_after_first = list_static_delegate_deliveries_at(&fixture.root) + .expect("list deliveries after first continuation"); + let replay = dispatch_static_delegate_clarification_continuation( + &fixture.root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &fixture.supervisor.run_id, + GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + "根据同一用户回答继续收敛 Fast GDD", + &fixture.acceptance_criteria, + &fixture.expected_artifacts, + &answered.original_delivery.delegation_id, + &answered.questions_sha256, + &answered.answers_sha256, + "planning-replay-second-action", + ); + assert_eq!(replay.status, "ok", "{replay:?}"); + assert_eq!( + list_static_delegate_deliveries_at(&fixture.root).expect("list deliveries after replay"), + deliveries_after_first, + "同一问答重放不得创建第二条 continuation delivery" + ); + let continuation_task = read_latest_game_creator_agent_runtime_task_by_delegation_id( + &fixture.root, + GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + &continuation.delegation_id, + ) + .expect("read replayed continuation task") + .expect("replayed continuation task exists"); + assert!( + ensure_plan_session_for_planning_child_task_at(&fixture.root, &continuation_task) + .expect("replay continuation session projection") + ); + assert_eq!( + read_plan_session_with_recovery(&fixture.root) + .expect("read session after continuation replay") + .expect("session after continuation replay exists"), + session_after_first, + "同一 continuation task 重放不得增加 session revision" + ); + + cleanup_planning_clarification_fixture(fixture); +} + +#[test] +fn planning_clarification_conflicting_answer_fails_without_projection() { + let mut fixture = planning_clarification_fixture("answer-conflict"); + let answered = + answer_planning_clarification_round(&mut fixture, 1, "接受推荐", "answer-conflict"); + let request_id = answered + .original_delivery + .clarification_request_id + .as_deref() + .expect("answered request id"); + let delivery_before = answered.original_delivery.clone(); + let session_before = read_plan_session_with_recovery(&fixture.root) + .expect("read session before answer conflict") + .expect("session before answer conflict exists"); + let deliveries_before = list_static_delegate_deliveries_at(&fixture.root) + .expect("list deliveries before answer conflict"); + + let different_answer = bind_static_delegate_clarification_answer_at( + &fixture.root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &fixture.supervisor.run_id, + &answered.original_delivery.delegation_id, + request_id, + &answered.questions_sha256, + &"0".repeat(64), + ) + .expect_err("different answer digest must fail closed"); + assert!(different_answer.contains("不同请求或答案")); + let different_request = bind_static_delegate_clarification_answer_at( + &fixture.root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &fixture.supervisor.run_id, + &answered.original_delivery.delegation_id, + "planning-conflicting-request", + &answered.questions_sha256, + &answered.answers_sha256, + ) + .expect_err("different request id must fail closed"); + assert!(different_request.contains("不同请求或答案")); + assert_eq!( + read_static_delegate_delivery_at(&fixture.root, &answered.original_delivery.delegation_id,) + .expect("read delivery after answer conflict") + .expect("delivery after answer conflict exists"), + delivery_before + ); + assert_eq!( + read_plan_session_with_recovery(&fixture.root) + .expect("read session after answer conflict") + .expect("session after answer conflict exists"), + session_before + ); + assert_eq!( + list_static_delegate_deliveries_at(&fixture.root) + .expect("list deliveries after answer conflict"), + deliveries_before + ); + + cleanup_planning_clarification_fixture(fixture); +} + +#[test] +fn planning_clarification_fourth_round_is_rejected_before_pending() { + let mut fixture = planning_clarification_fixture("fourth-round"); + for round in 1..=3 { + let answered = + answer_planning_clarification_round(&mut fixture, round, "接受推荐", "fourth-round"); + dispatch_answered_planning_continuation( + &mut fixture, + &answered, + &format!("fourth-round-{round}"), + ); + } + let session_before = read_plan_session_with_recovery(&fixture.root) + .expect("read session before fourth question") + .expect("session before fourth question exists"); + assert_eq!(session_before.applied_answers.len(), 3); + let deliveries_before = list_static_delegate_deliveries_at(&fixture.root) + .expect("list deliveries before fourth question"); + let (_, fourth_question) = planning_clarification_question_body(4); + let current = fixture.current_delivery.clone(); + mark_and_claim_static_delegate_needs_user_input( + &fixture.root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &fixture.supervisor.run_id, + GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + ¤t.target_session_id, + ¤t.target_run_id, + ¤t.delegation_id, + &fixture.expected_artifacts, + &fourth_question, + "planning-fourth-round-claim", + ); + let claimed = claimed_static_delegate_deliveries_at( + &fixture.root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &fixture.supervisor.run_id, + ) + .expect("read fourth-round claimed delivery"); + let error = + ensure_static_delegate_user_input_wait_at(&fixture.root, &mut fixture.supervisor, &claimed) + .expect_err("fourth planning clarification card must be rejected"); + assert!( + error.contains("PLAN_CLARIFICATION_LIMIT_REACHED"), + "{error}" + ); + assert!( + !game_creator_agent_runtime_pending_tool_action_path( + &fixture.root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &fixture.supervisor.run_id, + ) + .exists(), + "第四轮必须在创建 Supervisor pending 前拒绝" + ); + assert_eq!( + read_plan_session_with_recovery(&fixture.root) + .expect("read session after fourth-round rejection") + .expect("session after fourth-round rejection exists"), + session_before + ); + assert_eq!( + list_static_delegate_deliveries_at(&fixture.root) + .expect("list deliveries after fourth-round rejection") + .len(), + deliveries_before.len(), + "第四轮拒绝不得创建 continuation delivery" + ); + + cleanup_planning_clarification_fixture(fixture); +} + +#[test] +fn planning_clarification_session_recovery_projects_existing_child_once_without_provider() { + let mut fixture = planning_clarification_fixture("recovery"); + let answered = answer_planning_clarification_round(&mut fixture, 1, "接受推荐", "recovery"); + let continuation = + dispatch_answered_planning_continuation(&mut fixture, &answered, "recovery-first"); + let continuation_task = read_latest_game_creator_agent_runtime_task_by_delegation_id( + &fixture.root, + GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + &continuation.delegation_id, + ) + .expect("read recovery continuation task") + .expect("recovery continuation task exists"); + let projected = read_plan_session_with_recovery(&fixture.root) + .expect("read initially projected continuation session") + .expect("initially projected continuation session exists"); + assert_eq!( + projected.session_revision, + answered.awaiting_session.session_revision + 1 + ); + + let primary_path = fixture + .root + .join(PLAN_SESSION_PATH.replace('/', std::path::MAIN_SEPARATOR_STR)); + let previous_path = fixture + .root + .join(PLAN_SESSION_PREVIOUS_PATH.replace('/', std::path::MAIN_SEPARATOR_STR)); + // A normal read consumes the single recovery copy once it has verified the + // successor chain. Recreate the exact awaiting predecessor to model the + // crash window after the continuation delivery is durable but before its + // successor session primary is published. + fs::write( + &previous_path, + canonical_plan_session_bytes(&answered.awaiting_session) + .expect("serialize awaiting recovery copy"), + ) + .expect("write awaiting recovery copy"); + fs::remove_file(&primary_path).expect("simulate crash before session successor publish"); + let promoted = read_plan_session_with_recovery(&fixture.root) + .expect("promote awaiting session after simulated crash") + .expect("promoted awaiting session exists"); + assert_eq!(promoted, answered.awaiting_session); + + let deliveries_before = list_static_delegate_deliveries_at(&fixture.root) + .expect("list deliveries before projection recovery"); + let provider_lifecycle_before = planning_provider_lifecycle_count(&fixture.root); + assert!( + ensure_plan_session_for_planning_child_task_at(&fixture.root, &continuation_task) + .expect("recover existing continuation session projection") + ); + let recovered = read_plan_session_with_recovery(&fixture.root) + .expect("read recovered continuation session") + .expect("recovered continuation session exists"); + assert_eq!(recovered.session_revision, promoted.session_revision + 1); + assert_eq!(recovered.phase, "collecting"); + assert_eq!( + recovered.active_run_id.as_deref(), + Some(continuation.target_run_id.as_str()) + ); + assert_eq!(recovered.latest_delegation_id, continuation.delegation_id); + assert_eq!(recovered.applied_answers.len(), 1); + validate_plan_session_for_clarification_round(&recovered, 1) + .expect("recovered session matches durable lineage"); + assert!( + ensure_plan_session_for_planning_child_task_at(&fixture.root, &continuation_task) + .expect("replay recovered continuation projection") + ); + assert_eq!( + read_plan_session_with_recovery(&fixture.root) + .expect("reread replayed recovered session") + .expect("replayed recovered session exists"), + recovered, + "恢复重放不得增加第二个 successor" + ); + assert_eq!( + list_static_delegate_deliveries_at(&fixture.root) + .expect("list deliveries after projection recovery"), + deliveries_before, + "恢复只能补 session 投影,不能创建新 child/delivery" + ); + assert_eq!( + planning_provider_lifecycle_count(&fixture.root), + provider_lifecycle_before, + "session 投影恢复不得产生 Provider 请求 lifecycle" + ); + + cleanup_planning_clarification_fixture(fixture); +} + +#[test] +fn planning_clarification_runtime_answer_obeys_project_then_execution_lock_order() { + let mut fixture = planning_clarification_fixture("answer-lock-order"); + let (pending, _, _) = + prepare_first_planning_clarification_wait(&mut fixture, "answer-lock-order"); + let execution_lock = try_acquire_game_creator_agent_runtime_task_lock( + &fixture.root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + ) + .expect("probe Supervisor execution lane") + .expect("Supervisor execution lane available"); + let thread_root = fixture.root.clone(); + let run_id = fixture.supervisor.run_id.clone(); + let action_id = pending.action_id.clone(); + let (started_sender, started_receiver) = std::sync::mpsc::channel(); + let worker = std::thread::spawn(move || { + started_sender + .send(()) + .expect("signal ordered answer lock acquisition"); + acquire_game_creator_agent_runtime_user_input_answer_locks_for_test( + &thread_root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &run_id, + &action_id, + ) + }); + started_receiver + .recv_timeout(std::time::Duration::from_secs(1)) + .expect("ordered answer lock worker started"); + + // The worker deliberately blocks on the occupied execution lane after it + // takes the project lock. Give Windows' parallel test scheduler enough + // time to run it after the start signal; the observed lock contention, + // rather than a short scheduling deadline, is the ordering assertion. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + let mut project_lock_observed = false; + while std::time::Instant::now() < deadline { + match acquire_project_write_lock(&fixture.root, "test.answer-lock-order.probe") { + Ok(project_lock) => { + drop(project_lock); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + Err(error) if error.starts_with("项目正在被其他写操作占用:") => { + project_lock_observed = true; + break; + } + Err(error) => panic!("probe project lock failed unexpectedly: {error}"), + } + } + assert!( + project_lock_observed, + "planning answer 必须先持有 project lock,再等待已占用的 execution lane" + ); + drop(execution_lock); + let (project_lock, runtime_lock) = worker + .join() + .expect("join ordered answer lock worker") + .expect("acquire ordered answer locks"); + assert!(project_lock.is_some(), "planning answer 必须取得项目写锁"); + drop(runtime_lock); + drop(project_lock); + cleanup_planning_clarification_fixture(fixture); +} + +#[test] +fn planning_clarification_answer_prepared_recovery_releases_execution_before_project_wait() { + let mut fixture = planning_clarification_fixture("prepared-lock-order"); + let (pending, request, question_id) = + prepare_first_planning_clarification_wait(&mut fixture, "prepared-lock-order"); + fs::write( + fixture + .root + .join(AGENT_RUNTIME_USER_INPUT_STOP_AFTER_PREPARED_FOR_TEST), + b"armed", + ) + .expect("arm answer-prepared crash window"); + let response_id = "planning-prepared-lock-order-response"; + let injected = answer_game_creator_agent_user_input_request_for_pending_at( + &fixture.root, + &pending, + &request.request_id, + response_id, + BTreeMap::from([(question_id, "接受推荐".to_string())]), + ) + .expect_err("answer must stop after durable answer-prepared"); + assert!(injected.contains("answer-prepared"), "{injected}"); + + let project_lock = acquire_project_write_lock( + &fixture.root, + "test.answer-prepared-recovery.project-holder", + ) + .expect("hold project lock across recovery reorder"); + let thread_root = fixture.root.clone(); + let (started_sender, started_receiver) = std::sync::mpsc::channel(); + let worker = std::thread::spawn(move || { + let runtime_lock = try_acquire_game_creator_agent_runtime_task_lock( + &thread_root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + ) + .expect("acquire initial recovery execution lane") + .expect("initial recovery execution lane available"); + started_sender + .send(()) + .expect("signal answer-prepared recovery start"); + resume_game_creator_agent_pending_tool_action_at( + &thread_root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + runtime_lock, + ) + }); + started_receiver + .recv_timeout(std::time::Duration::from_secs(1)) + .expect("answer-prepared recovery started with execution lane"); + + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(1); + let reacquired_execution = loop { + if let Some(runtime_lock) = try_acquire_game_creator_agent_runtime_task_lock( + &fixture.root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + ) + .expect("probe released recovery execution lane") + { + break runtime_lock; + } + assert!( + std::time::Instant::now() < deadline, + "answer-prepared recovery 必须先释放 execution lane,再等待 project lock" + ); + std::thread::sleep(std::time::Duration::from_millis(5)); + }; + drop(reacquired_execution); + drop(project_lock); + let recovery = worker + .join() + .expect("join answer-prepared recovery") + .expect("recover answer-prepared request"); + drop(recovery); + + let release_deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + loop { + if let Some(runtime_lock) = try_acquire_game_creator_agent_runtime_task_lock( + &fixture.root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + ) + .expect("probe post-recovery Supervisor lane") + { + drop(runtime_lock); + break; + } + assert!( + std::time::Instant::now() < release_deadline, + "answer-prepared continuation 必须在有界时间内释放 execution lane" + ); + std::thread::sleep(std::time::Duration::from_millis(20)); + } + let answered_delivery = + read_static_delegate_delivery_at(&fixture.root, &fixture.current_delivery.delegation_id) + .expect("read answer-prepared delivery") + .expect("answer-prepared delivery exists"); + assert!(answered_delivery.clarification_answers_sha256.is_some()); + cleanup_planning_clarification_fixture(fixture); +} + +#[test] +fn planning_clarification_parent_wake_defers_while_execution_lane_is_busy_and_replays_once() { + let mut fixture = planning_clarification_fixture("parent-wake-lock-order"); + let current = fixture.current_delivery.clone(); + let (_, questions_body) = planning_clarification_question_body(1); + mark_and_claim_static_delegate_needs_user_input( + &fixture.root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &fixture.supervisor.run_id, + GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + ¤t.target_session_id, + ¤t.target_run_id, + ¤t.delegation_id, + &fixture.expected_artifacts, + &questions_body, + "planning-parent-wake-lock-order-claim", + ); + fixture.supervisor.status = "running".to_string(); + fixture.supervisor.phase = "waiting-for-delegate-receipts".to_string(); + fixture.supervisor.current_action = "等待创建用户澄清请求".to_string(); + fixture.supervisor.waiting_on = "lane 外 parent-wake".to_string(); + fixture.supervisor.next_step = "按 project → execution 锁序创建 pending".to_string(); + fixture.supervisor.pending_tool_action = None; + append_game_creator_agent_runtime_task(&fixture.root, &fixture.supervisor) + .expect("persist parent receipt-wait task"); + refresh_game_creator_agent_runtime_task_queue(&fixture.root, &mut fixture.supervisor) + .expect("refresh parent receipt-wait queue"); + write_game_creator_agent_runtime_state(&fixture.root, &fixture.supervisor) + .expect("persist parent receipt-wait state"); + let parent_task = read_latest_game_creator_agent_runtime_task_by_run_id( + &fixture.root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &fixture.supervisor.run_id, + ) + .expect("read receipt-wait parent task") + .expect("receipt-wait parent task exists"); + let provider_lifecycle_before = planning_provider_lifecycle_count(&fixture.root); + let execution_lock = try_acquire_game_creator_agent_runtime_task_lock( + &fixture.root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + ) + .expect("acquire busy parent execution lane") + .expect("parent execution lane available"); + assert!( + !wake_waiting_static_delegate_parent_run_for_test_at(&fixture.root, &parent_task) + .expect("busy parent wake must defer"), + "parent-wake 不得越过已占用的 execution lane 写 pending" + ); + assert!(!game_creator_agent_runtime_pending_tool_action_exists( + &fixture.root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &fixture.supervisor.run_id, + )); + drop(execution_lock); + + assert!( + wake_waiting_static_delegate_parent_run_for_test_at(&fixture.root, &parent_task) + .expect("parent wake after lane release"), + "lane 释放后必须创建唯一澄清 pending" + ); + let pending = read_game_creator_agent_runtime_pending_tool_action( + &fixture.root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &fixture.supervisor.run_id, + ) + .expect("read parent-wake pending"); + let awaiting_session = read_plan_session_with_recovery(&fixture.root) + .expect("read parent-wake planning session") + .expect("parent-wake planning session exists"); + assert_eq!(awaiting_session.phase, "awaiting_user_input"); + assert_eq!( + read_game_creator_agent_runtime_at( + &fixture.root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + ) + .expect("read parent-wake runtime") + .state + .phase, + "waiting-for-user-input" + ); + assert!( + !wake_waiting_static_delegate_parent_run_for_test_at(&fixture.root, &parent_task) + .expect("replay completed parent wake") + ); + assert_eq!( + read_game_creator_agent_runtime_pending_tool_action( + &fixture.root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &fixture.supervisor.run_id, + ) + .expect("read replayed parent-wake pending") + .action_id, + pending.action_id + ); + assert_eq!( + read_plan_session_with_recovery(&fixture.root) + .expect("read replayed parent-wake session") + .expect("replayed parent-wake session exists"), + awaiting_session + ); + assert_eq!( + planning_provider_lifecycle_count(&fixture.root), + provider_lifecycle_before, + "parent-wake 投影不得请求 Provider" + ); + cleanup_planning_clarification_fixture(fixture); +} + +#[tokio::test] +async fn planning_clarification_main_loop_releases_execution_lane_before_parent_wake() { + let fixture = planning_clarification_fixture("main-loop-lane-release"); + let current = fixture.current_delivery.clone(); + let (_, questions_body) = planning_clarification_question_body(1); + mark_and_claim_static_delegate_needs_user_input( + &fixture.root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &fixture.supervisor.run_id, + GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + ¤t.target_session_id, + ¤t.target_run_id, + ¤t.delegation_id, + &fixture.expected_artifacts, + &questions_body, + "planning-main-loop-lane-release-claim", + ); + let provider_lifecycle_before = planning_provider_lifecycle_count(&fixture.root); + let execution_lock = try_acquire_game_creator_agent_runtime_task_lock( + &fixture.root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + ) + .expect("acquire main-loop Supervisor execution lane") + .expect("main-loop Supervisor execution lane available"); + + let outcome = run_game_creator_agent_background_task_with_context( + fixture.root.clone(), + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(), + fixture.supervisor.current_task.clone(), + fixture.supervisor.clone(), + AgentRuntimeContinuationContext::default(), + ) + .await; + assert!(matches!( + outcome, + AgentBackgroundTaskOutcome::WaitingForDelegateReceipts + )); + let receipt_wait = + read_game_creator_agent_runtime_at(&fixture.root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID) + .expect("read main-loop receipt wait") + .state; + assert_eq!(receipt_wait.status, "running"); + assert_eq!(receipt_wait.phase, "waiting-for-delegate-receipts"); + assert!( + !game_creator_agent_runtime_pending_tool_action_exists( + &fixture.root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &fixture.supervisor.run_id, + ), + "main loop 持有 execution lane 时不得直接投影澄清 pending" + ); + + drop(execution_lock); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + let pending = loop { + match read_game_creator_agent_runtime_pending_tool_action( + &fixture.root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &fixture.supervisor.run_id, + ) { + Ok(pending) => break pending, + Err(error) => { + assert!( + std::time::Instant::now() < deadline, + "execution lane 释放后 parent-wake 未在有界时间内创建 pending:{error}" + ); + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + } + } + }; + let awaiting_session = loop { + match read_plan_session_with_recovery(&fixture.root) { + Ok(Some(session)) => break session, + Ok(None) => panic!("main-loop awaiting planning session must exist"), + Err(error) => { + assert!( + std::time::Instant::now() < deadline, + "parent-wake 写入 pending 后未在有界时间内释放项目锁:{error}" + ); + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + } + } + }; + assert_eq!(awaiting_session.session_revision, 2); + assert_eq!(awaiting_session.phase, "awaiting_user_input"); + assert!(awaiting_session.active_run_id.is_none()); + assert_eq!(awaiting_session.latest_delegation_id, current.delegation_id); + assert!(awaiting_session.applied_answers.is_empty()); + let waiting = + read_game_creator_agent_runtime_at(&fixture.root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID) + .expect("read main-loop user-input wait") + .state; + assert_eq!(waiting.status, "waiting-for-user-input"); + assert_eq!(waiting.phase, "waiting-for-user-input"); + assert_eq!( + waiting + .pending_tool_action + .as_ref() + .map(|summary| summary.action_id.as_str()), + Some(pending.action_id.as_str()) + ); + assert_eq!( + planning_provider_lifecycle_count(&fixture.root), + provider_lifecycle_before, + "main-loop receipt wait 与 lane 外 parent-wake 都不得启动 Provider" + ); + + cleanup_planning_clarification_fixture(fixture); +} + +#[test] +fn planning_clarification_recovery_treats_concurrent_answer_as_obsolete_candidate() { + let mut fixture = planning_clarification_fixture("recovery-obsolete-answer"); + let (mut pending, request, question_id) = + prepare_first_planning_clarification_wait(&mut fixture, "recovery-obsolete-answer"); + let project_lock = acquire_project_write_lock( + &fixture.root, + "test.recovery-obsolete-answer.project-holder", + ) + .expect("hold project lock for recovery race"); + let thread_root = fixture.root.clone(); + let (started_sender, started_receiver) = std::sync::mpsc::channel(); + let worker = std::thread::spawn(move || { + let runtime_lock = try_acquire_game_creator_agent_runtime_task_lock( + &thread_root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + ) + .expect("acquire old recovery candidate lane") + .expect("old recovery candidate lane available"); + started_sender + .send(()) + .expect("signal obsolete recovery candidate start"); + resume_game_creator_agent_pending_tool_action_at( + &thread_root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + runtime_lock, + ) + }); + started_receiver + .recv_timeout(std::time::Duration::from_secs(1)) + .expect("obsolete recovery candidate started"); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(1); + let execution_lock = loop { + if let Some(runtime_lock) = try_acquire_game_creator_agent_runtime_task_lock( + &fixture.root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + ) + .expect("probe old recovery lane release") + { + break runtime_lock; + } + assert!( + std::time::Instant::now() < deadline, + "旧恢复候选必须在等待 project lock 前释放 execution lane" + ); + std::thread::sleep(std::time::Duration::from_millis(5)); + }; + let (_, observation) = answer_game_creator_agent_user_input_request_for_pending_at_locked( + &fixture.root, + &pending, + &request.request_id, + "planning-recovery-obsolete-response", + BTreeMap::from([(question_id, "接受推荐".to_string())]), + &project_lock, + ) + .expect("advance answer while old recovery waits"); + pending.status = AGENT_RUNTIME_PENDING_ACTION_STATUS_OBSERVED_APPROVED.to_string(); + pending.observation = Some(observation); + pending.updated_at = unix_timestamp(); + write_game_creator_agent_runtime_pending_tool_action(&fixture.root, &pending) + .expect("persist concurrently advanced pending"); + let mut runtime = + read_game_creator_agent_runtime_at(&fixture.root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID) + .expect("read concurrently advanced runtime") + .state; + runtime.status = "running".to_string(); + runtime.phase = "observation".to_string(); + runtime.current_action = "已收到用户回答".to_string(); + runtime.pending_tool_action = Some(pending.summary()); + runtime.updated_at = unix_timestamp(); + append_game_creator_agent_runtime_task(&fixture.root, &runtime) + .expect("persist concurrently advanced task"); + refresh_game_creator_agent_runtime_task_queue(&fixture.root, &mut runtime) + .expect("refresh concurrently advanced queue"); + write_game_creator_agent_runtime_state(&fixture.root, &runtime) + .expect("persist concurrently advanced runtime"); + drop(execution_lock); + drop(project_lock); + + match worker + .join() + .expect("join obsolete recovery candidate") + .expect("obsolete recovery candidate must not fail") + { + AgentRuntimePendingActionResume::NotFound(runtime_lock) => drop(runtime_lock), + AgentRuntimePendingActionResume::Handled(_) => { + panic!("concurrently advanced answer must make the old recovery candidate obsolete") + } + } + let current = + read_game_creator_agent_runtime_at(&fixture.root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID) + .expect("read runtime after obsolete recovery") + .state; + assert_eq!(current.phase, "observation"); + assert_ne!(current.phase, "needs-reconciliation"); + cleanup_planning_clarification_fixture(fixture); +} + const CLARIFICATION_QUESTION_BODY: &str = concat!( "{\"questions\":[{\"id\":\"confirm\",\"header\":\"确认\",", "\"question\":\"请确认继续?\",", diff --git a/apps/ai-game-creator-shell/src-tauri/src/user_input.rs b/apps/ai-game-creator-shell/src-tauri/src/user_input.rs index 9e94618a5..57abd8f17 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/user_input.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/user_input.rs @@ -19,6 +19,16 @@ const AGENT_RUNTIME_USER_INPUT_MAX_OPTION_DESCRIPTION_CHARS: usize = 240; const AGENT_RUNTIME_USER_INPUT_MAX_ANSWER_CHARS: usize = 4_000; const AGENT_RUNTIME_USER_INPUT_MAX_TOTAL_ANSWER_CHARS: usize = 8_000; const AGENT_RUNTIME_USER_INPUT_MAX_RESPONSE_ID_CHARS: usize = 160; +#[cfg(test)] +pub(crate) const AGENT_RUNTIME_USER_INPUT_STOP_AFTER_PREPARED_FOR_TEST: &str = + ".agent/runtime/test-stop-user-input-after-answer-prepared"; + +fn planning_answer_requires_precheck(status: &str) -> bool { + matches!( + status, + AGENT_RUNTIME_USER_INPUT_STATUS_PENDING | AGENT_RUNTIME_USER_INPUT_STATUS_ANSWER_PREPARED + ) +} #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(deny_unknown_fields, rename_all = "camelCase")] @@ -60,6 +70,16 @@ pub(crate) struct AgentRuntimeUserInputRequestView { pub(crate) updated_at: u64, } +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct PlanStaticDelegateAnsweredInput { + pub(crate) request_id: String, + pub(crate) response_id: String, + pub(crate) question: AgentRuntimeUserInputQuestion, + pub(crate) answer: String, + pub(crate) questions_sha256: String, + pub(crate) answers_sha256: String, +} + #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(deny_unknown_fields, rename_all = "camelCase")] struct AgentRuntimeUserInputRecord { @@ -623,37 +643,35 @@ fn build_user_input_observation( }) } -fn validate_user_input_record( +fn validate_user_input_record_payload( root: &Path, - pending: &AgentRuntimePendingToolAction, record: &AgentRuntimeUserInputRecord, ) -> Result<(), String> { - let expected = build_new_user_input_record(root, pending)?; + let normalized_questions = normalize_user_input_questions(record.questions.clone())?; + let questions_sha256 = user_input_sha256_json(&normalized_questions)?; + let (question_count, option_count, question_chars) = + user_input_question_counts(&normalized_questions); if record.schema_version != AGENT_RUNTIME_USER_INPUT_SCHEMA_VERSION - || record.project_id != expected.project_id - || record.agent_id != expected.agent_id - || record.task_id != expected.task_id - || record.session_id != expected.session_id - || record.run_id != expected.run_id - || record.source != expected.source - || record.action_id != expected.action_id - || record.action_fingerprint != expected.action_fingerprint - || record.goal_id != expected.goal_id - || record.goal_revision != expected.goal_revision - || record.goal_snapshot_fingerprint != expected.goal_snapshot_fingerprint - || record.planned_steer_cursor != expected.planned_steer_cursor - || record.request_id != expected.request_id - || record.questions != expected.questions - || record.questions_sha256 != expected.questions_sha256 - || record.question_count != expected.question_count - || record.option_count != expected.option_count - || record.question_chars != expected.question_chars - || record.question_message_id != expected.question_message_id + || record.project_id != game_creator_agent_runtime_context_project_id(root)? + || record.questions != normalized_questions + || record.questions_sha256 != questions_sha256 + || record.question_count != question_count + || record.option_count != option_count + || record.question_chars != question_chars + || record.question_message_id != user_input_question_message_id(&record.request_id) + || record.agent_id.trim().is_empty() + || record.task_id.trim().is_empty() + || record.session_id.trim().is_empty() + || record.run_id.trim().is_empty() + || record.source.trim().is_empty() + || record.action_id.trim().is_empty() + || !valid_user_input_sha256(&record.action_fingerprint) + || record.request_id.trim().is_empty() || record.created_at == 0 || record.updated_at == 0 || !valid_user_input_sha256(&record.questions_sha256) { - return Err("用户输入请求 sidecar 身份或问题正文冲突".to_string()); + return Err("用户输入请求 sidecar payload 身份或问题正文冲突".to_string()); } if !matches!( record.status.as_str(), @@ -728,6 +746,37 @@ fn validate_user_input_record( Ok(()) } +fn validate_user_input_record( + root: &Path, + pending: &AgentRuntimePendingToolAction, + record: &AgentRuntimeUserInputRecord, +) -> Result<(), String> { + let expected = build_new_user_input_record(root, pending)?; + if record.project_id != expected.project_id + || record.agent_id != expected.agent_id + || record.task_id != expected.task_id + || record.session_id != expected.session_id + || record.run_id != expected.run_id + || record.source != expected.source + || record.action_id != expected.action_id + || record.action_fingerprint != expected.action_fingerprint + || record.goal_id != expected.goal_id + || record.goal_revision != expected.goal_revision + || record.goal_snapshot_fingerprint != expected.goal_snapshot_fingerprint + || record.planned_steer_cursor != expected.planned_steer_cursor + || record.request_id != expected.request_id + || record.questions != expected.questions + || record.questions_sha256 != expected.questions_sha256 + || record.question_count != expected.question_count + || record.option_count != expected.option_count + || record.question_chars != expected.question_chars + || record.question_message_id != expected.question_message_id + { + return Err("用户输入请求 sidecar 身份或问题正文冲突".to_string()); + } + validate_user_input_record_payload(root, record) +} + fn read_user_input_record( root: &Path, pending: &AgentRuntimePendingToolAction, @@ -746,6 +795,127 @@ fn read_user_input_record( Ok(record) } +fn validate_answered_plan_static_delegate_user_input_identity( + delivery: &StaticDelegateDeliveryRecord, + root_task: &AgentRuntimeTaskRecord, + record: &AgentRuntimeUserInputRecord, + request_id: &str, + expected_questions_sha256: &str, + expected_answers_sha256: &str, +) -> Result<(), String> { + // The task record, rather than the static Supervisor agent ID, is the + // durable source of `taskId`: task IDs are not a substitute identity for + // agent IDs. Keep every other sidecar field anchored to this same root + // task so a valid answer cannot be transplanted across root runs/sessions. + if root_task.agent_id != delivery.parent_agent_id + || root_task.task_id.trim().is_empty() + || root_task.session_id != delivery.parent_session_id + || root_task.run_id != delivery.parent_run_id + || root_task.source != AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE + || root_task.parent_agent_id.is_some() + || root_task.parent_run_id.is_some() + { + return Err("planning user-input sidecar 的 plan root task 身份冲突".to_string()); + } + if record.agent_id != root_task.agent_id + || record.task_id != root_task.task_id + || record.session_id != root_task.session_id + || record.run_id != root_task.run_id + || record.source != root_task.source + || record.request_id != request_id + || record.status != AGENT_RUNTIME_USER_INPUT_STATUS_ANSWERED + || record.questions_sha256 != expected_questions_sha256 + || record.answers_sha256.as_deref() != Some(expected_answers_sha256) + || record.questions.len() != 1 + || record.answers.len() != 1 + { + return Err("planning user-input sidecar 与 delivery 回答绑定冲突".to_string()); + } + Ok(()) +} + +/// Read the answered Supervisor sidecar that is already bound to one exact +/// planning delivery. Only the fields needed for the derived plan-session +/// projection escape this module; the complete private answer record remains +/// encapsulated here. +pub(crate) fn read_answered_plan_static_delegate_user_input_at( + root: &Path, + delivery: &StaticDelegateDeliveryRecord, +) -> Result { + if delivery.parent_agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID + || delivery.target_agent_id != GAME_CREATOR_PROJECT_PLANNING_AGENT_ID + || delivery.status != StaticDelegateDeliveryStatus::ClaimedByParent + || delivery.structured_result.as_ref().is_none_or(|result| { + result.contract_status != StaticDelegateContractStatus::NeedsUserInput + }) + { + return Err("planning answer 只能从已认领的 NeedsUserInput delivery 读取".to_string()); + } + validate_project_supervisor_plan_root_binding_for_crate_at( + root, + &delivery.parent_agent_id, + &delivery.parent_run_id, + )?; + let request_id = delivery + .clarification_request_id + .as_deref() + .ok_or_else(|| "planning delivery 尚未绑定 user-input requestId".to_string())?; + let expected_answers_sha256 = delivery + .clarification_answers_sha256 + .as_deref() + .ok_or_else(|| "planning delivery 尚未绑定 answersSha256".to_string())?; + let relative_path = user_input_relative_path( + &delivery.parent_agent_id, + &delivery.parent_run_id, + request_id, + ); + let record = read_agent_runtime_json_sidecar_with_max_bytes::( + root, + &relative_path, + "Fast GDD 澄清回答", + AGENT_RUNTIME_USER_INPUT_SIDECAR_MAX_BYTES, + )? + .ok_or_else(|| "planning delivery 已绑定答案但 user-input sidecar 缺失".to_string())?; + validate_user_input_record_payload(root, &record)?; + let expected_questions_sha256 = delivery + .structured_result + .as_ref() + .and_then(|result| result.user_input_questions_sha256.as_deref()) + .ok_or_else(|| "planning delivery 缺少 questionsSha256".to_string())?; + let root_task = read_latest_game_creator_agent_runtime_task_by_run_id( + root, + &delivery.parent_agent_id, + &delivery.parent_run_id, + )? + .ok_or_else(|| "planning user-input sidecar 缺少所属 plan root task".to_string())?; + validate_answered_plan_static_delegate_user_input_identity( + delivery, + &root_task, + &record, + request_id, + expected_questions_sha256, + expected_answers_sha256, + )?; + let question = record.questions[0].clone(); + let answer = record + .answers + .get(&question.id) + .cloned() + .ok_or_else(|| "planning user-input sidecar 缺少唯一答案".to_string())?; + Ok(PlanStaticDelegateAnsweredInput { + request_id: record.request_id, + response_id: record + .response_id + .ok_or_else(|| "planning user-input sidecar 缺少 responseId".to_string())?, + question, + answer, + questions_sha256: record.questions_sha256, + answers_sha256: record + .answers_sha256 + .ok_or_else(|| "planning user-input sidecar 缺少 answersSha256".to_string())?, + }) +} + fn write_user_input_record( root: &Path, pending: &AgentRuntimePendingToolAction, @@ -763,11 +933,27 @@ fn write_user_input_record( ) } -fn finish_prepared_user_input_answer( +fn finish_prepared_user_input_answer_with_project_lock( root: &Path, pending: &AgentRuntimePendingToolAction, mut record: AgentRuntimeUserInputRecord, + project_lock: Option<&ProjectWriteLock>, ) -> Result { + match project_lock { + Some(project_lock) => validate_plan_clarification_answer_for_pending_at_locked( + root, + pending, + &record.questions, + &record.answers, + project_lock, + )?, + None => validate_plan_clarification_answer_for_pending_at( + root, + pending, + &record.questions, + &record.answers, + )?, + } append_user_input_answer_message(root, &record)?; let observation = build_user_input_observation(&record)?; let now = unix_timestamp(); @@ -811,6 +997,29 @@ fn bind_user_input_record_to_static_delegate_at( pub(crate) fn prepare_game_creator_agent_user_input_request_at( root: &Path, pending: &AgentRuntimePendingToolAction, +) -> Result { + prepare_game_creator_agent_user_input_request_with_project_lock_at(root, pending, None) +} + +pub(crate) fn prepare_game_creator_agent_user_input_request_at_locked( + root: &Path, + pending: &AgentRuntimePendingToolAction, + project_lock: &ProjectWriteLock, +) -> Result { + if !project_lock.guards_project_root(root)? { + return Err("恢复 planning 用户输入缺少当前项目写锁".to_string()); + } + prepare_game_creator_agent_user_input_request_with_project_lock_at( + root, + pending, + Some(project_lock), + ) +} + +fn prepare_game_creator_agent_user_input_request_with_project_lock_at( + root: &Path, + pending: &AgentRuntimePendingToolAction, + project_lock: Option<&ProjectWriteLock>, ) -> Result { validate_user_input_action_owner(root, pending)?; let mut record = match read_user_input_record(root, pending)? { @@ -823,7 +1032,12 @@ pub(crate) fn prepare_game_creator_agent_user_input_request_at( }; append_user_input_question_message(root, &record)?; if record.status == AGENT_RUNTIME_USER_INPUT_STATUS_ANSWER_PREPARED { - record = finish_prepared_user_input_answer(root, pending, record)?; + record = finish_prepared_user_input_answer_with_project_lock( + root, + pending, + record, + project_lock, + )?; } match record.status.as_str() { AGENT_RUNTIME_USER_INPUT_STATUS_PENDING => Ok(AgentRuntimeUserInputRecovery::Waiting( @@ -875,6 +1089,57 @@ pub(crate) fn answer_game_creator_agent_user_input_request_for_pending_at( AgentRuntimeToolObservation, ), String, +> { + answer_game_creator_agent_user_input_request_for_pending_with_project_lock_at( + root, + pending, + request_id, + response_id, + answers, + None, + ) +} + +pub(crate) fn answer_game_creator_agent_user_input_request_for_pending_at_locked( + root: &Path, + pending: &AgentRuntimePendingToolAction, + request_id: &str, + response_id: &str, + answers: BTreeMap, + project_lock: &ProjectWriteLock, +) -> Result< + ( + AgentRuntimeUserInputRequestView, + AgentRuntimeToolObservation, + ), + String, +> { + if !project_lock.guards_project_root(root)? { + return Err("提交 planning 用户回答缺少当前项目写锁".to_string()); + } + answer_game_creator_agent_user_input_request_for_pending_with_project_lock_at( + root, + pending, + request_id, + response_id, + answers, + Some(project_lock), + ) +} + +fn answer_game_creator_agent_user_input_request_for_pending_with_project_lock_at( + root: &Path, + pending: &AgentRuntimePendingToolAction, + request_id: &str, + response_id: &str, + answers: BTreeMap, + project_lock: Option<&ProjectWriteLock>, +) -> Result< + ( + AgentRuntimeUserInputRequestView, + AgentRuntimeToolObservation, + ), + String, > { validate_user_input_action_owner(root, pending)?; let request_id = request_id.trim(); @@ -886,6 +1151,23 @@ pub(crate) fn answer_game_creator_agent_user_input_request_for_pending_at( } let (answers, answer_chars) = normalize_user_input_answers(&record.questions, answers)?; let answers_sha256 = user_input_sha256_json(&answers)?; + if planning_answer_requires_precheck(&record.status) { + match project_lock { + Some(project_lock) => validate_plan_clarification_answer_for_pending_at_locked( + root, + pending, + &record.questions, + &answers, + project_lock, + )?, + None => validate_plan_clarification_answer_for_pending_at( + root, + pending, + &record.questions, + &answers, + )?, + } + } match record.status.as_str() { AGENT_RUNTIME_USER_INPUT_STATUS_PENDING => { let now = unix_timestamp(); @@ -902,6 +1184,14 @@ pub(crate) fn answer_game_creator_agent_user_input_request_for_pending_at( record.answer_prepared_at = Some(now); record.updated_at = now; write_user_input_record(root, pending, &record)?; + #[cfg(test)] + if std::fs::remove_file( + root.join(AGENT_RUNTIME_USER_INPUT_STOP_AFTER_PREPARED_FOR_TEST), + ) + .is_ok() + { + return Err("测试注入:用户回答停在 answer-prepared".to_string()); + } } AGENT_RUNTIME_USER_INPUT_STATUS_ANSWER_PREPARED | AGENT_RUNTIME_USER_INPUT_STATUS_ANSWERED => { @@ -918,7 +1208,12 @@ pub(crate) fn answer_game_creator_agent_user_input_request_for_pending_at( _ => return Err("用户输入请求状态无效".to_string()), } if record.status == AGENT_RUNTIME_USER_INPUT_STATUS_ANSWER_PREPARED { - record = finish_prepared_user_input_answer(root, pending, record)?; + record = finish_prepared_user_input_answer_with_project_lock( + root, + pending, + record, + project_lock, + )?; } append_user_input_question_message(root, &record)?; append_user_input_answer_message(root, &record)?; @@ -1085,4 +1380,167 @@ mod tests { assert!(!metadata.contains("response-private")); assert!(metadata.contains("answerChars=12")); } + + #[test] + fn planning_answer_precheck_stops_after_answered_commit() { + assert!(planning_answer_requires_precheck( + AGENT_RUNTIME_USER_INPUT_STATUS_PENDING + )); + assert!(planning_answer_requires_precheck( + AGENT_RUNTIME_USER_INPUT_STATUS_ANSWER_PREPARED + )); + assert!(!planning_answer_requires_precheck( + AGENT_RUNTIME_USER_INPUT_STATUS_ANSWERED + )); + assert!(!planning_answer_requires_precheck( + AGENT_RUNTIME_USER_INPUT_STATUS_CANCELLED + )); + } + + #[test] + fn answered_plan_sidecar_requires_root_task_id_not_supervisor_agent_id() { + // Keep this at the pure identity seam: the production reader has + // already validated the sidecar payload, project identity and durable + // Run Profile binding before it reaches this check. + let mut root_task = AgentRuntimeTaskRecord { + schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(), + agent_id: GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(), + task_id: "plan-root-task-id".to_string(), + session_id: "plan-root-session".to_string(), + run_id: "plan-root-run".to_string(), + source: AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE.to_string(), + run_profile: AGENT_RUNTIME_RUN_PROFILE_STANDARD.to_string(), + run_profile_binding_fingerprint: "binding".to_string(), + parent_agent_id: None, + parent_run_id: None, + delegation_id: None, + goal_id: None, + goal_revision: 0, + goal_status: None, + task: "收敛 Fast GDD".to_string(), + status: "running".to_string(), + phase: "waiting-for-user-input".to_string(), + current_action: "等待用户回答".to_string(), + terminal_detail: None, + error: None, + updated_at: 1, + }; + let question = AgentRuntimeUserInputQuestion { + id: "route_choice".to_string(), + header: "第1轮·关键决定".to_string(), + question: "当前要决定:首版路线。".to_string(), + options: vec![ + AgentRuntimeUserInputOption { + label: "接受推荐".to_string(), + description: "采用推荐。".to_string(), + }, + AgentRuntimeUserInputOption { + label: "暂按推荐".to_string(), + description: "暂按推荐。".to_string(), + }, + AgentRuntimeUserInputOption { + label: "需要原型验证".to_string(), + description: "先验证。".to_string(), + }, + ], + }; + let questions = vec![question.clone()]; + let answers = BTreeMap::from([("route_choice".to_string(), "接受推荐".to_string())]); + let questions_sha256 = user_input_sha256_json(&questions).expect("questions fingerprint"); + let answers_sha256 = user_input_sha256_json(&answers).expect("answers fingerprint"); + let request_id = "user-input-plan-root-task-id"; + let response_id = "plan-root-answer"; + let record = AgentRuntimeUserInputRecord { + schema_version: AGENT_RUNTIME_USER_INPUT_SCHEMA_VERSION.to_string(), + project_id: "project-id".to_string(), + agent_id: root_task.agent_id.clone(), + task_id: root_task.task_id.clone(), + session_id: root_task.session_id.clone(), + run_id: root_task.run_id.clone(), + source: root_task.source.clone(), + action_id: "action-id".to_string(), + action_fingerprint: "a".repeat(64), + goal_id: None, + goal_revision: 0, + goal_snapshot_fingerprint: String::new(), + planned_steer_cursor: 0, + request_id: request_id.to_string(), + questions, + questions_sha256: questions_sha256.clone(), + question_count: 1, + option_count: 3, + question_chars: user_input_question_counts(&[question.clone()]).2, + question_message_id: user_input_question_message_id(request_id), + status: AGENT_RUNTIME_USER_INPUT_STATUS_ANSWERED.to_string(), + response_id: Some(response_id.to_string()), + answers, + answers_sha256: Some(answers_sha256.clone()), + answer_count: 1, + answer_chars: "接受推荐".chars().count() as u32, + answer_message_id: Some(user_input_answer_message_id(request_id, response_id)), + observation: None, + created_at: 1, + answer_prepared_at: Some(1), + answered_at: Some(1), + cancelled_at: None, + updated_at: 1, + }; + let delivery = StaticDelegateDeliveryRecord { + schema_version: "game-creator-static-delegate-delivery.v1".to_string(), + parent_agent_id: root_task.agent_id.clone(), + parent_session_id: root_task.session_id.clone(), + parent_run_id: root_task.run_id.clone(), + parent_action_id: "delegate-action".to_string(), + delegation_id: "delegation-id".to_string(), + target_agent_id: GAME_CREATOR_PROJECT_PLANNING_AGENT_ID.to_string(), + target_session_id: "planning-session".to_string(), + target_run_id: "planning-run".to_string(), + acceptance_criteria: Vec::new(), + expected_artifacts: Vec::new(), + repair_of_delegation_id: None, + clarification_request_id: Some(request_id.to_string()), + clarification_answers_sha256: Some(answers_sha256.clone()), + status: StaticDelegateDeliveryStatus::ClaimedByParent, + terminal_status: Some("completed".to_string()), + result_summary: Some("需要用户澄清".to_string()), + structured_result: None, + claimed_by_action_id: Some("claim-action".to_string()), + updated_at: 1, + }; + + validate_answered_plan_static_delegate_user_input_identity( + &delivery, + &root_task, + &record, + request_id, + &questions_sha256, + &answers_sha256, + ) + .expect("actual plan root taskId is accepted"); + + let mut wrong_task_id = record.clone(); + wrong_task_id.task_id = GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(); + assert!(validate_answered_plan_static_delegate_user_input_identity( + &delivery, + &root_task, + &wrong_task_id, + request_id, + &questions_sha256, + &answers_sha256, + ) + .expect_err("agentId must not be accepted as taskId") + .contains("delivery 回答绑定冲突")); + + root_task.parent_run_id = Some("unexpected-parent".to_string()); + assert!(validate_answered_plan_static_delegate_user_input_identity( + &delivery, + &root_task, + &record, + request_id, + &questions_sha256, + &answers_sha256, + ) + .expect_err("plan root task must not have a parent") + .contains("plan root task 身份冲突")); + } } diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 7477e6e5c..1118df526 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -1,5 +1,22 @@ # 决策记录 +## 2026-08-17 M1C-2b 隔离工作树实现完成:策划澄清中转、链路派生与预算注入 + +- **当前基线**:`M1C-1`、`M1C-2a` 已合回 `feat/five_min_design`;本隔离分支开工后又以 merge commit `9f12d8467` 合入原分支截至 `a8215a599` 的全部已提交改动,包含 P4 的 Fast GDD 识别顺序修复与 P5 的委派栅栏 detail 等价性锁定。原工作树未提交的 `planning_approval.rs` 不属于该合并且未触碰。M1C-2a 的固定 Goal Contract、验收图与审批前置门作为既有前置,不在本包回改。 +- **本包范围**:只把已发布的 `AGC_NEEDS_USER_INPUT_V1` 子 Agent → Supervisor 中转链接入 Fast GDD 的 planning session:回答以 `(requestId, answersSha256)` 原子绑定原 delivery 后,严格派生唯一 continuation identity、更新 `appliedAnswers` / session phase / active run 投影,并让 planning 子 Agent 的后续 Provider 请求获得当前澄清轮次与累计活跃时间的受控上下文。 +- **不在范围**:不重做通用 user-input、静态委派深度/轮次规则、审批 UI、hydrate、构建准入、完整下游构建或 game-chat 链路;不把用户答案正文写入公共审计,也不把 session 当作轮次真相。 +- **先行不变量**:轮次仍只由 delivery lineage 的 `clarification_round` 派生,非 game-chat 上限为 3;同一 `(parentRunId, delegationId, questionsSha256, answersSha256)` 只能派生一个 continuation;同一原 delivery 绑定不同 request 或答案必须失败关闭;`accumulatedAgentMillis` 只计 Provider 活跃区间、仅单调增加,不计用户/审批等待或进程休眠时间。 +- **编码前裁决**:现役 answer sidecar 只有题目、原始回答及 transport hash,不能提供 session schema 对 `decisionsSummary` / `prototypeValidationItems` 的必需字段;若等 continuation Provider 补字段,该 Provider 又会先被 `appliedAnswers.length != clarification_round` 拒绝。故冻结 Runtime 的确定性派生:从固定问题前缀提取 topic,按三个固定选项/自由填写映射 state、answerSource、answerSummary;“需要原型验证”同步生成固定四字段 30~90 分钟微型原型项。派生失败或已有同 ID 内容不一致均失败关闭,不请求 Provider 猜测。 +- **开工验证计划**:先以现有澄清中转回归为基础,补 planning 专属的三轮边界、答案冲突、重复 wake/continuation 幂等、session 链与预算注入断言;随后运行对应 Rust 定向测试、格式/编码/diff 门禁。实现和验证结论在本条持续补充。 +- **当前实现**:新增 planning coordinator,在首个 `project-planning` child 落 revision 1 initial-request session;`NeedsUserInput` delivery 投影为 `awaiting_user_input`;回答绑定且 continuation child durable 后,在同一项目写锁内严格派生 `appliedAnswers`、`decisionsSummary`、`prototypeValidationItems`、`collecting + activeRunId`。固定三选项及自由填写均按技术方案映射,问题必须是单题、`第N轮·关键决定`、固定三选项且 N 为 1~3;第四轮在建立 Supervisor pending 前失败关闭。 +- **恢复与锁序**:本条只约束 **M1C-2b 新增的 planning 澄清写投影路径**,不把结论扩大到整个 Agent Runtime。Supervisor 直接回答先以只读候选判别是否为 planning 澄清,再按 `project write lock → execution lock` 重取并在双锁内重读 pending;`answer-prepared` 恢复先释放旧 execution lock,再按同一顺序重取,期间旧候选若已被并发回答、替换或清理,只按 obsolete candidate 让路,不把合法前滚误标为 reconciliation;planning parent-wake 先把父 run 持久化为 `waiting-for-delegate-receipts`,待主循环返回并释放 execution lane 后,再在 lane 外按 `project → execution` 创建唯一澄清 pending,lane 忙时只 deferred、重放不增加 session revision 或 action。恢复仍在 planning child 进入 Provider 路径前补 session 投影;已精确投影的 retry/provider handoff 只恢复冻结请求,不因 usage fold 的 `Deferred` 误进 reconciliation。**边界说明**:`main_loop.rs` 既有通用 completion blocker 仍存在 execution lane 内调用 project-lock wrapper 的路径,它不是 M1C-2b 新增逻辑,也不在本包重构范围;因此本包不得表述为“项目写锁始终先于全部 Session lane / execution lock”。 +- **预算事实**:Provider 真实 future 的 completed / failed / interrupted 活跃区间以 requestId create-only fact 写入 Agent DB;同 ID 内容冲突失败关闭。下一次新的 planning request 在项目锁内、重建 request 前折叠合法 facts 到 `accumulatedAgentMillis`,因此冻结 binding 不会在 Provider 返回处漂移;项目锁、请求构造、用户/审批等待、retry backoff、handoff、工具执行与停机时间均不计入。fold 发现当前 run 仍有 ready / executing lifecycle 时返回 `Deferred`,不擅自改写 session。末次 `plan.submit_gdd` 的 usage fact 会被 v4 submit batch 暂时挡住;receipt 已完成 session 投影且精确消费 standalone/v4 anchors 后,同一项目锁内再 fold,确保直接 approve 而无下一次 planning request 时该区间也计入 session;任何 deferred/identity/I/O 异常只留 `recoveryPending`,不强写。 +- **本轮已修的明确缺陷**:plan 回答读取曾把 opaque `taskId` 误与 Supervisor `agentId` 比较,会令第一轮 continuation 必然失败。现改为读取同一 parent run 的 Supervisor root task,并精确核对 taskId、agentId、sessionId、runId、source、requestId、questionsSha256 与 answersSha256;回答 sidecar 的共用 payload 校验保持完整,不降低普通 user-input 的身份校验。 +- **终审修复**:完成至少一轮澄清后,审批 `revise/reject` 会保留 `appliedAnswers`,但新修订 delivery 的身份不再等于最后回答 continuation;旧纯 session 判据会把合法修订 successor 固定拒成 `PLAN_IDENTITY_CONFLICT`。现把独立 schema 校验收窄为“不得回退到已消费问题 delivery”,并在 session 新值、已有 primary/previous、发布后回读及普通读取边界读取真实 static-delivery 谱系:从 latest 回到最后回答 continuation 的**每一条边**都必须由父 delivery 的 `UserRevisionRequested` 状态授权,且 root/agent/session 身份一致、无 Unknown、缺节点或循环;质量返工边不得借路径中其它用户修订继续保留旧回答。正向回归同时覆盖 `revise/reject` 后 continuation、轮次/回答/决定保留与 Provider 注入;负向回归证明混入质量返工边时,即使重算合法 session fingerprint 仍失败关闭。 +- **门禁中修复的测试缺陷**:并发整组首次复跑时,锁序测试把“回答线程开始”误当成“已得到调度”,180ms 内未观察到 project lock 竞争而失败;同用例精确复跑通过。测试只将调度观察窗口放宽到 2 秒,断言仍要求真实 project lock 竞争发生后才释放被占用的 execution lane,未改变生产锁序或放松结果判据。 +- **保留观察**:第四轮违规澄清信封当前会在建立 pending 前失败关闭并进入 reconciliation,而方案目标是第三轮后由 planning 子 Agent 转入 submit。现有回归明确只证明“不建立第四轮 pending”,未证明强制 submit;修复会扩到更宽的 Provider/终态状态机,当前也未引起本包测试失败,按缺陷处置规则留待后续单列,不在 M1C-2b 收口中顺手扩修。 +- **最终门禁证据**:`planning_clarification_*` **11 passed / 0 failed**(原 9 条之外新增真实 main-loop 释放 execution lane 后 parent-wake 回归,以及已回答后 `revise/reject` 修订回归);`tests::collaboration::static_deliveries::*` **44 passed**;planning storage **13 passed**(含重新计算 fingerprint 的混合质量返工谱系负例);`planning_submit` **53 passed**;`planning_provider_usage` **4 passed**;真实末次 submit usage receipt 回归 **1 passed**;`barrier_detail_*` **3 passed**。`cargo fmt --check`、`cargo check --offline --all-targets --target-dir target-m1c2b`、`npm run check:encoding`(7810 files)及整个工作树 `git diff --check` 均通过。**M1C-2b 本包实现及门禁已完成,仍只在隔离分支,尚未合回原分支。** + ## 2026-08-15 M1C-2a 隔离工作树实现:固定 Goal Contract 与审批前置门 - **本轮范围**:只实现 Supervisor 根 run 的 Goal Contract / Acceptance Graph 与 Fast GDD 审批前置门;不接 `M1C-2b` 澄清中转、审批 UI、构建准入或下游完整构建。当前变更仍在隔离 worktree,尚未合回原分支。 diff --git a/docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md b/docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md index 751562f89..968d9f434 100644 --- a/docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md +++ b/docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md @@ -1,9 +1,9 @@ # 立项策划 Agent(Fast GDD)技术方案 - 日期:2026-08-10 -- 状态:2026-08-12 **M0 代码工作包全部完成**(`M0A-2`、`M0B-1`、`M0B-2` 已合入 M0 集成分支并通过各自门禁,见第 23.4 节);同日 **D6 作废、拓扑改变**,`M0A-1` 交付的文档基线随之失效,需以工作包 `M0A-3` 修订,**修订完成前 M0 不计完整完成**(见第 1.1 节)。2026-08-13 **D9 二次作废、D10 作废,由 D11 取代**:立项策划节点改为 Project Supervisor 通过 `agent.delegate` 发起的静态委派子 Agent,问询复用 PR #165 中转链路(见第 1.1 节「D11 新拓扑」);D11 依赖 WP1(静态委派澄清轮次与返工深度拆分)为强制前置,**该前置已于 2026-08-13 落地并合入**(`WP1` 生产代码 + `WP2` 回归,完成状态与门禁见第 23.5 节),澄清轮次上限现为 3(game-chat source 仍为 1)。随后 `M1A-1`、`M1A-2`、`M1A-3`、`M1A-4` 已分别落地:`M1A-2` 仅收口两层工具面、`project-planning` role brief 注入和 fail-closed 拒绝边界,`M1A-4` 收窄 plan 根 run 的子 Agent 创建面。**2026-08-14 `M1B-1` 已通过门禁并合入本分支**:已落地 `.agent/planning` storage module、strict schema/typed 指纹/canonical parser、GDD 版本链、session 原子恢复、Runtime 写入身份及只挡写门禁;golden vector 与 11 个定向 storage 测试通过,writer/index/recovery 门禁已完成。**2026-08-15 `M1B-2` 工作包已通过本包门禁并以 `27c3eb847` 合入本分支**:已落地 `plan.submit_gdd`、exact planning Provider binding/structured injection、专用提交点与崩溃恢复;本包不包含 `gdd-approval` planning pending、审批等待、receipt、审批命令或 UI。**2026-08-14 `M1C-0` 已合回本分支**:仅新增用户修订状态及 lineage 分类,不包含审批写入方。**2026-08-15 `M1C-0b` 已通过定向门禁并完成**:只改静态委派 durable status 的前向兼容读路径,未知字符串显式保留为 `Unknown(raw)` 并最大化阻塞;不含审批写入方。**当前隔离 worktree 已完成并通过 `M1C-2a` 本包门禁**:已接通固定 Goal Contract、Supervisor 根 run 的完整分页 `file.read` evidence、claim 后三态 acceptance gate、审批 pending 恢复及 completion/finalization 门;改动尚未合回。**`M1C-2b`、审批 UI、构建准入与下游完整构建仍后置,M1 整体不可交付**(见第 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)` 并最大化阻塞;不含审批写入方。`M1C-1` 与 `M1C-2a` 已提供审批核心、固定 Goal Contract、完整分页 `file.read` evidence、claim 后三态 acceptance gate、审批 pending 恢复及 completion/finalization 门。**当前隔离 worktree 的 `M1C-2b` 已完成本包实现并通过门禁,尚未合回**:planning 澄清中转、确定性 continuation/session 投影、审批后用户修订谱系、Provider 活跃时间预算与末次 submit usage fold 已实现,11 条 `planning_clarification_*` 回归及关联 Rust 门禁通过。审批 UI、hydrate、构建准入与下游完整构建仍后置,M1 整体不可交付(见第 23.6、23.8 节)。 - 适用范围: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 的前向兼容读路径,`M1C-1` 已提供 receipt/审批核心、投影恢复和 plan 根完成门;当前隔离 worktree 的 `M1C-2a` 已补齐固定 Goal Contract、验收图证据与生产 acceptance gate。`M1C-2b` 澄清中转、审批 UI、构建绑定和正式入口仍不可用 +- 当前实现边界:本文件是后续详细设计与实现的仓库内阶段基线;M0 工作包冻结 Fast GDD 合同并修复现有 owner 验证、game-chat retry 与前端投影边界,`M1A-1`~`M1A-4` 已提供 plan source、两层工具面、角色 brief 与子 Agent 创建面收窄的 Runtime 基础,`M1C-0` 已提供用户修订 lineage 分类,已合入的 `M1B-1` 提供 storage 基础与写入隔离;`M1B-2` 已提供提交点与恢复,`M1C-0b` 已补齐静态委派未知 durable status 的前向兼容读路径,`M1C-1` 已提供 receipt/审批核心、投影恢复和 plan 根完成门,`M1C-2a` 已补齐固定 Goal Contract、验收图证据与生产 acceptance gate。当前隔离 worktree 的 `M1C-2b` 已实现 planning 澄清中转、三轮确定性 session/continuation 投影、审批后修订谱系和 Provider 活跃时间预算,并通过本包 Rust 门禁;审批 UI、hydrate、构建绑定和正式入口仍不可用 ## 1. 背景与目标 @@ -454,7 +454,7 @@ exact plan source 的 `user.input_request` 仍是四个 action tool 之一,不 } ``` -三个 option 的标签与顺序必须逐字等于上表;plan question ID 在现役 snake_case 规则上进一步限制为最多 32 个 ASCII 字符,并且不能映射成 `initial-request`。Runtime 确定性令 `decisionId = questionId.replace('_', '-')`,因此该 ID 必然满足第 8.3 节 GDD decision ID 合同,Provider 不能另选身份。Provider batch 仍须满足 `user.input_request` sole-action 规则;exact plan 的普通 tool-plan 只要产生 action,即使仅一项也必须强制 durable 写 v4 batch,不能走现役“少于两项则 NotNeeded”的优化。Supervisor 的 `user.input_request` 与策划子 Agent 的 `plan.submit_gdd` 因此各有唯一 v4 member;exact planning 的 `final-reply`、`context-compaction`、`final-reply-context-compaction` 无 action、不创建 batch,但仍写第 12 节 v3 lifecycle/binding。非 plan source 的 input wire、数量和校验保持现状,任何额外 plan metadata 都因 unknown field 失败。 +三个 option 的标签与顺序必须逐字等于上表;plan question ID 在现役 snake_case 规则上进一步限制为最多 32 个 ASCII 字符,并且不能映射成 `initial-request`。Runtime 确定性令 `decisionId = questionId.replace('_', '-')`,因此该 ID 必然满足第 8.3 节 GDD decision ID 合同,Provider 不能另选身份。D11/M1C-2b 下 Supervisor 的 `user.input_request` 不是 Supervisor Provider tool-plan action:它由 Runtime 在认领 `NeedsUserInput` delivery 后、释放父 run execution lane,再经 planning parent-wake 直接投影为唯一 pending,因此不创建 Supervisor v4 Provider action batch。exact planning 子 Agent 的普通 tool-plan 只要产生 action,即使仅一项也必须强制 durable 写 v4 batch,不能走现役“少于两项则 NotNeeded”的优化;`plan.submit_gdd` 仍是该 batch 的唯一 member。exact planning 的 `final-reply`、`context-compaction`、`final-reply-context-compaction` 无 action、不创建 batch,但仍写第 12 节 v3 lifecycle/binding。非 plan source 的 input wire、数量和校验保持现状,任何额外 plan metadata 都因 unknown field 失败。 > **2026-08-13 按 D11 重写本节后半(原 D10「Runtime 直投」状态机整段作废)。** 原文描述的链路是:策划节点持续存活于同一 run,自己调 `user.input_request`,Runtime 在同一 run 内截获、写 `activeQuestion` session checkpoint,回答后再发一次 `plan-decision-checkpoint` 专用 Provider 请求取得设计解释,并以新 session primary 作为线性化点。D11 下这条链路的每一环都换了承载物,且**不是换实现是换机制**——用的是 PR #165 已发布、已有回归覆盖的静态委派澄清中转,不再自造状态机。 @@ -470,16 +470,19 @@ exact plan source 的 `user.input_request` 仍是四个 action tool 之一,不 **Supervisor 侧的 `user.input_request`。** 转达用的仍是现役 strict input、仍是 `questions` 恰好一题、三个 option 的标签与顺序仍须逐字等于上表。plan question ID 在现役 snake_case 规则上进一步限制为最多 32 个 ASCII 字符,且不能映射成 `initial-request`。回答提交沿用现役 `requestId + responseId + answers` transport;规范化答案精确等于三个固定 label 之一时按上表识别为 option,其它值一律是自由填写,不能由 UI 另传一个未持久化的「答案类型」布尔值。plan 回答额外限制为 1~400 scalar,不得截断。 -**转述保真是本方案唯一没有机制兜底的地方,此处如实记录。** Runtime 只校验 `questionsSha256` / `answersSha256` 的哈希绑定,**不校验转述内容与已确认答案的语义一致性**。做结构相等校验的 `static_delegate_clarification_pending_matches_delivery_at` 唯一生产调用点(`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_recovery.rs:791`)外层套着 `run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD`,而做方案链路跑 `standard`,**该校验根本不触发**。因此在本链路上,Supervisor 既可以自行发起提问,也可以改写子 Agent 的问题原文,Runtime 都不拦。两个后果都要写清楚: +**M1C-2b 的确定性决定投影。** continuation 子 Run 的首个 Provider request 必须先取得合法 session,而 `appliedAnswers` 又必须引用同轮 `decisionsSummary`;因此不能等待该子 Run 再补决定字段。Runtime 在 delivery 已绑定回答、continuation 任务已 durable 后,从原 user-input sidecar 确定性派生本轮投影:`decisionId = questionId.replace('_', '-')`;`topic` 取问题正文固定前缀 `当前要决定:` 之后、首个 `。/;/,/?/?` 之前的规范化 1~80 scalar;三个固定选项分别映射为上表状态与来源,`answerSummary` 固定为“选项 label:该选项 description”,自由填写则逐字使用规范化回答。选择“需要原型验证”时,Runtime 同时生成同 ID 的固定四字段微型原型项:问题=`验证“{topic}”是否成立`,最小原型=`用 30~90 分钟制作只覆盖“{topic}”的最小可交互原型`,观察=`记录玩家在无额外提示时的行为与对“{topic}”的口头解释`,通过标准=`至少 3 次独立试玩中有 2 次出现预期行为,且测试者能说明对应取舍`。其它回答不得生成原型项。任一题目形状、轮次 header、文本上限或既有同 ID 投影不一致都失败关闭;不得请求 Provider 猜字段,也不得覆盖已落 session。 -- **问题侧**:用户看到的问题可能不是子 Agent 想问的。 -- **答案侧**:用户的答案物理落在 Supervisor 的会话文件里,**不在子 Agent 的会话里**。子 Agent 对「用户答了什么」的全部认知,来自 Supervisor 写进 continuation 委派 task 文本的转述(硬上限 `AGENT_RUNTIME_TASK_MAX_CHARS = 4_000`,超限直接 `Err`,不静默截断)。漏一条或改写一条,子 Agent 就会按空白重问或按错误前提出稿。 +**M1C-2b 已把 planning 链路的问答保真从 Prompt 约束提升为结构约束。** Runtime 只允许把已认领、`targetAgentId=project-planning`、`contractStatus=NeedsUserInput` 的原 delivery 投影成澄清 pending;展示前逐字校验单题、轮次 header、固定三选项、`questionsSha256` 与当前 planning session lineage。回答仍物理落在 Supervisor 会话及私有 user-input sidecar,但 `(requestId, questionsSha256, answersSha256)` 必须原子绑回同一 delivery;continuation identity 与这些指纹绑定,coordinator 再直接读取原 sidecar,确定性生成 `appliedAnswers` / `decisionsSummary` / `prototypeValidationItems` 并注入 planning 子 Agent 的首个 Provider 请求。因此 Supervisor 的自然语言 continuation task 不再是子 Agent 获取已确认答案的唯一事实源,改写 task 文案不能改写结构化答案事实。 -这是**产品约束不是机制约束**,M1 前只有 Prompt 兜底。要把它变成机制约束,须为 `standard` 下的 plan 根 run 单独接一道等价校验(见第 23.6 节「待执行项」)。本文档不假装该校验已经存在。 +**审批后的用户修订继续保留已确认问答,但只跨用户修订边。** 完成至少一轮澄清后,`revise/reject` 产生的新 planning delivery 可以沿同一 session 继续 `collecting`,保留既有 `appliedAnswers`、`decisionsSummary` 与原型验证项。该继承不能只靠可重算的 session fingerprint:Runtime 在 session 写入、现有 primary/previous 读取、发布后回读与普通恢复读取边界,都从 `latestDelegationId` 反向核真实 static-delivery 谱系,直到最后回答 continuation;每一条跨越边必须由其父 delivery 的 `UserRevisionRequested` 状态授权,且 root/agent/session 身份一致、无 Unknown、缺节点或循环。任一质量返工边都必须按 coordinator 规则清空 `appliedAnswers`,不能借更早或最新节点上的用户修订状态保留旧 transport 绑定。 + +边界仍需如实保留:Runtime 不做自然语言语义等价判断,也不要求 Supervisor continuation task 逐字复述答案;上述机制兜底只覆盖 exact planning 澄清,不能反向宣称通用 PR #165 静态委派问答都已获得同等级结构化决定投影。 **轮次计数。** 本轮是第几轮由委派链上的 `clarification_round` 派生值决定(沿 `repair_of_delegation_id` 上溯推断,语义见第 23.5 节),上限 3;session 不再自累加 `roundsUsed`。达到上限后 Runtime 在下一轮子 run 的上下文里注入「必须出稿」,若子 Agent 仍输出信封则拒绝该信封并要求改为 `plan.submit_gdd`。 -**Provider batch 规则。** exact plan 的普通 tool-plan 只要产生 action,即使仅一项也必须强制 durable 写 v4 batch,不走现役「少于两项则 NotNeeded」的优化。Supervisor 侧的 `user.input_request` 与策划子 Agent 侧的 `plan.submit_gdd` 因此各有唯一 v4 member;exact planning 的 `final-reply`、`context-compaction`、`final-reply-context-compaction` 均无 action batch但仍写 v3 lifecycle/binding,planning idle compaction 不支持。非 plan source 的 input wire、数量和校验保持现状,任何额外 plan metadata 都因 unknown field 失败。 +当前实现边界:第四轮违规信封已能在建立 Supervisor pending 前失败关闭,但失败后目前进入 reconciliation,尚未形成“同一子 run 被强制改为 `plan.submit_gdd`”的自动闭环。该差距不影响三轮、重放、身份、锁序与预算门禁,但涉及更宽的 Provider/终态状态机,留待后续单列,不将 M1C-2b 的“第四轮不建 pending”回归夸大为已完成强制 submit。 + +**Provider batch 规则。** exact planning 子 Agent 的普通 tool-plan 只要产生 action,即使仅一项也必须强制 durable 写 v4 batch,不走现役「少于两项则 NotNeeded」的优化,`plan.submit_gdd` 因此是唯一 v4 member。Supervisor 侧的澄清 `user.input_request` 由 Runtime parent-wake 从 delivery 直接投影,不是 Supervisor Provider action、也不创建 v4 batch;该 pending 的 exactly-once 由 delivery/request/answer 指纹、pending identity 与 planning session revision 共同保证。exact planning 的 `final-reply`、`context-compaction`、`final-reply-context-compaction` 均无 action batch但仍写 v3 lifecycle/binding,planning idle compaction 不支持。非 plan source 的 input wire、数量和校验保持现状,任何额外 plan metadata 都因 unknown field 失败。 ### 5.3 Fast GDD 固定内容 @@ -1925,7 +1928,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`、`M1C-1` 已落地并合入**;当前隔离 worktree 的 `M1C-2a` 已完成固定 Goal Contract、完整分页 `file.read` evidence、claim 后三态 acceptance gate、pending exactly-once 恢复与 completion/finalization 接线,并通过本包定向门禁,尚未合回。**`M1C-2b` 澄清中转、审批 UI、构建准入与下游完整构建仍未完成**,因此 M1 整体仍不可交付。合入门见第 23.8 节 | +| 五 | M1 本体:策划闭环功能实现 | **`M1A-1`、`M1A-2`、`M1A-3`、`M1A-4`、`M1B-1`、`M1B-2`、`M1C-0`、`M1C-0b`、`M1C-1`、`M1C-2a` 已落地**;当前隔离 worktree 的 `M1C-2b` 已实现 planning 澄清中转、三轮确定性 session/continuation 投影、审批后用户修订谱系、Provider 活跃时间 usage fact/fold 与末次 submit usage 收口,11 条 planning 澄清定向回归及关联 Rust 门禁通过,尚未合回。**审批 UI、hydrate、构建准入与下游完整构建仍未完成**,因此 M1 整体仍不可交付。合入门见第 23.8 节 | 批二在 2026-08-13 拆成两半,因为其中一半在 M1 代码存在之前**做不完**: @@ -2015,7 +2018,7 @@ M0 完成不表示完整策划闭环已经上线。`M1A-1`~`M1A-4`、`M1B-1` | `M1C-0b` | 静态委派 durable enum 的前向兼容粒度:未知 `contract_status` 解析为显式 `Unknown` 并最大化阻塞 | `M1C-0` | **已完成**:纯读路径、无写入方、审批状态、pending、receipt 或 UI(不含 `M1C-1`)。四种已知 durable 值保持原 serde;未知字符串解析为 `Unknown(raw)`,非字符串仍拒绝,`Serialize` 及读-改-写均原样保留 raw。`Unknown` 计入 completion barrier 与 waiting blocker,返工入口无条件拒绝(含 `depth=0`),lineage 按“其它”最保守分类(`depth + 1`、`round = 0`);planning Provider、自治 liveness、终态扫描等既有读路径同步 fail closed。截断、非法 JSON、非 UTF-8、超过 128 KiB 的 sidecar 仍按整目录 fail closed,不做单条跳过。**不新增或改变 `M1B-*` 功能依赖(仅复核其既有读路径);不包含 `M1C-1` 的审批写入、receipt、UI 或构建准入** | | `M1C-1` | `gdd-approval` pending、审批命令、receipt;receipt 写入上述 status 与 plan 根完成门 | `M1B-2`、`M1C-0`(前向兼容粒度另见 `M1C-0b`) | **已落地并合入**:三动作幂等、版本/指纹竞态防护、receipt 后 index/Markdown/audit/terminal observation/session 投影与恢复、generic v5/v4 anchor 精确消费、terminal summary 完整性校验,以及仅作用于 exact plan 根的只读 completion blocker;生产 acceptance-gate pending caller 与验收前置取证门按拆包纪律由 `M1C-2a` 承接。审批 UI / 澄清中转 / 构建准入仍未完成。连续修订 barrier 与 `UserRevisionRequested` 规则按第 23.7 节执行 | | `M1C-2a` | Goal Contract 接线:turn 1 冻结、固定验收图、审批前置门取证 | `M1C-1`、`M1A-3` | **当前隔离 worktree 已完成并通过本包门禁,尚未合回**:turn 1 的 request-scoped schema 与格式修复都只允许一个固定 `agent.goal_contract`;按项目变化的四项之外,`preferences=[]`、唯一验收节点及证据工具均冻结。Fast GDD evidence 只接受当前 Supervisor 根 run 对 `game/fast_gdd.md` 从第 1 行到 EOF 的同 hash 完整分页;无/旧证据先继续读取,显式 failed 才给原 delivery 的 `repairOfDelegationId`,passed 且 delivery 已认领才建 pending。pending/recovery/completion/finalization 均按同 identity 幂等,审批后 Markdown 改写不损坏 Graph。格式、Provider 强判据、M1C-2a、Acceptance Graph、planning submit/approval、finalization、all-targets、编码与 diff 门禁均通过;扩展 autonomous completion 整组的无关 game-chat 并行超时及精确复跑结果见 decision-log 同日条,不改该路径。不包含 `M1C-2b`、UI 或构建准入 | -| `M1C-2b` | 澄清中转接线、轮次派生、预算注入 | `M1C-2a` | 3 轮上限;continuation 重放幂等不增加轮次;答案绑定冲突被拒 | +| `M1C-2b` | 澄清中转接线、轮次派生、预算注入 | `M1C-2a` | **隔离 worktree 实现与本包门禁已完成,尚未合回**:首 child 的 revision 1 session、`NeedsUserInput → awaiting_user_input`、回答绑定后 continuation 的确定性 session 投影、审批后 `revise/reject` 用户修订谱系及 Provider 活跃时间 usage fact/fold 已接线;末次 `plan.submit_gdd` usage 在 receipt/session successor 落盘且 standalone/v4 anchors 精确消费后于同一项目锁内折叠,真实 receipt 回归证明累计值恰好推进一次,重复审批与 recovery reconcile 不二次推进。`planning_clarification_*` **11 passed / 0 failed**,另有 static deliveries 44、planning storage 13、planning submit 53、Provider usage 4、末次 usage receipt 1、barrier detail 3 条定向回归通过;格式与 offline all-targets 通过,编码/diff 结果见同日 decision-log。锁序承诺只适用于 **M1C-2b 新增的 planning 澄清写投影路径**;`main_loop` 既有通用 completion blocker 的 execution→project 路径不在本包。第四轮违规信封已拒绝 pending,但拒绝后自动转 submit 尚未闭环,按同日保留观察后置。审批 UI、hydrate、构建准入和下游完整构建不在本包范围 | | `M1D-1` | 前端 hydrate 与 GDD 审批卡 | `M1C-2b` | 前端只经 `hydrate_game_creator_plan_gdd_state` 读权威状态,不在页面侧合成批准事实 | | `M1D-2` | 入口分流与阶段进度 | `M1D-1` | 「直接开建」跳过路径与现状零差异 | | `M1E` | 端到端与故障注入收口 | `M1D-2` | 第 21 节测试矩阵中跨层场景 |