Files
HomeAgent/internal/kernelboot/kernelstate.go
JianFeeeee ed8f28ea6c test(kernelboot,ci): 用真实启动图装配整机补判据,race 门由写死两包扩到全仓
背景:老大指出「CI 测试不够灵活、覆盖不到内核方方面面,应从启动图入手」。
先定位 CI 失败:六个门禁在基线上本地全绿,复现不出红 —— 实质是**覆盖面太窄**
而非配置写错(原 race 门只跑 ./internal/agent/core/ 与 ./cmd/waiter/ 两个包,
kernelboot 下 scheduler/syncbus/stress 的并发用例全在裸奔,绿是因为没测到)。

## 一、新增 internal/kernelboot/modules/registry_test.go(913 行,5 条判据)

kernelboot 自身判据很密(图编解码/分层/调度/迁移回滚/并发压测),但**全部用
假模块**在图上验算法。「真实 17 个模块按真实登记表装配成整机、逐层启动、
再严格逆序关闭」一条用例都没有。这中间藏着一整类失败:算法对、单模块对,
但装配起来不对(漏挂模块 / 某层关闭函数没登记 / 关闭顺序反了)。

5 条判据(零假模块,全部基于 kernelmod.Registered() 的 17 个真模块):
- TestRealRegistryAssemblesWholeMachine:每模块进图、设施真就绪(不是"登记过")
- TestLayerTopologyIsRespected:层间严格有序、层内两两无依赖、分层真带来并行
- TestShutdownIsStrictlyReverseLayerOrder:关闭严格逆序(实测哨兵序 [4 3 2 1 0])
- TestShutdownLeavesNoResidue:连续启停 3 轮,goroutine 回基线 + 临时目录删净
  + syncbus 订阅者不增长
- TestEveryRegisteredModuleIsCompiledIn:源码树扫描与登记表**双向**集合比对

### 判据 5 的真相源为何是"类型+四方法集"而不是 Register 调用点(初版栽过)

初版扫 `kernelmod.Register(...)` 调用点。实测:删掉叶子模块 multimodal 的
init() 后,**5 条判据全绿** —— 调用点消失 ⇒ 源码侧与登记表侧同时少一个 ⇒
集合比对照样相等。而"忘记初始化注册"恰是它声称要抓的场景。
根因是把「要检测的对象」当成了「检测的入口」,入口一被破坏就失去视野。
改成扫「实现了 Name/Requires/Provides/Init 四方法的类型」:模块哪怕不
Register、不被 import,四个方法仍写在源码里。
坑:本仓大量类型都叫 Module,只按类型名做键会撞成一堆(实测撞到只剩 2 个)
⇒ 用「目录+类型名」复合键;agent 是指针接收者 *Module,要处理 StarExpr。
修后恰扫出 17 个类型,与登记表 17 个名字一一对应,零误报。

### 失败面逐条实测(不是注释声称)

M1 删叶子模块 init() → 只有判据 5 红(1–4 全绿,这正是它存在的理由)
M2 CloseAll 逆序改正序 → 判据 3 红
M3 删聚合 import → 判据 5 红
其余:跳过某层会让内核 panic(llm 拿到 nil LuaVM,SIGSEGV);层顺序改逆序
红在装配失败(依赖数据为 nil)而非顺序断言 —— 这两种能发现但报错可读性差,
已写入文件注释,修它属业务改动(另派)。

## 二、.github/workflows/ci.yml

- race 门:写死两包 → `go list ./...` 全仓动态枚举(剔除 cmd/gui,
  枚举 <50 包则响亮失败),实测 92 包
- 新增 -skip 排除表:每项必须写「原因 + 修复单号」,无原因该步骤自身失败;
  每次 CI 打印完整清单,避免"排除项静默腐烂"
- 新增「已知红的可核对证据」步:用**静态断言**检查两处已知缺陷是否仍在
  (webui gzipResponseWriter 无锁、kernel Set* 无锁直写)。
  不用"DATA RACE 报告数>0"当断言 —— 实测这些竞争偶发(正向 0 条、
  另一次 8 条),那样必然假绿。
- cross 门:cmd 清单由写死 → 逐包 `CGO_ENABLED=0 go build` 实测枚举
  (实测 4 个纯 Go / 8 个依赖 cgo;homed-key-rotate 一直是纯 Go 却从没进过
  清单,白少一个覆盖);矩阵补 windows/arm64 与 linux/386
