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:
kami
2026-07-31 02:32:24 +04:00
parent 32687b3712
commit 2f00593411
3 changed files with 150 additions and 7 deletions
+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.