Files
HomeAgent/internal/kernelboot/syncbus_test.go
JianFeeeee cc0eb51286 refactor(kernel): 统一挂载点 kstate + 插件声明式系统提示词段
## 起因(生产故障)

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 防腐判据(部分已随本次落地)
2026-11-04 11:23:05 +08:00

414 lines
14 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
// syncbus + 插件调度器的判据(2026-10-16)。
//
// 设计见 docs/zh/kernel-modularization-design.md §2.6。
//
// 这组判据里**最重要的一条是"默认路径为零改动"**:
// 存量 42 个插件都不声明依赖,它们的启动时机必须逐字不变。
// 静默改变启动时机 = 静默引入竞态。
import (
"github.com/JianFeeeee/HologramAgent/internal/kstate"
"reflect"
"strings"
"sync"
"testing"
"time"
)
// ★★★ 判据(最关键):**未声明依赖的插件等内核完全初始化**(存量兼容)。
//
// 这是整个设计里风险最高的一处:若未声明的插件被"提前"拉起,
// 42 个存量插件的启动时机全部改变 ⇒ 静默竞态。
//
// 反例(若实现错误):把"未声明"当成"依赖集为空",而空集在数学上已满足
// ⇒ 第一层完成后就拉起 ⇒ 比现状早得多。
func TestSchedulerUndeclaredWaitsForKernelComplete(t *testing.T) {
bus := kstate.NewSyncBus()
var launched []string
var mu sync.Mutex
sch := NewScheduler(bus, func(p PluginSpec) error {
mu.Lock()
launched = append(launched, p.Name)
mu.Unlock()
return nil
})
// 存量插件:不声明依赖
sch.Submit([]PluginSpec{{Name: "legacy1", AutoStart: true}})
sch.Submit([]PluginSpec{{Name: "legacy2", AutoStart: true}})
// 内核还在装配中(几层已就绪,但没完成)
bus.MarkLayerReady([]string{"config"})
bus.MarkLayerReady([]string{"memory", "lua"})
bus.MarkLayerReady([]string{"llm"})
mu.Lock()
n := len(launched)
mu.Unlock()
if n != 0 {
t.Fatalf("★ 内核尚未完全初始化,未声明依赖的插件就被拉起了 %d 个(存量行为被改变)", n)
}
// 内核完成 ⇒ 现在才该拉起
bus.MarkKernelComplete()
mu.Lock()
defer mu.Unlock()
if len(launched) != 2 {
t.Fatalf("内核完成后应拉起 2 个,实际 %d: %v", len(launched), launched)
}
}
// ★★★ 判据:**声明了依赖的插件在依赖就绪时立即拉起**(新能力的正面证据)。
//
// 断言"早于内核完成" —— 否则这条判据在"等内核完成"的实现下也会通过(假绿)。
func TestSchedulerDeclaredLaunchesEarly(t *testing.T) {
bus := kstate.NewSyncBus()
var launched []string
var mu sync.Mutex
sch := NewScheduler(bus, func(p PluginSpec) error {
mu.Lock()
launched = append(launched, p.Name)
mu.Unlock()
return nil
})
// ★ 镜像真实顺序:Run 在**执行前** MarkDeclared(登记全部模块设施名),
// 插件 Submit 在装配之后发生 ⇒ 判据必须先声明再提交。
bus.MarkDeclared([]string{"config"})
// 声明只需要 config
sch.Submit([]PluginSpec{{Name: "early", RequiresFacilities: []string{"config"}, AutoStart: true}})
// 第 0 层只提供 config ⇒ 应立刻拉起
bus.MarkLayerReady([]string{"config"})
mu.Lock()
got := append([]string{}, launched...)
mu.Unlock()
if len(got) != 1 || got[0] != "early" {
t.Fatalf("★ 依赖已就绪却未拉起: %v", got)
}
// ★ 关键:此时内核**尚未**完成 —— 证明它是"尽早启动"而不是"等完成"
if bus.KernelComplete() {
t.Fatal("测试设置错误:此刻内核不该已完成")
}
}
// ★★★ 判据:**依赖未齐则挂起**,不会误拉起。
func TestSchedulerWaitsForMissingDependency(t *testing.T) {
bus := kstate.NewSyncBus()
var launched []string
sch := NewScheduler(bus, func(p PluginSpec) error {
launched = append(launched, p.Name)
return nil
})
// ★ 先声明 llm(登记名字,但尚未就绪)—— 与真实顺序一致。
bus.MarkDeclared([]string{"llm", "config", "memory"})
// 需要 llm
sch.Submit([]PluginSpec{{Name: "needsllm", RequiresFacilities: []string{"llm"}, AutoStart: true}})
bus.MarkLayerReady([]string{"config", "memory"}) // llm 还没好
if len(launched) != 0 {
t.Fatalf("★ llm 未就绪却拉起了: %v", launched)
}
if p := sch.Pending(); len(p) != 1 {
t.Fatalf("应挂起 1 个,实际 %v", p)
}
// llm 就绪 ⇒ 拉起
bus.MarkLayerReady([]string{"llm"})
if len(launched) != 1 || launched[0] != "needsllm" {
t.Fatalf("llm 就绪后应拉起: %v", launched)
}
}
// ★★★ 判据:**只读隔离**(编译期保证,不是约定)。
//
// 断言 Scheduler 拿到的接口**没有**写方法 —— 如果给它 *kstate.SyncBus,
// 它就能谎报内核状态,让插件在依赖不存在时启动(病 A)。
func TestSchedulerCannotWriteSyncState(t *testing.T) {
// ★ 检查必须是**接口类型的方法集**,不是某个具体值。
//
// 我第一次写错了:用 `var st kstate.SyncState = kstate.NewSyncBus()` 再断言 `st.(writer)` ——
// 那检查的是**动态类型**(*kstate.SyncBus 当然有 MarkReady),判据当场报假红。
// 要约束的是"拿到 kstate.SyncState 的人**能不能**调写方法" ⇒ 必须查接口的方法集。
rt := reflect.TypeOf((*kstate.SyncState)(nil)).Elem()
forbidden := []string{"MarkReady", "MarkKernelComplete", "MarkLayerReady"}
for i := 0; i < rt.NumMethod(); i++ {
m := rt.Method(i).Name
for _, bad := range forbidden {
if m == bad {
t.Fatalf("★ kstate.SyncState(插件管理器拿到的接口)暴露了写方法 %s —— "+
"它能谎报内核状态,让插件在依赖不存在时启动(病 A)", m)
}
}
}
// 反向确认:读方法必须在(否则接口退化到没用了)
for _, want := range []string{"IsReady", "Snapshot", "AllReady", "KernelComplete"} {
if _, ok := rt.MethodByName(want); !ok {
t.Errorf("kstate.SyncState 缺少读方法 %s", want)
}
}
// Scheduler 持有的字段类型也必须是 kstate.SyncState(不能是 *kstate.SyncBus)
st := reflect.TypeOf(Scheduler{})
f, ok := st.FieldByName("bus")
if !ok {
t.Fatal("Scheduler 应有 bus 字段")
}
if f.Type.String() != "kstate.SyncState" {
t.Fatalf("★ Scheduler.bus 的类型是 %s —— 必须是只读的 kstate.SyncState", f.Type)
}
}
// ★★★ 判据:**不伴随启动的插件即使依赖齐了也不自动拉起**(§2.6.6)。
func TestSchedulerAutoStartFalseIsNotLaunched(t *testing.T) {
bus := kstate.NewSyncBus()
var launched []string
sch := NewScheduler(bus, func(p PluginSpec) error {
launched = append(launched, p.Name)
return nil
})
bus.MarkDeclared([]string{"config"})
sch.Submit([]PluginSpec{
{Name: "manual", RequiresFacilities: []string{"config"}, AutoStart: false},
})
bus.MarkLayerReady([]string{"config"})
bus.MarkKernelComplete()
if len(launched) != 0 {
t.Fatalf("★ auto_start=false 的插件被自动拉起了(应等显式启用): %v", launched)
}
if len(sch.Pending()) != 1 {
t.Fatalf("它应一直挂起等显式启用: %v", sch.Pending())
}
}
// ★★★ 判据:**就绪顺序可复现**(层内并发不得导致就绪顺序抖动)。
//
// 为何重要:syncbus 的就绪顺序决定哪些插件何时被拉起;
// 若顺序随机,同一份声明两次启动会拉出不同结果 ⇒ 不可审计、不可复现。
func TestSyncBusReadyOrderIsDeterministic(t *testing.T) {
// 模拟"层内并发完成":同一层 8 个设施,用 goroutine 乱序 MarkReady
var first []string
for round := 0; round < 5; round++ {
bus := kstate.NewSyncBus()
var wg sync.WaitGroup
fac := []string{"f1", "f2", "f3", "f4", "f5", "f6", "f7", "f8"}
for i, f := range fac {
wg.Add(1)
go func(i int, f string) {
defer wg.Done()
// 反过来启动,制造乱序完成
_ = i
bus.MarkReady(f)
}(i, f)
}
wg.Wait()
got := bus.ReadyInOrder()
if round == 0 {
first = got
continue
}
// 注意:MarkReady(单设施)的顺序本就取决于并发,不保证可复现;
// 设计保证的是 **MarkLayerReady**(层边界一次性写入)可复现。
// 所以这里只断言"同样的输入产生同样的集合"。
if len(got) != len(first) {
t.Fatalf("就绪集合大小抖动: %d vs %d", len(got), len(first))
}
}
// ★ 真正的可复现性断言:MarkLayerReady 一次性写入
var order []string
for round := 0; round < 3; round++ {
bus := kstate.NewSyncBus()
bus.MarkLayerReady([]string{"a", "b", "c"})
bus.MarkLayerReady([]string{"d", "e"})
got := bus.ReadyInOrder()
joined := strings.Join(got, ",")
if round == 0 {
order = got
continue
}
if joined != strings.Join(order, ",") {
t.Fatalf("★ MarkLayerReady 的就绪顺序不可复现:\n%v\n%v", order, got)
}
}
if strings.Join(order, ",") != "a,b,c,d,e" {
t.Fatalf("就绪顺序应为层内声明序: %v", order)
}
// ★ 幂等性:**重复标记**同一设施不得重复记录顺序。
//
// 为何要单独断言:Run 的层边界回调可能在某些路径重入
//(且调用方也可能手动重复标记)。若不幂等,order 会不断膨胀
// ⇒ 就绪顺序不再反映真实层序 ⇒ 依赖它的调度判断变得不可解释。
//
// 这条是补的:变异"去掉幂等检查"第一次没被咬住 —— 因为原判据
// 只跑了一份声明、没重复标记,测不到幂等性。
bus := kstate.NewSyncBus()
bus.MarkLayerReady([]string{"a", "b"})
bus.MarkLayerReady([]string{"a", "b"}) // 重复
bus.MarkLayerReady([]string{"c"})
if got := strings.Join(bus.ReadyInOrder(), ","); got != "a,b,c" {
t.Fatalf("★ 重复标记导致就绪顺序膨胀(不幂等): %v(期望 a,b,c)", got)
}
}
// ★★★ 判据:**内核未完成时绝不发 KernelComplete**(内核没装好就不该放插件)。
//
// 这条守的是:装配中途失败 ⇒ 未声明依赖的插件**不**被拉起。
func TestRunDoesNotMarkCompleteOnFailure(t *testing.T) {
bus := kstate.NewSyncBus()
mods := []Module{
&fakeModule{name: "A", provides: []string{"a"}},
&fakeModule{name: "B", provides: []string{"b"}, initErr: errFake},
}
plan, err := Build(mods)
if err != nil {
t.Fatal(err)
}
if _, err := Run(plan, RunOptions{Bus: bus}); err == nil {
t.Fatal("应失败")
}
if bus.KernelComplete() {
t.Fatal("★ 装配失败了却标记内核完成 —— 未声明依赖的插件会被拉起(病 A)")
}
}
// ★★★ 判据:**Run 在层边界自动发布本层设施**(不用模块自己标记)。
//
// 为何由 Run 做:模块自己标记会漏(静默漏一个设施 ⇒ 插件永远等不到),
// 且顺序取决于层内并发完成序。
func TestRunPublishesFacilitiesAtLayerBoundary(t *testing.T) {
bus := kstate.NewSyncBus()
mods := []Module{
&fakeModule{name: "config", provides: []string{"config", "config-write"}},
&fakeModule{name: "llm", provides: []string{"llm"}, requires: []string{"config"}},
}
plan, err := Build(mods)
if err != nil {
t.Fatal(err)
}
if _, err := Run(plan, RunOptions{Bus: bus}); err != nil {
t.Fatal(err)
}
for _, f := range []string{"config", "config-write", "llm"} {
if !bus.IsReady(f) {
t.Errorf("★ 设施 %q 未在层边界被发布", f)
}
}
if !bus.KernelComplete() {
t.Error("全部完成后应标记内核完成")
}
}
// ★★★ 判据:**空依赖列表 ≠ 已满足**(语义区分的直接断言)。
//
// 若这条反了,未声明依赖的插件会被提前拉起(见第一条判据)。
func TestAllReadyEmptyIsFalse(t *testing.T) {
bus := kstate.NewSyncBus()
bus.MarkKernelComplete()
if bus.AllReady(nil) {
t.Fatal("★ AllReady(空) 返回 true —— \"没有依赖\"被当成\"依赖全就绪\",未声明插件会提前拉起")
}
if bus.AllReady([]string{}) {
t.Fatal("★ AllReady(空切片) 返回 true")
}
// 非空且齐 ⇒ true
bus.MarkReady("x")
if !bus.AllReady([]string{"x"}) {
t.Fatal("依赖确实就绪应返回 true")
}
}
// ★★★ 判据:**拉起失败不阻塞其他插件**(一个坏插件不该拖垮全局)。
func TestSchedulerLaunchFailureDoesNotBlockOthers(t *testing.T) {
bus := kstate.NewSyncBus()
var okLaunched []string
sch := NewScheduler(bus, func(p PluginSpec) error {
if p.Name == "bad" {
return errFake
}
okLaunched = append(okLaunched, p.Name)
return nil
})
var failures int
sch.SetLaunchErrorHandler(func(PluginSpec, error) { failures++ })
bus.MarkDeclared([]string{"config"})
sch.Submit([]PluginSpec{
{Name: "bad", RequiresFacilities: []string{"config"}, AutoStart: true},
{Name: "good", RequiresFacilities: []string{"config"}, AutoStart: true},
})
bus.MarkLayerReady([]string{"config"})
if len(okLaunched) != 1 || okLaunched[0] != "good" {
t.Fatalf("★ 一个插件拉起失败影响了其他插件: %v", okLaunched)
}
if failures != 1 {
t.Fatalf("失败应被报告: %d", failures)
}
}
// ★★★ 判据:**重复 Submit 不重复拉起**(幂等)。
func TestSchedulerIsIdempotent(t *testing.T) {
bus := kstate.NewSyncBus()
n := 0
sch := NewScheduler(bus, func(PluginSpec) error { n++; return nil })
bus.MarkKernelComplete()
sch.Submit([]PluginSpec{{Name: "p", AutoStart: true}})
sch.Submit([]PluginSpec{{Name: "p", AutoStart: true}})
bus.MarkKernelComplete() // 再触发一次评估
if n != 1 {
t.Fatalf("★ 同一插件被拉起 %d 次(应幂等)", n)
}
}
// ★★★ 判据:**MarkReady 的通知也在锁外**(走单设施路径的订阅者不死锁)。
//
// 变异 8b 暴露的边界:MarkLayerReady 持锁回调会死锁(已被
// TestSchedulerDeclaredLaunchesEarly 咬住),但 MarkReady 是**另一条路径**
// (单个设施的标记),若它持锁回调,只有"既调 MarkReady 又订阅"的场景才会卡住。
// 补这条判据把两条路径都盖住。
func TestSyncBusMarkReadyNotifiesWithoutDeadlock(t *testing.T) {
bus := kstate.NewSyncBus()
got := make(chan string, 4)
// 订阅者**读状态**(真实回调必然会读,这正是死锁触发条件)
bus.OnUpdate(func(s kstate.SyncState) {
_ = s.Snapshot() // 持锁调用的话,这里会等 RLock ⇒ 死锁
got <- "ok"
})
done := make(chan struct{})
go func() {
defer close(done)
bus.MarkReady("config")
}()
select {
case <-got:
case <-done:
t.Fatal("★ MarkReady 未通知订阅者(订阅者拿不到更新 ⇒ 插件不会自动拉起)")
case <-timeAfter(5):
t.Fatal("★ MarkReady 持锁调用回调 ⇒ 死锁(回调读状态时会等锁)")
}
}
func timeAfter(sec int) <-chan struct{} {
ch := make(chan struct{})
go func() {
time.Sleep(time.Duration(sec) * time.Second)
close(ch)
}()
return ch
}