一份补丁怎样落盘,又怎样成为 TurnDiff
从固定版本的 Hunk、ApplyPatchAction 与 AppliedPatchDelta 出发,拆清 FileChangeItem 的拟议计划和 TurnDiffTracker 的已提交文本效果。
第 14 章已经把通用 registry、并发 admission 和 call-id output 闭环收住。本章直接接它交来的具体对象:
ToolInvocation {
tool_name: ToolName::plain("apply_patch"),
payload: ToolPayload::Custom { input },
session,
step_context,
tracker,
call_id,
cancellation_token,
// turn 与 source 也已由 router 补齐
}
router 到这里已经不再决定 patch 语义。它只是把 ToolCall 拆出 tool_name、call_id 和 payload,再连同同一 sampling step 的 Session、StepContext、取消令牌与 tracker 构造成 ToolInvocation。这段通用构造已经由第 14 章钉住;从这一行往里,所有权转到 ApplyPatchHandler。
这里最容易读错的地方,是把“模型给出的 patch”“UI 展示的文件变化”和“磁盘上已经发生的变化”都叫 diff。固定版本实际上保存了三种 patch 表示,后面又投影出两类事件。只有先把它们分开,partial failure、move 和 TurnDiff 才能讲清楚。
完整输入先变成有序 Hunk
ApplyPatchHandler 只接受 ToolPayload::Custom。它对完整 input 调用 parse_patch,再读取可选 environment id、选择当前 step 中的 environment,并用该 environment 的 filesystem 做 verify_apply_patch_args。
parser 的全局开关在这个版本固定为 Lenient。实际入口会对整段文本 trim(),按行切开,做 lenient boundary 检查,再用 join("\n") 得到规范化 patch。若输入带兼容 heredoc wrapper,wrapper 被剥掉;ApplyPatchAction.patch 保存的是这份规范化结果,不保证逐字等于模型最初吐出的字节串。
parse 的主要产物是 Vec<Hunk>,顺序与 patch 一致。三个 variant 的路径语义不完全对称:
AddFile { path, contents }与DeleteFile { path }只有一个路径。UpdateFile { path, move_path, chunks }中,path始终是读取和删除的源路径,move_path才是可选目标。Hunk::resolve_path()对 Update 明确选择源路径;Hunk::path()在 move 存在时返回目标路径,供“受影响路径”摘要使用。两个 helper 回答的是不同问题。
parse 只检查 patch 语法。Core 随后的 verify 才把每个 hunk 放回 filesystem:Delete 先读原文,Update 读源文件并计算 unified_diff 与 new_content,Add 直接保留内容。这个循环从头按序验证 hunks,遇到读取、路径解析或上下文匹配错误就立即返回 correctness error。
重要的是,此时没有写文件。verify 只读当前 environment,并把结果折叠成 HashMap。若同一源路径在一份 patch 里出现多次,后一次 insert 会覆盖前一次拟议 change;HashMap 也不保留 hunk 顺序。原始顺序仍在 action.patch 中,执行期会重新 parse 它。
一份 patch 的三种表示
这三个类型不能互换:
| 表示 | 容器 | 保存什么 | 会丢什么 |
|---|---|---|---|
ApplyPatchArgs.hunks | Vec<Hunk> | 规范化 patch 的有序语义单元 | 尚未证明当前文件内容匹配,也没有提交结果 |
ApplyPatchAction.changes | HashMap<PathUri, Change> | 全量只读 verify 后的拟议文件视图 | 同源路径的早期 change 可被覆盖;跨路径顺序不可恢复 |
AppliedPatchDelta.changes | Vec<AppliedPatchChange> | 确定已经提交的文本变化前缀 | exact=false 时只给保守下界;目录等非文本副作用本就不在内 |
ApplyPatchAction 的定义在 apply-patch/src/lib.rs,不在 parser。它的私有 changes 是拟议 map,公开的 patch 与 cwd 则留给后续 runtime 再执行。称它为“提交记录”会直接破坏后面的失败语义。
AppliedPatchDelta 的注释则明确写着 “actually committed”。它保存有序 Vec 与一个 exact 位;后一次 delta 追加到前一次后面,聚合 exactness 用逻辑与收紧。因此 sandbox retry 等多次 runtime attempt 可以共享一个按尝试顺序增长的 committed prefix。
Core 在 begin 之前做完整预检
direct apply_patch 的先后顺序值得逐行看:
- 检查 custom payload。
- 完整 parse,选择 environment。
- 对整份 patch 做只读 verify。
- 计算 effective permission inputs。
- 调用
assess_patch_safety。 - 只有得到
DelegateToRuntime,才构造 emitter 并发出 FileChange begin。
所以 parse error、environment error、后段 hunk 的常见 correctness error,以及 safety 的同步 Reject,都发生在 begin 之前。它们没有 FileChange start/end,也没有可交给 tracker 的 delta,自然不会产生本次调用的 TurnDiff。
这也解释了一个看似矛盾的现象:standalone crate 能在第一条 hunk 已写入后因第二条失败,而 Core direct tool 对同一份稳定 workspace 往往会在 begin 前预检出第二条错误,保持零写入。两条路径拥有不同的 preflight 边界。
permission 只追到 runtime attempt
assess_patch_safety 接收完整 action、approval policy、PermissionProfile、effective filesystem sandbox policy、cwd 与 Windows sandbox level。它只产出三类结果:AutoApprove、AskUser、Reject。Core 随后把前两类翻译成 ExecApprovalRequirement::Skip { bypass_sandbox: false } 或 NeedsApproval;Reject 直接变成模型可见错误。
资源请求也不是“patch 里出现过的一个路径”,而且这里存在一个固定版本的控制面缺口。预检的 try_verify_apply_patch_args 按 resolved source 写入一个 HashMap;move destination 不会成为第二个 map key,而是保存在 source key 对应的 Update.move_path 里。同一 source 出现两次时,后一个 hunk 会连同它自己的 move_path 覆盖前一个 map entry。
权限路径在下一步才由 file_paths_for_action 提取:每个折叠后 entry 先加入 source,若它仍带 move_path,再加入 destination。于是单个保留下来的 move 能同时检查源和目标,但已被同 source 后项覆盖的早期 destination 不再存在于 permission/safety 输入里。runtime 随后仍按完整 Vec<Hunk> 顺序重新执行 patch,所以控制面看到的 folded map 与执行面使用的完整 patch 不是同一份集合。
这应当写成当前版本的边界,而不是“所有 source 和 destination 都已进入权限视图”的保证。若要把它改成严格的一一对应,需要同时改预检数据结构、safety/resource 遍历和重复 source 的语义,再补针对重复 hunk 的测试;本书只记录现状。
begin 之后,ToolOrchestrator 才消费 ExecApprovalRequirement、选择第一次 SandboxType,并把 effective PermissionProfile、cwd、workspace roots 与平台参数装入 SandboxAttempt。AutoApprove 在这里不等于“无沙箱”;前面的 bypass_sandbox: false 明确保留了沙箱选择。
ApplyPatchRuntime 再把 attempt 变成 filesystem sandbox context:SandboxType::None 直接返回 None;其他类型用 attempt permissions 与 request additional permissions 计算 effective profile。也就是说,safety assessment 决定是否需要审批,orchestrator 拥有实际 attempt,runtime 只消费已经具体化的 sandbox 输入。
审批缓存、permission hooks、guardian 路由以及不同 approval policy 的矩阵留到第 17 章。本章只保留两个与落盘直接相关的结果:审批拒绝发生在 begin 之后时,会形成 Declined end;第一次 runtime attempt 若被判为 sandbox denied,orchestrator 先决定是否需要一轮新的 retry approval,只有这轮通过后才构造并运行第二次 attempt。若 retry approval 被拒绝,第二次 attempt 根本不会开始,但第一次 attempt 已经发生的文件副作用也不会回滚。
工具 hook 也遵守这个先后顺序。ApplyPatchHandler 在真正执行前提供 PreToolUsePayload,允许 hook block 或改写 command;handler 返回成功 output 以后,registry 才构造 PostToolUsePayload 并运行后置 hook。PostToolUse 可以阻断或替换交给模型的结果,却没有撤销已经完成的 handler 执行,更不会回滚文件副作用。
重放不是从“未完成的下一条 hunk”继续。第二次 run_attempt 仍收到同一个 request,因此又从整份 action.patch 开始。第一次尝试已经提交的 delta 不会丢,ApplyPatchRuntime.committed_delta 会继续追加第二次尝试的结果。
execute 没有 staging,也没有 rollback
每次 ApplyPatchRuntime::run 都拿 action.patch 与 action.cwd 重新调用 crate。crate 再 parse 一次 raw patch,按 Vec<Hunk> 顺序直接操作 selected environment filesystem。结果若是普通 apply failure,runtime 取出 failure delta、把 exit code 设为 1,追加到跨 attempt 的 committed_delta,然后仍返回 Ok(ApplyPatchRuntimeOutput)。只有被识别成 sandbox denial 等 runtime 级错误时才转成 orchestrator 的 Err。
apply_hunks 从空 delta 开始,逐 hunk 调用 filesystem。只有全部 hunk 完成,才打印 Success. Updated the following files: 摘要;中途错误会把当时的 delta 与 stderr 一起返回。这个流程没有临时树、Git index staging、commit,也没有失败 rollback。
因此失败不能统一解释成“无副作用”:
| 失败位置 | begin/end | 已知磁盘效果 |
|---|---|---|
| custom payload、parse、environment、完整 verify | 都没有 | Core 尚未写入 |
| safety Reject | 都没有 | runtime 尚未开始 |
| initial approval denial | 有 begin,通常有 Declined end | runtime 尚未开始 |
| first sandbox denial 后,retry approval 被拒绝 | 已经有一次 attempt;第二次 attempt 未开始 | 第一次 attempt 的副作用不会回滚 |
| runtime I/O 或 verify 后内容漂移 | 有 begin,通常有 Failed end | 前缀可能已提交,delta 记录能够确认的部分 |
| sandbox denial与重试 | 有 begin,terminal 取最终路径 | 前次尝试的前缀可能保留,后次尝试会重放整份 patch |
| cancellation | begin 与 finish 之间可被中断 | 已发生写入不回滚,FileChange lifecycle 可能没有成对 terminal |
最后一行尤其要克制。取消令牌由外层 ToolCallRuntime 处理,它可以 abort 正在执行 handler 的 task;ApplyPatchHandler 的 begin 与 finish 是两次独立 await,中间没有事务保护。于是取消后的模型 output、UI lifecycle 和磁盘状态不必同时完整。
move 是写目标,再删源
Update 带 move_path 时,crate 先从源文件推导 new_contents,读取可能被覆盖的目标内容,再写目标。目标写成功后,它先向 delta 放一条临时 Add;随后才检查并删除源。
若删源失败,函数直接带着这条目标 Add 返回。源可能仍在,目标也已经存在,两份内容可以共存。只有删源成功,临时 Add 才会被改写为以源路径为 key、携带 move_path 的 Update。这里没有原子 rename(2)。
exact=true 也不代表“事务成功”。它只说明 delta 对这套代码能够观察的文本文件 mutation 是精确的。Add 遇到缺失父目录时会递归创建目录;目录创建不进入 AppliedPatchDelta,即使最终文件写成功,exact 仍可为 true。反过来,失败写可能在报错前已经 truncate,symlink、非普通文件或无法可靠读取的旧内容也会让 exact 降为 false。
所以 exact=false 应读成保守下界:changes 仍是确定已提交的文本前缀,但系统承认还有无法精确重建的效果。它不是“没有变化”,也不是“delta 中每一条都失败”。
planned view 与 committed effect 分开走
模型还在流式生成 custom input 时,ApplyPatchArgumentDiffConsumer 会把新 delta 喂给 streaming parser。只要解析出 hunk,就可能发 PatchApplyUpdated;500 ms buffer 只影响发送节奏。Update 在这里显式用源路径,Delete 的 content 还是空字符串。它是“目前看起来想改什么”的预览,完整 parse、环境读取、permission 与执行都尚未完成。
flowchart TB
accTitle: apply_patch 的计划视图与实际效果
accDescr: 流式预览和 FileChange 展示拟议计划;runtime 另产出 committed delta,exact delta 才进入 tracker,inexact 会清空聚合结果
INPUT["model input chunks"]
PREVIEW["PatchApplyUpdated<br/>partial planned preview"]
VERIFY["complete parse and verify"]
ACTION["ApplyPatchAction<br/>full proposed map"]
BEGIN["FileChange begin<br/>full proposed map"]
ORCH["ToolOrchestrator<br/>approval and attempt"]
OUTCOME["runtime outcome<br/>status and committed delta"]
END["FileChange end<br/>same map plus status"]
DELTA["consume AppliedPatchDelta<br/>after FileChange end"]
EXACT{"exact?"}
TRACK["TurnDiffTracker<br/>net text state"]
INVALID["invalidate aggregate"]
NOOP["no tracker change"]
DIFF["TurnDiff snapshot"]
CLEAR["optional empty TurnDiff"]
INPUT -. "while streaming" .-> PREVIEW
INPUT --> VERIFY
VERIFY --> ACTION
ACTION --> BEGIN
BEGIN --> ORCH
ORCH --> OUTCOME
OUTCOME --> END
END --> DELTA
DELTA --> EXACT
EXACT -->|yes and non-empty| TRACK
EXACT -->|yes and empty| NOOP
EXACT -->|no| INVALID
TRACK --> DIFF
INVALID --> CLEAR
begin 与 end 的 FileChangeItem.changes 都来自 convert_apply_patch_to_protocol(&action) 的完整拟议 HashMap。begin 带 status: None 与 auto_approved;end 仍 clone 同一份 changes,只是补上 Completed、Failed 或 Declined 以及 stdout/stderr。即使 status 是 Failed 或 Declined,changes 也不会缩成实际落盘子集。
FileChangeItem 本身只有 changes、status、auto_approved、stdout 与 stderr;没有 committed delta 字段。协议里旧 PatchApplyEndEvent 的注释把 changes 称为 applied 并说它 mirrors begin,当前 Core construction 中真正可靠的是后半句:它镜像完整计划。实际提交前缀只沿内部 AppliedPatchDelta 进入 tracker。
普通 crate failure 在 runtime 层可能已经变成 Ok(output),只是 exit_code=1。event 层据此把 FileChange status 设为 Failed,同时向模型返回失败 output;delta 仍能继续更新 tracker。内部 ToolEventStage::Success 只表示拿到了 output 对象,不能解读为 patch 成功。
end 的发送还早于 tracker 更新。Core 先 emit_turn_item_completed(FileChangeItem),再 lock tracker,消费 delta 或 invalidate,最后才可能发 EventMsg::TurnDiff。因此 UI 看到 FileChange end 时,不能假设 TurnDiff 已经出现;没有可跟踪 delta、没有既有 aggregate,或 finish 根本没有跑到时,也不会补造一个。
TurnDiffTracker 不读 Git 状态
TurnDiffTracker 的类型注释直接限定了数据源:它从 committed apply_patch mutations 计算当前 turn 的 net text diff,且不重新读取 workspace filesystem。它没有运行 git diff,不查看 Git index,也不拿当前 worktree 与 HEAD 比较。
内部状态有三组:
baseline_by_path保存某条路径在本 turn 第一次可知的旧文本。current_by_path保存 committed delta 推演出的当前文本。origin_by_current_path保存 move 后当前路径的来源。
track_delta 只接受 exact delta。遇到 exact=false 会永久 invalidate() 当前 tracker,清掉 rendered cache 与 aggregate;后续 exact delta 也不会重新启用它。如果 invalidation 前已有 diff,event 层会发一份空 TurnDiff,让客户端清掉旧画面。
同一个用户可见 turn 中,tracker 在 run_turn 的 follow-up loop 外创建。模型连续发多次 apply_patch 时,它们进入同一 baseline/current;下一次 run_turn 会新建 tracker。这样 Add 后再 Update 会折叠成一份最终 Add,Delete 后同路径 Add 会折叠成 Update,来回修改回到原文则可能没有净 diff。
渲染前,路径按 display path 排序并去重。每个路径只比较 baseline 与 current,已经不再展示中间 hunk 或每次调用的边界。每次 patch end 都可能发当前累计快照,sampling completed 还可能再次发同一 aggregate,所以消费者不能把多份 TurnDiff 依次拼接;它们是可能重复的快照。
最终格式只是 Git-style unified diff。tracker 自己算 blob SHA-1 与 diff --git、index、---、+++ header,再用 similar::TextDiff::diff_lines 生成正文。路径改变但左右内容完全相同时,render_diff 直接返回 None,所以 pure rename 可能从 TurnDiff 中消失。move 覆盖已有目标时,目标已有 baseline,通常不会被识别为 pure rename pair,而会渲染为删除源加更新目标。
这个 tracker 也不是完整副作用日志。普通 shell 或外部进程直接写文件不会产生 AppliedPatchDelta,因此不会可靠进入 aggregate;被 Core 识别并 intercept 成 apply_patch 的 shell heredoc 是例外。目录创建、权限变化与未知 inexact 效果同样不在文本 diff 中。
exact test 只钉住一条成功 Add 路径
archive 与 lockfile 校准由本仓库的 scripts/codex-handbook-part3.sh 统一提供。本章代码块在自己的 shell 中 source helper、调用 prepare_codex_part3 并注册 cleanup trap;没有博客 checkout 时,把该 helper 文件保存到任意路径,再通过 CODEX_HANDBOOK_PART3_HELPER 指向它。下面的断言仍会拒绝缺失的副本、错误的 target 目录和被污染的固定 checkout。测试仍经仓库规定的 just test 进入 nextest,不能改回裸 cargo test:
SOURCE_ROOT="${SOURCE_ROOT:-/tmp/codex-handbook-final-rust-v0.144.6}"
SOURCE_DIR="$SOURCE_ROOT/codex-rs"
CODEX_HANDBOOK_PART3_HELPER="${CODEX_HANDBOOK_PART3_HELPER:-$PWD/scripts/codex-handbook-part3.sh}"
test -f "$CODEX_HANDBOOK_PART3_HELPER"
source "$CODEX_HANDBOOK_PART3_HELPER"
prepare_codex_part3
trap 'cleanup_codex_part3 "$?"' EXIT
ARCHIVE_CODEX_RS="$ARCHIVE_DIR/codex-rs"
export CARGO_TARGET_DIR="$ARCHIVE_DIR/target"
test -d "$ARCHIVE_CODEX_RS"
test "$CARGO_TARGET_DIR" = "$ARCHIVE_DIR/target"
test -z "$(git -C "$SOURCE_ROOT" status --porcelain)"
cd "$ARCHIVE_CODEX_RS"
run_checked_test apply-patch 1 \
just --set rust_min_stack 16777216 test --locked -p codex-core --test all --no-capture \
-E 'test(=suite::apply_patch_cli::apply_patch_emits_turn_diff_event_with_unified_diff)'
关键输出:
PASS suite::apply_patch_cli::apply_patch_emits_turn_diff_event_with_unified_diff
Summary: 1 test run: 1 passed
共享 runner 还会拒绝 zero-match、Skipping test... 和 skip_if_。fixture 构造一份 Add patch,等待直到 TurnComplete,并记住期间至少一份 EventMsg::TurnDiff;最后只断言它含 diff --git、old header(--- /dev/null 或 --- a/)与 +++ b/。
这条通过只证明:成功 Add 的 Core 集成路径最终至少产生一份带上述 marker 的 Git-style TurnDiff。它没有断言文件内容,没有证明事件唯一性或精确时机,也没有覆盖 FileChange 顺序、partial failure、move、inexact、approval、sandbox retry 与 cancellation。
源码 codex-rs/apply-patch/tests/suite/tool.rs:261-276 另有 standalone test test_apply_patch_cli_failure_after_partial_success_leaves_changes:先 Add created.txt,再 Update 不存在的 missing.txt,CLI 失败后仍断言 created.txt == "hello\n"。本章没有额外运行它;它证明 crate/CLI 的顺序执行可以留下前缀,不能拿来证明 Core direct tool 会绕过全量 preflight。
把这条链交给 Shell
apply_patch 把 parse -> permission -> execute -> event 拆成了一条通用执行主线,以及一条只有 effect-aware runtime 才具备的副作用支线:
opaque model payload
-> semantic parse/verify
-> concrete resource/permission request
-> orchestrated runtime attempt
-> standardized runtime output
proposed-effect lifecycle: begin before attempt -> terminal after outcome
committed-effect ledger: AppliedPatchDelta -> actual-effect TurnDiff
Shell 可以复用第一条主线:模型参数仍要先归一化,命令要变成具体进程与资源请求,再进入审批/沙箱 attempt,最后形成标准 output。普通命令也有 CommandExecution lifecycle,但没有 AppliedPatchDelta 这样的 committed-effect ledger。Core 无法只靠 stdout、exit code 或拟议 argv 重建可靠文件变化,因此不能把 apply_patch 的 TurnDiff 机制泛化成任意 shell 的真实工作树 diff。
第 16 章:模型返回的 Shell 参数,为什么还不是一条进程会从 opaque shell payload 开始,追命令规范化与进程资源请求。本章停在 committed text effect;进程启动、stdio、timeout 与 shell 自身的副作用所有权不提前展开。