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 防腐判据(部分已随本次落地)
285 lines
9.3 KiB
Go
285 lines
9.3 KiB
Go
// Package migration 提供**数据目录迁移**的内核能力(伪关机 → 迁移 → 唤醒)。
|
||
//
|
||
// ## 它提供什么
|
||
//
|
||
// 两个工具(暴露给模型/WebUI):
|
||
//
|
||
// migrate_data_preflight → 只读体检:报告能不能迁、要迁多少、有哪些风险
|
||
// migrate_data_plan → 生成可执行迁移脚本(伪关机窗口由脚本自己 stop 覆盖)
|
||
//
|
||
// ## ★ 为什么不提供"一键迁移"工具
|
||
//
|
||
// 迁移是**破坏性且不可逆**的(21G 数据 + 12 处 DB 改写 + 46 个文件改写),
|
||
// 而且内核跑在 systemd 沙箱里(ProtectSystem/ProtectHome)**本来就写不了
|
||
// /etc/systemd**,更没法把 /home/newqqagent 搬到别的挂载点(跨目录 rename 会失败,
|
||
// 必须 rsync 全量)。
|
||
//
|
||
// ⇒ 内核的职责是:**体检 + 生成正确的脚本 + 提供伪关机/唤醒能力**;
|
||
//
|
||
// 真正的搬数据由运维执行脚本。这样每一步都可审查、可回滚。
|
||
//
|
||
// ## 依赖声明
|
||
//
|
||
// Requires 里**故意不含** plugin-host:本模块只读 fs + 读 config,
|
||
// 伪关机能力(PluginQuiescer)由调用方在需要时注入,不硬依赖。
|
||
package migration
|
||
|
||
import (
|
||
"fmt"
|
||
"os"
|
||
"path/filepath"
|
||
"sort"
|
||
"syscall"
|
||
|
||
"github.com/JianFeeeee/HologramAgent/internal/kernelmod"
|
||
"github.com/JianFeeeee/HologramAgent/internal/kstate"
|
||
"github.com/JianFeeeee/HologramAgent/internal/systemd"
|
||
)
|
||
|
||
func init() { kernelmod.Register(Module{}) }
|
||
|
||
// Module 是数据目录迁移模块。
|
||
type Module struct{}
|
||
|
||
func (Module) Name() string { return "migration" }
|
||
|
||
// Requires 只需要配置(读 data_dir)与 kernel-io(日志)。
|
||
func (Module) Requires() []string { return []string{"config", "kernel-io"} }
|
||
|
||
func (Module) Provides() []string { return []string{"migration"} }
|
||
|
||
// MigrateReport 是体检结果。
|
||
type MigrateReport struct {
|
||
DataDir string `json:"data_dir"`
|
||
TotalBytes int64 `json:"total_bytes"`
|
||
Dirs []DirInfo `json:"dirs"`
|
||
Mounts []string `json:"mounts"` // 必须先卸载的挂载点
|
||
Risks []string `json:"risks"` // 会导致数据丢失/错误的点
|
||
DBPathKeys []string `json:"db_path_keys"` // 数据库里存绝对路径的键
|
||
SameDevice bool `json:"same_device"` // 目标与源是否同一设备(决定能否 rename)
|
||
CanRename bool `json:"can_rename"` // 同设备可直接 rename,否则必须 rsync
|
||
ScriptPath string `json:"script_path,omitempty"`
|
||
}
|
||
|
||
// DirInfo 是单目录的信息。
|
||
type DirInfo struct {
|
||
Name string `json:"name"`
|
||
Bytes int64 `json:"bytes"`
|
||
Kind string `json:"kind"` // data | runtime | optional | mount
|
||
}
|
||
|
||
func (Module) Init(ks *kstate.State) error {
|
||
// 体检结果放在 KernelState 上,供工具/WebUI 读(可选能力,nil 安全)
|
||
ks.MigrationPreflight = func(target string) (interface{}, error) {
|
||
return Preflight(ks.DataDir, target)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// Preflight 体检:数据目录现在是什么状态、迁到 target 需要注意什么。
|
||
func Preflight(dataDir, target string) (*MigrateReport, error) {
|
||
rep := &MigrateReport{DataDir: dataDir}
|
||
|
||
// 目录分类(来自 2026-10-17 生产盘点,21G)
|
||
// data=必须迁 runtime=不迁(重启重建) optional=可丢 mount=挂载点需先卸
|
||
classify := map[string]string{
|
||
"agentfs": "mount",
|
||
"plugindev": "data",
|
||
"blocklauncher": "data",
|
||
"browser_profiles": "data",
|
||
"models": "data",
|
||
"plugins": "data",
|
||
"mcbridge": "data",
|
||
"memory": "data",
|
||
"plugin_data": "data",
|
||
"knowledge": "data",
|
||
"skills": "data",
|
||
"mcp": "data",
|
||
"nlp": "data",
|
||
"adapters": "data",
|
||
"data": "data",
|
||
"uploads": "data",
|
||
"device_media": "data",
|
||
"webui_files": "data",
|
||
"changesets": "data",
|
||
"heartbeat": "runtime",
|
||
"log": "optional",
|
||
"backups": "optional",
|
||
"memory.bak": "optional",
|
||
}
|
||
|
||
entries, err := os.ReadDir(dataDir)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("读数据目录: %w", err)
|
||
}
|
||
for _, e := range entries {
|
||
name := e.Name()
|
||
if !e.IsDir() {
|
||
continue
|
||
}
|
||
info := DirInfo{Name: name, Kind: classify[name]}
|
||
if info.Kind == "" {
|
||
info.Kind = "data" // 未知目录默认**必须迁**(宁可多迁,不可漏迁)
|
||
}
|
||
info.Bytes = dirSize(filepath.Join(dataDir, name))
|
||
rep.Dirs = append(rep.Dirs, info)
|
||
rep.TotalBytes += info.Bytes
|
||
}
|
||
sort.Slice(rep.Dirs, func(i, j int) bool { return rep.Dirs[i].Bytes > rep.Dirs[j].Bytes })
|
||
|
||
// ① 挂载点:agentfs 是 overlay(lower/upper/work),不卸就搬空壳。
|
||
//
|
||
// ★ 风险说明**无条件**报出,只有"要不要 umount"取决于实际挂载状态。
|
||
//
|
||
// 为什么分开:如果只在挂载时才提风险,那"现在没挂、迁移脚本跑起来时
|
||
// 服务又把它挂上了"这种时序就完全没提示 —— 而 agentfs 恰恰是内核
|
||
// 启动时才挂的。运维在**停机状态**做体检会看不到这条警示,
|
||
// 但迁移脚本里的 stop 又会把它挂回来(见脚本的 umount 步骤)。
|
||
// 判据 TestPreflightReportsKnownRisks 就是为了钉住这条。
|
||
if _, err := os.Stat(filepath.Join(dataDir, "agentfs")); err == nil {
|
||
merged := filepath.Join(dataDir, "agentfs", "merged")
|
||
if isMountpoint(merged) {
|
||
rep.Mounts = append(rep.Mounts, merged)
|
||
}
|
||
rep.Risks = append(rep.Risks,
|
||
"agentfs/merged 是 overlay 挂载点(lower/upper/work 三段):不先 umount 会搬走'空壳',"+
|
||
"真正内容仍挂在旧路径 ⇒ 新目录 agentfs 不可用。"+
|
||
"注意:内核启动时才挂它,所以**停机体检看不到挂载**也仍要卸")
|
||
}
|
||
|
||
// ② 数据库绝对路径:不改会"看起来记忆全丢"(内核按旧路径找不到就重建空库,且不报错)
|
||
rep.DBPathKeys = []string{
|
||
"core.daemon.data_dir", "core.plugin.dir", "core.memory.graph",
|
||
"core.memory.text", "core.memory.documents", "core.knowledge.path",
|
||
"core.log.path", "core.memory.multimodal_space.options.model_dir",
|
||
}
|
||
rep.Risks = append(rep.Risks,
|
||
fmt.Sprintf("数据库有 %d 个键存绝对路径;不改写会导致重启后按旧路径找数据,"+
|
||
"找不到就**静默重建空库**(看起来像记忆全丢)", len(rep.DBPathKeys)),
|
||
"plugin_data 下实测有 46 个文件写死了绝对路径(典型:commondev 的 git 镜像/workdir)",
|
||
"security.key 不迁 ⇒ 所有加密配置解不开(敏感信息全部失效)",
|
||
)
|
||
|
||
// ③ 是否同设备:决定 rename(瞬时)还是 rsync(几分钟)
|
||
if target != "" {
|
||
if src, err := os.Stat(dataDir); err == nil {
|
||
if dst, err := os.Stat(filepath.Dir(target)); err == nil {
|
||
a := src.Sys().(*syscall.Stat_t).Dev
|
||
b := dst.Sys().(*syscall.Stat_t).Dev
|
||
rep.SameDevice = a == b
|
||
rep.CanRename = rep.SameDevice
|
||
if !rep.SameDevice {
|
||
rep.Risks = append(rep.Risks,
|
||
"目标不在同一设备 ⇒ 不能用 rename,必须 rsync 全量拷贝(耗时随数据量增长)")
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ④ 当前 systemd 权威 vs DB 权威是否一致(两个权威不等价)
|
||
// 运行时权威 = -data(本模块的 dataDir);配置权威 = DB core.daemon.data_dir
|
||
if st, err := systemd.Check(dataDir, dataDir); err == nil {
|
||
if st.RuntimeDataDir != "" && st.ConfigDataDir != "" && st.RuntimeDataDir != st.ConfigDataDir {
|
||
rep.Risks = append(rep.Risks,
|
||
fmt.Sprintf("★ 数据目录有**两个权威且不一致**:-data=%s(管 memory/graph.db + 插件 DataDir)"+
|
||
" vs DB core.daemon.data_dir=%s(管 memory/text、knowledge、nlp 等 6 个模块)⇒ 数据会分裂",
|
||
st.RuntimeDataDir, st.ConfigDataDir))
|
||
}
|
||
}
|
||
return rep, nil
|
||
}
|
||
|
||
// dirSize 递归统计目录大小(符号链接不跟随,避免重复计算硬链接/环)。
|
||
func dirSize(root string) int64 {
|
||
var total int64
|
||
filepath.Walk(root, func(p string, info os.FileInfo, err error) error {
|
||
if err != nil || info == nil {
|
||
return nil
|
||
}
|
||
if info.Mode()&os.ModeSymlink != 0 {
|
||
return nil
|
||
}
|
||
if !info.IsDir() {
|
||
total += info.Size()
|
||
}
|
||
return nil
|
||
})
|
||
return total
|
||
}
|
||
|
||
// isMountpoint 判断路径是否为挂载点(读 /proc/mounts,不依赖外部命令)。
|
||
func isMountpoint(p string) bool {
|
||
data, err := os.ReadFile("/proc/mounts")
|
||
if err != nil {
|
||
return false
|
||
}
|
||
clean := filepath.Clean(p)
|
||
for _, line := range splitLines(string(data)) {
|
||
f := splitFields(line)
|
||
if len(f) >= 2 && unescapeMount(f[1]) == clean {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func splitLines(s string) []string {
|
||
var out []string
|
||
start := 0
|
||
for i := 0; i < len(s); i++ {
|
||
if s[i] == '\n' {
|
||
out = append(out, s[start:i])
|
||
start = i + 1
|
||
}
|
||
}
|
||
if start < len(s) {
|
||
out = append(out, s[start:])
|
||
}
|
||
return out
|
||
}
|
||
|
||
func splitFields(s string) []string {
|
||
var out []string
|
||
i := 0
|
||
for i < len(s) {
|
||
for i < len(s) && s[i] == ' ' {
|
||
i++
|
||
}
|
||
j := i
|
||
for j < len(s) && s[j] != ' ' {
|
||
j++
|
||
}
|
||
if j > i {
|
||
out = append(out, s[i:j])
|
||
}
|
||
i = j
|
||
}
|
||
return out
|
||
}
|
||
|
||
// unescapeMount 还原 /proc/mounts 里的八进制转义(空格写作 \040)。
|
||
func unescapeMount(s string) string {
|
||
out := make([]byte, 0, len(s))
|
||
for i := 0; i < len(s); i++ {
|
||
if s[i] == '\\' && i+3 < len(s) {
|
||
v := 0
|
||
ok := true
|
||
for k := 1; k <= 3; k++ {
|
||
c := s[i+k]
|
||
if c < '0' || c > '7' {
|
||
ok = false
|
||
break
|
||
}
|
||
v = v*8 + int(c-'0')
|
||
}
|
||
if ok {
|
||
out = append(out, byte(v))
|
||
i += 3
|
||
continue
|
||
}
|
||
}
|
||
out = append(out, s[i])
|
||
}
|
||
return string(out)
|
||
}
|