terraphim_orchestrator 1.20.3

AI Dark Factory orchestrator wiring spawner, router, supervisor into a reconciliation loop
Documentation
use chrono::{DateTime, TimeZone, Utc};
use terraphim_orchestrator::{AgentDefinition, AgentLayer, ScheduleEvent, TimeScheduler};

fn make_agent(name: &str, layer: AgentLayer, schedule: Option<&str>) -> AgentDefinition {
    AgentDefinition {
        name: name.to_string(),
        layer,
        cli_tool: "echo".to_string(),
        task: "test task".to_string(),
        model: None,
        default_tier: None,
        schedule: schedule.map(String::from),
        capabilities: vec![],
        max_memory_bytes: None,
        budget_monthly_cents: None,
        provider: None,
        persona: None,
        terraphim_role: None,
        skill_chain: vec![],
        sfia_skills: vec![],
        fallback_provider: None,
        fallback_model: None,
        grace_period_secs: None,
        max_cpu_seconds: None,
        pre_check: None,

        gitea_issue: None,
        event_only: false,
        evolution_enabled: false,
        rlm_enabled: None,
        bypass_kg_routing: false,
        enabled: true,

        project: None,
    }
}

fn dt(year: i32, month: u32, day: u32, hour: u32, min: u32, sec: u32) -> DateTime<Utc> {
    Utc.with_ymd_and_hms(year, month, day, hour, min, sec)
        .single()
        .unwrap()
}

fn should_fire(
    schedule: &str,
    last_tick_time: DateTime<Utc>,
    now: DateTime<Utc>,
    last_fire: Option<DateTime<Utc>>,
) -> bool {
    use cron::Schedule;
    use std::str::FromStr;

    let full_expr = format!("0 {} *", schedule);
    let schedule = Schedule::from_str(&full_expr).unwrap();

    let next_fire = match schedule.after(&last_tick_time).next() {
        Some(t) => t,
        None => return false,
    };

    if next_fire > now {
        return false;
    }

    if let Some(lf) = last_fire {
        if next_fire <= lf {
            return false;
        }
    }

    true
}

/// Integration test: scheduler correctly injects events via the sender channel.
/// Design reference: test_scheduler_fires_at_cron_time
#[tokio::test]
async fn test_scheduler_fires_at_cron_time() {
    let agents = vec![
        make_agent("sentinel", AgentLayer::Safety, None),
        make_agent("sync", AgentLayer::Core, Some("0 3 * * *")),
        make_agent("reviewer", AgentLayer::Growth, None),
    ];

    let mut scheduler = TimeScheduler::new(&agents, Some("0 2 * * *")).unwrap();

    // Use the event sender to inject a synthetic event (simulates what the
    // background cron tick would do)
    let tx = scheduler.event_sender();

    // Inject a Spawn event for the core agent
    let spawn_def = make_agent("sync", AgentLayer::Core, Some("0 3 * * *"));
    tx.send(ScheduleEvent::Spawn(Box::new(spawn_def)))
        .await
        .unwrap();

    // Inject a CompoundReview event
    tx.send(ScheduleEvent::CompoundReview).await.unwrap();

    // Verify events are received in order
    let event1 = scheduler.next_event().await;
    match event1 {
        ScheduleEvent::Spawn(def) => assert_eq!(def.name, "sync"),
        other => panic!("expected Spawn, got {:?}", other),
    }

    let event2 = scheduler.next_event().await;
    match event2 {
        ScheduleEvent::CompoundReview => {} // expected
        other => panic!("expected CompoundReview, got {:?}", other),
    }
}

/// Integration test: Stop event propagates through scheduler channel.
#[tokio::test]
async fn test_scheduler_stop_event() {
    let agents = vec![make_agent("sentinel", AgentLayer::Safety, None)];
    let mut scheduler = TimeScheduler::new(&agents, None).unwrap();

    let tx = scheduler.event_sender();
    tx.send(ScheduleEvent::Stop {
        agent_name: "sentinel".to_string(),
    })
    .await
    .unwrap();

    let event = scheduler.next_event().await;
    match event {
        ScheduleEvent::Stop { agent_name } => assert_eq!(agent_name, "sentinel"),
        other => panic!("expected Stop, got {:?}", other),
    }
}

