# Scheduling

**After:** You can schedule delayed and recurring work reliably.

Jido provides three scheduling mechanisms: declarative schedules in the agent definition, one-time delays via `Schedule`, and dynamic recurring jobs via `Cron`. All are timer-based and tied to the agent's process lifecycle.

Dynamic cron scheduling now lives in Jido core so durable runtime registrations survive hibernate/thaw while keeping the implementation intentionally small. `crontab` parses cron expressions and the configured time zone database resolves named timezones.

## Declarative Schedules

The simplest way to add recurring jobs is to declare them in your agent definition. Schedules target signal types, which get routed through `signal_routes/1` like any other signal:

```elixir
defmodule MyAgent do
  use Jido.Agent,
    name: "my_agent",
    schema: [
      tick_count: [type: :integer, default: 0],
      last_cleanup: [type: :any, default: nil]
    ],
    schedules: [
      {"*/5 * * * *", "heartbeat.tick", job_id: :heartbeat},
      {"@daily", "cleanup.run", job_id: :cleanup, timezone: "America/New_York"}
    ],
    signal_routes: [
      {"heartbeat.tick", HeartbeatAction},
      {"cleanup.run", CleanupAction}
    ]
end
```

Declarative schedules are registered automatically when the AgentServer starts. They flow through the normal signal routing pipeline — the same `signal_routes/1`, strategy, and `cmd/2` that handle all other signals.

### Schedule Format

```elixir
schedules: [
  # Minimal: cron expression + signal type
  {"* * * * *", "my.signal"},

  # With job ID (for cancellation/upsert)
  {"*/5 * * * *", "heartbeat.tick", job_id: :heartbeat},

  # With timezone
  {"@daily", "cleanup.run", job_id: :cleanup, timezone: "America/New_York"}
]
```

Job IDs are automatically namespaced as `{:agent_schedule, agent_name, job_id}` to avoid collisions with plugin schedules and dynamic cron jobs.

### When to Use Declarative vs Dynamic

| Use case | Approach |
|----------|----------|
| Known at compile time, always runs | `schedules:` in agent definition |
| Depends on runtime state or user input | `Directive.cron/3` in an action |
| One-time delayed message | `Directive.schedule/2` in an action |

## Delayed Messages with Schedule

The `Schedule` directive sends a message back to your agent after a delay:

```elixir
defmodule RetryAction do
  use Jido.Action,
    name: "retry",
    schema: [attempt: [type: :integer, default: 1]]

  alias Jido.Agent.Directive

  def run(%{attempt: attempt}, context) do
    if attempt < 3 do
      retry_signal = Jido.Signal.new!(
        "task.retry",
        %{attempt: attempt + 1},
        source: "/agent/#{context.agent.id}"
      )

      {:ok, %{scheduled_retry: true}, [Directive.schedule(5_000, retry_signal)]}
    else
      {:error, Jido.Error.execution_error("Max retries exceeded")}
    end
  end
end
```

The message arrives as a signal after the delay. `Process.send_after/3` powers the implementation — if the agent crashes before the timer fires, the scheduled message is lost.

### Schedule API

```elixir
alias Jido.Agent.Directive

Directive.schedule(delay_ms, message)

Directive.schedule(5_000, :timeout)
Directive.schedule(1_000, {:check, some_ref})
Directive.schedule(30_000, my_signal)
```

## Dynamic Recurring Jobs with Cron

For schedules that depend on runtime state or user input, use the `Cron` directive to register recurring jobs dynamically from within an action:

```elixir
defmodule SetupCronAction do
  use Jido.Action, name: "setup_cron", schema: []

  alias Jido.Agent.Directive

  def run(_params, context) do
    tick_signal = Jido.Signal.new!(
      "heartbeat.tick",
      %{},
      source: "/agent/#{context.agent.id}"
    )

    {:ok, %{}, [
      Directive.cron("*/5 * * * *", tick_signal, job_id: :heartbeat)
    ]}
  end
end
```

Use dynamic cron for lightweight trigger work. Each tick sends a message or signal back into the owning agent's normal routing path; it is not a general-purpose detached job runner.

### Cron Expressions

Standard 5-field expressions are supported:

| Expression | Meaning |
|------------|---------|
| `* * * * *` | Every minute |
| `*/5 * * * *` | Every 5 minutes |
| `0 * * * *` | Every hour |
| `0 0 * * *` | Daily at midnight |
| `0 9 * * MON` | Every Monday at 9 AM |

