补丁只落了一半?那它得自己说清楚是哪一半

一次补丁工具的修正:失败按文件隔离,被忽略的锚点不管成败都要留下脚印。
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 核对时需要自己换算;痕迹只告诉你"工具没听锚点的",并不判断落点是否符合你的本意——那个判断仍然是调用方的。这是有意的:工具能做的是不替你猜、不替你瞒,而不是替你确认你心里想的是哪一块。