Add digest as a real outcome: suppressed care nudges get resurfaced, not lost

Vikunja #281. The interruption policy promised four outcomes — deliver_now,
queue, digest, drop — but only three existed: a care candidate the restraint
gate suppressed for quiet hours / away / calendar-busy simply vanished in
loop.Tick's `continue`, with only the trace remembering why.

internal/morning turned out not to be the natural drain: it's a fixed
Item/FactKey checklist engine, not a generic message bundler, so gate-
suppressed nudge text has nowhere to plug into its evidence model. Built a
parallel (but small, reusing the outbox's shape) durable digest instead:

- internal/store: digest_entries table + EnqueueDigestEntry (dedupes by
  rule+body, mirroring the delivery outbox's bodyHash), PendingDigestEntries,
  ExpireStaleDigestEntries, DrainDigestEntries (mark, never delete — an
  audit trail of what she actually said).
- internal/loop: DigestEligible(severity, blockedBy) is the pure boundary —
  only genuine restraint blocks (quiet_hours/calendar_busy/presence) even
  qualify (cooldown/snooze are not "suppression"); within care, Sev2 (break)
  digests, Sev1 (water/meal — stale by the time anyone could resurface them)
  drops. High severity never digests; alarms bypass the gate and deliver
  unchanged, on purpose.
- cmd/mavend/tick.go: each tick scans ExplainTick's trace for eligible
  blocked candidates, enqueues them, sweeps stale entries (24h expiry — the
  care rules are daily-cadence, so anything older is describing a day
  that's over), and drains the bundle only once the suppression reason has
  actually cleared, capped at 3 spoken items plus a trailing count so a
  digest can't turn into the exact nagging it was built to avoid.

Tests: store-level round-trip/restart-survival/dedupe/expiry/drain, loop-
level severity-boundary unit tests, and tick-level integration tests for
the drain-only-when-clear and never-digest-high-severity behavior.
This commit is contained in:
kami
2026-07-31 23:09:22 +04:00
parent 73d13f1ea6
commit b0f5a16ec9
7 changed files with 737 additions and 0 deletions
+48
View File
@@ -286,6 +286,54 @@ func TestRemindersStillHonourSnooze(t *testing.T) {
}
}
// ---------------------------- digest eligibility ------------------------------
// A Sev2 care candidate (break) suppressed for a genuine restraint reason is
// worth resurfacing later.
func TestDigestEligibleSev2SuppressedByRestraint(t *testing.T) {
for _, reason := range []string{"quiet_hours", "calendar_busy", "presence"} {
if !DigestEligible(Sev2, reason) {
t.Errorf("sev2 blocked by %q: want digest-eligible", reason)
}
}
}
// A Sev1 care candidate (water/meal) never digests — a biological timer
// nudge is stale by the time anyone could resurface it, so it just drops.
func TestDigestEligibleSev1NeverDigests(t *testing.T) {
for _, reason := range []string{"quiet_hours", "calendar_busy", "presence"} {
if DigestEligible(Sev1, reason) {
t.Errorf("sev1 blocked by %q: want drop, got digest-eligible", reason)
}
}
}
// Ops severities are never blocked by these reasons in practice (Gate only
// applies quiet_hours/calendar_busy/presence to care severities), but the
// boundary itself must refuse to digest a high severity even if asked —
// alarms bypass the gate and deliver now, unchanged, never delayed.
func TestDigestEligibleNeverDigestsHighSeverity(t *testing.T) {
for _, sev := range []Severity{Sev3, Sev4} {
for _, reason := range []string{"quiet_hours", "calendar_busy", "presence"} {
if DigestEligible(sev, reason) {
t.Errorf("sev%d blocked by %q: high severity must never digest", sev, reason)
}
}
}
}
// cooldown and snooze are not "suppression" in the digest sense — cooldown
// means it was already said recently, snooze means the user asked to not
// hear about it. Neither should resurface later just because the severity
// matches.
func TestDigestEligibleExcludesCooldownAndSnooze(t *testing.T) {
for _, reason := range []string{"cooldown", "snooze", "inert_no_data", "predicate", ""} {
if DigestEligible(Sev2, reason) {
t.Errorf("sev2 blocked by %q: should not be digest-eligible", reason)
}
}
}
// GAP — the gate reads State.SnoozeUntil, but the Gatherer hard-codes it to nil
// (internal/loop/gather.go:153), so snooze is dead in the running daemon: the
// unit tests above pass while nothing can ever populate the map. This asserts
+30
View File
@@ -105,6 +105,36 @@ func Tick(s State, rules []Rule) *Candidate {
return fire
}
// DigestEligible decides digest-vs-drop for a care candidate the gate
// suppressed this tick (see ExplainGate's blockedBy). Pure — no I/O, no
// state, just the two facts that matter: why it was suppressed, and how
// insistent it was.
//
// Only genuine RESTRAINT blocks are eligible at all — quiet_hours,
// calendar_busy, presence(away). cooldown and snooze are not suppression in
// this sense: cooldown means "you already heard this recently" (resurfacing
// it later would be an actual repeat, not a rescue) and snooze is the user
// explicitly saying "not this" (digesting it anyway would defeat the ask).
// Ops severities (Sev3/4) never reach here — the gate never blocks them for
// these reasons in the first place (see Gate), and even if a future rule
// dropped Sev3+ into "care", digest still refuses them: alarms bypass the
// gate on purpose and must never be silently delayed into a bundle.
//
// Within care (Sev12), the boundary is severity itself: Sev1 (water, meal —
// biological timers with no "still relevant later" property; a water nudge
// from 3 hours into quiet hours is just wrong by morning) drops. Sev2
// (break — "you worked through a long stretch without a break while I
// couldn't reach you") is information that stays true and useful after the
// fact, so it digests.
func DigestEligible(sev Severity, blockedBy string) bool {
switch blockedBy {
case "quiet_hours", "calendar_busy", "presence":
default:
return false
}
return sev == Sev2
}
// 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 is the one part of restraint that still applies.