Aliases are also available:

| Alias | Equivalent |
|-------|------------|
| `@yearly` / `@annually` | `0 0 1 1 *` |
| `@monthly` | `0 0 1 * *` |
| `@weekly` | `0 0 * * 0` |
| `@daily` / `@midnight` | `0 0 * * *` |
| `@hourly` | `0 * * * *` |

### Timezone Support

```elixir
Directive.cron("0 9 * * *", morning_signal, 
  job_id: :morning_task,
  timezone: "America/New_York"
)
```

Default timezone is `Etc/UTC`.

Jido does **not** mutate the global calendar timezone database at runtime.
Named timezones use Jido's configured time zone database, which defaults to `TimeZoneInfo.TimeZoneDatabase`:

```elixir
# config/config.exs
config :jido, :time_zone_database, TimeZoneInfo.TimeZoneDatabase
```

You can override that setting if your application needs a different `Calendar.TimeZoneDatabase` implementation.

If timezone configuration is missing or invalid, cron registration returns
`{:error, {:invalid_timezone, reason}}` and the agent process stays alive.

### Upsert Behavior

Registering a cron job with an existing `job_id` validates and starts the replacement first, then swaps it in and cancels the old job:

```elixir
Directive.cron("*/5 * * * *", tick_signal, job_id: :heartbeat)

Directive.cron("*/10 * * * *", tick_signal, job_id: :heartbeat)
```

The second directive cancels the 5-minute job and starts a 10-minute one.

## Cancelling Scheduled Jobs

Use `CronCancel` to stop a recurring job by its `job_id`:

```elixir
defmodule StopHeartbeatAction do
  use Jido.Action, name: "stop_heartbeat", schema: []

  alias Jido.Agent.Directive

  def run(_params, _context) do
    {:ok, %{}, [Directive.cron_cancel(:heartbeat)]}
  end
end
```

Cancelling a non-existent job is a no-op — it doesn't raise an error.

## Semantics & Guarantees

### Timer-Based Delivery, Optional Durable Registration

`Schedule` is always in-memory (`Process.send_after/3`).

`Cron` is process-local at runtime, with optional durable registration when the
agent is managed by `Jido.Agent.InstanceManager` **and** storage is enabled.
In that mode, dynamic cron specs are persisted through `Jido.Persist` and
re-registered on thaw.

Only dynamic `Directive.cron/3` registrations are persisted. Declarative `schedules:` entries and plugin schedules are recreated from code when the `AgentServer` starts and remain runtime-only.

**What this means:**

| Scenario | Behavior |
|----------|----------|
| Agent crashes before `Schedule` timer fires | Scheduled message lost |
| Agent crashes before `Cron` tick fires | Tick may be missed |
| InstanceManager + storage | Dynamic cron register/cancel is write-through durable |
| InstanceManager + storage + thaw/restart | Dynamic cron registrations are restored |
| `storage: nil` or non-persistent lifecycle | Dynamic cron registrations are runtime-only |
| Timer fires during agent busy | Message queued in mailbox |

Dynamic cron write-through ordering:

- `Cron` (register/upsert): start runtime job, persist proposed manifest, then commit runtime state
- `CronCancel`: persist manifest removal first, then stop runtime job and commit state

If persistence fails, registration/cancellation is isolated and the agent keeps the prior state.

### Failure Isolation and Recovery

- Invalid dynamic cron input (bad cron/timezone) does not crash `AgentServer`.
- Scheduler startup/runtime failures are non-fatal to the owning agent.
- Cron runtime pids are monitored separately from child lifecycle monitors.
- Abnormal cron job exits trigger capped exponential-backoff restart from in-memory runtime specs while the owning `AgentServer` remains alive.
- Only dynamic `Directive.cron/3` registrations are restored after thaw/restart from durable `cron_specs`.
- Normal/shutdown cron exits are treated as expected removal (no restart).

### Missed-Run Behavior

**Cron jobs do not catch up on missed runs.** If your agent is down when a cron tick would fire, that tick is simply missed. After restart/thaw, scheduling resumes from the next scheduled time.

Example: An agent with a `@daily` job at midnight crashes at 11:50 PM and restarts at 12:30 AM. The midnight run is missed entirely — no catch-up occurs.

