补 322 个测试,挖出 19 个 bug

补 322 个测试,挖出 19 个 bug

开发记录 · 2026-08-08 · RepoPilot

项目原本有 67 个测试,集中在 mutation 引擎和端到端链路 ------ 因为那是我觉得最容易错的地方。 command.tsverify.tspatch.tstools.tsregistry.ts 一个单测都没有, 理由是"这些逻辑很直"。

补完之后:389 个测试,19 个真实缺陷。

那些"很直"的模块,缺陷密度最高。

先说最扎心的一条

现有的端到端测试里有这么一条断言,从写下那天起一直是绿的:

ts 复制代码
// 规划阶段没有产生任何 R1 副作用
const planPhaseWrites = recorder.toolCalls.filter(
  (t) => t.risk !== 'R0' && t.at < recorder.planApprovedAt!,
);
expect(planPhaseWrites).toHaveLength(0);

看起来它在守护一条重要的安全性质:规划阶段只读。

它绿,只是因为脚本化的模型恰好没点名 R1 工具。

真实情况是:dispatchTool 查的是全局 TOOLS_BY_NAME,函数签名里根本没有 phase 参数。PLANNING_TOOLS 只决定「给模型看哪些 schema」。模型只要凭记忆 写出 workspace_mutate 这个名字(它在训练数据里见过无数次类似的工具名), 规划阶段就能真的改文件。

一个永远不会红的断言,等于没有断言。

新的测试专门让模型去点名:

ts 复制代码
/** 规划期就点名写工具的模型。第二轮才老老实实提交计划。 */
class RogueModel implements ModelInvoker {
  async invoke(input) {
    if (input.purpose === 'PLANNING' && this.turn === 1) {
      // 规划期直接点名 R1 工具 ------ 模型完全可以凭记忆写出这个名字
      this.attempted.push('workspace_mutate');
      return toolUse('workspace_mutate', {
        operations: [{ kind: 'CREATE_FILE', path: 'src/planted.ts', newText: '...' }],
      });
    }
    ...
  }
}

断言里第一句是前提检查

ts 复制代码
// 前提:模型确实尝试了 ------ 否则这条测试什么都没验证
expect(model.attempted).toEqual(['workspace_mutate', 'run_command']);

const denied = host.calls.filter((c) => c.resolution === 'DENIED');
expect(denied.map((c) => c.toolName).sort()).toEqual(['run_command', 'workspace_mutate']);
for (const c of denied) expect(c.reason).toBe('PHASE_READONLY');

// 最硬的断言:那个文件根本不存在,generation 也没推进
expect(existsSync(join(workspace.activePath, 'src/planted.ts'))).toBe(false);
expect(workspace.activeGeneration).toBe(0);

修法是给 dispatchTool 加 phase 参数,在风险门之前加一道真闸门。

最严重的运行时缺陷:SIGKILL 是死代码

command.ts 的注释写着「用 detached 进程组 + kill(-pid) 终止整棵进程树, 避免留下孤儿构建进程」,代码里也确实有 SIGTERM → SIGKILL 的升级逻辑:

ts 复制代码
const killTree = (): void => {
  try { process.kill(-child.pid, 'SIGTERM'); } catch { ... }
  setTimeout(() => {
    if (settled || child.pid === undefined) return;   // ← 这里
    process.kill(-child.pid, 'SIGKILL');
  }, 3000).unref();
};

问题在 settled。两个调用点都是:

ts 复制代码
killTree();
finish('TIMEOUT', null, null);   // finish() 同步把 settled 置为 true

3 秒后定时器触发,settled 早就是 true 了,回调必然直接 return。 升级逻辑一次都不会执行。

实测:一个 process.on('SIGTERM', () => {}) 的 node 子进程, 在 runCommand 返回 TIMEOUT 之后 6 秒仍然活着。

后果是:任何屏蔽 SIGTERM 的构建 / watch 工具超时后都会变成永久孤儿进程 , 而返回值报告的是 TIMEOUT ------ 从调用方的角度完全看不出来。

根因是语义混用settled 表示"Promise 已定型",不是"进程已死"。

ts 复制代码
let settled = false;
/** 进程是否真的退出了。**不能**复用 settled ------ 那表示 Promise 已定型,不是进程已死。 */
let childClosed = false;

顺带那个 .unref() 也得去掉 ------ 不然进程可能在升级发生前就随主进程退出了。

第二严重的:输出截断方向反了

这条特别隐蔽,因为它平时完全正常,只在输出很大的时候出问题。

两层截断,方向相反:

ts 复制代码
// 捕获层:累计到 512KB 就停止累积 ------ 保留的是【开头】
const capture = (chunk, into) => {
  const total = stdout.length + stderr.length;
  if (total >= MAX_CAPTURE_BYTES) { truncated = true; return; }
  ...
};

// 预览层:从中取尾巴 ------ 因为构建错误在【结尾】
function tailPreview(text: string): string {
  const tail = trimmed.slice(-PREVIEW_MAX_BYTES);
  ...
}

于是拿到的是「前 512KB 的末尾」,而不是进程真正的末尾。

