Skip to main content

Agent Scheduling

Crewship includes a built-in scheduler that triggers agent runs on a cron schedule. Scheduled runs are fully autonomous — the agent receives a prompt, executes inside its crew container, and the result is recorded as a run with the SCHEDULED trigger type.

How It Works

The scheduler is a background goroutine that manages a pool of cron jobs, one per enabled agent schedule. On startup it loads all agents with schedule_enabled = 1 from the database and registers their cron expressions. Each tick of a cron job triggers a full agent run cycle.

Enabling a Schedule

Schedules attach to a saved routine (pipeline) and fire it on a cron expression. Manage them via crewship routine schedules:
The same operations are also available on the REST API under /api/v1/workspaces/{ws}/pipeline-schedules — the CLI is a thin wrapper around those endpoints, so server-side behavior (cron parsing, 30 s tick resolution, timezone handling) is identical.

Pinning a schedule to a routine version

By default a schedule fires the routine’s head (latest) version — an edit to the routine immediately changes what the next tick runs. For production schedules that must not drift when an agent (or teammate) edits the routine, pin the schedule to an immutable version:
Pinning semantics:
  • A pinned schedule executes exactly the pinned version’s definition on every fire, no matter how far head has moved. The run record stores the executed version (pipeline_version) and that version’s definition_hash, so the Runs view always shows what actually ran.
  • The pin survives unrelated updates: patching the cron, timezone, name, inputs, or enabled state keeps the existing pin. Only an explicit --pin-version N / --unpin (API: target_pipeline_version: N / : null) changes it.
  • If the pinned version no longer exists, the fire fails with a legible error and raises the standard scheduled-run-failed inbox alert (MANAGER-targeted). It deliberately does not fall back to head — silently running an unexpected definition is the exact hazard pinning exists to prevent. Fix by re-pinning to an existing version or unpinning.
  • Governance still reads the live routine: a disabled or proposed routine refuses to fire even when pinned, and per-step operator overrides apply on top of the pinned definition just as they do on head.
  • A pinned run that parks on an approval gate resumes against the same pinned version, even if head moved while it waited.
  • Webhooks support the identical pin (target_pipeline_version on the webhook; crewship routine webhooks create --pin-version N). A webhook whose pinned version is missing answers 409 instead of dispatching.
The schedules list shows the pin in the ROUTINE column as slug@vN.

Fire outcomes (last_status)

Each fire records one of:
Per-agent schedule_cron fields described elsewhere in this guide are a separate legacy path. New work should go through crewship routine schedules against a saved routine.For a quick single-agent cron, the per-agent path is now settable straight from the CLI (previously API-only):
Read it back with crewship agent get <agent> — when a cron is set the detail view surfaces a Schedule row (cron (enabled|disabled)), the Schedule Prompt, and the resolved Next Run / Last Run, so you can confirm the cron is live without hitting the raw API. Clear it with --schedule-cron '' (or pause with --schedule-enabled=false).Runs that the scheduler dispatches this way get the tighter routine turn cap automatically — the internal orchestrator.RoutineMaxTurns (20), applied to scheduled dispatches (not a CLI flag you pass).

Cron Expression Format

The scheduler uses standard 5-field cron expressions:
Examples:
Invalid cron expressions are rejected at registration time. The API call will return an error if the expression cannot be parsed.

Execution Flow

Each scheduled trigger follows this sequence:
1

Chat Session Creation

A new chat session is created with a deterministic ID (format: sched_{unixnano}_{random_hex}). The chat title is set to "Scheduled: {agent_name}".
2

Chat Resolution

The ResolveChat call loads the full agent context: credentials, system prompt, skills, MCP servers, network policy, and crew membership. This ensures the scheduled run has the same capabilities as an interactive run.
3

Container Management

If a container provider is configured, the scheduler ensures the crew container is running before execution. Default resource limits:
4

Run Record

A run record is created with trigger type SCHEDULED before execution begins. Metadata includes the CLI adapter, crew info, and a scheduled tag.
5

Agent Execution

The agent runs through orchestrator.RunAgent with the same pipeline as interactive runs: sidecar proxy, credential injection, memory, and conversation history.
6

Result Recording

After execution completes, the run record is updated with:
  • Status: COMPLETED or FAILED
  • Duration in milliseconds
  • Cost and usage metadata (if available from the LLM provider)
The assistant response is persisted to the conversation store and the message count is incremented.

At-most-once firing

