Files
Maven/internal/loop/loop.go
T
kami 2f00593411 Wire the snooze read into the Gatherer and honour it for reminders (#364)
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
2026-07-31 02:32:24 +04:00

168 lines
5.8 KiB
Go
Raw 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
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 (sev12) 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 — sev12 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)
}