Files
HomeAgent/internal/kstate/syncbus.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

291 lines
9.3 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 kstate
// syncbus:内核就绪状态的同步快照,驱动插件调度(设计 §2.6)。
//
// ## 一句话
//
// 内核每完成一层装配就把该层贡献的设施标记为"就绪";
// 插件管理器**只读**这份状态,据此拉起"依赖已齐"的插件。
//
// ## 为什么要有它(解决什么)
//
// 现状(`cmd/homed/main.go:291 loadPlugins`)是**一刀切**:
// 12 个 init* 全部做完才加载全部插件。
// 于是"只读本地文件"的插件也要等多模态空间(ONNX 加载)+ 知识库建索引做完。
//
// 而内核那 12 步本身有明确先后(§2.3 算出 4 层),"阶段"是天然存在的,
// 只是此前没有对外发布。syncbus 就是那个发布物。
//
// ## ★ 权限:插件管理器只读(这不是习惯,是设计)
//
// 若插件管理器能写 syncbus,它就能**谎报内核状态**,让插件在自己依赖的东西
// 还不存在时启动 —— 那正是病 A(静默顺序错)。
//
// 所以类型层面分离:内核拿 `*SyncBus`(可写),
// 插件管理器只拿 `SyncState` 接口(**上面没有写方法**)。
// 想写也写不了 —— 编译期保证,而非"约定不要写"。
// 同 `Get[T]` 泛型取用(类型错=编译不过)是一个思路。
import (
"sort"
"sync"
)
// SyncState 是 syncbus 的**只读**视图。
//
// ★ 刻意不含 MarkReady/Reset:插件管理器拿到的就是这个接口,
// 因此它**在编译期就不可能**篡改内核状态。
type SyncState interface {
// IsReady 报告某设施是否已就绪。
IsReady(facility string) bool
// Snapshot 返回当前全部就绪状态的副本(调用方改不动内部)。
Snapshot() map[string]bool
// ReadyInOrder 按**就绪顺序**返回设施名(调试/判据用)。
ReadyInOrder() []string
// KernelComplete 报告内核是否已完全初始化(最后一层完成)。
KernelComplete() bool
// AllReady 报告给定设施是否**全部**就绪(插件调度器的核心判断)。
//
// ★ 空列表返回 false(不是 true):见实现处的说明 ——
// "没有依赖"与"依赖全就绪"是不同语义,混淆会让未声明依赖的插件提前拉起。
AllReady(facilities []string) bool
// Known 报告某设施名**是否被内核注册过**(不论是否已就绪)。
//
// ## ★ 为什么必须有它(修一个"静默永不启动"的缺口)
//
// 插件声明 `requires_facilities` 是**字符串**。若拼错(如写 "llm " 或
// "llmservice"),原本的行为是:它永远等不到 → **永不拉起**,
// 且**没有任何报错**、没有超时兜底(我第一版设计文档写了"超时兜底"
// 但从未实现)。
//
// 这正是本仓反复吃亏的"静默降级"形态:系统看着正常,某插件就是不工作。
//
// ⇒ 有了 Known,调度器能在**提交时**立刻报错:
// "插件 X 声明了内核从未注册过的设施 Y(可能是拼写错误)"
// 并列出全部已知设施名供对照。
Known(facility string) bool
// KnownFacilities 返回内核**注册过的全部设施名**(含尚未就绪的)。
//
// 用途:错误信息里列出可选值(让"拼错了"当场可修)。
KnownFacilities() []string
}
// SyncBus 是可写的同步总线(内核持有)。
type SyncBus struct {
mu sync.RWMutex
// known 是内核**注册过的全部设施名**(不论是否已就绪)。
//
// ★ 它存在的唯一目的是让"插件拼错设施名"从**静默永不启动**
// 变成**提交时立刻报错**(见 SyncState.Known 的说明)。
known map[string]bool
ready map[string]bool
order []string
complete bool
onUpdate []func(SyncState)
notifyMu sync.Mutex
}
// NewSyncBus 创建空总线。
func NewSyncBus() *SyncBus {
return &SyncBus{ready: map[string]bool{}, known: map[string]bool{}}
}
// MarkReady 标记一个设施就绪。**只有内核该调它。**
//
// 幂等:重复标记同一设施不报错(便于层边界回调重入)。
// 但**不重复记录**顺序,保证 ReadyInOrder 可复现。
func (b *SyncBus) MarkReady(facility string) {
if facility == "" {
return
}
b.mu.Lock()
if b.ready[facility] {
b.mu.Unlock()
return
}
b.ready[facility] = true
b.order = append(b.order, facility)
b.mu.Unlock()
b.fire()
}
// MarkLayerReady 在**层边界**标记该层所有模块贡献的设施(设计 §2.6.2)。
//
// ★ 为何以"层"而不是"单个模块"为同步点:
// 层内是**并发**执行的,若层内逐个模块标记就绪,就绪顺序会**不确定**,
// 而判据要求可复现。层边界天然是一个确定的同步点。
func (b *SyncBus) MarkLayerReady(provides []string) {
// 先在锁内一次性写入,再统一通知(避免 N 次通知、N 次唤醒)
b.mu.Lock()
changed := false
for _, f := range provides {
if f == "" {
continue
}
b.known[f] = true // 注册过(不论是否首次就绪)
if b.ready[f] {
continue
}
b.ready[f] = true
b.order = append(b.order, f)
changed = true
}
b.mu.Unlock()
if changed {
b.fire()
}
}
// MarkDeclared 在**执行前**登记一批设施名(视为"内核注册过")。
//
// ## ★ 为什么需要它(时序)
//
// 插件的 `requires_facilities` 校验要在**插件提交时**做(那是错误最该被发现
// 的时刻,紧挨着写错的插件作者)。但插件的提交发生在内核装配**完成之后**
// —— 那时所有层都跑完了,Known 自然齐了。
//
// 但仍然需要本方法,因为:
//
// · 装配**中途失败**时,后面的层没跑 ⇒ 那些设施名从未登记 ⇒
// 若此时还有插件提交,会报"内核从未注册过 X"(**误报**:其实是没跑到)
// · 判据要能在**不执行**的情况下校验"插件声明 ⊆ 内核声明"
// (只看声明,不看执行结果)
//
// ⇒ 装配开始时把**全部模块的 Provides** 一次性登记为 known。
// 这样 Known 的语义精确为"**模块声明过**这个设施",与"是否已就绪"解耦。
func (b *SyncBus) MarkDeclared(facilities []string) {
b.mu.Lock()
for _, f := range facilities {
if f != "" {
b.known[f] = true
}
}
b.mu.Unlock()
}
// MarkKernelComplete 标记内核已完全初始化。
//
// 这是**未声明依赖的插件**的启动信号(默认路径 = 现行为,§2.6.5)。
func (b *SyncBus) MarkKernelComplete() {
b.mu.Lock()
if b.complete {
b.mu.Unlock()
return
}
b.complete = true
b.mu.Unlock()
b.fire()
}
// OnUpdate 注册状态变化回调(插件调度器用它重新检查挂起的插件)。
//
// 回调在**不持锁**的情况下调用 —— 否则回调里再读 syncbus 会死锁。
// (这个坑很容易踩:回调显然要读状态,持锁调用必然死锁。)
func (b *SyncBus) OnUpdate(fn func(SyncState)) {
b.notifyMu.Lock()
b.onUpdate = append(b.onUpdate, fn)
b.notifyMu.Unlock()
}
func (b *SyncBus) fire() {
b.notifyMu.Lock()
cbs := make([]func(SyncState), len(b.onUpdate))
copy(cbs, b.onUpdate)
b.notifyMu.Unlock()
for _, fn := range cbs {
fn(b) // b 实现了 SyncState(只读方法)
}
}
// --- SyncState 实现(只读) ---
func (b *SyncBus) IsReady(facility string) bool {
b.mu.RLock()
defer b.mu.RUnlock()
return b.ready[facility]
}
func (b *SyncBus) Snapshot() map[string]bool {
b.mu.RLock()
defer b.mu.RUnlock()
out := make(map[string]bool, len(b.ready))
for k, v := range b.ready {
out[k] = v
}
return out
}
func (b *SyncBus) ReadyInOrder() []string {
b.mu.RLock()
defer b.mu.RUnlock()
out := make([]string, len(b.order))
copy(out, b.order)
return out
}
func (b *SyncBus) KernelComplete() bool {
b.mu.RLock()
defer b.mu.RUnlock()
return b.complete
}
// Known 报告某设施名是否被内核注册过(不论是否已就绪)。
//
// ★ 为何"注册过但未就绪"也算 Known:那是**正常的中间状态**
// (如 llm 声明了但还在第 1 层)。真正的错误是"内核从未注册过这个名字"。
func (b *SyncBus) Known(facility string) bool {
b.mu.RLock()
defer b.mu.RUnlock()
return b.known[facility]
}
// KnownFacilities 返回内核注册过的全部设施名(升序,便于对照阅读)。
func (b *SyncBus) KnownFacilities() []string {
b.mu.RLock()
defer b.mu.RUnlock()
out := make([]string, 0, len(b.known))
for k := range b.known {
out = append(out, k)
}
sort.Strings(out)
return out
}
// AllReady 报告给定的设施**全部**就绪(插件调度器的核心判断)。
//
// 空列表 = 真的全就绪 → 返回 false。
//
// ★ 为何空列表返回 false 而不是 true:
// "没有依赖" 与 "依赖全就绪" 是**不同语义**,混淆会让"未声明依赖的插件"
// 被当成"依赖已满足"而**提前拉起** —— 那就破坏了 §2.6.5 的存量兼容。
// 未声明走的是 KernelComplete 那条路径,与这条刻意分开。
func (b *SyncBus) AllReady(facilities []string) bool {
if len(facilities) == 0 {
return false
}
b.mu.RLock()
defer b.mu.RUnlock()
for _, f := range facilities {
if !b.ready[f] {
return false
}
}
return true
}
// Missing 返回尚未就绪的设施名(诊断用:告诉用户"还差什么")。
func (b *SyncBus) Missing(facilities []string) []string {
b.mu.RLock()
defer b.mu.RUnlock()
var out []string
for _, f := range facilities {
if !b.ready[f] {
out = append(out, f)
}
}
sort.Strings(out)
return out
}