实测:产生 720KB 输出、最后一行是 FINAL-ERROR-MARKER,预览里完全没有这一行, 全是中段噪音。

tscwebpack、大仓库的测试输出动辄上 MB,而错误摘要恰恰在最后几行。 也就是说:输出越大,越拿不到结论 ------ 正好和你需要它的时候相反。

修法是把捕获层改成保留尾部的环形缓冲,从头部丢弃而不是停止累积:

ts 复制代码
class TailBuffer {
  push(chunk: Buffer): void {
    ...
    while (this.bytes > MAX_CAPTURE_BYTES && this.chunks.length > 1) {
      this.bytes -= this.chunks.shift()!.byteLength;   // 从头部丢
      this.droppedHead = true;
    }
  }
}

同一个文件里还有三条

outputTruncated 在被截断时报 false。 判定用的是两条流之和 > 8000, 而实际截断发生在 tailPreview 里、按每条流 4000 字节。单条 stdout 输出 6000 字节时, 预览已经被腰斩,标志却是 false ------ 下游据此把残缺日志当成完整证据。 修法:让截断层自己回报,不在外面用另一套阈值猜。

跨 chunk 的多字节字符被解成 U+FFFD。 每个 chunk 独立 toString('utf8'), 64KB 的 pipe 边界几乎不可能整除 3。实测中文输出会随机糊掉几个字符, 而且是静默发生的。修法:StringDecoder 持有跨块状态。

按字节判阈值、按字符切片。 Buffer.byteLength(x) > 4000 判定, x.slice(-4000) 截取。纯中文时返回 12000 字节 ------ 调用方按 4000 字节规划 IPC 载荷和提示词预算,实际拿到三倍。这个模式在三个地方各写了一遍 (command.tailPreviewtools.projectpatch.sealPatch)。

一个我很喜欢的缺陷:原型链

profile.commands 是普通对象字面量,三处都这么判存在性:

ts 复制代码
const def = profile.commands[id];
if (!def) { /* 记为 SPAWN_ERROR */ }

commandId 是模型可控的字符串。当它是 'constructor' / 'toString' / 'valueOf' 时,取到的是 Object.prototype 上的成员 ------ truthy 。 于是 !def 为 false,代码往下走:

ts 复制代码
const [bin, ...args] = def.argv;   // def.argv 是 undefined
// TypeError: undefined is not iterable

整个 runVerification 的 Promise reject。而这违反了同一个文件里写着的不变式: 「命令不存在时不是跳过成功,而是记为 SPAWN_ERROR」。

更妙的是入口校验处有同样的洞:

ts 复制代码
const unknownCommands = input.verificationCommandIds.filter(
  (id) => !effectiveProfile.commands[id],
);

所以 'constructor' 能顺利通过「未登记的验证命令」这道 BAD_REQUEST 拦截, 一路走到运行时崩溃。

根因修在构造处,call site 再加一道:

ts 复制代码
// 用无原型对象:commandId 是模型可控的字符串,普通对象字面量会让
// 'constructor' / 'toString' 这类 key 命中 Object.prototype 并被当成"已登记命令"
const commands: Record<string, CommandDefinition> = Object.create(null);

{...obj} 会重新带上原型,所以三个 call site 还是得用 Object.prototype.hasOwnProperty.call。)

静默省略又出现了两次

这个项目反复强调「任何省略都要报数量和原因」,但代码里还是漏了两处:

被删除的文件在补丁里彻底消失。 changedVsBaseline() 只遍历"当前存在"的文件, 所以基线里有、现在没了的文件既不在 authored 也不在 generatedPatchFileEntry.changeKind 甚至只有 MODIFIED | ADDED。 这直接违反 patch.ts 自己 docblock 里的「不允许静默把某个文件从列表里去掉」。

fs_grep 跳过大文件和被取消时都返回「(无匹配)」。 超过 500KB 的文件 静默跳过,命中被丢掉;搜索被 abort 时也一路走到「(无匹配)」。 于是「真的搜完了没找到」和「压根没搜完」长得一模一样。

有意思的是,同一个函数里为 MAX_HITS 早停加了「结果不完整」声明 ------ 说明作者(我)知道这个原则,只是没贯彻到每一条分支。

测试自己的两个问题

复核 agent 挑出来的,都很中肯。

24 处 it.fails 写测试的 agent 用它来标记"这条测的是已知缺陷"。 但 vitest 的 it.fails 对失败原因不做任何区分 ------ 夹具报错、ENOENT、 workspace 创建失败,全都会让它变绿。

最典型的是那条 SIGKILL 用例:它 readFileSync(pidFile) 拿 pid, 如果子进程因为任何原因没起来、pid 文件不存在,readFileSync 抛 ENOENT, it.fails 照样绿 ------ 而我们真正想验证的"进程还活着"一次都没被检查。

这正是本项目反对的那种断言。缺陷修完后我把 24 处全转成了正常 it

