diff --git a/cmd/server/main.go b/cmd/server/main.go index a97ca26..bec36ad 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -192,6 +192,9 @@ func run(cfg config.Config, logger *slog.Logger) error { if err != nil { 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()) if err != nil { diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 18f0724..c4db4a0 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -309,20 +309,24 @@ automatic** (D-021). `suggest_side_quests` for 5 Side quests (scope: the batch id), then 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 - left. A Side quest no recipe grounds is dropped; each round's grounded - ones are stored before the next — a dinner with status `suggested`, - origin `side_quest`, grounding_status `grounded` with the recipe's - recipe_id, photo_id, method, minutes_total and difficulty and the - model's cuisine, and a `pending` suggestion with the name it is - suggested under, the reason and `cited_regular_ids` — unless, since the - job read its input, a dinner 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`, + left. A Side quest no recipe grounds is dropped. A `replenish` batch + stores each round's grounded ones before the next; a `scheduled` or + `manual` batch, which notifies, holds them and stores them all in one + transaction once its last round is done. Each is stored as a dinner + with status `suggested`, origin `side_quest`, grounding_status + `grounded` with the recipe's recipe_id, photo_id, method, + minutes_total and difficulty and the model's cuisine, and a `pending` + suggestion with the name it is suggested under, the reason and + `cited_regular_ids` — unless, since the job read its input, a dinner + 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 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 - `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 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 @@ -775,7 +779,7 @@ purge are fixed and not configurable. | 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. | | `shopping_chat_autosend` | Saturday 08:00 | Sends the Supply run to Google Chat. | | `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 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 - 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 run is first created, by `POST /shopping/{weekStart}/generate` or after Push to Calendar; remaking it does not notify. - - `new_suggestions:{batchId}` (URL `/discover`) when a `scheduled` or - `manual` suggestion batch first adds Side quests to the deck, in the - transaction storing them, so they are announced even when a later - round or recording the batch's end fails; it names that round's Side - quests, and a later round's emit stores nothing. A `replenish` batch - never notifies. + - `new_suggestions:{YYYY-Www}` for a `scheduled` batch, the ISO week it + was scheduled for (carried in its job, so a batch caught up late is + still keyed by its own week), or `new_suggestions:{batchId}` for a + `manual` one (URL `/discover`), once, when the batch's last round is + done, in the one transaction that stores all its Side quests, so they + 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 `plan.Service.EmitDayOfReminder(date)`, which names the dinner planned on the date and emits nothing for a week with no plan. The scheduler diff --git a/internal/chat/messages.go b/internal/chat/messages.go index eadf111..b358616 100644 --- a/internal/chat/messages.go +++ b/internal/chat/messages.go @@ -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, diff --git a/internal/chat/messages_test.go b/internal/chat/messages_test.go index 5a9f6a3..44becb9 100644 --- a/internal/chat/messages_test.go +++ b/internal/chat/messages_test.go @@ -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", diff --git a/internal/scheduler/suggestions_test.go b/internal/scheduler/suggestions_test.go new file mode 100644 index 0000000..c40e1c8 --- /dev/null +++ b/internal/scheduler/suggestions_test.go @@ -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) + } +} diff --git a/internal/suggest/batch.go b/internal/suggest/batch.go index 00f26b0..a6e130c 100644 --- a/internal/suggest/batch.go +++ b/internal/suggest/batch.go @@ -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 ¬ice{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 ¬ice{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 diff --git a/internal/suggest/schedule.go b/internal/suggest/schedule.go new file mode 100644 index 0000000..ca20b22 --- /dev/null +++ b/internal/suggest/schedule.go @@ -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 + }, + } +} diff --git a/internal/suggest/suggest.go b/internal/suggest/suggest.go index 8042a5f..6b3c1f5 100644 --- a/internal/suggest/suggest.go +++ b/internal/suggest/suggest.go @@ -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 } diff --git a/internal/suggest/suggest_test.go b/internal/suggest/suggest_test.go index 4a0eea7..2caa7cf 100644 --- a/internal/suggest/suggest_test.go +++ b/internal/suggest/suggest_test.go @@ -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{}