青雲的博客
深入浅出 Pi 第六部:工具与扩展怎样进入运行时 第 32 章

读写文件时,Pi 怎样避免相互踩踏

从 edit 与 write 的执行函数进入 file mutation queue,解释同一路径为何串行、不同路径为何仍可并行,以及 abort 为什么不能提前释放锁。

源码版本
v0.83.0
验证日期
Commit
845d6ff1f6643aba440341cce877ce1c43ebbc39

第 17 章已经说明,Agent loop 可以并行执行一批 tool call。到了文件工具,这个优化会遇到一个具体冲突:两个 edit 同时读取同一个旧内容,各自计算新内容,再先后写回,后完成的那次会覆盖前一次。它们的参数都合法,单次执行也都成功,最终文件却丢了一部分修改。

Pi 没有把所有文件工具塞进一把全局锁。withFileMutationQueue(filePath, fn) 以文件为 key:同一个 key 等前一项结束,不同 key 继续并行。这个粒度与问题相符,也保留了并行 tool batch 对不同文件的收益。

两层排队解决两个不同的竞争

这段实现里有一个容易忽略的 registrationQueue。如果只看 fileMutationQueues: Map<string, Promise<void>>,似乎每次取 map、创建 next promise、再 set 回去就够了。但 key 的生成包含异步 realpath()。两个调用若同时解析同一 symlink,可能都在 map 尚未登记前继续,随后各自看到空队列。Pi 先把“解析 key 并占据队尾”这段登记动作串行化,再让真正的文件操作按 key 并行。

登记完成后,每项拿到四个值:canonical key、进入前的 currentQueue、代表自己与后继关系的 chainedQueue、以及 releaseNext。执行者先 await currentQueue,然后运行 fn()。无论 fn() 返回还是抛错,finally 都会释放后继;只有自己仍是 map 中最后一段链时才删除 key,避免前一项误删已有后继的队列。

flowchart TD
  accTitle: 同文件 mutation 的两层排队
  accDescr: registrationQueue 串行解析 canonical key 并登记队尾;每个文件 key 再维护自己的 Promise 链,不同文件的执行阶段互不阻塞。
  CALLS["edit/write calls"] --> REGISTER["registrationQueue"]
  REGISTER --> KEY["realpath or resolved path"]
  KEY --> A["queue: file A"]
  KEY --> B["queue: file B"]
  A --> A1["mutation A1"] --> A2["mutation A2"]
  B --> B1["mutation B1"]

realpath 让已有文件的 symlink 路径与真实路径汇合到同一 key。对于还不存在的文件,只能使用 resolve(filePath),因为此时没有 inode 或 canonical target 可查。这留下一个明确边界:两个不同的尚不存在 symlink-like 路径,之后若指向同一个实体,队列未必提前知道它们相同。源码只对 ENOENTENOTDIR 做 fallback,权限错误不会被装成“文件不存在”。

write 为什么要把 abort 检查放在 await 之后

write 先把相对路径解析到 cwd,再在队列内创建父目录并写文件。每个异步操作前后都检查 signal.aborted。注释刻意拒绝“监听 abort 事件后立刻 reject”的写法:文件系统 promise 可能还在内核或线程池里继续,外层 promise 却已经进入 finally 并释放队列。下一项随即写同一个文件,就重新制造了并发覆盖。

这是一种 cooperative abort:无法取消的 fs.writeFile 已经开始后,Pi 等它 settled,再把“Operation aborted”报告给上层。调用者看到失败,不代表磁盘一定保持原样;它只代表 executor 不再把这次操作当成功继续。队列保护的是后继不与未 settled I/O 重叠,不是提供回滚。

write 还有另一个朴素边界:它是整文件覆盖。队列能保证两个 write 依次发生,却不能把两份内容合并。第二项仍然可以有意覆盖第一项,只是结果由排队顺序确定,不再由完成时机偶然决定。

edit 把读、计算和写全部放在同一临界区

edit 的临界区更长。它先检查访问权限,读取 Buffer,去掉 BOM,探测换行风格,将内容归一到 LF,再针对同一份原始内容应用一组不重叠 replacement。最后恢复 BOM 与原换行风格,写回并生成 diff/patch。若只锁最后的 writeFile,两个 edit 仍会基于同一个旧快照计算,所以读与变换也必须在队列里面。

同一次 edit call 中的多项 replacement 又有一层语义:每个 oldText 都匹配原始文件,而不是前一项 edit 后的中间结果;重叠或嵌套修改应在参数层合并。per-file queue 解决多个 tool call 的并发,applyEditsToNormalizedContent 解决一次 call 内 replacement 的确定性,两者不能互相替代。

可以用固定源码里的测试名做一组可复核索引,不必执行用户目录中的 Pi:

repo="${PI_SOURCE_DIR:-/tmp/pi-handbook-qbQTcA}"
git -C "$repo" grep -n 'serialize mutations for the same file\|allow mutations for different files' \
  v0.83.0 -- packages/coding-agent/test/file-mutation-queue.test.ts
git -C "$repo" grep -n 'symlink' v0.83.0 -- \
  packages/coding-agent/test/file-mutation-queue.test.ts

如果测试措辞在未来版本变化,仍应回到 withFileMutationQueue 的实现确认合同:同 key 串行、不同 key 并行、canonical path 尽量去重。测试名只是导航,不是本章唯一证据。

这把锁没有覆盖什么

read 工具不进入 mutation queue。它可能在 write 进行到一半时由另一个进程读取,也可能在 Pi 自己的写操作之间读到某个已完成版本。bash、扩展工具或宿主程序直接调用文件系统,同样不受这张进程内 map 约束。多开两个 Pi 进程也各自拥有独立 map。

因此不能把它描述成文件事务、reader-writer lock 或跨进程 advisory lock。它是一条很局部但重要的运行时纪律:内置 editwrite 对同一 canonical path 不重叠执行,并且在不可取消的 I/O settled 之前不释放后继。

文件写入的主要风险来自并发覆盖,Bash 的风险则来自无界输出。下一章看另一种“保留完整事实、限制模型可见结果”的实现:屏幕与 tool result 只留下尾部时,完整字节流究竟放到了哪里。