diff --git a/cmd/mavend/tick.go b/cmd/mavend/tick.go index 922e8a0..7135d56 100644 --- a/cmd/mavend/tick.go +++ b/cmd/mavend/tick.go @@ -250,6 +250,7 @@ func (t *tickLoop) tick(ctx context.Context, now time.Time) { log.Printf("tick: unacked telegram rules: %v", err) return } + keys = t.repeatableRules(keys) if len(keys) == 0 { return } @@ -261,6 +262,35 @@ func (t *tickLoop) tick(ctx context.Context, now time.Time) { } } +// repeatableRules drops keys whose rule is not wired any more. +// +// The repeat path reads the nudges table, not the rule set: any sev4 telegram +// row still at outcome=pending is re-sent every repeat_interval until it is +// acked. So turning a rule off in `disabled_rules` silenced new nudges and left +// the last un-acked one re-sending every five minutes, forever — a knob that +// stops the cause and not the symptom is worse than no knob. Found the evening +// of 2026-08-01, two messages after the rule was supposedly off. +// +// Filtering on the wired set rather than on the disabled list also covers the +// rule that was deleted from the code entirely: its orphan rows go quiet +// instead of nagging about a rule nobody can ack from the UI any more. +func (t *tickLoop) repeatableRules(keys []string) []string { + if len(keys) == 0 { + return nil + } + wired := make(map[string]bool, len(t.rules)) + for _, r := range t.rules { + wired[r.Name] = true + } + out := keys[:0:0] + for _, k := range keys { + if wired[k] { + out = append(out, k) + } + } + return out +} + // cachePhrase keeps the latest phrased nudge per rule for the sev4-repeat // path. writing under a mutex; the repeat path reads under the same. the // cache is bounded by the rule count (≤ ~30 per spec) so eviction is not a diff --git a/cmd/mavend/tick_test.go b/cmd/mavend/tick_test.go index 69dfcbd..7290ece 100644 --- a/cmd/mavend/tick_test.go +++ b/cmd/mavend/tick_test.go @@ -667,3 +667,43 @@ func TestDigestDeduplicatesByRule(t *testing.T) { t.Fatalf("after duplicate queue attempt: digestQ = %d, want 1 (dedup)", len(tl.digestQ)) } } + +// The repeat path reads the nudges table, not the rule set, so a rule turned +// off in `disabled_rules` used to keep re-sending its last un-acked telegram +// nudge every repeat_interval. Two arrived after the rule was off on +// 2026-08-01. A disabled rule must be unreachable on every path. +func TestRepeatableRulesDropsDisabledRules(t *testing.T) { + tl := &tickLoop{rules: mustRules(t, []string{"service_down"})} + got := tl.repeatableRules([]string{"service_down", "water"}) + if len(got) != 1 || got[0] != "water" { + t.Fatalf("repeatableRules = %v, want [water]", got) + } +} + +// An orphan row for a rule that no longer exists in the code goes quiet too: +// nothing can ack what the UI cannot show. +func TestRepeatableRulesDropsUnknownRules(t *testing.T) { + tl := &tickLoop{rules: loop.DefaultRules()} + if got := tl.repeatableRules([]string{"rule_deleted_last_year"}); len(got) != 0 { + t.Fatalf("repeatableRules = %v, want none", got) + } +} + +func TestRepeatableRulesKeepsWiredRules(t *testing.T) { + tl := &tickLoop{rules: loop.DefaultRules()} + got := tl.repeatableRules([]string{"service_down", "water"}) + if len(got) != 2 { + t.Fatalf("repeatableRules = %v, want both", got) + } +} + +// mustRules returns DefaultRules minus the named ones, failing if a name +// matched nothing — a typo here would make the test pass for the wrong reason. +func mustRules(t *testing.T, disabled []string) []loop.Rule { + t.Helper() + rules, dropped := loop.RulesExcept(disabled) + if len(dropped) != len(disabled) { + t.Fatalf("dropped %v, want %v", dropped, disabled) + } + return rules +}