/// Integration test: scheduler partitions agents by layer correctly.
#[test]
fn test_scheduler_layer_partitioning() {
    let agents = vec![
        make_agent("safety-1", AgentLayer::Safety, None),
        make_agent("safety-2", AgentLayer::Safety, None),
        make_agent("core-1", AgentLayer::Core, Some("0 1 * * *")),
        make_agent("core-2", AgentLayer::Core, Some("0 2 * * *")),
        make_agent("growth-1", AgentLayer::Growth, None),
    ];
    let scheduler = TimeScheduler::new(&agents, None).unwrap();

    let immediate = scheduler.immediate_agents();
    assert_eq!(immediate.len(), 2);
    assert!(immediate.iter().all(|a| a.layer == AgentLayer::Safety));

    let scheduled = scheduler.scheduled_agents();
    assert_eq!(scheduled.len(), 2);
    assert!(scheduled.iter().all(|(a, _)| a.layer == AgentLayer::Core));
}

// =============================================================================
// Synthetic Time Tests for Cron Scheduling
// =============================================================================
// These tests verify the cron fire-time calculation logic using production
// schedules from terraphim.toml (e.g., "30 0-10 * * *" fires at XX:30)

/// AC1: Agent fires when last_tick_time is just before schedule fire time and agent is inactive.
/// Uses production schedule "30 0-10 * * *" which fires at XX:30.
#[test]
fn test_cron_fires_when_time_matches_schedule() {
    // Schedule "30 0-10 * * *" fires at XX:30
    // At 10:00:00, next fire is 10:30
    // If last_tick_time is 10:00 and now is 10:30, should fire
    let last_tick = dt(2026, 5, 16, 10, 0, 0); // 10:00:00
    let now = dt(2026, 5, 16, 10, 30, 0); // 10:30:00

    let result = should_fire("30 0-10 * * *", last_tick, now, None);
    assert!(
        result,
        "Agent should fire when tick is before fire time and now >= fire time"
    );
}

/// AC2: Agent does NOT fire again within same schedule occurrence (re-trigger prevention).
#[test]
fn test_cron_no_retrigger_same_occurrence() {
    // Schedule fires at 10:30
    // First fire at 10:30, then second check at 10:30:01 (still same tick window)
    let last_tick = dt(2026, 5, 16, 10, 0, 0); // 10:00:00
    let now = dt(2026, 5, 16, 10, 30, 0); // 10:30:00 - first fire

    // First call - should fire
    let first_fire = should_fire("30 0-10 * * *", last_tick, now, None);
    assert!(first_fire, "First call should fire");

    // Second call with same time window and last_fire set to now
    // next_fire after last_tick is 10:30, which is <= last_fire (10:30)
    // so should NOT fire
    let last_fire = dt(2026, 5, 16, 10, 30, 0);
    let second_fire = should_fire("30 0-10 * * *", last_tick, now, Some(last_fire));
    assert!(
        !second_fire,
        "Should NOT fire again within same schedule occurrence"
    );
}

/// AC3: Agent fires again at next schedule occurrence.
#[test]
fn test_cron_fires_next_occurrence() {
    // Use schedule "30 1-11 * * *" which fires at XX:30 for hours 1-11
    // First occurrence: 09:30, second: 10:30
    let last_tick_first = dt(2026, 5, 16, 9, 0, 0); // 9:00:00
    let now_first = dt(2026, 5, 16, 9, 30, 0); // 9:30:00

    let first_fire = should_fire("30 1-11 * * *", last_tick_first, now_first, None);
    assert!(first_fire, "First occurrence should fire at 9:30");

    // Advance time to 10:00 and check for 10:30 fire
    let last_tick_second = dt(2026, 5, 16, 9, 30, 1); // 9:30:01 (after first fire)
    let now_second = dt(2026, 5, 16, 10, 30, 0); // 10:30:00
    let last_fire = dt(2026, 5, 16, 9, 30, 0); // Fire time of first occurrence

    let second_fire = should_fire(
        "30 1-11 * * *",
        last_tick_second,
        now_second,
        Some(last_fire),
    );
    assert!(second_fire, "Should fire at next occurrence (10:30)");
}

/// AC4: Agent is skipped if already fired (active agents check is handled separately).
/// This tests the scheduling logic - actual active check is in check_cron_schedules.
#[test]
fn test_cron_skips_already_fired_agent() {
    // Same scenario: agent already fired at 10:30
    let last_tick = dt(2026, 5, 16, 10, 0, 0);
    let now = dt(2026, 5, 16, 10, 30, 0);
    let last_fire = dt(2026, 5, 16, 10, 30, 0);

    // If last_fire equals the next fire time, should NOT fire
    let result = should_fire("30 0-10 * * *", last_tick, now, Some(last_fire));
    assert!(
        !result,
        "Agent should not fire if already fired at this time"
    );
}

