Compare commits

...

2 Commits

Author SHA1 Message Date
kami ee3e6a9eaf 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:33:51 +04:00
kami 8acb8a97c6 Read the recorded snooze outcomes back out of the nudges table (#364)
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CGeSZxh1DCtRxmFVSYVGvJ
2026-07-31 02:33:42 +04:00
6 changed files with 195 additions and 9 deletions
-2
View File
@@ -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")
+9 -1
View File
@@ -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
View File
@@ -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.
+2
View File
@@ -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
+45
View File
@@ -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 (1560m) 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) {
+104
View File
@@ -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)
}
}