两处 afterAll 无护栏地 rmSync(PATHS.root) PATHS 来自 vi.mock('../paths'),指向一个 mkdtemp 出来的临时目录。但一旦 mock 因为重构失效 (import 路径变了、mock 提升行为变化、有人改成动态 import),PATHS.root 就是用户真实的 ~/Library/Application Support/RepoPilotPrototype ------ 这一行会把它整棵删掉:projects.json、credentials.bin、全部快照。

ts 复制代码
// 护栏:PATHS 来自 vi.mock,一旦 mock 失效这行会删掉用户真实的数据根。
// 只允许删临时目录,绝不碰 ~/Library/Application Support。
if (PATHS.root.startsWith(tmpdir()) || PATHS.root.startsWith('/private')) {
  rmSync(PATHS.root, { recursive: true, force: true });
}

编排

10 个 agent 各写一个测试文件(文件互不重叠,可以安全并行),另有 1 个 agent 统一复核测试质量并核实报上来的缺陷。

给写测试的 agent 的核心约束有三条:

markdown 复制代码
- **负向测试是主体。** 正向路径不容易错,错都错在边界上。
  一个只有 happy path 的测试文件是不合格的。
- **断言要卡在"会说谎"的地方。** 例如不能只断言 passed === false ------
  要断言失败的**原因**是对的(spawn 失败和真实构建失败都会让 passed=false)。
- **失败必须证明无副作用。** 凡是有写操作的模块,失败用例要断言状态逐字节不变。

以及一条很重要的分工纪律:

markdown 复制代码
有失败就判断是测试写错了还是**产品真有 bug**:
  - 测试写错 → 改测试
  - 产品有 bug → **不要去改产品代码**(并行作业),把缺陷报上来,
    让主进程统一处理

并行作业里最怕的就是九个 agent 同时去改同一个 command.ts

11 个 agent,29 分钟,112 万 token。10 个写手里 9 个成功、1 个(gateway)连接中断, 最后落地 10 个新测试文件 ------ 所以 model gateway 的单测覆盖到现在仍然是空白,这条得说出来。

(后记:这个空白在 08-09 被补上了 ------ 先是 6 条门禁用例,随后 A2 又给两个适配器补了 30 条,见 同一道检查,只装在了一半的入口上。)

修完之后的一个信号

修完 command.ts 那六条,跑测试:

bash 复制代码
× 输出超过 MAX_CAPTURE_BYTES 时,真正的末尾错误行仍应可见
  → Expect test to fail
× 预览被截断时 outputTruncated 必须为 true
  → Expect test to fail
× 跨 chunk 的多字节字符不能被解码成乱码
  → Expect test to fail

六条 it.fails 全部变成了「Expect test to fail」 ------ 它们本该失败, 现在却通过了。

这是个很干净的信号:缺陷真的修好了,而且是被这些测试精确捕捉到的那一条, 不是碰巧变绿。

可以带走的

  1. 永远不会红的断言等于没有断言。 检查你的安全性断言: 有没有一个测试是主动去触发它守护的那件事的?
  2. "这个逻辑很直"是缺陷密度最高的地方。 我跳过单测的五个模块,占了 19 个缺陷里的 16 个。
  3. 别复用语义相近的变量。 settled(Promise 定型)和 childClosed(进程已死) 只差一点点,但那一点点让整段升级逻辑成了死代码。
  4. 截断要注意方向。 日志的结论在结尾,捕获层却在保留开头。
  5. it.fails 不是好工具。 它对失败原因不做区分。要标记已知缺陷, 就写成断言当前(错误)行为的普通 it,修好时它会带着有意义的 diff 变红。
  6. 测试里的 rmSync 要有护栏。 尤其是路径来自 mock 的时候。
  7. 并行写测试时禁止改产品代码。 报上来统一处理,否则九个 agent 会打架。

上一篇:写了不能读的日志,不叫证据 下一篇:删除也要诚实

相关推荐
Olafur_zbj1 小时前
【AI】CUDA编程中的维度
人工智能·算法
敲代码的玉米C1 小时前
怎么让 Agent 没法假装自己成功了
前端·人工智能·架构
Marvin_Harrison1 小时前
今天来聊聊 LLM 的 Temperature:从 logits、softmax 到模型如何选出下一个 Token
人工智能
不一样的少年_1 小时前
不用LangChain:用 200 行代码手搓 Agent 对话记忆与上下文压缩
人工智能·agent·ai编程
LaughingZhu1 小时前
Product Hunt 每日热榜 | 2026-08-09
人工智能·深度学习·神经网络·搜索引擎·百度
狂师1 小时前
推荐一款开源 Skill:让 AI Agent 给你做一份"能改"的 PPT,支持 上千套模板!
人工智能·agent
阿图灵1 小时前
基于 LSTM 的中文电商评论情感分类:从数据处理到 91% 准确率实战
人工智能·深度学习·分类·nlp·lstm·情感分类
用户298698530141 小时前
HTML 转 Word 指南:新手入门教程
人工智能·后端·python
冬哥聊AI1 小时前
淘天一面:Prefix Caching 原理是什么?Agent 框架怎么保证不破坏缓存?
人工智能