From 9e2648893bf48b19658b2e49224d2c923dab43d2 Mon Sep 17 00:00:00 2001 From: Linghong Date: Wed, 26 Aug 2026 08:13:40 +0000 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E9=80=9A=E4=BF=AE=E8=AE=A2=E8=BD=AE?= =?UTF-8?q?=E5=87=BA=E7=A8=BF=EF=BC=9A=E6=8A=95=E5=BD=B1=E5=AE=88=E5=8D=AB?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E5=81=87=E8=AE=BE=E3=80=8C=E4=B8=80=E6=9D=A1?= =?UTF-8?q?=20lineage=20=E5=8F=AA=E6=8F=90=E4=BA=A4=E4=B8=80=E6=AC=A1?= =?UTF-8?q?=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 审批卡点「修改/退回」后,策划子 Agent 能提交 v2、GDD 也落盘了,但 `project_submit_successors_locked` 拒绝写 session successor,返回 recoveryPending=true。Runtime 把这次工具动作标成 needs-reconciliation, hydrate 再看到「未审批的 v2 vs session 还指着 v1」,抛 PLAN_SESSION_RECOVERY_REQUIRED。UI 上「重试恢复」走的是同一个投影守卫, 永远清不掉;「结束旧任务」不重写 session,同样清不掉——项目就此砖掉。 根因是 source_session_matches_gdd 里的 latest_submitted_ref.is_none()。 它不是安全判据:相邻的 sessionRevision + sessionFingerprint 已经是全内容 CAS——读取路径 parse_plan_session_bytes → validate_plan_session 会重算指纹 并拒绝不自洽的 session,所以指纹相等即意味着 session 与出稿时的快照逐字段 相等。那一条只是「一条 lineage 只提交一次」的残留假设,而同文件的提交闸 validate_current_session_cas 早在 §M1C-2b 那段注释里宣布该假设作废。删掉 之后,投影守卫的判据集与提交闸逐条相同——本来就该是这个关系。 顺带:已经砖掉的项目重放时命中同一条路径补写 successor,点一次「重试恢复」 即可自愈,不需要迁移。 守门扩在既有的 rejected_session_needs_a_collecting_continuation_not_a_wider_submit_gate 上。那条测试本来就构造出了这个 session 形状(带 v1 的 latestSubmittedRef 和 reject 的 lastDecisionRef),却只断言提交闸放行就收尾,差一步没走到。补上真正 提交 v2 并断言 !recovery_pending;把 is_none() 加回去这条会红,已验证非空转。 Co-Authored-By: Claude Opus 5 --- .../agent/runtime_protocol/planning_submit.rs | 28 ++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) 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 e8dc24c0d..b63d5953f 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 @@ -1717,11 +1717,20 @@ fn project_submit_successors_locked( // successor. The only safe forward path is an exact source-session // snapshot (including the still-active child run) or an already // projected session pointing at this immutable ref. + // These judgements are deliberately the same set `validate_current_session_cas` + // already enforced at the submit gate. Do not narrow them with a + // `latest_submitted_ref.is_none()` style assertion: revision plus the + // recomputed `sessionFingerprint` (`validate_plan_session` rejects a session + // whose fingerprint does not hash its own content) already pin the session to + // the exact snapshot the submitter observed, so any extra field-shape check + // only re-encodes the obsolete "one submission per lineage" rule. A user + // revision round legitimately arrives carrying the previous version's + // `latestSubmittedRef`; refusing it strands a committed GDD behind a + // `recoveryPending` that no replay can clear. let source_session_matches_gdd = session_identity_matches_gdd && previous_session.session_revision == gdd.source_session_revision && previous_session.session_fingerprint == gdd.source_session_fingerprint && previous_session.active_run_id.as_deref() == Some(gdd.created_by_run_id.as_str()) - && previous_session.latest_submitted_ref.is_none() && matches!( previous_session.phase.as_str(), "collecting" | "revision_requested" @@ -4240,6 +4249,23 @@ mod tests { next_context.source_session_fingerprint = continuation.session_fingerprint.clone(); validate_current_session_cas(&continuation, &next_context, &input) .expect("reject 之后的 continuation 必须能提交同一 lineage 的下一版本"); + + // 提交闸放行还不够:投影守卫必须认同一条 continuation。这条 session 必然带着 + // v1 的 latestSubmittedRef 和 reject 的 lastDecisionRef,投影守卫若据此判它不是 + // 合法起点,v2 就会越过提交点却收不了口,留下一个任何重放都清不掉的 + // recoveryPending。 + write_plan_session_atomic_locked(&root, &continuation).expect("write continuation session"); + next_context.action_id = "action-89abcdef0123456789abcdef".to_string(); + next_context.action_fingerprint = "4".repeat(64); + next_context.approval_request_id = + Some("gdd-approval-00000000-0000-4000-8000-000000000041".to_string()); + let resubmit = + execute_plan_submit_gdd(&root, &next_context, &input).expect("continuation 提交 v2"); + assert_eq!(resubmit.gdd_ref.version, 2); + assert!( + !resubmit.recovery_pending, + "提交闸放行的 continuation,投影守卫也必须放行" + ); cleanup_fixture(root); }