Plan draft schedule (Feature 20) #175
@@ -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 {
|
||||
|
||||
+12
-3
@@ -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
|
||||
|
||||
+25
-5
@@ -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
|
||||
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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"})
|
||||
}
|
||||
Reference in New Issue
Block a user