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 防腐判据(部分已随本次落地)
321 lines
9.0 KiB
Go
321 lines
9.0 KiB
Go
package kernelboot
|
||
|
||
// 内核装配层(syncbus / scheduler / KernelState)的**压力测试**。
|
||
//
|
||
// ## 为什么必须有压力测试(不是"跑一遍判据就够了")
|
||
//
|
||
// 这层代码的并发行为**只有压力才能暴露**:
|
||
// · 判据用 2~3 个 goroutine 验证语义正确性
|
||
// · 压力用数百 goroutine × 数千轮验证**没有数据竞争 / 死锁 / 状态丢失**
|
||
//
|
||
// 本仓已有两处真实教训支持这个判断:
|
||
// ① ModuleView 第一版把「当前模块」放在共享对象上 ⇒ 判据**偶发**失败
|
||
// (3 次里 1 次)—— 那是"偶发"而不是"必现",正是压力测试的领域
|
||
// ② syncbus 的 OnUpdate 若持锁回调 ⇒ **死锁**(只在回调读状态时触发)
|
||
//
|
||
// ## 压什么(本层真实的并发面)
|
||
//
|
||
// · SyncBus:内核写(MarkDeclared/MarkLayerReady/MarkKernelComplete)
|
||
// 与插件读(Known/IsReady/Snapshot/AllReady/**订阅回调**)并发
|
||
// · Scheduler:多 goroutine 并发 Submit(同一插件可能被重复提交)
|
||
// + 状态变化并发触发 evaluate
|
||
// · KernelState:同层模块**并行**写**各自**字段(不同字段,无竞争)
|
||
// · PlanFromGraph:并发读图(只读,但验证无共享可变状态)
|
||
// · Run:真实分层执行,验证有界并发真的没超
|
||
|
||
import (
|
||
"fmt"
|
||
"github.com/JianFeeeee/HologramAgent/internal/kstate"
|
||
"sync"
|
||
"sync/atomic"
|
||
"testing"
|
||
"time"
|
||
)
|
||
|
||
// ★★★ 压力:**kstate.SyncBus 读写并发**(内核写 + N 个插件读 + 订阅回调)。
|
||
//
|
||
// 用 -race 跑时,任何未保护的状态都会报数据竞争。
|
||
func TestStressSyncBusConcurrentReadWrite(t *testing.T) {
|
||
bus := kstate.NewSyncBus()
|
||
const readers = 64
|
||
const facilities = 200
|
||
|
||
var callbacks int64
|
||
// 订阅者**读状态**(这正是死锁的触发条件:若 fire 持锁调用回调,
|
||
// 回调里 Snapshot() 会等读锁 ⇒ 死锁。压力下必现)
|
||
for i := 0; i < 8; i++ {
|
||
bus.OnUpdate(func(s kstate.SyncState) {
|
||
_ = s.Snapshot()
|
||
_ = s.AllReady([]string{"llm"})
|
||
atomic.AddInt64(&callbacks, 1)
|
||
})
|
||
}
|
||
|
||
var wg sync.WaitGroup
|
||
stop := make(chan struct{})
|
||
|
||
// 读侧:N 个"插件"持续查询
|
||
for i := 0; i < readers; i++ {
|
||
wg.Add(1)
|
||
go func(i int) {
|
||
defer wg.Done()
|
||
for {
|
||
select {
|
||
case <-stop:
|
||
return
|
||
default:
|
||
}
|
||
f := fmt.Sprintf("fac%d", i%facilities)
|
||
_ = bus.Known(f)
|
||
_ = bus.IsReady(f)
|
||
_ = bus.Snapshot()
|
||
_ = bus.ReadyInOrder()
|
||
_ = bus.KernelComplete()
|
||
_ = bus.AllReady([]string{"fac1", "fac2"})
|
||
}
|
||
}(i)
|
||
}
|
||
|
||
// 写侧:内核持续标记
|
||
wg.Add(1)
|
||
go func() {
|
||
defer wg.Done()
|
||
for i := 0; i < facilities; i++ {
|
||
bus.MarkLayerReady([]string{fmt.Sprintf("fac%d", i)})
|
||
}
|
||
}()
|
||
|
||
// 让它跑一会儿
|
||
time.Sleep(400 * time.Millisecond)
|
||
bus.MarkKernelComplete()
|
||
time.Sleep(100 * time.Millisecond)
|
||
close(stop)
|
||
wg.Wait()
|
||
|
||
if atomic.LoadInt64(&callbacks) == 0 {
|
||
t.Fatal("订阅回调从未被调用 —— 压力没有真正覆盖通知路径")
|
||
}
|
||
if !bus.KernelComplete() {
|
||
t.Fatal("KernelComplete 应已置位")
|
||
}
|
||
t.Logf("回调触发 %d 次,%d 个并发读者", atomic.LoadInt64(&callbacks), readers)
|
||
}
|
||
|
||
// ★★★ 压力:**多 goroutine 并发 Submit**(同一插件可能被重复提交)。
|
||
//
|
||
// 真实场景:插件加载器可能分批提交(发现新插件、重扫目录),
|
||
// 而每次提交都会触发状态评估。并发下要保证:
|
||
//
|
||
// · 不重复拉起同一插件(幂等)
|
||
// · 不丢插件(全部最终被拉起)
|
||
// · 无数据竞争
|
||
func TestStressSchedulerConcurrentSubmit(t *testing.T) {
|
||
bus := kstate.NewSyncBus()
|
||
bus.MarkDeclared([]string{"config", "llm", "memory"})
|
||
// ★ 必须 MarkLayerReady:MarkDeclared 只登记"名字存在",
|
||
// 不代表就绪。插件要求 config **就绪**才拉起。
|
||
// (我第一版漏了这步 ⇒ 压力判据报"0 个拉起" —— 那是判据 bug,非缺陷。)
|
||
bus.MarkLayerReady([]string{"config", "llm", "memory"})
|
||
|
||
const plugins = 300
|
||
var mu sync.Mutex
|
||
launched := map[string]int{}
|
||
|
||
sch := NewScheduler(bus, func(p PluginSpec) error {
|
||
mu.Lock()
|
||
launched[p.Name]++
|
||
mu.Unlock()
|
||
return nil
|
||
})
|
||
sch.SetLaunchErrorHandler(func(p PluginSpec, err error) {
|
||
t.Errorf("不应有拉起错误: %v", err)
|
||
})
|
||
|
||
// 16 个 goroutine 并发提交同一批插件(模拟重复扫描)
|
||
var wg sync.WaitGroup
|
||
for g := 0; g < 16; g++ {
|
||
wg.Add(1)
|
||
go func(g int) {
|
||
defer wg.Done()
|
||
var specs []PluginSpec
|
||
for i := 0; i < plugins; i++ {
|
||
specs = append(specs, PluginSpec{
|
||
Name: fmt.Sprintf("p%03d", i),
|
||
RequiresFacilities: []string{"config"},
|
||
AutoStart: true,
|
||
})
|
||
}
|
||
sch.Submit(specs)
|
||
}(g)
|
||
}
|
||
bus.MarkKernelComplete()
|
||
wg.Wait()
|
||
|
||
mu.Lock()
|
||
defer mu.Unlock()
|
||
if len(launched) != plugins {
|
||
t.Fatalf("★ 应拉起 %d 个插件,实际 %d(并发提交丢了插件)", plugins, len(launched))
|
||
}
|
||
for name, n := range launched {
|
||
if n != 1 {
|
||
t.Fatalf("★ 插件 %s 被拉起 %d 次(应恰好 1 次 —— 并发下幂等性破坏)", name, n)
|
||
}
|
||
}
|
||
}
|
||
|
||
// ★★★ 压力:**状态变化 + 提交并发**(真实时序:内核在标记就绪的同时插件在提交)。
|
||
func TestStressSchedulerConcurrentStateChange(t *testing.T) {
|
||
bus := kstate.NewSyncBus()
|
||
bus.MarkDeclared([]string{"config", "llm", "memory", "stagehost"})
|
||
|
||
var launched int64
|
||
sch := NewScheduler(bus, func(p PluginSpec) error {
|
||
atomic.AddInt64(&launched, 1)
|
||
return nil
|
||
})
|
||
sch.SetLaunchErrorHandler(func(p PluginSpec, err error) {})
|
||
|
||
const plugins = 200
|
||
var wg sync.WaitGroup
|
||
|
||
// 写侧:内核逐层标记就绪
|
||
wg.Add(1)
|
||
go func() {
|
||
defer wg.Done()
|
||
for _, f := range []string{"config", "llm", "memory", "stagehost"} {
|
||
bus.MarkLayerReady([]string{f})
|
||
}
|
||
bus.MarkKernelComplete()
|
||
}()
|
||
|
||
// 提交侧:8 个并发提交者
|
||
for g := 0; g < 8; g++ {
|
||
wg.Add(1)
|
||
go func() {
|
||
defer wg.Done()
|
||
for i := 0; i < plugins; i++ {
|
||
sch.Submit([]PluginSpec{{
|
||
Name: fmt.Sprintf("q%03d", i),
|
||
RequiresFacilities: []string{"config"},
|
||
AutoStart: true,
|
||
}})
|
||
}
|
||
}()
|
||
}
|
||
wg.Wait()
|
||
bus.MarkKernelComplete() // 再触发一次评估,确保挂起的也被拉起
|
||
|
||
if n := atomic.LoadInt64(&launched); n != plugins {
|
||
t.Fatalf("★ 并发 提交×状态变化 下应拉起 %d 个,实际 %d", plugins, n)
|
||
}
|
||
}
|
||
|
||
// ★★★ 压力:**PlanFromGraph 并发读**(多插件/多协程同时读启动计划)。
|
||
func TestStressPlanFromGraphConcurrentRead(t *testing.T) {
|
||
mods := planTestModules()
|
||
built, err := Build(mods)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
gf := decodeGraphOrFatal(t, EncodeGraph(planToEdgesForTest(built)))
|
||
|
||
var wg sync.WaitGroup
|
||
errs := make(chan error, 64)
|
||
for i := 0; i < 64; i++ {
|
||
wg.Add(1)
|
||
go func() {
|
||
defer wg.Done()
|
||
for j := 0; j < 50; j++ {
|
||
plan, err := PlanFromGraph(gf, mods)
|
||
if err != nil {
|
||
errs <- err
|
||
return
|
||
}
|
||
if len(plan.Layers) != 3 {
|
||
errs <- fmt.Errorf("层数不对: %d", len(plan.Layers))
|
||
return
|
||
}
|
||
}
|
||
}()
|
||
}
|
||
wg.Wait()
|
||
close(errs)
|
||
for err := range errs {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
|
||
// ★★★ 压力:**Run 的并发上限真的生效**(大量模块同层时不得超出)。
|
||
//
|
||
// ★ 这条与判据的 TestRunRespectsMaxParallel 不同:那条 8 个模块,
|
||
// 这条 **128 个模块**同层 —— 验证信号量在高压下不漏(观察峰值)。
|
||
func TestStressRunRespectsMaxParallelAtScale(t *testing.T) {
|
||
const n = 128
|
||
const maxP = 8
|
||
|
||
var active, peak int64
|
||
var mu sync.Mutex
|
||
var mods []Module
|
||
for i := 0; i < n; i++ {
|
||
mods = append(mods, &fakeModule{
|
||
name: fmt.Sprintf("m%03d", i),
|
||
provides: []string{fmt.Sprintf("f%03d", i)},
|
||
onInit: func() {
|
||
cur := atomic.AddInt64(&active, 1)
|
||
mu.Lock()
|
||
if cur > peak {
|
||
peak = cur
|
||
}
|
||
mu.Unlock()
|
||
time.Sleep(2 * time.Millisecond)
|
||
atomic.AddInt64(&active, -1)
|
||
},
|
||
})
|
||
}
|
||
plan, err := Build(mods)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
res, err := Run(plan, RunOptions{MaxParallel: maxP})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
mu.Lock()
|
||
observed := peak
|
||
mu.Unlock()
|
||
if observed > maxP {
|
||
t.Fatalf("★ 并发上限 %d 被突破:观测峰值 %d(%d 个模块同层)", maxP, observed, n)
|
||
}
|
||
if len(res.Order) != n {
|
||
t.Fatalf("应执行 %d 个模块,实际 %d", n, len(res.Order))
|
||
}
|
||
t.Logf("%d 个模块同层,上限 %d,观测峰值 %d", n, maxP, observed)
|
||
}
|
||
|
||
// ★★★ 压力:**kstate.State 同层并行写**(各写自己字段,无竞争)。
|
||
func TestStressKernelStateParallelWrites(t *testing.T) {
|
||
ks := kstate.New()
|
||
const n = 256
|
||
|
||
var wg sync.WaitGroup
|
||
for i := 0; i < n; i++ {
|
||
wg.Add(1)
|
||
go func(i int) {
|
||
defer wg.Done()
|
||
// 每个"模块"只写自己字段 —— 这正是真实约定。
|
||
// 若有人违反(写别人的字段),-race 会报。
|
||
ks.AddCloser(func() {})
|
||
}(i)
|
||
}
|
||
wg.Wait()
|
||
if len(ks.Closers) != n {
|
||
t.Fatalf("应记录 %d 个关闭函数,实际 %d(AddCloser 有丢失)", n, len(ks.Closers))
|
||
}
|
||
if err := ks.CloseAll(); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(ks.Closers) != 0 {
|
||
t.Fatal("CloseAll 后应清空")
|
||
}
|
||
}
|