From 32687b37122e71cc73bcca7aaf08832a478a8228 Mon Sep 17 00:00:00 2001 From: kami Date: Fri, 31 Jul 2026 02:29:51 +0400 Subject: [PATCH 1/2] Read the recorded snooze outcomes back out of the nudges table (#364) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gate honours State.SnoozeUntil but nothing ever filled it. New store.SnoozedUntil returns, per rule, when the newest snooze runs out. Reviewer: the fixed 2h SnoozeDuration and its reasoning in nudges.go — nothing upstream can supply a per-nudge length, so no new column. Expired snoozes are dropped in SQL, so silence can never be permanent. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CGeSZxh1DCtRxmFVSYVGvJ --- internal/store/migrations.go | 2 + internal/store/nudges.go | 45 ++++++++++++ internal/store/nudges_snooze_test.go | 104 +++++++++++++++++++++++++++ 3 files changed, 151 insertions(+) create mode 100644 internal/store/nudges_snooze_test.go diff --git a/internal/store/migrations.go b/internal/store/migrations.go index c19d52f..325dd4d 100644 --- a/internal/store/migrations.go +++ b/internal/store/migrations.go @@ -70,6 +70,8 @@ ALTER TABLE reminders ADD COLUMN next_fire_ts INTEGER;`, // #2 CHECK (resolution_state IN ('none','pending','resolved','ambiguous','not_found')); CREATE INDEX IF NOT EXISTS idx_facts_entity_id ON facts (entity_id) WHERE entity_id IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_facts_resolution_pending ON facts (resolution_state) WHERE resolution_state = 'pending';`, // #7 — entity-aware memory (Vikunja #279): facts about a subject get resolved to a Nexus entity_id async + + `CREATE INDEX IF NOT EXISTS idx_nudges_snoozed ON nudges (outcome_ts) WHERE outcome = 'snoozed';`, // #8 — SnoozedUntil runs every tick; keep it off a full scan (Vikunja #364) } // migrate applies every migration with a number greater than the DB's current diff --git a/internal/store/nudges.go b/internal/store/nudges.go index 2a0db37..a295050 100644 --- a/internal/store/nudges.go +++ b/internal/store/nudges.go @@ -28,6 +28,20 @@ const ( NudgeIgnored = "ignored" ) +// SnoozeDuration — how long one `snoozed` outcome keeps its rule quiet. +// +// The nudges table records THAT a snooze happened and when, never for how +// long: nothing upstream can supply a length. ResolveNudge takes only +// (id, outcome, ts), and so do the IPC method and the web/telegram callers +// behind it. So a fixed default it is, rather than a new column no writer +// could fill. +// +// Two hours: longer than every rule's base cooldown (15–60m) so a snooze +// actually buys quiet instead of being swallowed by the cooldown, and short +// enough that a snooze the operator forgets about clears the same day. A +// snooze can never outlive this window, so Maven cannot go quiet forever. +const SnoozeDuration = 2 * time.Hour + var ( ErrNudgeNotFound = errors.New("store: nudge not found") ErrNudgeOutcome = errors.New("store: nudge already resolved") @@ -138,6 +152,37 @@ func (s *Store) UnackedTelegramRules(ctx context.Context) ([]string, error) { return out, rows.Err() } +// SnoozedUntil — per rule, when its most recent snooze runs out. This is the +// read behind the gate's snooze check: the `snoozed` outcome already in the +// nudges table IS the restraint memory, so there is no snooze table. +// +// Rules with no live snooze are absent from the map, which is what the gate +// wants (a missing key means "not snoozed"). Expired snoozes are filtered out +// in SQL, so an old snooze can never come back as a silent forever-mute. +// +// Called every tick (~60s). One indexed lookup over the snoozed rows only. +func (s *Store) SnoozedUntil(ctx context.Context, now time.Time) (map[string]time.Time, error) { + cutoff := now.Add(-SnoozeDuration).UnixMilli() + rows, err := s.db.QueryContext(ctx, + `SELECT rule, MAX(outcome_ts) FROM nudges + WHERE outcome = 'snoozed' AND outcome_ts > ? + GROUP BY rule`, cutoff) + if err != nil { + return nil, fmt.Errorf("snoozed until: %w", err) + } + defer rows.Close() + out := make(map[string]time.Time) + for rows.Next() { + var rule string + var tsMilli int64 + if err := rows.Scan(&rule, &tsMilli); err != nil { + return nil, err + } + out[rule] = time.UnixMilli(tsMilli).UTC().Add(SnoozeDuration) + } + return out, rows.Err() +} + // RecentNudges — the newest n nudges across all rules, with outcomes, for the // monitoring dash. Newest first. func (s *Store) RecentNudges(ctx context.Context, n int) ([]Nudge, error) { diff --git a/internal/store/nudges_snooze_test.go b/internal/store/nudges_snooze_test.go new file mode 100644 index 0000000..b9321ea --- /dev/null +++ b/internal/store/nudges_snooze_test.go @@ -0,0 +1,104 @@ +package store + +import ( + "context" + "testing" + "time" +) + +// snoozeNudge records a nudge and immediately snoozes it at ts. +func snoozeNudge(t *testing.T, s *Store, rule string, ts time.Time) { + t.Helper() + ctx := context.Background() + id, err := s.RecordNudge(ctx, rule, "voice", "drink water", ts) + if err != nil { + t.Fatalf("RecordNudge: %v", err) + } + if err := s.ResolveNudge(ctx, id, NudgeSnoozed, ts); err != nil { + t.Fatalf("ResolveNudge: %v", err) + } +} + +func TestSnoozedUntilPerRule(t *testing.T) { + s := newTestStore(t) + now := time.Now().UTC().Truncate(time.Millisecond) + + snoozeNudge(t, s, "water", now.Add(-10*time.Minute)) + snoozeNudge(t, s, "break", now.Add(-30*time.Minute)) + + got, err := s.SnoozedUntil(context.Background(), now) + if err != nil { + t.Fatalf("SnoozedUntil: %v", err) + } + if len(got) != 2 { + t.Fatalf("want 2 snoozed rules, got %v", got) + } + wantWater := now.Add(-10 * time.Minute).Add(SnoozeDuration) + if !got["water"].Equal(wantWater) { + t.Fatalf("water until = %v, want %v", got["water"], wantWater) + } +} + +// The map must only ever hold the newest snooze for a rule, so a stale one +// can't shorten (or lengthen) the live one. +func TestSnoozedUntilUsesNewestSnooze(t *testing.T) { + s := newTestStore(t) + now := time.Now().UTC().Truncate(time.Millisecond) + + snoozeNudge(t, s, "water", now.Add(-90*time.Minute)) + snoozeNudge(t, s, "water", now.Add(-5*time.Minute)) + + got, err := s.SnoozedUntil(context.Background(), now) + if err != nil { + t.Fatalf("SnoozedUntil: %v", err) + } + want := now.Add(-5 * time.Minute).Add(SnoozeDuration) + if !got["water"].Equal(want) { + t.Fatalf("water until = %v, want %v", got["water"], want) + } +} + +// A snooze must expire. If this ever regresses Maven goes quiet forever and +// nobody can tell why. +func TestSnoozedUntilExpires(t *testing.T) { + s := newTestStore(t) + now := time.Now().UTC().Truncate(time.Millisecond) + + snoozeNudge(t, s, "water", now.Add(-SnoozeDuration-time.Minute)) + + got, err := s.SnoozedUntil(context.Background(), now) + if err != nil { + t.Fatalf("SnoozedUntil: %v", err) + } + if _, ok := got["water"]; ok { + t.Fatalf("expired snooze still active: %v", got) + } +} + +// Other outcomes are not snoozes. +func TestSnoozedUntilIgnoresOtherOutcomes(t *testing.T) { + s := newTestStore(t) + ctx := context.Background() + now := time.Now().UTC().Truncate(time.Millisecond) + + for _, outcome := range []string{NudgeActed, NudgeIgnored} { + id, err := s.RecordNudge(ctx, "water", "voice", "drink water", now) + if err != nil { + t.Fatalf("RecordNudge: %v", err) + } + if err := s.ResolveNudge(ctx, id, outcome, now); err != nil { + t.Fatalf("ResolveNudge: %v", err) + } + } + if _, err := s.RecordNudge(ctx, "break", "voice", "stand up", now); err != nil { + t.Fatalf("RecordNudge: %v", err) + } + + got, err := s.SnoozedUntil(ctx, now) + if err != nil { + t.Fatalf("SnoozedUntil: %v", err) + } + if len(got) != 0 { + t.Fatalf("want no snoozes, got %v", got) + } +} From 2f00593411dc587662dc03856087272e39ff3d52 Mon Sep 17 00:00:00 2001 From: kami Date: Fri, 31 Jul 2026 02:32:24 +0400 Subject: [PATCH 2/2] Wire the snooze read into the Gatherer and honour it for reminders (#364) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01CGeSZxh1DCtRxmFVSYVGvJ --- internal/loop/gate_test.go | 106 +++++++++++++++++++++++++++++++++++++ internal/loop/gather.go | 10 +++- internal/loop/loop.go | 41 +++++++++++--- 3 files changed, 150 insertions(+), 7 deletions(-) create mode 100644 internal/loop/gate_test.go diff --git a/internal/loop/gate_test.go b/internal/loop/gate_test.go new file mode 100644 index 0000000..81f10e1 --- /dev/null +++ b/internal/loop/gate_test.go @@ -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) + } +} diff --git a/internal/loop/gather.go b/internal/loop/gather.go index 405aa0f..c55919f 100644 --- a/internal/loop/gather.go +++ b/internal/loop/gather.go @@ -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, diff --git a/internal/loop/loop.go b/internal/loop/loop.go index bc884fe..ae572d9 100644 --- a/internal/loop/loop.go +++ b/internal/loop/loop.go @@ -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.