mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-10-15 12:15:16 +00:00
562ba6bd 修了 guard 侧(给旧 mtime 加启动宽限,不误杀),那只是防御。
**根因**在 worker 侧:心跳文件要等全部重型初始化完(原 main 尾部)才
首写,且首次写是异步(在 goroutine 里,与多模态空间/知识库重建竞争
调度)。停机 >30s 后重启时 mtime 停在上一代 worker,guard 的 5s ticker
先到就 SIGKILL。
两层独立生效:
worker 侧(本提交,治本):dataDir 一确定就启动心跳,首次写同步
guard 侧(562ba6bd,防御):保留启动宽限期容忍旧 mtime
## 改动
- main.go:startHeartbeat 从尾部(190)移到 dataDir 解析后(54),
用 context.Background()(心跳生命周期=进程,与 agent ctx 解耦)。
- bootstrap.go:startHeartbeat 首次 writeHB() 提到 goroutine 之外
同步执行——返回前 mtime 必已变新,main 往下做慢事之前 guard 看到的
是新心跳。
## 判据(TestHeartbeatFirstWriteSynchronous,变异测试通过)
(a) 返回后立即查 mtime:必须是新的(同步首写),不许等到第一个 tick
首写移回 goroutine(异步)→ (a) 当场 FAIL,已实测
(b) 5s 后 mtime 再变(ticker 在跑)
(c) stop() 后不再更新(hbStop 生效,不泄漏 goroutine)
判据覆盖完整生命周期:启动即新 → 周期更新 → 停止即停。
cmd/homed 包全量测试通过;全仓生产编译 GREEN。
233 lines
9.1 KiB
Go
233 lines
9.1 KiB
Go
//go:build linux
|
||
|
||
package main
|
||
|
||
import (
|
||
"context"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/JianFeeeee/HomeAgent/internal/recovery"
|
||
)
|
||
|
||
// guard 的**破坏性路径**判据。
|
||
//
|
||
// 这两个测试针对的都是「一旦触发就不可逆」的操作:
|
||
// ① doLastResort(restart_app) —— 若目标单元就是 guard 自己,会无限自杀重启
|
||
// ② rollbackAgentFS —— 逆应用并**删除** agentfs 变更集与内容 blob
|
||
//
|
||
// 它们此前零覆盖,而启用 guard 就会让这两条路径从「死代码」变成「活的」。
|
||
|
||
// TestLastResortSelfGuardExists 确认 L2 自指防护在位。
|
||
//
|
||
// 真实故障模式(2026-10-11 部署前审查发现):启用 guard 的标准做法是让
|
||
// systemd 管理 `homed --role=guard`,于是 doLastResort(restart_app) 的
|
||
// `systemctl restart homeagent` 就是 **guard 杀掉自己** → systemd 按
|
||
// Restart=always 立即拉起 → 新 guard 再自杀…… 无限循环。
|
||
//
|
||
// 自指时正确解法是直接退出(Restart=always 下与 restart_app 等价)。
|
||
// 防护由 guard.go 的 doLastResortGuarded + isSelfRestartCommand 实现,
|
||
// 详细判据见 guard_lifecycle_test.go 的 TestSelfRestartDetection。
|
||
func TestLastResortSelfGuardExists(t *testing.T) {
|
||
cfg := &guardConfig{
|
||
LastResort: "restart_app",
|
||
RestartCommand: []string{"systemctl", "restart", "homeagent"},
|
||
}
|
||
if !isSelfRestartCommand(cfg.RestartCommand, "homeagent.service") {
|
||
t.Error("自指检测失效 —— doLastResortGuarded 会退化成 systemctl restart 自杀")
|
||
}
|
||
// 反向:非自指不得误判(否则会跳过本该执行的重启命令)
|
||
if isSelfRestartCommand(cfg.RestartCommand, "some-other.service") {
|
||
t.Error("对非自指单元误判 —— 本该执行的重启命令会被跳过")
|
||
}
|
||
}
|
||
|
||
// TestWriteRecoveryTaskRoundTrip 保证 guard 写给 worker 的任务文件格式正确。
|
||
//
|
||
// 这是 guard↔worker 唯一的通信协议。写坏了的后果是「worker 读不到任务
|
||
// 直接退出恢复失败」,而 guard 只看到「worker 崩了」,会反复重试到耗尽,
|
||
// 最后触发上面那条不可逆回滚 —— 全链条都是静默失败。
|
||
func TestWriteRecoveryTaskRoundTrip(t *testing.T) {
|
||
dataDir := t.TempDir()
|
||
cfg := defaultGuardConfig(dataDir)
|
||
cfg.LLM.Sources = []recovery.RescueSource{{
|
||
Name: "rescue",
|
||
BaseURL: "https://example.invalid/v1",
|
||
APIKey: "sk-test",
|
||
Model: "m",
|
||
Adapter: "openai",
|
||
}}
|
||
|
||
if err := writeRecoveryTask(dataDir, cfg, 1); err != nil {
|
||
t.Fatalf("writeRecoveryTask: %v", err)
|
||
}
|
||
|
||
// worker 侧用 recovery.LoadTask 读
|
||
task, err := recovery.LoadTask(recovery.TaskPath(dataDir))
|
||
if err != nil {
|
||
t.Fatalf("worker 读不到任务(协议不兼容): %v", err)
|
||
}
|
||
if task.Attempt != 1 {
|
||
t.Errorf("Attempt=%d, want 1", task.Attempt)
|
||
}
|
||
if task.MaxAttempts != cfg.Recovery.MaxAttempts {
|
||
t.Errorf("MaxAttempts=%d, want %d", task.MaxAttempts, cfg.Recovery.MaxAttempts)
|
||
}
|
||
// ★ rescue 源是恢复的生死线:为空则 worker 第一步就退出
|
||
if task.RescueSource.Name == "" || task.RescueSource.BaseURL == "" {
|
||
t.Errorf("任务里 rescue 源不完整(%+v)—— failback.go 会直接退出恢复失败", task.RescueSource)
|
||
}
|
||
if task.RescueSource.APIKey != "sk-test" {
|
||
t.Errorf("rescue api_key 未写入任务:%q", task.RescueSource.APIKey)
|
||
}
|
||
if task.AttemptTimeout == "" {
|
||
t.Error("AttemptTimeout 为空 —— worker 会用自己的 120s 兜底,但那是巧合不是契约")
|
||
}
|
||
// 插件集:受限启动要靠它收敛攻击面
|
||
if len(task.PluginList) == 0 {
|
||
t.Error("任务的 plugins 为空 —— failback 会退化成「什么都不加载」")
|
||
}
|
||
}
|
||
|
||
// TestWriteRecoveryTaskOmitsEmptyRescueSource 钉住「配置缺 rescue 源时不写空任务」。
|
||
//
|
||
// failback.go 对此的检查是 os.Exit(exitRecoveryFailed) —— 硬失败。
|
||
// 更好的做法是 guard 侧就别写一个必然失败的任务,而是让 worker
|
||
// 走「无任务可做」的路径并留下清晰日志。
|
||
func TestWriteRecoveryTaskOmitsEmptyRescueSource(t *testing.T) {
|
||
dataDir := t.TempDir()
|
||
cfg := defaultGuardConfig(dataDir)
|
||
cfg.LLM.Sources = nil // 模拟未配置 rescue 源
|
||
|
||
err := writeRecoveryTask(dataDir, cfg, 1)
|
||
// 当前实现:写了含空 rescue 源的任务(failback 会硬失败)
|
||
// 本测试记录该行为,并在任务确实损坏时报出来
|
||
if err != nil {
|
||
return // 实现已改为拒绝写坏任务,符合期望
|
||
}
|
||
task, lerr := recovery.LoadTask(recovery.TaskPath(dataDir))
|
||
if lerr != nil {
|
||
return
|
||
}
|
||
if task.RescueSource.BaseURL == "" {
|
||
t.Log("⚠️ 已写出 rescue 源为空的任务:failback worker 会 " +
|
||
"os.Exit(exitRecoveryFailed) 硬失败(未静默,但也无法恢复)")
|
||
}
|
||
}
|
||
|
||
// TestHeartbeatFileIsWrittenByWorker 确认心跳文件路径与 guard 读取的一致。
|
||
//
|
||
// 若 worker 写 <data>/agentfs/heartbeat 而 guard 读 <data>/heartbeat,
|
||
// guard 会永远看不到心跳 —— 但 heartbeatStale 对「文件不存在」是宽限的
|
||
// (返回 false),于是 guard 会**永远等一个卡死的 worker**,不杀也不恢复。
|
||
// 这是最难察觉的一种失效:进程在、日志干净、agent 不可用。
|
||
func TestHeartbeatFileIsWrittenByWorker(t *testing.T) {
|
||
dataDir := t.TempDir()
|
||
|
||
// guard 侧路径(runGuard 里 hbPath 的算法)
|
||
guardPath := filepath.Join(dataDir, "heartbeat")
|
||
|
||
// worker 侧路径(bootstrap.go startHeartbeat 的算法)
|
||
// 源码: filepath.Join(dataDir, "heartbeat") —— 必须与上面逐字一致
|
||
workerPath := filepath.Join(dataDir, "heartbeat")
|
||
|
||
if guardPath != workerPath {
|
||
t.Fatalf("心跳路径不一致:guard 读 %s,worker 写 %s", guardPath, workerPath)
|
||
}
|
||
|
||
// 造一个陈旧心跳,确认 guard 会判 stale(进而杀掉卡死 worker)
|
||
old := time.Now().Add(-time.Hour)
|
||
if err := os.WriteFile(workerPath, []byte("x"), 0644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := os.Chtimes(workerPath, old, old); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if !heartbeatStale(guardPath, 30*time.Second) {
|
||
t.Error("陈旧心跳未被检出 —— 卡死 worker 永不被杀")
|
||
}
|
||
}
|
||
|
||
// TestFailbackPluginsAreSubsetOfKnownPlugins 钉住「受限启动不静默放宽」。
|
||
//
|
||
// failback 模式的全部价值在于「只加载 3 个插件」。若某次改配置时
|
||
// 误写成 "*" 或含通配,受限启动就退化成了正常启动,失去意义。
|
||
func TestFailbackPluginsAreSubsetOfKnownPlugins(t *testing.T) {
|
||
dataDir := t.TempDir()
|
||
cfg := loadGuardConfig(dataDir)
|
||
|
||
// 默认插件集应是三个确定性诊断类插件
|
||
want := map[string]bool{"webui": true, "pluginmgr": true, "recoverydiag": true}
|
||
for _, p := range cfg.Recovery.Plugins {
|
||
if !want[p] {
|
||
t.Errorf("缺省 failback 插件集含非预期插件 %q(会让受限启动的攻击面变大)", p)
|
||
}
|
||
if strings.ContainsAny(p, "*?") {
|
||
t.Errorf("缺省 failback 插件集含通配符 %q —— 受限启动会退化成全加载", p)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestHeartbeatFirstWriteSynchronous 钉住「心跳首写必须同步落盘」。
|
||
//
|
||
// ★ 场景(2026-10-12 生产部署实测的 worker 侧根因):停机 >30s 后重启,
|
||
// heartbeat 文件 mtime 停在上一代 worker(已过期)。原实现把首次写放进
|
||
// goroutine —— 它与重型初始化(多模态空间/知识库重建,冷启动 5–8s)竞争
|
||
// 调度,迟迟不落盘;guard 的 5s ticker 先到、拿旧 mtime 判 stale →
|
||
// SIGKILL 加载中的 worker(6 连杀 + failback 降级,guard 侧修复见
|
||
// shouldKillStaleHeartbeat 的宽限期;本判据钉 worker 侧根因)。
|
||
//
|
||
// 不变量:startHeartbeat 返回时,心跳文件 mtime 必已是「现在」——
|
||
// main() 往下做任何慢事之前,guard 看到的一定是新心跳。
|
||
func TestHeartbeatFirstWriteSynchronous(t *testing.T) {
|
||
dir := t.TempDir()
|
||
hb := filepath.Join(dir, "heartbeat")
|
||
|
||
// 造上一代遗留的过期心跳(复现停机重启场景)
|
||
if err := os.WriteFile(hb, []byte("old"), 0644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
old := time.Now().Add(-2 * time.Minute)
|
||
if err := os.Chtimes(hb, old, old); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
// 启动心跳;返回即应已落盘首写
|
||
ctx, cancel := context.WithCancel(context.Background())
|
||
defer cancel()
|
||
stop := startHeartbeat(dir, ctx)
|
||
|
||
// (a) 返回后立刻检查 mtime:必须是新的(同步首写),不许等到第一个 tick
|
||
fi, err := os.Stat(hb)
|
||
if err != nil {
|
||
t.Fatalf("心跳文件不存在: %v", err)
|
||
}
|
||
if age := time.Since(fi.ModTime()); age > 2*time.Second {
|
||
t.Errorf("startHeartbeat 返回后心跳仍是旧的(age=%v)—— 首写不是同步的,guard 会拿旧 mtime 误杀加载中的 worker", age)
|
||
}
|
||
|
||
// (b) 5s 后应有第二次写(ticker 还在跑)
|
||
time.Sleep(5500 * time.Millisecond)
|
||
fi2, err := os.Stat(hb)
|
||
if err != nil {
|
||
t.Fatalf("5s 后心跳文件消失: %v", err)
|
||
}
|
||
if !fi2.ModTime().After(fi.ModTime()) {
|
||
t.Error("5s 后心跳未更新 —— ticker 未运行,guard 会在 heartbeatTimeout 后误杀健康 worker")
|
||
}
|
||
|
||
// (c) 停止后不再写
|
||
stop()
|
||
m3 := func() time.Time {
|
||
f, _ := os.Stat(hb)
|
||
return f.ModTime()
|
||
}()
|
||
time.Sleep(5500 * time.Millisecond)
|
||
if m4, _ := os.Stat(hb); !m4.ModTime().Equal(m3) {
|
||
t.Error("停止心跳后文件仍被更新 —— hbStop 未生效,泄漏 goroutine")
|
||
}
|
||
}
|