diff options
Diffstat (limited to 'src/terminal')
| -rw-r--r-- | src/terminal/state.rs | 195 |
1 files changed, 192 insertions, 3 deletions
diff --git a/src/terminal/state.rs b/src/terminal/state.rs index 632558fd..815a8b80 100644 --- a/src/terminal/state.rs +++ b/src/terminal/state.rs @@ -1,8 +1,6 @@ use std::collections::HashMap; use std::path::PathBuf; -#[cfg(any(windows, test))] -use std::time::Duration; -use std::time::Instant; +use std::time::{Duration, Instant}; // Effective state arbitration is intentionally centralized here. Full lifecycle // Herdr hook integrations are hook-authoritative while live; screen recovery @@ -46,6 +44,22 @@ struct StaleFullLifecycleHookSession { session_ref: crate::agent_resume::AgentSessionRef, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ManagedAgentPhase { + Pending { + ready_after: Option<Instant>, + deadline: Instant, + }, + Active, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct ManagedAgent { + kind: Agent, + observed_expected: bool, + phase: ManagedAgentPhase, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct EffectiveStateChange { pub previous_agent_label: Option<String>, @@ -89,6 +103,7 @@ pub struct TerminalState { pub terminal_title: Option<String>, pub manual_label: Option<String>, pub agent_name: Option<String>, + managed_agent: Option<ManagedAgent>, hook_report_sequences: HashMap<String, u64>, suppressed_full_lifecycle_hook_reports: HashMap<String, SuppressedFullLifecycleHookReport>, stale_full_lifecycle_hook_sessions: HashMap<String, Vec<StaleFullLifecycleHookSession>>, @@ -119,6 +134,7 @@ impl TerminalState { terminal_title: None, manual_label: None, agent_name: None, + managed_agent: None, hook_report_sequences: HashMap::new(), suppressed_full_lifecycle_hook_reports: HashMap::new(), stale_full_lifecycle_hook_sessions: HashMap::new(), @@ -1307,8 +1323,125 @@ impl TerminalState { self.agent_name = (!name.is_empty()).then_some(name); } + pub fn begin_managed_agent( + &mut self, + name: String, + kind: Agent, + now: Instant, + settle_delay: Duration, + timeout: Duration, + ) { + self.set_agent_name(name); + self.managed_agent = Some(ManagedAgent { + kind, + observed_expected: false, + phase: ManagedAgentPhase::Pending { + ready_after: Some(now.checked_add(settle_delay).unwrap_or(now)), + deadline: now.checked_add(timeout).unwrap_or(now), + }, + }); + } + + pub fn managed_agent_launch_pending(&self) -> bool { + self.managed_agent + .is_some_and(|managed| matches!(managed.phase, ManagedAgentPhase::Pending { .. })) + } + + pub fn managed_agent_interactive_ready(&self) -> bool { + self.managed_agent.is_some_and(|managed| { + matches!(managed.phase, ManagedAgentPhase::Active) + && self.effective_known_agent() == Some(managed.kind) + && matches!(self.state, AgentState::Idle | AgentState::Blocked) + }) + } + + pub fn managed_agent_kind(&self) -> Option<Agent> { + self.managed_agent.map(|managed| managed.kind) + } + + pub fn next_managed_agent_deadline(&self) -> Option<Instant> { + let ManagedAgentPhase::Pending { + ready_after, + deadline, + } = self.managed_agent?.phase + else { + return None; + }; + Some(ready_after.unwrap_or(deadline).min(deadline)) + } + + pub fn reconcile_managed_agent_at(&mut self, now: Instant, process_exited: bool) -> bool { + let Some(managed) = self.managed_agent else { + return false; + }; + let known_agent = self.effective_known_agent(); + let observed_expected = managed.observed_expected || known_agent == Some(managed.kind); + let clear = process_exited + || known_agent.is_some_and(|agent| agent != managed.kind) + || matches!(managed.phase, ManagedAgentPhase::Pending { .. }) + && observed_expected + && known_agent.is_none(); + if clear { + self.managed_agent = None; + self.agent_name = None; + return true; + } + if let ManagedAgentPhase::Pending { + ready_after, + deadline, + } = managed.phase + { + if now >= deadline { + self.managed_agent = None; + self.agent_name = None; + return true; + } + if ready_after.is_none_or(|ready_after| now >= ready_after) { + if known_agent == Some(managed.kind) + && matches!(self.state, AgentState::Idle | AgentState::Blocked) + { + self.managed_agent = Some(ManagedAgent { + kind: managed.kind, + observed_expected: true, + phase: ManagedAgentPhase::Active, + }); + return true; + } + if ready_after.is_some() { + self.managed_agent = Some(ManagedAgent { + kind: managed.kind, + observed_expected, + phase: ManagedAgentPhase::Pending { + ready_after: None, + deadline, + }, + }); + return true; + } + } + if observed_expected != managed.observed_expected { + self.managed_agent = Some(ManagedAgent { + observed_expected, + ..managed + }); + return true; + } + } + false + } + + pub fn restore_managed_agent(&mut self, name: String, kind: Agent) { + self.set_agent_name(name); + self.managed_agent = Some(ManagedAgent { + kind, + observed_expected: true, + phase: ManagedAgentPhase::Active, + }); + } + pub fn clear_agent_name(&mut self) { self.agent_name = None; + self.managed_agent = None; } pub fn clear_agent_runtime_identity_after_respawn(&mut self) { @@ -1414,6 +1547,62 @@ mod tests { } #[test] + fn managed_agent_activates_only_after_matching_settled_detection() { + let mut terminal = test_terminal(); + let now = Instant::now(); + terminal.begin_managed_agent( + "reviewer".into(), + Agent::Pi, + now, + Duration::from_millis(100), + Duration::from_secs(1), + ); + terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle); + + assert!(terminal.managed_agent_launch_pending()); + assert!(!terminal.managed_agent_interactive_ready()); + assert!(terminal.reconcile_managed_agent_at(now + Duration::from_millis(100), false)); + assert!(!terminal.managed_agent_launch_pending()); + assert!(terminal.managed_agent_interactive_ready()); + assert_eq!(terminal.agent_name.as_deref(), Some("reviewer")); + + terminal.set_detected_state(None, AgentState::Unknown); + assert!(!terminal.reconcile_managed_agent_at(now + Duration::from_millis(101), false)); + assert_eq!(terminal.agent_name.as_deref(), Some("reviewer")); + assert!(terminal.reconcile_managed_agent_at(now + Duration::from_millis(102), true)); + assert_eq!(terminal.agent_name, None); + } + + #[test] + fn managed_agent_mismatch_and_timeout_release_name() { + let now = Instant::now(); + let mut mismatch = test_terminal(); + mismatch.begin_managed_agent( + "reviewer".into(), + Agent::Pi, + now, + Duration::ZERO, + Duration::from_secs(1), + ); + mismatch.set_detected_state(Some(Agent::Codex), AgentState::Idle); + assert!(mismatch.reconcile_managed_agent_at(now, false)); + assert_eq!(mismatch.agent_name, None); + assert_eq!(mismatch.managed_agent_kind(), None); + + let mut timed_out = test_terminal(); + timed_out.begin_managed_agent( + "reviewer".into(), + Agent::Pi, + now, + Duration::from_millis(10), + Duration::from_millis(20), + ); + assert!(timed_out.reconcile_managed_agent_at(now + Duration::from_millis(20), false)); + assert_eq!(timed_out.agent_name, None); + assert_eq!(timed_out.managed_agent_kind(), None); + } + + #[test] fn stabilization_uses_raw_policy_state() { let detection = AgentDetection { state: AgentState::Idle, |
