回滚坏信封的谱系计费改动,改走原型口径的 run 内重试

Revert f38ce456d 和 f7644ff5c。

对照原型(local-scripts/deisgn_agent)后确认这两个提交修错了层。原型把
信封解析失败当成「本回合没推进流程」,在**同一个 child run 内**注入错误重来,
与「超轮次仍提问」「纯自由文本」共用一份 MAX_WASTED_TURNS=3 的预算;坏信封
根本不会变成一条 delivery,委派谱系、澄清轮次、返工深度、session 投影一个
都碰不到。

生产是让坏信封逃逸成 needs-repair delivery,于是牵动三套编码了同一条假设
(新委派 = 新段落)的不变量。逐道放松的代价已经现形:改完谱系计数撞上
session 投影的 quality-repair 分支,改完那支又撞上
PLAN_IDENTITY_CONFLICT「非 UserRevisionRequested 父边不能保留
appliedAnswers」。

正确做法是在 final reply 返回处就地解析信封、失败则有界重取,不让它逃逸。
届时「重试 3 次仍写不出合法信封」确实是质量问题,按普通质量返工计费与三道
守卫全部自洽,不需要 userInputEnvelopeUnparsable 字段,也不需要
EnvelopeRetry 那支跳类型文案。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-24 03:15:26 +00:00
parent 9ad22bf4ae
commit b7bc4c4442
5 changed files with 0 additions and 316 deletions
@@ -214,19 +214,12 @@ pub(crate) fn observe_agent_runtime_agent_message(
/// 平坦的 depth <= 1 门,那时「唯一返工轮」对澄清跳也成立;本仓库改成按谱系分类后
/// 把预算抬到 3,这句话就变成了假天花板——生产实测 4 次澄清续跑全部命中它,命中后
/// 全部直接出稿,没有任何一个 run 走到第 2 轮。
/// - `EnvelopeRetry`:上一跳的 AGC_NEEDS_USER_INPUT_V1 信封解析不了(实测形态是
/// 输出被截断,字节级前缀与重投那次完全相同、只少了收尾的 `]}`)。它不消耗
/// `repair_depth`,所以这里也不能说「唯一返工轮」——那既是假天花板,又会在做游戏
/// 链路上误触发三个视觉角色的 `replaceExisting=true` 授权。
/// - `None`:普通委派,不加这一段。
pub(in crate::agent) enum StaticDelegateHopNote<'a> {
None,
Repair {
original_delegation_id: &'a str,
},
EnvelopeRetry {
original_delegation_id: &'a str,
},
PlanClarification {
original_delegation_id: &'a str,
rounds_used: u32,
@@ -245,11 +238,6 @@ impl StaticDelegateHopNote<'_> {
StaticDelegateHopNote::Repair {
original_delegation_id,
} => format!("\n\n这是对已认领委派 {original_delegation_id} 的唯一返工轮。"),
StaticDelegateHopNote::EnvelopeRetry {
original_delegation_id,
} => format!(
"\n\n上一轮对已认领委派 {original_delegation_id} 的 AGC_NEEDS_USER_INPUT_V1 信封没能解析完整,多半是那一轮输出被截断。这是同一轮的重投,不是返工轮,不消耗返工深度,也不改变已用澄清轮次。请原样重出上一轮那个问题,并确保信封 JSON 完整闭合到最外层的 `]}}`。"
),
// 预算用尽:planning_coordinator 出卡时会用
// `current_round >= 3` 直接拒掉第四张卡,所以这里不能再邀请提问,
// 只能要求收稿——语义上等价于原型的 INJ_MUST_DRAFT_ROUNDS。
@@ -934,13 +922,6 @@ pub(crate) fn observe_agent_runtime_agent_delegate_at_locked(
} else {
None
};
// 坏信封重投在谱系计数里不吃返工深度,任务正文就不能继续叫它「唯一返工轮」。
// 读不到 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| {
static_delegate_next_hop_is_free_envelope_retry(&deliveries, original)
})
});
let hop_note = match (
repair_of_delegation_id.as_deref(),
plan_clarification_rounds,
@@ -952,11 +933,6 @@ pub(crate) fn observe_agent_runtime_agent_delegate_at_locked(
rounds_limit,
}
}
(Some(original_delegation_id), None) if envelope_retry => {
StaticDelegateHopNote::EnvelopeRetry {
original_delegation_id,
}
}
(Some(original_delegation_id), None) => StaticDelegateHopNote::Repair {
original_delegation_id,
},
@@ -2079,38 +2055,4 @@ mod tests {
"预算用尽时不得再给出下一轮 header,那是一张永远递不上去的卡:{exhausted}"
);
}
/// 坏信封重投既不吃返工深度,任务正文也不能说它是返工轮——那句话同时是做游戏
/// 链路三个视觉角色 `replaceExisting=true` 的授权信号。
#[test]
fn envelope_retry_hop_note_is_neither_a_repair_round_nor_a_clarification_round() {
let retry = render_static_delegate_task_contract(
"任务",
"project-supervisor",
"run-1",
"delegation-new",
&["交付 game/fast_gdd.md".to_string()],
&["game/fast_gdd.md".to_string()],
StaticDelegateHopNote::EnvelopeRetry {
original_delegation_id: "delegation-old",
},
)
.expect("render envelope retry");
assert!(
!retry.contains("唯一返工轮"),
"坏信封重投不是返工轮,复用这句话会误触发 replaceExisting 授权:{retry}"
);
assert!(
!retry.contains("的澄清续跑"),
"重投是同一轮的重发,不得写成推进一轮的澄清续跑:{retry}"
);
assert!(
retry.contains("不改变已用澄清轮次"),
"必须写明重投不推进轮次,否则子 Agent 会把 header 的 N 加一:{retry}"
);
assert!(
retry.contains("delegation-old") && retry.contains("输出被截断"),
"重投必须指出原委派和真实原因,子 Agent 才知道要原样重出并闭合 JSON{retry}"
);
}
}
@@ -69,10 +69,6 @@ pub(crate) fn schema_max_clarification_envelope() -> String {
// 从未承诺给它 3 轮预算,因此取 1;其它 source(包括 Project Supervisor 常规协作)取 3。
const STATIC_DELEGATE_CLARIFICATION_ROUND_LIMIT_DEFAULT: u32 = 3;
const STATIC_DELEGATE_CLARIFICATION_ROUND_LIMIT_GAME_CHAT: u32 = 1;
// 一条谱系上最多给几次「坏信封重投」的免费额度。取 1:信封写坏一次通常是这一轮
// 输出没写完,重投就能拿到同一份内容;连续两次多半是子 Agent 真的不会写这个契约,
// 那就该按质量返工计费,让既有的 repair_depth <= 1 把它兜住。
const STATIC_DELEGATE_ENVELOPE_RETRY_LIMIT: u32 = 1;
// 链上重放的防环 / 防越界上限,远大于设计允许的最大 7 跳,纯粹是安全阀。
const STATIC_DELEGATE_LINEAGE_MAX_HOPS: usize = 32;
@@ -177,11 +173,6 @@ pub(crate) struct StaticDelegateStructuredResult {
pub(crate) user_input_questions: Vec<AgentRuntimeUserInputQuestion>,
#[serde(default)]
pub(crate) user_input_questions_sha256: Option<String>,
/// 本次终态带了 AGC_NEEDS_USER_INPUT_V1 首行但信封 JSON 解析不了。
/// 这是「本轮输出没写完/写坏」,不是子 Agent 的方案质量问题,所以谱系计数
/// 不把它当返工跳。旧记录反序列化为 false,按质量返工照常处理。
#[serde(default)]
pub(crate) user_input_envelope_unparsable: bool,
}
impl Default for StaticDelegateStructuredResult {
@@ -196,7 +187,6 @@ impl Default for StaticDelegateStructuredResult {
error: None,
user_input_questions: Vec::new(),
user_input_questions_sha256: None,
user_input_envelope_unparsable: false,
}
}
}
@@ -440,7 +430,6 @@ pub(crate) fn mark_static_delegate_delivery_ready_at(
error: (terminal_status != "completed").then(|| result_summary.to_string()),
user_input_questions: Vec::new(),
user_input_questions_sha256: None,
user_input_envelope_unparsable: false,
};
mark_static_delegate_delivery_ready_with_result_at(
root,
@@ -1286,18 +1275,6 @@ fn static_delegate_original_is_user_revision_requested(
})
}
/// 上一跳的终态带了信封前缀但 JSON 解析不了——实测的形态是输出被截断,字节级
/// 前缀完全相同、只少了收尾的 `]}`。这属于「这一轮没写完」,重投一次就能拿到
/// 同一份内容,不该按方案质量返工计费。
pub(crate) fn static_delegate_original_envelope_was_unparsable(
delivery: &StaticDelegateDeliveryRecord,
) -> bool {
delivery
.structured_result
.as_ref()
.is_some_and(|result| result.user_input_envelope_unparsable)
}
/// A newer durable contract status is intentionally not a repairable contract.
/// The old client may preserve and report it, but must not manufacture a
/// mutation under semantics it does not understand.
@@ -1334,31 +1311,14 @@ pub(crate) fn static_delegate_lineage_counters(
chain.reverse();
let mut depth = 0u32;
let mut round = 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 返工;保留两个运行时派生计数。
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)
@@ -1381,26 +1341,6 @@ 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,
@@ -1804,9 +1744,6 @@ pub(crate) fn build_static_delegate_structured_result_at(
Ok((questions, sha256)) => (questions, sha256, None),
Err(parse_error) => (None, None, Some(parse_error)),
};
// parse_static_delegate_user_input_request 只在首行确实是信封前缀时才会报错,
// 所以这个标记不会被普通文本终态误置。
let user_input_envelope_unparsable = user_input_parse_error.is_some();
let needs_user_input = completed && user_input_questions.is_some();
let contract_status = if needs_user_input {
StaticDelegateContractStatus::NeedsUserInput
@@ -1872,7 +1809,6 @@ pub(crate) fn build_static_delegate_structured_result_at(
error: derived_error,
user_input_questions: user_input_questions.unwrap_or_default(),
user_input_questions_sha256,
user_input_envelope_unparsable,
})
}
@@ -3374,196 +3310,6 @@ mod tests {
delivery
}
/// 坏信封(首行是 AGC_NEEDS_USER_INPUT_V1 但 JSON 解析不了)重投不吃返工深度,
/// 但额度有限:连续第二次就落回质量返工,由既有的 depth<=1 门兜住。
///
/// 生产实测的形态是输出被截断——重投那次的正文与被截断那次逐字节相同,只多出
/// 收尾的 `]}`。原实现把它记成质量返工,一次字节级截断就把整条委派唯一的返工
/// 额度吃光,之后真出现方案质量问题时已经没有返工可用。
#[test]
fn static_delegate_unparsable_envelope_retry_is_free_once_then_costs_repair_depth() {
let root = std::env::temp_dir().join(format!(
"genarrative-static-envelope-retry-{}-{}",
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-retry", "坏信封重投计费测试")
.expect("project init");
let parent_run_id = "m1c0-envelope-retry-parent-run";
let mut mark_unparsable = |delegation_id: &str, repair_of: Option<&str>| {
let mut delivery = claimed_static_delegate_for_lineage_test(
parent_run_id,
delegation_id,
repair_of,
StaticDelegateContractStatus::NeedsRepair,
);
delivery
.structured_result
.as_mut()
.expect("structured result")
.user_input_envelope_unparsable = true;
write_static_delegate_delivery_at(&root, &delivery).expect("write delivery");
delivery
};
let d0 = mark_unparsable("m1c0-envelope-retry-d0", None);
let d1 = mark_unparsable("m1c0-envelope-retry-d1", Some(&d0.delegation_id));
let deliveries = list_static_delegate_deliveries_at(&root).expect("list deliveries");
assert_eq!(
static_delegate_lineage_counters(&deliveries, &d1.delegation_id),
(0, 0),
"第一次坏信封重投既不吃返工深度,也不动澄清轮次"
);
validate_static_delegate_repair_request_at(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
parent_run_id,
"m1c0-envelope-retry-d2",
"design-director",
&[],
&[],
Some(&d1.delegation_id),
)
.expect("first envelope retry must leave the quality-repair budget intact");
let d2 = claimed_static_delegate_for_lineage_test(
parent_run_id,
"m1c0-envelope-retry-d2",
Some(&d1.delegation_id),
StaticDelegateContractStatus::NeedsRepair,
);
write_static_delegate_delivery_at(&root, &d2).expect("write d2");
let deliveries = list_static_delegate_deliveries_at(&root).expect("list deliveries");
assert_eq!(
static_delegate_lineage_counters(&deliveries, &d2.delegation_id),
(1, 0),
"免费额度只有一次,第二次坏信封按质量返工计费"
);
let blocked = validate_static_delegate_repair_request_at(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
parent_run_id,
"m1c0-envelope-retry-d3",
"design-director",
&[],
&[],
Some(&d2.delegation_id),
)
.expect_err("repeated bad envelopes must fall back to the depth gate");
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!(
@@ -1007,7 +1007,6 @@ fn project_supervisor_static_ready_receipt_over_budget_is_non_mutating() {
error: None,
user_input_questions: Vec::new(),
user_input_questions_sha256: None,
user_input_envelope_unparsable: false,
};
mark_static_delegate_delivery_ready_with_result_at(
&root,
@@ -1467,7 +1467,6 @@ fn canvas_replacement_requires_the_claimed_original_and_unique_repair_contract()
error: None,
user_input_questions: Vec::new(),
user_input_questions_sha256: None,
user_input_envelope_unparsable: false,
});
original.claimed_by_action_id = Some("canvas-replacement-claim-action".to_string());
create_or_read_static_delegate_delivery_at(&root, &original)
@@ -1695,7 +1694,6 @@ async fn canvas_replacement_rejects_parent_run_that_terminates_during_external_r
error: None,
user_input_questions: Vec::new(),
user_input_questions_sha256: None,
user_input_envelope_unparsable: false,
});
original.claimed_by_action_id = Some("canvas-repair-claim-action".to_string());
create_or_read_static_delegate_delivery_at(&root, &original)
@@ -6799,7 +6799,6 @@ fn legacy_pre_clarification_delivery_repair_counts_toward_depth() {
error: Some("缺少 1 个预期产物".to_string()),
user_input_questions: Vec::new(),
user_input_questions_sha256: None,
user_input_envelope_unparsable: false,
});
legacy_delivery.claimed_by_action_id = Some("legacy-delivery-claim-action".to_string());
write_static_delegate_delivery_at(&root, &legacy_delivery).expect("persist legacy delivery");