Announce a batch's Side quests once, when it completes
CI / ci (push) Successful in 8m50s
CI / ci (pull_request) Successful in 7m26s

A scheduled or manual batch now holds every grounding round's Side
quests and stores them all, with one new_suggestions, in a single
transaction once its last round is done. The notification counts and
names every Side quest the batch stored, instead of only the first
round's (MQ-172-1), and a batch that fails at any round, including a
later round's normalize_recipe, stores nothing and sends nothing, so no
Side quest is left in the deck unannounced (MQ-172-2).

suggest.store stays the only new_suggestions emitter; a notifying batch
now calls it once. A replenish batch, which never notifies, still
stores round by round so the deck fills as the household swipes.

Maestro task 459aab71-5c3d-4853-9855-236a3602b1e0

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-09 20:23:46 -04:00
co-authored by Claude Opus 5.5
parent 94b1378a7b
commit 7bbd1e2b0f
3 changed files with 153 additions and 82 deletions
+23 -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
@@ -833,13 +837,14 @@ Fake-clock tests of a schedule live in `internal/scheduler`, where
- `new_suggestions:{YYYY-Www}` for a `scheduled` batch, the ISO week it - `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 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 still keyed by its own week), or `new_suggestions:{batchId}` for a
`manual` one (URL `/discover`), when the batch first adds Side quests `manual` one (URL `/discover`), once, when the batch's last round is
to the deck, in the transaction storing them, so they are announced done, in the one transaction that stores all its Side quests, so they
even when a later round or recording the batch's end fails. Its title and their notification commit together or not at all. Its title
counts that round's Side quests ("3 new Side quests are in") and its counts every Side quest the batch stored ("5 new Side quests are in")
body names them; a later round's emit stores nothing. A batch that and its body names them. A batch that fails at any round, as when
fails before storing any, as when `suggest_side_quests` fails (the `suggest_side_quests` or a later round's `normalize_recipe` fails (the
failure is in `ai_runs`), sends nothing. A `replenish` batch never 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 notifies. This is the only place `new_suggestions` is emitted; the
`suggestions_weekly` schedule does not emit it itself. `suggestions_weekly` schedule does not emit it itself.
- `day_of_reminder:{date}` (URL `/dinner/{id}`) through - `day_of_reminder:{date}` (URL `/dinner/{id}`) through
+45 -22
View File
@@ -53,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 (see noticeOf) in the // When a batch stores its Side quests depends on whether it notifies (see
// 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 {
@@ -102,8 +105,9 @@ func (s *Service) runBatch(ctx context.Context, p BatchJob, publish func(batchRe
} }
// fill does runBatch's work for job of a batch with trigger, returning a // fill does runBatch's work for job of a batch with trigger, returning a
// *jobs.Failure for a failure the household may see. Unless the batch is a // *jobs.Failure for a failure the household may see. A batch with a notice
// replenish one, store emits new_suggestions with the Side quests it adds. // 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) { func (s *Service) fill(ctx context.Context, trigger string, job BatchJob, publish func(batchResult) error) (batchResult, error) {
batchID := job.BatchID batchID := job.BatchID
res := batchResult{BatchID: batchID, Stage: StageSuggesting, SuggestionIDs: []string{}} res := batchResult{BatchID: batchID, Stage: StageSuggesting, SuggestionIDs: []string{}}
@@ -131,28 +135,46 @@ func (s *Service) fill(ctx context.Context, trigger string, job BatchJob, publis
return batchResult{}, err return batchResult{}, err
} }
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, note, 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 // notice is how a batch's new_suggestions is keyed: by id, as
// new_suggestions:{id}, for the week starting on week. // new_suggestions:{id}, for the week starting on week.
type notice struct { type notice struct {
@@ -265,8 +287,9 @@ func (s *Service) ground(ctx context.Context, quests []skills.SideQuest) ([]grou
// note, counting and naming those stored, is emitted in the same // note, counting and naming those stored, is emitted in the same
// transaction, 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
// only place new_suggestions is emitted.
func (s *Service) store(ctx context.Context, batchID string, note *notice, quests []groundedQuest) ([]string, error) { 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
+85 -42
View File
@@ -495,53 +495,58 @@ func TestBatchEmitsNewSuggestions(t *testing.T) {
} }
} }
// 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, key := e.startBatch(t, tc.trigger) b, key := e.startBatch(t, tc.trigger)
job := e.waitJob(t, b.JobID) if job := e.waitJob(t, b.JobID); job.Status != jobs.StatusSucceeded {
want, deck := jobs.StatusSucceeded, suggest.BatchSize t.Fatalf("batch job %s, want succeeded", job.Status)
if tc.lastFails {
want, deck = jobs.StatusFailed, suggest.BatchSize-1
} }
if job.Status != want { deck := e.deckNames(t)
t.Fatalf("batch job %s, want %s", job.Status, want) 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)
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 {
@@ -550,9 +555,47 @@ func TestBatchNotifiesAsSideQuestsAreStored(t *testing.T) {
} }
return return
} }
if len(rows) != 1 || rows[0].Key != key || rows[0].Payload.Title != "4 new Side quests are in" || 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 %s counting and naming the first round", rows, key) 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)
} }
}) })
} }