撤销与信任
这个项目的成败不在"能不能操作电脑",而在"操作完别人敢不敢再用"。撤销是这件事的地基。
撤销的语义
撤销不是"反向执行一遍差不多的事",而是精确恢复到任务开始前的状态。
- 移动:移回日志记录的原路径;
- 新建目录:删除该目录(仅当为空);
- 写文件:如果文件原本不存在,删除;如果原本存在,写回原内容。清理该次写入自动创建的空父目录,保留原有目录与其它文件;
- 缺失/null
undo或irreversible: true:执行前需要显式确认,执行后无法恢复。fs.delete缺少撤销动作时始终被拒绝,即使调用方已经确认。
失败即回滚
执行过程中任意一步失败,内核会:
- 停止后续步骤;
- 先撤销失败那一步自己可能已经产生的副作用(写完文件才断言失败,文件必须删掉);
- 再把已成功的步骤按逆序逐个尝试撤销,个别撤销失败后继续处理其它步骤;
- 在审计日志里仅把成功撤销的步骤标成
rolled-back; - 返回执行失败原因和恢复过程中的失败原因。
第 2 步是 v0.1 修掉的一个真 bug:最初只回滚"之前成功的步骤",于是"写完文件、断言失败"会留下一个孤儿文件,还会连带让上层目录删不掉(ENOTEMPTY)。
所需撤销操作全部成功时,任务会回到起点。已确认的不可逆动作、缺失撤销记录或撤销失败,都可能留下改动;报告必须如实呈现恢复不完整,不能谎报成功。
想亲眼看这个过程:
node bin/deskshell.ts story --fail
它会在第三步故意制造断言失败,然后展示:
✓ 1-write-a
✓ 2-mkdir-sub
✗ 3-write-b-断言必失败 后置断言失败:...
回滚 3 步(含失败动作自身的副作用);工作目录残留:0 项(空)
为什么撤销要放在内核里
如果撤销是每个 skill 自己实现的,就一定会有 skill 忘记写、写错、或者偷懒。
放在内核里有三个好处:
- skill 作者不需要考虑回滚,只需要声明
undo; - 撤销逻辑只写一遍,所有 skill 共享;
- 审计日志天然记录了撤销所需的信息,不需要额外埋点。
权限边界
- 能力必须在 manifest 里声明,且必须落在作用域内;空声明不授权任何动作;
- 未授予的能力会导致整个任务被拒绝(不是跳过某一步);
- 用户的授权是"这次任务"级别的,不是永久授权。
审计日志
每次执行都会追加 JSONL:
{"ts":1793201985000,"taskId":"task-1793201984999","actionId":"move:a","kind":"fs.move","summary":"move a.txt → .../docs/a.txt","status":"ok","undo":{"kind":"fs.move","from":".../docs/a.txt","to":".../a.txt"}}
它是三类人的工具:用户看"它到底做了什么",开发者看"哪一步坏了",审计看"谁在什么时候动了什么"。
批量撤销
deskshell undo --task <taskId> # 撤一个任务
deskshell undo --last # 撤最近 1 个任务
deskshell undo --last 3 # 撤最近 3 个任务
deskshell undo --since 30m # 撤最近 30 分钟内开始的任务(30m / 2h / 1d)
批量模式从新到旧撤,每个任务打一行。某一步撤销失败(比如文件被改过)时,该任务保持未撤销、其它任务照常回滚,退出码非零。部分成功会如实报告,不会藏起来。
缺少撤销动作的条目会返回失败,不计入 rolledBack,日志保留原状态;旧日志中缺少 undo 的条目也遵守此规则。只有全部条目确实撤销后,重复撤销才返回零步。
文件在任务之后被改过
每个 fs.write 步骤都会记下写入内容的 sha256。撤销前拿它跟磁盘上的文件比对:
✗ write-note 文件在任务之后被修改,拒绝覆盖:…/note.txt(可用 deskshell undo --force 强制)
被拒绝的那一步不会在日志里标成 rolled-back——审计记录不谎报回滚。否则就会出现"用旧内容盖掉别人刚写的东西"这种静默数据丢失。
长期运行的日志
日志每步大约增长 400 字节(实测 1000 步 387 KB)。超过 5 MiB 时会改名成 journal-<时间>.jsonl,主文件重新开始。
load()会把所有轮转文件加当前文件一起读,并按时间排序;- 撤销只重写包含目标条目的那个文件,状态一致且不会产生重复行;
DESKSHELL_JOURNAL_MAX_BYTES可以改阈值(测试里用 600 字节)。
撤销覆盖率(实测)
scripts/undo-coverage-test.ts 先给目录拍快照(每个路径 + 每个文件的 sha256,排除 .deskshell/),执行 skill,再撤销,然后逐字节比对两次快照。当前结果:
PASS downloads-cleanup 10 步 · 撤销 10 步 · 逐字节一致
PASS photo-sort 13 步 · 撤销 13 步 · 逐字节一致
PASS log-archive 5 步 · 撤销 5 步 · 逐字节一致
PASS rename-batch 3 步 · 撤销 3 步 · 逐字节一致
PASS first-skill 1 步 · 撤销 1 步 · 逐字节一致
SKIP browser-tabs 需要真实浏览器窗口(GUI)
SKIP tile-windows 需要真实窗口(GUI)
GUI 类 skill 是带理由跳过并打印出来的,不会静默略过——假装通过的测试比没有测试更糟。
逐项确认能力
deskshell run <skill> --ask 在执行前逐条问:
授予能力 fs.read(读取下载目录)?[y/N] y
授予能力 fs.write(移动与写入)?[y/N] y
任何一条回 n,整个任务取消,退出码 2,而且没有任何副作用——不写日志、不动文件。管道输入也行(每行一个 y/n),CI 就是这么测的;空输入等同于拒绝。
已知边界
- 外部副作用无法撤销(例如已经发出的网络请求),缺失/null
undo需要显式确认,已确认的改动可能在后续失败时保留下来; - 当前文件与写入时记录的哈希不一致时,
fs.restore拒绝覆盖; - 跨卷移动在部分平台上不是原子操作,失败时依赖日志重试。
当一步撤不回来
动作可以声明 irreversible: true(按下的按钮、发出去的邮件),缺失/null undo 也会被归为不可逆。内核会在动手之前整单拒绝,除非你明确点头:
deskshell run submit-form --mode apply # → 退出码 2:这一步不可逆,需要显式确认
deskshell run submit-form --mode apply --ask # 交互:问一次,再执行
deskshell run submit-form --mode apply --allow-irreversible # CI:显式开关
preview 永远不需要确认——它是只读的。MCP 调用方则传 allowIrreversible: true。
确认只允许执行,不会让动作变得可撤销,也不会绕过其它策略。显式 irreversible: true 会阻止附带的 undo(包括 noop)被记为恢复操作。缺失/null undo 的 fs.delete 仍然被禁止。
为什么是硬闸而不是警告:对唯一撤不回来的那类动作,"我把警告点掉了"不构成同意。