修复立项策划子 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:
+127
-2
@@ -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();
|
||||
|
||||
+35
-1
@@ -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}")
|
||||
})?;
|
||||
}
|
||||
|
||||
+2
-1
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user