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
+70 -3
View File
@@ -12,6 +12,7 @@ import (
"strings"
"sync"
"testing"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
@@ -46,11 +47,15 @@ type env struct {
planID string
// ids are the dinners' ids by name.
ids map[string]string
// runner runs the title jobs, two at a time, once start starts it.
runner *jobs.Runner
// model answers archive_title, which titles the Supply runs; with
// nothing queued it fails, and the lists stay untitled.
model *llm.Fake
// beforeTitle, when set, runs as archive_title is asked for a title.
beforeTitle func()
// beforeModel, when set, runs as the model is sent a request.
beforeModel func(llm.Request)
// log holds what the service logged.
log *syncBuffer
}
@@ -95,7 +100,7 @@ func newEnv(t *testing.T) *env {
}
cfg := harness.Config{
Settings: llm.Settings{Provider: "anthropic", Model: "claude-test", Temperature: 0.7},
NewProvider: func(llm.Settings, string) (llm.Provider, error) { return e.model, nil },
NewProvider: func(llm.Settings, string) (llm.Provider, error) { return hookedModel{e}, nil },
Store: st,
Guard: guard,
}
@@ -105,13 +110,70 @@ func newEnv(t *testing.T) *env {
}
return skills.TitleArchive(ctx, cfg, scope, in)
}
e.svc, err = shopping.NewService(st, title, slog.New(slog.NewTextHandler(e.log, nil)))
logger := slog.New(slog.NewTextHandler(e.log, nil))
e.runner, err = jobs.NewRunner(st, logger, 2)
if err != nil {
t.Fatal(err)
}
e.svc, err = shopping.NewService(st, title, e.runner, logger)
if err != nil {
t.Fatal(err)
}
return e
}
// hookedModel is the env's model, running beforeModel first.
type hookedModel struct{ e *env }
func (m hookedModel) Generate(ctx context.Context, req llm.Request) (llm.Response, error) {
if m.e.beforeModel != nil {
m.e.beforeModel(req)
}
return m.e.model.Generate(ctx, req)
}
func (m hookedModel) ListModels(ctx context.Context) ([]llm.Model, error) {
return m.e.model.ListModels(ctx)
}
func (m hookedModel) Verify(ctx context.Context) error { return m.e.model.Verify(ctx) }
// start starts the env's job runner, stopping it when the test ends.
func (e *env) start(t *testing.T) {
t.Helper()
ctx, cancel := context.WithCancel(context.Background())
if err := e.runner.Start(ctx); err != nil {
cancel()
t.Fatal(err)
}
t.Cleanup(func() {
cancel()
e.runner.Wait()
})
}
// waitTitle waits for g's title job to finish and returns it.
func (e *env) waitTitle(t *testing.T, g shopping.Generated) jobs.Job {
t.Helper()
if g.TitleJobID == nil {
t.Fatal("generation enqueued no title job")
}
deadline := time.Now().Add(10 * time.Second)
for {
job, err := e.runner.Get(t.Context(), *g.TitleJobID)
if err != nil {
t.Fatal(err)
}
if job.Status == jobs.StatusSucceeded || job.Status == jobs.StatusFailed {
return job
}
if time.Now().After(deadline) {
t.Fatalf("title job %s still %s", job.ID, job.Status)
}
time.Sleep(5 * time.Millisecond)
}
}
// ing is a recipe ingredient as recipe grounding stores it: an empty
// qty or unit is NULL.
type ing struct {
@@ -395,8 +457,13 @@ func TestGenerateSevenDayPlan(t *testing.T) {
}
// Generating again from the same plan changes nothing: same rows, same
// ids, no extra rows.
// ids, no extra rows. The list is still untitled, so it gets another
// title job.
again := e.generate(t)
if again.TitleJobID == nil || g.TitleJobID == nil || *again.TitleJobID == *g.TitleJobID {
t.Errorf("title jobs = %v then %v, want one per generation of the untitled list", deref(g.TitleJobID), deref(again.TitleJobID))
}
again.TitleJobID = g.TitleJobID
if !reflect.DeepEqual(again, g) {
t.Errorf("second generation =\n %+v\nwant the first\n %+v", again, g)
}