台账逐项比对失败改判为可回灌的输入错误
Project CI / Repository checks (pull_request) Successful in 3m13s
Project CI / Frontend tests (pull_request) Successful in 3m49s
Project CI / Backend tests (pull_request) Successful in 4m49s
Project CI / Native shell tests (pull_request) Successful in 16m15s

plan.submit_gdd 的 session_decisions_match_input 校验此前与三条真 CAS 判据
(sessionRevision 溢出、session 已被其它动作推进、Runtime source
revision/fingerprint 无效)共用 PLAN_SESSION_CAS_CONFLICT,因此被
plan_submit_error_is_business_rejection 漏掉,一次不匹配就 needs-reconciliation
硬阻断整个策划子 Agent。

两者性质不同:真 CAS 说明 durable 权威已变或已坏,重交同一份 input 不可能成功;
台账不匹配时权威完好,错的是本次 Provider input——子 Agent 把决策摘要抄漏、抄错,
或多追加了一条非 default_pending 决定。按第 12 节自己的判据,后者属于「本次
Provider input」,该走 rejected observation 回灌并受既有 5 次预算约束。

拆出 PLAN_SESSION_DECISIONS_MISMATCH 并纳入 business rejection,真 CAS 三支
原样保留 reconciliation。不变量未放松:不匹配照样拒、照样不产生任何事实,
伪造用户确认仍然不可行,只是拒绝的后果从叫人变成让它改稿。

实测触发:子 Agent 连撞三次形状层(PLAN_INVALID_REQUEST),每次都按回灌理由
改对一部分,机制运转正常;第四次形状合法后随即撞上台账比对,直接阻断,
整条链路零产物。即越接近提交成功越容易撞上不给重试的门。

覆盖:改写既有决定正文 → 新码;同一份 input 只改陈旧 revision → 仍是 CAS;
追加伪造 confirmed 决定 → 新码且不落任何事实;分类器两向断言。分类器一条
已变异验证。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-21 02:45:37 +00:00
parent d284639903
commit db740d8ae6
3 changed files with 76 additions and 4 deletions
@@ -160,8 +160,15 @@ pub(super) fn game_creator_agent_final_reply_error_allows_fallback(error: &str)
matches!(kind.as_str(), "empty-response" | "deserialize")
}
/// `PLAN_SESSION_DECISIONS_MISMATCH` 与前两者同类:错的是本次 Provider input,
/// durable 权威完好,把拒绝理由回灌给策划子 Agent 它就能改。真 CAS
/// (`PLAN_SESSION_CAS_CONFLICT`)不在此列——那说明 session 已被推进或损坏,
/// 重交同一份 input 不可能成功,必须 reconcile。
fn plan_submit_error_is_business_rejection(error: &PlanningStorageError) -> bool {
matches!(error.code(), "PLAN_INVALID_REQUEST" | "PLAN_SIZE_LIMIT")
matches!(
error.code(),
"PLAN_INVALID_REQUEST" | "PLAN_SIZE_LIMIT" | "PLAN_SESSION_DECISIONS_MISMATCH"
)
}
/// A malformed Fast GDD is useful feedback for the planning child, but it
@@ -4600,4 +4607,27 @@ mod plan_gdd_blocker_projection_tests {
"版本上限由既有 lineage 决定,重试相同 Provider submit 不会改变它"
);
}
/// 台账逐项比对失败是本次 Provider input 写错,durable 权威完好,回灌理由后
/// 策划子 Agent 能自行改稿;真 CAS 则说明 session 已被推进或损坏,重交同一份
/// input 不可能成功。两者曾共用 `PLAN_SESSION_CAS_CONFLICT`,导致前者也被判成
/// 硬阻断——实测中策划子 Agent 靠回灌连改三轮修好了形状层,紧接着撞上这一支
/// 直接 needs-reconciliation,整条链路无产物收场。
#[test]
fn session_ledger_mismatch_is_provider_feedback_but_a_real_cas_conflict_is_not() {
assert!(
plan_submit_error_is_business_rejection(&PlanningStorageError::new(
"PLAN_SESSION_DECISIONS_MISMATCH",
"submit input 未逐项匹配当前 planning session 决策摘要"
)),
"台账不匹配应回灌给 Provider 修正,受既有 5 次预算约束"
);
assert!(
!plan_submit_error_is_business_rejection(&PlanningStorageError::new(
"PLAN_SESSION_CAS_CONFLICT",
"planning session 已被其它动作推进"
)),
"真 CAS 必须走 reconciliation,不得消耗 Provider 重试额度"
);
}
}
@@ -1310,9 +1310,17 @@ fn validate_current_session_cas(
"当前 planning session 仍有未决 GDD",
));
}
// 这一支和上面三条 CAS 判据性质不同,因此不共用 `PLAN_SESSION_CAS_CONFLICT`。
// 真 CAS(revision 溢出、session 已被其它动作推进、Runtime source
// revision/fingerprint 无效)说明 durable 权威变了或坏了,重交同一份 input 也
// 没用,只能 reconcile;而台账逐项比对失败时权威完好,错的是本次 Provider
// input——策划子 Agent 把 session 决策摘要抄漏、抄错或多追加了一条非默认决定。
// 这正是第 12 节划归「本次 Provider input」的那一类,应该走 rejected
// observation 回灌让它改,受既有 5 次预算约束,而不是硬阻断等人。
// 不变量本身一个字没放松:不匹配照样拒,只是改了拒绝的后果。
if !session_decisions_match_input(session, input) {
return Err(submit_error(
"PLAN_SESSION_CAS_CONFLICT",
"PLAN_SESSION_DECISIONS_MISMATCH",
"submit input 未逐项匹配当前 planning session 决策摘要",
));
}
@@ -3430,7 +3438,32 @@ mod tests {
let error = execute_plan_submit_gdd(&root, &context, &input)
.expect_err("a non-default decision outside the session prefix must fail");
assert_eq!(error.code(), "PLAN_SESSION_CAS_CONFLICT");
// 伪造用户确认照样被拒;只是错误码从 CAS 换成了可回灌的输入类,
// 让策划子 Agent 能按理由改稿而不是把整个 Agent 阻断到人工核对。
assert_eq!(error.code(), "PLAN_SESSION_DECISIONS_MISMATCH");
assert!(!root.join(".agent/planning/gdd.v1.json").exists());
cleanup_fixture(root);
}
/// 台账逐项比对失败必须与真 CAS 区分开:前者是本次 Provider input 写错,
/// 后者是 durable 权威已变。两者都拒,但只有前者值得回灌重试。
#[test]
fn session_ledger_mismatch_is_reported_apart_from_a_real_session_cas_conflict() {
// 抄错既有决定的正文(权威没变,错的是 input)。
let (root, context, mut input) = submit_fixture();
input.decisions[0].answer_summary.push_str("(被改写)");
let mismatch = execute_plan_submit_gdd(&root, &context, &input)
.expect_err("a rewritten session decision must fail");
assert_eq!(mismatch.code(), "PLAN_SESSION_DECISIONS_MISMATCH");
assert!(!root.join(".agent/planning/gdd.v1.json").exists());
cleanup_fixture(root);
// 同一份合法 input,只把 session revision 弄陈旧(权威已被推进)。
let (root, mut stale_context, input) = submit_fixture();
stale_context.source_session_revision = 2;
let cas = execute_plan_submit_gdd(&root, &stale_context, &input)
.expect_err("a stale session must still be a CAS conflict");
assert_eq!(cas.code(), "PLAN_SESSION_CAS_CONFLICT");
assert!(!root.join(".agent/planning/gdd.v1.json").exists());
cleanup_fixture(root);
}