feat(update): update 注册为内核标准模块(第 0 层)+ 启动失败自动回滚

## 为什么必须是第 0 层(用户口径)

update 肩负「启动失败回滚上一次升级」的职责,该职责必须**早于所有模块**。

若排在后面:升级后新内核在 memory/llm 阶段崩了,而回滚还没跑 ⇒ 机器进
崩溃循环,只能靠 guard 的 failback(受限启动)兜底。failback 是最后手段,
不该承担"换错二进制"这种**已知可回滚**的故障。

⇒ Requires() 为空 ⇒ 依赖图天然把它放 Layers[0]。

## 两段式升级确认(rollback.go)

	升级成功 ──→ 写 pending 标记("这个版本尚未被确认能启动")
	                ↓
	        重启 → 内核启动 → update 模块(第 0 层)读标记
	                ↓
	   ┌────────────┴────────────┐
	  标记还在                   标记已不在
	(上次升级没能活到清除)      (上次升级已确认成功)
	     ↓                          ↓
	  回滚到备份版本               正常继续 + 清标记

清除时机:模块跑完自检后立刻清。只依赖"进程活着且执行到第 0 层",
不依赖其他模块 ⇒ 不会误判。

**为什么不用 TTL / 重启次数**:那是 guard 已有的 failback,但判据是
"崩了几次",不是"这次升级该不该撤销"。用重启次数会在"新版本偶发崩溃"时
误回滚 —— 而那种情况该修的是崩溃,不是回退版本。

## 分层(与仓库其他模块同一惯例)

	internal/update/           业务实现(三端共用:CLI/WebAPI/agent 工具)
	internal/kernelmod/update/  适配层(读 ks → 调业务包 → 写回 ks)

参照 supervisor 模块:实现在 internal/supervisor,模块里只做装配。
分开的理由:internal/update 被三端调用,它们都没有 KernelState。

## 标记写入时机(一个真实的顺序坑)

MarkPendingUpgrade 在**替换二进制之前**调用。反过来(替换后才写)会在
"替换成功但没来得及写标记就崩了"时丢标记 ⇒ 下次启动不知道该回滚。

且写标记失败 ⇒ **放弃本次升级**(未替换任何文件):没有标记就没有回滚
能力,而不带回滚的升级等于赌运气。

## 回滚失败必须响亮

备份不存在时返回 error,调用方(模块 Init)必须中止启动 —— 继续装配等于
用一个已知起不来的版本。错误文案带备份路径与标记路径(删标记即可放弃回滚),
人能照着修。

## 判据

internal/update/rollback_test.go 10 条,2 条经变异验证:
· TestRollback_RestoresBackup —— 二进制+depgraph 同批回滚、标记清除、
  坏版本留档 homed-broken-*、版本轨迹记录
  变异:回滚后不清标记 ⇒ 红
· TestRollback_MissingBackupIsLoud —— 备份缺失时报错且可执行
  变异:静默返回 nil ⇒ 红
· TestPendingMarker_AbsentIsNotError —— 无标记返回 (nil,nil) 而非 error
  (否则每次启动都走"回滚失败"分支 ⇒ 内核永远起不来)
· TestPendingMarker_CorruptIsActionable —— 标记损坏时提示可删
· TestStateSummary_* —— 回滚信息进 healthcheck;nil 安全

## 顺带修正的既有判据

· runtime_dep_test.go:L0 设施集合从 2 个变 3 个(新增 kernel.version),
  改为断言**集合**而非顺序(注册表本就是乱序的)
· facilities.go:新增 FacVersion,并登记为 internalOnlyFacilities
  (它是启动时序事实,不是插件契约 —— facilities_sync_test 的守卫正确拦住了
  "把内部标记泄漏成对外契约")

三个 DoUpgrade 调用点补 dataDir 参数(标记落点必须与内核 --data 一致)。
This commit is contained in:
JianFeeeee
2026-10-26 02:08:29 +08:00
parent 475aac867b
commit d17e261b76
14 changed files with 681 additions and 13 deletions

63
internal/update/state.go Normal file
View File

@ -0,0 +1,63 @@
package update
import (
"os"
"path/filepath"
)
// State 是更新子系统的公共数据(挂在 KernelState.Update 上,由 update 模块填充)。
//
// 只放**别人要读**的东西:版本、是否回滚过、二进制路径。
// 更新实现(Check/Upgrade/DoUpgrade)是函数,不需要常驻状态。
//
// ★ 定义在 internal/update 而不是 kernelmod:三端(CLI/WebAPI/agent 工具)
// 都要用,而 KernelState 只有内核启动一条路径。模块通过
// kernelboot.UpdateStateProvider 接口把它暴露给内核,避免 import 循环。
type State struct {
BinaryPath string
DepGraphPath string
Version string
Commit string
SDKVersion string
ProbeErr error
// RolledBack 表示本次启动是否回滚了上一次升级。
RolledBack bool
RolledFrom string
RolledTo string
}
// Summary 返回给 healthcheck / 状态面的摘要。
func (s *State) Summary() map[string]interface{} {
if s == nil {
return nil
}
out := map[string]interface{}{
"version": s.Version,
"commit": ShortCommit(s.Commit),
"sdk_version": s.SDKVersion,
"binary": s.BinaryPath,
"rolled_back": s.RolledBack,
}
if s.ProbeErr != nil {
out["probe_error"] = s.ProbeErr.Error()
}
if s.RolledBack {
out["rolled_from"], out["rolled_to"] = s.RolledFrom, s.RolledTo
}
return out
}
// KernelBinaryPath 返回本进程的内核二进制路径。
//
// 用 os.Executable 而不是硬编码 /usr/local/bin/homed:部署路径可变
// (install.sh 支持 PREFIX),而"我自己是谁"永远可靠。
func KernelBinaryPath() string {
if exe, err := os.Executable(); err == nil {
if resolved, err2 := filepath.EvalSymlinks(exe); err2 == nil {
return resolved
}
return exe
}
return "homed"
}