1 minute read

系列 · 我的评测工具链(持续更新)

  1. 从 trace 到 eval:trace 设计、agent eval 方法论、一个 eval harness 的实现,和它抓到的上游并发 bug
  2. 给 skill 建门禁:一天里的三种沉默失败、一次红线写法实证,和它抓到的上游 bug(又一只)
  3. 我给 skill-up 报了一个不存在的 bug
  4. CI 红、本地绿(本文)

一个测试在 macOS 上绿、在 Linux CI 上红。我花了一个小时排查进程组、信号升级和平台差异——全部猜错。真相是 CI 和我测的压根不是同一份代码。

1. 现场:一个「平台相关」的失败

接着上一篇说。给 skill-up 提的 PR #265(超时时合成兜底产物)CI 红了,挂在两处:三个 lint 问题,加一个测试失败:

--- FAIL: TestCustomAgent_RunLocal_TimeoutSynthesizesOutputFile (1.01s)
    custom_test.go:1298: generated_files = [], want the synthesized session-result.json registered

这个测试的逻辑:让一个 custom engine 进程睡死(sleep 30),一秒超时杀掉,然后断言框架合成的兜底文件被登记进了 generated_files

我的第一反应和所有工程师一样:本地跑一遍。

$ go test -race -run TestCustomAgent_RunLocal_TimeoutSynthesizesOutputFile ./internal/agent/
ok  	github.com/alibaba/skill-up/internal/agent	2.945s

绿的。CI 是 Linux,本机是 macOS——「平台差异」的假设就此成立。这个测试涉及超时杀进程:进程组隔离、SIGTERM 一秒宽限后升级 SIGKILL、管道回收的 WaitDelay……每一处都有正当的平台差异嫌疑。我顺着这条线读了 configureProcessGroup 的源码、classifyExecError 的错误分类、kill 升级的时序,越读越觉得每一处都平台无关,但又找不到别的解释。

一个小时就这么进去了。

2. 真相:CI 测的代码和我测的不一样

真正的突破口不是读代码,是一条 git log

这个 PR 的分支最近被 merge 过一次上游 main(保持 PR 新鲜的常规操作)。merge 进了一大批上游新提交,其中一个是 #250:给 custom engine 加多轮会话支持。它顺带做了一个语义收窄——框架自写的输入/输出文件不再登记进公开的 generated_files,改登记进一个仅供 diff 排除用的内部字段generated_file_sources),理由是框架 JSON 里可能带着可续跑的 session ID,不该泄漏给 judge。

而我们的测试,断言的正是旧字段。文本上 merge 得干干净净,一个冲突标记都没有;语义上,上游把我们断言依赖的行为拆走了。merge 干净只证明两边没改同一行,不证明两边对同一个字段的理解还一致。

3. 但等等——merge 之前 CI 就红了

故事到这儿还差一环。翻 CI 记录,这个断言在 merge 进 main 之前的那次运行(两天前)就已经在 Linux 上挂了。如果语义冲突是 merge 带来的,那次失败算什么?难道真有平台差异,只是恰好和 merge 撞在同一个断言上?

答案是 GitHub Actions 一个容易被遗忘的机制:pull_request 事件构建的不是你的分支,而是「分支 ⊕ 最新 main」的合并态refs/pull/N/merge)。上游的 #250 在 9 月 17 日就进了 main;我们的 PR 是 9 月 19 日提的、9 月 20 日跑的 CI——那时 GitHub 拼出来的合并结果里已经含着语义变更。而我在本机 checkout 的是分支裸态,没有 #250。

所以根本没有什么平台差异。CI 红、本地绿的时候,第一个问题不该是「平台差在哪」,而是「CI 测的是哪份代码」。 我为一个不存在的平台差异读了一个小时的进程管理源码——那一个小时里我离真相的距离,比不看代码还远。

4. 第二层:我们的测试为什么会这么脆

merge 冲突的部分是运气,但有个不依赖运气的部分值得单独说:这个测试断言的是上游正在演进的字段语义

generated_files 在那段时间是上游的活跃重构面——多轮会话支持逼着他们重新划分「哪些是引擎产物、哪些是框架簿记」。我们的测试把「合成产物被登记」这个需求,直接绑死在了「登记进 generated_files 这个具体字段」上。上游没有改坏任何对外契约,他们只是把一个内部字段的语义收窄了,我们的测试就成了 Hyrum’s Law 的标准受害人:

只要一个 API 的可观测行为足够多,就总会有人依赖它——不管你承诺过什么。

修法也因此很轻:断言改到 generated_file_sources 上——那个字段的职责(登记给 artifact 收集和 diff 排除)才是我们真正需要的不变量,合成文件本身照常被 workspace 收集器捞走。测试意图不变,绑定的语义从「字段 A」换成了「字段 A 存在的目的」。顺带把三个 lint 修掉(os.Remove 没检查返回值、os.CreateTemp 该用 t.TempDir()、函数圈复杂度超阈值拆出一个方法),本地 golangci-lint 零告警、全量测试绿,推上去收工。

测试该断言什么,这个系列其实一直在绕同一个问题打转:第一篇是「断言路径还是断言结果」,这一篇是「断言字段还是断言字段的职责」。答案似乎是同一个:断言你真正依赖的那个不变量,而不是它当前恰好寄生的载体。

5. 收尾

第三篇讲「观测会骗人」——mtime 是真的、数字是一致的、结论是错的。这一篇是它的姊妹篇:绿灯也会骗人——本地的绿是真的、CI 的红也是真的,因为两盏灯照的就不是同一份代码。

两条实操纪律沉淀下来:

  1. CI 红本地绿,先核「CI 构建的是哪份代码」(GitHub 的 pull_request 构建合并态;长期挂着的 PR 要勤跟 main,语义漂移每天都在发生)
  2. 给上游提测试时,检查每个断言绑定的是「字段」还是「职责」——前者随上游重构贬值,后者才扛得住

以及一个我越来越有感触的元观察:这个系列写到现在四篇,每一篇的「bug」最后都不在代码里——在 trace 的缺失里、在对照组的纯度里、在观测者的先入之见里、在这次,在「merge 成功」四个字的承诺里。工具越来越复杂,失误的形态却没怎么变过:我们始终败给「以为自己知道」。


本文涉及的 PR:skill-up#265(修复本体)、上游语义变更 skill-up#250

Tags: ,

Categories:

Updated: