qwen-code/review发散哨兵(successor-chain):基于 closure 血缘的跨轮评审收敛诊断
【免费下载链接】qwen-codeAn open-source AI coding agent that lives in your terminal.项目地址: https://gitcode.com/GitHub_Trending/qw/qwen-code
qwen-code 的/review跨轮评审会针对同一 PR 反复运行多轮修复—评审循环,而本轮设计文档(docs/design/review-divergence-sentinel.md)描述的"发散哨兵"(divergence sentinel,issue #9905)用于识别循环中同一子系统连续两轮各关闭一个 Critical、本轮又新长出一个 Critical的"补丁—回归"级联形态。本文将结合packages/cli/src/commands/review/下的实际源码与测试,完整讲解closed列表的铸造、携带与校验机制,K=2 血缘检查的判定逻辑,以及successor-chain建议码的机器可读输出,帮助你理解这套"只测量、不裁决"的收敛观测体系是如何做到确定性、可审计且诚实披露的。
背景:评审循环的"换代级联"发散
推送触发的评审加上修复发现的 Agent 构成一个反馈回路,当回路的增益大于 1 时(每次被接受的修复都会扩大 diff,下一轮评审更多代码、返回更多发现),循环就不会收敛。仓库实测中,PR 曾携带数百个未关闭线程仍未收敛,其中一个在约 500 条评论时仍未合入(见 convergence.ts 头部注释)。
问题在于:/review的跨轮 ledger 虽然追踪 finding id、carry 与 supersession,却没有任何环节读取"血缘"(lineage)——当第 N 轮 Critical 的修复在第 N+1 轮同一子系统上产生新的 Critical 时,每一轮局部看都是正常的,而子系统整体正在发散。设计文档引用的 #9659 案例是典型:
- 停止轮次的 blocker-dating 链产生了三代后继 Critical:
R9-1 → R10-2/3/4 → R11-4/R11-6 → R12-1/R12-2; - 识别发散靠的是人工的轮次数分析;
- 在机制被删除、开放 finding 数量崩塌之前,整整花费了四轮 patch-and-regress。
而既有收敛模块的 recurrence cluster 只能表述"该文件再次出现发现",无法表述"修复关闭了一个、机制又长出了另一个"——因为没有任何一轮记录 closures(关闭事件)。
设计总览:确定性血缘检查
解决方案是一个确定性的血缘检查,位于diagnoseConvergence内部,由 ledger marker 携带的有界 closure 列表供数:
- 不依赖任何模型判断——只是对管道本来就会写入的数据做一次遍历;
- 每次触发都是本 PR 自身轮次之间的比较,没有阈值、没有"评论太多"之类的数字门槛(这是收敛模块的一贯原则:一个工具拥有的策略,就必须在它一无所知的仓库上为它辩护);
- 输出永远只是观察(observation),不移动事件、不扣留 anchor、不封顶裁决——是否继续修复、重构、拆分或合入,是作者与运营者的决定。
Ledger 标记携带closed列表
数据结构
Ledger增加一个可选的closed数组,条目{r, id, f}记录一个 Critical:
r:该 finding 从工作列表消失时所属的轮次;id:被关闭 finding 的原始轮次 id(如R9-1);f:被关闭 finding 所在文件。
对应源码为 ledger.ts 中的LedgerClosure接口,以及Ledger.closed字段(ledger.ts):
export interface LedgerClosure { /** The round whose marker the finding is absent from. */ r: number; /** The closed finding's id — its ORIGINAL round's id, e.g. `R9-1`. */ id: string; /** The closed finding's file. */ f: string; }字段刻意使用单字母r/f,与 finding 相同的"body 字节纪律"保持一致——marker 是嵌在 PR 评论体里的 HTML 注释,每字节都算数。
铸造(minting):位置的差异即关闭
铸造发生在组成轮次r时:把恢复出的上一轮工作列表与本轮构建的 ledger做位置差分,每个消失的 Critical id 追加一条 closure 条目。要点:
- 只追踪 Critical——Suggestion 不追踪,Critical 的流失才是信号;
fixed与superseded都读作关闭——位置差分无法区分二者,而咨询性注释也不需要区分;- 只带一代、不向前携带——K=2 检查在第 N 轮只读取第 N 轮(组成时铸造)与第 N-1 轮(从上一轮 marker 读回)的 closure,更早的条目是死字节。
界限与字节预算
closure 列表严格遵守 marker 的"脚注、绝不是 payload"契约:
- 数量上限:
LEDGER_MAX_CLOSED = 50(ledger.ts),恰好等于一整份工作列表(LEDGER_MAX_FINDINGS同为 50),保留最新; - 字节位置:在序列化器的 shed 级联中,closures 排在卷量遥测之后、anchor 对与工作列表之前(ledger.ts)。级联顺序依次为:双卷量 → 单卷量 → 无卷量 →丢弃 closure 列表→ 丢弃 anchor 对 → 逐条裁剪 finding。之所以让 advisory 数据最先被 shed,是因为丢失它只损失一轮血缘,而 anchor 和工作列表是下一轮要据以行动的声明;
- shed 从不设置
dropped:closures 不证明任何范围(range),因此不存在需要保护"完整性"的声明(见 ledger.ts 注释与 ledger.ts 解析侧的处理)。
铸造抑制:诚实原则的 fail-closed
铸造在"缺席于发布集合"**并不等于"被判定已修复"**的所有场合被抑制——这与openCriticals门对同一推断施加的诚实规则完全一致(见 compose-review.ts):
- 不完整的恢复工作列表(
dropped/被拒绝条目)——消失的 id 可能是截断,而非裁决; - 纯 diff-only 轮次——无法作出裁决;
- 本轮对某 Critical 公开回答"无法判断"——
anchorFailsClosed谓词同时约束 closures; - 纯外来(pure-foreign)前列表——条目是陌生人的,不是本账号列表的缩短版(#9526);
- 匿名采纳(anonymous adoption)——持久化接缝的机器可读记录表明该文件 finding 是在无身份担保的情况下被采纳的。
铸造还按CLAIM 身份而非id 身份收敛:一个本轮以重新铸造的 id 重新发布的 claim(如重新生成的 gate Critical、readback 丢失的模型重发)仍然成立。具体实现是(compose-review.ts):
- 以
claimLocator投影(去 id、去—分隔、去反引号)对比构建侧与恢复侧的 claim,凡仍在standingClaims集合中的 claim 一律不算关闭; - 重发通道——typed deferral 通道与 floor 强制执行喂入的 reroute——以它们携带的 IDjoin:标题带原始 finding id 的条目,无论以何种 severity 或路径重发,该 claim 都保持成立;不带任何 id 的条目无法证明它重发的是什么,本轮整个铸造 fail closed,什么都不铸造;
- 铸造前提还包括:上一轮工作列表完整(
carriedWorkList.complete)、本轮的构建非空、且prevFacts.foreign !== true || prevFacts.merged === true(合并过的外来标记仍可铸造)。
薄历史(thin history)保持沉默而非猜测——这是整个机制反复出现的诚实姿态。
解析与准入
parseLedger通过isLedgerClosure校验 closure(上限与轮次约束,与序列化器镜像):
- 轮次必须是
[1, markerRound]内的整数(closure 声称的轮次不可能超过 marker 自身的轮次); - id 必须满足
LEDGER_ID_SHAPE(^R\d+-\d+$)、长度 ≤LEDGER_MAX_ID(24),且 id 的铸造轮次严格小于 closure 轮次r(只有轮次封顶处例外)——否则一条从未处于开放状态的代会被种进血缘连接(ledger.ts); - 解析侧同样不设
dropped对应物——closure 不证明任何范围,截断的历史没有需要保护的完整性声明。
prevLedgerFacts在 side-file 路径上用同样的准入测试把 closures 带进 compose(pr-context.ts 在接缝处会从外来 marker 剥离closed,见 pr-context.ts 附近)。
K=2 检查:successor chain 的判定
在diagnoseConvergence内部、recurrence cluster 旁边,一个文件 F 触发successor chain需要同时满足三个条件(K=2,含当前轮):
- F 在上一轮关闭了一个 Critical(
prev.closed中r === round - 1的条目); - F 在本轮关闭了一个 Critical(
closuresThisRound中r === round的条目); - 本轮在 F 上发布了一个新鲜的Critical——以构建后 ledger 的
R<round>-*id 为准(构建 id 的轮次就是首报轮次,经过 readback 与准入之后)。
对应实现位于 convergence.ts,数据结构为:
export interface SuccessorChain { /** The subsystem, named by the file every generation landed on. */ file: string; /** The closure ids of each of the two rounds, oldest first. */ generations: string[][]; /** The fresh Critical ids this round posts on the file. */ newIds: string[]; }cluster 自身的文件规则原样适用:
(body)/(unknown)占位名(LEDGER_BODY_FILE/LEDGER_UNKNOWN_FILE)永不参与——闭包条目不携带k标志,若参与会把一条从未锚定在任何机制上的血统发出来;k标志仍用于区分"碰巧拼得像占位名的真实路径"(ledger.ts 的isStandInName);- 路径截断的镜像回退:closure 在每条路由上都以
LEDGER_MAX_FILE(200)封顶,而检查侧以原始构建路径为键,超过上限时回退到前缀切片(与priorFor相同),否则哨兵恰好会在它存在的深子系统上失效(convergence.ts); - 新鲜侧的身份防御:一个 readback 丢失 carried id 的重发会在构建中被盖上全新 id,所以仅凭
birthRound === round会把仍成立的 claim 误算作链条新一代。检查以铸造成的claimLocator投影对比恢复的前列表——条目"存在"本身就是证据,无论列表完整性如何(convergence.ts)。
与 #9659 证据的对照
设计文档给出的验证:组成第 11 轮时,在第 10 轮关闭R9-1(r=10)的基础上关闭R10-2/3/4(r=11),而R11-4/R11-6落在同一文件上——注释在第 11 轮触发,比人工分析发现反弹早一轮。这一场景在 convergence.test.ts 有对应单测("fires on the #9659 rebound shape — closed in each of the last two rounds, fresh Critical now")。
输出:咨询性的一等公民
类型化链条与渲染
ConvergenceDiagnosis.successorChains携带类型化链条{file, generations, newIds},由renderSuccessorChain渲染为R9-1 → R10-2/R10-3 → R11-4(convergence.ts):
export function renderSuccessorChain(c: SuccessorChain): string { const renderGeneration = (ids: string[]): string => { const shown = ids.slice(0, MAX_CHAIN_IDS_PER_GENERATION).join('/'); return ids.length > MAX_CHAIN_IDS_PER_GENERATION ? `${shown} … (+${ids.length - MAX_CHAIN_IDS_PER_GENERATION})` : shown; }; return [...c.generations, c.newIds].map(renderGeneration).join(' → '); }每代最多展示MAX_CHAIN_IDS_PER_GENERATION = 6个 id,超出以… (+N)省略——防止一轮关闭整个家族时段落无限膨胀。人类可读的 prose 与机器可读的 basis共用这一个渲染器,保证两侧永远不会对它们指称的血缘产生分歧。
双语 Divergence 语句
收敛观察(发布的 body + stderr 的CONVERGENCE:行)以⚠️ Divergence 语句开头,点名子系统与链条,中英双语,且每个 PR 可控片段都经mdField转义(convergence.ts):
- EN:
⚠️ Divergence: the same subsystem closed Critical(s) in the previous round and again this round, and posts a new one now — <file> (<chain>); and N more. - ZH:
⚠️ 发散:同一子系统在上一轮和本轮各关闭了至少一个 Critical,本轮又出现了新的 Critical——<file>(<chain>);另有 N 个。
链条完整点名(不只列文件),因为 id 是作者据以行动的证据。此外还会附证据说明(Evidence)条款:链条最新一代携带本轮铸造的 id——一个未解决断言在不携带原 id 的情况下被重新表述,在那里与新的 Critical 无法区分,该身份差距被显式披露而非断言"新一代是新的"(convergence.ts)。
机器可读建议码:successor-chain
recommendationsFor为匹配到的链条发射一个新的闭集词汇代码successor-chain,链条渲染结果放入其basis字段——这正是 autofix 工具链读取的机器可读半边(convergence.ts):
out.push({ code: 'successor-chain', basis: `${d.successorChains.length} subsystem(s) closed Critical(s) in the previous round and again this round, and post a new one now: ` + `${shown.join(', ')}${d.successorChains.length > shown.length ? ', …' : ''}`, });它随组装的产物一起通过既有的recommendations校验——校验会拿代码对照运行时列表RECOMMENDATION_CODES(convergence.ts):
export const RECOMMENDATION_CODES = [ 'root-cause-triage', 'successor-chain', 'land-and-defer', 'batch-fixes', 'stem-surface', ] as const;设计文档中的建议菜单原本有 12 个代码,本模块实际只发射这 5 个(successor-chain随 #9905 加入),其余 7 个因"本轮证据不足以支撑"而缺席,且每个缺席都有说明理由而非遗忘。闭集是刻意为之:调用方在不解析散文的情况下把这些代码接到动作上,词汇表就是一份契约;匹配是"测量 → 建议",零常量、零决策。
与链条匹配的人类可读建议是一句机制层面的指引(convergence.ts):"每次修复都长出下一个 Critical 的机制是在发散而非收敛——先把这一模式提给该机制的负责人,通常比继续打补丁更快结束循环。"
测试验证
convergence.test.ts 起用一组describe('diagnoseConvergence — the successor chain (#9905)')覆盖该功能:
- #9659 反弹形态:上轮关闭
R9-1(r=10)、本轮关闭R10-2(r=11)、本轮新发R11-4于同一文件 → 断言successorChains的结构与successor-chain建议码的 basis 文本; - 非相邻轮次(r=1 与 r=3 的闭包)不触发——K=2 要求严格相邻;
- 同一轮次的闭包(r=2 与 r=2)不触发;
- 占位名文件
(body)不参与连接; - 超长路径的切片回退:超过
LEDGER_MAX_FILE的原始路径经前缀切片后仍能命中; - 新鲜侧防重铸:仍在上一轮列表中立足的 claim 以新 id 重发时,不计为新一代;
- 排序:多链条按新 Critical 数量、闭包总量、码元路径稳定排序;
- 每代 6 个 id 的上限与
+N省略、多文件多链条渲染等。
闭包铸造侧的抑制逻辑(不完整列表、外来列表、匿名采纳、无法识别的重发条目)由 compose 侧与 ledger 侧测试共同守护,例如 ledger.test.ts 中对isLedgerClosure轮次/id 文法/长度上限的校验。
它刻意不做的事
设计文档明确列出了边界,避免读者高估能力:
- 无法区分
fixed与superseded,也无法区分被丢弃的 carry——关闭是位置性的。注释是咨询性的,罕见的误报只花一句话;保持 carry 诚实的是LEDGER_ID_READBACK与 stray-id 规则那套机制,而非本检查; - 从不封顶裁决、从不扣留 anchor——发散是被评审代码的属性,不是本轮读到了什么;
- 仅文件级匹配——ledger 不记录符号(issue 中的 "symbol, if recorded"——它没有被记录),因此无法下钻到函数或符号级别。
总结
review-divergence-sentinel设计为/review跨轮评审补上了最后一块"收敛观测"拼图:通过让 ledger marker 携带一代有界 closure 列表,diagnoseConvergence得以在 K=2 窗口内用纯确定性数据识别"修复长出下一个 Critical"的换代级联,比人工轮次数分析早一轮发出 ⚠️ Divergence 警告,并通过successor-chain建议码把同一事实同时交给人类阅读与 autofix 工具链消费。整套机制自始至终坚持三个约束:只测量不裁决(不封顶、不扣留、不移动事件)、薄历史保持沉默(所有推断都在证据完整时才发声)、单一事实来源(marker 记录、诊断、建议码与渲染文本共用同一份数据,永不各自漂移)。
相关实现与测试:
- 设计文档:docs/design/review-divergence-sentinel.md
- 诊断与建议码:packages/cli/src/commands/review/lib/convergence.ts
- ledger 数据结构、序列化与解析:packages/cli/src/commands/review/lib/ledger.ts
- 闭包铸造与组装:packages/cli/src/commands/review/compose-review.ts
- 前轮事实恢复与外来剥离:packages/cli/src/commands/review/pr-context.ts
- 单测:packages/cli/src/commands/review/lib/convergence.test.ts
【免费下载链接】qwen-codeAn open-source AI coding agent that lives in your terminal.项目地址: https://gitcode.com/GitHub_Trending/qw/qwen-code
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考