Files
HomeAgent/internal/kernelmod/all/wiring_test.go
JianFeeeee 01c7c0145e fix(kernel): 补上统一挂载点的生产接线 —— 上一版功能实际未生效
## 这个 commit 修的是我自己上一版的破绽

上一轮我报告"63 包全绿"。今天用户问「确定都可以正常工作」,
一查发现:**生产路径上根本没人调 kstate.Mount()**。

    $ grep -rn 'kstate.Mount(' --include='*.go' internal/ cmd/ | grep -v _test
    (空)

后果链:

    kstate.Get() 永远返回 nil
      ⇒ agent/core 的 renderSystemPrompt 走 `if ks != nil` 跳过分支
      ⇒ 插件声明的系统提示词段**一条都不渲染**
      ⇒ 模型看不到任何 skill 索引,只能凭工具名猜用法
      ⇒ 正是本次重构要修的那个生产故障(拿 terminal_create 当 cmd_run)

而且**日志里一个字都没有** —— 静默跳过,不是报错。

## 为什么测试全绿

因为测试自己调了 `MountForTest`:

    restore := kstate.MountForTest(&kstate.State{PromptSections: reg})
    t.Cleanup(restore)

判据把缺失的那根线补上了,所以永远绿。这正是本仓反复吃过的亏
(engineering-lessons §1.1)。

## 一共两个断点

  ① kernelboot.Run 没调 kstate.Mount(ks)
  ② sdk.SetSystemPromptAPI 生产上没人调
     ⇒ 插件的 s.SystemPrompt() 返回 nil
     ⇒ skillmgr.publishIndex() 对 nil 是静默 return(它不能 panic)

## 修复

  ① kernelboot.Run 装入挂载点(唯一入口)
  ② 补齐三段接线:
       plugin.Registry 加 promptSections 字段 + SetPromptSections
       kernelmod/pluginhost 从 ks.PromptSections 注入进去
       internal/sdk 收到后调 SetSystemPromptAPI(promptSectionAdapter)

## 新增判据:**不自己 Mount**,只读 Run 之后的状态

  TestKstateIsMountedByRun             源码判据(那行必须在)
  TestPromptSectionsWiredToPlugins     三段接线逐个核对
  TestSharedDataInjectionsHaveProducers 通用:注入点必须有生产者
  TestRunActuallyMountsState           ★ 端到端

最后一条是关键:它先 `MountForTest(nil)` **清空**挂载点,再跑真实
`kernelboot.Run` + 真实模块登记表,然后从底层包视角(kstate.Get())
检查能不能看到东西。它不能靠"测试自己挂"蒙混过关。

变异验证(实测):
  · 删掉 kstate.Mount(ks) ⇒ 源码判据红 + 端到端判据红
    「kernelboot.Run 跑完了,但 kstate.Get() 仍是 nil」

## 验证

  go build ./...              PASS
  go vet ./...                PASS
  gofmt -l                    干净
  go test ./... -count=1      63 包全过,0 FAIL
  干净检出(git worktree)     build + 端到端判据全绿
  六环人工核对                 ①Mount ②Run ③产出 ④注入 ⑤sdk ⑥skillmgr 各 1 处

## 教训

"全绿"只在**判据不自己补线**时才是证据。凡是"被测方需要某个前置
装配、而判据自己提供了它"的判据,都必须再配一条"从零开始"的
端到端版本。
2026-11-04 12:41:56 +08:00

