修通澄清信封被收束门禁拦死的活锁,并给最终回复重试加兜底

策划子 Agent 每轮都在正确地吐 AGC_NEEDS_USER_INPUT_V1 信封(header 带主题、
三选项齐全),却被拒了 26 次,一条生产 run 空转 65 轮直到人工介入。

信封是通过 respond_to_user 交付的,走最终回复通道,于是撞上两道「计划必须全部
完成」的判据:
- runtime_state.rs 收束链首位的 structured_plan_completion_blocker
- runtime_protocol/finalization.rs 上「finalization 必须绑定已全部完成的计划
  快照」这条 journal 持久不变量(第二道,是上面那条测试逼出来的)

而计划里「按用户决定收敛并提交」那一步在用户答之前永远不可能 completed:想问
用户就得先 respond_to_user,问不出去就答不了。对任何含「答完之后再做 X」步骤的
计划,这条判据都不可满足。

以前没炸靠两件偶然:策划子 Agent 不提交结构化计划(判据第一行就跳过),或者
提交了之后肯把没做的步骤标成 completed。翻了同机全部历史 run:4 条没提交计划、
2 条首版 3/4 改成 4/4 放行、这条首版 1/4 之后再没改过——就死了。

修法是判据豁免而不是代填步骤状态:澄清信封是本 run 挂起等用户答,剩余步骤归
用户答复后的 continuation run,把它们标成 completed 是伪造进度。豁免只放行
「计划未完成」这一件事,快照自身的结构合法性仍然逐项校验,其余 blocker 照常。

第二件:最终回复被拦下后 run 原地续跑重试,此前没有任何上限。空转闸只认裸
update_agent_plan,而这里模型每轮都在认真调 respond_to_user,没有任何计数器会
累加。新增 stale_finalization_rounds(持久 Runtime state,与
plan_submit_gdd_rejection_count / plan_update_idle_rounds 同一模式,重启不能把
活锁洗成新的无限 Provider 开销),上限 32——刻意留在两道软闸之上,让自愈路径
先有机会起作用。真实推进后清零。

守门两条:
- 同一份未完成计划,普通交付收束必须被拦(blocker.tool=runtime.plan_update)、
  澄清信封必须放行。两半都实测非空转:撤掉任一处豁免,对应那半立刻红。
- 兜底额度必须高于两道软闸,防止有人把它调到软闸以下抢跑自愈。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-26 10:08:44 +00:00
parent 92e571e1dc
commit 15c0d6f028
7 changed files with 187 additions and 6 deletions
@@ -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(_)),
"澄清信封是挂起等用户答,不能被计划完成度判据拦下"
);
}
@@ -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)
@@ -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<AgentBackgroundTaskOutcome, String> {
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,
@@ -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
@@ -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,
@@ -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)]
@@ -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)]