Files
Maven/cmd/mavend/tick.go
T
claude c4cf06d610 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.
2026-08-05 02:03:49 +04:00

493 lines
19 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
}
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. 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)
}
}
}
// 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 := 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
}