一次补丁工具的修正:失败按文件隔离,被忽略的锚点不管成败都要留下脚印。

1. 两种让人不敢信的部分失败⌗

Agent 改代码,最常用的工具是一个补丁式编辑器:一段 *** Begin Patch … *** End Patch,里面若干个 *** Update File:,每个文件若干个 hunk,每个 hunk 可以带一个 @@ <锚点行> 来缩小搜索范围。

设想一个抽象的任务:重命名一个函数,补丁一次改三个文件。其中一个文件的上下文是旧的——Agent 读它之后,别处已经改过一行。

这时候工具有两种很自然、但都伤信任的做法:

做法表面上的好处实际代价
整批不写:任何一处失败,全部回滚原子,干净两个本来能落地的文件也没写;调用方必须重发全部内容,而它往往只修了那一处,重发时又可能引入新的漂移
尽量写:能匹配的就写,歧义就挑一个进度最大调用方以为自己指定的块被改了,其实改的是另一个;结果里没有任何痕迹

第一种让 Agent 白干,第二种让 Agent 不知道自己干了什么。后者更糟:前者至少会报错,后者是一个成功的返回值里藏着错误。

这次落地之前的那一轮修正已经解决了第一种,但第二种只解决了一半。这次补的就是另一半。

2. 失败的边界是文件,不是整批⌗

先看前一轮已经立住的框架。applyPatchBuiltin 解析完补丁后,不再一口气构建所有动作,而是按解析后的绝对路径分组,每组独立构建:

for (const [path, group] of groups) {
  try {
    actions.push(...await buildPatchActions(group))
  } catch (err) {
    failures.push({ path, error: ..., hint: ... })
    remaining.push(...group)
  }
}

几条规则:

  • 同一路径的多个 Update File 链式应用:第二个基于第一个的结果,最后只算一次 sha。所以它们是一个失败单元——一个文件里任何一个 hunk 失败,这个文件一个字都不写。
  • 不同文件互不牵连:失败的组进 failures,成功的组照常原子写入。
  • success 只在没有任何失败时为 true;files_changed 和 changed 只列真实写入的文件。
  • 失败时附带 remaining_patch:只包含失败那几组操作、重新序列化好的补丁。调用方修完那一处,重发的是这一小段,不是全部。

为什么边界选文件,不选 hunk?因为同一文件里的 hunk 互相依赖:前一个 hunk 插了两行,后一个 hunk 的位置就跟着变;只写一半的文件,往往是一个编译不过、调用方也说不清处于什么状态的文件。文件之间则通常没有这种行号级的耦合——就算语义上有(比如函数定义与调用方),调用方也能从 changed 和 failures 里精确看到哪边落地了、哪边没有,而不是去猜。

patch
 ├─ a.ts  ──build ok──▶ write  ──▶ changed[]
 ├─ b.ts  ──hunk 2 ✗──▶ (skip)  ──▶ failures[] + remaining_patch
 └─ c.ts  ──build ok──▶ write  ──▶ changed[]

3. 歧义锚点:忽略,而不是挑一个⌗

