撤销与信任

这个项目的成败不在"能不能操作电脑",而在"操作完别人敢不敢再用"。撤销是这件事的地基。

撤销的语义

撤销不是"反向执行一遍差不多的事",而是精确恢复到任务开始前的状态。

失败即回滚

执行过程中任意一步失败,内核会:

  1. 停止后续步骤;
  2. 先撤销失败那一步自己可能已经产生的副作用(写完文件才断言失败,文件必须删掉);
  3. 再把已成功的步骤按逆序逐个尝试撤销,个别撤销失败后继续处理其它步骤;
  4. 在审计日志里仅把成功撤销的步骤标成 rolled-back;
  5. 返回执行失败原因和恢复过程中的失败原因。

第 2 步是 v0.1 修掉的一个真 bug:最初只回滚"之前成功的步骤",于是"写完文件、断言失败"会留下一个孤儿文件,还会连带让上层目录删不掉(ENOTEMPTY)。

所需撤销操作全部成功时,任务会回到起点。已确认的不可逆动作、缺失撤销记录或撤销失败,都可能留下改动;报告必须如实呈现恢复不完整,不能谎报成功。

想亲眼看这个过程:

node bin/deskshell.ts story --fail

它会在第三步故意制造断言失败,然后展示:

✓ 1-write-a
✓ 2-mkdir-sub
✗ 3-write-b-断言必失败  后置断言失败:...
回滚 3 步(含失败动作自身的副作用);工作目录残留:0 项(空)

为什么撤销要放在内核里

如果撤销是每个 skill 自己实现的,就一定会有 skill 忘记写、写错、或者偷懒。

放在内核里有三个好处:

权限边界

审计日志

每次执行都会追加 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,主文件重新开始。

撤销覆盖率(实测)

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 就是这么测的;空输入等同于拒绝。

已知边界

当一步撤不回来

动作可以声明 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 仍然被禁止。

为什么是硬闸而不是警告:对唯一撤不回来的那类动作,"我把警告点掉了"不构成同意。