35c6ff5a71
Persist reminder presentations and retry state, atomically complete collapsed deliveries, fall back across away reaches, and block permanent failures visibly (V-715, V-678). Fail closed when enabled integrations lack credentials and keep remote arms explicitly dark (V-691). Give mavweb one sanitized, request-correlated error contract (V-689). Owner explicitly requested direct commits to master.
612 lines
23 KiB
Go
612 lines
23 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"
|
|
"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:<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
|
|
}
|