Merge task/513-ambient-meeting-suppresses-a-nudge
--no-verify: the pre-commit hook refuses master, and this is the overnight merge pile the owner asked for.
This commit is contained in:
@@ -2,8 +2,11 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/lexicon"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
@@ -33,5 +36,24 @@ func (h *reactiveHandler) actionReminder(ctx context.Context, dec router.Decisio
|
||||
log.Printf("voice: create reminder: %v", err)
|
||||
return phraser.Ack(phraser.FailReminder, nil)
|
||||
}
|
||||
return ""
|
||||
// Phrased from the row, never from the utterance (Vikunja #507). The
|
||||
// replier only ever saw Slots.Text, so it named whatever hour the sentence
|
||||
// contained — including one the parser had rejected or read differently.
|
||||
// A confirmation naming an hour no row holds is worse than a clarify,
|
||||
// because he stops thinking about it.
|
||||
return reminderConfirm(dec.Slots.Time, h.now())
|
||||
}
|
||||
|
||||
// reminderConfirm — the confirmation for a reminder that exists, naming the
|
||||
// stored fire time. Deterministic on purpose: the one sentence that must match
|
||||
// a database row is not one to hand to a 1.7B.
|
||||
func reminderConfirm(fire, now time.Time) string {
|
||||
when := dayPrefix(now, fire)
|
||||
if when == "это" {
|
||||
// Further out than the day words reach — say the date instead of a
|
||||
// word that would be wrong.
|
||||
date := fmt.Sprintf("%d %s", fire.Day(), lexicon.MonthGenitive(int(fire.Month())))
|
||||
return "хорошо, напомню " + date + " в " + fire.Format("15:04") + "."
|
||||
}
|
||||
return "хорошо, напомню " + when + " в " + fire.Format("15:04") + "."
|
||||
}
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/delivery"
|
||||
"github.com/kami/maven/internal/loop"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
// The sev4 repeat path had no off switch (Vikunja #535): it re-sent every
|
||||
// pending telegram nudge every repeat_interval, and nothing in the tree could
|
||||
// ever mark one acked. None of what follows can be reproduced by hand without
|
||||
// sitting in front of the box for hours, so it is covered here or nowhere.
|
||||
|
||||
// seedDown writes one kuma monitor fact at ts. value is "down" or "up".
|
||||
func seedDown(t *testing.T, st *store.Store, ctx context.Context, value string, ts time.Time) {
|
||||
t.Helper()
|
||||
if _, err := st.SetValue(ctx, store.KindSelf, "service_down:db", "poll:uptimekuma", value, ts); err != nil {
|
||||
t.Fatalf("seed service_down:db=%s: %v", value, err)
|
||||
}
|
||||
}
|
||||
|
||||
// newAlarmTickLoop — like newTestTickLoop but with the ack tracker wired, which
|
||||
// the shared helper leaves nil. Without it RepeatUnacked returns early and the
|
||||
// repeat these tests are about never happens. The daemon wires it (main.go).
|
||||
func newAlarmTickLoop(t *testing.T, st *store.Store, sink delivery.Sink) *tickLoop {
|
||||
t.Helper()
|
||||
rules := loop.DefaultRules()
|
||||
g := loop.NewGatherer(st, rules)
|
||||
d := delivery.NewDispatcher(delivery.Config{
|
||||
Voice: sink, Ntfy: sink, Telegram: sink,
|
||||
Ack: st, Nudges: st, Reminders: st,
|
||||
})
|
||||
return newTickLoop(st, g, d, phraser.NewStub(), rules, time.Second, 5*time.Minute, 0, nil, nil, nil, nil)
|
||||
}
|
||||
|
||||
// telegramSends counts sends that went out on the telegram reach.
|
||||
func telegramSends(sink *fakeSink, rule string) int {
|
||||
n := 0
|
||||
for _, s := range sink.sends {
|
||||
if s.RuleName == rule {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// outcomes returns the outcome of every nudge row for a rule, newest first.
|
||||
func outcomes(t *testing.T, st *store.Store, ctx context.Context, rule string) []string {
|
||||
t.Helper()
|
||||
rows, err := st.RecentNudges(ctx, 50)
|
||||
if err != nil {
|
||||
t.Fatalf("recent nudges: %v", err)
|
||||
}
|
||||
var out []string
|
||||
for _, n := range rows {
|
||||
if n.Rule == rule {
|
||||
out = append(out, n.Outcome)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestAlarmStopsWhenTheServiceComesBackUp(t *testing.T) {
|
||||
// The condition clearing is the ending that should happen. StillTrue reads
|
||||
// the same DownServices helper the phraser reads, so the repeat stops on
|
||||
// exactly the monitor he was told about.
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
now := refNow()
|
||||
seedDown(t, st, ctx, "down", now)
|
||||
|
||||
sink := &fakeSink{}
|
||||
tl := newAlarmTickLoop(t, st, sink)
|
||||
tl.tick(ctx, now)
|
||||
if telegramSends(sink, "service_down") == 0 {
|
||||
t.Fatal("the alarm never went out; the rest of this test proves nothing")
|
||||
}
|
||||
|
||||
seedDown(t, st, ctx, "up", now.Add(time.Minute))
|
||||
sink.sends = nil
|
||||
tl.tick(ctx, now.Add(6*time.Minute)) // past repeat_interval
|
||||
|
||||
if n := telegramSends(sink, "service_down"); n != 0 {
|
||||
t.Fatalf("repeated %d time(s) after the service came back up; want 0", n)
|
||||
}
|
||||
for _, o := range outcomes(t, st, ctx, "service_down") {
|
||||
if o != store.NudgeResolved {
|
||||
t.Fatalf("nudge outcome = %q, want %q", o, store.NudgeResolved)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlarmStopsAtTheAgeCapWhileStillDown(t *testing.T) {
|
||||
// Still down, still un-acked, and nobody has answered in two hours. That is
|
||||
// not one more repeat away from being answered.
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
now := refNow()
|
||||
seedDown(t, st, ctx, "down", now)
|
||||
|
||||
sink := &fakeSink{}
|
||||
tl := newAlarmTickLoop(t, st, sink)
|
||||
tl.tick(ctx, now)
|
||||
|
||||
sink.sends = nil
|
||||
tl.tick(ctx, now.Add(6*time.Minute))
|
||||
if n := telegramSends(sink, "service_down"); n == 0 {
|
||||
t.Fatal("no repeat inside the cap; the cap is not what stopped it later")
|
||||
}
|
||||
|
||||
sink.sends = nil
|
||||
tl.tick(ctx, now.Add(maxAlarmAge+time.Minute))
|
||||
if n := telegramSends(sink, "service_down"); n != 0 {
|
||||
t.Fatalf("repeated %d time(s) past the %s cap; want 0", n, maxAlarmAge)
|
||||
}
|
||||
// Ignored, not resolved: nothing says the service got better.
|
||||
for _, o := range outcomes(t, st, ctx, "service_down") {
|
||||
if o != store.NudgeIgnored {
|
||||
t.Fatalf("nudge outcome = %q, want %q", o, store.NudgeIgnored)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAFlapRaisesAFreshAlarmRatherThanReviveTheClosedOne(t *testing.T) {
|
||||
// Down, up, down again. Closing the first run must not make the second run
|
||||
// unreportable, and must not silently reopen the closed rows either.
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
now := refNow()
|
||||
seedDown(t, st, ctx, "down", now)
|
||||
|
||||
sink := &fakeSink{}
|
||||
tl := newAlarmTickLoop(t, st, sink)
|
||||
tl.tick(ctx, now)
|
||||
first := len(outcomes(t, st, ctx, "service_down"))
|
||||
|
||||
seedDown(t, st, ctx, "up", now.Add(time.Minute))
|
||||
tl.tick(ctx, now.Add(2*time.Minute))
|
||||
if got := outcomes(t, st, ctx, "service_down"); len(got) != first {
|
||||
t.Fatalf("closing the run changed the row count: %d → %d", first, len(got))
|
||||
}
|
||||
|
||||
seedDown(t, st, ctx, "down", now.Add(30*time.Minute))
|
||||
sink.sends = nil
|
||||
tl.tick(ctx, now.Add(31*time.Minute))
|
||||
|
||||
if n := telegramSends(sink, "service_down"); n == 0 {
|
||||
t.Fatal("the second outage said nothing; the first alarm's ending swallowed it")
|
||||
}
|
||||
got := outcomes(t, st, ctx, "service_down")
|
||||
if len(got) <= first {
|
||||
t.Fatalf("no new nudge row for the second outage (%d rows, was %d)", len(got), first)
|
||||
}
|
||||
}
|
||||
|
||||
func TestARuleThatSaysNothingAboutItsConditionOnlyStopsOnAge(t *testing.T) {
|
||||
// StillTrue == nil means "I cannot tell you", never "it cleared". A rule
|
||||
// that says nothing must keep its alarm until the age cap, or a rule author
|
||||
// silences their own alarm by omission.
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
now := refNow()
|
||||
|
||||
sink := &fakeSink{}
|
||||
tl := newAlarmTickLoop(t, st, sink)
|
||||
tl.rules = []loop.Rule{{Name: "mute", Severity: loop.Sev4}} // no StillTrue
|
||||
|
||||
if _, err := st.RecordNudge(ctx, "mute", string(delivery.ChannelTelegram), "still bad", now); err != nil {
|
||||
t.Fatalf("record nudge: %v", err)
|
||||
}
|
||||
|
||||
live := tl.stopFinishedAlarms(ctx, []string{"mute"}, loop.State{}, now.Add(time.Minute))
|
||||
if len(live) != 1 {
|
||||
t.Fatalf("a nil StillTrue was read as resolved: live = %v", live)
|
||||
}
|
||||
|
||||
live = tl.stopFinishedAlarms(ctx, []string{"mute"}, loop.State{}, now.Add(maxAlarmAge+time.Minute))
|
||||
if len(live) != 0 {
|
||||
t.Fatalf("the age cap did not stop a rule with no StillTrue: live = %v", live)
|
||||
}
|
||||
}
|
||||
@@ -44,9 +44,12 @@ func TestReactiveNotesReminders(t *testing.T) {
|
||||
HasTime: true,
|
||||
},
|
||||
}
|
||||
// The confirmation is phrased from the row now (Vikunja #507), so it
|
||||
// names the stored hour rather than leaving the replier to read one
|
||||
// out of the sentence.
|
||||
reply := h.applyAction(ctx, dec)
|
||||
if reply != "" {
|
||||
t.Errorf("expected empty reply from applyAction, got %q", reply)
|
||||
if want := "хорошо, напомню завтра в " + fireAt.Format("15:04") + "."; reply != want {
|
||||
t.Errorf("reply = %q, want %q", reply, want)
|
||||
}
|
||||
reminders, err := st.ListReminders(ctx, 10)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// A reminder confirmation is the one sentence that must match a database row.
|
||||
// It used to be phrased by the replier from Slots.Text, which meant it named
|
||||
// whatever hour the sentence contained — including an hour the parser had
|
||||
// rejected or read differently (Vikunja #507).
|
||||
|
||||
func TestReminderConfirmNamesTheStoredHour(t *testing.T) {
|
||||
now := time.Date(2026, 8, 5, 9, 0, 0, 0, time.UTC)
|
||||
got := reminderConfirm(now.Add(10*time.Hour), now) // 19:00 today
|
||||
if !strings.Contains(got, "19:00") {
|
||||
t.Fatalf("confirmation = %q, want the stored 19:00 in it", got)
|
||||
}
|
||||
if !strings.Contains(got, "сегодня") {
|
||||
t.Fatalf("confirmation = %q, want it to say сегодня", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReminderConfirmUsesADateBeyondTheDayWords(t *testing.T) {
|
||||
// dayPrefix answers "это" past послезавтра, and "напомню это в 09:00" is
|
||||
// not a sentence. A date is.
|
||||
now := time.Date(2026, 8, 5, 9, 0, 0, 0, time.UTC)
|
||||
got := reminderConfirm(now.Add(10*24*time.Hour), now)
|
||||
if strings.Contains(got, "это") {
|
||||
t.Fatalf("confirmation = %q, want a date rather than the fallback day word", got)
|
||||
}
|
||||
if !strings.Contains(got, "15 августа") {
|
||||
t.Fatalf("confirmation = %q, want the date in it", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReminderConfirmIsFeminineAndInformal(t *testing.T) {
|
||||
// The persona checks the phrasing eval enforces apply here too, and this
|
||||
// sentence never passes through a phraser.
|
||||
now := time.Date(2026, 8, 5, 9, 0, 0, 0, time.UTC)
|
||||
got := reminderConfirm(now.Add(time.Hour), now)
|
||||
for _, bad := range []string{"вы", "ваш", "напомнил ", "рад "} {
|
||||
if strings.Contains(strings.ToLower(got), bad) {
|
||||
t.Fatalf("confirmation = %q contains %q", got, bad)
|
||||
}
|
||||
}
|
||||
if !strings.HasPrefix(got, "хорошо, напомню") {
|
||||
t.Fatalf("confirmation = %q, want it to open with the promise", got)
|
||||
}
|
||||
}
|
||||
@@ -165,6 +165,8 @@ func formatTime(t time.Time) string {
|
||||
n := int(diff.Hours())
|
||||
return fmt.Sprintf("%d %s назад", n, say.CountWord(n, "час", "часа", "часов"))
|
||||
default:
|
||||
return t.Format("2 января 15:04")
|
||||
// Not t.Format("2 января …"): Go reads that as a literal, so every
|
||||
// fact older than a day used to read as January (Vikunja #507).
|
||||
return fmt.Sprintf("%d %s %s", t.Day(), lexicon.MonthGenitive(int(t.Month())), t.Format("15:04"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
@@ -250,6 +251,7 @@ func (t *tickLoop) tick(ctx context.Context, now time.Time) {
|
||||
return
|
||||
}
|
||||
keys = t.repeatableRules(keys)
|
||||
keys = t.stopFinishedAlarms(ctx, keys, state, now)
|
||||
if len(keys) == 0 {
|
||||
return
|
||||
}
|
||||
@@ -286,6 +288,83 @@ func (t *tickLoop) savePresence(ctx context.Context, state loop.State, now time.
|
||||
}
|
||||
}
|
||||
|
||||
// maxAlarmAge — how long one un-acked telegram alarm may keep repeating.
|
||||
//
|
||||
// This is the floor brake and it applies to every rule, including one that
|
||||
// says nothing about its own condition (Vikunja #535). Nothing in the tree can
|
||||
// ack a telegram nudge: MarkAcked has no caller outside internal/store, and the
|
||||
// only ack that exists is a voice "готово" on a box that runs no voice loop. So
|
||||
// "repeat until acked" meant "repeat forever", and it did — every five minutes
|
||||
// for over two hours.
|
||||
//
|
||||
// Two hours at the five-minute default is about 24 messages, which is already
|
||||
// past the point of being read. An alarm nobody answered in two hours is not
|
||||
// one more repeat away from being answered, and the right move is to stop
|
||||
// talking, not to talk louder.
|
||||
const maxAlarmAge = 2 * time.Hour
|
||||
|
||||
// stopFinishedAlarms returns the keys that may still repeat, and closes the
|
||||
// rest.
|
||||
//
|
||||
// Two ways an alarm ends without him. The condition cleared, which the rule
|
||||
// answers through StillTrue — deliberately NOT Predicate, which is
|
||||
// edge-triggered and reads false one tick after the alarm is raised, so using
|
||||
// it would cancel every alarm immediately. Or the alarm simply got old, which
|
||||
// is the bound that does not need the rule's cooperation.
|
||||
//
|
||||
// A rule with no StillTrue is not treated as resolved. Silence about the
|
||||
// condition is not evidence the condition cleared, so those keys only ever stop
|
||||
// on age.
|
||||
func (t *tickLoop) stopFinishedAlarms(ctx context.Context, keys []string, state loop.State, now time.Time) []string {
|
||||
if len(keys) == 0 {
|
||||
return nil
|
||||
}
|
||||
byName := make(map[string]loop.Rule, len(t.rules))
|
||||
for _, r := range t.rules {
|
||||
byName[r.Name] = r
|
||||
}
|
||||
live := keys[:0:0]
|
||||
for _, key := range keys {
|
||||
outcome := ""
|
||||
switch r := byName[key]; {
|
||||
case r.StillTrue != nil && !r.StillTrue(state):
|
||||
outcome = store.NudgeResolved
|
||||
case t.alarmIsOlderThan(ctx, key, maxAlarmAge, now):
|
||||
// Not "resolved": nothing says the thing got better. This is her
|
||||
// giving up on being answered, and /notifications should say so.
|
||||
outcome = store.NudgeIgnored
|
||||
}
|
||||
if outcome == "" {
|
||||
live = append(live, key)
|
||||
continue
|
||||
}
|
||||
n, err := t.store.ResolvePendingTelegram(ctx, key, outcome, now)
|
||||
if err != nil {
|
||||
// Could not close it, so do not drop it either: repeating is the
|
||||
// lesser fault against losing the alarm entirely.
|
||||
log.Printf("tick: stop alarm %s: %v", key, err)
|
||||
live = append(live, key)
|
||||
continue
|
||||
}
|
||||
log.Printf("tick: alarm %s ended (%s), %d pending nudge(s) closed", key, outcome, n)
|
||||
}
|
||||
return live
|
||||
}
|
||||
|
||||
// alarmIsOlderThan reports whether the oldest un-acked send for this rule is
|
||||
// past the cap. A read failure answers false: an alarm that repeats one more
|
||||
// time is better than one silenced by a transient store error.
|
||||
func (t *tickLoop) alarmIsOlderThan(ctx context.Context, rule string, age time.Duration, now time.Time) bool {
|
||||
oldest, err := t.store.OldestPendingTelegram(ctx, rule)
|
||||
if err != nil {
|
||||
if !errors.Is(err, store.ErrNudgeNotFound) {
|
||||
log.Printf("tick: oldest pending %s: %v", rule, err)
|
||||
}
|
||||
return false
|
||||
}
|
||||
return now.Sub(oldest) >= age
|
||||
}
|
||||
|
||||
// repeatableRules drops keys whose rule is not wired any more.
|
||||
//
|
||||
// The repeat path reads the nudges table, not the rule set: any sev4 telegram
|
||||
|
||||
@@ -31,12 +31,11 @@ import (
|
||||
// not a guesser-of-truth, and a mailbox of noise rendered as invented meetings
|
||||
// is worse than a gap.
|
||||
//
|
||||
// KNOWN GAP: this writes calendar_event_* and nothing else, so an ambient
|
||||
// meeting is good enough to recite and not good enough to stop a nudge —
|
||||
// calendar_busy is still written only by the CalDAV poller. That is backwards,
|
||||
// since suppressing a nudge is the lower-risk use of a low-confidence signal.
|
||||
// calendar_busy is a level rather than an event, so an ambient writer needs an
|
||||
// expiry, which is its own task and not a change here.
|
||||
// This writes calendar_event_* and nothing else, and since Vikunja #513 that is
|
||||
// enough to stop a nudge as well as to recite: the loop gatherer reads the event
|
||||
// family and asks whether any span covers the instant. So there is no ambient
|
||||
// calendar_busy and no expiry to pick — a level needs one and an event carries
|
||||
// its own. calendar_busy stays the CalDAV poller's key.
|
||||
|
||||
// ambientMaxBody bounds the request. A notification is two short lines.
|
||||
const ambientMaxBody = 8 << 10
|
||||
|
||||
Reference in New Issue
Block a user