Keep Pass history immutable and replenish atomic with decisions
CI / ci (push) Successful in 2m56s
CI / ci (pull_request) Successful in 2m55s

Fixes the three review findings on 50399b6 (pull request #102):

- A batch now rereads every declined name inside its storing transaction,
  so a Pass committed after the batch read its input but before it stores
  can no longer let the passed dish into the deck (SQ-01).
- A decision that leaves the deck under five creates the replenish batch
  and its job in the decision's own transaction, so a decision is never
  recorded without the replenish it calls for; when they cannot be
  written, neither is the decision, and a retry decides again (SQ-02).
  StartBatch commits the batch with its job the same way, replacing the
  mutex and the fail-on-enqueue cleanup. jobs gains Kind.EnqueueTx and
  Kind.Wake for this.
- Migration 0012 gives each suggestion the name its dish was suggested
  under; declined names for batches and the household context's passed
  list read it instead of the dinner's mutable name, so renaming a passed
  dinner no longer erases it from dedupe (SQ-03).

Task: b4f2558c-227f-4627-852b-be5aa190242a

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 23:47:45 -04:00
co-authored by Claude Opus 5.5
parent 50399b6329
commit 4e2773703b
18 changed files with 482 additions and 125 deletions
+150
View File
@@ -17,6 +17,7 @@ import (
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
"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/recipes"
@@ -41,9 +42,12 @@ type env struct {
side *llm.Fake
// regulars are the Regulars' ids by name.
regulars map[string]string
grounder *recipes.Grounder
mu sync.Mutex
inputs []skills.SideQuestsRequest
// stub, when set, answers in place of suggest_side_quests.
stub func(in skills.SideQuestsRequest) skills.SideQuests
}
// newEnv returns a suggestions service over a migrated store with the
@@ -83,6 +87,7 @@ func newEnv(t *testing.T) *env {
e.regulars[name] = e.dinner(t, name, "regular")
}
g := recipestest.NewGrounder(t, st, dataDir, e.search, e.pages, e.images, e.model)
e.grounder = g
guard, err := harness.NewGuard(harness.DefaultMaxCalls)
if err != nil {
t.Fatal(err)
@@ -96,7 +101,11 @@ func newEnv(t *testing.T) *env {
suggester := func(ctx context.Context, scope string, in skills.SideQuestsRequest) (skills.SideQuests, error) {
e.mu.Lock()
e.inputs = append(e.inputs, in)
stub := e.stub
e.mu.Unlock()
if stub != nil {
return stub(in), nil
}
return skills.SuggestSideQuests(ctx, cfg, scope, in)
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
@@ -169,6 +178,7 @@ func (e *env) seedDeck(t *testing.T, names ...string) []string {
ID: id,
DinnerID: e.dinner(t, name, "suggested"),
BatchID: b.ID,
Name: name,
Reason: "Like your Beef Chili.",
CitedRegularIds: fmt.Sprintf("[%q]", e.regulars["Beef Chili"]),
CreatedAt: store.Now().String(),
@@ -672,6 +682,146 @@ func TestLaterBatchNeverResuggestsDeclined(t *testing.T) {
}
}
// TestBatchNeverStoresDishPassedWhileRunning passes a Side quest after a
// batch has read its input but before it stores, with suggest_side_quests
// answering from that input: the batch must not store the passed dish.
func TestBatchNeverStoresDishPassedWhileRunning(t *testing.T) {
e := newEnv(t)
e.start(t)
ids := e.seedDeck(t, "Chicken Shawarma Bowls")
e.recipe(t, "Chicken Shawarma Bowls")
e.recipe(t, "Beef Ragu")
held, release := make(chan struct{}), make(chan struct{})
stub := func(skills.SideQuestsRequest) skills.SideQuests {
close(held)
<-release
quest := func(name, cited string) skills.SideQuest {
return skills.SideQuest{Name: name, Cuisine: "Fusion", Method: "stovetop", EstMinutes: 35, Difficulty: "easy",
Reason: "Like your " + cited + ".", CitedRegulars: []string{cited}, SearchQuery: name}
}
return skills.SideQuests{Suggestions: []skills.SideQuest{
quest("Chicken Shawarma Bowls", "Chicken Fajitas"),
quest("Beef Ragu", "Beef Chili"),
}}
}
e.mu.Lock()
e.stub = stub
e.mu.Unlock()
b, err := e.svc.StartBatch(t.Context(), suggest.TriggerManual)
if err != nil {
t.Fatal(err)
}
<-held
if _, err := e.svc.Decline(t.Context(), ids[0], member); err != nil {
close(release)
t.Fatal(err)
}
close(release)
job := e.waitJob(t, b.JobID)
if job.Status != jobs.StatusSucceeded {
t.Fatalf("batch job %s: %v", job.Status, *job.Error)
}
var res batchResult
if err := json.Unmarshal(job.Result, &res); err != nil {
t.Fatal(err)
}
if len(res.SuggestionIDs) != 1 || res.Dropped != 1 {
t.Errorf("batch result = %+v, want 1 kept and the passed dish dropped", res)
}
if got := e.deckNames(t); !slices.Equal(got, []string{"Beef Ragu"}) {
t.Errorf("deck = %v, want [Beef Ragu]", got)
}
}
// TestLaterBatchNeverResuggestsRenamedPass passes a Side quest, renames
// its archived dinner, and checks the next batch still refuses the name it
// was suggested under.
func TestLaterBatchNeverResuggestsRenamedPass(t *testing.T) {
e := newEnv(t) // runner not started: the replenish batch waits for the rename
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
dinnerSvc, err := dinners.NewService(e.st, e.grounder, e.runner, logger)
if err != nil {
t.Fatal(err)
}
ids := e.seedDeck(t, "Chicken Shawarma Bowls")
d, err := e.svc.Decline(t.Context(), ids[0], member)
if err != nil {
t.Fatal(err)
}
if d.ReplenishJobID == nil {
t.Fatal("passing the last Side quest started no replenish batch")
}
renamed := "Leftover Wrap"
if _, err := dinnerSvc.Update(t.Context(), d.Suggestion.DinnerID, dinners.Patch{Name: &renamed}, member); err != nil {
t.Fatal(err)
}
// The respelled passed dish would ground, so only the declined names
// keep it out of the deck.
e.recipe(t, "chicken shawarma bowls!")
e.recipe(t, "Beef Ragu")
e.reply(
sideQuest("chicken shawarma bowls!", "Spiced chicken, like your Chicken Fajitas.", "Chicken Fajitas"),
sideQuest("Beef Ragu", "Slow beef like your Beef Chili.", "Beef Chili"),
)
e.start(t)
job := e.waitJob(t, *d.ReplenishJobID)
if job.Status != jobs.StatusSucceeded {
t.Fatalf("replenish job %s: %v", job.Status, *job.Error)
}
if got := e.deckNames(t); !slices.Equal(got, []string{"Beef Ragu"}) {
t.Errorf("deck = %v, want [Beef Ragu]", got)
}
e.mu.Lock()
in := e.inputs[0]
e.mu.Unlock()
if !slices.Equal(in.Declined, []string{"Chicken Shawarma Bowls"}) || !slices.Equal(in.Context.Passed, []string{"Chicken Shawarma Bowls"}) {
t.Errorf("replenish batch declined %v, passed %v; want the name it was suggested under in both", in.Declined, in.Context.Passed)
}
}
// TestDecisionNeverCommitsWithoutItsReplenish makes the replenish batch's
// job fail to store: the decision must not be recorded either, so the
// retry decides again and starts the replenish.
func TestDecisionNeverCommitsWithoutItsReplenish(t *testing.T) {
e := newEnv(t) // runner not started: the replenish batch stays pending
ids := e.seedDeck(t, "Pho", "Ramen", "Laksa", "Bibimbap", "Katsu")
if _, err := e.st.Writer.ExecContext(t.Context(), `CREATE TRIGGER refuse_jobs BEFORE INSERT ON jobs BEGIN SELECT RAISE(ABORT, 'jobs refused'); END`); err != nil {
t.Fatal(err)
}
if _, err := e.svc.Decline(t.Context(), ids[0], member); err == nil {
t.Fatal("decline succeeded with no replenish job stored")
}
if got := e.deckNames(t); len(got) != 5 {
t.Errorf("deck after the failed decline = %v, want all 5 still pending", got)
}
if n := e.activeBatches(t); n != 0 {
t.Errorf("%d active batches after the failed decline, want 0", n)
}
if _, err := e.st.Writer.ExecContext(t.Context(), `DROP TRIGGER refuse_jobs`); err != nil {
t.Fatal(err)
}
d, err := e.svc.Decline(t.Context(), ids[0], member)
if err != nil {
t.Fatal(err)
}
if d.Suggestion.Status != suggest.StatusDeclined || d.ReplenishJobID == nil {
t.Fatalf("retried decline = %+v, want declined with a replenish job", d)
}
job, err := e.runner.Get(t.Context(), *d.ReplenishJobID)
if err != nil {
t.Fatal(err)
}
if job.Kind != suggest.KindSuggestionBatch || job.Status != jobs.StatusQueued {
t.Errorf("replenish job = %+v, want a queued %s", job, suggest.KindSuggestionBatch)
}
if n := e.activeBatches(t); n != 1 {
t.Errorf("%d active batches, want 1", n)
}
}
func TestBatchFailsWhenAIFails(t *testing.T) {
e := newEnv(t)
e.start(t)