三层回退恢复机制(L0写前留档/L1恢复梯子/L2离线回滚)+ guard 父守护

- 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 单元测试全过
This commit is contained in:
JianFeeeee
2026-08-05 16:00:08 +08:00
parent 49cc1dbe16
commit 840296379c
28 changed files with 3675 additions and 167 deletions

View File

@ -2,7 +2,10 @@ package config
import (
"database/sql"
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"sort"
"strconv"
@ -28,10 +31,11 @@ type ConfigDef struct {
}
type ConfigRegistry struct {
mu sync.RWMutex
db *sql.DB
dbPath string
defs map[string]*ConfigDef
mu sync.RWMutex
db *sql.DB
dbPath string
defs map[string]*ConfigDef
llmSnapFile string
}
func NewConfigRegistry(dbPath string) *ConfigRegistry {
@ -265,6 +269,9 @@ func (r *ConfigRegistry) Get(key string) (interface{}, error) {
func (r *ConfigRegistry) Set(key string, value interface{}) error {
r.mu.Lock()
defer r.mu.Unlock()
if strings.HasPrefix(key, "core.llm.") {
r.writeLLMSnapshotLocked()
}
_, err := r.db.Exec(`INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)`, key, fmt.Sprint(value))
if err == nil && strings.HasPrefix(key, "core.llm.sources.") {
rest := strings.TrimPrefix(key, "core.llm.sources.")
@ -303,6 +310,107 @@ func (r *ConfigRegistry) Delete(key string) error {
return err
}
// SnapshotCoreLLM 捕获全部 core.llm.* 键值(LLM 源密度快照),供写前留档。
// 返回值是 key→value 的不可变拷贝;写入 guard 配置恢复的基线。
func (r *ConfigRegistry) SnapshotCoreLLM() map[string]string {
r.mu.RLock()
defer r.mu.RUnlock()
return r.listPrefixLocked("core.llm.")
}
// SetLLMSnapshotFile 设定写前留档文件:此后任意写入 core.llm.* 键时,
// 先把当前 llm 配置整体快照到该文件(guard 恢复的外部基线)。
func (r *ConfigRegistry) SetLLMSnapshotFile(path string) {
r.mu.Lock()
defer r.mu.Unlock()
if path != "" {
r.writeLLMSnapshotToFileLocked(path)
}
r.llmSnapFile = path
}
// writeLLMSnapshotLocked 调用方须持有写锁;若已配置快照文件则写入当前 llm 快照。
func (r *ConfigRegistry) writeLLMSnapshotLocked() {
if r.llmSnapFile == "" {
return
}
r.writeLLMSnapshotToFileLocked(r.llmSnapFile)
}
func (r *ConfigRegistry) writeLLMSnapshotToFileLocked(path string) {
snap := r.listPrefixLocked("core.llm.")
if err := SaveLLMSnapshot(path, snap); err != nil {
log.Printf("[config] save llm snapshot %s: %v", path, err)
}
}
// RestoreCoreLLM 精确还原到快照状态:快照里有的键回写旧值,
// 当前存在但快照里没有的 core.llm.* 键删除(保持与快照一致)。
func (r *ConfigRegistry) RestoreCoreLLM(snap map[string]string) error {
r.mu.Lock()
defer r.mu.Unlock()
current := r.listPrefixLocked("core.llm.")
seen := make(map[string]bool, len(snap))
for k, v := range snap {
seen[k] = true
if _, err := r.db.Exec(`INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)`, k, v); err != nil {
return err
}
}
for k := range current {
if seen[k] {
continue
}
if _, err := r.db.Exec(`DELETE FROM config WHERE key = ?`, k); err != nil {
return err
}
}
return nil
}
// SaveLLMSnapshot 把 LLM 快照持久化到文件(guard 恢复的外部基线)。
func SaveLLMSnapshot(path string, snap map[string]string) error {
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return err
}
data, err := json.MarshalIndent(snap, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, data, 0600)
}
// LoadLLMSnapshot 从文件读回 LLM 快照。
func LoadLLMSnapshot(path string) (map[string]string, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
snap := make(map[string]string)
if err := json.Unmarshal(data, &snap); err != nil {
return nil, err
}
return snap, nil
}
// listPrefixLocked 调用方须持有锁;返回 prefix 开头的全部键值。
func (r *ConfigRegistry) listPrefixLocked(prefix string) map[string]string {
out := make(map[string]string)
rows, err := r.db.Query(`SELECT key, value FROM config WHERE key LIKE ? ORDER BY key`, prefix+"%")
if err != nil {
return out
}
defer rows.Close()
for rows.Next() {
var k, v string
if err := rows.Scan(&k, &v); err == nil {
out[k] = v
}
}
return out
}
func (r *ConfigRegistry) Dump() map[string]interface{} {
r.mu.RLock()
defer r.mu.RUnlock()
@ -464,6 +572,7 @@ func (r *ConfigRegistry) seedCoreDefs(dataDir string) {
reg(ConfigDef{Key: "core.daemon.heartbeat_interval", Default: "15s", Type: "duration", DisplayName: "心跳间隔", Description: "Agent 心跳检查间隔", Category: "daemon"})
reg(ConfigDef{Key: "core.daemon.check_interval", Default: "30s", Type: "duration", DisplayName: "检查间隔", Description: "网络状态检查间隔", Category: "daemon"})
reg(ConfigDef{Key: "core.daemon.log_level", Default: "info", Type: "select", DisplayName: "日志级别", Description: "日志输出级别", Options: []string{"debug", "info", "warn", "error"}, Category: "daemon"})
reg(ConfigDef{Key: "core.defaults.llm_endpoints", Default: "", Type: "string", DisplayName: "探活端点", Description: "健康检查的 LLM 探活端点,逗号分隔;留空自动取 LLM 源 base_url", Category: "daemon"})
reg(ConfigDef{Key: "core.llm.provider", Default: "deepseek", Type: "string", DisplayName: "默认提供商", Description: "默认 LLM 提供商名称,需匹配 sources 中的定义", Category: "llm"})
reg(ConfigDef{Key: "core.llm.model", Default: "deepseek-v4-flash", Type: "string", DisplayName: "默认模型", Description: "默认 LLM 模型名称", Category: "llm"})
@ -698,6 +807,23 @@ func (r *ConfigRegistry) ToConfig() *types.Config {
cfg.Plugin.Dir = read("core.plugin.dir", cfg.Plugin.Dir)
// 探活端点:优先显式配置,缺省取 LLM 源 base_url(去重),保证健康检查有实际目标
if eps := read("core.defaults.llm_endpoints", ""); eps != "" {
for _, ep := range strings.Split(eps, ",") {
if ep = strings.TrimSpace(ep); ep != "" {
cfg.Defaults.LLMEndpoints = append(cfg.Defaults.LLMEndpoints, ep)
}
}
} else {
seen := make(map[string]bool)
for _, src := range cfg.LLM.Sources {
if src.BaseURL != "" && !seen[src.BaseURL] {
seen[src.BaseURL] = true
cfg.Defaults.LLMEndpoints = append(cfg.Defaults.LLMEndpoints, src.BaseURL)
}
}
}
cfg.InputProcessing.Image.FallbackProvider = read("core.input_processing.image.fallback_provider", cfg.InputProcessing.Image.FallbackProvider)
cfg.InputProcessing.Image.FallbackModel = read("core.input_processing.image.fallback_model", cfg.InputProcessing.Image.FallbackModel)
cfg.InputProcessing.Image.DescribePrompt = read("core.input_processing.image.describe_prompt", cfg.InputProcessing.Image.DescribePrompt)

View File

@ -220,3 +220,73 @@ func TestGetHelpers(t *testing.T) {
t.Fatalf("GetBool fallback: expected true, got %v", got)
}
}
func TestSnapshotRestoreCoreLLM(t *testing.T) {
r := NewConfigRegistry("")
defer r.Close()
r.Set("core.llm.sources.main.base_url", "https://a")
r.Set("core.llm.sources.main.model", "m1")
r.Set("core.llm.sources.main.api_key", "k1")
snap := r.SnapshotCoreLLM()
if len(snap) != 3 {
t.Fatalf("expected 3 keys, got %d: %v", len(snap), snap)
}
// 模拟写坏
r.Set("core.llm.sources.main.base_url", "https://broken")
r.Set("core.llm.sources.main.api_key", "hacked")
r.Set("core.llm.sources.extra.model", "intruder")
if err := r.RestoreCoreLLM(snap); err != nil {
t.Fatalf("RestoreCoreLLM: %v", err)
}
got := r.SnapshotCoreLLM()
if len(got) != 3 {
t.Fatalf("after restore expected 3 keys, got %d: %v", len(got), got)
}
for k, v := range snap {
if got[k] != v {
t.Errorf("key %s: want %q got %q", k, v, got[k])
}
}
}
func TestLLMSnapshotFile(t *testing.T) {
path := filepath.Join(t.TempDir(), "llm_snapshot.json")
snap := map[string]string{"core.llm.sources.main.base_url": "https://a", "core.llm.sources.main.model": "m1"}
if err := SaveLLMSnapshot(path, snap); err != nil {
t.Fatalf("Save: %v", err)
}
got, err := LoadLLMSnapshot(path)
if err != nil {
t.Fatalf("Load: %v", err)
}
if got["core.llm.sources.main.base_url"] != "https://a" || got["core.llm.sources.main.model"] != "m1" {
t.Fatalf("round-trip mismatch: %v", got)
}
}
func TestSetLLMSnapshotFile(t *testing.T) {
r := NewConfigRegistry("")
defer r.Close()
r.Set("core.llm.sources.main.base_url", "https://orig")
r.Set("core.llm.sources.main.model", "m0")
path := filepath.Join(t.TempDir(), "llm_pre.json")
r.SetLLMSnapshotFile(path)
// 再次写入:写前自动留档应记录当前值 orig/m0,随后才被覆盖
r.Set("core.llm.sources.main.base_url", "https://broken")
got, err := LoadLLMSnapshot(path)
if err != nil {
t.Fatalf("Load snapshot: %v", err)
}
if got["core.llm.sources.main.base_url"] != "https://orig" {
t.Fatalf("write-ahead snapshot should record pre-write value, got %q", got["core.llm.sources.main.base_url"])
}
if got["core.llm.sources.main.model"] != "m0" {
t.Fatalf("snapshot missing untouched key model: %v", got)
}
}

143
internal/ipc/ipc.go Normal file
View File

@ -0,0 +1,143 @@
// Package ipc 实现 guard↔worker 之间的 PING/ACK 心跳协议:worker 监听 unix socket,
// guard 发 PING,worker 回 ACK(含 kernel 状态快照),实现带自诊断上报的存活判定,
// 取代单纯的文件心跳 + 退出码。
package ipc
import (
"bufio"
"encoding/json"
"fmt"
"net"
"os"
"path/filepath"
"time"
)
// sinkPath 心跳 socket 文件路径。
func sinkPath(dataDir string) string {
return filepath.Join(dataDir, "worker.ipc")
}
// Status worker 上报给 guard 的自诊断快照。
type Status struct {
PID int `json:"pid"`
Boot string `json:"boot"` // normal | failback
UptimeSec int64 `json:"uptime_sec"` // 进程存活秒数
LLMOK *bool `json:"llm_ok,omitempty"` // rescue 源可达性(nil=未探)
Tools int `json:"tools"` // 已注册工具数
LastDiag string `json:"last_diag,omitempty"` // 最近一次自诊断结论(如 diag_loc.cause)
}
// StatusFunc 组装 worker 当前状态。
type StatusFunc func() *Status
// Server worker 侧:监听 unix socket,处理 PING→ACK。
type Server struct {
path string
ln net.Listener
status StatusFunc
stop chan struct{}
done chan struct{}
}
// NewServer 创建心跳服务端(尚未 Listen,见 Start)。
func NewServer(dataDir string, status StatusFunc) *Server {
return &Server{path: sinkPath(dataDir), status: status, stop: make(chan struct{}), done: make(chan struct{})}
}
// Start 启动监听。若 socket 已存在则先清理(无心跳残留)。
func (s *Server) Start() error {
if s.status == nil {
s.status = func() *Status { return nil }
}
if err := os.RemoveAll(s.path); err != nil {
return err
}
ln, err := net.Listen("unix", s.path)
if err != nil {
return fmt.Errorf("ipc listen %s: %w", s.path, err)
}
s.ln = ln
go s.acceptLoop()
return nil
}
func (s *Server) acceptLoop() {
defer close(s.done)
for {
conn, err := s.ln.Accept()
if err != nil {
return
}
go s.handle(conn)
}
}
func (s *Server) handle(conn net.Conn) {
defer conn.Close()
conn.SetDeadline(time.Now().Add(5 * time.Second))
sc := bufio.NewScanner(conn)
if !sc.Scan() {
return
}
line := sc.Text()
if line != "PING" {
return
}
resp := map[string]interface{}{"type": "ACK"}
if st := s.status(); st != nil {
resp["status"] = st
}
data, _ := json.Marshal(resp)
conn.Write(append(data, '\n'))
}
// Stop 关闭监听。
func (s *Server) Stop() {
if s.ln != nil {
s.ln.Close()
}
<-s.done
}
// Client guard 侧:向 worker 心跳 socket 发 PING 并等 ACK。
type Client struct {
path string
}
// NewClient 创建客户端。
func NewClient(dataDir string) *Client {
return &Client{path: sinkPath(dataDir)}
}
// Ping 发一次 PING、收 ACK。timeout 内未收到返回错误。返回 (status, error);
// status 可能为 nil(ACK 无状态体)。
func (c *Client) Ping(timeout time.Duration) (*Status, error) {
conn, err := net.DialTimeout("unix", c.path, timeout)
if err != nil {
return nil, err
}
defer conn.Close()
conn.SetDeadline(time.Now().Add(timeout))
if _, err := conn.Write([]byte("PING\n")); err != nil {
return nil, err
}
sc := bufio.NewScanner(conn)
if !sc.Scan() {
return nil, fmt.Errorf("ipc: empty ACK")
}
var resp struct {
Type string `json:"type"`
Status *Status `json:"status"`
}
if err := json.Unmarshal(sc.Bytes(), &resp); err != nil {
return nil, err
}
if resp.Type != "ACK" {
return nil, fmt.Errorf("ipc: unexpected reply %q", resp.Type)
}
return resp.Status, nil
}
// Close 无状态(客户端用完即释放连接)。
func (c *Client) Close() {}

67
internal/ipc/ipc_test.go Normal file
View File

@ -0,0 +1,67 @@
package ipc
import (
"testing"
"time"
)
func TestPingAck(t *testing.T) {
dir := t.TempDir()
ok := true
srv := NewServer(dir, func() *Status {
return &Status{PID: 123, Boot: "normal", UptimeSec: 42, LLMOK: &ok, Tools: 5, LastDiag: "diag:network"}
})
if err := srv.Start(); err != nil {
t.Fatalf("Start: %v", err)
}
defer srv.Stop()
cli := NewClient(dir)
st, err := cli.Ping(2 * time.Second)
if err != nil {
t.Fatalf("Ping: %v", err)
}
if st == nil {
t.Fatal("nil status")
}
if st.PID != 123 || st.Boot != "normal" || st.Tools != 5 {
t.Fatalf("status mismatch: %+v", st)
}
if st.LLMOK == nil || !*st.LLMOK {
t.Fatal("LLMOK should be true")
}
if st.LastDiag != "diag:network" {
t.Fatalf("LastDiag = %q", st.LastDiag)
}
}
func TestPingTimeout(t *testing.T) {
dir := t.TempDir()
// 未启动 server → 立即超时
cli := NewClient(dir)
start := time.Now()
_, err := cli.Ping(500 * time.Millisecond)
if err == nil {
t.Fatal("expected error when no server")
}
if time.Since(start) > 3*time.Second {
t.Fatal("timeout took too long")
}
}
func TestServerNoStatusFunc(t *testing.T) {
dir := t.TempDir()
srv := NewServer(dir, nil)
if err := srv.Start(); err != nil {
t.Fatalf("Start: %v", err)
}
defer srv.Stop()
cli := NewClient(dir)
st, err := cli.Ping(2 * time.Second)
if err != nil {
t.Fatalf("Ping: %v", err)
}
if st != nil {
t.Fatalf("expected nil status when no status func, got %+v", st)
}
}

View File

@ -44,6 +44,7 @@ func NewMonitor(interval time.Duration) *Monitor {
},
},
interval: interval,
status: make([]EndpointStatus, 0),
}
}
@ -143,13 +144,20 @@ func (m *Monitor) AggregateResult() types.NetworkCheckResult {
defer m.mu.RUnlock()
result := types.NetworkCheckResult{
LLMAPIReachable: true,
DNSResolving: true,
TCPReachable: true,
LLMAPIReachable: true,
EndpointsConfigured: len(m.status) > 0,
DNSResolving: true,
TCPReachable: true,
}
var totalLatency time.Duration
checked := 0
if !result.EndpointsConfigured {
// 无任何探活端点:不谎报"可达",标为未配置
result.LLMAPIReachable = false
result.Error = "no LLM endpoints configured for health check"
}
for _, s := range m.status {
if !s.Reachable {
result.LLMAPIReachable = false

View File

@ -0,0 +1,39 @@
package network
import "testing"
func TestAggregateResultNoEndpoints(t *testing.T) {
m := NewMonitor(0)
res := m.AggregateResult()
if res.EndpointsConfigured {
t.Error("no endpoints configured should be false")
}
if res.LLMAPIReachable {
t.Error("LLMAPIReachable must not be true when no endpoints configured")
}
if res.Error == "" {
t.Error("should report no-endpoints error")
}
}
func TestAggregateResultWithEndpointsUnreachable(t *testing.T) {
m := NewMonitor(0)
m.mu.Lock()
m.status = []EndpointStatus{{URL: "http://127.0.0.1:1/", Reachable: false, Error: "dial refused"}}
m.mu.Unlock()
res := m.AggregateResult()
if !res.EndpointsConfigured {
t.Error("endpoints configured should be true")
}
if res.LLMAPIReachable {
t.Error("unreachable endpoint should make LLMAPIReachable false")
}
}
func TestAllReachableEmpty(t *testing.T) {
m := NewMonitor(0)
if m.AllReachable() {
t.Error("AllReachable must be false with no endpoints (not vacuously true)")
}
}

View File

@ -7,18 +7,19 @@ import (
"os"
"path/filepath"
"sort"
"strings"
"sync"
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
internalConfig "gitcode.com/JianFeeeee/HomeAgent/internal/config"
"gitcode.com/JianFeeeee/HomeAgent/internal/events"
"gitcode.com/JianFeeeee/HomeAgent/internal/knowledge"
luaVM "gitcode.com/JianFeeeee/HomeAgent/internal/lua"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
doc "gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/text"
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
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/tracker"
"gitcode.com/JianFeeeee/HomeAgent/pkg/types"
)
@ -69,17 +70,17 @@ type Registry struct {
pluginAutoRestart map[string]bool
sdkRefs map[string]*sdk.PluginSDK
iom *agentIO.IOManager
evBus *events.Bus
memDB *memory.GraphDB
textMem *text.Memory
docStore *doc.Store
ks *knowledge.Store
mgr *agentAPI.ProviderManager
cfgReg *internalConfig.ConfigRegistry
plgDir string
lua *luaVM.VM
baseKey string
iom *agentIO.IOManager
evBus *events.Bus
memDB *memory.GraphDB
textMem *text.Memory
docStore *doc.Store
ks *knowledge.Store
mgr *agentAPI.ProviderManager
cfgReg *internalConfig.ConfigRegistry
plgDir string
lua *luaVM.VM
baseKey string
regTool sdk.ToolRegistrar
regStage sdk.StageRegistrar
@ -95,6 +96,7 @@ type Registry struct {
idx *memory.Indexer
knownDisabled map[string]bool
allowlist map[string]bool
}
func NewRegistry() *Registry {
@ -107,27 +109,51 @@ func NewRegistry() *Registry {
}
}
func (r *Registry) SetIOManager(iom *agentIO.IOManager) { r.iom = iom }
func (r *Registry) SetEventBus(evBus *events.Bus) { r.evBus = evBus }
func (r *Registry) SetMemory(memDB *memory.GraphDB) { r.memDB = memDB }
func (r *Registry) SetTextMemory(tm *text.Memory) { r.textMem = tm }
func (r *Registry) SetDocStore(ds *doc.Store) { r.docStore = ds }
func (r *Registry) SetKnowledge(ks *knowledge.Store) { r.ks = ks }
func (r *Registry) SetProviderManager(mgr *agentAPI.ProviderManager) { r.mgr = mgr }
func (r *Registry) SetIOManager(iom *agentIO.IOManager) { r.iom = iom }
func (r *Registry) SetEventBus(evBus *events.Bus) { r.evBus = evBus }
func (r *Registry) SetMemory(memDB *memory.GraphDB) { r.memDB = memDB }
func (r *Registry) SetTextMemory(tm *text.Memory) { r.textMem = tm }
func (r *Registry) SetDocStore(ds *doc.Store) { r.docStore = ds }
func (r *Registry) SetKnowledge(ks *knowledge.Store) { r.ks = ks }
func (r *Registry) SetProviderManager(mgr *agentAPI.ProviderManager) { r.mgr = mgr }
func (r *Registry) SetConfigRegistry(cfgReg *internalConfig.ConfigRegistry) { r.cfgReg = cfgReg }
func (r *Registry) SetPluginDir(dir string) { r.plgDir = dir }
func (r *Registry) SetLuaVM(vm *luaVM.VM) { r.lua = vm }
func (r *Registry) SetBaseAPIKey(key string) { r.baseKey = key }
func (r *Registry) SetToolRegistrar(fn sdk.ToolRegistrar) { r.regTool = fn }
func (r *Registry) SetStageRegistrar(fn sdk.StageRegistrar) { r.regStage = fn }
func (r *Registry) SetAPIRegistrar(fn sdk.APIRegistrar) { r.regAPI = fn }
func (r *Registry) SetToolCleaner(tc PluginToolCleaner) { r.toolCleaner = tc }
func (r *Registry) SetStatusProvider(sp sdk.StatusAPI) { r.status = sp }
func (r *Registry) SetSupervisor(sup sdk.SupervisorAPI) { r.sup = sup }
func (r *Registry) SetTracker(trk *tracker.Tracker) { r.trk = trk }
func (r *Registry) SetConfig(cfg *types.Config) { r.cfg = cfg }
func (r *Registry) SetStageHost(sh sdk.ToolSource) { r.stageHost = sh }
func (r *Registry) SetIndexer(idx *memory.Indexer) { r.idx = idx }
func (r *Registry) SetPluginDir(dir string) { r.plgDir = dir }
func (r *Registry) SetLuaVM(vm *luaVM.VM) { r.lua = vm }
func (r *Registry) SetBaseAPIKey(key string) { r.baseKey = key }
func (r *Registry) SetToolRegistrar(fn sdk.ToolRegistrar) { r.regTool = fn }
func (r *Registry) SetStageRegistrar(fn sdk.StageRegistrar) { r.regStage = fn }
func (r *Registry) SetAPIRegistrar(fn sdk.APIRegistrar) { r.regAPI = fn }
func (r *Registry) SetToolCleaner(tc PluginToolCleaner) { r.toolCleaner = tc }
func (r *Registry) SetStatusProvider(sp sdk.StatusAPI) { r.status = sp }
func (r *Registry) SetSupervisor(sup sdk.SupervisorAPI) { r.sup = sup }
func (r *Registry) SetTracker(trk *tracker.Tracker) { r.trk = trk }
func (r *Registry) SetConfig(cfg *types.Config) { r.cfg = cfg }
func (r *Registry) SetStageHost(sh sdk.ToolSource) { r.stageHost = sh }
func (r *Registry) SetIndexer(idx *memory.Indexer) { r.idx = idx }
// SetLoadAllowlist 限制 Load 仅装载指定插件名(failback 受限启动用)。
// 空/未设置 = 装载全部。违反白名单的插件(含已注册工厂)一律跳过。
func (r *Registry) SetLoadAllowlist(names []string) {
r.mu.Lock()
defer r.mu.Unlock()
r.allowlist = nil
if len(names) == 0 {
return
}
r.allowlist = make(map[string]bool, len(names))
for _, n := range names {
r.allowlist[strings.TrimSpace(n)] = true
}
}
func (r *Registry) allowlistAllows(name string) bool {
r.mu.RLock()
defer r.mu.RUnlock()
if r.allowlist == nil {
return true
}
return r.allowlist[name]
}
func (r *Registry) RegisterNative(name string, factory NativeFactory) {
r.mu.Lock()
@ -144,13 +170,13 @@ type channelDevice struct {
chDef agentIO.ChannelDef
}
func (d *channelDevice) Name() string { return d.name }
func (d *channelDevice) Type() agentIO.DeviceType { return agentIO.DeviceOutput }
func (d *channelDevice) Description() string { return d.desc }
func (d *channelDevice) Start() error { return nil }
func (d *channelDevice) Stop() error { return nil }
func (d *channelDevice) Name() string { return d.name }
func (d *channelDevice) Type() agentIO.DeviceType { return agentIO.DeviceOutput }
func (d *channelDevice) Description() string { return d.desc }
func (d *channelDevice) Start() error { return nil }
func (d *channelDevice) Stop() error { return nil }
func (d *channelDevice) OutputCapabilities() agentIO.OutputCapability { return d.caps }
func (d *channelDevice) Tools() []agentIO.ToolDef { return nil }
func (d *channelDevice) Tools() []agentIO.ToolDef { return nil }
func (d *channelDevice) Execute(tool string, args map[string]interface{}) (interface{}, error) {
return d.handler(args)
}
@ -243,6 +269,9 @@ func (r *Registry) Load(dir string) error {
continue
}
name := entry.Name()
if !r.allowlistAllows(name) {
continue
}
plgDir := filepath.Join(dir, name)
if r.loadOne(plgDir, name) {
loaded[name] = true
@ -269,6 +298,9 @@ func (r *Registry) Load(dir string) error {
if loaded[name] {
continue
}
if !r.allowlistAllows(name) {
continue
}
if r.isDisabled(name) {
log.Printf("[plugin] %s is disabled, skipping", name)
r.mu.Lock()

View File

@ -12,6 +12,7 @@ import (
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin"
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
"gitcode.com/JianFeeeee/HomeAgent/internal/system"
)
func init() {
@ -26,6 +27,7 @@ type Plugin struct {
sdk *sdk.PluginSDK
mu sync.RWMutex
filesDir string
baseDir string // L0 写前留档根目录(<data>/file_baseline 的父目录),空则禁用
}
func New(name string) *Plugin {
@ -57,6 +59,29 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
}
p.filesDir = abs
// L0 写前留档:主 agent 写 /etc 等受保护路径前自动存档原文(homed 注入 <data>)
if cfg := s.Config().Get(); cfg != nil {
if dd := cfg.Daemon.DataDir; dd != "" {
p.baseDir = dd
}
}
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "baseline_dir",
Default: p.baseDir,
Type: "string",
DisplayName: "写前留档目录",
Description: "写受保护系统路径前自动存档原文的目录(空禁用)",
Category: "files",
})
if v, err := s.Settings().Get("baseline_dir"); err == nil && v != nil {
if s, ok := v.(string); ok && s != "" {
p.baseDir = s
}
}
if p.baseDir != "" {
log.Printf("[%s] write-ahead baseline dir: %s", p.name, system.FileBaselineDir(p.baseDir))
}
tp := p.name + "_"
s.RegisterTool(tp+"read", sdk.ToolDef{
@ -251,6 +276,15 @@ func (p *Plugin) handleWrite(args map[string]interface{}) (interface{}, error) {
return errorResult(err.Error()), nil
}
// L0 写前留档:覆盖已有受保护文件前,原文存档供 guard 还原。
if mode != "create" {
if archived, aerr := p.archiveBeforeWrite(absPath); aerr != nil {
log.Printf("[%s] write-ahead archive %s: %v", p.name, absPath, aerr)
} else if archived {
log.Printf("[%s] write-ahead archived %s", p.name, absPath)
}
}
switch mode {
case "create":
if _, err := os.Stat(absPath); err == nil {
@ -337,6 +371,13 @@ func (p *Plugin) handleEdit(args map[string]interface{}) (interface{}, error) {
return errorResult(err.Error()), nil
}
// L0 写前留档(edit 可能覆盖已存在文件)
if archived, aerr := p.archiveBeforeWrite(absPath); aerr != nil {
log.Printf("[%s] write-ahead archive %s: %v", p.name, absPath, aerr)
} else if archived {
log.Printf("[%s] write-ahead archived %s", p.name, absPath)
}
rawEdits, ok := args["edits"].([]interface{})
if !ok || len(rawEdits) == 0 {
return errorResult("edits must be a non-empty array"), nil
@ -481,6 +522,14 @@ func errorResult(msg string) map[string]interface{} {
}
}
// archiveBeforeWrite L0 写前留档:若目标为受保护系统路径且已存在,则存档原文。
func (p *Plugin) archiveBeforeWrite(absPath string) (bool, error) {
if p.baseDir == "" {
return false, nil
}
return system.ArchiveBeforeWrite(p.baseDir, absPath)
}
func getSetting[T any](s sdk.SettingsAPI, key string, def T) T {
v, err := s.Get(key)
if err != nil || v == nil {

View File

@ -0,0 +1,233 @@
// Package recovery 实现 L1 failback 的确定性恢复梯子与 guard↔worker 之间的
// 恢复任务/结果文件协议。整个梯子无 token 消耗(除 rescue 源 QuickChat 复检外):
// probe(main 可达) → 还原 DNS/proxy → probe → 还原 config 快照+ReloadFromConfig → probe
package recovery
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"time"
)
// Brightness 恢复梯子各阶段的判定强度(越低越优先主张)。
type Brightness int
const (
BrightDeterministic Brightness = iota // 无推理
BrightMinimal // 最小推理(未知/混合分支)
)
// RescueSource guard.yaml 中配置的救援 LLM 源(锚定 IP,绕开被破坏的 DNS/代理)。
type RescueSource struct {
Name string `yaml:"name" json:"name"`
BaseURL string `yaml:"base_url" json:"base_url"`
APIKey string `yaml:"api_key" json:"api_key"`
Model string `yaml:"model" json:"model"`
Adapter string `yaml:"adapter" json:"adapter"`
Thinking bool `yaml:"thinking_enabled" json:"thinking_enabled"`
}
// Task guard 写、failback worker 读的恢复任务(跨进程文件协议)。
type Task struct {
Attempt int `json:"attempt"`
MaxAttempts int `json:"max_attempts"`
AttemptTimeout string `json:"attempt_timeout"` // 单轮超时,如 "120s"
TriggerPrompt string `json:"trigger_prompt,omitempty"`
KnowledgeBase string `json:"knowledge_base,omitempty"`
RescueSource RescueSource `json:"rescue_source"`
PluginList []string `json:"plugins,omitempty"` // failback 插件集(缺省回退 config.db)
}
// Plugins 返回 failback 插件集;为空则返回 nil。
func (t *Task) Plugins() []string { return t.PluginList }
// TaskPath 返回 <dataDir>/recovery/recovery_task.json。
func TaskPath(dataDir string) string {
return filepath.Join(dataDir, "recovery", "recovery_task.json")
}
// SaveTask 持久化恢复任务。
func SaveTask(path string, t *Task) error {
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return err
}
data, err := json.MarshalIndent(t, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, data, 0600)
}
// LoadTask 读回恢复任务。
func LoadTask(path string) (*Task, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var t Task
if err := json.Unmarshal(data, &t); err != nil {
return nil, err
}
return &t, nil
}
// Result recovery worker 单次 failback 尝试的结论,写给 guard 读取。
type Result struct {
Attempt int `json:"attempt"`
Success bool `json:"success"`
Cause string `json:"cause"` // 判定出的根因类(取 diag / 梯子命中)
NetworkOK bool `json:"network_ok"` // 网络(resolv/hosts)是否与基线一致
RestoredNet []string `json:"restored_net"` // 已还原的网络文件
ConfigRestored bool `json:"config_restored"` // 是否还原 LLM 配置快照
QuickChatOK bool `json:"quickchat_ok"` // 最后用 rescue 源复检是否可达
Steps []string `json:"steps"` // 走过的恢复步骤
Message string `json:"message"`
Timestamp time.Time `json:"timestamp"`
}
// Quote 生成一行可读摘要(写入 worker 日志 / guard 决策用)。
func (r *Result) Quote() string {
return fmt.Sprintf("success=%v cause=%q net_ok=%v config_restored=%v quickchat=%v steps=%d",
r.Success, r.Cause, r.NetworkOK, r.ConfigRestored, r.QuickChatOK, len(r.Steps))
}
// Prober 探测 LLM 源是否可达(QuickChat)。用于梯子各阶段复检。
type Prober func(ctx context.Context, label string) (bool, error)
// NetworkRestorer 还原 DNS/proxy。返回改动列表。
type NetworkRestorer func() ([]string, error)
// ConfigRestorer 还原 LLM 配置快照并 ReloadFromConfig。返回精度(改动键数)。
type ConfigRestorer func() (int, error)
// Ladder 确定性恢复梯子。
type Ladder struct {
ResultFile string
Attempt int
Probe Prober
RestoreNetwork NetworkRestorer
RestoreConfig ConfigRestorer
Log func(format string, args ...interface{})
}
// Run 执行一次恢复梯子并返回结论(同时持久化到 ResultFile)。错误仅表示梯子
// 自身失败;结论成败由 Result.Success 表达。
func (l *Ladder) Run(ctx context.Context) *Result {
res := &Result{
Attempt: l.Attempt,
Cause: "unknown",
Timestamp: time.Now(),
}
l.addStep(res, "begin attempt")
// 1. 初始探测:quickchat 已通 → 无需恢复(网络与配置至少一个坏,但 rescue 可达)
ok, err := l.Probe(ctx, "initial")
if err == nil && ok {
res.Success = true
res.Cause = "healthy"
l.addStep(res, "initial probe: LLM reachable")
l.finish(res)
return res
}
res.Cause = "unreachable"
l.addStep(res, "initial probe: unreachable: %v", err)
// 2. 还原 DNS/proxy(L1 第一步小修命中即停)
if nested, rerr := l.RestoreNetwork(); rerr != nil {
l.addStep(res, "network restore error: %v", rerr)
} else {
res.RestoredNet = nested
res.NetworkOK = len(nested) == 0
l.addStep(res, "network restore: %d changed", len(nested))
}
if ok, _ = l.Probe(ctx, "after-network"); err == nil && ok {
res.Success = true
res.Cause = "network_config"
l.addStep(res, "post-network probe: reachable")
l.finish(res)
return res
}
l.addStep(res, "post-network probe: unreachable")
// 3. 还原 config 快照 + ReloadFromConfig(L1 第二步)
if n, cerr := l.RestoreConfig(); cerr != nil {
l.addStep(res, "config restore error: %v", cerr)
} else {
res.ConfigRestored = n > 0
l.addStep(res, "config restore: %d keys", n)
}
if ok, _ = l.Probe(ctx, "after-config"); err == nil && ok {
res.Success = true
res.Cause = "config"
l.addStep(res, "post-config probe: reachable")
l.finish(res)
return res
}
l.addStep(res, "post-config probe: unreachable")
res.QuickChatOK = false
l.addStep(res, "ladder exhausted")
l.finish(res)
return res
}
func (l *Ladder) addStep(res *Result, format string, args ...interface{}) {
msg := fmt.Sprintf(format, args...)
res.Steps = append(res.Steps, msg)
if l.Log != nil {
l.Log("[recovery] %s", msg)
}
}
func (l *Ladder) finish(res *Result) {
res.QuickChatOK = res.Success
if l.ResultFile != "" {
if err := SaveResult(l.ResultFile, res); err != nil {
if l.Log != nil {
l.Log("[recovery] save result: %v", err)
}
}
}
}
// SaveResult 把恢复结论持久化到文件(guard 读取)。
func SaveResult(path string, r *Result) error {
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return err
}
data, err := json.MarshalIndent(r, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, data, 0600)
}
// LoadResult 读回恢复结论。
func LoadResult(path string) (*Result, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var r Result
if err := json.Unmarshal(data, &r); err != nil {
return nil, err
}
return &r, nil
}
// ResultPath 返回 <dataDir>/recovery/recovery_result.json。
func ResultPath(dataDir string) string {
return filepath.Join(dataDir, "recovery", "recovery_result.json")
}
// SortSteps 供测试/展示封装(保序打印)。
func SortSteps(s []string) []string {
out := make([]string, len(s))
copy(out, s)
sort.Strings(out)
return out
}

View File

@ -0,0 +1,208 @@
package recovery
import (
"context"
"os"
"path/filepath"
"testing"
)
type fakeProber struct {
results map[string]bool // label → ok
mu chan struct{}
}
func newFakeProber() *fakeProber { return &fakeProber{results: map[string]bool{}} }
func (f *fakeProber) set(label string, ok bool) {
if f.results == nil {
f.results = map[string]bool{}
}
f.results[label] = ok
}
func (f *fakeProber) probe(ctx context.Context, label string) (bool, error) {
if f.results == nil {
return false, nil
}
ok, _ := f.results[label]
return ok, nil
}
func TestLadderHealthy(t *testing.T) {
dir := t.TempDir()
p := newFakeProber()
p.set("initial", true) // 初始即通,不触发任何恢复
l := &Ladder{
ResultFile: ResultPath(dir),
Attempt: 1,
Probe: p.probe,
RestoreNetwork: func() ([]string, error) {
t.Fatal("network restore should not be called when healthy")
return nil, nil
},
RestoreConfig: func() (int, error) {
t.Fatal("config restore should not be called when healthy")
return 0, nil
},
}
res := l.Run(context.Background())
if !res.Success {
t.Fatalf("expected success, got %s", res.Quote())
}
if res.Cause != "healthy" {
t.Fatalf("cause = %q, want healthy", res.Cause)
}
if len(res.Steps) != 2 {
t.Fatalf("steps = %d, want 2", len(res.Steps))
}
// 结果落盘
onDisk, err := LoadResult(ResultPath(dir))
if err != nil {
t.Fatalf("LoadResult: %v", err)
}
if !onDisk.Success {
t.Fatal("disk result not success")
}
}
func TestLadderNetworkRecovery(t *testing.T) {
dir := t.TempDir()
p := newFakeProber()
p.set("initial", false)
p.set("after-network", true) // 还原网络后通了
var netCalls int
l := &Ladder{
ResultFile: ResultPath(dir),
Attempt: 2,
Probe: p.probe,
RestoreNetwork: func() ([]string, error) {
netCalls++
return []string{"/etc/resolv.conf"}, nil
},
RestoreConfig: func() (int, error) {
t.Fatal("config restore should not run when network fixes it")
return 0, nil
},
}
res := l.Run(context.Background())
if !res.Success {
t.Fatalf("expected success, got %s", res.Quote())
}
if res.Cause != "network_config" {
t.Fatalf("cause = %q, want network_config", res.Cause)
}
if netCalls != 1 {
t.Fatalf("network restore calls = %d, want 1", netCalls)
}
if len(res.RestoredNet) != 1 {
t.Fatalf("RestoredNet = %v, want 1 entry", res.RestoredNet)
}
}
func TestLadderConfigRecovery(t *testing.T) {
dir := t.TempDir()
p := newFakeProber()
p.set("initial", false)
p.set("after-network", false)
p.set("after-config", true)
var cfgCalls int
var netCalls int
l := &Ladder{
ResultFile: ResultPath(dir),
Attempt: 3,
Probe: p.probe,
RestoreNetwork: func() ([]string, error) {
netCalls++
return nil, nil
},
RestoreConfig: func() (int, error) {
cfgCalls++
return 7, nil
},
}
res := l.Run(context.Background())
if !res.Success {
t.Fatalf("expected success, got %s", res.Quote())
}
if res.Cause != "config" {
t.Fatalf("cause = %q, want config", res.Cause)
}
if cfgCalls != 1 || netCalls != 1 {
t.Fatalf("cfg=%d net=%d, want both 1", cfgCalls, netCalls)
}
if !res.ConfigRestored {
t.Fatal("ConfigRestored should be true")
}
}
func TestLadderExhausted(t *testing.T) {
dir := t.TempDir()
p := newFakeProber()
p.set("initial", false)
p.set("after-network", false)
p.set("after-config", false)
l := &Ladder{
ResultFile: ResultPath(dir),
Attempt: 1,
Probe: p.probe,
RestoreNetwork: func() ([]string, error) { return nil, nil },
RestoreConfig: func() (int, error) { return 0, nil },
}
res := l.Run(context.Background())
if res.Success {
t.Fatal("expected failure when ladder exhausted")
}
if res.Cause != "unreachable" {
t.Fatalf("cause = %q, want unreachable", res.Cause)
}
if res.QuickChatOK {
t.Fatal("QuickChatOK should be false on failure")
}
if len(res.Steps) < 6 {
t.Fatalf("steps = %d, want >= 6", len(res.Steps))
}
}
func TestTaskFileRoundtrip(t *testing.T) {
dir := t.TempDir()
path := TaskPath(dir)
task := &Task{
Attempt: 2,
MaxAttempts: 4,
AttemptTimeout: "120s",
TriggerPrompt: "你是恢复 agent",
RescueSource: RescueSource{Name: "rescue", BaseURL: "http://1.2.3.4:8080", Adapter: "openai"},
PluginList: []string{"webui", "recoverydiag"},
}
if err := SaveTask(path, task); err != nil {
t.Fatalf("SaveTask: %v", err)
}
loaded, err := LoadTask(path)
if err != nil {
t.Fatalf("LoadTask: %v", err)
}
if loaded.RescueSource.BaseURL != task.RescueSource.BaseURL {
t.Fatal("rescue base_url mismatch")
}
if len(loaded.Plugins()) != 2 {
t.Fatalf("plugins = %v, want 2", loaded.Plugins())
}
_ = os.Remove(path)
}
func TestResultFileRoundtrip(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "recovery", "result.json")
r := &Result{Attempt: 1, Success: true, Cause: "network_config", QuickChatOK: true}
if err := SaveResult(path, r); err != nil {
t.Fatalf("SaveResult: %v", err)
}
loaded, err := LoadResult(path)
if err != nil {
t.Fatalf("LoadResult: %v", err)
}
if !loaded.Success || loaded.Cause != "network_config" {
t.Fatalf("roundtrip mismatch: %+v", loaded)
}
}

View File

@ -8,21 +8,23 @@ import (
"time"
"gitcode.com/JianFeeeee/HomeAgent/internal/network"
"gitcode.com/JianFeeeee/HomeAgent/internal/tracker"
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
"gitcode.com/JianFeeeee/HomeAgent/internal/tracker"
"gitcode.com/JianFeeeee/HomeAgent/pkg/types"
)
const directAgentID types.AgentID = "main"
type Daemon struct {
cfg *types.Config
nm *network.Monitor
trk *tracker.Tracker
agents map[types.AgentID]*agentInstance
mu sync.RWMutex
ctx context.Context
cancel context.CancelFunc
cfg *types.Config
nm *network.Monitor
trk *tracker.Tracker
agents map[types.AgentID]*agentInstance
mu sync.RWMutex
ctx context.Context
cancel context.CancelFunc
heartbeatSource func(id types.AgentID) (time.Time, types.HealthStatus, error)
restartHandler func(id types.AgentID)
}
type agentInstance struct {
@ -51,6 +53,21 @@ func (d *Daemon) SetTracker(trk *tracker.Tracker) {
d.trk = trk
}
// SetHeartbeatSource 接入 agent 真实存活探测源(direct 模式下为同进程 agent core 状态)。
// 成功返回时 lastHB 才更新;无源时 agent 健康保持未知。
func (d *Daemon) SetHeartbeatSource(fn func(id types.AgentID) (time.Time, types.HealthStatus, error)) {
d.mu.Lock()
defer d.mu.Unlock()
d.heartbeatSource = fn
}
// SetRestartHandler 接入真实重启动作(direct 模式下由 homed 注册"清理后以特殊码退出",交给 guard/systemd 重建)。
func (d *Daemon) SetRestartHandler(fn func(id types.AgentID)) {
d.mu.Lock()
defer d.mu.Unlock()
d.restartHandler = fn
}
func (d *Daemon) Start() error {
log.Println("[homed] starting HomeAgent daemon")
@ -127,18 +144,35 @@ func (d *Daemon) checkAllAgents() {
}
func (d *Daemon) checkAgent(id types.AgentID, agent *agentInstance) {
netStatus := d.nm.AggregateResult()
// 1) agent 存活源:只有确认存活才更新 lastHB
d.mu.RLock()
hbSrc := d.heartbeatSource
d.mu.RUnlock()
if !netStatus.LLMAPIReachable {
agent.health = types.HealthDegraded
agent.failCount++
log.Printf("[homed] agent %s: LLM API unreachable (fail %d)", id, agent.failCount)
if hbSrc != nil {
hbTime, hbHealth, err := hbSrc(id)
if err != nil || hbTime.IsZero() {
agent.health = types.HealthDown
log.Printf("[homed] agent %s heartbeat lost: %v", id, err)
} else {
agent.lastHB = hbTime
agent.health = hbHealth
}
} else {
agent.health = types.HealthHealthy
agent.failCount = 0
agent.health = types.HealthUnknown
}
agent.lastHB = time.Now()
// 2) 网络:仅在配置了探活端点时才据此判定降级
netStatus := d.nm.AggregateResult()
if netStatus.EndpointsConfigured && !netStatus.LLMAPIReachable {
if agent.health != types.HealthDown {
agent.health = types.HealthDegraded
}
agent.failCount++
log.Printf("[homed] agent %s: LLM API unreachable (fail %d)", id, agent.failCount)
} else if agent.health == types.HealthHealthy {
agent.failCount = 0
}
if agent.failCount >= agent.cfg.RollbackPolicy.MaxRetries {
d.handleFailure(id, agent)
@ -164,13 +198,23 @@ func (d *Daemon) handleFailure(id types.AgentID, agent *agentInstance) {
}
func (d *Daemon) restartAgent(id types.AgentID, agent *agentInstance) {
log.Printf("[homed] resetting agent %s", id)
agent.state = types.AgentStateStopped
d.RegisterAgent(id)
agent.failCount = 0
log.Printf("[homed] agent %s reset", id)
d.mu.RLock()
handler := d.restartHandler
d.mu.RUnlock()
if handler != nil {
log.Printf("[homed] agent %s restart handler invoked", id)
handler(id)
return
}
// 无真实重启通道:退回内存态复位(记录,不再假装成功)
d.RegisterAgent(id)
agent.failCount = 0
log.Printf("[homed] agent %s reset in-memory only (no restart handler registered)", id)
}
func (d *Daemon) GetAgentStatus(id types.AgentID) (*AgentStatus, error) {

View File

@ -184,7 +184,7 @@ func TestRegisterAgentMultiple(t *testing.T) {
d := New(cfg)
for i := 0; i < 5; i++ {
d.RegisterAgent(types.AgentID(string(rune('a'+i))))
d.RegisterAgent(types.AgentID(string(rune('a' + i))))
}
agents := d.ListAgents()
@ -207,3 +207,64 @@ func TestConcurrentAccess(t *testing.T) {
go d.RegisterAgent(types.AgentID(string(rune('a' + i))))
}
}
func TestCheckAgentHeartbeatSource(t *testing.T) {
cfg := &types.Config{
Daemon: types.DaemonConfig{CheckInterval: time.Minute, HeartbeatInterval: 30 * time.Second},
}
cfg.Defaults.RollbackPolicy.MaxRetries = 3
d := New(cfg)
d.RegisterAgent("main")
a := d.agents["main"]
d.SetHeartbeatSource(func(id types.AgentID) (time.Time, types.HealthStatus, error) {
return time.Now(), types.HealthHealthy, nil
})
regHB := a.lastHB
if regHB.IsZero() {
t.Fatal("lastHB should be set at register")
}
d.checkAgent("main", a)
if a.lastHB.Before(regHB) {
t.Fatal("lastHB should update after a successful heartbeat poll")
}
if a.health != types.HealthHealthy {
t.Fatalf("expected healthy, got %v", a.health)
}
// 健康源丢失:lastHB 不应再更新,状态置 Down
lastHB := a.lastHB
time.Sleep(2 * time.Millisecond)
d.SetHeartbeatSource(func(id types.AgentID) (time.Time, types.HealthStatus, error) {
return time.Time{}, types.HealthDown, nil
})
d.checkAgent("main", a)
if a.health != types.HealthDown {
t.Fatalf("expected down, got %v", a.health)
}
if !a.lastHB.Equal(lastHB) {
t.Fatal("lastHB must NOT update when the agent does not respond")
}
}
func TestRestartHandlerInvoked(t *testing.T) {
cfg := &types.Config{
Daemon: types.DaemonConfig{CheckInterval: time.Minute, HeartbeatInterval: 30 * time.Second},
}
cfg.Defaults.RollbackPolicy.MaxRetries = 3
d := New(cfg)
d.RegisterAgent("main")
a := d.agents["main"]
called := false
d.SetRestartHandler(func(id types.AgentID) { called = true })
d.restartAgent("main", a)
if !called {
t.Fatal("restart handler should be invoked")
}
if a.failCount != 0 {
t.Fatalf("failCount should reset, got %d", a.failCount)
}
}

146
internal/system/guard.go Normal file
View File

@ -0,0 +1,146 @@
package system
import (
"fmt"
"os"
"path/filepath"
"strings"
)
// ProtectedPaths L0 写前留档保护的系统路径前缀。主 agent 通过 files 插件写
// 这些路径前,先自动把原文存档到 <dataDir>/file_baseline/,供 guard 还原。
//
// 该集合强依赖当前 Linux 发行版布局(/etc、/home)。默认值仅作兜底;
// 生产环境应由 homed/main 或 guard.yaml 显式注入(SetProtectedPaths),
// 以适配具体发行版/部署路径,避免硬编码失效。
var protectedPrefixes = []string{
"/etc/",
}
// SetProtectedPaths 覆盖受保护路径前缀集合(发行版/部署路径适配入口)。
// 幂等地保留 / 前缀兜底;传入 nil/空则恢复默认。
func SetProtectedPaths(prefixes []string) {
if len(prefixes) == 0 {
protectedPrefixes = []string{"/etc/"}
return
}
ns := make([]string, 0, len(prefixes))
for _, p := range prefixes {
if p = strings.TrimSpace(p); p != "" {
ns = append(ns, p)
}
}
protectedPrefixes = ns
}
// ProtectedPaths 返回当前受保护路径前缀(副本)。
func ProtectedPaths() []string {
out := make([]string, len(protectedPrefixes))
copy(out, protectedPrefixes)
return out
}
// IsProtectedPath 判断路径是否属于受保护的写前留档范围。
func IsProtectedPath(path string) bool {
abs, err := filepath.Abs(path)
if err != nil {
return false
}
for _, p := range protectedPrefixes {
if strings.HasPrefix(abs, p) {
return true
}
}
return false
}
// IsProtectedPathExplicit 同 IsProtectedPath,但仅匹配传入的显式前缀集
// (不改全局状态时的判断入口,供测试/工具使用)。
func IsProtectedPathExplicit(prefixes []string, path string) bool {
abs, err := filepath.Abs(path)
if err != nil {
return false
}
for _, p := range prefixes {
if strings.HasPrefix(abs, p) {
return true
}
}
return false
}
// ArchiveBeforeWrite 在覆盖某个受保护路径前调用:若目标为已存在的普通文件,
// 则把原文复制到 <dataDir>/file_baseline/<相对路径>(幂等:已存在同 hash 则跳过),
// 返回是否发生了留档。用于 L0 写 /etc 前的自动留档。
//
// archive base 为 <dataDir>/file_baseline。path 为绝对路径。
func ArchiveBeforeWrite(baseDir, path string) (archived bool, err error) {
if !IsProtectedPath(path) {
return false, nil
}
info, err := os.Stat(path)
if err != nil {
if os.IsNotExist(err) {
return false, nil // 新建文件无需留档
}
return false, err
}
if info.IsDir() {
return false, nil
}
rel := strings.TrimPrefix(path, "/")
dst := filepath.Join(filepath.Join(baseDir, "file_baseline"), rel)
if _, err := os.Stat(dst); err == nil {
// 已留档(内容未再变化时避免重复)
if same, _ := sameContent(path, dst); same {
return false, nil
}
}
if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil {
return false, err
}
data, err := os.ReadFile(path)
if err != nil {
return false, err
}
if err := os.WriteFile(dst, data, 0600); err != nil {
return false, err
}
return true, nil
}
func sameContent(a, b string) (bool, error) {
da, ea := os.ReadFile(a)
if ea != nil {
return false, ea
}
db, eb := os.ReadFile(b)
if eb != nil {
return false, eb
}
return string(da) == string(db), nil
}
// FileBaselineDir 返回写前留档根目录。
func FileBaselineDir(dataDir string) string {
return filepath.Join(dataDir, "file_baseline")
}
// RestoreFileFromBaseline 从写前留档恢复一个受保护文件(guard 使用)。
// 若留档存在则写回并返回 true 与恢复路径;否则 false。
func RestoreFileFromBaseline(dataDir, path string) (bool, error) {
if !IsProtectedPath(path) {
return false, nil
}
rel := strings.TrimPrefix(path, "/")
dst := filepath.Join(FileBaselineDir(dataDir), rel)
data, err := os.ReadFile(dst)
if err != nil {
return false, nil
}
if err := os.WriteFile(path, data, 0644); err != nil {
return false, fmt.Errorf("restore from baseline %s: %w", dst, err)
}
return true, nil
}

240
internal/system/network.go Normal file
View File

@ -0,0 +1,240 @@
// Package system 提供 L0/L1 层的系统级网络配置基线:捕获与还原本机的
// DNS(resolv.conf)/ hosts / 代理环境,供 failback 在救援源恢复前还原被主 agent
// 改坏的网络配置。
package system
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
)
// NetworkBaseline 一张系统网络配置快照:捕获时点 + DNS/hosts/代理环境原文。
type NetworkBaseline struct {
CapturedAt string `json:"captured_at"`
ResolvConf string `json:"resolv_conf,omitempty"` // /etc/resolv.conf 原文
Hosts string `json:"hosts,omitempty"` // /etc/hosts 原文
ProxyEnv map[string]string `json:"proxy_env,omitempty"` // http_proxy/https_proxy/no_proxy 等
}
const baselineFileName = "network_baseline.json"
// relevantPath 本机网络相关文件,与代理环境变量一同纳入基线。
// 默认值面向主流 Linux 发行版(systemd-resolved 等也写 /etc/resolv.conf)。
// 若目标发行版布局不同(如 resolv.conf 在 /run/systemd/resolve/),由
// SetNetworkPaths 在启动时注入,避免硬编码失效。
var relevantPath = []string{
"/etc/resolv.conf",
"/etc/hosts",
"/etc/environment",
}
// SetNetworkPaths 覆盖纳入网络基线的文件路径列表(发行版适配入口)。
// resolv.conf / hosts 用于还原与"被改动"判定;其余文件仅捕获(诊断用)。
// 传 nil/空则恢复 Linux 默认。
func SetNetworkPaths(paths []string) {
if len(paths) == 0 {
relevantPath = []string{"/etc/resolv.conf", "/etc/hosts", "/etc/environment"}
return
}
ns := make([]string, 0, len(paths))
for _, p := range paths {
if p = strings.TrimSpace(p); p != "" {
ns = append(ns, p)
}
}
relevantPath = ns
}
// NetworkPaths 返回当前纳入基线的网络文件路径(副本)。
func NetworkPaths() []string {
out := make([]string, len(relevantPath))
copy(out, relevantPath)
return out
}
// envProxyKeys 纳入基线的代理相关环境变量(大小写归一,捕获时同时保存小写与大写形式)。
var envProxyKeys = []string{
"http_proxy", "https_proxy", "no_proxy",
"HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY",
"all_proxy", "ALL_PROXY",
}
// fileKey 映射路径 → 基线字段。已知可还原字段:resolv_conf / hosts。
func fileKey(p string) string {
switch {
case strings.Contains(p, "resolv"):
return "resolv"
case strings.Contains(p, "hosts"):
return "hosts"
}
return ""
}
// CaptureNetwork 捕获当前系统网络配置成基线对象(不落盘)。
func CaptureNetwork() (*NetworkBaseline, error) {
b := &NetworkBaseline{
CapturedAt: time.Now().Format(time.RFC3339),
ProxyEnv: make(map[string]string),
}
for _, p := range relevantPath {
if data, err := os.ReadFile(p); err == nil {
switch fileKey(p) {
case "resolv":
b.ResolvConf = string(data)
case "hosts":
b.Hosts = string(data)
default:
if b.ProxyEnv == nil {
b.ProxyEnv = make(map[string]string)
}
b.ProxyEnv["file:"+p] = string(data)
}
}
}
for _, k := range envProxyKeys {
if v, ok := os.LookupEnv(k); ok {
b.ProxyEnv[k] = v
}
}
return b, nil
}
// SaveNetworkBaseline 把网络基线写入 <dataDir>/network_baseline.json。
func SaveNetworkBaseline(dataDir string, b *NetworkBaseline) error {
path := NetworkBaselinePath(dataDir)
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return err
}
data, err := json.MarshalIndent(b, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, data, 0600)
}
// LoadNetworkBaseline 从 <dataDir>/network_baseline.json 读回网络基线。
func LoadNetworkBaseline(dataDir string) (*NetworkBaseline, error) {
path := NetworkBaselinePath(dataDir)
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var b NetworkBaseline
if err := json.Unmarshal(data, &b); err != nil {
return nil, err
}
return &b, nil
}
// NetworkBaselinePath 返回网络基线文件路径。
func NetworkBaselinePath(dataDir string) string {
return filepath.Join(dataDir, "recovery", baselineFileName)
}
// ApplyProxyEnv 把基线中的代理环境变量写回当前进程环境(guard 在拉起 worker 前调用,
// 使 worker 继承干净代理)。
func (b *NetworkBaseline) ApplyProxyEnv() {
for _, k := range envProxyKeys {
if v, ok := b.ProxyEnv[k]; ok {
os.Setenv(k, v)
}
}
}
// ExtraProxyKeys 返回基线中额外捕获的键(如 ENVIRONMENT),排序后供诊断/报告使用。
func (b *NetworkBaseline) ExtraProxyKeys() []string {
var keys []string
for k := range b.ProxyEnv {
found := false
for _, p := range envProxyKeys {
if k == p {
found = true
break
}
}
if !found {
keys = append(keys, k)
}
}
sort.Strings(keys)
return keys
}
// restoreTargets 返回可还原的网络文件(键→基线内容)。由注入路径派生,
// 适配不同发行版的 resolv.conf/hosts 位置。
func (b *NetworkBaseline) restoreTargets() map[string]string {
m := map[string]string{}
for _, p := range relevantPath {
switch fileKey(p) {
case "resolv":
if b.ResolvConf != "" {
m[p] = b.ResolvConf
}
case "hosts":
if b.Hosts != "" {
m[p] = b.Hosts
}
}
}
return m
}
// RestoreFiles 把基线中的 resolv.conf / hosts 写回原路径。仅当该路径被主 agent
// 改动过(当前内容与基线不同)时才覆盖,返回改动项列表。
func (b *NetworkBaseline) RestoreFiles() ([]string, error) {
var changed []string
for path, want := range b.restoreTargets() {
cur, err := os.ReadFile(path)
if err != nil || string(cur) == want {
continue
}
if err := os.WriteFile(path, []byte(want), 0644); err != nil {
return changed, fmt.Errorf("restore %s: %w", path, err)
}
changed = append(changed, path)
}
return changed, nil
}
// MismatchedFiles 返回与基线不一致的系统网络文件(guard 判定"网络是否被破坏")。
// 返回名字列表;无则空。忽略读取失败的文件。
func (b *NetworkBaseline) MismatchedFiles() []string {
var out []string
for path, want := range b.restoreTargets() {
if cur, err := os.ReadFile(path); err != nil {
out = append(out, path+"(unreadable)")
} else if string(cur) != want {
out = append(out, path+"(modified)")
}
}
if len(out) == 0 {
return nil
}
sort.Strings(out)
return out
}
// IsNetworkMismatch 判断主机网络配置与基线是否不一致(resolv.conf/hosts 中任一改动)。
func (b *NetworkBaseline) IsNetworkMismatch() bool {
return len(b.MismatchedFiles()) > 0
}
// Summary 生成基线摘要(用于 failback 报告)。
func (b *NetworkBaseline) Summary() string {
parts := []string{fmt.Sprintf("captured=%s", b.CapturedAt)}
if b.ResolvConf != "" {
parts = append(parts, fmt.Sprintf("resolv=%dbytes", len(b.ResolvConf)))
}
if b.Hosts != "" {
parts = append(parts, fmt.Sprintf("hosts=%dbytes", len(b.Hosts)))
}
if n := len(b.ProxyEnv); n > 0 {
parts = append(parts, fmt.Sprintf("proxy_vars=%d", n))
}
return strings.Join(parts, " ")
}

View File

@ -0,0 +1,162 @@
package system
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestIsProtectedPath(t *testing.T) {
cases := map[string]bool{
"/etc/resolv.conf": true,
"/etc/hosts": true,
"/etc/apt/sources.list": true,
"/etc/environment": true,
"/tmp/foo.txt": false,
"/home/user/x": false,
"/usr/local/bin/homed": false,
}
for p, want := range cases {
if got := IsProtectedPath(p); got != want {
t.Errorf("IsProtectedPath(%q) = %v, want %v", p, got, want)
}
}
// 发行版/部署路径注入:显式前缀集可扩展受保护范围
SetProtectedPaths([]string{"/opt/llm-mock", "/home/newqqagent"})
if !IsProtectedPath("/opt/llm-mock/mock_server.py") {
t.Error("explicit prefix /opt/llm-mock should be protected")
}
if !IsProtectedPath("/home/newqqagent/config.yaml") {
t.Error("explicit prefix /home/newqqagent should be protected")
}
SetProtectedPaths(nil) // 恢复默认
if IsProtectedPath("/opt/llm-mock/mock_server.py") {
t.Error("default should not protect /opt/llm-mock")
}
}
func TestArchiveBeforeWrite(t *testing.T) {
dir := t.TempDir()
// 受保护路径
target := "/etc/ArchiveBeforeWrite.test.tmp"
os.WriteFile(target, []byte("original"), 0644)
defer os.Remove(target)
archived, err := ArchiveBeforeWrite(dir, target)
if err != nil {
t.Fatalf("ArchiveBeforeWrite: %v", err)
}
if !archived {
t.Fatal("expected archive to happen")
}
dst := filepath.Join(dir, "file_baseline", strings.TrimPrefix(target, "/"))
data, err := os.ReadFile(dst)
if err != nil {
t.Fatalf("read archived: %v", err)
}
if string(data) != "original" {
t.Fatalf("archived content = %q, want original", data)
}
// 幂等:同内容不重复
archived2, err := ArchiveBeforeWrite(dir, target)
if err != nil {
t.Fatalf("ArchiveBeforeWrite #2: %v", err)
}
if archived2 {
t.Fatal("expected idempotent archive (no repeat)")
}
// 非保护路径不存档
tmp := filepath.Join(t.TempDir(), "x.txt")
os.WriteFile(tmp, []byte("x"), 0644)
archived3, err := ArchiveBeforeWrite(dir, tmp)
if err != nil {
t.Fatalf("ArchiveBeforeWrite non-protected: %v", err)
}
if archived3 {
t.Fatal("non-protected path should not archive")
}
}
func TestRestoreFileFromBaseline(t *testing.T) {
dir := t.TempDir()
target := "/etc/RestoreFileFromBaseline.test.tmp"
os.WriteFile(target, []byte("v1"), 0644)
defer os.Remove(target)
ArchiveBeforeWrite(dir, target)
os.WriteFile(target, []byte("v2"), 0644)
restored, err := RestoreFileFromBaseline(dir, target)
if err != nil {
t.Fatalf("RestoreFileFromBaseline: %v", err)
}
if !restored {
t.Fatal("expected restore to happen")
}
data, _ := os.ReadFile(target)
if string(data) != "v1" {
t.Fatalf("restored content = %q, want v1", data)
}
}
func TestCaptureNetworkBaselineRoundtrip(t *testing.T) {
base, err := CaptureNetwork()
if err != nil {
t.Fatalf("CaptureNetwork: %v", err)
}
if base.CapturedAt == "" {
t.Fatal("captured_at empty")
}
if base.ResolvConf == "" {
t.Log("warning: no /etc/resolv.conf readable on this host")
}
dir := t.TempDir()
if err := SaveNetworkBaseline(dir, base); err != nil {
t.Fatalf("SaveNetworkBaseline: %v", err)
}
loaded, err := LoadNetworkBaseline(dir)
if err != nil {
t.Fatalf("LoadNetworkBaseline: %v", err)
}
if loaded.ResolvConf != base.ResolvConf {
t.Fatal("resolv.conf roundtrip mismatch")
}
if loaded.CapturedAt != base.CapturedAt {
t.Fatal("captured_at roundtrip mismatch")
}
}
func TestMismatchedFiles(t *testing.T) {
dir := t.TempDir()
base := &NetworkBaseline{ResolvConf: "# baselinetest", Hosts: "# hosts"}
// RestoreFiles 只写"当前内容与基线不同且基线非空"的文件;若 resolv.conf 恰好与
// 测试用的假基线一致仍存在,则跳过。此处以空字段基线验证幂等(不破坏真实 /etc)。
emptyBase := &NetworkBaseline{ResolvConf: "", Hosts: ""}
if changed, err := emptyBase.RestoreFiles(); err != nil {
t.Fatalf("RestoreFiles empty: %v", err)
} else if len(changed) > 0 {
t.Fatalf("empty baseline should change nothing, got %v", changed)
}
if base.Summary() == "" {
t.Fatal("summary empty")
}
if _, err := LoadNetworkBaseline(dir); err == nil {
t.Fatal("expected error loading missing baseline")
}
}
func TestExtraProxyKeys(t *testing.T) {
base := &NetworkBaseline{ProxyEnv: map[string]string{
"http_proxy": "http://p:8080",
"HTTP_PROXY": "http://p:8080",
"ENVIRONMENT": "FOO=bar",
}}
keys := base.ExtraProxyKeys()
if len(keys) != 1 || keys[0] != "ENVIRONMENT" {
t.Fatalf("ExtraProxyKeys = %v, want [ENVIRONMENT]", keys)
}
}

View File

@ -40,6 +40,9 @@ func fileHash(path string) (string, int64, error) {
return hex.EncodeToString(h[:]), int64(len(data)), nil
}
// maxCapturedContent 回滚内容捕获上限:超大文件不保存原文(回滚时跳过并告警)。
const maxCapturedContent = 8 << 20
func fileInfo(path string) (size int64, modTime time.Time, err error) {
info, err := os.Stat(path)
if err != nil {
@ -53,6 +56,7 @@ type FSState struct {
Root string `json:"root"`
}
// captureFSState 仅记录哈希/尺寸(用于"之后"快照,省内存)。
func captureFSState(root string) (*FSState, error) {
state := &FSState{
Files: make(map[string]FileChange),
@ -77,6 +81,27 @@ func captureFSState(root string) (*FSState, error) {
return state, err
}
// captureFSStateWithContent 额外捕获文件原文(用于"之前"基线,供回滚还原被改/被删文件)。
func captureFSStateWithContent(root string) (*FSState, error) {
state, err := captureFSState(root)
if err != nil {
return nil, err
}
for rel := range state.Files {
path := filepath.Join(root, rel)
info, err := os.Stat(path)
if err != nil || info.Size() > maxCapturedContent {
continue
}
if data, err := os.ReadFile(path); err == nil {
fc := state.Files[rel]
fc.Content = data
state.Files[rel] = fc
}
}
return state, nil
}
func diffStates(before, after *FSState) []FileChange {
var changes []FileChange
if before == nil || after == nil {
@ -95,6 +120,7 @@ func diffStates(before, after *FSState) []FileChange {
HashAfter: afterFile.HashAfter,
SizeBefore: beforeFile.SizeAfter,
SizeAfter: afterFile.SizeAfter,
Content: beforeFile.Content, // 原始内容,供回滚还原
})
}
} else {
@ -114,6 +140,7 @@ func diffStates(before, after *FSState) []FileChange {
Type: ChangeFileDeleted,
HashBefore: before.Files[path].HashAfter,
SizeBefore: before.Files[path].SizeAfter,
Content: before.Files[path].Content, // 原始内容,供回滚还原
})
}
}

114
internal/tracker/disk.go Normal file
View File

@ -0,0 +1,114 @@
package tracker
import (
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"sort"
"strings"
"time"
)
// LoadChangeSetsFromDisk 把 <dataDir>/changesets/*.json 读回内存 changeSets
// (按修改时间正序),使 guard 可在 worker 未运行时离线回滚 agentfs。
func (t *Tracker) LoadChangeSetsFromDisk() (int, error) {
dir := filepath.Join(t.dataDir, "changesets")
entries, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
return 0, nil
}
return 0, err
}
type csFile struct {
path string
mod time.Time
}
var files []csFile
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") {
continue
}
info, err := e.Info()
if err != nil {
continue
}
files = append(files, csFile{path: filepath.Join(dir, e.Name()), mod: info.ModTime()})
}
sort.Slice(files, func(i, j int) bool { return files[i].mod.Before(files[j].mod) })
t.mu.Lock()
defer t.mu.Unlock()
t.changeSets = t.changeSets[:0]
for _, f := range files {
data, err := os.ReadFile(f.path)
if err != nil {
continue
}
var cs ChangeSet
if err := json.Unmarshal(data, &cs); err != nil {
continue
}
t.loadContentBlobs(&cs, dir)
t.changeSets = append(t.changeSets, &cs)
}
log.Printf("[tracker] loaded %d changesets from disk", len(t.changeSets))
return len(t.changeSets), nil
}
// RollbackFromDisk 供 guard 在 worker 离线时执行 L2 agentfs 回滚:
// 读回全部持久化 changeset 并按时间逆序逆应用(还原被改/被删文件、删除新增),
// 然后删除这些 changeset 文件。返回还原的 changeset 数。
func (t *Tracker) RollbackFromDisk() (int, error) {
if _, err := t.LoadChangeSetsFromDisk(); err != nil {
return 0, err
}
t.mu.Lock()
defer t.mu.Unlock()
n := len(t.changeSets)
if n == 0 {
log.Printf("[tracker] rollback from disk: nothing to revert")
return 0, nil
}
for i := n - 1; i >= 0; i-- {
t.applyReverseLocked(t.changeSets[i])
}
dir := filepath.Join(t.dataDir, "changesets")
for _, cs := range t.changeSets {
os.Remove(filepath.Join(dir, cs.ID+".json"))
removeContentBlobs(dir, cs.ID)
}
t.changeSets = t.changeSets[:0]
log.Printf("[tracker] rollback from disk: reverted %d change sets", n)
return n, nil
}
// ChangesetsOnDisk 返回磁盘上持久化 changeset 数量(guard 决策用)。
func (t *Tracker) ChangesetsOnDisk() int {
dir := filepath.Join(t.dataDir, "changesets")
entries, err := os.ReadDir(dir)
if err != nil {
return 0
}
n := 0
for _, e := range entries {
if !e.IsDir() && strings.HasSuffix(e.Name(), ".json") {
n++
}
}
return n
}
// NewOfflineTracker 构造一个仅用于离线回滚的 tracker(不 mount overlay)。
// worker 目录不存在时也会自动创建(Init 语义)。
func NewOfflineTracker(dataDir, workDir string) *Tracker {
t := NewTracker(dataDir, workDir)
_ = t.Init()
return t
}
var _ = fmt.Sprintf

View File

@ -0,0 +1,92 @@
package tracker
import (
"os"
"path/filepath"
"testing"
)
// 构造一条写入磁盘的 changeset(模拟 worker 运行期间的变更),随后用
// RollbackFromDisk 离线还原。
func TestRollbackFromDisk(t *testing.T) {
dataDir := t.TempDir()
workDir := t.TempDir()
trk := NewTracker(dataDir, workDir)
if err := trk.Init(); err != nil {
t.Fatalf("Init: %v", err)
}
upper := trk.upperDir
os.MkdirAll(upper, 0755)
// 1. 先创建一个文件(对应"新建")
newFile := filepath.Join(upper, "new.txt")
os.WriteFile(newFile, []byte("brand new"), 0644)
// 2. 修改一个文件(对应"修改",带原文)
modFile := filepath.Join(upper, "mod.txt")
os.WriteFile(modFile, []byte("after"), 0644)
beforeContent := []byte("before")
csMod := &ChangeSet{
ID: "cs_mod",
Files: []FileChange{{
Path: "mod.txt",
Type: ChangeFileModified,
Content: beforeContent,
}},
}
trk.saveChangeSet(csMod)
csNew := &ChangeSet{
ID: "cs_new",
Files: []FileChange{{
Path: "new.txt",
Type: ChangeFileCreated,
}},
}
trk.saveChangeSet(csNew)
if trk.ChangesetsOnDisk() != 2 {
t.Fatalf("ChangesetsOnDisk = %d, want 2", trk.ChangesetsOnDisk())
}
// 离线回滚(模拟 guard 在 worker 崩溃后调用)
n, err := trk.RollbackFromDisk()
if err != nil {
t.Fatalf("RollbackFromDisk: %v", err)
}
if n != 2 {
t.Fatalf("rolled back %d, want 2", n)
}
if trk.ChangesetsOnDisk() != 0 {
t.Fatalf("ChangesetsOnDisk after rollback = %d, want 0", trk.ChangesetsOnDisk())
}
// 新建文件被删除
if _, err := os.Stat(newFile); !os.IsNotExist(err) {
t.Fatal("created file should be removed after rollback")
}
// 修改文件还原原文
data, err := os.ReadFile(modFile)
if err != nil {
t.Fatalf("read modFile: %v", err)
}
if string(data) != "before" {
t.Fatalf("modFile restored to %q, want %q", data, beforeContent)
}
}
func TestRollbackFromDiskEmpty(t *testing.T) {
dataDir := t.TempDir()
workDir := t.TempDir()
trk := NewTracker(dataDir, workDir)
if err := trk.Init(); err != nil {
t.Fatalf("Init: %v", err)
}
n, err := trk.RollbackFromDisk()
if err != nil {
t.Fatalf("RollbackFromDisk: %v", err)
}
if n != 0 {
t.Fatalf("rolled back %d, want 0", n)
}
}

View File

@ -26,7 +26,7 @@ type Tracker struct {
active bool
before *FSState
changeSets []*ChangeSet
keepChangesets int // 保留最近 N 份 changeset,0 = 不限
keepChangesets int // 保留最近 N 份 changeset,0 = 不限
maxChangesetAge time.Duration // changeset 最大保留时长,0 = 不限
}
@ -115,7 +115,7 @@ func (t *Tracker) PostAction(action string) *ChangeSet {
t.mu.Lock()
defer t.mu.Unlock()
after := t.capture()
after, _ := captureFSState(t.upperDir)
changes := diffStates(t.before, after)
cs := NewChangeSet(action)
@ -146,7 +146,7 @@ func (t *Tracker) ChangeSets() []*ChangeSet {
}
func (t *Tracker) capture() *FSState {
state, err := captureFSState(t.upperDir)
state, err := captureFSStateWithContent(t.upperDir)
if err != nil {
return &FSState{Files: make(map[string]FileChange), Root: t.upperDir}
}
@ -192,6 +192,41 @@ func (t *Tracker) saveChangeSet(cs *ChangeSet) {
if err := os.WriteFile(path, data, 0644); err != nil {
log.Printf("[tracker] write changeset %s: %v", cs.ID, err)
}
// Content 字段是 json:"-",不随 JSON 落盘;为保证 L2 离线回滚(guard 重启后)
// 仍能还原被改/被删文件,把回滚原文作为伴随 blob 单独持久化。
t.saveContentBlobs(cs, dir)
}
// saveContentBlobs 把 changeset 中各文件的回滚原文写入 <dataDir>/changesets/<csID>_blobs/<i>.bin。
func (t *Tracker) saveContentBlobs(cs *ChangeSet, dir string) {
blobDir := filepath.Join(dir, cs.ID+"_blobs")
os.MkdirAll(blobDir, 0755)
for i, f := range cs.Files {
if len(f.Content) == 0 {
continue
}
bp := filepath.Join(blobDir, fmt.Sprintf("%03d.bin", i))
if err := os.WriteFile(bp, f.Content, 0600); err != nil {
log.Printf("[tracker] write blob %s: %v", bp, err)
}
}
}
// loadContentBlobs 读回 <csID>_blobs 目录中的回滚原文到 FileChange.Content。
func (t *Tracker) loadContentBlobs(cs *ChangeSet, dir string) {
blobDir := filepath.Join(dir, cs.ID+"_blobs")
for i := range cs.Files {
bp := filepath.Join(blobDir, fmt.Sprintf("%03d.bin", i))
if data, err := os.ReadFile(bp); err == nil {
cs.Files[i].Content = data
}
}
}
// removeContentBlobs 删除一个 changeset 的 blob 目录。
func removeContentBlobs(dir, id string) {
os.RemoveAll(filepath.Join(dir, id+"_blobs"))
}
func (t *Tracker) cleanupChangeSets() {
@ -202,8 +237,8 @@ func (t *Tracker) cleanupChangeSets() {
}
type csFile struct {
name string
info os.FileInfo
name string
info os.FileInfo
}
var files []csFile
for _, e := range entries {
@ -243,6 +278,7 @@ func (t *Tracker) cleanupChangeSets() {
for i := 0; i < excess; i++ {
path := filepath.Join(dir, remaining[i].name)
os.Remove(path)
removeContentBlobs(dir, strings.TrimSuffix(remaining[i].name, ".json"))
}
remaining = remaining[excess:]
}
@ -253,6 +289,9 @@ func (t *Tracker) cleanupChangeSets() {
}
}
// Rollback 全量回滚:按时间逆序应用每个 changeset 的逆操作,把工作区恢复到
// 首条 changeset 之前的状态(用捕获的原文还原被改/被删文件、删除新增文件)。
// 无任何 changeset 时退回整目录重置(移除 upper 重建,丢弃全部变更)。
func (t *Tracker) Rollback() error {
t.mu.Lock()
defer t.mu.Unlock()
@ -263,22 +302,77 @@ func (t *Tracker) Rollback() error {
}
}
if len(t.changeSets) > 0 {
for i := len(t.changeSets) - 1; i >= 0; i-- {
t.applyReverseLocked(t.changeSets[i])
}
log.Printf("[tracker] rollback complete: reverted %d change sets", len(t.changeSets))
} else {
if err := t.resetUpperLocked(); err != nil {
return err
}
log.Printf("[tracker] rollback complete: no change sets, reset upper dir")
}
t.changeSets = nil
t.before = nil
t.mounted = false
return nil
}
// RollbackLatest 仅撤销最近一条 changeset(定向回滚,不动更早的改动)。
func (t *Tracker) RollbackLatest() error {
t.mu.Lock()
defer t.mu.Unlock()
if len(t.changeSets) == 0 {
return fmt.Errorf("no change sets to roll back")
}
cs := t.changeSets[len(t.changeSets)-1]
t.applyReverseLocked(cs)
t.changeSets = t.changeSets[:len(t.changeSets)-1]
t.before = t.capture()
log.Printf("[tracker] rolled back latest change set %s (%d files)", cs.ID, len(cs.Files))
return nil
}
// applyReverseLocked 逆应用一个 changeset:created→删除;modified→写回原文;deleted→用原文重建。
// 调用方须持有写锁。
func (t *Tracker) applyReverseLocked(cs *ChangeSet) {
for _, f := range cs.Files {
path := filepath.Join(t.upperDir, filepath.Clean(f.Path))
switch f.Type {
case ChangeFileCreated:
if err := os.RemoveAll(path); err != nil {
log.Printf("[tracker] rollback remove %s: %v", f.Path, err)
}
case ChangeFileModified, ChangeFileDeleted:
if len(f.Content) == 0 {
log.Printf("[tracker] rollback %s: original content not captured, skipping", f.Path)
continue
}
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
log.Printf("[tracker] rollback mkdir %s: %v", filepath.Dir(f.Path), err)
continue
}
if err := os.WriteFile(path, f.Content, 0644); err != nil {
log.Printf("[tracker] rollback restore %s: %v", f.Path, err)
}
}
}
}
// resetUpperLocked 整目录重置(无 changeset 时的兜底),调用方须持有写锁。
func (t *Tracker) resetUpperLocked() error {
if err := os.RemoveAll(t.upperDir); err != nil {
return fmt.Errorf("remove upper: %w", err)
}
if err := os.RemoveAll(filepath.Join(t.workDir, "work")); err != nil {
return fmt.Errorf("remove work: %w", err)
}
if err := os.MkdirAll(t.upperDir, 0755); err != nil {
return fmt.Errorf("recreate upper: %w", err)
}
t.changeSets = nil
t.before = nil
t.mounted = false
log.Printf("[tracker] rollback complete")
return nil
}

View File

@ -312,3 +312,134 @@ func TestCaptureDirNotExist(t *testing.T) {
t.Error("expected error for nonexistent directory")
}
}
func TestDiffStatesModifiedContent(t *testing.T) {
dir := t.TempDir()
f := filepath.Join(dir, "f.txt")
os.WriteFile(f, []byte("original-content"), 0644)
before, err := captureFSStateWithContent(dir)
if err != nil {
t.Fatal(err)
}
os.WriteFile(f, []byte("tampered-content"), 0644)
after, _ := captureFSState(dir)
changes := diffStates(before, after)
if len(changes) != 1 || changes[0].Type != ChangeFileModified {
t.Fatalf("expected 1 modified, got %+v", changes)
}
if string(changes[0].Content) != "original-content" {
t.Fatalf("modified change should carry original content, got %q", changes[0].Content)
}
}
func TestDiffStatesDeletedContent(t *testing.T) {
dir := t.TempDir()
f := filepath.Join(dir, "f.txt")
os.WriteFile(f, []byte("do-not-lose"), 0644)
before, err := captureFSStateWithContent(dir)
if err != nil {
t.Fatal(err)
}
os.Remove(f)
after, _ := captureFSState(dir)
changes := diffStates(before, after)
if len(changes) != 1 || changes[0].Type != ChangeFileDeleted {
t.Fatalf("expected 1 deleted, got %+v", changes)
}
if string(changes[0].Content) != "do-not-lose" {
t.Fatalf("deleted change should carry original content, got %q", changes[0].Content)
}
}
func TestRollbackLatest(t *testing.T) {
dir := t.TempDir()
work := filepath.Join(dir, "work")
tr := NewTracker(filepath.Join(dir, "data"), work)
if err := tr.Init(); err != nil {
t.Fatal(err)
}
upper := tr.upperDir
os.WriteFile(filepath.Join(upper, "keep.txt"), []byte("stable"), 0644)
os.WriteFile(filepath.Join(upper, "gone.txt"), []byte("do-not-lose"), 0644)
// 动作1:改 keep、加 new(应保留)
tr.PreAction("action1")
os.WriteFile(filepath.Join(upper, "keep.txt"), []byte("tampered"), 0644)
os.WriteFile(filepath.Join(upper, "new.txt"), []byte("added"), 0644)
tr.PostAction("action1")
// 动作2:删 gone(仅撤销这条)
tr.PreAction("action2")
os.Remove(filepath.Join(upper, "gone.txt"))
tr.PostAction("action2")
if !tr.HasChanges() {
t.Fatal("expected changes after PostAction")
}
if err := tr.RollbackLatest(); err != nil {
t.Fatalf("RollbackLatest: %v", err)
}
restored, err := os.ReadFile(filepath.Join(upper, "gone.txt"))
if err != nil || string(restored) != "do-not-lose" {
t.Fatalf("gone.txt should be recreated with original content, got %q err=%v", restored, err)
}
// action1 的改动不受影响
got, err := os.ReadFile(filepath.Join(upper, "keep.txt"))
if err != nil || string(got) != "tampered" {
t.Fatalf("keep.txt should keep action1 changes, got %q err=%v", got, err)
}
if _, err := os.Stat(filepath.Join(upper, "new.txt")); err != nil {
t.Fatalf("new.txt should still exist after latest-only rollback, err=%v", err)
}
if tr.HasChanges() != true {
t.Fatal("earlier change sets should remain after latest-only rollback")
}
if got := len(tr.ChangeSets()); got != 1 {
t.Fatalf("expected 1 remaining change set, got %d", got)
}
}
func TestRollbackFull(t *testing.T) {
dir := t.TempDir()
work := filepath.Join(dir, "work")
tr := NewTracker(filepath.Join(dir, "data"), work)
if err := tr.Init(); err != nil {
t.Fatal(err)
}
upper := tr.upperDir
os.WriteFile(filepath.Join(upper, "keep.txt"), []byte("stable"), 0644)
os.WriteFile(filepath.Join(upper, "deleteme.txt"), []byte("bye"), 0644)
// 动作1:改 keep、加 new
tr.PreAction("a")
os.WriteFile(filepath.Join(upper, "keep.txt"), []byte("tampered"), 0644)
os.WriteFile(filepath.Join(upper, "new.txt"), []byte("added"), 0644)
tr.PostAction("a")
// 动作2:删 deleteme(在受追踪的动作内)
tr.PreAction("b")
os.Remove(filepath.Join(upper, "deleteme.txt"))
tr.PostAction("b")
if err := tr.Rollback(); err != nil {
t.Fatalf("Rollback: %v", err)
}
got, _ := os.ReadFile(filepath.Join(upper, "keep.txt"))
if string(got) != "stable" {
t.Fatalf("keep.txt should be restored, got %q", got)
}
if _, err := os.Stat(filepath.Join(upper, "new.txt")); !os.IsNotExist(err) {
t.Fatalf("new.txt should be gone after full rollback")
}
restored, err := os.ReadFile(filepath.Join(upper, "deleteme.txt"))
if err != nil || string(restored) != "bye" {
t.Fatalf("deleted file should be recreated with original content, got %q err=%v", restored, err)
}
}