feat(update): 更新进度走 EventBus + SSE(穿越进程重启的通知通道)

## 为什么必须走事件总线(用户口径:为什么不走 eventbus)

升级是**长耗时且会让进程消失**的动作。此前进度只往 log.Printf 写,
WebUI 要看得自己轮询 /kernel/update —— 而轮询在重启瞬间必然断线,
断线那一刻恰恰是用户最需要知道"升到哪一步/失败了/已回滚"的时刻。

EventBus 是内核既有的发布订阅机制(同步投递、handler panic 隔离),
用它不需要新造一条通知通道。

## 三个新事件类型

	update_check     检查结果(只读,不改动)
	update_progress  升级步骤:verify/backup/replace/restart/rollback/done/failed
	update_result    最终结果(含回滚轨迹)

## ★ 顺序约束:先发事件,再动作

升级尾部几步(replace → restart)会让进程消失。顺序一反,最后几步的
事件永远发不出去。所以每个 publish* 都紧贴在其描述的动作**之前**。

## 直接 import events,不搞接口垫层

第一版我写了 EventPublisher 接口 + map[string]interface{} payload 想
"避免依赖",那是自欺欺人:events 包**不依赖任何内部包**(已验证),
import 它不会成环;而垫层会把事件降级成无类型 map,丢掉编译期检查。
已回退该设计,改为直接传 *events.Bus。

## SDK 补 EventBus() getter

PluginSDK 原先只有 Publish/Subscribe,没有暴露总线对象。而长流程
(升级闭环)需要把总线本身传下去持续发事件 —— 只给 Publish 的话,
每个调用点都要手动转发一遍。

## SSE 通路(守卫判据逼出来的正确修法)

TestEveryPublishedEventHasAnOutwardPath 抓到三个新事件"有发布者但无对外
通路"——症状是「发了、没报错、客户端收不到」。修法不是加白名单豁免,
而是真的接进 SSE:三者都进 sseEventRing(不只是实时推送),
断线重连要能补到**最终结论**(update_result),否则用户重连后只看到
"开始升级"而没有结局。

## 调用点

· pluginmgr(kernel_update):p.sdk.EventBus() → DoUpgradeBus
· webui(/kernel/update):h.sdk.EventBus() → runKernelUpgrade(pkg, bus)
· CLI:bus=nil,退化成纯 printf(CLI 没有总线)
This commit is contained in:
JianFeeeee
2026-10-26 09:23:58 +08:00
parent d17e261b76
commit 5b759e80d7
8 changed files with 190 additions and 7 deletions

View File

@ -30,6 +30,7 @@ import (
"os"
"path/filepath"
"github.com/JianFeeeee/HologramAgent/internal/events"
"github.com/JianFeeeee/HologramAgent/internal/update"
)
@ -91,7 +92,13 @@ func (p *Plugin) kernelUpdateApply(pkgPath string) (interface{}, error) {
_ = kc.RequestKernelRestart("kernel_update apply")
}
go func() {
if err := update.DoUpgrade(root, kernelBinPath(p), newDG, p.pluginDir, kernelDataDir(p)); err != nil {
// 走事件总线:升级的每一步(含重启前的最后几步)都让 WebUI 看得到。
// 顺序上 publish 紧贴动作之前 —— 进程消失后事件就发不出去了。
bus := (*events.Bus)(nil)
if p.sdk != nil {
bus = p.sdk.EventBus()
}
if err := update.DoUpgradeBus(root, kernelBinPath(p), newDG, p.pluginDir, kernelDataDir(p), bus); err != nil {
// DoUpgrade 内部失败已自行回滚;这里只留一条诊断日志
fmt.Fprintf(os.Stderr, "[pluginmgr] kernel_update upgrade failed (rolled back): %v\n", err)
}

View File

@ -774,7 +774,17 @@ func (h *Handler) handleChatEvents(w http.ResponseWriter, r *http.Request) {
// 即每次工具调用都亮同一批无关节点。
//
// 事件本身很小(只含块 ID 数组,不含正文)—— 与 tool_call 同量级。
"memory_access"}
"memory_access",
// update_check / update_progress / update_result:更新子系统的三个事件。
//
// ★ 为什么必须进 SSE:升级是**长耗时且会让进程消失**的动作。
// WebUI 若只能轮询 /kernel/update,重启瞬间必然断线 —— 而断线那一刻
// 用户最需要知道"升到哪一步/失败了/已回滚"。事件是唯一能穿越重启的
// 通知通道(SSE 客户端会按 Last-Event-ID 重连补发)。
//
// 三者都进 sseEventRing(不只是实时推送):断线重连要能补到最终结论
// (update_result),否则用户重连后只看到"开始升级"而没有结局。
"update_check", "update_progress", "update_result"}
// token 级流式增量事件:实时转发给浏览器做逐 token 渲染。
// 不进 sseEventRing —— 断线重连只重放聚合事件(最终真相),
// 避免重放 delta 与聚合内容重复追加。

View File

@ -20,6 +20,7 @@ import (
"os"
"os/exec"
"github.com/JianFeeeee/HologramAgent/internal/events"
"github.com/JianFeeeee/HologramAgent/internal/update"
)
@ -65,7 +66,12 @@ func (h *Handler) handleKernelUpdateApply(w http.ResponseWriter, r *http.Request
}
_ = json.NewDecoder(r.Body).Decode(&body)
go runKernelUpgrade(body.Pkg)
// WebUI 侧天然有总线(它就是 SSE 的上游):升级每一步都让界面看得到
var bus *events.Bus
if h.sdk != nil {
bus = h.sdk.EventBus()
}
go runKernelUpgrade(body.Pkg, bus)
}
// currentKernelVersion 读本机 homed 的版本(尽力而为)。
@ -82,7 +88,9 @@ func currentKernelVersion() *update.BinVersion {
}
// runKernelUpgrade 在后台执行完整升级闭环(含重启与回滚)。
func runKernelUpgrade(pkgPath string) {
//
// bus 为 nil 时退化成纯日志(该部署未接事件总线)。
func runKernelUpgrade(pkgPath string, bus *events.Bus) {
if pkgPath == "" {
// 没给本地包:从最新 release 资产下载。
// 下载与升级的完整闭环由 CLI 工具实现,这里复用同一二进制
@ -107,5 +115,5 @@ func runKernelUpgrade(pkgPath string) {
if newBin == "" {
return
}
_ = update.DoUpgrade(root, "/usr/local/bin/homed", newDG, "/home/newqqagent/plugins", "/home/newqqagent")
_ = update.DoUpgradeBus(root, "/usr/local/bin/homed", newDG, "/home/newqqagent/plugins", "/home/newqqagent", bus)
}