mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-05 16:57:32 +00:00
## 起因(生产故障)
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 防腐判据(部分已随本次落地)
479 lines
19 KiB
Go
479 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)
|
||
} 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)
|
||
}
|