### Cleanup on Termination

When an agent stops (normal or crash), all its cron jobs are automatically cancelled in the `terminate/2` callback. You don't need to manually clean up.

## Idempotency Patterns

Since Jido scheduling provides **at-most-once delivery** (messages can be lost on crash), you need patterns to handle potential gaps or duplicates.

### Dedupe Keys

Track processed work to avoid duplicates if you retry externally:

```elixir
defmodule ProcessTickAction do
  use Jido.Action, name: "process_tick", schema: []

  alias Jido.Agent.StateOp

  def run(%{tick_id: tick_id}, context) do
    processed = Map.get(context.state, :processed_ticks, MapSet.new())

    if MapSet.member?(processed, tick_id) do
      {:ok, %{skipped: true}}
    else
      new_processed = MapSet.put(processed, tick_id)
      {:ok, %{processed: true}, [StateOp.set_state(%{processed_ticks: new_processed})]}
    end
  end
end
```

### Last-Run Timestamps

Track when work last ran to detect gaps:

```elixir
defmodule DailyReportAction do
  use Jido.Action, name: "daily_report", schema: []

  alias Jido.Agent.StateOp

  def run(_params, context) do
    last_run = Map.get(context.state, :last_report_at)
    now = DateTime.utc_now()

    if last_run && DateTime.diff(now, last_run, :hour) < 20 do
      {:ok, %{skipped: true, reason: "Too soon since last run"}}
    else
      report = generate_report()
      {:ok, %{report: report}, [StateOp.set_state(%{last_report_at: now})]}
    end
  end

  defp generate_report, do: %{generated_at: DateTime.utc_now()}
end
```

### Exactly-Once Semantics

Jido does **not** provide exactly-once guarantees for scheduled work. If you need exactly-once:

1. Use external persistent schedulers (Oban, Quantum with database backing)
2. Implement your own persistence layer
3. Use idempotency keys with external storage

For many use cases, at-most-once with last-run tracking is sufficient.

## Complete Example: Daily Report Generation

Here's a complete agent that generates a daily report using declarative schedules:

```elixir
defmodule DailyReportAgent do
  use Jido.Agent,
    name: "daily_report_agent",
    schema: [
      last_report_at: [type: {:custom, DateTime, :from_iso8601, []}, default: nil],
      report_count: [type: :integer, default: 0]
    ],
    schedules: [
      {"0 6 * * *", "report.generate",
        job_id: :daily_report, timezone: "America/New_York"}
    ],
    signal_routes: [
      {"report.generate", GenerateReportAction},
      {"report.cancel", CancelReportAction}
    ]

  defmodule GenerateReportAction do
    use Jido.Action, name: "generate_report", schema: []

    alias Jido.Agent.{Directive, StateOp}

    def run(_params, context) do
      last_run = Map.get(context.state, :last_report_at)
      now = DateTime.utc_now()

      cond do
        last_run && DateTime.diff(now, last_run, :hour) < 20 ->
          {:ok, %{skipped: true}}

        true ->
          report = build_report(context.state)
          count = Map.get(context.state, :report_count, 0)

          notification = Jido.Signal.new!(
            "notification.send",
            %{type: :report, data: report},
            source: "/agent/#{context.agent.id}"
          )

          {:ok, %{report: report}, [
            StateOp.set_state(%{
              last_report_at: now,
              report_count: count + 1
            }),
            Directive.emit(notification)
          ]}
      end
    end

    defp build_report(state) do
      %{
        generated_at: DateTime.utc_now(),
        report_number: Map.get(state, :report_count, 0) + 1,
        summary: "Daily metrics summary"
      }
    end
  end

  defmodule CancelReportAction do
    use Jido.Action, name: "cancel_report", schema: []

    def run(_params, _context) do
      {:ok, %{}, [Directive.cron_cancel({:agent_schedule, "daily_report_agent", :daily_report})]}
    end
  end
end
```

Start the agent — the daily report schedule is registered automatically:

```elixir
{:ok, _} = Jido.start_link(name: MyApp.Jido)

{:ok, pid} = Jido.start_agent(MyApp.Jido, DailyReportAgent,
  id: "report-agent-1"
)

# No setup signal needed — the schedule is already running
```

---

**Related guides:** [Directives](directives.md) • [Runtime](runtime.md)
