Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ uuid = { version = "1.15", features = ["v4", "serde"] }
# Time handling
chrono = { version = "0.4", features = ["serde"] }
chrono-tz = "0.10"
cron = "0.12"

# Regular expressions (for leak detection)
regex = "1.11"
Expand Down
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,8 @@ Every memory has a type, an importance score, and graph edges connecting it to r
Cron jobs created and managed from conversation or config:

- **Natural scheduling** — "check my inbox every 30 minutes" becomes a cron job with a delivery target
- **Clock-aligned intervals** — sub-daily intervals snap to UTC boundaries so jobs fire on clean marks (e.g. every 30 min fires at :00 and :30)
- **Strict wall-clock schedules** — use cron expressions for exact local-time execution (for example, `0 9 * * *` for 9:00 every day)
- **Legacy interval compatibility** — existing `interval_secs` jobs still run and remain configurable
- **Configurable timeouts** — per-job `timeout_secs` to cap execution time (defaults to 120s)
- **Active hours** — restrict jobs to specific time windows (supports midnight wrapping)
- **Circuit breaker** — auto-disables after 3 consecutive failures
Expand Down Expand Up @@ -375,9 +376,9 @@ Memories are structured objects, not files. Every memory is a row in SQLite with

Scheduled recurring tasks. Each cron job gets a fresh short-lived channel with full branching and worker capabilities.

