2f00593411
The Gatherer now fills State.SnoozeUntil from store.SnoozedUntil instead of nil, so a snooze finally reaches the gate. RemindDecisions gains the one restraint check that applies to a reminder — quiet hours, presence and cooldown are still bypassed, so "wake me 7" is unchanged. Reviewer: the two tests in internal/loop/gate_test.go are the contract. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CGeSZxh1DCtRxmFVSYVGvJ
168 lines
5.8 KiB
Go
168 lines
5.8 KiB
Go
package loop
|
||
|
||
import (
|
||
"fmt"
|
||
"time"
|
||
|
||
"github.com/kami/maven/internal/store"
|
||
)
|
||
|
||
// Gate — 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.
|
||
//
|
||
// The gate does NOT itself decide "should this rule run" — the Rule.Predicate
|
||
// does. The gate answers "is it ALLOWED to fire right NOW" given the snapshot.
|
||
// Suppression-context ("don't nag mid-meeting") moves INTO the gate as an env
|
||
// predicate, not the LLM's job — same boundary as "rules decide, llm phrases."
|
||
//
|
||
// Gate is pure. no I/O. reads State + Rule only.
|
||
func Gate(s State, r Rule) bool {
|
||
now := s.Now
|
||
|
||
// snooze — per-rule "leave me alone until X". overrides everything below.
|
||
if until, ok := s.SnoozeUntil[r.Name]; ok && now.Before(until) {
|
||
return false
|
||
}
|
||
|
||
// cooldown — most recent same-rule nudge + base/feedback-tuned duration.
|
||
// Persisted as `facts (source=feedback)`; Gatherer rolls it into CooldownUntil.
|
||
if until, ok := s.CooldownUntil[r.Name]; ok && now.Before(until) {
|
||
return false
|
||
}
|
||
|
||
// quiet hours — care nudges (sev1–2) shut up. ops (sev ≥3) still surface
|
||
// (a failed backup at 2am genuinely matters and maven routes to telegram).
|
||
if s.QuietHours && r.Severity.IsCare() {
|
||
return false
|
||
}
|
||
|
||
// calendar busy — "don't nag mid-meeting" lifted INTO the gate as an env
|
||
// predicate; not the LLM's call.
|
||
if s.CalendarBusy && r.Severity.IsCare() {
|
||
return false
|
||
}
|
||
|
||
// presence — sev1–2 DROP on away (missed water nudge is noise).
|
||
// sev ≥3 HOLDS — see the delivery channel-routing table in the spec.
|
||
// (the loop doesn't pick the channel; it just decides whether to emit.)
|
||
if s.Presence == store.Away && r.Severity.IsCare() {
|
||
return false
|
||
}
|
||
|
||
// no-data inertness — since(key)==null → don't fire. shut up when uncertain.
|
||
// The predicate MAY have encoded this itself; the gate enforces it for any
|
||
// rule that declared InertWhenNoData keys.
|
||
for _, k := range r.InertWhenNoData {
|
||
if _, ok := s.Fact(k); !ok {
|
||
return false
|
||
}
|
||
}
|
||
|
||
return true
|
||
}
|
||
|
||
// Candidate — a rule that Wants (predicate true) AND Is Allowed (gate true).
|
||
// The loop picks one per tick (max severity).
|
||
type Candidate struct {
|
||
Rule Rule
|
||
Severity Severity
|
||
State State // snapshot at evaluation time — for the phraser's context
|
||
}
|
||
|
||
// Tick — PURE. Evaluates the configured rules against the snapshot, returns
|
||
// AT MOST one proactive candidate (max severity, with a deterministic tie-break).
|
||
// Returns nil when nothing fires ("shuts up" is the default outcome of a tick).
|
||
//
|
||
// Phrasing + sending happen OUT of the loop — the daemon hands Candidate to
|
||
// the phraser (the resident model, Qwen3-1.7B) and delivery module. The loop
|
||
// just decides.
|
||
//
|
||
// Reminders are NOT handled here — they're a separate, gate-bypassing class.
|
||
// See DueReminders (gathered separately) and RemindDecisions (the loop output
|
||
// flag for the daemon).
|
||
func Tick(s State, rules []Rule) *Candidate {
|
||
var fire *Candidate
|
||
for _, r := range rules {
|
||
if !r.Predicate(s) {
|
||
continue // rule doesn't want to fire — skip gate entirely (cheap path)
|
||
}
|
||
if !Gate(s, r) {
|
||
continue // wanted but suppressed this tick
|
||
}
|
||
c := Candidate{Rule: r, Severity: r.Severity, State: s}
|
||
if fire == nil {
|
||
fire = &c
|
||
continue
|
||
}
|
||
// max severity wins; tie-break: severity desc, then name asc for determinism.
|
||
if c.Severity > fire.Severity ||
|
||
(c.Severity == fire.Severity && c.Rule.Name < fire.Rule.Name) {
|
||
fire = &c
|
||
}
|
||
}
|
||
return fire
|
||
}
|
||
|
||
// ReminderDecision — a due reminder the daemon should deliver now.
|
||
// NOT gated by the universal Gate (per spec: "wake me 7" fires in quiet hours;
|
||
// that's the point). Snooze is the one part of restraint that still applies.
|
||
type ReminderDecision struct {
|
||
Reminder store.Reminder
|
||
State State
|
||
}
|
||
|
||
// ReminderSnoozeKey — the SnoozeUntil key that holds back every due reminder.
|
||
// Reminders have no rule name, so they share one key. A snooze aimed at a
|
||
// single reminder uses ReminderSnoozeKeyFor instead.
|
||
const ReminderSnoozeKey = "reminder"
|
||
|
||
// ReminderSnoozeKeyFor — the SnoozeUntil key for one reminder by id.
|
||
func ReminderSnoozeKeyFor(id int64) string {
|
||
return fmt.Sprintf("%s:%d", ReminderSnoozeKey, id)
|
||
}
|
||
|
||
// RemindDecisions — returns the due reminders the daemon should deliver.
|
||
// Pure: accepts an already-filtered (due) list. The Gatherer produces that
|
||
// list from `fire_ts <= now AND pending`.
|
||
//
|
||
// Quiet hours, presence and cooldown are deliberately NOT consulted — a
|
||
// reminder must wake you at 7 even in the middle of quiet hours. Only snooze
|
||
// holds one back. A held reminder stays pending, so it comes back once the
|
||
// snooze runs out.
|
||
func RemindDecisions(s State, due []store.Reminder) []ReminderDecision {
|
||
out := make([]ReminderDecision, 0, len(due))
|
||
for _, r := range due {
|
||
if reminderSnoozed(s, r) {
|
||
continue
|
||
}
|
||
out = append(out, ReminderDecision{Reminder: r, State: s})
|
||
}
|
||
return out
|
||
}
|
||
|
||
// reminderSnoozed — true when a snooze on this reminder, or on reminders as a
|
||
// class, is still running.
|
||
func reminderSnoozed(s State, r store.Reminder) bool {
|
||
keys := []string{ReminderSnoozeKey, ReminderSnoozeKeyFor(r.ID)}
|
||
for _, k := range keys {
|
||
if until, ok := s.SnoozeUntil[k]; ok && s.Now.Before(until) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// CooldownFor — helper for the Gatherer: given the active cooldown base
|
||
// (the rule's static Base, OR the feedback tuner's persisted tuning) and the
|
||
// last send ts, compute the wall-clock "cooldown-until" the gate will check.
|
||
// Pure. The auto-tuner writes the base the gatherer reads as a feedback
|
||
// fact; this function just adds it to the last send.
|
||
func CooldownFor(base time.Duration, lastSend time.Time) time.Time {
|
||
if lastSend.IsZero() {
|
||
return time.Time{} // never sent → no cooldown active
|
||
}
|
||
return lastSend.Add(base)
|
||
}
|