From 15860fbbafa98828d4f53c62c74f875b9a5505aa Mon Sep 17 00:00:00 2001 From: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com> Date: Sat, 10 Oct 2026 12:34:06 -0400 Subject: [PATCH] Plan draft schedule (Feature 20) (#175) Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com> --- cmd/server/main.go | 3 + docs/ARCHITECTURE.md | 15 +- internal/plan/plan.go | 30 +- internal/plan/schedule.go | 42 +++ internal/scheduler/plan_draft_test.go | 393 ++++++++++++++++++++++++++ 5 files changed, 475 insertions(+), 8 deletions(-) create mode 100644 internal/plan/schedule.go create mode 100644 internal/scheduler/plan_draft_test.go diff --git a/cmd/server/main.go b/cmd/server/main.go index bec36ad..969bdfd 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -195,6 +195,9 @@ func run(cfg config.Config, logger *slog.Logger) error { if err := sched.Register(suggestSvc.WeeklySchedule()); err != nil { return err } + if err := sched.Register(planSvc.DraftSchedule()); err != nil { + return err + } router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, settings, search, ai, chatSvc, dinnerSvc, suggestSvc, planSvc, shopSvc, googleSvc, pushSvc, ideasSvc, runner, photos, frontend.Assets()) if err != nil { diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c4db4a0..e0d9b0a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -780,7 +780,7 @@ purge are fixed and not configurable. | Schedule | When | Does | | --- | --- | --- | | `suggestions_weekly` | Sunday 08:00 | New batch of Side quests (`suggest.Service.WeeklySchedule`). | -| `plan_draft` | Friday 09:00 | AI drafts next week's plan. | +| `plan_draft` | Friday 09:00 | AI drafts next week's plan (`plan.Service.DraftSchedule`). | | `shopping_chat_autosend` | Saturday 08:00 | Sends the Supply run to Google Chat. | | `day_of_reminder` | Daily 16:30 | Tonight's dinner reminder. | | `rotation_check` | Daily 09:00 | Asks "Keep it in rotation?" for tried dinners. | @@ -814,6 +814,14 @@ Its run calls `StartScheduledBatch(period.Week)`, which starts the same batch and job as `POST /api/v1/suggestions/batches` (`StartBatch(manual)`), with the `scheduled` trigger, and returns at once; the batch runs as a job. When a batch is already pending or running the period starts none and logs it. +`plan.Service.DraftSchedule()` is `plan_draft`, keyed by the ISO week +after the fire date (`scheduler.WeeklyForNextWeek`). Its run calls +`StartScheduledDraft(period.Week)`, which enqueues the same `draft_week` +job as `POST /api/v1/plans/{weekStart}/draft` without confirm, so a stored +draft emits `plan_ready:{weekStart}` from the job and a failed one (e.g. +`ai_failed`, recorded in `ai_runs`) emits nothing. When the week already +has a plan, in any status, it enqueues nothing; when a draft of the week +is already queued or running it joins it. Either way it logs it. Fake-clock tests of a schedule live in `internal/scheduler`, where `SetClock` is visible to tests. @@ -825,8 +833,9 @@ Fake-clock tests of a schedule live in `internal/scheduler`, where - Where each event is emitted, with its idempotency key. Each is stored by `notify.EmitTx` in the transaction of the change it is about, so the change and its notification commit together or not at all: - - `plan_ready:{weekStart}` (URL `/plan`) when a draft job stores the - AI's draft of a week. Only the first draft of a week notifies: a + - `plan_ready:{weekStart}` (URL `/plan`) when a draft job, asked for + by the API or the `plan_draft` schedule, stores the AI's draft of a + week. Only the first draft of a week notifies: a redraft (Rebuild week) has the same key, and a manual swap never drafts. Its title is "Next week's plan is ready" (or "This week's", or "The plan for the week of Oct 26") and its body the drafted diff --git a/internal/plan/plan.go b/internal/plan/plan.go index 6f4a415..9cbb60a 100644 --- a/internal/plan/plan.go +++ b/internal/plan/plan.go @@ -152,8 +152,8 @@ type Service struct { // cal with links under publicURL, tells the household through notifier, // which must be over st, and registers the KindDraftWeek job on // runner, which must not have started yet. The kind is service-only: -// StartDraft, which keeps one draft in flight per week, is the only way to -// enqueue it. +// StartDraft and StartScheduledDraft, which keep one draft in flight per +// week, are the only way to enqueue it. func NewService(st *store.Store, draft Drafter, runner *jobs.Runner, cal Calendar, notifier *notify.Service, publicURL *url.URL, logger *slog.Logger) (*Service, error) { switch { case st == nil: @@ -342,10 +342,27 @@ type Draft struct { // enqueues nothing. Drafting over a synced or changed plan needs confirm, // else it fails with ErrConfirmRequired and enqueues nothing. func (s *Service) StartDraft(ctx context.Context, weekStart store.LocalDate, confirm bool) (Draft, error) { + d, _, err := s.startDraft(ctx, weekStart, confirm, false) + return d, err +} + +// StartScheduledDraft is StartDraft, without confirm, for the plan_draft +// schedule: it enqueues the same KindDraftWeek job, or returns the one +// queued or running, only while the week starting weekStart has no plan. +// A week that has a plan, in any status, is left as it is: nothing is +// enqueued and planned is true. +func (s *Service) StartScheduledDraft(ctx context.Context, weekStart store.LocalDate) (d Draft, planned bool, err error) { + return s.startDraft(ctx, weekStart, false, true) +} + +// startDraft is StartDraft, and when onlyUnplanned is set it enqueues +// nothing for a week that has a plan and reports planned true. +func (s *Service) startDraft(ctx context.Context, weekStart store.LocalDate, confirm, onlyUnplanned bool) (Draft, bool, error) { if _, err := household.WeekDays(weekStart); err != nil { - return Draft{}, &InvalidWeekError{Value: weekStart.String()} + return Draft{}, false, &InvalidWeekError{Value: weekStart.String()} } var d Draft + planned := false err := s.st.WithTx(ctx, func(tx *sql.Tx) error { q := db.New(tx) cur, err := q.GetWeekPlan(ctx, weekStart.String()) @@ -353,6 +370,9 @@ func (s *Service) StartDraft(ctx context.Context, weekStart store.LocalDate, con case errors.Is(err, sql.ErrNoRows): case err != nil: return fmt.Errorf("plan: read week %s: %w", weekStart, err) + case onlyUnplanned: + planned = true + return nil case cur.Status != StatusDraft && !confirm: return ErrConfirmRequired } @@ -375,12 +395,12 @@ func (s *Service) StartDraft(ctx context.Context, weekStart store.LocalDate, con return nil }) if err != nil { - return Draft{}, err + return Draft{}, false, err } if d.Created { s.draftJob.Wake() } - return d, nil + return d, planned, nil } // Swap plans dinnerID on date of the week starting weekStart, marking the diff --git a/internal/plan/schedule.go b/internal/plan/schedule.go new file mode 100644 index 0000000..527f814 --- /dev/null +++ b/internal/plan/schedule.go @@ -0,0 +1,42 @@ +package plan + +import ( + "context" + "time" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/scheduler" +) + +// DraftSchedule is the plan_draft schedule: at the household's +// plan_draft_dow and plan_draft_time it drafts the week after the fire +// date's, its period scheduler.WeeklyForNextWeek keys plan_draft:YYYY-Www +// for that week. The draft is the KindDraftWeek job a manual draft runs +// (StartScheduledDraft), so a stored draft emits plan_ready keyed +// plan_ready:{weekStart} and a draft that fails, as when the AI fails, +// stores no plan and emits nothing. A week that already has a plan is left +// as it is, and so is a draft of it already queued or running; either way +// the period says so. +func (s *Service) DraftSchedule() scheduler.Schedule { + return scheduler.Schedule{ + Name: scheduler.PlanDraft, + Rule: func(settings household.Settings) scheduler.Rule { + return scheduler.WeeklyForNextWeek(time.Weekday(settings.PlanDraftDow), settings.PlanDraftTime) + }, + Run: func(ctx context.Context, p scheduler.Period) error { + d, planned, err := s.StartScheduledDraft(ctx, p.Week) + if err != nil { + return err + } + switch { + case planned: + s.logger.InfoContext(ctx, "scheduled week draft not started: the week already has a plan", + "period", p.Key, "week_start", p.Week.String()) + case !d.Created: + s.logger.WarnContext(ctx, "scheduled week draft not started: a draft of the week is already under way", + "period", p.Key, "week_start", p.Week.String(), "job_id", d.JobID) + } + return nil + }, + } +} diff --git a/internal/scheduler/plan_draft_test.go b/internal/scheduler/plan_draft_test.go new file mode 100644 index 0000000..ad59e47 --- /dev/null +++ b/internal/scheduler/plan_draft_test.go @@ -0,0 +1,393 @@ +package scheduler_test + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "net/url" + "strings" + "sync" + "testing" + "time" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness" + "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/gcal" + "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/notify" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify/notifytest" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/scheduler" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" +) + +// The tests in this file run plan's plan_draft schedule on the fake clock +// against the real plan service, job runner, harness, draft_week and +// notification outbox, with the AI faked. + +// planEnv is a plan service over a scheduler env's store, the "process" a +// scheduler runs it in. model answers draft_week. +type planEnv struct { + e *env + svc *plan.Service + model *llm.Fake + ids map[string]string + logs *syncBuffer + stop func() +} + +// poolNames are the seven Regulars pool stores, enough for a week. +var poolNames = []string{"Beef Chili", "Pot Roast", "Chicken Fajitas", "Spaghetti Bolognese", "Sheet Pan Salmon", "Tacos", "Pancakes"} + +// pool stores poolNames as Regulars and returns their ids by name. +func (e *env) pool() map[string]string { + e.t.Helper() + ids := map[string]string{} + for _, name := range poolNames { + now := store.Now().String() + d, err := db.New(e.st.Writer).CreateDinner(context.Background(), db.CreateDinnerParams{ + ID: store.NewID(), + Name: name, + NormalizedName: harness.Normalize(name), + Status: "regular", + Origin: "regular", + GroundingStatus: "manual", + CreatedAt: now, + UpdatedAt: now, + }) + if err != nil { + e.t.Fatal(err) + } + ids[name] = d.ID + } + return ids +} + +// publicURL is MEALQUEST_PUBLIC_URL in these tests. +var publicURL = &url.URL{Scheme: "https", Host: "meals.example.com"} + +// noCalendar is a Calendar the schedule must never push to. +type noCalendar struct{ t *testing.T } + +func (c noCalendar) PushEvents(context.Context, []gcal.Event, func() error) error { + c.t.Error("plan_draft pushed to Calendar") + return errors.New("no calendar") +} + +// plans builds a plan service drafting from the dinners ids over e with a +// started job runner, which stop stops (and the test's end, if not before). +func (e *env) plans(ids map[string]string) *planEnv { + e.t.Helper() + p := &planEnv{e: e, model: &llm.Fake{}, ids: ids, logs: &syncBuffer{}} + guard, err := harness.NewGuard(harness.DefaultMaxCalls) + if err != nil { + e.t.Fatal(err) + } + cfg := harness.Config{ + Settings: llm.Settings{Provider: "anthropic", Model: "claude-test", Temperature: 0.7}, + NewProvider: func(llm.Settings, string) (llm.Provider, error) { return p.model, nil }, + Store: e.st, + Guard: guard, + } + drafter := func(ctx context.Context, scope string, in skills.WeekRequest) (skills.Week, error) { + return skills.DraftWeek(ctx, cfg, scope, in) + } + logger := slog.New(slog.NewTextHandler(p.logs, nil)) + runner, err := jobs.NewRunner(e.st, logger, 1) + if err != nil { + e.t.Fatal(err) + } + p.svc, err = plan.NewService(e.st, drafter, runner, noCalendar{e.t}, notifytest.New(e.t, e.st), publicURL, logger) + if err != nil { + e.t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + if err := runner.Start(ctx); err != nil { + cancel() + e.t.Fatal(err) + } + var once sync.Once + p.stop = func() { + once.Do(func() { + cancel() + runner.Wait() + }) + } + e.t.Cleanup(p.stop) + return p +} + +// reply scripts the next draft_week answer: poolNames on the days of the +// week starting weekStart, Monday to Sunday. +func (p *planEnv) reply(weekStart store.LocalDate) { + p.e.t.Helper() + dates, err := household.WeekDays(weekStart) + if err != nil { + p.e.t.Fatal(err) + } + var days []string + for i, name := range poolNames { + days = append(days, fmt.Sprintf(`{"date":%q,"dinner_id":%q}`, dates[i], p.ids[name])) + } + js := fmt.Sprintf(`{"days":[%s],"note":""}`, strings.Join(days, ",")) + p.model.QueueGenerate(llm.GenerateResult{Response: llm.Response{Text: js, JSON: json.RawMessage(js)}}) +} + +// draftJob is a draft_week jobs row. +type draftJob struct { + Status, Error string +} + +// drafts waits until no draft_week job is queued or running and returns +// them all, oldest first. +func (e *env) drafts() []draftJob { + e.t.Helper() + deadline := time.Now().Add(10 * time.Second) + for { + rows, err := e.st.Reader.Query(`SELECT status, COALESCE(error, '') FROM jobs WHERE kind = ? ORDER BY created_at, id`, plan.KindDraftWeek) + if err != nil { + e.t.Fatal(err) + } + var out []draftJob + busy := false + for rows.Next() { + var j draftJob + if err := rows.Scan(&j.Status, &j.Error); err != nil { + e.t.Fatal(err) + } + busy = busy || j.Status == string(jobs.StatusQueued) || j.Status == string(jobs.StatusRunning) + out = append(out, j) + } + if err := rows.Err(); err != nil { + e.t.Fatal(err) + } + rows.Close() + if !busy { + return out + } + if time.Now().After(deadline) { + e.t.Fatalf("draft jobs still under way: %+v", out) + } + time.Sleep(5 * time.Millisecond) + } +} + +// wantDrafts fails unless the draft_week jobs are got. +func wantDrafts(t *testing.T, what string, got []draftJob, want ...draftJob) { + t.Helper() + if fmt.Sprint(got) != fmt.Sprint(want) { + t.Fatalf("%s: draft jobs = %+v, want %+v", what, got, want) + } +} + +// weekPlans returns the week_start of every plan, oldest first. +func (e *env) weekPlans() []string { + e.t.Helper() + rows, err := e.st.Reader.Query(`SELECT week_start FROM week_plans ORDER BY week_start`) + if err != nil { + e.t.Fatal(err) + } + defer rows.Close() + var out []string + for rows.Next() { + var w string + if err := rows.Scan(&w); err != nil { + e.t.Fatal(err) + } + out = append(out, w) + } + if err := rows.Err(); err != nil { + e.t.Fatal(err) + } + return out +} + +// planReady returns the distinct plan_ready keys stored, oldest first. +func (e *env) planReady() []string { + e.t.Helper() + var keys []string + for _, r := range notifytest.Rows(e.t, e.st, notify.PlanReady) { + if len(keys) == 0 || keys[len(keys)-1] != r.Key { + keys = append(keys, r.Key) + } + } + return keys +} + +var ( + succeeded = draftJob{Status: string(jobs.StatusSucceeded)} + aiFailed = draftJob{Status: string(jobs.StatusFailed), Error: plan.ReasonAIFailed} + // w43 is the Monday of 2026-W43, the week after Friday Oct 16. + w43 = mustLocalDate("2026-10-19") + w44 = mustLocalDate("2026-10-26") +) + +func mustLocalDate(s string) store.LocalDate { + d, err := store.ParseLocalDate(s) + if err != nil { + panic(err) + } + return d +} + +// TestPlanDraftDraftsNextWeek: at the seeded Friday 09:00 the schedule +// drafts the next ISO week through the plan service's draft job, period +// plan_draft:YYYY-Www of that week, and the draft emits plan_ready keyed +// by its Monday once. +func TestPlanDraftDraftsNextWeek(t *testing.T) { + e := newEnv(t) + p := e.plans(e.pool()) + p.reply(w43) + p.reply(w44) + + // Wednesday Oct 14: the Friday Oct 9 09:00 fire is days ago, missed. + r := e.start(at(2026, 10, 14, 12, 0), p.svc.DraftSchedule()) + r.runUntil(at(2026, 10, 16, 8, 59)) + wantDrafts(t, "before Friday 09:00", e.drafts()) + wantKeys(t, "plans before Friday 09:00", e.weekPlans(), nil) + + r.runUntil(at(2026, 10, 16, 9, 0)) + wantDrafts(t, "at Friday 09:00", e.drafts(), succeeded) + wantKeys(t, "plans at Friday 09:00", e.weekPlans(), []string{"2026-10-19"}) + wantKeys(t, "plan_ready of W43", e.planReady(), []string{"plan_ready:2026-10-19"}) + got, err := p.svc.Get(context.Background(), w43) + if err != nil { + t.Fatal(err) + } + if len(got.Days) != 7 || got.Days[0].Dinner.Name != "Beef Chili" || got.Status != plan.StatusDraft { + t.Errorf("plan of W43 = %+v, want the drafted week", got) + } + + r.runUntil(at(2026, 10, 23, 9, 0)) + wantDrafts(t, "W44", e.drafts(), succeeded, succeeded) + wantKeys(t, "plans", e.weekPlans(), []string{"2026-10-19", "2026-10-26"}) + wantKeys(t, "plan_ready of W43 and W44", e.planReady(), []string{"plan_ready:2026-10-19", "plan_ready:2026-10-26"}) + wantKeys(t, "schedule_runs", e.claimed(scheduler.PlanDraft), []string{"2026-W42", "2026-W43", "2026-W44"}) +} + +// TestPlanDraftLeavesAnExistingPlan: when next week already has a plan, +// here one the household drafted by hand, the period drafts nothing, +// notifies no one and says so. +func TestPlanDraftLeavesAnExistingPlan(t *testing.T) { + e := newEnv(t) + p := e.plans(e.pool()) + p.reply(w43) + p.reply(w43) // a second draft would succeed too + if _, err := p.svc.StartDraft(context.Background(), w43, false); err != nil { + t.Fatal(err) + } + wantDrafts(t, "manual draft", e.drafts(), succeeded) + wantKeys(t, "plan_ready of the manual draft", e.planReady(), []string{"plan_ready:2026-10-19"}) + before, err := p.svc.Get(context.Background(), w43) + if err != nil { + t.Fatal(err) + } + + r := e.start(at(2026, 10, 16, 8, 0), p.svc.DraftSchedule()) + r.runUntil(at(2026, 10, 16, 9, 0)) + wantDrafts(t, "with a plan", e.drafts(), succeeded) + wantKeys(t, "plan_ready with a plan", e.planReady(), []string{"plan_ready:2026-10-19"}) + wantKeys(t, "schedule_runs", e.claimed(scheduler.PlanDraft), []string{"2026-W42", "2026-W43"}) + after, err := p.svc.Get(context.Background(), w43) + if err != nil { + t.Fatal(err) + } + if fmt.Sprint(after) != fmt.Sprint(before) { + t.Errorf("plan of W43 = %+v, want it unchanged from %+v", after, before) + } + if logs := p.logs.String(); !strings.Contains(logs, "the week already has a plan") || !strings.Contains(logs, "period=2026-W43") { + t.Errorf("logs = %s, want the skipped period logged", logs) + } +} + +// TestPlanDraftNoDuplicateAfterRestart: a restart after the week was +// drafted, still within the catch-up window, drafts nothing more and sends +// no second plan_ready: the period is already claimed. +func TestPlanDraftNoDuplicateAfterRestart(t *testing.T) { + e := newEnv(t) + ids := e.pool() + p := e.plans(ids) + p.reply(w43) + r := e.start(at(2026, 10, 16, 8, 0), p.svc.DraftSchedule()) + r.runUntil(at(2026, 10, 16, 9, 0)) + wantDrafts(t, "first process", e.drafts(), succeeded) + r.stop() + p.stop() + + // The restarted process has a new job runner, plan service and + // scheduler; it would answer a second draft if one were started. + p2 := e.plans(ids) + p2.reply(w43) + r2 := e.start(at(2026, 10, 16, 10, 0), p2.svc.DraftSchedule()) + r2.runUntil(at(2026, 10, 23, 8, 59)) + wantDrafts(t, "after restart", e.drafts(), succeeded) + wantKeys(t, "plans after restart", e.weekPlans(), []string{"2026-10-19"}) + wantKeys(t, "plan_ready after restart", e.planReady(), []string{"plan_ready:2026-10-19"}) + wantKeys(t, "schedule_runs", e.claimed(scheduler.PlanDraft), []string{"2026-W42", "2026-W43"}) +} + +// TestPlanDraftAIFailureEmitsNothing: when draft_week fails the draft job +// fails as ai_failed with the failure recorded in ai_runs, no plan is made +// up and no one is notified; a restart within the catch-up window does not +// draft the claimed period again. +func TestPlanDraftAIFailureEmitsNothing(t *testing.T) { + e := newEnv(t) + ids := e.pool() + p := e.plans(ids) + p.model.QueueGenerate(llm.GenerateResult{Err: errors.New("provider down")}) + + r := e.start(at(2026, 10, 16, 8, 0), p.svc.DraftSchedule()) + r.runUntil(at(2026, 10, 16, 9, 0)) + wantDrafts(t, "after the AI failed", e.drafts(), aiFailed) + wantKeys(t, "plans after the AI failed", e.weekPlans(), nil) + wantKeys(t, "plan_ready after the AI failed", e.planReady(), nil) + wantKeys(t, "schedule_runs", e.claimed(scheduler.PlanDraft), []string{"2026-W42", "2026-W43"}) + + var status, msg string + if err := e.st.Reader.QueryRow(`SELECT status, COALESCE(error, '') FROM ai_runs WHERE skill = 'draft_week'`).Scan(&status, &msg); err != nil { + t.Fatalf("ai_runs row of draft_week: %v", err) + } + if status != string(harness.StatusProviderError) || msg == "" { + t.Errorf("ai_runs draft_week = %s %q, want %s with its message", status, msg, harness.StatusProviderError) + } + if logs := p.logs.String(); !strings.Contains(logs, "week draft failed") || !strings.Contains(logs, "reason="+plan.ReasonAIFailed) { + t.Errorf("logs = %s, want the draft failure with reason %s", logs, plan.ReasonAIFailed) + } + + r.stop() + p.stop() + p2 := e.plans(ids) + p2.reply(w43) + e.start(at(2026, 10, 16, 10, 0), p2.svc.DraftSchedule()) + wantDrafts(t, "after restart", e.drafts(), aiFailed) + wantKeys(t, "plan_ready after restart", e.planReady(), nil) +} + +// TestPlanDraftFollowsSettingsWithoutRestart: a plan_draft_dow and +// plan_draft_time change takes effect in the running scheduler; the old +// Friday 09:00 no longer fires and the new Wednesday 18:30 drafts the next +// week. +func TestPlanDraftFollowsSettingsWithoutRestart(t *testing.T) { + e := newEnv(t) + p := e.plans(e.pool()) + p.reply(w43) + + r := e.start(at(2026, 10, 12, 12, 0), p.svc.DraftSchedule()) // Monday + e.update(household.SettingsPatch{PlanDraftDow: ptr(3), PlanDraftTime: ptr("18:30")}) + r.changed() + r.runUntil(at(2026, 10, 14, 18, 29)) // Wednesday + wantDrafts(t, "before Wednesday 18:30", e.drafts()) + r.runUntil(at(2026, 10, 14, 18, 30)) + wantDrafts(t, "at Wednesday 18:30", e.drafts(), succeeded) + wantKeys(t, "plan_ready", e.planReady(), []string{"plan_ready:2026-10-19"}) + + // Friday 09:00, the old fire time, drafts nothing more. + r.runUntil(at(2026, 10, 16, 9, 0)) + wantDrafts(t, "at Friday 09:00", e.drafts(), succeeded) + wantKeys(t, "schedule_runs", e.claimed(scheduler.PlanDraft), []string{"2026-W42", "2026-W43"}) +}