fix(update): 重启交还 guard、OTA 支持 release/包两种来源、修三处真 bug

## 一、重启不再由 update 自己 systemctl(用户口径:应该通知 guard)

原 internal/update 直接 `systemctl restart homeagent`。三个问题:

① 杀掉整个 cgroup(unit 里 KillMode=control-group)⇒ **guard 跟着死**,
   而 guard 存在的意义就是不让自己死。
② 丢失 exit 42 语义:guard 的 failures/failbackRound/failbackDone 全丢。
③ 自毁竞态:发命令的进程正是被重启杀掉的进程 ⇒ CombinedOutput 可能报错,
   **一次成功的升级被误判失败而回滚**。

现在 internal/update 只到"产物就绪":预检 → 备份 → 写 pending → 原子替换。
重启由调用方走**既有**通道(restartCh → exit 42 → guard 用新二进制重拉):

· webui      h.kernelCtl.RequestKernelRestart(本来就有,且是白名单内)
· pluginmgr  p.sdk.KernelControl().RequestKernelRestart(见下面第三条)
· CLI        外部管理命令,restart unit 后按**版本自报**核对是否真生效

## 二、OTA 支持两种来源(release 自动更新 / 指定安装包)

新增 internal/update/source.go:

	Source{Kind: FromPackage, Path}  → 直接用本地包
	Source{Kind: FromRelease}        → 查最新 Release → 下载 → 校验 digest

ParseSource(pkg, release) 统一判空规则(pkg 优先),避免三端各写一套。
两条来源解析后进**同一条** Apply,不存在两份升级实现。

## 三、修三处已提交代码里的真 bug

① **HEAD 编译不过**:d17e261 提交了 kernelmod/update/mod.go(引用 ks.Update),
   但 kernelboot/kernelstate.go 的字段改动从未提交。干净 worktree 实测
   `ks.Update undefined` —— 推上去的仓库是坏的。本次补上。

② **kernelDataDir 算错一级**:Dir(Dir("/home/newqqagent/plugins")) = "/home",
   而第 0 层 update 模块从 ks.DataDir=/home/newqqagent 读 pending
   ⇒ **agent 发起的升级一旦启动失败就永不回滚**(静默失效)。改为 Dir 一次。

③ **pluginmgr 的 KernelControl 恒为 nil**:它不在 kernelControlAllowed 里,
   于是 `if kc := p.sdk.KernelControl(); kc != nil` 永不成立 ⇒
   换完二进制**从不重启** ⇒ pending 留着 ⇒ 下次重启被判"未确认"而回滚。

   修法**不是**把 pluginmgr 加进完整白名单 —— 那等于让 LLM 能关机整个服务
   (kernel_control_gate_test 明确拦这个)。而是拆出**仅重启**授权:

	kernelControlAllowed  = {webui}       完整(含关机)
	kernelRestartAllowed  = {pluginmgr}   仅重启(shutdown 直接报错)

   两者的危险程度差一个量级:重启几秒后 guard 自己拉回来,关机不会自己回来。

## 四、EventBus:插件请求更新不必 import internal/update

新增 events.EventUpdateRequest,pluginmgr 订阅并受理:
scope=kernel → kernelUpdateApply;scope=plugin → pluginUpdate(apply)。

它在 internal/plugins/webui 的 SSE 守卫里显式登记为**内核内部控制事件**
(不该推给浏览器:前端要的是 update_progress/update_result,不是"谁发了请求")。

## 五、顺手修掉的静默失败

· webui /kernel/update 原实现:**先**写 202 Accepted,**再**解析 body,
  之后每个错误都直接 `return` —— 用户看到"已受理"然后什么都没发生。
  改为同步 Apply(失败可如实报 HTTP 状态),成功后才请求重启。
· pluginmgr 拿不到控制通道时不再假装成功,而是回报 applied=true + 明确原因
  (否则用户以为已升级,进程却还在跑旧镜像)。
· update 事件 publishResult 的 errMsg 原先是死代码(`_ = errMsg`),
  按 payload 约定写回 error 字段。
This commit is contained in:
JianFeeeee
2026-10-26 13:45:54 +08:00
parent 6dc87965ad
commit 817f6c59ae
12 changed files with 557 additions and 200 deletions