261 lines
10 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 all
// 判据:**生产装配路径必须真的接通**(2026-11-04)。
//
// ## 这组判据补的是一个真实的"测试绿、生产坏"
//
// 我第一版实现里,`agent/core` 的 renderSystemPrompt 已经从
// `kstate.Get().PromptSections` 读段,测试也全绿 —— 但**生产上是坏的**:
//
// · `kstate.Mount(ks)` 在生产路径上**没有任何调用者** ⇒ Get() 永远 nil
// · `sdk.SetSystemPromptAPI` 生产上没人调 ⇒ 插件声明拿 nil API
//
// 测试为什么绿?因为测试用 `MountForTest` 自己挂了挂载点。
// 这就是本仓反复吃过的亏(engineering-lessons §1.1):
//
// 判据把缺失的那根线补上了,所以永远绿。
//
// 所以本组判据不看"行不行",只看**装配代码里有没有那根线**——
// 它们失败时指向的是一行代码,而不是一个行为。
import (
internalConfig "github.com/JianFeeeee/HologramAgent/internal/config"
"os"
"path/filepath"
"strings"
"testing"
"github.com/JianFeeeee/HologramAgent/internal/kernelboot"
"github.com/JianFeeeee/HologramAgent/internal/kstate"
)
// TestKstateIsMountedByRun 钉住 kernelboot.Run 会装入统一挂载点。
//
// ## 为什么必须是这一条
//
// `kstate.Get()` 是底层包(agent/core 等)读共享数据的**唯一**入口。
// 它返回 nil 时所有读取都走 `if ks != nil` 的分支 ⇒ **静默跳过**。
//
// 静默跳过比报错危险得多:插件声明的技能索引不渲染,模型看不到任何
// skill 说明,于是凭工具名猜用法 —— 那正是"拿 terminal_create 当
// cmd_run"的根因。而日志里一个字都没有。
//
// 变红条件:删掉 kernelboot.Run 里的 kstate.Mount(ks) ⇒ 红。
func TestKstateIsMountedByRun(t *testing.T) {
src, err := os.ReadFile("../../kernelboot/kernelboot.go")
if err != nil {
t.Fatalf("读 kernelboot.go: %v", err)
}
if !strings.Contains(string(src), "kstate.Mount(ks)") {
t.Fatal("★ kernelboot.Run 没有调 kstate.Mount(ks)\n\n" +
"后果:kstate.Get() 永远返回 nil ⇒ 所有读共享数据的代码\n" +
"都走 `if ks != nil` 的跳过分支 ⇒ **静默不生效**(不是报错)。\n\n" +
"具体症状:插件声明的系统提示词段一条都不渲染,模型看不到\n" +
"任何 skill 索引,只能凭工具名猜用法。")
}
}
// TestPromptSectionsWiredToPlugins 钉住插件声明面被真正注入。
//
// 三处缺一不可:
//
// kernelmod/stagehost 产出 ks.PromptSections(注册表本身)
// kernelmod/pluginhost 从 ks 取出来 → reg.SetPromptSections
// internal/sdk reg 把它传进 SDKConfig → SetSystemPromptAPI
//
// 缺任何一处,插件的 `s.SystemPrompt()` 都返回 nil,而 skillmgr 的
// publishIndex 对 nil 是**静默返回**(它不能 panic,因为插件可能跑在
// 没有该能力的宿主里)—— 于是技能索引永远进不了提示词。
//
// 这三条分开写而不是合成一条:失败时要一眼看出断在哪一节。
func TestPromptSectionsWiredToPlugins(t *testing.T) {
// ① 注册表被产出
stagehost, err := os.ReadFile("../../kernelmod/stagehost/mod.go")
if err != nil {
t.Fatalf("读 stagehost/mod.go: %v", err)
}
if !strings.Contains(string(stagehost), "ks.PromptSections = ") {
t.Fatal("★ stage-host 模块没有产出 ks.PromptSections —— 注册表不存在")
}
// ② 被注入给插件注册表
pluginhost, err := os.ReadFile("../../kernelmod/pluginhost/mod.go")
if err != nil {
t.Fatalf("读 pluginhost/mod.go: %v", err)
}
if !strings.Contains(string(pluginhost), "reg.SetPromptSections(") {
t.Fatal("★ plugin-host 模块没有把 ks.PromptSections 注入给插件注册表\n\n" +
"后果:sdk.SystemPrompt() 返回 nil ⇒ skillmgr.publishIndex 静默返回\n" +
"⇒ 技能索引永远进不了系统提示词。")
}
// ③ 被传进 SDK 并被接上
sdkplug, err := os.ReadFile("../../sdk/plugin.go")
if err != nil {
t.Fatalf("读 sdk/plugin.go: %v", err)
}
sdkText := string(sdkplug)
if !strings.Contains(sdkText, "SetSystemPromptAPI(") {
t.Fatal("★ internal/sdk 没有调用 SetSystemPromptAPI\n\n" +
"后果:插件的 s.SystemPrompt() 返回 nil,DeclarePromptSection 无处落地。")
}
// 注意匹配的是**字段声明**(`PromptSections *promptsec.Registry`),
// 不是赋值写法(`PromptSections:` 只出现在 registry.go 的 SDKConfig
// 字面量里)。第一版判据写成了冒号形式,于是把已经接好的代码判成红。
if !strings.Contains(sdkText, "PromptSections *promptsec.Registry") {
t.Fatal("★ SDKConfig 没有 PromptSections 字段 —— 插件声明的段无处传递")
}
}
// TestSharedDataInjectionsHaveProducers 是**通用**的一条。
//
// 本轮重构引入了若干"注入点"(Setter),每一个都要求有人调它。
// 没人调的注入点 = 功能静默缺失。
//
// 这条判据列出的每一项,都必须在生产代码(非 _test)里存在调用者。
// 它是从上面那两次真实事故里抽象出来的:
//
// · kstate.Mount 漏了 ⇒ Get() 永远 nil
// · SetPromptSections 漏了 ⇒ 插件声明无处落地
//
// 变红条件:新增一个注入点却忘了接 ⇒ 在这里补一行就会红,直到真接上。
func TestSharedDataInjectionsHaveProducers(t *testing.T) {
// 注入点 → 期望在哪个包里找到生产者(子串匹配,跨文件搜索)。
wants := []struct {
method string
where string // 生产代码里的调用形态
}{
{"kstate.Mount", "kstate.Mount(ks)"},
{"plugin.Registry.SetPromptSections", "reg.SetPromptSections("},
{"sdk.SetSystemPromptAPI", "SetSystemPromptAPI("},
{"plugin.Registry.SetSecurityRuntime", "reg.SetSecurityRuntime("},
{"plugin.Registry.SetSecretStore", "reg.SetSecretStore("},
{"config.SetCodec", "cfgReg.SetCodec("},
}
for _, w := range wants {
found := false
// 扫所有非 _test 的 Go 文件。
roots := []string{"../../kernelboot", "../../kernelmod", "../../sdk", "../../plugin", "../../config", "../../agent"}
for _, root := range roots {
if filepathContains(t, root, w.where) {
found = true
break
}
}
if !found {
t.Fatalf("★ 注入点 %s 在生产代码里没有调用者(找 %q)\n\n"+
"没人调的注入点 = 功能静默缺失:拿不到注入的那一方会走\n"+
"\"未装配就跳过\"的分支,日志里一个字都没有。", w.method, w.where)
}
}
}
// filepathContains 递归搜索目录下所有非测试 Go 文件是否包含 substr。
func filepathContains(t *testing.T, dir, substr string) bool {
t.Helper()
entries, err := os.ReadDir(dir)
if err != nil {
return false
}
for _, e := range entries {
p := dir + "/" + e.Name()
if e.IsDir() {
if filepathContains(t, p, substr) {
return true
}
continue
}
if !strings.HasSuffix(e.Name(), ".go") || strings.HasSuffix(e.Name(), "_test.go") {
continue
}
b, err := os.ReadFile(p)
if err != nil {
continue
}
if strings.Contains(string(b), substr) {
return true
}
}
return false
}
// TestRunActuallyMountsState 是**端到端**的那一条(其余是源码判据)。
//
// ## 为什么源码判据不够
//
// TestKstateIsMountedByRun 只确认"那行代码存在"。它证明不了:
//
// · 那行在**真实装配流程**里被执行到(可能被 early return 跳过)
// · Mount 的是**同一个** State(而不是另一份)
// · 装配完成后 ks.PromptSections 真的被 stage-host 填上了
//
// 所以这条跑真实 `kernelboot.Run` + 真实模块登记表,然后从**底层包的
// 视角**(kstate.Get())检查能不能看到东西。
//
// ## 为什么这条能防住我犯的那个错
//
// 第一版实现里 agent/core 读 kstate.Get(),而生产上没人 Mount ⇒ 永远 nil。
// 测试全绿是因为测试自己 MountForTest。本判据**不自己 Mount**,
// 只读 Run 之后的状态 —— 所以它能抓到"没人 Mount"。
func TestRunActuallyMountsState(t *testing.T) {
// 先清空挂载点,确保"绿"只能来自 Run 自己。
restore := kstate.MountForTest(nil)
defer restore()
if kstate.Get() != nil {
t.Fatal("前置不成立:清空挂载点失败")
}
// 用**完整** fixture:emptyState 缺 CfgReg,onnx 模块会直接解引用 nil
// (实测 panic)。这里按 bootgraph_test.go 的整机夹具建 state。
// ★ 不用 t.TempDir():整机装配会挂 agentfs/merged(真实 overlay 挂载点),
// t.TempDir 的严格 RemoveAll 会报 EBUSY。改用与 bootgraph_test.go 相同的
// "重试删除"策略(Linux 上 dentry 回收可能稍晚)。
dir, err := os.MkdirTemp("", "wiring-mounts-")
if err != nil {
t.Fatalf("建临时目录失败: %v", err)
}
t.Cleanup(func() {
_ = removeAllWithRetry(dir, 50)
})
reg := internalConfig.NewConfigRegistry(filepath.Join(dir, "config.db"))
t.Cleanup(func() { reg.Close() })
ks := kstate.New()
ks.CfgReg = reg
ks.DataDir = dir
ks.AgentWorkDir = filepath.Join(dir, "agentfs")
ks.Cfg = reg.ToConfig()
ks.Cfg.Daemon.DataDir = dir
ks.Cfg.Plugin.Dir = filepath.Join(dir, "plugins")
ks.HTTPAddr = "127.0.0.1:0"
plan := realPlan(t)
if _, err := kernelboot.Run(plan, kernelboot.RunOptions{
State: ks, Bus: kstate.NewSyncBus(), MaxParallel: 2,
}); err != nil {
t.Skipf("整机装配未通过(%v)—— 本判据依赖完整装配,跳过而不是假绿", err)
}
// ① 挂载点必须是 Run 装入的那一份。
got := kstate.Get()
if got == nil {
t.Fatal("★ kernelboot.Run 跑完了,但 kstate.Get() 仍是 nil\n\n" +
"后果:所有读共享数据的底层包(agent/core 等)都走\n" +
"`if ks != nil` 的跳过分支 ⇒ 静默不生效。\n" +
"测试之所以没发现,是因为测试自己调了 MountForTest ——\n" +
"判据把缺失的那根线补上了,所以永远绿。")
}
if got != ks {
t.Fatal("★ Run 装入的挂载点不是传入的那个 State —— 底层包读到的会是另一份")
}
// ② stage-host 必须已产出插件声明式提示词段的注册表。
if got.PromptSections == nil {
t.Fatal("★ 装配完成后 ks.PromptSections 仍是 nil\n\n" +
"stage-host 模块应产出它。缺了它 ⇒ 插件声明无处落地 ⇒\n" +
"skillmgr 的技能索引永远进不了系统提示词。")
}
}