/// AC5: Helper correctly updates internal state.
/// This is validated by the should_fire function working correctly.
#[test]
fn test_fire_time_calculation_with_helper() {
    // Verify the helper function correctly calculates fire times
    let last_tick = dt(2026, 5, 16, 9, 0, 0); // 9:00:00
    let now = dt(2026, 5, 16, 9, 31, 0); // 9:31:00 (after fire)

    // "30 0-10 * * *" should fire at 9:30
    let result = should_fire("30 0-10 * * *", last_tick, now, None);
    assert!(result, "Should fire at 9:30 when checked at 9:31");
}

/// Boundary condition: last_tick_time set exactly at fire time.
#[test]
fn test_cron_boundary_exactly_at_fire_time() {
    // If last_tick_time is exactly at fire time (10:30), what happens?
    // schedule.after(10:30:00) returns the NEXT occurrence (11:30)
    // since 10:30 is already in the past
    let last_tick = dt(2026, 5, 16, 10, 30, 0); // exactly at fire time
    let now = dt(2026, 5, 16, 10, 30, 0); // exactly at fire time

    // With last_tick at 10:30 and now at 10:30, next fire after 10:30 is 11:30
    // 11:30 > 10:30 (now), so should NOT fire
    let result = should_fire("30 0-10 * * *", last_tick, now, None);
    assert!(
        !result,
        "Should NOT fire when last_tick and now are exactly at fire time"
    );
}

/// Boundary condition: last_tick_time is 1 second before fire time.
#[test]
fn test_cron_boundary_one_second_before() {
    let last_tick = dt(2026, 5, 16, 10, 29, 59); // 1 second before fire
    let now = dt(2026, 5, 16, 10, 30, 0); // at fire time

    let result = should_fire("30 0-10 * * *", last_tick, now, None);
    assert!(
        result,
        "Should fire when tick is 1 second before and now is at fire time"
    );
}

/// Test compound review schedule: "0 2 * * *" fires at 02:00 daily.
#[test]
fn test_cron_compound_review_schedule() {
    let last_tick = dt(2026, 5, 16, 1, 0, 0); // 1:00 AM
    let now = dt(2026, 5, 16, 2, 0, 0); // 2:00 AM

    let result = should_fire("0 2 * * *", last_tick, now, None);
    assert!(result, "Compound review should fire at 2:00 AM");
}

/// Test compound review re-trigger prevention.
#[test]
fn test_cron_compound_review_no_retrigger() {
    let last_tick = dt(2026, 5, 16, 1, 0, 0);
    let now = dt(2026, 5, 16, 2, 0, 0);
    let last_fire = dt(2026, 5, 16, 2, 0, 0);

    let result = should_fire("0 2 * * *", last_tick, now, Some(last_fire));
    assert!(
        !result,
        "Compound review should NOT re-trigger at same time"
    );
}

/// Test multiple schedule occurrences: "45 0-10 * * *" fires at XX:45
#[test]
fn test_cron_multiple_occurrences() {
    // At 1:00, next fire for "45 0-10 * * *" is 1:45
    let last_tick = dt(2026, 5, 16, 1, 0, 0);
    let now = dt(2026, 5, 16, 1, 45, 0);

    let result = should_fire("45 0-10 * * *", last_tick, now, None);
    assert!(result, "Should fire at 1:45");

    // At 1:46, should NOT fire again at 1:45
    let now_late = dt(2026, 5, 16, 1, 46, 0);
    let last_fire = dt(2026, 5, 16, 1, 45, 0);

    let result_no_fire = should_fire("45 0-10 * * *", last_tick, now_late, Some(last_fire));
    assert!(
        !result_no_fire,
        "Should NOT fire again after already firing at 1:45"
    );
}

/// Test production schedule "35 0-10 * * *" (test-guardian)
#[test]
fn test_cron_production_schedule_test_guardian() {
    let last_tick = dt(2026, 5, 16, 0, 0, 0); // midnight
    let now = dt(2026, 5, 16, 0, 35, 0); // 00:35

    let result = should_fire("35 0-10 * * *", last_tick, now, None);
    assert!(result, "test-guardian schedule should fire at 00:35");
}