修复立项策划子 Run 持锁自锁导致的静默停摆

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 <noreply@anthropic.com>
This commit is contained in:
2026-08-20 08:48:41 +00:00
parent af1fc63de2
commit 7ca7e9704b
3 changed files with 164 additions and 4 deletions
@@ -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<PlanSessionV1>,
) -> 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();
@@ -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<bool, String> {
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<bool, String> {
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<bool, String> {
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}")
})?;
}
@@ -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,