Files
HomeAgent/internal/agent/core/plugin_health.go
JianFeeeee 8fa6f36982 fix(agent): 插件崩溃熔断从不生效 —— 冷却锚点用了从未赋值的字段
## 缺陷(写上一个提交的判据时撞出来的)

recordCrash 标记 Unhealthy=true,但 isHealthy 的冷却判据是:

    time.Since(r.LastReload) > reloadCooldown

而 LastReload **只在 markReloaded 里被赋值**(即"重载成功"之后)。
插件刚崩、从未重载时它是**零值**:

    time.Since(零值) ≈ 2000 年 >> 30s
    ⇒ 判"冷却期早已过去" ⇒ 立刻把 Unhealthy 置回 false

实测(连打 3 次 recordCrash):

    crash #1 → needReload=false healthy=true
    crash #2 → needReload=false healthy=true
    crash #3 → needReload=true  healthy=true   ← 刚标完就自愈

## 影响

三处「插件不健康则跳过执行」全是**死分支**:

    task.go:1065        stepToolBegin 中的跳过
    task.go:1135        (上一提交刚补的那个分支的另一处同构)
    toolbatch.go:155    并发批次中的跳过

即:一个正在崩溃循环的插件会被**持续调用**,每次调用再崩一次。
recordCrash 返回的 needReload=true 也没意义 —— 下一句 isHealthy 就说它健康了。

pendingReloads 用同一个零值字段 ⇒ autoReloadPlugins 每次都会尝试重载
**刚崩的**插件,很可能立刻再崩(崩溃循环)。

## 修法:冷却锚点改为 LastCrash

语义上「熔断」是"刚崩完先别用它,等 30s 再看" —— 观察窗口应锚在
**最近一次崩溃**,而不是"最近一次重载成功"。

    isHealthy      锚点 LastReload → LastCrash
    pendingReloads 锚点 LastReload → LastCrash

## 顺带删掉冗余

    unhealthyPlugins()      零生产调用方(全仓 grep 只有定义本身)
    record.FirstCrash       只写不读(写它的唯一目的是被下面这个判断用,
                            而那个判断早就改成只看 LastCrash 了)

## 判据(5 条,3 次变异全部实测变红)

  TestCircuitBreakerActuallyOpens                   ★ 主判据
      变异:锚点改回 LastReload ⇒ "★ 连续 3 次崩溃后插件仍被判健康"

  TestCircuitBreakerWaitsForCooldownBeforeReload
      变异:pendingReloads 锚点改回 LastReload ⇒
      "★ 刚崩完就进了重载队列(应等冷却期):[p]"

  TestCircuitBreakerRecoversAfterCooldown
      变异:删掉恢复分支 ⇒ "★ 冷却期过后仍未恢复健康 ——
      插件会被永久停用(比不熔断更糟)"

  TestCircuitBreakerUnknownPluginIsHealthy    从未崩过的插件不记账
  TestCircuitBreakerWindowResets              窗口外的旧崩溃不该累加

## 判据本身的两个坑(已避开)

① 只断言 recordCrash 的返回值 —— 它本来就返回 true,测了等于没测。
   必须断言 **isHealthy 的结论**,那才是被熔断逻辑消费的东西。

② 不测"冷却之后会恢复" —— 那样把代码改成恒 return false 也能过,
   而那就成了永久禁用插件(比不熔断更糟)。

## 连带:deniedpair_test 的前置条件改回真实状态机

上一提交里,第二条判据只能**手工置 Unhealthy**(因为 recordCrash 标完
立刻被自愈撤销)。现在熔断真生效了,改回用 recordCrash 打满阈值 ——
判据依赖公开状态机而不是私有字段。

## 验证

    go build ./...            PASS
    go vet ./...              PASS
    gofmt -l                  干净
    go test ./... -count=1    62 包全过,0 FAIL
    干净检出(worktree)       build + TestCircuitBreaker/TestDenied 全绿
2026-11-04 16:34:46 +08:00

