Skip to main content

Cron Scheduling

ClawDesk includes a built-in cron scheduling system for running automated tasks—daily summaries, periodic health checks, scheduled messages, and more. Tasks run through the agent pipeline with full access to tools, memory, and channels.

Architecture

Key Components

ComponentDescription
CronManagerTop-level orchestrator; manages task lifecycle, scheduling, and state
CronExecutorExecutes tasks in isolated contexts with timeout enforcement
HeartbeatMonitors task health and detects stuck/hung executions
Schedule ParserParses cron expressions (standard + extended syntax)
Overlap GuardPrevents concurrent executions of the same task

Schedule Syntax

ClawDesk supports standard cron syntax and convenient shorthand expressions:

Standard Cron Format

┌───────── minute (0–59)
│ ┌───────── hour (0–23)
│ │ ┌───────── day of month (1–31)
│ │ │ ┌───────── month (1–12 or JAN–DEC)
│ │ │ │ ┌───────── day of week (0–7 or SUN–SAT, 0 and 7 = Sunday)
│ │ │ │ │
* * * * *

Examples

ExpressionDescription
0 9 * * *Every day at 9:00 AM
*/15 * * * *Every 15 minutes
0 9 * * MON-FRIWeekdays at 9:00 AM
0 0 1 * *First day of every month at midnight
30 14 * * WEDEvery Wednesday at 2:30 PM
0 */4 * * *Every 4 hours
0 8,12,17 * * *At 8 AM, 12 PM, and 5 PM daily
0 9 1,15 * *1st and 15th of each month at 9 AM

Shorthand Expressions

ShorthandEquivalentDescription
@yearly / @annually0 0 1 1 *Once a year (Jan 1)
@monthly0 0 1 * *First of every month
@weekly0 0 * * 0Every Sunday
@daily / @midnight0 0 * * *Every day at midnight
@hourly0 * * * *Every hour
@every 30mEvery 30 minutes
@every 2hEvery 2 hours
@every 1dEvery 24 hours
tip

Use @every for simple intervals. It's easier to read and supports arbitrary durations:

schedule = "@every 45m"

Task Configuration

Defining Tasks in TOML

# A daily summary task
[[cron.tasks]]
id = "daily-summary"
name = "Daily Conversation Summary"
schedule = "0 9 * * MON-FRI"
enabled = true
channel = "tg_admin"
timezone = "America/New_York"

[cron.tasks.message]
text = """
Generate a summary of all conversations from the past 24 hours.
Include: total messages, active channels, common topics, and any unresolved issues.
"""
provider = "anthropic"
model = "claude-sonnet-4-20250514"
tools = ["knowledge_base"]

[cron.tasks.execution]
timeout_secs = 60
max_retries = 2
overlap_policy = "skip" # "skip" | "queue" | "cancel_previous"

# A periodic health check
[[cron.tasks]]
id = "health-check"
name = "System Health Check"
schedule = "@every 30m"
enabled = true
channel = "tg_admin"

[cron.tasks.message]
text = "Run a system health check and report any issues."
tools = ["system_status", "metrics_query"]

[cron.tasks.execution]
timeout_secs = 30
overlap_policy = "skip"

# A weekly report
[[cron.tasks]]
id = "weekly-report"
name = "Weekly Analytics Report"
schedule = "0 10 * * MON"
enabled = true
channel = "sl_work"

[cron.tasks.message]
text = """
Generate a weekly analytics report covering:
1. Message volume by channel
2. Provider usage and costs
3. Error rates and top errors
4. User engagement metrics
Format as a Slack-friendly message with sections.
"""
tools = ["metrics_query", "cost_tracker"]

[cron.tasks.execution]
timeout_secs = 120
max_retries = 3

Via CLI

# Add a task interactively
clawdesk cron add

