diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-19 03:32:53 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-19 03:32:53 +0300 |
| commit | 26c5f97a908c1ebe4228302d1b9e8c5423b2ab75 (patch) | |
| tree | 43b7aaff6796451ffe2d13d9503ed58bf604e0d7 /src | |
| parent | 9f4f1630893f74697a2097ef11bf5bf512a37850 (diff) | |
fix: reanchor lifecycle hook generations
refs #684
Diffstat (limited to 'src')
| -rw-r--r-- | src/config/sound.rs | 1 | ||||
| -rw-r--r-- | src/detect/manifest.rs | 2 | ||||
| -rw-r--r-- | src/detect/manifest/tests.rs | 23 | ||||
| -rw-r--r-- | src/detect/mod.rs | 26 | ||||
| -rw-r--r-- | src/pane.rs | 97 | ||||
| -rw-r--r-- | src/terminal/state.rs | 866 |
6 files changed, 881 insertions, 134 deletions
diff --git a/src/config/sound.rs b/src/config/sound.rs index 25376f2a..8624881a 100644 --- a/src/config/sound.rs +++ b/src/config/sound.rs @@ -126,6 +126,7 @@ impl AgentSoundOverrides { Some(Agent::Devin) => self.devin, Some(Agent::Antigravity) => self.agy, Some(Agent::Cline) => self.cline, + Some(Agent::Omp) => AgentSoundSetting::Default, Some(Agent::OpenCode) => self.open_code, Some(Agent::GithubCopilot) => self.github_copilot, Some(Agent::Kimi) => self.kimi, diff --git a/src/detect/manifest.rs b/src/detect/manifest.rs index a6da1277..f798e871 100644 --- a/src/detect/manifest.rs +++ b/src/detect/manifest.rs @@ -288,7 +288,7 @@ fn manifest_cache() -> &'static RwLock<ManifestCache> { fn build_manifest_cache() -> ManifestCache { ManifestCache { - manifests: Agent::ALL + manifests: Agent::SCREEN_MANIFEST_AGENTS .into_iter() .map(|agent| (agent, load_manifest_uncached(agent))) .collect(), diff --git a/src/detect/manifest/tests.rs b/src/detect/manifest/tests.rs index 2570aa03..30a94220 100644 --- a/src/detect/manifest/tests.rs +++ b/src/detect/manifest/tests.rs @@ -291,28 +291,7 @@ fn detection_uses_cached_manifest_until_explicit_reload() { #[test] fn all_bundled_manifests_parse_and_validate() { - let agents = [ - Agent::Pi, - Agent::Claude, - Agent::Codex, - Agent::Gemini, - Agent::Cursor, - Agent::Devin, - Agent::Antigravity, - Agent::Cline, - Agent::OpenCode, - Agent::GithubCopilot, - Agent::Kimi, - Agent::Kiro, - Agent::Droid, - Agent::Amp, - Agent::Grok, - Agent::Hermes, - Agent::Kilo, - Agent::Qodercli, - ]; - - for agent in agents { + for agent in Agent::SCREEN_MANIFEST_AGENTS { assert!( bundled_manifest(agent).is_some(), "missing bundled manifest for {}", diff --git a/src/detect/mod.rs b/src/detect/mod.rs index 66443877..c8292ab2 100644 --- a/src/detect/mod.rs +++ b/src/detect/mod.rs @@ -49,6 +49,7 @@ pub enum Agent { Devin, Antigravity, Cline, + Omp, OpenCode, GithubCopilot, Kimi, @@ -62,7 +63,7 @@ pub enum Agent { } impl Agent { - pub const ALL: [Self; 18] = [ + pub const SCREEN_MANIFEST_AGENTS: [Self; 18] = [ Self::Pi, Self::Claude, Self::Codex, @@ -94,6 +95,7 @@ pub fn agent_label(agent: Agent) -> &'static str { Agent::Devin => "devin", Agent::Antigravity => "agy", Agent::Cline => "cline", + Agent::Omp => "omp", Agent::OpenCode => "opencode", Agent::GithubCopilot => "copilot", Agent::Kimi => "kimi", @@ -118,6 +120,7 @@ pub fn parse_agent_label(agent: &str) -> Option<Agent> { "devin" | "devin-cli" | "devin cli" => Some(Agent::Devin), "agy" | "antigravity" | "antigravity-cli" => Some(Agent::Antigravity), "cline" => Some(Agent::Cline), + "omp" => Some(Agent::Omp), "opencode" | "open-code" => Some(Agent::OpenCode), "copilot" | "github-copilot" | "ghcs" => Some(Agent::GithubCopilot), "kimi" | "kimi-code" | "kimi code" => Some(Agent::Kimi), @@ -146,6 +149,7 @@ pub fn identify_agent(process_name: &str) -> Option<Agent> { "devin" | "devin-cli" | "devin cli" => Some(Agent::Devin), "agy" | "antigravity" | "antigravity-cli" => Some(Agent::Antigravity), "cline" => Some(Agent::Cline), + "omp" => Some(Agent::Omp), "opencode" | "open-code" => Some(Agent::OpenCode), "copilot" | "github-copilot" | "ghcs" => Some(Agent::GithubCopilot), "kimi" | "kimi-code" | "kimi code" => Some(Agent::Kimi), @@ -592,6 +596,7 @@ mod tests { assert_eq!(identify_agent("agy"), Some(Agent::Antigravity)); assert_eq!(identify_agent("antigravity-cli"), Some(Agent::Antigravity)); assert_eq!(identify_agent("cline"), Some(Agent::Cline)); + assert_eq!(identify_agent("omp"), Some(Agent::Omp)); assert_eq!(identify_agent("opencode"), Some(Agent::OpenCode)); assert_eq!(identify_agent("opencode.exe"), Some(Agent::OpenCode)); assert_eq!(identify_agent("kimi"), Some(Agent::Kimi)); @@ -616,6 +621,7 @@ mod tests { assert_eq!(parse_agent_label("devin-cli"), Some(Agent::Devin)); assert_eq!(parse_agent_label("agy"), Some(Agent::Antigravity)); assert_eq!(parse_agent_label("antigravity"), Some(Agent::Antigravity)); + assert_eq!(parse_agent_label("omp"), Some(Agent::Omp)); assert_eq!(parse_agent_label("opencode.exe"), Some(Agent::OpenCode)); assert_eq!(parse_agent_label("copilot"), Some(Agent::GithubCopilot)); assert_eq!(parse_agent_label("kimi-code"), Some(Agent::Kimi)); @@ -637,6 +643,7 @@ mod tests { assert_eq!(agent_label(Agent::OpenCode), "opencode"); assert_eq!(agent_label(Agent::Devin), "devin"); assert_eq!(agent_label(Agent::Antigravity), "agy"); + assert_eq!(agent_label(Agent::Omp), "omp"); assert_eq!(agent_label(Agent::Kiro), "kiro"); assert_eq!(agent_label(Agent::Grok), "grok"); assert_eq!(agent_label(Agent::Hermes), "hermes"); @@ -759,6 +766,23 @@ mod tests { } #[test] + fn identify_agent_in_job_detects_bun_wrapped_omp() { + let job = crate::platform::ForegroundJob { + process_group_id: 123, + processes: vec![foreground_process( + 123, + "bun", + &["bun", "/home/can/.bun/bin/omp"], + )], + }; + + assert_eq!( + identify_agent_in_job(&job), + Some((Agent::Omp, "omp".to_string())) + ); + } + + #[test] fn identify_agent_in_job_detects_windows_cmd_wrapped_codex() { let job = crate::platform::ForegroundJob { process_group_id: 123, diff --git a/src/pane.rs b/src/pane.rs index 7cb3e12b..468eaad6 100644 --- a/src/pane.rs +++ b/src/pane.rs @@ -318,10 +318,13 @@ fn foreground_group_changed( fn should_skip_process_probe_for_lifecycle_authority( full_lifecycle_authority_active: bool, - process_exit_pending: bool, - release_pending: bool, + input: ProcessProbeInput, ) -> bool { - full_lifecycle_authority_active && !process_exit_pending && !release_pending + full_lifecycle_authority_active + && !input.pending_foreground_shell_clear + && input.suppressed_agent.is_none() + && input.has_process_probe + && !foreground_group_changed(input.foreground_pgid, input.last_foreground_pgid) } fn should_probe_foreground_job(input: ProcessProbeInput) -> bool { @@ -518,9 +521,6 @@ fn spawn_basic_detection_task( let pid = child_pid.load(Ordering::Acquire); let mut agent_changed = false; let mut agent = agent_presence.current_agent(); - let process_exit_pending = pending_foreground_shell_clear - && agent.is_some() - && !foreground_shell_exit_reported; let lifecycle_authority_active = full_lifecycle_authority_active.load(Ordering::Acquire); let foreground_pgid = (pid > 0) @@ -528,13 +528,8 @@ fn spawn_basic_detection_task( .flatten(); let process_group_changed = foreground_group_changed(foreground_pgid, last_foreground_pgid); - let should_check_process = pid > 0 - && !should_skip_process_probe_for_lifecycle_authority( - lifecycle_authority_active, - process_exit_pending, - suppressed_agent.is_some(), - ) - && should_probe_foreground_job(ProcessProbeInput { + let should_check_process = pid > 0 && { + let process_probe_input = ProcessProbeInput { current_agent: agent, suppressed_agent, foreground_pgid, @@ -545,7 +540,12 @@ fn spawn_basic_detection_task( pending_foreground_shell_clear, pending_restore_probe: false, elapsed_since_process_check: now.duration_since(last_process_check), - }); + }; + !should_skip_process_probe_for_lifecycle_authority( + lifecycle_authority_active, + process_probe_input, + ) && should_probe_foreground_job(process_probe_input) + }; if should_check_process { last_process_check = now; @@ -1870,9 +1870,6 @@ impl PaneRuntime { release_was_active = suppressed_agent.is_some(); let pid = child_pid.load(Ordering::Acquire); let mut agent = agent_presence.current_agent(); - let process_exit_pending = pending_foreground_shell_clear - && agent.is_some() - && !foreground_shell_exit_reported; let lifecycle_authority_active = full_lifecycle_authority_active_for_task.load(Ordering::Acquire); let foreground_pgid = (pid > 0) @@ -1880,13 +1877,8 @@ impl PaneRuntime { .flatten(); let process_group_changed = foreground_group_changed(foreground_pgid, last_foreground_pgid); - let should_check_process = pid > 0 - && !should_skip_process_probe_for_lifecycle_authority( - lifecycle_authority_active, - process_exit_pending, - suppressed_agent.is_some(), - ) - && should_probe_foreground_job(ProcessProbeInput { + let should_check_process = pid > 0 && { + let process_probe_input = ProcessProbeInput { current_agent: agent, suppressed_agent, foreground_pgid, @@ -1897,7 +1889,12 @@ impl PaneRuntime { pending_foreground_shell_clear, pending_restore_probe, elapsed_since_process_check: now.duration_since(last_process_check), - }); + }; + !should_skip_process_probe_for_lifecycle_authority( + lifecycle_authority_active, + process_probe_input, + ) && should_probe_foreground_job(process_probe_input) + }; let mut agent_changed = false; if should_check_process { @@ -3104,22 +3101,62 @@ mod tests { } #[test] - fn lifecycle_authority_skips_normal_process_probe() { + fn lifecycle_authority_skips_stable_routine_process_probe() { assert!(should_skip_process_probe_for_lifecycle_authority( - true, false, false + true, + ProcessProbeInput { + current_agent: Some(Agent::Pi), + elapsed_since_process_check: PROCESS_RECHECK_IDENTIFIED, + ..process_probe_input() + } )); assert!(!should_skip_process_probe_for_lifecycle_authority( - false, false, false + false, + ProcessProbeInput { + current_agent: Some(Agent::Pi), + elapsed_since_process_check: PROCESS_RECHECK_IDENTIFIED, + ..process_probe_input() + } )); } #[test] fn lifecycle_authority_preserves_process_exit_and_release_probes() { assert!(!should_skip_process_probe_for_lifecycle_authority( - true, true, false + true, + ProcessProbeInput { + current_agent: Some(Agent::Pi), + pending_foreground_shell_clear: true, + ..process_probe_input() + } )); assert!(!should_skip_process_probe_for_lifecycle_authority( - true, false, true + true, + ProcessProbeInput { + current_agent: Some(Agent::Pi), + suppressed_agent: Some(Agent::Pi), + ..process_probe_input() + } + )); + } + + #[test] + fn lifecycle_authority_preserves_initial_and_foreground_group_change_probes() { + assert!(!should_skip_process_probe_for_lifecycle_authority( + true, + ProcessProbeInput { + current_agent: None, + has_process_probe: false, + ..process_probe_input() + } + )); + assert!(!should_skip_process_probe_for_lifecycle_authority( + true, + ProcessProbeInput { + current_agent: Some(Agent::Pi), + foreground_pgid: Some(43), + ..process_probe_input() + } )); } diff --git a/src/terminal/state.rs b/src/terminal/state.rs index b1c0640c..01b6bdfe 100644 --- a/src/terminal/state.rs +++ b/src/terminal/state.rs @@ -32,6 +32,19 @@ struct SuppressedFullLifecycleHookReport { agent_label: String, session_ref: Option<crate::agent_resume::AgentSessionRef>, observed_at: Instant, + reason: FullLifecycleHookSuppressionReason, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum FullLifecycleHookSuppressionReason { + HookClear, + ProcessExit, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct StaleFullLifecycleHookSession { + agent_label: String, + session_ref: crate::agent_resume::AgentSessionRef, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -71,6 +84,7 @@ pub struct TerminalState { pub agent_name: Option<String>, hook_report_sequences: HashMap<String, u64>, suppressed_full_lifecycle_hook_reports: HashMap<String, SuppressedFullLifecycleHookReport>, + stale_full_lifecycle_hook_sessions: HashMap<String, Vec<StaleFullLifecycleHookSession>>, metadata_report_sequences: HashMap<String, u64>, pub state: AgentState, pub last_agent_state_change_seq: Option<u64>, @@ -96,6 +110,7 @@ impl TerminalState { agent_name: None, hook_report_sequences: HashMap::new(), suppressed_full_lifecycle_hook_reports: HashMap::new(), + stale_full_lifecycle_hook_sessions: HashMap::new(), metadata_report_sequences: HashMap::new(), state: AgentState::Unknown, last_agent_state_change_seq: None, @@ -237,7 +252,16 @@ impl TerminalState { crate::detect::parse_agent_label(&authority.agent_label) == agent }) { - self.suppress_current_full_lifecycle_hook_authority(); + let cleared_source = self + .hook_authority + .as_ref() + .map(|authority| authority.source.clone()); + self.suppress_current_full_lifecycle_hook_authority( + FullLifecycleHookSuppressionReason::ProcessExit, + ); + if let Some(source) = cleared_source { + self.hook_report_sequences.remove(&source); + } self.hook_authority = None; } if self.hook_authority_not_newer_than(now) @@ -249,7 +273,9 @@ impl TerminalState { == previous_detected_agent }))) { - self.suppress_current_full_lifecycle_hook_authority(); + self.suppress_current_full_lifecycle_hook_authority( + FullLifecycleHookSuppressionReason::HookClear, + ); self.hook_authority = None; } let detected_agent_changed_or_disappeared = @@ -345,15 +371,23 @@ impl TerminalState { if self.full_lifecycle_hook_report_is_suppressed(&source, &agent_label, &session_ref) { return None; } - if !self.accept_hook_report(&source, seq) { + if self.full_lifecycle_hook_report_matches_stale_session( + &source, + &agent_label, + &session_ref, + ) { return None; } - - let previous_agent_label = self.effective_agent_label().map(str::to_string); - let previous_known_agent = self.effective_known_agent(); - let previous_state = self.state; - let previous_presentation = self.effective_presentation_for_state_at(previous_state, now); - let previous_session = self.current_session_identity_for_persistence(); + let reanchor_sequence = + self.full_lifecycle_hook_report_has_fresh_session_after_suppression( + &source, + &agent_label, + &session_ref, + ) || self.full_lifecycle_hook_report_has_fresh_session_after_stale_session( + &source, + &agent_label, + &session_ref, + ); if self.known_agent_label_conflicts_with_detected_agent(&agent_label) { return None; } @@ -368,8 +402,28 @@ impl TerminalState { ) { return None; } + if reanchor_sequence { + self.hook_report_sequences.remove(&source); + } + if !self.accept_hook_report(&source, seq) { + return None; + } + + let previous_agent_label = self.effective_agent_label().map(str::to_string); + let previous_known_agent = self.effective_known_agent(); + let previous_state = self.state; + let previous_presentation = self.effective_presentation_for_state_at(previous_state, now); + let previous_session = self.current_session_identity_for_persistence(); if session_ref.is_some() { - self.suppressed_full_lifecycle_hook_reports.remove(&source); + if let Some(suppressed) = self.suppressed_full_lifecycle_hook_reports.remove(&source) { + if let Some(suppressed_ref) = suppressed.session_ref { + self.remember_stale_full_lifecycle_hook_session( + source.clone(), + suppressed.agent_label, + suppressed_ref, + ); + } + } } self.persisted_agent_session = None; self.hook_authority = Some(HookAuthority { @@ -459,49 +513,72 @@ impl TerminalState { .is_some_and(|session| session.source == source && session.agent == agent) } - fn suppress_current_full_lifecycle_hook_authority(&mut self) { - if let Some((source, agent_label)) = self - .hook_authority - .as_ref() - .filter(|authority| { + fn suppress_current_full_lifecycle_hook_authority( + &mut self, + reason: FullLifecycleHookSuppressionReason, + ) { + if let Some((source, agent_label, session_ref)) = + self.hook_authority.as_ref().and_then(|authority| { crate::detect::full_lifecycle_hook_authority( &authority.source, &authority.agent_label, ) + .then(|| { + ( + authority.source.clone(), + authority.agent_label.clone(), + authority.session_ref.clone(), + ) + }) }) - .map(|authority| (authority.source.clone(), authority.agent_label.clone())) { - let session_ref = self - .hook_authority - .as_ref() - .and_then(|authority| authority.session_ref.clone()); - self.suppressed_full_lifecycle_hook_reports.insert( + self.suppress_full_lifecycle_hook_report_with_session_ref( source, - SuppressedFullLifecycleHookReport { - agent_label, - session_ref, - observed_at: Instant::now(), - }, + agent_label, + session_ref, + reason, ); } } - fn suppress_full_lifecycle_hook_report(&mut self, source: &str, agent_label: &str) { + fn suppress_full_lifecycle_hook_report( + &mut self, + source: &str, + agent_label: &str, + reason: FullLifecycleHookSuppressionReason, + ) { if crate::detect::full_lifecycle_hook_authority(source, agent_label) { - self.suppressed_full_lifecycle_hook_reports.insert( + let session_ref = self + .hook_authority + .as_ref() + .and_then(|authority| authority.session_ref.clone()); + self.suppress_full_lifecycle_hook_report_with_session_ref( source.to_string(), - SuppressedFullLifecycleHookReport { - agent_label: agent_label.to_string(), - session_ref: self - .hook_authority - .as_ref() - .and_then(|authority| authority.session_ref.clone()), - observed_at: Instant::now(), - }, + agent_label.to_string(), + session_ref, + reason, ); } } + fn suppress_full_lifecycle_hook_report_with_session_ref( + &mut self, + source: String, + agent_label: String, + session_ref: Option<crate::agent_resume::AgentSessionRef>, + reason: FullLifecycleHookSuppressionReason, + ) { + self.suppressed_full_lifecycle_hook_reports.insert( + source, + SuppressedFullLifecycleHookReport { + agent_label, + session_ref, + observed_at: Instant::now(), + reason, + }, + ); + } + fn full_lifecycle_hook_report_is_suppressed( &self, source: &str, @@ -517,6 +594,9 @@ impl TerminalState { if suppressed.agent_label != agent_label { return false; } + if suppressed.reason == FullLifecycleHookSuppressionReason::ProcessExit { + return true; + } match (&suppressed.session_ref, session_ref) { (Some(suppressed_ref), Some(incoming_ref)) => incoming_ref == suppressed_ref, (Some(_), None) => true, @@ -526,6 +606,71 @@ impl TerminalState { }) } + fn full_lifecycle_hook_report_has_fresh_session_after_suppression( + &self, + source: &str, + agent_label: &str, + session_ref: &Option<crate::agent_resume::AgentSessionRef>, + ) -> bool { + if !crate::detect::full_lifecycle_hook_authority(source, agent_label) { + return false; + } + self.suppressed_full_lifecycle_hook_reports + .get(source) + .is_some_and(|suppressed| { + suppressed.agent_label == agent_label + && suppressed.reason != FullLifecycleHookSuppressionReason::ProcessExit + && matches!( + (&suppressed.session_ref, session_ref), + (Some(suppressed_ref), Some(incoming_ref)) + if incoming_ref != suppressed_ref + ) + }) + } + + fn full_lifecycle_hook_report_matches_stale_session( + &self, + source: &str, + agent_label: &str, + session_ref: &Option<crate::agent_resume::AgentSessionRef>, + ) -> bool { + if !crate::detect::full_lifecycle_hook_authority(source, agent_label) { + return false; + } + self.stale_full_lifecycle_hook_sessions + .get(source) + .is_some_and(|stale_sessions| { + session_ref.as_ref().is_some_and(|incoming_ref| { + stale_sessions.iter().any(|stale| { + stale.agent_label == agent_label && incoming_ref == &stale.session_ref + }) + }) + }) + } + + fn full_lifecycle_hook_report_has_fresh_session_after_stale_session( + &self, + source: &str, + agent_label: &str, + session_ref: &Option<crate::agent_resume::AgentSessionRef>, + ) -> bool { + if !crate::detect::full_lifecycle_hook_authority(source, agent_label) { + return false; + } + self.stale_full_lifecycle_hook_sessions + .get(source) + .is_some_and(|stale_sessions| { + stale_sessions + .iter() + .any(|stale| stale.agent_label == agent_label) + && session_ref.as_ref().is_some_and(|incoming_ref| { + stale_sessions.iter().all(|stale| { + stale.agent_label != agent_label || incoming_ref != &stale.session_ref + }) + }) + }) + } + fn live_full_lifecycle_hook_authority_conflicts_with_session( &self, source: &str, @@ -560,10 +705,57 @@ impl TerminalState { if previous_detected_agent == Some(detected_agent) { return; } + let detected_label = crate::detect::agent_label(detected_agent); + let mut stale_sessions = Vec::new(); self.suppressed_full_lifecycle_hook_reports - .retain(|_, agent_label| { - crate::detect::parse_agent_label(&agent_label.agent_label) != Some(detected_agent) + .retain(|source, suppressed| { + let should_clear = crate::detect::parse_agent_label(&suppressed.agent_label) + == Some(detected_agent); + if should_clear { + if let Some(session_ref) = suppressed.session_ref.clone() { + stale_sessions.push(( + source.clone(), + StaleFullLifecycleHookSession { + agent_label: suppressed.agent_label.clone(), + session_ref, + }, + )); + } + } + !should_clear }); + for (source, stale_session) in stale_sessions { + self.remember_stale_full_lifecycle_hook_session( + source, + stale_session.agent_label, + stale_session.session_ref, + ); + } + self.hook_report_sequences.retain(|source, _| { + !crate::detect::full_lifecycle_hook_authority(source, detected_label) + }); + } + + fn remember_stale_full_lifecycle_hook_session( + &mut self, + source: String, + agent_label: String, + session_ref: crate::agent_resume::AgentSessionRef, + ) { + let stale_session = StaleFullLifecycleHookSession { + agent_label, + session_ref, + }; + let source_stale_sessions = self + .stale_full_lifecycle_hook_sessions + .entry(source) + .or_default(); + if !source_stale_sessions + .iter() + .any(|existing| existing == &stale_session) + { + source_stale_sessions.push(stale_session); + } } fn detected_state_observed_before_release_suppression( @@ -768,7 +960,9 @@ impl TerminalState { if !should_clear { return None; } - self.suppress_current_full_lifecycle_hook_authority(); + self.suppress_current_full_lifecycle_hook_authority( + FullLifecycleHookSuppressionReason::HookClear, + ); self.hook_authority = None; self.persisted_agent_session = None; Some(TerminalStateMutation { @@ -822,7 +1016,11 @@ impl TerminalState { let previous_state = self.state; let previous_presentation = self.effective_presentation_for_state_at(previous_state, now); let previous_session = self.current_session_identity_for_persistence(); - self.suppress_full_lifecycle_hook_report(source, agent_label); + self.suppress_full_lifecycle_hook_report( + source, + agent_label, + FullLifecycleHookSuppressionReason::HookClear, + ); self.detected_agent = None; self.fallback_state = AgentState::Unknown; self.fallback_visible_blocker = false; @@ -905,6 +1103,7 @@ impl TerminalState { self.persisted_agent_session = None; self.agent_metadata.clear(); self.suppressed_full_lifecycle_hook_reports.clear(); + self.stale_full_lifecycle_hook_sessions.clear(); self.state = AgentState::Unknown; self.last_agent_state_change_seq = None; self.launch_argv = None; @@ -1046,8 +1245,9 @@ mod tests { } #[test] - fn omp_hook_authority_works_without_detected_agent_variant() { + fn omp_hook_authority_overrides_detected_fallback() { let mut terminal = test_terminal(); + terminal.set_detected_state(Some(Agent::Omp), AgentState::Idle); terminal.set_hook_authority( "herdr:omp".into(), "omp".into(), @@ -1056,20 +1256,20 @@ mod tests { None, ); - assert_eq!(terminal.detected_agent, None); + assert_eq!(terminal.detected_agent, Some(Agent::Omp)); assert_eq!(terminal.effective_agent_label(), Some("omp")); - assert_eq!(terminal.effective_known_agent(), None); + assert_eq!(terminal.effective_known_agent(), Some(Agent::Omp)); assert_eq!(terminal.state, AgentState::Working); let change = terminal.set_detected_state_with_visible_blocker( - None, + Some(Agent::Omp), AgentState::Blocked, true, false, false, ); - assert_eq!(terminal.fallback_state, AgentState::Unknown); + assert_eq!(terminal.fallback_state, AgentState::Idle); assert_eq!(terminal.state, AgentState::Working); assert!(change.is_none()); } @@ -1158,39 +1358,6 @@ mod tests { } #[test] - fn process_exit_clears_omp_full_lifecycle_hook_authority_without_known_agent() { - let now = Instant::now(); - let mut terminal = test_terminal(); - terminal.set_hook_authority_with_custom_status_at( - "herdr:omp".into(), - "omp".into(), - AgentState::Working, - None, - None, - None, - Some(10), - now, - ); - - let change = terminal.set_detected_state_with_screen_signals_at( - None, - AgentState::Idle, - false, - true, - false, - true, - now + Duration::from_millis(1), - ); - - assert!(terminal.hook_authority.is_none()); - assert_eq!(terminal.state, AgentState::Idle); - assert_eq!( - change.effective_state_change.unwrap().previous_state, - AgentState::Working - ); - } - - #[test] fn late_full_lifecycle_hook_after_process_exit_does_not_reacquire_authority() { let now = Instant::now(); let mut terminal = test_terminal(); @@ -1360,6 +1527,91 @@ mod tests { } #[test] + fn changed_session_ref_reanchors_hook_sequence_after_release() { + let mut terminal = test_terminal(); + terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle); + terminal.set_hook_authority_with_session_ref( + "herdr:pi".into(), + "pi".into(), + AgentState::Working, + None, + None, + crate::agent_resume::AgentSessionRef::path(test_session_path("old.jsonl")), + Some(1000), + ); + terminal.release_agent("herdr:pi", "pi", Some(3000)); + + let fresh = terminal.set_hook_authority_with_session_ref( + "herdr:pi".into(), + "pi".into(), + AgentState::Working, + None, + None, + crate::agent_resume::AgentSessionRef::path(test_session_path("new.jsonl")), + Some(1500), + ); + + assert!(fresh.is_some()); + assert!(terminal.hook_authority.is_some()); + assert_eq!(terminal.state, AgentState::Working); + } + + #[test] + fn stale_session_suppression_survives_multiple_release_generations() { + let mut terminal = test_terminal(); + let session_a = test_session_path("release-generation-a.jsonl"); + let session_b = test_session_path("release-generation-b.jsonl"); + let session_c = test_session_path("release-generation-c.jsonl"); + terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle); + terminal.set_hook_authority_with_session_ref( + "herdr:pi".into(), + "pi".into(), + AgentState::Working, + None, + None, + crate::agent_resume::AgentSessionRef::path(session_a.clone()), + Some(1000), + ); + terminal.release_agent("herdr:pi", "pi", Some(2000)); + + let generation_b = terminal.set_hook_authority_with_session_ref( + "herdr:pi".into(), + "pi".into(), + AgentState::Working, + None, + None, + crate::agent_resume::AgentSessionRef::path(session_b), + Some(1500), + ); + assert!(generation_b.is_some()); + terminal.release_agent("herdr:pi", "pi", Some(3000)); + + let late_generation_a = terminal.set_hook_authority_with_session_ref( + "herdr:pi".into(), + "pi".into(), + AgentState::Working, + None, + Some("late".into()), + crate::agent_resume::AgentSessionRef::path(session_a), + Some(2500), + ); + let generation_c = terminal.set_hook_authority_with_session_ref( + "herdr:pi".into(), + "pi".into(), + AgentState::Working, + None, + None, + crate::agent_resume::AgentSessionRef::path(session_c), + Some(2500), + ); + + assert!(late_generation_a.is_none()); + assert!(generation_c.is_some()); + assert!(terminal.hook_authority.is_some()); + assert_eq!(terminal.state, AgentState::Working); + } + + #[test] fn live_full_lifecycle_hook_rejects_different_session_ref_for_same_source() { let mut terminal = test_terminal(); terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle); @@ -1432,6 +1684,393 @@ mod tests { } #[test] + fn fresh_detected_process_reanchors_hook_sequence_after_process_exit() { + let mut terminal = test_terminal(); + terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle); + terminal.set_hook_authority( + "herdr:pi".into(), + "pi".into(), + AgentState::Working, + None, + Some(1000), + ); + let process_exit_seen_at = Instant::now() + Duration::from_millis(1); + terminal.set_detected_state_with_screen_signals_at( + Some(Agent::Pi), + AgentState::Idle, + false, + true, + false, + true, + process_exit_seen_at, + ); + + let fresh_process_seen_at = process_exit_seen_at + Duration::from_millis(1); + terminal.set_detected_state_with_screen_signals_at( + None, + AgentState::Unknown, + false, + false, + false, + false, + fresh_process_seen_at, + ); + terminal.set_detected_state_with_screen_signals_at( + Some(Agent::Pi), + AgentState::Unknown, + false, + false, + false, + false, + fresh_process_seen_at + Duration::from_millis(1), + ); + let fresh = terminal.set_hook_authority( + "herdr:pi".into(), + "pi".into(), + AgentState::Working, + None, + Some(500), + ); + + assert!(fresh.is_some()); + assert!(terminal.hook_authority.is_some()); + assert_eq!(terminal.state, AgentState::Working); + } + + #[test] + fn fresh_detected_process_keeps_old_session_suppressed_after_process_exit() { + let mut terminal = test_terminal(); + let old_session = test_session_path("old-process-exit.jsonl"); + let new_session = test_session_path("new-process-exit.jsonl"); + terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle); + terminal.set_hook_authority_with_session_ref( + "herdr:pi".into(), + "pi".into(), + AgentState::Working, + None, + None, + crate::agent_resume::AgentSessionRef::path(old_session.clone()), + Some(1000), + ); + let process_exit_seen_at = Instant::now() + Duration::from_secs(1); + terminal.set_detected_state_with_screen_signals_at( + Some(Agent::Pi), + AgentState::Idle, + false, + true, + false, + true, + process_exit_seen_at, + ); + + let fresh_process_seen_at = process_exit_seen_at + Duration::from_millis(1); + terminal.set_detected_state_with_screen_signals_at( + None, + AgentState::Unknown, + false, + false, + false, + false, + fresh_process_seen_at, + ); + terminal.set_detected_state_with_screen_signals_at( + Some(Agent::Pi), + AgentState::Unknown, + false, + false, + false, + false, + fresh_process_seen_at + Duration::from_millis(1), + ); + + let late_old = terminal.set_hook_authority_with_session_ref( + "herdr:pi".into(), + "pi".into(), + AgentState::Working, + None, + Some("late".into()), + crate::agent_resume::AgentSessionRef::path(old_session), + Some(500), + ); + let fresh_new = terminal.set_hook_authority_with_session_ref( + "herdr:pi".into(), + "pi".into(), + AgentState::Working, + None, + None, + crate::agent_resume::AgentSessionRef::path(new_session), + Some(500), + ); + + assert!(late_old.is_none()); + assert!(fresh_new.is_some()); + assert!(terminal.hook_authority.is_some()); + assert_eq!(terminal.state, AgentState::Working); + } + + #[test] + fn different_session_after_process_exit_waits_for_fresh_process_evidence() { + let mut terminal = test_terminal(); + let old_session = test_session_path("old-before-process-exit.jsonl"); + let new_session = test_session_path("new-after-process-exit.jsonl"); + let now = Instant::now(); + terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle); + terminal.set_hook_authority_with_custom_status_at( + "herdr:pi".into(), + "pi".into(), + AgentState::Working, + None, + None, + crate::agent_resume::AgentSessionRef::path(old_session), + Some(1000), + now, + ); + terminal.set_detected_state_with_screen_signals_at( + Some(Agent::Pi), + AgentState::Idle, + false, + true, + false, + true, + now + Duration::from_millis(1), + ); + + let early_new = terminal.set_hook_authority_with_custom_status_at( + "herdr:pi".into(), + "pi".into(), + AgentState::Working, + None, + None, + crate::agent_resume::AgentSessionRef::path(new_session.clone()), + Some(500), + now + Duration::from_millis(2), + ); + + assert!(early_new.is_none()); + assert!(terminal.hook_authority.is_none()); + + terminal.set_detected_state_with_screen_signals_at( + None, + AgentState::Unknown, + false, + false, + false, + false, + now + Duration::from_millis(3), + ); + terminal.set_detected_state_with_screen_signals_at( + Some(Agent::Pi), + AgentState::Unknown, + false, + false, + false, + false, + now + Duration::from_millis(4), + ); + let fresh_new = terminal.set_hook_authority_with_custom_status_at( + "herdr:pi".into(), + "pi".into(), + AgentState::Working, + None, + None, + crate::agent_resume::AgentSessionRef::path(new_session), + Some(500), + now + Duration::from_millis(5), + ); + + assert!(fresh_new.is_some()); + assert!(terminal.hook_authority.is_some()); + assert_eq!(terminal.state, AgentState::Working); + } + + #[test] + fn missing_session_after_process_exit_waits_for_fresh_process_evidence() { + let mut terminal = test_terminal(); + let old_session = test_session_path("old-before-nosession-process-exit.jsonl"); + let now = Instant::now(); + terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle); + terminal.set_hook_authority_with_custom_status_at( + "herdr:pi".into(), + "pi".into(), + AgentState::Working, + None, + None, + crate::agent_resume::AgentSessionRef::path(old_session), + Some(1000), + now, + ); + terminal.set_detected_state_with_screen_signals_at( + Some(Agent::Pi), + AgentState::Idle, + false, + true, + false, + true, + now + Duration::from_millis(1), + ); + + let early_without_session = terminal.set_hook_authority_with_custom_status_at( + "herdr:pi".into(), + "pi".into(), + AgentState::Working, + None, + None, + None, + Some(500), + now + Duration::from_millis(2), + ); + + assert!(early_without_session.is_none()); + assert!(terminal.hook_authority.is_none()); + + terminal.set_detected_state_with_screen_signals_at( + None, + AgentState::Unknown, + false, + false, + false, + false, + now + Duration::from_millis(3), + ); + terminal.set_detected_state_with_screen_signals_at( + Some(Agent::Pi), + AgentState::Unknown, + false, + false, + false, + false, + now + Duration::from_millis(4), + ); + let fresh_without_session = terminal.set_hook_authority_with_custom_status_at( + "herdr:pi".into(), + "pi".into(), + AgentState::Working, + None, + None, + None, + Some(500), + now + Duration::from_millis(5), + ); + + assert!(fresh_without_session.is_some()); + assert!(terminal.hook_authority.is_some()); + assert_eq!(terminal.state, AgentState::Working); + } + + #[test] + fn stale_session_suppression_survives_multiple_process_generations() { + let mut terminal = test_terminal(); + let session_a = test_session_path("generation-a.jsonl"); + let session_b = test_session_path("generation-b.jsonl"); + let session_c = test_session_path("generation-c.jsonl"); + let now = Instant::now(); + terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle); + terminal.set_hook_authority_with_custom_status_at( + "herdr:pi".into(), + "pi".into(), + AgentState::Working, + None, + None, + crate::agent_resume::AgentSessionRef::path(session_a.clone()), + Some(1000), + now, + ); + + terminal.set_detected_state_with_screen_signals_at( + Some(Agent::Pi), + AgentState::Idle, + false, + true, + false, + true, + now + Duration::from_millis(1), + ); + terminal.set_detected_state_with_screen_signals_at( + None, + AgentState::Unknown, + false, + false, + false, + false, + now + Duration::from_millis(2), + ); + terminal.set_detected_state_with_screen_signals_at( + Some(Agent::Pi), + AgentState::Unknown, + false, + false, + false, + false, + now + Duration::from_millis(3), + ); + let generation_b = terminal.set_hook_authority_with_custom_status_at( + "herdr:pi".into(), + "pi".into(), + AgentState::Working, + None, + None, + crate::agent_resume::AgentSessionRef::path(session_b), + Some(500), + now + Duration::from_millis(4), + ); + assert!(generation_b.is_some()); + + terminal.set_detected_state_with_screen_signals_at( + Some(Agent::Pi), + AgentState::Idle, + false, + true, + false, + true, + now + Duration::from_millis(5), + ); + terminal.set_detected_state_with_screen_signals_at( + None, + AgentState::Unknown, + false, + false, + false, + false, + now + Duration::from_millis(6), + ); + terminal.set_detected_state_with_screen_signals_at( + Some(Agent::Pi), + AgentState::Unknown, + false, + false, + false, + false, + now + Duration::from_millis(7), + ); + + let late_generation_a = terminal.set_hook_authority_with_custom_status_at( + "herdr:pi".into(), + "pi".into(), + AgentState::Working, + None, + Some("late".into()), + crate::agent_resume::AgentSessionRef::path(session_a), + Some(250), + now + Duration::from_millis(8), + ); + let generation_c = terminal.set_hook_authority_with_custom_status_at( + "herdr:pi".into(), + "pi".into(), + AgentState::Working, + None, + None, + crate::agent_resume::AgentSessionRef::path(session_c), + Some(250), + now + Duration::from_millis(9), + ); + + assert!(late_generation_a.is_none()); + assert!(generation_c.is_some()); + assert!(terminal.hook_authority.is_some()); + assert_eq!(terminal.state, AgentState::Working); + } + + #[test] fn release_suppression_ignores_same_agent_idle_publish() { let now = Instant::now(); let mut terminal = test_terminal(); @@ -1496,8 +2135,6 @@ mod tests { assert_eq!(terminal.state, AgentState::Working); } - // omp has no Agent identity (the process is pi), so its release suppression - // can only be cleared by a fresh session ref, never by process detection. // Regression for #614: a same-pane restart must reacquire lifecycle authority. #[test] fn omp_reacquires_full_lifecycle_hook_after_release_with_fresh_session_ref() { @@ -1543,6 +2180,75 @@ mod tests { } #[test] + fn omp_reacquires_full_lifecycle_hook_after_process_exit_with_fresh_process_and_session_ref() { + let now = Instant::now(); + let mut terminal = test_terminal(); + terminal.set_detected_state(Some(Agent::Omp), AgentState::Idle); + terminal.set_hook_authority_with_session_ref( + "herdr:omp".into(), + "omp".into(), + AgentState::Working, + None, + None, + crate::agent_resume::AgentSessionRef::id("omp-old"), + Some(1000), + ); + terminal.set_detected_state_with_screen_signals_at( + Some(Agent::Omp), + AgentState::Idle, + false, + true, + false, + true, + now + Duration::from_millis(1), + ); + + let stale = terminal.set_hook_authority_with_session_ref( + "herdr:omp".into(), + "omp".into(), + AgentState::Working, + None, + None, + crate::agent_resume::AgentSessionRef::id("omp-old"), + Some(500), + ); + assert!(stale.is_none()); + assert!(terminal.hook_authority.is_none()); + + terminal.set_detected_state_with_screen_signals_at( + None, + AgentState::Unknown, + false, + false, + false, + false, + now + Duration::from_millis(2), + ); + terminal.set_detected_state_with_screen_signals_at( + Some(Agent::Omp), + AgentState::Unknown, + false, + false, + false, + false, + now + Duration::from_millis(3), + ); + let fresh = terminal.set_hook_authority_with_session_ref( + "herdr:omp".into(), + "omp".into(), + AgentState::Working, + None, + None, + crate::agent_resume::AgentSessionRef::id("omp-new"), + Some(500), + ); + + assert!(fresh.is_some()); + assert!(terminal.hook_authority.is_some()); + assert_eq!(terminal.state, AgentState::Working); + } + + #[test] fn visible_blocker_overrides_non_blocked_hook_for_same_agent() { let mut terminal = test_terminal(); terminal.set_detected_state(Some(Agent::Codex), AgentState::Idle); |
