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. A cron tick does not execute the agent: it accepts the due occurrence into the durable work queue — the cursor advance and the immutable occurrence commit in one database transaction — and the shared dispatcher owns every execution attempt, including on a later process.
A boot-time sweep (repeated every 30 s) accepts occurrences whose due instant already passed — downtime or a leadership change cannot silently skip a daily occurrence until tomorrow’s tick. Only the single persisted overdue instant (schedule_next_run) is accepted: the cursor then jumps to the next occurrence after now, so several missed intervals collapse into one run, not one run per missed interval.

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

After the dispatcher claims the accepted work item (and re-checks its authority), the scheduled runtime follows this sequence:
1

Chat Session Creation

A new chat session is created with the ID scheduled-{run_id} and the title “Scheduled agent run”.
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 runtime 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 — and is acknowledged only after its run.started entry is durably journalled; an unavailable journal fails closed rather than letting the agent start unrecorded. 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. schedule_last_run is written when the run settles, carrying the run’s start time, and only if the agent exec was actually requested; a failed write is logged, not retried or surfaced.

At-most-once firing

Agent schedules. Each scheduled occurrence becomes at most one work item. The cursor advance and the occurrence reservation commit together: a duplicate tick within the same minute — a second replica, or a tick racing the overdue sweep — resolves to the existing work item and reports it as a duplicate; it is never accepted twice. The next occurrence is a distinct work item with its own identity and fires normally. Because acceptance is the durable commit, a crash after acceptance does not drop the occurrence: the work item is already in the queue, and the dispatcher — including on a later process, once the overdue sweep has run — executes it. If acceptance itself fails (database unavailable, disk guard), the occurrence remains due and is retried on the next tick or sweep; the fire fails closed, never double-runs. Duplicate protection and delivery are both durable. Routine schedules, wake-check probes, deferred runs and recurring issues do not go through the durable work queue. They keep the older reserve-then-run discipline: before any side effect, the firing path reserves a deterministic per-occurrence key (ScheduledFireIdempotencyKey) in the shared pipeline_run_idempotency table, and a duplicate tick resolves to the existing reservation and is skipped. This is at-most-once, not exactly-once: the key is reserved before the run, so a crash between the reservation and the run drops that occurrence rather than running it twice, and an unreachable idempotency store fails closed (the occurrence is skipped).
This is single-leader protection per occurrence. Running multiple replicas is safe with leader election enabled (see below); the per-item atomic claim remains as a second line of defence.

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 hard cap applied by the scheduled runtime, preserved even when an agent has no per-run timeout configured. If the agent does not complete within this window, the context is cancelled and the dispatcher owns the stop and reconciliation from there.
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: Because the cursor advances at acceptance, schedule_next_run never moves as a side effect of execution latency — a slow or retried run cannot make the schedule fall behind or double-fire.

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

Failures split by where they happen:
  • At acceptance (database unavailable, disk guard refusal): the occurrence stays due and the next tick or the 30 s overdue sweep retries it — logged, never dropped, never doubled.
  • At dispatch (authority re-check): if the schedule was disabled, or the agent, its crew or the workspace changed or was deleted after acceptance, the item is refused with the reason — an accepted occurrence is not a licence to run against a world that changed. An agent staged for review is re-checked after 30 s rather than refused outright.
  • Before the agent starts (chat creation or resolution, crew container start, a run-record write that provably did not land, a refused exec): nothing left the machine, so the same work item is retried with exponential backoff and full jitter — up to 5 attempts in total, 2 s base, 300 s cap (internal/work/work.go). After the fifth attempt the item fails.
  • Once the agent may have acted (an agent-side failure, or the 45-minute deadline firing): the outcome is unclear — the agent could already have pushed, posted or called something — so the item goes to reconciliation and is not retried.
  • Unreadable input (a work item whose immutable input snapshot does not decode or no longer matches its agent): final failure, no retry — a retry would read the same bytes.
In every case the cursor advanced at acceptance, so a failure never re-fires the accepted occurrence — the next cron tick is the next run.

Scheduler leader lease

Release 1.0 supports exactly one writing server per persistent local database. An exclusive process-lifetime writer lease refuses a second writing server. Enabling scheduler leader election does not permit active replicas or make whole-instance backups safe on shared storage. Distributed replicas require a separate fencing and backup contract; see the single-writer decision. Stop all old daemons before upgrading to the enforcing version.
Crewship runs three scheduling loops: the agent cron scheduler (this guide), the pipeline/routine cron scheduler, and the recurring-issue dispatcher. They share a database-backed leader lease in scheduler_leader. A holder renews its lease and fires only while it owns the non-expired row. Per-occurrence idempotency remains a second line of defence against duplicate acceptance.
  • Enabled by default. A lone server wins a free lease immediately, so ordinary single-server startup requires no configuration.
  • No extra infrastructure. The lease lives in the same SQLite database.
  • Recovery. The TTL is 60 seconds and renewal occurs every 20 seconds. After a crash, the next exclusive writer waits for the old lease to expire unless it has the same configured holder identity. Clean shutdown releases the scheduler lease immediately. This logical scheduler lease is separate from the process-lifetime database writer lease.

Configuration

What’s Next