# Add a task with flags
clawdesk cron add \
--id "daily-summary" \
--name "Daily Summary" \
--schedule "0 9 * * MON-FRI" \
--channel tg_admin \
--message "Summarize yesterday's conversations" \
--timezone "America/New_York"

# List all tasks
clawdesk cron list

Example output of clawdesk cron list:

┌────────────────┬──────────────────────┬─────────────────┬─────────┬──────────────────┬───────────┐
│ ID │ Name │ Schedule │ Status │ Last Run │ Next Run │
├────────────────┼──────────────────────┼─────────────────┼─────────┼──────────────────┼───────────┤
│ daily-summary │ Daily Summary │ 0 9 * * MON-FRI │ Active │ 2026-02-16 09:00 │ in 14h │
│ health-check │ Health Check │ @every 30m │ Active │ 2 min ago │ in 28m │
│ weekly-report │ Weekly Report │ 0 10 * * MON │ Active │ 2026-02-10 10:00 │ in 5d │
│ cleanup │ Session Cleanup │ 0 3 * * * │ Paused │ 2026-02-15 03:00 │ — │
└────────────────┴──────────────────────┴─────────────────┴─────────┴──────────────────┴───────────┘

Execution Model

Isolated Execution

Each cron task runs in an isolated execution context:

pub struct CronExecutor {
pipeline: Arc<AgentPipeline>,
channel_registry: Arc<ChannelRegistry>,
}

impl CronExecutor {
pub async fn execute(&self, task: &CronTask) -> Result<ExecutionResult> {
// Create an isolated context for this execution
let ctx = ExecutionContext::new()
.with_task_id(&task.id)
.with_timeout(task.execution.timeout)
.with_provider_override(task.message.provider.as_deref())
.with_model_override(task.message.model.as_deref());

// Build an inbound message from the task
let message = InboundMessage::from_cron(task);

// Process through the agent pipeline
let response = tokio::time::timeout(
task.execution.timeout,
self.pipeline.process_with_context(message, ctx),
).await??;

// Send to the target channel
if let Some(channel_id) = &task.channel {
let channel = self.channel_registry.get(channel_id)?;
channel.send(response.into()).await?;
}

Ok(ExecutionResult {
task_id: task.id.clone(),
started_at: Instant::now(),
duration: response.duration,
status: ExecutionStatus::Success,
output_preview: response.text_preview(200),
})
}
}

Overlap Prevention

Three overlap policies control what happens when a task is triggered while a previous execution is still running:

PolicyBehavior
skipSkip the new execution, keep the running one (default)
queueQueue the new execution to run after the current one finishes
cancel_previousCancel the running execution and start a new one
[cron.tasks.execution]
overlap_policy = "skip"
warning

Use cancel_previous with caution. If a task involves tool calls with side effects (e.g., sending emails, creating tickets), cancelling mid-execution may leave operations in an incomplete state.

Timeout Enforcement

Every cron task has a configurable timeout:

[cron.tasks.execution]
timeout_secs = 60 # per-execution timeout
max_retries = 2 # retry on failure
retry_delay_secs = 10 # delay between retries

If a task exceeds its timeout, it's forcefully terminated and marked as TimedOut in the execution history.


Heartbeat System

The heartbeat system monitors running tasks and detects hung executions:

pub struct Heartbeat {
interval: Duration,
stale_threshold: Duration,
running_tasks: Arc<DashMap<TaskId, HeartbeatEntry>>,
}

pub struct HeartbeatEntry {
pub task_id: TaskId,
pub started_at: Instant,
pub last_heartbeat: Instant,
pub progress: Option<String>,
}

