From cb5af31e79057e5d5468690e946e893d8e527b14 Mon Sep 17 00:00:00 2001 From: Linghong Date: Mon, 17 Aug 2026 07:08:47 +0000 Subject: [PATCH 1/2] =?UTF-8?q?P4=EF=BC=9A=E5=85=88=E8=AF=86=E5=88=AB=20Fa?= =?UTF-8?q?st=20GDD=20=E5=86=8D=E8=B5=B0=E7=BB=91=E5=AE=9A=E7=88=B6?= =?UTF-8?q?=E9=93=BE=EF=BC=8C=E5=88=AB=E8=AE=A9=E7=A5=96=E5=85=88=E5=9D=8F?= =?UTF-8?q?=E6=8E=89=E8=AF=AF=E4=BC=A4=E9=9D=9E=E7=AD=96=E5=88=92=20run?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit plan_root_completion_identity_at 读绑定用的是会遍历并校验整条祖先链的入口, 而识别 Fast GDD 只看 run 自己那条记录的 source/profile。链走排在识别前面,于是 任何祖先绑定的毛病都先变成 Err,再被完成门统一翻成 needs-reconciliation——扣在一个 下一行本来就会被判「不是策划根」的 run 头上,让它再也收束不了。 可达:task_start 里 requires_public_start_status 明确把 agent-delegate-receipt 与 agent-isolated-join 排除在「无父的公开启动」之外,带父链的 supervisor run 在生产中 确实存在。新增复现用例在修复前报 Some(NeedsReconciliation)。 改动安全的两条依据: - 两个入口对同一个 (agent, run) 返回的绑定值完全相同(链走版本最后返回的就是 run 自己那条记录),链走纯属校验副作用,识别判据一字未变。 - 策划根的严格度一点没降:validate_project_supervisor_plan_root_binding_at 内部读的 就是链走版本,且强制 parent 必须为空。被移除的只是即将判定「不是策划根」那条路径上 的链走,顺带消掉同一条绑定被连着走两遍父链。 另外把 PLAN_GDD_APPROVAL_SOURCE 与 AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE 必须同值钉成 编译期断言:识别用前者、复核用后者,两者分处不同模块各自定义,一旦分叉每个策划根都会 先通过识别再被复核拒掉,全部塌成 needs-reconciliation,而且没有测试会指向这个原因。 Co-Authored-By: Claude Opus 5 --- .../runtime_protocol/planning_approval.rs | 92 ++++++++++++++++++- 1 file changed, 91 insertions(+), 1 deletion(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_approval.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_approval.rs index 9238c6d70..d25a6db2b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_approval.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_approval.rs @@ -1579,6 +1579,22 @@ fn plan_gdd_session_matches_receipt( }) } +// 这道门用 `PLAN_GDD_APPROVAL_SOURCE` 识别策划根,而随后的 +// `validate_project_supervisor_plan_root_binding_at` 用 `AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE` +// 复核同一条绑定。两个常量分别定义在 planning_storage 与 runtime_driver,值必须相同: +// 一旦分叉,每个策划根都会先通过识别、再被复核拒掉,全部塌成 needs-reconciliation, +// 而且没有任何测试会直接指向这个原因。钉成编译期条件。 +const _: () = { + let identified = PLAN_GDD_APPROVAL_SOURCE.as_bytes(); + let validated = AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE.as_bytes(); + assert!(identified.len() == validated.len()); + let mut index = 0; + while index < identified.len() { + assert!(identified[index] == validated[index]); + index += 1; + } +}; + fn plan_root_completion_identity_at( root: &Path, agent_id: &str, @@ -1587,8 +1603,15 @@ fn plan_root_completion_identity_at( if agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID || run_id.trim().is_empty() { return Ok(false); } + // 识别只看 run 自己那条绑定记录。链走版本会遍历并校验整条祖先链;把它排在识别 + // 前面,等于让任何祖先绑定的毛病都先变成 Err,而调用方会把 Err 一律翻成 + // needs-reconciliation——扣在一个下一行本来就会被判「不是策划根」的 run 头上,让它 + // 再也收束不了。被误伤的只可能是非策划 run:真正的策划根无父(下面 + // `validate_project_supervisor_plan_root_binding_at` 强制 parent 必须为空),链走 + // 对它本就是空转。两个入口对同一个 (agent, run) 返回的绑定值完全相同,差别仅在于 + // 是否顺带校验祖先链,所以识别判据一字未变。 let Some(binding) = - read_game_creator_agent_runtime_run_profile_binding(root, agent_id, run_id)? + read_game_creator_agent_runtime_run_profile_binding_once(root, agent_id, run_id.trim())? else { return Ok(false); }; @@ -1597,6 +1620,8 @@ fn plan_root_completion_identity_at( { return Ok(false); } + // 确认是策划根之后才走完整父链:严格度一点没降—— + // `validate_project_supervisor_plan_root_binding_at` 内部读的就是链走版本。 let binding = validate_project_supervisor_plan_root_binding_at(root, agent_id, run_id)?; let runtime = read_game_creator_agent_runtime_at(root, agent_id)?.state; if runtime.run_id != run_id @@ -2053,4 +2078,69 @@ mod tests { ); let _ = fs::remove_dir_all(root); } + + /// 祖先绑定坏掉不能让一个跟立项策划无关的 supervisor run 被判成「策划根身份不明」。 + /// + /// `plan_root_completion_identity_at` 读绑定走的是会遍历完整父链的入口,而识别 + /// Fast GDD 只看 run 自己那条记录的 source/profile。链走排在识别前面,于是任何 + /// 祖先绑定的问题都先变成 Err,再被完成门统一翻成 needs-reconciliation——扣在一个 + /// 下一行就会被判为「不是策划根」的 run 头上,让它再也收束不了。 + /// + /// 真正的策划根没有父(`validate_project_supervisor_plan_root_binding_at` 强制 + /// parent 必须为空),所以链走对它本来就是空转;会被链走误伤的只可能是非策划 run。 + #[test] + fn broken_ancestor_binding_must_not_make_a_non_plan_supervisor_run_a_plan_root_suspect() { + let root = std::env::temp_dir().join(format!( + "genarrative-plan-root-identity-{}", + uuid::Uuid::new_v4().simple() + )); + init_local_game_project_at(&root, "project-test-plan-root", "plan root identity") + .expect("init project"); + + let parent_run_id = "p4-isolated-join-parent"; + let child_run_id = "p4-isolated-join-child"; + bind_game_creator_agent_runtime_run_profile_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_STANDARD), + None, + ) + .expect("bind ancestor run"); + bind_game_creator_agent_runtime_run_profile_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + child_run_id, + AGENT_RUNTIME_ISOLATED_JOIN_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_STANDARD), + Some(&AgentRuntimeTaskLink { + parent_agent_id: Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string()), + parent_run_id: Some(parent_run_id.to_string()), + delegation_id: None, + }), + ) + .expect("bind isolated-join child run"); + + // 破坏祖先:子记录仍带着 parentBindingFingerprint,父绑定却没了,正好命中 + // 父链遍历里的「父绑定缺失」。子 run 自己那条记录始终是完好可读的。 + fs::remove_file(game_creator_agent_runtime_run_profile_binding_path( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + )) + .expect("remove ancestor binding"); + + let blocker = plan_gdd_typed_completion_blocker_at_locked( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + child_run_id, + ); + assert!( + blocker.is_none(), + "isolated-join run 的 source 不是立项策划,完成门不该拦它,更不该说它策划根身份不明:{:?}", + blocker.map(|blocker| blocker.kind) + ); + let _ = fs::remove_dir_all(root); + } } From a8215a59979766bde18779581026eda3f5fbecc5 Mon Sep 17 00:00:00 2001 From: Linghong Date: Mon, 17 Aug 2026 07:24:55 +0000 Subject: [PATCH 2/2] =?UTF-8?q?P5=EF=BC=9A=E6=8A=8A=E5=A7=94=E6=B4=BE?= =?UTF-8?q?=E6=A0=85=E6=A0=8F=20detail=20=E7=9A=84=E3=80=8C=E6=8B=BC?= =?UTF-8?q?=E4=B8=B2=E2=86=92=E5=86=8D=E8=A7=A3=E6=9E=90=E3=80=8D=E9=94=81?= =?UTF-8?q?=E6=88=90=E7=AD=89=E4=BB=B7=E5=85=B3=E7=B3=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M1C-1 把 userRevisionPending 同时加进了 StaticDelegateCompletionBarrier::detail() 的输出和 project_gates 的解析门,两边靠一个字段名字符串隔空对齐,中间没有共享 schema。 当时生产侧只有 delegation.rs 一条 detail().contains("userRevisionPending=1"),解析侧 一条测试都没有,两者之间也没有任何东西相连:字段名一改,生产侧那条照过,而三个门静默 返回 false,父 run 就会越过用户修订边界收束。 已核实这条路径是「单一生产者 → 四个解析点」:static_delegate_completion_blocker_at 原样使用 detail: Some(barrier.detail()),main_loop 的四处解析都以 tool == "runtime.delegate_receipts" 为前提。 新增用例锁的是等价关系而不是拼写:对七个计数的全部 128 种 0/1 组合,断言三个门的判定 与 barrier 自己的语义谓词逐一相等;另加多位数计数与「键之间互不为前缀」两条护栏——后者 是 strip_prefix 读对值的隐含前提,等价关系测试抓不到这层前提何时被打破。 鉴别力已用变异验证:把解析器的 userRevisionPending 改名为 userRevisionRequested, 或往 has_waiting() 里加一个解析器不认的计数,两个漂移方向都会被抓住。 Co-Authored-By: Claude Opus 5 --- .../agent/runtime_actions/project_gates.rs | 94 +++++++++++++++++++ 1 file changed, 94 insertions(+) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs index 57c8d3bd1..5058c504b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs @@ -1729,3 +1729,97 @@ pub(crate) fn acquire_game_creator_agent_provider_plan_project_write_lock_with_w ) -> Result { acquire_game_creator_agent_runtime_project_write_lock_with_wait(root, command_id) } + +/// M1C-1 把 `userRevisionPending` 同时加进了 `StaticDelegateCompletionBarrier::detail()` +/// 的输出和本模块的三个解析门,两边靠一个字段名字符串隔空对齐,中间没有共享 schema。 +/// +/// 当时生产侧只有 `delegation.rs` 一条 `detail().contains("userRevisionPending=1")`, +/// 解析侧一条测试都没有,两者之间也没有任何东西相连。字段名一改,生产侧那条照过,而这 +/// 三个门会静默返回 false——父 run 于是越过用户修订边界收束。同样,往 `has_waiting()` +/// 里加一个计数而忘了加进解析器(或反之),也没有任何用例会报警。 +/// +/// 所以这里锁的不是拼写,是**等价关系**:对七个计数的全部组合,解析门的判定必须与 +/// barrier 自己的语义谓词逐一相等。 +#[cfg(test)] +mod static_delegate_barrier_detail_gate_tests { + use super::*; + use crate::delegation::StaticDelegateCompletionBarrier; + + fn assert_gates_agree_with_barrier(barrier: StaticDelegateCompletionBarrier) { + let detail = barrier.detail(); + assert_eq!( + static_delegate_barrier_has_waiting_deliveries(&detail), + barrier.has_waiting(), + "has_waiting() 与 detail 解析必须等价:{barrier:?}\ndetail={detail}" + ); + assert_eq!( + static_delegate_barrier_requires_repair(&detail), + barrier.repair_required_count > 0, + "repairRequired 往返失真:{barrier:?}\ndetail={detail}" + ); + assert_eq!( + static_delegate_barrier_requires_user_revision(&detail), + barrier.user_revision_pending_count > 0, + "userRevisionPending 往返失真:{barrier:?}\ndetail={detail}" + ); + } + + #[test] + fn barrier_detail_round_trips_through_every_gate_for_all_count_combinations() { + let mut checked = 0usize; + for bits in 0u32..(1 << 7) { + let present = |index: u32| usize::from(bits & (1 << index) != 0); + assert_gates_agree_with_barrier(StaticDelegateCompletionBarrier { + waiting_count: present(0), + ready_unclaimed_count: present(1), + unobserved_claim_count: present(2), + repair_required_count: present(3), + user_input_required_count: present(4), + user_revision_pending_count: present(5), + unknown_contract_status_count: present(6), + }); + checked += 1; + } + assert_eq!(checked, 128, "必须覆盖七个计数的全部 0/1 组合"); + } + + /// 门读的是计数而不是「等于 1」:`detail()` 里出现多位数时不能失配。 + #[test] + fn barrier_detail_gates_read_multi_digit_counts() { + assert_gates_agree_with_barrier(StaticDelegateCompletionBarrier { + waiting_count: 12, + ready_unclaimed_count: 34, + unobserved_claim_count: 56, + repair_required_count: 78, + user_input_required_count: 90, + user_revision_pending_count: 123, + unknown_contract_status_count: 456, + }); + } + + /// 没有任何键是另一个键的前缀——否则 `strip_prefix` 会读到隔壁字段的值。 + /// 这条是给未来改名加的护栏:等价关系测试能抓到读错值,但抓不到「读对了值却 + /// 是因为两个键碰巧不冲突」这层前提何时被打破。 + #[test] + fn barrier_detail_keys_are_prefix_free() { + let detail = StaticDelegateCompletionBarrier::default().detail(); + let keys = detail + .split_whitespace() + .filter_map(|part| part.split_once('=').map(|(key, _)| key)) + .collect::>(); + assert!( + keys.len() >= 7, + "detail 必须仍以 key=value 形式给出全部计数:{detail}" + ); + for (index, key) in keys.iter().enumerate() { + for (other_index, other) in keys.iter().enumerate() { + if index != other_index { + assert!( + !other.starts_with(key), + "detail 键 `{key}` 是 `{other}` 的前缀,strip_prefix 会读错字段" + ); + } + } + } + } +}