Weekly suggestions schedule (Feature 20) (#172)
CI / ci (push) Successful in 9m3s

Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
This commit was merged in pull request #172.
This commit is contained in:
2026-10-10 08:53:14 -04:00
committed by approver_agent
parent e5c13732d6
commit c54d809bfd
9 changed files with 771 additions and 127 deletions
+7 -5
View File
@@ -77,13 +77,15 @@ func DayOfReminder(date store.LocalDate, dinnerID, name string) notify.Event {
}
}
// NewSuggestions is the new_suggestions event of suggestion batch batchID,
// made in the week starting on week, naming its Side quests.
func NewSuggestions(batchID string, week store.LocalDate, names []string) notify.Event {
// NewSuggestions is the new_suggestions event keyed by id, the ISO week
// ("2026-W41") of a scheduled suggestion batch or the batch id of a manual
// one, of the week starting on week: how many new Side quests there are,
// and their names.
func NewSuggestions(id string, week store.LocalDate, names []string) notify.Event {
return notify.Event{
Type: notify.NewSuggestions,
Key: string(notify.NewSuggestions) + ":" + batchID,
Title: "New Side quests are in",
Key: string(notify.NewSuggestions) + ":" + id,
Title: count(len(names), "new Side quest is in", "new Side quests are in"),
Body: nameList(names),
URL: "/discover",
Week: week,
+12 -2
View File
@@ -110,9 +110,19 @@ func TestMessages(t *testing.T) {
key: "new_suggestions:01J9BATCH",
url: "/discover",
week: "2026-10-12",
title: "New Side quests are in",
title: "5 new Side quests are in",
body: "Lemon orzo, Bibimbap, Shakshuka + 2 more",
chatT: "New Side quests are in: Lemon orzo, Bibimbap, Shakshuka + 2 more. Open Discover →",
chatT: "5 new Side quests are in: Lemon orzo, Bibimbap, Shakshuka + 2 more. Open Discover →",
},
{
name: "new suggestions of a scheduled week",
ev: NewSuggestions("2026-W42", week, []string{"Pho"}),
key: "new_suggestions:2026-W42",
url: "/discover",
week: "2026-10-12",
title: "1 new Side quest is in",
body: "Pho",
chatT: "1 new Side quest is in: Pho. Open Discover →",
},
{
name: "rotation review",
+370
View File
@@ -0,0 +1,370 @@
package scheduler_test
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"strings"
"sync"
"testing"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
"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/jobs"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify/notifytest"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/scheduler"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/suggest"
)
// The tests in this file run suggest's suggestions_weekly schedule on the
// fake clock against the real suggestion service, job runner, harness and
// notification outbox, with the AI and recipe sources faked.
// suggestEnv is a suggestion service over a scheduler env's store, the
// "process" a scheduler runs it in. side answers suggest_side_quests and
// model normalize_recipe while grounding.
type suggestEnv struct {
e *env
svc *suggest.Service
side *llm.Fake
model *llm.Fake
search *recipestest.Search
pages *recipestest.Pages
images *recipestest.Images
logs *syncBuffer
stop func()
}
// syncBuffer collects log output written by concurrent goroutines.
type syncBuffer struct {
mu sync.Mutex
buf strings.Builder
}
func (b *syncBuffer) Write(p []byte) (int, error) {
b.mu.Lock()
defer b.mu.Unlock()
return b.buf.Write(p)
}
func (b *syncBuffer) String() string {
b.mu.Lock()
defer b.mu.Unlock()
return b.buf.String()
}
// regulars stores the Regulars Beef Chili and Chicken Fajitas, so a batch
// has Regulars to suggest from.
func (e *env) regulars() {
e.t.Helper()
for _, name := range []string{"Beef Chili", "Chicken Fajitas"} {
now := store.Now().String()
if _, err := db.New(e.st.Writer).CreateDinner(context.Background(), db.CreateDinnerParams{
ID: store.NewID(),
Name: name,
NormalizedName: harness.Normalize(name),
Status: "regular",
Origin: "regular",
GroundingStatus: "manual",
CreatedAt: now,
UpdatedAt: now,
}); err != nil {
e.t.Fatal(err)
}
}
}
// suggestions builds a suggestion service over e with a started job
// runner, which stop stops (and the test's end, if not before).
func (e *env) suggestions() *suggestEnv {
e.t.Helper()
s := &suggestEnv{
e: e,
side: &llm.Fake{},
model: &llm.Fake{},
search: &recipestest.Search{Results: map[string][]recipes.Candidate{}},
pages: &recipestest.Pages{Bodies: map[string][]byte{}},
images: &recipestest.Images{JPEGs: map[string][]byte{}},
logs: &syncBuffer{},
}
g := recipestest.NewGrounder(e.t, e.st, e.t.TempDir(), s.search, s.pages, s.images, s.model)
guard, err := harness.NewGuard(harness.DefaultMaxCalls)
if err != nil {
e.t.Fatal(err)
}
cfg := harness.Config{
Settings: llm.Settings{Provider: "anthropic", Model: "claude-test", Temperature: 0.7},
NewProvider: func(llm.Settings, string) (llm.Provider, error) { return s.side, nil },
Store: e.st,
Guard: guard,
}
suggester := func(ctx context.Context, scope string, in skills.SideQuestsRequest) (skills.SideQuests, error) {
return skills.SuggestSideQuests(ctx, cfg, scope, in)
}
logger := slog.New(slog.NewTextHandler(s.logs, nil))
runner, err := jobs.NewRunner(e.st, logger, 1)
if err != nil {
e.t.Fatal(err)
}
s.svc, err = suggest.NewService(e.st, g, suggester, runner, notifytest.New(e.t, e.st), logger)
if err != nil {
e.t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
if err := runner.Start(ctx); err != nil {
cancel()
e.t.Fatal(err)
}
var once sync.Once
s.stop = func() {
once.Do(func() {
cancel()
runner.Wait()
})
}
e.t.Cleanup(s.stop)
return s
}
// week scripts the next batch: suggest_side_quests answers with the
// Side quests named names, each grounded by its own recipe.
func (s *suggestEnv) week(names ...string) {
s.e.t.Helper()
var quests []string
for _, name := range names {
slug := strings.ReplaceAll(strings.ToLower(name), " ", "-")
page, photo := "https://r.example/"+slug, "https://r.example/"+slug+".jpg"
s.search.Results[name] = []recipes.Candidate{{URL: page}}
s.pages.Bodies[page] = recipestest.RecipePage(name, "R Kitchen", photo, 35)
s.images.JPEGs[photo] = recipestest.JPEG(s.e.t, 400, 320, uint8(len(s.images.JPEGs)+1))
s.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 35))
quests = append(quests, fmt.Sprintf(`{"name":%q,"cuisine":"Fusion","method":"stovetop","est_minutes":35,"difficulty":"easy","reason":"Like your Beef Chili.","cited_regulars":["Beef Chili"],"search_query":%q}`, name, name))
}
js := `{"suggestions":[` + strings.Join(quests, ",") + `]}`
s.side.QueueGenerate(llm.GenerateResult{Response: llm.Response{Text: js, JSON: json.RawMessage(js)}})
}
// batch is a suggestion_batches row.
type batch struct {
ID, Trigger, Status string
}
// batches returns every suggestion batch, oldest first.
func (e *env) batches() []batch {
e.t.Helper()
rows, err := e.st.Reader.Query(`SELECT id, trigger, status FROM suggestion_batches ORDER BY created_at, id`)
if err != nil {
e.t.Fatal(err)
}
defer rows.Close()
var out []batch
for rows.Next() {
var b batch
if err := rows.Scan(&b.ID, &b.Trigger, &b.Status); err != nil {
e.t.Fatal(err)
}
out = append(out, b)
}
if err := rows.Err(); err != nil {
e.t.Fatal(err)
}
return out
}
// settled waits until no batch is pending or running and returns the
// batches.
func (e *env) settled() []batch {
e.t.Helper()
deadline := time.Now().Add(10 * time.Second)
for {
bs := e.batches()
busy := false
for _, b := range bs {
busy = busy || b.Status == suggest.BatchPending || b.Status == suggest.BatchRunning
}
if !busy {
return bs
}
if time.Now().After(deadline) {
e.t.Fatalf("batches still under way: %+v", bs)
}
time.Sleep(5 * time.Millisecond)
}
}
// wantBatches fails unless the batches are scheduled with statuses want.
func wantBatches(t *testing.T, what string, got []batch, want ...string) {
t.Helper()
ok := len(got) == len(want)
for i := 0; ok && i < len(got); i++ {
ok = got[i].Trigger == suggest.TriggerScheduled && got[i].Status == want[i]
}
if !ok {
t.Fatalf("%s: batches = %+v, want scheduled %q", what, got, want)
}
}
// notified returns the new_suggestions keys stored, oldest first, and
// reports an error for any whose title is not the one titles gives it.
func (e *env) notified(titles ...string) []string {
e.t.Helper()
var keys []string
rows := notifytest.Rows(e.t, e.st, notify.NewSuggestions)
for i, r := range rows {
keys = append(keys, r.Key)
if i < len(titles) && r.Payload.Title != titles[i] {
e.t.Errorf("new_suggestions %s title = %q, want %q", r.Key, r.Payload.Title, titles[i])
}
}
return keys
}
// TestSuggestionsWeeklyOneBatchAndNotificationPerWeek: at the seeded
// Sunday 08:00 the schedule starts one scheduled batch a week, period
// suggestions_weekly:YYYY-Www, and each tells the household once, with
// new_suggestions:YYYY-Www counting its new Side quests.
func TestSuggestionsWeeklyOneBatchAndNotificationPerWeek(t *testing.T) {
e := newEnv(t)
e.regulars()
s := e.suggestions()
s.week("Chili Verde", "Chicken Shawarma Bowls")
s.week("Lamb Kofta", "Black Bean Tostadas", "Pork Carnitas")
// Wednesday Oct 7: the Sunday Oct 4 08:00 fire is days ago, missed.
r := e.start(at(2026, 10, 7, 12, 0), s.svc.WeeklySchedule())
r.runUntil(at(2026, 10, 11, 7, 59))
wantBatches(t, "before Sunday 08:00", e.settled())
wantKeys(t, "new_suggestions before Sunday 08:00", e.notified(), nil)
r.runUntil(at(2026, 10, 11, 8, 0))
wantBatches(t, "at Sunday 08:00", e.settled(), suggest.BatchDone)
wantKeys(t, "new_suggestions of W41", e.notified("2 new Side quests are in"), []string{"new_suggestions:2026-W41"})
r.runUntil(at(2026, 10, 18, 7, 59))
wantBatches(t, "rest of W41", e.settled(), suggest.BatchDone)
r.runUntil(at(2026, 10, 18, 9, 0))
wantBatches(t, "W42", e.settled(), suggest.BatchDone, suggest.BatchDone)
wantKeys(t, "new_suggestions of W41 and W42", e.notified("2 new Side quests are in", "3 new Side quests are in"),
[]string{"new_suggestions:2026-W41", "new_suggestions:2026-W42"})
wantKeys(t, "schedule_runs", e.claimed(scheduler.SuggestionsWeekly), []string{"2026-W40", "2026-W41", "2026-W42"})
}
// TestSuggestionsWeeklyFollowsSettings: the schedule fires at the
// household's suggestions_dow and suggestions_time.
func TestSuggestionsWeeklyFollowsSettings(t *testing.T) {
e := newEnv(t)
e.regulars()
e.update(household.SettingsPatch{SuggestionsDow: ptr(3), SuggestionsTime: ptr("18:30")})
s := e.suggestions()
s.week("Chili Verde")
r := e.start(at(2026, 10, 12, 12, 0), s.svc.WeeklySchedule()) // Monday
r.runUntil(at(2026, 10, 14, 18, 29)) // Wednesday
wantBatches(t, "before Wednesday 18:30", e.settled())
r.runUntil(at(2026, 10, 14, 18, 30))
wantBatches(t, "at Wednesday 18:30", e.settled(), suggest.BatchDone)
wantKeys(t, "new_suggestions", e.notified("1 new Side quest is in"), []string{"new_suggestions:2026-W42"})
}
// TestSuggestionsWeeklyAIFailureSendsNothing: when suggest_side_quests
// fails the batch fails with the failure logged and recorded in ai_runs,
// and the household is not notified.
func TestSuggestionsWeeklyAIFailureSendsNothing(t *testing.T) {
e := newEnv(t)
e.regulars()
s := e.suggestions()
s.side.QueueGenerate(llm.GenerateResult{Err: errors.New("provider down")})
r := e.start(at(2026, 10, 11, 7, 0), s.svc.WeeklySchedule())
r.runUntil(at(2026, 10, 11, 8, 0))
wantBatches(t, "after the AI failed", e.settled(), suggest.BatchFailed)
wantKeys(t, "new_suggestions after the AI failed", e.notified(), nil)
// W40 is the boot's missed period, recorded without running.
wantKeys(t, "schedule_runs", e.claimed(scheduler.SuggestionsWeekly), []string{"2026-W40", "2026-W41"})
var status, msg string
if err := e.st.Reader.QueryRow(`SELECT status, COALESCE(error, '') FROM ai_runs WHERE skill = 'suggest_side_quests'`).Scan(&status, &msg); err != nil {
t.Fatalf("ai_runs row of suggest_side_quests: %v", err)
}
if status != string(harness.StatusProviderError) || msg == "" {
t.Errorf("ai_runs suggest_side_quests = %s %q, want %s with its message", status, msg, harness.StatusProviderError)
}
if logs := s.logs.String(); !strings.Contains(logs, "suggestion batch failed") || !strings.Contains(logs, "reason="+suggest.ReasonAIFailed) {
t.Errorf("logs = %s, want the batch failure with reason %s", logs, suggest.ReasonAIFailed)
}
}
// TestSuggestionsWeeklyNoDuplicateAfterRestart: a restart after the week's
// batch ran, still within the catch-up window, starts no second batch and
// sends no second notification: the period is already claimed.
func TestSuggestionsWeeklyNoDuplicateAfterRestart(t *testing.T) {
e := newEnv(t)
e.regulars()
s := e.suggestions()
s.week("Chili Verde", "Chicken Shawarma Bowls")
r := e.start(at(2026, 10, 11, 7, 0), s.svc.WeeklySchedule())
r.runUntil(at(2026, 10, 11, 8, 0))
wantBatches(t, "first process", e.settled(), suggest.BatchDone)
r.stop()
s.stop()
// The restarted process has a new job runner, suggestion service and
// scheduler; it would answer a second batch if one were started.
s2 := e.suggestions()
s2.week("Lamb Kofta")
r2 := e.start(at(2026, 10, 11, 9, 0), s2.svc.WeeklySchedule())
r2.runUntil(at(2026, 10, 18, 7, 59))
wantBatches(t, "after restart", e.settled(), suggest.BatchDone)
wantKeys(t, "new_suggestions after restart", e.notified(), []string{"new_suggestions:2026-W41"})
wantKeys(t, "schedule_runs", e.claimed(scheduler.SuggestionsWeekly), []string{"2026-W40", "2026-W41"})
}
// TestSuggestionsWeeklyCatchUpAfterTheWeekEnds: a Sunday fire caught up on
// Monday, in the next ISO week, still runs W41's batch keyed by W41, and
// the next Sunday's batch notifies for W42.
func TestSuggestionsWeeklyCatchUpAfterTheWeekEnds(t *testing.T) {
e := newEnv(t)
e.regulars()
s := e.suggestions()
s.week("Chili Verde")
s.week("Lamb Kofta")
r := e.start(at(2026, 10, 12, 7, 0), s.svc.WeeklySchedule()) // Monday, 23h late
wantBatches(t, "after catch-up", e.settled(), suggest.BatchDone)
wantKeys(t, "new_suggestions after catch-up", e.notified(), []string{"new_suggestions:2026-W41"})
r.runUntil(at(2026, 10, 18, 8, 0))
wantBatches(t, "W42", e.settled(), suggest.BatchDone, suggest.BatchDone)
wantKeys(t, "new_suggestions", e.notified(), []string{"new_suggestions:2026-W41", "new_suggestions:2026-W42"})
}
// TestSuggestionsWeeklyStartsNoBatchWhileOneIsUnderWay: a period that
// fires while a batch is pending starts no second one and says so.
func TestSuggestionsWeeklyStartsNoBatchWhileOneIsUnderWay(t *testing.T) {
e := newEnv(t)
e.regulars()
s := e.suggestions()
s.stop() // the manual batch stays pending
manual, err := s.svc.StartBatch(context.Background(), suggest.TriggerManual)
if err != nil {
t.Fatal(err)
}
r := e.start(at(2026, 10, 11, 7, 0), s.svc.WeeklySchedule())
r.runUntil(at(2026, 10, 11, 8, 0))
if bs := e.batches(); len(bs) != 1 || bs[0].ID != manual.ID {
t.Fatalf("batches = %+v, want only the manual batch %s", bs, manual.ID)
}
if logs := s.logs.String(); !strings.Contains(logs, "scheduled suggestion batch not started") || !strings.Contains(logs, "period=2026-W41") {
t.Errorf("logs = %s, want the skipped period logged", logs)
}
}
+97 -37
View File
@@ -22,6 +22,9 @@ import (
// BatchJob is the KindSuggestionBatch payload: the batch to run.
type BatchJob struct {
BatchID string `json:"batch_id"`
// Week is the YYYY-MM-DD Monday of the ISO week a scheduled batch is
// for, which keys its new_suggestions; empty for any other trigger.
Week string `json:"week,omitempty"`
}
// Batch stages, as the job's result reports them.
@@ -50,19 +53,22 @@ func (r batchResult) PublicJSON() ([]byte, error) { return json.Marshal(r) }
// wanted at a time, until BatchSize are kept or none are left. A Side quest
// no recipe grounds is dropped; any other grounding failure fails the
// batch, as does suggest_side_quests failing, with no Side quest made up
// instead. Each round's grounded Side quests are stored before the next
// round starts, so the deck fills as the batch goes. The batch ends done
// or failed; Side quests stored before a failure stay in the deck. When
// recording the batch's end fails, so does the job; when it times out at
// shutdown, the job is left running and the batch with it, for the next
// start to fail.
// instead. The batch ends done or failed. When recording the batch's end
// fails, so does the job; when it times out at shutdown, the job is left
// running and the batch with it, for the next start to fail.
//
// A scheduled or manual batch emits new_suggestions, keyed by the batch,
// in the transaction that first adds Side quests to the deck, so the
// household is told of every Side quest stored, whether or not the batch
// then ends done: a later round, or recording the batch's end, may fail.
// A replenish batch, which tops up the deck as the household swipes, never
// notifies. See store.
// When a batch stores its Side quests depends on whether it notifies (see
// noticeOf). A scheduled or manual batch notifies: it holds every round's
// grounded Side quests and, once the last round is done, stores them all
// in one transaction with one new_suggestions counting and naming every
// Side quest stored. So the household is told of exactly what the batch
// added, once; and a batch that fails at any round, including a later
// round's normalize_recipe, stores nothing and sends nothing, leaving no
// Side quest in the deck that was never announced. A replenish batch,
// which tops up the deck as the household swipes and never notifies,
// stores each round's Side quests before the next round starts, so the
// deck fills as it goes; those stored before a failure stay in the deck.
// See store.
func (s *Service) runBatch(ctx context.Context, p BatchJob, publish func(batchResult) error) (batchResult, error) {
batch, err := db.New(s.st.Reader).GetSuggestionBatch(ctx, p.BatchID)
if err != nil {
@@ -78,7 +84,7 @@ func (s *Service) runBatch(ctx context.Context, p BatchJob, publish func(batchRe
if n == 0 {
return batchResult{}, fmt.Errorf("suggest: batch %s is not pending", p.BatchID)
}
res, err := s.fill(ctx, p.BatchID, batch.Trigger != TriggerReplenish, publish)
res, err := s.fill(ctx, batch.Trigger, p, publish)
if err == nil {
if err := s.finishBatch(ctx, p.BatchID, BatchDone); err != nil {
return batchResult{}, err
@@ -98,10 +104,12 @@ func (s *Service) runBatch(ctx context.Context, p BatchJob, publish func(batchRe
return batchResult{}, errors.Join(err, finishErr)
}
// fill does runBatch's work, returning a *jobs.Failure for a failure the
// household may see. When notifies is true, store emits new_suggestions
// with the Side quests it adds.
func (s *Service) fill(ctx context.Context, batchID string, notifies bool, publish func(batchResult) error) (batchResult, error) {
// fill does runBatch's work for job of a batch with trigger, returning a
// *jobs.Failure for a failure the household may see. A batch with a notice
// is stored once, when its last round is done; any other batch round by
// round.
func (s *Service) fill(ctx context.Context, trigger string, job BatchJob, publish func(batchResult) error) (batchResult, error) {
batchID := job.BatchID
res := batchResult{BatchID: batchID, Stage: StageSuggesting, SuggestionIDs: []string{}}
if err := publish(res); err != nil {
return batchResult{}, err
@@ -110,6 +118,10 @@ func (s *Service) fill(ctx context.Context, batchID string, notifies bool, publi
if err != nil {
return batchResult{}, err
}
note, err := noticeOf(trigger, job, in.Context.Today)
if err != nil {
return batchResult{}, err
}
out, err := s.suggest(ctx, batchID, in)
if err != nil {
if ctx.Err() != nil {
@@ -122,34 +134,81 @@ func (s *Service) fill(ctx context.Context, batchID string, notifies bool, publi
if err := publish(res); err != nil {
return batchResult{}, err
}
var week *store.LocalDate
if notifies {
w := household.WeekStart(in.Context.Today)
week = &w
}
left := out.Suggestions
for len(res.SuggestionIDs) < BatchSize && len(left) > 0 {
round := left[:min(BatchSize-len(res.SuggestionIDs), len(left))]
// held are a notifying batch's grounded Side quests, not yet stored.
var held []groundedQuest
for len(res.SuggestionIDs)+len(held) < BatchSize && len(left) > 0 {
round := left[:min(BatchSize-len(res.SuggestionIDs)-len(held), len(left))]
left = left[len(round):]
grounded, err := s.ground(ctx, round)
if err != nil {
return batchResult{}, err
}
res.Dropped += len(round) - len(grounded)
ids, err := s.store(ctx, batchID, week, grounded)
if err != nil {
if note != nil {
held = append(held, grounded...)
} else if err := s.storeInto(ctx, &res, nil, grounded); err != nil {
return batchResult{}, err
}
res.Dropped += len(grounded) - len(ids)
res.SuggestionIDs = append(res.SuggestionIDs, ids...)
if err := publish(res); err != nil {
return batchResult{}, err
}
}
if note != nil {
if err := s.storeInto(ctx, &res, note, held); err != nil {
return batchResult{}, err
}
}
res.Stage = StageDone
return res, nil
}
// storeInto stores quests for res's batch with note (see store) and adds
// the suggestions stored, and the Side quests left out, to res.
func (s *Service) storeInto(ctx context.Context, res *batchResult, note *notice, quests []groundedQuest) error {
ids, err := s.store(ctx, res.BatchID, note, quests)
if err != nil {
return err
}
res.Dropped += len(quests) - len(ids)
res.SuggestionIDs = append(res.SuggestionIDs, ids...)
return nil
}
// notice is how a batch's new_suggestions is keyed: by id, as
// new_suggestions:{id}, for the week starting on week.
type notice struct {
id string
week store.LocalDate
}
// noticeOf returns the notice of job, run for a batch with trigger on
// household-local date today, or nil for a replenish batch, which never
// notifies. A scheduled batch is keyed by the ISO week it was scheduled
// for ("2026-W41"), not the week it happens to run in, so each week's
// schedule tells the household once even when its batch runs late; a
// manual batch is keyed by its batch id, for the week of today. A job
// whose week does not fit its trigger is an error.
func noticeOf(trigger string, job BatchJob, today store.LocalDate) (*notice, error) {
if trigger != TriggerScheduled {
if job.Week != "" {
return nil, fmt.Errorf("suggest: %s batch %s has a week", trigger, job.BatchID)
}
if trigger == TriggerReplenish {
return nil, nil
}
return &notice{id: job.BatchID, week: household.WeekStart(today)}, nil
}
week, err := store.ParseLocalDate(job.Week)
if err != nil {
return nil, fmt.Errorf("suggest: scheduled batch %s week: %w", job.BatchID, err)
}
if household.WeekStart(week) != week {
return nil, fmt.Errorf("suggest: scheduled batch %s week %s is not a Monday", job.BatchID, week)
}
return &notice{id: household.PeriodKey(week), week: week}, nil
}
// request reads what suggest_side_quests is given: the household context,
// the deck's names and every passed name (ListPassedNames). A household with no Regulars
// fails with ReasonNoRegulars.
@@ -224,13 +283,14 @@ func (s *Service) ground(ctx context.Context, quests []skills.SideQuest) ([]grou
// or archived. Passed names are read in the same transaction as the store,
// so a Pass or drop committed before it is never missed.
//
// When week is not nil and a Side quest is stored, new_suggestions:{batchID}
// naming those stored is emitted in the same transaction, for the week
// starting week, so the Side quests and their notification commit together
// When note is not nil and a Side quest is stored, new_suggestions keyed by
// note, counting and naming those stored, is emitted in the same
// transaction, so the Side quests and their notification commit together
// or not at all: a notification that cannot be stored fails the store, and
// the batch with it. The event's key makes the first round to store Side
// quests the one that notifies; a later round's emit stores nothing.
func (s *Service) store(ctx context.Context, batchID string, week *store.LocalDate, quests []groundedQuest) ([]string, error) {
// the batch with it. A notifying batch calls store once, with all its
// Side quests, so the count is the batch's (see runBatch). This is the
// only place new_suggestions is emitted.
func (s *Service) store(ctx context.Context, batchID string, note *notice, quests []groundedQuest) ([]string, error) {
ids := []string{}
var stored []string
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
@@ -298,15 +358,15 @@ func (s *Service) store(ctx context.Context, batchID string, week *store.LocalDa
ids = append(ids, id)
stored = append(stored, d.Name)
}
if week == nil || len(stored) == 0 {
if note == nil || len(stored) == 0 {
return nil
}
return s.notifier.EmitTx(ctx, tx, chat.NewSuggestions(batchID, *week, stored))
return s.notifier.EmitTx(ctx, tx, chat.NewSuggestions(note.id, note.week, stored))
})
if err != nil {
return nil, err
}
if week != nil && len(stored) > 0 {
if note != nil && len(stored) > 0 {
s.notifier.Wake()
}
return ids, nil
+36
View File
@@ -0,0 +1,36 @@
package suggest
import (
"context"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/scheduler"
)
// WeeklySchedule is the suggestions_weekly schedule: at the household's
// suggestions_dow and suggestions_time it starts a scheduled batch for the
// ISO week of the fire date, its period scheduler.Weekly keys
// suggestions_weekly:YYYY-Www. The batch tells the household of the Side
// quests it adds with new_suggestions:YYYY-Www for that week, once; a batch
// that fails before adding any, as when the AI fails, sends nothing. When a
// batch is already pending or running the period starts none, and says so.
func (s *Service) WeeklySchedule() scheduler.Schedule {
return scheduler.Schedule{
Name: scheduler.SuggestionsWeekly,
Rule: func(settings household.Settings) scheduler.Rule {
return scheduler.Weekly(time.Weekday(settings.SuggestionsDow), settings.SuggestionsTime)
},
Run: func(ctx context.Context, p scheduler.Period) error {
b, err := s.StartScheduledBatch(ctx, p.Week)
if err != nil {
return err
}
if !b.Created {
s.logger.WarnContext(ctx, "scheduled suggestion batch not started: a batch is already under way",
"period", p.Key, "batch_id", b.ID, "trigger", b.Trigger)
}
return nil
},
}
}
+29 -9
View File
@@ -19,6 +19,7 @@ import (
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
@@ -154,17 +155,35 @@ type Batch struct {
Created bool `json:"-"`
}
// StartBatch creates a batch with trigger and enqueues the
// StartBatch creates a manual or replenish batch and enqueues the
// KindSuggestionBatch job that runs it, unless a batch is already pending
// or running, which it returns instead with Created false.
// or running, which it returns instead with Created false. A scheduled
// batch is started by StartScheduledBatch.
func (s *Service) StartBatch(ctx context.Context, trigger string) (Batch, error) {
if trigger == TriggerScheduled {
return Batch{}, errors.New("suggest: a scheduled batch is started by StartScheduledBatch")
}
if !slices.Contains(triggers, trigger) {
return Batch{}, fmt.Errorf("suggest: unknown batch trigger %q", trigger)
}
return s.start(ctx, trigger, "")
}
// StartScheduledBatch is StartBatch for a scheduled batch for the ISO week
// starting on Monday week, which keys its new_suggestions (see noticeOf).
func (s *Service) StartScheduledBatch(ctx context.Context, week store.LocalDate) (Batch, error) {
if week.IsZero() || household.WeekStart(week) != week {
return Batch{}, fmt.Errorf("suggest: scheduled batch week %s is not a Monday", week)
}
return s.start(ctx, TriggerScheduled, week.String())
}
// start does StartBatch's work for a batch with trigger and the job week.
func (s *Service) start(ctx context.Context, trigger, week string) (Batch, error) {
var b Batch
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
var err error
b, err = s.startBatch(ctx, tx, trigger)
b, err = s.startBatch(ctx, tx, trigger, week)
return err
})
if err != nil {
@@ -228,10 +247,11 @@ func failedBatch(ctx context.Context, q *db.Queries) (*Batch, error) {
return &Batch{ID: cur.ID, JobID: jobID, Trigger: cur.Trigger, Status: cur.Status}, nil
}
// startBatch does StartBatch's work in tx: the batch and the job that runs
// it are committed together, so a batch is never seen without its job. The
// caller wakes the job runner once tx has committed and Created is true.
func (s *Service) startBatch(ctx context.Context, tx *sql.Tx, trigger string) (Batch, error) {
// startBatch does StartBatch's work in tx, for a batch with trigger whose
// job has week (BatchJob.Week): the batch and the job that runs it are
// committed together, so a batch is never seen without its job. The caller
// wakes the job runner once tx has committed and Created is true.
func (s *Service) startBatch(ctx context.Context, tx *sql.Tx, trigger, week string) (Batch, error) {
q := db.New(tx)
cur, err := s.activeBatch(ctx, q)
if err != nil {
@@ -250,7 +270,7 @@ func (s *Service) startBatch(ctx context.Context, tx *sql.Tx, trigger string) (B
if err != nil {
return Batch{}, fmt.Errorf("suggest: create batch: %w", err)
}
jobID, err := s.batchJob.EnqueueTx(ctx, tx, BatchJob{BatchID: row.ID})
jobID, err := s.batchJob.EnqueueTx(ctx, tx, BatchJob{BatchID: row.ID, Week: week})
if err != nil {
return Batch{}, fmt.Errorf("suggest: enqueue batch %s: %w", row.ID, err)
}
@@ -428,7 +448,7 @@ func (s *Service) decide(ctx context.Context, id, decision, dinnerStatus, update
if left >= ReplenishBelow {
return nil
}
b, err := s.startBatch(ctx, tx, TriggerReplenish)
b, err := s.startBatch(ctx, tx, TriggerReplenish, "")
if err != nil {
return err
}
+178 -56
View File
@@ -300,6 +300,45 @@ func (e *env) runBatch(t *testing.T) batchResult {
return res
}
// scheduledWeek is the week the tests' scheduled batches are for: the
// Monday of 2026-W41.
var scheduledWeek = mustDate("2026-10-05")
func mustDate(s string) store.LocalDate {
d, err := store.ParseLocalDate(s)
if err != nil {
panic(err)
}
return d
}
func mustZone(t *testing.T, name string) *time.Location {
t.Helper()
loc, err := household.LoadZone(name)
if err != nil {
t.Fatal(err)
}
return loc
}
// startBatch starts a batch with trigger, a scheduled one for
// scheduledWeek, and returns it with the key its new_suggestions has.
func (e *env) startBatch(t *testing.T, trigger string) (suggest.Batch, string) {
t.Helper()
if trigger == suggest.TriggerScheduled {
b, err := e.svc.StartScheduledBatch(t.Context(), scheduledWeek)
if err != nil {
t.Fatal(err)
}
return b, "new_suggestions:2026-W41"
}
b, err := e.svc.StartBatch(t.Context(), trigger)
if err != nil {
t.Fatal(err)
}
return b, "new_suggestions:" + b.ID
}
func (e *env) batchStatus(t *testing.T, id string) string {
t.Helper()
var status string
@@ -404,8 +443,9 @@ func TestBatchStoresGroundedSideQuests(t *testing.T) {
}
// TestBatchEmitsNewSuggestions: a scheduled or manual batch that adds Side
// quests stores new_suggestions keyed by the batch, naming them; a
// replenish batch, or one that adds none, stores nothing.
// quests stores new_suggestions counting and naming them, keyed by the
// scheduled ISO week or the manual batch, for that week or the current
// one; a replenish batch, or one that adds none, stores nothing.
func TestBatchEmitsNewSuggestions(t *testing.T) {
for _, tc := range []struct {
trigger string
@@ -428,10 +468,7 @@ func TestBatchEmitsNewSuggestions(t *testing.T) {
sideQuest("Chicken Shawarma Bowls", "Spiced chicken, like your Chicken Fajitas.", "Chicken Fajitas"),
sideQuest("Chili Verde", "Your Beef Chili gone green.", "Beef Chili"),
)
b, err := e.svc.StartBatch(t.Context(), tc.trigger)
if err != nil {
t.Fatal(err)
}
b, key := e.startBatch(t, tc.trigger)
if job := e.waitJob(t, b.JobID); job.Status != jobs.StatusSucceeded {
t.Fatalf("batch job %s", job.Status)
}
@@ -446,64 +483,70 @@ func TestBatchEmitsNewSuggestions(t *testing.T) {
t.Fatalf("new_suggestions rows = %+v, want one on push", rows)
}
r := rows[0]
if r.Key != "new_suggestions:"+b.ID || r.Channel != notify.ChannelPush || r.Payload.URL != "/discover" ||
r.Payload.Title != "New Side quests are in" || r.Payload.Body != "Chicken Shawarma Bowls, Chili Verde" || r.Payload.Week == "" {
t.Errorf("new_suggestions row = %+v, want key new_suggestions:%s naming the Side quests, /discover", r, b.ID)
week := household.WeekStart(store.LocalDateOf(time.Now().In(mustZone(t, "America/New_York")))).String()
if tc.trigger == suggest.TriggerScheduled {
week = scheduledWeek.String()
}
if r.Key != key || r.Channel != notify.ChannelPush || r.Payload.URL != "/discover" ||
r.Payload.Title != "2 new Side quests are in" || r.Payload.Body != "Chicken Shawarma Bowls, Chili Verde" || r.Payload.Week != week {
t.Errorf("new_suggestions row = %+v, want key %s counting and naming the Side quests, week %s, /discover", r, key, week)
}
})
}
}
// TestBatchNotifiesAsSideQuestsAreStored: new_suggestions is stored with
// the first round to add Side quests, so a batch whose later round fails
// still tells the household of those in the deck, once; a later round that
// succeeds adds no second notification; a replenish batch notifies nothing.
func TestBatchNotifiesAsSideQuestsAreStored(t *testing.T) {
// twoRoundBatch scripts a suggest_side_quests reply of BatchSize+1 Side
// quests, Fajita Dish A to F, whose first round grounds all but B, so a
// second round grounds F. Unless lastGrounds, normalize_recipe fails for
// F.
func (e *env) twoRoundBatch(t *testing.T, lastGrounds bool) {
t.Helper()
var quests []string
for i := range suggest.BatchSize + 1 {
name := fmt.Sprintf("Fajita Dish %c", 'A'+i)
e.search.Results[name] = nil
switch {
case i == suggest.BatchSize && !lastGrounds:
// A page is found, but no normalize_recipe reply is scripted
// for it, so the AI call grounding it fails.
slug := strings.ReplaceAll(strings.ToLower(name), " ", "-")
page, photo := "https://r.example/"+slug, "https://r.example/"+slug+".jpg"
e.search.Results[name] = []recipes.Candidate{{URL: page}}
e.pages.Bodies[page] = recipestest.RecipePage(name, "R Kitchen", photo, 35)
e.images.JPEGs[photo] = recipestest.JPEG(t, 400, 320, uint8(len(e.images.JPEGs)+1))
case i != 1:
e.recipe(t, name)
}
quests = append(quests, sideQuest(name, "Like your Chicken Fajitas.", "Chicken Fajitas"))
}
e.reply(quests...)
}
// TestBatchNotifiesOnceWithEverySideQuestStored: a batch that fills across
// two grounding rounds stores new_suggestions once, when it completes,
// counting and naming every Side quest it stored, not just the first
// round's; a replenish batch notifies nothing.
func TestBatchNotifiesOnceWithEverySideQuestStored(t *testing.T) {
for _, tc := range []struct {
trigger string
lastFails bool
notify bool
trigger string
notify bool
}{
{suggest.TriggerScheduled, true, true},
{suggest.TriggerManual, true, true},
{suggest.TriggerManual, false, true},
{suggest.TriggerReplenish, true, false},
{suggest.TriggerScheduled, true},
{suggest.TriggerManual, true},
{suggest.TriggerReplenish, false},
} {
t.Run(fmt.Sprintf("%s last round fails %v", tc.trigger, tc.lastFails), func(t *testing.T) {
t.Run(tc.trigger, func(t *testing.T) {
e := newEnv(t)
e.start(t)
// The first round grounds BatchSize Side quests but B; the
// second round grounds F.
var quests []string
for i := range suggest.BatchSize + 1 {
name := fmt.Sprintf("Fajita Dish %c", 'A'+i)
e.search.Results[name] = nil
if i != 1 {
e.recipe(t, name)
}
quests = append(quests, sideQuest(name, "Like your Chicken Fajitas.", "Chicken Fajitas"))
}
e.reply(quests...)
if tc.lastFails {
if _, err := e.st.Writer.ExecContext(t.Context(), `CREATE TRIGGER refuse_f BEFORE INSERT ON dinners WHEN NEW.name = 'Fajita Dish F' BEGIN SELECT RAISE(ABORT, 'F refused'); END`); err != nil {
t.Fatal(err)
}
}
e.twoRoundBatch(t, true)
b, err := e.svc.StartBatch(t.Context(), tc.trigger)
if err != nil {
t.Fatal(err)
b, key := e.startBatch(t, tc.trigger)
if job := e.waitJob(t, b.JobID); job.Status != jobs.StatusSucceeded {
t.Fatalf("batch job %s, want succeeded", job.Status)
}
job := e.waitJob(t, b.JobID)
want, deck := jobs.StatusSucceeded, suggest.BatchSize
if tc.lastFails {
want, deck = jobs.StatusFailed, suggest.BatchSize-1
}
if job.Status != want {
t.Fatalf("batch job %s, want %s", job.Status, want)
}
if got := e.deckNames(t); len(got) != deck {
t.Fatalf("deck = %v, want %d Side quests", got, deck)
deck := e.deckNames(t)
if want := []string{"Fajita Dish A", "Fajita Dish C", "Fajita Dish D", "Fajita Dish E", "Fajita Dish F"}; !slices.Equal(deck, want) {
t.Fatalf("deck = %v, want %v", deck, want)
}
rows := notifytest.Rows(t, e.st, notify.NewSuggestions)
if !tc.notify {
@@ -512,9 +555,47 @@ func TestBatchNotifiesAsSideQuestsAreStored(t *testing.T) {
}
return
}
if len(rows) != 1 || rows[0].Key != "new_suggestions:"+b.ID ||
rows[0].Payload.Body != "Fajita Dish A, Fajita Dish C, Fajita Dish D + 1 more" {
t.Errorf("new_suggestions rows = %+v, want one keyed by batch %s naming the first round", rows, b.ID)
want := fmt.Sprintf("%d new Side quests are in", len(deck))
if len(rows) != 1 || rows[0].Key != key || rows[0].Payload.Title != want ||
rows[0].Payload.Body != "Fajita Dish A, Fajita Dish C, Fajita Dish D + 2 more" {
t.Errorf("new_suggestions rows = %+v, want one keyed %s titled %q", rows, key, want)
}
})
}
}
// TestBatchFailingInALaterRoundStoresNothing: when normalize_recipe fails
// in a batch's second grounding round, a scheduled or manual batch stores
// none of the first round's Side quests and sends no new_suggestions, so
// no Side quest is left in the deck unannounced; a replenish batch, which
// never notifies, keeps the first round's in the deck.
func TestBatchFailingInALaterRoundStoresNothing(t *testing.T) {
for _, tc := range []struct {
trigger string
deck int
}{
{suggest.TriggerScheduled, 0},
{suggest.TriggerManual, 0},
{suggest.TriggerReplenish, suggest.BatchSize - 1},
} {
t.Run(tc.trigger, func(t *testing.T) {
e := newEnv(t)
e.start(t)
e.twoRoundBatch(t, false)
b, _ := e.startBatch(t, tc.trigger)
job := e.waitJob(t, b.JobID)
if job.Status != jobs.StatusFailed || job.Error == nil || *job.Error != suggest.ReasonAIFailed {
t.Fatalf("batch job %s, error %+v, want failed %s", job.Status, job.Error, suggest.ReasonAIFailed)
}
if got := e.batchStatus(t, b.ID); got != suggest.BatchFailed {
t.Errorf("batch status = %s, want failed", got)
}
if got := e.deckNames(t); len(got) != tc.deck {
t.Errorf("deck = %v, want %d Side quests", got, tc.deck)
}
if rows := notifytest.Rows(t, e.st, notify.NewSuggestions); len(rows) != 0 {
t.Errorf("new_suggestions rows = %+v, want none", rows)
}
})
}
@@ -1299,7 +1380,7 @@ func TestBatchJobFailsWhenRecordingItsEndFails(t *testing.T) {
t.Errorf("batch status = %s with its job failed, want failed", got)
}
// Its Side quests stay in the deck, and the household was told of them.
if rows := notifytest.Rows(t, e.st, notify.NewSuggestions); len(rows) != 1 || rows[0].Key != "new_suggestions:"+b.ID || rows[0].Payload.Body != "Chili Verde" {
if rows := notifytest.Rows(t, e.st, notify.NewSuggestions); len(rows) != 1 || rows[0].Key != "new_suggestions:"+b.ID || rows[0].Payload.Title != "1 new Side quest is in" || rows[0].Payload.Body != "Chili Verde" {
t.Errorf("new_suggestions rows of a batch whose end was not recorded = %+v, want one naming Chili Verde", rows)
}
}
@@ -1474,6 +1555,47 @@ func TestStartBatchRefusesUnknownTrigger(t *testing.T) {
}
}
// TestScheduledBatchNeedsItsWeek: a scheduled batch is started only by
// StartScheduledBatch, for a Monday.
func TestScheduledBatchNeedsItsWeek(t *testing.T) {
e := newEnv(t)
if _, err := e.svc.StartBatch(t.Context(), suggest.TriggerScheduled); err == nil {
t.Error("StartBatch started a scheduled batch with no week")
}
for _, week := range []store.LocalDate{{}, mustDate("2026-10-06")} {
if _, err := e.svc.StartScheduledBatch(t.Context(), week); err == nil {
t.Errorf("StartScheduledBatch(%q) accepted a week that is not a Monday", week)
}
}
if n := e.activeBatches(t); n != 0 {
t.Errorf("%d active batches, want none", n)
}
}
// TestScheduledBatchKeyedByItsWeekWhenRunLate: a scheduled batch's
// new_suggestions is keyed by the week it was scheduled for, not the week
// it runs in, so a batch caught up after its week ended still tells the
// household of that week's Side quests and leaves the next week's key free.
func TestScheduledBatchKeyedByItsWeekWhenRunLate(t *testing.T) {
e := newEnv(t)
e.start(t)
e.recipe(t, "Chili Verde")
e.reply(sideQuest("Chili Verde", "Your Beef Chili gone green.", "Beef Chili"))
// A week long past: the batch runs today, weeks later.
b, err := e.svc.StartScheduledBatch(t.Context(), mustDate("2026-01-05"))
if err != nil {
t.Fatal(err)
}
if job := e.waitJob(t, b.JobID); job.Status != jobs.StatusSucceeded {
t.Fatalf("batch job %s", job.Status)
}
rows := notifytest.Rows(t, e.st, notify.NewSuggestions)
if len(rows) != 1 || rows[0].Key != "new_suggestions:2026-W02" || rows[0].Payload.Week != "2026-01-05" ||
rows[0].Payload.Title != "1 new Side quest is in" {
t.Errorf("new_suggestions rows = %+v, want one keyed new_suggestions:2026-W02 for 2026-01-05", rows)
}
}
// noCalendar is a plan.Calendar for tests that never push.
type noCalendar struct{}