diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_queue.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_queue.rs index fec0877aa..ccbbbdcb2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_queue.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_queue.rs @@ -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"); + } +}