为 DeepSeek Harness(DSH)Web 端提供一个 TRAE 式「回退到本轮对话发起前」的插件:按轮次建立检查点,一键把【工作区文件】和【模型上下文】同时回退到某一轮发起之前,保持同一会话 id。
是什么
dsh-rollback 忠实实现了 TRAE 回退功能的核心设计思想——对话状态与文件状态对齐回滚:
- 模型行为由「对话历史」和「工作区文件」共同决定,因此回退必须同时回滚两者,否则会出现幻觉延续或状态冲突。
- 回退 = 恢复该轮及之后触碰过的文件(修改→写回原内容,新建→删除)+ 原位截断对话历史(同一 session id,模型不再看到被截断的内容)。
功能特性
| 能力 | 说明 |
|---|
| 按轮次检查点 | 每轮发起前建立检查点,只记录该轮实际触碰的文件(Copy-before-Write 前置内容),非全量快照 |
| 10 轮滑动窗口 | 借鉴 TRAE「仅最近 10 轮」,超出窗口的检查点被丢弃 |
| 文件回退 | 修改过的文件写回本轮前内容;已被删除的文件放回来;本轮新建的文件被删除;无法恢复的文件单独报告跳过 |
| 原位截断 | 用 user/message 承载的表层 replace(内置 /compact 同款官方原语)就地替换模型上下文,回退当场即生效,保持同一 session id |
| 两种触发入口 | 人工命令 /rollback、Web 端每轮结束后的「回退」按钮(正常轮次在回复的动作条上;被中断的轮次在轮次页脚) |
| 受影响文件列表 | Web 按钮弹出对话框,列出本轮及之后受影响文件及动作(恢复/找回/删除/跳过),点击文件可在编辑器打开 |
| 运行中禁止回退 | 只要有一轮还在跑就整体拒绝,必须等它结束或暂停(运行中的轮次本来也不会出现按钮) |
快速上手
安装
dsh plugin --profile web add @domitor-syh/dsh-rollback
然后重启 dsh web。从源码运行 DSH 时:
pnpm dsh plugin --profile web add @domitor-syh/dsh-rollback
使用
- Web 按钮:每条已完成 AI 回复下方、与「赞/踩」并排的动作条里出现 ↩「回退」按钮 → 弹出受影响文件列表 → 确认回退。
- 人工命令:输入框键入
/rollback list — 列出可回退到的轮次
/rollback preview <n> — 预览回退到第 n 轮前会影响的文件(不执行)
/rollback <n> — 回退到第 n 轮发起之前
- 不提供模型工具:模型自己调用回退必然发生在"某一轮进行中",而运行中一律禁止回退,所以这个工具无法成立,已移除(回退始终由人发起)。
界面预览
-
回退按钮:每轮结束后出现——正常轮次在回复下方动作条里(与「赞/踩」并排);被中断的轮次在轮次页脚(那一轮没有收尾回复,动作条上不可能有按钮)。运行中按钮保留但置灰。
回退按钮
-
回退弹窗与文件修改提示:点击 ↩ 后弹出确认框,逐条列出受影响文件及其动作——修改过的写回原内容(恢复)、被删掉的放回来(找回)、新建的删除(删除)、无法恢复的标「跳过」。
回退弹窗与文件修改提示
-
被回退的消息从对话流中隐藏:确认后被回退的消息立刻隐藏(界面上不渲染分隔线);被回退那一轮的用户文本 / 图片自动回到输入框,方便接着改。
-
回退首条消息的界面:回退到第一条消息之前时,对话区显示「已回退到对话发起前」欢迎页。
回退首条消息的界面
架构
| 文件 | 职责 |
|---|
src/core/ | 纯逻辑(无 DSH 依赖):检查点模型、捕获合并、回退规划、截断规划、滑动窗口、会话折叠,全部单测覆盖 |
src/service.ts | Host 侧执行:tools/result 捕获写/改的前置内容 + session/event 折叠轮次;执行恢复/删除/截断 |
src/index.ts | 插件体(Host 半侧):注册 rollback 工具与 /rollback 命令 |
src/client/index.ts | 浏览器半侧:官方 assistant-actions 槽的「回退」按钮 + 受影响文件对话框 + 本地化,经已出厂 commands Remote 触发宿主 |
关键实现点:
- 前置内容捕获:
write/edit 的执行结果里已带 before/after,用 ctx.on('tools/result') 取完整前置内容;str_replace_editor 的结果只有渲染文本,改由 tools/pre-execute 在调用前预读目标。
- 盘根写入兜底:Windows 上文件工具无法操作盘符根目录正下方的文件——文件系统层写前会先
mkdir 父目录,而 dirname('E:\\file.txt') 是带尾分隔符的 E:\,Windows 对卷根 mkdir 返回 EPERM。插件包装 ctx.fs.writeText 与 ctx.fs.editText:仅当原路抛出这一精确形状的错误时,改用「同目录临时文件 + rename」落盘(不做 mkdir 预检)。edit 分支还逐字复刻了字面匹配语义(FS_EDIT_NOT_FOUND / FS_AMBIGUOUS_EDIT 的判定与文案)并保留原文件的行尾风格与权限位;只包装后端实际实现了的方法。其余错误、以及策略不允许的路径(fail closed)一律按原样抛出。文件系统层若不再预建目录,该分支自动失效。
- 空目录清理:回退删掉它创建的文件后,把「已被清空、且创建时间落在被回退时间段内」的祖先目录一并删除(最深优先;把本次即将删除的子目录视为已不存在,所以整条新目录链会一起清掉)。创建时间用于区分两种情况:目录在第 3 轮创建、文件在第 5 轮创建时——回退到第 5 轮之前只删文件、保留目录,回退到第 3 轮之前两者都删。创建时间不可得、目录不可读、或仍有内容时一律保留(fail closed)。
- 边界重扫 + 最后已知内容:捕获只看得到文件工具,所以插件在每一轮结束时(以及每条用户消息到来时)复查它监视过的那些路径(状态指纹不变就只 stat、不读),把「被 shell 改写」或「被 shell 删除」的变化记到该轮上——模型删完、这一轮一结束就已经记录,不需要你再多发一条消息;回退到该轮之前即可恢复(回退在规划前会等待正在进行的扫描,避免"刚结束就回退"的竞态)。判定是 fail-closed 的:内容从未被读过就消失的文件不记录(记了也只会在恢复阶段被标为无法恢复,而任何一个无法恢复的文件都会中止整次回退),只报一次警告;超过 8MiB 的文件不再监视而不是假装能恢复;回退成功后注册表只清"内容记忆"、保留路径继续监视(否则回退会悄悄丢掉覆盖)。
- 写入偏好提示:向模型注入一条常驻运行说明——只有被
write/edit 触碰过的文件才进入回退跟踪,改内容请用这两个工具而不是 shell。
- 原位截断:对当前
session.surface.nodes 中「第 n 轮及之后」的连续节点,append 一条 user/message 表层 replace(surfaceOp: { op:'replace', start, end } + sourceEventSeqs 覆盖全部被遮蔽节点),就地替换这段历史;会话 id 不变。
- 标记在
/rollback 执行时当场写入日志,被回退区间随即从模型历史中消失。
- 标记内容是一段自动生成的检查点说明,并指示模型不要提及它;再次回退到同一点时,新标记的替换范围覆盖旧标记,只保留一条。
- 界面隐藏:客户端按标记的替换起点,把被回退区间内的聊天座位隐藏(
display:none);隐藏由日志里的持久标记驱动,刷新/重启后保持。
- 回退标签分四类:
恢复(文件还在,写回旧内容 · 绿)、找回(文件已被删除,重新放回 · 蓝)、删除(撤销该区间新建的文件 · 红)、跳过(无法恢复)。前三者的区别由记录里的 kind 决定(updated / removed / ),所以"写回内容"和"把文件找回来"在数据上就是两件事。空列表显示"无文件变更"——插件看不见 shell 直接写出的文件,这种话它保证不了。
已知限制(Known Limitations)
- 无法恢复的文件会被跳过,而不是中止整次回退:某个文件恢复不了(前置内容从未记录,或文件系统拒绝:被占用、无权限、沙箱不允许恢复工作区外的路径)时,插件照做能做的、对话照样截断,并在结果里列出跳过项。这是刻意的取舍——之前"任何文件失败就整次中止"会导致:文件已部分恢复、对话没截断、而卡住的原因往往不会自己消失 → 反复失败 → 用户被彻底卡住。跳过项在确认前的预览里就标着「跳过」,所以是知情选择;而且被跳过路径的记录会保留,下次回退会再试一次(文件锁释放后就能恢复)✓
- 超出保留范围的回退目标会被拒绝:检查点是 10 轮滑动窗口,更早的状态无法重建。GUI 里超出范围的按钮置灰(不额外加提示——置灰本身就是说明),命令返回报错并给出可回退范围——此前它会静默按最旧的保留记录恢复(错的状态、无提示)✗
- 聊天轨迹仍保留已回退的消息:DSH 的聊天轨迹按 append-origin 事件渲染,而日志本身 append-only、无法改写;表层
replace 只作用于模型上下文。插件在界面层把被回退区间隐藏(由日志里的持久标记驱动,刷新/重启后保持)。
- 模型会读到一行检查点文字:轮次之间写不出"模型不可见"的标记(那需要一个已开启的 step),所以模型会读到那段检查点说明,约 34 token(136 字符;压缩前是 331 字符 / 约 83 token)。这段文字会一直留在模型上下文里直到本次会话结束(只有下一次回退会替换它),所以每个后续请求都要付一次——因此措辞写到最紧,同时保住四件事:① 它是机器生成的、不是用户说的;② 此后的消息已移除;③ 工作区文件同时被还原;④ 从剩下的内容继续、不要在意也不要提及这个检查点。
tests/truncation-plan.test.ts 用一条字符预算断言钉住它,防止以后又长回去。
- 检查点为进程内存态 + 20 轮 sidecar:折叠状态随会话对象存于内存(
WeakMap);重启后由 sidecar(storages/dsh-rollback/checkpoints-v2/)重建,保留最近 20 轮(KEEP_TURNS),更早的记录在加载时被剪枝。
- 新建文件删除与空目录清理走本地文件系统:文件系统抽象层没有删除原语,删除通过
processPath + Node unlink、空目录清理通过 Node rmdir 完成,仅对本地后端可靠。
- 回退不可撤销:执行即替换历史,不提供 redo 链。
- shell 新建的文件、以及从未被文件工具碰过的文件不在回退范围:插件只从
write/edit 的结果里捕获改动,并在消息边界复查这些路径——所以「shell 改写了/删除了已登记文件」(含工作区外,如 E:\、桌面)可以回退;而「shell 新建一个全新文件」或「改动一个从未被工具碰过的文件」无法回退(后者连它存在过都不知道)。插件会注入提示引导模型改走文件工具,但无法强制。
开发
pnpm install # 安装依赖(prepare 会先构建一次)
pnpm build # 从 src/ 产出 lib/index.js、lib/invariant.js、lib/client.js
pnpm test # 运行核心逻辑(src/core/)单测
pnpm typecheck # tsc 检查 core + tests;scripts/typecheck-host.mjs 再检查
# src/service.ts、src/index.ts、src/client/index.ts
# (这三个文件 import 的 @deepseek-ai/* 不在本仓库安装,
# 故只忽略这些包造成的 TS2307/TS7006/TS7016,
# 其余一律视为真错误——含 TS2304「未定义标识符」)
pnpm deploy:profile # 打包并部署到 DSH profile(默认 web),随后需重启 DSH
⚠️ 改了代码必须 pnpm deploy:profile:DSH 不是从本仓库加载插件,而是从 profile 的
node_modules 加载 file: tarball 解出来的副本(见 scripts/deploy-profile.mjs 的说明)。
只跑 pnpm build 改的是本仓库的 lib/,运行时毫无变化。脚本会打包、更新 profile 引用的
tarball、覆盖已解出的副本并校验字节一致,最后提醒重启 DSH 与刷新页面——而重启必须由你手动做:
宿主半侧在启动时载入,脚本无法替正在运行的进程换代码。
许可证
MIT