Wake the scheduler on plan and dinner changes (MQ-177-01)
CI / ci (push) Successful in 7m58s
CI / ci (pull_request) Successful in 7m20s

A swap, stored draft, or dinner method/minutes change (edit or grounding)
now calls Scheduler.Wake once committed, so a crock-pot dinner swapped in
for tonight is reminded at start_by minus 15 minutes rather than at the
next hourly pass. Adds a fake-clock test that swaps in a crock-pot
Quest-log dinner at 09:01 and asserts the start reminder at 09:15, once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-10 14:55:07 -04:00
co-authored by Claude Opus 5.5
parent a7a56623e5
commit 765a36fa97
18 changed files with 172 additions and 39 deletions
@@ -8,6 +8,8 @@ import (
"testing"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify/notifytest"
@@ -265,3 +267,62 @@ func TestDailyAtLookupFailureIsRetried(t *testing.T) {
wantKeys(t, "next day", rec.keys(), []string{"start:2026-10-11", "start:2026-10-12", "start:2026-10-13"})
wantKeys(t, "schedule_runs", e.claimed(scheduler.DayOfReminder), rec.keys())
}
// questLog stores name as a Quest-log dinner, a crock-pot dinner of
// minutes, and returns its id. It is not a Regular.
func (p *planEnv) questLog(name string, minutes int) string {
p.e.t.Helper()
now := store.Now().String()
d, err := db.New(p.e.st.Writer).CreateDinner(context.Background(), db.CreateDinnerParams{
ID: store.NewID(),
Name: name,
NormalizedName: harness.Normalize(name),
Status: "quest_log",
Origin: "side_quest",
GroundingStatus: "manual",
CreatedAt: now,
UpdatedAt: now,
})
if err != nil {
p.e.t.Fatal(err)
}
p.ids[name] = d.ID
p.crockPot(name, minutes)
return d.ID
}
// TestStartReminderAfterSwapFiresOnTime: swapping a crock-pot dinner in
// for tonight wakes the scheduler, so its start reminder fires at start_by
// minus 15 minutes, 09:15, and not at the next hourly check, 10:00. At
// 09:00 tonight is Beef Chili, not a crock-pot dinner, and the scheduler's
// next timer is 10:00; at 09:01 the household swaps in an eight-hour
// crock-pot Quest-log dinner, start by 09:30. Each reminder fires once.
func TestStartReminderAfterSwapFiresOnTime(t *testing.T) {
e := newEnv(t)
p := e.wakingPlans(e.pool())
stew := p.questLog("Crock Pot Stew", 480) // start by 09:30
p.planWeek(w42)
r := e.start(at(2026, 10, 12, 8, 0), p.svc.ReminderSchedules()...)
r.runUntil(at(2026, 10, 12, 9, 0))
if next, _ := r.clock.nextDeadline(); !next.Equal(at(2026, 10, 12, 10, 0)) {
t.Fatalf("next wake at 09:00 = %s, want the hourly 10:00", next)
}
r.runUntil(at(2026, 10, 12, 9, 1))
if _, err := p.svc.Swap(context.Background(), w42, "2026-10-12", stew); err != nil {
t.Fatal(err)
}
r.changed()
r.runUntil(at(2026, 10, 12, 9, 14))
wantKeys(t, "before 09:15", e.reminderKeys(), nil)
r.runUntil(at(2026, 10, 12, 9, 15))
wantKeys(t, "at 09:15", e.reminderKeys(), []string{"day_of_reminder:start:2026-10-12"})
p.wantReminder("day_of_reminder:start:2026-10-12", "Start the crock pot by 9:30 am", "Crock Pot Stew", "Crock Pot Stew")
r.runUntil(at(2026, 10, 13, 23, 59))
wantKeys(t, "Tuesday", e.reminderKeys(), []string{"day_of_reminder:start:2026-10-12", "day_of_reminder:2026-10-12", "day_of_reminder:2026-10-13"})
p.wantReminder("day_of_reminder:2026-10-12", "Tonight: Crock Pot Stew · Recipe →", "", "Crock Pot Stew")
wantKeys(t, "schedule_runs", e.claimed(scheduler.DayOfReminder), []string{"2026-10-11", "2026-10-12", "2026-10-13", "start:2026-10-12"})
}
+17 -1
View File
@@ -81,7 +81,23 @@ func (c noCalendar) PushEvents(context.Context, []gcal.Event, func() error) erro
// plans builds a plan service drafting from the dinners ids over e with a
// started job runner, which stop stops (and the test's end, if not before).
// Its changes do not wake the scheduler: a draft job's wake would add a
// pass the fake clock's waits do not expect.
func (e *env) plans(ids map[string]string) *planEnv {
e.t.Helper()
return e.newPlans(ids, func() {})
}
// wakingPlans is plans whose changes wake the scheduler, as in the server.
// A test using it changes plans only from its own goroutine and waits for
// the wake's pass with changed.
func (e *env) wakingPlans(ids map[string]string) *planEnv {
e.t.Helper()
return e.newPlans(ids, e.wake)
}
// newPlans is plans whose service calls wake after a plan change.
func (e *env) newPlans(ids map[string]string, wake func()) *planEnv {
e.t.Helper()
p := &planEnv{e: e, model: &llm.Fake{}, ids: ids, logs: &syncBuffer{}}
guard, err := harness.NewGuard(harness.DefaultMaxCalls)
@@ -102,7 +118,7 @@ func (e *env) plans(ids map[string]string) *planEnv {
if err != nil {
e.t.Fatal(err)
}
p.svc, err = plan.NewService(e.st, drafter, runner, noCalendar{e.t}, notifytest.New(e.t, e.st), publicURL, logger)
p.svc, err = plan.NewService(e.st, drafter, runner, noCalendar{e.t}, notifytest.New(e.t, e.st), wake, publicURL, logger)
if err != nil {
e.t.Fatal(err)
}
+21 -7
View File
@@ -50,10 +50,11 @@
// than the settings: its function gives, for each household-local date,
// the instant the date's period fires, or none. The period is that date
// even when the instant falls on another day. The scheduler asks again on
// every pass, so a change to the data moves the fire time by the next pass,
// at most maxWait after the change; the claim still runs a period at most
// once. A lookup that fails is logged and repeated every ClaimRetry, as a
// failed claim is.
// every pass, and whoever changes that data calls Wake once the change is
// committed, so the next pass comes at once and a fire time the change
// moved fires on time rather than up to maxWait late; the claim still runs
// a period at most once. A lookup that fails is logged and repeated every
// ClaimRetry, as a failed claim is.
//
// # Settings changes
//
@@ -242,8 +243,9 @@ type Scheduler struct {
now func() time.Time
after func(d time.Duration) <-chan time.Time
// wake holds one token when the settings changed since the loop last
// looked; pending is the changes to apply, oldest first.
// wake holds one token when the settings or the data a DailyAt rule
// reads changed since the loop last looked; pending is the settings
// changes to apply, oldest first.
wake chan struct{}
pendingMu sync.Mutex
pending []change
@@ -356,9 +358,21 @@ func (s *Scheduler) changed(settings household.Settings) {
s.pendingMu.Lock()
s.pending = append(s.pending, change{at: s.now(), settings: settings})
s.pendingMu.Unlock()
s.signal()
}
// Wake makes the scheduler look up its fire times again now rather than at
// its next timer. Call it after committing a change to the data a DailyAt
// rule reads, such as tonight's planned dinner. It never blocks.
func (s *Scheduler) Wake() {
s.signal()
}
// signal leaves one token in wake for the loop.
func (s *Scheduler) signal() {
select {
case s.wake <- struct{}{}:
default: // the loop already has a token and reads pending then
default: // the loop already has a token and looks again then
}
}
+18 -2
View File
@@ -7,6 +7,7 @@ import (
"log/slog"
"slices"
"sync"
"sync/atomic"
"testing"
"time"
@@ -59,10 +60,13 @@ func (c *fakeClock) Now() time.Time {
return c.now
}
// After returns a timer that fires d from now. The scheduler waits on only
// its latest timer, so this one replaces any still pending: a timer the
// scheduler gave up on when a wake came first never fires.
func (c *fakeClock) After(d time.Duration) <-chan time.Time {
c.mu.Lock()
ch := make(chan time.Time, 1)
c.timers = append(c.timers, fakeTimer{deadline: c.now.Add(d), ch: ch})
c.timers = []fakeTimer{{deadline: c.now.Add(d), ch: ch}}
c.mu.Unlock()
c.waits <- struct{}{}
return ch
@@ -134,6 +138,16 @@ type env struct {
t *testing.T
st *store.Store
settings *household.SettingsService
// sched is the scheduler start last started, which wake wakes.
sched atomic.Pointer[scheduler.Scheduler]
}
// wake wakes the scheduler start last started, as the services' wake does
// in the server; before the first start it does nothing.
func (e *env) wake() {
if s := e.sched.Load(); s != nil {
s.Wake()
}
}
func newEnv(t *testing.T) *env {
@@ -214,6 +228,7 @@ func (e *env) start(now time.Time, schedules ...scheduler.Schedule) *running {
cancel()
e.t.Fatal(err)
}
e.sched.Store(s)
var once sync.Once
stop := func() {
once.Do(func() {
@@ -255,7 +270,8 @@ func (r *running) runUntil(to time.Time) {
}
}
// changed waits for the scheduler to apply a settings change.
// changed waits for the scheduler to apply a settings change, or to look
// again after a wake.
func (r *running) changed() {
r.t.Helper()
r.idle()