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:
@@ -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 (Sev1–2), 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.
|
||||
|
||||
Reference in New Issue
Block a user