交付与更新

同一个更新链路,我改了十一版设计

OTA 链路 11 个设计版本的演进轨迹:从后端签名→前端下载验证→容错恢复→故障注入,两个关键转折点。

2026-04-30 到 05-07,OTA 链路从 plan1 走到 plan11,每个版本都在刮之前的设计债。这条线的有意思的地方,不在单个方案本身,而在整体的两个转折:从"能更新"转向"更新失败也能恢复",再从"人工验证"转向"故障注入"。

起点:plan1-5 搭主干

Plan 1 是契约的交割。admin-panel 停止持有 OTA 私钥,改为接收 launcher 上报的"要更新哪个版本"后,运维丢一个 release-manifest 的 URL 过来,backend 返回 RolloutWrapper(不签名,只是策略提示)。真正的验证权移到客户端:launcher 拿到 unsigned wrapper 后,通过 trust waterfall 去验证 release-manifest 的签名。同时出炉离线签名 CLI wakou-ota-sign,私钥永不上线。

Plan 2 把 launcher 拔到启动链的信任锚点位置。之前 Start-Wakou.cmd 直接调 PowerShell 脚本启动 cache,launcher 是被 spawn 的后台进程。现在反过来:launcher.exe boot 成为启动链主角,它依次调 ps1 prepare cache、spawn panel、启动 guardian。同时集中化 WAKOU_UPDATE_STATE_BASE 环境变量,所有涉及 OTA 状态的地方都从这里取路径。

Plan 3 锁死 skills 的物理布局。把用户装的各种 skill 统一归到 <root>/skills/<engine>/ 下,用 NTFS junction 桥接到引擎的默认扫描路径。guardian 同步白名单从 5 条扩到 7 条,加上 skills\openclawskills\hermes。这看似是小改动,但为后续 OTA 主链"不动 skills"的假设打好地基。

Plan 4 把 USB 准入从 stub 变成真实。stage 0 启动时,launcher 通过 Win32 IOCTL 读当前 USB 硬件 serial,用编译期固化的 USB allowlist 公钥验签 USB 上的 .config/wakou-allowlist.json。新建离线签名 CLI wakou-usb-allowlist-sign,出厂烧录用。admission reject 直接 exit,stage 0 就杀掉整个启动链。

Plan 5 落地完整 OTA 主链。stage 6 所有者的 ota_check 从 stub 变成真实:调 trust waterfall 拿 release-manifest、HTTP Range resume 下载 zip、sha256+minisign 验证、extract 到本地、robocopy 到 USB、写 version.json 作为提交点。state_machine 从 Idle 推进到 Done,每一步都 fsync state.json。panel 读 env 判断有没有更新,用户点击后 launcher 拉起 --update-only 子命令。

这五个版本把基础设施搭好了:后端不签名、前端信任瀑布、launcher 当主角、skills 物理拓扑、USB 准入、主链路。能跑了

第一个转折:plan6 之后全是容错

Plan 6 是转折点。标题写的是"容错 saga + 测试补全",但实质是:Plan 5 主链有个致命漏洞——只写了 happy path。如果 download 失败、verify 失败、local apply 一半死、USB 拔掉,state_machine 就卡在中间状态。下次启动时,launcher 不知道这个中间态,stage 1-5 直接跑完,stage 6 又开始 OTA,结果两个 OTA 流同时写 state.json,状态爆炸。

Plan 6 把这条断裂线缝好:新增 7 个 resume cell,分别对应 Idle/Downloading/Verifying/LocalApplying/LocalApplied/UsbStaging/UsbSwapping/UsbCommitted/Cleanup 这 9 个 phase 中的各个断点。launcher 启动时先读 state.json,看 phase 是啥,直接跳到对应的 resume 分支。比如上次卡在 LocalApplying,这次来了直接从 resume_local_applying 开始,继续 extract、trash、install。

同时引入 trash corrupt 兜底:如果 trash manifest 校验失败(文件被删了或损坏),OTA 拒绝继续,返回 OtaStateCorrupt 退出码,让 boot 流程拒绝启动。需要修复的话,跑 launcher.exe --ota-repair 收尾。USB 拔盘也错误化了:设个 pending_replug flag,记录当时的 USB serial,等用户插回原 USB,resume 检查 serial 一致再继续;serial 不对直接 reject。

从 Plan 6 开始,OTA 设计的所有新增都围绕"能恢复"这个主题展开。Plan 7 加后台轮询和设置页,但核心还是——轮询过程中如果出错,不杀进程,error event 写进 ota-events.jsonl,等下次自动重试。Plan 8 加 zip bomb 防护和 trash v2,但归根结底是——compressed 能搞出多大炸弹?单 entry 4GB,多 entry 呢?trash 从 { paths: Vec } 升到 { entries: Vec<{ path, kind }> },让后续 verify 时能准确判断该删啥。

