ai-dispatch 11.1.0

Multi-AI CLI team orchestrator
// Hard-cap wrap-up regression tests through the background reaper.
// Deps: parent task/spec fixtures, Store, input_signal.

use super::*;
use crate::types::EventKind;

fn poll_at(age: i64, cap: u64, agent: AgentKind) -> (Store, Task) {
    let store = Store::open_memory().expect("store");
    let mut task = task("t-wrap", TaskStatus::Running);
    task.agent = agent;
    task.read_only = false;
    task.created_at = Local::now() - Duration::seconds(age);
    store.insert_task(&task).expect("task");
    store
        .insert_event(&crate::types::TaskEvent {
            task_id: task.id.clone(),
            timestamp: Local::now(),
            event_kind: EventKind::Reasoning,
            detail: "Agent is working".into(),
            metadata: None,
        })
        .expect("progress");
    let mut spec = spec(task.id.as_str());
    spec.agent_name = agent.as_str().into();
    spec.max_duration_secs = Some(cap);
    spec.idle_timeout_secs = Some(10_000);
    save_spec(&spec).expect("spec");
    check_zombie_tasks_with(&store, |_| true).expect("reap");
    (store, task)
}

#[test]
fn ninety_percent_cap_steers_commit_and_report_once_refusal_regression() {
    let _home = setup_home();
    let (store, task) = poll_at(540, 600, AgentKind::Gemini);
    let messages = store.list_messages_for_task(task.id.as_str()).expect("messages");
    assert_eq!(messages.len(), 1);
    assert_eq!(messages[0].source.as_str(), "steer");
    assert!(messages[0].content.contains("commit"));
    assert!(messages[0].content.contains("report"));
    assert!(
        store.pending_inbound_for_task(task.id.as_str())
            .expect("pending steer")
            .len() == 1
    );
    check_zombie_tasks_with(&store, |_| true).expect("reap again");
    assert_eq!(
        store
            .list_messages_for_task(task.id.as_str())
            .expect("messages")
            .len(),
        1
    );
    let events = store.get_events(task.id.as_str()).expect("events");
    let queued = events.iter().find(|event| event.detail.contains("wrap-up")).unwrap();
    assert_eq!(queued.metadata.as_ref().unwrap()["input_delivery"], "queued");
    assert!(queued.metadata.as_ref().unwrap().get("steered").is_none());
    assert_eq!(
        events
            .iter()
            .filter(|e| e.event_kind == EventKind::Milestone && e.detail.contains("wrap-up"))
            .count(),
        1
    );
    assert_eq!(
        store
            .get_task(task.id.as_str())
            .expect("task")
            .expect("task")
            .status,
        TaskStatus::Running
    );
}

#[test]
fn wrap_up_skips_early_short_and_unsupported_runs_refusal_regression() {
    for (age, cap, agent) in [
        (539, 600, AgentKind::Gemini),
        (550, 599, AgentKind::Gemini),
        (540, 600, AgentKind::Codex),
        (540, 600, AgentKind::Claude),
        (540, 600, AgentKind::Grok),
    ] {
        let _home = setup_home();
        let (store, task) = poll_at(age, cap, agent);
        assert!(
            store
                .list_messages_for_task(task.id.as_str())
                .expect("messages")
                .is_empty()
        );
        assert!(
            crate::input_signal::take_steer(task.id.as_str())
                .expect("steer")
                .is_none()
        );
    }
}

#[test]
fn read_only_reaper_never_queues_wrap_up_fix_regression() {
    let _home = setup_home();
    let store = Store::open_memory().expect("store");
    let mut task = task("t-read-only-wrap", TaskStatus::Running);
    task.agent = AgentKind::Gemini;
    task.read_only = true;
    task.created_at = Local::now() - Duration::seconds(540);
    store.insert_task(&task).expect("task");
    let mut spec = spec(task.id.as_str());
    spec.read_only = true;
    spec.max_duration_secs = Some(600);
    spec.idle_timeout_secs = Some(10_000);
    save_spec(&spec).expect("spec");
    std::fs::write(crate::paths::log_path(task.id.as_str()), "agent working").expect("progress");
    check_zombie_tasks_with(&store, |_| true).expect("reap");
    assert_eq!(store.get_task(task.id.as_str()).expect("task").expect("task").status,
        TaskStatus::Running);
    assert!(store.get_events(task.id.as_str()).expect("events").is_empty());
    assert!(store.list_messages_for_task(task.id.as_str()).expect("messages").is_empty());
    assert!(!crate::paths::steer_signal_path(task.id.as_str()).exists());
}

#[test]
fn wrap_up_delivery_failure_can_recover_once_fix_regression() {
    let _home = setup_home();
    let store = Store::open_memory().expect("store");
    let mut task = task("t-wrap-recovery", TaskStatus::Running);
    task.agent = AgentKind::Gemini;
    task.read_only = false;
    store.insert_task(&task).expect("task");
    let signal = crate::paths::steer_signal_path(task.id.as_str());
    store.db().execute_batch(
        "CREATE TRIGGER fail_wrap_insert BEFORE INSERT ON task_messages BEGIN
           SELECT RAISE(ABORT, 'synthetic publication failure'); END;",
    ).expect("blocked publication");
    assert!(crate::cmd::steer::wrap_up_before_timeout(&store, task.id.as_str(), 540, 600).is_err());
    store.db().execute_batch("DROP TRIGGER fail_wrap_insert;").expect("unblock");
    assert!(crate::cmd::steer::wrap_up_before_timeout(&store, task.id.as_str(), 541, 600)
        .expect("retry"));
    assert_eq!(store.pending_inbound_for_task(task.id.as_str()).expect("pending steer").len(), 1);
    assert!(!crate::cmd::steer::wrap_up_before_timeout(&store, task.id.as_str(), 542, 600)
        .expect("duplicate poll"));
    assert_eq!(store.get_events(task.id.as_str()).expect("events").len(), 1);
    assert_eq!(store.list_messages_for_task(task.id.as_str()).expect("messages").len(), 1);
    assert!(!signal.exists());
}

#[test]
fn legacy_wrap_up_record_prevents_duplicate_queueing() {
    let _home = setup_home();
    let store = Store::open_memory().unwrap();
    let mut task = task("t-legacy-wrap", TaskStatus::Running);
    task.agent = AgentKind::Gemini;
    store.insert_task(&task).unwrap();
    store.insert_event(&crate::types::TaskEvent {
        task_id: task.id.clone(), timestamp: Local::now(), event_kind: EventKind::Milestone,
        detail: "Hard-cap wrap-up: committing work in progress and writing the report requested".into(),
        metadata: Some(serde_json::json!({
            "steered": true, "hard_cap_wrap_up": true
        })),
    }).unwrap();
    assert!(!crate::cmd::steer::wrap_up_before_timeout(&store, task.id.as_str(), 540, 600).unwrap());
    assert!(store.list_messages_for_task(task.id.as_str()).unwrap().is_empty());
    let events = store.get_events(task.id.as_str()).unwrap();
    assert_eq!(events[0].metadata.as_ref().unwrap()["steered"], true);
}