golem

Scheduled tasks

core/schedule persists the user’s scheduled tasks, reloads the ACTIVE ones at startup, and fires them at the agent in the creating conversation. It is a facade over scheduling, not a scheduler implementation: you inject one, wrapping your scheduler library in a three-method interface. The injected scheduler is also the sole validator of cron expressions — golem parses nothing.

The seam

// core/schedule
type Scheduler interface {
    // ScheduleCron arms run on each match of the cron expression; recurring.
    // Re-arming after a firing is the implementation's job. An expression
    // the library cannot parse is an error — this is creation-time validation.
    ScheduleCron(id string, expr string, run func()) error
    // ScheduleAt arms run to fire once at the instant; a past instant fires
    // immediately.
    ScheduleAt(id string, at time.Time, run func())
    // Unschedule removes the job, stopping a run in flight if it can.
    Unschedule(id string)
}

With robfig/cron, for example:

type robfigScheduler struct{ c *cron.Cron }

func (s robfigScheduler) ScheduleCron(id, expr string, run func()) error {
    _, err := s.c.AddFunc(expr, run) // re-arming on each match is the library's
    return err                       // job; an unparsable expr is the error
}

func (s robfigScheduler) ScheduleAt(id string, at time.Time, run func()) {
    s.c.Schedule(oneTime{at}, cron.FuncJob(run)) // a small Schedule wrapper
}

func (s robfigScheduler) Unschedule(id string) { /* Remove by entry */ }

A deployment wanting a minimum firing interval (five minutes, say) rejects sub-floor expressions in its adapter — golem enforces no policy of its own.

Wiring

runner, err := schedule.New(backend.ScheduledTasks(), a, schedule.Config{
    Prompt:    cfg.AI.ScheduledTaskPrompt, // template over {taskText}; validated in New
    Scheduler: robfigScheduler{c: cron.New()},
}, logger)
if err != nil { return err }
if err := runner.Start(ctx); err != nil { return err } // reload ACTIVE tasks
schedule.RegisterBuiltins(provider, schedule.NewTools(runner, backend.ScheduledTasks()))
// shutdown order: runner.Stop() before agent.Shutdown, so no new firing
// starts while old ones drain.

Without a scheduler the schedule tools are simply not offered.

Lifecycle

Task identity includes the creating ConversationID, and optionally the group and tenant scope. These values are copied into each firing so memory, files, knowledge, and sandbox access remain aligned with the original run.

The store is the source of truth: the armed jobs are derived state, rebuilt from it, so a restart loses nothing but the wall-clock position of a recurring task.