Codex 不改原文件而是复制一份新的:根因 apply_patch 回退 + 防御规则

Codex 在 `utils.ts` 旁边新建 `utils.v2.ts` 而不就地改——真正的根因是 apply_patch 从 Update 退化成 Add。用 AGENTS.md 规则、git 兜底和正确的沙箱模式强制就地编辑。

你让 Codex 给 UserService.ts 加一个 validate() 方法。它返回一份漂亮的 diff——但 diff 里建了个 UserServiceV2.ts 把方法放进去了,原文件一个字都没改。结果:你的 import 还指向旧类,新文件是死代码,最后还是你自己手动把变更挪回去。

最快修复:删掉那个重复文件,然后用明确措辞重新发:就地编辑 src/UserService.ts,不要新建文件;如果补丁打不干净,就停下来解释原因。 这一句就能解决大部分情况。如果还是反复出现,根因几乎一定是 Codex 的 apply_patch*** Update File: 退化成了 *** Add File:(补丁没打上,或沙箱禁止覆盖)。彻底的修复办法是一条 AGENTS.md 规则加上正确的沙箱模式,下面都会讲。

根因:apply_patch 的 Update→Add 回退

Codex 通过一个工具 apply_patch 来改文件,每个 hunk 都在补丁头里声明意图:

*** Begin Patch
*** Update File: src/UserService.ts   ← 就地修改文件
*** Add File: src/UserServiceV2.ts     ← 新建一个全新文件
*** Delete File: src/old.ts
*** End Patch

之所以冒出重复文件,是因为 Codex 本来想发 *** Update File:,结果发成了 *** Add File:。原因分两大类:模型主动选这么做(你的 prompt、不熟悉的文件,或者没有规则约束它),或者它被逼着这么做(Update 补丁打不上去,或沙箱禁止覆盖,于是它建了个兄弟文件)。下面这张表帮你把症状对应到正确的桶,对症下药。

信号最快修复
Prompt 措辞prompt 里有 “new version”、“v2”、“improved”、“refactored”改成「就地编辑」重发
不熟悉的代码新文件写法比原文件更朴素(裸函数 vs 带 decorator 的类)加一条 AGENTS.md 就地规则(Step 2)
没规则AGENTS.md 没写文件创建策略加一条 AGENTS.md 就地规则(Step 2)
补丁失败chat 里在新文件之前出现 “patch failed” / “could not apply”先 commit,再让它打更小的定向补丁(Step 3)
沙箱禁覆盖对文件做 no-op edit 报权限/写入错误sandbox_mode(Step 4)
本来就该两份任务确实要做 TS 移植或一个变种不是 bug——按用途命名(Step 7)

常见原因

按命中率从高到低:

1. Prompt 里用了「new version」「v2」这种词

Codex 对文件命名词非常字面。「写一个 utils.ts 的 new version」会产出 utils-new.ts;「做个 v2」就给你 utils.v2.ts。线索是你自己给的,不是 Codex 主动选的。

如何判断:在 prompt 里搜 “new”、“v2”、“improved”、“refactored”。改成「edit in place」重跑一遍,重复文件就消失。

2. 原文件里有 Codex 不愿动的写法

utils.ts 用了自定义 decorator 或不常见的泛型——模型对这类写法把握不大。它就对冲:原文件不动,新文件用它熟悉的写法重写。

如何判断:对比新旧文件的写法——新的明显更朴素(裸函数 vs 带 decorator 的类),这种不对称就是信号。

3. AGENTS.md 里没写文件创建规则

没明确规则时,Codex 在改动量大时偏向「新建」——因为 *** Add File: 可逆(删掉就行),而把原文件改坏的 *** Update File: 不一定可逆。

如何判断grep -i "in place\|do not create" AGENTS.md——空就是没规则。注意 Codex 会从 git root 一路读到你当前目录(外加全局的 ~/.codex/AGENTS.md)并拼接起来,所以每一层都要检查。

4. Update 补丁太大、打不干净

Codex 试了 *** Update File: 编辑,补丁因为上下文行和磁盘上的文件对不上而失败,于是退化成整文件 *** Add File:。这个回退不一定会清楚地暴露出来。

如何判断:在 transcript 里搜 “patch failed”、“could not apply”、“context did not match”——紧挨在新文件出现之前就是这个原因。

5. 沙箱模式禁了覆盖

