Skip to main content

agentmux_srv/backend/cron/
mod.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Persistent cron scheduler for AgentMux.
5//!
6//! Backs each enabled `CronJob` with a tokio task that sleeps until the
7//! next scheduled fire time (computed from the 5-field UTC cron expression),
8//! delivers the prompt as `/agentmux/reactive/inject` would — in process
9//! since identity M4c-3 (see [`FireDelivery`]) — and records the fire in the
10//! DB.
11//!
12//! On startup, runs one catch-up fire for any job whose next scheduled time
13//! after `last_fired` is already in the past (FIRE_ONCE_NOW misfire policy —
14//! never replay all missed fires, never cause a cron storm).
15//!
16//! See `docs/specs/SPEC_CRON_LOOP_ROBUSTNESS_2026_06_25.md §3.2`.
17
18use agentmux_common::AUTH_KEY_HEADER;
19use std::collections::HashMap;
20use std::str::FromStr;
21use std::sync::{Arc, Mutex};
22
23use agentmux_common::api_types::InjectRequest;
24use chrono::{DateTime, Utc};
25use cron::Schedule;
26
27use crate::backend::storage::store::Store;
28use crate::backend::storage::cron::CronJob;
29use crate::backend::mps::Broker;
30use crate::backend::reactive::types::InjectionRequest;
31
32/// Abort handles for every currently scheduled cron task.
33type HandleMap = Mutex<HashMap<String, tokio::task::AbortHandle>>;
34
35/// In-process delivery of a fire: the server's shared inject path
36/// (`server::reactive::deliver`, on the instance-key tier), given the job's
37/// request and its creator's UID. Identity M4c-3
38/// (SPEC_AGENT_IDENTITY_CARRIED_NOT_DERIVED_2026_09_23.md §6.5.9): the
39/// scheduler is built before `AppState`, so the server installs this once
40/// `AppState` exists ([`CronScheduler::install_delivery`]), as it installs
41/// agent-turn delivery. Until then a fire POSTs to the route, as before.
42pub type FireDelivery = Arc<
43    dyn Fn(
44            InjectionRequest,
45            String,
46        ) -> std::pin::Pin<Box<dyn std::future::Future<Output = serde_json::Value> + Send>>
47        + Send
48        + Sync,
49>;
50
51pub struct CronScheduler {
52    handles: HandleMap,
53    shared_store: Option<Arc<Store>>,
54    http_client: reqwest::Client,
55    local_url: String,
56    auth_key: String,
57    broker: Arc<Broker>,
58    delivery: std::sync::OnceLock<FireDelivery>,
59}
60
61impl CronScheduler {
62    pub fn new(
63        shared_store: Option<Arc<Store>>,
64        http_client: reqwest::Client,
65        local_url: String,
66        auth_key: String,
67        broker: Arc<Broker>,
68    ) -> Arc<Self> {
69        Arc::new(Self {
70            handles: Mutex::new(HashMap::new()),
71            shared_store,
72            http_client,
73            local_url,
74            auth_key,
75            broker,
76            delivery: std::sync::OnceLock::new(),
77        })
78    }
79
80    /// Deliver fires in process from now on (see [`FireDelivery`]). Once;
81    /// a second install is ignored.
82    pub fn install_delivery(&self, delivery: FireDelivery) {
83        let _ = self.delivery.set(delivery);
84    }
85
86    fn publish_changed(&self) {
87        self.broker.publish_event::<crate::backend::mps_events::CronChanged>(&(), vec![], 0);
88    }
89
90    /// Load all enabled jobs from the DB and schedule them. Call once at startup.
91    pub async fn start(self: &Arc<Self>) {
92        let store = match &self.shared_store {
93            Some(s) => s.clone(),
94            None => {
95                tracing::warn!("cron: no shared store — cron scheduler disabled");
96                return;
97            }
98        };
99
100        let jobs = match store.cron_list_enabled() {
101            Ok(j) => j,
102            Err(e) => {
103                tracing::error!(error = %e, "cron: failed to load jobs on startup");
104                return;
105            }
106        };
107
108        let now_dt = Utc::now();
109        let count = jobs.len();
110
111        for job in jobs {
112            // FIRE_ONCE_NOW: if the job missed its window, fire once immediately
113            // as catch-up. did_catchup is passed to schedule_job so the live
114            // task's fires counter is seeded at fire_count+1 and the catch-up
115            // fire counts toward max_fires.
116            let did_catchup = should_catchup(&job, now_dt);
117            if did_catchup {
118                let sched = self.clone();
119                let job_id = job.id.clone();
120                let job_prompt = job.prompt.clone();
121                let job_target = job.target.clone();
122                let job_target_uid = job.target_uid.clone();
123                let job_created_by_uid = job.created_by_uid.clone();
124                tokio::spawn(async move {
125                    sched
126                        .fire(&job_id, &job_prompt, &job_target, &job_target_uid, &job_created_by_uid)
127                        .await;
128                });
129            }
130
131            let initial_fires = job.fire_count + if did_catchup { 1 } else { 0 };
132            self.schedule_job_with_fires(&job, initial_fires);
133        }
134
135        tracing::info!(count, "cron: scheduled {} job(s) from DB", count);
136    }
137
138    /// Schedule a single job (or reschedule it after a DB change). Replaces
139    /// any existing task for the same `job.id`. Call sites that don't need to
140    /// account for a simultaneous catch-up fire should pass `job.fire_count`.
141    pub fn schedule_job(self: &Arc<Self>, job: &CronJob) {
142        self.schedule_job_with_fires(job, job.fire_count);
143    }
144
145    fn schedule_job_with_fires(self: &Arc<Self>, job: &CronJob, initial_fires: i64) {
146        self.cancel_job(&job.id);
147
148        let schedule = match Schedule::from_str(&format!("0 {}", job.expression)) {
149            Ok(s) => s,
150            Err(e) => {
151                tracing::warn!(id = %job.id, expr = %job.expression, error = %e, "cron: invalid expression — skipping job");
152                return;
153            }
154        };
155
156        let sched = self.clone();
157        let job_id = job.id.clone();
158        let job_prompt = job.prompt.clone();
159        let job_target = job.target.clone();
160        let job_target_uid = job.target_uid.clone();
161        let job_created_by_uid = job.created_by_uid.clone();
162        let job_max_fires = job.max_fires;
163        let job_created_at = job.created_at;
164        let job_max_age_secs = job.max_age_secs;
165
166        let handle = tokio::spawn(async move {
167            // `initial_fires` is seeded from the persisted fire_count (plus 1
168            // if a catch-up fire was also dispatched at startup) so max_fires
169            // is enforced across restarts, not per process run.
170            let mut fires: i64 = initial_fires;
171            loop {
172                // Guard at top of loop: if fires was seeded at or above max_fires
173                // (e.g. a catch-up fire brought the persisted count to the cap),
174                // don't fire again before sleeping — this prevents one extra fire
175                // on restart when the catch-up itself hits the limit. Age is
176                // checked the same way (Phase 0, SPEC_AGENT_POLLING_AND_WAKEUP_
177                // HARDENING_2026_08_04.md) — a job created before max_age_secs
178                // existed has max_age_secs = None and is never expired by this.
179                if let Some(max) = job_max_fires {
180                    if fires >= max {
181                        sched.disable_job(&job_id, "max_fires reached");
182                        break;
183                    }
184                }
185                if is_expired_by_age(job_created_at, job_max_age_secs, Utc::now().timestamp()) {
186                    sched.disable_job(&job_id, "max_age_secs reached");
187                    break;
188                }
189                let next = match schedule.upcoming(Utc).next() {
190                    Some(t) => t,
191                    None => break,
192                };
193                // Reviewer-caught gap (P1, PR #2418): checking age only in "now"
194                // terms at the top of the loop lets one extra fire slip through
195                // when the cron interval is longer than the remaining time to
196                // expiry — the loop would sleep straight past the expiry bound
197                // and fire anyway, only catching it on the *next* iteration.
198                // Check whether the fire we're about to sleep for would itself
199                // land at/after expiry, and skip it (disable now, don't sleep)
200                // if so — the "hard expiry bound, regardless of fire count"
201                // guarantee has to hold at fire time, not just at loop-top time.
202                if is_expired_by_age(job_created_at, job_max_age_secs, next.timestamp()) {
203                    sched.disable_job(&job_id, "max_age_secs would be reached before the next scheduled fire");
204                    break;
205                }
206                let delay = (next - Utc::now()).to_std().unwrap_or_default();
207                tokio::time::sleep(delay).await;
208                sched
209                    .fire(&job_id, &job_prompt, &job_target, &job_target_uid, &job_created_by_uid)
210                    .await;
211                fires += 1;
212                // Post-fire check: enforce max_fires. The top-of-loop guard
213                // handles the restart/seeded-at-cap case; this handles the
214                // normal live-run case.
215                if let Some(max) = job_max_fires {
216                    if fires >= max {
217                        sched.disable_job(&job_id, "max_fires reached");
218                        break;
219                    }
220                }
221            }
222        });
223
224        self.handles.lock().unwrap().insert(job.id.clone(), handle.abort_handle());
225    }
226
227    /// Stop (abort) a job's scheduled task without removing it from the DB.
228    pub fn cancel_job(&self, id: &str) {
229        if let Some(handle) = self.handles.lock().unwrap().remove(id) {
230            handle.abort();
231        }
232    }
233
234    /// Disable a job in the DB (audit trail preserved, unlike delete), drop
235    /// its live task handle, and notify listeners. Shared by every
236    /// self-disabling reason a scheduled job's own loop hits (max_fires,
237    /// max_age_secs) — extracted so those call sites don't each repeat the
238    /// same three-step disable sequence.
239    fn disable_job(&self, job_id: &str, reason: &str) {
240        tracing::info!(id = job_id, reason, "cron: disabling job");
241        if let Some(store) = &self.shared_store {
242            let _ = store.cron_set_enabled(job_id, false);
243        }
244        self.handles.lock().unwrap().remove(job_id);
245        self.publish_changed();
246    }
247
248    /// Fire a cron job: deliver the prompt and record the fire in DB.
249    ///
250    /// Identity M3 (spec §5.4): a job that captured its target's UID at
251    /// creation fires BY UID — a job firing at 03:00 must never resolve a
252    /// name. A job with no UID (created before M3, or by a caller that did
253    /// not carry one) fires by name as before, counted.
254    ///
255    /// Identity M4c-3 (§6.5.9): delivered in process ([`FireDelivery`]), with
256    /// the creator's UID as attribution on this instance only — a forwarded
257    /// hop never carries it. **The sender name stays `"cron"`**: showing the
258    /// creator's name unsigned would make every fire of an agent-created job
259    /// look forged (its key is on file under that name) and echo each fire
260    /// into the creator's pane. A job with no captured creator UID fires as
261    /// before, counted.
262    pub(crate) async fn fire(&self, id: &str, prompt: &str, target: &str, target_uid: &str, created_by_uid: &str) {
263        if created_by_uid.is_empty() {
264            crate::backend::agent_resolve::record_uid_fallback("m4c.cron_fire_unattributed");
265        }
266        let target_agent = fire_target(target, target_uid).to_string();
267        let body = match self.delivery.get() {
268            Some(deliver) => {
269                let req = InjectionRequest {
270                    target_agent,
271                    message: prompt.to_string(),
272                    source_agent: Some("cron".to_string()),
273                    ..Default::default()
274                };
275                Some(deliver(req, created_by_uid.to_string()).await)
276            }
277            None => {
278                if self.local_url.is_empty() || self.auth_key.is_empty() {
279                    tracing::warn!(id, "cron: no local_url/auth_key — skipping fire");
280                    return;
281                }
282                crate::backend::agent_resolve::record_uid_fallback("m4c.cron_fire_http");
283                self.post_fire(id, target_agent, prompt).await
284            }
285        };
286        if let Some(body) = body {
287            // Inject answers `success: false` when the target is not
288            // reachable — since M3 that includes a UID nobody is registered
289            // under. A fire that reached no one is a failure, and a firing
290            // job has nobody to tell but the log (§5.4).
291            if body.get("success").and_then(|v| v.as_bool()) == Some(false) {
292                tracing::warn!(
293                    id,
294                    target,
295                    target_uid,
296                    error = body.get("error").and_then(|v| v.as_str()).unwrap_or(""),
297                    "cron: fire was not delivered"
298                );
299            } else {
300                tracing::debug!(id, target, "cron: fired");
301            }
302        }
303
304        if let Some(store) = &self.shared_store {
305            let now = Utc::now().timestamp();
306            if let Err(e) = store.cron_record_fire(id, now) {
307                tracing::warn!(id, error = %e, "cron: failed to record fire in DB");
308            }
309        }
310        self.publish_changed();
311    }
312
313    /// The pre-M4c-3 fire: POST to the local inject route with the instance
314    /// key. Only until [`Self::install_delivery`] runs. `None` when the POST
315    /// itself failed (logged here).
316    async fn post_fire(&self, id: &str, target_agent: String, prompt: &str) -> Option<serde_json::Value> {
317        let url = format!("{}/agentmux/reactive/inject", self.local_url.trim_end_matches('/'));
318        let req = InjectRequest {
319            target_agent,
320            message: prompt.to_string(),
321            source_agent: Some("cron".to_string()),
322            ..Default::default()
323        };
324        match self.http_client.post(&url).header(AUTH_KEY_HEADER, &self.auth_key).json(&req).send().await {
325            Ok(r) if r.status().is_success() => Some(r.json().await.unwrap_or_default()),
326            Ok(r) => {
327                tracing::warn!(id, status = %r.status(), "cron: inject returned non-2xx");
328                None
329            }
330            Err(e) => {
331                tracing::warn!(id, error = %e, "cron: inject request failed");
332                None
333            }
334        }
335    }
336}
337
338/// Determine if a job needs a catch-up fire on startup.
339///
340/// True when there is a scheduled fire time that falls strictly between
341/// `last_fired` and `now` — i.e., the first occurrence of the cron expression
342/// after `last_fired` is already in the past. This is correct regardless of
343/// schedule granularity: a daily job that ran at 09:00 and is restarted at
344/// 09:05 yields a next-after-last of TOMORROW 09:00, which is NOT < now, so
345/// no spurious catch-up fires.
346///
347/// Jobs that have never fired are skipped (they'll fire at the next naturally
348/// scheduled time without any missed-window concept).
349fn should_catchup(job: &CronJob, now_dt: DateTime<Utc>) -> bool {
350    let Some(last) = job.last_fired else { return false; };
351    let last_dt = match DateTime::from_timestamp(last, 0) {
352        Some(dt) => dt,
353        None => return false,
354    };
355    let Ok(schedule) = Schedule::from_str(&format!("0 {}", job.expression)) else {
356        return false;
357    };
358    // First scheduled time after last_fired — if it's already past, a fire was missed.
359    match schedule.after(&last_dt).next() {
360        Some(next_after_last) => next_after_last < now_dt,
361        None => false,
362    }
363}
364
365/// True when a job's hard age-expiry bound (`max_age_secs`, seconds since
366/// `created_at`) has been reached. `None` = no bound, never expires by age
367/// (the default for every job created before this field existed, and for
368/// any job that explicitly opts out). See
369/// docs/specs/SPEC_AGENT_POLLING_AND_WAKEUP_HARDENING_2026_08_04.md Phase 0.
370fn is_expired_by_age(created_at: i64, max_age_secs: Option<i64>, now: i64) -> bool {
371    match max_age_secs {
372        Some(max_age) => now - created_at >= max_age,
373        None => false,
374    }
375}
376
377#[cfg(test)]
378mod tests {
379    use super::*;
380
381    #[test]
382    fn is_expired_by_age_none_never_expires() {
383        assert!(!is_expired_by_age(1_000, None, 1_000_000_000));
384    }
385
386    #[test]
387    fn is_expired_by_age_before_bound_is_not_expired() {
388        assert!(!is_expired_by_age(1_000, Some(3_600), 1_000 + 3_599));
389    }
390
391    #[test]
392    fn is_expired_by_age_at_bound_is_expired() {
393        // >= at the boundary, matching the max_fires check's own >= semantics.
394        assert!(is_expired_by_age(1_000, Some(3_600), 1_000 + 3_600));
395    }
396
397    #[test]
398    fn is_expired_by_age_past_bound_is_expired() {
399        assert!(is_expired_by_age(1_000, Some(3_600), 1_000 + 10_000));
400    }
401
402    /// Regression test for PR #2418's P1 review finding: the scheduler now
403    /// calls this same function with the *next scheduled fire's* timestamp,
404    /// not just "now", to decide whether to skip a fire that would land past
405    /// expiry rather than sleeping through the bound and firing anyway. A
406    /// job created 1000s ago with a 3600s bound, checked against a `next`
407    /// fire time far past that bound (e.g. a daily cron job's next fire is
408    /// hours away), must report expired — this is exactly the "would this
409    /// fire itself violate the hard expiry bound" check the loop performs
410    /// before deciding whether to sleep at all.
411    #[test]
412    fn is_expired_by_age_catches_a_next_fire_time_past_the_bound() {
413        let created_at = 1_000;
414        let max_age_secs = Some(3_600);
415        let next_fire_far_in_the_future = created_at + 86_400; // 24h away
416        assert!(is_expired_by_age(created_at, max_age_secs, next_fire_far_in_the_future));
417    }
418}
419
420/// What a cron fire addresses: the UID when one was captured at creation,
421/// else the name (counted, spec §9.2 — `cron.fire_by_name` reaching zero is
422/// part of M5's exit criterion).
423fn fire_target<'a>(target: &'a str, target_uid: &'a str) -> &'a str {
424    let uid = target_uid.trim();
425    if uid.is_empty() {
426        crate::backend::agent_resolve::record_uid_fallback("cron.fire_by_name");
427        target
428    } else {
429        uid
430    }
431}
432
433#[cfg(test)]
434mod identity_m3_tests {
435    use super::fire_target;
436
437    #[test]
438    fn fires_by_uid_when_captured_and_by_name_otherwise() {
439        assert_eq!(fire_target("AgentY", "4f3c-a91"), "4f3c-a91");
440        assert_eq!(fire_target("AgentY", ""), "AgentY");
441        assert_eq!(fire_target("AgentY", "   "), "AgentY");
442    }
443}
444
445#[cfg(test)]
446mod identity_m4c3_tests {
447    use super::*;
448
449    /// Identity M4c-3 (spec §6.5.9): with delivery installed, a fire goes in
450    /// process — by target UID, as `"cron"`, with the creator's UID beside
451    /// it — and never over HTTP.
452    #[tokio::test]
453    async fn a_fire_is_delivered_in_process_as_cron_with_the_creators_uid() {
454        let sched = CronScheduler::new(
455            None,
456            reqwest::Client::new(),
457            // Unreachable: a fire that fell back to HTTP could not succeed.
458            "http://127.0.0.1:1".to_string(),
459            "test".to_string(),
460            Arc::new(Broker::new()),
461        );
462        let seen: Arc<Mutex<Vec<(InjectionRequest, String)>>> = Arc::default();
463        let sink = seen.clone();
464        sched.install_delivery(Arc::new(move |req, creator_uid| {
465            sink.lock().unwrap().push((req, creator_uid));
466            Box::pin(async { serde_json::json!({"success": true}) })
467        }));
468
469        sched.fire("job-1", "check in", "AgentY", "uid-target", "uid-creator").await;
470
471        let seen = seen.lock().unwrap();
472        assert_eq!(seen.len(), 1);
473        let (req, creator_uid) = &seen[0];
474        assert_eq!(req.target_agent, "uid-target");
475        assert_eq!(req.message, "check in");
476        assert_eq!(req.source_agent.as_deref(), Some("cron"), "the sender name stays cron");
477        assert_eq!(creator_uid, "uid-creator");
478        assert_eq!(req.audit_source_uid, "", "the delivery path sets it, on this instance only");
479    }
480}