- Multiple cron jobs run independently at different intervals
- Multiple cron jobs run independently on wall-clock schedules (or legacy intervals)
- Stored in the database, created via config, conversation, or programmatically
- Clock-aligned intervals snap to UTC boundaries for predictable firing times
- Cron expressions execute against the resolved cron timezone for predictable local-time firing
- Per-job `timeout_secs` to cap execution time
- Circuit breaker auto-disables after 3 consecutive failures
- Active hours support with midnight wrapping
Expand Down
3 changes: 2 additions & 1 deletion docs/content/docs/(configuration)/config.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ writable_paths = ["/home/user/projects/myapp"] # additional writable directories
[[agents.cron]]
id = "daily-check"
prompt = "Check in on ongoing projects and report status."
interval_secs = 86400
cron_expr = "0 9 * * *"
delivery_target = "discord:123456789"
active_start_hour = 9
active_end_hour = 17
Expand Down Expand Up @@ -566,6 +566,7 @@ writable_paths = ["/home/user/projects/myapp", "/var/data/shared"]
|-----|------|---------|-------------|
| `id` | string | **required** | Cron job identifier |
| `prompt` | string | **required** | Prompt sent to a fresh channel on each tick |
| `cron_expr` | string | None | Strict wall-clock schedule (cron expression, e.g. `0 9 * * *`) |
| `interval_secs` | integer | 3600 | Seconds between firings |
| `delivery_target` | string | **required** | Where to send results (`adapter:target`) |
| `active_start_hour` | integer | None | Start of active hours window (24h format) |
Expand Down
13 changes: 11 additions & 2 deletions docs/content/docs/(features)/cron.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ The configuration table. One row per cron job.
CREATE TABLE cron_jobs (
id TEXT PRIMARY KEY,
prompt TEXT NOT NULL,
cron_expr TEXT,
interval_secs INTEGER NOT NULL DEFAULT 3600,
delivery_target TEXT NOT NULL,
active_start_hour INTEGER,
Expand All @@ -57,6 +58,7 @@ CREATE TABLE cron_jobs (
|--------|-------------|
| `id` | Short unique name (e.g. "check-email", "daily-summary") |
| `prompt` | The instruction to execute on each run |
| `cron_expr` | Optional strict wall-clock schedule (cron expression, e.g. `0 9 * * *`) |
| `interval_secs` | Seconds between runs (3600 = hourly, 86400 = daily) |
| `delivery_target` | Where to send results, format `adapter:target` (e.g. `discord:123456789`) |
| `active_start_hour` | Optional start of active window (0-23, 24h local time) |
Expand Down Expand Up @@ -112,6 +114,12 @@ delivery_target = "discord:123456789012345678"
active_start_hour = 9
active_end_hour = 10

[[agents.cron]]
id = "daily-standup"
prompt = "Post a standup reminder."
cron_expr = "0 9 * * 1-5"
delivery_target = "discord:123456789012345678"

[[agents.cron]]
id = "check-inbox"
prompt = "Check the inbox for anything that needs attention."
Expand All @@ -130,6 +138,7 @@ A user says "check my email every day at 9am" and the channel LLM calls the `cro
"action": "create",
"id": "check-email",
"prompt": "Check the user's email inbox and summarize any important messages.",
"cron_expr": "0 9 * * *",
"interval_secs": 86400,
"delivery_target": "discord:123456789",
"active_start_hour": 9,
Expand Down Expand Up @@ -164,7 +173,7 @@ If active hours are not set, the cron job runs at all hours.

If a configured timezone is invalid, Spacebot logs a warning and falls back to server local timezone.

Active hours don't affect the timer interval — the timer still ticks at `interval_secs`. When a tick lands outside the active window, it's skipped. The next tick happens at the normal interval, not "as soon as the window opens."
For cron-expression jobs, active hours are evaluated at fire time and can further gate delivery. For legacy interval jobs, active hours don't change tick cadence — ticks outside the window are skipped.

## Circuit Breaker

Expand Down Expand Up @@ -279,7 +288,7 @@ pub struct CronContext {

## What's Not Implemented Yet

- **Cron expressions** — only fixed intervals for now. A cron job that should run "at 9am daily" currently uses `interval_secs: 86400` with `active_start_hour: 9, active_end_hour: 10`. Real cron scheduling would be more precise.
- **Cron expressions in config/tool/API are now supported** and are preferred for exact local-time schedules.
- **Error backoff** — on failure, the next attempt happens at the normal interval. Progressive backoff (30s → 1m → 5m → 15m → 60m) would reduce cost during outages.
- **Cross-run context** — each cron job starts with a blank history. A cron job that needs to know what it found last time would need to use memory recall.
- **Cortex management** — the cortex should be able to observe cron job health, re-enable circuit-broken jobs, and create new cron jobs based on patterns.
Expand Down
2 changes: 2 additions & 0 deletions migrations/20260226000001_cron_expression.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- Add wall-clock cron expression schedule support.
ALTER TABLE cron_jobs ADD COLUMN cron_expr TEXT;
2 changes: 1 addition & 1 deletion prompts/en/tools/cron_description.md.j2
Original file line number Diff line number Diff line change
@@ -1 +1 @@
Manage scheduled tasks (cron jobs). Use this to create, list, or delete cron jobs. A cron job runs a prompt on a timer and delivers the result to a messaging channel. Use `run_once: true` for one-time reminders; otherwise jobs are recurring.
Manage scheduled tasks (cron jobs). Use this to create, list, or delete cron jobs. Prefer `cron_expr` for strict wall-clock schedules; `interval_secs` remains available for legacy interval-based jobs. A cron job runs a prompt and delivers the result to a messaging channel. Use `run_once: true` for one-time reminders; otherwise jobs are recurring.
35 changes: 34 additions & 1 deletion src/api/cron.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ use axum::Json;
use axum::extract::{Query, State};
use axum::http::StatusCode;
use serde::{Deserialize, Serialize};
use std::str::FromStr;
use std::sync::Arc;

#[derive(Deserialize)]
Expand All @@ -29,6 +30,8 @@ pub(super) struct CreateCronRequest {
agent_id: String,
id: String,
prompt: String,
#[serde(default)]
cron_expr: Option<String>,
#[serde(default = "default_interval")]
interval_secs: u64,
delivery_target: String,
Expand Down Expand Up @@ -75,6 +78,7 @@ pub(super) struct ToggleCronRequest {
struct CronJobWithStats {
id: String,
prompt: String,
cron_expr: Option<String>,
interval_secs: u64,
delivery_target: String,
enabled: bool,
Expand Down Expand Up @@ -130,6 +134,7 @@ pub(super) async fn list_cron_jobs(
jobs.push(CronJobWithStats {
id: config.id,
prompt: config.prompt,
cron_expr: config.cron_expr,
interval_secs: config.interval_secs,
delivery_target: config.delivery_target,
enabled: config.enabled,
Expand Down Expand Up @@ -194,7 +199,13 @@ fn validate_cron_request(request: &CreateCronRequest) -> Result<(), (StatusCode,
));
}

if request.interval_secs < MIN_CRON_INTERVAL_SECS {
let cron_expr = request
.cron_expr
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty());

if cron_expr.is_none() && request.interval_secs < MIN_CRON_INTERVAL_SECS {
return Err((
StatusCode::BAD_REQUEST,
format!(
Expand All @@ -204,6 +215,22 @@ fn validate_cron_request(request: &CreateCronRequest) -> Result<(), (StatusCode,
));
}

if let Some(expr) = cron_expr {
let field_count = expr.split_whitespace().count();
if field_count != 5 {
return Err((
StatusCode::BAD_REQUEST,
format!("cron_expr must have exactly 5 fields (got {field_count}): '{expr}'"),
));
}
cron::Schedule::from_str(expr).map_err(|error| {
(
StatusCode::BAD_REQUEST,
format!("invalid cron_expr '{expr}': {error}"),
)
})?;
}

if request.prompt.len() > MAX_CRON_PROMPT_LENGTH {
return Err((
StatusCode::BAD_REQUEST,
Expand Down Expand Up @@ -291,6 +318,12 @@ pub(super) async fn create_or_update_cron(
let config = crate::cron::CronConfig {
id: request.id.clone(),
prompt: request.prompt,
cron_expr: request
.cron_expr
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToString::to_string),
interval_secs: request.interval_secs,
delivery_target: request.delivery_target,
active_hours,
Expand Down
5 changes: 5 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -971,6 +971,9 @@ pub struct AgentConfig {
pub struct CronDef {
pub id: String,
pub prompt: String,
/// Optional cron expression (wall-clock schedule) in standard 5-field format.
/// When set, this takes precedence over `interval_secs`.
pub cron_expr: Option<String>,
pub interval_secs: u64,
/// Delivery target in "adapter:target" format (e.g. "discord:123456789").
pub delivery_target: String,
Expand Down Expand Up @@ -2135,6 +2138,7 @@ struct TomlAgentConfig {
struct TomlCronDef {
id: String,
prompt: String,
cron_expr: Option<String>,
interval_secs: Option<u64>,
Comment on lines +2141 to 2142

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Normalize cron_expr when loading TOML to reject empty schedules.

Right now an empty/whitespace cron_expr is passed through unchanged. Normalizing here avoids carrying an invalid “set” schedule into runtime behavior.

🧹 Suggested fix
-                        cron_expr: h.cron_expr,
+                        cron_expr: h
+                            .cron_expr
+                            .map(|expr| expr.trim().to_string())
+                            .filter(|expr| !expr.is_empty()),

Also applies to: 3754-3758

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/config.rs` around lines 2141 - 2142, When loading/parsing the TOML into
the struct that contains the fields cron_expr and interval_secs, normalize
cron_expr by trimming whitespace and converting empty or all-whitespace strings
to None so you never carry an Option<String> with an empty schedule into
runtime; update the TOML-to-config conversion path (e.g. the Config::from_toml /
deserialize logic that populates cron_expr) to perform s = s.trim(); if
s.is_empty() set cron_expr = None otherwise set Some(s.to_string()); apply the
same normalization where similar schedule parsing occurs (the other occurrence
around the block referenced at lines ~3754-3758).

delivery_target: String,
active_start_hour: Option<u8>,
Expand Down Expand Up @@ -3756,6 +3760,7 @@ impl Config {
.map(|h| CronDef {
id: h.id,
prompt: h.prompt,
cron_expr: h.cron_expr,
interval_secs: h.interval_secs.unwrap_or(3600),
delivery_target: h.delivery_target,
active_hours: match (h.active_start_hour, h.active_end_hour) {
Expand Down
Loading