Files
Maven/cmd/mavend/tick.go
T
claude b54ccccd0a presence: persist the resolved bucket, so hysteresis has a yesterday (V-532)
SavePresenceState had no caller outside tests. GatherState computed the score,
resolved the bucket against the last one and threw the result away, so the
singleton row was never written at all. Two things were broken by the one
missing write.

Hysteresis was dead. lastBucket read the cold-start Away every tick, so
store.Resolve only ever took the `last == Away` arm and demanded a full
PresenceEnter score to say he is at the desk. The 0.30-0.55 hold band the
function exists to provide never applied once — with a 60s desk poster and
tau=8min, presence dropped at about four minutes of idle instead of holding to
the exit threshold at about nine.

And every readout lied. /dash and ipc.Presence read this row, so they showed
"away — score 0.00 (never)" while desk_active facts arrived every sixty
seconds from workpc.

The write goes in the tick, not in GatherState: 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. A failure
logs and the tick continues, because the gate reads the in-memory bucket —
which is why nudge routing kept working through all of this, and why the defect
lived long enough to be found by looking at a dashboard.

The existing hysteresis test scores the pure function and passed throughout,
which is why nobody caught it. The new tests assert the round trip instead: the
tick writes what gather resolved, a later write overwrites rather than appends,
and the persisted bucket is what makes the hold band apply.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011x5DgnExQ5XZy8TZPs5bot
2026-08-05 01:31:23 +04:00

414 lines
16 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"
"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)
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)
}
}
// 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
}