文件编辑的工程学:先读后写
read / edit / write 三件套,没读过的文件不许改。核心源码:packages/fs/fs-observation-policy/src/index.ts。
packages/fs/fs-observation-policy/src/index.ts 的 writeIntent / editIntent 与 docs/subsystems/filesystem.zh.md 的错误分类。玩的时候盯住账本:判定从来不看文件本身,只看这个会话亲眼见过什么。Agent 改文件的三大翻车现场:改错位置、覆盖没读过的文件、拿着过期内容做编辑。DSH 的对策是三件套加一本账。三件套是面向模型的 read、edit、write 工具(docs/tool-catalog.zh.md):read 窗口化读取带行号的文本,edit 做字面量替换,write 整文件创建或覆盖。账是 fs-observation-policy 插件肚子里的一个弱引用映射:以会话为键,记下每个文件目标的观测状态。
账本只有三种状态。未见:账本里压根没这个文件的条目。present@vN:读到过,而且读到的是版本 vN,版本号是文件系统后端签发的不透明新鲜度凭证。absent:确认过这个路径不存在,比如 read 扑了个空。每次 read、write、edit 成功后,工具会发一个 fs/observed 事件,插件同步记账。
判定发生在动手之前。工具要写或要改时,会分发 fs/write-intent 或 fs/edit-intent 事件,这是单槽瀑布:第一个返回决定的监听器独占决策权,按部署约定就是这个策略插件。它对着账本给出守卫条件,真正的检查由后端在一个原子临界区里完成:先验版本再匹配再替换,中途谁也插不进来。
write 永远有路走没读过就 write,守卫是 createIfAbsent:文件不存在就创建,存在就拒绝(FS_NOT_OBSERVED)。读过再 write,守卫是 replaceIfVersion:版本对上才替换。新建文件不用先读,覆盖别人的文件不行。
edit 一步都不让没读过直接 FS_NOT_OBSERVED,账本记着 absent 就 FS_NOT_FOUND,读过则带版本守卫上路。版本检查排在字面量匹配之前,所以拿过期内容编辑报的是 FS_STALE_VERSION,不会退化成一个误导性的匹配失败。
错误带结构化身份所有失败都带稳定的 FsError code,工具注册表在错误结果上保留 { name, code }。重试逻辑和 UI 按 code 分支就行,不用解析错误文案(docs/subsystems/filesystem.zh.md「错误分类体系」)。
整个策略插件不到 140 行,核心就是两个查账函数。write 的判定是一个三行的选择:查账发现读到过(present),就返回带版本号的 replaceIfVersion 守卫,版本对上才许替换;账本里没条目或者确认过不存在,就返回 createIfAbsent,文件不存在才许创建。函数头上的注释把这张决策表用两个箭头写完了。这就是「write 永远有路走」的实现:新建文件不用先读,覆盖别人的文件不行。
出处:packages/fs/fs-observation-policy/src/index.ts 第 61 至 71 行的 writeIntent,核对日期 2026-08-13。
edit 的判定更严,没读过连守卫都拿不到,直接抛错。这个函数值得整段看,两个 throw 就是本章标题的全部内容:
editIntent(target: FsTarget, actor: object | undefined): { version: FsVersion } {
const owner = this.owner(actor)
const prior = owner ? this.get(owner, target.targetKey) : undefined
if (!owner || prior === undefined) {
throw new FsError(`edit requires reading "${target.displayPath}" first`, 'FS_NOT_OBSERVED')
}
if (prior.kind === 'absent') {
throw new FsError(`cannot edit "${target.displayPath}": not found`, 'FS_NOT_FOUND')
}
return { version: prior.version }
}
packages/fs/fs-observation-policy/src/index.ts,核对日期 2026-08-13。代码块保留源码原文。返回的 { version: prior.version } 就是那张新鲜度凭证。后端 editText 拿到它,先对当前版本,不符报 FS_STALE_VERSION;对上了才做字面量匹配,old_string 必须恰好命中一次,命中多处报 FS_AMBIGUOUS_EDIT,一处都没有报 FS_EDIT_NOT_FOUND,除非模型显式传了 replace_all。匹配、行尾处理、陈旧检查、原子替换全在一个临界区内完成(docs/subsystems/filesystem.zh.md 第 151 行)。
还有三个值得记的细节。其一,这套防线是可拔的:卸掉插件,write 和 edit 退回无条件的裸提供方行为,工具 schema 一个字不变,因为工具只分发事件、从不直接调策略。其二,read 的授权只看新鲜度,不分整读还是窗口读:只要文件没变,读 10 行也能授权后续整个文件的 edit。其三,read_image 是条件注册的典型:部署没有 ctx.attachments 能力就根本不注册,注册了但当前路由的模型不吃图,执行时也拒绝(docs/tool-catalog.zh.md 第 718 行)。顺带一提演进史:edit 结果里那张带上下文的 diff 卡,最早是靠结果时刻重算 hunk 实现的,方案记录在已归档的 Agent Note .agents/notes/archived/architecture/2026-07-02-result-time-applied-hunk-diffs.zh.md,后来后端直接返回 before/after 全文,工具算 hunk 存进 meta,回放免重算,这条通道在 工具输出契约 一课刚讲过。
Claude Code 也强制先读后写,规则直接写进了工具说明书。FileEditTool 的 prompt 原文:
「You must use your ${FILE_READ_TOOL_NAME} tool at least once in the conversation before editing. This tool will error if you attempt an edit without reading the file.」
「This tool will error」说明 CC 的运行时确有强制检查,不只是提示词客气一下;old_string 不唯一会失败、要么加上下文要么 replace_all 这条也和 DSH 完全同构。差别在防线的挂载位置:CC 的先读检查长在 FileEditTool 自己身上,DSH 把它抽成独立插件,read、edit、write、str_replace_editor 四个工具共享同一本账,工具本体一行权限代码都没有。至于文件被外部改动后 CC 如何检测过期读取,已核对的书稿材料未展示实现细节,这条基于已公开证据保留。
Grok Build 的 search_replace 工具留下了同一场斗争的痕迹。配置里有个 skip_read_before_edit 字段,注释标着已废弃的运行时空操作,只在配置期把关 Read 工具依赖,说明先读后写曾是硬开关、后来松了绑。对过期读取的处理更能看出取向差异:
「When true, append a hint that the user may have changed the file to NoMatchesFound error messages. This nudges the model to re-read instead of blindly retrying with the same stale content.」
翻译一下:文件被人改了导致匹配失败时,Grok 在报错文案里加一句提示,劝模型重新读一遍再试。这是提示语浓度的防线,靠模型自觉。DSH 是版本凭证浓度:版本对不上就 FS_STALE_VERSION,物理上不给写。Grok 也有自己的强项,编码坑那关它准备了 unicode_normalized_fallback,智能引号、长横线这类肉眼难辨的字符匹配失败时可以做归一化重试(同文件第 103 至 110 行),DSH 的 edit 目前只按行尾规范化后精确匹配。hunk 级的变更追踪 Grok 单独抽了 xai-hunk-tracker crate 来做,Grok 工具系统的全景可以看站内 实现族、注册表与动态 MCP。
推演一次三连击
会话刚开始,模型依次做三件事:write 一个不存在的 draft.md、edit 这个 draft.md、然后你在编辑器里手动改了 draft.md 一个字,模型又发起第二次 edit。请写出三次调用各自的守卫(createIfAbsent / replaceIfVersion / 版本守卫)和结局,标出账本在每一步之后的状态。特别想一想第二步:write 成功会 emit fs/observed 吗?如果不记这笔账,第二步的 edit 会发生什么?(提示:writeIntent 的决策表里,present 走 replaceIfVersion,而 edit 没读过直接 FS_NOT_OBSERVED。)