子 run 卡在 needs-reconciliation 时把父 run 一起抬出回执等待
Project CI / Backend tests (pull_request) Failing after 13s
Project CI / Repository checks (pull_request) Failing after 13s
Project CI / Frontend tests (pull_request) Successful in 5m51s
Project CI / Native shell tests (pull_request) Successful in 14m36s

现场:上游网关把流掐断在 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:
2026-08-24 09:36:52 +00:00
parent 4a3a5beaf0
commit 4c20e06a9a
@@ -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,
&current_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");
}
}