5447f08c06
Both Due functions key their last-fired map by routine name, so two routines sharing a name took turns suppressing each other and one of them never fired. Validate now rejects a duplicate name in either package. parseHHMM checked the digits arithmetically, which let a stray character cancel out: window_start of 2 :00 loaded as 04:00 and passed the validation that exists to catch that typo. Each of the four positions is now checked as a digit, which makes the negative bounds unreachable and they are gone. Folded the three copies of the unevidenced-item loop in Evaluate, Outstanding and Due into one helper.
140 lines
5.2 KiB
Go
140 lines
5.2 KiB
Go
// Package routine is maven's scheduled-behavior engine: things she does on a
|
|
// cron schedule, independent of any reactive request or care predicate.
|
|
//
|
|
// A Routine is the third proactive class, distinct from the two that already
|
|
// exist:
|
|
//
|
|
// - a Reminder (internal/store) is a USER-stated one-off/recurring intent —
|
|
// "напомни завтра позвонить маме". It exists because the user asked.
|
|
// - a care Rule (internal/loop) fires on WORLD-STATE predicates — water/meal/
|
|
// break/service_down. It exists because a snapshot crossed a threshold.
|
|
// - a Routine is OPERATOR-declared clockwork — an 08:00 morning briefing, a
|
|
// 22:00 wind-down. It exists purely because the clock said so.
|
|
//
|
|
// This package is pure: no store, no clock of its own, no I/O. The daemon's tick
|
|
// driver owns the impurity (it holds the last-fired map and calls Due each tick),
|
|
// exactly as it does for the loop package. That keeps the schedule logic here
|
|
// unit-testable without time side effects.
|
|
package routine
|
|
|
|
import (
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/robfig/cron/v3"
|
|
)
|
|
|
|
// Routine — one scheduled behavior. Cron is a standard 5-field expression
|
|
// (minute hour dom month dow). Body is the RU text delivered verbatim through
|
|
// the normal dispatcher (routines are NOT LLM-phrased: the operator writes the
|
|
// line, so it's deterministic and can't hallucinate). Severity (1-4) drives
|
|
// routing the same way a nudge's does — care-class (≤2) is suppressed by quiet
|
|
// hours and drops when away; ops-class (3-4) reaches away channels.
|
|
type Routine struct {
|
|
Name string
|
|
Cron string
|
|
Body string
|
|
Severity int
|
|
}
|
|
|
|
// Validate reports the first structural problem with a routine set: a missing
|
|
// name/cron/body, a duplicate name or an unparseable cron expression. Called at
|
|
// config load so a typo surfaces at startup, not as a silently-never-firing
|
|
// routine at runtime.
|
|
//
|
|
// Names must be unique because Due keys its last-fired map by name. Two
|
|
// routines sharing one would take turns being suppressed by each other's fire.
|
|
func Validate(routines []Routine) error {
|
|
seen := make(map[string]bool, len(routines))
|
|
for _, r := range routines {
|
|
if r.Name == "" {
|
|
return fmt.Errorf("routine: name is required")
|
|
}
|
|
if seen[r.Name] {
|
|
return fmt.Errorf("routine %q: duplicate name", r.Name)
|
|
}
|
|
seen[r.Name] = true
|
|
if r.Body == "" {
|
|
return fmt.Errorf("routine %q: body is required", r.Name)
|
|
}
|
|
if _, err := cron.ParseStandard(r.Cron); err != nil {
|
|
return fmt.Errorf("routine %q: bad cron %q: %w", r.Name, r.Cron, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Accepted — an accepted routine proposal as the tick driver sees it. This is a
|
|
// different shape from Routine: the schedule is a plain interval the pattern
|
|
// detector measured, not an operator-written cron expression. Accepted is when
|
|
// the human said yes; LastFired is nil until the first nudge.
|
|
type Accepted struct {
|
|
ID int64
|
|
Name string
|
|
IntervalDays float64
|
|
Accepted time.Time
|
|
LastFired *time.Time
|
|
}
|
|
|
|
// DueAccepted returns the accepted routines whose interval has passed. It does
|
|
// not mutate anything — the caller persists the new last-fired time, because
|
|
// that has to survive a restart (unlike Due's in-memory map).
|
|
//
|
|
// The clock starts at LastFired, or at Accepted for a routine that has never
|
|
// nudged. A routine with a non-positive interval never fires: a bad interval
|
|
// should mean silence, not a nudge every tick.
|
|
//
|
|
// One occurrence per call, no catch-up: the caller stamps the fire time as now,
|
|
// so a routine that was silent for a month nudges once and then waits a full
|
|
// interval. Never a backlog.
|
|
func DueAccepted(rs []Accepted, now time.Time) []Accepted {
|
|
var out []Accepted
|
|
for _, r := range rs {
|
|
if r.IntervalDays <= 0 {
|
|
continue
|
|
}
|
|
since := r.Accepted
|
|
if r.LastFired != nil {
|
|
since = *r.LastFired
|
|
}
|
|
gap := time.Duration(r.IntervalDays * 24 * float64(time.Hour))
|
|
if !now.Before(since.Add(gap)) {
|
|
out = append(out, r)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Due returns the routines whose schedule crossed since their last fire and
|
|
// records now as the new last-fire time for each one returned. The caller owns
|
|
// `last` (the tick driver holds it across ticks); Due mutates it in place.
|
|
//
|
|
// Cold-start guard: a routine absent from `last` (never seen — fresh daemon, or
|
|
// a newly-added routine) is SEEDED to now WITHOUT firing. Without this, a daemon
|
|
// restart at 12:00 would replay the 08:00 briefing, because Next() computed from
|
|
// the zero time is always in the past. The cost is that a routine can't fire in
|
|
// the same tick the daemon booted — an acceptable trade for never replaying a
|
|
// missed schedule on restart.
|
|
//
|
|
// A routine whose cron fails to parse is skipped (Validate rejects those at
|
|
// load; this is defence in depth so a bad expression can't fire every tick).
|
|
func Due(routines []Routine, last map[string]time.Time, now time.Time) []Routine {
|
|
var out []Routine
|
|
for _, r := range routines {
|
|
sched, err := cron.ParseStandard(r.Cron)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
prev, seen := last[r.Name]
|
|
if !seen {
|
|
last[r.Name] = now // seed on first sight — don't fire (cold-start guard)
|
|
continue
|
|
}
|
|
if next := sched.Next(prev); !next.After(now) {
|
|
last[r.Name] = now
|
|
out = append(out, r)
|
|
}
|
|
}
|
|
return out
|
|
}
|