agentmux_srv/backend/cron/
mod.rs1use 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
32type HandleMap = Mutex<HashMap<String, tokio::task::AbortHandle>>;
34
35pub 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 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 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 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 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 let mut fires: i64 = initial_fires;
171 loop {
172 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 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 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 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 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 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 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 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
338fn 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 match schedule.after(&last_dt).next() {
360 Some(next_after_last) => next_after_last < now_dt,
361 None => false,
362 }
363}
364
365fn 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 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 #[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; assert!(is_expired_by_age(created_at, max_age_secs, next_fire_far_in_the_future));
417 }
418}
419
420fn 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 #[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 "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}