Skip to main content

meerkat_mobkit/runtime/
scheduling.rs

1//! Scheduling subsystem — schedule dispatch, tick evaluation, and module-backed execution.
2
3use super::module_boundary::{
4    CORE_MODULE_MCP_TIMEOUT, SCHEDULING_DISPATCH_MCP_TOOL, call_module_mcp_tool_json,
5    mcp_required_error, module_uses_mcp,
6};
7use super::*;
8
9pub fn evaluate_schedules_at_tick(
10    schedules: &[ScheduleDefinition],
11    tick_ms: u64,
12) -> Result<ScheduleEvaluation, ScheduleValidationError> {
13    validate_schedule_tick_ms_supported(tick_ms)?;
14    validate_schedules(schedules)?;
15    let mut due_triggers = Vec::new();
16    // Shared across every schedule in this request so a batch of sparse crons
17    // cannot multiply the per-schedule lookback into a soft-DoS.
18    let mut lookback_budget = CRON_LOOKBACK_BUDGET_PER_REQUEST;
19    for schedule in schedules.iter().filter(|s| s.enabled) {
20        let canonical_schedule_id = canonical_schedule_id(&schedule.schedule_id);
21        let interval = parse_schedule_interval(&schedule.interval).ok_or_else(|| {
22            ScheduleValidationError::InvalidInterval {
23                schedule_id: canonical_schedule_id.clone(),
24                interval: schedule.interval.clone(),
25            }
26        })?;
27        let timezone = parse_schedule_timezone(&schedule.timezone).ok_or_else(|| {
28            ScheduleValidationError::InvalidTimezone {
29                schedule_id: canonical_schedule_id.clone(),
30                timezone: schedule.timezone.clone(),
31            }
32        })?;
33        let Some(due_tick_ms) = latest_due_tick_at_or_before(
34            &canonical_schedule_id,
35            &interval,
36            &timezone,
37            schedule.jitter_ms,
38            tick_ms,
39            &mut lookback_budget,
40        )
41        .map_err(|_| ScheduleValidationError::LookbackBudgetExceeded {
42            schedule_id: canonical_schedule_id.clone(),
43        })?
44        else {
45            continue;
46        };
47        if due_tick_ms != tick_ms {
48            continue;
49        }
50        due_triggers.push(ScheduleTrigger {
51            schedule_id: canonical_schedule_id,
52            interval: schedule.interval.clone(),
53            timezone: schedule.timezone.clone(),
54            due_tick_ms,
55        });
56    }
57
58    due_triggers.sort_by(|left, right| {
59        left.due_tick_ms
60            .cmp(&right.due_tick_ms)
61            .then_with(|| left.schedule_id.cmp(&right.schedule_id))
62            .then_with(|| left.interval.cmp(&right.interval))
63            .then_with(|| left.timezone.cmp(&right.timezone))
64    });
65
66    Ok(ScheduleEvaluation {
67        tick_ms,
68        due_triggers,
69    })
70}
71
72pub(crate) fn validate_schedules(
73    schedules: &[ScheduleDefinition],
74) -> Result<(), ScheduleValidationError> {
75    let mut seen = BTreeSet::new();
76    for schedule in schedules {
77        let canonical_schedule_id = canonical_schedule_id(&schedule.schedule_id);
78        if canonical_schedule_id.is_empty() {
79            return Err(ScheduleValidationError::EmptyScheduleId);
80        }
81        if !seen.insert(canonical_schedule_id.clone()) {
82            return Err(ScheduleValidationError::DuplicateScheduleId(
83                canonical_schedule_id,
84            ));
85        }
86        if parse_schedule_interval(&schedule.interval).is_none() {
87            return Err(ScheduleValidationError::InvalidInterval {
88                schedule_id: canonical_schedule_id,
89                interval: schedule.interval.clone(),
90            });
91        }
92        if parse_schedule_timezone(&schedule.timezone).is_none() {
93            return Err(ScheduleValidationError::InvalidTimezone {
94                schedule_id: canonical_schedule_id,
95                timezone: schedule.timezone.clone(),
96            });
97        }
98    }
99    Ok(())
100}
101
102impl MobkitRuntimeHandle {
103    fn parse_scheduling_runtime_injection_response(
104        response: Value,
105    ) -> Result<Option<(String, String)>, RuntimeBoundaryError> {
106        let Some(injection) = response
107            .as_object()
108            .and_then(|payload| payload.get("runtime_injection"))
109            .and_then(Value::as_object)
110            .cloned()
111        else {
112            return Ok(None);
113        };
114        let member_id = injection
115            .get("member_id")
116            .and_then(Value::as_str)
117            .map(str::trim)
118            .filter(|value| !value.is_empty())
119            .ok_or_else(|| {
120                RuntimeBoundaryError::Mcp(McpBoundaryError::InvalidToolPayload {
121                    module_id: "scheduling".to_string(),
122                    tool: SCHEDULING_DISPATCH_MCP_TOOL.to_string(),
123                    reason: "runtime_injection.member_id must be a non-empty string".to_string(),
124                })
125            })?;
126        let message = injection
127            .get("message")
128            .and_then(Value::as_str)
129            .map(str::trim)
130            .filter(|value| !value.is_empty())
131            .ok_or_else(|| {
132                RuntimeBoundaryError::Mcp(McpBoundaryError::InvalidToolPayload {
133                    module_id: "scheduling".to_string(),
134                    tool: SCHEDULING_DISPATCH_MCP_TOOL.to_string(),
135                    reason: "runtime_injection.message must be a non-empty string".to_string(),
136                })
137            })?;
138        Ok(Some((member_id.to_string(), message.to_string())))
139    }
140
141    fn scheduling_runtime_injection_for_dispatch(
142        &self,
143        schedule_id: &str,
144        interval: &str,
145        timezone: &str,
146        due_tick_ms: u64,
147        tick_ms: u64,
148        claim_key: &str,
149    ) -> Result<Option<(String, String)>, RuntimeBoundaryError> {
150        let Some((scheduling_module, pre_spawn)) = self.module_and_prespawn("scheduling") else {
151            return Ok(None);
152        };
153        if !self.is_module_loaded("scheduling") {
154            return Ok(None);
155        }
156        if !module_uses_mcp(scheduling_module, pre_spawn) {
157            return Err(mcp_required_error(
158                "scheduling",
159                SCHEDULING_DISPATCH_MCP_TOOL,
160            ));
161        }
162        let response = call_module_mcp_tool_json(
163            scheduling_module,
164            pre_spawn,
165            SCHEDULING_DISPATCH_MCP_TOOL,
166            &serde_json::json!({
167                "schedule_id": schedule_id,
168                "interval": interval,
169                "timezone": timezone,
170                "due_tick_ms": due_tick_ms,
171                "tick_ms": tick_ms,
172                "claim_key": claim_key,
173            }),
174            CORE_MODULE_MCP_TIMEOUT,
175        )?;
176        Self::parse_scheduling_runtime_injection_response(response)
177    }
178
179    fn next_scheduling_dispatch_sequence(&mut self) -> u64 {
180        Self::next_sequence(&mut self.scheduling_dispatch_sequence)
181    }
182    pub fn evaluate_schedule_tick(
183        &self,
184        schedules: &[ScheduleDefinition],
185        tick_ms: u64,
186    ) -> Result<ScheduleEvaluation, ScheduleValidationError> {
187        evaluate_schedules_at_tick(schedules, tick_ms)
188    }
189
190    pub fn dispatch_schedule_tick(
191        &mut self,
192        schedules: &[ScheduleDefinition],
193        tick_ms: u64,
194    ) -> Result<ScheduleDispatchReport, ScheduleValidationError> {
195        validate_schedule_tick_ms_supported(tick_ms)?;
196        validate_schedules(schedules)?;
197        self.prune_schedule_claims(tick_ms);
198        self.prune_scheduling_last_due_ticks(tick_ms);
199        let mut due_triggers = Vec::new();
200        // Shared across every schedule in this request so a batch of sparse
201        // crons cannot multiply the per-schedule lookback into a soft-DoS.
202        let mut lookback_budget = CRON_LOOKBACK_BUDGET_PER_REQUEST;
203        for schedule in schedules.iter().filter(|s| s.enabled) {
204            let canonical_schedule_id = canonical_schedule_id(&schedule.schedule_id);
205            let interval = parse_schedule_interval(&schedule.interval).ok_or_else(|| {
206                ScheduleValidationError::InvalidInterval {
207                    schedule_id: canonical_schedule_id.clone(),
208                    interval: schedule.interval.clone(),
209                }
210            })?;
211            let timezone = parse_schedule_timezone(&schedule.timezone).ok_or_else(|| {
212                ScheduleValidationError::InvalidTimezone {
213                    schedule_id: canonical_schedule_id.clone(),
214                    timezone: schedule.timezone.clone(),
215                }
216            })?;
217            let Some(due_tick_ms) = latest_due_tick_at_or_before(
218                &canonical_schedule_id,
219                &interval,
220                &timezone,
221                schedule.jitter_ms,
222                tick_ms,
223                &mut lookback_budget,
224            )
225            .map_err(|_| ScheduleValidationError::LookbackBudgetExceeded {
226                schedule_id: canonical_schedule_id.clone(),
227            })?
228            else {
229                continue;
230            };
231            let last_due_tick = self
232                .scheduling_last_due_ticks
233                .get(&canonical_schedule_id)
234                .copied();
235            if schedule.catch_up {
236                if last_due_tick.is_some_and(|last| last >= due_tick_ms) {
237                    continue;
238                }
239            } else if last_due_tick
240                .is_some_and(|last| last >= due_tick_ms && due_tick_ms != tick_ms)
241            {
242                continue;
243            }
244            due_triggers.push((schedule, canonical_schedule_id, due_tick_ms));
245        }
246        due_triggers.sort_by(
247            |(left_schedule, left_schedule_id, left_due_tick),
248             (right_schedule, right_schedule_id, right_due_tick)| {
249                left_due_tick
250                    .cmp(right_due_tick)
251                    .then_with(|| left_schedule_id.cmp(right_schedule_id))
252                    .then_with(|| left_schedule.interval.cmp(&right_schedule.interval))
253                    .then_with(|| left_schedule.timezone.cmp(&right_schedule.timezone))
254            },
255        );
256        let mut dispatched = Vec::new();
257        let mut skipped_claims = Vec::new();
258        let scheduling_signal = self.scheduling_supervisor_signal();
259        let mut supervisor_restart_emitted = false;
260
261        for (trigger, canonical_schedule_id, due_tick_ms) in &due_triggers {
262            let claim_key = format!("{canonical_schedule_id}:{due_tick_ms}");
263            if !self.record_schedule_claim(claim_key.clone(), tick_ms) {
264                skipped_claims.push(claim_key);
265                continue;
266            }
267            self.scheduling_last_due_ticks
268                .insert(canonical_schedule_id.clone(), *due_tick_ms);
269            self.prune_scheduling_last_due_ticks(tick_ms);
270
271            let event_sequence = self.next_scheduling_dispatch_sequence();
272            let event_id =
273                format!("evt-schedule-{canonical_schedule_id}-{due_tick_ms}-{event_sequence}");
274            insert_event_sorted(
275                &mut self.merged_events,
276                EventEnvelope {
277                    event_id: event_id.clone(),
278                    source: "module".to_string(),
279                    timestamp_ms: tick_ms,
280                    event: UnifiedEvent::Module(ModuleEvent {
281                        module: "scheduling".to_string(),
282                        event_type: "dispatch".to_string(),
283                        payload: serde_json::json!({
284                            "schedule_id": canonical_schedule_id,
285                            "interval": trigger.interval,
286                            "timezone": trigger.timezone,
287                            "tick_ms": tick_ms,
288                            "due_tick_ms": due_tick_ms,
289                            "claim_key": claim_key,
290                            "supervisor_signal": scheduling_signal,
291                        }),
292                    }),
293                },
294            );
295
296            if let Some(signal) = &scheduling_signal
297                && signal.restart_observed
298                && !supervisor_restart_emitted
299            {
300                insert_event_sorted(
301                    &mut self.merged_events,
302                    EventEnvelope {
303                        event_id: format!("evt-scheduling-supervisor-{tick_ms}-{event_sequence}"),
304                        source: "module".to_string(),
305                        timestamp_ms: tick_ms,
306                        event: UnifiedEvent::Module(ModuleEvent {
307                            module: "scheduling".to_string(),
308                            event_type: "supervisor.restart".to_string(),
309                            payload: serde_json::json!({
310                                "module_id": signal.module_id,
311                                "latest_state": signal.latest_state,
312                                "latest_attempt": signal.latest_attempt,
313                                "restart_observed": signal.restart_observed,
314                            }),
315                        }),
316                    },
317                );
318                supervisor_restart_emitted = true;
319            }
320
321            let mut runtime_injection = None;
322            let mut runtime_injection_error = None;
323            match self.scheduling_runtime_injection_for_dispatch(
324                canonical_schedule_id,
325                &trigger.interval,
326                &trigger.timezone,
327                *due_tick_ms,
328                tick_ms,
329                &claim_key,
330            ) {
331                Ok(Some((member_id, message))) => {
332                    let injection_event_id =
333                        format!("evt-runtime-injection-{tick_ms}-{event_sequence}");
334                    insert_event_sorted(
335                        &mut self.merged_events,
336                        EventEnvelope {
337                            event_id: injection_event_id.clone(),
338                            source: "module".to_string(),
339                            timestamp_ms: tick_ms,
340                            event: UnifiedEvent::Module(ModuleEvent {
341                                module: "runtime".to_string(),
342                                event_type: "injection.dispatch".to_string(),
343                                payload: serde_json::json!({
344                                    "schedule_id": canonical_schedule_id,
345                                    "claim_key": claim_key,
346                                    "member_id": member_id,
347                                    "message": message,
348                                }),
349                            }),
350                        },
351                    );
352                    runtime_injection = Some(ScheduleRuntimeInjection {
353                        member_id,
354                        message,
355                        injection_event_id,
356                    });
357                }
358                Ok(None) => {}
359                Err(error) => {
360                    runtime_injection_error = Some(format!("{error:?}"));
361                    insert_event_sorted(
362                        &mut self.merged_events,
363                        EventEnvelope {
364                            event_id: format!(
365                                "evt-runtime-injection-failed-{tick_ms}-{event_sequence}"
366                            ),
367                            source: "module".to_string(),
368                            timestamp_ms: tick_ms,
369                            event: UnifiedEvent::Module(ModuleEvent {
370                                module: "runtime".to_string(),
371                                event_type: "runtime.injection.failed".to_string(),
372                                payload: serde_json::json!({
373                                    "schedule_id": canonical_schedule_id,
374                                    "claim_key": claim_key,
375                                    "error": format!("{error:?}"),
376                                }),
377                            }),
378                        },
379                    );
380                }
381            }
382
383            dispatched.push(ScheduleDispatch {
384                claim_key,
385                schedule_id: canonical_schedule_id.clone(),
386                interval: trigger.interval.clone(),
387                timezone: trigger.timezone.clone(),
388                due_tick_ms: *due_tick_ms,
389                tick_ms,
390                event_id,
391                supervisor_signal: scheduling_signal.clone(),
392                runtime_injection,
393                runtime_injection_error,
394            });
395        }
396
397        Ok(ScheduleDispatchReport {
398            tick_ms,
399            due_count: due_triggers.len(),
400            dispatched,
401            skipped_claims,
402        })
403    }
404    fn record_schedule_claim(&mut self, claim_key: String, tick_ms: u64) -> bool {
405        if !self.scheduling_claims.insert(claim_key.clone()) {
406            return false;
407        }
408        self.scheduling_claim_ticks
409            .entry(tick_ms)
410            .or_default()
411            .push(claim_key);
412        true
413    }
414
415    fn prune_schedule_claims(&mut self, current_tick_ms: u64) {
416        let cutoff_tick = current_tick_ms.saturating_sub(SCHEDULING_CLAIM_RETENTION_WINDOW_MS);
417        let expired_ticks = self
418            .scheduling_claim_ticks
419            .keys()
420            .copied()
421            .take_while(|tick| *tick < cutoff_tick)
422            .collect::<Vec<_>>();
423        for tick in expired_ticks {
424            if let Some(keys) = self.scheduling_claim_ticks.remove(&tick) {
425                for key in keys {
426                    self.scheduling_claims.remove(&key);
427                }
428            }
429        }
430
431        while self.scheduling_claims.len() > SCHEDULING_CLAIMS_MAX_RETAINED {
432            let Some(oldest_tick) = self.scheduling_claim_ticks.keys().next().copied() else {
433                break;
434            };
435            if let Some(keys) = self.scheduling_claim_ticks.remove(&oldest_tick) {
436                for key in keys {
437                    self.scheduling_claims.remove(&key);
438                }
439            } else {
440                break;
441            }
442        }
443    }
444
445    fn prune_scheduling_last_due_ticks(&mut self, current_tick_ms: u64) {
446        let cutoff_tick = current_tick_ms.saturating_sub(SCHEDULING_CLAIM_RETENTION_WINDOW_MS);
447        self.scheduling_last_due_ticks
448            .retain(|_, due_tick| *due_tick >= cutoff_tick);
449
450        while self.scheduling_last_due_ticks.len() > SCHEDULING_LAST_DUE_MAX_RETAINED {
451            let Some(oldest_schedule_id) = self
452                .scheduling_last_due_ticks
453                .iter()
454                .min_by(|(left_id, left_due), (right_id, right_due)| {
455                    left_due.cmp(right_due).then_with(|| left_id.cmp(right_id))
456                })
457                .map(|(schedule_id, _)| schedule_id.clone())
458            else {
459                break;
460            };
461            self.scheduling_last_due_ticks.remove(&oldest_schedule_id);
462        }
463    }
464    fn scheduling_supervisor_signal(&self) -> Option<SchedulingSupervisorSignal> {
465        let module_transitions = self
466            .supervisor_report
467            .transitions
468            .iter()
469            .filter(|transition| transition.module_id == "scheduling")
470            .collect::<Vec<_>>();
471        let latest = module_transitions.last()?;
472        let restart_observed = module_transitions
473            .iter()
474            .any(|transition| transition.to == ModuleHealthState::Restarting);
475        Some(SchedulingSupervisorSignal {
476            module_id: latest.module_id.clone(),
477            latest_state: latest.to.clone(),
478            latest_attempt: latest.attempt,
479            restart_observed,
480        })
481    }
482}
483
484#[derive(Debug, Clone, PartialEq, Eq)]
485enum ParsedInterval {
486    Marker { interval_ms: u64 },
487    Cron(CronExpression),
488}
489
490impl ParsedInterval {
491    fn jitter_base_interval_ms(&self) -> u64 {
492        match self {
493            Self::Marker { interval_ms } => *interval_ms,
494            // Five-field cron expressions are minute-based.
495            Self::Cron(_) => 60_000,
496        }
497    }
498}
499
500#[derive(Debug, Clone, PartialEq, Eq)]
501enum ParsedTimezone {
502    FixedOffsetMs(i64),
503    Iana(chrono_tz::Tz),
504}
505
506#[derive(Debug, Clone, PartialEq, Eq)]
507struct CronExpression {
508    minute: CronFieldSet,
509    hour: CronFieldSet,
510    day_of_month: CronFieldSet,
511    month: CronFieldSet,
512    day_of_week: CronFieldSet,
513}
514
515#[derive(Debug, Clone, PartialEq, Eq)]
516struct CronFieldSet {
517    any: bool,
518    min: u32,
519    allowed: Vec<bool>,
520}
521
522impl CronExpression {
523    fn parse(expression: &str) -> Option<Self> {
524        let fields = expression.split_whitespace().collect::<Vec<_>>();
525        if fields.len() != 5 {
526            return None;
527        }
528        let parsed = Self {
529            minute: parse_cron_field(fields[0], 0, 59, false)?,
530            hour: parse_cron_field(fields[1], 0, 23, false)?,
531            day_of_month: parse_cron_field(fields[2], 1, 31, false)?,
532            month: parse_cron_field(fields[3], 1, 12, false)?,
533            day_of_week: parse_cron_field(fields[4], 0, 7, true)?,
534        };
535
536        // Keep standard DOM/DOW OR semantics. Only reject expressions that can never fire
537        // when day-of-week is wildcard and the selected day-of-month never exists in selected months.
538        if parsed.day_of_week.any
539            && !parsed.day_of_month.any
540            && !parsed.has_possible_day_of_month_for_selected_months()
541        {
542            return None;
543        }
544
545        Some(parsed)
546    }
547
548    fn matches(&self, local: &LocalDateTimeFields) -> bool {
549        if !self.minute.matches(local.minute)
550            || !self.hour.matches(local.hour)
551            || !self.month.matches(local.month)
552        {
553            return false;
554        }
555
556        let dom_match = self.day_of_month.matches(local.day_of_month);
557        let dow_match = self.day_of_week.matches(local.day_of_week);
558
559        if self.day_of_month.any && self.day_of_week.any {
560            true
561        } else if self.day_of_month.any {
562            dow_match
563        } else if self.day_of_week.any {
564            dom_match
565        } else {
566            dom_match || dow_match
567        }
568    }
569
570    fn has_possible_day_of_month_for_selected_months(&self) -> bool {
571        for month in 1..=12 {
572            if !self.month.matches(month) {
573                continue;
574            }
575            let max_day = max_day_for_month_with_feb_29(month);
576            for day in 1..=max_day {
577                if self.day_of_month.matches(day) {
578                    return true;
579                }
580            }
581        }
582        false
583    }
584}
585
586impl CronFieldSet {
587    fn matches(&self, value: u32) -> bool {
588        if value < self.min {
589            return false;
590        }
591        let idx = (value - self.min) as usize;
592        self.allowed.get(idx).copied().unwrap_or(false)
593    }
594}
595
596#[derive(Debug, Clone, PartialEq, Eq)]
597struct LocalDateTimeFields {
598    minute: u32,
599    hour: u32,
600    day_of_month: u32,
601    month: u32,
602    day_of_week: u32,
603    second: u32,
604    subsec_nanos: u32,
605}
606
607fn parse_cron_field(
608    field: &str,
609    min: u32,
610    max: u32,
611    map_sunday_seven_to_zero: bool,
612) -> Option<CronFieldSet> {
613    let mut allowed = vec![false; (max - min + 1) as usize];
614
615    for raw_token in field.split(',') {
616        let token = raw_token.trim();
617        if token.is_empty() {
618            return None;
619        }
620        let (base, step) = match token.split_once('/') {
621            Some((base, step)) => {
622                let step = step.parse::<u32>().ok()?;
623                if step == 0 {
624                    return None;
625                }
626                (base.trim(), step)
627            }
628            None => (token, 1),
629        };
630
631        if base == "*" {
632            let mut value = min;
633            while value <= max {
634                let mapped = normalize_cron_value(value, map_sunday_seven_to_zero);
635                let idx = (mapped - min) as usize;
636                allowed[idx] = true;
637                match value.checked_add(step) {
638                    Some(next) => value = next,
639                    None => break,
640                }
641            }
642            continue;
643        }
644
645        if let Some((start, end)) = base.split_once('-') {
646            let start = parse_cron_raw_value(start.trim(), min, max)?;
647            let end = parse_cron_raw_value(end.trim(), min, max)?;
648            if start > end {
649                return None;
650            }
651            let mut value = start;
652            while value <= end {
653                let mapped = normalize_cron_value(value, map_sunday_seven_to_zero);
654                let idx = (mapped - min) as usize;
655                allowed[idx] = true;
656                match value.checked_add(step) {
657                    Some(next) => value = next,
658                    None => break,
659                }
660            }
661            continue;
662        }
663
664        let value = parse_cron_value(base, min, max, map_sunday_seven_to_zero)?;
665        let idx = (value - min) as usize;
666        allowed[idx] = true;
667    }
668
669    if allowed.iter().all(|allowed| !allowed) {
670        return None;
671    }
672
673    let any = cron_field_is_semantic_wildcard(min, max, map_sunday_seven_to_zero, &allowed);
674    Some(CronFieldSet { any, min, allowed })
675}
676
677fn cron_field_is_semantic_wildcard(
678    min: u32,
679    max: u32,
680    map_sunday_seven_to_zero: bool,
681    allowed: &[bool],
682) -> bool {
683    let mut covered = vec![false; allowed.len()];
684    for raw in min..=max {
685        let mapped = normalize_cron_value(raw, map_sunday_seven_to_zero);
686        if mapped < min || mapped > max {
687            return false;
688        }
689        let mapped_idx = (mapped - min) as usize;
690        covered[mapped_idx] = true;
691    }
692
693    covered
694        .iter()
695        .enumerate()
696        .filter(|(_, is_semantic_value)| **is_semantic_value)
697        .all(|(idx, _)| allowed.get(idx).copied().unwrap_or(false))
698}
699
700fn parse_cron_value(raw: &str, min: u32, max: u32, map_sunday_seven_to_zero: bool) -> Option<u32> {
701    let value = normalize_cron_value(
702        parse_cron_raw_value(raw, min, max)?,
703        map_sunday_seven_to_zero,
704    );
705    if value < min || value > max {
706        return None;
707    }
708    Some(value)
709}
710
711fn parse_cron_raw_value(raw: &str, min: u32, max: u32) -> Option<u32> {
712    let value = raw.parse::<u32>().ok()?;
713    if value < min || value > max {
714        return None;
715    }
716    Some(value)
717}
718
719fn normalize_cron_value(value: u32, map_sunday_seven_to_zero: bool) -> u32 {
720    if map_sunday_seven_to_zero && value == 7 {
721        0
722    } else {
723        value
724    }
725}
726
727fn max_day_for_month_with_feb_29(month: u32) -> u32 {
728    match month {
729        2 => 29,
730        4 | 6 | 9 | 11 => 30,
731        _ => 31,
732    }
733}
734
735/// Parse a `*/N{s|m|h|d}` interval marker to milliseconds — the same
736/// cadence syntax `schedules.toml` uses. `pub(crate)` so the steward's
737/// cadence config (agent-memory §8.5) validates against the scheduling
738/// subsystem's own grammar instead of growing a dialect.
739pub(crate) fn parse_interval_marker_ms(interval: &str) -> Option<u64> {
740    let marker = interval.trim().to_ascii_lowercase();
741    let marker = marker.strip_prefix("*/")?;
742    if marker.len() < 2 {
743        return None;
744    }
745    let (count_part, unit_part) = marker.split_at(marker.len() - 1);
746    let count = count_part.parse::<u64>().ok()?;
747    if count == 0 {
748        return None;
749    }
750    let unit_ms = match unit_part {
751        "s" => 1_000,
752        "m" => 60_000,
753        "h" => 3_600_000,
754        "d" => 86_400_000,
755        _ => return None,
756    };
757    count.checked_mul(unit_ms)
758}
759
760fn parse_schedule_interval(interval: &str) -> Option<ParsedInterval> {
761    parse_interval_marker_ms(interval)
762        .map(|interval_ms| ParsedInterval::Marker { interval_ms })
763        .or_else(|| CronExpression::parse(interval.trim()).map(ParsedInterval::Cron))
764}
765
766fn deterministic_jitter_offset_ms(schedule_id: &str, jitter_ms: u64, interval_ms: u64) -> u64 {
767    if jitter_ms == 0 || interval_ms <= 1 {
768        return 0;
769    }
770    let mut hash = 1_469_598_103_934_665_603_u64;
771    for byte in schedule_id.bytes() {
772        hash ^= byte as u64;
773        hash = hash.wrapping_mul(1_099_511_628_211);
774    }
775    let max_jitter = jitter_ms.min(interval_ms.saturating_sub(1));
776    hash % (max_jitter + 1)
777}
778
779fn parse_schedule_timezone(timezone: &str) -> Option<ParsedTimezone> {
780    let timezone = timezone.trim();
781    if timezone.is_empty() {
782        return None;
783    }
784    parse_timezone_offset_ms(timezone)
785        .map(ParsedTimezone::FixedOffsetMs)
786        .or_else(|| {
787            timezone
788                .parse::<chrono_tz::Tz>()
789                .ok()
790                .map(ParsedTimezone::Iana)
791        })
792}
793
794fn parse_timezone_offset_ms(timezone: &str) -> Option<i64> {
795    let tz = timezone.trim();
796    if tz.is_empty() {
797        return None;
798    }
799    if tz.eq_ignore_ascii_case("utc") || tz == "Z" {
800        return Some(0);
801    }
802    let offset = tz
803        .strip_prefix("UTC")
804        .or_else(|| tz.strip_prefix("utc"))
805        .or_else(|| tz.strip_prefix("GMT"))
806        .or_else(|| tz.strip_prefix("gmt"))
807        .unwrap_or(tz);
808    parse_hhmm_offset(offset)
809}
810
811fn parse_hhmm_offset(offset: &str) -> Option<i64> {
812    if offset.is_empty() {
813        return Some(0);
814    }
815    let sign = if offset.starts_with('+') {
816        1_i64
817    } else if offset.starts_with('-') {
818        -1_i64
819    } else {
820        return None;
821    };
822    let body = &offset[1..];
823    let (hours, minutes) = if let Some((h, m)) = body.split_once(':') {
824        (h, m)
825    } else if body.len() == 4 {
826        body.split_at(2)
827    } else {
828        return None;
829    };
830    let hours = hours.parse::<i64>().ok()?;
831    let minutes = minutes.parse::<i64>().ok()?;
832    if hours > 23 || minutes > 59 {
833        return None;
834    }
835    let total_minutes = hours.saturating_mul(60).saturating_add(minutes);
836    Some(sign.saturating_mul(total_minutes).saturating_mul(60_000))
837}
838
839fn utc_datetime_from_tick_ms(tick_ms: u64) -> Option<chrono::DateTime<Utc>> {
840    let tick_ms = i64::try_from(tick_ms).ok()?;
841    chrono::DateTime::<Utc>::from_timestamp_millis(tick_ms)
842}
843
844fn local_fields_at_tick(timezone: &ParsedTimezone, tick_ms: u64) -> Option<LocalDateTimeFields> {
845    let utc = utc_datetime_from_tick_ms(tick_ms)?;
846    let (minute, hour, day_of_month, month, day_of_week, second, subsec_nanos) = match timezone {
847        ParsedTimezone::FixedOffsetMs(offset_ms) => {
848            let offset_seconds = i32::try_from(offset_ms / 1_000).ok()?;
849            let offset = chrono::FixedOffset::east_opt(offset_seconds)?;
850            let local = utc.with_timezone(&offset);
851            (
852                local.minute(),
853                local.hour(),
854                local.day(),
855                local.month(),
856                local.weekday().num_days_from_sunday(),
857                local.second(),
858                local.nanosecond(),
859            )
860        }
861        ParsedTimezone::Iana(timezone) => {
862            let local = utc.with_timezone(timezone);
863            (
864                local.minute(),
865                local.hour(),
866                local.day(),
867                local.month(),
868                local.weekday().num_days_from_sunday(),
869                local.second(),
870                local.nanosecond(),
871            )
872        }
873    };
874    Some(LocalDateTimeFields {
875        minute,
876        hour,
877        day_of_month,
878        month,
879        day_of_week,
880        second,
881        subsec_nanos,
882    })
883}
884
885fn timezone_offset_ms_at_tick(timezone: &ParsedTimezone, tick_ms: u64) -> Option<i64> {
886    match timezone {
887        ParsedTimezone::FixedOffsetMs(offset) => Some(*offset),
888        ParsedTimezone::Iana(tz) => {
889            let utc = utc_datetime_from_tick_ms(tick_ms)?;
890            let local = utc.with_timezone(tz);
891            Some(i64::from(local.offset().fix().local_minus_utc()).saturating_mul(1_000))
892        }
893    }
894}
895
896fn latest_due_marker_tick_at_or_before(
897    interval_ms: u64,
898    timezone: &ParsedTimezone,
899    tick_ms: u64,
900) -> Option<u64> {
901    match timezone {
902        ParsedTimezone::FixedOffsetMs(timezone_offset_ms) => {
903            latest_due_marker_tick_at_or_before_with_offset(
904                interval_ms,
905                *timezone_offset_ms,
906                tick_ms,
907            )
908        }
909        ParsedTimezone::Iana(_) => {
910            let mut timezone_offset_ms = timezone_offset_ms_at_tick(timezone, tick_ms)?;
911            for _ in 0..4 {
912                let due_tick = latest_due_marker_tick_at_or_before_with_offset(
913                    interval_ms,
914                    timezone_offset_ms,
915                    tick_ms,
916                )?;
917                let due_offset_ms = timezone_offset_ms_at_tick(timezone, due_tick)?;
918                if due_offset_ms == timezone_offset_ms {
919                    return Some(due_tick);
920                }
921                timezone_offset_ms = due_offset_ms;
922            }
923            latest_due_marker_tick_at_or_before_with_offset(
924                interval_ms,
925                timezone_offset_ms,
926                tick_ms,
927            )
928        }
929    }
930}
931
932fn latest_due_marker_tick_at_or_before_with_offset(
933    interval_ms: u64,
934    timezone_offset_ms: i64,
935    tick_ms: u64,
936) -> Option<u64> {
937    let local_tick = i128::from(tick_ms) + i128::from(timezone_offset_ms);
938    if local_tick < 0 {
939        return None;
940    }
941    let local_tick = local_tick as u64;
942    let latest_due_local_tick = local_tick - (local_tick % interval_ms);
943    let due_tick = i128::from(latest_due_local_tick) - i128::from(timezone_offset_ms);
944    if due_tick < 0 {
945        return None;
946    }
947    Some(due_tick as u64)
948}
949
950fn canonical_schedule_id(schedule_id: &str) -> String {
951    schedule_id.trim().to_string()
952}
953
954fn validate_schedule_tick_ms_supported(tick_ms: u64) -> Result<(), ScheduleValidationError> {
955    if tick_ms > i64::MAX as u64 {
956        return Err(ScheduleValidationError::InvalidTickMs(tick_ms));
957    }
958    Ok(())
959}
960
961/// Marker returned when a cron lookback exhausts the shared per-request
962/// iteration budget (`CRON_LOOKBACK_BUDGET_PER_REQUEST`). Distinct from a plain
963/// "no due tick within lookback" (`Ok(None)`) so the caller can fail the whole
964/// request with `LookbackBudgetExceeded` instead of silently returning no
965/// triggers (which would mask a soft-DoS attempt).
966#[derive(Debug)]
967struct LookbackBudgetExhausted;
968
969fn latest_due_cron_tick_at_or_before(
970    cron: &CronExpression,
971    timezone: &ParsedTimezone,
972    tick_ms: u64,
973    budget: &mut u64,
974) -> Result<Option<u64>, LookbackBudgetExhausted> {
975    let mut candidate = tick_ms - (tick_ms % 60_000);
976    for _ in 0..=CRON_LOOKBACK_MINUTES {
977        if *budget == 0 {
978            return Err(LookbackBudgetExhausted);
979        }
980        *budget -= 1;
981        let Some(fields) = local_fields_at_tick(timezone, candidate) else {
982            return Ok(None);
983        };
984        if fields.second == 0 && fields.subsec_nanos == 0 && cron.matches(&fields) {
985            return Ok(Some(candidate));
986        }
987        let Some(next) = candidate.checked_sub(60_000) else {
988            return Ok(None);
989        };
990        candidate = next;
991    }
992    Ok(None)
993}
994
995fn latest_due_tick_at_or_before(
996    schedule_id: &str,
997    interval: &ParsedInterval,
998    timezone: &ParsedTimezone,
999    jitter_ms: u64,
1000    tick_ms: u64,
1001    budget: &mut u64,
1002) -> Result<Option<u64>, LookbackBudgetExhausted> {
1003    let jitter_offset_ms =
1004        deterministic_jitter_offset_ms(schedule_id, jitter_ms, interval.jitter_base_interval_ms());
1005    let Some(tick_without_jitter) = tick_ms.checked_sub(jitter_offset_ms) else {
1006        return Ok(None);
1007    };
1008    let due_without_jitter = match interval {
1009        ParsedInterval::Marker { interval_ms } => {
1010            latest_due_marker_tick_at_or_before(*interval_ms, timezone, tick_without_jitter)
1011        }
1012        ParsedInterval::Cron(cron) => {
1013            latest_due_cron_tick_at_or_before(cron, timezone, tick_without_jitter, budget)?
1014        }
1015    };
1016    Ok(due_without_jitter.and_then(|tick| tick.checked_add(jitter_offset_ms)))
1017}
1018
1019#[cfg(test)]
1020#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
1021mod budget_tests {
1022    use super::*;
1023
1024    /// `0 0 29 2 *` (Feb 29) only fires on leap years, so resolving it from a
1025    /// non-leap tick walks back well over a million minutes. With ample budget
1026    /// the leap-day match must still resolve (the multi-year lookback is
1027    /// deliberate and must not regress).
1028    #[test]
1029    fn sparse_leap_day_cron_still_resolves_with_ample_budget() {
1030        let cron = match parse_schedule_interval("0 0 29 2 *").expect("valid leap-day cron") {
1031            ParsedInterval::Cron(cron) => cron,
1032            ParsedInterval::Marker { .. } => panic!("expected a cron interval"),
1033        };
1034        let tz = parse_schedule_timezone("UTC").expect("utc");
1035        // 2025-06-15 00:00 UTC — a non-leap-year mid-year tick. The most recent
1036        // Feb 29 before it is 2024-02-29, ~1.5M minutes back.
1037        let tick_ms = 1_750_032_000_000;
1038        let mut budget = CRON_LOOKBACK_BUDGET_PER_REQUEST;
1039        let due = latest_due_cron_tick_at_or_before(&cron, &tz, tick_ms, &mut budget)
1040            .expect("ample budget must not be exhausted");
1041        let due = due.expect("a Feb 29 must exist within the lookback window");
1042        // The resolved tick is 2024-02-29 00:00 UTC.
1043        let fields = local_fields_at_tick(&tz, due).expect("local fields");
1044        assert_eq!((fields.month, fields.day_of_month), (2, 29));
1045        assert!(
1046            budget < CRON_LOOKBACK_BUDGET_PER_REQUEST,
1047            "budget was consumed"
1048        );
1049    }
1050
1051    /// A tiny shared budget is exhausted by a sparse cron, surfacing the
1052    /// `LookbackBudgetExhausted` marker instead of silently stalling — the
1053    /// soft-DoS guard. Models the per-request ceiling shrunk to a few
1054    /// iterations.
1055    #[test]
1056    fn exhausted_budget_returns_marker_for_sparse_cron() {
1057        let cron = match parse_schedule_interval("0 0 29 2 *").expect("valid leap-day cron") {
1058            ParsedInterval::Cron(cron) => cron,
1059            ParsedInterval::Marker { .. } => panic!("expected a cron interval"),
1060        };
1061        let tz = parse_schedule_timezone("UTC").expect("utc");
1062        let tick_ms = 1_750_032_000_000;
1063        // Only 10 minutes of lookback allowed — far short of the ~1.5M needed.
1064        let mut budget = 10u64;
1065        let result = latest_due_cron_tick_at_or_before(&cron, &tz, tick_ms, &mut budget);
1066        assert!(
1067            result.is_err(),
1068            "a tiny budget must surface LookbackBudgetExhausted, not stall or silently miss"
1069        );
1070        assert_eq!(budget, 0, "budget must be fully consumed before failing");
1071    }
1072
1073    /// The budget is SHARED across schedules within a request: a second sparse
1074    /// cron evaluated after the budget is spent fails closed rather than adding
1075    /// another full multi-million-iteration walk.
1076    #[test]
1077    fn budget_is_shared_across_schedules_in_a_request() {
1078        let cron = match parse_schedule_interval("0 0 29 2 *").expect("valid leap-day cron") {
1079            ParsedInterval::Cron(cron) => cron,
1080            ParsedInterval::Marker { .. } => panic!("expected a cron interval"),
1081        };
1082        let tz = parse_schedule_timezone("UTC").expect("utc");
1083        let tick_ms = 1_750_032_000_000;
1084        // Enough for exactly one resolution, then nothing left for the next.
1085        let mut budget = CRON_LOOKBACK_BUDGET_PER_REQUEST;
1086        latest_due_cron_tick_at_or_before(&cron, &tz, tick_ms, &mut budget)
1087            .expect("first sparse cron resolves within budget");
1088        let remaining = budget;
1089        assert!(remaining < CRON_LOOKBACK_BUDGET_PER_REQUEST);
1090        // Force the remaining budget to a sub-resolution amount and confirm the
1091        // next sparse cron in the same request fails closed.
1092        budget = 5;
1093        let second = latest_due_cron_tick_at_or_before(&cron, &tz, tick_ms, &mut budget);
1094        assert!(
1095            second.is_err(),
1096            "shared budget must fail the second sparse cron closed"
1097        );
1098    }
1099}