impl Heartbeat {
/// Start the heartbeat monitor
pub async fn start(&self) {
loop {
tokio::time::sleep(self.interval).await;

for entry in self.running_tasks.iter() {
if entry.last_heartbeat.elapsed() > self.stale_threshold {
tracing::warn!(
task_id = %entry.task_id,
elapsed = ?entry.last_heartbeat.elapsed(),
"Task heartbeat stale — possible hang"
);
// Optionally kill the task
}
}
}
}

/// Record a heartbeat from a running task
pub fn beat(&self, task_id: &TaskId, progress: Option<String>) {
if let Some(mut entry) = self.running_tasks.get_mut(task_id) {
entry.last_heartbeat = Instant::now();
entry.progress = progress;
}
}
}

Configure the heartbeat system:

[cron.heartbeat]
enabled = true
interval_secs = 10 # how often to check
stale_threshold_secs = 60 # time without heartbeat before alert
kill_on_stale = false # kill stale tasks automatically
alert_channel = "tg_admin" # send alerts to this channel

Monitoring

Execution History

# View execution history for a task
clawdesk cron history daily-summary --limit 10

# Example output:
# ┌──────────────────────┬──────────┬──────────┬──────────────────────────────┐
# │ Executed At │ Duration │ Status │ Output Preview │
# ├──────────────────────┼──────────┼──────────┼──────────────────────────────┤
# │ 2026-02-17 09:00:02 │ 4.2s │ ✅ OK │ Yesterday's summary: 142... │
# │ 2026-02-14 09:00:01 │ 3.8s │ ✅ OK │ Thursday summary: 98 me... │
# │ 2026-02-13 09:00:03 │ 12.1s │ ⚠️ Retry │ Timed out, retried succ... │
# │ 2026-02-12 09:00:01 │ 3.5s │ ✅ OK │ Tuesday summary: 76 mes... │
# └──────────────────────┴──────────┴──────────┴──────────────────────────────┘

Metrics

MetricTypeDescription
clawdesk_cron_executions_totalCounterTotal executions by task and status
clawdesk_cron_duration_secondsHistogramExecution duration by task
clawdesk_cron_errors_totalCounterErrors by task and error type
clawdesk_cron_skipped_totalCounterSkipped executions (overlap)
clawdesk_cron_active_tasksGaugeCurrently running tasks
clawdesk_cron_heartbeat_staleCounterStale heartbeat events

Manual Triggers

# Run a task immediately (outside its schedule)
clawdesk cron run daily-summary

# Run with output to terminal
clawdesk cron run daily-summary --output terminal

# Dry run (show what would execute without actually running)
clawdesk cron run daily-summary --dry-run

Advanced Patterns

Chained Tasks

Execute tasks in sequence:

[[cron.tasks]]
id = "etl-extract"
schedule = "0 2 * * *"
# ...

[[cron.tasks]]
id = "etl-transform"
schedule = "" # no schedule — triggered only by chain
depends_on = "etl-extract" # runs after etl-extract succeeds

[[cron.tasks]]
id = "etl-load"
schedule = ""
depends_on = "etl-transform"

Conditional Execution

Tasks can include conditions:

[[cron.tasks]]
id = "weekend-summary"
schedule = "0 10 * * SAT"

[cron.tasks.condition]
# Only run if there were messages in the past week
check = "message_count"
threshold = 10
period = "7d"

Timezone Support

All schedules support timezone specification:

[[cron.tasks]]
id = "standup-reminder"
schedule = "0 9 * * MON-FRI"
timezone = "America/New_York" # defaults to UTC if unset

[[cron.tasks]]
id = "asia-report"
schedule = "0 9 * * MON-FRI"
timezone = "Asia/Tokyo"
info

When no timezone is specified, schedules use UTC. Always set an explicit timezone for tasks that should run at local business hours.


Troubleshooting

ProblemSolution
Task not runningCheck enabled = true, verify schedule syntax with clawdesk cron run <id> --dry-run
Task runs at wrong timeVerify timezone setting, check system clock
Task always skippedCheck overlap policy — may be hitting overlap with long-running previous execution
Heartbeat stale alertsIncrease stale_threshold_secs or investigate slow provider responses
Task output not sentVerify channel ID exists and is connected