Files
HomeAgent/internal/recovery/recovery.go
root bc9ef15eb0 三层回退恢复机制(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 单元测试全过
2026-08-05 16:00:08 +08:00

233 lines
7.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Package 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/proxyL1 第一步小修命中即停)
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 快照 + ReloadFromConfigL1 第二步)
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
}