Filesystem absence is an observation and guarded creation never replaces
文件系统中的缺失是一种观测,带防护的创建绝不执行替换
The event-gated filesystem policy originally records only successful reads and mutations as a target version. If a session reads a file and an external command deletes it, the first guarded mutation correctly fails stale, but the prescribed reread returns `FS_NOT_FOUND` before emitting `fs/observed`. The old positive version therefore remains forever: write keeps choosing `replaceIfVersion`, the provider keeps reject
English
Problem
The event-gated filesystem policy originally records only successful reads and mutations as a target version. If a session reads a file and an external command deletes it, the first guarded mutation correctly fails stale, but the prescribed reread returns FS_NOT_FOUND before emitting fs/observed. The old positive version therefore remains forever: write keeps choosing replaceIfVersion, the provider keeps rejecting the missing target, and the model-facing “re-read the file, then retry” instruction becomes an unrecoverable loop.
Treating a failed read as permission to create also exposes a second boundary. Both local and E2B providers probe before staging, then historically publish with rename; another process can create the target between those steps and be overwritten even though the caller supplied createIfAbsent. An in-process target lock does not protect that cross-process publication race.
Decision
dsh-fs owns an explicit observation union: { kind: 'present', version: FsVersion } | { kind: 'absent' }. The fs/observed event carries that union. Successful reads and mutations emit present; a metadata miss from read or the str_replace_editor view, str_replace, or insert command emits absent synchronously before returning FS_NOT_FOUND. Other read failures do not manufacture absence.
dsh-fs-observation-policy stores three logical states per owner and target without injecting or calling ctx.fs: missing map entry is unseen, absent is confirmed absence, and present(version) is a replacement/edit basis. Write maps unseen and absent to the existing createIfAbsent intent and present to replaceIfVersion. Edit maps unseen to FS_NOT_OBSERVED, absent to FS_NOT_FOUND, and present to its version guard. A successful create or mutation replaces absence with its produced present version.
Every provider must enforce createIfAbsent at the publication point, not only at its initial probe. dsh-fs-local stages and fsyncs in a private sibling directory, then hard-links the staged file to the destination; after a failed link it inspects the destination entry so a regular-file collision returns FS_NOT_OBSERVED, a non-regular entry returns FS_NOT_REGULAR_FILE, and a failure against a still-missing target returns FS_IO_ERROR. dsh-fs-e2b uses remote ln -T with an explicit created/existing result and derives the committed target version from metadata obtained before the non-cancellable commit. Replacements and bare unconditional writes retain their existing publication paths.
This decision does not claim cross-process linearizability for replaceIfVersion: the provider version check and replacement remain protected only against writers represented by the provider's own lock and detectable metadata. The narrower guarantee is exact and sufficient for absence recovery: guarded creation never clobbers a target that appears before publication. Local guarded creation requires hard-link support; once any local publication succeeds, staging cleanup is best effort because private residue cannot make the committed write false.
Alternatives considered
- Delete the cached version when a read returns not found. Rejected because it conflates unseen with confirmed absence, cannot give edit the correct
FS_NOT_FOUNDresult, and erases the state transition the event is meant to communicate. - Have
dsh-fs-observation-policycallstatbefore choosing an intent. Rejected because it makes the event-only policy depend on a provider, adds I/O to every decision, and still leaves a TOCTOU gap before publication. - Let
replaceIfVersioncreate when its target disappeared. Rejected because a positive observation is evidence for replacement, not creation; silently changing that provider intent would bypass the required missing reread and weaken stale protection. - Keep the deleted-target dead end fail-closed. Rejected because the model-facing recovery instruction is then false and a normal external cleanup cannot be recovered within the session.
Consequences
The first mutation after an unobserved external deletion still fails FS_STALE_VERSION; the user or model must follow the existing reread remedy. That missing reread returns FS_NOT_FOUND while changing policy state, after which edit remains forbidden and write may recreate the path. If another writer wins the create race, the retry returns FS_NOT_OBSERVED and leaves the winner intact; a competing directory, special entry, or dangling symbolic link instead returns FS_NOT_REGULAR_FILE without prescribing another read.
The observation payload is a package-owned event contract change, so every producer, listener, invariant, generated Cordis catalog, subsystem document, and both filesystem tool families move together. The policy keeps its one-stat read and zero-stat write/edit budget, owner isolation, disposal behavior, and optional deployment boundary from the event-gate decision.
The assembled filesystem snapshot pins the model-visible recovery chain, while provider tests inject a creator after staging to prove no-clobber publication. The guarded-mutation remedy decision remains the owner of model-facing recovery wording; this note makes its deletion path actionable.
中文
问题
事件门控的文件系统策略最初只把成功读取和变更记录成目标版本。如果某个会话读取文件后,外部命令将其删除,第一次带防护的变更会正确地因陈旧而失败,但按指示执行的重新读取会在发出 fs/observed 前返回 FS_NOT_FOUND。因此,旧的存在版本会一直保留:写入仍不断选择 replaceIfVersion,提供方仍不断拒绝缺失目标,而面向模型的「re-read the file, then retry」指令则形成无法恢复的循环。
把一次失败的读取视作创建授权,还会暴露第二个边界。本地与 E2B 提供方都会先探测再暂存,此前随后通过 rename 发布;另一进程可能在两步之间创建目标,即使调用方提供了 createIfAbsent,该目标仍会被覆盖。进程内目标锁无法防范这种跨进程发布竞态。
决策
dsh-fs 拥有一个显式观测联合类型:{ kind: 'present', version: FsVersion } | { kind: 'absent' }。fs/observed 事件携带该联合类型。成功的读取与变更发出存在观测;read 的元数据未命中,或 str_replace_editor 的 view、str_replace、insert 命令发生元数据未命中时,都会在返回 FS_NOT_FOUND 前同步发出缺失观测。其他读取失败不会产生缺失观测。
dsh-fs-observation-policy 按所有者与目标存储三种逻辑状态,既不注入也不调用 ctx.fs:映射中无条目即未见,absent 表示确认缺失,present(version) 是替换/编辑基准。写入把未见和缺失映射到现有 createIfAbsent 意图,把存在映射到 replaceIfVersion。编辑把未见映射到 FS_NOT_OBSERVED,把缺失映射到 FS_NOT_FOUND,把存在映射到其版本守卫。成功创建或变更后,系统会用其产生的存在版本取代缺失状态。
每个提供方都必须在发布点执行 createIfAbsent,不能只在初始探测时执行。dsh-fs-local 在私有同级目录中暂存并执行 fsync,再通过硬链接把暂存文件发布到目标位置;链接失败后,它会检查目标条目:与普通文件冲突时返回 FS_NOT_OBSERVED,条目非普通时返回 FS_NOT_REGULAR_FILE,目标仍然缺失时返回 FS_IO_ERROR。dsh-fs-e2b 使用远程 ln -T 返回明确的已创建/已存在结果,并根据不可取消提交前取得的元数据推导已提交目标的版本。替换操作和裸无条件写入仍沿用现有发布路径。
本决策不宣称 replaceIfVersion 具有跨进程线性一致性:提供方的版本检查与替换仍只能防范被其自身锁纳入协调的写入方,以及能通过元数据检测到的写入方。更窄的保证边界准确且足以支持缺失恢复:带防护的创建绝不会覆盖在发布前出现的目标。本地带防护的创建要求支持硬链接;任何本地发布一旦成功,暂存清理便采用尽力而为语义,因为私有残留无法否定已提交写入的成功。
曾考虑的替代方案
- 读取返回未找到时删除缓存版本。 不予采用,因为这会混淆未见与确认缺失,无法让 edit 返回正确的
FS_NOT_FOUND结果,还会抹去该事件本应传达的状态转换。 - 让
dsh-fs-observation-policy在选择意图前调用stat。 不予采用,因为这会让只依赖事件的策略转而依赖提供方,为每次决策增加 I/O,并且在发布前仍留下 TOCTOU 间隙。 - 允许
replaceIfVersion在目标消失后执行创建。 不予采用,因为存在观测是执行替换而非创建的依据;静默改变该提供方意图会绕过必须针对缺失目标执行的重新读取,并削弱陈旧保护。 - 让删除目标后的死路继续保持 fail-closed。 不予采用,因为这样会使面向模型的恢复指令失实,而且正常的外部清理操作无法在会话内恢复。
影响
尚未观测到外部删除时,第一次变更仍以 FS_STALE_VERSION 失败;用户或模型必须遵循现有的重新读取恢复指令。该次针对缺失目标的重新读取会返回 FS_NOT_FOUND 并同时改变策略状态,此后 edit 仍被禁止,而 write 可以重新创建该路径。如果另一个写入方赢得创建竞态,本次重试会返回 FS_NOT_OBSERVED,并保留获胜方写入的文件;若竞态目标是目录、特殊条目或悬空符号链接,则改为返回 FS_NOT_REGULAR_FILE,且不会要求再次读取。
观测载荷是由包拥有的事件约定变更,因此所有生产方、监听器、不变式、生成的 Cordis 目录、子系统文档以及两套文件系统工具都必须同步更新。策略保留事件门禁决策确立的 read 一次 stat、write/edit 零次 stat 预算、所有者隔离、dispose 行为和可选部署边界。
组装后的文件系统快照固定面向模型的恢复链;提供方测试则在暂存后注入一个创建者,以证明发布不会覆盖竞争目标。带防护变更恢复指令决策仍然拥有面向模型的恢复措辞;本 Agent Note 使其中的删除路径能够生效。