From 57f44ce4e71440be025c90054a5d0307d62a12ef Mon Sep 17 00:00:00 2001 From: James Pine Date: Thu, 26 Feb 2026 17:02:29 -0800 Subject: [PATCH 1/4] feat(cron): add strict wall-clock schedule support --- Cargo.lock | 12 ++ Cargo.toml | 1 + README.md | 7 +- docs/content/docs/(configuration)/config.mdx | 3 +- docs/content/docs/(features)/cron.mdx | 13 +- migrations/20260226000001_cron_expression.sql | 2 + prompts/en/tools/cron_description.md.j2 | 2 +- src/api/cron.rs | 28 ++- src/config.rs | 5 + src/cron/scheduler.rs | 199 ++++++++++++++---- src/cron/store.rs | 12 +- src/main.rs | 1 + src/tools/cron.rs | 38 +++- 13 files changed, 265 insertions(+), 58 deletions(-) create mode 100644 migrations/20260226000001_cron_expression.sql diff --git a/Cargo.lock b/Cargo.lock index 3afdf1dfa..d0abf1379 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1555,6 +1555,17 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "cron" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8c3e73077b4b4a6ab1ea5047c37c57aee77657bc8ecd6f29b0af082d0b0c07" +dependencies = [ + "chrono", + "nom 7.1.3", + "once_cell", +] + [[package]] name = "crossbeam-channel" version = "0.5.15" @@ -8407,6 +8418,7 @@ dependencies = [ "chrono-tz", "clap", "config", + "cron", "daemonize", "dialoguer", "dirs", diff --git a/Cargo.toml b/Cargo.toml index b611c57d5..e2a19593a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/README.md b/README.md index 0ac61fba0..b21d30c8e 100644 --- a/README.md +++ b/README.md @@ -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 @@ -355,9 +356,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 diff --git a/docs/content/docs/(configuration)/config.mdx b/docs/content/docs/(configuration)/config.mdx index 140b1fd2d..ac7db2c0d 100644 --- a/docs/content/docs/(configuration)/config.mdx +++ b/docs/content/docs/(configuration)/config.mdx @@ -130,7 +130,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 @@ -562,6 +562,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) | diff --git a/docs/content/docs/(features)/cron.mdx b/docs/content/docs/(features)/cron.mdx index 946d2e4e9..19307c4da 100644 --- a/docs/content/docs/(features)/cron.mdx +++ b/docs/content/docs/(features)/cron.mdx @@ -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, @@ -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) | @@ -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." @@ -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, @@ -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 @@ -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. diff --git a/migrations/20260226000001_cron_expression.sql b/migrations/20260226000001_cron_expression.sql new file mode 100644 index 000000000..fe0dd530f --- /dev/null +++ b/migrations/20260226000001_cron_expression.sql @@ -0,0 +1,2 @@ +-- Add wall-clock cron expression schedule support. +ALTER TABLE cron_jobs ADD COLUMN cron_expr TEXT; diff --git a/prompts/en/tools/cron_description.md.j2 b/prompts/en/tools/cron_description.md.j2 index ea121248f..b3493c555 100644 --- a/prompts/en/tools/cron_description.md.j2 +++ b/prompts/en/tools/cron_description.md.j2 @@ -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. diff --git a/src/api/cron.rs b/src/api/cron.rs index 1615dadcb..b445c70a7 100644 --- a/src/api/cron.rs +++ b/src/api/cron.rs @@ -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)] @@ -29,6 +30,8 @@ pub(super) struct CreateCronRequest { agent_id: String, id: String, prompt: String, + #[serde(default)] + cron_expr: Option, #[serde(default = "default_interval")] interval_secs: u64, delivery_target: String, @@ -75,6 +78,7 @@ pub(super) struct ToggleCronRequest { struct CronJobWithStats { id: String, prompt: String, + cron_expr: Option, interval_secs: u64, delivery_target: String, enabled: bool, @@ -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, @@ -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!( @@ -204,6 +215,15 @@ fn validate_cron_request(request: &CreateCronRequest) -> Result<(), (StatusCode, )); } + if let Some(expr) = cron_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, @@ -291,6 +311,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, diff --git a/src/config.rs b/src/config.rs index 216a2f9a3..d0f1d2914 100644 --- a/src/config.rs +++ b/src/config.rs @@ -964,6 +964,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, pub interval_secs: u64, /// Delivery target in "adapter:target" format (e.g. "discord:123456789"). pub delivery_target: String, @@ -2116,6 +2119,7 @@ struct TomlAgentConfig { struct TomlCronDef { id: String, prompt: String, + cron_expr: Option, interval_secs: Option, delivery_target: String, active_start_hour: Option, @@ -3685,6 +3689,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) { diff --git a/src/cron/scheduler.rs b/src/cron/scheduler.rs index 04844f604..31ec31448 100644 --- a/src/cron/scheduler.rs +++ b/src/cron/scheduler.rs @@ -13,7 +13,9 @@ use crate::messaging::target::{BroadcastTarget, parse_delivery_target}; use crate::{AgentDeps, InboundMessage, MessageContent, OutboundResponse}; use chrono::Timelike; use chrono_tz::Tz; +use cron::Schedule; use std::collections::HashMap; +use std::str::FromStr; use std::sync::Arc; use tokio::sync::RwLock; use tokio::time::Duration; @@ -23,6 +25,8 @@ use tokio::time::Duration; pub struct CronJob { pub id: String, pub prompt: String, + /// Optional wall-clock cron expression (5-field syntax). + pub cron_expr: Option, pub interval_secs: u64, pub delivery_target: BroadcastTarget, pub active_hours: Option<(u8, u8)>, @@ -39,6 +43,8 @@ pub struct CronJob { pub struct CronConfig { pub id: String, pub prompt: String, + /// Optional wall-clock cron expression (5-field syntax). + pub cron_expr: Option, #[serde(default = "default_interval")] pub interval_secs: u64, /// Delivery target in "adapter:target" format (e.g. "discord:123456789"). @@ -134,9 +140,11 @@ impl Scheduler { )) })?; + let cron_expr = normalize_cron_expr(config.cron_expr.clone())?; let job = CronJob { id: config.id.clone(), prompt: config.prompt, + cron_expr, interval_secs: config.interval_secs, delivery_target, active_hours: normalize_active_hours(config.active_hours), @@ -155,7 +163,13 @@ impl Scheduler { self.start_timer(&config.id).await; } - tracing::info!(cron_id = %config.id, interval_secs = config.interval_secs, run_once = config.run_once, "cron job registered"); + tracing::info!( + cron_id = %config.id, + interval_secs = config.interval_secs, + cron_expr = ?config.cron_expr, + run_once = config.run_once, + "cron job registered" + ); Ok(()) } @@ -180,48 +194,65 @@ impl Scheduler { } let handle = tokio::spawn(async move { - // Look up interval before entering the loop - let interval_secs = { - let j = jobs.read().await; - j.get(&job_id).map(|j| j.interval_secs).unwrap_or(3600) - }; + let execution_lock = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let mut interval_first_tick = true; - // For sub-daily intervals that divide evenly into 86400 (e.g. 1800s, 3600s, 21600s), - // align the first tick to the next UTC clock boundary so the job fires on clean marks - // like :00 and :30 rather than at an arbitrary offset from service start. - // Daily/weekly jobs are left on relative timing (interval_at with one interval offset) - // to avoid overcomplicating scheduling for jobs with active_hours constraints. - let first_tick = if interval_secs < 86400 && 86400 % interval_secs == 0 { - let now_unix = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - let remainder = now_unix % interval_secs; - let secs_until = if remainder == 0 { - interval_secs - } else { - interval_secs - remainder + loop { + let job = { + let j = jobs.read().await; + match j.get(&job_id) { + Some(j) if !j.enabled => { + tracing::debug!(cron_id = %job_id, "cron job disabled, stopping timer"); + break; + } + Some(j) => j.clone(), + None => { + tracing::debug!(cron_id = %job_id, "cron job removed, stopping timer"); + break; + } + } }; - tracing::info!( - cron_id = %job_id, - interval_secs, - secs_until_first_tick = secs_until, - "clock-aligned timer: first tick in {secs_until}s" - ); - tokio::time::Instant::now() + Duration::from_secs(secs_until) - } else { - tokio::time::Instant::now() + Duration::from_secs(interval_secs) - }; - - let mut ticker = - tokio::time::interval_at(first_tick, Duration::from_secs(interval_secs)); - // Skip catch-up ticks if processing falls behind — maintain original cadence. - ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); - let execution_lock = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let sleep_duration = if let Some(cron_expr) = job.cron_expr.as_deref() { + match next_fire_duration(&context, &job_id, cron_expr) { + Some((duration, next_fire_utc, timezone)) => { + tracing::debug!( + cron_id = %job_id, + cron_expr, + cron_timezone = %timezone, + next_fire_utc = %next_fire_utc.to_rfc3339(), + sleep_secs = duration.as_secs(), + "wall-clock cron next fire computed" + ); + duration + } + None => { + tracing::warn!( + cron_id = %job_id, + cron_expr, + "failed to compute next wall-clock fire; retrying in 60s" + ); + Duration::from_secs(60) + } + } + } else { + let interval_secs = job.interval_secs; + let delay = if interval_first_tick { + interval_first_tick = false; + interval_initial_delay(interval_secs) + } else { + Duration::from_secs(interval_secs) + }; + tracing::debug!( + cron_id = %job_id, + interval_secs, + sleep_secs = delay.as_secs(), + "interval cron next fire computed" + ); + delay + }; - loop { - ticker.tick().await; + tokio::time::sleep(sleep_duration).await; let job = { let j = jobs.read().await; @@ -457,6 +488,7 @@ impl Scheduler { CronJob { id: config.id.clone(), prompt: config.prompt, + cron_expr: normalize_cron_expr(config.cron_expr)?, interval_secs: config.interval_secs, delivery_target, active_hours: normalize_active_hours(config.active_hours), @@ -562,6 +594,95 @@ fn normalize_active_hours(active_hours: Option<(u8, u8)>) -> Option<(u8, u8)> { active_hours.filter(|(start, end)| start != end) } +fn normalize_cron_expr(cron_expr: Option) -> Result> { + let Some(expr) = cron_expr else { + return Ok(None); + }; + + let trimmed = expr.trim(); + if trimmed.is_empty() { + return Ok(None); + } + + Schedule::from_str(trimmed).map_err(|error| { + crate::error::Error::Other(anyhow::anyhow!( + "invalid cron expression '{trimmed}': {error}" + )) + })?; + + Ok(Some(trimmed.to_string())) +} + +fn interval_initial_delay(interval_secs: u64) -> Duration { + if interval_secs < 86400 && 86400 % interval_secs == 0 { + let now_unix = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let remainder = now_unix % interval_secs; + let secs_until = if remainder == 0 { + interval_secs + } else { + interval_secs - remainder + }; + Duration::from_secs(secs_until) + } else { + Duration::from_secs(interval_secs) + } +} + +fn resolve_cron_timezone(context: &CronContext) -> (Option, String) { + let timezone = context.deps.runtime_config.cron_timezone.load(); + match timezone.as_deref() { + Some(name) => match name.parse::() { + Ok(timezone) => (Some(timezone), name.to_string()), + Err(error) => { + tracing::warn!( + agent_id = %context.deps.agent_id, + cron_timezone = %name, + %error, + "invalid cron timezone in runtime config, falling back to system timezone" + ); + (None, SYSTEM_TIMEZONE_LABEL.to_string()) + } + }, + None => (None, SYSTEM_TIMEZONE_LABEL.to_string()), + } +} + +fn next_fire_duration( + context: &CronContext, + cron_id: &str, + cron_expr: &str, +) -> Option<(Duration, chrono::DateTime, String)> { + let schedule = match Schedule::from_str(cron_expr) { + Ok(schedule) => schedule, + Err(error) => { + tracing::warn!(cron_id = %cron_id, cron_expr, %error, "invalid cron expression"); + return None; + } + }; + + let now_utc = chrono::Utc::now(); + let (timezone, timezone_label) = resolve_cron_timezone(context); + let next_utc = if let Some(timezone) = timezone { + let now_local = now_utc.with_timezone(&timezone); + schedule + .after(&now_local) + .next()? + .with_timezone(&chrono::Utc) + } else { + let now_local = chrono::Local::now(); + schedule + .after(&now_local) + .next()? + .with_timezone(&chrono::Utc) + }; + let delay_ms = (next_utc - now_utc).num_milliseconds().max(0) as u64; + + Some((Duration::from_millis(delay_ms), next_utc, timezone_label)) +} + fn ensure_cron_dispatch_readiness(context: &CronContext, cron_id: &str) { let readiness = context.deps.runtime_config.work_readiness(); if readiness.ready { diff --git a/src/cron/store.rs b/src/cron/store.rs index 2b9a3c38c..90c5b6d20 100644 --- a/src/cron/store.rs +++ b/src/cron/store.rs @@ -24,10 +24,11 @@ impl CronStore { sqlx::query( r#" - INSERT INTO cron_jobs (id, prompt, interval_secs, delivery_target, active_start_hour, active_end_hour, enabled, run_once, timeout_secs) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO cron_jobs (id, prompt, cron_expr, interval_secs, delivery_target, active_start_hour, active_end_hour, enabled, run_once, timeout_secs) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET prompt = excluded.prompt, + cron_expr = excluded.cron_expr, interval_secs = excluded.interval_secs, delivery_target = excluded.delivery_target, active_start_hour = excluded.active_start_hour, @@ -39,6 +40,7 @@ impl CronStore { ) .bind(&config.id) .bind(&config.prompt) + .bind(config.cron_expr.as_deref()) .bind(config.interval_secs as i64) .bind(&config.delivery_target) .bind(active_start) @@ -57,7 +59,7 @@ impl CronStore { pub async fn load_all(&self) -> Result> { let rows = sqlx::query( r#" - SELECT id, prompt, interval_secs, delivery_target, active_start_hour, active_end_hour, enabled, run_once, timeout_secs + SELECT id, prompt, cron_expr, interval_secs, delivery_target, active_start_hour, active_end_hour, enabled, run_once, timeout_secs FROM cron_jobs WHERE enabled = 1 ORDER BY created_at ASC @@ -72,6 +74,7 @@ impl CronStore { .map(|row| CronConfig { id: row.try_get("id").unwrap_or_default(), prompt: row.try_get("prompt").unwrap_or_default(), + cron_expr: row.try_get::, _>("cron_expr").ok().flatten(), interval_secs: row.try_get::("interval_secs").unwrap_or(3600) as u64, delivery_target: row.try_get("delivery_target").unwrap_or_default(), active_hours: { @@ -148,7 +151,7 @@ impl CronStore { pub async fn load_all_unfiltered(&self) -> Result> { let rows = sqlx::query( r#" - SELECT id, prompt, interval_secs, delivery_target, active_start_hour, active_end_hour, enabled, run_once, timeout_secs + SELECT id, prompt, cron_expr, interval_secs, delivery_target, active_start_hour, active_end_hour, enabled, run_once, timeout_secs FROM cron_jobs ORDER BY created_at ASC "#, @@ -162,6 +165,7 @@ impl CronStore { .map(|row| CronConfig { id: row.try_get("id").unwrap_or_default(), prompt: row.try_get("prompt").unwrap_or_default(), + cron_expr: row.try_get::, _>("cron_expr").ok().flatten(), interval_secs: row.try_get::("interval_secs").unwrap_or(3600) as u64, delivery_target: row.try_get("delivery_target").unwrap_or_default(), active_hours: { diff --git a/src/main.rs b/src/main.rs index 55eaba035..76a3390d1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1593,6 +1593,7 @@ async fn initialize_agents( let cron_config = spacebot::cron::CronConfig { id: cron_def.id.clone(), prompt: cron_def.prompt.clone(), + cron_expr: cron_def.cron_expr.clone(), interval_secs: cron_def.interval_secs, delivery_target: cron_def.delivery_target.clone(), active_hours: cron_def.active_hours, diff --git a/src/tools/cron.rs b/src/tools/cron.rs index c2fb22d63..c05696cba 100644 --- a/src/tools/cron.rs +++ b/src/tools/cron.rs @@ -6,6 +6,7 @@ use rig::completion::ToolDefinition; use rig::tool::Tool; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +use std::str::FromStr; use std::sync::Arc; /// Minimum allowed interval between cron job runs (seconds). @@ -51,6 +52,10 @@ pub struct CronArgs { /// Required for "create": the prompt/instruction to execute on each run. #[serde(default)] pub prompt: Option, + /// Optional for "create": strict wall-clock cron expression (5-field syntax). + /// When provided, this takes precedence over interval-based scheduling. + #[serde(default)] + pub cron_expr: Option, /// Required for "create": interval in seconds between runs. #[serde(default)] pub interval_secs: Option, @@ -88,6 +93,7 @@ pub struct CronOutput { pub struct CronEntry { pub id: String, pub prompt: String, + pub cron_expr: Option, pub interval_secs: u64, pub delivery_target: String, pub run_once: bool, @@ -121,6 +127,10 @@ impl Tool for CronTool { "type": "string", "description": "For 'create': the instruction to execute on each run." }, + "cron_expr": { + "type": "string", + "description": "For 'create': strict wall-clock schedule in cron format (e.g. '0 9 * * *' for daily at 09:00)." + }, "interval_secs": { "type": "integer", "description": "For 'create': seconds between runs (e.g. 3600 = hourly, 86400 = daily)." @@ -177,9 +187,13 @@ impl CronTool { let prompt = args .prompt .ok_or_else(|| CronError("'prompt' is required for create".into()))?; - let interval_secs = args - .interval_secs - .ok_or_else(|| CronError("'interval_secs' is required for create".into()))?; + let cron_expr = args + .cron_expr + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string); + let interval_secs = args.interval_secs.unwrap_or(3600); let delivery_target = args .delivery_target .as_deref() @@ -208,12 +222,17 @@ impl CronTool { } // Prevent excessively short intervals that could cause resource exhaustion - if interval_secs < MIN_CRON_INTERVAL_SECS { + if cron_expr.is_none() && interval_secs < MIN_CRON_INTERVAL_SECS { return Err(CronError(format!( "'interval_secs' must be at least {MIN_CRON_INTERVAL_SECS} (got {interval_secs})" ))); } + if let Some(expr) = cron_expr.as_deref() { + cron::Schedule::from_str(expr) + .map_err(|error| CronError(format!("invalid 'cron_expr' '{expr}': {error}")))?; + } + // Cap prompt length to prevent context flooding if prompt.len() > MAX_CRON_PROMPT_LENGTH { return Err(CronError(format!( @@ -244,6 +263,7 @@ impl CronTool { let config = CronConfig { id: id.clone(), prompt: prompt.clone(), + cron_expr: cron_expr.clone(), interval_secs, delivery_target: delivery_target.clone(), active_hours, @@ -264,12 +284,15 @@ impl CronTool { .await .map_err(|error| CronError(format!("failed to register: {error}")))?; - let interval_desc = format_interval(interval_secs); + let schedule_desc = cron_expr + .as_deref() + .map(|expr| format!("on schedule `{expr}`")) + .unwrap_or_else(|| format!("{}", format_interval(interval_secs))); let timezone = self.scheduler.cron_timezone_label(); let mut message = if run_once { - format!("Cron job '{id}' created. First run {interval_desc}; it then disables itself.") + format!("Cron job '{id}' created. First run {schedule_desc}; it then disables itself.") } else { - format!("Cron job '{id}' created. Runs {interval_desc}.") + format!("Cron job '{id}' created. Runs {schedule_desc}.") }; if let Some((start, end)) = active_hours { if timezone == "system" { @@ -306,6 +329,7 @@ impl CronTool { .map(|config| CronEntry { id: config.id, prompt: config.prompt, + cron_expr: config.cron_expr, interval_secs: config.interval_secs, delivery_target: config.delivery_target, run_once: config.run_once, From dc9988a29eee52f25c12d39aa6e978dcdc51f7f1 Mon Sep 17 00:00:00 2001 From: James Pine Date: Thu, 26 Feb 2026 17:07:10 -0800 Subject: [PATCH 2/4] fix(cron): satisfy clippy schedule description formatting --- src/tools/cron.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tools/cron.rs b/src/tools/cron.rs index c05696cba..956a7b98c 100644 --- a/src/tools/cron.rs +++ b/src/tools/cron.rs @@ -287,7 +287,7 @@ impl CronTool { let schedule_desc = cron_expr .as_deref() .map(|expr| format!("on schedule `{expr}`")) - .unwrap_or_else(|| format!("{}", format_interval(interval_secs))); + .unwrap_or_else(|| format_interval(interval_secs)); let timezone = self.scheduler.cron_timezone_label(); let mut message = if run_once { format!("Cron job '{id}' created. First run {schedule_desc}; it then disables itself.") From b7a06232970285eb347da6ed66010a4307d6e299 Mon Sep 17 00:00:00 2001 From: Jamie Pine <32987599+jamiepine@users.noreply.github.com> Date: Thu, 26 Feb 2026 19:04:14 -0800 Subject: [PATCH 3/4] Update src/cron/scheduler.rs Co-authored-by: tembo[bot] <208362400+tembo[bot]@users.noreply.github.com> --- src/cron/scheduler.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cron/scheduler.rs b/src/cron/scheduler.rs index 31ec31448..af15c74d6 100644 --- a/src/cron/scheduler.rs +++ b/src/cron/scheduler.rs @@ -678,7 +678,7 @@ fn next_fire_duration( .next()? .with_timezone(&chrono::Utc) }; - let delay_ms = (next_utc - now_utc).num_milliseconds().max(0) as u64; + let delay_ms = (next_utc - now_utc).num_milliseconds().max(1) as u64; Some((Duration::from_millis(delay_ms), next_utc, timezone_label)) } From 8e39cb3f5080d76a6bd28844d373bf5fa6b283d0 Mon Sep 17 00:00:00 2001 From: James Pine Date: Thu, 26 Feb 2026 19:10:42 -0800 Subject: [PATCH 4/4] fix(cron): enforce 5-field cron expressions --- src/api/cron.rs | 7 +++++++ src/cron/scheduler.rs | 7 +++++++ src/tools/cron.rs | 6 ++++++ 3 files changed, 20 insertions(+) diff --git a/src/api/cron.rs b/src/api/cron.rs index b445c70a7..a0b3c3309 100644 --- a/src/api/cron.rs +++ b/src/api/cron.rs @@ -216,6 +216,13 @@ 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, diff --git a/src/cron/scheduler.rs b/src/cron/scheduler.rs index af15c74d6..36493bcb6 100644 --- a/src/cron/scheduler.rs +++ b/src/cron/scheduler.rs @@ -604,6 +604,13 @@ fn normalize_cron_expr(cron_expr: Option) -> Result> { return Ok(None); } + let field_count = trimmed.split_whitespace().count(); + if field_count != 5 { + return Err(crate::error::Error::Other(anyhow::anyhow!( + "cron expression must have exactly 5 fields (got {field_count}): '{trimmed}'" + ))); + } + Schedule::from_str(trimmed).map_err(|error| { crate::error::Error::Other(anyhow::anyhow!( "invalid cron expression '{trimmed}': {error}" diff --git a/src/tools/cron.rs b/src/tools/cron.rs index 956a7b98c..84dc1e222 100644 --- a/src/tools/cron.rs +++ b/src/tools/cron.rs @@ -229,6 +229,12 @@ impl CronTool { } if let Some(expr) = cron_expr.as_deref() { + let field_count = expr.split_whitespace().count(); + if field_count != 5 { + return Err(CronError(format!( + "'cron_expr' must have exactly 5 fields (got {field_count}): '{expr}'" + ))); + } cron::Schedule::from_str(expr) .map_err(|error| CronError(format!("invalid 'cron_expr' '{expr}': {error}")))?; }