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:
@@ -0,0 +1,106 @@
|
|||||||
|
package loop
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// openStore — a real store for the two snooze paths below.
|
||||||
|
func openStore(t *testing.T) *store.Store {
|
||||||
|
t.Helper()
|
||||||
|
st, err := store.Open(context.Background(), t.TempDir()+"/m.db")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open store: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = st.Close() })
|
||||||
|
return st
|
||||||
|
}
|
||||||
|
|
||||||
|
// snoozeRule sends a nudge for rule and snoozes it at ts.
|
||||||
|
func snoozeRule(t *testing.T, st *store.Store, rule string, ts time.Time) {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
id, err := st.RecordNudge(ctx, rule, "voice", "drink water", ts)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RecordNudge: %v", err)
|
||||||
|
}
|
||||||
|
if err := st.ResolveNudge(ctx, id, store.NudgeSnoozed, ts); err != nil {
|
||||||
|
t.Fatalf("ResolveNudge: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The bug in Vikunja #364: the Gatherer used to hard-code SnoozeUntil to nil,
|
||||||
|
// so a snooze the operator asked for never reached the gate and Maven nudged
|
||||||
|
// him again.
|
||||||
|
func TestGathererPopulatesSnoozeUntil(t *testing.T) {
|
||||||
|
st := openStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
now := refTime()
|
||||||
|
|
||||||
|
snoozeAt := now.Add(-15 * time.Minute)
|
||||||
|
snoozeRule(t, st, "water", snoozeAt)
|
||||||
|
// an old snooze on another rule must NOT come back.
|
||||||
|
snoozeRule(t, st, "break", now.Add(-store.SnoozeDuration-time.Hour))
|
||||||
|
|
||||||
|
g := NewGatherer(st, DefaultRules())
|
||||||
|
s, _, err := g.GatherState(ctx, now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GatherState: %v", err)
|
||||||
|
}
|
||||||
|
want := snoozeAt.Add(store.SnoozeDuration)
|
||||||
|
if got, ok := s.SnoozeUntil["water"]; !ok || !got.Equal(want) {
|
||||||
|
t.Fatalf("water snooze-until = %v (present %v), want %v", got, ok, want)
|
||||||
|
}
|
||||||
|
if _, ok := s.SnoozeUntil["break"]; ok {
|
||||||
|
t.Fatalf("expired snooze leaked into the snapshot: %v", s.SnoozeUntil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// and the gate must now actually suppress the snoozed rule.
|
||||||
|
if Gate(s, WaterRule()) {
|
||||||
|
t.Fatal("gate let a snoozed rule fire")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DESIGN.md § User reminders: a reminder bypasses the gate, but "Snooze still
|
||||||
|
// applies."
|
||||||
|
func TestRemindersStillHonourSnooze(t *testing.T) {
|
||||||
|
now := refTime()
|
||||||
|
due := []store.Reminder{{ID: 1, Payload: `{"text":"wake me"}`}}
|
||||||
|
|
||||||
|
// snoozed as a class → held back.
|
||||||
|
s := State{Now: now, SnoozeUntil: map[string]time.Time{
|
||||||
|
ReminderSnoozeKey: now.Add(time.Hour),
|
||||||
|
}}
|
||||||
|
if got := RemindDecisions(s, due); len(got) != 0 {
|
||||||
|
t.Fatalf("snoozed reminder still delivered: %+v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// snoozed by id → that one held back, others still delivered.
|
||||||
|
s = State{Now: now, SnoozeUntil: map[string]time.Time{
|
||||||
|
ReminderSnoozeKeyFor(1): now.Add(time.Hour),
|
||||||
|
}}
|
||||||
|
two := append([]store.Reminder{}, due...)
|
||||||
|
two = append(two, store.Reminder{ID: 2, Payload: `{"text":"call mum"}`})
|
||||||
|
got := RemindDecisions(s, two)
|
||||||
|
if len(got) != 1 || got[0].Reminder.ID != 2 {
|
||||||
|
t.Fatalf("per-id snooze wrong: %+v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// expired snooze → delivered again. silence must never be permanent.
|
||||||
|
s = State{Now: now, SnoozeUntil: map[string]time.Time{
|
||||||
|
ReminderSnoozeKey: now.Add(-time.Minute),
|
||||||
|
}}
|
||||||
|
if got := RemindDecisions(s, due); len(got) != 1 {
|
||||||
|
t.Fatalf("expired snooze still holding the reminder: %+v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// quiet hours, away and calendar-busy must STILL not hold a reminder back
|
||||||
|
// — "wake me 7" is the point.
|
||||||
|
s = State{Now: now, Presence: store.Away, QuietHours: true, CalendarBusy: true}
|
||||||
|
if got := RemindDecisions(s, due); len(got) != 1 {
|
||||||
|
t.Fatalf("reminder must bypass the rest of the gate: %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -119,6 +119,14 @@ func (g *Gatherer) GatherState(ctx context.Context, now time.Time) (State, []sto
|
|||||||
return State{}, nil, err
|
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.
|
// env flags — QuietHours / CalendarBusy as config facts.
|
||||||
// QuietHours: presence != reachability, sleep/quiet-hours handled separately
|
// QuietHours: presence != reachability, sleep/quiet-hours handled separately
|
||||||
// in the gate. We read a config `quiet_hours` fact for the boolean.
|
// 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,
|
PresenceScore: score,
|
||||||
Facts: facts,
|
Facts: facts,
|
||||||
LastNudge: lastNudge,
|
LastNudge: lastNudge,
|
||||||
SnoozeUntil: nil, // no snooze persistence yet — daemon wires in
|
SnoozeUntil: snoozeUntil,
|
||||||
CooldownUntil: cooldownUntil,
|
CooldownUntil: cooldownUntil,
|
||||||
QuietHours: quiet,
|
QuietHours: quiet,
|
||||||
CalendarBusy: calBusy,
|
CalendarBusy: calBusy,
|
||||||
|
|||||||
+35
-6
@@ -1,6 +1,7 @@
|
|||||||
package loop
|
package loop
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/kami/maven/internal/store"
|
"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.
|
// ReminderDecision — a due reminder the daemon should deliver now.
|
||||||
// NOT gated by the universal Gate (per spec: "wake me 7" fires in quiet hours;
|
// 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
|
// that's the point). Snooze is the one part of restraint that still applies.
|
||||||
// snooze-until the gatherer consults; for the scaffold, fired-reminders move
|
|
||||||
// straight to MarkReminder(fired).
|
|
||||||
type ReminderDecision struct {
|
type ReminderDecision struct {
|
||||||
Reminder store.Reminder
|
Reminder store.Reminder
|
||||||
State State
|
State State
|
||||||
}
|
}
|
||||||
|
|
||||||
// RemindDecisions — returns all due reminders (without gating their delivery
|
// ReminderSnoozeKey — the SnoozeUntil key that holds back every due reminder.
|
||||||
// by restraint). Pure: accepts an already-filtered (due) list. The Gatherer
|
// Reminders have no rule name, so they share one key. A snooze aimed at a
|
||||||
// produces that list from `fire_ts <= now AND pending`.
|
// 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 {
|
func RemindDecisions(s State, due []store.Reminder) []ReminderDecision {
|
||||||
out := make([]ReminderDecision, 0, len(due))
|
out := make([]ReminderDecision, 0, len(due))
|
||||||
for _, r := range due {
|
for _, r := range due {
|
||||||
|
if reminderSnoozed(s, r) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
out = append(out, ReminderDecision{Reminder: r, State: s})
|
out = append(out, ReminderDecision{Reminder: r, State: s})
|
||||||
}
|
}
|
||||||
return out
|
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
|
// CooldownFor — helper for the Gatherer: given the active cooldown base
|
||||||
// (the rule's static Base, OR the feedback tuner's persisted tuning) and the
|
// (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.
|
// last send ts, compute the wall-clock "cooldown-until" the gate will check.
|
||||||
|
|||||||
Reference in New Issue
Block a user