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
This commit is contained in:
@@ -273,7 +273,6 @@ func TestRemindersBypassEverySuppressor(t *testing.T) {
|
||||
// passes every due reminder straight through with no snooze check, so a snoozed
|
||||
// reminder fires anyway. The test below is what the contract asks for.
|
||||
func TestRemindersStillHonourSnooze(t *testing.T) {
|
||||
t.Skip("snooze is not applied to reminders — RemindDecisions ignores SnoozeUntil, internal/loop/loop.go:120")
|
||||
|
||||
now := refTime()
|
||||
s := State{
|
||||
@@ -292,7 +291,6 @@ func TestRemindersStillHonourSnooze(t *testing.T) {
|
||||
// unit tests above pass while nothing can ever populate the map. This asserts
|
||||
// the Gatherer actually produces a snooze map.
|
||||
func TestGathererPopulatesSnoozeUntil(t *testing.T) {
|
||||
t.Skip("Gatherer never populates SnoozeUntil, so snooze cannot suppress anything at runtime, internal/loop/gather.go:153")
|
||||
|
||||
ctx := context.Background()
|
||||
st, err := store.Open(ctx, t.TempDir()+"/m.db")
|
||||
|
||||
@@ -119,6 +119,14 @@ func (g *Gatherer) GatherState(ctx context.Context, now time.Time) (State, []sto
|
||||
return State{}, nil, err
|
||||
}
|
||||
|
||||
// live snoozes — "leave me alone until X", per rule. The `snoozed` outcome
|
||||
// on the nudges table is the whole record; the store turns it into an
|
||||
// expiry. Absent rules mean "not snoozed", which is what the gate reads.
|
||||
snoozeUntil, err := g.store.SnoozedUntil(ctx, now)
|
||||
if err != nil {
|
||||
return State{}, nil, err
|
||||
}
|
||||
|
||||
// env flags — QuietHours / CalendarBusy as config facts.
|
||||
// QuietHours: presence != reachability, sleep/quiet-hours handled separately
|
||||
// in the gate. We read a config `quiet_hours` fact for the boolean.
|
||||
@@ -150,7 +158,7 @@ func (g *Gatherer) GatherState(ctx context.Context, now time.Time) (State, []sto
|
||||
PresenceScore: score,
|
||||
Facts: facts,
|
||||
LastNudge: lastNudge,
|
||||
SnoozeUntil: nil, // no snooze persistence yet — daemon wires in
|
||||
SnoozeUntil: snoozeUntil,
|
||||
CooldownUntil: cooldownUntil,
|
||||
QuietHours: quiet,
|
||||
CalendarBusy: calBusy,
|
||||
|
||||
+35
-6
@@ -1,6 +1,7 @@
|
||||
package loop
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/store"
|
||||
@@ -106,25 +107,53 @@ func Tick(s State, rules []Rule) *Candidate {
|
||||
|
||||
// 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 still applies — represented by a separate
|
||||
// snooze-until the gatherer consults; for the scaffold, fired-reminders move
|
||||
// straight to MarkReminder(fired).
|
||||
// that's the point). Snooze is the one part of restraint that still applies.
|
||||
type ReminderDecision struct {
|
||||
Reminder store.Reminder
|
||||
State State
|
||||
}
|
||||
|
||||
// RemindDecisions — returns all due reminders (without gating their delivery
|
||||
// by restraint). Pure: accepts an already-filtered (due) list. The Gatherer
|
||||
// produces that list from `fire_ts <= now AND pending`.
|
||||
// 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.
|
||||
|
||||
Reference in New Issue
Block a user