# 文件回退的追踪边界 > 回退文件时,插件**只写回它确实备份过的内容**。被写类工具(`write`、`edit`)改动过的文件可以精确还原;某一段状态**没有被备份**时,还原就会**不充分**——它宁可不动,也不猜测。 > > 对话这一半是独立的、始终可用的:回退追加一条 `user/message` 标记,其 `surfaceOp: replace` 覆盖目标消息及之后的所有节点。日志只追加,被切断的只是模型可见的表面,对话内容从不删除。文件还原只从快照存储取材,不读其他来源。 ## 一、哪些文件会被还原 ### 1.1 写前备份(`write` / `edit`) 模型调用 `write` 或 `edit` 时,插件在改动落盘**之前**、于 `tools/execute` 的环绕派发阶段捕获文件当前内容。该阶段位于审批门之后:允许的调用仍会被捕获,被拒绝的调用不记录任何东西。备份在 `tools/post-execute` 提交到磁盘,锚定在这一轮所属的那条用户消息的 seq 上。记录的是**解析后的展示路径**,因此无论模型如何书写路径,还原都指向真实文件。这条路径是确定性的:备份一旦提交,该文件就一定可以还原。 ### 1.2 已跟踪文件的外部变更与消息边界重扫 文件第一次被写类工具处理时,加入该会话的**追踪集合**。此后每条新的用户消息到达时,插件复查这个集合:若文件当前的磁盘状态与其最近一次记录的内容不同,就把当前状态记为**新消息**名下的备份。这是外部编辑(shell 命令、构建脚本、编辑器手工保存)的唯一入口,也是回退能恢复"模型没有用工具碰过、但会话已经跟踪过"的文件的原因。 这个复查**就是**消息边界重扫:它异步运行,不占 `session/event` 热路径;只记录真正变化过的文件(未变化的文件不写任何东西);回退进行中会跳过(此时还原正在写这些文件,跳过的一轮会在下一个真实边界重新推导出来)。 ### 1.3 工作区首次发现(shell 命令前) 写类工具是文件进入追踪集合的**主要**入口,但不是唯一入口。任何会改动工作区的 shell 命令(`pwsh`、`bash`、`shell`、`terminal_send`)执行**之前**,插件还会扫一遍工作区,把**从未被记录过**的文件逐个做一份写前备份: - 范围:工作区根目录下的常规文件,递归;`node_modules`、`.git`、`dist`、`build`、`target`、`coverage`、`.venv` 等依赖/构建目录整枝跳过; - 上限:单次最多 `workspaceScanMaxFiles`(默认 5000)个文件、`workspaceScanMaxBytes`(默认 200 MiB)字节,单文件仍受 8 MiB 上限约束;到顶即停,剩下的文件由下一次命令再次提供; - 只看文件是否存在与实际字节:已记录过的路径直接跳过,因此每个文件只会被完整读一次,之后交给 1.2 的边界复查维护; - 开关:配置项 `workspaceScanEnabled`(默认开)。关掉后行为回到旧版——只有写类工具碰过的文件才能还原; - 失败按路径隔离:读不到就跳过该路径,命令照常执行。 这条入口解决的是「文件不是我写的、也不在我追踪集合里,但被 shell 删掉了」这一最常见也最致命的场景:只要它在**被删除之前**经历过任何一次 shell 命令(或写类工具),它就有备份,回退就能把它写回来。 ### 1.4 还原时如何使用这些备份 对每个路径,取**目标消息及其之后最早**的一份备份;目标消息自己没有备份时,向更晚的消息找最早的一份。然后与当前磁盘比对: - 内容一致(或 `null` 备份对应的文件仍不存在)→ **不做任何操作**,重复回退保持幂等; - 不一致 → 写回备份的字节;若备份记录的是"当时不存在"(新建),则删除该文件。 只有真正会改变磁盘的动作才会进入计划,因此确认预览与实际执行必然一致。 ## 二、哪些文件不会被还原 ### 2.1 从未进入追踪集合的文件 没有备份,就没有还原。一个文件进入追踪集合有两条路:被写类工具处理过(1.1),或在 shell 命令前被工作区首次发现(1.3)。两条都没走过的文件——例如会话开始时就已经存在、而用户从未让 agent 跑过任何 shell 命令——仍然没有备份。`workspaceScanEnabled` 关闭时同样回到这条限制。 ### 2.2 超过体积上限的文件 单个常规文件超过 **8 MiB**(配置项 `maxFileBytes`,设为 `0` 关闭)时不备份。捕获路径在读取字节**之前**就拒绝,消息边界复查也在比较内容之前跳过。这是声明的限制,不是增量方案:本插件是面向单次对话的轻量回退,不是备份系统;每次编辑都完整复制超大文件会让存储无界增长并打满磁盘 IO。 上限还有安全侧的第二重作用:记录只可能存在于上限以内的内容,所以磁盘上的文件一旦超限,说明其记录早于未被跟踪的修改。还原计划因此**拒绝**该路径并在结果中说明,而不是用更旧、更小的内容覆盖更大的现存文件。文件当前仍超限时,**任何**回退都不会动它。 ### 2.3 子代理会话 子代理运行自己的会话,该会话永远不是回退目标:宿主会拒绝子代理身份上的通用会话 RPC,所以记录在子会话 id 下的备份不可能被任何回退(父级或子级)还原,只会在磁盘上泄漏。一个判定(`isSubagentSession`:`header.origin === 'subagent'` 或 `delegationDepth > 0`)统一守护所有跳过点:写前捕获、消息边界复查、`agent/created` 的格式核对、以及惰性清理触发。父会话自己视角里的工作(子代理工具调用及其结果)仍随父会话回退。 ### 2.4 符号链接、硬链接与移动的父目录 **末级组件**是符号链接或硬链接(`lstat().nlink > 1`)的路径既不进入计划,也不会被穿透写入——它被报告为跳过。符号链接会把写入重定向到检查点之外;硬链接会连带改写同一 inode 上的所有名字。该规则同样作用于复查侧,被链接的路径连复查都不会做。 符号链接的**祖先目录**另作处理:条目记录检查点时刻父目录的 `realpath`,父目录不再解析到那里时还原拒绝该路径——稳定的符号链接工作区根在两侧解析结果相同,不会被误判为跳过。被重指向或移动的祖先同样覆盖:路径被跳过,绝不写入猜测的位置;父目录链缺失时,只有在最近的存活祖先仍解析在钉住位置之内时才会重建。 ### 2.5 记录之外的路径与丢失字节的历史记录 还原不是工作区扫描:它不列目录、不展开通配符、不推断路径。动作集合恰好是计划器与磁盘核对过的、记录中的展示路径。不在该集合里的文件不会被碰,即使它在同一轮里也变过。此外,旧版本可能以有损方式解码非 UTF-8 字节,记录里留下替换字符,原始字节不可知;这类记录可以与磁盘比较,但**永不写回**——报告跳过,而不是用有损内容破坏现存字节。 ## 三、记录的结构 ### 3.1 一份备份 = 一条消息开始时的状态 插件不保存"工作区当前状态",保存的是**状态转变**。每份备份回答一个问题:*这条消息开始时,这个文件是什么?* 内容有两种形态:`null` 表示文件**不存在**(还原表现为删除),否则是完整字节副本。这些备份合起来就是该会话的**快照**,按消息分组。 同一条消息的回合里,每次写类调用都留下自己的备份;还原取其中**最早**的一份,正是这条消息开始时的状态。备份是原始字节副本(`copyFile`),从不经过解码字符串,因此二进制与非 UTF-8 文件可以字节精确地往返。权限位一并记录,仅在内容被还原时应用,且从不作为是否还原的依据;连续相同的内容会存成原地去重链接,而不是第二份副本。 ### 3.2 不产生备份的情形 - **不变不存**——边界复查发现磁盘与最近记录一致,不写任何东西。没有改动文件的消息因此没有自己的备份。 - **超量不存**——见 2.2。 - **主动关闭快照备份**——`fileCheckpointingEnabled` 为 false 期间,写前捕获与边界复查都不记录;重新开启后,关闭期间仍是一段缺口。 ## 四、示例 | 消息 | 回合内发生 | 文件内容 | 备份:这条消息开始时是什么 | | :---: | --- | :---: | --- | | 1 | `write` 新建 | `—` → `A` | `null`(落盘前备份,文件由此加入追踪) | | 2 | — | `A` | `A`(磁盘与上一份备份 `null` 不同) | | 3 | — | `A` | (无:与最近一份备份一致,本轮未编辑) | | 4 | `edit` | `A` → `B` | `A`(本次编辑之前的内容) | | 5 | `bash` 改写 | `B` → `C` | `B`(消息 5 到达时消息 4 的编辑已生效) | 磁盘当前是 `C`。回退结果:目标 1 → `null`,删除文件;目标 2 → `A`;目标 3 → 自己没有备份,向更晚找最早的一份是消息 4 的 `A`;目标 4、5 → 分别为 `A`、`B`。 ## 五、已知限制 两项限制的根因相同:**某一段状态没有被备份**。限制一导致**完全无法还原**,限制二导致**还原不充分**。 ### 5.1 限制一:没有进入追踪的历史 被写类工具处理过,是文件加入追踪集合的前提。在此之前,它经历的所有外部编辑都没有备份。若整个会话都没有处理过它,回退到任何目标都只能报告"没有可还原的改动";若后来处理过它(更常见),备份只从那一刻起存在: | 消息 | 回合内发生 | 文件内容 | 备份 | | :---: | --- | :---: | --- | | 1 | — | `v0` | (无:尚未追踪) | | 2 | 手动编辑 | `v0` → `v1` | (无) | | 3 | 手动编辑 | `v1` → `v2` | (无) | | 4 | `edit` 首次处理 | `v2` → `v3` | `v2`(写前备份,追踪从此刻开始) | | 5 | — | `v3` | `v3`(与 `v2` 不同) | 回退到消息 5 → `v3`;消息 4 → `v2`;而**回退到消息 1、2、3 同样得到 `v2`**——它们之后最早的备份就是消息 4 的。`v2` 确实是首次处理那一刻的内容,但这三条消息开始时分别是 `v0`、`v0`、`v1`,插件从未见过。 ### 5.2 限制二:还原不充分 "不变不存"、"超量不存"、关闭备份,都会留下一段没有记录的状态。目标消息自己没有备份时,插件向更晚的消息找;一旦目标与那份备份之间存在**未被记录的改动**,取到的就不再是目标开始时的状态。 **表现 1——变更还没有归属到任何消息** | 消息 | 回合内发生 | 文件内容 | 备份 | | :---: | --- | :---: | --- | | 1 | `write` 新建 | `—` → `A` | `null` | | 2 | — | `A` | `A` | | 3 | — | `A` | (无:与最近一份备份一致) | | (之后) | 手动编辑 | `A` → `X` | (无:还没有消息认领) | 回退到消息 3:它自己及其之后都没有备份,插件不知道文件变过,于是**不做任何操作,文件停在 `X`**。要取回 `A`,回退到消息 2。 **表现 2——记下的是变更之后的状态** | 消息 | 回合内发生 | 文件内容 | 备份 | | :---: | --- | :---: | --- | | 1 | `write` 新建 | `—` → `A` | `null` | | 2 | — | `A` | `A` | | 3 | — | `A` | (无) | | 4 | 命令改成 `B` | `A` → `B` | (无:消息 4 到达时磁盘还是 `A`) | | 5 | — | `B` | `B`(与 `A` 不同) | 回退到消息 3 或 4 会取到消息 5 的 `B`——变更**之后**的状态,而它们开始时是 `A`。磁盘已经一致,插件不做操作;若此后文件又变过,就会把 `B` 写上去覆盖更新的内容。若消息 4 的回合是命令**删除**了文件,消息 5 的备份就是 `null`,回退到 3 或 4 会**删除当前文件**。 **表现 3——变更期间文件超过上限** | 消息 | 回合内发生 | 文件内容 | 备份 | | :---: | --- | :---: | --- | | 1 | `write` 新建 | `—` → `A` | `null` | | 2 | 扩充超过上限 | `A` → `G1` | `A` | | 3 | 工具或外部修改 | `G1` → `G2` | (无:超过上限) | | 4 | 缩回 `B` | `G2` → `B` | (无:到达时仍超限) | | 5 | `edit` | `B` → `X` | `B`(重新追踪) | 超限的那一段是**缺口**。回退到消息 3、4 会取到消息 5 的 `B` 并写入;"大"的状态永远无法还原。 **表现 4——备份关闭后又开启** | 消息 | 回合内发生 | 文件内容 | 备份 | | :---: | --- | :---: | --- | | 1 | `write` 新建 | `—` → `A` | `null` | | 2 | `edit` | `A` → `B` | (无:备份关闭) | | 3 | `edit` | `B` → `C` | `B`(重新开启) | 关闭期间的变化不在记录里,跨该时段的回退可能不充分:消息 2 之后最早的备份是消息 3 的 `B`。 ## 六、上限与失败处理 | 边界 / 失败 | 行为 | | --- | --- | | 单文件上限 | 默认 8 MiB(`maxFileBytes`,`0` 表示不限)。超限文件既不暂存也不复查;磁盘上已超限的文件在还原时被拒绝,并说明原因(该路径未还原,已超过备份上限) | | 每会话组数 | 保留最新 **100** 个消息组,更早的整组清理;未终结的还原日志仍引用的组会被钉住,直到该操作有结果 | | 快照根 | 默认 `/rewind-snapshots/`(配置 `snapshotDir` 优先;`config.dshHome` > `$DSH_HOME` > `~/.dsh`) | | 备份已关闭 | 不再产生新记录;结果会注明「快照备份已关闭,还原可能不充分」 | | 存储格式更新 | 整个操作失败关闭:不还原、不清除、工作区不变;对话回退本身仍然成功 | | 单文件失败 | 整趟不中断:失败按路径报告,其余路径照常还原 | | 还原中途崩溃 | 任何改动之前先写日志与还原前(rescue)状态;重启后可以继续或整趟回滚,已执行部分以磁盘为准 | | 父目录无法验证 | 跳过并报告,绝不写入无法验证的位置 | ## 七、为什么接受这些限制 消除**限制一**要求插件知道任意命令或外部编辑动了哪些文件。两条路都不通:让所有外部写入主动申报(`sed`、构建脚本、编辑器都不会),或者全量追踪工作区、每次改动前做整树快照——体量极大,而工作区级的精确历史正是 Git 的职责。本插件只做面向 Agent 工具的轻量回退。 消除**限制二**必须放弃"不变不存"与"超量不存":每个消息边界都要记录**每个**被追踪文件,大文件会被反复完整复制。代价是存储随对话线性增长,且每次备份都要完整读写文件,大文件会打满磁盘 IO——单文件上限正是为此而设。两项限制换来的是轻量、可预测,以及**只写它确实备份过的内容、绝不做猜测**。需要工作区级精确历史时,请使用 Git。 ## 相关 [SECURITY.md](../../SECURITY.md) · [快照自动清理](../snapshot-auto-cleanup.zh.md) · [兼容性审计](audit.md) · [快照格式](../format.md)