Files
claude 4f516657da loop, store: read a fact family by prefix (V-444)
A rule over a key set that only exists at read time cannot declare its keys
at wiring time. Kuma has one monitor per service and the names live in the
gauge, so the rule declares a prefix and the gatherer resolves the family per
tick.

ServiceDownRule now fires on any monitor reading down, names it through
DownServices, and is edge-triggered: a service that stays down is one nudge,
not one per tick with cooldown as the only brake.
2026-08-04 05:14:50 +04:00

138 lines
5.3 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package loop is maven's proactive trigger engine.
//
// Loop contract (from spec):
//
// - ticks ~60s. no llm. 99% of ticks evaluate a few predicates and die for free.
// - a predicate is `(State) -> Bool`, PURE, no i/o → unit-testable with a fake State.
// - since(key)==null → don't fire. silence on no-data = "shuts up when uncertain".
// - the GATE is universal, applied by the loop, never per-rule. quiet-hours,
// presence, cooldown, snooze, calendar-busy all live in ONE fires(). cross-
// cutting restraint in one place or it drifts.
// - ONE nudge per tick (max severity), never dogpile.
// - rules as code, not a DSL. revisit at ~30 rules.
//
// Reminders are a SEPARATE class — reuses the loop, NOT a second scheduler:
// - predicate: `fire_ts <= now AND pending`
// - BYPASSES the restraint gate — "wake me 7" fires in quiet hours; that's the point
// - snooze still applies; fires once (pending → fired)
//
// Architecture: the Gatherer is the only impure bit — it builds a State snapshot
// under the store lock. Everything from there on is PURE functions over State.
// Phrasing (the llm lane) and delivery are out of scope here — the loop emits
// Decisions; the daemon wires them to phrasing + delivery.
package loop
import (
"sort"
"strings"
"time"
"github.com/kami/maven/internal/store"
)
// Severity — higher = more insistent (harder to suppress).
//
// Per the spec's delivery table:
//
// sev12: care nudges (water, meal, break). voice when present; DROP on away.
// sev3: ops soft (backup failed, cert soon). voice + once over ntfy when away.
// sev4: ops hard (disk critical, service down). voice + ntfy present;
// telegram, repeat til ack, when away.
//
// "Max severity" in one-nudge-per-tick therefore means: the loudest/most insistent
// candidate wins — a disk-full nudge (sev4) preempts a water nudge (sev1).
type Severity int
const (
Sev1 Severity = 1 // care, lowest insistence — drops on away
Sev2 Severity = 2
Sev3 Severity = 3 // ops soft
Sev4 Severity = 4 // ops hard, highest insistence
)
func (s Severity) IsCare() bool { return s <= Sev2 } // suppressed by quiet hours + away
// State — the loop's view of the world, gathered under the store lock at the
// start of each tick. From here on everything is pure — predicates and the gate
// read ONLY this struct and never touch the store.
//
// Keys in Facts/LastNudge/SnoozeUntil/CooldownUntil are rule-specific lookups
// the rules + gate have pre-arranged. Presence/PresenceScore/Now are global.
// The Gatherer decides what to populate; rules see what's in the snapshot.
type State struct {
Now time.Time
Presence store.Bucket
PresenceScore float64
// Latest non-voided fact per key the loop requested at gather time.
// Missing key (or zero Value Fact with Ts.IsZero) ⇒ since(key)==null ⇒ don't fire.
Facts map[string]store.Fact
// Last nudge per rule, for cooldown enforcement (newest send).
LastNudge map[string]store.Nudge
// Per-rule snooze-until — explicit "don't bother me about this rule until X".
// Overlays on top of cooldown; both must be clear to fire.
SnoozeUntil map[string]time.Time
// Per-rule cooldown-until — derived from LastNudge + cooldown duration
// (the auto-tuner adjusts the duration via feedback outcomes).
CooldownUntil map[string]time.Time
// Cross-cutting env flags derived from facts at gather time:
// QuietHours — the care gate suppresses sev12 when true. ops still surface.
QuietHours bool
// CalendarBusy — "don't nag mid-meeting". acts as an env predicate in the gate.
CalendarBusy bool
}
// Fact returns the latest non-voided fact for key, or
// (store.Fact{}, false) — the predicate's "no data" case.
// since(key)==null → don't fire is implemented by checking the bool.
func (s State) Fact(key string) (store.Fact, bool) {
f, ok := s.Facts[key]
if !ok || f.Ts.IsZero() {
return store.Fact{}, false
}
return f, true
}
// FactsUnder returns every gathered fact whose key starts with prefix, ordered
// by key so a caller that names them speaks them in a stable order. Facts with
// a zero Ts are skipped, the same "no data" rule Fact applies.
func (s State) FactsUnder(prefix string) []store.Fact {
var out []store.Fact
for k, f := range s.Facts {
if strings.HasPrefix(k, prefix) && !f.Ts.IsZero() {
out = append(out, f)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].Key < out[j].Key })
return out
}
// NudgedSince reports whether rule already sent a nudge at or after ts.
//
// It is what makes a rule edge-triggered. A polled fact is written only when
// the value changes, so its Ts is the moment the service went down — but the
// predicate reads the current value, so a service that stays down keeps
// qualifying forever and cooldown alone only slows the repetition. Asking
// whether he was already told about THIS transition stops it.
func (s State) NudgedSince(rule string, ts time.Time) bool {
n, ok := s.LastNudge[rule]
return ok && !n.Ts.Before(ts)
}
// Since returns the duration since the latest fact for key, or (0,false).
// "false" ⇒ no data ⇒ shuts up when uncertain.
func (s State) Since(key string) (time.Duration, bool) {
f, ok := s.Fact(key)
if !ok {
return 0, false
}
if s.Now.Before(f.Ts) {
return 0, true
}
return s.Now.Sub(f.Ts), true
}