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 17864bb43..65e55eaad 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 @@ -691,8 +691,31 @@ pub(crate) fn validate_plan_provider_session_binding_current_at( binding: &PlanProviderSessionBindingV1, ) -> Result<(), String> { validate_plan_provider_session_binding(binding).map_err(|error| error.to_string())?; - let session = read_plan_session_with_recovery(root) - .map_err(|error| error.to_string())? + let session = read_plan_session_with_recovery(root).map_err(|error| error.to_string())?; + validate_plan_provider_session_binding_against(root, binding, session) +} + +/// 调用方已经持有项目写锁时的同一道校验。 +/// +/// 不持锁的版本会在内部自己去抢项目写锁,在持锁上下文里必然拿不到;而拿不到的 +/// 错误会被上层包成 reconciliation 前缀,主循环见到该前缀直接静默返回,Run 既 +/// 不失败也不重试,父 run 于是永远等不到回执。所以持锁路径必须走这一支。 +pub(crate) fn validate_plan_provider_session_binding_current_at_locked( + root: &std::path::Path, + binding: &PlanProviderSessionBindingV1, +) -> Result<(), String> { + validate_plan_provider_session_binding(binding).map_err(|error| error.to_string())?; + let session = + read_plan_session_with_recovery_locked(root).map_err(|error| error.to_string())?; + validate_plan_provider_session_binding_against(root, binding, session) +} + +fn validate_plan_provider_session_binding_against( + root: &std::path::Path, + binding: &PlanProviderSessionBindingV1, + session: Option, +) -> Result<(), String> { + let session = session .ok_or_else(|| "planning provider session 已丢失,不能创建 durable batch".to_string())?; if session.project_id != binding.project_id || session.gdd_id != binding.gdd_id @@ -2803,6 +2826,108 @@ mod tests { } } + /// 生产里唯一带 planning binding 追加 `started` 的调用点(Provider 请求启动) + /// 是持着项目写锁进来的,而在这次修复之前没有任何用例覆盖持锁形态:既有用例 + /// 都在 append 之前就把锁 drop 了。于是不持锁校验里那次自我抢锁一路失败、被包 + /// 成 reconciliation 前缀、被主循环静默吞掉,策划子 Run 停在 running/planning + /// 不动,父 run 永远等不到回执。这条用例锁的就是持锁形态本身。 + #[test] + fn planning_provider_started_lifecycle_appends_while_holding_the_project_write_lock() { + let (root, context, _) = submit_fixture(); + ensure_agent_conversation_session_at( + &root, + GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + &context.session_id, + "planning Provider locked started", + ) + .expect("ensure planning Provider locked conversation session"); + let mut runtime = start_game_creator_agent_runtime_task_for_session_at( + &root, + GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + Some(&context.session_id), + "形成 Fast GDD", + &context.created_by_run_id, + "agent-delegate", + "验证持锁 lifecycle 追加", + vec!["验证持锁 started".to_string()], + ) + .expect("start planning Provider locked runtime"); + runtime.parent_agent_id = context.parent_agent_id.clone(); + runtime.parent_run_id = context.parent_run_id.clone(); + runtime.delegation_id = Some(context.delegation_id.clone()); + runtime.updated_at = unix_timestamp(); + append_game_creator_agent_runtime_task(&root, &runtime) + .expect("append planning Provider locked task"); + refresh_game_creator_agent_runtime_task_queue(&root, &mut runtime) + .expect("refresh planning Provider locked queue"); + write_game_creator_agent_runtime_state(&root, &runtime) + .expect("persist planning Provider locked runtime"); + let snapshot = capture_game_creator_agent_runtime_provider_request_snapshot( + &root, + &runtime.agent_id, + &runtime.session_id, + &runtime.run_id, + "tool-plan", + "m1b2-locked-started", + runtime.applied_steer_cursor, + ) + .expect("capture planning Provider locked snapshot"); + + let control_lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + &root, + "test.planning_provider_started_under_lock", + ) + .expect("hold the project write lock like the provider request start path"); + let binding = capture_plan_provider_session_binding_for_snapshot( + &root, + &runtime, + &snapshot, + &format!("sha256-serde-json-v2:{}", "8".repeat(64)), + ) + .expect("capture planning Provider locked binding"); + let request_id = binding.provider_request_id.clone(); + let snapshot = snapshot.with_planning_session_binding(Some(binding)); + + // 不持锁版本在持锁上下文里必然失败,而且失败带 reconciliation 前缀—— + // 这正是被静默吞掉的那条错误。 + let error = append_game_creator_agent_runtime_provider_request_lifecycle( + &root, + &snapshot, + &request_id, + "started", + ) + .expect_err("unlocked lifecycle append must not be usable while holding the lock"); + assert!(error.starts_with(AGENT_RUNTIME_PROVIDER_REQUEST_RECONCILIATION_PREFIX)); + assert!(read_agent_db_lifecycle_transitions_at( + &root, + AGENT_RUNTIME_PROVIDER_REQUEST_LIFECYCLE_RECORD_TYPE, + "requestId", + &request_id, + ) + .expect("read lifecycle after the unlocked attempt") + .is_empty()); + + assert!( + append_game_creator_agent_runtime_provider_request_lifecycle_at_locked( + &root, + &snapshot, + &request_id, + "started", + ) + .expect("locked lifecycle append must succeed under the control lock") + ); + assert!(!read_agent_db_lifecycle_transitions_at( + &root, + AGENT_RUNTIME_PROVIDER_REQUEST_LIFECYCLE_RECORD_TYPE, + "requestId", + &request_id, + ) + .expect("read lifecycle after the locked append") + .is_empty()); + drop(control_lock); + cleanup_fixture(root); + } + #[test] fn planning_provider_started_rechecks_parent_and_delegation_binding() { let (root, context, _) = submit_fixture(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/provider_control.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/provider_control.rs index 230d496f4..1e061353a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/provider_control.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/provider_control.rs @@ -320,6 +320,35 @@ pub(in crate::agent) fn append_game_creator_agent_runtime_provider_request_lifec snapshot: &AgentRuntimeProviderRequestSnapshot, request_id: &str, status: &str, +) -> Result { + append_game_creator_agent_runtime_provider_request_lifecycle_with_session_read( + root, snapshot, request_id, status, false, + ) +} + +/// 调用方已经持有项目写锁时的同一次 lifecycle 追加。 +/// +/// `started` + planning binding 这条分支会重新校验冻结的 planning session;那道 +/// 校验的不持锁版本会自己再去抢同一把项目写锁,在持锁上下文里必然失败,而失败会 +/// 被包成 reconciliation 前缀,主循环见到该前缀就静默返回,策划子 Run 于是停在 +/// running/planning 不动。持锁调用方必须用这一支。 +pub(in crate::agent) fn append_game_creator_agent_runtime_provider_request_lifecycle_at_locked( + root: &Path, + snapshot: &AgentRuntimeProviderRequestSnapshot, + request_id: &str, + status: &str, +) -> Result { + append_game_creator_agent_runtime_provider_request_lifecycle_with_session_read( + root, snapshot, request_id, status, true, + ) +} + +fn append_game_creator_agent_runtime_provider_request_lifecycle_with_session_read( + root: &Path, + snapshot: &AgentRuntimeProviderRequestSnapshot, + request_id: &str, + status: &str, + project_write_lock_held: bool, ) -> Result { let snapshot = if let Some(binding) = snapshot.planning_session_binding.as_ref() { let request_slot = game_creator_agent_runtime_provider_request_slot_for_id( @@ -344,7 +373,12 @@ pub(in crate::agent) fn append_game_creator_agent_runtime_provider_request_lifec .planning_session_binding .as_ref() .expect("planning binding checked above"); - validate_plan_provider_session_binding_current_at(root, binding).map_err(|error| { + let validated = if project_write_lock_held { + validate_plan_provider_session_binding_current_at_locked(root, binding) + } else { + validate_plan_provider_session_binding_current_at(root, binding) + }; + validated.map_err(|error| { format!("{AGENT_RUNTIME_PROVIDER_REQUEST_RECONCILIATION_PREFIX}: {error}") })?; } 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 5f96e2c24..16b519b90 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/real_e2e_checkpoint.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/real_e2e_checkpoint.rs @@ -1155,7 +1155,8 @@ where "{AGENT_RUNTIME_PROVIDER_REQUEST_RECONCILIATION_PREFIX}: requestId={request_id}" )); } - match append_game_creator_agent_runtime_provider_request_lifecycle( + // 这里仍然持有上面的 control_lock,必须走持锁版本。 + match append_game_creator_agent_runtime_provider_request_lifecycle_at_locked( root, &snapshot, &request_id,