- go job:单列「整机启动图判据」一步(带 -count=1,便于失败定位)

## 三、新增 .github/scripts/check-ci-gates.py(180 行)

官方复核时发现:第一版判据只跑 gofmt/build/vet/go test,**完全不校验 ci.yml**,
而 ci.yml 正是改动占比最大的部分 —— 用「删掉 cmd/gui 剔除」这个变异咬出了该缺口。
本脚本用 YAML 解析断言 6 类门禁机制仍在,并给每个 run 步骤做 bash -n。
关键细节:`run: |` 块里的 `# ...` 是**字符串不是 YAML 注释**,`"cmd/gui" in run`
会被一句说明性注释满足 —— 真把 grep -v 删掉断言仍绿。故剥 shell 注释后再断言,
并给剥注释函数**本身**加了自测。

## 四、两处纯注释纠偏

- modules/registry.go:文档写「防漏 import 判据见 registry_test.go」(main 上该
  文件当时不存在)、第④步指向 `modules/imports.go`(真实位置是
  internal/kernelmod/all/all.go)。已纠正并落地为两条判据的指引。
- kernelboot/kernelstate.go:把「判据见 modules」具体化为函数名。

## 门禁(全部 -count=1,无缓存)

go build ./...          = 0
go vet ./...            = 0
go test ./...           = 0(59 ok / 0 FAIL)
go test -race 91 包      = 0(58 ok / 0 FAIL)
check-ci-gates.py       = 0
证据步骤正向 = 0;反向(给 SetLuaVM 加锁)= 1(证明是活断言不是死代码)

## 已知红(均待另开线,本线不越界改业务)

1. 内核:agent.wirePluginSDK 与 pluginhost.injectBaseDeps 在第 4 层并行重复
   写同一批 plugin.Registry 字段,而 Set* 全不取锁(r.mu 存在但没用)。
   实测偶发约 4%(1/25)。当前以 pkg 级排除临时挡住,**定性为临时手段**;
   唯一出口是给 Set* 加锁(修复单 KERNEL-REGISTRY-SETLOCK)。
2. webui:gzipResponseWriter 的 mode/wroteHeader/buf 无锁,handler goroutine
   与增量写 goroutine 并发写同一 ResponseWriter(修复单 WEBUI-GZIP-RACE)。
3. internal/agent/core TestStress_Nesting... 时序偶发(-race 放大 300ms 假设,
   无 DATA RACE 报告),未 skip,复现命令记在 ci.yml 注释里。

边界:未改任何内核业务逻辑(两个业务文件 diff 逐行只有注释);未 push、未开 PR。
2026-10-24 11:30:51 +08:00

