台账逐项比对失败改判为可回灌的输入错误
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`。
// 真 CASrevision 溢出、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);
}
@@ -1162,7 +1162,15 @@ GDD handler 只能从已验证 batch binding 复制 `sourceSessionRevision/sourc
4. Provider transient failure/物理中断但 session、context 和 request slot 未变时,才沿用同一 base ID 的 attempt 派生规则。已知 retryable transport/upstream failure 先把旧 attempt durable 闭合为 `failed`Runner/进程恢复只有在 boot/owner/lease 证据证明旧物理请求不再存活且无 handoff/batch 时,才闭合为 `interrupted`。旧终态写入、同步并回读成功后,才能创建 attempt N+1 的新 `started`;不能原地复用同一 providerRequestId,也不能让两个 started attempt 并存。无法证明旧请求已终止时进入 recovery required,不自动重发。每个 attempt 始终有独立 `started → completed|failed|interrupted` lifecycle。
5. 除第 12 项明确允许的同 binding `started + ready batch` 崩溃组合,以及上文 delivery 问题落盘/答案绑定的已消费证明外,binding 缺失/损坏、lifecycle 与 batch 不一致、同 revision 下 requestContextFingerprint 漂移、session 不是合法 successor,或 batch 已进入执行/等待状态时返回 `PLAN_NEEDS_RECONCILIATION`。此路径不自动删除、不补默认 binding、不重绑、不重试。
严格 submit input 被 Runtime 以 `PLAN_INVALID_REQUEST` 或由该输入导出的候选 GDD `PLAN_SIZE_LIMIT` 拒绝时,当前策划子 run 最多产生 **5 次** `plan.submit_gdd / rejected` observation:前 4 次关闭原 sole-action batch 后可在同一 run 续跑,让 Provider 根据最后一条 observation 修正;第 5 次仍须先完整落盘 rejected observation,再把该 run 终态失败,**不得**请求第 6 次 Provider tool-plan。该分类只针对本次 Provider input / 候选 GDD;读取既有不可变 GDD 或 receipt 时出现同名大小上限、既有 lineage 已达版本上限,或任何其它 durable authority 异常,一律是 `PLAN_NEEDS_RECONCILIATION`,不得消耗 Provider 重试额度。计数是 Runtime state 的 durable、每个 child run 独立的字段,进程重启不能清零;只有新建的策划 child run 才从 0 开始。它不依赖前端、Prompt 文字或 Provider 自报,且普通工具 observation 不计入。
严格 submit input 被 Runtime 以 `PLAN_INVALID_REQUEST``PLAN_SESSION_DECISIONS_MISMATCH`2026-08-21 补,见下)或由该输入导出的候选 GDD `PLAN_SIZE_LIMIT` 拒绝时,当前策划子 run 最多产生 **5 次** `plan.submit_gdd / rejected` observation:前 4 次关闭原 sole-action batch 后可在同一 run 续跑,让 Provider 根据最后一条 observation 修正;第 5 次仍须先完整落盘 rejected observation,再把该 run 终态失败,**不得**请求第 6 次 Provider tool-plan。该分类只针对本次 Provider input / 候选 GDD;读取既有不可变 GDD 或 receipt 时出现同名大小上限、既有 lineage 已达版本上限,或任何其它 durable authority 异常,一律是 `PLAN_NEEDS_RECONCILIATION`,不得消耗 Provider 重试额度。计数是 Runtime state 的 durable、每个 child run 独立的字段,进程重启不能清零;只有新建的策划 child run 才从 0 开始。它不依赖前端、Prompt 文字或 Provider 自报,且普通工具 observation 不计入。
**2026-08-21)台账逐项比对失败从 `PLAN_SESSION_CAS_CONFLICT` 拆出为 `PLAN_SESSION_DECISIONS_MISMATCH`,并纳入上述可重试分类。** 第 8.2 节「input 必须逐项包含并精确等于 source session 的 `decisionsSummary``prototypeValidationItems`」这条校验(实现为 `planning_submit.rs``session_decisions_match_input`)原先与三条真 CAS 判据(`sessionRevision` 溢出、session 已被其它动作推进、Runtime source revision/fingerprint 无效)共用一个错误码,因此被 `plan_submit_error_is_business_rejection` 漏掉,一次不匹配即 `needs-reconciliation` 硬阻断整个策划子 Agent。
两者性质本就不同,按本节自己的判据即可区分:真 CAS 说明 **durable 权威**已变或已坏,重交同一份 input 不可能成功;台账不匹配时权威完好,错的是**本次 Provider input**——策划子 Agent 把决策摘要抄漏、抄错,或多追加了一条非 `default_pending` 决定。后者正是本节划归「本次 Provider input / 候选 GDD」的那一类。
**不变量未放松**:不匹配照样拒绝、照样不产生任何事实,只是拒绝的后果从「叫人核对」变成「回灌 rejected observation 让 Provider 改稿」,仍受同一个 5 次 durable 预算约束,第 5 次照常终态失败。伪造用户确认(追加 `confirmed + user_option`)等第 8.2 节禁止的写法一条都没有变得可行。
**触发这次拆分的实测**:策划子 Agent 连续三次 submit 撞形状层(`PLAN_INVALID_REQUEST`),每次都按回灌的理由改对一部分——机制运转正常;第四次形状终于合法,随即撞上台账比对这一支,直接 `needs-reconciliation`,整条链路零产物收场。即**越接近提交成功越容易撞上不给重试的门**,这与「5 次预算让 Provider 自行收敛」的设计意图直接冲突。
第 2 项的自动前滚必须与 session successor、batch supersede/cleanup 和 replacement request 的 started 写入都在项目锁内按幂等步骤恢复;任一断点重启后只能继续相同步骤。这样合法 steer 能确定性替换旧输出,而身份污染不会被“自动恢复”掩盖。
@@ -1451,6 +1459,7 @@ type PlanGddError = {
| 'PLAN_UNSUPPORTED_KNOWLEDGE_BASIS'
| 'PLAN_CORRUPT_AUTHORITY'
| 'PLAN_SESSION_CAS_CONFLICT'
| 'PLAN_SESSION_DECISIONS_MISMATCH'
| 'PLAN_SESSION_RECOVERY_REQUIRED'
| 'PLAN_NEEDS_RECONCILIATION'
| 'PLAN_DURABILITY_FAILED'