Codex 在沙箱里执行命令。默认是 workspace-write(读所有、在工作区内写)。但如果它实际上变成了只读——sandbox_mode 配错、编辑目标不在 sandbox_workspace_write.writable_roots 里,或者命中已知的 Windows 桌面应用 bug(apply_patch新建文件但无法更新现有文件,openai/codex issue #25860)——它就绕开限制建了个并行文件。

如何判断:对 utils.ts 做个 no-op edit(加个行尾空格)。报权限或写入错误就是沙箱拦了就地写入。在桌面应用上,留意那种 Add 成功、Update 却返回 exit code 1 且无输出的 apply_patch

6. Task 本来就是要并行实现

「加一个 utils.js 的 TypeScript 版本」「做一个严格模式变种」——这两种本来就要两份文件。先确认你不是要「就地替换」。

如何判断:回看原 task。没出现「就地」「替换」这种词,重复可能是合规的。

最短修复路径

按收益从高到低,Step 1 一步覆盖大部分情况。

Step 1:删掉重复文件,用明确「就地」措辞重 prompt

删掉新建那个,保留原文件,发:

直接就地(IN PLACE)编辑 `src/utils.ts`。
- 用 apply_patch 的 *** Update File:,不要用 *** Add File:。
- 不要新建文件。
- 不要重命名文件。
- 直接改原文件。
- 如果补丁打不干净,停下来解释原因,不要建替代文件。

点名 *** Update File: 机制和「停下来解释」这两条都很关键:它们一起堵死了「新建重复文件」这个逃生口,换成一个你能据此处理的干净失败。

Step 2:把规则永久写进 AGENTS.md

在 repo 根目录加进 AGENTS.md。Codex 每次运行都会读它,从 git root 一路拼接到你的工作目录(想全局生效就放 ~/.codex/AGENTS.md),冲突时靠后的文件胜出;拼接后的指令链默认上限 32 KiB。

## 文件创建策略

- 永远就地编辑现有文件(apply_patch 用 `*** Update File:`)。
- 不允许的变种命名:`*-new.ts``*.v2.ts``*_copy.ts``*-improved.ts``*-refactored.ts`
- 仅以下情况可以新建文件(`*** Add File:`):
  - 用户明确说 "create a new file"
  - 新功能确实需要新模块
  - 给现有模块加测试文件(如 `utils.test.ts`
- 就地 edit 风险大、或补丁打不上时,先停下来问,不要自作主张建新文件。

规则跨 session 生效。(提示:codex/init 不会覆盖已存在的 AGENTS.md,所以直接改文件。)

Step 3:用 git 兜底,不要靠新建文件兜底

Codex 之所以「对冲」,是怕弄坏原文件。把这个担忧用 git 接走:

# 改动前打一个 checkpoint
git add -A && git commit -m "checkpoint before Codex edit"

# 让 Codex 就地改
# ... 如果改坏了:
git restore src/utils.ts  # 一行回滚

在 prompt 里告诉它:

当前状态已经 commit,改坏了我可以 `git restore` 立即回滚。
请就地 edit——git 是安全网,不要靠建新文件兜底。

如果重复来自补丁失败(「补丁失败」那个桶),再让 Codex 做更小、更定向的改动:「只改 validate 方法,文件其余部分一字节都不要动。」更小的 hunk 更容易和文件上下文行对上,几乎不会触发 Add 回退。

Step 4:确认沙箱真的能就地写入

如果 no-op edit 失败(对应桶 5),别跟症状死磕,去查沙箱模式。默认的 workspace-write 应该允许 Codex 更新工作区内的文件。

# 查看当前模式和可写根目录
codex --help          # 显示 --sandbox 和审批相关 flag
# 用能写工作区的模式跑一个 session:
codex --sandbox workspace-write

或者写进 ~/.codex/config.toml

sandbox_mode = "workspace-write"          # read-only | workspace-write | danger-full-access
approval_policy = "on-request"

对于那个已知的 Windows 桌面应用 bug(apply_patch 能 add 不能 update),官方建议的临时绕法是把 session 切到完全访问(仅在你信任该任务时用):

codex --sandbox danger-full-access

确认你要编辑的目录被 sandbox_workspace_write.writable_roots 覆盖;编辑目标落在这些根目录之外会被当成只读,把 Codex 推向建兄弟文件。

Step 5:清理历史遗留的重复文件

repo 里之前 session 留下的 *-v2*-new*-copy 全部列出来:

find src -type f \( \
  -name "*-new.*" -o \
  -name "*.v2.*" -o \
  -name "*-v2.*" -o \
  -name "*_copy.*" -o \
  -name "*-copy.*" -o \
  -name "*-refactored.*" \
\) | sort

每一对决定保留哪个:

| 旧文件          | 新文件             | 处理                            |
|---|---|---|
| utils.ts        | utils-new.ts       | utils-new 内容并回 utils.ts,删 utils-new |
| UserService.ts  | UserServiceV2.ts   | 如果 import 用 V2,rename V2 → 原名;删未被 import 的那个 |

然后用 IDE 的 “rename symbol” 批量改 import。

Step 6:在 PR review 阶段拦截变种文件名

CI 加一条:新增文件名匹配变种模式就 fail:

# .github/workflows/no-variant-files.yml
- name: Block variant filenames
  run: |
    if git diff --name-only --diff-filter=A origin/main...HEAD | grep -E '(\-new|\.v2\.|\-v2\.|_copy\.|\-copy\.|\-refactored)' ; then
      echo "不允许变种文件名,请就地编辑。"
      exit 1
    fi

Step 7:真要并行实现,按用途命名而不是按版本

两种实现真要共存(老 API + 新 API),按功能命名,不要按版本:

auth/oauth.ts        + auth/magic-link.ts          ← 好
api/users-v1.ts      + api/users-v2.ts             ← 公开 API 版本可接受
api/utils.ts         + api/utils-new.ts            ← 永远不要

这个命名约定能让 Codex 不把「改」理解成「再来一个变种」。

如何确认已修好

  1. 在就地 prompt 和 AGENTS.md 规则都到位后,重跑原任务。
  2. 看 diff:补丁头应该是 *** Update File: src/utils.ts,而不是 *** Add File:
  3. 确认没有新建兄弟文件:git status --short 显示 M src/utils.ts,而不是一行新的 A
  4. 确认 import 仍然解析得到、测试通过——改动落在了你代码真正 import 的那个文件里。

预防建议

  • 「就地编辑」写进 AGENTS.md 一次就够——Codex 每次运行都读。
  • 除非真要分文件,否则 prompt 里不要出现 “new version”、“v2”、“improved”、“refactored”。
  • 改高风险代码前先 commit 一次,让 Codex 不必「防灾性新建」。
  • 保持 sandbox_mode = "workspace-write"(或配好正确的 writable_roots),别让就地更新被拦成兄弟文件。
  • CI 加一条变种文件名检查——人工 review 漏掉的它能拦。
  • 季度审一次现存变种文件——它们悄悄堆积、最后让 import target 一团乱。
  • 真要并行实现,按功能命名(oauth + magic-link),不要按版本。

常见问题

为什么 Codex 建了 UserServiceV2.ts 而不去改 UserService.ts 因为它的 apply_patch 发出的是 *** Add File: 而不是 *** Update File:。触发原因有:带版本号味道的 prompt 措辞、不熟悉的原文件、AGENTS.md 里缺就地规则、补丁打不上、或沙箱禁止覆盖。用上面那张桶表对号入座。

怎么强制 Codex 永远就地编辑?AGENTS.md 里加一段「文件创建策略」(Step 2),强制 *** Update File: 并禁止变种文件名,同时任务开头写「就地编辑,不要新建文件」。AGENTS.md 规则对每次运行都生效,prompt 那句管当前这次。

Codex 说补丁失败然后又建了个新文件,怎么办? 这就是 Update→Add 回退。先 commit 一个 checkpoint,再让它做更小、更定向的改动(「只改这个函数,其余不动」)。更小的 hunk 更容易和文件上下文行匹配,于是 update 能打上,不会退化成 Add。

在 Windows 上桌面应用一直只新建、不更新,是 bug 吗? 是——这是已知问题(openai/codex #25860):apply_patch 能新建文件但无法更新现有文件,常常返回 exit code 1 且无输出。用 --sandbox danger-full-access 跑 session 是官方记录的绕法;同样的流程在 WSL 下是正常的。

Codex 建第二个文件有没有合理的时候? 有。把 JS 文件真正移植成 TypeScript、确实是新模块、测试文件,或者两个公开 API 版本(users-v1 / users-v2),都该是独立文件。有问题的只是那种遮住你本想就地改的文件的版本后缀兄弟文件(-new.v2-copy)。

相关阅读

标签: #Codex #Coding Agent #排查 #排查 #复制文件