--- /dev/null
+package main
+
+import (
+ "cmp"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "os"
+ "slices"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+)
+
+const (
+ // scheduleFileName is the file of the schedules in the directory of the
+ // agent.
+ scheduleFileName = "schedules.json"
+
+ // scheduleTextBytes is the most bytes of the text of a schedule.
+ scheduleTextBytes = 4096
+
+ // scheduleAtLayout is the layout of the time of a one-time schedule.
+ scheduleAtLayout = "2006-01-02 15:04"
+
+ // scheduleTimeLayout is the layout of the times in the answers of the
+ // tools and in the log.
+ scheduleTimeLayout = "Monday 2006-01-02 15:04 -07:00"
+
+ // scheduleLate is how long after its time a run is late.
+ scheduleLate = time.Minute
+
+ // scheduleEmptyReply is the last line of the message of a run.
+ scheduleEmptyReply = "If nothing in this task needs the user, end your turn with an empty reply."
+
+ // scheduleLateLine tells the agent that a run is late.
+ scheduleLateLine = "This run is late: the daemon was stopped at that time."
+)
+
+// openSchedules reads the schedules of the file at path. A missing file holds
+// none. The clock gives the time and the time zone of the schedules.
+func openSchedules(path string, clock func() time.Time) (*Schedules, error) {
+ s := &Schedules{
+ path: path,
+ clock: clock,
+ }
+
+ data, err := os.ReadFile(path)
+ if errors.Is(err, os.ErrNotExist) {
+ return s, nil
+ }
+
+ if err != nil {
+ return nil, fmt.Errorf("read schedules: %w", err)
+ }
+
+ err = json.Unmarshal(data, &s.file)
+ if err != nil {
+ return nil, fmt.Errorf("%s: %w", path, err)
+ }
+
+ for _, schedule := range s.file.Schedules {
+ if schedule.Cron == "" {
+ continue
+ }
+
+ _, err = scheduleParseCron(schedule.Cron)
+ if err != nil {
+ return nil, fmt.Errorf("%s: schedule %d: %w", path, schedule.ID, err)
+ }
+ }
+
+ return s, nil
+}
+
+// Schedules holds the schedules of the agent: tasks that start a turn at one
+// time or at the times of a cron expression. Each change writes the file whole
+// before it returns, so a schedule survives a restart. The mutex protects the
+// schedules, because the tools change them from a turn while the runs read
+// them.
+type Schedules struct {
+ path string
+ clock func() time.Time
+
+ mutex sync.Mutex
+ file ScheduleFile
+}
+
+// Set adds a schedule with text and returns the answer of the tool. Exactly one
+// of at and cron is not nil. at is a local time in scheduleAtLayout after now,
+// and it goes through scheduleWallTime, so a time in the hour that the clock
+// repeats is its first occurrence. cron is an expression of scheduleParseCron.
+func (s *Schedules) Set(text string, at, cron *string) (string, error) {
+ text = strings.TrimSpace(text)
+ if text == "" || len(text) > scheduleTextBytes {
+ return "", fmt.Errorf("the text must have 1 to %d bytes", scheduleTextBytes)
+ }
+
+ now := s.clock()
+ schedule := Schedule{
+ Text: text,
+ }
+
+ switch {
+ case (at == nil) == (cron == nil):
+ return "", errors.New("give either at or cron, and null for the other")
+ case at != nil:
+ parsed, err := time.Parse(scheduleAtLayout, *at)
+ if err != nil {
+ return "", fmt.Errorf("at must be a local time such as %s", now.Format(scheduleAtLayout))
+ }
+
+ schedule.Due = scheduleWallTime(parsed.Year(), parsed.Month(), parsed.Day(), parsed.Hour(), parsed.Minute(), now.Location())
+ if !schedule.Due.After(now) {
+ return "", fmt.Errorf("the time %s is not after now, %s", schedule.Due.Format(scheduleTimeLayout), now.Format(scheduleTimeLayout))
+ }
+ default:
+ parsed, err := scheduleParseCron(*cron)
+ if err != nil {
+ return "", err
+ }
+
+ next, found := parsed.Next(now)
+ if !found {
+ return "", fmt.Errorf("the cron expression %q matches no time in %d years", *cron, scheduleCronYears)
+ }
+
+ schedule.Cron = parsed.String()
+ schedule.Due = next
+ }
+
+ s.mutex.Lock()
+ defer s.mutex.Unlock()
+
+ s.file.Next++
+ schedule.ID = s.file.Next
+ s.file.Schedules = append(s.file.Schedules, schedule)
+ err := s.save()
+ if err != nil {
+ return "", err
+ }
+
+ return fmt.Sprintf("Schedule %d set. Next: %s.", schedule.ID, schedule.Due.In(now.Location()).Format(scheduleTimeLayout)), nil
+}
+
+// List returns the answer of the tool: one line for each schedule, by due time,
+// with its ID, its next time, its cron expression or once, and its text on one
+// line.
+func (s *Schedules) List() string {
+ location := s.clock().Location()
+ schedules := s.sorted()
+ if len(schedules) == 0 {
+ return "No schedules."
+ }
+
+ lines := make([]string, 0, len(schedules))
+ for _, schedule := range schedules {
+ lines = append(lines, strconv.Itoa(schedule.ID)+" | "+schedule.Due.In(location).Format(scheduleTimeLayout)+" | "+cmp.Or(schedule.Cron, "once")+" | "+strings.Join(strings.Fields(schedule.Text), " "))
+ }
+
+ return strings.Join(lines, "\n")
+}
+
+// Remove removes the schedule id and returns the answer of the tool.
+func (s *Schedules) Remove(id int) (string, error) {
+ s.mutex.Lock()
+ defer s.mutex.Unlock()
+
+ index := slices.IndexFunc(s.file.Schedules, func(schedule Schedule) bool {
+ return schedule.ID == id
+ })
+
+ if index < 0 {
+ return "", fmt.Errorf("no schedule %d", id)
+ }
+
+ s.file.Schedules = slices.Delete(s.file.Schedules, index, index+1)
+ err := s.save()
+ if err != nil {
+ return "", err
+ }
+
+ return fmt.Sprintf("Schedule %d removed.", id), nil
+}
+
+// Due returns the schedules whose time is not after now, by due time.
+func (s *Schedules) Due(now time.Time) []Schedule {
+ var due []Schedule
+ for _, schedule := range s.sorted() {
+ if !schedule.Due.After(now) {
+ due = append(due, schedule)
+ }
+ }
+
+ return due
+}
+
+// Done ends the run of schedule at now: a one-time schedule goes, and a
+// repeating schedule gets the next time after now. So a repeating schedule that
+// missed several times runs once. When a remove took the schedule first, Done
+// changes nothing.
+func (s *Schedules) Done(schedule Schedule, now time.Time) error {
+ s.mutex.Lock()
+ defer s.mutex.Unlock()
+
+ index := slices.IndexFunc(s.file.Schedules, func(held Schedule) bool {
+ return held.ID == schedule.ID
+ })
+
+ if index < 0 {
+ return nil
+ }
+
+ if schedule.Cron == "" {
+ s.file.Schedules = slices.Delete(s.file.Schedules, index, index+1)
+
+ return s.save()
+ }
+
+ cron, err := scheduleParseCron(schedule.Cron)
+ if err != nil {
+ return fmt.Errorf("schedule %d: %w", schedule.ID, err)
+ }
+
+ next, found := cron.Next(now)
+ if !found {
+ s.file.Schedules = slices.Delete(s.file.Schedules, index, index+1)
+
+ return s.save()
+ }
+
+ s.file.Schedules[index].Due = next
+
+ return s.save()
+}
+
+// Wait returns how long after now the first schedule is due, or false when
+// there is no schedule.
+func (s *Schedules) Wait(now time.Time) (time.Duration, bool) {
+ schedules := s.sorted()
+ if len(schedules) == 0 {
+ return 0, false
+ }
+
+ return max(schedules[0].Due.Sub(now), 0), true
+}
+
+// sorted returns a copy of the schedules by due time, then by ID.
+func (s *Schedules) sorted() []Schedule {
+ s.mutex.Lock()
+ schedules := slices.Clone(s.file.Schedules)
+ s.mutex.Unlock()
+
+ slices.SortFunc(schedules, func(a, b Schedule) int {
+ return cmp.Or(a.Due.Compare(b.Due), cmp.Compare(a.ID, b.ID))
+ })
+
+ return schedules
+}
+
+// save writes the file whole, with mode 0600. The caller holds the mutex.
+func (s *Schedules) save() error {
+ data, err := json.Marshal(s.file)
+ if err == nil {
+ err = writeFileAtomic(s.path, append(data, '\n'))
+ }
+
+ if err != nil {
+ return fmt.Errorf("save schedules: %w", err)
+ }
+
+ return nil
+}
+
+// ScheduleFile is the content of the file of the schedules: the ID of the last
+// schedule, so that an ID is never used again, and the schedules.
+type ScheduleFile struct {
+ Next int `json:"next"`
+ Schedules []Schedule `json:"schedules"`
+}
+
+// Schedule is one schedule: its ID, the task for the agent, the cron expression
+// of a repeating schedule, empty for a one-time schedule, and its next time.
+type Schedule struct {
+ ID int `json:"id"`
+ Text string `json:"text"`
+ Cron string `json:"cron"`
+ Due time.Time `json:"due"`
+}
+
+// Message returns the text of a run at now for the log, with the times in the
+// location of now. The first line starts with [Schedule ID] and gives the due
+// time and once or the cron expression. Then come the text, the line about a
+// late run when now is more than scheduleLate after the due time, and the line
+// about the empty reply.
+func (s Schedule) Message(now time.Time) string {
+ lines := []string{
+ "[Schedule " + strconv.Itoa(s.ID) + "] " + s.Due.In(now.Location()).Format(scheduleTimeLayout) + ", " + s.repeats(),
+ s.Text,
+ }
+
+ if now.Sub(s.Due) > scheduleLate {
+ lines = append(lines, scheduleLateLine)
+ }
+
+ return strings.Join(append(lines, scheduleEmptyReply), "\n")
+}
+
+// repeats returns once for a one-time schedule, or else repeats and the cron
+// expression.
+func (s Schedule) repeats() string {
+ if s.Cron == "" {
+ return "once"
+ }
+
+ return "repeats " + s.Cron
+}
--- /dev/null
+package main
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestSchedulesSet(t *testing.T) {
+ testCases := []struct {
+ name string
+ text string
+ at *string
+ cron *string
+ answer string
+ listed string
+ }{
+ {
+ name: "a one-time schedule at a local time",
+ text: "Remind the user to drink water.",
+ at: new("2026-11-09 09:00"),
+ answer: "Schedule 1 set. Next: Monday 2026-11-09 09:00 -03:00.",
+ listed: "1 | Monday 2026-11-09 09:00 -03:00 | once | Remind the user to drink water.",
+ },
+ {
+ name: "a repeating schedule at the next match",
+ text: "Tell the user\nthe time.",
+ cron: new("*/5 * * * *"),
+ answer: "Schedule 1 set. Next: Sunday 2026-11-08 20:05 -03:00.",
+ listed: "1 | Sunday 2026-11-08 20:05 -03:00 | */5 * * * * | Tell the user the time.",
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ schedules, _ := scheduleTestOpen(t)
+
+ answer, err := schedules.Set(tc.text, tc.at, tc.cron)
+ require.NoError(t, err)
+ require.Equal(t, tc.answer, answer)
+ require.Equal(t, tc.listed, schedules.List())
+ })
+ }
+}
+
+func TestSchedulesSetRefuses(t *testing.T) {
+ testCases := []struct {
+ name string
+ text string
+ at *string
+ cron *string
+ message string
+ }{
+ {
+ name: "an empty text",
+ text: " \n",
+ at: new("2026-11-09 09:00"),
+ message: "the text must have 1 to 4096 bytes",
+ },
+ {
+ name: "a text that is too long",
+ text: strings.Repeat("x", scheduleTextBytes+1),
+ at: new("2026-11-09 09:00"),
+ message: "the text must have 1 to 4096 bytes",
+ },
+ {
+ name: "neither at nor cron",
+ text: "Tell the user the time.",
+ message: "give either at or cron, and null for the other",
+ },
+ {
+ name: "both at and cron",
+ text: "Tell the user the time.",
+ at: new("2026-11-09 09:00"),
+ cron: new("0 9 * * *"),
+ message: "give either at or cron, and null for the other",
+ },
+ {
+ name: "a time in another layout",
+ text: "Tell the user the time.",
+ at: new("2026-11-09T09:00:00-03:00"),
+ message: "at must be a local time such as 2026-11-08 20:00",
+ },
+ {
+ name: "a time that is past",
+ text: "Tell the user the time.",
+ at: new("2026-11-08 20:00"),
+ message: "the time Sunday 2026-11-08 20:00 -03:00 is not after now, Sunday 2026-11-08 20:00 -03:00",
+ },
+ {
+ name: "runs less than 5 minutes apart",
+ text: "Tell the user the time.",
+ cron: new("* * * * *"),
+ message: "at least 5 minutes apart",
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ schedules, path := scheduleTestOpen(t)
+
+ _, err := schedules.Set(tc.text, tc.at, tc.cron)
+ require.ErrorContains(t, err, tc.message)
+ require.NoFileExists(t, path)
+ })
+ }
+}
+
+func TestSchedulesSurviveARestart(t *testing.T) {
+ schedules, path := scheduleTestOpen(t)
+
+ _, err := schedules.Set("Remind the user to drink water.", new("2026-11-09 09:00"), nil)
+ require.NoError(t, err)
+
+ _, err = schedules.Set("Tell the user the news.", nil, new("0 8 * * 1-5"))
+ require.NoError(t, err)
+
+ _, err = schedules.Remove(1)
+ require.NoError(t, err)
+
+ info, err := os.Stat(path)
+ require.NoError(t, err)
+ require.Equal(t, os.FileMode(0o600), info.Mode().Perm())
+
+ reopened, err := openSchedules(path, schedules.clock)
+ require.NoError(t, err)
+ require.Equal(t, "2 | Monday 2026-11-09 08:00 -03:00 | 0 8 * * 1-5 | Tell the user the news.", reopened.List())
+
+ answer, err := reopened.Set("Remind the user to call.", new("2026-11-08 21:00"), nil)
+ require.NoError(t, err)
+ require.Equal(t, "Schedule 3 set. Next: Sunday 2026-11-08 21:00 -03:00.", answer)
+}
+
+func TestSchedulesRemove(t *testing.T) {
+ schedules, _ := scheduleTestOpen(t)
+
+ _, err := schedules.Set("Remind the user to drink water.", new("2026-11-09 09:00"), nil)
+ require.NoError(t, err)
+
+ answer, err := schedules.Remove(1)
+ require.NoError(t, err)
+ require.Equal(t, "Schedule 1 removed.", answer)
+ require.Equal(t, "No schedules.", schedules.List())
+
+ _, err = schedules.Remove(1)
+ require.EqualError(t, err, "no schedule 1")
+}
+
+func TestSchedulesDone(t *testing.T) {
+ schedules, _ := scheduleTestOpen(t)
+ start := schedules.clock()
+
+ _, err := schedules.Set("Remind the user to drink water.", new("2026-11-08 21:00"), nil)
+ require.NoError(t, err)
+
+ _, err = schedules.Set("Tell the user the time.", nil, new("0 * * * *"))
+ require.NoError(t, err)
+
+ wait, found := schedules.Wait(start)
+ require.True(t, found)
+ require.Equal(t, time.Hour, wait)
+
+ // The daemon was stopped for three hours, so each schedule missed its
+ // times and runs once.
+ now := start.Add(3*time.Hour + 30*time.Minute)
+ due := schedules.Due(now)
+ require.Len(t, due, 2)
+ for _, schedule := range due {
+ require.NoError(t, schedules.Done(schedule, now))
+ }
+
+ require.Empty(t, schedules.Due(now))
+ require.Equal(t, "2 | Monday 2026-11-09 00:00 -03:00 | 0 * * * * | Tell the user the time.", schedules.List())
+
+ _, err = schedules.Remove(2)
+ require.NoError(t, err)
+ require.NoError(t, schedules.Done(due[1], now))
+ require.Equal(t, "No schedules.", schedules.List())
+
+ _, found = schedules.Wait(now)
+ require.False(t, found)
+}
+
+func TestScheduleMessage(t *testing.T) {
+ zone := time.FixedZone("", -3*60*60)
+ due := time.Date(2026, time.November, 9, 9, 0, 0, 0, zone)
+ testCases := []struct {
+ name string
+ schedule Schedule
+ now time.Time
+ message string
+ }{
+ {
+ name: "a one-time run on time",
+ schedule: Schedule{
+ ID: 3,
+ Text: "Remind the user to drink water.",
+ Due: due,
+ },
+ now: due.Add(time.Second),
+ message: "[Schedule 3] Monday 2026-11-09 09:00 -03:00, once\nRemind the user to drink water.\n" + scheduleEmptyReply,
+ },
+ {
+ name: "a late repeating run",
+ schedule: Schedule{
+ ID: 4,
+ Text: "Tell the user the news.",
+ Cron: "0 9 * * 1-5",
+ Due: due,
+ },
+ now: due.Add(2 * time.Hour),
+ message: "[Schedule 4] Monday 2026-11-09 09:00 -03:00, repeats 0 9 * * 1-5\nTell the user the news.\n" + scheduleLateLine + "\n" + scheduleEmptyReply,
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ require.Equal(t, tc.message, tc.schedule.Message(tc.now))
+ })
+ }
+}
+
+func TestOpenSchedulesRefusesABadCron(t *testing.T) {
+ path := filepath.Join(t.TempDir(), scheduleFileName)
+ require.NoError(t, os.WriteFile(path, []byte(`{"next":1,"schedules":[{"id":1,"text":"x","cron":"* * * * *","due":"2026-11-09T09:00:00-03:00"}]}`), 0o600))
+
+ _, err := openSchedules(path, time.Now)
+ require.ErrorContains(t, err, "schedule 1: the runs of")
+}
+
+// scheduleTestOpen opens the schedules of a new file, with a clock at Sunday
+// 2026-11-08 20:00 -03:00, and returns them and the path of the file.
+func scheduleTestOpen(t *testing.T) (*Schedules, string) {
+ path := filepath.Join(t.TempDir(), scheduleFileName)
+ clock := &TestClock{
+ now: time.Date(2026, time.November, 8, 20, 0, 0, 0, time.FixedZone("", -3*60*60)),
+ }
+
+ schedules, err := openSchedules(path, clock.Now)
+ require.NoError(t, err)
+
+ return schedules, path
+}