ObanCodex.Agent.Tick (oban_codex v0.6.0)

Copy Markdown View Source

The adapter between Oban.Plugins.Cron (which schedules at the worker layer) and the agent state machine (which owns turn enqueueing): a scheduled tick delivers a prompt to an agent through the facade, never a Codex job behind the machine's back.

Modeled on the "Routines" idea: a routine is a prompt on a schedule, aimed at a named agent. The Codex turn itself is still enqueued by ObanCodex.Agent.Instance, so every machine invariant (session threading, :running + watchdog, approval gating) holds for scheduled work exactly as for interactive work.

{Oban.Plugins.Cron,
 crontab: [
   {"0 9 * * *", ObanCodex.Agent.Tick,
    args: %{
      "agent_id" => "standup",
      "arc_id" => "daily-sweep",
      "prompt" => "Summarize overnight CI failures.",
      "session" => "fresh",
      "if_offline" => "start",
      "start" => %{"args" => %{"model" => "gpt-5"}}
    }}
 ]}

Args

  • "agent_id" (required) -- the agent to prompt.
  • "prompt" (required) -- the prompt to deliver.
  • "arc_id" -- optional named conversation arc. Omission uses the backward-compatible "default" arc.
  • "if_busy" -- what to do when the agent is not :idle:
    • "skip" (default) -- cancel this beat ({:cancel, :agent_busy}); a missed beat is better than a backlog of stale scheduled prompts.
    • "queue" -- deliver anyway; the machine queues it behind the in-flight turn / pending gate. Beware: on a slow agent a fast schedule accumulates a backlog.
  • "if_offline" -- what to do when the agent is not running (e.g. after a restart):
    • "skip" (default) -- cancel this beat ({:cancel, :agent_not_running}).
    • "start" -- start the agent, then deliver. Config comes from the optional "start" map -- "args", "approved_args", "job_timeout", "session_arcs", "max_session_arcs", and "config_revision" (the JSON-clean subset of ObanCodex.Agent.Instance config; a custom :worker or :oban needs the agent started by the host app instead). With "start" the crontab is effectively the agent's spec.
  • "session" -- "resume" (default) continues the agent's Codex session; "fresh" starts a new one for this beat. Prefer "fresh" for long-lived routines, or the session's context grows without bound.

Delivery uses ObanCodex.Agent.cast_prompt/3 with origin: :tick, so a scheduled prompt can never be consumed as the operator's answer to a pending :waiting_for_user question -- it queues behind it. The "skip" policies are a best-effort status pre-check; the origin flag is the race-safe backstop.

A :paused agent never receives a tick ({:cancel, :agent_paused}) -- lockdown outranks the schedule, in both "if_busy" modes.

Admission

A host that owns Agent configuration may set :tick_admission to a module with admit/5:

config :oban_codex, :tick_admission, MyApp.TickAdmission

def admit(:codex, agent_id, expected_delivery_revision, context, run) do
  MyApp.with_current_agent_config(agent_id, expected_delivery_revision, context, run)
end

The callback receives the provider, agent id, the optional top-level "delivery_revision", a context map containing the optional :arc_id, and a zero-arity function. Existing admit/4 callbacks remain supported without the context argument. Jobs without the revision field fall back to "start"["config_revision"] for compatibility. The function contains the complete status check, optional Agent start, and prompt delivery. The callback's return value becomes the worker result. With no callback configured, Tick invokes the function directly.

max_attempts: 1: a tick is a point-in-time beat; retrying a failed one later would deliver a stale prompt (and risk a duplicate), so a missed beat is simply missed. The cancels are visible per-beat in the oban_jobs table.

Ticks default to their OWN queue (:ticks -- run it alongside the agents queue, e.g. queues: [agents: 2, ticks: 1]). On a queue shared with agent turns a tick serializes BEHIND the very turn it should observe, so by the time it runs the agent looks idle again and the "skip" policy can never fire -- a lesson from live fleet use.