ironclad-schedule 0.4.2

Cron/heartbeat scheduler with DB-backed leases and wake signaling
Documentation
use ironclad_core::SurvivalTier;
use serde::{Deserialize, Serialize};

use crate::heartbeat::TickContext;

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum HeartbeatTask {
    SurvivalCheck,
    UsdcMonitor,
    YieldTask,
    MemoryPrune,
    CacheEvict,
    MetricSnapshot,
    AgentCardRefresh,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TaskResult {
    pub task: HeartbeatTask,
    pub success: bool,
    pub message: String,
    pub should_wake: bool,
}

pub fn execute_task(task: &HeartbeatTask, ctx: &TickContext) -> TaskResult {
    match task {
        HeartbeatTask::SurvivalCheck => {
            let should_wake = matches!(
                ctx.survival_tier,
                SurvivalTier::Critical | SurvivalTier::Dead
            );
            TaskResult {
                task: task.clone(),
                success: true,
                message: format!("survival tier: {:?}", ctx.survival_tier),
                should_wake,
            }
        }
        HeartbeatTask::UsdcMonitor => {
            let should_wake =
                ctx.usdc_balance > 0.0 && !matches!(ctx.survival_tier, SurvivalTier::High);
            TaskResult {
                task: task.clone(),
                success: true,
                message: format!("usdc_balance={:.4}", ctx.usdc_balance),
                should_wake,
            }
        }
        HeartbeatTask::YieldTask => {
            let active = matches!(ctx.survival_tier, SurvivalTier::High | SurvivalTier::Normal);
            TaskResult {
                task: task.clone(),
                success: true,
                message: if active {
                    "yield evaluation active".into()
                } else {
                    "yield skipped — tier too low".into()
                },
                should_wake: false,
            }
        }
        HeartbeatTask::MemoryPrune => TaskResult {
            task: task.clone(),
            success: true,
            message: "memory prune queued".into(),
            should_wake: false,
        },
        HeartbeatTask::CacheEvict => TaskResult {
            task: task.clone(),
            success: true,
            message: "cache eviction complete".into(),
            should_wake: false,
        },
        HeartbeatTask::MetricSnapshot => TaskResult {
            task: task.clone(),
            success: true,
            message: "metrics snapshot captured".into(),
            should_wake: false,
        },
        HeartbeatTask::AgentCardRefresh => TaskResult {
            task: task.clone(),
            success: true,
            message: "agent card refreshed".into(),
            should_wake: false,
        },
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::heartbeat::build_tick_context;

    #[test]
    fn survival_check_wakes_on_critical() {
        let ctx = build_tick_context(0.05, 0.01);
        assert_eq!(ctx.survival_tier, SurvivalTier::Critical);

        let result = execute_task(&HeartbeatTask::SurvivalCheck, &ctx);
        assert!(result.success);
        assert!(result.should_wake);
    }

    #[test]
    fn survival_check_no_wake_on_high() {
        let ctx = build_tick_context(10.0, 5.0);
        assert_eq!(ctx.survival_tier, SurvivalTier::High);

        let result = execute_task(&HeartbeatTask::SurvivalCheck, &ctx);
        assert!(result.success);
        assert!(!result.should_wake);
    }

    #[test]
    fn usdc_monitor_wakes_when_balance_present_and_not_high() {
        let ctx = build_tick_context(0.30, 1.0);
        assert_eq!(ctx.survival_tier, SurvivalTier::Normal);

        let result = execute_task(&HeartbeatTask::UsdcMonitor, &ctx);
        assert!(result.success);
        assert!(result.should_wake);
    }

    #[test]
    fn usdc_monitor_no_wake_when_high_tier() {
        let ctx = build_tick_context(10.0, 5.0);
        assert_eq!(ctx.survival_tier, SurvivalTier::High);

        let result = execute_task(&HeartbeatTask::UsdcMonitor, &ctx);
        assert!(result.success);
        assert!(!result.should_wake);
    }

    #[test]
    fn yield_task_skipped_on_low_tier() {
        let ctx = build_tick_context(0.05, 0.01);
        assert_eq!(ctx.survival_tier, SurvivalTier::Critical);

        let result = execute_task(&HeartbeatTask::YieldTask, &ctx);
        assert!(result.success);
        assert!(result.message.contains("skipped"));
    }

    #[test]
    fn yield_task_active_on_normal_tier() {
        let ctx = build_tick_context(2.0, 0.0);
        assert_eq!(ctx.survival_tier, SurvivalTier::Normal);

        let result = execute_task(&HeartbeatTask::YieldTask, &ctx);
        assert!(result.success);
        assert!(result.message.contains("active"));
    }

    #[test]
    fn memory_prune_task_execution() {
        let ctx = build_tick_context(1.0, 1.0);
        let result = execute_task(&HeartbeatTask::MemoryPrune, &ctx);
        assert!(result.success);
        assert!(!result.should_wake);
        assert_eq!(result.message, "memory prune queued");
        assert_eq!(result.task, HeartbeatTask::MemoryPrune);
    }

    #[test]
    fn cache_evict_task_execution() {
        let ctx = build_tick_context(1.0, 1.0);
        let result = execute_task(&HeartbeatTask::CacheEvict, &ctx);
        assert!(result.success);
        assert!(!result.should_wake);
        assert_eq!(result.message, "cache eviction complete");
        assert_eq!(result.task, HeartbeatTask::CacheEvict);
    }

    #[test]
    fn metric_snapshot_task_execution() {
        let ctx = build_tick_context(1.0, 1.0);
        let result = execute_task(&HeartbeatTask::MetricSnapshot, &ctx);
        assert!(result.success);
        assert!(!result.should_wake);
        assert_eq!(result.message, "metrics snapshot captured");
        assert_eq!(result.task, HeartbeatTask::MetricSnapshot);
    }

    #[test]
    fn agent_card_refresh_task_execution() {
        let ctx = build_tick_context(1.0, 1.0);
        let result = execute_task(&HeartbeatTask::AgentCardRefresh, &ctx);
        assert!(result.success);
        assert!(!result.should_wake);
        assert_eq!(result.message, "agent card refreshed");
        assert_eq!(result.task, HeartbeatTask::AgentCardRefresh);
    }
}