tick: a disabled rule must not keep repeating its last alarm

`disabled_rules` stopped the loop from creating new nudges and did nothing
about the ones already sent. The sev4 repeat path does not consult the rule
set at all: RepeatUnacked re-sends any telegram nudge still at outcome=pending
every repeat_interval (5m by default), driven by store.UnackedTelegramRules.
So service_down kept arriving on a five-minute cadence after being switched
off, from a row written hours earlier — two messages after the deploy, which
is how it was found.

That cadence, not the unsealed database, is what "she keeps spamming me"
always was. The seal bug erased the acks that would have stopped it.

Filter the repeat keys against the wired rule set. Filtering on wired rather
than on the disabled list also silences a rule deleted from the code: nothing
can ack what the UI no longer lists.

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:43:27 +04:00
parent 8fdb9e5cd1
commit 47dda97226
2 changed files with 70 additions and 0 deletions
+40
View File
@@ -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
}