From 7ca7e9704bdd7c35b29cfd65b925cd95369b7f27 Mon Sep 17 00:00:00 2001 From: Linghong Date: Thu, 20 Aug 2026 08:48:41 +0000 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=AB=8B=E9=A1=B9=E7=AD=96?= =?UTF-8?q?=E5=88=92=E5=AD=90=20Run=20=E6=8C=81=E9=94=81=E8=87=AA=E9=94=81?= =?UTF-8?q?=E5=AF=BC=E8=87=B4=E7=9A=84=E9=9D=99=E9=BB=98=E5=81=9C=E6=91=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Provider 请求启动路径持有项目写锁时会追加 lifecycle "started",其中策划专属分支 要重新校验冻结的 planning session;而那道校验走的是不持锁版本,会自己再去抢同一 把项目写锁。持锁上下文里必然抢不到,错误又被包成 RECONCILIATION 前缀,主循环见 到该前缀直接静默返回:不写失败态、不发事件、不置 error。结果是策划子 Run 永远停 在 running/planning,父 Run 等一个永远不会来的委派回执。 打点实测整段 prelude 只花 300 毫秒,卡点就在这一行,锁本身 1 毫秒就能拿到——不是 锁竞争,是同一路径自锁。只有做方案会中招:只有 planning 请求带 session binding, 做游戏与做素材进不了这条分支。codex 与 provider 两种模式表现一致,因为这段在模式 分发之前。 按仓内既有 `_at_locked` 惯例补上持锁变体,校验体抽成私有函数供两者共用;生产里唯 一持锁调用点改用新变体。既有用例都在 append 之前就 drop 了锁,持锁形态从来没有被 覆盖过,而且断言的是 error.contains("reconciliation")——真踩了自锁也会因错误恰好 带这个前缀而"通过"。新增用例先断言不持锁版本在持锁上下文里必然失败且带该前缀,把 bug 的形状写进测试,再断言持锁版本成功并真的落了 lifecycle 记录。 Co-Authored-By: Claude Opus 5 --- .../agent/runtime_protocol/planning_submit.rs | 129 +++++++++++++++++- .../runtime_protocol/provider_control.rs | 36 ++++- .../runtime_protocol/real_e2e_checkpoint.rs | 3 +- 3 files changed, 164 insertions(+), 4 deletions(-) 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,