Files
HomeAgent/cmd/homed/bootstrap.go
JianFeeeee cc0eb51286 refactor(kernel): 统一挂载点 kstate + 插件声明式系统提示词段
## 起因(生产故障)

A2A 分诊助手(驻留子)把 terminal_create 当一次性命令执行器:

    terminal_create(command='cd /usr/lib/node_modules/@deepseek-ai && ls -d */ | head -60 && …')
    → read /dev/ptmx: input/output error
    → 再开 term_7、term_8、term_9 …

链路拆开四层:
  ① cmd 插件 auto_start=false ⇒ 工具表里没有 cmd_run
  ② 需要 cmd-tools skill 说明"先 plugin_start cmd"
  ③ 但 skill 索引没进那个 agent 的提示词
  ④ 因为 skillIndex 只是根 agent 的字段、由 bootstrap 手工接线

④ 才是总根。而它之所以必须手工接线:agent/core 读不到内核的共享数据。

## 环的精确形状(实测,非推测)

kernelboot 因为持有 KernelState 而依赖 19 个业务包:

    kernelboot ──▶ agent/core ──▶ …
               ──▶ plugin
               ──▶ security
               ──▶ memory / knowledge / text / coldcontext / …

而 kernelboot.go / scheduler.go / funcmodule.go / syncbus.go / lifecycle.go
**一行业务代码都不需要** —— 依赖全在 kernelstate.go 这一个数据文件里。

后果是四个底层包各自造通道:

    core      → Agent 字段(每种 agent 构造点都要记得传)
    plugin    → 12 个 SetXxx
    sdk       → SDKConfig 传参
    security  → 包级单例 Global() / RuntimeGlobal() / ConfigCodec()

四条通道是同一个根因的四个表现。

## 本次做的事

### 1. 抽出 internal/kstate(断环)

kernelstate.go / syncbus.go / lifecycle.go 搬出 kernelboot:

    internal/kstate     纯数据 + 唯一挂载点(叶子,谁都能 import)
    internal/kernelboot 装配引擎,零业务包

挂载点 API:Mount / Get(nil 安全)/ MountForTest(返回 restore,并行测试隔离)。

验证:core 现在能 import kstate(此前 import cycle not allowed)。

### 2. StageHost / Agent 字段存 any

它们是 core 自己的类型(core 是环的一极,这是真环)。按用户裁定的方向:

    > 是其他包在kstate挂共享内容啊

存 any,消费方断言回具体类型 —— 与本仓既有的 PluginCatalog /
PluginScheduler / UpdateCapabilities 同型。

★ 其余字段保持**具体类型**,没有一律改 any。判据是"它会不会反向依赖
agent/core":实测 plugin / security / sdk 都不依赖 core,所以它们作为
具体类型不构成环,而类型化字段让"拼错/取错类型"是编译错误(本结构体的
设计原则)。

### 3. 插件声明式系统提示词段

SDK 新增 SystemPromptAPI(见 submodule 提交)。内核侧:

    kstate.State.PromptSections *promptsec.Registry   ← 唯一事实来源
    Agent.renderSystemPrompt() 每轮重建时读它

skillmgr 从"实现 SkillIndexProvider 让内核来问"改为"声明自己的段":

    DeclarePromptSection{Section:"skillmgr.skills", Order:100, Volatile:true}

并挂到全部变更点(启动全扫后、移交事件后、load/unload/create/install/
enable/disable 后、Stop 时撤销)。

### 4. 内核自身的提示词段也走同一管理器

按用户裁定「把现在内核所有 systemprompt 的相关注入都接入到这个统一的
systemprompt 管理器上」,把 buildSystemPrompt 里硬拼的十几块
(人格卡/任务提示词/记忆说明/输出规则/工具目录…)改为声明式:
kernelSections() 当轮计算 → 与插件段按 Order 合并渲染。

顺序由 Order 常量决定,改结构变成调常量而不是挪代码块。

## 判据(均经变异验证)

  TestCoreCanImportKstate            真编译探针;环重现则红(实测)
  TestKstateDoesNotDependOnCore      静态版,指向具体字段
  TestKstateIsLeaf                   kstate 不得反依赖上层
  TestRTChildSeesPromptSections      子 agent 能看到插件段
  TestResidentSeesPromptSections     生产实际中招的那一类;变异后红(实测)
  TestRootAgentSeesPromptSections    确认没改坏根路径
  TestNoPromptSectionsMount          未装配时不 panic、不凭空出现段
  TestDeclareTwiceRendersOnce        ★ 回应"会不会多份":声明 5 次渲染 1 份
  TestSectionRemovalTakesEffect      Stop 后段消失
  TestRuntimeUpdateVisibleToExistingAgent  同一实例下一轮看到新内容
  TestSectionOrderIsRespected        Order 生效且稳定

## 验证

  go build ./...                   PASS
  go vet ./...                     PASS
  gofmt -l internal/ cmd/ pkg/     干净
  go test ./... -count=1           63 包全过,0 FAIL
  干净检出(git worktree)           build + 4 包测试全绿

## 未做(见 kernelplan.md)

  T3 security 三单例收敛(含 Store 复用 Codec 的惰性迁移)
  T5 plugin Setter 收敛
  T6 防腐判据(部分已随本次落地)