@@ render() { 这种锚点的设计意图是"在这个块里找"。但如果文件里有两个类都有 render() {,锚点就是歧义的。

实现的选择是:歧义锚点不参与缩域。

// 仅唯一锚点缩域;缺失或歧义锚点按游标与全文件找。
function searchRegions(fileLines, anchorAt, cursor): Region[] {
  const all: Region = [0, fileLines.length]
  if (anchorAt === undefined) return [[cursor, fileLines.length], all]
  return [[anchorAt, blockEnd(fileLines, anchorAt)], [anchorAt + 1, fileLines.length], all]
}

locateAnchor 只有在恰好一行匹配时才给出 at;两行及以上,at 为空,但 matches 里留着全部位置。然后由 hunk 自己的上下文和删除行去匹配,按 exact → trim-end → trim 三个严格度、每个严格度依次在各区域里找,第一个恰好命中一处的区域胜出;同一区域命中多处直接报错,并列出每个候选所在的外层行。

为什么不"挑第一个锚点"?因为那是把调用方没表达清楚的意图替它补完,补错了也没人知道。忽略锚点、退回内容匹配,至少落点是由被删除的那几行唯一决定的——调用方要改的内容确实就在那儿。

但"唯一"有个限定:它是在某个搜索区域里唯一。没有可用锚点时,第一个区域是"从游标到文件尾"。如果同一个 Update File 里前一个 hunk 把游标推到了后面,而要删的内容在游标前后各有一份,那么游标后那份会在第一个区域里唯一命中,直接胜出。落点仍然是一个确实匹配内容的位置,但未必是调用方心里那个。

这正是为什么"忽略"本身不够,忽略必须被看见。

4. 前一轮的缺口:只有失败才留痕⌗

前一轮已经开始报告被忽略的锚点,但只在失败路径上:hunk 没匹配或多处匹配时,错误信息前面会加一句 @@ anchor "…" matched N lines and was ignored.。

成功路径上,同样的事情发生了——锚点歧义、被忽略、靠上下文落到了某处——返回值却只有一个干净的 success: true。第 1 节表格里的第二种失败,换了个更隐蔽的样子留了下来。

这次的修正:applyCodexPatchUpdate 的返回值从 string 变成

{ content: string; ignoredAnchors: IgnoredPatchAnchor[] }

interface IgnoredPatchAnchor {
  hunk: number               // 在本次 Update File 里的序号,从 1 开始
  anchor: string             // 原样的锚点文本
  matches: number[]          // 锚点在文件里命中的所有行(1-based)
  applied_at_line: number    // 这个 hunk 实际落在哪一行
  handling: 'ignored ambiguous anchor; used unique hunk context'
}

buildPatchActions 链式合并同一路径的多次更新时,把每一次的 ignoredAnchors 拼接起来。最终只要有任何一个写入的更新带着被忽略的锚点,返回值里就多出:

{
  "success": true,
  "files_changed": 1,
  "ignored_anchors": [
    { "path": ".../methods.ts", "hunk": 1, "anchor": "render() {",
      "matches": [2, 7], "applied_at_line": 8, "handling": "..." },
    { "path": ".../methods.ts", "hunk": 1, "anchor": "render() {",
      "matches": [2, 7], "applied_at_line": 3, "handling": "..." }
  ],
  "anchor_note": "Ambiguous @@ anchors were ignored, not selected. ..."
}

这是测试里的真实形状:两个类各有一个 render() {,补丁对同一文件做了两次 Update File,各自带着同一个歧义锚点,分别改 return 2 和 return 1。两处都改对了,但调用方现在能看到:锚点在第 2、7 行都有,所以没被采纳;一处落在第 8 行,一处落在第 3 行。要核对是否落在了自己想要的那个块,比对一下就行,不用重新读整个文件。

anchor_note 里有两句话值得单独说:

  • “ignored, not selected”:明确告诉调用方,工具没有在锚点候选里挑一个。这句话防止的是一个很自然的误读——看到 matches: [2, 7] 就以为工具选了其中一个。
  • 行号的参照系:hunk 序号是每个 Update File 内部的;applied_at_line 是这个 hunk 的输入——即前面 hunk 都应用之后的文件——里的行号,不是最终文件的行号。不写明这一点,多 hunk 场景下的行号就是在误导。

还有一个刻意的不对称:失败的文件不进 ignored_anchors。测试里把第二处删除行改成文件里不存在的 return 9,结果是 success: false、files_changed: 0、ignored_anchors 字段不存在,锚点说明出现在 error 文本里。ignored_anchors 描述的是"已经写进磁盘的改动是怎么落位的";没写的文件不该出现在这里,否则调用方会把它当成已落地的证据。

5. 用 slice 切人话,切掉的是证据⌗

这次修的第二个问题更小,但更有代表性。

一个文件有多个 hunk 失败时,工具会把它们聚合成一条错误:

update file /long/path/x.ts: 2 of 3 hunks failed (nothing was written; ...):
- hunk 2/3 matched 2 locations: ...
- hunk 3/3 did not match file content

每一行 - … 来自单个 hunk 的错误信息。旧代码是这样拿到它的:

failures.map(f => `- ${f.message.slice(`update file ${path}: `.length)}`)

即假设每条消息都以 update file <path>: 开头,切掉这个长度的前缀就是正文。前一轮给失败路径加锚点说明时,把说明放在了消息最前面:

@@ anchor "render() {" matched 2 lines and was ignored. update file /long/path/x.ts: hunk 2/3 ...

于是 slice 按路径长度切掉的,变成了锚点说明的开头那一截——剩下半句残缺的说明,后面还挂着本该被切掉的 update file …: 前缀。路径越长切得越多。测试特意用了 /long/path/x.ts 这个路径,就是为了让这种按长度切的错误必然暴露。

修法不是把前缀算得更准,而是不再从格式化好的人话里反解结构:

class CodexPatchError extends Error {
  constructor(message: string, readonly hint?: string, readonly detail: string = message) { ... }
}

matchHunk 构造错误时,先组装 detail = label + 问题 + 锚点说明,message 是 update file ${path}: ${detail},同时把 detail 原样带在错误对象上。聚合时直接用 f.detail。锚点说明也挪到了句尾,并补上位置:

- hunk 2/3 matched 2 locations: line 4 (in "render() {"), line 9 (in "render() {");
  @@ anchor "render() {" matched 2 lines and was ignored (anchor locations: line 3, line 8);
  searched by hunk context instead

(实际是一行,这里为排版折开。)测试要求聚合错误里 anchor locations: line 3, line 8 恰好出现两次——两个失败的 hunk 各带一份完整说明,一个字都不能被切。

教训很普通,但在 Agent 工具里代价格外高:错误信息的读者是模型,模型会照着它行动。一条被切坏的诊断不只是难看,它会把下一轮修补引到错误的方向。结构化的信息就该以结构化的形态传递,人话只在最后一步拼出来。

6. 测试就是规格⌗

这次新增的四个测试,各钉住一条行为:

测试钉住的行为
reports ignored ambiguous anchors and the actual successful match成功路径也返回 ignoredAnchors,含全部锚点位置与实际落点
preserves ambiguous anchors, locations and failure labels when aggregating hunks多 hunk 聚合错误保留每个 hunk 的标签、候选行与完整锚点说明;行号按"前面 hunk 应用后"的文件计
apply_patch exposes ignored anchors across chained updates and reports only real writes同一文件链式更新时痕迹会累积;失败时 files_changed 为 0、不产生 ignored_anchors、文件原样
apply_patch aggregated diagnostics saying nothing was written leave the file untouched错误里说"nothing was written",磁盘上就真的什么都没写

最后一条看起来多余,其实不是:一个声称"没写"的错误如果与磁盘状态不符,比不报错还糟。诊断文字本身就是一种承诺,测试要检验的是这个承诺。

7. 为什么这比常见做法往前走了一步⌗

常见的补丁工具在部分失败上有两种默认:要么像 git apply 那样默认整批原子(要部分应用得显式选 --reject,留一堆 .rej 文件让人去拼),要么像很多编辑器插件那样尽力而为、静默模糊匹配。前者是为一个会自己打开 .rej 慢慢看的人类设计的;后者是为一个盯着屏幕、改错了马上能看见的人类设计的。

Agent 两者都不是。它不会去翻 .rej,也看不见屏幕;它只读返回值,并且完全按返回值决定下一步。所以返回值必须同时回答三个问题:

1. 哪些文件真的写了?           → changed / files_changed
2. 哪些没写、为什么、怎么补?   → failures / error / hint / remaining_patch
3. 写了的那些,有没有哪处是工具
   替我做了判断才落位的?       → ignored_anchors / anchor_note

前一轮回答了前两个。这次补上第三个,并且修掉了一个让第二个答案自己损坏的 bug。

剩下的边也写清楚:applied_at_line 不是最终文件的行号,跨多个 hunk 核对时需要自己换算;痕迹只告诉你"工具没听锚点的",并不判断落点是否符合你的本意——那个判断仍然是调用方的。这是有意的:工具能做的是不替你猜、不替你瞒,而不是替你确认你心里想的是哪一块。