Chainlink flaky-test 深度调查协议:三角色 Agent 辩论机制与 JSON 记录规范
Chainlink flaky-test 深度调查协议:三角色 Agent 辩论机制与 JSON 记录规范
本文围绕 Chainlink 仓库 tools/test 测试修复技能中的“复杂调查协议”(complex investigation protocol)展开:当一次常规排查不足以定位复杂 flaky test 的根因时,如何通过 Proposer / Challenger / Arbiter 三个独立 Agent 角色、最多 3 轮辩论,配合 CI 失败日志与 diagnose 运行数据,产出可执行的结构化修复结论。读完本文,你将掌握该协议的激活时机、各角色职责与读取预算、五种 JSON 记录的字段规范、结果路由(PROCEED / INCONCLUSIVE / ABANDONED)的完整流程,以及它与 make test ARGS="diagnose ..." 测试夹具(harness)的衔接方式。
1. 协议定位:何时需要“复杂调查”
该协议是 fix-flaky-tests 技能 的一个子模块,服务于 Chainlink 这类大型 Go 仓库中“系统测试和部分集成测试”级别的疑难 flake。其设计前提是:单次排查(single pass)可能不足以理解复杂测试不稳定的成因,此时需要让辩论发生,并补充更多数据点(CI 失败日志、Docker/应用日志等)。
协议本身的全文见 complex-investigation-protocol.md。它与主技能的衔接关系在 SKILL.md 中定义:
- 初始化阶段(第 2 步):开始前必须询问用户该 flake 属于“相对简单自包含”还是“复杂、需要深入理解应用”(例如系统测试和部分集成测试);若是后者,在形成任何假设之前先激活复杂调查协议;
- 排查循环(第 5 步):如果判定为复杂测试,则按本协议执行,否则走常规“形成假设 → 修复 → 验证”的循环。
主技能还给出了一组与本流程强相关的全局约束(<absolute_constraints>),直接决定了协议运行时的操作边界:
- 禁止使用裸
go test命令,只能从仓库根目录执行make test ARGS="diagnose ...",单次运行用--iterations 1; - 不得修改测试的核心目标来让它通过;除非用更好的断言替换或删除确认的死代码,否则不得删除测试/断言;
- 不得为了让局部测试通过而修改包级公共 helper;
- 不得修复第三方库;若 flakiness 源于三方库,告知用户并停止(这条直接对应本协议的 ABANDONED 出口);
- 写新工具代码前必须检查
go.mod,优先复用已有库; - 预期运行超过 2 分钟的
diagnose要在后台执行、只做一次 30 秒崩溃检查,然后等待report.json通知,不得轮询。
2. 证据收集:CI 失败链接与日志分析
辩论开始前,协议要求先补齐证据链:
- 询问 CI 失败链接。如果用户尚未提供,必须向其索取 CI failure link;只接受 GitHub 或其他 CI 提供商的链接,明确拒绝 Trunk 链接(与 SKILL.md 中“不要打开 JIRA 中指向 Trunk.io 的链接”一致)。
- 用子 Agent 分析 CI 失败。链接要交给 github-failure-analyzer 子 Agent 分析。该子 Agent 被定义为一个“无头、只读的 GitHub workflow 解析器”,温度设为 0.0,只允许
gh、grep、find、wc、cat、sed等只读工具,要求它从日志末尾开始读取、下载失败相关的 workflow artifacts,并且只输出固定结构的原始 JSON(含urls_read、artifact_errors、artifact_locations、failure_diagnosis四个字段)。 - 寻找堆栈、测试日志与应用日志。重点找可能包含应用日志的 stack trace、test logs 和 workflow artifacts。
- 应用日志 / Docker 日志。若可获得应用或 Docker 日志,需逐行梳(comb)出与观察到的测试失败可能相关的错误。
这些证据后续会进入 discussion_record.evidence 字段,来源标记为 user | ci | diagnose 三类之一。
3. 三角色定义:Proposer、Challenger、Arbiter
协议的核心是三个必须分别调用的 Agent 角色。文档明确警告:“绝不能把多个角色合并进一次 Agent 调用——单模型的自我批评违背了该机制的初衷”(Never collapse multiple roles into one agent call — self-critique by a single model defeats the purpose)。
3.1 Proposer(提案者,standard 模型)
职责:读取测试及其 helper 代码,从以下典型 flakiness 源中分析:
- 时序依赖(timing dependencies)
- 共享全局状态(shared global state)
- 顺序假设(ordering assumptions)
- 缺失的清理/拆除逻辑(missing cleanup/teardown)
- 非确定性数据(non-deterministic data)
- 其他常见 flakiness 源
分析后提出最可能的根因和一个带文件与行号引用的具体修复,返回 proposed_fix_record。
两条关键规则:
- 调查必须参考此前被否决的方案。这些方案不得被回避——除非出现支持它们的新决定性证据。这防止多轮辩论原地打转。
- 读取预算无上限(unbounded),但代价是:每一次文件/代码范围读取必须追加到
discussion_record.code_snippets缓存中,供后续轮次和其他角色复用,避免重复读取。
proposed_fix_record 的 JSON 结构:
{
"root_cause": "string",
"fixes": [
{
"id": "integer",
"fix_file": "string",
"fix_line": "integer",
"fix_description": "string"
}
],
"excluded_approaches": ["string"]
}
3.2 Challenger(挑战者,reasoning 模型)
职责:接收 Proposer 的完整输出和 discussion_record(含 code_snippets),对提案发起挑战——替代成因、边界情况、破坏其他测试的风险。它必须明确表态:提案所主张的因果机制本身是否成立(必须显式给出立场,而非泛泛质疑)。挑战结果返回 fix_evaluation_record。
读取预算为 0(默认):Challenger 只能基于 code_snippets 推理。例外是它至多可以执行 2 次定向读取来验证某个具体论断——但每次读取必须写明 file:start_line-end_line 以及它要验证的论断内容,且读取内容同样必须追加进 code_snippets。
fix_evaluation_record 的 JSON 结构:
[
{
"fix_id": "integer",
"challenges": ["string"]
}
]
3.3 Arbiter(仲裁者,reasoning 模型)
职责:同时接收 Proposer 与 Challenger 的产物,决定停止(信心已足够)还是再跑一轮(总共最多 3 轮),并给出最终裁决,返回 verdict_record。
读取预算为 0:Arbiter 只对两份记录本身做推理,不读取任何文件。
verdict_record 的 JSON 结构:
{
"decision": "PROCEED | ANOTHER_ROUND | GIVE_UP",
"rationale": "string"
}
4. discussion_record:辩论的共享状态
三个角色之间传递的不是自由文本,而是一份结构化的 discussion_record。它由编排方(orchestrator)构建,随轮次推进不断填充:
{
"evidence": [
{
"source": "user | ci | diagnose",
"content": "string"
}
],
"code_snippets": [
{
"file": "string",
"start_line": "integer",
"end_line": "integer",
"snippet": "string",
"why_relevant": "string"
}
],
"investigation_history": {
"excluded_approaches": ["string"],
"rejection_reasons": ["string"]
}
}
字段规则(field-rules):
- 历史种子化:如果存在与该测试/包相关的 Jira 历史修复评论,或者存在
diagnose-attempted-fixes-*.jsonl文件,构建记录时必须从这些来源初始化investigation_history。这与主技能排查循环第 9 步形成闭环——每次尝试修复后都要向该 jsonl 文件追加{"timestamp", "model", "hypothesis", "experiment", "result", "next"}记录,因此跨会话的调查记忆由此文件承载; code_snippets共享缓存:初始为空,各角色读取代码时追加;按(file, start_line, end_line)三元组去重。
缓存的粒度规则(来自绝对约束第 5 条):code_snippets 缓存是按工单(per-ticket)隔离的。两个工单只有在“一个测试名是另一个的子测试”(如 TestFoo 与 TestFoo/Subcase)且合并后的缓存保持在约 50 条以内时,才允许共享缓存。
5. 运行逻辑:一轮辩论的完整数据流
协议给出的 <logic> 定义了编排方的六步流程:
- 汇集证据:整合用户提供的信息与
diagnose运行产物;读取diagnose-attempted-fixes-*.jsonl中的既往尝试并种子化investigation_history;如可获得应用或 Docker 日志,梳理其中可能与失败相关的错误。 - 构建空的
discussion_record(code_snippets为空),交给 Proposer,启动第 1 轮。 - Proposer 完成后,把
proposed_fix_record和(此时已填充的)discussion_record交给 Challenger。 - Challenger 完成后,把
proposed_fix_record、fix_evaluation_record和discussion_record一并交给 Arbiter。 - 按 Arbiter 裁决分派:
PROCEED→ 进入第 6 步;ANOTHER_ROUND(仅当当前轮次 < 3)→ 把被否决的修复追加进discussion_record.investigation_history.excluded_approaches,把 Challenger 的推理追加进rejection_reasons,保留code_snippets,丢弃本轮的proposed_fix_record与fix_evaluation_record,开启下一轮;GIVE_UP,或达到 3 轮上限仍未PROCEED→ 结果为INCONCLUSIVE。
- 构建并返回
discussion_result_record,然后按<outcome_routing>路由后续动作。
注意第 5 步的状态管理细节:跨轮保留的是证据与历史(code_snippets + investigation_history),丢弃的是本轮观点(两份 record)。这正是“被否决的方案会约束后续轮次”这一设计的落地点——Proposer 在第 2、3 轮会带着 excluded_approaches 与 rejection_reasons 重新出发,而不是重复已被证伪的假设。
6. 最终产物与结果路由
discussion_result_record 是协议对外输出的最终 JSON,字段按结果类型条件性置值:
{
"jira_key": "KEY-NNN | null",
"test_name": "string",
"outcome": "PROCEED | INCONCLUSIVE | ABANDONED",
"evidence": [
{
"source": "user | ci | diagnose",
"content": "string"
}
],
"fixes": [{
"fix_file": "string (PROCEED only, null otherwise)",
"fix_line": "integer (PROCEED only, null otherwise)",
"fix_description": "string (PROCEED only, null otherwise)"
}],
"proposer_root_cause": "string (PROCEED or INCONCLUSIVE only, null otherwise)",
"excluded_approaches": ["string (PROCEED only, null otherwise)"],
"inconclusive_reason": "string (INCONCLUSIVE only, null otherwise)",
"recommended_next_step": "string | null (INCONCLUSIVE only)",
"abandoned_reason": "string (ABANDONED only, null otherwise)"
}
<outcome_routing> 定义了三种出口各自的后续动作:
PROCEED:带着fixes回到主技能<loop>的第 7 步(Implement the fix),随后按标准流程跑diagnose验证修复有效性;INCONCLUSIVE:先向 jsonl 文件写入一条记录(hypothesis=proposer_root_cause,experiment="complex protocol, N rounds, INCONCLUSIVE",result=inconclusive_reason,next=recommended_next_step),然后分三种情况:- 该工单第一次 INCONCLUSIVE → 按
recommended_next_step以更宽的信号重跑diagnose(更多迭代次数 /-race/-trace),再重新进入协议一次。没有第二次延期(No second extension); - 批量/自治模式(batch/autonomous mode)→ 执行 abandon-ticket.md 流程;
- 交互模式(interactive mode)→ 询问用户三选一:把 Proposer 的猜测当作单次修复尝试 / 放弃工单 / 携带新证据重新定向;
- 该工单第一次 INCONCLUSIVE → 按
ABANDONED:执行abandon-ticket.md。当某个角色判定根因位于第三方库代码时发出此结果——这与主技能“不要修复第三方库,告知用户并停止”的绝对约束一致。abandon-ticket.md 本身规定:已认领的工单绝不能停留在 "In Progress",需解除 assignee、把工单流转回 Open,并写入一条 Outcome 为 ABANDONED 的调查评论。
7. 绝对约束:保证协议不退化的五条硬规则
协议用 <absolute_constraints> 固化了五条不可违反的约束,理解它们能看清整个设计的安全边界:
- 最多 3 轮;
- 每个角色是独立的 Agent 调用(使用 Agent tool);
- 绝不允许把多个角色合并到一次调用——单模型自我批评违背机制初衷;
- 不同工单的调查可以并行;但同一调查内部的 3 个角色必须串行;
code_snippets缓存按工单隔离,共享仅限子测试关系且合并后约 50 条以内。
这些约束共同解决了多 Agent 辩论机制的三个典型失效模式:轮数失控(约束 1)、辩论形式化(约束 2、3 强制真实的外部质疑)、以及并行调查间的上下文串扰(约束 4、5)。
8. 与 Chainlink 测试夹具的衔接
协议中的证据源 diagnose 来自仓库自带的测试 harness:tools/test/README.md 说明它是基于 testrig 的 Go 测试运行器,make test 目标(见 GNUmakefile 第 312 行 test: $(TOOLS_TEST_BIN))会构建 harness 二进制并转发参数。协议与之的衔接点有:
- 运行命令:只使用
make test ARGS="diagnose [harness_flags] -- [go_test_flags] ./path",其中--ai-output必须位于--之前,禁止-count,重复运行严格通过--iterations实现(harness flags 还有--fail-fast-on=(timeout|slow)、--parallel-iterations N;Go flags 如--run '^TestName$'、--timeout 10m、--race); - 迭代次数与漏检概率:SKILL.md 给出
--iterations与漏检 flake 概率的对照——5 次约 50% 漏检、30 次约 10%、60 次约 5%、150 次约 2%、300 次约 1%、500 次以上 <1%。因此INCONCLUSIVE后“以更多迭代次数重跑”是有量化依据的; - 日志产物结构:
diagnose运行会产出iteration-n.log.jsonl(完整输出,按需读取)、postgres-state-n.md(数据库错误/挂起时查看最终状态)、report.json(摘要,可用jq .run提取参数)和logs/pkg_TestName_iter-n.log(特定测试失败日志)。协议逻辑第 1 步“读取diagnose的产物”即针对这些文件; - 数据库隔离背景:从 SKILL.md 的
<tests-context>可知,Chainlink 测试共享同一个 Postgres 数据库、diagnose循环会创建新 DB——“共享全局状态”正是该仓库 flake 的典型来源,也是 Proposer 分析清单里的高优先项。
9. 小结:这套协议可迁移的方法论
剥离 Chainlink 的具体上下文,complex-investigation-protocol.md 展示了一套可复用的疑难问题排查范式:
- 先证据后假设:辩论前必须汇集 CI 失败链接、堆栈、应用/Docker 日志,并种子化历史尝试,避免重复走死路;
- 三角色分离 + 读取预算分层:提案者可充分读码(unbounded),挑战者默认零读取、仅允许 2 次定向验证,仲裁者纯记录推理。预算差异迫使每个角色承担不同认知成本,而
code_snippets缓存用(file, start_line, end_line)去重保证证据只读一次、全角色可见; - 状态与观点分离:跨轮保留的是证据与历史(
code_snippets、investigation_history),丢弃的是单轮观点(两份 record),使“被否决方案进入下一轮约束集”成为机制性保证; - 有限轮次 + 条件性出口:3 轮上限、无第二次延期、INCONCLUSIVE 后按模式分流(重试 / 放弃 / 询问用户)、ABANDONED 强制走工单回滚流程,保证自治模式下的资源与工单状态收敛;
- 全链路结构化 JSON:从
proposed_fix_record、fix_evaluation_record、verdict_record到最终的discussion_result_record,每个阶段的产物都有严格 schema,可被上游技能循环、jsonl 历史文件与工单评论直接消费。
对维护大型 Go 测试套件的团队而言,这套协议的价值不仅在于它如何修 flaky test,更在于它示范了如何用“角色隔离、预算约束、共享证据缓存和有限轮次”四个机制,让多 Agent 协作排查保持可控、可审计、可复现。