diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs index 63d45fcf1..5dddec5a7 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs @@ -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 重试额度" + ); + } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_submit.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_submit.rs index 65e55eaad..d04cdc49f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_submit.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_submit.rs @@ -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); } diff --git a/docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md b/docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md index da1e5014b..e3ec6ce43 100644 --- a/docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md +++ b/docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md @@ -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. 除第 1~2 项明确允许的同 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'