From bc9ef15eb04878aa65e0eb6d34a7a7aefa493876 Mon Sep 17 00:00:00 2001 From: root Date: Wed, 5 Aug 2026 16:00:08 +0800 Subject: [PATCH] =?UTF-8?q?=E4=B8=89=E5=B1=82=E5=9B=9E=E9=80=80=E6=81=A2?= =?UTF-8?q?=E5=A4=8D=E6=9C=BA=E5=88=B6=EF=BC=88L0=E5=86=99=E5=89=8D?= =?UTF-8?q?=E7=95=99=E6=A1=A3/L1=E6=81=A2=E5=A4=8D=E6=A2=AF=E5=AD=90/L2?= =?UTF-8?q?=E7=A6=BB=E7=BA=BF=E5=9B=9E=E6=BB=9A=EF=BC=89+=20guard=20?= =?UTF-8?q?=E7=88=B6=E5=AE=88=E6=8A=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - L0: files 插件写受保护系统路径(/etc 等)前自动留档,AbstractBeforeWrite 到 data/file_baseline - L1: failback 受限 worker 执行恢复梯子 probe→还原DNS/proxy→还原LLM配置+ReloadFromConfig→probe,N轮有界 - L2: tracker changeset 持久化原文 blob,guard 离线 RollbackFromDisk 回滚 agentfs;SystemSnapshot 支撑 - guard 父守护: 心跳 IPC(PING/ACK unix socket, 文件心跳回退)、失败计数、退出码协议(42/43/44)、最后手段 - 发行版路径适配: system.protected_paths/network_paths 可注入,默认面向主流 Linux - Windows 兼容: guard.go/failback.go 加 //go:build linux, guard_windows.go 提供 no-op 桩 - 修复: guard.yaml last_resort 键冲突、changeset Content 不落盘导致离线回滚丢原文 Build 全绿, vet 干净, system/recovery/ipc/tracker 单元测试全过 --- cmd/homed/failback.go | 138 +++++++++ cmd/homed/guard.go | 456 +++++++++++++++++++++++++++++ cmd/homed/guard_windows.go | 32 ++ cmd/homed/main.go | 225 ++++++++++---- cmd/homed/worker_exit.go | 30 ++ demo.md | 364 +++++++++++++++++++++++ docs/zh/plan.md | 284 ++++++++++++++++++ internal/config/registry.go | 134 ++++++++- internal/config/registry_test.go | 70 +++++ internal/ipc/ipc.go | 143 +++++++++ internal/ipc/ipc_test.go | 67 +++++ internal/network/monitor.go | 14 +- internal/network/monitor_test.go | 39 +++ internal/plugin/registry.go | 112 ++++--- internal/plugins/files/plugin.go | 49 ++++ internal/recovery/recovery.go | 233 +++++++++++++++ internal/recovery/recovery_test.go | 208 +++++++++++++ internal/supervisor/daemon.go | 86 ++++-- internal/supervisor/daemon_test.go | 63 +++- internal/system/guard.go | 146 +++++++++ internal/system/network.go | 240 +++++++++++++++ internal/system/system_test.go | 162 ++++++++++ internal/tracker/changeset.go | 27 ++ internal/tracker/disk.go | 114 ++++++++ internal/tracker/disk_test.go | 92 ++++++ internal/tracker/tracker.go | 118 +++++++- internal/tracker/tracker_test.go | 131 +++++++++ pkg/types/types.go | 65 ++-- 28 files changed, 3675 insertions(+), 167 deletions(-) create mode 100644 cmd/homed/failback.go create mode 100644 cmd/homed/guard.go create mode 100644 cmd/homed/guard_windows.go create mode 100644 cmd/homed/worker_exit.go create mode 100644 demo.md create mode 100644 docs/zh/plan.md create mode 100644 internal/ipc/ipc.go create mode 100644 internal/ipc/ipc_test.go create mode 100644 internal/network/monitor_test.go create mode 100644 internal/recovery/recovery.go create mode 100644 internal/recovery/recovery_test.go create mode 100644 internal/system/guard.go create mode 100644 internal/system/network.go create mode 100644 internal/system/system_test.go create mode 100644 internal/tracker/disk.go create mode 100644 internal/tracker/disk_test.go diff --git a/cmd/homed/failback.go b/cmd/homed/failback.go new file mode 100644 index 0000000..fcf8f3f --- /dev/null +++ b/cmd/homed/failback.go @@ -0,0 +1,138 @@ +//go:build linux + +// L1 failback 恢复 worker(Linux 专属):在受限 boot 下执行恢复梯子, +// 依赖 DNS/代理网络基线、LLM 配置快照与 reload 等 Linux 语义。 +// 非 Linux 平台不编译,由 guard_windows.go 提供桩。 +package main + +import ( + "context" + "fmt" + "log" + "os" + "path/filepath" + "time" + + agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api" + internalConfig "gitcode.com/JianFeeeee/HomeAgent/internal/config" + luaVM "gitcode.com/JianFeeeee/HomeAgent/internal/lua" + "gitcode.com/JianFeeeee/HomeAgent/internal/recovery" + sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk" + "gitcode.com/JianFeeeee/HomeAgent/internal/system" +) + +// runFailbackRecovery 在 failback worker 内执行 L1 恢复梯子: +// probe(rescue 源) → 还原 DNS/proxy → probe → 还原 LLM 配置快照+ReloadFromConfig → probe +// 成功后写 recovery_result.json 并以 exitRecovered 退出;失败以 exitRecoveryFailed 退出。 +// 全程确定性、无 token 消耗(除 rescue 源 QuickChat 复检外)。 +func runFailbackRecovery(dataDir string, cfgReg *internalConfig.ConfigRegistry, lua *luaVM.VM, providerMgr *agentAPI.ProviderManager, baseAPIKey string) { + applySystemConfig(loadGuardConfig(dataDir)) // 发行版/部署路径适配:guard.yaml system.* + task, err := recovery.LoadTask(recovery.TaskPath(dataDir)) + if err != nil { + log.Printf("[failback] no recovery task, skipping: %v", err) + os.Exit(exitRecoveryFailed) + } + if task.RescueSource.Name == "" || task.RescueSource.BaseURL == "" { + log.Printf("[failback] rescue source missing in task, cannot recover") + os.Exit(exitRecoveryFailed) + } + log.Printf("[failback] recovery task: attempt %d/%d rescue=%s", task.Attempt, task.MaxAttempts, task.RescueSource.Name) + + timeout := 120 * time.Second + if d, err := time.ParseDuration(task.AttemptTimeout); err == nil && d > 0 { + timeout = d + } + + // 注册 rescue 源并设为默认(锚定 IP 直连,绕开被破坏的 DNS/代理) + key := task.RescueSource.APIKey + if key == "" { + key = baseAPIKey + } + rescueProvider := agentAPI.NewLuaAdaptedProvider(agentAPI.BaseConfig{ + Model: task.RescueSource.Model, + BaseURL: task.RescueSource.BaseURL, + APIKey: key, + Temperature: 0.7, + MaxTokens: 512, + ContextWindow: 8192, + }, lua, task.RescueSource.Name, task.RescueSource.Adapter) + providerMgr.Register("rescue", rescueProvider) + if err := providerMgr.SetDefault("rescue"); err != nil { + log.Printf("[failback] set rescue default: %v", err) + } + + probe := func(ctx context.Context, label string) (bool, error) { + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + resp, err := providerMgr.QuickChat(ctx, "回复 OK") + if err != nil { + log.Printf("[failback] probe(%s): %v", label, err) + return false, nil + } + log.Printf("[failback] probe(%s): reachable, reply=%q", label, truncateStr(resp.Content, 60)) + return true, nil + } + + restoreNet := func() ([]string, error) { + base, err := system.LoadNetworkBaseline(dataDir) + if err != nil { + return nil, fmt.Errorf("load network baseline: %w", err) + } + changed, err := base.RestoreFiles() + if err != nil { + return nil, err + } + if len(changed) > 0 { + log.Printf("[failback] restored network files: %v", changed) + } + return changed, nil + } + + restoreCfg := func() (int, error) { + snapPath := filepath.Join(dataDir, "llm_snapshot.json") + snap, err := internalConfig.LoadLLMSnapshot(snapPath) + if err != nil { + return 0, fmt.Errorf("load llm snapshot: %w", err) + } + if err := cfgReg.RestoreCoreLLM(snap); err != nil { + return 0, err + } + if err := sdk.NewLLM(providerMgr, cfgReg, lua, baseAPIKey).ReloadFromConfig(); err != nil { + return 0, err + } + log.Printf("[failback] restored %d llm keys and reloaded from config", len(snap)) + return len(snap), nil + } + + ladder := &recovery.Ladder{ + ResultFile: recovery.ResultPath(dataDir), + Attempt: task.Attempt, + Probe: probe, + RestoreNetwork: restoreNet, + RestoreConfig: restoreCfg, + Log: log.Printf, + } + res := ladder.Run(context.Background()) + log.Printf("[failback] recovery round result: %s", res.Quote()) + + if res.Success { + os.Exit(exitRecovered) + } + os.Exit(exitRecoveryFailed) +} + +func truncateStr(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "..." +} + +// lastDiagSummary 读取最近一次 recovery_result 生成一行自诊断摘要(worker IPC ACK 上报)。 +func lastDiagSummary(dataDir string) string { + res, err := recovery.LoadResult(recovery.ResultPath(dataDir)) + if err != nil { + return "" + } + return res.Quote() +} diff --git a/cmd/homed/guard.go b/cmd/homed/guard.go new file mode 100644 index 0000000..9727020 --- /dev/null +++ b/cmd/homed/guard.go @@ -0,0 +1,456 @@ +//go:build linux + +// L0/L1/L2 三层回退恢复机制的核心:父守护 guard(Linux 专属)。 +// +// 依赖 Linux 设施:syscall.Reboot / /etc 网络基线 / overlayfs 离线回滚 / +// unix socket IPC / systemctl 重启。Windows 等平台不编译本文件, +// 由 guard_windows.go 提供 no-op 桩,保证 cmd/homed 跨平台可构建。 +package main + +import ( + "errors" + "fmt" + "log" + "os" + "os/exec" + "os/signal" + "path/filepath" + "strings" + "syscall" + "time" + + internalConfig "gitcode.com/JianFeeeee/HomeAgent/internal/config" + "gitcode.com/JianFeeeee/HomeAgent/internal/ipc" + "gitcode.com/JianFeeeee/HomeAgent/internal/recovery" + "gitcode.com/JianFeeeee/HomeAgent/internal/system" + "gitcode.com/JianFeeeee/HomeAgent/internal/tracker" + "gopkg.in/yaml.v3" +) + +// guardConfig 父守护配置:独立于 config.db,避开被改坏的配置数据库。 +type guardConfig struct { + MaxRestarts int `yaml:"max_restarts"` // 连续 normal 崩溃重试上限 + HeartbeatTimeout string `yaml:"heartbeat_timeout"` // 心跳多久未更新判定卡死(如 30s) + HeartbeatInterval string `yaml:"heartbeat_interval"` // 期待 worker 心跳频率(仅日志) + LLMSnapshot string `yaml:"llm_snapshot"` // LLM 配置基线文件,为空用 /llm_snapshot.json + FailbackEnabled bool `yaml:"failback_enabled"` // 崩溃超限后是否进入受限 failback 启动 + LastResort string `yaml:"last_resort"` // restart_app | reboot + RestartCommand []string `yaml:"restart_command"` // last_resort=restart_app 时的命令(如 systemctl restart homeagent) + RebootGrace string `yaml:"reboot_grace"` // last_resort=reboot 前的缓冲 + LLM llmConfig `yaml:"llm"` // rescue 源(锚定 IP,绕开被破坏的 DNS/代理) + Recovery recoveryConfig `yaml:"recovery"` // failback 恢复参数(N 轮有界) + LastResortCfg lastResortConfig `yaml:"last_resort_cfg"` // 最后手段细节(snapshot_before 等) + System systemConfig `yaml:"system"` // 发行版/部署路径适配 + + heartbeatTimeout time.Duration + heartbeatInterval time.Duration + rebootGrace time.Duration +} + +type llmConfig struct { + Sources []recovery.RescueSource `yaml:"sources"` +} + +type recoveryConfig struct { + MaxAttempts int `yaml:"max_attempts"` // failback 恢复尝试轮数上限 + AttemptTimeout string `yaml:"attempt_timeout"` // 单轮恢复超时 + KnowledgeBase string `yaml:"knowledge_base"` // 恢复知识库目录 + TriggerPrompt string `yaml:"trigger_prompt"` // 恢复 agent 的系统提示词(未知/混合分支) + Plugins []string `yaml:"plugins"` // failback 插件集(缺省用 core.agent.failback_plugins) + attemptTimeout time.Duration +} + +type lastResortConfig struct { + SnapshotBefore bool `yaml:"snapshot_before"` // 最后手段前是否回滚 agentfs 到最近快照 +} + +// systemConfig 发行版/部署路径适配:把 L0 写前留档保护范围与网络基线文件列表 +// 从默认的 Linux 路径换成当前部署实际路径,避免硬编码失效。 +type systemConfig struct { + ProtectedPaths []string `yaml:"protected_paths"` // L0 写前留档保护前缀(默认 /etc/) + NetworkPaths []string `yaml:"network_paths"` // 网络基线文件(默认 resolv.conf/hosts/environment) +} + +// applySystemConfig 应用发行版路径适配,供 guard 与 failback worker 共用。 +func applySystemConfig(cfg *guardConfig) { + if len(cfg.System.ProtectedPaths) > 0 { + system.SetProtectedPaths(cfg.System.ProtectedPaths) + } + if len(cfg.System.NetworkPaths) > 0 { + system.SetNetworkPaths(cfg.System.NetworkPaths) + } +} + +func defaultGuardConfig(dataDir string) *guardConfig { + return &guardConfig{ + MaxRestarts: 3, + HeartbeatTimeout: "30s", + HeartbeatInterval: "5s", + LLMSnapshot: filepath.Join(dataDir, "llm_snapshot.json"), + FailbackEnabled: true, + LastResort: "restart_app", + RestartCommand: []string{"systemctl", "restart", "homeagent"}, + RebootGrace: "10s", + Recovery: recoveryConfig{ + MaxAttempts: 3, + AttemptTimeout: "120s", + }, + LastResortCfg: lastResortConfig{SnapshotBefore: true}, + } +} + +func loadGuardConfig(dataDir string) *guardConfig { + cfg := defaultGuardConfig(dataDir) + path := filepath.Join(dataDir, "guard.yaml") + data, err := os.ReadFile(path) + if err != nil { + return cfg + } + if err := yaml.Unmarshal(data, cfg); err != nil { + log.Printf("[guard] parse %s: %v, using defaults", path, err) + return cfg + } + if cfg.LLMSnapshot == "" { + cfg.LLMSnapshot = filepath.Join(dataDir, "llm_snapshot.json") + } + parseDur := func(s string, def time.Duration) time.Duration { + if s == "" { + return def + } + d, err := time.ParseDuration(s) + if err != nil { + return def + } + return d + } + cfg.heartbeatTimeout = parseDur(cfg.HeartbeatTimeout, 30*time.Second) + cfg.heartbeatInterval = parseDur(cfg.HeartbeatInterval, 5*time.Second) + cfg.rebootGrace = parseDur(cfg.RebootGrace, 10*time.Second) + cfg.Recovery.attemptTimeout = parseDur(cfg.Recovery.AttemptTimeout, 120*time.Second) + if cfg.MaxRestarts < 1 { + cfg.MaxRestarts = 1 + } + if cfg.LastResort != "reboot" && cfg.LastResort != "restart_app" { + cfg.LastResort = "restart_app" + } + if len(cfg.RestartCommand) == 0 { + cfg.RestartCommand = []string{"systemctl", "restart", "homeagent"} + } + if cfg.Recovery.MaxAttempts < 1 { + cfg.Recovery.MaxAttempts = 1 + } + // 缺省 failback 插件集 + if len(cfg.Recovery.Plugins) == 0 { + cfg.Recovery.Plugins = []string{"webui", "pluginmgr", "recoverydiag"} + } + return cfg +} + +// runGuard 父守护主循环:拉起 worker(--role=agent),心跳探活 + waitpid, +// 崩溃后按序 重试→LLM 配置基线恢复→failback 受限启动(N 轮有界,每轮复检)→L2 最后手段。 +func runGuard(dataDir string) { + cfg := loadGuardConfig(dataDir) + applySystemConfig(cfg) + hbPath := filepath.Join(dataDir, "heartbeat") + os.MkdirAll(filepath.Join(dataDir, "log"), 0755) + + exe, err := os.Executable() + if err != nil { + log.Fatalf("[guard] resolve executable: %v", err) + } + + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + + log.Printf("[guard] starting, data=%s max_restarts=%d hb_timeout=%v last_resort=%s failback_rounds=%d", + dataDir, cfg.MaxRestarts, cfg.heartbeatTimeout, cfg.LastResort, cfg.Recovery.MaxAttempts) + + // 启动即捕获网络基线(L0:写前留档 + L1 还原依据),并给当前 worker 恢复代理环境 + captureNetworkBaseline(dataDir) + + failures := 0 // 连续 normal 崩溃次数 + failbackRound := 0 // 已进行的 failback 轮次 + failbackDone := false // 本轮 failback 是否已进入 + + for { + // 决定本次 boot 模式:normal 崩溃超限 → 进入 failback + boot := "normal" + if cfg.FailbackEnabled && !failbackDone && failures >= cfg.MaxRestarts { + log.Printf("[guard] %d failures >= max %d, entering failback (Safe-Mode)", failures, cfg.MaxRestarts) + // L1 前置:恢复 LLM 基线 + 还原 DNS/proxy + 写恢复任务 + restoreLLMBaseline(dataDir, cfg.LLMSnapshot) + restoreNetworkBaseline(dataDir) + failbackRound = 1 + failbackDone = true + if err := writeRecoveryTask(dataDir, cfg, failbackRound); err != nil { + log.Printf("[guard] write recovery task: %v", err) + } + boot = "failback" + } else if failbackDone { + boot = "failback" + } + + // 恢复 agent 环境(代理变量)在 worker 拉起前注入 + if b, err := system.LoadNetworkBaseline(dataDir); err == nil { + b.ApplyProxyEnv() + } + + outcome, spawnErr := spawnWorkerOnce(exe, dataDir, boot, hbPath, cfg, sigCh) + if spawnErr != nil { + log.Printf("[guard] spawn worker: %v, last_resort=%s", spawnErr, cfg.LastResort) + doLastResort(cfg) + failures = 0 + failbackDone = false + failbackRound = 0 + time.Sleep(2 * time.Second) + continue + } + + switch outcome { + case workerCleanExit: + log.Printf("[guard] worker exited cleanly, guard exiting") + return + case workerRestartRequested: + log.Printf("[guard] worker requested restart, respawning") + continue + case workerRecovered: + // failback 成功:重置失败轮次,交回主 agent(下一轮 normal boot) + log.Printf("[guard] failback recovery succeeded, handing back to main agent") + failures = 0 + failbackDone = false + failbackRound = 0 + continue + case workerCrash: + // fallthrough below + } + failures++ + + if failbackDone { + // failback 轮次内崩溃:读结果判定是否成功 + if res, err := recovery.LoadResult(recovery.ResultPath(dataDir)); err == nil && res.Success { + log.Printf("[guard] failback round %d result success: %s", failbackRound, res.Quote()) + failures = 0 + failbackDone = false + failbackRound = 0 + continue + } + if failbackRound >= cfg.Recovery.MaxAttempts { + log.Printf("[guard] failback exhausted after %d rounds, L2 last_resort=%s", failbackRound, cfg.LastResort) + if cfg.LastResortCfg.SnapshotBefore { + rollbackAgentFS(dataDir) + } + doLastResort(cfg) + failures = 0 + failbackDone = false + failbackRound = 0 + time.Sleep(2 * time.Second) + continue + } + // 进入下一轮 failback + failbackRound++ + log.Printf("[guard] failback round %d failed, next round %d (max %d)", failbackRound-1, failbackRound, cfg.Recovery.MaxAttempts) + if err := writeRecoveryTask(dataDir, cfg, failbackRound); err != nil { + log.Printf("[guard] write recovery task: %v", err) + } + continue + } + + if failures >= cfg.MaxRestarts { + log.Printf("[guard] %d normal failures, entering failback next loop", failures) + } + } +} + +type workerOutcome int + +const ( + workerCleanExit workerOutcome = iota + workerCrash + workerRestartRequested + workerRecovered +) + +// spawnWorkerOnce 拉起一个 worker 并监控到其退出。 +// 返回 (outcome workerOutcome, spawnErr error);spawnErr 非 nil 表示未能拉起进程。 +func spawnWorkerOnce(exe, dataDir, boot, hbPath string, cfg *guardConfig, sigCh chan os.Signal) (workerOutcome, error) { + cmd := exec.Command(exe, "--role=agent", "--data="+dataDir, "--boot="+boot) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Stdin = nil + + if err := cmd.Start(); err != nil { + return workerCrash, err + } + pid := cmd.Process.Pid + log.Printf("[guard] worker up pid=%d boot=%s", pid, boot) + + done := make(chan error, 1) + go func() { done <- cmd.Wait() }() + + hbTicker := time.NewTicker(cfg.heartbeatInterval) + defer hbTicker.Stop() + + ipcClient := ipc.NewClient(dataDir) + + for { + select { + case sig := <-sigCh: + log.Printf("[guard] signal %v, stopping worker", sig) + cmd.Process.Signal(syscall.SIGTERM) + select { + case <-done: + case <-time.After(10 * time.Second): + cmd.Process.Kill() + <-done + } + return workerCleanExit, nil + case err := <-done: + if err == nil { + log.Printf("[guard] worker pid=%d exited cleanly", pid) + return workerCleanExit, nil + } + var ee *exec.ExitError + if errors.As(err, &ee) { + switch ee.ExitCode() { + case exitRestartRequested: + log.Printf("[guard] worker pid=%d requested restart (exit %d), respawning without counting failure", pid, exitRestartRequested) + return workerRestartRequested, nil + case exitRecovered: + log.Printf("[guard] worker pid=%d recovered main agent (exit %d)", pid, exitRecovered) + return workerRecovered, nil + case exitRecoveryFailed: + log.Printf("[guard] worker pid=%d failback attempt failed (exit %d)", pid, exitRecoveryFailed) + return workerCrash, nil + } + } + log.Printf("[guard] worker pid=%d crashed: %v", pid, err) + return workerCrash, nil + case <-hbTicker.C: + // IPC PING/ACK 存活判定(带自诊断上报),失败回退文件心跳 mtime + if st, perr := ipcClient.Ping(cfg.heartbeatTimeout); perr != nil { + if heartbeatStale(hbPath, cfg.heartbeatTimeout) { + log.Printf("[guard] heartbeat stale for pid=%d (ipc: %v), treating as hang, SIGKILL", pid, perr) + cmd.Process.Kill() + <-done + return workerCrash, nil + } + } else if st != nil && st.LastDiag != "" { + log.Printf("[guard] worker pid=%d self-diagnosis: %s", pid, st.LastDiag) + } + } + } +} + +func heartbeatStale(path string, timeout time.Duration) bool { + fi, err := os.Stat(path) + if err != nil { + // 无心跳文件:worker 刚启动或异常;宽限处理 + return false + } + return time.Since(fi.ModTime()) > timeout +} + +// restoreLLMBaseline 从文件基线恢复 core.llm.* 配置(存在才恢复)。 +func restoreLLMBaseline(dataDir, snapPath string) { + if _, err := os.Stat(snapPath); err != nil { + log.Printf("[guard] no llm snapshot baseline at %s, skip restore", snapPath) + return + } + snap, err := internalConfig.LoadLLMSnapshot(snapPath) + if err != nil { + log.Printf("[guard] load llm snapshot %s: %v", snapPath, err) + return + } + cfgReg := internalConfig.NewConfigRegistry(filepath.Join(dataDir, "config.db")) + defer cfgReg.Close() + if err := cfgReg.RestoreCoreLLM(snap); err != nil { + log.Printf("[guard] restore llm baseline: %v", err) + return + } + log.Printf("[guard] restored %d llm keys from %s", len(snap), snapPath) +} + +// captureNetworkBaseline L0:启动即捕获网络基线(DNS/hosts/代理),供 failback 还原。 +func captureNetworkBaseline(dataDir string) { + base, err := system.CaptureNetwork() + if err != nil { + log.Printf("[guard] capture network baseline: %v", err) + return + } + if err := system.SaveNetworkBaseline(dataDir, base); err != nil { + log.Printf("[guard] save network baseline: %v", err) + return + } + log.Printf("[guard] network baseline captured: %s", base.Summary()) +} + +// restoreNetworkBaseline L1:把 resolv.conf/hosts 还原到基线(DNS/proxy 小修命中即停)。 +func restoreNetworkBaseline(dataDir string) { + base, err := system.LoadNetworkBaseline(dataDir) + if err != nil { + log.Printf("[guard] no network baseline, skip DNS/proxy restore: %v", err) + return + } + changed, err := base.RestoreFiles() + if err != nil { + log.Printf("[guard] restore network baseline: %v", err) + return + } + if len(changed) > 0 { + log.Printf("[guard] restored network files: %v", changed) + } else { + log.Printf("[guard] network baseline already consistent") + } +} + +// writeRecoveryTask 写本轮 failback 恢复任务文件。 +func writeRecoveryTask(dataDir string, cfg *guardConfig, attempt int) error { + task := &recovery.Task{ + Attempt: attempt, + MaxAttempts: cfg.Recovery.MaxAttempts, + AttemptTimeout: cfg.Recovery.AttemptTimeout, + TriggerPrompt: cfg.Recovery.TriggerPrompt, + KnowledgeBase: cfg.Recovery.KnowledgeBase, + PluginList: cfg.Recovery.Plugins, + } + for _, s := range cfg.LLM.Sources { + if s.Name == "rescue" || s.Name != "" { + task.RescueSource = s + break + } + } + return recovery.SaveTask(recovery.TaskPath(dataDir), task) +} + +// rollbackAgentFS L2:guard 在 worker 离线时回滚 agentfs 最近快照(read changesets 逆应用)。 +func rollbackAgentFS(dataDir string) { + workDir := filepath.Join(dataDir, "agentfs") + trk := tracker.NewOfflineTracker(dataDir, workDir) + n, err := trk.RollbackFromDisk() + if err != nil { + log.Printf("[guard] agentfs rollback: %v", err) + return + } + log.Printf("[guard] agentfs rolled back %d change sets", n) +} + +func doLastResort(cfg *guardConfig) { + switch cfg.LastResort { + case "reboot": + log.Printf("[guard] last_resort=reboot, sync + reboot in %v", cfg.rebootGrace) + time.Sleep(cfg.rebootGrace) + syscall.Sync() + if err := syscall.Reboot(syscall.LINUX_REBOOT_CMD_RESTART); err != nil { + log.Printf("[guard] reboot failed (likely no privilege): %v", err) + } + case "restart_app": + cmd := exec.Command(cfg.RestartCommand[0], cfg.RestartCommand[1:]...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + log.Printf("[guard] restart_app command %s: %v", strings.Join(cfg.RestartCommand, " "), err) + } + } +} + +var _ = fmt.Sprintf diff --git a/cmd/homed/guard_windows.go b/cmd/homed/guard_windows.go new file mode 100644 index 0000000..ba864e3 --- /dev/null +++ b/cmd/homed/guard_windows.go @@ -0,0 +1,32 @@ +//go:build !linux + +// L0/L1/L2 三层回退恢复机制为 Linux 专属(依赖 /etc 网络基线、overlayfs、 +// syscall.Reboot、unix socket IPC、systemctl)。在 Windows 等非 Linux 平台 +// 本文件提供 no-op 桩:guard/failback 角色不执行恢复,其余主 agent 功能照常。 +package main + +import ( + "log" + "os" + + agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api" + internalConfig "gitcode.com/JianFeeeee/HomeAgent/internal/config" + luaVM "gitcode.com/JianFeeeee/HomeAgent/internal/lua" +) + +// runGuard 桩:guard 父守护模式仅支持 Linux,其余平台打印提示并退出。 +func runGuard(dataDir string) { + log.Printf("[guard] guard mode is Linux-only (data=%s), exiting", dataDir) + os.Exit(0) +} + +// runFailbackRecovery 桩:failback 恢复为 Linux 专属,其余平台直接退出。 +func runFailbackRecovery(dataDir string, cfgReg *internalConfig.ConfigRegistry, lua *luaVM.VM, providerMgr *agentAPI.ProviderManager, baseAPIKey string) { + log.Printf("[failback] recovery is Linux-only (data=%s), exiting", dataDir) + os.Exit(exitRecoveryFailed) +} + +// lastDiagSummary 桩:无 recovery_result 时返回空自诊断。 +func lastDiagSummary(dataDir string) string { + return "" +} diff --git a/cmd/homed/main.go b/cmd/homed/main.go index 0d7eca3..aa95962 100644 --- a/cmd/homed/main.go +++ b/cmd/homed/main.go @@ -7,58 +7,61 @@ import ( "io" "log" "os" - "os/exec" "os/signal" "path/filepath" "strings" "syscall" "time" + agentPkg "gitcode.com/JianFeeeee/HomeAgent/internal/agent" agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api" agentCore "gitcode.com/JianFeeeee/HomeAgent/internal/agent/core" agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io" - agentPkg "gitcode.com/JianFeeeee/HomeAgent/internal/agent" internalConfig "gitcode.com/JianFeeeee/HomeAgent/internal/config" "gitcode.com/JianFeeeee/HomeAgent/internal/events" - logpkg "gitcode.com/JianFeeeee/HomeAgent/internal/log" + "gitcode.com/JianFeeeee/HomeAgent/internal/ipc" "gitcode.com/JianFeeeee/HomeAgent/internal/knowledge" - "gitcode.com/JianFeeeee/HomeAgent/internal/meta" + logpkg "gitcode.com/JianFeeeee/HomeAgent/internal/log" luapkg "gitcode.com/JianFeeeee/HomeAgent/internal/lua" "gitcode.com/JianFeeeee/HomeAgent/internal/memory" "gitcode.com/JianFeeeee/HomeAgent/internal/memory/document" "gitcode.com/JianFeeeee/HomeAgent/internal/memory/pipeline" "gitcode.com/JianFeeeee/HomeAgent/internal/memory/social" "gitcode.com/JianFeeeee/HomeAgent/internal/memory/text" + "gitcode.com/JianFeeeee/HomeAgent/internal/meta" "gitcode.com/JianFeeeee/HomeAgent/internal/nlp" "gitcode.com/JianFeeeee/HomeAgent/internal/plugin" - cli "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/cli" + _ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins" _ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/clawhubadapter" + cli "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/cli" _ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/healthcheck" _ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/pluginmgr" _ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/webui" + "gitcode.com/JianFeeeee/HomeAgent/internal/recovery" sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk" "gitcode.com/JianFeeeee/HomeAgent/internal/supervisor" "gitcode.com/JianFeeeee/HomeAgent/internal/tracker" - _ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins" + "gitcode.com/JianFeeeee/HomeAgent/pkg/types" ) func main() { dataDir := flag.String("data", "", "data directory (default: auto-detect next to binary)") httpAddr := flag.String("webui", "", "webui listen address (default: webui.listen_addr from config)") cliSocket := flag.String("socket", "", "cli unix socket path (default: /cli.sock)") + role := flag.String("role", "agent", "process role: guard (父守护) | agent (工作进程)") + boot := flag.String("boot", "normal", "agent boot mode: normal | failback (受限启动,仅 failback 插件集)") flag.Parse() + // 父守护模式:只负责拉起/守护 worker,不初始化 agent 内核 + if *role == "guard" { + runGuard(resolveDataDir(*dataDir)) + return + } + + log.Printf("[homed] role=agent boot=%s", *boot) + if *dataDir == "" { - exe, err := os.Executable() - if err == nil { - *dataDir = filepath.Join(filepath.Dir(exe), "data") - } else { - if exe, err := exec.LookPath(os.Args[0]); err == nil { - *dataDir = filepath.Join(filepath.Dir(exe), "data") - } else { - *dataDir = "./data" - } - } + *dataDir = resolveDataDir(*dataDir) } if *cliSocket == "" { @@ -137,6 +140,8 @@ func main() { cfgReg := internalConfig.NewConfigRegistry(filepath.Join(*dataDir, "config.db")) defer cfgReg.Close() cfgReg.SeedDefaults(*dataDir) + // LLM 配置写前留档(config_set 写 core.llm.* 前自动快照),guard 恢复用基线 + cfgReg.SetLLMSnapshotFile(filepath.Join(*dataDir, "llm_snapshot.json")) cfg := cfgReg.ToConfig() // 共享词嵌入:蒸馏提取(Phase 3 TransE 验证)与 Agent 上下文复用同一实例, @@ -235,24 +240,24 @@ func main() { if err := textMem.Append(te); err != nil { log.Printf("[homed] text memory append: %v", err) } - } + } - if input != "" && memDB != nil { - distiller.Append("agent", "user", input) - } - if response != "" && memDB != nil { - distiller.Append("agent", "assistant", response) - } + if input != "" && memDB != nil { + distiller.Append("agent", "user", input) + } + if response != "" && memDB != nil { + distiller.Append("agent", "assistant", response) + } - // 工具输出接入蒸馏管线 - for _, tr := range toolResults { - if trMap, ok := tr.(map[string]interface{}); ok { - if text, ok := trMap["output"].(string); ok && text != "" && memDB != nil { - distiller.Append("agent", "tool", text) + // 工具输出接入蒸馏管线 + for _, tr := range toolResults { + if trMap, ok := tr.(map[string]interface{}); ok { + if text, ok := trMap["output"].(string); ok && text != "" && memDB != nil { + distiller.Append("agent", "tool", text) + } } } } - } } } }() @@ -291,6 +296,12 @@ func main() { } provider := providerMgr.Default() + // L1 failback:受限 worker 启动即跑恢复梯子(probe→还原DNS/proxy→还原config+ReloadFromConfig→probe), + // 结果以退出码 exitRecovered=43 / exitRecoveryFailed=44 交回 guard,不进入主 agent 循环。 + if *boot == "failback" { + runFailbackRecovery(*dataDir, cfgReg, luaVM, providerMgr, baseAPIKey) + } + // ======================================================================== // 文档记忆 + 知识库 // ======================================================================== @@ -383,32 +394,32 @@ func main() { } agent := agentCore.New(agentCore.AgentConfig{ - ID: "main", - SystemPrompt: sysPrompt, - Provider: provider, - ProviderManager: providerMgr, - IO: iom, - Memory: memDB, - Indexer: memIdx, - Tracker: trk, - DocStore: docStore, - Knowledge: ks, - SocialStore: socialStore, - TextMemory: textMem, - Personality: personality, - PluginReg: pluginReg, - PluginDir: cfg.Plugin.Dir, - DistillInterval: cfgReg.GetDuration("core.agent.distill_interval", 30*time.Minute), - ArchiveInterval: cfgReg.GetDuration("core.agent.archive_interval", 60*time.Minute), - ReviewInterval: cfgReg.GetDuration("core.agent.review_interval", 120*time.Minute), - MergeInterval: cfgReg.GetDuration("core.agent.merge_interval", 120*time.Minute), + ID: "main", + SystemPrompt: sysPrompt, + Provider: provider, + ProviderManager: providerMgr, + IO: iom, + Memory: memDB, + Indexer: memIdx, + Tracker: trk, + DocStore: docStore, + Knowledge: ks, + SocialStore: socialStore, + TextMemory: textMem, + Personality: personality, + PluginReg: pluginReg, + PluginDir: cfg.Plugin.Dir, + DistillInterval: cfgReg.GetDuration("core.agent.distill_interval", 30*time.Minute), + ArchiveInterval: cfgReg.GetDuration("core.agent.archive_interval", 60*time.Minute), + ReviewInterval: cfgReg.GetDuration("core.agent.review_interval", 120*time.Minute), + MergeInterval: cfgReg.GetDuration("core.agent.merge_interval", 120*time.Minute), ContextSavePath: filepath.Join(cfg.Daemon.DataDir, "memory", "context.json"), EmbeddingModelPath: cfgReg.GetString("core.agent.embedding_model_path", ""), - Embedder: embedder, + Embedder: embedder, StageHost: stageHost, - EventBus: evBus, - ThinkingEnabled: cfg.LLM.ThinkingEnabled, - InputProcessing: cfg.InputProcessing, + EventBus: evBus, + ThinkingEnabled: cfg.LLM.ThinkingEnabled, + InputProcessing: cfg.InputProcessing, }) // 通过 Registry 将内核依赖注入每个插件的 PluginSDK(阶段6 将替换遗留的 util.Configure) @@ -454,6 +465,24 @@ func main() { // Auto-create plugins directory (without hardcoding plugin names) os.MkdirAll(cfg.Plugin.Dir, 0755) + // failback 受限启动:仅装载 failback 插件集(webfetch/files/cmd 为内核内置, + // 此处仅控制外部插件,默认含 recoverydiag 以便直接在受限态产出恢复结论) + if *boot == "failback" { + list := cfgReg.GetString("core.agent.failback_plugins", "webui,pluginmgr,recoverydiag") + // 优先使用 guard.yaml 经过 recovery 任务下发的插件集(guard 是 failback 权威) + if task, terr := recovery.LoadTask(recovery.TaskPath(*dataDir)); terr == nil && len(task.Plugins()) > 0 { + list = strings.Join(task.Plugins(), ",") + } + var names []string + for _, s := range strings.Split(list, ",") { + if s = strings.TrimSpace(s); s != "" { + names = append(names, s) + } + } + pluginReg.SetLoadAllowlist(names) + log.Printf("[homed] failback boot: plugin allowlist = %v", names) + } + // Load all plugins — each scans its own dir and is loaded via factory or .so if err := pluginReg.Load(cfg.Plugin.Dir); err != nil { log.Printf("[homed] warning: load plugins: %v", err) @@ -468,9 +497,60 @@ func main() { agent.Start() defer agent.Stop() + // PING/ACK 心跳服务:worker 监听 unix socket,guard 发 PING、worker 回 ACK + // (含自诊断 kernel 状态快照),替换纯文件心跳。文件心跳保留作回退。 + ipcServer := ipc.NewServer(*dataDir, func() *ipc.Status { + st := agent.GetKernelStatus() + llmOK := st != nil && st.LLM.Available + tools := 0 + if st != nil { + tools = len(st.Tools) + } + uptime := int64(0) + if st != nil { + if d, err := time.ParseDuration(st.Uptime); err == nil { + uptime = int64(d.Seconds()) + } + } + return &ipc.Status{ + PID: os.Getpid(), + Boot: *boot, + UptimeSec: uptime, + LLMOK: &llmOK, + Tools: tools, + LastDiag: lastDiagSummary(*dataDir), + } + }) + if err := ipcServer.Start(); err != nil { + log.Printf("[homed] warning: ipc heartbeat server: %v", err) + } else { + defer ipcServer.Stop() + } + sup.SetTracker(trk) sup.RegisterAgent("main") + // 真实存活源 + 重启通道:daemon 心跳语义由此修正(lastHB 只在确认存活时更新), + // 重启动作不再空转——清理后以特殊退出码交给 guard/systemd 重建。 + restartCh := make(chan struct{}, 1) + sup.SetHeartbeatSource(func(id types.AgentID) (time.Time, types.HealthStatus, error) { + st := agent.GetKernelStatus() + if st == nil { + return time.Time{}, types.HealthDown, fmt.Errorf("no kernel status") + } + h := types.HealthHealthy + if !st.LLM.Available { + h = types.HealthDegraded + } + return time.Now(), h, nil + }) + sup.SetRestartHandler(func(id types.AgentID) { + select { + case restartCh <- struct{}{}: + default: + } + }) + log.Printf("[homed] main agent started, model=%s base=%s sources=%d adapters=%d", cfg.LLM.Model, cfg.LLM.BaseURL, len(cfg.LLM.Sources), len(luaVM.ListAdapters())) log.Printf("[homed] kernel ready, waiting for plugin IO...") @@ -481,9 +561,42 @@ func main() { sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) - <-sigCh - log.Printf("[homed] shutting down...") + // 心跳:每 5s 触碰 /heartbeat,guard 据此判定工作进程是否存活/卡死 + hbPath := filepath.Join(*dataDir, "heartbeat") + hbStop := make(chan struct{}) + go func() { + t := time.NewTicker(5 * time.Second) + defer t.Stop() + writeHB := func() { + if f, err := os.OpenFile(hbPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644); err == nil { + fmt.Fprintf(f, "t=%d\n", time.Now().Unix()) + f.Close() + } + } + writeHB() + for { + select { + case <-t.C: + writeHB() + case <-hbStop: + return + case <-ctx.Done(): + return + } + } + }() + + restartRequested := false + select { + case <-sigCh: + log.Printf("[homed] shutting down...") + case <-restartCh: + restartRequested = true + log.Printf("[homed] restart requested, shutting down cleanly then exiting with code %d", exitRestartRequested) + } + + close(hbStop) pluginReg.StopAll() if trk != nil { trk.Stop() @@ -493,4 +606,8 @@ func main() { } sup.Shutdown() log.Printf("[homed] stopped") + + if restartRequested { + os.Exit(exitRestartRequested) + } } diff --git a/cmd/homed/worker_exit.go b/cmd/homed/worker_exit.go new file mode 100644 index 0000000..7f614a2 --- /dev/null +++ b/cmd/homed/worker_exit.go @@ -0,0 +1,30 @@ +package main + +import ( + "os" + "os/exec" + "path/filepath" +) + +// worker 退出码协议:guard 通过 worker 退出码判定下一步动作。 +// 该协议跨平台一致(Windows 下 guard 为 no-op,退出码仍保留)。 +const ( + exitRestartRequested = 42 // worker 清理后请求重建(不计失败轮次) + exitRecovered = 43 // failback worker 恢复成功,guard 应重置失败轮次并交回主 agent + exitRecoveryFailed = 44 // failback worker 单轮恢复失败,guard 计入失败并进入下一轮/最后手段 +) + +// resolveDataDir 复用原有的数据目录探测逻辑。 +func resolveDataDir(dataDir string) string { + if dataDir != "" { + return dataDir + } + exe, err := os.Executable() + if err == nil { + return filepath.Join(filepath.Dir(exe), "data") + } + if exe, err := exec.LookPath(os.Args[0]); err == nil { + return filepath.Join(filepath.Dir(exe), "data") + } + return "./data" +} diff --git a/demo.md b/demo.md new file mode 100644 index 0000000..eea61db --- /dev/null +++ b/demo.md @@ -0,0 +1,364 @@ +# HomeAgent 自愈 / Failback 架构设计与讨论全程记录 (demo.md) + +> 本文档按讨论演进顺序记录"守护 / 保活 / 文件追踪 / 崩溃自愈 / failback"整个设计过程, +> 含代码勘查结论、现实日志记录模式分析,以及最终定稿的架构与尚未落地的接口清单。 + +--- + +## 0. 背景与目标 + +框架目标:**内核零 IO、插件承载所有 IO**(`homed 内核 ← PluginSDK → 插件`),三层记忆 + 常用块。 + +**Failback 的定位:所有错误的一层兜底(Safe-Mode 式),而非针对单一场景。** + +- 主 agent(全量 LLM agent)是一切骚操作的执行者,可能把自己搞到无法自愈的任意状态: + LLM 源改坏 / 系统网络(proxy、DNS、host)破坏 / 配置文件损坏 / OOM / panic / 崩溃循环…… + 这些错误无法在**同一个被污染环境内**用自身操作自救。 +- 故需要一层**脱离主 agent 坏环境的、最小厚度且独立可控的恢复层**—— + 类似 Windows **安全模式 / 启动修复**:只带最小驱动集合 + 干净 LLM 源(锚定 IP), + 单一职责:**让主 agent 回到可用状态;若不可行,则做最后的系统级兜底(回滚快照 / 重启)。** +- 它不替代主 agent 的功能,只在主 agent 无法自愈时作为最后一道防线出现。能省则省、能判定就不推理、有底即主张。 + +--- + +## 1. 现状盘点(代码勘查结论) + +### 1.1 守护进程(`internal/supervisor/daemon.go`) + +- 主 agent in-process 常驻,`agent.Start()` 在 `cmd/homed/main.go:468` 直接启动,**无独立进程边界**。 +- `healthLoop` → `checkAgent` 判 LLM 是否可达,判定**仅依赖 `network.Monitor` 的 `AggregateResult().LLMAPIReachable`**(`daemon.go:132`)。 +- `failCount >= MaxRetries`(默认 3)→ `handleFailure`: + - 有 tracker → `trk.Rollback()`;失败才降级 `restartAgent`。 + - `restartAgent`(`daemon.go:166`)**只改内存状态再重新 Register,不真重启任何进程**,几乎空转。 + +关键问题: +- 探测是系统级(HTTP/DNS/TCP),回滚只作用于 `/agentfs` overlay 的 upper,**二者对象错位**。 +- `AgregateResult` 在无 LLM endpoint 时恒 healthy,机制形同虚设。 +- `lastHB` 每轮都置 `time.Now()`,`Uptime` 无意义。 +- `RollbackPolicy` 的 `HealthThreshold/CooldownPeriod/AutoRollback` 都是死字段,只用 `MaxRetries`。 + +### 1.2 文件追踪(`internal/tracker`) + +- overlayfs 三层:`lower/upper/work → merged`(`tracker.go:156`)。 +- `captureFSState(upperDir)` 递归遍历 upper 并 sha256(`changeset.go:56`);`PreAction/PostAction` 前后 diff(`toolcall.go:86-94`)。 +- **lower 恒空**(`Init` 只 `MkdirAll`,从不填充)→ 无 canonical 基线可回滚。 +- `Rollback()` = `RemoveAll(upper)` 清空全部 changesets;`FileChange.Content`(本应存回滚原文)**从未回填**。 + +结论:overlay/tracker 对"LLM 可达性"这主场景**错位**,只能作数据兜底。 + +### 1.3 通信插件真相(`third_party/homeagent-sdk/example/qq/plugin.go`) + +- agent 对外通信全部由**插件设置**驱动,存于 **ConfigRegistry / SQLite config.db**: + `qq.napcat_url`、`qq.listen`、`qq.files_dir`、`qq.remote_dir`、`dm/group_policy`(`plugin.go:120-130`)。 +- 插件在 `Start()` 里 `getSetting(...)` 读设置(`plugin.go:134-143`)→ **改动配置需重载插件才生效**。 +- `cfgmgr` 提供 `config_set / config_batch_set` 可运行时改任意 core/插件配置(`cfgmgr/plugin.go:54,103`)。 + +### 1.4 LLM 源与"恢复即生效" + +`internal/sdk/llm_impl.go:103 ReloadFromConfig()` **已存在**: +- `cfg := cfgReg.ToConfig()` 从 config.db 重建(含 `core.llm.sources.*`,见 `registry.go:590`) +- `mgr.Reset()` → 逐源 `NewLuaAdaptedProvider` → 重设默认。 + +即:**LLM 源的"恢复即生效"钩子已经具备**,缺的是"快照 + 探测 + 触发"三件事。 + +--- + +## 2. 现实环境:日志记录模式内参 + +> 看真实 systemd 托管的 HomeAgent(`/home/newqqagent`)日志,**目的是弄清现有的日志模型** +> (写哪、什么格式、工具调用打在哪),为 failback / recoveryDiag 的 `diag_log_scan` 提供准确的解析依据。 + +### 2.1 systemd 托管现状(样例) + +``` +homeagent.service: Type=simple, ExecStart=/usr/local/bin/homed -data /home/newqqagent, Restart=always, RestartSec=10 +llm-mock.service: ExecStart=/usr/bin/python3 /opt/llm-mock/mock_server.py, Restart=always, RestartSec=3 +``` + +- 实测数据区:`/home/newqqagent/` 下有 `log/`、`config.db`、`agentfs/`(overlay merged)、`snapshots/`、`changesets/`、`knowledge/`、`memory/`、`plugins/`、 + `cli.sock`、`adapters/`、`homed.log`、`memos.json` 等——**日志以独立子目录 `log/` 存放,与配置/快照/knowledge 分置**。 +- 启动段确认:`[files] started, sandbox: /`(**files 沙箱=全主机 `/` 实锤**);`main agent started, model=mock-model base=http://127.0.0.1:18080/v1 sources=3 adapters=8`(LLM 走本地 mock)。 + +### 2.2 日志目录与格式(核心) + +- **目录配置**:`core.log.path`,默认 `/log`(`internal/config/registry.go:415,508`)。 +- **单次运行文件**:每次启动新建 `homed_.log`(`cmd/homed/main.go:75`), + 写入 `logDir` 下;`log.SetOutput(io.MultiWriter(os.Stderr, logFile))`(`main.go:80`)—— + **同时进 stderr(systemd 捕获到 journald/`journalctl -u`)与文件**。 +- **格式**:标准 Go `log.Printf`,即 `YYYY/MM/DD HH:MM:SS file.go:line: [module] message`。 + 用户可看文件,也可用 `journalctl -u homeagent.service` 看同一来源(同一行)。 +- **层级压缩 + 保留**(`internal/log/manager.go:26-28` + `compressor.go`): + - 周度压缩 → `week_-W.tar.gz`;月度 → `month_.tar.gz`;年度 `year_*.tar.gz`; + raw 文件正则 `^homed_(\d{4}-\d{2}-\d{2})_\d{2}-\d{2}-\d{2}\.log$`(`compressor.go:16`)。 + - 保留策略:`core.log.retention`(default forever)、`core.log.retention_months`(default 3), + `applyRetention` 只留当前周 + 近 N 月(`retention.go`)。 + 实测:`log/` 下即为 `homed_2026-08-03_08-03-38.log` + `month_2026-*.tar.gz` + `week_2026-W31.tar.gz`,与代码一致。 + +### 2.3 工具调用日志打在哪儿(进程主循环 `internal/agent/core/process.go`) + +| 位置 | 日志行内容 | 备注 | +|---|---|---| +| `process.go:36` | `[agent] tool call loop start, max_ctx=… target=… fixed=… mem=… ctx=… N tools, M events, personality=X, docs=K` | 每轮循环开头上下文统计 | +| `process.go:196` | `[agent] executing tool: (plugin=

