Files
HomeAgent/cmd/homed/bootstrap.go
JianFeeeee 198811253a feat(webui+guard): 内核重启/关机按钮,指令走心跳复用既有协议
## 需求

WebUI 加关机与重启按钮,内核提供对应内部接口。有些状态变更只能靠重启
生效(换 LLM 源、换主密钥),没有这条通道时用户只能 ssh 上去敲 systemctl。

## 设计:复用心跳,不新开通道(用户指出的方向)

guard 与 worker 之间已经有一条现成的、每 5s 一次的单向通报(心跳文件,
内容原本是 `t=<unix>`,只用来刷 mtime)。用户点按钮本质就是"我要你在
下一次心跳时知道这件事"——再开 unix socket / 信号 / 临时文件,只会多出
一套要防重、防丢、防竞态的机制。

    WebUI 点按钮
      → POST /api/v1/kernel/{restart,shutdown}
      → sdk.KernelControlAPI(只投递,立即返回)
      → 下一次心跳把 `cmd=restart|shutdown reason=...` 写进 <data>/heartbeat
      → 重启:worker 清理 → exit 42 → guard 重拉(**既有机制,零改动**)
        关机:guard 在 workerCleanExit 分支读到 cmd=shutdown
              → systemctl stop → 不再拉起

★ 关机为什么必须由 guard 做:
  unit 是 Restart=always,guard 自己 return 会被 systemd 立刻拉起
  (用户点了"关机"、几秒后服务又活了)。必须显式 systemctl stop。
  而不让 worker 自己停:它是被守护方,且**只有 guard 知道这次该不该重拉**。

★ 指令必须一次性消费:guard 读完立即原子重写文件清除 cmd 行,
  否则每次心跳都读到同一条 ⇒ 无限重启/关机。worker 侧还要检查
  "指令写过但文件里没了" ⇒ 已被消费 ⇒ 清本地 pending,否则又写回去。

## 顺带修的真缺陷:心跳文件无限增长

生产实测 **14.6MB**(纯 O_APPEND,约 70 天)。心跳的唯一职责是刷 mtime,
历史行没有任何读者 ⇒ 每次 consume 要整读 14.6MB(实测 27ms)。
改为 O_TRUNC 滚动写:只保留当前一行。真机验证 15,322,164 → 13 字节。
consume 也加了尺寸守卫(大文件只读尾部 8KB)。

## 安全:能力分级 + 白名单(两道)

1. proc 桥的 method 能力表:新增 `CapKernelControl` 一档。
   ★ 判据 `TestCapability_AllMethodsClassified` 当场报出
   "未登记 ⇒ 会按 CapCore 放行,绕过权限检查" —— 那意味着任何插件都能关机。
2. 内置插件走 `SDKConfig.KernelControl`,第一版我写成"有就发",
   等于全部内置插件都能关机。改为按名字白名单(目前只有 webui)。

## 判据(7 条)

心跳:解析 / 消费后必须清除 / worker 不得写回已消费指令 / 未消费时必须续写
能力:只有白名单插件拿得到 / 未装配时返回 nil 不 panic / SDK nil 安全
心跳文件:不得无限增长 / 大文件只读尾部且读后收敛

变异 6/6 咬住。全量 53 包 ok。

## UI

导航底栏两个图标按钮(与主题/语言/退出并列):重启有确认框 + 轮询等它
回来并自动刷新;关机有**二次确认且写清后果**(必须人工 systemctl start)。
两者都先把响应写回浏览器并 Flush,再让内核退出 —— 否则用户看到的是
connection reset,无法区分"正在重启"和"真出故障了"。
2026-10-17 21:18:07 +08:00

