dsh 的 Filesystem 接缝:读写编辑与观察策略
dsh的文件系统不是一组读写函数,而是四个解耦的包:provider 只管原子读写,观察策略通过事件加新鲜度护栏,工具只负责执行和渲染,三方靠fs/*事件共享一套词汇表,谁都不直接依赖谁。最反常识的一条:read-before-edit 不是 provider 自带的行为,是一层可选的策略插件。裸 provider 会无条件覆盖,装上策略才变成"先读再写",卸掉策略工具照常工作。 护栏的形态也值得先记住:写和编辑的可选护栏分别是"不存在才建"和"版本匹配才替换",护栏的校验发生在变更临界区内部,不是事前检查。陈旧的编辑报的是"文件变了",不是"没找到那串文本",这一条语义差别决定了 agent 能不能从失败里恢复。
为什么文件系统要做成接缝
粗看,agent 读写文件就是几个函数:读、写、改、列目录。直接调 fs.readFile 不就行了?
认真想下去,"直接用"会在真实场景里连环撞墙。
agent 在远程沙箱里跑时,文件不在本地磁盘上,fs.readFile 读的是宿主机,不是 agent 看到的世界。agent 改文件前,怎么保证它看过这个文件?不看过就改,等于盲改,可能把别人刚写进去的内容整个覆盖掉。同一个文件有多条路径可达(符号链接、相对路径),怎么保证不同路径指向的是同一个东西?一个写操作跨进程边界时,怎么和沙箱的"能写哪里"对齐?文件 IO 要不要设超时,设了能不能真的管用?
这些问题里藏着一个更根本的区分:读写能力和读写策略是两层。能力回答"怎么把字节写进去",策略回答"现在该不该写"。把两层焊在一起,换后端就得重写策略,换策略就得动后端。dsh 把它们拆开,四个包各管一摊:
| 包 | 角色 | 职责 |
|---|---|---|
dsh-fs |
接缝定义 | 拥有 ctx.fs、原子文本操作、fs/* 事件词汇表 |
dsh-fs-local |
Provider | 本地磁盘实现 |
dsh-fs-observation-policy |
策略(可选) | 记录观察到的存在与缺席,通过事件加新鲜度护栏 |
dsh-tool-fs |
消费者 | 模型面向的 read/write/edit,渲染读取窗口 |
dsh-fs-observation-policy 是可选的,这是整个设计的题眼。没有它,FileSystem 接口、一个 provider、一个消费者,就构成一个完整但不受约束的文件系统:write 无条件创建或覆盖,edit 无条件替换字面文本。策略插件改变的是这些操作的意图决策,不是操作本身。卸掉它工具不坏,因为工具调的是 ctx.fs、派发的是事件,从不调用策略的方法。
部署上的约定是工具和策略一起加载,于是默认行为是 read-before-write。注意这是约定不是编译期保证,后面谈代价时再回到这一点。
不透明的目标身份
每个操作的第一步,都是把用户给的路径解析成一个不透明的后端目标。FsTarget 有两个字段:targetKey 是不透明 key,消费者被明文禁止解析它,也不能假设它是本地绝对路径;displayPath 是给模型和 UI 看的路径,可能是本地绝对路径、工作区相对路径或远程 URI。
本地后端的 targetKey 用类似 realpath 的字符串,远程后端可能用 workspace URI 或文件 id。把它做成黑盒,是为了逼消费者走正路拿坐标,而不是自己去猜后端的内部表示。猜的后果很具体:一旦某个消费者假设了 targetKey 是本地路径,远程后端接上来的那天,那个消费者就在解析一个根本不是路径的字符串,错误会在离原因很远的地方爆炸。
那消费者要跨能力协作怎么办?比如 fs 读到的文件,shell 要打开它。答案是通过 provider 提供的坐标方法:processPath 返回这个执行世界里子进程能打开的规范绝对路径,fileUrl 返回 provider 平台的 file: URI,contains 测规范身份或后代包含关系。三个方法把"跨能力坐标"收敛到 provider 一处:fs-local 给本地路径,fs-sandbox 或远程 provider 给那个世界里的路径。消费者不用知道差异,shell 和 fs 才能共享同一个执行世界。contains 有个附加条件:两个参数必须来自同一个 provider,混用两个 provider 的目标做包含判断没有定义。
解析本身是异步的。这不是故弄玄虚:远程后端解析一个路径可能要走一次网络往返,接缝把这一点如实暴露,而不是用同步签名骗本地调用方。相对路径的锚点也是接缝的事:解析时可以给一个工作目录选项,相对路径相对它解析,消费者不用自己先拼绝对路径。
displayPath 值得单独看一眼。模型上下文里出现的路径就是它,可能是本地绝对路径,也可能是远程 URI。它的职责是让模型有一个稳定可引用的名字,而不是让模型拿它去做坐标运算。真正的坐标运算是消费者的活:要交给子进程就调 processPath,要生成链接就调 fileUrl。一个远程后端可以把 displayPath 做成人类可读的 URI,同时把真实身份藏在 file id 后面,两边互不干扰。
新鲜度令牌与元数据
FsVersion 是另一个 branded 不透明令牌,write 和 edit 靠它防陈旧。本地后端从高分辨率 stat 身份和新鲜度字段派生它,远程后端可能用 revision id。策略层记录它做陈旧检查,消费者不解释它。把版本做成不透明令牌而不是时间戳或计数器,是因为不同后端对"什么是一次变更"的定义不同:本地是 inode 身份加 mtime 纳秒,远程是服务端 revision。令牌化之后,后端各自用最诚实的方式实现,消费者只做一件事,比较。
元数据读取有两个原语,分工是刻意错开的。
stat 接目标,返回元数据,绝不返回内容;目标不存在时返回 undefined 而不是抛错。返回的 FsInfo 带版本、类型(file、directory、other)、可选的 size。类型让消费者在读之前就拒绝目录和特殊文件,size 让文本消费者不靠试错就能选 readText 还是 streamText。"不存在"用 undefined 表达,使得"确认缺席"成为一次正常的观察结果而不是异常,后面会看到这个选择撑起了整个缺席语义。
lstat 接路径,不跟随符号链接。为什么形态不同?因为 resolve 故意跟随符号链接以产生稳定身份,而要做信任边界检查的消费者需要的是"这个路径本身是什么"。仓库里有个符号链接指向仓库外,你不希望 agent 顺着它读出去,检查的时机就必须在 resolve 之前:先 lstat,看到类型是 symlink,拒绝。如果 lstat 也跟随链接,这个检查窗口就不存在了。
listDir 列直接子项,按名字稳定排序,从不读内容。坏掉的子项以 other 类型出现、不带元数据;权限或 IO 失败让整个列目录失败,报对应的错误码。要么给一份诚实的清单,要么明确失败,没有"跳过读不了的部分继续列"的中间态,因为静默跳过会让消费者误以为清单是完整的。
读取的三种形态与读取窗口
读取有三个原语,对应三种消费形态。
readText 把整个文件读成一个字符串,适合小文件。streamText 吐解码后的文本块流,适合大文件;跨块的 UTF-8 解码和二进制拒绝是后端的责任,策略层从不碰原始字节。readBytes 读原始字节,带一个必填的 maxBytes 上限:已知超限或读到一半发现超限,都以 FS_TOO_LARGE 失败,而不是截断或无界缓冲。把上限做成必填参数,是让后端永远没有机会缓冲一个无界文件;宁可明确失败,也不静默截断,因为截断的文本对模型来说是完整的事实,它不知道自己看到的是半句话。
模型面向的读取结果不是整个文件,是一个窗口。FileReadOutcome 带一基的起始偏移、带行号的文本行、精确的 totalLines、可选的字节截断标记。读取受行窗口、字节上限和后端限制三重约束,撞到字节上限后扫描继续走但不保留行,所以 totalLines 永远是精确值。这个细节体现了一种取舍:消费者需要知道文件有多大来决定要不要再读一段,这个数字宁可多花一次扫描也要给准。
文本原语撞上非文本内容,失败码是 FS_NOT_TEXT,属于封闭词表的一员。图片、字体、压缩包这类东西走文本路径没有意义,明确失败比硬解码出一屏乱码好,乱码对模型来说是可引用的"内容",它会真的去读。要消费这类文件,调用方该走 readBytes 并自带上限,比如把图片交给多模态附件链路。三个读取原语的分工覆盖了一个实际问题:先 stat 看类型和大小,文本小文件走 readText,文本大文件走 streamText 加窗口,非文本走 readBytes,每一步都有确定的行为。
write 与 edit:临界区里的护栏
这是文件系统设计最精巧的部分。writeText 和 editText 的护栏都是可选的,类型是 FsWriteIntent,两个变体:createIfAbsent 表示目标不存在才建,已存在则报 FS_NOT_OBSERVED;replaceIfVersion 表示版本匹配才替换,否则报 FS_STALE_VERSION。
省略护栏就是无条件创建或覆盖。"不护栏"通过省略表达,不是联合类型里的第三个分支,write 和 edit 因此共用同一个可选 expected 字段。这个形状有个好处:裸 provider 的行为就是"没传护栏",而不是"传了一个叫 none 的护栏",词表里没有为放任留一个显式的名字。
关键在护栏什么时候校验:在变更临界区内部,不是事前检查。createIfAbsent 会抓住"探测时目标还不存在、写入时目标已经出现"的竞态,因为发布动作本身是不覆盖的:校验和写入是同一个原子操作的两半,中间没有窗口。如果护栏只是写前的 if 检查,检查和写入之间的微秒窗口里外部进程建了文件,你的写就把人家覆盖了,而且没人知道发生过。把校验挪进临界区,这个窗口就关死了。
editText 是 provider 级的单一变更,不是在外面用读加写拼出来的。带护栏时,顺序是先校验版本、再字面匹配。这个顺序有明确的语义意图:文件在你上次观察之后被别人改过,你的编辑失败时报的是 FS_STALE_VERSION(文件变了),而不是"没找到要替换的文本"。这两种失败对 agent 的意义完全不同。前者的正确反应是重读文件再重新编辑;后者的反应可能是怀疑自己记错了内容,去上下文里翻旧版本,或者原地造一个新串再试。把陈旧误报成匹配失败,会把 agent 引向错误的恢复路径。
无论是否带护栏,匹配、行尾处理、陈旧检查、原子替换都在一个变更临界区里:一次编辑要么完整成功,要么完整失败,没有"匹配了但没写成"的中间态。取消信号在原子发布生效之前中止,同样不留半成品。
返回的 outcome 带 before 和 after,都是 LF 规范化的存储文本,不是 diff。两个细节值得记住。一是存的是文本,diff 是派生的,消费者在自己这边算上下文 diff,"存储真值"和"展示差异"就此分开,不同消费者可以对同一份 before/after 渲染出不同粒度的 diff。二是 before 在 write 的结果里可能为 null:创建时本来就没有前内容,或者后端拒绝回读之前的内容(旧内容是二进制或非 UTF-8,或撞了独占限制)。edit 的 before 则永远存在,因为编辑的前提就是匹配到了旧文本。这个不对称是诚实的:write 的过去可以未知,edit 的过去必然已知。
一次竞态的解剖
把前面的机制放进一个具体场景,看它们怎么咬合。
agent 在一个配置文件上工作。它先读了这个文件,策略层记下一条观察:这个目标存在,版本是 v7。agent 想了一会儿(模型生成需要时间),这期间一个外部进程改了这个文件,版本变成 v8,然后又一个进程把文件删了。
agent 发起编辑。策略层查观察表:最后一次观察说存在、版本 v7。它给编辑带上 replaceIfVersion: v7。provider 在临界区里发现目标不存在:编辑失败,报 FS_STALE_VERSION。注意不是 FS_NOT_FOUND,因为护栏路径上"目标没了"是"版本不再匹配"的一种,统一报陈旧。agent 的正确反应是重新观察:再 stat 一次。
假设外部进程没删文件,只是改成了 v8。编辑同样失败报陈旧。agent 重读,策略层更新观察到 v8,重新编辑,这次护栏匹配,原子替换成功。整个过程中,外部进程改的内容一次都没有被覆盖;每一次失败都带着明确的语义,agent 不需要猜。
再换一个开局:agent 要建一个新文件,它先确认过这个路径不存在(一次读失败,策略层记下缺席观察)。它发起写入,策略层带上 createIfAbsent。写入瞬间,另一个进程恰好建了这个文件。临界区里的不覆盖发布让这次写入失败,报 FS_NOT_OBSERVED,那个外部进程的内容完好。如果没有这个机制,agent 的"我刚确认过它不存在"就是一句陈旧的证词,覆盖照常发生。
三次失败,三个不同的码,三条不同的恢复路径。这就是护栏的语义层价值:它不只防覆盖,它还把失败变成可路由的信号。
read-before-edit:一层策略,不是 provider 内置
现在讲最反常识的部分的机制细节。"先读再改"由 dsh-fs-observation-policy 实现,它不是服务,不注册任何 context 贡献,纯靠监听 fs/* 事件存在。
观察状态是个两层映射:owner 是键,每个 owner 一张从 targetKey 到观察记录的表。观察记录三态:缺表项表示没见过;absent 表示一次读取或编辑的元数据 miss 确认了它不存在;present 加版本表示读、写或编辑观察到了这个版本。
两个决策都从三态出发。写入决策把"没见过"和"缺席"都映射成 createIfAbsent,把"存在"映射成 replaceIfVersion。编辑决策把"没见过"映射成 FS_NOT_OBSERVED(你不该改你没见过的东西),"缺席"映射成 FS_NOT_FOUND(你见过它,现在没了),"存在"映射成版本护栏。
owner 从事件的 actor 派生,通常是执行的 agent 加会话,策略包把它当不透明的 WeakMap 键用,从不读它的任何字段。事件载荷里也只有 dsh-fs 的词汇加一个不透明的 actor 对象,没有模型面向的概念,没有 agent 或 session 的结构。策略包需要的执行上下文,通过一个最小结构视图拿到:只要有 agent 和会话的形状就行,工具把自己的执行对象直接传过去,策略包因此不用引入工具、agent、session 任何一方的依赖。插件销毁时 WeakMap 里的全部状态随之丢弃,热重载不留垃圾;策略本身不做任何文件系统 IO。
观察的作用域也由这个键定死:每个 owner 一张表,agent 的每个会话各自记账。两个并发的会话对同一个文件各有各的观察,互不担保;一个子 agent 被委派出来,它从空表开始,要编辑文件得先自己读一次。这看起来像浪费,其实是护栏语义的必然:观察授权的依据是"这个会话亲眼见过这个版本",跨会话传递观察等于让人替别人作证,护栏就名存实亡了。会话内重放则不成问题,因为重放的事件流会重建出同样的观察序列。
共享词汇表,发射方不依赖监听方
事件层有三个事件,两种性格。
fs/write-intent 和 fs/edit-intent 是单槽决策 waterfall。工具派发时带一个默认 thunk,返回 undefined 就表示裸 provider 行为;监听器完整决策,不调 next()。槽按注册顺序先到先得,策略插件拥有这个槽是个部署约定,不是强制不变量。理论上另一个插件可以先注册占住槽,让 read-before-edit 整个失效;实践中部署把工具和策略一起加载,没人这么干。这是用部署纪律换包边界的干净:接缝定义包不需要知道策略包的存在。
fs/observed 是即发即忘的记录事件,携带观察记录。监听器必须同步、只做副作用,因为工具不保护这个 emit。一个抛错的监听器可能把一次读的错误替换掉,或在变更已经成功之后让工具的结果变成错误态。这个设计把复杂度从发射方挪到了监听方:发射方保持简单,不用包一层 try-catch 去防御它不认识的监听器;监听方必须自律,知道自己的异常会污染别人的结果。这是一个可以用"谁有能力防"来辩护的取舍:策略插件是仓库自己的代码,可审查;而发射方要防御的是一个开放集合。
局部读取就能授权编辑
很多人以为,要安全地编辑一个文件,得先把它整个读过。dsh 的答案是不必,而且这条答案的依据很硬。
读取的结果只有展示意义,没有 full 或 partial 之分。授权基于新鲜度:工具在 stat 拿到版本后,直接发出一条 present 的观察。所以任何窗口化的读取,哪怕只读了开头几行,都能在文件未变时授权后续的 write 和 edit。一次元数据 miss(读一个不存在的文件)则发出一条 absent 观察,允许后续护栏式写入重建一个被外部删除的目标,但不授权编辑。
这条规矩的实际意义很大:agent 读一个大文件的一小段,就能安全地编辑它,不用把整个文件塞进上下文。授权的依据是版本令牌,不是"读全了没有"。反过来说,把授权挂在"读过全文"上,等于逼着每个编辑都先付一遍全文件读取的上下文成本,大文件场景下 agent 要么烧钱要么干脆放弃护栏。
词表上也体现了这个设计:错误码里没有 FS_PARTIAL_OBSERVATION 这种东西,因为新鲜度授权没有部分授权的概念,观察要么带版本要么缺席。
与沙箱共享执行世界
writeText 和 editText 都接一个可选的 sandboxPolicy 参数(模式加工作区根)。沙箱后端按它围栏这次写,裸后端忽略它。
把 ctx.fs 和 ctx.subprocess 指向同一个远程沙箱时,fs 的写、shell 的命令、LSP 的查询全跟着同一个沙箱模式走。接缝在签名上预留这个参数,"按共享模式围栏"就是一次调用能携带的事,不用在外面拼装。反过来,本地裸后端拿到这个参数也只是忽略,调用方不需要为两种后端写两套调用。
错误码上能看到这层关系的落点:FS_SANDBOX_DENIED 是沙箱后端的策略拒绝(模式围栏否决了这次写),FS_PERMISSION_DENIED 是宿主内核拒绝。前者该提示用户调整模式或发起升级,后者该检查文件系统权限,两条分支不该混。
文件 IO 没有超时
read、write、edit 不接超时参数,provider 契约也不上截止时间。
原因在于本地系统调用顶多是尽力可中止:超时没法强制一个进行中的 fsync 或 rename 停下来。在这里设超时,就是一个接缝无法执行的截止时间,而"显式优于隐式"的规矩禁止在无法执行的地方放一个假装能执行的默认值。一个杀了进程但留下半写的文件的超时,比没有超时更糟。
对比能看清这条边界画在哪:bash 和 web 消费超时库;subprocess 支撑的 glob 和 grep 声明的超时由工具调用超时策略执行。那些是进程支撑的,截止时间真能杀掉工作。文件 IO 不行,所以干脆不设。取消仍然通过工具执行信号在系统调用边界尽力传播,中止发生在原子发布生效之前,不会留半成品。
错误分类:稳定码
文件系统失败用稳定的错误码字符串,由 HarnessError 子类携带。工具注册表在错误结果上保留名字和码,重试、权限、UI 层不用解析消息就能分支。封闭联合共十三个码。
值得记住的区分有三处。FS_NOT_OBSERVED 是策略没有观察记录(或 createIfAbsent 撞上了已存在的文件),FS_NOT_FOUND 是确认缺席,一个"没看过"、一个"看过不在",编辑决策靠这两个码分开两种截然不同的情况。FS_STALE_VERSION 是版本不匹配,陈旧编辑报它,语义是"文件变了,重新观察",不是"你的字符串没匹配上"。FS_SANDBOX_DENIED 和 FS_PERMISSION_DENIED 的区分上一节讲过。这套码是护栏语义的出口:临界区里拦下的每一次失败,都以可路由的码离开文件系统。
权衡与局限
这套设计把一部分可靠性押在部署约定和监听器自律上,换来的正交性有具体的价码。
read-before-edit 是部署约定,不是强制不变量。单槽 waterfall 的拥有权靠注册顺序先到先得,另一个插件理论上可以先注册占住槽,让护栏静默失效。防御手段只有部署审查:加载了工具就一定加载策略,这条线在人手里,不在类型系统手里。
fs/observed 的 emit 不受保护。一个行为异常的同步监听器能在变更已经成功后把结果变成错误态,或替换掉读错误。发射方因此保持简单,代价转到监听方头上。如果你在写一个监听这个事件的插件,异常纪律是你的责任,没有框架兜底。
不透明身份增加调试难度。targetKey 和版本令牌都是 branded 字符串,日志里看到的是一串看不出含义的 id,要回到 provider 才知道它指什么。给目标加可读的调试通道是有诱惑的,但每开一个口子,"消费者不许解析"的禁令就漏一次风。
文件 IO 无超时,长卡的操作没有截止手段,只能靠取消信号在系统调用边界尽力中止。绝大多数本地操作感知不到这一点,挂载了慢速网络文件系统的场景要有心理预期。
结论
ctx.fs 把文件操作拆成能力、策略、消费三层,靠 fs/* 事件共享词汇表:provider 只管原子读写和版本令牌,read-before-edit 是可选策略插件加上的护栏,工具不依赖策略插件的存在。护栏校验在变更临界区内部,先版本后匹配,陈旧报陈旧;窗口化读取靠版本令牌就能授权编辑,不必读全文。"换后端"(本地、沙箱、远程)和"换策略"(是否 read-before-edit)是两个正交的轴,这就是接缝的价值。
这套设计里最值得带走的是失败语义的分层:竞态被临界区关死,陈旧和缺席被稳定码分开,每次失败都指向一条明确的恢复路径。agent 编辑文件这件事的可靠性,不在于失败不发生,而在于失败发生后,下一步往哪走是清楚的。
延伸阅读
- Filesystem 官方文档:本文主要依据,含全部类型与接缝定义
- Capability Seams:
ctx.fs行,fs-local 与 fs-sandbox provider - Process Sandbox:
sandboxPolicy参数与执行世界共享 packages/fs/fs源码目录:FileSystem接缝与fs/*事件定义
上一篇:沙箱、审批与权限:dsh 怎么安全地放 agent 上机 下一篇:dsh 命令执行三层:Subprocess / Shell / Terminal
GitHub 原文:20-filesystem-seam.md
评论
EMPTY