Files
HomeAgent/internal/kernelmod/update/mod.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

150 lines
6.7 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 update 是 update 模块的适配层:把 internal/update 的能力接进内核模块体系。
//
// ## 为什么分两层(与仓库其他模块同一惯例)
//
// internal/update/ 业务实现(版本比较、解包、替换、回滚、Release 查询)
// internal/kernelmod/update/ 适配层(读 ks → 调业务包 → 写回 ks)
//
// 参照 supervisor 模块:实现在 internal/supervisor,这里只做装配。
// 分开的理由:internal/update 要被 CLI / WebAPI / agent 工具三端调用,
// 它们都没有 KernelState;而模块体系只有内核启动一条路径。
//
// ## 它为什么必须在第 0 层
//
// 肩负「启动失败回滚上一次升级」的职责(详见 internal/update/rollback.go)。
// 该职责必须早于所有其他模块:若排在后面,升级后新内核在 memory/llm 阶段
// 崩了,而回滚还没跑 ⇒ 机器进崩溃循环,只能靠 guard 的 failback 兜底。
// failback 是最后手段,不该承担"换错二进制"这种已知可回滚的故障。
package update
import (
"github.com/JianFeeeee/HologramAgent/internal/kstate"
"log"
"path/filepath"
"github.com/JianFeeeee/HologramAgent/internal/kernelboot"
"github.com/JianFeeeee/HologramAgent/internal/kernelmod"
upd "github.com/JianFeeeee/HologramAgent/internal/update"
)
// FacilityVersion 是本模块提供的设施名。
//
// 语义:启动时的版本自检与回滚判定**已完成**。需要按版本行事的模块
// (healthcheck 上报版本、pluginhost 判插件兼容性)应 Requires 它,
// 以确保读到的是回滚后的最终版本,而不是升级前的那个。
const FacilityVersion = "kernel.version"
// Module 实现 kernelboot.Module。
type Module struct{}
func (Module) Name() string { return "update" }
func (Module) Requires() []string { return nil } // 第 0 层:不依赖任何人
func (Module) Provides() []string { return []string{FacilityVersion} }
// Init 执行版本自检,必要时回滚上一次升级。
func (Module) Init(ks *kstate.State) error {
// ⓪ 把内核**真实**的 dataDir 注入 update 包。
//
// 必须最先做:pending 标记/备份/回滚验全都靠它。进程内调用方
// (pluginmgr / webui)用 upd.KernelDataDir() 取值,而不是自己推导。
//
// 推导必错(实测三类):从 exe 推 → /usr/bin/data(只读,备份必挂);
// 硬编码 → 迁移后指向不存在的目录;读 env → 未必与 -data 一致。
upd.SetKernelDataDir(ks.DataDir)
st := &upd.State{}
ks.Update = st
// ⓪ 把内核**真实**的 --data 注入 update 包。
//
// 进程内调用方(pluginmgr / webui)用 upd.KernelDataDir() 取值,
// 而不是自己推导路径。推导必错:生产是
//
// ExecStart=/usr/bin/homed --role=guard --data /var/lib/hologram
//
// 而“exe 同级 data”这种推导会得出 /usr/bin/data(只读目录,建备份必 EROFS;
// 且 pending 标记落到内核读不到的地方 ⇒ 静默不回滚)。
upd.SetKernelDataDir(ks.DataDir)
if ks.DataDir == "" {
// 不阻断启动(第 0 层职责是回滚判定,不是参数校验),
// 但必须响亮:此时任何升级都会被 Apply 的守卫拒绝。
log.Printf("[update] ks.DataDir 为空:内核升级将一律被拒绝(无回滚能力)")
}
bin := upd.KernelBinaryPath()
st.BinaryPath = bin
// ① 自报版本(升级是否真换成功,只有二进制自己能答)
v, err := upd.ReadBinVersion(bin)
if err != nil {
// 读不出来不等于致命:可能是极老版本(无 --version)。
// 没有证据表明升级失败 ⇒ 不回滚,记录后继续。
log.Printf("[update] kernel version probe failed: %v", err)
st.ProbeErr = err
} else {
st.Version, st.Commit, st.SDKVersion = v.Version, v.Commit, v.SDKVersion
log.Printf("[update] kernel %s (commit %s, sdk %s)", v.Version, upd.ShortCommit(v.Commit), v.SDKVersion)
}
// ② 上一次升级的回滚判定
//
// ★ rerr != nil 必须中止启动:继续装配等于用一个**已知**回滚失败的
// 二进制(RollbackIfUnconfirmed 已保证只在"回滚成功且回读校验通过"时
// 返回 nil,其余失败均不清 marker)。
//
// ★ 但 marker **损坏**(掉电截断)不算失败:RollbackIfUnconfirmed
// 已经把坏 marker 改名留存并返回 (false,nil),它自己就吞掉了那种情形。
// 早前版本在这里 `if rerr != nil { return rerr }` 之前把解析错误原样
// 上抛 ⇒ 内核拒启动、且走不到回滚分支 ⇒ 既起不来也回滚不了。
rolled, from, to, rerr := upd.RollbackIfUnconfirmed(ks.DataDir, bin)
if rerr != nil {
// 回滚失败必须响亮:继续装配等于用一个已知起不来的版本
return rerr
}
if rolled {
log.Printf("[update] ROLLED BACK kernel %s → %s(上一次升级未能确认启动)", to, from)
st.RolledBack = true
st.RolledFrom, st.RolledTo = from, to
// 回滚后重读版本:后续模块要看到**回滚后**的版本
if v2, err2 := upd.ReadBinVersion(bin); err2 == nil {
st.Version, st.Commit, st.SDKVersion = v2.Version, v2.Commit, v2.SDKVersion
}
} else {
// ③ 版本已确认能启动 ⇒ 清除待确认标记
//
// 只依赖"进程活着且执行到第 0 层",不依赖其他模块 ⇒ 不会误判。
if err := upd.ClearPendingUpgrade(ks.DataDir); err != nil {
// 清不掉不阻断启动:后果只是下次启动再判一次(幂等)
log.Printf("[update] clear pending marker: %v", err)
}
}
// ④ 依赖图路径(真正的 digest 校验在 kernelboot 装载期,那里失败会拒绝启动)
if dg := filepath.Join(filepath.Dir(bin), "depgraph.bin"); upd.FileExists(dg) {
st.DepGraphPath = dg
}
// ⑤ 安装形态与升级能力**探测**(不改任何状态,只产出可读快照)。
//
// 为何放在第 0 层:它就是第 0 层自己的事实——“我能怎么升”。
// 而且它要读 dpkg/rpm 数据库与目录权限,这些在启动时只跑一次。
//
// ★ 探测失败不能阻断启动:它只是诊断信息。拿不到就报 Unknown,
// Apply 会因 Unknown 拒绝自替换(失败方向是收紧)。
capab := upd.Detect(bin, ks.DataDir)
ks.UpdateCapabilities = &capab
log.Printf("[update] install kind=%s variant=%s channel=%s can_replace=%v",
capab.Kind, capab.Variant, capab.Channel(), capab.CanWriteBin)
if !capab.CanWriteBin {
// 不阻断启动,但必须响亮:否则用户以为“点一下就能升级”。
log.Printf("[update] 本机不支持自替换升级:%s", capab.Reason)
}
log.Printf("[update] subsystem ready (version=%s rolled_back=%v)", st.Version, st.RolledBack)
return nil
}
func init() { kernelmod.Register(Module{}) }
var _ kernelboot.Module = Module{}