433 lines
17 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package main
import (
"context"
"flag"
"fmt"
"io"
"log"
"os"
"os/signal"
"path/filepath"
"strings"
"syscall"
"time"
agentCore "github.com/JianFeeeee/HomeAgent/internal/agent/core"
internalConfig "github.com/JianFeeeee/HomeAgent/internal/config"
"github.com/JianFeeeee/HomeAgent/internal/ipc"
logpkg "github.com/JianFeeeee/HomeAgent/internal/log"
"github.com/JianFeeeee/HomeAgent/internal/memory"
"github.com/JianFeeeee/HomeAgent/internal/memory/pipeline"
"github.com/JianFeeeee/HomeAgent/internal/memory/social"
"github.com/JianFeeeee/HomeAgent/internal/plugin"
"github.com/JianFeeeee/HomeAgent/internal/recovery"
safeclose "github.com/JianFeeeee/HomeAgent/internal/safeclose"
"github.com/JianFeeeee/HomeAgent/internal/supervisor"
"github.com/JianFeeeee/HomeAgent/internal/tracker"
"github.com/JianFeeeee/HomeAgent/pkg/types"
)
// setupLogging 初始化日志:行号前缀 + 同时输出到控制台与 <data>/log/ 下的本次启动文件。
//
// 本函数体是 main() 里对应启动阶段的整块平移:语句、日志文本、错误语义不变,
// 只把「*dataDir」变成参数、把 defer 变成由调用点注册的 cleanup。
func setupLogging(dataDir string) string {
log.SetFlags(log.Ldate | log.Ltime | log.Lshortfile)
// 文件日志:同时输出到控制台和 data/log/ 目录
logDir := filepath.Join(dataDir, "log")
if err := os.MkdirAll(logDir, 0755); err != nil {
log.Printf("[homed] warning: cannot create log dir: %v", err)
} else {
logPath := filepath.Join(logDir, fmt.Sprintf("homed_%s.log", time.Now().Format("2006-01-02_15-04-05")))
logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
if err != nil {
log.Printf("[homed] warning: cannot open log file: %v", err)
} else {
log.SetOutput(io.MultiWriter(os.Stderr, logFile))
log.Printf("[homed] logging to %s", logPath)
}
}
return logDir
}
// ensureDataDirs 建好启动期需要的全部目录,返回 agent 的 overlayfs 工作目录。
//
// 本函数体是 main() 里对应启动阶段的整块平移:语句、日志文本、错误语义不变,
// 只把「*dataDir」变成参数、把 defer 变成由调用点注册的 cleanup。
func ensureDataDirs(dataDir string) string {
agentWorkDir := filepath.Join(dataDir, "agentfs")
dirs := []string{
dataDir,
filepath.Join(dataDir, "snapshots"),
filepath.Join(dataDir, "plugins"),
filepath.Join(dataDir, "changesets"),
filepath.Join(dataDir, "memory"),
filepath.Join(dataDir, "memory", "raw"),
filepath.Join(dataDir, "adapters"),
agentWorkDir,
}
for _, d := range dirs {
if err := os.MkdirAll(d, 0755); err != nil {
log.Fatalf("create dir %s: %v", d, err)
}
}
return agentWorkDir
}
// memoryStack 聚合记忆侧组件:图库、索引器、社交图、蒸馏器。
type memoryStack struct {
db *memory.GraphDB
indexer *memory.Indexer
social *social.SocialStore
distiller *pipeline.Distiller
}
// loadPlugins 建插件目录、按启动模式决定 allowlist,然后加载全部插件。
//
// 本函数体是 main() 里对应启动阶段的整块平移:语句、日志文本、错误语义不变,
// 只把「*dataDir」变成参数、把 defer 变成由调用点注册的 cleanup。
func loadPlugins(cfg *types.Config, cfgReg *internalConfig.ConfigRegistry, pluginReg *plugin.Registry,
stageHost *agentCore.StageHost, bootMode, dataDir string) {
// Auto-create plugins directory (without hardcoding plugin names)
os.MkdirAll(cfg.Plugin.Dir, 0755)
// failback 受限启动:仅装载 failback 插件集(webfetch/files/cmd 为内核内置,
// 此处仅控制外部插件,默认含 recoverydiag 以便直接在受限态产出恢复结论)
if bootMode == "failback" {
list := cfgReg.GetString("core.agent.failback_plugins", "webui,pluginmgr,recoverydiag")
// 优先使用 guard.yaml 经过 recovery 任务下发的插件集(guard 是 failback 权威)
if task, terr := recovery.LoadTask(recovery.TaskPath(dataDir)); terr == nil && len(task.Plugins()) > 0 {
list = strings.Join(task.Plugins(), ",")
}
var names []string
for _, s := range strings.Split(list, ",") {
if s = strings.TrimSpace(s); s != "" {
names = append(names, s)
}
}
pluginReg.SetLoadAllowlist(names)
log.Printf("[homed] failback boot: plugin allowlist = %v", names)
}
// Load all plugins — each scans its own dir and is loaded via factory or .so
if err := pluginReg.Load(cfg.Plugin.Dir); err != nil {
log.Printf("[homed] warning: load plugins: %v", err)
}
log.Printf("[homed] stage host ready with %d registered tools", stageHost.ToolCount())
}
// startAgentRuntime 接线技能索引、起日志管理、启动 agent,返回逆序关停的 cleanup。
//
// 本函数体是 main() 里对应启动阶段的整块平移:语句、日志文本、错误语义不变,
// 只把「*dataDir」变成参数、把 defer 变成由调用点注册的 cleanup。
func startAgentRuntime(cfgReg *internalConfig.ConfigRegistry, pluginReg *plugin.Registry,
agent *agentCore.Agent, logDir string, ctx context.Context) func() {
// 技能索引接线:skillmgr 插件实现 SkillIndexProvider 时注入 agent(方案B prompt 注入)
if sp := pluginReg.Get("skillmgr"); sp != nil {
if prov, ok := sp.(agentCore.SkillIndexProvider); ok {
agent.SetSkillIndexProvider(prov)
log.Printf("[homed] skill index wired from skillmgr plugin")
}
}
// 日志管理:层级压缩 + 保留策略
logManager := logpkg.NewManager(logDir, cfgReg)
go logManager.Start(ctx)
agent.Start()
return func() {
// 与原 main 的两个 defer 同序(LIFO):先停 agent,再停日志管理。
agent.Stop()
logManager.Stop()
}
}
// startIPCServer 起 PING/ACK 心跳服务(含 kernel 状态快照),返回仅在启动成功后生效的 cleanup。
//
// 本函数体是 main() 里对应启动阶段的整块平移:语句、日志文本、错误语义不变,
// 只把「*dataDir」变成参数、把 defer 变成由调用点注册的 cleanup。
func startIPCServer(dataDir, bootMode string, agent *agentCore.Agent) (*ipc.Server, func()) {
started := false
ipcServer := ipc.NewServer(dataDir, func() *ipc.Status {
st := agent.GetKernelStatus()
llmOK := st != nil && st.LLM.Available
tools := 0
if st != nil {
tools = len(st.Tools)
}
uptime := int64(0)
if st != nil {
if d, err := time.ParseDuration(st.Uptime); err == nil {
uptime = int64(d.Seconds())
}
}
return &ipc.Status{
PID: os.Getpid(),
Boot: bootMode,
UptimeSec: uptime,
LLMOK: &llmOK,
Tools: tools,
LastDiag: lastDiagSummary(dataDir),
}
})
if err := ipcServer.Start(); err != nil {
log.Printf("[homed] warning: ipc heartbeat server: %v", err)
} else {
}
return ipcServer, func() {
if started {
ipcServer.Stop()
}
}
}
// startSupervisorRuntime 把真实存活源与重启通道接到 supervisor 上,返回重启请求通道。
//
// 本函数体是 main() 里对应启动阶段的整块平移:语句、日志文本、错误语义不变,
// 只把「*dataDir」变成参数、把 defer 变成由调用点注册的 cleanup。
func startSupervisorRuntime(sup *supervisor.Daemon, trk *tracker.Tracker, agent *agentCore.Agent) chan struct{} {
sup.SetTracker(trk)
sup.RegisterAgent("main")
// 真实存活源 + 重启通道:daemon 心跳语义由此修正(lastHB 只在确认存活时更新),
// 重启动作不再空转——清理后以特殊退出码交给 guard/systemd 重建。
restartCh := make(chan struct{}, 1)
sup.SetHeartbeatSource(func(id types.AgentID) (time.Time, types.HealthStatus, error) {
st := agent.GetKernelStatus()
if st == nil {
return time.Time{}, types.HealthDown, fmt.Errorf("no kernel status")
}
h := types.HealthHealthy
if !st.LLM.Available {
h = types.HealthDegraded
}
return time.Now(), h, nil
})
sup.SetRestartHandler(func(id types.AgentID) {
select {
case restartCh <- struct{}{}:
default:
}
})
return restartCh
}
// startHeartbeat 每 5s 触碰 <data>/heartbeat(guard 据此判定 worker 存活/卡死),返回停止函数。
//
// 本函数体是 main() 里对应启动阶段的整块平移:语句、日志文本、错误语义不变,
// 只把「*dataDir」变成参数、把 defer 变成由调用点注册的 cleanup。
func startHeartbeat(dataDir string, ctx context.Context) func() {
// 心跳:每 5s 触碰 <data>/heartbeat,guard 据此判定工作进程是否存活/卡死
hbPath := filepath.Join(dataDir, "heartbeat")
writeHB := func() {
// ★ 若有待发的控制指令,**整文件重写**(保留 t= 行 + cmd= 行)。
//
// 为什么不能继续 append:guard 消费指令后会重写文件清除 cmd 行;
// 若这里仍 append,历史 cmd 行会越积越多(每次读到的还是第一条)。
// 无指令时保持原来的 append 语义(历史行为不变,只刷 mtime)。
if content := nextHeartbeatWrite(hbPath, time.Now().Unix()); content != "" {
if err := os.WriteFile(hbPath, []byte(content), 0644); err == nil {
return
}
}
// ★ 心跳文件必须**滚动**,不能无限 append(2026-10-17 实测:14.6MB)。
//
// heartbeat 的唯一职责是让 guard 看到 mtime 变新(见下面的注释)。
// 历史行**没有任何读者** —— 唯一读内容的是 consumeHeartbeatCmd,
// 而它只关心 cmd= 行。所以累加历史行纯属浪费:
// · 每 5s 12 字节 ⇒ 约 70 天长到 14.6MB(生产实测就是这个量级)
// · consumeHeartbeatCmd 要 ReadFile 整个文件 ⇒ 白白读 14.6MB
// · 长期跑下去还会一直涨(磁盘与读取成本都随天数线性增长)
//
// 改成截断重写:只保留**当前**这一行。mtime 语义完全不变
// (文件仍在每次心跳被写),而体积恒定在十几字节。
//
// 用 O_TRUNC 而不是 O_APPEND:内容不再需要累积。
if f, err := os.OpenFile(hbPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644); err == nil {
fmt.Fprintf(f, "t=%d\n", time.Now().Unix())
f.Close()
}
}
// ★ 首次写入必须**同步**(返回前落盘),不能丢进 goroutine:
//
// heartbeat 文件在 worker 侧的唯一职责是「让 guard 看到 mtime 变新」。
// 停机 >heartbeatTimeout 后重启时,文件 mtime 停在上一代 worker(已过期);
// 若首次写是异步的,它可能与重型初始化(多模态空间/知识库重建,冷启动
// 5–8s)竞争调度,迟迟不落盘 —— guard 的 ticker 先到、拿旧 mtime 判 stale。
// (2026-10-12 生产部署实测:6 连杀 + failback 降级。)
// 同步首写保证:main() 继续往下做任何慢事之前,mtime 已经是新的。
writeHB()
hbStop := make(chan struct{})
go func() {
t := time.NewTicker(5 * time.Second)
defer t.Stop()
for {
select {
case <-t.C:
writeHB()
case <-hbStop:
return
case <-ctx.Done():
return
}
}
}()
// ★★ 必须幂等(2026-10-17 实测 panic:close of closed channel)。
//
// 调用点有**两处**:
// · main.go:70 `defer stopHeartbeatEarly()`
// · bootstrap.go waitForShutdown 里的 `stopHeartbeat()`
// 直接 `close(hbStop)` ⇒ 第二次关闭**必然 panic**:
//
// panic: close of closed channel
// goroutine 1 [running]:
// main.startHeartbeat.func3() bootstrap.go:259
// main.main() main.go:293
//
// 而且它发生在**退出路径**上:进程已经做完清理工作,
// 却在最后一步崩溃 ⇒ 退出码变成 2(panic),
// systemd/guard 会把它当成"异常退出"而不是"正常停止",
// 进而触发不必要的重启/告警。实测:SIGTERM 后退出但日志尾部是 panic 栈。
//
// 修法:`sync.Once` 保护。stop 函数做成幂等是本仓退出路径的通用要求
// (它们往往既被 defer 调、又被显式调)。
// 用 safeclose 统一入口(本仓 close 纪律:见 internal/safeclose)。
return safeclose.SafeFunc(hbStop)
}
// waitForShutdown 阻塞至 SIGINT/SIGTERM 或 supervisor 请求重启,然后按原 main 的顺序清理,需要重建时以退出码交回 guard。
//
// 本函数体是 main() 里对应启动阶段的整块平移:语句、日志文本、错误语义不变,
// 只把「*dataDir」变成参数、把 defer 变成由调用点注册的 cleanup。
func waitForShutdown(ctx context.Context, dataDir string, restartCh chan struct{}, stopHeartbeat func(),
pluginReg *plugin.Registry, trk *tracker.Tracker, cfgReg *internalConfig.ConfigRegistry, sup *supervisor.Daemon,
stopKernelModules func()) {
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
restartRequested := false
select {
case <-sigCh:
log.Printf("[homed] shutting down...")
case <-restartCh:
restartRequested = true
log.Printf("[homed] restart requested, shutting down cleanly then exiting with code %d", exitRestartRequested)
}
stopHeartbeat()
pluginReg.StopAll()
if trk != nil {
trk.Stop()
}
if err := cfgReg.Flush(); err != nil {
log.Printf("[homed] flush config: %v", err)
}
sup.Shutdown()
// ★★ 必须显式跑一次模块关闭,**不能只靠 defer**(2026-10-17 实测缺陷)。
//
// 两条退出路径原先**不对称**:
// 路径 A(SIGTERM):return 到 main → defer ks.CloseAll() ✓
// 路径 B(exit 42):下面的 os.Exit(42) **跳过所有 defer** ✗
//
// 而路径 B 是**每次热更新都走**的。后果是这些写者从不 flush:
// · docStore.Stop() → flush() 文档索引可能陈旧
// · distiller.Stop() → flush() 待蒸馏规则丢失
// · textMem.Stop() → Close() 文本记忆未落盘
// · luaVM/multimodal → 资源未释放(靠进程退出兜底)
//
// ⇒ 显式调用(CloseAll 幂等:第二次执行时 Closers 已空,无副作用)。
stopKernelModules()
log.Printf("[homed] stopped")
if restartRequested {
os.Exit(exitRestartRequested)
}
}
// resolveBaseAPIKey 解析兜底 API key:配置项 > LLM_API_KEY > DEEPSEEK_API_KEY。
func resolveBaseAPIKey(cfg *types.Config) string {
apiKey := cfg.LLM.APIKey
if apiKey == "" {
apiKey = os.Getenv("LLM_API_KEY")
}
if apiKey == "" {
apiKey = os.Getenv("DEEPSEEK_API_KEY")
}
return apiKey
}
// resolveWebUIOverride 解析 webui 监听地址的覆盖值,空串表示不覆盖。
//
// 优先级:CLI --webui > 核心配置 webui.listen_addr(仅当它被改成非内置默认值)。
// 两者都不给时由 webui 插件自己的 settings["addr"] 决定。
//
// 为什么不写成“内核在插件加载前 Set 插件 settings['addr']”:那时
// config_webui 表还没建(表只在插件注册 def 时创建),PluginSettings.Set 的
// INSERT 会失败而错误被忽略,随后插件 Start 里 RegisterDef 才建表并写入默认
// :8080 —— 于是 CLI --webui 与 webui.listen_addr **一直是死配置**,
// 无论怎么传都监听 :8080。覆盖值改由插件自己接收(webui.SetListenOverride)。
func resolveWebUIOverride(cfgReg *internalConfig.ConfigRegistry, httpAddr string) string {
if strings.TrimSpace(httpAddr) != "" {
return strings.TrimSpace(httpAddr)
}
// webui.listen_addr 的播种默认值就是 ":8080";与默认值相同视为“未配置”,
// 否则会把用户在设置页里改过的插件 addr 顶掉。
if v := strings.TrimSpace(cfgReg.GetString("webui.listen_addr", ":8080")); v != "" && v != ":8080" {
return v
}
return ""
}
// options 是 worker 的命令行参数。
type options struct {
dataDir string
httpAddr string
cliSocket string
role string
boot string
}
// parseFlags 解析命令行参数。
func parseFlags() options {
dataDir := flag.String("data", "", "data directory (default: auto-detect next to binary)")
httpAddr := flag.String("webui", "", "webui listen address (default: webui.listen_addr from config)")
cliSocket := flag.String("socket", "", "cli unix socket path (default: <data>/cli.sock)")
role := flag.String("role", "agent", "process role: guard (父守护) | agent (工作进程)")
boot := flag.String("boot", "normal", "agent boot mode: normal | failback (受限启动,仅 failback 插件集)")
flag.Parse()
return options{dataDir: *dataDir, httpAddr: *httpAddr, cliSocket: *cliSocket, role: *role, boot: *boot}
}
// compactConfigDB 在空闲页够多时压缩配置库;失败只告警(不影响启动)。
//
// 触发条件(见 internal/config.MaybeCompact):空闲页 >= 1MB 且占页数 >= 25%。
// 放在插件加载之后调用——迁移/清理大值发生在插件 Start 里,之前调用没有意义。
func compactConfigDB(cfgReg *internalConfig.ConfigRegistry) {
before := int64(-1)
if st, err := os.Stat(cfgReg.DBPath()); err == nil {
before = st.Size()
}
done, err := cfgReg.MaybeCompact(1<<20, 0.25)
if err != nil {
log.Printf("[homed] warning: 配置库压缩失败: %v", err)
return
}
if !done {
return
}
after := before
if st, err := os.Stat(cfgReg.DBPath()); err == nil {
after = st.Size()
}
log.Printf("[homed] 配置库已压缩: %d -> %d 字节", before, after)
}