diff --git a/cmd/homed/guard.go b/cmd/homed/guard.go index edb8f987..5229bc25 100644 --- a/cmd/homed/guard.go +++ b/cmd/homed/guard.go @@ -335,6 +335,16 @@ func spawnWorkerOnce(exe, dataDir, boot, hbPath string, cfg *guardConfig, sigCh done := make(chan error, 1) go func() { done <- cmd.Wait() }() + // ★ 启动宽限期:新 worker 要先做完全部重型初始化(graph/文本/向量/ + // 多模态空间/知识库,实测冷启动约 5–8s)才开始写心跳(main.go:190)。 + // 而 heartbeat 文件保存的是**上一代 worker** 的 mtime——停机 >heartbeatTimeout + // 后,新 worker 还没启动、文件就已"过期"。若无宽限,guard 的 heartbeatInterval + // ticker 先到、拿旧 mtime 判 stale → 把还在正常加载的 worker SIGKILL, + // 循环重启抖动(2026-10-12 部署实测触发:连续 6 次 failback Safe-Mode)。 + // 宽限 = 给 worker 完整的启动时长再开始心跳判定。 + startupGrace := cfg.heartbeatTimeout + startupDeadline := time.Now().Add(startupGrace) + hbTicker := time.NewTicker(cfg.heartbeatInterval) defer hbTicker.Stop() @@ -374,9 +384,15 @@ func spawnWorkerOnce(exe, dataDir, boot, hbPath string, cfg *guardConfig, sigCh log.Printf("[guard] worker pid=%d crashed: %v", pid, err) return workerCrash, nil case <-hbTicker.C: - // IPC PING/ACK 存活判定(带自诊断上报),失败回退文件心跳 mtime + // IPC PING/ACK 存活判定(带自诊断上报),失败回退文件心跳 mtime。 if st, perr := ipcClient.Ping(cfg.heartbeatTimeout); perr != nil { - if heartbeatStale(hbPath, cfg.heartbeatTimeout) { + // ★ 启动宽限:新 worker 先做完全部重型初始化(graph/文本/向量/ + // 多模态空间/知识库,实测冷启动约 5–8s)才写心跳(main.go:190)。 + // 而 heartbeat 文件 mtime 是**上一代 worker** 的——停机 >heartbeatTimeout + // 后,新 worker 还没启动、文件就已"过期"。无宽限时 guard 的 ticker + // 先到、拿旧 mtime 判 stale → 把还在加载的 worker SIGKILL,循环抖动 + // (2026-10-12 部署实测触发:6 次 failback Safe-Mode)。 + if shouldKillStaleHeartbeat(time.Now(), startupDeadline, hbPath, cfg.heartbeatTimeout) { log.Printf("[guard] heartbeat stale for pid=%d (ipc: %v), treating as hang, SIGKILL", pid, perr) cmd.Process.Kill() <-done @@ -389,6 +405,7 @@ func spawnWorkerOnce(exe, dataDir, boot, hbPath string, cfg *guardConfig, sigCh } } +// heartbeatStale 判心跳文件是否过期(纯函数,见 TestHeartbeatStale)。 func heartbeatStale(path string, timeout time.Duration) bool { fi, err := os.Stat(path) if err != nil { @@ -398,6 +415,19 @@ func heartbeatStale(path string, timeout time.Duration) bool { return time.Since(fi.ModTime()) > timeout } +// shouldKillStaleHeartbeat 在启动宽限期满后,才对陈旧心跳做杀进程判定。 +// +// 纯函数,把 spawnWorkerOnce 里的决策抽出来便于判据覆盖。 +// 之所以不能只测 heartbeatStale:真正的回归是「拿上一代 worker 的 +// 旧 mtime,在新 worker 还在加载时就判 stale」——只有与启动宽限 +// 结合才暴露(见 TestStartupGraceAllowsInit)。 +func shouldKillStaleHeartbeat(now, startupDeadline time.Time, hbPath string, timeout time.Duration) bool { + if now.Before(startupDeadline) { + return false // 启动宽限期:不判 + } + return heartbeatStale(hbPath, timeout) +} + // restoreLLMBaseline 从文件基线恢复 core.llm.* 配置(存在才恢复)。 func restoreLLMBaseline(dataDir, snapPath string) { if _, err := os.Stat(snapPath); err != nil { diff --git a/cmd/homed/guard_config_test.go b/cmd/homed/guard_config_test.go index 91fdb53a..0766bf8d 100644 --- a/cmd/homed/guard_config_test.go +++ b/cmd/homed/guard_config_test.go @@ -241,3 +241,55 @@ func TestSnapshotBeforeDefaultsTrue(t *testing.T) { t.Error("failback 缺省应启用") } } + +// TestStartupGraceAllowsInit 钉住「启动宽限期」这一部署关键行为。 +// +// ★ 场景(2026-10-12 生产部署实测触发的故障): +// 停机 34s 后重启,heartbeat 文件 mtime 停在上一代 worker(已过期), +// 新 worker 要先做完全部重型初始化(chineseclip + 静态词向量 + 知识重建, +// 冷启动 5–8s)才开始写心跳。guard 的 5s ticker 先到、拿旧 mtime 判 stale +// → 把还在加载的 worker SIGKILL → 连续 6 次重启、guard 进入 failback +// Safe-Mode(服务降级)。 +// +// 不变量: +// (a) 宽限期内,即使心跳文件 mtime 极旧,也**不许**判杀 +// (b) 宽限期满后,陈旧心跳**必须**判杀(卡死的 worker 不能靠宽限躲过去) +// (c) 宽限期满后,新鲜心跳不许判杀(误杀健康 worker 同样是事故) +func TestStartupGraceAllowsInit(t *testing.T) { + dir := t.TempDir() + hb := filepath.Join(dir, "heartbeat") + if err := os.WriteFile(hb, []byte("x"), 0644); err != nil { + t.Fatal(err) + } + // 上一代 worker 的旧 mtime(停机 34s → 远超 30s 阈值) + old := time.Now().Add(-2 * time.Minute) + if err := os.Chtimes(hb, old, old); err != nil { + t.Fatal(err) + } + + spawn := time.Now() + deadline := spawn.Add(30 * time.Second) + + // (a) 宽限期内(刚 spawn 1s):旧 mtime 也不许杀 + if shouldKillStaleHeartbeat(spawn.Add(1*time.Second), deadline, hb, 30*time.Second) { + t.Error("宽限期内判杀 —— 会把还在加载多模态空间/知识库的 worker SIGKILL(生产已实测触发 6 连杀)") + } + // 宽限最后一刻同样不许杀(ticker 恰在 deadline 前触发是常态) + if shouldKillStaleHeartbeat(deadline.Add(-50*time.Millisecond), deadline, hb, 30*time.Second) { + t.Error("宽限期末沿判杀 —— worker 还没来得及写第一次心跳") + } + + // (b) 宽限期满 + 心跳仍旧(真的卡死):必须杀 + if !shouldKillStaleHeartbeat(deadline.Add(1*time.Second), deadline, hb, 30*time.Second) { + t.Error("宽限期满后陈旧心跳未判杀 —— 卡死的 worker 靠宽限躲过去,agent 实际不可用") + } + + // (c) 宽限期满 + 心跳新鲜(健康 worker):不许杀 + fresh := time.Now() + if err := os.Chtimes(hb, fresh, fresh); err != nil { + t.Fatal(err) + } + if shouldKillStaleHeartbeat(deadline.Add(2*time.Second), deadline, hb, 30*time.Second) { + t.Error("宽限期满后新鲜心跳被判杀 —— 误杀健康 worker") + } +}