R0 —— 失败后副作用归零率

所有 computer-use 项目都在报成功率,没有人报"失败之后留下了什么"。R0 就是第二个数。

node scripts/recovery-bench.ts          # 表
node scripts/recovery-bench.ts --json   # 同一组数,机器可读

五个场景(写入自动创建的目录、显式嵌套写入、覆盖+移动、按类型归档、移动到自动创建的目录)各跑两遍:

  1. 正常路径 + undo —— 目录必须逐字节回到原样;
  2. 失败注入:对每一步 k 都注入一次失败 —— 内核必须回滚干净,什么都不留。

量的是什么

| 指标 | 定义 | | --- | --- | | R0-残渣 | 注入失败后,目录是否与执行前逐字节一致 | | R0-回滚 | 失败前已执行的步骤是否全部被撤销 | | R0-可追溯 | 适配器是否报告该失败任务至少有一行日志 |

当前可追溯分数只检查日志存在性,尚未逐条核对步骤标识和字段。合成场景通过后置断言注入失败,不能据此保证任意文件系统错误、进程崩溃或撤销失败都能恢复。

当前结果

R0-残渣     100.0%(17 次失败注入)
R0-回滚     100.0%
R0-可追溯   100.0%
正常路径撤销后逐字节还原:5/5

这个指标已经抓到了什么

新增的 write-into-new-dir 场景覆盖了一个缺口:fs.restore 删除新写入的文件,却留下当步自动创建的父目录。scripts/write-recovery-test.ts 还会验证预先存在的目录和其他程序添加的文件得到保留。

第一次跑出来 R0-可追溯 = 76.9%。原因:某一步失败时,内核对日志文件写了 failed 记录,但 RunReport.journal 只收集成功的条目,而且报告里根本没有 taskId——失败的那一单事后无法被指认。两处都已修(RunReport.taskId、失败条目入账),现在是 100%。

一个从不发现问题的指标只是装饰。这个指标上线第一天就在审计链上找到个洞。

用同一套夹具量别的工具

发布 R0 的意义就在于别人能跑。任何工具只要实现三个方法就能接进来:

// scripts/my-tool-adapter.ts
import type { R0Adapter } from "./recovery-bench.ts";

export function createAdapter(): R0Adapter {
  return {
    name: "my-tool",
    async apply(root, steps, failAt) {
      // 真实执行 1..failAt-1,在 failAt 停手,返回计数
      return { executed: failAt - 1, rolledBack: /* 你撤回了多少 */ 0 };
    },
    async undo(root) { /* 撤销最近一次 */ },
    journalLines(root) { return null; },  // null = 没有审计日志
  };
}
node scripts/recovery-bench.ts --adapter scripts/my-tool-adapter.ts

证明这套基准真的会判人失败

一个永远都过的基准是广告,不是测量。scripts/r0-adapter-naive.ts 是一个"只写不回滚、也没有日志"的工具,用同一套夹具跑出来是:

R0-残渣     0.0%
R0-回滚     0.0%
R0-可追溯   0.0%
正常路径撤销后逐字节还原:0/5

进程退出码非零。scripts/r0-negative-test.ts 就是断言这件事(5/5)。所以当 deskshell 报 100% 时,它是由一套已经证明能把别人判失败的夹具量出来的。

UI 那一面:恢复与执行前拒绝

文件可以逐字节还原;窗口和控件以前不行——直到有了 ui.value。

scripts/recovery-bench-ui.ts 用同一套注入矩阵跑一个 mock 窗口,量的是状态(几何 + 控件值 + 可用性):

| 场景 | 失败注入点 | 状态归零 | 已撤销/已执行 |
| move-window      | 1 | ✅ | 0/0 |
| move-window      | 2 | ✅ | 1/1 |
| type-into-field  | 1 | ✅ | 0/0 |
| type-into-field  | 2 | ✅ | 1/1 |
| invoke-button    | 1 | ✅ | 0/0 |

R0-UI 状态归零   100.0%(5 次注入)
R0-UI 回滚       100.0%
正常路径撤销后一致:2/3

改了什么: 协议新增撤销种类 ui.value——把前值写回去(null 表示"这个控件本来没有值")。ui.type 现在声明它,所以打进控件的字可以撤回。驱动需要实现可选的 setValue 原语;Windows 实现了(ValuePattern,不支持时退回"聚焦 + 全选 + 粘贴"),Linux 的窗口级驱动没有,并且会明说。

失败注入的 100% 包含了在执行前拒绝不可逆的按钮动作。拒绝使初始状态保持不变,并不代表按下按钮后能够撤销。明确允许执行后,该按钮动作无法恢复,所以正常路径撤销仍为 2/3。

这条顺带暴露两个坑:

边界