my WhatsApp groups talked about". A schedule has a text, the task for
the agent, and either one time or a cron expression. The agent also
lists the schedules with their next times, and removes one when the user
-asks. Only the agent has the schedule tools, not its subagents.
+asks. The root and its children have the schedule tools, not their
+subagents. Each schedule records its owner and wakes only that agent.
A cron expression has five fields: minute, hour, day of month, month,
and day of week, where 0 and 7 are Sunday. A field is a comma list of
whatsapp, _, _ := whatsappStartTest(t, dir, clock)
subagents := newSubagents(filepath.Join(agent.dir, subagentDirName), clock.Now, subagentNameRandom)
- schedules, err := openSchedules(filepath.Join(agent.dir, scheduleFileName), clock.Now, discardLog())
+ schedules, err := openSchedules(filepath.Join(agent.dir, scheduleFileName), "OneChat", clock.Now, discardLog())
require.NoError(t, err)
server := newModelFake(t)
return nil, fmt.Errorf("open memory: %w", err)
}
- schedules, err := openSchedules(filepath.Join(agent.dir, scheduleFileName), clock, logger)
+ schedules, err := openSchedules(filepath.Join(agent.dir, scheduleFileName), agent.config.Name, clock, logger)
if err != nil {
return nil, errors.Join(fmt.Errorf("open schedules: %w", err), memory.Close())
}
}
}
+func TestChildScheduleWakesOnlyItsOwner(t *testing.T) {
+ server := newModelFake(t)
+ server.reply = func(w http.ResponseWriter, _ ModelFakeCall) {
+ modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("")}, 100, 0, 5))
+ }
+ test := newDaemonMessageTest(t, t.TempDir(), server)
+ child := test.agents[1]
+ at := child.clock().Add(time.Minute).Format(scheduleAtLayout)
+ output := test.loops[1].tools.run(t.Context(), "schedule_set", `{"text":"Check the plants.","at":"`+at+`","cron":null}`)
+ require.Contains(t, output, "Schedule 1 set.")
+ require.Equal(t, "Garden", child.schedules.sorted()[0].Owner)
+
+ child.schedules.clock = func() time.Time {
+ return child.clock().Add(time.Minute)
+ }
+ require.ErrorContains(t, child.schedules.deliver(t.Context(), test.loops[0]), "does not match")
+ require.NoError(t, child.schedules.deliver(t.Context(), test.loops[1]))
+ test.Start(t, 0, 1, 2)
+ test.events[1].WaitFor(t, "idle", 1)
+ require.Equal(t, 1, server.Calls())
+ require.Equal(t, child.agent.CacheKey(), server.Call(0).body["prompt_cache_key"])
+ require.Zero(t, test.agents[0].memory.Len())
+ require.Zero(t, test.agents[2].memory.Len())
+ require.Contains(t, test.events[1].LastEvent("work").Text, "Check the plants.")
+ require.Equal(t, scheduleSource, test.events[1].LastEvent("work").Source)
+}
+
// DaemonMessageTest owns test agents and the loops selected by Start.
type DaemonMessageTest struct {
agents DaemonAgents
)
// 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, and logger
-// gets one line for each run.
-func openSchedules(path string, clock func() time.Time, logger *slog.Logger) (*Schedules, error) {
+// none. The owner names its agent. Schedules without an owner take this name. A
+// different stored owner is an error. The clock gives the time and the time
+// zone of the schedules, and logger gets one line for each run.
+func openSchedules(path, owner string, clock func() time.Time, logger *slog.Logger) (*Schedules, error) {
s := &Schedules{
path: path,
+ owner: owner,
clock: clock,
logger: logger,
check: scheduleCheck,
return nil, fmt.Errorf("%s: %w", path, err)
}
- for _, schedule := range s.file.Schedules {
+ changed := false
+ for i := range s.file.Schedules {
+ schedule := &s.file.Schedules[i]
+ if schedule.Owner == "" {
+ schedule.Owner = owner
+ changed = true
+ }
+
+ if schedule.Owner != owner {
+ return nil, fmt.Errorf("schedule %d belongs to %q, not %q", schedule.ID, schedule.Owner, owner)
+ }
+
if schedule.Cron == "" {
continue
}
}
}
+ if changed {
+ err = s.save()
+ if err != nil {
+ return nil, fmt.Errorf("record schedule owner: %w", err)
+ }
+ }
+
return s, nil
}
// changed tells Run that a set or a remove changed the times.
type Schedules struct {
path string
+ owner string
clock func() time.Time
logger *slog.Logger
check time.Duration
now := s.clock()
schedule := Schedule{
- Text: text,
+ Text: text,
+ Owner: s.owner,
}
switch {
// the ID of the schedule and, for a late run, how late it is. The line holds no
// text of the schedule.
func (s *Schedules) deliver(ctx context.Context, l *TurnLoop) error {
+ if l.agent.config.Name != s.owner {
+ return fmt.Errorf("schedule owner %q does not match agent %q", s.owner, l.agent.config.Name)
+ }
+
now := s.clock()
var turns []Message
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.
+// Schedule is one schedule: its owner, 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"`
+ Owner string `json:"owner"`
+ 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
func newTestSchedules(t *testing.T, clock func() time.Time) *Schedules {
t.Helper()
- schedules, err := openSchedules(filepath.Join(t.TempDir(), scheduleFileName), clock, discardLog())
+ schedules, err := openSchedules(filepath.Join(t.TempDir(), scheduleFileName), "OneChat", clock, discardLog())
require.NoError(t, err)
return schedules
require.NoError(t, err)
require.Equal(t, os.FileMode(0o600), info.Mode().Perm())
- reopened, err := openSchedules(path, schedules.clock, discardLog())
+ reopened, err := openSchedules(path, "OneChat", schedules.clock, discardLog())
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())
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, discardLog())
+ _, err := openSchedules(path, "OneChat", time.Now, discardLog())
require.ErrorContains(t, err, "schedule 1: the runs of")
}
}
logs := &SyncBuffer{}
- schedules, err := openSchedules(filepath.Join(t.TempDir(), scheduleFileName), clock.Now, daemonTestLogger(logs))
+ schedules, err := openSchedules(filepath.Join(t.TempDir(), scheduleFileName), "OneChat", clock.Now, daemonTestLogger(logs))
require.NoError(t, err)
server := newModelFake(t)
}
logs := &SyncBuffer{}
- schedules, err := openSchedules(path, clock.Now, daemonTestLogger(logs))
+ schedules, err := openSchedules(path, "OneChat", clock.Now, daemonTestLogger(logs))
require.NoError(t, err)
server := newModelFake(t)
now: time.Date(2026, time.November, 8, 20, 0, 0, 0, time.FixedZone("", -3*60*60)),
}
- schedules, err := openSchedules(filepath.Join(t.TempDir(), scheduleFileName), clock.Now, discardLog())
+ schedules, err := openSchedules(filepath.Join(t.TempDir(), scheduleFileName), "OneChat", clock.Now, discardLog())
require.NoError(t, err)
schedules.check = time.Millisecond
require.Equal(t, `error: unknown tool "schedule_remove"`, box.run(t.Context(), "schedule_remove", `{"id":1}`))
}
+func TestSchedulesKeepTheirOwnerAcrossRestart(t *testing.T) {
+ path := filepath.Join(t.TempDir(), scheduleFileName)
+ clock := newTestClock()
+ schedules, err := openSchedules(path, "GreenOtter", clock.Now, discardLog())
+ require.NoError(t, err)
+
+ _, err = schedules.Set("Check the plants.", new(clock.Now().Add(time.Minute).Format(scheduleAtLayout)), nil)
+ require.NoError(t, err)
+
+ reopened, err := openSchedules(path, "GreenOtter", clock.Now, discardLog())
+ require.NoError(t, err)
+ require.Equal(t, "GreenOtter", reopened.sorted()[0].Owner)
+
+ _, err = openSchedules(path, "OneChat", clock.Now, discardLog())
+ require.ErrorContains(t, err, "belongs to")
+}
+
+func TestSchedulesAssignTheOwnerToSavedRootSchedules(t *testing.T) {
+ path := filepath.Join(t.TempDir(), scheduleFileName)
+ require.NoError(t, os.WriteFile(path, []byte(`{"next":1,"schedules":[{"id":1,"text":"Check the news.","due":"2026-11-09T09:00:00-03:00"}]}`), 0o600))
+
+ schedules, err := openSchedules(path, "OneChat", newTestClock().Now, discardLog())
+ require.NoError(t, err)
+ require.Equal(t, "OneChat", schedules.sorted()[0].Owner)
+
+ data, err := os.ReadFile(path)
+ require.NoError(t, err)
+ require.Contains(t, string(data), `"owner":"OneChat"`)
+}
+
// ScheduleTestClock is a clock that a test sets while Run reads it.
type ScheduleTestClock struct {
mutex sync.Mutex
now: time.Date(2026, time.November, 8, 20, 0, 0, 0, time.FixedZone("", -3*60*60)),
}
- schedules, err := openSchedules(path, clock.Now, discardLog())
+ schedules, err := openSchedules(path, "OneChat", clock.Now, discardLog())
require.NoError(t, err)
return schedules, path