Each scheduled occurrence fires at most once. Before any side effect (chat, container, run record), the scheduler reserves the occurrence in a shared idempotency table using a deterministic key derived from the agent id and the occurrence’s minute bucket. A duplicate tick within the same minute — or a process restart that happens after a run started but before the schedule’s next_run was advanced — resolves to the existing reservation and is skipped rather than run a second time. The next occurrence falls in a distinct minute bucket, gets a fresh key, and fires normally. This is at-most-once, not exactly-once: the key is reserved before the run executes, so a crash between the reservation and execution drops that occurrence (it is not retried) rather than running it twice. If the idempotency store is unreachable the fire fails closed — the occurrence is skipped, never double-run. Duplicate protection is the guarantee; delivery is best-effort. The agent scheduler, the routine (pipeline) scheduler, its wake-check probe, and deferred (pending) runs all share one dedup discipline — the same pipeline_run_idempotency table and the ScheduledFireIdempotencyKey helper — so every firing path is at-most-once.
This is single-instance protection. Running multiple replicas would still double-fire (each replica runs its own in-memory cron); true multi-replica exactly-once needs leader election, tracked separately.

Missed-run catch-up (routine schedules)

At-most-once firing says a re-fire of the SAME occurrence never runs twice. It doesn’t say what happens when a routine (pipeline) schedule fell behind by more than one occurrence — downtime, a long disable/re-enable gap, etc. That is catchup_policy on crewship routine schedules create/update --catchup: skip drops the whole backlog, once (default, unchanged pre-existing behaviour) fires exactly once for it, all fires once per missed occurrence (capped at 20 fires/tick). See Missed-run catch-up in the Routines guide for the full semantics and the inbox notice it raises.

Timeout

Each scheduled run has a 45-minute timeout. If the agent does not complete within this window, the context is cancelled and the run is marked as failed.
This is separate from the per-agent timeout_secs setting, which controls the LLM execution timeout within the container. The 45-minute limit is the outer boundary for the entire scheduled run cycle including container startup and chat resolution.

Monitoring

Timestamp Fields

The scheduler maintains two fields on each agent: These fields are updated even on error — schedule_next_run always reflects the next trigger time so monitoring dashboards stay accurate.

Run Records

Scheduled runs appear in the standard agent runs list with trigger = "SCHEDULED":
Each run record contains:
  • trigger: "SCHEDULED"
  • tags: ["scheduled", "{cli_adapter}"]
  • duration_ms: execution time
  • total_cost_usd: LLM cost (if reported)
  • num_turns: conversation turns
  • status: COMPLETED or FAILED

Dynamic Updates

When an agent’s schedule is modified via the API, the scheduler hot-reloads the cron entry without restart. The UpdateSchedule method:
  1. Removes the old cron entry (if any)
  2. Registers the new cron expression
  3. Immediately calculates and persists the next run time
Disabling a schedule (schedule_enabled: false) removes the cron entry and stops future triggers. The schedule_last_run timestamp is preserved for audit purposes.

Error Handling

If any step in the trigger pipeline fails (chat creation, resolution, container startup), the scheduler:
  1. Logs the error
  2. Updates schedule_next_run to the next trigger time
  3. Does not update schedule_last_run (only updated on successful execution start)
The next scheduled trigger will attempt the full pipeline again. There is no exponential backoff — the cron schedule determines retry timing.

Running multiple replicas (leader election)

Crewship runs three scheduling loops in each server process: the agent cron scheduler (this guide), the pipeline/routine cron scheduler, and the recurring-issue dispatcher. If you run more than one crewship replica against the same database, every replica would tick — and without coordination they would all fire the same due job, double-running scheduled agents and double-stamping recurring issues. To make a multi-replica deploy safe, the schedulers use a small database-backed leader election lease. A single row in the scheduler_leader table records the current leader and an expiry; each replica renews it on an interval and only fires while it holds a non-expired lease. Exactly one replica is leader at a time, so each due occurrence fires once. (Per-occurrence idempotency remains as a second line of defence.)
  • Enabled by default. A lone replica wins the lease immediately, so single-instance behaviour is unchanged — nothing to configure for a single server.
  • No extra infrastructure. The lease lives in the same database the schedulers already use (SQLite or Postgres) — no etcd/consul/redis.
  • Failover. The lease TTL is 60 seconds and the leader renews every 20 seconds. If the leader crashes or loses the database, a standby steals the expired lease within one TTL and takes over. A clean shutdown releases the lease immediately, so failover is near-instant.

Configuration

Until you enable leader election (or while it is disabled), run exactly one scheduler-bearing replica. Two replicas with election disabled will double-fire every schedule and recurring issue.

What’s Next