Title Supply runs in a job, scoped by their dinner names
CI / ci (push) Successful in 4m52s
CI / ci (pull_request) Successful in 4m55s

archive_title's single-flight scope was <weekStart>@<plan revision>, but
renaming a planned dinner changes the names to title without changing
the plan revision, so two overlapping generations across a rename could
share one model result. The scope is now
<weekStart>@<plan revision>/<sha256 of the ordered dinner names>, the
same list the skill is given, through the harness's own guard.

Push to Calendar and POST /shopping/{weekStart}/generate also waited
for the title call. Generation now enqueues a title_supply_run job
(service-only, in the generation's transaction) for a list still
untitled and returns without waiting; the list reads untitled until the
job stores its title. A failed title still leaves the list untitled, is
logged, recorded in ai_runs, fails the job title_failed, and is retried
by the week's next generation. A titled list enqueues no job.

Maestro task fa4b94c9-6623-41ab-8d2a-0ca7d1e6401a.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 06:23:16 -04:00
co-authored by Claude Opus 5.5
parent fdfd0123fb
commit 1d19fb9545
8 changed files with 455 additions and 77 deletions
+133 -48
View File
@@ -6,12 +6,15 @@
// the plan changes. It is deterministic and runs no model: an item is one
// ingredient of the dinner's recipe exactly as recipe grounding stored it
// (see Generate). The one model call is the Supply run's title, written by
// archive_title when the list is generated untitled (see Generate).
// archive_title in a KindTitleSupplyRun job when the list is generated
// untitled (see Generate).
package shopping
import (
"context"
"crypto/sha256"
"database/sql"
"encoding/json"
"errors"
"fmt"
"log/slog"
@@ -21,6 +24,7 @@ import (
"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/store"
)
@@ -39,6 +43,15 @@ const (
SkipNoIngredients = "no_ingredients"
)
// KindTitleSupplyRun is the job kind that titles a Supply run with
// archive_title. Only Generate and GeneratePushed enqueue it.
const KindTitleSupplyRun = "title_supply_run"
// ReasonTitleFailed is the failure code of a KindTitleSupplyRun job that
// stored no title: archive_title failed, or the Supply run was frozen or
// replaced first. The Supply run stays untitled.
const ReasonTitleFailed = "title_failed"
// ErrPlanNotFound is a generation for a week with no plan.
var ErrPlanNotFound = errors.New("the week has no plan")
@@ -57,30 +70,44 @@ var ErrListNotFound = errors.New("the week has no Supply run")
// ErrItemNotFound is a change to an item on no Supply run.
var ErrItemNotFound = errors.New("no Supply run has that item")
// Titler runs archive_title on in; scope names the week and its dinners.
// Titler runs archive_title on in; scope names the week, its plan revision
// and its dinners.
// In production it is skills.TitleArchive with the household's
// harness.Config.
type Titler func(ctx context.Context, scope string, in skills.ArchiveTitleRequest) (skills.ArchiveTitle, error)
// Service generates Supply runs.
type Service struct {
st *store.Store
title Titler
logger *slog.Logger
st *store.Store
title Titler
titleJob *jobs.Kind[TitleJob]
logger *slog.Logger
}
// NewService returns a service over st that titles Supply runs with title
// and logs to logger.
func NewService(st *store.Store, title Titler, logger *slog.Logger) (*Service, error) {
// in KindTitleSupplyRun jobs, which it registers on runner, and logs to
// logger. runner must not have started yet.
func NewService(st *store.Store, title Titler, runner *jobs.Runner, logger *slog.Logger) (*Service, error) {
switch {
case st == nil:
return nil, errors.New("shopping: nil store")
case title == nil:
return nil, errors.New("shopping: nil titler")
case runner == nil:
return nil, errors.New("shopping: nil job runner")
case logger == nil:
return nil, errors.New("shopping: nil logger")
}
return &Service{st: st, title: title, logger: logger}, nil
s := &Service{st: st, title: title, logger: logger}
kind, err := jobs.Register(runner, KindTitleSupplyRun, s.runTitleJob, ReasonTitleFailed)
if err != nil {
return nil, err
}
if err := kind.ServiceOnly(); err != nil {
return nil, err
}
s.titleJob = kind
return s, nil
}
// List is a Supply run.
@@ -158,6 +185,9 @@ type Generated struct {
// Skipped are the plan days whose dinner has no ingredients to shop
// for, by date.
Skipped []SkippedDay `json:"skipped"`
// TitleJobID is the KindTitleSupplyRun job titling the list; nil when
// the list already has its title.
TitleJobID *string `json:"title_job_id"`
}
// Generate makes the Supply run of the week starting weekStart from the
@@ -182,12 +212,15 @@ type Generated struct {
// new and needed; an item no longer on the plan is removed, and with it a
// group no longer on the plan.
//
// A list still untitled once it is stored, as a new list always is, is
// then titled by archive_title from the names of the plan's dinners in day
// order, outside the transaction. A title is written once: a remake keeps
// it. Titling never fails the generation: a title that cannot be written
// leaves the list untitled, as it is returned, the failure is logged, and
// the next generation of the week, Generate or GeneratePushed, tries again.
// A list still untitled once it is stored, as a new list always is, gets a
// KindTitleSupplyRun job, enqueued in the same transaction, that titles it
// with archive_title from the names of the plan's dinners in day order.
// Generate returns without waiting for the job: the list is returned
// untitled, with the job's id. A title is written once: a remake keeps it
// and enqueues no job. Titling never fails the generation: a title that
// cannot be written leaves the list untitled, the failure is logged and
// the job failed with ReasonTitleFailed, and the next generation of the
// week, Generate or GeneratePushed, tries again.
//
// A week with no plan is ErrPlanNotFound; a list frozen into the Supply
// archive is ErrFrozen. A fetched recipe's ingredient without a name is an
@@ -216,11 +249,7 @@ func (s *Service) generate(ctx context.Context, weekStart store.LocalDate, check
if _, err := household.WeekDays(weekStart); err != nil {
return Generated{}, fmt.Errorf("shopping: generate: %w", err)
}
var (
out Generated
revision int64
names []string // the plan's dinners, in day order
)
var out Generated
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
q := db.New(tx)
plan, err := q.GetWeekPlan(ctx, weekStart.String())
@@ -237,7 +266,6 @@ func (s *Service) generate(ctx context.Context, weekStart store.LocalDate, check
if err != nil {
return err
}
revision, names = plan.Revision, dinnerNames
now := store.Now().String()
list, err := q.GetShoppingList(ctx, weekStart.String())
if err != nil && !errors.Is(err, sql.ErrNoRows) {
@@ -263,43 +291,77 @@ func (s *Service) generate(ctx context.Context, weekStart store.LocalDate, check
return err
}
out = Generated{List: listOf(list, groups), Skipped: skipped}
if list.Title.Valid {
return nil
}
id, err := s.titleJob.EnqueueTx(ctx, tx, TitleJob{
ListID: list.ID, WeekStart: list.WeekStart, Revision: plan.Revision, Dinners: dinnerNames,
})
if err != nil {
return fmt.Errorf("shopping: enqueue title: %w", err)
}
out.TitleJobID = &id
return nil
})
if err != nil {
return Generated{}, err
}
if out.List.Title == nil {
s.titleList(ctx, &out.List, revision, names)
if out.TitleJobID != nil {
s.titleJob.Wake()
}
return out, nil
}
// titleList titles l, an untitled Supply run made from dinners, the names
// of its plan's dinners in day order at plan revision, with archive_title,
// and stores the title on it. A title that cannot be written leaves l
// untitled; the failure is logged, never returned.
func (s *Service) titleList(ctx context.Context, l *List, revision int64, dinners []string) {
title, err := s.writeTitle(ctx, l, revision, dinners)
if err != nil {
s.logger.WarnContext(ctx, "Supply run left untitled; its next generation titles it again",
"week", l.WeekStart, "list", l.ID, "error", err)
return
}
l.Title = &title
// TitleJob is a KindTitleSupplyRun job's payload: the untitled Supply run
// ListID of the week WeekStart, made from the plan at Revision, whose
// dinners in day order are Dinners.
type TitleJob struct {
ListID string `json:"list_id"`
WeekStart string `json:"week_start"`
Revision int64 `json:"revision"`
Dinners []string `json:"dinners"`
}
// writeTitle runs archive_title for l and stores its title. When l was
// titled by another generation meanwhile, that title stands and is
// returned.
func (s *Service) writeTitle(ctx context.Context, l *List, revision int64, dinners []string) (string, error) {
scope := fmt.Sprintf("%s@%d", l.WeekStart, revision)
out, err := s.title(ctx, scope, skills.ArchiveTitleRequest{Dinners: dinners})
// titleResult is a KindTitleSupplyRun job's result: the Supply run and the
// title it has.
type titleResult struct {
ListID string `json:"list_id"`
Title string `json:"title"`
}
// PublicJSON returns the Supply run's id and title.
func (r titleResult) PublicJSON() ([]byte, error) { return json.Marshal(r) }
// runTitleJob titles the Supply run of p with archive_title and stores the
// title. A title that cannot be stored leaves the list untitled: the
// failure is logged and the job fails with ReasonTitleFailed.
func (s *Service) runTitleJob(ctx context.Context, p TitleJob, _ func(titleResult) error) (titleResult, error) {
title, err := s.writeTitle(ctx, p)
if err != nil {
s.logger.WarnContext(ctx, "Supply run left untitled; its next generation titles it again",
"week", p.WeekStart, "list", p.ListID, "error", err)
return titleResult{}, &jobs.Failure{Code: ReasonTitleFailed}
}
return titleResult{ListID: p.ListID, Title: title}, nil
}
// writeTitle runs archive_title for p's Supply run and stores its title.
// When the list was titled by another job, before this one ran or
// meanwhile, that title stands and is returned.
func (s *Service) writeTitle(ctx context.Context, p TitleJob) (string, error) {
q := db.New(s.st.Writer)
title, err := s.currentTitle(ctx, q, p)
if err != nil || title != "" {
return title, err
}
out, err := s.title(ctx, titleScope(p), skills.ArchiveTitleRequest{Dinners: p.Dinners})
if err != nil {
return "", fmt.Errorf("shopping: title: %w", err)
}
q := db.New(s.st.Writer)
n, err := q.SetShoppingListTitle(ctx, db.SetShoppingListTitleParams{
Title: sql.NullString{String: out.Title, Valid: true}, UpdatedAt: store.Now().String(), ID: l.ID,
write, cancel := jobs.FinalWriteContext(ctx)
defer cancel()
n, err := q.SetShoppingListTitle(write, db.SetShoppingListTitleParams{
Title: sql.NullString{String: out.Title, Valid: true}, UpdatedAt: store.Now().String(), ID: p.ListID,
})
if err != nil {
return "", fmt.Errorf("shopping: store title: %w", err)
@@ -307,14 +369,37 @@ func (s *Service) writeTitle(ctx context.Context, l *List, revision int64, dinne
if n == 1 {
return out.Title, nil
}
list, err := q.GetShoppingList(ctx, l.WeekStart)
if err != nil {
return "", fmt.Errorf("shopping: store title: read list: %w", err)
title, err = s.currentTitle(write, q, p)
if err == nil && title == "" {
err = errors.New("shopping: store title: the Supply run is still untitled")
}
if list.ID != l.ID || !list.Title.Valid {
return title, err
}
// currentTitle returns the title of p's Supply run, or "" while it is
// untitled. A list frozen or replaced untitled is an error: it can no
// longer be titled.
func (s *Service) currentTitle(ctx context.Context, q *db.Queries, p TitleJob) (string, error) {
list, err := q.GetShoppingList(ctx, p.WeekStart)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return "", fmt.Errorf("shopping: title: read list: %w", err)
}
switch {
case err == nil && list.ID == p.ListID && list.Title.Valid:
return list.Title.String, nil
case err != nil || list.ID != p.ListID || frozen(list):
return "", errors.New("shopping: store title: the Supply run was frozen or replaced before its title was stored")
}
return list.Title.String, nil
return "", nil
}
// titleScope is the archive_title scope of p: its week and plan revision,
// and a fingerprint of its dinners in day order, so runs share a title only
// when they title the same dinner names. A renamed dinner changes the
// names but not the plan revision.
func titleScope(p TitleJob) string {
names, _ := json.Marshal(p.Dinners) // a []string always encodes
return fmt.Sprintf("%s@%d/%x", p.WeekStart, p.Revision, sha256.Sum256(names))
}
// Get returns the Supply run of the week starting weekStart, as stored: a