题记:更新程序最重要的能力,不是换掉自己,而是换坏以后仍有人能收场。

1. 启动成功,是一个太便宜的承诺⌗

一个 Agent 的工具宿主负责在机器上执行工具调用。给它加自动更新,很容易写出这样一条链:下载、校验、替换可执行文件、启动新进程、删除旧文件。

看起来每一步都成功了,Agent 却可能再也调不到工具。

原因不神秘:进程被操作系统创建,不代表宿主连回了网关;窗口出现,不代表执行工具的子进程正常;连接打开,也不代表应用协议完成握手。

已发生的事实际证明了什么没有证明什么
下载摘要匹配文件内容符合指定摘要文件能在当前环境运行
新进程启动创建进程这一步成功初始化能够完成
窗口出现UI 启动到了某个阶段工具宿主已经在线
WebSocket 打开传输连接已建立网关已接受这个宿主
网关 ready 握手完成宿主完成接入协议所有工具都正确、以后不会崩溃

这次改造把成功线从前几行移到了最后一行。更关键的是:新版本还没证明自己可用以前,恢复责任不能交给新版本。

下文机制来自落地版本的源码及架构文档,测试用于说明规格和覆盖范围,不代表生产故障率统计。文件路径均为源码仓库相对路径。

2. 让旧版本留下来,但换一份工作⌗

src/temp-host/main.ts 增加了独立的更新监督角色。它不是候选进程内部的一条定时器,而是从保留的旧可执行文件启动的另一个进程。

这一区别决定了它能处理哪类失败。候选程序如果根本无法启动,写在候选程序里的自救逻辑等于没有;旧版本启动的监督进程则不依赖候选程序完成初始化。

交接过程如下:

旧宿主
  │ 写入更新记录:来源摘要、目标摘要、截止时间
  │ 保留旧可执行文件,换入候选文件
  ▼
旧可执行文件启动独立 watchdog
  │ 将状态改为 watching
  │ 回应 armed
  ├──────────────────────► 旧窗口此时才允许退出
  │ 启动候选窗口及其工具宿主
  ▼
等待候选宿主完成网关 ready 握手
  ├─ 成功:ready → committed → 清理旧文件
  └─ 失败:停止候选 → 隔离目标摘要 → 恢复旧文件 → 重启旧版

armed 不是“启动命令已经发出”,而是监督者确认自己已接手。旧窗口等到这条回应才退出,避免把交接假设藏进进程启动的时序里。

监督入口还检查自身确实运行于目标文件对应的 .old 路径。候选窗口、工具宿主和 watchdog 虽然来自同一种程序角色设计,却不是同一个生命周期。

这不是发明了一种新的 watchdog。它的价值在于把成熟的监督模式用在了自更新最容易漏掉的地方:被替换者先把恢复职责交出去,再退场。

3. ready 不是 committed⌗

更新记录由 src/temp-host/update-recovery.ts 管理,包含来源与目标摘要、截止时间以及阶段。正常路径是:

pending → watching → ready → committed → 清除更新记录

为什么还要分 ready 和 committed?因为报告“我接入成功了”的主体,与负责结束监督的主体不是同一个。

候选宿主在网关握手完成、运行时状态变为 online 后,调用 confirmUpdateReady。这个函数核对目标摘要及截止时间,再写入 ready。watchdog 看到匹配的确认后,把它推进为 committed。

旧文件的清理只在提交后进行。候选不能刚报出 ready,就抢先删除监督者还在使用的旧程序。

这个细节在 Windows 上尤其具体:监督进程退出附近,旧映像仍可能被系统占用。清理失败时保留文件,由 UI 桥接 worker 后续重试,而不是把“已完成更新”和“旧文件已物理删除”强行绑定成同一瞬间。

另一个约束藏在 swapExecutable 中:如果上一次的旧文件还没有退役,下一轮更新直接拒绝替换,不覆盖它。否则一次尚未结束的更新,可能被下一次更新抹掉恢复依据。

本地准备窗口为 90 秒,来自 UPDATE_READY_MS;计时从替换前写入更新记录开始,而不是候选进程启动后才开始。它是这份实现选择的截止时间,不是所有宿主都适用的启动性能标准。

4. 回滚以后,谁来拦住“热心”的协调器?⌗

独立监督只解决了一半问题。

