子 run 卡在 needs-reconciliation 时把父 run 一起抬出回执等待
现场:上游网关把流掐断在 response.created 之后,策划子 run 落到 needs-reconciliation,父 Supervisor 在 waiting-for-delegate-receipts 上静默 二十分钟,界面全程显示「正在启动处理」。前端认得 needs-reconciliation, 但它读的是父 run,而父 run 看上去一切正常。 成因是三件事叠起来: 1. 委派子 run 的其余每一条终态出口都会向父 run 发布结果—— finish_game_creator_agent_runtime_turn_at、fail_..._turn_at、 fail_..._budget_at 三条都调 publish_game_creator_agent_delegate_result_for_state ——唯独 needs-reconciliation 不发。它在 task_queue 的 outcome match 里从 `outcome => return outcome` 那条兜底臂返回,一个通知都没有。 2. delivery 于是永远停在 Dispatched,而 static_delegate_completion_barrier_at 无条件把 Dispatched 计入 waiting。 3. 父唤醒是一次性事件驱动的:drive_waiting_static_delegate_parent_wake_pass 一旦看到 has_waiting() 为真就永久 return,没有任何周期性复查。 那条出口拒绝**认领结果**是对的——Runtime 无法证明服务端副作用是否已经发生, 伪造一份 delivery 结果比卡住更糟。缺的是「这条委派不会再产生回执」这句话。 改法:在 outcome match 里给 NeedsReconciliation 补一条臂,把父 run 也标成 需要人工核对(复用既有的 mark_static_delegate_parent_wake_needs_reconciliation_at), 不伪造任何 delivery 结果。该原语自带门槛——父 run 的 run_id 不匹配、或父 run 不在 waiting-for-delegate-receipts 时直接返回 Ok(())——所以重复调用与竞态安全。 只覆盖立项策划子 Agent。做游戏与做素材的委派子 run 逐字保持既有行为; 它们那条链路上同一个死锁仍然存在,解开需要另行评估各自的父 run 语义。 守卫的四个前提拿现场 gameagent-75a9be8d 的落盘数据逐条验过:子 run phase=needs-reconciliation、parentAgentId=project-supervisor、parentRunId 与 父 runId 逐字相同、父 phase=waiting-for-delegate-receipts。这个修复会在那次 事故上真的触发。 三条新测试:策划子 run 卡死必须把父 run 抬起来;重复通知无副作用; design-director 子 run 同样卡死时父 run 一个字节都不变。 需要说明的是,被测的是 notify 助手本身,outcome match 那一行接线没有被覆盖 ——驱动一次真实的 NeedsReconciliation 需要 Provider。 delegated_child_reconciliation_tests 3/3、delegation::tests 19/19、 tool_plan 93/93、user_input 21/21、prompt::tests 32/32、 plan_envelope_repair_tests 6/6。tests::collaboration 仍是本机既有的 4 条失败, 已 stash 做 A/B 确认失败集合逐条相同。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -476,7 +476,232 @@ pub(crate) async fn run_game_creator_agent_background_task_with_context(
|
||||
AgentBackgroundTaskOutcome::WaitingForProviderHandoff => {
|
||||
return AgentBackgroundTaskOutcome::WaitingForProviderHandoff;
|
||||
}
|
||||
AgentBackgroundTaskOutcome::NeedsReconciliation => {
|
||||
notify_static_delegate_parent_of_child_reconciliation_at(
|
||||
&root,
|
||||
&agent_id,
|
||||
¤t_run_id,
|
||||
);
|
||||
return AgentBackgroundTaskOutcome::NeedsReconciliation;
|
||||
}
|
||||
outcome => return outcome,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 子 run 停在 needs-reconciliation 时,把父 run 一起抬到人工核对。
|
||||
///
|
||||
/// 委派子 run 的其余每一条终态出口都会向父 run 发布结果——finish / fail / budget 三条
|
||||
/// 都会走 publish_game_creator_agent_delegate_result_for_state——唯独 needs-reconciliation
|
||||
/// 不发。那条出口拒绝**认领结果**是对的:Runtime 无法证明服务端副作用是否已经发生,
|
||||
/// 伪造一份 delivery 结果比卡住更糟。但它连「这条委派不会再产生回执」都没说,于是:
|
||||
///
|
||||
/// - delivery 永远停在 Dispatched;
|
||||
/// - `static_delegate_completion_barrier_at` 无条件把 Dispatched 计入 waiting;
|
||||
/// - 父唤醒是一次性事件驱动的,`drive_waiting_static_delegate_parent_wake_pass` 一旦看到
|
||||
/// `has_waiting()` 为真就永久 return,没有任何周期性复查。
|
||||
///
|
||||
/// 三者叠起来就是死锁。现场形态:上游网关把流掐断在 response.created 之后,策划子 run
|
||||
/// 落到 needs-reconciliation,父 Supervisor 在 waiting-for-delegate-receipts 上静默二十
|
||||
/// 分钟,界面全程显示「正在启动处理」——前端认得 needs-reconciliation,但它读的是父 run,
|
||||
/// 而父 run 看上去一切正常。
|
||||
///
|
||||
/// 这里不伪造任何 delivery 结果,只把父 run 也标成需要人工核对,让状态浮出水面。
|
||||
fn notify_static_delegate_parent_of_child_reconciliation_at(
|
||||
root: &Path,
|
||||
agent_id: &str,
|
||||
run_id: &str,
|
||||
) {
|
||||
// 只覆盖立项策划子 Agent。做游戏与做素材的委派子 run 逐字保持既有行为——它们那条
|
||||
// 链路上同一个死锁仍然存在,要一并解开得另行评估各自的父 run 语义。
|
||||
if agent_id != GAME_CREATOR_PROJECT_PLANNING_AGENT_ID {
|
||||
return;
|
||||
}
|
||||
let Ok(runtime) = read_game_creator_agent_runtime_at(root, agent_id) else {
|
||||
return;
|
||||
};
|
||||
let state = runtime.state;
|
||||
if state.run_id != run_id || state.phase != "needs-reconciliation" {
|
||||
return;
|
||||
}
|
||||
let (Some(parent_agent_id), Some(parent_run_id)) = (
|
||||
state.parent_agent_id.as_deref(),
|
||||
state.parent_run_id.as_deref(),
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
let reason = format!(
|
||||
"委派子 Agent {agent_id} 停在 needs-reconciliation,本条委派不会再产生回执:{}",
|
||||
state.error.as_deref().unwrap_or("(无错误详情)")
|
||||
);
|
||||
// 该原语自带门槛:父 run 的 run_id 不匹配、或父 run 不在 waiting-for-delegate-receipts
|
||||
// 时直接返回 Ok(()),所以重复调用与竞态都是安全的。
|
||||
let _ = mark_static_delegate_parent_wake_needs_reconciliation_at(
|
||||
root,
|
||||
parent_agent_id,
|
||||
parent_run_id,
|
||||
&reason,
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod delegated_child_reconciliation_tests {
|
||||
use super::*;
|
||||
|
||||
struct WedgedPair {
|
||||
temporary: tempfile::TempDir,
|
||||
parent_run_id: String,
|
||||
child_run_id: String,
|
||||
}
|
||||
|
||||
/// 造一对现场同构的父子 run:父停在 waiting-for-delegate-receipts,子停在
|
||||
/// needs-reconciliation,delivery 还是 Dispatched。
|
||||
fn wedged_pair(tag: &str, child_agent_id: &str) -> WedgedPair {
|
||||
let temporary = crate::tests::canonical_test_tempdir("delegate-child-reconciliation-");
|
||||
let root = temporary.path();
|
||||
init_local_game_project_at(root, tag, "子 run reconciliation 冒泡测试")
|
||||
.expect("init project");
|
||||
|
||||
let parent_run_id = format!("{tag}-supervisor-run");
|
||||
bind_game_creator_agent_runtime_run_profile_at(
|
||||
root,
|
||||
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
|
||||
&parent_run_id,
|
||||
AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE,
|
||||
Some(AGENT_RUNTIME_RUN_PROFILE_STANDARD),
|
||||
None,
|
||||
)
|
||||
.expect("bind parent run profile");
|
||||
let mut parent = start_game_creator_agent_runtime_task_at(
|
||||
root,
|
||||
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
|
||||
"委派 project-planning 收敛 Fast GDD",
|
||||
&parent_run_id,
|
||||
AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE,
|
||||
"等待专业 Agent 回执",
|
||||
vec!["取得可审批的 Fast GDD".to_string()],
|
||||
)
|
||||
.expect("start parent run");
|
||||
parent.status = "running".to_string();
|
||||
parent.phase = "waiting-for-delegate-receipts".to_string();
|
||||
append_game_creator_agent_runtime_task(root, &parent).expect("append parent task");
|
||||
write_game_creator_agent_runtime_state(root, &parent).expect("write parent state");
|
||||
|
||||
let child_run_id = format!("delegated-{tag}-child-run");
|
||||
bind_game_creator_agent_runtime_run_profile_at(
|
||||
root,
|
||||
child_agent_id,
|
||||
&child_run_id,
|
||||
"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(parent_run_id.clone()),
|
||||
delegation_id: Some(format!("delegation-{tag}")),
|
||||
}),
|
||||
)
|
||||
.expect("bind child run profile");
|
||||
let mut child = start_game_creator_agent_runtime_task_at(
|
||||
root,
|
||||
child_agent_id,
|
||||
"根据用户初始需求形成 Fast GDD",
|
||||
&child_run_id,
|
||||
"agent-delegate",
|
||||
"生成澄清信封",
|
||||
vec!["提出一个关键决定".to_string()],
|
||||
)
|
||||
.expect("start child run");
|
||||
child.status = "failed".to_string();
|
||||
child.phase = "needs-reconciliation".to_string();
|
||||
child.parent_agent_id = Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string());
|
||||
child.parent_run_id = Some(parent_run_id.clone());
|
||||
child.error = Some(
|
||||
"provider-request-needs-reconciliation: requestId=provider-request-abc".to_string(),
|
||||
);
|
||||
append_game_creator_agent_runtime_task(root, &child).expect("append child task");
|
||||
write_game_creator_agent_runtime_state(root, &child).expect("write child state");
|
||||
|
||||
WedgedPair {
|
||||
temporary,
|
||||
parent_run_id,
|
||||
child_run_id,
|
||||
}
|
||||
}
|
||||
|
||||
fn parent_phase(root: &Path) -> String {
|
||||
read_game_creator_agent_runtime_at(root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID)
|
||||
.expect("read parent runtime")
|
||||
.state
|
||||
.phase
|
||||
}
|
||||
|
||||
/// 现场形态:网关把流掐断,策划子 run 落到 needs-reconciliation。此前它是**唯一**
|
||||
/// 不向父 run 发布任何东西的终态出口,父 run 因此在 waiting-for-delegate-receipts
|
||||
/// 上长眠——delivery 永远是 Dispatched,屏障永远 has_waiting,而父唤醒是一次性的。
|
||||
#[test]
|
||||
fn a_wedged_planning_child_lifts_its_parent_out_of_the_receipt_wait() {
|
||||
let pair = wedged_pair("planning-wedged", GAME_CREATOR_PROJECT_PLANNING_AGENT_ID);
|
||||
let root = pair.temporary.path();
|
||||
assert_eq!(parent_phase(root), "waiting-for-delegate-receipts");
|
||||
|
||||
notify_static_delegate_parent_of_child_reconciliation_at(
|
||||
root,
|
||||
GAME_CREATOR_PROJECT_PLANNING_AGENT_ID,
|
||||
&pair.child_run_id,
|
||||
);
|
||||
|
||||
assert_eq!(parent_phase(root), "needs-reconciliation");
|
||||
let parent =
|
||||
read_game_creator_agent_runtime_at(root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID)
|
||||
.expect("reread parent runtime")
|
||||
.state;
|
||||
assert_eq!(parent.run_id, pair.parent_run_id);
|
||||
let error = parent.error.expect("parent carries the child's reason");
|
||||
assert!(error.contains("needs-reconciliation"), "{error}");
|
||||
}
|
||||
|
||||
/// 重复调用必须无副作用:父 run 已经离开 waiting-for-delegate-receipts 之后,
|
||||
/// 这条通知不得再改写它的任何状态。
|
||||
#[test]
|
||||
fn notifying_twice_leaves_the_parent_untouched() {
|
||||
let pair = wedged_pair("planning-twice", GAME_CREATOR_PROJECT_PLANNING_AGENT_ID);
|
||||
let root = pair.temporary.path();
|
||||
notify_static_delegate_parent_of_child_reconciliation_at(
|
||||
root,
|
||||
GAME_CREATOR_PROJECT_PLANNING_AGENT_ID,
|
||||
&pair.child_run_id,
|
||||
);
|
||||
let first =
|
||||
read_game_creator_agent_runtime_at(root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID)
|
||||
.expect("read parent after first notify")
|
||||
.state;
|
||||
notify_static_delegate_parent_of_child_reconciliation_at(
|
||||
root,
|
||||
GAME_CREATOR_PROJECT_PLANNING_AGENT_ID,
|
||||
&pair.child_run_id,
|
||||
);
|
||||
let second =
|
||||
read_game_creator_agent_runtime_at(root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID)
|
||||
.expect("read parent after second notify")
|
||||
.state;
|
||||
assert_eq!(first.phase, second.phase);
|
||||
assert_eq!(first.error, second.error);
|
||||
}
|
||||
|
||||
/// 做游戏与做素材的委派子 run 逐字保持既有行为:同样卡死,父 run 也不会被这条
|
||||
/// 通知碰一下。它们那条链路上同一个死锁仍然存在,解开需要另行评估父 run 语义。
|
||||
#[test]
|
||||
fn other_lanes_keep_the_existing_behaviour() {
|
||||
let pair = wedged_pair("design-wedged", "design-director");
|
||||
let root = pair.temporary.path();
|
||||
assert_eq!(parent_phase(root), "waiting-for-delegate-receipts");
|
||||
|
||||
notify_static_delegate_parent_of_child_reconciliation_at(
|
||||
root,
|
||||
"design-director",
|
||||
&pair.child_run_id,
|
||||
);
|
||||
|
||||
assert_eq!(parent_phase(root), "waiting-for-delegate-receipts");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user