Title Supply runs in a job, scoped by their dinner names
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:
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user