Compare commits

...
Author SHA1 Message Date
dev_agentandClaude Opus 5.5 8a1c780137 Recheck a Side quest held from an earlier round in the replacement test
CI / ci (push) Successful in 8m15s
CI / ci (pull_request) Successful in 8m10s
TestBatchReplacesSideQuestMadeIneligibleWhileHeld now leaves the first
round one short (Fajita Dish B is ungroundable), blocks the second
round, and adds a dinner repeating Fajita Dish A, held from the first
round. A further spare lets a third round ground its replacement; the
test asserts BatchSize stored, 2 dropped and one new_suggestions.

Maestro task 244f9834-faa8-4bb8-b5e6-43677dec0cc0, finding MQ-172-3-TEST.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 12:08:48 -04:00
dev_agent 6e8b44bb15 Merge remote-tracking branch 'origin/feature/7383c2cb-3179-4b45-b558-bf1bc1833fad-launch-readiness' into task/suggest-batch-replacement_244f9834-faa8-4bb8-b5e6-43677dec0cc0
CI / ci (push) Successful in 8m10s
CI / ci (pull_request) Successful in 8m15s
2026-10-11 10:35:25 -04:00
orchestrator-agent d182349646 Plan draft schedule tests: assert the plan_ready title and channel (Maestro ece60217) (#195)
CI / ci (push) Successful in 8m11s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
2026-10-11 09:38:12 -04:00
orchestrator-agent 72755a8b61 Assert Home tonight dinner exact id and photo URL (Maestro 9d81c325) (#196)
CI / ci (push) Successful in 9m42s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
2026-10-11 09:38:06 -04:00
dev_agentandClaude Opus 5.5 0942e65392 Replace a held Side quest made ineligible before a batch's final store
CI / ci (push) Successful in 9m38s
CI / ci (pull_request) Successful in 8m21s
A notifying (scheduled or manual) suggestion batch held each round's
grounded Side quests and only checked them at its single final store, so
a household change during grounding could drop a held card after the
fill loop had stopped, leaving the batch short of BatchSize with spare
candidates unused (MQ-172-3). The batch now rechecks every held Side
quest after each round with the same eligibility check the store uses
(eligible, extracted from store), so a later round grounds a spare in
its place. The single-transaction store and once-only new_suggestions
are unchanged.

Narrow ARCHITECTURE.md's "a failed scheduled or manual batch has stored
nothing" to failures before the final store, and state that a job that
fails recording the batch's end keeps its Side quests and
new_suggestions (MQ-172-4).

Maestro task 244f9834-faa8-4bb8-b5e6-43677dec0cc0

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 08:53:58 -04:00
13 changed files with 295 additions and 64 deletions
+1 -1
View File
@@ -158,7 +158,7 @@ func run(cfg config.Config, logger *slog.Logger) error {
return err
}
planSvc, err := plan.NewService(db, weekDrafter(ai, guard), runner, googleSvc, notifier, sched.Wake, cfg.PublicURL, logger)
planSvc, err := plan.NewService(db, weekDrafter(ai, guard), runner, googleSvc, notifier, sched.Wake, cfg.PublicURL, sched.Now, logger)
if err != nil {
return err
}
+10 -3
View File
@@ -319,15 +319,22 @@ automatic** (D-021).
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
cites renamed or archived (checked in the storing transaction). A
notifying batch also rechecks the Side quests it holds after each round
and lets go of those no longer eligible, so a later round grounds a
spare in place of each; only a change committed between the last
recheck and the store can leave it short of 5 with spares unused. 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`. A failed `scheduled` or `manual` batch has stored nothing; a
`failed`. A `scheduled` or `manual` batch that fails before its final
store (the AI, grounding, or the store itself) 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, even after the final
store committed: a notifying batch's Side quests and their
`new_suggestions` then stay; 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
not retried, and the next start fails the job as interrupted. Recording
+34 -6
View File
@@ -94,7 +94,7 @@ func newEnv(t *testing.T) *env {
draft := func(context.Context, string, skills.WeekRequest) (skills.Week, error) {
return skills.Week{}, errors.New("no model in this test")
}
plans, err := plan.NewService(st, draft, runner, googleSvc, notifier, func() {}, pub, logger)
plans, err := plan.NewService(st, draft, runner, googleSvc, notifier, func() {}, pub, time.Now, logger)
if err != nil {
t.Fatal(err)
}
@@ -222,16 +222,44 @@ func TestTonightSwitchesAtLocalMidnight(t *testing.T) {
func TestTonightDinner(t *testing.T) {
t.Parallel()
e := newEnv(t)
e.planWeek(t)
ctx := t.Context()
planID := e.planWeek(t)
planned := func(date string) string {
t.Helper()
var id string
if err := e.st.Reader.QueryRowContext(ctx, `SELECT dinner_id FROM plan_days WHERE plan_id = ? AND date = ?`, planID, date).Scan(&id); err != nil {
t.Fatal(err)
}
return id
}
h := e.at(t, "2026-10-13 18:00")
if h.Tonight == nil || h.Tonight.ID == "" || h.Tonight.Origin != "regular" || h.Tonight.Photo != nil ||
if h.Tonight == nil || h.Tonight.ID != planned("2026-10-13") || h.Tonight.Origin != "regular" || h.Tonight.Photo != nil ||
h.Tonight.MinutesTotal == nil || *h.Tonight.MinutesTotal != 30 {
t.Errorf("tonight = %+v, want Tuesday's 30-minute Regular with no photo", h.Tonight)
t.Errorf("tonight = %+v, want Tuesday's 30-minute Regular %s with no photo", h.Tonight, planned("2026-10-13"))
}
h = e.at(t, "2026-10-14 18:00")
if h.Tonight == nil || h.Tonight.Origin != "side_quest" || h.Tonight.MinutesTotal != nil {
t.Errorf("tonight = %+v, want Wednesday's Side quest of unknown minutes", h.Tonight)
if h.Tonight == nil || h.Tonight.ID != planned("2026-10-14") || h.Tonight.Origin != "side_quest" || h.Tonight.MinutesTotal != nil {
t.Errorf("tonight = %+v, want Wednesday's Side quest %s of unknown minutes", h.Tonight, planned("2026-10-14"))
}
// Thursday's dinner has a photo: Home serves its list-sized copy.
now := store.Now().String()
photo, err := db.New(e.st.Writer).CreatePhoto(ctx, db.CreatePhotoParams{
ID: store.NewID(), SourceUrl: "https://example.com/thursday.jpg", LocalPath: "photos/thursday.jpg",
Width: 800, Height: 600, Sha256: "thursday", FetchedAt: now, CreatedAt: now, UpdatedAt: now,
})
if err != nil {
t.Fatal(err)
}
thursday := planned("2026-10-15")
if _, err := e.st.Writer.ExecContext(ctx, `UPDATE dinners SET photo_id = ? WHERE id = ?`, photo.ID, thursday); err != nil {
t.Fatal(err)
}
h = e.at(t, "2026-10-15 18:00")
wantPhoto := "/media/photos/" + photo.ID + "-400.jpg"
if h.Tonight == nil || h.Tonight.ID != thursday || h.Tonight.Photo == nil || *h.Tonight.Photo != wantPhoto {
t.Errorf("tonight = %+v, want Thursday's dinner %s with photo %s", h.Tonight, thursday, wantPhoto)
}
}
+1 -1
View File
@@ -96,7 +96,7 @@ func (s *Service) revision(ctx context.Context, weekStart store.LocalDate) (int6
// the household context, the pool, last week's dinners and the dinner
// time.
func (s *Service) request(ctx context.Context, weekStart store.LocalDate) (skills.WeekRequest, error) {
hctx, err := harness.BuildContext(ctx, s.st, time.Now(), ContextBudget)
hctx, err := harness.BuildContext(ctx, s.st, s.now(), ContextBudget)
if err != nil {
return skills.WeekRequest{}, fmt.Errorf("plan: %w", err)
}
+10 -5
View File
@@ -149,16 +149,19 @@ type Service struct {
// publicURL is MEALQUEST_PUBLIC_URL, under which a pushed event links
// to its dinner.
publicURL *url.URL
// now is the clock today is read from: the scheduler's.
now func() time.Time
}
// NewService returns a service over st that drafts with draft, pushes to
// cal with links under publicURL, tells the household through notifier,
// which must be over st, calls wake (the scheduler's Wake) after every
// committed change to a plan's days, and registers the KindDraftWeek job on
// runner, which must not have started yet. The kind is service-only:
// committed change to a plan's days, reads today from now (the scheduler's
// Now) and registers the KindDraftWeek job on runner, which must not have
// started yet. The kind is service-only:
// StartDraft and StartScheduledDraft, which keep one draft in flight per
// week, are the only way to enqueue it.
func NewService(st *store.Store, draft Drafter, runner *jobs.Runner, cal Calendar, notifier *notify.Service, wake func(), publicURL *url.URL, logger *slog.Logger) (*Service, error) {
func NewService(st *store.Store, draft Drafter, runner *jobs.Runner, cal Calendar, notifier *notify.Service, wake func(), publicURL *url.URL, now func() time.Time, logger *slog.Logger) (*Service, error) {
switch {
case st == nil:
return nil, errors.New("plan: nil store")
@@ -174,10 +177,12 @@ func NewService(st *store.Store, draft Drafter, runner *jobs.Runner, cal Calenda
return nil, errors.New("plan: nil wake")
case publicURL == nil:
return nil, errors.New("plan: nil public URL")
case now == nil:
return nil, errors.New("plan: nil clock")
case logger == nil:
return nil, errors.New("plan: nil logger")
}
s := &Service{st: st, draft: draft, cal: cal, notifier: notifier, wake: wake, logger: logger, publicURL: publicURL}
s := &Service{st: st, draft: draft, cal: cal, notifier: notifier, wake: wake, logger: logger, publicURL: publicURL, now: now}
kind, err := jobs.Register(runner, KindDraftWeek, s.runDraft, Reasons...)
if err != nil {
return nil, err
@@ -501,7 +506,7 @@ func (s *Service) Tonight(ctx context.Context) (week, today store.LocalDate, err
if err != nil {
return store.LocalDate{}, store.LocalDate{}, fmt.Errorf("plan: household settings: %w", err)
}
today = household.Today(time.Now(), zone)
today = household.Today(s.now(), zone)
week = household.WeekStart(today)
_, err = q.GetWeekPlan(ctx, week.String())
if errors.Is(err, sql.ErrNoRows) {
+1 -1
View File
@@ -146,7 +146,7 @@ func newEnv(t *testing.T) *env {
if err != nil {
t.Fatal(err)
}
e.svc, err = plan.NewService(st, drafter, e.runner, e.gcal, notifytest.New(t, st), func() {}, publicURL, logger)
e.svc, err = plan.NewService(st, drafter, e.runner, e.gcal, notifytest.New(t, st), func() {}, publicURL, time.Now, logger)
if err != nil {
t.Fatal(err)
}
+73 -13
View File
@@ -7,6 +7,7 @@ import (
"fmt"
"log/slog"
"net/url"
"slices"
"strings"
"sync"
"testing"
@@ -85,7 +86,21 @@ func (c noCalendar) PushEvents(context.Context, []gcal.Event, func() error) erro
// pass the fake clock's waits do not expect.
func (e *env) plans(ids map[string]string) *planEnv {
e.t.Helper()
return e.newPlans(ids, func() {})
return e.newPlans(ids, func() {}, time.Now)
}
// scheduledPlans is plans whose today is read from the clock of the
// scheduler start last started, as in the server, so a draft's plan_ready
// title follows the fake clock. It drafts only once a scheduler started.
func (e *env) scheduledPlans(ids map[string]string) *planEnv {
e.t.Helper()
return e.newPlans(ids, func() {}, func() time.Time {
s := e.sched.Load()
if s == nil {
panic("plan service read the clock before a scheduler started")
}
return s.Now()
})
}
// wakingPlans is plans whose changes wake the scheduler, as in the server.
@@ -93,11 +108,12 @@ func (e *env) plans(ids map[string]string) *planEnv {
// the wake's pass with changed.
func (e *env) wakingPlans(ids map[string]string) *planEnv {
e.t.Helper()
return e.newPlans(ids, e.wake)
return e.newPlans(ids, e.wake, time.Now)
}
// newPlans is plans whose service calls wake after a plan change.
func (e *env) newPlans(ids map[string]string, wake func()) *planEnv {
// newPlans is plans whose service calls wake after a plan change and reads
// today from now.
func (e *env) newPlans(ids map[string]string, wake func(), now func() time.Time) *planEnv {
e.t.Helper()
p := &planEnv{e: e, model: &llm.Fake{}, ids: ids, logs: &syncBuffer{}}
guard, err := harness.NewGuard(harness.DefaultMaxCalls)
@@ -118,7 +134,7 @@ func (e *env) newPlans(ids map[string]string, wake func()) *planEnv {
if err != nil {
e.t.Fatal(err)
}
p.svc, err = plan.NewService(e.st, drafter, runner, noCalendar{e.t}, notifytest.New(e.t, e.st), wake, publicURL, logger)
p.svc, err = plan.NewService(e.st, drafter, runner, noCalendar{e.t}, notifytest.New(e.t, e.st), wake, publicURL, now, logger)
if err != nil {
e.t.Fatal(err)
}
@@ -235,6 +251,41 @@ func (e *env) planReady() []string {
return keys
}
// readyRow is a stored plan_ready notification as these tests check it.
type readyRow struct {
Key string
Channel notify.Channel
Title string
}
// planReadyRows returns every plan_ready notification stored, oldest
// first, each week's Google Chat row before its push row.
func (e *env) planReadyRows() []readyRow {
e.t.Helper()
var out []readyRow
for _, r := range notifytest.Rows(e.t, e.st, notify.PlanReady) {
out = append(out, readyRow{Key: r.Key, Channel: r.Channel, Title: r.Payload.Title})
}
return out
}
// nextWeekReady is the plan_ready rows of a draft of the week starting
// week, drafted the week before: one to Google Chat and one to push.
func nextWeekReady(week string) []readyRow {
const title = "Next week's plan is ready"
key := string(notify.PlanReady) + ":" + week
return []readyRow{{key, notify.ChannelChat, title}, {key, notify.ChannelPush, title}}
}
// wantReady fails unless the plan_ready rows are got.
func wantReady(t *testing.T, what string, got []readyRow, want ...[]readyRow) {
t.Helper()
all := slices.Concat(want...)
if !slices.Equal(got, all) {
t.Fatalf("%s: plan_ready rows = %+v, want %+v", what, got, all)
}
}
var (
succeeded = draftJob{Status: string(jobs.StatusSucceeded)}
aiFailed = draftJob{Status: string(jobs.StatusFailed), Error: plan.ReasonAIFailed}
@@ -257,7 +308,7 @@ func mustLocalDate(s string) store.LocalDate {
// by its Monday once.
func TestPlanDraftDraftsNextWeek(t *testing.T) {
e := newEnv(t)
p := e.plans(e.pool())
p := e.scheduledPlans(e.pool())
p.reply(w43)
p.reply(w44)
@@ -271,6 +322,7 @@ func TestPlanDraftDraftsNextWeek(t *testing.T) {
wantDrafts(t, "at Friday 09:00", e.drafts(), succeeded)
wantKeys(t, "plans at Friday 09:00", e.weekPlans(), []string{"2026-10-19"})
wantKeys(t, "plan_ready of W43", e.planReady(), []string{"plan_ready:2026-10-19"})
wantReady(t, "plan_ready rows of W43", e.planReadyRows(), nextWeekReady("2026-10-19"))
got, err := p.svc.Get(context.Background(), w43)
if err != nil {
t.Fatal(err)
@@ -283,6 +335,7 @@ func TestPlanDraftDraftsNextWeek(t *testing.T) {
wantDrafts(t, "W44", e.drafts(), succeeded, succeeded)
wantKeys(t, "plans", e.weekPlans(), []string{"2026-10-19", "2026-10-26"})
wantKeys(t, "plan_ready of W43 and W44", e.planReady(), []string{"plan_ready:2026-10-19", "plan_ready:2026-10-26"})
wantReady(t, "plan_ready rows of W43 and W44", e.planReadyRows(), nextWeekReady("2026-10-19"), nextWeekReady("2026-10-26"))
wantKeys(t, "schedule_runs", e.claimed(scheduler.PlanDraft), []string{"2026-W42", "2026-W43", "2026-W44"})
}
@@ -291,23 +344,25 @@ func TestPlanDraftDraftsNextWeek(t *testing.T) {
// notifies no one and says so.
func TestPlanDraftLeavesAnExistingPlan(t *testing.T) {
e := newEnv(t)
p := e.plans(e.pool())
p := e.scheduledPlans(e.pool())
p.reply(w43)
p.reply(w43) // a second draft would succeed too
r := e.start(at(2026, 10, 16, 8, 0), p.svc.DraftSchedule())
if _, err := p.svc.StartDraft(context.Background(), w43, false); err != nil {
t.Fatal(err)
}
wantDrafts(t, "manual draft", e.drafts(), succeeded)
wantKeys(t, "plan_ready of the manual draft", e.planReady(), []string{"plan_ready:2026-10-19"})
wantReady(t, "plan_ready rows of the manual draft", e.planReadyRows(), nextWeekReady("2026-10-19"))
before, err := p.svc.Get(context.Background(), w43)
if err != nil {
t.Fatal(err)
}
r := e.start(at(2026, 10, 16, 8, 0), p.svc.DraftSchedule())
r.runUntil(at(2026, 10, 16, 9, 0))
wantDrafts(t, "with a plan", e.drafts(), succeeded)
wantKeys(t, "plan_ready with a plan", e.planReady(), []string{"plan_ready:2026-10-19"})
wantReady(t, "plan_ready rows with a plan", e.planReadyRows(), nextWeekReady("2026-10-19"))
wantKeys(t, "schedule_runs", e.claimed(scheduler.PlanDraft), []string{"2026-W42", "2026-W43"})
after, err := p.svc.Get(context.Background(), w43)
if err != nil {
@@ -327,23 +382,25 @@ func TestPlanDraftLeavesAnExistingPlan(t *testing.T) {
func TestPlanDraftNoDuplicateAfterRestart(t *testing.T) {
e := newEnv(t)
ids := e.pool()
p := e.plans(ids)
p := e.scheduledPlans(ids)
p.reply(w43)
r := e.start(at(2026, 10, 16, 8, 0), p.svc.DraftSchedule())
r.runUntil(at(2026, 10, 16, 9, 0))
wantDrafts(t, "first process", e.drafts(), succeeded)
wantReady(t, "plan_ready rows of the first process", e.planReadyRows(), nextWeekReady("2026-10-19"))
r.stop()
p.stop()
// The restarted process has a new job runner, plan service and
// scheduler; it would answer a second draft if one were started.
p2 := e.plans(ids)
p2 := e.scheduledPlans(ids)
p2.reply(w43)
r2 := e.start(at(2026, 10, 16, 10, 0), p2.svc.DraftSchedule())
r2.runUntil(at(2026, 10, 23, 8, 59))
wantDrafts(t, "after restart", e.drafts(), succeeded)
wantKeys(t, "plans after restart", e.weekPlans(), []string{"2026-10-19"})
wantKeys(t, "plan_ready after restart", e.planReady(), []string{"plan_ready:2026-10-19"})
wantReady(t, "plan_ready rows after restart", e.planReadyRows(), nextWeekReady("2026-10-19"))
wantKeys(t, "schedule_runs", e.claimed(scheduler.PlanDraft), []string{"2026-W42", "2026-W43"})
}
@@ -354,7 +411,7 @@ func TestPlanDraftNoDuplicateAfterRestart(t *testing.T) {
func TestPlanDraftAIFailureEmitsNothing(t *testing.T) {
e := newEnv(t)
ids := e.pool()
p := e.plans(ids)
p := e.scheduledPlans(ids)
p.model.QueueGenerate(llm.GenerateResult{Err: errors.New("provider down")})
r := e.start(at(2026, 10, 16, 8, 0), p.svc.DraftSchedule())
@@ -362,6 +419,7 @@ func TestPlanDraftAIFailureEmitsNothing(t *testing.T) {
wantDrafts(t, "after the AI failed", e.drafts(), aiFailed)
wantKeys(t, "plans after the AI failed", e.weekPlans(), nil)
wantKeys(t, "plan_ready after the AI failed", e.planReady(), nil)
wantReady(t, "plan_ready rows after the AI failed", e.planReadyRows())
wantKeys(t, "schedule_runs", e.claimed(scheduler.PlanDraft), []string{"2026-W42", "2026-W43"})
var status, msg string
@@ -377,11 +435,12 @@ func TestPlanDraftAIFailureEmitsNothing(t *testing.T) {
r.stop()
p.stop()
p2 := e.plans(ids)
p2 := e.scheduledPlans(ids)
p2.reply(w43)
e.start(at(2026, 10, 16, 10, 0), p2.svc.DraftSchedule())
wantDrafts(t, "after restart", e.drafts(), aiFailed)
wantKeys(t, "plan_ready after restart", e.planReady(), nil)
wantReady(t, "plan_ready rows after restart", e.planReadyRows())
}
// TestPlanDraftFollowsSettingsWithoutRestart: a plan_draft_dow and
@@ -390,7 +449,7 @@ func TestPlanDraftAIFailureEmitsNothing(t *testing.T) {
// week.
func TestPlanDraftFollowsSettingsWithoutRestart(t *testing.T) {
e := newEnv(t)
p := e.plans(e.pool())
p := e.scheduledPlans(e.pool())
p.reply(w43)
r := e.start(at(2026, 10, 12, 12, 0), p.svc.DraftSchedule()) // Monday
@@ -401,6 +460,7 @@ func TestPlanDraftFollowsSettingsWithoutRestart(t *testing.T) {
r.runUntil(at(2026, 10, 14, 18, 30))
wantDrafts(t, "at Wednesday 18:30", e.drafts(), succeeded)
wantKeys(t, "plan_ready", e.planReady(), []string{"plan_ready:2026-10-19"})
wantReady(t, "plan_ready rows", e.planReadyRows(), nextWeekReady("2026-10-19"))
// Friday 09:00, the old fire time, drafts nothing more.
r.runUntil(at(2026, 10, 16, 9, 0))
+5
View File
@@ -378,6 +378,11 @@ func (s *Scheduler) Wake() {
s.signal()
}
// Now is the time on the scheduler's clock, by which its schedules fire.
func (s *Scheduler) Now() time.Time {
return s.now()
}
// signal leaves one token in wake for the loop.
func (s *Scheduler) signal() {
select {
+1 -1
View File
@@ -73,7 +73,7 @@ func newIdeaActionsRouter(t *testing.T, model, normalize *llm.Fake, search *reci
t.Error("draft_week ran: an idea action must never draft a plan")
return skills.Week{}, context.Canceled
}
planSvc, err := plan.NewService(st, draft, runner, newTestGoogle(t), notifytest.New(t, st), func() {}, &url.URL{Scheme: "https", Host: "mealquest.example"}, logger)
planSvc, err := plan.NewService(st, draft, runner, newTestGoogle(t), notifytest.New(t, st), func() {}, &url.URL{Scheme: "https", Host: "mealquest.example"}, time.Now, logger)
if err != nil {
t.Fatal(err)
}
+2 -1
View File
@@ -14,6 +14,7 @@ import (
"sync"
"testing"
"testing/fstest"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
@@ -93,7 +94,7 @@ func newPlansRouterTitled(t *testing.T, title shopping.Titler) plansEnv {
t.Fatal(err)
}
notifier := notifytest.New(t, st)
svc, err := plan.NewService(st, fail, runner, googleSvc, notifier, func() {}, pub, logger)
svc, err := plan.NewService(st, fail, runner, googleSvc, notifier, func() {}, pub, time.Now, logger)
if err != nil {
t.Fatal(err)
}
+3 -2
View File
@@ -15,6 +15,7 @@ import (
"strings"
"testing"
"testing/fstest"
"time"
"github.com/go-chi/chi/v5"
"golang.org/x/oauth2"
@@ -240,7 +241,7 @@ func newTestPlan(t *testing.T) *plan.Service {
fail := func(context.Context, string, skills.WeekRequest) (skills.Week, error) {
return skills.Week{}, errors.New("no model in this test")
}
svc, err := plan.NewService(st, fail, runner, newTestGoogle(t), notifytest.New(t, st), func() {}, &url.URL{Scheme: "https", Host: "mealquest.example"}, logger)
svc, err := plan.NewService(st, fail, runner, newTestGoogle(t), notifytest.New(t, st), func() {}, &url.URL{Scheme: "https", Host: "mealquest.example"}, time.Now, logger)
if err != nil {
t.Fatal(err)
}
@@ -303,7 +304,7 @@ func newTestHomeOn(t *testing.T, st *store.Store) *home.Service {
if err != nil {
t.Fatal(err)
}
planSvc, err := plan.NewService(st, draft, runner, googleSvc, notifier, func() {}, pub, logger)
planSvc, err := plan.NewService(st, draft, runner, googleSvc, notifier, func() {}, pub, time.Now, logger)
if err != nil {
t.Fatal(err)
}
+85 -29
View File
@@ -54,7 +54,8 @@ func (r batchResult) PublicJSON() ([]byte, error) { return json.Marshal(r) }
// 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. 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
// fails, so does the job, with what the batch stored, and any
// new_suggestions it emitted, kept; when it times out at shutdown, the job is left
// running and the batch with it, for the next start to fail.
//
// When a batch stores its Side quests depends on whether it notifies (see
@@ -64,7 +65,12 @@ func (r batchResult) PublicJSON() ([]byte, error) { return json.Marshal(r) }
// 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,
// Side quest in the deck that was never announced. After each round it
// rechecks every Side quest it holds (see eligible) and lets go of those
// the household's changes made ineligible, so a later round grounds a
// spare Side quest in place of each; only a change committed between the
// last recheck and the store can still leave the batch short of
// BatchSize while spare Side quests remain. 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.
@@ -146,7 +152,13 @@ func (s *Service) fill(ctx context.Context, trigger string, job BatchJob, publis
}
res.Dropped += len(round) - len(grounded)
if note != nil {
held = append(held, grounded...)
// Recheck every held Side quest, not just this round's, so
// one the household's changes made ineligible while this
// round grounded is replaced by a later round, not dropped by
// the store once no round is left to replace it.
if held, err = s.recheck(ctx, &res, append(held, grounded...)); err != nil {
return batchResult{}, err
}
} else if err := s.storeInto(ctx, &res, nil, grounded); err != nil {
return batchResult{}, err
}
@@ -163,6 +175,22 @@ func (s *Service) fill(ctx context.Context, trigger string, job BatchJob, publis
return res, nil
}
// recheck returns, in order, those of a notifying batch's held Side
// quests that store would still keep (see eligible), adding those left
// out to res.Dropped.
func (s *Service) recheck(ctx context.Context, res *batchResult, held []groundedQuest) ([]groundedQuest, error) {
kept, err := s.eligible(ctx, db.New(s.st.Reader), res.BatchID, held)
if err != nil {
return nil, err
}
res.Dropped += len(held) - len(kept)
out := make([]groundedQuest, len(kept))
for i, k := range kept {
out[i] = k.groundedQuest
}
return out, 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 {
@@ -277,11 +305,11 @@ func (s *Service) ground(ctx context.Context, quests []skills.SideQuest) ([]grou
// store stores each grounded Side quest of batchID as a dinner with status
// suggested and origin side_quest, grounded in its recipe, and a pending
// suggestion citing its Regulars by id, all in one transaction, and
// returns the suggestion ids. A Side quest is left out when, since
// suggest_side_quests ran, a dinner it repeats was added, a suggestion it
// repeats passed or a Rotation check dropped, or a Regular it cites renamed
// or archived. Passed names are read in the same transaction as the store,
// so a Pass or drop committed before it is never missed.
// returns the suggestion ids. A Side quest eligible leaves out is not
// stored: since suggest_side_quests ran, a dinner it repeats was added, a
// suggestion it repeats passed or a Rotation check dropped, or a Regular
// it cites renamed or archived. eligible reads in the same transaction as
// the store, so a Pass or drop committed before it is never missed.
//
// 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
@@ -295,28 +323,13 @@ func (s *Service) store(ctx context.Context, batchID string, note *notice, quest
var stored []string
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
q := db.New(tx)
existing, err := q.ListUnarchivedDinners(ctx)
kept, err := s.eligible(ctx, q, batchID, quests)
if err != nil {
return fmt.Errorf("suggest: list dinners for dedupe: %w", err)
return err
}
names, err := q.ListPassedNames(ctx)
if err != nil {
return fmt.Errorf("suggest: list passed names for dedupe: %w", err)
}
regulars := map[string][]string{}
for _, row := range existing {
names = append(names, row.Dinner.Name)
if row.Dinner.Status == dinners.StatusRegular {
regulars[row.Dinner.Name] = append(regulars[row.Dinner.Name], row.Dinner.ID)
}
}
for _, gq := range quests {
cited, ok := citedIDs(gq.quest.CitedRegulars, regulars)
if !ok || repeats(gq.quest.Name, names) {
s.logger.InfoContext(ctx, "suggested dinner dropped: changed while grounding", "batch_id", batchID)
continue
}
citedJSON, err := json.Marshal(cited)
for _, k := range kept {
gq := k.groundedQuest
citedJSON, err := json.Marshal(k.cited)
if err != nil {
return fmt.Errorf("suggest: encode cited Regulars: %w", err)
}
@@ -354,7 +367,6 @@ func (s *Service) store(ctx context.Context, batchID string, note *notice, quest
}); err != nil {
return fmt.Errorf("suggest: create suggestion: %w", err)
}
names = append(names, d.Name)
ids = append(ids, id)
stored = append(stored, d.Name)
}
@@ -372,6 +384,50 @@ func (s *Service) store(ctx context.Context, batchID string, note *notice, quest
return ids, nil
}
// eligibleQuest is a Side quest store would keep, with the ids of the
// Regulars it cites.
type eligibleQuest struct {
groundedQuest
cited []string
}
// eligible returns, in order, those of batchID's quests that q's dinners
// and passed names still allow, each with the ids of the Regulars it
// cites. A Side quest is left out when a dinner it repeats is unarchived,
// a suggestion it repeats passed or a Rotation check dropped, a Regular it
// cites is renamed or archived, or an earlier Side quest of quests kept
// repeats it. store checks with this in its transaction; a notifying
// batch checks the Side quests it holds with it after each round (see
// fill).
func (s *Service) eligible(ctx context.Context, q *db.Queries, batchID string, quests []groundedQuest) ([]eligibleQuest, error) {
existing, err := q.ListUnarchivedDinners(ctx)
if err != nil {
return nil, fmt.Errorf("suggest: list dinners for dedupe: %w", err)
}
names, err := q.ListPassedNames(ctx)
if err != nil {
return nil, fmt.Errorf("suggest: list passed names for dedupe: %w", err)
}
regulars := map[string][]string{}
for _, row := range existing {
names = append(names, row.Dinner.Name)
if row.Dinner.Status == dinners.StatusRegular {
regulars[row.Dinner.Name] = append(regulars[row.Dinner.Name], row.Dinner.ID)
}
}
var kept []eligibleQuest
for _, gq := range quests {
cited, ok := citedIDs(gq.quest.CitedRegulars, regulars)
if !ok || repeats(gq.quest.Name, names) {
s.logger.InfoContext(ctx, "suggested dinner dropped: changed while grounding", "batch_id", batchID)
continue
}
names = append(names, gq.quest.Name)
kept = append(kept, eligibleQuest{groundedQuest: gq, cited: cited})
}
return kept, nil
}
// citedIDs returns the ids of the Regulars named cited, false when one
// names no Regular or more than one.
func citedIDs(cited []string, regulars map[string][]string) ([]string, bool) {
+69 -1
View File
@@ -660,6 +660,74 @@ func TestBatchKeepsAtMostBatchSize(t *testing.T) {
}
}
// TestBatchReplacesSideQuestMadeIneligibleWhileHeld: a notifying batch
// holds each round's Side quests until its last round is done; when the
// household adds a dinner repeating one held from an earlier round while a
// later round grounds, the batch lets it go and grounds a spare Side quest
// in its place, so it still stores BatchSize and announces them once.
func TestBatchReplacesSideQuestMadeIneligibleWhileHeld(t *testing.T) {
for _, trigger := range []string{suggest.TriggerScheduled, suggest.TriggerManual} {
t.Run(trigger, func(t *testing.T) {
e := newEnv(t)
e.start(t)
// Fajita Dish B is ungroundable, so the first round holds
// BatchSize-1 and a second round grounds the next; the last is
// the spare a third round grounds in place of Fajita Dish A.
var quests, want []string
for i := range suggest.BatchSize + 2 {
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"))
if i > 1 {
want = append(want, name)
}
}
e.reply(quests...)
// The second round's search waits, so the household adds a
// dinner repeating Fajita Dish A, held from the first round,
// while that round grounds.
second := fmt.Sprintf("Fajita Dish %c", 'A'+suggest.BatchSize)
gate := make(chan struct{})
e.search.Gates = map[string]chan struct{}{second: gate}
b, key := e.startBatch(t, trigger)
deadline := time.Now().Add(10 * time.Second)
for !slices.Contains(e.search.Queries(), second) {
if time.Now().After(deadline) {
close(gate)
t.Fatalf("search for %s never ran", second)
}
time.Sleep(5 * time.Millisecond)
}
e.dinner(t, "Fajita Dish A", "regular")
close(gate)
job := e.waitJob(t, b.JobID)
if job.Status != jobs.StatusSucceeded {
t.Fatalf("batch job %s, error %v", job.Status, job.Error)
}
var res batchResult
if err := json.Unmarshal(job.Result, &res); err != nil {
t.Fatal(err)
}
if len(res.SuggestionIDs) != suggest.BatchSize || res.Dropped != 2 {
t.Errorf("batch result = %+v, want %d stored and 2 dropped", res, suggest.BatchSize)
}
if got := e.deckNames(t); !slices.Equal(got, want) {
t.Errorf("deck = %v, want %v", got, want)
}
rows := notifytest.Rows(t, e.st, notify.NewSuggestions)
title := fmt.Sprintf("%d new Side quests are in", suggest.BatchSize)
if len(rows) != 1 || rows[0].Key != key || rows[0].Payload.Title != title {
t.Errorf("new_suggestions rows = %+v, want one keyed %s titled %q", rows, key, title)
}
})
}
}
func TestDeckIsOldestFirst(t *testing.T) {
e := newEnv(t)
e.start(t)
@@ -1157,7 +1225,7 @@ func TestLaterBatchNeverResuggestsRenamedDrop(t *testing.T) {
fail := func(context.Context, string, skills.WeekRequest) (skills.Week, error) {
return skills.Week{}, errors.New("no draft in this test")
}
planSvc, err := plan.NewService(e.st, fail, e.runner, noCalendar{}, notifytest.New(t, e.st), func() {}, &url.URL{Scheme: "https", Host: "meals.example.com"}, logger)
planSvc, err := plan.NewService(e.st, fail, e.runner, noCalendar{}, notifytest.New(t, e.st), func() {}, &url.URL{Scheme: "https", Host: "meals.example.com"}, time.Now, logger)
if err != nil {
t.Fatal(err)
}