2026-11-04 11:23:05 +08:00

479 lines
19 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/HologramAgent/internal/agent/core"
internalConfig "github.com/JianFeeeee/HologramAgent/internal/config"
"github.com/JianFeeeee/HologramAgent/internal/ipc"
logpkg "github.com/JianFeeeee/HologramAgent/internal/log"
"github.com/JianFeeeee/HologramAgent/internal/memory"
"github.com/JianFeeeee/HologramAgent/internal/memory/pipeline"
"github.com/JianFeeeee/HologramAgent/internal/memory/social"
"github.com/JianFeeeee/HologramAgent/internal/meta"
"github.com/JianFeeeee/HologramAgent/internal/plugin"
"github.com/JianFeeeee/HologramAgent/internal/recovery"
safeclose "github.com/JianFeeeee/HologramAgent/internal/safeclose"
"github.com/JianFeeeee/HologramAgent/internal/supervisor"
"github.com/JianFeeeee/HologramAgent/internal/tracker"
"github.com/JianFeeeee/HologramAgent/pkg/types"
)
// logSourceEnv 是「日志带源文件位置」的开关环境变量。
//
// 为什么默认关:生产日志要紧凑可读。加了 `file.go:NNN` 之后每行前面多出一截
// 内部实现信息,而**大多数日志本身已经带了模块前缀**(`[kbtree]`、`[plugin]`、
// `[agent]`),定位能力并不缺。
//
// 而它也不便宜:`log.Lshortfile` 在**每一次** log 调用里做一次 runtime.Caller。
//
// 为什么保留开关而不是删掉:排查插件/装配问题时,同名文件(多个插件都有自己的
// plugin.go)靠行号才能区分。部署方临时 `HOMED_LOG_SOURCE=1` 重启即可。
const logSourceEnv = "HOMED_LOG_SOURCE"
// logFlags 返回日志前缀标志位(抽出来便于判据直接验,不必真去建目录/开文件)。
func logFlags() int {
flags := log.Ldate | log.Ltime
switch strings.ToLower(strings.TrimSpace(os.Getenv(logSourceEnv))) {
case "1", "true", "yes", "on":
flags |= log.Lshortfile
}
return flags
}
// setupLogging 初始化日志:时间前缀 + 同时输出到控制台与 <data>/log/ 下的本次启动文件。
//
// 源文件位置默认不带,见 logSourceEnv。
//
// 本函数体是 main() 里对应启动阶段的整块平移:语句、日志文本、错误语义不变,
// 只把「*dataDir」变成参数、把 defer 变成由调用点注册的 cleanup。
func setupLogging(dataDir string) string {
log.SetFlags(logFlags())
// 文件日志:同时输出到控制台和 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。
//
// ★ 2026-11-04 删掉了这里的"技能索引接线"(原 5 行)。
//
// 那 5 行是「内核能力靠启动代码手工接给根 agent」的典型:skillmgr 实现了
// agentCore.SkillIndexProvider,main 在插件加载后 type-assert 它、再塞进
// 根 agent 的字段。于是 rtchild、驻留子各自构造时都得记得再传一遍 ——
// 漏传不报错、不崩,只是那个 agent 的提示词里一条 skill 都没有。
//
// 生产症状:A2A 分诊助手(驻留子)不知道 cmd 插件要先 plugin_start,
// 拿 terminal_create 当一次性命令执行器,一条命令开一个终端。
//
// 现在系统提示词段是**插件声明式**的(sdk.SystemPromptAPI):插件声明进
// 内核级注册表(KernelState.PromptSections),任何 agent 每轮重建提示词时
// 读同一份 —— 启动代码不需要知道有哪些插件、也不需要 type-assert 任何东西。
func startAgentRuntime(cfgReg *internalConfig.ConfigRegistry, pluginReg *plugin.Registry,
agent *agentCore.Agent, logDir string, ctx context.Context) func() {
// 日志管理:层级压缩 + 保留策略
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:
// · coldcontextStore.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 插件集)")
// --version:打印版本后退出,不启动任何模块。
//
// ★ 为 OTA 而加:升级前要能**问一个二进制它是什么版本**,否则只能先替换
// 再启动看日志——那时旧版本已经被覆盖了。有了它,OTA 可以在临时目录里
// 跑 `新homed --version` 做预检,不通过就不碰线上文件。
//
// 输出保持单行 key=value 形态,便于脚本解析(与 BuildTime/Commit 一起给,
// 因为"同一个版本号不同 commit"会让"升级成功但内容没变"难以察觉)。
showVersion := flag.Bool("version", false, "打印版本信息后退出(不启动内核)")
flag.Parse()
if *showVersion {
fmt.Printf("version=%s\ncommit=%s\nbuild_time=%s\nsdk=%s\n",
meta.Version, meta.Commit, meta.BuildTime, meta.SDKCompatibleVersion)
os.Exit(0)
}
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: config DB compaction failed: %v", err)
return
}
if !done {
return
}
after := before
if st, err := os.Stat(cfgReg.DBPath()); err == nil {
after = st.Size()
}
log.Printf("[homed] config DB compacted: %d -> %d bytes", before, after)
}