diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/response_stream_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/response_stream_tests.rs index 477e54c39..54cefdf91 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/response_stream_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/response_stream_tests.rs @@ -1847,3 +1847,68 @@ fn finalization_v4_binds_response_request_slot_into_identity() { .expect_err("tampered v4 responseRequestSlot must break finalization identity"); assert!(error.contains("幂等身份不匹配")); } + +/// 澄清信封退出不受结构化计划完成度判据约束,普通交付收束仍然受。 +/// +/// 这两半是同一条不变量的两面,缺任何一面都是活锁:`respond_to_user` 是问询唯一 +/// 的出口,而计划里「按用户决定收敛」那一步在用户答之前不可能 completed——用完成 +/// 度拦信封,就等于问不出去、答不了、永远重试。实测一条生产 run 因此空转 65 轮。 +#[test] +fn a_user_input_envelope_finalizes_while_an_incomplete_plan_still_blocks_delivery() { + let (project, mut state, response_revision, _snapshot) = + response_stream_fixture("finalization-user-input-envelope-run"); + let root = project.path(); + state.plan_revision = 1; + state.plan_explanation = "先问清核心闭环再出稿。".to_string(); + state.plan = vec!["发起首轮澄清".to_string(), "按用户决定出稿".to_string()]; + state.plan_steps = vec![ + AgentRuntimePlanStep { + index: 0, + title: "发起首轮澄清".to_string(), + status: AGENT_RUNTIME_PLAN_STATUS_IN_PROGRESS.to_string(), + detail: None, + updated_at: unix_timestamp(), + }, + AgentRuntimePlanStep { + index: 1, + title: "按用户决定出稿".to_string(), + status: AGENT_RUNTIME_PLAN_STATUS_PENDING.to_string(), + detail: None, + updated_at: unix_timestamp(), + }, + ]; + state.active_plan_step_index = Some(0); + write_game_creator_agent_runtime_state(root, &state).expect("write incomplete plan state"); + + let delivery = "已完成本轮交付。"; + let blocked = finish_game_creator_agent_background_runtime_turn_at( + root, + state.clone(), + delivery, + response_revision, + &[], + ) + .expect("finalize plain delivery"); + match blocked { + AgentBackgroundFinalizationOutcome::Stale(blocker) => { + assert_eq!(blocker.tool, "runtime.plan_update"); + } + other => panic!("计划未完成时普通交付收束必须被拦下,实际 {other:?}"), + } + + let envelope = format!( + "{STATIC_DELEGATE_USER_INPUT_PREFIX}{{\"questions\":[{{\"id\":\"core_loop\",\"header\":\"第1轮·当前要决定:核心闭环\",\"question\":\"本局主要追求什么?\",\"options\":[{{\"label\":\"A · 推荐:抵达终点\",\"description\":\"沿路线避障抵达终点。\"}},{{\"label\":\"B · 计分生存\",\"description\":\"在加速路线里刷新分数。\"}},{{\"label\":\"需要原型验证\",\"description\":\"各做一个最小原型让目标玩家试玩。\"}}]}}]}}" + ); + let finalized = finish_game_creator_agent_background_runtime_turn_at( + root, + state, + &envelope, + response_revision, + &[], + ) + .expect("finalize clarification envelope"); + assert!( + !matches!(finalized, AgentBackgroundFinalizationOutcome::Stale(_)), + "澄清信封是挂起等用户答,不能被计划完成度判据拦下" + ); +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/finalization.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/finalization.rs index 7cb0b3801..fc5d9579a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/finalization.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/finalization.rs @@ -393,7 +393,11 @@ pub(in crate::agent) fn resume_game_creator_agent_finalization_at( } if journal.status == AGENT_RUNTIME_FINALIZATION_STATUS_PREPARED && !assistant_exists { let current_revision = read_game_creator_agent_runtime_project_revision(root)?; - let blocker = if let Some(blocker) = structured_plan_completion_blocker(&state) { + // 与 `finish_game_creator_agent_background_runtime_turn_with_checkpoint_at` + // 同一判据:澄清信封是挂起等用户答,不是交付收束,用计划完成度拦它会死锁。 + let blocker = if let Some(blocker) = structured_plan_completion_blocker(&state) + .filter(|_| !response_is_static_delegate_user_input_envelope(&journal.response)) + { Some(blocker) } else if let Some(blocker) = plan_gdd_completion_blocker_at_locked(root, &journal.agent_id, &journal.run_id) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs index d2ec3406f..4e690c44f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs @@ -255,6 +255,39 @@ fn finish_plan_submit_business_rejection_limit_at( /// Runtime state 上,进程重启不能把一次活锁洗成新的无限 Provider 开销。 const AGENT_RUNTIME_PLAN_UPDATE_IDLE_LIMIT: u32 = 4; +/// 最终回复被收束门禁拦下后 run 会原地续跑重试。多数 blocker 是模型自己能解的 +/// (补动作、补证据、重新规划),所以这里的额度比上面两个宽得多;它拦的是另一 +/// 类:模型根本无法满足的 blocker。那种情况下每一轮都是同一个请求换来同一个拒绝, +/// 没有任何计数器会累加——空转闸只认裸 `update_agent_plan`,而这里模型每轮都在 +/// 认真调 `respond_to_user`。实测一条生产 run 因此空转 65 轮直到人工介入。 +const AGENT_RUNTIME_STALE_FINALIZATION_LIMIT: u32 = 32; + +fn stale_finalization_limit_reached(rounds: u32) -> bool { + rounds >= AGENT_RUNTIME_STALE_FINALIZATION_LIMIT +} + +fn next_stale_finalization_rounds(current: u32) -> (u32, bool) { + let next = current.saturating_add(1); + (next, stale_finalization_limit_reached(next)) +} + +fn finish_stale_finalization_limit_at( + root: &Path, + runtime: &AgentRuntimeState, +) -> Result { + let error = format!( + "最终回复连续 {} 轮被收束门禁拦下,已停止自动续跑;请检查最后一次 blocker observation 后重新发起本轮任务。", + AGENT_RUNTIME_STALE_FINALIZATION_LIMIT + ); + let failed = fail_game_creator_agent_runtime_turn_at(root, runtime.clone(), &error)?; + let _ = append_game_creator_agent_background_task_failed_audit( + root, + &failed, + AGENT_RUNTIME_BACKGROUND_FAILURE_KIND_STALE_FINALIZATION_LIMIT, + ); + Ok(AgentBackgroundTaskOutcome::Finished) +} + /// 纯只读工具不算「推进」。 /// /// 空转计数只在**裸 `update_agent_plan` 且步骤没有真实变化**时累加,早期实现却让 @@ -350,6 +383,22 @@ mod plan_update_idle_guard_threshold_tests { assert!(first_repair_round < AGENT_RUNTIME_PLAN_UPDATE_IDLE_LIMIT); } + /// 最终回复重试额度是 runaway 兜底,不是主判据:它必须留在两道软闸之上, + /// 让「摘掉 update_agent_plan 逼它调真动作」和空转闸先有机会自愈。调到软闸 + /// 以下,兜底就会抢在自愈之前把正常 run 打断。 + #[test] + fn the_stale_finalization_backstop_sits_above_the_self_healing_guards() { + assert!(AGENT_RUNTIME_STALE_FINALIZATION_LIMIT > AGENT_RUNTIME_PLAN_UPDATE_IDLE_LIMIT); + assert!(AGENT_RUNTIME_STALE_FINALIZATION_LIMIT > PLAN_SUBMIT_GDD_BUSINESS_REJECTION_LIMIT); + assert!(!stale_finalization_limit_reached( + AGENT_RUNTIME_STALE_FINALIZATION_LIMIT - 1 + )); + assert_eq!( + next_stale_finalization_rounds(AGENT_RUNTIME_STALE_FINALIZATION_LIMIT - 1), + (AGENT_RUNTIME_STALE_FINALIZATION_LIMIT, true) + ); + } + #[test] fn idle_limit_is_reached_only_at_the_configured_round() { assert!(!plan_update_idle_limit_reached(0)); @@ -459,6 +508,8 @@ const AGENT_RUNTIME_BACKGROUND_FAILURE_KIND_PLAN_SUBMIT_REJECTION_LIMIT: &str = "plan-submit-validation-retries-exhausted"; const AGENT_RUNTIME_BACKGROUND_FAILURE_KIND_PLAN_UPDATE_IDLE_LIMIT: &str = "plan-update-idle-rounds-exhausted"; +const AGENT_RUNTIME_BACKGROUND_FAILURE_KIND_STALE_FINALIZATION_LIMIT: &str = + "stale-finalization-rounds-exhausted"; const AGENT_RUNTIME_BACKGROUND_FAILURE_KIND_BUDGET: &str = "loop-budget-exhausted"; const AGENT_RUNTIME_BACKGROUND_FAILURE_KIND_FINAL_REPLY: &str = "final-reply-failed"; const AGENT_RUNTIME_BACKGROUND_FAILURE_KIND_FINALIZATION: &str = "finalization-failed"; @@ -531,6 +582,20 @@ async fn run_game_creator_agent_background_task_pass_without_deadline( }; } + // 同上:计数随上一轮的 blocker 一起落盘,重启不能把第 N 次被拦洗成新一轮。 + if stale_finalization_limit_reached(runtime.stale_finalization_rounds) { + return match finish_stale_finalization_limit_at(&root, &runtime) { + Ok(outcome) => outcome, + Err(error) => fail_game_creator_agent_background_context_at( + &root, + &agent_id, + &session_id, + runtime, + &format!("收束已耗尽的最终回复重试失败:{error}"), + ), + }; + } + if continuation.applied_steer_cursor < runtime.applied_steer_cursor { return fail_game_creator_agent_background_context_at( &root, @@ -1578,6 +1643,9 @@ async fn run_game_creator_agent_background_task_pass_without_deadline( { // 本轮至少有一个能推进 durable 状态的动作,计划没有空转。 runtime.plan_update_idle_rounds = 0; + // 同一个判据也给最终回复重试额度解锁:真实推进之后再被拦,是新的一 + // 轮尝试,不该继承上一段死循环的计数。 + runtime.stale_finalization_rounds = 0; } if plan.actions.is_empty() { // blocked 的 plan_gdd blocker 有三种截然不同的继续推进态,phase 与 @@ -3879,6 +3947,9 @@ async fn run_game_creator_agent_background_task_pass_without_deadline( AgentBackgroundTaskOutcome::Finished } Ok(AgentBackgroundFinalizationOutcome::Stale(blocker)) => { + let (stale_rounds, exhausted) = + next_stale_finalization_rounds(runtime.stale_finalization_rounds); + runtime.stale_finalization_rounds = stale_rounds; if let Err(error) = provider_handoff::remove_at(&root, &agent_id, &runtime.run_id) { return fail_game_creator_agent_background_context_at( &root, @@ -3908,6 +3979,20 @@ async fn run_game_creator_agent_background_task_pass_without_deadline( ); } }; + // 计数已随 blocker 一起落盘,这里才收束:让最后一次拒绝的 observation + // 留在续跑上下文里,失败原因指得回具体 blocker 而不是一句「超限」。 + if exhausted { + return match finish_stale_finalization_limit_at(&root, &runtime) { + Ok(outcome) => outcome, + Err(error) => fail_game_creator_agent_background_context_at( + &root, + &agent_id, + &session_id, + runtime, + &format!("收束已耗尽的最终回复重试失败:{error}"), + ), + }; + } AgentBackgroundTaskOutcome::ContinueSameRun { state: runtime, continuation, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/finalization.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/finalization.rs index e4bf34b09..5b2f387d8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/finalization.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/finalization.rs @@ -268,7 +268,13 @@ pub(in crate::agent) fn validate_game_creator_agent_runtime_finalization_journal &journal.plan_steps, journal.active_plan_step_index, )?; + // 澄清信封是本 run 挂起等用户答,不是交付收束:剩余步骤要等用户答复后的 + // continuation run 才做,在这里既不可能 completed,也不该被 Runtime 代填成 + // completed——那是伪造进度。只有真正宣告做完的最终回复才受这条不变量约束。 + // 这里放行的是「计划未完成」这一件事;快照自身的结构合法性仍由上面的 + // `validate_agent_runtime_structured_plan_snapshot` 逐项校验。 if journal.plan_revision > 0 + && !response_is_static_delegate_user_input_envelope(&journal.response) && (journal.active_plan_step_index.is_some() || journal .plan_steps diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_state.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_state.rs index 2f1829a55..2eaccbcbd 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_state.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_state.rs @@ -1204,7 +1204,13 @@ where )); } let current_revision = read_game_creator_agent_runtime_project_revision(root)?; - let blocker = if let Some(blocker) = structured_plan_completion_blocker(&state) { + // 澄清信封不是交付收束,是本 run 挂起等用户答。结构化计划完成度判据对它不可 + // 满足:想问用户就得先 respond_to_user,而计划里「按用户决定收敛并提交」那一 + // 步在用户答之前永远不可能 completed,于是问不出去、答不了、永远转。实测一条 + // 生产 run 因此空转 65 轮直到人工介入。其余判据仍然照常生效。 + let blocker = if let Some(blocker) = structured_plan_completion_blocker(&state) + .filter(|_| !response_is_static_delegate_user_input_envelope(response)) + { Some(blocker) } else if let Some(blocker) = game_creator_agent_goal_completion_blocker_at_locked(root, &state) { @@ -1603,6 +1609,7 @@ pub(crate) fn default_game_creator_agent_runtime_state( loop_iteration: 0, plan_submit_gdd_rejection_count: 0, plan_update_idle_rounds: 0, + stale_finalization_rounds: 0, max_loop_iterations: AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT as u32, tool_action_budget: AGENT_RUNTIME_BACKGROUND_TOOL_ACTION_LIMIT as u32, plan_revision: 0, diff --git a/apps/ai-game-creator-shell/src-tauri/src/delegation.rs b/apps/ai-game-creator-shell/src-tauri/src/delegation.rs index 67cba1ea8..f49737d6b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/delegation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/delegation.rs @@ -28,16 +28,24 @@ pub(crate) fn static_delegate_result_detail_max_chars( value: &str, default_max_chars: usize, ) -> usize { - if value - .trim_start() - .starts_with(STATIC_DELEGATE_USER_INPUT_PREFIX) - { + if response_is_static_delegate_user_input_envelope(value) { STATIC_DELEGATE_USER_INPUT_MAX_RESPONSE_CHARS } else { default_max_chars } } +/// 这条回复是不是澄清信封,而不是一次交付收束。 +/// +/// 收束门禁按「任务是否做完」判据拦最终回复,而澄清信封恰恰相反:它是本 run +/// 就此挂起、把决定权交回用户,剩下的工作由用户答完之后的 continuation run 接着 +/// 做。用完成度判据去拦它,对任何含「答完之后再做 X」步骤的计划都不可满足。 +pub(crate) fn response_is_static_delegate_user_input_envelope(response: &str) -> bool { + response + .trim_start() + .starts_with(STATIC_DELEGATE_USER_INPUT_PREFIX) +} + /// 构造一份贴着问询 schema 上限的合法澄清信封,供跨模块的通道用例复用。 /// 通道必须容得下 schema 允许的最大合法问询,而不只是「碰巧短」的那一条。 #[cfg(test)] diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index 57d7d1595..249cb257c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -313,6 +313,12 @@ struct AgentRuntimeState { /// Provider spend. #[serde(default)] plan_update_idle_rounds: u32, + /// Consecutive final replies this run had refused by a completion blocker. + /// Runtime-owned durable state for the same reason as the two counters + /// above: a blocker the model cannot satisfy is a livelock, and a runner + /// restart must not launder it back into unbounded Provider spend. + #[serde(default)] + stale_finalization_rounds: u32, #[serde(default)] max_loop_iterations: u32, #[serde(default)]