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
+3
View File
@@ -192,6 +192,9 @@ func run(cfg config.Config, logger *slog.Logger) error {
if err != nil { if err != nil {
return err return err
} }
if err := sched.Register(suggestSvc.WeeklySchedule()); err != nil {
return err
}
router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, settings, search, ai, chatSvc, dinnerSvc, suggestSvc, planSvc, shopSvc, googleSvc, pushSvc, ideasSvc, runner, photos, frontend.Assets()) router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, settings, search, ai, chatSvc, dinnerSvc, suggestSvc, planSvc, shopSvc, googleSvc, pushSvc, ideasSvc, runner, photos, frontend.Assets())
if err != nil { if err != nil {
+39 -18
View File
@@ -309,20 +309,24 @@ automatic** (D-021).
`suggest_side_quests` for 5 Side quests (scope: the batch id), then `suggest_side_quests` for 5 Side quests (scope: the batch id), then
grounds the survivors in the model's order with `Grounder.GroundMany`, grounds the survivors in the model's order with `Grounder.GroundMany`,
as many as are still wanted at a time, until 5 are kept or none are as many as are still wanted at a time, until 5 are kept or none are
left. A Side quest no recipe grounds is dropped; each round's grounded left. A Side quest no recipe grounds is dropped. A `replenish` batch
ones are stored before the next — a dinner with status `suggested`, stores each round's grounded ones before the next; a `scheduled` or
origin `side_quest`, grounding_status `grounded` with the recipe's `manual` batch, which notifies, holds them and stores them all in one
recipe_id, photo_id, method, minutes_total and difficulty and the transaction once its last round is done. Each is stored as a dinner
model's cuisine, and a `pending` suggestion with the name it is with status `suggested`, origin `side_quest`, grounding_status
suggested under, the reason and `cited_regular_ids` — unless, since the `grounded` with the recipe's recipe_id, photo_id, method,
job read its input, a dinner it repeats was added, a suggestion it minutes_total and difficulty and the model's cuisine, and a `pending`
repeats passed, or a Regular it cites renamed or archived (checked in suggestion with the name it is suggested under, the reason and
the storing transaction). The job publishes `cited_regular_ids` — unless, since the job read its input, a dinner
`{batch_id, stage, suggestion_ids, dropped}` as it goes (`suggesting`, it repeats was added, a suggestion it repeats passed, or a Regular it
cites renamed or archived (checked in the storing transaction). The job
publishes `{batch_id, stage, suggestion_ids, dropped}` as it goes (`suggesting`,
`grounding`, `done`). It fails with `no_regulars`, `ai_failed` (the `grounding`, `done`). It fails with `no_regulars`, `ai_failed` (the
skill or `normalize_recipe` failed; no Side quest is made up instead, skill or `normalize_recipe` failed; no Side quest is made up instead,
D-017), `search_not_configured` or `search_failed`, and the batch is set D-017), `search_not_configured` or `search_failed`, and the batch is set
`failed`; Side quests stored before the failure stay in the deck. If `failed`. A failed `scheduled` or `manual` batch has stored nothing; a
failed `replenish` batch's Side quests stored before the failure stay
in the deck. If
recording the batch's end fails, its job fails; the job runner retries recording the batch's end fails, its job fails; the job runner retries
a job's final status write with backoff until it lands, each attempt a job's final status write with backoff until it lands, each attempt
bounded at 5 seconds; at shutdown an attempt that fails or times out is bounded at 5 seconds; at shutdown an attempt that fails or times out is
@@ -775,7 +779,7 @@ purge are fixed and not configurable.
| Schedule | When | Does | | Schedule | When | Does |
| --- | --- | --- | | --- | --- | --- |
| `suggestions_weekly` | Sunday 08:00 | New batch of Side quests. | | `suggestions_weekly` | Sunday 08:00 | New batch of Side quests (`suggest.Service.WeeklySchedule`). |
| `plan_draft` | Friday 09:00 | AI drafts next week's plan. | | `plan_draft` | Friday 09:00 | AI drafts next week's plan. |
| `shopping_chat_autosend` | Saturday 08:00 | Sends the Supply run to Google Chat. | | `shopping_chat_autosend` | Saturday 08:00 | Sends the Supply run to Google Chat. |
| `day_of_reminder` | Daily 16:30 | Tonight's dinner reminder. | | `day_of_reminder` | Daily 16:30 | Tonight's dinner reminder. |
@@ -803,6 +807,16 @@ claimed without running (logged as missed) so it never runs later. DST
follows `household.LocalTime`: a skipped time fires later by the gap, a follows `household.LocalTime`: a skipped time fires later by the gap, a
repeated time fires once, at its first occurrence. repeated time fires once, at its first occurrence.
Each schedule is built by the service that does its work, as a
`scheduler.Schedule` value, and registered in `cmd/server` before the
scheduler starts: `suggest.Service.WeeklySchedule()` is `suggestions_weekly`.
Its run calls `StartScheduledBatch(period.Week)`, which starts the same
batch and job as `POST /api/v1/suggestions/batches` (`StartBatch(manual)`),
with the `scheduled` trigger, and returns at once; the batch runs as a job. When a
batch is already pending or running the period starts none and logs it.
Fake-clock tests of a schedule live in `internal/scheduler`, where
`SetClock` is visible to tests.
## Notifications ## Notifications
- Events: `plan_ready`, `list_ready`, `day_of_reminder`, `new_suggestions`, - Events: `plan_ready`, `list_ready`, `day_of_reminder`, `new_suggestions`,
@@ -820,12 +834,19 @@ repeated time fires once, at its first occurrence.
- `list_ready:{weekStart}` (URL `/plan/shopping`) when a week's Supply - `list_ready:{weekStart}` (URL `/plan/shopping`) when a week's Supply
run is first created, by `POST /shopping/{weekStart}/generate` or run is first created, by `POST /shopping/{weekStart}/generate` or
after Push to Calendar; remaking it does not notify. after Push to Calendar; remaking it does not notify.
- `new_suggestions:{batchId}` (URL `/discover`) when a `scheduled` or - `new_suggestions:{YYYY-Www}` for a `scheduled` batch, the ISO week it
`manual` suggestion batch first adds Side quests to the deck, in the was scheduled for (carried in its job, so a batch caught up late is
transaction storing them, so they are announced even when a later still keyed by its own week), or `new_suggestions:{batchId}` for a
round or recording the batch's end fails; it names that round's Side `manual` one (URL `/discover`), once, when the batch's last round is
quests, and a later round's emit stores nothing. A `replenish` batch done, in the one transaction that stores all its Side quests, so they
never notifies. and their notification commit together or not at all. Its title
counts every Side quest the batch stored ("5 new Side quests are in")
and its body names them. A batch that fails at any round, as when
`suggest_side_quests` or a later round's `normalize_recipe` fails (the
failure is in `ai_runs`), stores no Side quest and sends nothing, so
no stored Side quest goes unannounced. A `replenish` batch never
notifies. This is the only place `new_suggestions` is emitted; the
`suggestions_weekly` schedule does not emit it itself.
- `day_of_reminder:{date}` (URL `/dinner/{id}`) through - `day_of_reminder:{date}` (URL `/dinner/{id}`) through
`plan.Service.EmitDayOfReminder(date)`, which names the dinner planned `plan.Service.EmitDayOfReminder(date)`, which names the dinner planned
on the date and emits nothing for a week with no plan. The scheduler on the date and emits nothing for a week with no plan. The scheduler
+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, // NewSuggestions is the new_suggestions event keyed by id, the ISO week
// made in the week starting on week, naming its Side quests. // ("2026-W41") of a scheduled suggestion batch or the batch id of a manual
func NewSuggestions(batchID string, week store.LocalDate, names []string) notify.Event { // 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{ return notify.Event{
Type: notify.NewSuggestions, Type: notify.NewSuggestions,
Key: string(notify.NewSuggestions) + ":" + batchID, Key: string(notify.NewSuggestions) + ":" + id,
Title: "New Side quests are in", Title: count(len(names), "new Side quest is in", "new Side quests are in"),
Body: nameList(names), Body: nameList(names),
URL: "/discover", URL: "/discover",
Week: week, Week: week,
+12 -2
View File
@@ -110,9 +110,19 @@ func TestMessages(t *testing.T) {
key: "new_suggestions:01J9BATCH", key: "new_suggestions:01J9BATCH",
url: "/discover", url: "/discover",
week: "2026-10-12", week: "2026-10-12",
title: "New Side quests are in", title: "5 new Side quests are in",
body: "Lemon orzo, Bibimbap, Shakshuka + 2 more", 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", 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. // BatchJob is the KindSuggestionBatch payload: the batch to run.
type BatchJob struct { type BatchJob struct {
BatchID string `json:"batch_id"` 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. // 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 // 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 // no recipe grounds is dropped; any other grounding failure fails the
// batch, as does suggest_side_quests failing, with no Side quest made up // 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 // instead. The batch ends done or failed. When recording the batch's end
// round starts, so the deck fills as the batch goes. The batch ends done // fails, so does the job; when it times out at shutdown, the job is left
// or failed; Side quests stored before a failure stay in the deck. When // running and the batch with it, for the next start to fail.
// 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, // When a batch stores its Side quests depends on whether it notifies (see
// in the transaction that first adds Side quests to the deck, so the // noticeOf). A scheduled or manual batch notifies: it holds every round's
// household is told of every Side quest stored, whether or not the batch // grounded Side quests and, once the last round is done, stores them all
// then ends done: a later round, or recording the batch's end, may fail. // in one transaction with one new_suggestions counting and naming every
// A replenish batch, which tops up the deck as the household swipes, never // Side quest stored. So the household is told of exactly what the batch
// notifies. See store. // 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) { 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) batch, err := db.New(s.st.Reader).GetSuggestionBatch(ctx, p.BatchID)
if err != nil { if err != nil {
@@ -78,7 +84,7 @@ func (s *Service) runBatch(ctx context.Context, p BatchJob, publish func(batchRe
if n == 0 { if n == 0 {
return batchResult{}, fmt.Errorf("suggest: batch %s is not pending", p.BatchID) 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 == nil {
if err := s.finishBatch(ctx, p.BatchID, BatchDone); err != nil { if err := s.finishBatch(ctx, p.BatchID, BatchDone); err != nil {
return batchResult{}, err 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) return batchResult{}, errors.Join(err, finishErr)
} }
// fill does runBatch's work, returning a *jobs.Failure for a failure the // fill does runBatch's work for job of a batch with trigger, returning a
// household may see. When notifies is true, store emits new_suggestions // *jobs.Failure for a failure the household may see. A batch with a notice
// with the Side quests it adds. // is stored once, when its last round is done; any other batch round by
func (s *Service) fill(ctx context.Context, batchID string, notifies bool, publish func(batchResult) error) (batchResult, error) { // 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{}} res := batchResult{BatchID: batchID, Stage: StageSuggesting, SuggestionIDs: []string{}}
if err := publish(res); err != nil { if err := publish(res); err != nil {
return batchResult{}, err return batchResult{}, err
@@ -110,6 +118,10 @@ func (s *Service) fill(ctx context.Context, batchID string, notifies bool, publi
if err != nil { if err != nil {
return batchResult{}, err return batchResult{}, err
} }
note, err := noticeOf(trigger, job, in.Context.Today)
if err != nil {
return batchResult{}, err
}
out, err := s.suggest(ctx, batchID, in) out, err := s.suggest(ctx, batchID, in)
if err != nil { if err != nil {
if ctx.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 { if err := publish(res); err != nil {
return batchResult{}, err return batchResult{}, err
} }
var week *store.LocalDate
if notifies {
w := household.WeekStart(in.Context.Today)
week = &w
}
left := out.Suggestions left := out.Suggestions
for len(res.SuggestionIDs) < BatchSize && len(left) > 0 { // held are a notifying batch's grounded Side quests, not yet stored.
round := left[:min(BatchSize-len(res.SuggestionIDs), len(left))] 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):] left = left[len(round):]
grounded, err := s.ground(ctx, round) grounded, err := s.ground(ctx, round)
if err != nil { if err != nil {
return batchResult{}, err return batchResult{}, err
} }
res.Dropped += len(round) - len(grounded) res.Dropped += len(round) - len(grounded)
ids, err := s.store(ctx, batchID, week, grounded) if note != nil {
if err != nil { held = append(held, grounded...)
} else if err := s.storeInto(ctx, &res, nil, grounded); err != nil {
return batchResult{}, err return batchResult{}, err
} }
res.Dropped += len(grounded) - len(ids)
res.SuggestionIDs = append(res.SuggestionIDs, ids...)
if err := publish(res); err != nil { if err := publish(res); err != nil {
return batchResult{}, err return batchResult{}, err
} }
} }
if note != nil {
if err := s.storeInto(ctx, &res, note, held); err != nil {
return batchResult{}, err
}
}
res.Stage = StageDone res.Stage = StageDone
return res, nil 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, // 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 // the deck's names and every passed name (ListPassedNames). A household with no Regulars
// fails with ReasonNoRegulars. // 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, // or archived. Passed names are read in the same transaction as the store,
// so a Pass or drop committed before it is never missed. // 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} // When note is not nil and a Side quest is stored, new_suggestions keyed by
// naming those stored is emitted in the same transaction, for the week // note, counting and naming those stored, is emitted in the same
// starting week, so the Side quests and their notification commit together // transaction, so the Side quests and their notification commit together
// or not at all: a notification that cannot be stored fails the store, and // 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 // the batch with it. A notifying batch calls store once, with all its
// quests the one that notifies; a later round's emit stores nothing. // Side quests, so the count is the batch's (see runBatch). This is the
func (s *Service) store(ctx context.Context, batchID string, week *store.LocalDate, quests []groundedQuest) ([]string, error) { // only place new_suggestions is emitted.
func (s *Service) store(ctx context.Context, batchID string, note *notice, quests []groundedQuest) ([]string, error) {
ids := []string{} ids := []string{}
var stored []string var stored []string
err := s.st.WithTx(ctx, func(tx *sql.Tx) error { 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) ids = append(ids, id)
stored = append(stored, d.Name) stored = append(stored, d.Name)
} }
if week == nil || len(stored) == 0 { if note == nil || len(stored) == 0 {
return nil 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 { if err != nil {
return nil, err return nil, err
} }
if week != nil && len(stored) > 0 { if note != nil && len(stored) > 0 {
s.notifier.Wake() s.notifier.Wake()
} }
return ids, nil 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/ai/skills"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db" "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/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/jobs"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
@@ -154,17 +155,35 @@ type Batch struct {
Created bool `json:"-"` 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 // 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) { 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) { if !slices.Contains(triggers, trigger) {
return Batch{}, fmt.Errorf("suggest: unknown batch trigger %q", 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 var b Batch
err := s.st.WithTx(ctx, func(tx *sql.Tx) error { err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
var err error var err error
b, err = s.startBatch(ctx, tx, trigger) b, err = s.startBatch(ctx, tx, trigger, week)
return err return err
}) })
if err != nil { 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 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 // startBatch does StartBatch's work in tx, for a batch with trigger whose
// it are committed together, so a batch is never seen without its job. The // job has week (BatchJob.Week): the batch and the job that runs it are
// caller wakes the job runner once tx has committed and Created is true. // committed together, so a batch is never seen without its job. The caller
func (s *Service) startBatch(ctx context.Context, tx *sql.Tx, trigger string) (Batch, error) { // 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) q := db.New(tx)
cur, err := s.activeBatch(ctx, q) cur, err := s.activeBatch(ctx, q)
if err != nil { if err != nil {
@@ -250,7 +270,7 @@ func (s *Service) startBatch(ctx context.Context, tx *sql.Tx, trigger string) (B
if err != nil { if err != nil {
return Batch{}, fmt.Errorf("suggest: create batch: %w", err) 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 { if err != nil {
return Batch{}, fmt.Errorf("suggest: enqueue batch %s: %w", row.ID, err) 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 { if left >= ReplenishBelow {
return nil return nil
} }
b, err := s.startBatch(ctx, tx, TriggerReplenish) b, err := s.startBatch(ctx, tx, TriggerReplenish, "")
if err != nil { if err != nil {
return err return err
} }
+178 -56
View File
@@ -300,6 +300,45 @@ func (e *env) runBatch(t *testing.T) batchResult {
return res 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 { func (e *env) batchStatus(t *testing.T, id string) string {
t.Helper() t.Helper()
var status string var status string
@@ -404,8 +443,9 @@ func TestBatchStoresGroundedSideQuests(t *testing.T) {
} }
// TestBatchEmitsNewSuggestions: a scheduled or manual batch that adds Side // TestBatchEmitsNewSuggestions: a scheduled or manual batch that adds Side
// quests stores new_suggestions keyed by the batch, naming them; a // quests stores new_suggestions counting and naming them, keyed by the
// replenish batch, or one that adds none, stores nothing. // 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) { func TestBatchEmitsNewSuggestions(t *testing.T) {
for _, tc := range []struct { for _, tc := range []struct {
trigger string trigger string
@@ -428,10 +468,7 @@ func TestBatchEmitsNewSuggestions(t *testing.T) {
sideQuest("Chicken Shawarma Bowls", "Spiced chicken, like your Chicken Fajitas.", "Chicken Fajitas"), sideQuest("Chicken Shawarma Bowls", "Spiced chicken, like your Chicken Fajitas.", "Chicken Fajitas"),
sideQuest("Chili Verde", "Your Beef Chili gone green.", "Beef Chili"), sideQuest("Chili Verde", "Your Beef Chili gone green.", "Beef Chili"),
) )
b, err := e.svc.StartBatch(t.Context(), tc.trigger) b, key := e.startBatch(t, tc.trigger)
if err != nil {
t.Fatal(err)
}
if job := e.waitJob(t, b.JobID); job.Status != jobs.StatusSucceeded { if job := e.waitJob(t, b.JobID); job.Status != jobs.StatusSucceeded {
t.Fatalf("batch job %s", job.Status) 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) t.Fatalf("new_suggestions rows = %+v, want one on push", rows)
} }
r := rows[0] r := rows[0]
if r.Key != "new_suggestions:"+b.ID || r.Channel != notify.ChannelPush || r.Payload.URL != "/discover" || week := household.WeekStart(store.LocalDateOf(time.Now().In(mustZone(t, "America/New_York")))).String()
r.Payload.Title != "New Side quests are in" || r.Payload.Body != "Chicken Shawarma Bowls, Chili Verde" || r.Payload.Week == "" { if tc.trigger == suggest.TriggerScheduled {
t.Errorf("new_suggestions row = %+v, want key new_suggestions:%s naming the Side quests, /discover", r, b.ID) 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 // twoRoundBatch scripts a suggest_side_quests reply of BatchSize+1 Side
// the first round to add Side quests, so a batch whose later round fails // quests, Fajita Dish A to F, whose first round grounds all but B, so a
// still tells the household of those in the deck, once; a later round that // second round grounds F. Unless lastGrounds, normalize_recipe fails for
// succeeds adds no second notification; a replenish batch notifies nothing. // F.
func TestBatchNotifiesAsSideQuestsAreStored(t *testing.T) { 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 { for _, tc := range []struct {
trigger string trigger string
lastFails bool notify bool
notify bool
}{ }{
{suggest.TriggerScheduled, true, true}, {suggest.TriggerScheduled, true},
{suggest.TriggerManual, true, true}, {suggest.TriggerManual, true},
{suggest.TriggerManual, false, true}, {suggest.TriggerReplenish, false},
{suggest.TriggerReplenish, true, 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 := newEnv(t)
e.start(t) e.start(t)
// The first round grounds BatchSize Side quests but B; the e.twoRoundBatch(t, true)
// 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)
}
}
b, err := e.svc.StartBatch(t.Context(), tc.trigger) b, key := e.startBatch(t, tc.trigger)
if err != nil { if job := e.waitJob(t, b.JobID); job.Status != jobs.StatusSucceeded {
t.Fatal(err) t.Fatalf("batch job %s, want succeeded", job.Status)
} }
job := e.waitJob(t, b.JobID) deck := e.deckNames(t)
want, deck := jobs.StatusSucceeded, suggest.BatchSize if want := []string{"Fajita Dish A", "Fajita Dish C", "Fajita Dish D", "Fajita Dish E", "Fajita Dish F"}; !slices.Equal(deck, want) {
if tc.lastFails { t.Fatalf("deck = %v, want %v", deck, want)
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)
} }
rows := notifytest.Rows(t, e.st, notify.NewSuggestions) rows := notifytest.Rows(t, e.st, notify.NewSuggestions)
if !tc.notify { if !tc.notify {
@@ -512,9 +555,47 @@ func TestBatchNotifiesAsSideQuestsAreStored(t *testing.T) {
} }
return return
} }
if len(rows) != 1 || rows[0].Key != "new_suggestions:"+b.ID || want := fmt.Sprintf("%d new Side quests are in", len(deck))
rows[0].Payload.Body != "Fajita Dish A, Fajita Dish C, Fajita Dish D + 1 more" { if len(rows) != 1 || rows[0].Key != key || rows[0].Payload.Title != want ||
t.Errorf("new_suggestions rows = %+v, want one keyed by batch %s naming the first round", rows, b.ID) 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) 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. // 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) 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. // noCalendar is a plan.Calendar for tests that never push.
type noCalendar struct{} type noCalendar struct{}