defmodule Alloy.Scheduler do @moduledoc """ GenServer for time-based agent runs (cron jobs and heartbeats). Schedules `Alloy.run/2` calls at regular intervals using `Process.send_after/3`. Each job runs in a supervised Task, keeping the Scheduler responsive. Overlap protection ensures a job is skipped if its previous run is still in progress. ## Usage {:ok, pid} = Alloy.Scheduler.start_link( jobs: [ %{ name: :ci_check, every: :timer.minutes(30), prompt: "Check CI status and report failures.", agent_opts: [ provider: {Alloy.Provider.Anthropic, api_key: "sk-ant-...", model: "claude-haiku-4-5-20251001"}, tools: [Alloy.Tool.Core.Bash] ], on_result: &MyApp.handle_ci_result/1 } ] ) ## Job Spec - `:name` — unique atom identifying the job - `:every` — interval in milliseconds - `:prompt` — the prompt string passed to `Alloy.run/2` - `:agent_opts` — options passed to `Alloy.run/2` - `:on_result` — optional callback `(result -> any)`, default: `Logger.info` """ use GenServer require Logger @type job :: %{ name: atom(), every: pos_integer(), prompt: String.t(), agent_opts: keyword(), on_result: (term() -> any()) | nil } # ── Client API ────────────────────────────────────────────────────────── @doc """ Starts the scheduler with initial jobs. """ @spec start_link(keyword()) :: GenServer.on_start() def start_link(opts) do {name_opts, init_opts} = Keyword.split(opts, [:name]) GenServer.start_link(__MODULE__, init_opts, name_opts) end @doc """ Lists all registered jobs. """ @spec list_jobs(GenServer.server()) :: [job()] def list_jobs(server) do GenServer.call(server, :list_jobs) end @doc """ Adds a job dynamically. The first tick fires after `job.every` ms. """ @spec add_job(GenServer.server(), job()) :: :ok def add_job(server, job) do GenServer.call(server, {:add_job, job}) end @doc """ Removes a job by name. Cancels its pending timer. """ @spec remove_job(GenServer.server(), atom()) :: :ok def remove_job(server, name) do GenServer.call(server, {:remove_job, name}) end @doc """ Manually triggers a job immediately, ignoring its schedule. """ @spec trigger(GenServer.server(), atom()) :: :ok | {:error, :not_found} def trigger(server, name) do GenServer.call(server, {:trigger, name}) end # ── Server Callbacks ──────────────────────────────────────────────────── @impl GenServer def init(opts) do {:ok, task_sup} = Task.Supervisor.start_link() state = %{ jobs: %{}, timers: %{}, running: MapSet.new(), task_refs: %{}, task_supervisor: task_sup } jobs = Keyword.get(opts, :jobs, []) state = Enum.reduce(jobs, state, fn job, acc -> schedule_job(acc, job) end) {:ok, state} end @impl GenServer def handle_call(:list_jobs, _from, state) do jobs = Map.values(state.jobs) {:reply, jobs, state} end def handle_call({:add_job, job}, _from, state) do state = schedule_job(state, job) {:reply, :ok, state} end def handle_call({:remove_job, name}, _from, state) do state = cancel_job(state, name) {:reply, :ok, state} end def handle_call({:trigger, name}, _from, state) do case Map.fetch(state.jobs, name) do {:ok, _job} -> state = run_job(state, name) {:reply, :ok, state} :error -> {:reply, {:error, :not_found}, state} end end @impl GenServer def handle_info({:tick, name}, state) do state = if MapSet.member?(state.running, name) do Logger.warning("Alloy.Scheduler: skipping #{name} (still running)") reschedule(state, name) else state = run_job(state, name) reschedule(state, name) end {:noreply, state} end # Task completed successfully def handle_info({ref, result}, state) when is_reference(ref) do Process.demonitor(ref, [:flush]) case Map.pop(state.task_refs, ref) do {nil, _} -> {:noreply, state} {name, task_refs} -> job = Map.get(state.jobs, name) callback = (job && job[:on_result]) || (&default_on_result/1) callback.(result) state = %{state | running: MapSet.delete(state.running, name), task_refs: task_refs} {:noreply, state} end end # Task crashed def handle_info({:DOWN, ref, :process, _pid, reason}, state) do case Map.pop(state.task_refs, ref) do {nil, _} -> {:noreply, state} {name, task_refs} -> Logger.error("Alloy.Scheduler: job #{name} crashed: #{inspect(reason)}") state = %{state | running: MapSet.delete(state.running, name), task_refs: task_refs} {:noreply, state} end end # ── Private ───────────────────────────────────────────────────────────── defp schedule_job(state, job) do timer_ref = Process.send_after(self(), {:tick, job.name}, job.every) %{ state | jobs: Map.put(state.jobs, job.name, job), timers: Map.put(state.timers, job.name, timer_ref) } end defp cancel_job(state, name) do case Map.get(state.timers, name) do nil -> :ok ref -> Process.cancel_timer(ref) end %{ state | jobs: Map.delete(state.jobs, name), timers: Map.delete(state.timers, name) } end defp reschedule(state, name) do case Map.get(state.jobs, name) do nil -> state job -> timer_ref = Process.send_after(self(), {:tick, job.name}, job.every) %{state | timers: Map.put(state.timers, job.name, timer_ref)} end end defp run_job(state, name) do case Map.get(state.jobs, name) do nil -> state job -> task = Task.Supervisor.async_nolink(state.task_supervisor, fn -> Alloy.run(job.prompt, job.agent_opts) end) %{ state | running: MapSet.put(state.running, name), task_refs: Map.put(state.task_refs, task.ref, name) } end end defp default_on_result({:ok, result}) do Logger.info("Alloy.Scheduler: job completed — #{result.text || "(no text)"}") end defp default_on_result({:error, result}) do Logger.error("Alloy.Scheduler: job failed — #{inspect(result.error)}") end end