这一段的逻辑是层层堵漏:Plan 6 堵 state machine 中间态、trash 损坏、USB 拔盘;Plan 7 堵轮询失败、panel crash;Plan 8 堵 zip bomb、trash 格式模糊。

第二个转折:plan11 从快照到注入

Plan 6 写了 8 个 cell 测试来覆盖 7 个 resume 分支(还有个 happy path)。但这些 cell 怎么测的?用的是 fixture snapshot——往测试里丢一个特定的 state.json,再丢一个特定的文件树,然后跑 resume 流程,看结果对不对。好处是快,坏处是假——fixture 是死的,真实场景里 download 一半网络断、verify 的 zip 损坏、local apply sync 时 USB I/O error,这些真实的 panic 和 error 在快照里体现不出来。

Plan 11 把这个反过来:不是"先造故障状态再恢复",而是"正常跑链路,中途注入故障"。新建 test_inject 模块,一个全局 Mutex<Option<(Stage, Fault)>>。测试侧 arm(Stage::Download, Fault::Transient {…}),然后跑链路,链路在 download 过程中调 take_for(Stage::Download),取出注入的 fault,转成 anyhow error,? 上抛,整个链继续处理这个错误——resume state、retry、最后要么成功要么进 error 分支。

这样做的收获是什么?真实的错误路径。Plan 6 的 cell 1(download 失败)现在不是"丢个 stale state.json",而是"真的跑 http client,中途注入 connection reset,看 resume path 怎么处理"。io::ErrorKind 对应 Transient(可重试),业务逻辑错误对应 Fatal(不重试)。这两条路在链路里分得清清楚楚。

而且这套注入框架为 Plan 10 的类型化错误做好铺垫。Plan 10 说的是把全链 43 处 anyhow! / bail! / .context() 统一替换成 typed OtaChainError { Fatal, Transient },这样 error event log 就能从一律记"fatal"升级成"fatal 还是 transient"。Plan 11 的 Fault enum 早就是二分的了,Plan 10 只需给它加个 .to_chain_error() 方法,然后 6 处注入点从 .to_anyhow() 改成 .to_chain_error() 即可。

整个演变的思路是可观测→可测试→可恢复→可调试。快照测试只能验证"结果对不对";注入测试能验证"错误路径走得对不对";类型化错误让事后分析能从日志直接看出是暂时故障还是永久故障。

中间夹的一些扎实细节

plan7 之前的 plan1-6 是基建和容错的两层楼;plan7-11 的后半段在补可观测细节鲁棒性

Plan 7 加的 ota.lock 三模式协议(Exclusive / MirrorLease / PendingIntent)是为了防 OTA 和 guardian 同时写 state.json。guardian 跑 robocopy 前先 try_acquire(MirrorLease, timeout=3s),改死锁等待为超时快速失败。

Plan 8 不光加了 zip bomb 防护的 10GB 上限,还把 symlink reject 从隐式(File::create 默认不创建 symlink)变成显式(检查 unix_permissions bit 0o120000,命中就 bail OTA_ZIP_SYMLINK_REJECTED)。

Plan 9 的 poll-error 缓冲是从 events log 里抽出错误,放进 localStorage 的 50 条 FIFO,让用户在设置页看最近错了什么。从工程角度这是"让故障可见";从产品角度这是"用户能自救的信息"。

Plan 10 的 error catalog 用 25 个 const code 给每种错误分类。不是所有 context string 都一样对等,SHA256_MISMATCHCONNECTION_TIMEOUT 是两个完全不同的恢复策略。Plan 10 显式化了这种区分。

版本演进的两条线

贯穿始终的是架构演进鲁棒性演进两条线。

架构线:后端契约(plan1)→ launcher 启动链(plan2)→ skills 布局(plan3)→ USB 准入(plan4)→ 主链路(plan5)。这五步是"能做什么"。

鲁棒性线:happy path(plan5)→ resume 主链(plan6)→ 后台轮询+观测(plan7)→ 防护+分类(plan8)→ 错误可见(plan9)→ 错误类型(plan10)→ 故障注入(plan11)。这七步是"当出错时,系统怎么活下来"。

两条线的交点在 Plan 6。plan1-5 是"system is go";plan6-11 是"system is broken,now what"。

这十一个版本压缩到一周多的时间里完成,version bump 这么快的原因就是——每一版都在修上一版的漏。不是说上一版设计有缺陷,而是真实场景的复杂度在逐版暴露出来。plan5 写完以为能跑了,结果 plan6 一加 resume 就发现"噢,原来还要处理中间态"。plan6 一写测试就发现"我的 fixture 不够真实,需要注入"。这种自洽的迭代,在设计冻结、被迫后测的项目里是看不到的。

星野的头像

星野 XINGYE

一个人维护 AI 平台的工程师。这里记录 63 篇复盘:18 份故障档案、OTA、架构演进与工作流。