View File

@ -21,9 +21,10 @@ import (
"sync"
"time"
"github.com/JianFeeeee/HologramAgent/internal/events"
"github.com/JianFeeeee/HologramAgent/internal/plugin"
"github.com/JianFeeeee/HologramAgent/internal/update"
sdk "github.com/JianFeeeee/HologramAgent/internal/sdk"
"github.com/JianFeeeee/HologramAgent/internal/update"
)
// platformBinary 按当前 OS/ARCH 选择正确的插件二进制文件名。
@ -97,6 +98,9 @@ type Plugin struct {
httpAddr string // 本实例的监听地址(默认 defaultHTTPAddr;来自 settings)
sdk *sdk.PluginSDK
pluginDir string
// unsubUpdateReq 注销 update_request 订阅(Stop 时调用)。
unsubUpdateReq func()
// maxPackageBytes 是单个插件包 **压缩后** 的上限(0 = 不限)。
//
// 为什么是配置项而不是常量:内嵌运行时(node+pi 之类)就要 ~100MB,
@ -495,12 +499,26 @@ func (p *Plugin) registerTools(s *sdk.PluginSDK) {
return p.pluginInfo(name)
})
// ── 订阅:其他插件的更新请求(EventBus,避免它们直接 import internal/update)──
//
// 为什么用事件而不是让插件调函数:
// · 请求方只说"我要更新",不必知道怎么拉源码/编译/替换/回滚
// · 外置插件根本 import 不到内核内部包
// · 请求与进度都在总线上,WebUI 能显示"谁请求 → 到哪步 → 结果如何"
//
// 注意:内核升级的**重启**仍走既有 KernelControlAPI(restartCh → exit 42
// → guard 重拉),本订阅只负责"受理请求 + 执行替换"。
if bus := s.EventBus(); bus != nil {
p.unsubUpdateReq = bus.Subscribe(events.EventUpdateRequest, p.onUpdateRequest)
}
s.RegisterTool("kernel_update", sdk.ToolDef{
Name: "kernel_update",
Description: "检查/升级**内核**(homed 二进制)。两阶段:默认(apply=false)只查 GitHub Release " +
"与本地版本比较,不改动任何东西;看过结果后传 apply=true + pkg(本机已存在的安装包路径)" +
"真正升级:校验 → 备份 → 原子替换 → 重启 → 版本复核 → 失败自动回滚。" +
"升级会让内核重启,连接断开数秒。从 URL 下载新包请用 CLI 的 homed-upgrade。",
"与本地版本比较,不改动任何东西;看过结果后传 apply=true,并给出包来源二选一:" +
"pkg=本机安装包路径,或 release=true 从最新 GitHub Release 自动下载。" +
"升级流程:校验 → 备份 → 原子替换 → 由 guard 重拉 → 新内核启动时确认,失败自动回滚。" +
"升级会让内核重启,连接断开数秒。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
@ -510,7 +528,11 @@ func (p *Plugin) registerTools(s *sdk.PluginSDK) {
},
"pkg": map[string]interface{}{
"type": "string",
"description": "安装包在本机的路径(.tar.gz)。仅 apply=true 时需要",
"description": "安装包在本机的路径(.tar.gz)。与 release 二选一;给了 pkg 就优先用它",
},
"release": map[string]interface{}{
"type": "boolean",
"description": "true=从仓库最新 GitHub Release 自动下载安装包。未给 pkg 时可用",
},
},
},
@ -519,8 +541,9 @@ func (p *Plugin) registerTools(s *sdk.PluginSDK) {
}, func(args map[string]interface{}) (interface{}, error) {
apply, _ := args["apply"].(bool)
pkgPath, _ := args["pkg"].(string)
release, _ := args["release"].(bool)
if apply {
return p.kernelUpdateApply(pkgPath)
return p.kernelUpdateApply(pkgPath, release)
}
return p.kernelUpdateCheck()
})
@ -732,7 +755,6 @@ func (p *Plugin) pluginUpdate(apply bool, scope string) (interface{}, error) {
}, nil
}
// ======== HTTP API ========
func (p *Plugin) startHTTPServer() {