ai-dispatch 10.17.2

Multi-AI CLI team orchestrator
// Idle policy for PTY-backed reply and unstick handling.
// Exports idle policy, output classification, and monitor status helpers.
// Deps: Store, task status, std::time, and the resolved timeout policy.

use anyhow::Result;
use std::time::{Duration, Instant};

use crate::store::Store;
use crate::types::TaskStatus;

const DEFAULT_NUDGE_MESSAGE: &str = "Task appears idle. Status update please?";

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum MonitorTaskStatus {
    Running,
    AwaitingInput,
    Stalled,
    Inactive,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum IdleAction {
    None,
    WarnEvent,
    SendNudge(String),
    Escalate,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct IdleDetector {
    pub(crate) warn_after: Duration,
    pub(crate) nudge_after: Duration,
    pub(crate) escalate_after: Duration,
}

impl Default for IdleDetector {
    fn default() -> Self {
        Self {
            warn_after: Duration::from_secs(crate::timeout_policy::DEFAULT_WARN_SECS),
            nudge_after: Duration::from_secs(crate::timeout_policy::DEFAULT_NUDGE_SECS),
            escalate_after: Duration::from_secs(crate::timeout_policy::DEFAULT_ESCALATE_SECS),
        }
    }
}

impl IdleDetector {
    pub(crate) fn from_policy(policy: crate::timeout_policy::TimeoutPolicy) -> Self {
        Self {
            warn_after: policy.nudge_ladder.warn,
            nudge_after: policy.nudge_ladder.nudge,
            escalate_after: policy.nudge_ladder.escalate,
        }
    }

    pub(crate) fn tick(
        &self,
        last_output_time: Instant,
        status: MonitorTaskStatus,
        idle_nudged: bool,
        accepts_nudge: bool,
    ) -> IdleAction {
        let idle_for = last_output_time.elapsed();
        if status != MonitorTaskStatus::Running || idle_for < self.warn_after {
            return IdleAction::None;
        }
        if !accepts_nudge {
            return if idle_for >= self.escalate_after {
                IdleAction::Escalate
            } else if idle_for >= self.nudge_after {
                IdleAction::None
            } else {
                IdleAction::WarnEvent
            };
        }
        if idle_for >= self.escalate_after {
            return if idle_nudged {
                IdleAction::Escalate
            } else {
                IdleAction::SendNudge(default_nudge_message())
            };
        }
        if idle_for >= self.nudge_after {
            return if idle_nudged {
                IdleAction::None
            } else {
                IdleAction::SendNudge(default_nudge_message())
            };
        }
        IdleAction::WarnEvent
    }
}

pub(crate) fn default_nudge_message() -> String {
    DEFAULT_NUDGE_MESSAGE.to_string()
}

/// True when a flushed stream line is meaningful agent output.
///
/// Raw/unparsed output still proves the agent is alive and working, so it must
/// refresh the idle clock. Lines that carry no text after terminal-escape
/// stripping are terminal-control noise (spinners, cursor hides) and must not
/// keep a wedged process alive.
pub(crate) fn is_agent_output(line: &str) -> bool {
    !line.trim().is_empty()
}

pub(crate) fn load_monitor_status(store: &Store, task_id: &str) -> Result<MonitorTaskStatus> {
    let status = store.get_task(task_id)?.map(|task| task.status);
    Ok(match status {
        Some(TaskStatus::Running) => MonitorTaskStatus::Running,
        Some(TaskStatus::AwaitingInput) => MonitorTaskStatus::AwaitingInput,
        Some(TaskStatus::Stalled) => MonitorTaskStatus::Stalled,
        _ => MonitorTaskStatus::Inactive,
    })
}

/// True when a stream line is only an echo of text aid itself wrote to the PTY.
/// Those echoes must not reset the idle / hung clocks (see idle-watchdog self-nudge).
pub(crate) fn take_inbound_echo(pending: &mut Vec<String>, line: &str) -> bool {
    let trimmed = line.trim();
    if trimmed.is_empty() {
        return false;
    }
    if let Some(idx) = pending.iter().position(|msg| msg.trim() == trimmed) {
        pending.remove(idx);
        true
    } else {
        false
    }
}

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

    fn detector() -> IdleDetector {
        IdleDetector {
            warn_after: Duration::from_secs(10),
            nudge_after: Duration::from_secs(20),
            escalate_after: Duration::from_secs(30),
        }
    }

    #[test]
    fn idle_thresholds_follow_expected_transitions() {
        assert_eq!(
            detector().tick(
                Instant::now() - Duration::from_secs(9),
                MonitorTaskStatus::Running,
                false,
                true,
            ),
            IdleAction::None
        );
        assert_eq!(
            detector().tick(
                Instant::now() - Duration::from_secs(10),
                MonitorTaskStatus::Running,
                false,
                true,
            ),
            IdleAction::WarnEvent
        );
        assert_eq!(
            detector().tick(
                Instant::now() - Duration::from_secs(20),
                MonitorTaskStatus::Running,
                false,
                true,
            ),
            IdleAction::SendNudge(default_nudge_message())
        );
        assert_eq!(
            detector().tick(
                Instant::now() - Duration::from_secs(30),
                MonitorTaskStatus::Running,
                true,
                true,
            ),
            IdleAction::Escalate
        );
    }

    #[test]
    fn non_running_states_never_emit_idle_actions() {
        for status in [
            MonitorTaskStatus::AwaitingInput,
            MonitorTaskStatus::Stalled,
            MonitorTaskStatus::Inactive,
        ] {
            assert_eq!(
                detector().tick(Instant::now() - Duration::from_secs(60), status, true, true),
                IdleAction::None
            );
        }
    }

    #[test]
    fn tick_skips_nudge_for_agents_that_do_not_accept_nudges() {
        assert_eq!(
            detector().tick(
                Instant::now() - Duration::from_secs(20),
                MonitorTaskStatus::Running,
                false,
                false,
            ),
            IdleAction::None
        );
    }

    #[test]
    fn tick_escalates_without_prior_nudge_when_agent_does_not_accept_nudges() {
        assert_eq!(
            detector().tick(
                Instant::now() - Duration::from_secs(30),
                MonitorTaskStatus::Running,
                false,
                false,
            ),
            IdleAction::Escalate
        );
    }

    #[test]
    fn tick_still_sends_nudge_for_agents_that_accept_nudges() {
        assert_eq!(
            detector().tick(
                Instant::now() - Duration::from_secs(20),
                MonitorTaskStatus::Running,
                false,
                true,
            ),
            IdleAction::SendNudge(default_nudge_message())
        );
    }

    #[test]
    fn take_inbound_echo_matches_exact_trim_once() {
        let mut pending = vec![default_nudge_message(), "other".to_string()];
        assert!(take_inbound_echo(&mut pending, "  Task appears idle. Status update please?  "));
        assert_eq!(pending, vec!["other".to_string()]);
        assert!(!take_inbound_echo(&mut pending, &default_nudge_message()));
        assert!(take_inbound_echo(&mut pending, "other"));
        assert!(pending.is_empty());
    }
}