, id=)` | **只记工具名/插件/id,不记 args** | +| `process.go:226` | `[agent] tool result: <截断100字符>` | 结果截断到 100 字符(`truncateStr`)| +| `process.go:218` | `[agent] skip tool : plugin unhealthy` | 插件崩溃态跳过 | +| `process.go:89/109/121/125` | LLM fallback:`trying provider %q (#%d)` / `switched active provider` / `provider %q marked unavailable (HTTP %d)` / `provider %q failed` | 主循环内 LLM 商可观测 | +| `toolcall.go:20` | `[agent] tool %s panic: %v` + `debug.Stack()` | 工具 panic + 完整栈 | +| `toolcall.go:41` | `[agent] tool %s timed out after 60s` | 60s 超时 | +| `toolcall.go:92` | `[agent] tool %s changed %d files (changeset: %s)` | overlay changeset 摘要 | +| 插件侧 | Lua 插件 `sdk.log` → `print("[lua-plugin] : ")` | 模板见 `cmd_debug.go:74`/`templates.go` | + +- 完整的工具**入参/结果**在 EventBus 事件 `EventToolCall`(`{tool, plugin, args, result, status}`,`process.go:184/205`)而非文件日志——**文件日志只是执行/结果的摘要指针**(结果被截断)。 +- 重要观察:日志里未见 shell/cmd 之类的操作系统执行类调用摘要落盘(`[cmd]` 只在工具结果里), + 需要的话由 `diag_log_scan` 对 `executing tool: cmd_*` 前缀做签名匹配即可。 + +### 2.4 崩溃 / 重启观察 + +- `NRestarts=0`;MainPID 自 08-03 起稳定 3330844。曾出现**真实重复 panic**(pid 3310036): + `[stage] handler panic: runtime error: invalid memory address or nil pointer dereference`(03:23 / 05:23 / 07:23,约每 2h), + 被 `stages.go:139` 的 `RunStage` recover 吞掉 → **进程未真崩**,systemd 未见重启。 +- 08:03:38 有过一次干净 `[homed] stopped` → systemd `Started` → pid 3310036 → 3330844。 +- 对 failback 的意义:现有崩溃防护全赖 **in-process recover**,真实进程级崩溃从未被监督; + 且当前 `Restart=always` 由 systemd **直绑 worker 且无 StartLimit**——一旦真崩并陷入循环, + systemd 每 10s 反复拉起,没有独立 failback/取证层。→ guard 取代点在此。 + +--- + +## 3. 设计演进(讨论全过程) + +### 3.0 起点:`internal/supervisor` + `internal/tracker` 我是"保活 + 文件追踪" + +- 保活 = 网络健康感知 + 内存态重置;追踪 = overlayfs 变更集 + `Rollback` 清空。 +- 意图:LLM 不可达 → 回滚 agent 文件改动 → 自愈。 + +### 3.1 第一次纠正:回滚对象错位 + +- 回滚只作用于 overlayfs upper;而真正能改坏网络的路径(files 默认 `/`,可写 `/etc/resolv.conf`、`/etc/hosts`、代理配置)与 LLM 配置(config.db、adapters 目录)**都不在 overlay 内**。 +- → 检测命中但回滚删错对象,闭环在"回滚"这一环断掉。 + +### 3.2 第二次纠正:授权不能靠外部插件主动配合 + +- 阶段管道是被动通知(`RunStage` 收集 error,不主动拒绝),拒绝依赖各插件 handler。 +- 插件是外部不可控对象 → "安全 = 插件主动授权放行"不成立。 +- → 安全应做**默认拒绝**,由内核在 `executeToolCall` 分发点按 ToolDef 的 capability 裁决;配置快照 + replug 只兜底"可文件化"改动。 +- 中途又修正:真正的祸首不是 IO 组件,而是 LLM 源/系统网络配置;IO 组件崩溃是正交偶发轴,不并进来。 + +### 3.3 第三次纠正:LLM 源才是核心(config.db + ReloadFromConfig 已具钩子) + +- `llm_set_source` 只切内存默认;真正改坏 LLM 源靠 `cfgmgr.config_set` 写 config.db `core.llm.sources.*`。 +- `ReloadFromConfig()` 已能"恢复即生效" → 只需补:**LLM 配置快照 + 真实 liveness 探测(QuickChat)+ 在 handleFailure 里触发 RFO 复检**。 +- 但用户进一步点明:**重点是 agent 改了系统网络配置文件(proxy/DNS)** —— 这类连救援 LLM 都连不上。 + +### 3.4 定稿架构一:lastFailback(独立进程,第一道防线) + +- 独立进程 + agent 碰不到的 root:0600 配置 + 最小插件集(文件读写 + cmd)+ 单一任务。 +- 用**锚定 IP/干净 DNS** 的 LLM 源绕开坏掉的 DNS/proxy。 +- 若 failback 也失败 → agentfs 回滚最近快照 + 重启计算机(应用快照前系统配置)。 +- 兼顾 systemd 托管冲突(主 agent 崩溃循环被 systemd 反复拉起 → failback 无限增殖)。 + +### 3.5 定稿架构二:去掉强 systemd 依赖 → 独立 guard 守护进程 + +- systemd 只绑定**静态、极小、永驻的 guard**;guard 作为父进程拉起 worker(`homed` 全量 agent)。 +- guard 通过 IPC(unix socket / 匿名 pipe)收 worker 心跳; + 父进程 `waitpid` 天然 reap 崩溃/OOM/信号退出。 +- worker 可崩可换,systemd 完全看不见 → **failback 唯一性 + 无风暴天然成立**。 +- guard 抗 OOM:`oom_score_adj` 拉低,优先杀 worker 而非 guard。 + +### 3.6 定稿架构三:更纯粹 —— 同一二进制的两个 role,非独立二进制 + +- **不新建二进制**。`homed` 拆两个入口: + - `homed --role=guard`:父守护进程,先起,负责拉起/监测/探活/裁决/恢复。 + - `homed --role=agent`:主 agent(工作进程,guard 的子进程)。 + - `homed --role=agent --boot=failback`:恢复用 agent(受限 bootstrap)。 +- guard 复用现有 homeagent 基础设置;检测到崩溃时拉起 failback agent,只加载 + **webfetch + 文件读写 + cmd** 三个插件,外加 **恢复知识库插件** 与 **常见错误检测插件**, + 复用 agent 核心以 `trigger_prompt` 初始化,要求其"尝试恢复主 agent"。 +- guard 配置**独立 YAML**,不复用 config.db(逃生舱知识必须脱离 agent 可达区)。 + +### 3.7 recoveryDiag:崩溃取证 / 根因定位插件(省 token 关键) + +- 铁律:**工具返回结论,不返回原文**(签名式/统计式/确定性排序)。 +- 工具集: + | 工具 | 作用 | + |---|---| + | `diag_triage` | exit code/信号+uptime+头部嫌疑,快速粗分"进程死亡 vs 配置类不可达" | + | `diag_db` | config.db integrity_check + LLM 源解析校验,逐项 ok/fail | + | `diag_log_scan` | 时间窗内命中已知错误签名(panic/provider failed/unreachable/sql/OOM)| + | `diag_delta` | 崩溃前 config/agentfs 与 last-good 快照 diff("改了什么")| + | `diag_loc` | 综合正交,输出按因果强度排序的定位结论 + 推荐动作 | + +- 崩溃类别 → 恢复分支决策表: + | 结论类 | 走分支 | + |---|---| + | 配置损坏类 | 还原 config 快照 + ReloadFromConfig + 拉活主 agent(无需 agent 推理)| + | 系统网络类 | 还原 DNS/proxy → 重载主 agent(第一步小修命中即停)| + | 进程失稳类(OOM/panic)| 不还原配置,检查内存/泄漏 → 重建 worker | + | 未知/混合 | 放开 webfetch/知识库,用 rescue 源 + diag_loc 摘要最小推理 | + +- 只有"未知/混合"消耗 token,前几类近乎 0 token。 +- 结论落盘 `recovery_kb/diag_.json`,回流知识库,同类崩溃下次直接命中,越用越省。 + +### 3.8 三层防御总览(最终) + +``` +L0 平时:核心只读探活 + 写前快照(config_set 写 core.llm.* 前、files 写 /etc 前自动留档) +L1 failback(guard 拉起,Safe-Mode 式兜底):按诊断分支逐类恢复—— + 还原 DNS/proxy → 还原 config 快照 + ReloadFromConfig → QuickChat 复检 → 拉起主 agent +L2 最后手段:agentfs 回滚最近快照 + 重启(应用快照前系统配置) +``` + +- failback 是**所有错误(LLM 不可达 / 网络 / 配置损坏 / OOM / panic / 崩溃循环)的统一兜底层**, + 并非只针对某一条;`diag_*` 决定它走哪条恢复路径。 + +--- + +## 4. 最终架构(定稿) + +### 4.1 进程拓扑(同一二进制,两个 role) + +``` +systemd ──▶ homed --role=guard # 父守护进程,永驻、静态、极小 + ├─ exec ──▶ homed --role=agent # 主 agent(可崩) + └─ exec ──▶ homed --role=agent --boot=failback # 恢复用 agent +``` + +- guard:先起,持有恢复知识(锚定源 / DNS/proxy 还原 / 配置快照 / failback 逻辑)。 +- worker:guard 子进程,心跳经 IPC,崩溃由 guard reap + 判型。 +- failback agent = 受限启动(webfetch+files+cmd + 恢复知识库 + recoveryDiag),单一任务"恢复主 agent",N 轮有界。 + +### 4.2 guard 独立 YAML 示例 + +> 现状实现(§5 已完成):`guard.yaml` 已落地为 `max_restarts / heartbeat_timeout / heartbeat_interval / llm_snapshot / failback_enabled / last_resort / restart_command / reboot_grace` 子集(`cmd/homed/guard.go`),恢复梯子=重试→LLM 基线恢复→failback 受限启动→last_resort。下表的 rescue 源 / trigger_prompt / N 轮 failback 推理是目标态,未实现。 + +```yaml +role: guard + +llm: + sources: + - name: rescue + base_url: http://1.2.3.4:8080 # 锚定 IP 直连,绕开被破坏的 DNS/代理 + api_key: ${GUARD_RESCUE_KEY} + adapter: ... # 锚定/SNI 型适配器 + +recovery: + max_attempts: 4 # 可配置尝试轮次 + attempt_timeout: 120s + knowledge_base: /opt/homeagent/recovery/ + trigger_prompt: "你是恢复 agent,唯一任务:让主 agent 恢复运行。优先还原 DNS/代理,再重载 LLM 源…" + plugins: [webfetch, files, cmd] + +last_resort: + action: reboot # restart_app | reboot + snapshot_before: true +``` + +### 4.3 guard 恢复状态机(N 轮有界) + +``` +guard 检测( exit≠0 | OOM | 心跳超时 | guard 锚定源探活失败 ) + 1. 固化追溯:exit/信号、panic、journal、OOM 上下文 → 永久区 + 2. 拉起 failback agent(受限插件 + rescue 源 + trigger_prompt + 知识库 + recoveryDiag) + for attempt in 1..N: + (可选先 diag_triage/diag_loc 判型) + failback 尝试恢复 + guard 每轮复检主 agent 是否可达/存活 + ├─ 成功 → 结束,交回主 agent + └─ 超时/失败 → kill 重建,进入下一轮 + 3. N 轮未成 → 取消 failback agent + → agentfs 回滚崩溃前最近快照 + → 依 yaml 执行最后手段:restart_app 或 reboot +``` + +### 4.4 systemd 绑定(极简,杜绝风暴) + +``` +[Unit] # guard +OnFailure=... # 备用,通常不触发(guard 稳定) + +[Service] # guard +Restart=always # guard 静态稳定 → 几乎不重启 +ExecStart=/usr/local/bin/homed --role=guard ... +# No StartLimit needed for loop 情况;guard 不崩 +``` + +- 主 agent 崩 → 只触发 guard 内部 failback;systemd 仅看 guard,看不到 worker 崩溃循环。 +- failback 唯一性 + 无启动风暴:由"guard 永驻、唯一裁决"天然保证。 +- guard 抗 OOM:`oom_score_adj` 拉低。 + +--- + +## 5. 尚未落地的接口 / 下一步 + +**已完成**: + +`recoverydiag` 快速检查插件(`third_party/homeagent-sdk/example/recoverydiag/`,外部插件)。 +- 五件套全实现:`diag_triage`(退出码/信号/存活粗分)、`diag_db`(config.db integrity_check + LLM 源字段校验,sqlite3 CLI 优先、缺失回退内核 Settings)、`diag_log_scan`(日志签名按类计数)、`diag_delta`(baseline vs 现状 diff)、`diag_loc`(四项结论正交排序 + 推荐恢复动作)。 +- 全部确定性、返回结论非原文、`NoMemory`;工具实际名带插件前缀 `recoverydiag_diag_*`。 +- 已通过 go vet + 6 个单测(对真实 config.db/日志跑通:3 个 LLM 源全 ok、日志命中 228 行主导 provider/fatal),并用**仓库内重建的 plugindev** 打出 `dist/recovery_diagnostics_linux_amd64.hmap`,装进运行实例(`/home/newqqagent/plugins/recoverydiag/`)加载成功、注册 5 工具。 +- **结论落盘 + 知识库回流**:`diag_loc` 增 `persist`(缺省 true)→ 写 `/recovery_kb/diag_.json`(可配 `recovery_kb_dir`),并经 `sdk.Knowledge().Add` 以 `diag::` 回流知识库(同类崩溃下次直接命中,越用越省);失败不阻塞工具。新增 `TestDiagLocPersist`。 +- 顺带修复:仓库内 `plugindev` 需重编译(`/usr/local/bin/plugindev` 是旧版、桥模板缺 `InjectInputSync`);重编译见 `third_party/homeagent-sdk/tools/plugindev`,`go build -o ... .`。 +- 注意:本环境 `snapshots/`、`changesets/` 均为空(direct 模式无基线)→ `diag_delta` 需显式传入 baseline_dir;未来接 guard 时由快照解包目录提供。 + +`ConfigRegistry` 快照钩子(`internal/config/registry.go`)。 +- `SnapshotCoreLLM()`:抓全部 `core.llm.*` 键值快照;`RestoreCoreLLM(snap)`:精确还原(快照内键回写、快照外当前键删除)。 +- `SetLLMSnapshotFile(path)`:写前自动留档——此后任意写 `core.llm.*` 键先把当前 LLM 配置整体快照到该文件(guard 恢复的外部基线);homed 启动即挂 `/llm_snapshot.json`。 +- 文件持久化对:`SaveLLMSnapshot/LoadLLMSnapshot`。新增 `TestSnapshotRestoreCoreLLM`、`TestLLMSnapshotFile`、`TestSetLLMSnapshotFile`。 + +`homed --role{guard,agent}` 入口拆分 + `--boot=failback` 受限插件集(`cmd/homed/`)。 +- `--role=guard` 父守护(永驻):读独立 `/guard.yaml`(避开被改坏的 config.db),拉起 worker(`--role=agent`)、心跳探活 + waitpid 收割、信号转发停机。 +- `--role=agent` 工作进程:默认启动全插件;`--boot=failback` 走插件白名单(`core.agent.failback_plugins`,缺省 `webui,pluginmgr,recoverydiag`),内核 webfetch/files/cmd 仍内置可用。 +- guard 恢复梯子(已端到端实测):连续 `max_restarts` 次 normal 崩溃 → `restoreLLMBaseline`(从 llm_snapshot.json 恢复 core.llm.*)→ failback 受限启动 → failback 也崩 → `last_resort`(restart_app / reboot)。 +- 心跳:worker 每 5s 触碰 `/heartbeat`(agent 角色 goroutine),guard 以 mtime 判定卡死(超 `heartbeat_timeout` 即 SIGKILL 计入崩溃)。 +- 插件注册表加 `SetLoadAllowlist(names)`:白名单外插件(含已注册工厂)一律跳过,failback 40 工具 → 4 工具实测通过。 + +现状 bug 修复(supervisor/network/tracker)。 + +- **monitor 无 endpoint 恒 healthy**(`internal/network/monitor.go` + `pkg/types`):`NetworkCheckResult` 增 `EndpointsConfigured`;无探活端点时不再谎报 `LLMAPIReachable=true`(置 false + Error),`NewMonitor` 初始化空切片消除启动竞态;daemon 仅在配置了端点时才据此判定降级。探活端点新增 `core.defaults.llm_endpoints`(逗号分隔,留空自动取 LLM 源 base_url),生产从此健康检查有真实目标。 +- **lastHB 恒置 now**(`internal/supervisor/daemon.go`):`checkAgent` 接入真实存活源 `SetHeartbeatSource`(homed 注册为 agent core `GetKernelStatus`),只在确认 agent 存活时更新 `lastHB`;无源置 `HealthUnknown`,存活源丢失置 `HealthDown` 且不再刷新 lastHB。 +- **restartAgent 只改内存空转**:增 `SetRestartHandler`(homed 注册为"清理后以 `exitRestartRequested=42` 退出"),不再假装成功;guard 把 42 识别为"请求重建"(`workerRestartRequested`,不计失败轮次直接重建),无 guard 时 systemd `Restart=always` 兜底。无 handler 时仅内存复位并打日志。 +- **tracker 三缺陷**(`internal/tracker/`):`captureFSStateWithContent` 为 before 基线捕获原文(上限 8MB)→ `diffStates` 对 modified/deleted 回填 `FileChange.Content`(回滚用原文);新增 `RollbackLatest()` 定向撤销最近一条 changeset;`Rollback()` 改为按时间逆序逐条逆应用(还原被改/被删文件原文、删除新增),无 changeset 时才退回整目录重置。新增 6 个测试覆盖。 + +剩余: + +- guard ↔ agent 心跳 IPC 升级为带自诊断上报的 `PING/ACK`(当前为文件心跳 + 退出码)。 +- supervisor 适配成 guard 的探测/裁决逻辑;`ReloadFromConfig()` 复用为"恢复即生效"。 +- 生产实例迁移:编译新 homed、改 systemd 只托管 guard(`--role=guard`),确认 failback 插件(recoverydiag)就位。 + +--- + +## 附录:真实日志节选(systemd 托管示例,`/home/newqqagent`) + +``` +# systemd unit +homeagent.service: Type=simple, ExecStart=/usr/local/bin/homed -data /home/newqqagent, Restart=always, RestartSec=10 +llm-mock.service: ExecStart=/usr/bin/python3 /opt/llm-mock/mock_server.py, Restart=always, RestartSec=3 + +# 日志文件与格式(log.Printf 标准格式) +2026/08/03 08:03:38 main.go:80: [homed] logging to /home/newqqagent/log/homed_2026-08-03_08-03-38.log +2026/08/03 08:03:38 daemon.go:61: [homed] daemon started successfully +2026/08/03 08:03:39 tracker.go:65: [tracker] initialized (work=/home/newqqagent/agentfs) + +# 工具调用摘要(process.go) +2026/08/03 10:03:52 process.go:36: [agent] tool call loop start, max_ctx=32768 target=26214 fixed=1306 mem=8302 ctx=16606 176 tools, 31 events, personality=true, docs=5589 +... process.go:196: [agent] executing tool: (plugin=

, id=) +... process.go:226: [agent] tool result: <截断100字符> + +# 启动 & 沙箱 +homed[3330844]: [files] started, sandbox: / +homed[3330844]: main agent started, model=mock-model base=http://127.0.0.1:18080/v1 sources=3 adapters=8 + +# 重复 in-process panic(被 RunStage recover 吞掉,未进程级崩溃) +homed[3310036]: [stage] handler panic: runtime error: invalid memory address or nil pointer dereference # 03:23 / 05:23 / 07:23 + +# 一次性干净重启(systemd 手动/触发 Started,pid 3310036 → 3330844) +homed[3310036]: [homed] stopped +systemd[1]: Stopped homeagent.service - HomeAgent - 24/7 AI Butler. +systemd[1]: Started homeagent.service - HomeAgent - 24/7 AI Butler. + +# 运行状态 +systemctl show homeagent.service -p NRestarts → 0 +systemctl show homeagent.service -p MainPID → 3330844(自 08-03 起稳定) + +# 归档 +/home/newqqagent/log/: homed_2026-08-03_08-03-38.log + week_2026-W31.tar.gz + month_2026-*.tar.gz +``` \ No newline at end of file diff --git a/docs/zh/plan.md b/docs/zh/plan.md new file mode 100644 index 0000000..2aed4f6 --- /dev/null +++ b/docs/zh/plan.md @@ -0,0 +1,284 @@ +# WebUI 布局与配置归位修复计划 + +## 一、背景 + +上一轮 SDK 接口化改造完成并部署后,用户指出三个问题: + +1. **WebUI 窄屏布局损坏**:顶部 `