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

View File

@ -40,4 +40,5 @@ import (
_ "github.com/JianFeeeee/HologramAgent/internal/kernelmod/supervisor"
_ "github.com/JianFeeeee/HologramAgent/internal/kernelmod/textmem"
_ "github.com/JianFeeeee/HologramAgent/internal/kernelmod/tracker"
_ "github.com/JianFeeeee/HologramAgent/internal/kernelmod/update"
)

View File

@ -39,6 +39,9 @@ import (
// knowledge-migrated)。反向也要防:有人误把内部标记放进 SDK 当对外契约。
var internalOnlyFacilities = map[string]bool{
"knowledge-migrated": true,
// kernel.version 同理:它是 update 模块的启动时序事实("版本自检与
// 升级回滚判定已完成"),消费者是内核模块,不是插件契约。
"kernel.version": true,
}
// extractFacilities 从 Go 源码里提取 `FacilityXxx = "值"` 形式的常量。

View File

@ -175,9 +175,24 @@ func TestRunRejectsLayerOrderViolation(t *testing.T) {
}
}
// ③ 违规发生在装配之前:只跑完了 L0(config / kernel-api)。
if strings.Join(dnr.Produced, ",") != kernelmod.FacConfig+","+kernelmod.FacKernelIO {
t.Errorf("★ 守卫失败时\"已产出事实\"应为 L0 的两个设施,实际 %v", dnr.Produced)
// ③ 违规发生在装配之前:只跑完了 L0。
//
// ★ L0 现在是三个:config / kernel-api / update。update 模块 Requires
// 为空(它肩负"启动失败回滚上一次升级",必须早于所有模块),所以
// 天然落在 L0。这里断言**集合**而非顺序 —— 注册表本就是乱序的。
got := dnr.Produced
wantL0 := map[string]bool{
kernelmod.FacConfig: true,
kernelmod.FacKernelIO: true,
kernelmod.FacVersion: true,
}
if len(got) != len(wantL0) {
t.Errorf("★ 守卫失败时 L0 应产出 %d 个设施,实际 %v", len(wantL0), dnr.Produced)
}
for _, f := range got {
if !wantL0[f] {
t.Errorf("★ L0 出现了意外的设施 %q(实际产出 %v)", f, dnr.Produced)
}
}
if ks.LuaVM != nil {
t.Error("★ 守卫失败后 ks.LuaVM 非 nil —— 说明 lua 模块被跑过了,与\"装配前校验\"矛盾")

View File

@ -43,4 +43,14 @@ const (
// FacMigrated 标记"知识库目录迁移已完成"(用于让 store 排在迁移之后)。
FacMigrated = "knowledge-migrated"
// FacVersion 标记"内核版本自检与升级回滚判定已完成"(update 模块提供)。
//
// 语义:读到它时,ks.Update 里的版本已经是**回滚后的最终版本**。
// 需要按版本行事的模块(healthcheck 上报版本、pluginhost 判插件兼容性)
// 应 Requires 它,否则可能读到升级前那个已被回滚的版本。
//
// 为什么在内部标记段:它是内核自用的启动时序事实,不是插件可选的
// 外部契约(插件声明它只会立刻满足,没有意义)。
FacVersion = "kernel.version"
)

View File

@ -0,0 +1,98 @@
// Package update 是 update 模块的适配层:把 internal/update 的能力接进内核模块体系。
//
// ## 为什么分两层(与仓库其他模块同一惯例)
//
// internal/update/ 业务实现(版本比较、解包、替换、回滚、Release 查询)
// internal/kernelmod/update/ 适配层(读 ks → 调业务包 → 写回 ks)
//
// 参照 supervisor 模块:实现在 internal/supervisor,这里只做装配。
// 分开的理由:internal/update 要被 CLI / WebAPI / agent 工具三端调用,
// 它们都没有 KernelState;而模块体系只有内核启动一条路径。
//
// ## 它为什么必须在第 0 层
//
// 肩负「启动失败回滚上一次升级」的职责(详见 internal/update/rollback.go)。
// 该职责必须早于所有其他模块:若排在后面,升级后新内核在 memory/llm 阶段
// 崩了,而回滚还没跑 ⇒ 机器进崩溃循环,只能靠 guard 的 failback 兜底。
// failback 是最后手段,不该承担"换错二进制"这种已知可回滚的故障。
package update
import (
"log"
"path/filepath"
"github.com/JianFeeeee/HologramAgent/internal/kernelboot"
"github.com/JianFeeeee/HologramAgent/internal/kernelmod"
upd "github.com/JianFeeeee/HologramAgent/internal/update"
)
// FacilityVersion 是本模块提供的设施名。
//
// 语义:启动时的版本自检与回滚判定**已完成**。需要按版本行事的模块
// (healthcheck 上报版本、pluginhost 判插件兼容性)应 Requires 它,
// 以确保读到的是回滚后的最终版本,而不是升级前的那个。
const FacilityVersion = "kernel.version"
// Module 实现 kernelboot.Module。
type Module struct{}
func (Module) Name() string { return "update" }
func (Module) Requires() []string { return nil } // 第 0 层:不依赖任何人
func (Module) Provides() []string { return []string{FacilityVersion} }
// Init 执行版本自检,必要时回滚上一次升级。
func (Module) Init(ks *kernelboot.KernelState) error {
st := &upd.State{}
ks.Update = st
bin := upd.KernelBinaryPath()
st.BinaryPath = bin
// ① 自报版本(升级是否真换成功,只有二进制自己能答)
v, err := upd.ReadBinVersion(bin)
if err != nil {
// 读不出来不等于致命:可能是极老版本(无 --version)。
// 没有证据表明升级失败 ⇒ 不回滚,记录后继续。
log.Printf("[update] kernel version probe failed: %v", err)
st.ProbeErr = err
} else {
st.Version, st.Commit, st.SDKVersion = v.Version, v.Commit, v.SDKVersion
log.Printf("[update] kernel %s (commit %s, sdk %s)", v.Version, upd.ShortCommit(v.Commit), v.SDKVersion)
}
// ② 上一次升级的回滚判定
rolled, from, to, rerr := upd.RollbackIfUnconfirmed(ks.DataDir, bin)
if rerr != nil {
// 回滚失败必须响亮:继续装配等于用一个已知起不来的版本
return rerr
}
if rolled {
log.Printf("[update] ROLLED BACK kernel %s → %s(上一次升级未能确认启动)", to, from)
st.RolledBack = true
st.RolledFrom, st.RolledTo = from, to
// 回滚后重读版本:后续模块要看到**回滚后**的版本
if v2, err2 := upd.ReadBinVersion(bin); err2 == nil {
st.Version, st.Commit, st.SDKVersion = v2.Version, v2.Commit, v2.SDKVersion
}
} else {
// ③ 版本已确认能启动 ⇒ 清除待确认标记
//
// 只依赖"进程活着且执行到第 0 层",不依赖其他模块 ⇒ 不会误判。
if err := upd.ClearPendingUpgrade(ks.DataDir); err != nil {
// 清不掉不阻断启动:后果只是下次启动再判一次(幂等)
log.Printf("[update] clear pending marker: %v", err)
}
}
// ④ 依赖图路径(真正的 digest 校验在 kernelboot 装载期,那里失败会拒绝启动)
if dg := filepath.Join(filepath.Dir(bin), "depgraph.bin"); upd.FileExists(dg) {
st.DepGraphPath = dg
}
log.Printf("[update] subsystem ready (version=%s rolled_back=%v)", st.Version, st.RolledBack)
return nil
}
func init() { kernelmod.Register(Module{}) }
var _ kernelboot.Module = Module{}