From 2f3dd86fb36b2dbd9c035fb6263ed7276aa2c84c Mon Sep 17 00:00:00 2001 From: Linghong Date: Thu, 13 Aug 2026 03:38:17 +0000 Subject: [PATCH] =?UTF-8?q?=E9=9D=99=E6=80=81=E5=A7=94=E6=B4=BE=EF=BC=9A?= =?UTF-8?q?=E8=BF=94=E5=B7=A5=E6=B7=B1=E5=BA=A6=E4=B8=8E=E6=BE=84=E6=B8=85?= =?UTF-8?q?=E8=BD=AE=E6=AC=A1=E6=94=B9=E4=B8=BA=E9=93=BE=E4=B8=8A=E6=8E=A8?= =?UTF-8?q?=E6=96=AD=EF=BC=8C=E4=B8=8D=E6=96=B0=E5=A2=9E=E6=8C=81=E4=B9=85?= =?UTF-8?q?=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WP1(本轮只改生产代码,测试由下一工作包处理): - 新增 static_delegate_lineage_counters:沿 repair_of_delegation_id 反向重放整条 delivery 链,现算目标节点的 (repair_depth, clarification_round)。二者都是运行时 派生值,故意不落盘——若给 StaticDelegateDeliveryRecord 加 repair_depth 字段并用 #[serde(default)] 兜底,磁盘上已有的返工记录会读出 0,深度门失效,等于让 “返工的返工”漏洞原样复活(fail-open,不可接受)。链上推断对历史记录反而是精确 的:#165 之前不存在 NeedsUserInput,老记录天然被正确分类为“非澄清”。 传播规则:R3 根节点 depth=0/round=0;R1 澄清跳 round=parent.round+1、depth 不变 (不重置深度,否则可插一次澄清洗掉返工深度变成无限返工);R2 返工跳 depth=parent.depth+1、round 重置为 0(返工后重新开工,不能吃掉澄清轮次预算)。 反向重放遇到重复 id、缺失节点或跳数超过 STATIC_DELEGATE_LINEAGE_MAX_HOPS 时 fail closed,返回 (u32::MAX, u32::MAX) 使调用方的门必然拒绝。 - 抽出 static_delegate_original_is_awaiting_clarification 作为“这一跳是否续接自 澄清”的唯一权威判据源,validate_static_delegate_repair_request_at 与 validate_static_delegate_clarification_continuation_at 共用,避免两处口径漂移。 - 重写 validate_static_delegate_repair_request_at 里原先无差别拒绝返工深度>=1 的 门:先用共享判据分类候选是澄清续接还是质量返工;返工路径按链上 depth 校验, 错误文案原样保留“静态委派返工深度最多为 1”(现有测试已断言该字符串);澄清 路径改为按链上 round 校验,用新文案“静态委派澄清轮次已达上限”。函数签名不变。 - 澄清轮次上限按 source 区分:新增 static_delegate_clarification_round_limit_at, 通过 read_game_creator_agent_runtime_run_profile_binding 读取 binding.source, AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE 取 1,其余取 3——game-chat 单主路径 定位零打扰,#165 从未承诺给它 3 轮预算。 验证:cargo check --all-targets 通过。cargo test 里 tests::collaboration::static_deliveries::clarification_continuation_chain_supports_multiple_rounds 按预期失败——它断言的是旧的“D2->D3 必被返工深度门拒绝”行为,新逻辑下 D2->D3 是 合法的第 2 轮澄清续接,测试留给下一工作包更新。project_supervisor_concurrent_repair_dispatch_creates_exactly_one_delivery 偶发失败,经对比 20 次 vs 20 次基线(约 10%-15% 失败率两边相当)确认是本机既有的 并发计时 flaky 测试,非本次改动引入的回归。 Co-Authored-By: Claude Opus 5 --- .../src-tauri/src/delegation.rs | 105 +++++++++++++++++- 1 file changed, 101 insertions(+), 4 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/delegation.rs b/apps/ai-game-creator-shell/src-tauri/src/delegation.rs index 71530c1f6..cde826b7c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/delegation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/delegation.rs @@ -15,6 +15,12 @@ const STATIC_DELEGATE_EXPECTED_ARTIFACT_MAX_CHARS: usize = 240; const STATIC_DELEGATE_MAX_EVIDENCE: usize = 16; const STATIC_DELEGATE_USER_INPUT_PREFIX: &str = "AGC_NEEDS_USER_INPUT_V1\n"; const STATIC_DELEGATE_USER_INPUT_MAX_RESPONSE_CHARS: usize = 500; +// 澄清轮次上限按 source 区分:诉求只来自策划节点,game-chat 单主路径定位零打扰, +// 从未承诺给它 3 轮预算,因此取 1;其它 source(包括 Project Supervisor 常规协作)取 3。 +const STATIC_DELEGATE_CLARIFICATION_ROUND_LIMIT_DEFAULT: u32 = 3; +const STATIC_DELEGATE_CLARIFICATION_ROUND_LIMIT_GAME_CHAT: u32 = 1; +// 链上重放的防环 / 防越界上限,远大于设计允许的最大 7 跳,纯粹是安全阀。 +const STATIC_DELEGATE_LINEAGE_MAX_HOPS: usize = 32; #[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "kebab-case")] @@ -1080,6 +1086,91 @@ pub(crate) fn game_chat_art_delivery_gap_at( Ok(None) } +/// 唯一权威判据:某条 delivery 是否处于「等待用户澄清」状态——也就是说,从它出发的 +/// 下一跳(若存在)应当被归类为澄清 continuation,而不是质量返工。 +/// `validate_static_delegate_repair_request_at` 与 +/// `validate_static_delegate_clarification_continuation_at` 共用这一个判据源, +/// 避免两处口径漂移。 +fn static_delegate_original_is_awaiting_clarification( + delivery: &StaticDelegateDeliveryRecord, +) -> bool { + delivery.structured_result.as_ref().is_some_and(|result| { + result.contract_status == StaticDelegateContractStatus::NeedsUserInput + }) +} + +/// 沿 repair_of_delegation_id 反向重放整条链,现算目标 delivery 的 +/// (repair_depth, clarification_round)。两个维度都是运行时派生值,故意不落盘: +/// - R3(链根,repair_of_delegation_id 为 None):depth = 0,round = 0。 +/// - R1(澄清跳:父节点处于 awaiting-clarification):round = parent.round + 1, +/// depth 原样继承(不重置),否则可以插一次澄清洗掉返工深度,变成无限返工。 +/// - R2(返工跳:父节点不处于 awaiting-clarification):depth = parent.depth + 1, +/// round 重置为 0,因为返工后策划节点重新开工,不能因为返工吃掉预设的澄清轮次预算。 +/// +/// 防环 / 防越界:反向重放阶段一旦遇到重复 id、缺失的上游节点,或跳数超出 +/// STATIC_DELEGATE_LINEAGE_MAX_HOPS,立即 fail closed,返回 (u32::MAX, u32::MAX), +/// 使调用方的深度门 / 轮次门必然拒绝,而不是静默放行。 +fn static_delegate_lineage_counters( + deliveries: &[StaticDelegateDeliveryRecord], + delegation_id: &str, +) -> (u32, u32) { + let mut chain: Vec<&StaticDelegateDeliveryRecord> = Vec::new(); + let mut seen_ids: std::collections::BTreeSet<&str> = std::collections::BTreeSet::new(); + let mut current_id = delegation_id; + loop { + if !seen_ids.insert(current_id) || chain.len() >= STATIC_DELEGATE_LINEAGE_MAX_HOPS { + return (u32::MAX, u32::MAX); + } + let Some(node) = deliveries + .iter() + .find(|delivery| delivery.delegation_id == current_id) + else { + return (u32::MAX, u32::MAX); + }; + chain.push(node); + match node.repair_of_delegation_id.as_deref() { + Some(parent_id) => current_id = parent_id, + None => break, + } + } + // chain 目前是 [目标 .. 根],反转成 [根 .. 目标] 便于按 R1/R2/R3 正向传播。 + chain.reverse(); + let mut depth = 0u32; + let mut round = 0u32; + for parent in &chain[..chain.len().saturating_sub(1)] { + if static_delegate_original_is_awaiting_clarification(*parent) { + round += 1; + } else { + depth += 1; + round = 0; + } + } + (depth, round) +} + +/// 澄清轮次上限只按发起请求所在 Run 的 source 区分(详见常量注释), +/// 与仓库已有的 game-chat 分支先例(见 agent/runtime_tools/delegation.rs 的 +/// may_be_game_chat 判定)同源:source 缺失 binding 时按非 game-chat 处理。 +fn static_delegate_clarification_round_limit_at( + root: &Path, + parent_agent_id: &str, + parent_run_id: &str, +) -> Result { + let is_game_chat = read_game_creator_agent_runtime_run_profile_binding( + root, + parent_agent_id, + parent_run_id, + )? + .is_some_and(|binding| { + binding.source.trim() == AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE + }); + Ok(if is_game_chat { + STATIC_DELEGATE_CLARIFICATION_ROUND_LIMIT_GAME_CHAT + } else { + STATIC_DELEGATE_CLARIFICATION_ROUND_LIMIT_DEFAULT + }) +} + pub(crate) fn validate_static_delegate_repair_request_at( root: &Path, parent_agent_id: &str, @@ -1116,7 +1207,15 @@ pub(crate) fn validate_static_delegate_repair_request_at( if original.target_agent_id != target_agent_id { return Err("静态委派返工必须交回原专业 Agent".to_string()); } - if original.repair_of_delegation_id.is_some() { + let (original_repair_depth, original_clarification_round) = + static_delegate_lineage_counters(&deliveries, &original.delegation_id); + if static_delegate_original_is_awaiting_clarification(original) { + let clarification_round_limit = + static_delegate_clarification_round_limit_at(root, parent_agent_id, parent_run_id)?; + if original_clarification_round >= clarification_round_limit { + return Err("静态委派澄清轮次已达上限".to_string()); + } + } else if original_repair_depth >= 1 { return Err("静态委派返工深度最多为 1".to_string()); } if original.acceptance_criteria != acceptance_criteria @@ -1180,9 +1279,7 @@ pub(crate) fn validate_static_delegate_clarification_continuation_at( read_static_delegate_delivery_at(root, repair_of_delegation_id)?.ok_or_else(|| { format!("澄清 continuation 引用的原 delivery 不存在:{repair_of_delegation_id}") })?; - let needs_user_input = original.structured_result.as_ref().is_some_and(|result| { - result.contract_status == StaticDelegateContractStatus::NeedsUserInput - }); + let needs_user_input = static_delegate_original_is_awaiting_clarification(&original); if !needs_user_input { if !continuation_of.is_empty() || !input_questions_sha.is_empty()