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 处

## 教训

"全绿"只在**判据不自己补线**时才是证据。凡是"被测方需要某个前置
装配、而判据自己提供了它"的判据,都必须再配一条"从零开始"的
端到端版本。
This commit is contained in:
JianFeeeee
2026-11-04 12:41:56 +08:00
parent 8cc8bca380
commit 01c7c0145e
5 changed files with 351 additions and 7 deletions

View File

@ -384,6 +384,15 @@ func Run(plan *Plan, opts RunOptions) (*RunResult, error) {
if ks == nil {
ks = kstate.New()
}
// ★ 装入统一挂载点(2026-11-04)。
//
// 这是**唯一**的挂载入口:底层包(agent/core 等)读不到 kernelboot
// (会成环),只能通过 kstate.Get() 拿共享数据。不在这里 Mount,
// Get() 就永远返回 nil —— 而"永远 nil"的症状是**静默不生效**
// (插件声明的系统提示词段全部不渲染),不是报错。
//
// 判据 TestKstateIsMountedByRun 钉住这一点。
kstate.Mount(ks)
// ★ 执行前把**全部**模块声明的设施登记为"内核注册过"。
//

View File

@ -0,0 +1,260 @@
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 的技能索引永远进不了系统提示词。")
}
}

View File

@ -307,6 +307,16 @@ func injectBaseDeps(ks *kstate.State) {
}
reg.SetTracker(ks.Tracker)
reg.SetConfig(ks.Cfg)
// 插件声明式系统提示词段:从内核挂载点取,注入给插件 SDK。
//
// ★ 不接这一行的后果:sdk.SystemPrompt() 返回 nil ⇒ skillmgr 的
// publishIndex 静默什么都不做 ⇒ 模型看不到任何 skill 索引。
// 而那正是本次重构要修的生产故障(分诊助手拿 terminal_create 当
// cmd_run 用)。症状是"静默不生效",不是报错。
if ks.PromptSections != nil {
reg.SetPromptSections(ks.PromptSections)
}
// 变量库注入:sdk.SecretsAPI 桥接到它(不再读包级单例)。
// ks.Security 可能为 nil(未启用安全模块),那正是"不可用"语义。
if ks.Security != nil {

View File

@ -6,6 +6,7 @@ import (
"encoding/json"
"errors"
"fmt"
"github.com/JianFeeeee/HologramAgent/internal/promptsec"
"github.com/JianFeeeee/HologramAgent/internal/security"
"log"
"os"
@ -81,6 +82,16 @@ type Registry struct {
// 只对声明了该能力的内置插件可见(目前是 WebUI)。
kernelCtl sdk.KernelControlAPI
// promptSections 是插件声明式系统提示词段的注册表(内核级共享)。
//
// ★ 为什么由 plugin 持有并传给 SDK:kstate 持有 *plugin.Registry
// (State.PluginRegistry),所以 plugin 不能 import kstate(会成环)。
// 方向只能是:plugin-host 模块从 ks.PromptSections 取出来注入。
//
// nil 合法:未装配时插件的 DeclarePromptSection 会拿到明确 error
//(而不是静默成功 —— 静默成功的症状是"插件以为声明了、模型看不到")。
promptSections *promptsec.Registry
// secretStore 是**变量库**(凭据别名),供 sdk.SecretsAPI 桥接。
//
// ★ 为什么是注入而不是 security.Global()(2026-11-04):
@ -361,6 +372,13 @@ func (r *Registry) SetSecurityRuntime(rt *security.Runtime) {
r.securityRT = rt
}
// SetPromptSections 注入插件声明式提示词段注册表(plugin-host 模块装配时调用)。
func (r *Registry) SetPromptSections(reg *promptsec.Registry) {
r.mu.Lock()
defer r.mu.Unlock()
r.promptSections = reg
}
// SetSecretStore 注入变量库(plugin-host 模块装配时调用)。
func (r *Registry) SetSecretStore(s *security.Store) {
r.mu.Lock()
@ -596,6 +614,7 @@ type sdkDeps struct {
kernelCtl sdk.KernelControlAPI
secretStore *security.Store
securityRT *security.Runtime
promptSections *promptsec.Registry
}
// snapshotDeps 在一次 RLock 内取走 buildSDK 需要的全部依赖。
@ -627,6 +646,7 @@ func (r *Registry) snapshotDeps() sdkDeps {
kernelCtl: r.kernelCtl,
secretStore: r.secretStore,
securityRT: r.securityRT,
promptSections: r.promptSections,
}
}
@ -803,6 +823,7 @@ func (r *Registry) buildSDK(name string) *sdk.PluginSDK {
// 内核未启用安全模块时为 nil ⇒ Available()==false(不是 nil API)。
Secrets: sdk.NewSecretsAPI(deps.secretStore, codecOf(deps.securityRT)),
SecurityRuntime: deps.securityRT,
PromptSections: deps.promptSections,
// ★ 策略声明面:插件**声明**档位(qq.owner / qq.guest…),
// 判定仍在内核 security.Runtime。Owner 由内核用真实插件名填,
// 插件无法伪造身份(见 internal/sdk/security_impl.go)。

View File

@ -1,6 +1,8 @@
package sdk
import (
"fmt"
"github.com/JianFeeeee/HologramAgent/internal/promptsec"
"github.com/JianFeeeee/HologramAgent/internal/security"
"log"
"sync"
@ -228,6 +230,37 @@ func (s *PluginSDK) Knowledge() KnowledgeAPI { return s.know }
// LLM 返回大模型源。媒体字节请走 Memory().MediaBytes(ref)(CAS 已淘汰)。
func (s *PluginSDK) LLM() LLMAPI { return s.llm }
// promptSectionAdapter 把内核的 promptsec.Registry 适配成公共 SDK 的
// SystemPromptAPI。
//
// ★ 为什么需要适配层:SDK 是对外契约(外部插件照它编译),不能 import
// 内核内部包;而 Registry 要能被内核其它部分直接读。两边共享的只有
// "段"这个数据形状。
type promptSectionAdapter struct{ reg *promptsec.Registry }
func (a promptSectionAdapter) DeclarePromptSection(s pubsdk.PromptSection) error {
if a.reg == nil {
// 未装配时**报错而不是静默丢弃**:静默丢的后果是"插件以为声明
// 成功了,模型却看不到",与不声明无法区分。
return fmt.Errorf("system prompt registry 未装配")
}
return a.reg.Declare(promptsec.Section{
Section: s.Section,
Owner: s.Owner,
Order: s.Order,
Content: s.Content,
Volatile: s.Volatile,
})
}
func (a promptSectionAdapter) RemovePromptSection(section string) error {
if a.reg == nil {
return fmt.Errorf("system prompt registry 未装配")
}
a.reg.Remove(section)
return nil
}
// ioAdapter 桥接 IOManager 到公共 SDK 的 IOInjector 接口,
// 确保外部插件通过 s.InjectText() 等方法的调用能被路由到内核 IO 层。
type ioAdapter struct{ iom *agentIO.IOManager }
@ -348,13 +381,18 @@ type SDKConfig struct {
// "单例装的时机"与"插件加载的时机"隐式耦合 —— 装晚了插件就用默认
// 最小策略,而日志里一个字都没有。
SecurityRuntime *security.Runtime
Memory MemoryAPI
TextMemory TextMemoryAPI
DocMemory DocMemoryAPI
Knowledge KnowledgeAPI
LLM LLMAPI
Settings SettingsAPI
Secrets pubsdk.SecretsAPI
// PromptSections 是插件声明式系统提示词段的注册表(内核级共享)。
//
// sdk 不能 import kstate(kstate 依赖 plugin → 依赖 sdk,成环),
// 所以由 plugin.Registry 从 ks.PromptSections 取出来传进来。
PromptSections *promptsec.Registry
Memory MemoryAPI
TextMemory TextMemoryAPI
DocMemory DocMemoryAPI
Knowledge KnowledgeAPI
LLM LLMAPI
Settings SettingsAPI
Secrets pubsdk.SecretsAPI
// Security 是**策略声明面**(插件声明安全档位;判定由内核执行)。
//
// ★ 只声明、不判定:本接口没有 CheckTool 之类的方法 —— 那属于内核。
@ -387,6 +425,12 @@ func New(name string, cfg SDKConfig) *PluginSDK {
if cfg.Security != nil {
base.SetSecurityAPI(cfg.Security)
}
// ★ 系统提示词声明面(2026-11-04)。不接这一行,插件的
// DeclarePromptSection 会拿到 nil API ⇒ skillmgr 的索引**静默不入**
// 系统提示词(测试全绿因为测试自己 Mount 了挂载点,生产是坏的)。
if cfg.PromptSections != nil {
base.SetSystemPromptAPI(promptSectionAdapter{reg: cfg.PromptSections})
}
if cfg.RawFiles != nil {
base.SetRawFilesAPI(cfg.RawFiles)
}