loop: let config turn a nudge rule off

The kuma service_down nudge cannot name the service. mavpoll folds the whole
monitor_status gauge into one boolean fact keyed `service_down`, and the
phraser names a service only when the fact key is the service name, so the
message is always the generic "a service on homesrv is down". Every fifteen
minutes, with nothing to act on. A fact per monitor is the real fix and it is
filed as Vikunja #444; this is what to do until then.

`disabled_rules` in mavend.json subtracts from loop.DefaultRules by name.
Config only subtracts — rules stay code, the set stays canonical and ordered
as written. A disabled rule is not gathered for either, since the gatherer
derives its key set from the rules it was given. Unknown names are ignored so
deleting a rule cannot brick a config that still lists it, and the boot log
says what was dropped, because a rule that vanishes silently looks exactly
like a rule that is broken.

Deploy turns service_down off.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TrVSBKe3RFDF4fGYKWYQnX
This commit is contained in:
kami
2026-08-01 20:20:32 +04:00
parent f1a809121b
commit 8fdb9e5cd1
5 changed files with 117 additions and 3 deletions
+14 -2
View File
@@ -236,7 +236,7 @@ func run(args []string) error {
coreFor := func() ipc.CoreAPI { return newIntakeAPI(ipc.NewStoreAPI(st), evBus, time.Now) }
if !locked {
rules = loop.DefaultRules()
rules = wireRules(cfg)
gatherer = loop.NewGatherer(st, rules)
if cfg.QuietHours != nil {
gatherer.SetQuietHours(cfg.QuietHours.Start, cfg.QuietHours.End)
@@ -508,7 +508,7 @@ func run(args []string) error {
}
// Wire everything.
rules = loop.DefaultRules()
rules = wireRules(cfg)
gatherer = loop.NewGatherer(st, rules)
if cfg.QuietHours != nil {
gatherer.SetQuietHours(cfg.QuietHours.Start, cfg.QuietHours.End)
@@ -810,3 +810,15 @@ func waitWorkers(wg *sync.WaitGroup, d time.Duration) bool {
return false
}
}
// wireRules builds the nudge rule set, minus anything config turned off. The
// drop is logged because a rule vanishing silently is indistinguishable from a
// rule that is broken, and the next person to wonder why she stopped nudging
// should find the answer in the boot log.
func wireRules(cfg *config.Config) []loop.Rule {
rules, dropped := loop.RulesExcept(cfg.DisabledRules)
for _, name := range dropped {
log.Printf("loop: rule %q disabled by config", name)
}
return rules
}
+10
View File
@@ -5,6 +5,16 @@
"socket_path": "/run/maven/mavend.sock",
"state_dir": "/var/lib/maven",
"//disabled_rules": [
"Nudge rules that are not wired at all. Names come from loop.DefaultRules:",
"water, meal, break, service_down, netdata_critical.",
"service_down is off because it cannot say WHICH service — mavpoll folds the",
"whole kuma gauge into one boolean, so the nudge is always the generic 'a",
"service on homesrv is down'. Nothing to act on, every fifteen minutes.",
"Turn it back on once Vikunja #444 lands a fact per monitor."
],
"disabled_rules": ["service_down"],
"phraser": {
"model_path": "/opt/maven/models/llm/qwen3/Qwen3-1.7B-UD-Q4_K_XL.gguf",
"bin_path": "llama-server",
+16
View File
@@ -144,6 +144,22 @@ type Config struct {
// nil ⇒ quiet hours only activate via the voice toggle.
QuietHours *QuietHoursConfig `json:"quiet_hours,omitempty"`
// DisabledRules — nudge rules that are not wired at all, by name
// ("service_down", "netdata_critical", "water", "meal", "break").
//
// Rules are code, not config (see loop.DefaultRules), and that stays true:
// this only subtracts. It exists because a rule can be right in principle
// and useless in practice — kuma's service_down cannot name the service it
// is nudging about (Vikunja #444), so being told "a service on homesrv is
// down" every fifteen minutes is noise with no action attached. Turning it
// off beats learning to ignore her.
//
// A disabled rule is never gathered for, never evaluated, and never
// delivered on any channel. Unknown names are ignored, so removing a rule
// from the code does not break a config that still lists it.
// Empty ⇒ every rule runs, which is the default.
DisabledRules []string `json:"disabled_rules,omitempty"`
// Digest — notification batching / digest mode. nil ⇒ digest disabled
// (every nudge is sent as it fires — legacy behaviour).
Digest *DigestConfig `json:"digest,omitempty"`
+35 -1
View File
@@ -1,6 +1,9 @@
package loop
import "time"
import (
"strings"
"time"
)
// Rule — a proactive rule. Rules are CODE, not a DSL config — until ~30 rules
// and you feel the pain (per spec). A Rule has a name (ids it in nudges.outcome
@@ -137,6 +140,37 @@ func NetdataCriticalRule() Rule {
}
}
// RulesExcept returns DefaultRules minus the named ones (config's
// `disabled_rules`). Config subtracts from the canonical set; it never adds to
// it and never reorders it, so the "rules are code" line above still holds.
//
// Names are matched exactly and an unknown one is ignored, on purpose: a
// config that still lists a rule someone deleted must not stop the daemon from
// booting. The logging of what was actually dropped belongs to the caller,
// which knows whether anyone is listening.
func RulesExcept(disabled []string) ([]Rule, []string) {
all := DefaultRules()
if len(disabled) == 0 {
return all, nil
}
off := make(map[string]bool, len(disabled))
for _, n := range disabled {
if n = strings.TrimSpace(n); n != "" {
off[n] = true
}
}
out := make([]Rule, 0, len(all))
var dropped []string
for _, r := range all {
if off[r.Name] {
dropped = append(dropped, r.Name)
continue
}
out = append(out, r)
}
return out, dropped
}
// DefaultRules — the canonical set the daemon wires. Add more as code, not config.
// Order here is NOT load-bearing — the loop picks max severity, ties broken by
// (severity desc, name asc) for deterministic output.
+42
View File
@@ -392,3 +392,45 @@ func TestPredicatesArePure(t *testing.T) {
}
}
}
func ruleNames(rs []Rule) []string {
out := make([]string, len(rs))
for i, r := range rs {
out[i] = r.Name
}
return out
}
func TestRulesExceptDropsOnlyTheNamed(t *testing.T) {
rules, dropped := RulesExcept([]string{"service_down"})
if len(rules) != len(DefaultRules())-1 {
t.Fatalf("got %v", ruleNames(rules))
}
for _, r := range rules {
if r.Name == "service_down" {
t.Error("a disabled rule was still wired")
}
}
if len(dropped) != 1 || dropped[0] != "service_down" {
t.Errorf("dropped = %v", dropped)
}
}
// A config that names a rule nobody wrote must not stop the daemon booting,
// and must not quietly drop a real rule alongside it.
func TestRulesExceptIgnoresUnknownNames(t *testing.T) {
rules, dropped := RulesExcept([]string{"no_such_rule", " ", ""})
if len(rules) != len(DefaultRules()) {
t.Errorf("an unknown name removed something: %v", ruleNames(rules))
}
if len(dropped) != 0 {
t.Errorf("dropped = %v, want nothing", dropped)
}
}
func TestRulesExceptEmptyKeepsEverything(t *testing.T) {
rules, dropped := RulesExcept(nil)
if len(rules) != len(DefaultRules()) || dropped != nil {
t.Errorf("rules = %v, dropped = %v", ruleNames(rules), dropped)
}
}