feat(advisor): snapshot builder + deterministic detectors

- internal/snapshot: compact cluster state via schedulerclient (nodes,
  PEND top-N most-starved with parsed pending_reason, cluster sums, queue
  garnish, recent exits). Field whitelists + stall-threshold filter keep
  the state a few KiB. Module-local PEND-reason table mirrors
  mcp-server/internal/pendreason.go (lift into schedulerclient is a P1
  cleanup).
- internal/detect: pend_stall / worker_health / util_skew / exit_burst
  triggers — threshold, then per-scenario cooldown, then dedup on a hash
  of the situation (job set + reasons / node set) so an unchanged cluster
  is never re-evaluated.

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
lei.chen 2026-10-19 03:33:54 +00:00
parent 1ab96521b8
commit fbc81d61d1
7 changed files with 850 additions and 5 deletions

View file

@ -0,0 +1,212 @@
// Package detect holds the advisor's deterministic triggers — cheap, no
// LLM. A detector looks at one snapshot and decides whether a scenario is
// worth a provider round: threshold, then cooldown (don't re-evaluate the
// same scenario too often), then dedup (don't re-evaluate an identical
// situation at all).
package detect
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"time"
"advisor/internal/snapshot"
)
// Scenario names, shared by detector, provider, and log.
const (
ScenarioPendStall = "pend_stall"
ScenarioWorkerHealth = "worker_health"
ScenarioUtilSkew = "util_skew"
ScenarioExitBurst = "exit_burst"
)
// Trigger is one fired detection.
type Trigger struct {
Scenario string
// DedupKey hashes the situation (job set + reason set / node set) so an
// unchanged situation is not re-evaluated on every scan. Two rounds with
// the same key produce the second finding only after the cooldown.
DedupKey string
// Fingerprint is a human-readable summary of what matched (logged, and
// what the echo provider re-derives from the state).
Fingerprint string
}
// Limits configures the detectors.
type Limits struct {
// PendStallAfter: PEND wait counted as stalled (default 5m).
PendStallAfter time.Duration
// PendSpikeMin: absolute PEND count growth that fires without waits.
PendSpikeMin int
// ExitBurstMin: non-zero exits within the window that fire.
ExitBurstMin int
// Cooldowns per scenario; a scenario on cooldown is skipped entirely.
Cooldowns map[string]time.Duration
}
// DefaultLimits returns the P0 defaults.
func DefaultLimits() Limits {
return Limits{
PendStallAfter: 5 * time.Minute,
PendSpikeMin: 5,
ExitBurstMin: 3,
Cooldowns: map[string]time.Duration{
ScenarioPendStall: 10 * time.Minute,
ScenarioWorkerHealth: 30 * time.Minute,
ScenarioUtilSkew: 30 * time.Minute,
ScenarioExitBurst: 30 * time.Minute,
},
}
}
// Detector evaluates snapshots against the limits. Stateful: tracks
// per-scenario cooldown and the last dedup key seen.
type Detector struct {
lim Limits
now func() time.Time // injectable clock for tests
lastFired map[string]time.Time
lastKey map[string]string
}
// NewDetector returns a Detector with the given limits.
func NewDetector(lim Limits) *Detector {
return &Detector{
lim: lim,
now: time.Now,
lastFired: map[string]time.Time{},
lastKey: map[string]string{},
}
}
// Detect runs all detectors over the snapshot and returns the scenarios to
// evaluate this round, in stable order.
func (d *Detector) Detect(st *snapshot.State) []Trigger {
var out []Trigger
if t, ok := d.pendStall(st); ok {
out = append(out, t)
}
if t, ok := d.workerHealth(st); ok {
out = append(out, t)
}
if t, ok := d.utilSkew(st); ok {
out = append(out, t)
}
if t, ok := d.exitBurst(st); ok {
out = append(out, t)
}
return out
}
// fire applies cooldown + dedup; ok=true means the scenario should be
// evaluated this round and the state recorded.
func (d *Detector) fire(scenario, key, fingerprint string) (Trigger, bool) {
if cd, ok := d.lim.Cooldowns[scenario]; ok {
if last, fired := d.lastFired[scenario]; fired && d.now().Sub(last) < cd {
return Trigger{}, false
}
}
// Identical situation as last fired round: skip regardless of cooldown.
if d.lastKey[scenario] == key {
return Trigger{}, false
}
d.lastFired[scenario] = d.now()
d.lastKey[scenario] = key
return Trigger{Scenario: scenario, DedupKey: key, Fingerprint: fingerprint}, true
}
func (d *Detector) pendStall(st *snapshot.State) (Trigger, bool) {
if len(st.PendTop) == 0 && st.Cluster.PendingJobs < d.lim.PendSpikeMin {
return Trigger{}, false
}
// Key: the stalled job set and their reasons.
key := hashOf(reasonKey(st.PendTop))
fp := "pending=" + itoa(st.Cluster.PendingJobs) + " stalled=" + itoa(len(st.PendTop))
return d.fire(ScenarioPendStall, key, fp)
}
func (d *Detector) workerHealth(st *snapshot.State) (Trigger, bool) {
var bad []string
for _, n := range st.Nodes {
switch n.Status {
case "down", "unknown", "unavail", "closed":
bad = append(bad, n.Name+":"+n.Status)
}
}
if len(bad) == 0 {
return Trigger{}, false
}
key := hashOf(join(bad, ","))
return d.fire(ScenarioWorkerHealth, key, join(bad, ","))
}
func (d *Detector) utilSkew(st *snapshot.State) (Trigger, bool) {
var hot, idle []string
for _, n := range st.Nodes {
if n.CoresTotal < 4 {
continue
}
switch {
case n.Utilization >= 0.8:
hot = append(hot, n.Name)
case n.Utilization <= 0.2:
idle = append(idle, n.Name)
}
}
if len(hot) == 0 || len(idle) == 0 {
return Trigger{}, false
}
key := hashOf("hot=" + join(hot, ",") + ";idle=" + join(idle, ","))
fp := "hot=[" + join(hot, ",") + "] idle=[" + join(idle, ",") + "]"
return d.fire(ScenarioUtilSkew, key, fp)
}
func (d *Detector) exitBurst(st *snapshot.State) (Trigger, bool) {
var ids []string
for _, x := range st.RecentExits {
if x.ExitCode != 0 {
ids = append(ids, x.ID+":"+itoa(x.ExitCode))
}
}
if len(ids) < d.lim.ExitBurstMin {
return Trigger{}, false
}
key := hashOf(join(ids, ","))
return d.fire(ScenarioExitBurst, key, join(ids, ","))
}
// reasonKey builds a stable string from the pending set: job id + reason.
func reasonKey(pend []snapshot.JobState) string {
parts := make([]string, 0, len(pend))
for _, j := range pend {
r := "-"
if j.Reason != nil {
r = j.Reason.MainReasonName
}
parts = append(parts, j.ID+"="+r)
}
return join(parts, "|")
}
func hashOf(s string) string {
sum := sha256.Sum256([]byte(s))
return hex.EncodeToString(sum[:8])
}
func join(parts []string, sep string) string {
out := ""
for i, p := range parts {
if i > 0 {
out += sep
}
out += p
}
return out
}
func itoa(i int) string {
b, _ := json.Marshal(i)
return string(b)
}