Files
Maven/cmd/mavend/tick.go
T
claude 8a13d189bb a sev4 alarm stops when the service is back, or after two hours (V-535)
It repeated every five minutes for over two hours. WasAcked was the only
stop condition and nothing reachable from telegram can mark a nudge acked
— the only ack is a voice 'готово' on a box that runs no voice loop.

Two endings now. The condition cleared, which the rule answers through the
new Rule.StillTrue — deliberately not Predicate, which is edge-triggered
and reads false one tick after the alarm is raised, so building the stop on
it would cancel every alarm immediately. Or the alarm got old, which is the
bound that needs no cooperation from the rule. A rule with no StillTrue is
never read as resolved and stops only on age.

Covered by tests including the flap case, since none of it can be
reproduced by hand without waiting hours.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 01:42:44 +04:00

467 lines
17 KiB
Go

// mavend/tick.go — the proactive loop driver.
//
// Per spec the 60s schedule loop (`for { tick; sleep }`) lives in the daemon
// main, NOT in `internal/loop/` — that keeps the loop package pure + unit-
// testable without time side effects. the driver here is the ONE impure
// orchestrator: it gathers state under the store lock, runs the pure Tick,
// phrases the candidate, dispatches it, then handles reminders + sev4 repeats
// and runs the feedback auto-tuner on its own slow cadence.
package main
import (
"context"
"errors"
"fmt"
"log"
"os"
"path/filepath"
"sync"
"time"
"github.com/kami/maven/internal/config"
"github.com/kami/maven/internal/delivery"
"github.com/kami/maven/internal/loop"
"github.com/kami/maven/internal/morning"
"github.com/kami/maven/internal/phraser"
"github.com/kami/maven/internal/routine"
"github.com/kami/maven/internal/store"
)
// QueuedNudge — a nudge held in the digest queue pending batch flush.
type QueuedNudge struct {
Rule string
Severity int
Body string
Key string
QueuedAt time.Time
}
// tickLoop — the impure driver. holds everything wired at daemon construction
// that the per-tick path needs. the rules slice is read-only here; the gatherer
// already captured it, but we keep it for a possible future re-seed path.
type tickLoop struct {
store *store.Store
gatherer *loop.Gatherer
dispatcher *delivery.Dispatcher
phraser phraser.Phraser
rules []loop.Rule
tickInterval time.Duration
repeatInterval time.Duration
autotuneInterval time.Duration // 0 ⇒ autotune disabled (gatherer falls back to static Base)
// digestCfg — the digest/batching config. nil ⇒ every nudge is sent
// immediately (legacy behaviour).
digestCfg *config.DigestConfig
// routines — operator-declared scheduled behaviors. fired through the
// dispatcher when their cron crosses. routineLast tracks the per-routine
// last-fire time across ticks (the driver owns it; routine.Due mutates it).
routines []routine.Routine
routineLast map[string]time.Time
// morningRoutines — daily checklists (see internal/morning). morningLast
// tracks the per-routine last-nudge day, mirroring routineLast.
morningRoutines []morning.Routine
morningLast map[string]time.Time
// proposalCfg — announcement policy for routines the tick inferred itself.
// nil ⇒ detect silently, never announce (the default). lastProposalAt is
// the cooldown clock, in-memory on purpose: a restart is allowed to permit
// one more announcement, and a restart-per-day loop is a bigger problem
// than a duplicate proposal notice.
proposalCfg *config.PatternProposalConfig
lastProposalAt time.Time
// digestQ — in-memory queue of eligible nudges waiting for batch flush.
// populated when digestCfg != nil && digestCfg.Enabled.
digestQ []QueuedNudge
// lastPhrase caches the phraser output per rule so the sev4 repeat path
// can re-send roughly what the user was first alerted with (an alarm
// that re-phrases differently every 5m is hostile; the same terse body
// IS the insistence signal). keyed by rule name. nil phrase for a rule
// = no successful initial dispatch yet (cold-start edge — fall back to
// a generic body).
mu sync.Mutex
lastPhrase map[string]delivery.PhrasedNudge
lastTrace *loop.TickTrace // cached from the most recent tick
}
func newTickLoop(
st *store.Store,
g *loop.Gatherer,
d *delivery.Dispatcher,
p phraser.Phraser,
rules []loop.Rule,
tickInterval, repeatInterval, autotuneInterval time.Duration,
digestCfg *config.DigestConfig,
routines []routine.Routine,
morningRoutines []morning.Routine,
proposalCfg *config.PatternProposalConfig,
) *tickLoop {
return &tickLoop{
store: st,
gatherer: g,
dispatcher: d,
phraser: p,
rules: rules,
tickInterval: tickInterval,
repeatInterval: repeatInterval,
autotuneInterval: autotuneInterval,
digestCfg: digestCfg,
digestQ: nil,
routines: routines,
routineLast: make(map[string]time.Time),
morningRoutines: morningRoutines,
morningLast: make(map[string]time.Time),
proposalCfg: proposalCfg,
lastPhrase: make(map[string]delivery.PhrasedNudge),
}
}
// run drives the loop until ctx is canceled. one tick per tickInterval;
// the first tick fires immediately so a freshly-started daemon doesn't sit
// idle for 60s before its first evaluation (cold-start responsiveness). the
// feedback auto-tuner runs on its own slower ticker (autotuneInterval) so it
// doesn't write a feedback fact every tick — append-only facts would churn.
func (t *tickLoop) run(ctx context.Context) {
t.tick(ctx, time.Now())
ticker := time.NewTicker(t.tickInterval)
defer ticker.Stop()
var autotune *time.Ticker
var autotuneC <-chan time.Time
if t.autotuneInterval > 0 {
autotune = time.NewTicker(t.autotuneInterval)
defer autotune.Stop()
autotuneC = autotune.C
}
for {
select {
case <-ctx.Done():
return
case now := <-ticker.C:
t.tick(ctx, now)
case <-autotuneC:
t.tune(ctx)
}
}
}
// tick — one pass of the proactive loop. gathers, decides, phrases, delivers.
// errors at any sub-step are logged and the tick continues / aborts as the
// layer warrants: a gather failure aborts (no consistent snapshot ⇒ no
// decisions); a phrase/dispatch failure logs the failure and continues so a
// transient delivery fault doesn't kill the whole loop.
func (t *tickLoop) tick(ctx context.Context, now time.Time) {
state, due, err := t.gatherer.GatherState(ctx, now)
if err != nil {
log.Printf("tick: gather: %v", err)
return
}
// proactive: at most one candidate, max severity.
cand, trace := loop.ExplainTick(state, t.rules)
t.mu.Lock()
t.lastTrace = trace
t.mu.Unlock()
if cand != nil {
if t.shouldQueue(cand) {
t.queueNudge(ctx, cand, state, now)
} else {
pn, err := t.phraser.PhraseNudge(ctx, *cand)
if err == nil {
pn = guardNudge(pn, *cand)
}
if err != nil {
log.Printf("tick: phrase nudge %s: %v", cand.Rule.Name, err)
} else {
t.cachePhrase(pn)
if _, err := t.dispatcher.DispatchNudge(ctx, pn, now); err != nil {
log.Printf("tick: dispatch nudge %s: %v", cand.Rule.Name, err)
}
}
}
}
// digest flush: after candidate processing, flush if the window has
// elapsed or MaxItems was reached. Processing the candidate first
// (with dedup) avoids re-queueing the same rule after a flush.
t.maybeFlush(ctx, now, state)
// gate-suppressed digest (Vikunja #281): rules the restraint gate held
// back this tick (quiet hours / away / calendar-busy), not because they
// weren't due, but because it wasn't the moment. Some of those are worth
// resurfacing later instead of just being lost — loop.DigestEligible
// draws that line. This is a SEPARATE mechanism from the in-memory
// digestQ above: that one batches candidates the gate already ALLOWED to
// fire; this one durably holds candidates the gate BLOCKED.
t.enqueueSuppressedDigest(ctx, trace, state, now)
t.expireStaleDigest(ctx, now)
t.maybeDrainDigest(ctx, state, now)
// routines: operator-declared scheduled behaviors. fire the ones whose cron
// crossed since last fire, delivered through the normal routing (voice when
// present, away channels otherwise). bodies are literal operator text — not
// LLM-phrased — so a routine can't hallucinate. severity comes from config.
t.fireRoutines(ctx, now, state)
// accepted routines: patterns the user confirmed. read straight from the
// store each tick so the schedule survives a restart.
t.fireAcceptedRoutines(ctx, now, state)
// morning routines: daily checklists (medicine/water/pets/...), nagged at
// most once per day per routine, and only for items still unevidenced at
// nudge time. See internal/morning for the "why not four timers" rationale.
t.fireMorningRoutines(ctx, now, state)
// pattern detection: scan every action+object pair with recorded events
// and propose a routine for any stable one not already decided (Vikunja
// #43). This used to only run as a side effect of the voice fact-write
// path, so a pattern already sitting in history went unnoticed until he
// happened to mention it again by voice. See patterns.go and
// detectPatterns below for how idempotence and dismissal are respected.
t.detectPatterns(ctx, now, state)
// reminders: gate-bypassing class. fired once, marked after a successful
// delivery. a failed send leaves the reminder pending — the next tick
// re-gathers and re-attempts.
for _, d := range loop.RemindDecisions(state, due) {
pr, err := t.phraser.PhraseReminder(ctx, d)
if err != nil {
log.Printf("tick: phrase reminder %d: %v", d.Reminder.ID, err)
continue
}
if _, err := t.dispatcher.DispatchReminder(ctx, pr, now); err != nil {
log.Printf("tick: dispatch reminder %d: %v", d.Reminder.ID, err)
}
}
// sev4-away repeats: re-send un-acked telegram nudges per repeatInterval.
// the source of ack truth IS the nudges table (outcome=pending ⇒ not
// acked); store.UnackedTelegramRules surfaces the keys. body/summary come
// from the cached phrase from the initial dispatch — see lastPhrase notes.
// if not cached (cold-start mid-alarm), fall back to a terse generic body.
keys, err := t.store.UnackedTelegramRules(ctx)
if err != nil {
log.Printf("tick: unacked telegram rules: %v", err)
return
}
keys = t.repeatableRules(keys)
keys = t.stopFinishedAlarms(ctx, keys, state, now)
if len(keys) == 0 {
return
}
for _, key := range keys {
body, summary := t.repeatPhrase(key)
if _, err := t.dispatcher.RepeatUnacked(ctx, []string{key}, now, t.repeatInterval, body, summary); err != nil {
log.Printf("tick: repeat telegram %s: %v", key, err)
}
}
}
// 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
// row still at outcome=pending is re-sent every repeat_interval until it is
// acked. So turning a rule off in `disabled_rules` silenced new nudges and left
// the last un-acked one re-sending every five minutes, forever — a knob that
// stops the cause and not the symptom is worse than no knob. Found the evening
// of 2026-08-01, two messages after the rule was supposedly off.
//
// Filtering on the wired set rather than on the disabled list also covers the
// rule that was deleted from the code entirely: its orphan rows go quiet
// instead of nagging about a rule nobody can ack from the UI any more.
func (t *tickLoop) repeatableRules(keys []string) []string {
if len(keys) == 0 {
return nil
}
wired := make(map[string]bool, len(t.rules))
for _, r := range t.rules {
wired[r.Name] = true
}
out := keys[:0:0]
for _, k := range keys {
if wired[k] {
out = append(out, k)
}
}
return out
}
// cachePhrase keeps the latest phrased nudge per rule for the sev4-repeat
// path. writing under a mutex; the repeat path reads under the same. the
// cache is bounded by the rule count (≤ ~30 per spec) so eviction is not a
// concern at this scale.
func (t *tickLoop) cachePhrase(pn delivery.PhrasedNudge) {
t.mu.Lock()
t.lastPhrase[pn.Candidate.Rule.Name] = pn
t.mu.Unlock()
}
func (t *tickLoop) repeatPhrase(rule string) (body, summary string) {
t.mu.Lock()
pn, ok := t.lastPhrase[rule]
t.mu.Unlock()
if !ok || pn.Summary == "" {
// cold-start mid-alarm: no cached phrase. a deliberately terse generic
// body — the alarm IS the insistence; the wording repeats, the ring
// is what changes. the LLM phraser impl will refresh this on its next
// tick when the rule re-fires through Tick.
return fmt.Sprintf("maven: %s still active", rule), rule
}
return pn.Body, pn.Summary
}
// tune — the feedback auto-tuner's impure step. runs on a slow cadence
// (autotuneInterval, see run) so it doesn't write a fact every tick. for each
// rule:
// 1. read store.RecentOutcomes for the last TuneSampleN resolved outcomes.
// 2. if there's not enough signal (TuneMinOutcomes), leave Base alone.
// 3. compute the tuned cooldown with loop.TuneCooldown (pure).
// 4. read the currently-persisted feedback fact; if the tuned value equals
// it, skip the write (RecentOutcomes is itself steady ⇒ no churn).
// 5. else write a `facts (kind=config, source=feedback, key=cooldown:<rule>)`
// row. the Gatherer reads it next tick.
//
// Error at any step logs + continues to the next rule — a transient store
// fault on one rule must not abort tuning for the rest.
func (t *tickLoop) tune(ctx context.Context) {
now := time.Now()
for _, r := range t.rules {
outcomes, err := t.store.RecentOutcomes(ctx, r.Name, loop.TuneSampleN)
if err != nil {
log.Printf("tune: outcomes %s: %v", r.Name, err)
continue
}
if len(outcomes) < loop.TuneMinOutcomes {
continue // sparse — no signal yet, don't whipsaw on first sight.
}
tuned := loop.TuneCooldown(r, outcomes)
// the currently-persisted tuned base, if any. equal ⇒ skip the write
// (RecentOutcomes is monotone-steady between resolved outcomes).
if cur, ok := t.currentTunedBase(ctx, r); ok && cur == tuned {
continue
}
if _, err := t.store.SetValue(
ctx, store.KindConfig, loop.FeedbackKey(r), loop.FeedbackSource,
tuned, now,
); err != nil {
log.Printf("tune: persist %s: %v", r.Name, err)
continue
}
log.Printf("tune: %s cooldown -> %v", r.Name, tuned)
}
}
// currentTunedBase — read the persisted feedback cooldown fact back into a
// duration. (dur, false) when no feedback fact exists yet OR it's malformed;
// the caller treats that as "differ from anything we'd write — write."
func (t *tickLoop) currentTunedBase(ctx context.Context, r loop.Rule) (time.Duration, bool) {
f, err := t.store.LatestFactBySource(ctx, loop.FeedbackKey(r), loop.FeedbackSource)
if err != nil {
return 0, false
}
return loop.ParseCooldownFact(f)
}
// defaultConfigPath — the config file path the daemon loads if -config wasn't
// passed. XDG_CONFIG_HOME/maven/mavend.json, falling back to ~/.config/maven.
func defaultConfigPath() string {
if x := os.Getenv("XDG_CONFIG_HOME"); x != "" {
return filepath.Join(x, "maven", "mavend.json")
}
home, err := os.UserHomeDir()
if err != nil || home == "" {
return "mavend.json"
}
return filepath.Join(home, ".config", "maven", "mavend.json")
}
// trace returns the most recent TickTrace, or nil if no tick has run yet.
func (t *tickLoop) trace() *loop.TickTrace {
t.mu.Lock()
defer t.mu.Unlock()
return t.lastTrace
}