Files
HomeAgent/internal/kernelboot/scheduler.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

311 lines
11 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 状态决定**何时**拉起各插件(设计 §2.6.4)。
//
// ## 核心规则(三条,按优先级)
//
// 未声明依赖 ⇒ 等内核**完全**初始化(默认路径 = 现行为,§2.6.5)
// 声明了依赖且全就绪 ⇒ **立即**拉起
// 声明了依赖但未全就绪 ⇒ 挂起,等 syncbus 更新后再判断
//
// ## ★ 兼容性的落点就在第一条
//
// 存量 42 个插件都不声明依赖 ⇒ 全部走第一条 ⇒ 行为**逐字不变**。
// 这不是"顺便兼容",而是本设计的硬约束:42 个插件里任何一个若因本设计
// 改变了启动时机,就可能在不该动的时候动(例如假设"Start 时 llm 已就绪")。
// **静默改变启动时机 = 静默引入竞态。**
//
// ## 为什么"未声明"不能等价于"依赖集为空"
//
// 空依赖集在数学上"已满足",若按此处理,未声明的插件会在**第一层完成后**
// 就被拉起 —— 比现状早得多,直接破坏兼容性。
// 所以"未声明"与"声明了空集"是**不同**语义,必须分开判断(见 Wants)。
//
// ## 为什么调度器不自带并发控制
//
// 拉起插件的实际动作(load/Start)由插件管理器做 —— 那里已有
// Registry 的锁与生命周期管理。本调度器只负责**判断时机**,
// 保持单一职责,也让判断逻辑可以脱离真实插件系统被单测(判据的关键)。
import (
"fmt"
"github.com/JianFeeeee/HologramAgent/internal/kstate"
"sort"
"strings"
"sync"
)
// PluginSpec 是调度器需要知道的插件声明(设计 §2.6.4/2.6.6)。
type PluginSpec struct {
Name string
// RequiresFacilities 是插件声明需要的设施名。
//
// nil/空 ⇒ **未声明**(走"等内核完全初始化"路径)
// 非空 ⇒ 声明的依赖;全就绪即拉起
RequiresFacilities []string
// AutoStart 为 false 表示"不伴随启动":即使依赖齐了也**不**自动拉起,
// 等 agent 经 plugin_enable 显式启用(设计 §2.6.6)。
//
// 用指针语义的注意:这里用 bool + 一个显式标记区分"未声明"与"false"。
// 但为了简洁,约定:**缺省(未声明)= true**(伴随启动,与现状一致),
// 由 manifest 层负责把"未声明"翻译成 true。
AutoStart bool
}
// Scheduler 按 syncbus 状态调度插件。
type Scheduler struct {
mu sync.Mutex
bus kstate.SyncState
pending []PluginSpec
launched map[string]bool
launch func(PluginSpec) error
// onLaunchErr 记录拉起失败(不中断其他插件)
onLaunchErr func(PluginSpec, error)
}
// NewScheduler 创建调度器。launch 是实际拉起动作(由插件管理器提供)。
//
// ★ bus 参数类型是 **kstate.SyncState**(只读接口),不是 *kstate.SyncBus。
// 这就是设计 §2.6.3 的权限隔离:调度器**在编译期就没有**写 syncbus 的能力。
func NewScheduler(bus kstate.SyncState, launch func(PluginSpec) error) *Scheduler {
s := &Scheduler{
bus: bus,
launch: launch,
launched: map[string]bool{},
}
// ★ 订阅状态变化:syncbus 每次更新(层边界 / 内核完成)就重新评估挂起队列。
//
// 这是"自动拉起"的实现方式:调度器不需要轮询(空转)也不需要调用方
// 记得在每处手动触发(漏了就会静默卡住)。订阅一次,永久生效。
//
// 若 bus 不支持订阅(只传了个只读快照实现),退化为"只在 Submit 时评估"——
// 那种情况下调度器仍可用,只是不会自动跟进后续状态变化。
if sub, ok := bus.(interface{ OnUpdate(func(kstate.SyncState)) }); ok {
sub.OnUpdate(func(kstate.SyncState) { s.evaluate() })
}
return s
}
// SetLaunch 补上拉起动作(供"两段式"调度:装配前订阅、装配后接动作)。
//
// ## 为什么需要它(真实缺口)
//
// 插件调度器必须在**装配开始前**订阅 syncbus,否则"声明提前启动"不会生效
// (提交时全部设施早已就绪 ⇒ 永远退化成"立即拉起")。
//
// 但**拉起动作**依赖插件注册表,而那是 stage-host 模块的产物
// ⇒ 装配前拿不到。所以分两段:
//
// · 装配前:NewScheduler(bus, nil) 只订阅
// · 装配后:SetLaunch(fn) 接上真实动作
//
// ★ 此时可能已有插件被标记 launched(分层过程中达到条件)。
// 调用方需自行补偿"已标记但未真正加载"的那些 —— 见 LaunchedFlag。
func (s *Scheduler) SetLaunch(fn func(PluginSpec) error) {
s.mu.Lock()
s.launch = fn
s.mu.Unlock()
// ★ 立刻重新评估一次:接上动作之前可能已有插件达到条件并被标记
// (它们在 syncbus 更新时就评估过,但那时 launch==nil ⇒ 只标记未执行)。
// 不补这一下,它们要等到**下一次** syncbus 更新才会被真正拉起 ——
// 而若内核装配已结束(不再有更新),就永远不拉。
s.evaluate()
}
// LaunchedFlag 报告某插件是否已被标记为"可拉起/已拉起"。
//
// ★ 与 Launched()(返回列表)分开:本方法用于**单个查询**,
// 供两段式调度的补偿逻辑判断"它到条件了但可能还没真正加载"。
func (s *Scheduler) LaunchedFlag(name string) bool {
s.mu.Lock()
defer s.mu.Unlock()
return s.launched[name]
}
// SetLaunchErrorHandler 注册拉起失败的观察者(诊断用)。
func (s *Scheduler) SetLaunchErrorHandler(fn func(PluginSpec, error)) {
s.mu.Lock()
s.onLaunchErr = fn
s.mu.Unlock()
}
// Submit 提交一批插件声明。返回**立即可拉起**的那些插件名。
//
// 未达条件的进挂起队列,等 OnUpdate 触发时再评估。
func (s *Scheduler) Submit(specs []PluginSpec) []string {
// ★ 先校验:声明的设施名必须是**内核注册过的**。
//
// ## 为什么这条校验必须存在(修一个"静默永不启动"的缺口)
//
// 插件声明 `requires_facilities` 是字符串。拼错时原行为是:
// 永远等不到 ⇒ **永不拉起**,且**无任何报错**、无超时兜底。
// 那是本仓反复吃亏的"静默降级"形态(系统看着正常,某插件就是不工作)。
//
// 有了这条校验,拼错在**提交那一刻**就报出来,并列出全部已知设施名
// 供对照("拼错了"当场可修)。
//
// ★ 为什么在这里而不在 canLaunch:canLaunch 是**每轮状态变化**都会调的
// 热路径,在那里报错会重复刷屏;而"名字不存在"是**一次性**的静态错误,
// 提交时检查一次即可。
s.mu.Lock()
var rejected []PluginSpec
var valid []PluginSpec
for _, p := range specs {
if bad := s.unknownFacilities(p); len(bad) > 0 {
rejected = append(rejected, p)
continue
}
valid = append(valid, p)
}
s.pending = append(s.pending, valid...)
errFn := s.onLaunchErr
s.mu.Unlock()
// 报错在锁外(回调可能反查调度器状态 ⇒ 持锁会死锁)
for _, p := range rejected {
bad := s.unknownFacilities(p)
err := fmt.Errorf("插件 %s 声明的设施名内核**从未注册过**: %v —— "+
"这通常是拼写错误。内核已知的设施名: %v。"+
"(若不校验,该插件会**永远等不到就绪**而静默不启动)",
p.Name, bad, s.bus.KnownFacilities())
if errFn != nil {
errFn(p, err)
}
}
return s.evaluate()
}
// unknownFacilities 返回插件声明里**内核从未注册过**的设施名(拼错检测)。
func (s *Scheduler) unknownFacilities(p PluginSpec) []string {
var bad []string
for _, f := range p.RequiresFacilities {
if !s.bus.Known(f) {
bad = append(bad, f)
}
}
sort.Strings(bad)
return bad
}
// Pending 返回仍在挂起的插件名(诊断/判据用)。
func (s *Scheduler) Pending() []string {
s.mu.Lock()
defer s.mu.Unlock()
out := make([]string, 0, len(s.pending))
for _, p := range s.pending {
out = append(out, p.Name)
}
sort.Strings(out)
return out
}
// Launched 返回已拉起的插件名(判据用)。
func (s *Scheduler) Launched() []string {
s.mu.Lock()
defer s.mu.Unlock()
out := make([]string, 0, len(s.launched))
for k := range s.launched {
out = append(out, k)
}
sort.Strings(out)
return out
}
// evaluate 挑出当前可拉起的插件并拉起它们。
//
// 返回本次真正拉起的插件名。
func (s *Scheduler) evaluate() []string {
s.mu.Lock()
// 挑出可拉起的 + 留下的。
//
// ★★ 必须**就地去重**:pending 里可能有同一插件的多份副本,
// 因为并发 Submit 各自把同一批插件追加进来。
//
// 实测(压力测试 TestStressSchedulerConcurrentStateChange):
// 8 个 goroutine 并发提交 200 个插件 + 内核并发标记就绪
// ⇒ 同一插件在**同一次** evaluate 的 ready 列表里出现多次
// ⇒ `launched` 查重救不了(它只在**跨轮**生效)⇒ 被拉起 203 次。
//
// 后果:插件被重复加载(spawn 两个进程 / 工具注册冲突)。
//
// 去重用 seen 集合(同一次 evaluate 内),并把它记进 launched
// 以确保跨轮也幂等 —— 两者都需要。
var ready []PluginSpec
var still []PluginSpec
seen := map[string]bool{}
for _, p := range s.pending {
if s.launched[p.Name] || seen[p.Name] {
continue // 已拉起(跨轮)或本批已选中(同批)⇒ 丢弃
}
if s.canLaunch(p) {
seen[p.Name] = true
ready = append(ready, p)
} else {
still = append(still, p)
}
}
s.pending = still
for _, p := range ready {
s.launched[p.Name] = true
}
launchFn := s.launch
errFn := s.onLaunchErr
s.mu.Unlock()
// ★ 在**不持锁**的情况下执行拉起动作:
// 拉起可能很慢(spawn 进程、握手),持锁会让其他插件全卡住;
// 且拉起过程可能回调调度器(如插件注册新插件)⇒ 死锁。
var names []string
for _, p := range ready {
names = append(names, p.Name)
if launchFn == nil {
continue
}
if err := launchFn(p); err != nil && errFn != nil {
errFn(p, err)
}
}
sort.Strings(names)
return names
}
// canLaunch 判断单个插件当前是否可拉起。
func (s *Scheduler) canLaunch(p PluginSpec) bool {
// ① 不伴随启动:依赖齐了也**不**自动拉起(§2.6.6)
if !p.AutoStart {
return false
}
// ② 未声明依赖 ⇒ 等内核**完全**初始化(§2.6.5,存量插件走这条)
if len(p.RequiresFacilities) == 0 {
return s.bus.KernelComplete()
}
// ③ 声明了依赖 ⇒ 全就绪即拉起
return s.bus.AllReady(p.RequiresFacilities)
}
// WaitString 渲染"还差什么"(供日志:让等待可解释,而不是静默卡住)。
func (s *Scheduler) WaitString(p PluginSpec) string {
if !p.AutoStart {
return "不伴随启动(等显式启用)"
}
if len(p.RequiresFacilities) == 0 {
return "等内核完全初始化"
}
// AllReady 需要 *kstate.SyncBus;这里只做只读渲染,用 Snapshot 判断
snap := s.bus.Snapshot()
var missing []string
for _, f := range p.RequiresFacilities {
if !snap[f] {
missing = append(missing, f)
}
}
if len(missing) == 0 {
return "依赖已就绪"
}
sort.Strings(missing)
return fmt.Sprintf("还差: %s", strings.Join(missing, ","))
}