Guarded-mutation errors append the recovery instruction at the model boundary
受防护变更错误在模型边界追加恢复指令
Guarded `write` and `edit` failures reach the model with messages that state the condition but not the only correct recovery: `FS_STALE_VERSION` ("file changed since it was read") and `FS_NOT_OBSERVED` ("edit requires reading … first"). The model must guess that the recovery is a re-read (or a first read) followed by a retry, and the retry/permission/UI layers that route on the structured code see the same message te
English
Problem
Guarded write and edit failures reach the model with messages that state the condition but not the only correct recovery: FS_STALE_VERSION ("file changed since it was read") and FS_NOT_OBSERVED ("edit requires reading … first"). The model must guess that the recovery is a re-read (or a first read) followed by a retry, and the retry/permission/UI layers that route on the structured code see the same message text. The provider-owned messages are part of the storage seam's machine-oriented vocabulary (filesystem capability seam), so the remedy cannot live there without leaking model-facing wording into every consumer of FsError.
Decision
dsh-tool-fs owns a model-facing error wrapper, remediateFsError in src/error.ts, applied in write.ts and edit.ts after the sandbox denial mapping. It appends the recovery instruction to the two guarded-mutation codes and passes everything else through untouched:
FS_STALE_VERSION(including a missing edit target, which shares the stale code) gains— re-read the file, then retry.FS_NOT_OBSERVEDgains— read the file, then retry.
The structured FsError code is preserved so retry/permission/UI layers keep routing on it, and the original error chains as cause. Provider messages stay machine-oriented and unchanged.
In edit.ts the fs/edit-intent waterfall now sits inside the same try as the provider mutation, so the policy plugin's FS_NOT_OBSERVED refusal thrown from the intent slot also receives the remedy — both refusal paths reach the model with the same recovery wording.
Alternatives considered
- Append the remedy to the provider messages in
dsh-fs/dsh-fs-local. Rejected because those messages are machine-oriented seam vocabulary consumed by retry, permission, UI, and model-facing layers; model-facing wording belongs at the model boundary, wheredsh-tool-fsalready owns result formatting (filesystem capability seam). - Add the recovery to prompt guidance instead. Rejected because the failure arrives mid-task; a static instruction does not reliably reach the retry decision, while the error message is present exactly when the model must act.
- Signal the remedy with a new
FsErrorcode. Rejected because the two failures are the same conditions retry layers already handle; splitting the code would fork routing on identical semantics.
Consequences
Model-visible text for the two codes changes; the fs-policy-reject keyless snapshot is re-recorded, and the READMEs of dsh-tool-fs and dsh-fs-observation-policy pin the exact appended text. Unit tests cover the wrapper directly (remedy text, code preservation, cause chaining, passthrough of other codes and non-FsError values) and the assembled tool paths assert the remedy reaches the model for both codes.
The filesystem absence-observation follow-up makes the stale remedy actionable for external deletion. The failed reread still returns FS_NOT_FOUND, but records confirmed absence: edit then returns FS_NOT_FOUND without another stale remedy, while write retries as an atomic createIfAbsent and preserves any concurrent creator.
中文
问题
受防护的 write 与 edit 失败以只陈述条件、不给出唯一正确恢复方式的消息到达模型:FS_STALE_VERSION(「file changed since it was read」)与 FS_NOT_OBSERVED(「edit requires reading … first」)。模型必须自行猜测恢复方式是重新读取(或首次读取)后重试,而基于结构化错误码路由的重试/权限/UI 层看到的也是同一段消息文本。提供方拥有的消息属于存储 seam 的面向机器词汇(文件系统能力 seam),因此恢复指令不能放在那里,否则会把面向模型的措辞泄漏给 FsError 的每个消费方。
决策
dsh-tool-fs 拥有一个面向模型的错误包装层 remediateFsError(位于 src/error.ts),在 write.ts 与 edit.ts 中于沙箱拒绝映射之后应用。它为两个受防护变更错误码追加恢复指令,其余错误原样透传:
FS_STALE_VERSION(包括缺失的编辑目标——它与陈旧错误共用同一错误码)追加— re-read the file, then retry。FS_NOT_OBSERVED追加— read the file, then retry。
结构化 FsError 错误码保持不变,使重试/权限/UI 层继续基于它路由;原始错误作为 cause 链入。提供方消息保持面向机器且不变。
在 edit.ts 中,fs/edit-intent waterfall(瀑布式事件)现在与提供方变更位于同一个 try 内,因此策略插件从 intent slot 抛出的 FS_NOT_OBSERVED 拒绝也会获得恢复指令——两条拒绝路径都以相同的恢复措辞到达模型。
考虑过的替代方案
- 在
dsh-fs/dsh-fs-local的提供方消息中追加恢复指令。 被拒绝:这些消息是面向机器的 seam 词汇,被重试、权限、UI 和面向模型的各层消费;面向模型的措辞应位于模型边界,即dsh-tool-fs已经拥有结果格式化之处(文件系统能力 seam)。 - 改为在提示词引导中加入恢复方式。 被拒绝:失败发生在任务中途;静态指令无法可靠地影响重试决策,而错误消息恰好在模型必须行动时出现。
- 用新的
FsError错误码表达恢复指令。 被拒绝:这两种失败对应的条件,重试层本就已经处理;拆分错误码会使相同语义采用不同路由。
后果
两个错误码的模型可见文本发生变化;fs-policy-reject 无密钥快照被重新录制,dsh-tool-fs 与 dsh-fs-observation-policy 的 README 逐字固定追加后的文本。单元测试直接覆盖包装层(恢复指令文本、错误码保留、cause 链、其他错误码与非 FsError 值的透传),组装后的工具路径断言两个错误码的恢复指令都到达模型。
文件系统缺失观测后续决策使外部删除场景下的陈旧恢复指令能够生效。失败的重新读取仍返回 FS_NOT_FOUND,但会记录确认缺失:随后 edit 返回 FS_NOT_FOUND,不再附加陈旧恢复指令;write 则以原子 createIfAbsent 重试,并保留任何并发创建者写入的文件。