134 lines
3.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 core
import (
"log"
"sync"
"time"
)
const (
maxPluginCrashes = 3
crashWindow = 5 * time.Minute
reloadCooldown = 30 * time.Second
)
type pluginHealthTracker struct {
mu sync.Mutex
records map[string]*pluginHealthRecord
}
type pluginHealthRecord struct {
CrashCount int
LastCrash time.Time
Unhealthy bool
LastReload time.Time
}
func newPluginHealthTracker() *pluginHealthTracker {
return &pluginHealthTracker{
records: make(map[string]*pluginHealthRecord),
}
}
// recordCrash 记录一次崩溃,返回 true 表示需要触发重载
func (t *pluginHealthTracker) recordCrash(plugin string) bool {
t.mu.Lock()
defer t.mu.Unlock()
now := time.Now()
r, ok := t.records[plugin]
if !ok {
r = &pluginHealthRecord{}
t.records[plugin] = r
}
if now.Sub(r.LastCrash) > crashWindow {
r.CrashCount = 0
}
r.CrashCount++
r.LastCrash = now
if r.CrashCount >= maxPluginCrashes {
r.Unhealthy = true
log.Printf("[plugin] %s: %d crashes within %v, marking unhealthy", plugin, r.CrashCount, crashWindow)
return true
}
log.Printf("[plugin] %s: crash #%d", plugin, r.CrashCount)
return false
}
// isHealthy 检查插件是否健康。
//
// ## 冷却期必须从**最近一次崩溃**起算(2026-11-04 修的实测缺陷)
//
// 原实现是 `time.Since(r.LastReload) > reloadCooldown`,而 LastReload
// **只在 markReloaded 里被赋值**(即只在"重载成功"之后)。于是:
//
// recordCrash 标记 Unhealthy=true
// 但 LastReload 仍是零值
// time.Since(零值) = 约 2000 年 ⇒ 远大于 30s
// ⇒ 判定"冷却期早已过去" ⇒ 立刻置回 healthy
//
// 实测:连续 3 次 recordCrash 后 needReload=true、Unhealthy=true,
// 紧接着 isHealthy 返回 **true**。即**崩溃熔断从不生效**,
// task.go 与 toolbatch.go 里三处「插件不健康则跳过执行」全是死分支。
//
// 修法:冷却期以 LastCrash 为锚点 —— "刚崩完就先别用它,等 30s 再说"。
// 这才是熔断的语义(原意是给插件一个恢复窗口,不是给重载计时的)。
func (t *pluginHealthTracker) isHealthy(plugin string) bool {
t.mu.Lock()
defer t.mu.Unlock()
r, ok := t.records[plugin]
if !ok {
return true
}
if !r.Unhealthy {
return true
}
if time.Since(r.LastCrash) > reloadCooldown {
r.Unhealthy = false
r.CrashCount = 0
log.Printf("[plugin] %s: cooldown passed (%v since last crash), restored to healthy",
plugin, reloadCooldown)
return true
}
return false
}
// markReloaded 标记插件已重载
func (t *pluginHealthTracker) markReloaded(plugin string) {
t.mu.Lock()
defer t.mu.Unlock()
r, ok := t.records[plugin]
if ok {
r.Unhealthy = false
r.CrashCount = 0
r.LastReload = time.Now()
}
}
// pendingReloads 返回已过冷却期、需要重载的插件列表
func (t *pluginHealthTracker) pendingReloads() []string {
t.mu.Lock()
defer t.mu.Unlock()
var result []string
now := time.Now()
for name, r := range t.records {
if !r.Unhealthy {
continue
}
// 与 isHealthy 同一锚点(LastCrash):刚崩完先等冷却,再尝试重载。
// 原实现用 LastReload(零值 ⇒ 恒为真)⇒ 每次 autoReloadPlugins 都会
// 尝试重载一个**刚崩的**插件,可能立刻再崩。
if now.Sub(r.LastCrash) > reloadCooldown {
result = append(result, name)
}
}
return result
}