b0f5a16ec9
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.
159 lines
5.4 KiB
Go
159 lines
5.4 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/kami/maven/internal/loop"
|
|
"github.com/kami/maven/internal/store"
|
|
)
|
|
|
|
// Vikunja #281 — the fourth delivery outcome: a care candidate the restraint
|
|
// gate suppresses (quiet hours / away / calendar-busy) is not necessarily
|
|
// lost. If it's worth resurfacing (loop.DigestEligible), it's durably held
|
|
// (internal/store's digest_entries) and spoken as one bundle once speaking
|
|
// is appropriate again — never while the suppression reason still holds.
|
|
|
|
func breakTrace(blockedBy string) *loop.TickTrace {
|
|
return &loop.TickTrace{
|
|
RuleTraces: []loop.RuleTrace{{
|
|
RuleName: "break",
|
|
Severity: loop.Sev2,
|
|
PredicateResult: true,
|
|
GateResult: false,
|
|
GateBlockedBy: blockedBy,
|
|
}},
|
|
}
|
|
}
|
|
|
|
// TestSuppressedCareDigestsAcrossQuietHours — a Sev2 care candidate blocked
|
|
// by quiet hours is enqueued into the durable digest, and is spoken as a
|
|
// "digest" nudge only once quiet hours actually end — never while still
|
|
// suppressed (that would just be a second way to nag through quiet hours).
|
|
func TestSuppressedCareDigestsAcrossQuietHours(t *testing.T) {
|
|
st := newTestStore(t)
|
|
sink := &fakeSink{}
|
|
tl := newTestTickLoop(t, st, sink, nil)
|
|
ctx := context.Background()
|
|
now := refNow()
|
|
|
|
quiet := loop.State{Now: now, QuietHours: true, Presence: store.Present}
|
|
tl.enqueueSuppressedDigest(ctx, breakTrace("quiet_hours"), quiet, now)
|
|
|
|
entries, err := st.PendingDigestEntries(ctx, now)
|
|
if err != nil {
|
|
t.Fatalf("pending: %v", err)
|
|
}
|
|
if len(entries) != 1 || entries[0].Rule != "break" {
|
|
t.Fatalf("want 1 pending digest entry for break, got %+v", entries)
|
|
}
|
|
|
|
// still quiet hours: draining now must not speak — the same restraint
|
|
// that suppressed the live nudge must suppress the bundle too.
|
|
tl.maybeDrainDigest(ctx, quiet, now)
|
|
if len(sink.sends) != 0 {
|
|
t.Fatalf("digest must not drain while quiet hours holds, got %+v", sink.sends)
|
|
}
|
|
|
|
// quiet hours end: this is the moment speaking is appropriate again.
|
|
after := now.Add(time.Hour)
|
|
clear := loop.State{Now: after, QuietHours: false, Presence: store.Present}
|
|
tl.maybeDrainDigest(ctx, clear, after)
|
|
|
|
if len(sink.sends) != 1 {
|
|
t.Fatalf("want exactly 1 dispatched digest bundle, got %d: %+v", len(sink.sends), sink.sends)
|
|
}
|
|
if sink.sends[0].RuleName != "digest" {
|
|
t.Fatalf("want RuleName digest, got %q", sink.sends[0].RuleName)
|
|
}
|
|
|
|
remaining, err := st.PendingDigestEntries(ctx, after)
|
|
if err != nil {
|
|
t.Fatalf("pending after drain: %v", err)
|
|
}
|
|
if len(remaining) != 0 {
|
|
t.Fatalf("drained entry must no longer be pending, got %+v", remaining)
|
|
}
|
|
}
|
|
|
|
// TestSuppressedCareDigestDedupesAcrossTicks — quiet hours holding for
|
|
// several ticks must not enqueue several copies of the same suppressed
|
|
// nudge; he hears it once when the bundle finally drains.
|
|
func TestSuppressedCareDigestDedupesAcrossTicks(t *testing.T) {
|
|
st := newTestStore(t)
|
|
sink := &fakeSink{}
|
|
tl := newTestTickLoop(t, st, sink, nil)
|
|
ctx := context.Background()
|
|
now := refNow()
|
|
|
|
quiet := loop.State{Now: now, QuietHours: true, Presence: store.Present}
|
|
for i := 0; i < 3; i++ {
|
|
tl.enqueueSuppressedDigest(ctx, breakTrace("quiet_hours"), quiet, now.Add(time.Duration(i)*time.Minute))
|
|
}
|
|
|
|
entries, err := st.PendingDigestEntries(ctx, now)
|
|
if err != nil {
|
|
t.Fatalf("pending: %v", err)
|
|
}
|
|
if len(entries) != 1 {
|
|
t.Fatalf("3 suppressions of the same nudge must collapse to 1 pending entry, got %d", len(entries))
|
|
}
|
|
}
|
|
|
|
// TestSuppressedCareDigestExpiresRatherThanDeliveringLate — an entry that
|
|
// aged out before the suppression cleared is dropped, not spoken late: a
|
|
// two-day-old "you skipped a break" is noise, not news.
|
|
func TestSuppressedCareDigestExpiresRatherThanDeliveringLate(t *testing.T) {
|
|
st := newTestStore(t)
|
|
sink := &fakeSink{}
|
|
tl := newTestTickLoop(t, st, sink, nil)
|
|
ctx := context.Background()
|
|
now := refNow()
|
|
|
|
quiet := loop.State{Now: now, QuietHours: true, Presence: store.Present}
|
|
tl.enqueueSuppressedDigest(ctx, breakTrace("quiet_hours"), quiet, now)
|
|
|
|
// well past digestExpiry (24h) before the suppression ever clears.
|
|
stale := now.Add(48 * time.Hour)
|
|
tl.expireStaleDigest(ctx, stale)
|
|
|
|
clear := loop.State{Now: stale, QuietHours: false, Presence: store.Present}
|
|
tl.maybeDrainDigest(ctx, clear, stale)
|
|
|
|
if len(sink.sends) != 0 {
|
|
t.Fatalf("a stale digest entry must be dropped, not delivered late; got %+v", sink.sends)
|
|
}
|
|
}
|
|
|
|
// TestSuppressedCareDigestIgnoresHighSeverity — defense in depth at the
|
|
// wiring layer: even if a RuleTrace somehow showed a high-severity rule
|
|
// blocked by a care-only gate reason, the tick driver must not durably
|
|
// digest it. Alarms bypass the gate and deliver now, unchanged; they must
|
|
// never be silently delayed into a bundle.
|
|
func TestSuppressedCareDigestIgnoresHighSeverity(t *testing.T) {
|
|
st := newTestStore(t)
|
|
sink := &fakeSink{}
|
|
tl := newTestTickLoop(t, st, sink, nil)
|
|
ctx := context.Background()
|
|
now := refNow()
|
|
|
|
trace := &loop.TickTrace{RuleTraces: []loop.RuleTrace{{
|
|
RuleName: "service_down",
|
|
Severity: loop.Sev4,
|
|
PredicateResult: true,
|
|
GateResult: false,
|
|
GateBlockedBy: "quiet_hours",
|
|
}}}
|
|
quiet := loop.State{Now: now, QuietHours: true, Presence: store.Present}
|
|
tl.enqueueSuppressedDigest(ctx, trace, quiet, now)
|
|
|
|
entries, err := st.PendingDigestEntries(ctx, now)
|
|
if err != nil {
|
|
t.Fatalf("pending: %v", err)
|
|
}
|
|
if len(entries) != 0 {
|
|
t.Fatalf("high severity must never be digested, got %+v", entries)
|
|
}
|
|
}
|