坏信封重投不得重置澄清轮次,额度按连续次数算

无头验证抓到的回归。上一版把坏信封的免费额度做成整条谱系一个总额,超出后
落回质量返工分支——而那一支会把 round 归零。

实测复现的链:坏信封 → 第1轮 → 第2轮 → 坏信封 → 目标。第二次截断超出总额,
round 被清零,planning_coordinator 于是按 current_round + 1 要求
「第1轮·关键决定」,子 Agent 按真实轮号写的 header 被拒,父 run 卡进
needs-reconciliation:
  PLAN_INVALID_CLARIFICATION: plan question header 必须精确等于 第1轮·关键决定

两处都改:
- 坏信封跳**永远不重置** round。一次输出被截断既没有推进也没有否定任何已
  确认的决定,凭什么把用户已经答过的两轮作废。
- 额度按**连续**次数算,不是整条谱系一个总额。分散在链上的多次截断各自独立;
  连续多次才说明子 Agent 真的不会写这个契约,那时才计返工深度。

跳类型文案跟着改:新增 static_delegate_next_hop_is_free_envelope_retry,
只在本跳确实免费时才说「不消耗返工深度」,否则退回质量返工文案——否则文案
在超额那一跳上是假的。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-24 02:42:29 +00:00
parent 58d9e9a5d4
commit f7644ff5c5
2 changed files with 145 additions and 12 deletions
@@ -938,10 +938,7 @@ pub(crate) fn observe_agent_runtime_agent_delegate_at_locked(
// 读不到 delivery 时退回 Repair 文案:宁可保守,也不凭空宣布这是免费重投。
let envelope_retry = repair_of_delegation_id.as_deref().is_some_and(|original| {
list_static_delegate_deliveries_at(root).is_ok_and(|deliveries| {
deliveries
.iter()
.find(|delivery| delivery.delegation_id == original)
.is_some_and(static_delegate_original_envelope_was_unparsable)
static_delegate_next_hop_is_free_envelope_retry(&deliveries, original)
})
});
let hop_note = match (
@@ -1334,22 +1334,31 @@ pub(crate) fn static_delegate_lineage_counters(
chain.reverse();
let mut depth = 0u32;
let mut round = 0u32;
let mut envelope_retries = 0u32;
let mut consecutive_envelope_retries = 0u32;
for parent in &chain[..chain.len().saturating_sub(1)] {
if static_delegate_original_is_awaiting_clarification(*parent) {
round += 1;
consecutive_envelope_retries = 0;
} else if static_delegate_original_is_user_revision_requested(*parent) {
// 用户明确触发的修订不是 runaway-agent 返工;保留两个运行时派生计数。
} else if static_delegate_original_envelope_was_unparsable(*parent)
&& envelope_retries < STATIC_DELEGATE_ENVELOPE_RETRY_LIMIT
{
// 坏信封重投既不吃返工深度,也不重置澄清轮次:它没有推进也没有否定
// 任何已确认的决定。给的额度是有限的——超出后落回下面的返工分支,
// 由 `repair_depth >= 1` 这道既有的门兜住,不会无限重投
envelope_retries += 1;
consecutive_envelope_retries = 0;
} else if static_delegate_original_envelope_was_unparsable(*parent) {
// 坏信封重投**永远不重置澄清轮次**:一次输出被截断既没有推进也没有否定
// 任何已确认的决定,把 round 归零会让 planning_coordinator 回头要求
// 「第1轮·关键决定」,子 Agent 按真实轮号写的 header 随即被拒,整条
// 父 run 卡进 needs-reconciliation(实测复现过)
//
// 额度按**连续次数**算,不是整条谱系一个总额:分散在链上的多次截断
// 各自独立,连续多次才说明子 Agent 真的不会写这个契约,那时才按质量
// 返工计费,由既有的 `repair_depth >= 1` 兜住。
consecutive_envelope_retries += 1;
if consecutive_envelope_retries > STATIC_DELEGATE_ENVELOPE_RETRY_LIMIT {
depth += 1;
}
} else {
depth += 1;
round = 0;
consecutive_envelope_retries = 0;
}
}
(depth, round)
@@ -1372,6 +1381,26 @@ pub(crate) fn static_delegate_lineage_contains_unknown_contract_status(
}))
}
/// 以 `original_delegation_id` 为父的下一跳,是否还落在坏信封免费重投额度内。
///
/// 额度按连续次数算,所以只需要数**紧挨着链尾**的那串连续坏信封节点。跳类型
/// 文案用它决定说「不消耗返工深度」还是退回质量返工文案——说反了就是撒谎。
pub(crate) fn static_delegate_next_hop_is_free_envelope_retry(
deliveries: &[StaticDelegateDeliveryRecord],
original_delegation_id: &str,
) -> bool {
let Some(chain) = static_delegate_lineage_nodes(deliveries, original_delegation_id) else {
return false;
};
// chain 是 [original .. 根],正好按「从链尾往回数」的顺序排列。
let consecutive = chain
.iter()
.take_while(|node| static_delegate_original_envelope_was_unparsable(node))
.count();
consecutive > 0
&& u32::try_from(consecutive).unwrap_or(u32::MAX) <= STATIC_DELEGATE_ENVELOPE_RETRY_LIMIT
}
fn static_delegate_lineage_nodes<'a>(
deliveries: &'a [StaticDelegateDeliveryRecord],
delegation_id: &str,
@@ -3428,6 +3457,113 @@ mod tests {
assert!(blocked.contains("深度最多为 1"), "{blocked}");
}
/// 坏信封重投永远不重置澄清轮次,额度也按**连续**次数算而不是整条谱系一个总额。
///
/// 生产实测复现过:链是 坏信封 → 第1轮 → 第2轮 → 坏信封 → 目标,第二次截断把
/// round 归零,planning_coordinator 回头要求「第1轮·关键决定」,子 Agent 按真实
/// 轮号写的 header 被拒,父 run 卡进 needs-reconciliation。
#[test]
fn static_delegate_envelope_retry_never_resets_the_clarification_round() {
let root = std::env::temp_dir().join(format!(
"genarrative-static-envelope-round-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("system time after unix epoch")
.as_nanos()
));
init_local_game_project_at(&root, "m1c0-envelope-round", "坏信封不重置轮次测试")
.expect("project init");
let parent_run_id = "m1c0-envelope-round-parent-run";
let write = |delegation_id: &str,
repair_of: Option<&str>,
status: StaticDelegateContractStatus,
unparsable: bool| {
let needs_user_input = status == StaticDelegateContractStatus::NeedsUserInput;
let mut delivery = claimed_static_delegate_for_lineage_test(
parent_run_id,
delegation_id,
repair_of,
status,
);
let result = delivery
.structured_result
.as_mut()
.expect("structured result");
result.user_input_envelope_unparsable = unparsable;
if needs_user_input {
// needs-user-input 的落盘校验要求问题非空且指纹匹配。
let envelope = format!(
"{STATIC_DELEGATE_USER_INPUT_PREFIX}{}",
serde_json::json!({
"questions": [{
"id": "core_loop",
"header": "关键决定",
"question": "本轮要定什么?",
"options": [
{"label": "A", "description": "方案 A"},
{"label": "B", "description": "方案 B"}
]
}]
})
);
let (questions, sha256) = parse_static_delegate_user_input_request(Some(&envelope))
.expect("fixture clarification envelope");
result.user_input_questions = questions.expect("fixture questions");
result.user_input_questions_sha256 = sha256;
}
write_static_delegate_delivery_at(&root, &delivery).expect("write delivery");
delivery
};
let d0 = write(
"m1c0-envelope-round-d0",
None,
StaticDelegateContractStatus::NeedsRepair,
true,
);
let d1 = write(
"m1c0-envelope-round-d1",
Some(&d0.delegation_id),
StaticDelegateContractStatus::NeedsUserInput,
false,
);
let d2 = write(
"m1c0-envelope-round-d2",
Some(&d1.delegation_id),
StaticDelegateContractStatus::NeedsUserInput,
false,
);
let d3 = write(
"m1c0-envelope-round-d3",
Some(&d2.delegation_id),
StaticDelegateContractStatus::NeedsRepair,
true,
);
let d4 = write(
"m1c0-envelope-round-d4",
Some(&d3.delegation_id),
StaticDelegateContractStatus::NeedsUserInput,
false,
);
let deliveries = list_static_delegate_deliveries_at(&root).expect("list deliveries");
assert_eq!(
static_delegate_lineage_counters(&deliveries, &d4.delegation_id),
(0, 2),
"两次截断被 2 轮澄清隔开,各自都在免费额度内,轮次必须保持 2"
);
assert!(
static_delegate_next_hop_is_free_envelope_retry(&deliveries, &d3.delegation_id),
"链尾只有一个连续坏信封,下一跳仍是免费重投"
);
assert!(
!static_delegate_next_hop_is_free_envelope_retry(&deliveries, &d2.delegation_id),
"父节点不是坏信封时不得声称是免费重投"
);
}
#[test]
fn static_delegate_user_revision_preserves_counters_and_bypasses_depth_gate() {
let root = std::env::temp_dir().join(format!(