329 lines
13 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 kernelboot
import (
"context"
"log"
"sync"
agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api"
agentCore "github.com/JianFeeeee/HologramAgent/internal/agent/core"
agentIO "github.com/JianFeeeee/HologramAgent/internal/agent/io"
"github.com/JianFeeeee/HologramAgent/internal/config"
"github.com/JianFeeeee/HologramAgent/internal/events"
"github.com/JianFeeeee/HologramAgent/internal/knowledge"
luapkg "github.com/JianFeeeee/HologramAgent/internal/lua"
"github.com/JianFeeeee/HologramAgent/internal/memory"
"github.com/JianFeeeee/HologramAgent/internal/memory/document"
"github.com/JianFeeeee/HologramAgent/internal/memory/pipeline"
"github.com/JianFeeeee/HologramAgent/internal/memory/social"
"github.com/JianFeeeee/HologramAgent/internal/memory/text"
"github.com/JianFeeeee/HologramAgent/internal/memory/vector"
"github.com/JianFeeeee/HologramAgent/internal/plugin"
"github.com/JianFeeeee/HologramAgent/internal/supervisor"
"github.com/JianFeeeee/HologramAgent/internal/tracker"
"github.com/JianFeeeee/HologramAgent/pkg/types"
)
// MemoryBundle 是"记忆子系统"的打包(memory 模块产出)。
//
// ★ 为何打包成一个类型而不是拆成 4 个字段:
// 这 4 个东西**同生共死**(同一个 initMemoryStack 产出、同一个 close 回收、
// 它们的持有者都假设彼此一致)。拆开会让"只填了 db 忘了 indexer"变成可能。
//
// ★ 为何放在 kernelboot 而不是 cmd/homed:
// KernelState 要引用它的类型 ⇒ 类型必须与 KernelState 同包
// (否则 kernelboot 得 import cmd/homed,那是不可能的)。
type MemoryBundle struct {
DB *memory.GraphDB
Indexer *memory.Indexer
Social *social.SocialStore
Distiller *pipeline.Distiller
}
// KernelState 是内核**唯一的公共数据持有者**(用户设计)。
//
// ## 设计意图(用户原话)
//
// > 内核通过一个结构体收集全部包级共享变量,其他模块通过这个结构体访问数据,
// > 各个模块的公共数据也统一注册到内核这个公共结构体中。
//
// > 结构体成员初始置 nil 加读取前检查不就好了
//
// ## 为什么这样比"每个包各持全局变量"好
//
// 本仓实测的隐藏依赖(设计 §2.8.2)大多来自**散落各包的包级变量**:
//
// internal/nlp/parser.go: var defaultParser Parser // SetDefaultParser 写,
// // NewExtractor 运行期读
//
// 这类变量的共同问题:
//
// · **看不见**:不读那个包的源码就不知道它存在、谁写谁读
// · **拼错不报**:`SetDefaultParser` 忘调 ⇒ 运行期静默降级
// (实测:图记忆实体退化成整句复合值,跨维度检索全失效)
// · **顺序无保证**:写入时机靠"碰巧"(原 main 里蒸馏先启动、解析器后装上,
// 靠 Interval=10min 兜着 —— 见 §2.8.2 A)
//
// 收到一个结构体后:
//
// · **一处可见**:一眼看全内核有哪些共享状态(可审计)
// · **类型化**:字段拼错 / 取错类型 ⇒ **编译错误**
// · **有归属**:字段属于某个模块的产物 ⇒ 顺序由依赖图保证
//
// ## ★ 取值方式:直接读字段 + 调用方检查 nil(用户指定的做法)
//
// 不用 map[string]any、不用泛型取用、不用"未声明依赖就取不到"的拦截。
// 理由:
//
// · 类型化字段 ⇒ 拼错/取错类型是**编译错误**(比任何运行期检查都早)
// · nil 检查 ⇒ 读得越直观越好(`if ks.LuaVM == nil { ... }`)
// · 少一层间接 ⇒ 少一处可能出错的地方
//
// 顺序正确性由**声明**驱动:`Build()` 校验缺依赖 / 环 / 重名,
// 判据断言关键顺序(见 internal/kernelboot/modules/registry_test.go 的
// TestLayerTopologyIsRespected:用真实模块登记表装配整机,逐层断言层间有序)。
//
// ## ★ 并发
//
// 同层模块**并行** Init,各自只写**自己那一个字段**
// ⇒ 字段互不相同,无数据竞争(判据用 -race 验证)。
//
// 内核**不**在字段上加锁:加锁会给调用方"可以乱写"的错觉,
// 正确约束是"各模块只写自己的字段"(由代码审查 + 判据守)。
type KernelState struct {
// ── Group 0 产出(core 自己立起来的东西,所有模块只读) ──
// Cfg 是配置快照(config 模块产出)。
//
// ★ 它就是原先"无主"的那个 cfg:`cfg := cfgReg.ToConfig()` 之后被手工
// 传给 11 个 init*。归到这里后,任何模块读 ks.Cfg 即可。
// 它在依赖图上是 config 模块的产物(第 0 层,前提由 Group 0 保证)。
Cfg *types.Config
// CfgReg 是配置中心(Group 0 产出,所有模块只读用)。
CfgReg *config.ConfigRegistry
// DataDir 是数据目录(Group 0 产出)。
DataDir string
// AgentWorkDir 是 agent 工作目录(ensureDataDirs 产出)。
AgentWorkDir string
// CLISocket 是 CLI 插件的 unix socket 路径(--cli-socket 产出)。
//
// 放在公共结构体里的理由:它是插件读的配置,必须与插件系统同时就绪
// (stage-host 模块用它设 cli.DefaultSocket —— 原先那行在 main 里
// 紧邻 loadPlugins)。
CLISocket string
// HTTPAddr 是 WebUI 监听地址覆盖值(--http-addr 产出)。
HTTPAddr string
// ── 各模块注册的公共数据(模块只写自己那几个字段) ──
// Memory 是图记忆栈(memory 模块产出)。
Memory *MemoryBundle
// LuaVM 是 Lua 协议适配 VM(lua 模块产出)。
LuaVM *luapkg.VM
// Supervisor 是代理生命周期守护(supervisor 模块产出)。
Supervisor *supervisor.Daemon
// Tracker 是变更追踪(tracker 模块产出)。
Tracker *tracker.Tracker
// IO 是内核 IO 抽象层(kernel-api 模块产出)。
IO *agentIO.IOManager
// EventBus 是事件总线(kernel-api 模块产出)。
EventBus *events.Bus
// TextMemory 是文本记忆(text-memory 模块产出)。
TextMemory *text.Memory
// Embedder 是共享词嵌入(memory 模块产出)。
//
// 蒸馏提取与 Agent 上下文**复用同一实例**,避免同一模型被二次加载
// (约 200k×300 维 ≈ 数百 MB 内存)。
Embedder *memory.StaticEmbedder
// LLMManager 是多源 provider 管理(llm 模块产出)。
LLMManager *agentAPI.ProviderManager
// LLMProvider 是默认 provider(llm 模块产出,= LLMManager.Default())。
LLMProvider agentAPI.Provider
// BaseAPIKey 是网关基础 key(llm 模块产出)。
BaseAPIKey string
// DocStore 是文档记忆(doc-store 模块产出)。
DocStore *document.Store
// Knowledge 是知识库(knowledge-store 模块产出)。
Knowledge *knowledge.Store
// MultimodalSpace 是多模态向量空间(multimodal 模块产出)。
MultimodalSpace vector.MultimodalEmbedder
// MultimodalProviderName / MultimodalErr 是"配了哪个 provider/为什么没启用"
// (multimodal 模块产出,供 healthcheck 状态报告)。
MultimodalProviderName string
MultimodalErr string
// StageHost 是阶段管道(stage-host 模块产出)。
StageHost *agentCore.StageHost
// PluginRegistry 是插件注册表(stage-host 模块产出)。
PluginRegistry *plugin.Registry
// PluginCatalog 是**插件元数据缓存**(plugin-host 模块产出)。
//
// ## 为什么需要它(用户指出的设计)
//
// > 插件管理器不应该在被拉起后就去读插件列表获取元数据并写入缓存准备拉起吗?
//
// 对。原先元数据散在两处、且**每次都要重扫磁盘**:
// · cmd/homed 的 collectPluginSpecs(决定启动时机)
// · pluginmgr 的 listPlugins(人看的列表)
// 两者各读各的 plugin.json,且调度器每轮评估都要用到。
//
// 现在统一由 plugin-host 模块**扫一次**、缓存下来,供:
// · 调度器(判断时机)
// · pluginmgr(列列表/状态)
// · 显式启用路径(校验名字是否存在)
//
// 类型是 any 以避免 import 环(kernelboot 不能依赖 plugin 包之外的
// 插件元数据类型)—— 取用处断言为 *PluginCatalog。
PluginCatalog any
// SyncBus 是内核**就绪状态总线**(由 Run 创建并填入)。
//
// ## ★ 为什么它必须在这里(而不只是 Run 的局部变量)
//
// 模块的 `Init(ks)` 只能看到 KernelState —— 若 bus 只在 Run 里,
// **模块拿不到它**,于是无法:
// · 订阅状态变化(plugin-host 要据此拉起"等更晚设施"的插件)
// · 标记就绪(虽然 Run 会自动做,但模块可能需要主动补标)
//
// 实测撞到:plugin-host 想在 Init 里挂"等 agent 就绪再拉起某些插件"的
// 回调,却找不到 bus。把它放进 KernelState(内核公共数据,用户设计)
// 是最自然的落点 —— 它本来就是**内核级共享状态**。
SyncBus *SyncBus
// PluginScheduler 是**插件拉起调度器**(plugin-host 模块产出)。
//
// 用途:诊断(还剩哪些插件挂起)、以及将来"显式启用某插件"时
// 判断它的依赖是否就绪。
//
// 类型是 any 以避免 kernelboot 反向依赖插件包(import 环)。
// 取用处断言为 *Scheduler。
PluginScheduler any
// PluginsReady 标记"插件调度已完成"(plugin-host 模块产出)。
//
// 用途:main 在装配后据此知道"插件该起的都起了",再往下走
// (如 compactConfigDB / 启动 runtime)。
PluginsReady bool
// Agent 是主 agent(agent 模块产出)。
Agent *agentCore.Agent
// ── 运行期(装配阶段建立,后续流程用) ──
// Ctx / Stop 是 agent 运行期的取消上下文。
//
// ★ 为何在这里而不是模块内部:main 在装配之后还要把它传给
// startAgentRuntime / startIPCServer。生命周期必须一致 ——
// 装配时创建的 ctx 若不交出去,那些组件会用另一个 ctx,
// 停机信号就传不进去。
Ctx context.Context
Stop context.CancelFunc
// Closers 是各模块注册的关闭函数(按**注册顺序**记录)。
//
// ★ 为何按注册顺序而不是按名字排:名字与依赖序**无关**。
// 实测反例:注册序 config→memory→llm,按名字逆序得 memory,llm,config(错),
// 正确的是 llm,memory,config。关闭时**逆序**执行(LIFO,与 defer 一致)。
//
// ★★ 为什么需要独立的锁(closerMu):
// **同层的模块是并行 Init 的**,而它们都会调 AddCloser。
// 实测(压力测试 + -race):第 1 层有 8 个模块并行,其中 5 个
// (docstore/lua/memory/multimodal/textmem)都注册关闭函数
// ⇒ 并发 append 到同一 slice ⇒ **数据竞争**。
//
// 后果不只是 race 警告:切片 append 在容量增长时可能**丢失元素**
// (两个 goroutine 同时读到旧 len、各自写同一槽)⇒ 某个模块的
// 关闭函数永远不被调用 ⇒ **资源泄漏**(且无任何报错)。
//
// 为什么不用 KernelState 上"模块只写自己字段"的约定来避免:
// Closers 不属于任一模块 —— 它是**共享的回收站**。
// 共享可变状态就必须有锁(这与"各写各的字段"是两条不同的保证)。
Closers []func()
// closerMu 保护 Closers(见上)。
closerMu sync.Mutex
// MigrationPreflight 是数据目录迁移体检(由 migration 模块填充,可 nil)。
//
// 用 func 字段而不是具体类型:kernelboot 不能依赖 migration 模块
// (那会成环),而迁移能力本身是**可选**的 —— nil 时调用方跳过即可。
MigrationPreflight func(target string) (interface{}, error)
// Quiescers 是可**暂停/恢复**的写者(伪关机用,见 lifecycle.go)。
//
// 与 Closers 的区别:Closers 是**一次性**的(关了就没了),
// 而迁移需要"停下来 → 搬数据 → 再起来" ⇒ 必须可逆。
Quiescers []Quiescer
quiesceMu sync.Mutex
}
// NewKernelState 创建公共结构体(所有字段零值 = nil,由各模块填充)。
func NewKernelState() *KernelState {
return &KernelState{}
}
// AddCloser 注册一个关闭函数(模块在 Init 里调用)。
//
// 关闭时按**注册顺序的逆序**执行(LIFO,与 `defer` 语义一致):
// 先初始化的一般是底层(config → memory → llm),停机应反向拆。
func (s *KernelState) AddCloser(fn func()) {
if fn == nil {
return
}
// ★ 必须加锁:同层模块**并行** Init 时都会调它(见 Closers 字段的注释)。
s.closerMu.Lock()
s.Closers = append(s.Closers, fn)
s.closerMu.Unlock()
}
// CloseAll 逆序执行全部关闭函数,返回首个错误。
//
// ★ 失败策略:**继续执行剩余关闭**(返回首个错误)。
// 一个模块关不掉不该阻止其他模块关 —— 那会造成更多泄漏。
// 关闭函数 panic 也被隔离(否则一个模块的问题会连带其他模块都关不掉)。
func (s *KernelState) CloseAll() error {
// ★ 先取快照再执行(不持锁执行回调):关闭函数可能很慢
// (停进程/flush 数据库),持锁会让并发调用 AddCloser 的模块卡住;
// 且回调可能反查 KernelState ⇒ 死锁。
s.closerMu.Lock()
fns := make([]func(), len(s.Closers))
copy(fns, s.Closers)
s.Closers = nil
s.closerMu.Unlock()
for i := len(fns) - 1; i >= 0; i-- {
fn := fns[i]
func() {
defer func() {
if r := recover(); r != nil {
log.Printf("[kernelboot] 关闭函数 panic(已隔离): %v", r)
}
}()
fn()
}()
}
return nil
}