设想一个纯抽象场景:控制面要求所有宿主运行版本 B。某台机器更新 B 失败,恢复了 A。协调器下一轮检查发现它还是 A,于是再次推送 B。

安装 B → 失败 → 回滚 A → 发现不是 B → 安装 B → ……

每个局部动作都很合理,组合起来却是故障放大器。回滚修好了机器,协调器又忠实地把它改坏。

这次实现将失败目标的摘要写入本地隔离文件。它不是简单地延迟重试,而是记住:这个具体候选版本已经失败过,不能继续自动安装。

层次负责拦截的入口行为
宿主周期自检AutoUpdateCheck发现摘要已隔离,不请求安装
宿主更新处理器createUpdateHandler拒绝指向隔离摘要的更新请求
控制面协调器host-updates.ts将隔离目标或未验证成功返回的更新标为 manual

三处并不是同一条检查机械复制三遍。它们分别约束本地主动检查、外来更新请求,以及控制面持续调和目标状态的行为。

控制面还有自己的验收:观察宿主是否在线,并报告目标可执行文件摘要。默认验证窗口为四分钟。若没有看到预期返回,就进入人工处理,而不是假定“它大概还没装好,下次再推”。

这里必须区分两个结论:没有验证到成功,不等于已经证明二进制有缺陷;但足以停止自动重复安装。 网络问题也可能导致验证失败。manual 是自动化的停止状态,不是根因诊断书。

隔离与停止都按目标版本区分。新的摘要仍可进入更新流程;同一摘要要重试,则需要调查后解除本地隔离,并重置控制面对应的人工处理记录。

代价也明确:一次暂时性的连接问题,可能让同一版本失去自动重试机会。这份设计选择少做一次自动修复,也不让协调器在证据不足时不断重演替换。

5. 测试真正守住了哪些承诺⌗

src/temp-host/update-recovery.test.ts 没有只测“更新成功后版本号变了”。它把恢复协议拆成了可断言的性质:

测试情形对应承诺
普通启动时尝试清理不能提前删除旧可执行文件
错误摘要或到达截止时间的确认不能完成就绪确认
正确、及时的确认监督者先提交,随后才能清理旧版
启动抛错、UI 退出、host 退出、截止时间到恢复旧文件、记录失败摘要、重启旧版
已隔离版本再次被请求更新处理器拒绝,周期自检也不发起安装
旧文件仍待退役下一次替换不得覆盖它
armed 被拆成两段传输能正确拼接;提前 EOF 或无关输出不能当作接手成功

控制面的测试另行验证:重复协调同一隔离目标,只发起一次更新请求、只产生一次人工处理报告;目标摘要变化后,才允许新的尝试。接受更新后离线或仍停留在旧版的情形,也不会被静默重试。

这些是状态机与协调策略的证据。测试使用可注入的时钟、启动和停止函数,不能据此宣称已经证明所有 Windows 文件占用行为、真实进程树清理和异常断电路径都可靠。

能证明什么就写什么。单元测试不是生产现场的替身。

6. 这不是一张永久健康证⌗

这套机制有三条不能略过的边界。

第一,第一次升级到“带监督者的版本”,不自动获得监督保护。 发起升级的旧版如果还没有 watchdog,就不能指望新版本倒过来保护自己的启动过程。架构文档明确保留了这条引导边界:新版可以延迟清理旧文件,但不能承诺挽救一个根本启动不了的首次候选。那次跨越仍需要独立验证的升级路径。

第二,握手完成之后,没有持续试用期。 当前监督成功条件是网关 ready。它不执行完整工具验收,也不监督候选此后的长期稳定性。握手成功后工具实现有问题,或者稍后崩溃,都不能拿这套启动回滚协议来兜底。

第三,独立于候选,不等于独立于整台机器。 watchdog 仍依赖本机进程执行和文件系统。停止候选、恢复文件等操作本身也可能失败;这不是事务型安装系统,更不是断电恢复证明。

我认为这次改造真正向前走的一步,不是“更敢自动更新”,而是把自动化的承诺收到了证据能覆盖的位置:

  • 新进程启动,只是候选开始接受检查。
  • 网关握手完成,才达到这份协议的启动成功线。
  • 未通过时,由旧版本监督恢复。
  • 恢复以后,控制面必须尊重这次失败,不再机械地推回同一候选。

自动更新的闭环,不止是把旧版本换成新版本;还包括更新失败之后,系统知道下一次不该做什么。