-
Notifications
You must be signed in to change notification settings - Fork 4.4k
11.1 Cron Job Configuration
Relevant source files
The following files were used as context for generating this wiki page:
This page documents the configuration schema for scheduled jobs in ZeroClaw's cron scheduler system. For information about scheduler security enforcement and runtime behavior, see Scheduler Security. For information about the overall scheduler architecture and polling mechanism, see Scheduler.
The cron scheduler supports two job types: shell jobs (execute commands) and agent jobs (invoke the agent with a prompt). Jobs are defined with a schedule (cron expression, fixed interval, or one-time execution), optional delivery configuration (announce results to a channel), and security constraints inherited from the global autonomy policy. This page covers:
- Job type selection and configuration
- Schedule syntax (cron, every, at)
- Delivery modes and channel routing
- Retry policies and timeout configuration
- One-shot job lifecycle
- Security validation applied at execution time
The scheduler supports two execution modes defined by the JobType enum:
| Type | Description | Configuration Fields |
|---|---|---|
Shell |
Executes a shell command via sh -lc
|
command (required) |
Agent |
Invokes the agent turn cycle with a prompt |
prompt (required), model (optional), session_target (optional) |
Sources: src/cron/scheduler.rs:8, src/cron/scheduler.rs:63-64
Shell jobs execute arbitrary commands in the workspace directory with a 120-second timeout. The command is passed to sh -lc to support shell features like pipes, environment variable expansion, and redirection.
Security enforcement:
- Autonomy level must allow actions (
can_act()returns true) - Command must pass allowlist validation
- Path arguments are validated against forbidden directories
- Rate limiting is enforced
- Symlink escape detection prevents path traversal
Sources: src/cron/scheduler.rs:377-389, src/cron/scheduler.rs:391-467
Agent jobs invoke crate::agent::run() with a prefixed prompt format: [cron:{job_id} {name}] {prompt}. The agent executes a full turn cycle, including tool calls and history management.
Configuration options:
-
session_target: Controls agent session isolation-
Main: Shares conversation history with interactive sessions -
Isolated: Independent conversation context
-
-
model: Optional model override (defaults to configured provider model) -
prompt: The user message sent to the agent
Performance warning: Agent jobs scheduled more frequently than every 5 minutes trigger a warning log because they incur LLM API costs on every execution.
Sources: src/cron/scheduler.rs:119-150, src/cron/scheduler.rs:213-238
Three schedule formats are supported, represented by the Schedule enum:
graph TD
Schedule["Schedule enum"]
Cron["Cron { expr, tz }"]
Every["Every { every_ms }"]
At["At { at }"]
Schedule --> Cron
Schedule --> Every
Schedule --> At
Cron --> CronDesc["Standard cron expression<br/>with optional timezone"]
Every --> EveryDesc["Fixed interval in milliseconds"]
At --> AtDesc["One-time execution<br/>at specific timestamp"]
Sources: src/cron/scheduler.rs:8
Standard 5-field cron syntax (minute hour day-of-month month day-of-week) with optional timezone support.
Example expressions:
-
"0 9 * * MON-FRI"- Every weekday at 9:00 AM -
"*/15 * * * *"- Every 15 minutes -
"0 0 1 * *"- First day of every month at midnight
Timezone support:
The optional tz field accepts IANA timezone identifiers (e.g., "America/New_York", "Europe/London"). If omitted, UTC is used.
Sources: src/cron/scheduler.rs:217-230
The Every variant schedules jobs at fixed millisecond intervals. This is useful for periodic tasks that don't align with wall-clock boundaries.
Example:
-
every_ms = 300000- Every 5 minutes -
every_ms = 3600000- Every hour
Sources: src/cron/scheduler.rs:218
The At variant schedules a job to run once at a specific timestamp. When combined with delete_after_run = true, the job is automatically removed after successful execution or disabled after failure.
Example:
-
at = "2024-12-31T23:59:59Z"- Execute once on New Year's Eve
Lifecycle behavior:
- Success: Job is deleted from the database
-
Failure: Job is disabled (
enabled = false) and preserved for debugging
Sources: src/cron/scheduler.rs:181-211, src/cron/scheduler.rs:209-211
The DeliveryConfig struct controls result announcement to channels after job execution.
| Field | Type | Description |
|---|---|---|
mode |
String | Delivery mode; "announce" enables delivery, other values disable it |
channel |
Option<String> | Channel type: "telegram", "discord", "slack", "mattermost"
|
to |
Option<String> | Target identifier (user ID, channel ID, thread identifier) |
best_effort |
bool | If true, delivery failure logs a warning but doesn't fail the job |
Sources: src/cron/scheduler.rs:8, src/cron/scheduler.rs:240-317
Only mode = "announce" triggers delivery. Any other value (or empty string) disables result announcement.
Channel routing:
-
Telegram:
tois the chat ID (e.g.,"123456789") -
Discord:
tois the channel ID (e.g.,"987654321098765432") -
Slack:
tois the channel ID (e.g.,"C01ABCD1234") -
Mattermost:
toischannel_idorchannel_id:root_idfor threaded replies
Best-effort delivery:
When best_effort = true, delivery failures are logged as warnings but don't mark the job as failed. When false (default), delivery failures cause the job to fail and trigger retry logic.
Sources: src/cron/scheduler.rs:240-244, src/cron/scheduler.rs:162-169
Delivery requires the corresponding channel to be configured in config.toml under [channels_config]. The scheduler instantiates channel objects on-demand:
graph LR
Job["Cron Job Execution"]
Delivery["deliver_if_configured()"]
TelegramCh["TelegramChannel::new()"]
DiscordCh["DiscordChannel::new()"]
SlackCh["SlackChannel::new()"]
MattermostCh["MattermostChannel::new()"]
Job --> Delivery
Delivery -->|"channel = telegram"| TelegramCh
Delivery -->|"channel = discord"| DiscordCh
Delivery -->|"channel = slack"| SlackCh
Delivery -->|"channel = mattermost"| MattermostCh
TelegramCh --> Send["channel.send()"]
DiscordCh --> Send
SlackCh --> Send
MattermostCh --> Send
Sources: src/cron/scheduler.rs:240-317
The scheduler executes jobs with retry logic, security validation, and result persistence:
sequenceDiagram
participant Scheduler as "run() loop"
participant DB as "due_jobs()"
participant Security as "SecurityPolicy"
participant Executor as "execute_job_with_retry()"
participant Job as "Shell/Agent Job"
participant Delivery as "deliver_if_configured()"
participant Persist as "record_run() / reschedule_after_run()"
Scheduler->>DB: Query jobs with next_run <= now
DB-->>Scheduler: Vec<CronJob>
loop For each due job (max_concurrent)
Scheduler->>Executor: execute_and_persist_job()
Executor->>Security: can_act() / record_action()
Security-->>Executor: Allowed/Denied
alt Security Denied
Executor-->>Scheduler: (job_id, false)
else Security Approved
loop Retry logic (0..=retries)
Executor->>Job: run_job_command() or run_agent_job()
Job-->>Executor: (success, output)
alt Success or Deterministic Failure
Note over Executor: Break retry loop
else Transient Failure
Note over Executor: Exponential backoff + jitter
end
end
Executor->>Delivery: Announce result to channel
alt Delivery Enabled
Delivery-->>Executor: Success/Failure
end
Executor->>Persist: record_run() + reschedule_after_run()
Persist-->>Executor: Updated job metadata
Executor-->>Scheduler: (job_id, success)
end
end
Sources: src/cron/scheduler.rs:21-44, src/cron/scheduler.rs:52-85, src/cron/scheduler.rs:87-101, src/cron/scheduler.rs:103-117
Before executing any job, the scheduler enforces security policies in this order:
-
Autonomy Check:
security.can_act()must returntrue(blocksReadOnlymode) -
Rate Limit Check:
security.is_rate_limited()must returnfalse -
Command Allowlist: For shell jobs,
security.is_command_allowed(command)validates againstallowed_commands -
Path Validation: For shell jobs,
forbidden_path_argument()blocks access to system directories -
Action Recording:
security.record_action()increments the action counter for rate limiting
Deterministic policy violations (autonomy, rate limit, command allowlist, forbidden paths) are not retried because they will always fail.
Sources: src/cron/scheduler.rs:397-433, src/cron/scheduler.rs:319-375
Job execution uses exponential backoff with jitter for transient failures:
| Configuration | Default | Description |
|---|---|---|
scheduler_retries |
3 | Maximum retry attempts (from config.reliability.scheduler_retries) |
provider_backoff_ms |
1000 | Initial backoff delay (from config.reliability.provider_backoff_ms) |
| Backoff multiplier | 2× | Each retry doubles the delay (capped at 30 seconds) |
| Jitter | 0-250ms | Random jitter added to prevent thundering herd |
Retry behavior:
-
Transient failures (command timeout, network errors) are retried up to
scheduler_retriestimes - Deterministic failures (security policy violations) are not retried
-
Backoff calculation:
delay = min(backoff_ms * 2^attempt + jitter, 30000)
Sources: src/cron/scheduler.rs:52-85, src/cron/scheduler.rs:58-59, src/cron/scheduler.rs:77-81
Shell jobs have a hardcoded 120-second execution timeout. If a command exceeds this limit, it is killed and the job is marked as failed.
Sources: src/cron/scheduler.rs:19, src/cron/scheduler.rs:449
[[scheduler.jobs]]
id = "daily-backup"
name = "Database Backup"
schedule.cron = "0 2 * * *"
schedule.tz = "America/New_York"
command = "pg_dump mydb | gzip > /backups/mydb-$(date +%Y%m%d).sql.gz"
enabled = true
delete_after_run = false
[scheduler.jobs.delivery]
mode = "announce"
channel = "slack"
to = "C01BACKUP"
best_effort = false[[scheduler.jobs]]
id = "weekly-summary"
name = "Generate Weekly Report"
schedule.cron = "0 9 * * MON"
prompt = "Generate a summary of last week's activity from memory and email it to the team."
model = "openai/gpt-4"
session_target = "isolated"
enabled = true
[scheduler.jobs.delivery]
mode = "announce"
channel = "mattermost"
to = "team-updates-channel-id"
best_effort = true[[scheduler.jobs]]
id = "release-reminder"
name = "Release Day Reminder"
schedule.at = "2024-12-15T09:00:00Z"
prompt = "Remind the team that today is release day. Check the release checklist and report any blockers."
session_target = "main"
enabled = true
delete_after_run = true
[scheduler.jobs.delivery]
mode = "announce"
channel = "telegram"
to = "123456789"
best_effort = false[[scheduler.jobs]]
id = "health-check"
name = "Service Health Monitor"
schedule.every_ms = 300000 # Every 5 minutes
command = "curl -f http://localhost:8080/health || echo 'Service down!'"
enabled = true
[scheduler.jobs.delivery]
mode = "announce"
channel = "discord"
to = "987654321098765432"
best_effort = trueSources: src/cron/scheduler.rs:8 (CronJob struct definition)
The scheduler maintains execution state for each job:
| Field | Type | Description |
|---|---|---|
next_run |
DateTime<Utc> | Calculated next execution time (updated by reschedule_after_run) |
last_run |
Option<DateTime<Utc>> | Timestamp of most recent execution |
last_status |
Option<String> |
"ok" or "error" from last run |
last_output |
Option<String> | Captured stdout/stderr or agent response |
State transitions:
-
Job Created:
next_runis calculated from schedule + creation time -
Job Executed:
last_run,last_status, andlast_outputare updated -
Rescheduled:
next_runis recalculated based on schedule type - One-shot Success: Job is deleted from database
-
One-shot Failure: Job is disabled (
enabled = false)
Sources: src/cron/scheduler.rs:152-207
Agent jobs support two session isolation modes via the SessionTarget enum:
| Target | Description | Use Case |
|---|---|---|
Main |
Shares conversation history with interactive sessions | Continuity with user conversations |
Isolated |
Independent conversation context per job execution | Stateless scheduled tasks |
The session_target field is only relevant for agent jobs; it has no effect on shell jobs.
Sources: src/cron/scheduler.rs:8, src/cron/scheduler.rs:125-137
Shell jobs undergo multi-layered path validation to prevent privilege escalation and data exfiltration:
The forbidden_path_argument() function parses shell commands and blocks access to system directories:
Forbidden paths (enforced by SecurityPolicy::is_path_allowed()):
-
/etc,/sys,/proc,/dev,/boot,/root,/var/log,/var/spool,/tmp,/var/tmp,/usr/bin,/usr/sbin,/sbin,/bin
Parsing logic:
- Split command on
&&,||,;,|, newlines - For each segment, skip leading environment variable assignments (
KEY=value) - Skip the executable token (first non-assignment word)
- For remaining tokens, strip quotes and check if they look like paths
- Validate path candidates against
SecurityPolicy::is_path_allowed()
Path detection heuristics:
- Starts with
/,./,../,~/ - Contains
/anywhere in the token - Skips flags (starts with
-) and URLs (contains://)
Sources: src/cron/scheduler.rs:319-375, src/cron/scheduler.rs:421-426
The allowed_commands configuration (from config.autonomy.allowed_commands) is enforced by SecurityPolicy::is_command_allowed(). Only the first token (executable name) is validated; arguments are not checked against the allowlist.
Example allowlist:
[autonomy]
allowed_commands = ["git", "npm", "cargo", "curl", "wget"]Sources: src/cron/scheduler.rs:411-419
The scheduler emits a warning when agent jobs are scheduled more frequently than every 5 minutes. This is a cost-protection mechanism because each agent job invokes an LLM API call, which incurs per-token charges.
Detection logic:
- For
Everyschedules:every_ms < 5 * 60 * 1000 - For
Cronschedules: Calculate time delta between consecutive runs
Warning message:
"Cron agent job '{job_id}' is scheduled more frequently than every 5 minutes"
Sources: src/cron/scheduler.rs:213-238
Core scheduler loop: src/cron/scheduler.rs:21-44
Job execution with retry: src/cron/scheduler.rs:52-85
Shell command execution: src/cron/scheduler.rs:391-467
Agent job execution: src/cron/scheduler.rs:119-150
Result delivery: src/cron/scheduler.rs:240-317
Security validation: src/cron/scheduler.rs:397-433
Path argument scanning: src/cron/scheduler.rs:319-375
One-shot lifecycle: src/cron/scheduler.rs:181-211