// 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" "crypto/sha256" "errors" "fmt" "log" "os" "path/filepath" "sort" "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 } t.savePresence(ctx, state, now) // 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. The presentation and retry clock live on // the reminder occurrence, so a transport outage neither spends the model // every tick nor changes what the reminder says after a restart. for _, d := range loop.RemindDecisions(state, due) { t.deliverReminder(ctx, d, now) } // 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) } } } // deliverReminder advances one due reminder (or collapsed bundle) through the // durable delivery state. A phrase is cached before the first external send; // every definite failure advances the persisted bounded backoff. func (t *tickLoop) deliverReminder(ctx context.Context, d loop.ReminderDecision, now time.Time) { originals := reminderOriginals(d.Reminder) pr, cached := cachedReminderPhrase(d, originals) if !cached { var err error pr, err = t.phraser.PhraseReminder(ctx, d) if err == nil && pr.Body == "" { err = errors.New("phraser returned an empty reminder body") } if err != nil { log.Printf("tick: phrase reminder %d: %v", d.Reminder.ID, err) t.scheduleReminderRetry(ctx, originals, now) return } if pr.Mood == "" { pr.Mood = "neutral" } group := reminderDeliveryGroup(originals) if err := t.store.CacheReminderPhrase( ctx, originals, group, pr.Body, pr.Summary, pr.Mood, ); err != nil { // A cancellation or another completion can win while phrasing. Do // not send a presentation that no longer owns every original. log.Printf("tick: cache reminder %d phrase: %v", d.Reminder.ID, err) return } // The store now owns the phrase, but this tick's value predates that // write. Stamp the exact persisted occurrence identity onto the value // handed to the dispatcher so its outbox row can suppress an ambiguous // crash for both a real reminder and a synthetic collapsed bundle. for i := range originals { originals[i].DeliveryGroup = group originals[i].PhraseBody = pr.Body originals[i].PhraseSummary = pr.Summary originals[i].PhraseMood = pr.Mood } if d.Reminder.ID == 0 { d.Reminder.Collapsed = originals } else { d.Reminder = originals[0] } } // A phraser is not allowed to substitute the reminder decision. In // particular, the durable group stamped above must reach the outbox. pr.Decision = d if _, err := t.dispatcher.DispatchReminder(ctx, pr, now); err != nil { log.Printf("tick: dispatch reminder %d: %v", d.Reminder.ID, err) t.scheduleReminderRetry(ctx, originals, now) } } func (t *tickLoop) scheduleReminderRetry(ctx context.Context, originals []store.Reminder, now time.Time) { if err := t.store.ScheduleReminderRetry(ctx, originals, now); err != nil { log.Printf("tick: schedule reminder retry: %v", err) } } // reminderOriginals converts the synthetic ID=0 bundle back to real store // rows. Keeping this in one helper makes it impossible to accidentally persist // retry state against reminder zero. func reminderOriginals(r store.Reminder) []store.Reminder { if r.ID == 0 { return append([]store.Reminder(nil), r.Collapsed...) } return []store.Reminder{r} } // cachedReminderPhrase reconstructs a PhrasedReminder only when every original // agrees on one persisted group and presentation. That agreement is what lets // a collapsed bundle survive a restart without being re-phrased. func cachedReminderPhrase(d loop.ReminderDecision, originals []store.Reminder) (delivery.PhrasedReminder, bool) { if len(originals) == 0 || !originals[0].HasDeliveryPhrase() { return delivery.PhrasedReminder{}, false } first := originals[0] for _, r := range originals[1:] { if !r.HasDeliveryPhrase() || r.DeliveryGroup != first.DeliveryGroup || r.PhraseBody != first.PhraseBody || r.PhraseSummary != first.PhraseSummary || r.PhraseMood != first.PhraseMood { return delivery.PhrasedReminder{}, false } } mood := first.PhraseMood if mood == "" { mood = "neutral" } return delivery.PhrasedReminder{ Decision: d, Body: first.PhraseBody, Summary: first.PhraseSummary, Mood: mood, }, true } // reminderDeliveryGroup deterministically names one occurrence or collapsed // set. The next-fire instant is part of the identity so a recurring reminder's // later occurrence can never inherit the previous occurrence's phrase. func reminderDeliveryGroup(originals []store.Reminder) string { ordered := append([]store.Reminder(nil), originals...) sort.Slice(ordered, func(i, j int) bool { if ordered[i].ID == ordered[j].ID { return ordered[i].NextFireTs.Before(ordered[j].NextFireTs) } return ordered[i].ID < ordered[j].ID }) h := sha256.New() for _, r := range ordered { _, _ = fmt.Fprintf(h, "%d:%d;", r.ID, r.NextFireTs.UnixMilli()) } sum := h.Sum(nil) return fmt.Sprintf("reminder:%x", sum[:12]) } // savePresence writes back the bucket GatherState just resolved. // // It lives here and not in GatherState because that method holds a read-only // transaction on purpose — one consistent snapshot per tick — and a write // inside it would either break that guarantee or quietly upgrade the // transaction. The tick is the layer that already owns writes. // // Nothing wrote this row before (Vikunja #532), and the row is the whole // mechanism, so two things were broken at once. Hysteresis was dead: lastBucket // read the cold-start Away on every tick, so store.Resolve only ever took the // `last == Away` arm and demanded a full PresenceEnter score to say he is // there. The 0.30-0.55 hold band the function exists to provide never applied // once. And every readout lied: /dash and ipc.Presence read this row, so they // showed "away — score 0.00 (never)" while desk_active facts were arriving // every sixty seconds. // // A failure logs and the tick continues. The gate reads the in-memory bucket, // which is why nudge routing kept working through all of this — losing the // write costs the next tick's hysteresis, not this tick's decisions. func (t *tickLoop) savePresence(ctx context.Context, state loop.State, now time.Time) { if err := t.store.SavePresenceState(ctx, state.Presence, state.PresenceScore, now); err != nil { log.Printf("tick: save presence state: %v", 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 := t.rulesByName() 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 := t.rulesByName() out := keys[:0:0] for _, k := range keys { if _, ok := wired[k]; ok { out = append(out, k) } } return out } // rulesByName indexes the wired rule set by name, for the two lookups above // that only care whether a key is still wired (repeatableRules) or need the // rule itself (stopFinishedAlarms). func (t *tickLoop) rulesByName() map[string]loop.Rule { byName := make(map[string]loop.Rule, len(t.rules)) for _, r := range t.rules { byName[r.Name] = r } return byName } // 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:)` // 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 }