mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-05 16:57:32 +00:00
staticcheck 门接入时把存量设为基线 24 条,本次全部清掉,基线改为 0。
## 生产代码里的真实缺陷(4 个)
### ① memory/fusion.go —— BFS 的 depth 参数形同虚设
for d := 1; d <= depth; d++ {
neighbors, _ := g.NeighbourBlocks(seed.BlockID) // ← 永远是 seed
...
break // ← 只跑一轮
}
三处叠加:循环体末尾无条件 `break`;每层传的还是 `seed.BlockID`;
于是 depth=2 与 depth=3 召回结果**完全一样**。
staticcheck SA4008("变量在循环条件里从不改变")正是从这里报出来的 ——
它看出来 `d` 除了打分没有参与寻址。
改成真正的逐层 BFS(frontier 推进到上一层新发现的节点)。实测:
depth=1 → 2 个 [节点0 节点1]
depth=2 → 3 个 [节点0 节点1 节点2] ← 修复前停在这里
depth=3 → 4 个 [节点0 节点1 节点2 节点3]
补判据 `TestBFS召回_层深真实生效`:必须用**链**(n0-n1-n2-n3),
星型在 depth=1 就能召回全部叶子、断不出有没有走深。
变异(改回单层)⇒ 实测红:`★ depth=2 召回 2 个,期望 3`。
### ② nlp/extractor.go —— subjRels/objRels 只写不读
两个切片被 append 了 4 处,但**从未被读取**。三元组的关系类型来自
`relLabel := result.Tokens[vi]`,置信度来自 `result.DepRels[si]`,
与这两个切片无关。删除。
### ③ plugins/cmd/plugin.go —— 配置解析失败会把上限写成 0
if n, err := fmt.Sscanf(s, "%d", &p.maxOutput); err == nil && n > 0 {
} // ← 空分支
直接 Sscanf 到 &p.maxOutput:解析失败时它可能已被写成 0 或部分值,
而空分支无法回滚 ⇒ 配置写错一个字符就把输出上限变成 0
(下游 `maxLen := p.maxOutput` 直接拿它算截断长度)。
改为解析到临时变量、失败时保持默认并记日志。
### ④ cmd/homed/bootstrap.go —— 空的 else 分支
`if err != nil { log } else { }` —— 删掉 else。
## 测试判据里的真实缺陷(3 个)
### ⑤ stop_semantics_test —— 短路求值让第二次调用从不执行
if !a.sched.consumeCancel() || !a.sched.consumeCancel()
第一次返回 true 时第二次**根本不会执行**,于是"两条配额都可消费"
这条断言实际只测了一次。拆成两次独立调用(两处同型)。
### ⑥ seq/store_test —— 永假断言
if maxCallDepth != maxCallDepth { t.Fatal("maxCallDepth 不稳定") }
自己跟自己比,从诞生起就不可能失败。改成显式读两次比较。
### ⑦ pluginhost/catalog_test —— typed-nil 守卫永假
var nb *kstate.SyncBus
var iface kstate.SyncState = nb
if iface == nil { t.Skip(...) } // ← 恒 false
把 typed-nil 装进接口后,接口的 (type, value) 二元组都不为 nil。
这道前置守卫**从未生效** —— 而这正是该判据要演示的陷阱本身。
改用 `reflect.ValueOf(iface).IsNil()`。
## 其余(写法/冗余,无行为变化)
agent.go / context.go / toolresult_store_test / apply_regression_test
空 if 块(注释移到真实分支上方)
codec_chunkfast_golden_test 未使用的三个返回值 + 空 if
scheduler_critical/stack_test `if _, _ = f(); true { }` → 直接调用
overflow_test / upgrade_test 变量被整体覆盖,首次赋值未使用
dense_test m[string(x)] 替代临时变量
lex_rules_test range over string 而非 []rune(string)
## 验证
staticcheck -checks 'SA4*,SA5*,SA6*,SA9*' 0 条(基线 24 → 0)
go build ./... PASS
go vet ./... PASS
gofmt -l 干净
go test ./... -count=1 65 包全过,0 FAIL
干净检出(worktree) build + memory/nlp/core 全绿
478 lines
19 KiB
Go
478 lines
19 KiB
Go
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)
|
||
}
|
||
|
||
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)
|
||
}
|