修通澄清信封被收束门禁拦死的活锁,并给最终回复重试加兜底
策划子 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:
+65
@@ -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)]
|
||||
|
||||
Reference in New Issue
Block a user