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/terminal | |
| parent | 9f4f1630893f74697a2097ef11bf5bf512a37850 (diff) | |
fix: reanchor lifecycle hook generations
refs #684
Diffstat (limited to 'src/terminal')
| -rw-r--r-- | src/terminal/state.rs | 866 |
1 files changed, 786 insertions, 80 deletions
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); |
