Skip to main content

oxios_kernel/
cron.rs

1//! Cron scheduler for time-based autonomous agent execution.
2//!
3//! Allows scheduling agents to run on cron-like schedules without user intervention.
4//! Supports 5-field (Linux cron) and 6-7 field expressions via the `cron` crate.
5
6use crate::config::CronConfig;
7use crate::git_layer::GitLayer;
8use crate::state_store::StateStore;
9use crate::types::Priority;
10use anyhow::{Result, bail};
11use chrono::{DateTime, Utc};
12use cron::Schedule;
13use parking_lot::{Mutex, RwLock};
14use serde::{Deserialize, Serialize};
15use std::collections::{HashMap, HashSet};
16use std::str::FromStr;
17use std::sync::Arc;
18use std::sync::atomic::{AtomicBool, Ordering};
19use uuid::Uuid;
20
21// ── Data types ─────────────────────────────────────────────
22
23/// Source of a cron job.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
25#[serde(rename_all = "lowercase")]
26pub enum JobSource {
27    /// Defined in config.toml.
28    Config,
29    /// Created via API.
30    #[default]
31    Api,
32}
33
34/// A cron job definition.
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct CronJob {
37    /// Unique job identifier.
38    pub id: Uuid,
39    /// Human-readable job name.
40    pub name: String,
41    /// Cron expression (e.g. "0 */6 * * *").
42    pub schedule: String,
43    /// Goal description for the agent.
44    pub goal: String,
45    /// Constraints on agent behavior.
46    #[serde(default)]
47    pub constraints: Vec<String>,
48    /// Criteria that must be met for success.
49    #[serde(default)]
50    pub acceptance_criteria: Vec<String>,
51    /// Toolchain preset name.
52    #[serde(default = "default_toolchain")]
53    pub toolchain: String,
54    /// Job priority.
55    #[serde(default)]
56    pub priority: Priority,
57    /// Whether the job is active.
58    #[serde(default = "default_true")]
59    pub enabled: bool,
60    /// Timestamp of the last execution.
61    #[serde(skip_serializing_if = "Option::is_none")]
62    pub last_run: Option<DateTime<Utc>>,
63    /// Timestamp of the next scheduled execution.
64    #[serde(skip_serializing_if = "Option::is_none")]
65    pub next_run: Option<DateTime<Utc>>,
66    /// Number of times this job has run.
67    #[serde(default)]
68    pub run_count: u64,
69    /// Summary of the last execution result.
70    #[serde(skip_serializing_if = "Option::is_none")]
71    pub last_result: Option<String>,
72    /// Whether the last execution succeeded.
73    #[serde(skip_serializing_if = "Option::is_none")]
74    pub last_success: Option<bool>,
75    /// How the job was created.
76    #[serde(default)]
77    pub source: JobSource,
78}
79
80fn default_toolchain() -> String {
81    "default".into()
82}
83
84fn default_true() -> bool {
85    true
86}
87
88impl CronJob {
89    /// Create a new cron job with a parsed schedule.
90    pub fn new(name: String, schedule: String, goal: String) -> Self {
91        Self {
92            id: Uuid::new_v4(),
93            name,
94            schedule,
95            goal,
96            constraints: vec![],
97            acceptance_criteria: vec![],
98            toolchain: default_toolchain(),
99            priority: Priority::default(),
100            enabled: true,
101            last_run: None,
102            next_run: None,
103            run_count: 0,
104            last_result: None,
105            last_success: None,
106            source: JobSource::Api,
107        }
108    }
109}
110
111/// Result of a single cron job execution.
112#[derive(Debug, Clone, Serialize, Deserialize)]
113pub struct CronJobResult {
114    /// ID of the executed job.
115    pub job_id: Uuid,
116    /// Name of the executed job.
117    pub job_name: String,
118    /// When execution started.
119    pub started_at: DateTime<Utc>,
120    /// When execution finished.
121    pub finished_at: DateTime<Utc>,
122    /// Whether the execution succeeded.
123    pub success: bool,
124    /// Human-readable result summary.
125    pub summary: String,
126}
127
128/// Update fields for an existing cron job.
129#[derive(Debug, Default, Deserialize)]
130pub struct CronJobUpdate {
131    /// New name.
132    pub name: Option<String>,
133    /// New cron expression.
134    pub schedule: Option<String>,
135    /// New goal description.
136    pub goal: Option<String>,
137    /// New constraints.
138    pub constraints: Option<Vec<String>>,
139    /// New acceptance criteria.
140    pub acceptance_criteria: Option<Vec<String>>,
141    /// New toolchain preset.
142    pub toolchain: Option<String>,
143    /// New priority.
144    pub priority: Option<Priority>,
145    /// Enable or disable.
146    pub enabled: Option<bool>,
147}
148
149// ── CronScheduler ───────────────────────────────────────────
150
151/// The cron scheduler for time-based autonomous agent execution.
152///
153/// Allows scheduling agents to run on cron-like schedules without user intervention.
154/// Supports 5-field (Linux cron) and 6-7 field expressions via the `cron` crate.
155pub struct CronScheduler {
156    jobs: Arc<RwLock<HashMap<Uuid, CronJob>>>,
157    schedules: Arc<Mutex<HashMap<Uuid, Schedule>>>,
158    running_jobs: Arc<Mutex<HashSet<Uuid>>>,
159    state_store: Arc<StateStore>,
160    cancel: Arc<AtomicBool>,
161    dirty: Arc<AtomicBool>,
162    tick_interval_secs: u64,
163    /// Optional git layer for version-controlled saves.
164    git_layer: Option<Arc<GitLayer>>,
165    /// Maximum concurrent cron job executions (default: 3).
166    max_concurrent_jobs: usize,
167    /// Timeout for individual cron job execution in seconds (default: 600 = 10 minutes).
168    job_timeout_secs: u64,
169}
170
171impl CronScheduler {
172    /// Create a new CronScheduler.
173    ///
174    /// # Arguments
175    /// * `state_store` - State store for persisting job definitions
176    /// * `tick_interval_secs` - How often to check for due jobs (in seconds)
177    pub fn new(state_store: Arc<StateStore>, tick_interval_secs: u64) -> Self {
178        Self {
179            jobs: Arc::new(RwLock::new(HashMap::new())),
180            schedules: Arc::new(Mutex::new(HashMap::new())),
181            running_jobs: Arc::new(Mutex::new(HashSet::new())),
182            state_store,
183            cancel: Arc::new(AtomicBool::new(false)),
184            dirty: Arc::new(AtomicBool::new(false)),
185            tick_interval_secs,
186            git_layer: None,
187            max_concurrent_jobs: 3,
188            job_timeout_secs: 600,
189        }
190    }
191
192    /// Set the maximum concurrent cron job executions.
193    ///
194    /// A value of `0` is rejected — it would silently disable every cron tick
195    /// (`current_running(0) >= max(0)` is always true). We clamp to `1` and
196    /// warn so the caller can correct their configuration instead of losing
197    /// all scheduled jobs without any signal.
198    pub fn set_max_concurrent_jobs(&mut self, max: usize) {
199        if max == 0 {
200            tracing::warn!("set_max_concurrent_jobs(0) would disable all cron jobs; clamping to 1");
201            self.max_concurrent_jobs = 1;
202        } else {
203            self.max_concurrent_jobs = max;
204        }
205    }
206
207    /// Set the timeout for individual cron job execution in seconds.
208    pub fn set_job_timeout_secs(&mut self, secs: u64) {
209        self.job_timeout_secs = secs;
210    }
211
212    /// Set the git layer for version-controlled saves.
213    pub fn set_git_layer(&mut self, gl: Arc<GitLayer>) {
214        self.git_layer = Some(gl);
215    }
216
217    /// Normalize a cron expression: prepend seconds field if 5-field (Linux style).
218    fn normalize_expr(expr: &str) -> String {
219        let fields: Vec<&str> = expr.split_whitespace().collect();
220        match fields.len() {
221            5 => format!("0 {expr}"),
222            _ => expr.to_string(),
223        }
224    }
225
226    /// Parse a cron expression into a `Schedule`.
227    fn parse_schedule(&self, expr: &str) -> Result<Schedule> {
228        let normalized = Self::normalize_expr(expr);
229        Schedule::from_str(&normalized)
230            .map_err(|e| anyhow::anyhow!("Invalid cron expression '{expr}': {e}"))
231    }
232
233    /// Compute the next fire time after `after`.
234    fn next_fire_time(&self, schedule: &Schedule, after: &DateTime<Utc>) -> Option<DateTime<Utc>> {
235        schedule.after(after).next()
236    }
237
238    /// Add a job. Parses schedule, computes next_run, stores.
239    pub async fn add_job(&self, job: CronJob) -> Result<Uuid> {
240        let schedule = self.parse_schedule(&job.schedule)?;
241        let next = self.next_fire_time(&schedule, &Utc::now());
242        let id = job.id;
243
244        self.schedules.lock().insert(id, schedule);
245        self.jobs.write().insert(
246            id,
247            CronJob {
248                next_run: next,
249                ..job
250            },
251        );
252        self.dirty.store(true, Ordering::Relaxed);
253        self.persist_jobs().await;
254
255        tracing::info!(
256            name = %self.jobs.read().get(&id).map(|j| j.name.as_str()).unwrap_or("?"),
257            %id,
258            "Cron job added"
259        );
260        Ok(id)
261    }
262
263    /// Remove a job by ID.
264    pub async fn remove_job(&self, id: Uuid) -> Result<()> {
265        self.schedules.lock().remove(&id);
266        self.jobs
267            .write()
268            .remove(&id)
269            .ok_or_else(|| anyhow::anyhow!("Job {id} not found"))?;
270        self.dirty.store(true, Ordering::Relaxed);
271        self.persist_jobs().await;
272        tracing::info!(%id, "Cron job removed");
273        Ok(())
274    }
275
276    /// Update a job's fields (enabled, schedule, goal, etc).
277    pub async fn update_job(&self, id: Uuid, update: CronJobUpdate) -> Result<()> {
278        // Separate the sync mutation from the async persist to avoid
279        // holding a !Send RwLockWriteGuard across an await point.
280        let should_persist = {
281            let mut jobs = self.jobs.write();
282            let job = jobs
283                .get_mut(&id)
284                .ok_or_else(|| anyhow::anyhow!("Job {id} not found"))?;
285
286            if let Some(name) = update.name {
287                job.name = name;
288            }
289            if let Some(schedule) = &update.schedule {
290                let parsed = self.parse_schedule(schedule)?;
291                self.schedules.lock().insert(id, parsed);
292                job.schedule = schedule.clone();
293                // Recompute next_run
294                let sched = self.schedules.lock().get(&id).cloned();
295                if let Some(s) = sched {
296                    job.next_run = self.next_fire_time(&s, &Utc::now());
297                }
298            }
299            if let Some(goal) = update.goal {
300                job.goal = goal;
301            }
302            if let Some(constraints) = update.constraints {
303                job.constraints = constraints;
304            }
305            if let Some(criteria) = update.acceptance_criteria {
306                job.acceptance_criteria = criteria;
307            }
308            if let Some(toolchain) = update.toolchain {
309                job.toolchain = toolchain;
310            }
311            if let Some(priority) = update.priority {
312                job.priority = priority;
313            }
314            if let Some(enabled) = update.enabled {
315                job.enabled = enabled;
316            }
317
318            self.dirty.store(true, Ordering::Relaxed);
319            true
320        }; // RwLockWriteGuard dropped here, before any .await
321
322        if should_persist {
323            self.persist_jobs().await;
324        }
325        Ok(())
326    }
327
328    /// Toggle a job enabled/disabled.
329    pub async fn toggle_job(&self, id: Uuid, enabled: bool) -> Result<()> {
330        self.update_job(
331            id,
332            CronJobUpdate {
333                enabled: Some(enabled),
334                ..Default::default()
335            },
336        )
337        .await
338    }
339
340    /// List all jobs.
341    pub fn list_jobs(&self) -> Vec<CronJob> {
342        self.jobs.read().values().cloned().collect()
343    }
344
345    /// Get a single job.
346    pub fn get_job(&self, id: Uuid) -> Option<CronJob> {
347        self.jobs.read().get(&id).cloned()
348    }
349
350    /// Check if a job is currently running.
351    pub fn is_running(&self, id: Uuid) -> bool {
352        self.running_jobs.lock().contains(&id)
353    }
354
355    /// Trigger a job immediately (manual execution, ignores schedule).
356    /// Returns the job goal as a string for the caller to execute.
357    /// The caller is responsible for calling `mark_job_completed` after execution.
358    pub fn trigger_job(&self, id: Uuid) -> Result<CronJob> {
359        let job = self
360            .jobs
361            .read()
362            .get(&id)
363            .cloned()
364            .ok_or_else(|| anyhow::anyhow!("Job {id} not found"))?;
365
366        if self.running_jobs.lock().contains(&id) {
367            bail!("Job '{}' is already running", job.name);
368        }
369
370        self.running_jobs.lock().insert(id);
371        Ok(job)
372    }
373
374    /// Mark a job execution as completed.
375    pub async fn mark_job_completed(&self, id: Uuid, success: bool, summary: String) {
376        self.running_jobs.lock().remove(&id);
377        let new_next_run = {
378            let mut jobs = self.jobs.write();
379            if let Some(job) = jobs.get_mut(&id) {
380                job.last_run = Some(Utc::now());
381                job.last_result = Some(summary);
382                job.last_success = Some(success);
383                job.run_count += 1;
384                // Recompute next_run
385                let sched = self.schedules.lock().get(&id).cloned();
386                sched.and_then(|s| self.next_fire_time(&s, &Utc::now()))
387            } else {
388                None
389            }
390        };
391        if let Some(next_run) = new_next_run {
392            let mut jobs = self.jobs.write();
393            if let Some(job) = jobs.get_mut(&id) {
394                job.next_run = Some(next_run);
395            }
396        }
397        self.dirty.store(true, Ordering::Relaxed);
398        self.persist_jobs().await;
399    }
400
401    /// Stop the scheduler loop.
402    pub fn stop(&self) {
403        self.cancel.store(true, Ordering::Relaxed);
404        tracing::info!("Cron scheduler stop requested");
405    }
406
407    /// Start the main loop. Must be called on an `Arc<Self>`.
408    ///
409    /// # Arguments
410    /// * `executor` - Async closure `(Uuid, String) -> Fut` where args are `(job_id, goal)`,
411    ///   returning `(success, summary)`.
412    ///
413    /// # Example
414    ///
415    /// ```no_run
416    /// use std::sync::Arc;
417    /// use oxios_kernel::state_store::StateStore;
418    /// use oxios_kernel::cron::CronScheduler;
419    ///
420    /// # async fn example() {
421    /// let state_store = Arc::new(StateStore::new("/tmp/state".into()).unwrap());
422    /// let scheduler = Arc::new(CronScheduler::new(state_store, 60));
423    /// scheduler.clone().start(|id, goal| async move {
424    ///     // execute the agent...
425    ///     (true, "Done".to_string())
426    /// }).await;
427    /// # }
428    /// ```
429    pub async fn start<F, Fut>(self: Arc<Self>, executor: F)
430    where
431        F: Fn(Uuid, String) -> Fut + Send + Sync + 'static,
432        Fut: std::future::Future<Output = (bool, String)> + Send + 'static,
433    {
434        let executor = Arc::new(executor);
435        let mut interval =
436            tokio::time::interval(std::time::Duration::from_secs(self.tick_interval_secs));
437
438        tracing::info!(
439            interval_secs = self.tick_interval_secs,
440            "Cron scheduler started"
441        );
442
443        loop {
444            tokio::select! {
445               _ = interval.tick() => {
446                   if self.cancel.load(Ordering::Relaxed) {
447                       tracing::info!("Cron scheduler stopped");
448                       return;
449                   }
450                   // `start` runs as `self: Arc<Self>`. Arc implements
451                   // Deref<Target = Self>, so this resolves to the
452                   // `&self` method without an explicit deref.
453                   self.tick_once(&executor).await;
454               }
455            }
456        }
457    }
458
459    /// Single tick: find due jobs and spawn execution.
460    ///
461    /// Enforces `max_concurrent_jobs` limit and `job_timeout_secs` per job.
462    ///
463    /// Public seam: callers (admin tooling, tests) can drive a single
464    /// deterministic tick without the wall-clock-driven `start` loop.
465    pub async fn tick_once<F, Fut>(&self, executor: &Arc<F>)
466    where
467        F: Fn(Uuid, String) -> Fut + Send + Sync + 'static,
468        Fut: std::future::Future<Output = (bool, String)> + Send + 'static,
469    {
470        let now = Utc::now();
471
472        // Check current concurrency before spawning more
473        let current_running = self.running_jobs.lock().len();
474        if current_running >= self.max_concurrent_jobs {
475            tracing::debug!(
476                running = current_running,
477                max = self.max_concurrent_jobs,
478                "Cron tick: max concurrent jobs reached, skipping"
479            );
480            return;
481        }
482
483        let due: Vec<(Uuid, String)> = {
484            let jobs = self.jobs.read();
485            jobs.iter()
486                .filter(|(_, job)| {
487                    job.enabled
488                        && job.next_run.is_some_and(|nr| nr <= now)
489                        && !self.running_jobs.lock().contains(&job.id)
490                })
491                .map(|(_, job)| (job.id, job.goal.clone()))
492                .collect()
493        };
494
495        let total_due = due.len();
496        for (spawned, (id, goal)) in due.into_iter().enumerate() {
497            // Check concurrency limit before each spawn
498            if self.running_jobs.lock().len() >= self.max_concurrent_jobs {
499                tracing::info!(
500                    spawned,
501                    remaining = total_due - spawned,
502                    "Cron tick: max concurrent jobs reached, deferring remaining"
503                );
504                break;
505            }
506
507            self.running_jobs.lock().insert(id);
508            let exec = executor.clone();
509            let me = self.clone();
510            let timeout_secs = self.job_timeout_secs;
511            tokio::spawn(async move {
512                tracing::info!(%id, "Cron job triggered");
513                let result = tokio::time::timeout(
514                    std::time::Duration::from_secs(timeout_secs),
515                    exec(id, goal),
516                )
517                .await;
518
519                let (success, summary) = match result {
520                    Ok((s, m)) => (s, m),
521                    Err(_) => {
522                        tracing::error!(%id, timeout_secs, "Cron job timed out");
523                        (false, format!("Timed out after {timeout_secs} seconds"))
524                    }
525                };
526                tracing::info!(%id, success, "Cron job completed");
527                me.mark_job_completed(id, success, summary).await;
528            });
529        }
530    }
531
532    /// Persist all jobs to disk. Public seam for tests and the kernel handle
533    /// to call directly (e.g. after a config import).
534    pub(crate) async fn persist_jobs(&self) {
535        let job_list: Vec<CronJob> = {
536            let jobs = self.jobs.read();
537            jobs.values().cloned().collect()
538        };
539        if let Err(e) = self.state_store.save_json("cron", "jobs", &job_list).await {
540            tracing::error!("Failed to persist cron jobs: {}", e);
541        }
542        // Fire-and-forget git commit if git layer is configured
543        if let Some(ref gl) = self.git_layer
544            && gl.is_enabled()
545        {
546            let _ = gl.commit_file("cron/jobs.json", "cron: update jobs");
547        }
548    }
549
550    /// Restore jobs from disk on startup.
551    pub async fn restore_jobs(&self) {
552        match self
553            .state_store
554            .load_json::<Vec<CronJob>>("cron", "jobs")
555            .await
556        {
557            Ok(Some(job_list)) => {
558                for mut job in job_list {
559                    // Re-parse schedule and recompute next_run
560                    match self.parse_schedule(&job.schedule) {
561                        Ok(schedule) => {
562                            job.next_run = self.next_fire_time(&schedule, &Utc::now());
563                            self.schedules.lock().insert(job.id, schedule);
564                            self.jobs.write().insert(job.id, job);
565                        }
566                        Err(e) => {
567                            tracing::error!(job = %job.name, error = %e, "Skipping job with invalid schedule");
568                        }
569                    }
570                }
571                tracing::info!(count = self.jobs.read().len(), "Cron jobs restored");
572            }
573            Ok(None) => {
574                tracing::info!("No saved cron jobs found");
575            }
576            Err(e) => {
577                tracing::error!("Failed to restore cron jobs: {}", e);
578            }
579        }
580    }
581
582    /// Load jobs defined in config (called during startup).
583    /// Config-defined jobs are only added if they don't already exist (API wins on conflict).
584    pub async fn load_from_config(&self, config: &CronConfig) {
585        if !config.enabled {
586            tracing::info!("Cron scheduler is disabled in config");
587            return;
588        }
589
590        for (name, inline) in &config.jobs {
591            let schedule = inline.schedule.clone();
592            let goal = inline.goal.clone();
593
594            let job = CronJob {
595                id: Uuid::new_v4(),
596                name: name.clone(),
597                schedule: schedule.clone(),
598                goal,
599                constraints: inline.constraints.clone(),
600                acceptance_criteria: inline.acceptance_criteria.clone(),
601                toolchain: inline.toolchain.clone(),
602                priority: inline.priority,
603                enabled: inline.enabled,
604                last_run: None,
605                next_run: None,
606                run_count: 0,
607                last_result: None,
608                last_success: None,
609                source: JobSource::Config,
610            };
611
612            // Check if a job with this name already exists (from API)
613            {
614                let jobs = self.jobs.read();
615                if jobs.values().any(|j| j.name == *name) {
616                    tracing::debug!(name = %name, "Skipping config job — already exists via API");
617                    continue;
618                }
619            }
620
621            if let Err(e) = self.add_job(job).await {
622                tracing::error!(name = %name, error = %e, "Failed to load config job");
623            } else {
624                tracing::info!(name = %name, "Loaded cron job from config");
625            }
626        }
627    }
628}
629
630impl Clone for CronScheduler {
631    fn clone(&self) -> Self {
632        Self {
633            jobs: self.jobs.clone(),
634            schedules: self.schedules.clone(),
635            running_jobs: self.running_jobs.clone(),
636            state_store: self.state_store.clone(),
637            cancel: self.cancel.clone(),
638            dirty: self.dirty.clone(),
639            tick_interval_secs: self.tick_interval_secs,
640            git_layer: self.git_layer.clone(),
641            max_concurrent_jobs: self.max_concurrent_jobs,
642            job_timeout_secs: self.job_timeout_secs,
643        }
644    }
645}
646
647#[cfg(test)]
648mod tests {
649    use super::*;
650    use chrono::Timelike;
651
652    fn test_store() -> Arc<StateStore> {
653        let temp_dir = tempfile::tempdir().unwrap();
654        Arc::new(StateStore::new(temp_dir.path().to_path_buf()).unwrap())
655    }
656
657    #[test]
658    fn test_normalize_5field() {
659        assert_eq!(CronScheduler::normalize_expr("0 9 * * *"), "0 0 9 * * *");
660    }
661
662    #[test]
663    fn test_normalize_6field() {
664        assert_eq!(CronScheduler::normalize_expr("0 0 9 * * *"), "0 0 9 * * *");
665    }
666
667    #[test]
668    fn test_normalize_7field() {
669        assert_eq!(
670            CronScheduler::normalize_expr("0 0 9 * * * 2026"),
671            "0 0 9 * * * 2026"
672        );
673    }
674
675    #[test]
676    fn test_parse_valid() {
677        let cs = CronScheduler::new(test_store(), 60);
678        assert!(cs.parse_schedule("0 9 * * *").is_ok());
679    }
680
681    #[test]
682    fn test_parse_invalid() {
683        let cs = CronScheduler::new(test_store(), 60);
684        assert!(cs.parse_schedule("invalid").is_err());
685    }
686
687    #[test]
688    fn test_next_fire_time_daily() {
689        let cs = CronScheduler::new(test_store(), 60);
690        let schedule = cs.parse_schedule("0 9 * * *").unwrap();
691        let now = chrono::NaiveDate::from_ymd_opt(2026, 5, 6)
692            .unwrap()
693            .and_hms_opt(8, 0, 0)
694            .unwrap();
695        let now_utc = DateTime::<Utc>::from_naive_utc_and_offset(now, Utc);
696        let next = cs.next_fire_time(&schedule, &now_utc);
697        assert!(next.is_some());
698        let next = next.unwrap();
699        assert_eq!(next.hour(), 9);
700    }
701
702    #[test]
703    fn test_next_fire_time_every_15min() {
704        let cs = CronScheduler::new(test_store(), 60);
705        let schedule = cs.parse_schedule("*/15 * * * *").unwrap();
706        let now = chrono::NaiveDate::from_ymd_opt(2026, 5, 6)
707            .unwrap()
708            .and_hms_opt(10, 7, 0)
709            .unwrap();
710        let now_utc = DateTime::<Utc>::from_naive_utc_and_offset(now, Utc);
711        let next = cs.next_fire_time(&schedule, &now_utc);
712        assert!(next.is_some());
713        let next = next.unwrap();
714        assert_eq!(next.minute(), 15);
715    }
716
717    #[test]
718    fn test_add_job_computes_next_run() {
719        let job = CronJob::new("test".into(), "0 9 * * *".into(), "Test goal".into());
720        assert!(job.next_run.is_none()); // not computed yet
721        assert!(job.enabled);
722        assert_eq!(job.run_count, 0);
723    }
724
725    #[test]
726    fn test_job_source_default() {
727        let job = CronJob::new("test".into(), "0 9 * * *".into(), "goal".into());
728        assert_eq!(job.source, JobSource::Api);
729    }
730
731    #[tokio::test]
732    async fn test_add_job() {
733        let store = test_store();
734        let cs = CronScheduler::new(store, 60);
735        let job = CronJob::new("test-job".into(), "0 9 * * *".into(), "Run me".into());
736        let id = cs.add_job(job).await.unwrap();
737        assert!(cs.get_job(id).is_some());
738        assert_eq!(cs.list_jobs().len(), 1);
739    }
740
741    #[tokio::test]
742    async fn test_remove_job() {
743        let store = test_store();
744        let cs = CronScheduler::new(store, 60);
745        let job = CronJob::new("remove-me".into(), "0 10 * * *".into(), "Gone".into());
746        let id = cs.add_job(job).await.unwrap();
747        cs.remove_job(id).await.unwrap();
748        assert!(cs.get_job(id).is_none());
749    }
750
751    #[tokio::test]
752    async fn test_trigger_job() {
753        let store = test_store();
754        let cs = CronScheduler::new(store, 60);
755        let job = CronJob::new("trigger-me".into(), "0 11 * * *".into(), "Goal text".into());
756        let id = cs.add_job(job).await.unwrap();
757
758        let triggered = cs.trigger_job(id).unwrap();
759        assert_eq!(triggered.goal, "Goal text");
760        assert!(cs.is_running(id));
761
762        cs.mark_job_completed(id, true, "ok".into()).await;
763        assert!(!cs.is_running(id));
764    }
765
766    #[tokio::test]
767    async fn test_trigger_already_running() {
768        let store = test_store();
769        let cs = CronScheduler::new(store, 60);
770        let job = CronJob::new("running".into(), "0 12 * * *".into(), "goal".into());
771        let id = cs.add_job(job).await.unwrap();
772        cs.trigger_job(id).unwrap();
773        let result = cs.trigger_job(id);
774        assert!(result.is_err());
775    }
776
777    #[tokio::test]
778    async fn test_update_job() {
779        let store = test_store();
780        let cs = CronScheduler::new(store, 60);
781        let job = CronJob::new("old-name".into(), "0 9 * * *".into(), "old goal".into());
782        let id = cs.add_job(job).await.unwrap();
783
784        cs.update_job(
785            id,
786            CronJobUpdate {
787                name: Some("new-name".into()),
788                goal: Some("new goal".into()),
789                enabled: Some(false),
790                ..Default::default()
791            },
792        )
793        .await
794        .unwrap();
795
796        let updated = cs.get_job(id).unwrap();
797        assert_eq!(updated.name, "new-name");
798        assert_eq!(updated.goal, "new goal");
799        assert!(!updated.enabled);
800    }
801
802    #[tokio::test]
803    async fn test_toggle_job() {
804        let store = test_store();
805        let cs = CronScheduler::new(store, 60);
806        let job = CronJob::new("toggle".into(), "0 9 * * *".into(), "goal".into());
807        let id = cs.add_job(job).await.unwrap();
808        assert!(cs.get_job(id).unwrap().enabled);
809
810        cs.toggle_job(id, false).await.unwrap();
811        assert!(!cs.get_job(id).unwrap().enabled);
812
813        cs.toggle_job(id, true).await.unwrap();
814        assert!(cs.get_job(id).unwrap().enabled);
815    }
816
817    #[tokio::test]
818    async fn test_mark_completed_updates_next_run() {
819        let store = test_store();
820        let cs = CronScheduler::new(store, 60);
821        let job = CronJob::new("comp".into(), "*/5 * * * *".into(), "goal".into());
822        let id = cs.add_job(job).await.unwrap();
823
824        let before = cs.get_job(id).unwrap().next_run;
825        assert!(before.is_some());
826
827        // Simulate time passing: set next_run to 5 minutes in the past
828        let now = Utc::now();
829        {
830            let mut jobs = cs.jobs.write();
831            if let Some(j) = jobs.get_mut(&id) {
832                j.next_run = Some(now - chrono::Duration::minutes(5));
833            }
834        }
835
836        cs.mark_job_completed(id, true, "ok".into()).await;
837        let after = cs.get_job(id).unwrap().next_run;
838        assert!(after.is_some());
839        // After completion, next_run should be set to a future time (>= now)
840        assert!(after.unwrap() >= now);
841    }
842
843    #[test]
844    fn test_max_concurrent_enforced() {
845        let temp_dir = tempfile::tempdir().unwrap();
846        let store = Arc::new(StateStore::new(temp_dir.path().to_path_buf()).unwrap());
847        let mut scheduler = CronScheduler::new(store, 60);
848        scheduler.set_max_concurrent_jobs(2);
849        assert_eq!(scheduler.max_concurrent_jobs, 2);
850    }
851
852    #[test]
853    fn test_job_timeout_configurable() {
854        let temp_dir = tempfile::tempdir().unwrap();
855        let store = Arc::new(StateStore::new(temp_dir.path().to_path_buf()).unwrap());
856        let mut scheduler = CronScheduler::new(store, 60);
857        scheduler.set_job_timeout_secs(300);
858        assert_eq!(scheduler.job_timeout_secs, 300);
859    }
860
861    // ── start-loop execution (tick_once public seam) ──────────────
862
863    /// Build a job whose `next_run` is in the past so the next tick fires.
864    async fn add_due_job(cs: &CronScheduler, name: &str, goal: &str) -> Uuid {
865        let id = cs
866            .add_job(CronJob::new(name.into(), "*/5 * * * *".into(), goal.into()))
867            .await
868            .unwrap();
869        // Force next_run into the past so the next tick picks it up
870        // immediately regardless of when add_job ran.
871        {
872            let mut jobs = cs.jobs.write();
873            if let Some(j) = jobs.get_mut(&id) {
874                j.next_run = Some(Utc::now() - chrono::Duration::seconds(60));
875            }
876        }
877        id
878    }
879
880    #[tokio::test]
881    async fn tick_once_runs_due_jobs_and_marks_completion() {
882        let cs = Arc::new(CronScheduler::new(test_store(), 60));
883        let id = add_due_job(&cs, "due", "do the thing").await;
884
885        let called = Arc::new(Mutex::new(Vec::<(Uuid, String)>::new()));
886        let called_for_tick = called.clone();
887        let exec = Arc::new(move |jid: Uuid, goal: String| {
888            let called = called_for_tick.clone();
889            async move {
890                called.lock().push((jid, goal));
891                (true, "ok".into())
892            }
893        });
894
895        cs.tick_once(&exec).await;
896
897        // Allow the spawned completion task to run.
898        for _ in 0..50 {
899            if cs.get_job(id).unwrap().run_count >= 1 {
900                break;
901            }
902            tokio::task::yield_now().await;
903        }
904
905        let log = called.lock().clone();
906        assert_eq!(log, vec![(id, "do the thing".into())]);
907        let job = cs.get_job(id).unwrap();
908        assert_eq!(job.run_count, 1, "completion must increment run_count");
909        assert_eq!(job.last_success, Some(true));
910        assert_eq!(job.last_result.as_deref(), Some("ok"));
911        assert!(job.last_run.is_some(), "completion must set last_run");
912        // next_run must be advanced to a future time.
913        assert!(job.next_run.unwrap() > Utc::now() - chrono::Duration::seconds(1));
914        assert!(!cs.is_running(id), "completion must clear running flag");
915    }
916
917    #[tokio::test]
918    async fn tick_once_skips_disabled_jobs() {
919        let cs = Arc::new(CronScheduler::new(test_store(), 60));
920        let id = add_due_job(&cs, "off", "ignored").await;
921        cs.toggle_job(id, false).await.unwrap();
922
923        let called = Arc::new(Mutex::new(0u32));
924        let called_for_tick = called.clone();
925        let exec = Arc::new(move |_jid: Uuid, _goal: String| {
926            let called = called_for_tick.clone();
927            async move {
928                *called.lock() += 1;
929                (true, String::new())
930            }
931        });
932
933        cs.tick_once(&exec).await;
934        tokio::task::yield_now().await;
935
936        assert_eq!(*called.lock(), 0);
937        assert_eq!(cs.get_job(id).unwrap().run_count, 0);
938    }
939
940    #[tokio::test]
941    async fn tick_once_prevents_overlap_with_already_running_job() {
942        // If a previous tick spawned a job whose spawned task is still
943        // in flight (still in `running_jobs`), the next tick must NOT
944        // re-trigger it — otherwise a slow agent would run multiple
945        // copies of the same job.
946        let cs = Arc::new(CronScheduler::new(test_store(), 60));
947        let id = add_due_job(&cs, "long", "slow").await;
948        // Manually mark the job as running, simulating an in-flight tick
949        // whose spawn has not yet finished.
950        cs.running_jobs.lock().insert(id);
951
952        let called = Arc::new(Mutex::new(0u32));
953        let called_for_tick = called.clone();
954        let exec = Arc::new(move |_jid: Uuid, _goal: String| {
955            let called = called_for_tick.clone();
956            async move {
957                *called.lock() += 1;
958                (true, String::new())
959            }
960        });
961
962        cs.tick_once(&exec).await;
963        tokio::task::yield_now().await;
964
965        assert_eq!(
966            *called.lock(),
967            0,
968            "overlap-prevention must skip a job already in running_jobs"
969        );
970        // Clean up so the scheduler's Drop doesn't observe a stale entry.
971        cs.running_jobs.lock().remove(&id);
972    }
973
974    #[tokio::test]
975    async fn tick_once_respects_max_concurrent_jobs() {
976        let mut cs = CronScheduler::new(test_store(), 60);
977        cs.set_max_concurrent_jobs(2);
978        let cs = Arc::new(cs);
979
980        // Three due jobs, capacity=2 → one is deferred on this tick.
981        add_due_job(&cs, "a", "a").await;
982        add_due_job(&cs, "b", "b").await;
983        add_due_job(&cs, "c", "c").await;
984
985        let called = Arc::new(Mutex::new(0u32));
986        let called_for_tick = called.clone();
987        let exec = Arc::new(move |_jid: Uuid, _goal: String| {
988            let called = called_for_tick.clone();
989            async move {
990                *called.lock() += 1;
991                (true, String::new())
992            }
993        });
994
995        cs.tick_once(&exec).await;
996        // Wait for all spawned ticks to finish. Each increments `called`
997        // synchronously before the executor returns, so this is bounded.
998        for _ in 0..200 {
999            if *called.lock() >= 2 {
1000                break;
1001            }
1002            tokio::task::yield_now().await;
1003        }
1004        assert_eq!(
1005            *called.lock(),
1006            2,
1007            "tick_once must enforce max_concurrent_jobs"
1008        );
1009    }
1010}