service_down nudges name the service again (V-534)
nudgeValues filled {service} from State.Fact("service_down"), an exact key
mavpoll stopped writing when per-monitor facts landed. The lookup could never
hit, so every variant carrying {service} was rejected as unfillable and the one
nameless variant was the only usable template, every time. A sev4 reaching him
on telegram said only that a service was down.
It now reads loop.DownServices, the same helper the rule fires on, so the
message cannot name a service that is up. Dropped the nameless variant and the
{since} one: service_down facts are keyed by monitor and the rule is
edge-triggered, so neither can fill. service_down joins routine and morning as
a family that always carries a name.
The tests passed through all of this because cand() built the pre-per-monitor
aggregate shape. downCand() builds what a tick actually produces.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011x5DgnExQ5XZy8TZPs5bot
This commit is contained in:
@@ -11,6 +11,63 @@ import (
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
// downCand builds a service_down candidate the way a tick actually does it:
|
||||
// one fact per kuma monitor under the prefix, carrying the source and value
|
||||
// loop.DownServices checks. The old cand() shape wrote a single fact keyed
|
||||
// plain "service_down", which mavpoll stopped producing, and that is why the
|
||||
// tests passed through the whole of #534.
|
||||
func downCand(names ...string) loop.Candidate {
|
||||
now := time.Date(2026, 7, 31, 21, 40, 0, 0, time.UTC)
|
||||
st := loop.State{Now: now, Facts: map[string]store.Fact{}}
|
||||
for _, n := range names {
|
||||
key := loop.ServiceDownPrefix + n
|
||||
st.Facts[key] = store.Fact{
|
||||
Key: key, Ts: now.Add(-3 * time.Minute),
|
||||
Source: loop.ServiceDownSource, Value: `"down"`,
|
||||
}
|
||||
}
|
||||
return loop.Candidate{
|
||||
Rule: loop.Rule{Name: "service_down", Severity: loop.Sev4},
|
||||
Severity: loop.Sev4, State: st,
|
||||
}
|
||||
}
|
||||
|
||||
// The nudge he reads on telegram must name what broke. It is a sev4 that
|
||||
// reaches him away from the box, so "a service is down" costs him a trip to
|
||||
// kuma to learn anything at all.
|
||||
func TestNudgeNamesTheDownService(t *testing.T) {
|
||||
// Lowercased before matching: a name that opens the sentence is
|
||||
// capitalized by capitalizeFirst, which is wanted.
|
||||
nt := newTestTemplates(t, 5)
|
||||
for i := 0; i < 40; i++ {
|
||||
body, _ := nt.Nudge(downCand("paperless"))
|
||||
if !strings.Contains(strings.ToLower(body), "paperless") {
|
||||
t.Fatalf("body does not name the service: %q", body)
|
||||
}
|
||||
}
|
||||
// Two down: both named, in the key order the rule itself uses.
|
||||
for i := 0; i < 40; i++ {
|
||||
body, _ := nt.Nudge(downCand("nginx", "paperless"))
|
||||
low := strings.ToLower(body)
|
||||
if !strings.Contains(low, "nginx") || !strings.Contains(low, "paperless") {
|
||||
t.Fatalf("body drops a service: %q", body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Nothing down means no template fits, and the fallback answers rather than
|
||||
// the picker inventing a name.
|
||||
func TestNudgeServiceDownWithoutFacts(t *testing.T) {
|
||||
nt := newTestTemplates(t, 5)
|
||||
body, mood := nt.Nudge(downCand())
|
||||
if body != "Сервис не отвечает." {
|
||||
t.Fatalf("fallback body %q", body)
|
||||
}
|
||||
if mood != "neutral" {
|
||||
t.Fatalf("mood %q", mood)
|
||||
}
|
||||
}
|
||||
|
||||
// cand builds a candidate the way a tick would.
|
||||
func cand(rule string, sinceMin int, factKey string) loop.Candidate {
|
||||
now := time.Date(2026, 7, 31, 21, 40, 0, 0, time.UTC)
|
||||
@@ -46,8 +103,12 @@ func TestNudgeTemplatesLoad(t *testing.T) {
|
||||
t.Errorf("%s: only %d variants", rule, len(set.Variants))
|
||||
}
|
||||
// Every rule needs one variant that needs no value, or a candidate
|
||||
// without context has nothing to say. routine and morning are exempt:
|
||||
// they always carry a name and must always say it.
|
||||
// without context has nothing to say. routine, morning and
|
||||
// service_down are exempt: they always carry a name and must always
|
||||
// say it. service_down's predicate cannot fire without a down fact,
|
||||
// so loop.DownServices always has something to fill {service} with,
|
||||
// and the nameless variant it used to carry was the bug (#534) —
|
||||
// {service} never filled, so that variant was the only fillable one.
|
||||
plain := 0
|
||||
seen := map[string]bool{}
|
||||
for _, v := range set.Variants {
|
||||
@@ -59,7 +120,7 @@ func TestNudgeTemplatesLoad(t *testing.T) {
|
||||
}
|
||||
seen[v] = true
|
||||
}
|
||||
if plain == 0 && rule != "routine" && rule != "morning" {
|
||||
if plain == 0 && rule != "routine" && rule != "morning" && rule != "service_down" {
|
||||
t.Errorf("%s: every variant needs a placeholder value", rule)
|
||||
}
|
||||
}
|
||||
@@ -102,8 +163,8 @@ func TestNudgeNoLeftoverPlaceholders(t *testing.T) {
|
||||
cand("water", 0, ""), // no duration
|
||||
cand("water", 30, ""), // under an hour
|
||||
cand("water", 200, ""), // hours
|
||||
cand("service_down", 3, "vaultwarden"),
|
||||
cand("service_down", 3, ""), // no service name
|
||||
downCand("vaultwarden"),
|
||||
downCand(), // nothing down: the fallback answers
|
||||
cand("routine:таблетки", 0, ""),
|
||||
cand("morning:утро", 0, ""),
|
||||
cand("unknown_rule", 0, ""),
|
||||
|
||||
Reference in New Issue
Block a user