diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-07-24 02:49:33 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-07-24 02:50:58 +0300 |
| commit | e608a75159fc5cca70485d83ffb8ee681c9da385 (patch) | |
| tree | 5ffdec62d0bee9a798418d6414ecbddae7ff8ae5 /src/terminal | |
| parent | 169a4fd90c20a00aec77d684ecec98b9f0f996cc (diff) | |
fix: make known-agent exit process-owned
refs #1792
refs #1648
Diffstat (limited to 'src/terminal')
| -rw-r--r-- | src/terminal/metadata.rs | 115 | ||||
| -rw-r--r-- | src/terminal/state.rs | 1899 |
2 files changed, 1233 insertions, 781 deletions
diff --git a/src/terminal/metadata.rs b/src/terminal/metadata.rs index 27ac8528..2a912b27 100644 --- a/src/terminal/metadata.rs +++ b/src/terminal/metadata.rs @@ -58,11 +58,47 @@ impl TerminalState { crate::metadata_tokens::sequence_is_fresh(&self.metadata_report_sequences, source, seq) } + pub(crate) fn metadata_report_agent( + source: &str, + agent_label: Option<&str>, + applies_to_source: Option<&str>, + ) -> Option<crate::detect::Agent> { + agent_label + .and_then(crate::detect::parse_agent_label) + .or_else(|| { + crate::detect::Agent::ALL.iter().copied().find(|agent| { + let agent_label = crate::detect::agent_label(*agent); + crate::agent_resume::is_official_agent_source(source, agent_label) + || applies_to_source.is_some_and(|source| { + crate::agent_resume::is_official_agent_source(source, agent_label) + }) + }) + }) + } + + pub(crate) fn metadata_report_blocked_by_process_exit( + &self, + source: &str, + agent_label: Option<&str>, + applies_to_source: Option<&str>, + ) -> bool { + let Some(exit) = self.recent_agent_process_exit else { + return false; + }; + let exited_agent_label = crate::detect::agent_label(exit.agent); + agent_label.and_then(crate::detect::parse_agent_label) == Some(exit.agent) + || crate::agent_resume::is_official_agent_source(source, exited_agent_label) + || applies_to_source.is_some_and(|source| { + crate::agent_resume::is_official_agent_source(source, exited_agent_label) + }) + } + pub(crate) fn accept_metadata_report( &mut self, source: &str, seq: Option<u64>, includes_tokens: bool, + agent: Option<crate::detect::Agent>, ) -> Result<bool, ()> { let Some(seq) = seq else { return Ok(true); @@ -79,6 +115,10 @@ impl TerminalState { } self.metadata_report_sequences .insert(source.to_string(), seq); + if let Some(agent) = agent { + self.metadata_report_agents + .insert(source.to_string(), agent); + } if includes_tokens { self.metadata_token_sequence_sources .insert(source.to_string()); @@ -105,8 +145,20 @@ impl TerminalState { &mut self, report: AgentMetadataReport, ) -> Option<TerminalStateMutation> { + if self.metadata_report_blocked_by_process_exit( + &report.source, + report.agent_label.as_deref(), + report.applies_to_source.as_deref(), + ) { + return None; + } + let report_agent = Self::metadata_report_agent( + &report.source, + report.agent_label.as_deref(), + report.applies_to_source.as_deref(), + ); if !matches!( - self.accept_metadata_report(&report.source, report.seq, false), + self.accept_metadata_report(&report.source, report.seq, false, report_agent), Ok(true) ) { return None; @@ -482,13 +534,13 @@ mod tests { let mut terminal = test_terminal(); for index in 0..=crate::metadata_tokens::MAX_SEQUENCE_SOURCES { assert_eq!( - terminal.accept_metadata_report(&format!("source-{index}"), Some(1), false), + terminal.accept_metadata_report(&format!("source-{index}"), Some(1), false, None,), Ok(true) ); } for index in 0..crate::metadata_tokens::MAX_SEQUENCE_SOURCES { assert_eq!( - terminal.accept_metadata_report(&format!("source-{index}"), Some(2), true), + terminal.accept_metadata_report(&format!("source-{index}"), Some(2), true, None,), Ok(true) ); } @@ -497,12 +549,69 @@ mod tests { &format!("source-{}", crate::metadata_tokens::MAX_SEQUENCE_SOURCES), Some(2), true, + None, ), Err(()) ); } #[test] + fn custom_metadata_reanchors_sequence_after_process_restart() { + let mut terminal = test_terminal(); + terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle); + let report = |seq, ttl| AgentMetadataReport { + source: "custom:pi-metadata".into(), + agent_label: Some("pi".into()), + applies_to_source: None, + title: Some("Pi task".into()), + display_agent: None, + state_labels: HashMap::new(), + clear_title: false, + clear_display_agent: false, + clear_state_labels: false, + ttl, + seq: Some(seq), + }; + assert!(terminal + .set_agent_metadata(report(100, Some(Duration::ZERO))) + .is_some()); + let deadline = terminal.next_agent_metadata_expiry().unwrap(); + terminal.expire_agent_metadata_at(deadline, deadline); + assert!(terminal.agent_metadata.is_empty()); + let exit_at = Instant::now() + Duration::from_millis(1); + terminal.set_detected_state_with_screen_signals_at( + Some(Agent::Pi), + AgentState::Idle, + false, + false, + false, + true, + exit_at, + ); + terminal.set_detected_state_with_screen_signals_at( + None, + AgentState::Unknown, + false, + false, + false, + false, + exit_at + Duration::from_millis(1), + ); + assert!(terminal.set_agent_metadata(report(1, None)).is_none()); + + terminal.set_detected_state_with_screen_signals_at( + Some(Agent::Pi), + AgentState::Idle, + false, + false, + false, + false, + exit_at + Duration::from_millis(2), + ); + assert!(terminal.set_agent_metadata(report(1, None)).is_some()); + } + + #[test] fn user_agent_metadata_overrides_presentation_fields_only() { let mut terminal = test_terminal(); terminal.set_hook_authority( diff --git a/src/terminal/state.rs b/src/terminal/state.rs index 38e4739e..4485c773 100644 --- a/src/terminal/state.rs +++ b/src/terminal/state.rs @@ -30,6 +30,14 @@ struct SuppressedFullLifecycleHookReport { session_ref: Option<crate::agent_resume::AgentSessionRef>, observed_at: Instant, reason: FullLifecycleHookSuppressionReason, + replacement_session_ref: Option<crate::agent_resume::AgentSessionRef>, + pending_replacement_report: Option<PendingFullLifecycleHookReport>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct PendingFullLifecycleHookReport { + authority: HookAuthority, + seq: u64, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -38,6 +46,12 @@ enum FullLifecycleHookSuppressionReason { ProcessExit, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum FullLifecycleHookReportRoute { + Accept { reanchor_sequence: bool }, + Ignore, +} + #[derive(Debug, Clone, PartialEq, Eq)] struct StaleFullLifecycleHookSession { agent_label: String, @@ -91,6 +105,12 @@ struct AgentNameOwner { session_ref: Option<crate::agent_resume::AgentSessionRef>, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct RecentAgentProcessExit { + agent: Agent, + observed_at: Instant, +} + /// Pure state for a server-owned terminal. /// /// During the migration this is still one-to-one with a pane-backed PTY, but @@ -116,13 +136,14 @@ pub struct TerminalState { suppressed_full_lifecycle_hook_reports: HashMap<String, SuppressedFullLifecycleHookReport>, stale_full_lifecycle_hook_sessions: HashMap<String, Vec<StaleFullLifecycleHookSession>>, metadata_report_sequences: HashMap<String, u64>, + metadata_report_agents: HashMap<String, Agent>, metadata_token_sequence_sources: std::collections::HashSet<String>, pub state: AgentState, pub last_agent_state_change_seq: Option<u64>, pub revision: u64, pub launch_argv: Option<Vec<String>>, pub respawn_shell_on_exit: bool, - recent_agent_process_exit_at: Option<Instant>, + recent_agent_process_exit: Option<RecentAgentProcessExit>, pub pending_agent_resume_plan: Option<crate::agent_resume::AgentResumePlan>, } @@ -148,13 +169,14 @@ impl TerminalState { suppressed_full_lifecycle_hook_reports: HashMap::new(), stale_full_lifecycle_hook_sessions: HashMap::new(), metadata_report_sequences: HashMap::new(), + metadata_report_agents: HashMap::new(), metadata_token_sequence_sources: std::collections::HashSet::new(), state: AgentState::Unknown, last_agent_state_change_seq: None, revision: 0, launch_argv: None, respawn_shell_on_exit: false, - recent_agent_process_exit_at: None, + recent_agent_process_exit: None, pending_agent_resume_plan: None, } } @@ -193,8 +215,8 @@ impl TerminalState { #[cfg(any(windows, test))] pub(crate) fn agent_process_exited_within(&self, now: Instant, max_age: Duration) -> bool { - self.recent_agent_process_exit_at - .is_some_and(|exited_at| now.saturating_duration_since(exited_at) <= max_age) + self.recent_agent_process_exit + .is_some_and(|exit| now.saturating_duration_since(exit.observed_at) <= max_age) } pub fn with_pending_agent_resume_plan( @@ -268,8 +290,17 @@ impl TerminalState { let previous_presentation = self.effective_presentation_for_state_at(previous_state, now); let previous_detected_agent = self.detected_agent; let previous_session = self.current_session_identity_for_persistence(); + let newer_custom_authority = process_exited + && self.hook_authority.as_ref().is_some_and(|authority| { + crate::detect::parse_agent_label(&authority.agent_label) == agent + && !crate::agent_resume::is_official_agent_source( + &authority.source, + &authority.agent_label, + ) + && authority.reported_at > now + }); let agent_released = process_exited - && self.hook_authority_not_newer_than(now) + && !newer_custom_authority && (previous_agent_label.is_some() || self.agent_name.is_some()); if self.should_ignore_detected_state_under_full_lifecycle_hook(agent, process_exited) { if self @@ -293,6 +324,11 @@ impl TerminalState { agent_released: false, }; } + let replacement_process_detected = !process_exited + && agent.is_some() + && self + .recent_agent_process_exit + .is_some_and(|exit| Some(exit.agent) == agent && exit.observed_at < now); if !process_exited && self.detected_state_observed_before_release_suppression(agent, now) { return TerminalStateMutation { effective_state_change: self.recompute_effective_state( @@ -314,43 +350,168 @@ impl TerminalState { } if !process_exited { self.clear_full_lifecycle_hook_suppression_for_detected_agent( - previous_detected_agent, + if replacement_process_detected { + None + } else { + previous_detected_agent + }, agent, ); } self.fallback_state = fallback_state; self.fallback_visible_blocker = visible_blocker && fallback_state == AgentState::Blocked; self.fallback_observed_at = Some(now); - if process_exited && agent.is_some() { - self.recent_agent_process_exit_at = Some(now); + if process_exited { + if let Some(agent) = agent { + self.recent_agent_process_exit = Some(RecentAgentProcessExit { + agent, + observed_at: now, + }); + } } else if agent.is_some() { - self.recent_agent_process_exit_at = None; + self.recent_agent_process_exit = None; } - if process_exited - && self.hook_authority_not_newer_than(now) - && self.hook_authority.as_ref().is_some_and(|authority| { - crate::detect::parse_agent_label(&authority.agent_label) == agent - }) - { - let cleared_source = self + if process_exited { + let mut reset_sources = Vec::new(); + let mut stale_sessions = Vec::new(); + for (source, suppressed) in &mut self.suppressed_full_lifecycle_hook_reports { + if crate::detect::parse_agent_label(&suppressed.agent_label) != agent + || suppressed.reason == FullLifecycleHookSuppressionReason::HookClear + { + continue; + } + let exited_session_ref = suppressed + .replacement_session_ref + .take() + .or_else(|| { + suppressed + .pending_replacement_report + .as_ref() + .and_then(|pending| pending.authority.session_ref.clone()) + }) + .or_else(|| suppressed.session_ref.clone()); + if let (Some(previous), Some(exited)) = + (suppressed.session_ref.as_ref(), exited_session_ref.as_ref()) + { + if previous != exited { + stale_sessions.push(( + source.clone(), + suppressed.agent_label.clone(), + previous.clone(), + )); + } + } + suppressed.session_ref = exited_session_ref; + suppressed.pending_replacement_report = None; + suppressed.observed_at = now; + reset_sources.push(source.clone()); + } + for (source, agent_label, session_ref) in stale_sessions { + self.remember_stale_full_lifecycle_hook_session(source, agent_label, session_ref); + } + for source in reset_sources { + self.hook_report_sequences.remove(&source); + } + + let official_session = 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 { + .filter(|authority| { + crate::agent_resume::is_official_agent_source( + &authority.source, + &authority.agent_label, + ) && crate::detect::parse_agent_label(&authority.agent_label) == agent + }) + .map(|authority| { + ( + authority.source.clone(), + authority.agent_label.clone(), + authority.session_ref.clone(), + ) + }) + .or_else(|| { + self.persisted_agent_session.as_ref().and_then(|session| { + (crate::agent_resume::is_official_agent_source( + &session.source, + &session.agent, + ) && crate::detect::parse_agent_label(&session.agent) == agent) + .then(|| { + ( + session.source.clone(), + session.agent.clone(), + Some(session.session_ref.clone()), + ) + }) + }) + }); + if let Some((source, agent_label, session_ref)) = official_session { self.hook_report_sequences.remove(&source); + self.suppress_full_lifecycle_hook_report_with_session_ref( + source, + agent_label, + session_ref, + FullLifecycleHookSuppressionReason::ProcessExit, + now, + ); + } + let cleared_hook_source = self.hook_authority.as_ref().and_then(|authority| { + (crate::detect::parse_agent_label(&authority.agent_label) == agent + && !newer_custom_authority) + .then(|| authority.source.clone()) + }); + if let Some(source) = cleared_hook_source { + self.hook_report_sequences.remove(&source); + self.hook_authority = None; + } + if !newer_custom_authority + && self + .persisted_agent_session + .as_ref() + .is_some_and(|session| { + crate::detect::parse_agent_label(&session.agent) == agent + }) + { + self.persisted_agent_session = None; + } + if let Some(agent) = agent { + let agent_label = crate::detect::agent_label(agent); + let mut cleared_metadata_sources = Vec::new(); + self.agent_metadata.retain(|source, metadata| { + let official_metadata = crate::agent_resume::is_official_agent_source( + &metadata.source, + agent_label, + ) || metadata.applies_to_source.as_deref().is_some_and( + |applies_to| { + crate::agent_resume::is_official_agent_source(applies_to, agent_label) + }, + ); + let matches_agent = + metadata.agent_label.as_deref() == Some(agent_label) || official_metadata; + let clear = matches_agent && (official_metadata || metadata.reported_at <= now); + if clear { + cleared_metadata_sources.push(source.clone()); + } + !clear + }); + for source in cleared_metadata_sources { + self.metadata_report_sequences.remove(&source); + self.metadata_report_agents.remove(&source); + self.metadata_token_sequence_sources.remove(&source); + } + let mut exited_generation_sources = Vec::new(); + self.metadata_report_agents.retain(|source, owner| { + if *owner == agent { + exited_generation_sources.push(source.clone()); + false + } else { + true + } + }); + for source in exited_generation_sources { + self.metadata_report_sequences.remove(&source); + self.metadata_token_sequence_sources.remove(&source); + } } - self.hook_authority = None; - } - if process_exited - && self - .persisted_agent_session - .as_ref() - .is_some_and(|session| crate::detect::parse_agent_label(&session.agent) == agent) - { - self.persisted_agent_session = None; } if self.hook_authority_not_newer_than(now) && (self.hook_authority_conflicts_with_detected_agent(agent) @@ -444,26 +605,25 @@ impl TerminalState { seq: Option<u64>, now: Instant, ) -> Option<TerminalStateMutation> { - if self.full_lifecycle_hook_report_is_suppressed(&source, &agent_label, &session_ref) { + if !crate::detect::full_lifecycle_hook_authority(&source, &agent_label) + && self.recent_agent_process_exit.is_some_and(|exit| { + crate::detect::parse_agent_label(&agent_label) == Some(exit.agent) + }) + { return None; } - if self.full_lifecycle_hook_report_matches_stale_session( + let reanchor_sequence = match self.route_full_lifecycle_hook_report( &source, &agent_label, + state, + message.as_deref(), &session_ref, + seq, + now, ) { - return None; - } - 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, - ); + FullLifecycleHookReportRoute::Accept { reanchor_sequence } => reanchor_sequence, + FullLifecycleHookReportRoute::Ignore => return None, + }; if self.known_agent_label_conflicts_with_detected_agent(&agent_label) { return None; } @@ -514,7 +674,7 @@ impl TerminalState { FullLifecycleHookSuppressionReason::HookClear, ); } - if session_ref.is_some() { + if session_ref.is_some() || reanchor_sequence { 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( @@ -611,6 +771,7 @@ impl TerminalState { agent_label, session_ref, reason, + Instant::now(), ); } } @@ -631,6 +792,7 @@ impl TerminalState { agent_label.to_string(), session_ref, reason, + Instant::now(), ); } } @@ -641,65 +803,143 @@ impl TerminalState { agent_label: String, session_ref: Option<crate::agent_resume::AgentSessionRef>, reason: FullLifecycleHookSuppressionReason, + observed_at: Instant, ) { self.suppressed_full_lifecycle_hook_reports.insert( source, SuppressedFullLifecycleHookReport { agent_label, session_ref, - observed_at: Instant::now(), + observed_at, reason, + replacement_session_ref: None, + pending_replacement_report: None, }, ); } - fn full_lifecycle_hook_report_is_suppressed( - &self, + fn route_full_lifecycle_hook_report( + &mut self, source: &str, agent_label: &str, + state: AgentState, + message: Option<&str>, session_ref: &Option<crate::agent_resume::AgentSessionRef>, - ) -> bool { + seq: Option<u64>, + reported_at: Instant, + ) -> FullLifecycleHookReportRoute { if !crate::detect::full_lifecycle_hook_authority(source, agent_label) { - return false; + return FullLifecycleHookReportRoute::Accept { + reanchor_sequence: false, + }; } - self.suppressed_full_lifecycle_hook_reports - .get(source) - .is_some_and(|suppressed| { - 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, - (None, Some(_)) => false, - (None, None) => true, - } + if self.full_lifecycle_hook_report_matches_stale_session(source, agent_label, session_ref) { + return FullLifecycleHookReportRoute::Ignore; + } + + let known_agent = crate::detect::parse_agent_label(agent_label); + let process_present = known_agent.is_some() + && self.detected_agent == known_agent + && self.recent_agent_process_exit.is_none(); + let session_anchored = self + .hook_authority + .as_ref() + .filter(|authority| authority.source == source && authority.agent_label == agent_label) + .and_then(|authority| authority.session_ref.as_ref()) + .or_else(|| { + self.persisted_agent_session + .as_ref() + .filter(|session| session.source == source && session.agent == agent_label) + .map(|session| &session.session_ref) }) - } + .is_some_and(|anchored| { + session_ref + .as_ref() + .is_none_or(|incoming| incoming == anchored) + }); + if let Some(suppressed) = self.suppressed_full_lifecycle_hook_reports.get(source) { + if suppressed.agent_label != agent_label { + return FullLifecycleHookReportRoute::Ignore; + } + if suppressed.reason == FullLifecycleHookSuppressionReason::HookClear { + let reanchor_sequence = matches!( + (&suppressed.session_ref, session_ref), + (Some(previous), Some(incoming)) if previous != incoming + ); + return if reanchor_sequence { + FullLifecycleHookReportRoute::Accept { + reanchor_sequence: true, + } + } else { + FullLifecycleHookReportRoute::Ignore + }; + } + } - 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; + if process_present + && session_anchored + && !self + .suppressed_full_lifecycle_hook_reports + .contains_key(source) + { + return FullLifecycleHookReportRoute::Accept { + reanchor_sequence: self + .full_lifecycle_hook_report_has_fresh_session_after_stale_session( + source, + agent_label, + session_ref, + ), + }; } - self.suppressed_full_lifecycle_hook_reports + + let Some(session_ref) = session_ref.clone() else { + return FullLifecycleHookReportRoute::Ignore; + }; + let Some(seq) = seq else { + return FullLifecycleHookReportRoute::Ignore; + }; + if self + .hook_report_sequences .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 - ) - }) + .is_some_and(|previous| seq <= *previous) + { + return FullLifecycleHookReportRoute::Ignore; + } + + let previous_session_ref = self + .persisted_agent_session + .as_ref() + .filter(|session| session.source == source && session.agent == agent_label) + .map(|session| session.session_ref.clone()); + let suppressed = self + .suppressed_full_lifecycle_hook_reports + .entry(source.to_string()) + .or_insert_with(|| SuppressedFullLifecycleHookReport { + agent_label: agent_label.to_string(), + session_ref: previous_session_ref, + observed_at: reported_at, + reason: FullLifecycleHookSuppressionReason::ProcessExit, + replacement_session_ref: None, + pending_replacement_report: None, + }); + let replace_pending = suppressed + .pending_replacement_report + .as_ref() + .is_none_or(|pending| seq > pending.seq); + if replace_pending { + suppressed.pending_replacement_report = Some(PendingFullLifecycleHookReport { + authority: HookAuthority { + source: source.to_string(), + agent_label: agent_label.to_string(), + state, + message: message.map(str::to_string), + reported_at, + session_ref: Some(session_ref), + }, + seq, + }); + } + FullLifecycleHookReportRoute::Ignore } fn full_lifecycle_hook_report_matches_stale_session( @@ -801,22 +1041,59 @@ impl TerminalState { } let detected_label = crate::detect::agent_label(detected_agent); let mut stale_sessions = Vec::new(); + let mut validated_replacement_sessions = Vec::new(); self.suppressed_full_lifecycle_hook_reports .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(( + if !should_clear { + return true; + } + if suppressed.reason == FullLifecycleHookSuppressionReason::ProcessExit { + if let Some(session_ref) = suppressed.replacement_session_ref.take() { + if let Some(exited_session_ref) = suppressed + .session_ref + .as_ref() + .filter(|exited_session_ref| *exited_session_ref != &session_ref) + .cloned() + { + stale_sessions.push(( + source.clone(), + StaleFullLifecycleHookSession { + agent_label: suppressed.agent_label.clone(), + session_ref: exited_session_ref, + }, + )); + } + let session_start_seq = self.hook_report_sequences.get(source).copied(); + let pending = + suppressed + .pending_replacement_report + .take() + .filter(|pending| { + pending.authority.session_ref.as_ref() == Some(&session_ref) + && session_start_seq.is_none_or(|seq| pending.seq > seq) + }); + validated_replacement_sessions.push(( source.clone(), - StaleFullLifecycleHookSession { - agent_label: suppressed.agent_label.clone(), - session_ref, - }, + suppressed.agent_label.clone(), + session_ref, + pending, )); + return false; } + return true; + } + 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 + false }); for (source, stale_session) in stale_sessions { self.remember_stale_full_lifecycle_hook_session( @@ -826,8 +1103,24 @@ impl TerminalState { ); } self.hook_report_sequences.retain(|source, _| { - !crate::detect::full_lifecycle_hook_authority(source, detected_label) + validated_replacement_sessions + .iter() + .any(|(validated_source, _, _, _)| validated_source == source) + || !crate::detect::full_lifecycle_hook_authority(source, detected_label) }); + for (source, agent_label, session_ref, pending) in validated_replacement_sessions { + self.forget_stale_full_lifecycle_hook_session(&source, &agent_label, &session_ref); + self.reconcile_agent_name_owner(&agent_label, Some(&session_ref)); + self.persisted_agent_session = Some(crate::agent_resume::PersistedAgentSession { + source: source.clone(), + agent: agent_label, + session_ref, + }); + if let Some(pending) = pending { + self.hook_report_sequences.insert(source, pending.seq); + self.hook_authority = Some(pending.authority); + } + } } fn remember_stale_full_lifecycle_hook_session( @@ -986,7 +1279,8 @@ impl TerminalState { "herdr:codex", "codex", Some("startup" | "clear" | "resume" | "compact") - ) | ("herdr:opencode", "opencode", Some("new")) + ) | ("herdr:mastracode", "mastracode", Some("startup")) + | ("herdr:opencode", "opencode", Some("new")) | ("herdr:pi", "pi", Some("new" | "resume" | "fork")) | ( "herdr:omp", @@ -1029,6 +1323,86 @@ impl TerminalState { session_start_source: Option<String>, ) -> Option<TerminalStateMutation> { let session_ref = session_ref?; + let known_agent = crate::detect::parse_agent_label(&agent_label); + let process_present = known_agent.is_some() + && self.detected_agent == known_agent + && self.recent_agent_process_exit.is_none(); + let full_lifecycle_source = + crate::detect::full_lifecycle_hook_authority(&source, &agent_label); + let generation_gated = self + .suppressed_full_lifecycle_hook_reports + .get(&source) + .is_some_and(|suppressed| { + suppressed.agent_label == agent_label + && suppressed.reason != FullLifecycleHookSuppressionReason::HookClear + }); + let session_anchored = self.hook_authority.as_ref().is_some_and(|authority| { + authority.source == source + && authority.agent_label == agent_label + && authority.session_ref.is_some() + }) || self.persisted_agent_session_matches(&source, &agent_label); + if full_lifecycle_source && (!process_present || generation_gated || !session_anchored) { + if !Self::session_start_source_is_recognized(session_start_source.as_deref()) { + return None; + } + let seq = seq?; + if self + .hook_report_sequences + .get(&source) + .is_some_and(|previous| seq <= *previous) + { + 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 now = Instant::now(); + let previous_presentation = + self.effective_presentation_for_state_at(previous_state, now); + let previous_session = self.current_session_identity_for_persistence(); + let suppressed = self + .suppressed_full_lifecycle_hook_reports + .entry(source.clone()) + .or_insert_with(|| SuppressedFullLifecycleHookReport { + agent_label: agent_label.clone(), + session_ref: None, + observed_at: now, + reason: FullLifecycleHookSuppressionReason::ProcessExit, + replacement_session_ref: None, + pending_replacement_report: None, + }); + if suppressed.replacement_session_ref.as_ref() != Some(&session_ref) { + if suppressed + .pending_replacement_report + .as_ref() + .is_some_and(|pending| { + pending.authority.session_ref.as_ref() != Some(&session_ref) + }) + { + suppressed.pending_replacement_report = None; + } + suppressed.replacement_session_ref = Some(session_ref); + } + self.hook_report_sequences.insert(source.clone(), seq); + + if process_present { + self.clear_full_lifecycle_hook_suppression_for_detected_agent(None, known_agent); + let current_session = self.current_session_identity_for_persistence(); + return Some(TerminalStateMutation { + effective_state_change: self.recompute_effective_state( + previous_agent_label, + previous_known_agent, + previous_state, + previous_presentation, + now, + ), + session_ref_changed: previous_session != current_session, + agent_released: false, + }); + } + return None; + } if !self.accept_hook_report(&source, seq) { return None; } @@ -1193,6 +1567,13 @@ impl TerminalState { .as_ref() .map(|authority| authority.source.clone()) }); + let should_clear = self + .hook_authority + .as_ref() + .is_some_and(|authority| source.is_none_or(|source| authority.source == source)); + if !should_clear { + return None; + } if let Some(source) = sequence_source.as_deref() { if !self.accept_hook_report(source, seq) { return None; @@ -1205,13 +1586,6 @@ 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(); - let should_clear = self - .hook_authority - .as_ref() - .is_some_and(|authority| source.is_none_or(|source| authority.source == source)); - if !should_clear { - return None; - } self.suppress_current_full_lifecycle_hook_authority( FullLifecycleHookSuppressionReason::HookClear, ); @@ -1230,27 +1604,12 @@ impl TerminalState { }) } - #[cfg(test)] - pub fn release_agent( - &mut self, - source: &str, - agent_label: &str, - seq: Option<u64>, - ) -> Option<EffectiveStateChange> { - self.release_agent_with_mutation(source, agent_label, seq) - .and_then(|mutation| mutation.effective_state_change) - } - pub fn release_agent_with_mutation( &mut self, source: &str, agent_label: &str, seq: Option<u64>, ) -> Option<TerminalStateMutation> { - if !self.accept_hook_report(source, seq) { - return None; - } - if self.hook_authority.as_ref().is_some_and(|authority| { authority.agent_label != agent_label || authority.source != source }) { @@ -1262,10 +1621,17 @@ impl TerminalState { if !matches_current_agent && !matches_persisted_session { return None; } + if !self.accept_hook_report(source, seq) { + return None; + } let preserve_foreign_persisted_session = self .persisted_agent_session .as_ref() .is_some_and(|session| session.source != source || session.agent != agent_label); + let process_owns_agent = + crate::detect::parse_agent_label(agent_label).is_some_and(|agent| { + self.detected_agent == Some(agent) && self.recent_agent_process_exit.is_none() + }); let now = Instant::now(); let previous_agent_label = self.effective_agent_label().map(str::to_string); @@ -1278,12 +1644,14 @@ impl TerminalState { agent_label, FullLifecycleHookSuppressionReason::HookClear, ); - self.detected_agent = None; - self.fallback_state = AgentState::Unknown; - self.fallback_visible_blocker = false; - self.fallback_observed_at = None; + if !process_owns_agent { + self.detected_agent = None; + self.fallback_state = AgentState::Unknown; + self.fallback_visible_blocker = false; + self.fallback_observed_at = None; + self.clear_agent_name(); + } self.hook_authority = None; - self.clear_agent_name(); if !preserve_foreign_persisted_session { self.persisted_agent_session = None; } @@ -1297,22 +1665,33 @@ impl TerminalState { now, ), session_ref_changed: previous_session != current_session, - agent_released: true, + agent_released: !process_owns_agent, }) } + fn hook_authority_is_effective(&self, authority: &HookAuthority) -> bool { + !crate::detect::full_lifecycle_hook_authority(&authority.source, &authority.agent_label) + || crate::detect::parse_agent_label(&authority.agent_label).is_none_or(|agent| { + self.detected_agent == Some(agent) && self.recent_agent_process_exit.is_none() + }) + } + pub fn effective_agent_label(&self) -> Option<&str> { self.hook_authority .as_ref() + .filter(|authority| self.hook_authority_is_effective(authority)) .map(|authority| authority.agent_label.as_str()) - .or_else(|| self.detected_agent.map(crate::detect::agent_label)) + .or_else(|| { + self.recent_agent_process_exit + .is_none() + .then(|| self.detected_agent.map(crate::detect::agent_label)) + .flatten() + }) } pub fn effective_known_agent(&self) -> Option<Agent> { - if let Some(authority) = &self.hook_authority { - return crate::detect::parse_agent_label(&authority.agent_label); - } - self.detected_agent + self.effective_agent_label() + .and_then(crate::detect::parse_agent_label) } pub(crate) fn unchanged_effective_state_change_at(&self, now: Instant) -> EffectiveStateChange { @@ -1351,7 +1730,11 @@ impl TerminalState { fn live_full_lifecycle_hook_authority(&self) -> bool { self.hook_authority.as_ref().is_some_and(|authority| { - crate::detect::full_lifecycle_hook_authority(&authority.source, &authority.agent_label) + self.hook_authority_is_effective(authority) + && crate::detect::full_lifecycle_hook_authority( + &authority.source, + &authority.agent_label, + ) }) } @@ -1533,13 +1916,14 @@ impl TerminalState { self.hook_authority = None; self.persisted_agent_session = None; self.agent_metadata.clear(); + self.metadata_report_agents.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; self.respawn_shell_on_exit = false; - self.recent_agent_process_exit_at = None; + self.recent_agent_process_exit = None; self.pending_agent_resume_plan = None; self.clear_agent_name(); } @@ -1612,6 +1996,7 @@ impl TerminalState { } else { self.hook_authority .as_ref() + .filter(|authority| self.hook_authority_is_effective(authority)) .map(|authority| authority.state) .unwrap_or(self.fallback_state) }; @@ -1663,6 +2048,21 @@ mod tests { .to_string() } + fn anchor_full_lifecycle_session( + terminal: &mut TerminalState, + agent: Agent, + source: &str, + agent_label: &str, + session_ref: crate::agent_resume::AgentSessionRef, + ) { + terminal.set_detected_state(Some(agent), terminal.fallback_state); + terminal.set_persisted_agent_session(crate::agent_resume::PersistedAgentSession { + source: source.into(), + agent: agent_label.into(), + session_ref, + }); + } + #[test] fn managed_agent_activates_only_after_matching_settled_detection() { let mut terminal = test_terminal(); @@ -1740,6 +2140,13 @@ mod tests { fn hook_authority_overrides_fallback_for_same_agent() { let mut terminal = test_terminal(); terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle); + anchor_full_lifecycle_session( + &mut terminal, + Agent::Pi, + "herdr:pi", + "pi", + crate::agent_resume::AgentSessionRef::path(test_session_path("root.jsonl")).unwrap(), + ); terminal.set_hook_authority( "herdr:pi".into(), "pi".into(), @@ -1776,6 +2183,13 @@ mod tests { fn omp_hook_authority_overrides_detected_fallback() { let mut terminal = test_terminal(); terminal.set_detected_state(Some(Agent::Omp), AgentState::Idle); + anchor_full_lifecycle_session( + &mut terminal, + Agent::Omp, + "herdr:omp", + "omp", + crate::agent_resume::AgentSessionRef::id("omp-root").unwrap(), + ); terminal.set_hook_authority( "herdr:omp".into(), "omp".into(), @@ -1838,6 +2252,45 @@ mod tests { } #[test] + fn startup_session_claim_activates_full_lifecycle_integrations() { + for (agent, source, label) in [ + (Agent::Kimi, "herdr:kimi", "kimi"), + (Agent::Kilo, "herdr:kilo", "kilo"), + (Agent::Hermes, "herdr:hermes", "hermes"), + ] { + let mut terminal = test_terminal(); + terminal.set_detected_state(Some(agent), AgentState::Idle); + let session_ref = crate::agent_resume::AgentSessionRef::id(format!("{label}-root")); + + let session = terminal.set_agent_session_ref_for_session_start( + source.into(), + label.into(), + session_ref.clone(), + Some(10), + Some("startup".into()), + ); + let working = terminal.set_hook_authority_with_session_ref( + source.into(), + label.into(), + AgentState::Working, + None, + session_ref, + Some(11), + ); + + assert!( + session.is_some(), + "{label} should accept its startup session" + ); + assert!( + working.is_some(), + "{label} should accept state after startup" + ); + assert_eq!(terminal.state, AgentState::Working); + } + } + + #[test] fn pi_session_replacement_reports_reanchor_full_lifecycle_authority() { for reason in ["new", "resume", "fork"] { let mut terminal = test_terminal(); @@ -1995,6 +2448,13 @@ mod tests { let old_session = test_session_path("pi-current.jsonl"); let new_session = test_session_path("pi-unexpected.jsonl"); terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle); + anchor_full_lifecycle_session( + &mut terminal, + Agent::Pi, + "herdr:pi", + "pi", + crate::agent_resume::AgentSessionRef::path(old_session.clone()).unwrap(), + ); terminal.set_hook_authority_with_session_ref( "herdr:pi".into(), "pi".into(), @@ -2095,91 +2555,6 @@ mod tests { } #[test] - fn process_exit_clears_matching_full_lifecycle_hook_authority() { - let now = Instant::now(); - let mut terminal = test_terminal(); - terminal.set_detected_state(Some(Agent::Pi), AgentState::Working); - terminal.set_hook_authority_at( - "herdr:pi".into(), - "pi".into(), - AgentState::Working, - None, - None, - Some(10), - now, - ); - - let change = terminal.set_detected_state_with_screen_signals_at( - Some(Agent::Pi), - 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 - ); - - let stale = terminal.set_hook_authority_at( - "herdr:pi".into(), - "pi".into(), - AgentState::Working, - None, - None, - Some(9), - now + Duration::from_millis(2), - ); - - assert!(stale.is_none()); - assert_eq!(terminal.state, AgentState::Idle); - } - - #[test] - fn late_full_lifecycle_hook_after_process_exit_does_not_reacquire_authority() { - let now = Instant::now(); - let mut terminal = test_terminal(); - terminal.set_detected_state(Some(Agent::Pi), AgentState::Working); - terminal.set_hook_authority_at( - "herdr:pi".into(), - "pi".into(), - AgentState::Working, - None, - None, - Some(20), - now, - ); - - terminal.set_detected_state_with_screen_signals_at( - Some(Agent::Pi), - AgentState::Idle, - false, - true, - false, - true, - now + Duration::from_millis(1), - ); - let late = terminal.set_hook_authority_at( - "herdr:pi".into(), - "pi".into(), - AgentState::Working, - None, - None, - Some(21), - now + Duration::from_millis(2), - ); - - assert!(late.is_none()); - assert!(terminal.hook_authority.is_none()); - assert_eq!(terminal.state, AgentState::Idle); - } - - #[test] fn late_full_lifecycle_hook_with_same_session_after_process_exit_does_not_reacquire_authority() { let now = Instant::now(); @@ -2219,249 +2594,252 @@ mod tests { } #[test] - fn late_full_lifecycle_hook_after_release_does_not_reacquire_authority() { + 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); - terminal.set_hook_authority( - "herdr:pi".into(), - "pi".into(), - AgentState::Working, - None, - Some(20), - ); - - terminal.release_agent("herdr:pi", "pi", Some(21)); - let late = terminal.set_hook_authority( - "herdr:pi".into(), - "pi".into(), - AgentState::Working, - None, - Some(22), + anchor_full_lifecycle_session( + &mut terminal, + Agent::Pi, + "herdr:pi", + "pi", + crate::agent_resume::AgentSessionRef::path(test_session_path("one.jsonl")).unwrap(), ); - - assert!(late.is_none()); - assert!(terminal.hook_authority.is_none()); - assert_eq!(terminal.state, AgentState::Unknown); - } - - #[test] - fn late_full_lifecycle_hook_with_same_session_after_release_does_not_reacquire_authority() { - let mut terminal = test_terminal(); - let session_path = test_session_path("pi.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, - crate::agent_resume::AgentSessionRef::path(session_path.clone()), + crate::agent_resume::AgentSessionRef::path(test_session_path("one.jsonl")), Some(20), ); - terminal.release_agent("herdr:pi", "pi", Some(21)); - let late = terminal.set_hook_authority_with_session_ref( + let mutation = terminal.set_hook_authority_with_session_ref( "herdr:pi".into(), "pi".into(), - AgentState::Working, + AgentState::Idle, None, - crate::agent_resume::AgentSessionRef::path(session_path), - Some(22), + crate::agent_resume::AgentSessionRef::path(test_session_path("two.jsonl")), + Some(21), ); - assert!(late.is_none()); - assert!(terminal.hook_authority.is_none()); - assert_eq!(terminal.state, AgentState::Unknown); + assert!(mutation.is_none()); + assert_eq!(terminal.state, AgentState::Working); + assert_eq!( + terminal + .hook_authority + .as_ref() + .and_then(|authority| authority.session_ref.as_ref()) + .map(|session_ref| session_ref.value.as_str()), + Some(test_session_path("one.jsonl").as_str()) + ); } #[test] - fn changed_session_ref_allows_full_lifecycle_hook_after_suppression() { + 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, - crate::agent_resume::AgentSessionRef::path(test_session_path("old.jsonl")), - Some(20), + 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, ); - terminal.release_agent("herdr:pi", "pi", Some(21)); - let fresh = terminal.set_hook_authority_with_session_ref( - "herdr:pi".into(), - "pi".into(), - AgentState::Working, + let fresh_process_seen_at = process_exit_seen_at + Duration::from_millis(1); + terminal.set_detected_state_with_screen_signals_at( None, - crate::agent_resume::AgentSessionRef::path(test_session_path("new.jsonl")), - Some(22), + 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), ); - assert!(fresh.is_some()); - assert!(terminal.hook_authority.is_some()); - assert_eq!(terminal.state, AgentState::Working); - } - - #[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( + let late_old = terminal.set_hook_authority_with_session_ref( "herdr:pi".into(), "pi".into(), AgentState::Working, None, - crate::agent_resume::AgentSessionRef::path(test_session_path("old.jsonl")), - Some(1000), + crate::agent_resume::AgentSessionRef::path(old_session), + Some(500), ); - terminal.release_agent("herdr:pi", "pi", Some(3000)); - - let fresh = terminal.set_hook_authority_with_session_ref( + let fresh_new = terminal.set_hook_authority_with_session_ref( "herdr:pi".into(), "pi".into(), AgentState::Working, None, - crate::agent_resume::AgentSessionRef::path(test_session_path("new.jsonl")), - Some(1500), + crate::agent_resume::AgentSessionRef::path(new_session.clone()), + Some(501), ); - assert!(fresh.is_some()); + assert!(late_old.is_none()); + assert!(fresh_new.is_none()); + terminal + .set_agent_session_ref_for_session_start( + "herdr:pi".into(), + "pi".into(), + crate::agent_resume::AgentSessionRef::path(new_session), + Some(400), + Some("startup".into()), + ) + .expect("fresh session should activate the buffered report"); assert!(terminal.hook_authority.is_some()); assert_eq!(terminal.state, AgentState::Working); } #[test] - fn stale_session_suppression_survives_multiple_release_generations() { + fn rapid_restart_replays_reports_that_arrive_before_process_evidence() { 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"); + let session_path = test_session_path("reports-before-process-evidence.jsonl"); + let now = Instant::now(); terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle); - terminal.set_hook_authority_with_session_ref( + terminal.set_hook_authority_at( "herdr:pi".into(), "pi".into(), AgentState::Working, None, - crate::agent_resume::AgentSessionRef::path(session_a.clone()), + crate::agent_resume::AgentSessionRef::path(session_path.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.release_agent("herdr:pi", "pi", Some(2000)); - let generation_b = terminal.set_hook_authority_with_session_ref( + let lower_sequence = terminal.set_hook_authority_at( "herdr:pi".into(), "pi".into(), - AgentState::Working, + AgentState::Idle, None, - crate::agent_resume::AgentSessionRef::path(session_b), - Some(1500), + crate::agent_resume::AgentSessionRef::path(session_path.clone()), + Some(1001), + now + Duration::from_millis(2), ); - assert!(generation_b.is_some()); - terminal.release_agent("herdr:pi", "pi", Some(3000)); - - let late_generation_a = terminal.set_hook_authority_with_session_ref( + let missing_sequence = terminal.set_hook_authority_at( "herdr:pi".into(), "pi".into(), - AgentState::Working, + AgentState::Idle, + None, + crate::agent_resume::AgentSessionRef::path(session_path.clone()), None, - crate::agent_resume::AgentSessionRef::path(session_a), - Some(2500), + now + Duration::from_millis(3), ); - let generation_c = terminal.set_hook_authority_with_session_ref( + let buffered_working = terminal.set_hook_authority_at( "herdr:pi".into(), "pi".into(), AgentState::Working, None, - crate::agent_resume::AgentSessionRef::path(session_c), - Some(2500), + crate::agent_resume::AgentSessionRef::path(session_path.clone()), + Some(2001), + now + Duration::from_millis(4), ); - - 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); - terminal.set_hook_authority_with_session_ref( + let startup = terminal.set_agent_session_ref_for_session_start( "herdr:pi".into(), "pi".into(), - AgentState::Working, - None, - crate::agent_resume::AgentSessionRef::path(test_session_path("one.jsonl")), - Some(20), + crate::agent_resume::AgentSessionRef::path(session_path), + Some(2000), + Some("startup".into()), ); + assert!(startup.is_none()); + assert!(lower_sequence.is_none()); + assert!(missing_sequence.is_none()); + assert!(buffered_working.is_none()); + assert!(!terminal.full_lifecycle_hook_authority_active()); - let mutation = terminal.set_hook_authority_with_session_ref( - "herdr:pi".into(), - "pi".into(), + terminal.set_detected_state_with_screen_signals_at( + Some(Agent::Pi), AgentState::Idle, - None, - crate::agent_resume::AgentSessionRef::path(test_session_path("two.jsonl")), - Some(21), + false, + true, + false, + false, + now + Duration::from_millis(5), ); - assert!(mutation.is_none()); + assert!(terminal.full_lifecycle_hook_authority_active()); assert_eq!(terminal.state, AgentState::Working); - assert_eq!( - terminal - .hook_authority - .as_ref() - .and_then(|authority| authority.session_ref.as_ref()) - .map(|session_ref| session_ref.value.as_str()), - Some(test_session_path("one.jsonl").as_str()) - ); } #[test] - fn fresh_detected_process_allows_full_lifecycle_hook_after_suppression() { + fn process_exit_discards_unclaimed_buffered_state_from_that_generation() { let mut terminal = test_terminal(); - terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle); - terminal.set_hook_authority( + let old_session = test_session_path("buffered-exit-old.jsonl"); + let shared_session = test_session_path("buffered-exit-shared.jsonl"); + let now = Instant::now(); + anchor_full_lifecycle_session( + &mut terminal, + Agent::Pi, + "herdr:pi", + "pi", + crate::agent_resume::AgentSessionRef::path(old_session.clone()).unwrap(), + ); + terminal.set_hook_authority_at( "herdr:pi".into(), "pi".into(), AgentState::Working, None, - Some(20), + crate::agent_resume::AgentSessionRef::path(old_session), + Some(1000), + now, ); - terminal.release_agent("herdr:pi", "pi", Some(21)); - let now = Instant::now(); - terminal.set_detected_state_with_screen_signals_at( Some(Agent::Pi), - AgentState::Unknown, + AgentState::Idle, false, + true, false, + true, + now + Duration::from_millis(1), + ); + terminal.set_detected_state_with_screen_signals_at( + Some(Agent::Pi), + AgentState::Idle, false, + true, false, - now, + false, + now + Duration::from_millis(2), ); - let fresh = terminal.set_hook_authority( + terminal.set_hook_authority_at( "herdr:pi".into(), "pi".into(), AgentState::Working, None, - Some(22), + crate::agent_resume::AgentSessionRef::path(shared_session.clone()), + Some(500), + now + Duration::from_millis(3), ); - assert!(fresh.is_some()); - assert!(terminal.hook_authority.is_some()); - assert_eq!(terminal.state, AgentState::Working); - } - - #[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, @@ -2469,56 +2847,46 @@ mod tests { 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, + now + Duration::from_millis(4), ); terminal.set_detected_state_with_screen_signals_at( Some(Agent::Pi), - AgentState::Unknown, - false, + AgentState::Idle, false, + true, 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), + now + Duration::from_millis(5), ); + terminal + .set_agent_session_ref_for_session_start( + "herdr:pi".into(), + "pi".into(), + crate::agent_resume::AgentSessionRef::path(shared_session), + Some(100), + Some("startup".into()), + ) + .expect("new generation session claim"); - assert!(fresh.is_some()); - assert!(terminal.hook_authority.is_some()); - assert_eq!(terminal.state, AgentState::Working); + assert!(terminal.hook_authority.is_none()); + assert_eq!(terminal.state, AgentState::Idle); } #[test] - fn fresh_detected_process_keeps_old_session_suppressed_after_process_exit() { + fn queued_fresh_process_evidence_uses_process_exit_observation_time() { 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"); + let session_path = test_session_path("queued-after-process-exit.jsonl"); + let process_exit_at = Instant::now() - Duration::from_secs(1); terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle); - terminal.set_hook_authority_with_session_ref( + terminal.set_hook_authority_at( "herdr:pi".into(), "pi".into(), AgentState::Working, None, - crate::agent_resume::AgentSessionRef::path(old_session.clone()), + crate::agent_resume::AgentSessionRef::path(session_path.clone()), Some(1000), + process_exit_at - Duration::from_millis(1), ); - let process_exit_seen_at = Instant::now() + Duration::from_secs(1); terminal.set_detected_state_with_screen_signals_at( Some(Agent::Pi), AgentState::Idle, @@ -2526,10 +2894,9 @@ mod tests { true, false, true, - process_exit_seen_at, + process_exit_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, @@ -2537,39 +2904,26 @@ mod tests { false, false, false, - fresh_process_seen_at, + process_exit_at + Duration::from_millis(1), ); terminal.set_detected_state_with_screen_signals_at( Some(Agent::Pi), - AgentState::Unknown, - false, + AgentState::Idle, false, + true, 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, - crate::agent_resume::AgentSessionRef::path(old_session), - Some(500), + process_exit_at + Duration::from_millis(2), ); - let fresh_new = terminal.set_hook_authority_with_session_ref( + let startup = terminal.set_agent_session_ref_for_session_start( "herdr:pi".into(), "pi".into(), - AgentState::Working, - None, - crate::agent_resume::AgentSessionRef::path(new_session), - Some(500), + crate::agent_resume::AgentSessionRef::path(session_path), + Some(2000), + Some("startup".into()), ); - assert!(late_old.is_none()); - assert!(fresh_new.is_some()); - assert!(terminal.hook_authority.is_some()); - assert_eq!(terminal.state, AgentState::Working); + assert!(startup.is_some()); } #[test] @@ -2629,14 +2983,12 @@ mod tests { false, now + Duration::from_millis(4), ); - let fresh_new = terminal.set_hook_authority_at( + let fresh_new = terminal.set_agent_session_ref_for_session_start( "herdr:pi".into(), "pi".into(), - AgentState::Working, - None, crate::agent_resume::AgentSessionRef::path(new_session), - Some(500), - now + Duration::from_millis(5), + Some(400), + Some("startup".into()), ); assert!(fresh_new.is_some()); @@ -2709,277 +3061,64 @@ mod tests { Some(500), now + Duration::from_millis(5), ); + assert!(fresh_without_session.is_none()); + assert!(terminal.hook_authority.is_none()); - 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_at( - "herdr:pi".into(), - "pi".into(), - AgentState::Working, - 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_at( + terminal + .set_agent_session_ref_for_session_start( + "herdr:pi".into(), + "pi".into(), + crate::agent_resume::AgentSessionRef::path(test_session_path( + "fresh-after-nosession-process-exit.jsonl", + )), + Some(600), + Some("startup".into()), + ) + .expect("fresh root session should claim the process generation"); + let child_update = terminal.set_hook_authority_at( "herdr:pi".into(), "pi".into(), AgentState::Working, 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, + Some(601), 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_at( - "herdr:pi".into(), - "pi".into(), - AgentState::Working, - None, - crate::agent_resume::AgentSessionRef::path(session_a), - Some(250), - now + Duration::from_millis(8), - ); - let generation_c = terminal.set_hook_authority_at( - "herdr:pi".into(), - "pi".into(), - AgentState::Working, - 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!(child_update.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(); - terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle); - terminal.set_hook_authority( - "herdr:pi".into(), - "pi".into(), - AgentState::Working, - None, - Some(20), - ); - terminal.release_agent("herdr:pi", "pi", Some(21)); - - let change = terminal.set_detected_state_with_screen_signals_at( - Some(Agent::Pi), - AgentState::Idle, - false, - true, - false, - false, - now, - ); - let late = terminal.set_hook_authority( - "herdr:pi".into(), - "pi".into(), - AgentState::Working, - None, - Some(22), - ); - - assert!(change.effective_state_change.is_none()); - assert!(late.is_none()); - assert_eq!(terminal.detected_agent, None); - assert_eq!(terminal.state, AgentState::Unknown); - } - - #[test] - fn fresh_session_ref_allows_full_lifecycle_hook_after_suppression() { + fn mastracode_session_start_replaces_current_root_session() { 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(20), - ); - terminal.release_agent("herdr:pi", "pi", Some(21)); - - let fresh = terminal.set_hook_authority_with_session_ref( - "herdr:pi".into(), - "pi".into(), - AgentState::Working, - None, - crate::agent_resume::AgentSessionRef::id("fresh-session"), - Some(22), - ); - - assert!(fresh.is_some()); - assert!(terminal.hook_authority.is_some()); - assert_eq!(terminal.state, AgentState::Working); - } - - // Regression for #614: a same-pane restart must reacquire lifecycle authority. - #[test] - fn omp_reacquires_full_lifecycle_hook_after_release_with_fresh_session_ref() { - assert_full_lifecycle_hook_reacquires_after_release_with_fresh_session_ref( - "herdr:omp", - "omp", - ); - } - - #[test] - fn mastracode_reacquires_full_lifecycle_hook_after_release_with_fresh_session_ref() { - assert_full_lifecycle_hook_reacquires_after_release_with_fresh_session_ref( - "herdr:mastracode", - "mastracode", - ); - } - - #[test] - fn mastracode_lifecycle_report_replaces_restored_thread_ref() { - let mut terminal = test_terminal(); - terminal.set_persisted_agent_session(crate::agent_resume::PersistedAgentSession { - source: "herdr:mastracode".into(), - agent: "mastracode".into(), - session_ref: crate::agent_resume::AgentSessionRef::id("mastracode-old").unwrap(), - }); - - let mutation = terminal - .set_hook_authority_with_session_ref( + terminal.set_detected_state(Some(Agent::Mastracode), AgentState::Idle); + terminal + .set_agent_session_ref_for_session_start( "herdr:mastracode".into(), "mastracode".into(), - AgentState::Working, - None, - crate::agent_resume::AgentSessionRef::id("mastracode-new"), + crate::agent_resume::AgentSessionRef::id("mastracode-old"), Some(20), + Some("startup".into()), ) - .expect("fresh MastraCode thread should replace restored thread id"); - - assert!(mutation.session_ref_changed); - assert_eq!( - terminal.current_session_identity_for_persistence(), - Some(( - "herdr:mastracode".into(), - "mastracode".into(), - crate::agent_resume::AgentSessionRefKind::Id, - "mastracode-new".into() - )) - ); - } + .expect("initial root session"); - fn assert_full_lifecycle_hook_reacquires_after_release_with_fresh_session_ref( - source: &str, - agent_label: &str, - ) { - let mut terminal = test_terminal(); - let old_session = format!("{agent_label}-old"); - let new_session = format!("{agent_label}-new"); - - terminal.set_hook_authority_with_session_ref( - source.into(), - agent_label.into(), - AgentState::Working, - None, - crate::agent_resume::AgentSessionRef::id(&old_session), - Some(20), - ); - terminal.release_agent(source, agent_label, Some(21)); - - // A late report from the released run keeps its old session ref and stays - // suppressed, so a just-exited agent cannot resurrect the pane. - let stale = terminal.set_hook_authority_with_session_ref( - source.into(), - agent_label.into(), - AgentState::Working, - None, - crate::agent_resume::AgentSessionRef::id(&old_session), - Some(22), + let replacement = terminal.set_agent_session_ref_for_session_start( + "herdr:mastracode".into(), + "mastracode".into(), + crate::agent_resume::AgentSessionRef::id("mastracode-new"), + Some(21), + Some("startup".into()), ); - assert!(stale.is_none()); - assert!(terminal.hook_authority.is_none()); - // A fresh run carries a new session ref and reacquires authority. - let fresh = terminal.set_hook_authority_with_session_ref( - source.into(), - agent_label.into(), - AgentState::Working, - None, - crate::agent_resume::AgentSessionRef::id(&new_session), - Some(23), + assert!(replacement.is_some_and(|mutation| mutation.session_ref_changed)); + assert_eq!( + terminal + .persisted_agent_session + .as_ref() + .map(|session| session.session_ref.value.as_str()), + Some("mastracode-new") ); - assert!(fresh.is_some()); - assert!(terminal.hook_authority.is_some()); - assert_eq!(terminal.state, AgentState::Working); } #[test] @@ -3035,6 +3174,15 @@ mod tests { false, now + Duration::from_millis(3), ); + terminal + .set_agent_session_ref_for_session_start( + "herdr:omp".into(), + "omp".into(), + crate::agent_resume::AgentSessionRef::id("omp-new"), + Some(400), + Some("startup".into()), + ) + .expect("fresh process and session should claim the pane"); let fresh = terminal.set_hook_authority_with_session_ref( "herdr:omp".into(), "omp".into(), @@ -3078,6 +3226,13 @@ mod tests { fn visible_blocker_does_not_override_full_lifecycle_hook_authority() { let mut terminal = test_terminal(); terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle); + anchor_full_lifecycle_session( + &mut terminal, + Agent::Pi, + "herdr:pi", + "pi", + crate::agent_resume::AgentSessionRef::path(test_session_path("root.jsonl")).unwrap(), + ); terminal.set_hook_authority( "herdr:pi".into(), "pi".into(), @@ -3206,6 +3361,13 @@ mod tests { let now = Instant::now(); let mut terminal = test_terminal(); terminal.set_detected_state(Some(Agent::OpenCode), AgentState::Working); + anchor_full_lifecycle_session( + &mut terminal, + Agent::OpenCode, + "herdr:opencode", + "opencode", + crate::agent_resume::AgentSessionRef::id("opencode-root").unwrap(), + ); terminal.set_hook_authority_at( "herdr:opencode".into(), "opencode".into(), @@ -3264,6 +3426,13 @@ mod tests { let now = Instant::now(); let mut terminal = test_terminal(); terminal.set_detected_state(Some(Agent::Hermes), AgentState::Idle); + anchor_full_lifecycle_session( + &mut terminal, + Agent::Hermes, + "herdr:hermes", + "hermes", + crate::agent_resume::AgentSessionRef::id("hermes-root").unwrap(), + ); terminal.set_hook_authority_at( "herdr:hermes".into(), "hermes".into(), @@ -3294,6 +3463,13 @@ mod tests { let now = Instant::now(); let mut terminal = test_terminal(); terminal.set_detected_state(Some(Agent::Kilo), AgentState::Idle); + anchor_full_lifecycle_session( + &mut terminal, + Agent::Kilo, + "herdr:kilo", + "kilo", + crate::agent_resume::AgentSessionRef::id("kilo-root").unwrap(), + ); terminal.set_hook_authority_at( "herdr:kilo".into(), "kilo".into(), @@ -3502,6 +3678,13 @@ mod tests { let now = Instant::now(); let mut terminal = test_terminal(); terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle); + anchor_full_lifecycle_session( + &mut terminal, + Agent::Pi, + "herdr:pi", + "pi", + crate::agent_resume::AgentSessionRef::path(test_session_path("root.jsonl")).unwrap(), + ); terminal.set_hook_authority_at( "herdr:pi".into(), "pi".into(), @@ -3592,7 +3775,7 @@ mod tests { assert!(terminal.hook_authority.is_none()); assert_eq!(terminal.detected_agent, Some(Agent::Codex)); - assert_eq!(terminal.effective_agent_label(), Some("codex")); + assert_eq!(terminal.effective_agent_label(), None); assert_eq!(terminal.state, AgentState::Idle); } @@ -3633,7 +3816,119 @@ mod tests { } #[test] - fn stale_process_exit_does_not_clear_newer_same_agent_hook_authority() { + fn stale_process_exit_preserves_newer_custom_authority() { + let mut terminal = test_terminal(); + let observed = Instant::now(); + terminal.set_detected_state_with_screen_signals_at( + Some(Agent::Pi), + AgentState::Idle, + false, + false, + false, + false, + observed, + ); + terminal.set_hook_authority_at( + "custom:pi".into(), + "pi".into(), + AgentState::Working, + None, + None, + Some(100), + observed + Duration::from_secs(1), + ); + + let mutation = terminal.set_detected_state_with_screen_signals_at( + Some(Agent::Pi), + AgentState::Idle, + false, + false, + false, + true, + observed, + ); + + assert!(!mutation.agent_released); + assert_eq!(terminal.state, AgentState::Working); + assert_eq!( + terminal + .hook_authority + .as_ref() + .map(|hook| hook.source.as_str()), + Some("custom:pi") + ); + } + + #[test] + fn custom_authority_reanchors_sequence_after_process_restart() { + let mut terminal = test_terminal(); + let observed = Instant::now(); + terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle); + terminal.set_hook_authority_at( + "custom:pi".into(), + "pi".into(), + AgentState::Working, + None, + None, + Some(100), + observed, + ); + terminal.set_detected_state_with_screen_signals_at( + Some(Agent::Pi), + AgentState::Idle, + false, + false, + false, + true, + observed + Duration::from_millis(1), + ); + terminal.set_detected_state_with_screen_signals_at( + None, + AgentState::Unknown, + false, + false, + false, + false, + observed + Duration::from_millis(2), + ); + + assert!(terminal + .release_agent_with_mutation("custom:pi", "pi", Some(200)) + .is_none()); + assert!(terminal + .clear_hook_authority_with_mutation(Some("custom:pi"), Some(201)) + .is_none()); + assert!(terminal + .set_hook_authority( + "custom:pi".into(), + "pi".into(), + AgentState::Working, + None, + Some(1), + ) + .is_none()); + terminal.set_detected_state_with_screen_signals_at( + Some(Agent::Pi), + AgentState::Idle, + false, + false, + false, + false, + observed + Duration::from_millis(3), + ); + assert!(terminal + .set_hook_authority( + "custom:pi".into(), + "pi".into(), + AgentState::Working, + None, + Some(1), + ) + .is_some()); + } + + #[test] + fn process_exit_clears_newer_same_agent_hook_authority() { let mut terminal = test_terminal(); let observed = Instant::now(); terminal.set_detected_state_with_screen_signals_at( @@ -3674,10 +3969,9 @@ mod tests { observed, ); - let authority = terminal.hook_authority.as_ref().expect("hook authority"); - assert_eq!(authority.reported_at, observed + Duration::from_secs(1)); - assert_eq!(terminal.state, AgentState::Working); - assert_eq!(terminal.effective_agent_label(), Some("codex")); + assert!(terminal.hook_authority.is_none()); + assert_eq!(terminal.state, AgentState::Idle); + assert_eq!(terminal.effective_agent_label(), None); } #[test] @@ -3701,28 +3995,16 @@ mod tests { } #[test] - fn release_agent_clears_identity_immediately() { + fn stale_hook_report_sequence_is_ignored_for_same_source() { 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, - None, + anchor_full_lifecycle_session( + &mut terminal, + Agent::Pi, + "herdr:pi", + "pi", + crate::agent_resume::AgentSessionRef::path(test_session_path("root.jsonl")).unwrap(), ); - - terminal.release_agent("herdr:pi", "pi", None); - - assert!(terminal.hook_authority.is_none()); - assert_eq!(terminal.detected_agent, None); - assert_eq!(terminal.fallback_state, AgentState::Unknown); - assert_eq!(terminal.state, AgentState::Unknown); - } - - #[test] - fn stale_hook_report_sequence_is_ignored_for_same_source() { - let mut terminal = test_terminal(); terminal.set_hook_authority( "herdr:pi".into(), "pi".into(), @@ -3751,6 +4033,13 @@ mod tests { fn accepted_hook_report_stores_session_ref() { let mut terminal = test_terminal(); let session_path = test_session_path("pi.jsonl"); + anchor_full_lifecycle_session( + &mut terminal, + Agent::Pi, + "herdr:pi", + "pi", + crate::agent_resume::AgentSessionRef::path(session_path.clone()).unwrap(), + ); let mutation = terminal .set_hook_authority_with_session_ref( "herdr:pi".into(), @@ -3762,7 +4051,7 @@ mod tests { ) .expect("accepted report"); - assert!(mutation.session_ref_changed); + assert!(!mutation.session_ref_changed); assert_eq!( terminal .hook_authority @@ -3781,6 +4070,13 @@ mod tests { let mut terminal = test_terminal(); let session_path = test_session_path("pi.jsonl"); let new_session_path = test_session_path("new.jsonl"); + anchor_full_lifecycle_session( + &mut terminal, + Agent::Pi, + "herdr:pi", + "pi", + crate::agent_resume::AgentSessionRef::path(session_path.clone()).unwrap(), + ); terminal.set_hook_authority_with_session_ref( "herdr:pi".into(), "pi".into(), @@ -3814,6 +4110,13 @@ mod tests { fn accepted_hook_report_without_session_ref_clears_previous_ref() { let mut terminal = test_terminal(); let session_path = test_session_path("pi.jsonl"); + anchor_full_lifecycle_session( + &mut terminal, + Agent::Pi, + "herdr:pi", + "pi", + crate::agent_resume::AgentSessionRef::path(session_path.clone()).unwrap(), + ); terminal.set_hook_authority_with_session_ref( "herdr:pi".into(), "pi".into(), @@ -3845,30 +4148,6 @@ mod tests { } #[test] - fn accepted_hook_report_marks_changed_when_same_owner_session_identity_changes() { - let mut terminal = test_terminal(); - terminal.set_persisted_agent_session(crate::agent_resume::PersistedAgentSession { - source: "herdr:pi".into(), - agent: "pi".into(), - session_ref: crate::agent_resume::AgentSessionRef::path(test_session_path("old.jsonl")) - .unwrap(), - }); - - let mutation = terminal - .set_hook_authority_with_session_ref( - "herdr:pi".into(), - "pi".into(), - AgentState::Working, - None, - crate::agent_resume::AgentSessionRef::path(test_session_path("new.jsonl")), - Some(20), - ) - .expect("accepted report"); - - assert!(mutation.session_ref_changed); - } - - #[test] fn different_same_agent_session_ref_is_ignored_until_current_session_clears() { let mut terminal = test_terminal(); terminal @@ -4008,12 +4287,14 @@ mod tests { #[test] fn opencode_new_session_ref_replaces_existing_session_ref() { let mut terminal = test_terminal(); + terminal.set_detected_state(Some(Agent::OpenCode), AgentState::Idle); terminal - .set_agent_session_ref( + .set_agent_session_ref_for_session_start( "herdr:opencode".into(), "opencode".into(), crate::agent_resume::AgentSessionRef::id("opencode-old"), Some(20), + Some("new".into()), ) .expect("initial session should be accepted"); @@ -4042,11 +4323,12 @@ mod tests { let mut terminal = test_terminal(); terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle); terminal - .set_agent_session_ref( + .set_agent_session_ref_for_session_start( "herdr:pi".into(), "pi".into(), crate::agent_resume::AgentSessionRef::id("pi-old"), Some(20), + Some("new".into()), ) .expect("initial session should be accepted"); terminal.set_agent_name("reviewer".into()); @@ -4104,12 +4386,14 @@ mod tests { #[test] fn opencode_session_ref_without_start_source_does_not_replace_existing() { let mut terminal = test_terminal(); + terminal.set_detected_state(Some(Agent::OpenCode), AgentState::Idle); terminal - .set_agent_session_ref( + .set_agent_session_ref_for_session_start( "herdr:opencode".into(), "opencode".into(), crate::agent_resume::AgentSessionRef::id("opencode-old"), Some(20), + Some("new".into()), ) .expect("initial session should be accepted"); @@ -4303,6 +4587,13 @@ mod tests { fn foreground_agent_session_replaces_stale_different_owner_hook_authority() { let mut terminal = test_terminal(); let now = std::time::Instant::now(); + anchor_full_lifecycle_session( + &mut terminal, + Agent::OpenCode, + "herdr:opencode", + "opencode", + crate::agent_resume::AgentSessionRef::id("opencode-session").unwrap(), + ); terminal .set_hook_authority_at( "herdr:opencode".into(), @@ -4356,13 +4647,22 @@ mod tests { assert!(late_old_session.is_none()); terminal.set_detected_state(Some(Agent::OpenCode), AgentState::Idle); + terminal + .set_agent_session_ref_for_session_start( + "herdr:opencode".into(), + "opencode".into(), + crate::agent_resume::AgentSessionRef::id("opencode-new-session"), + Some(23), + Some("new".into()), + ) + .expect("fresh root session"); let fresh_session = terminal.set_hook_authority_with_session_ref( "herdr:opencode".into(), "opencode".into(), AgentState::Working, None, crate::agent_resume::AgentSessionRef::id("opencode-new-session"), - Some(23), + Some(24), ); assert!(fresh_session.is_some()); } @@ -4425,8 +4725,16 @@ mod tests { } #[test] - fn hook_authority_preserves_current_session_ref_when_incoming_ref_differs() { + fn hook_authority_rejects_state_from_a_different_session() { let mut terminal = test_terminal(); + terminal.set_detected_state(Some(Agent::OpenCode), AgentState::Idle); + anchor_full_lifecycle_session( + &mut terminal, + Agent::OpenCode, + "herdr:opencode", + "opencode", + crate::agent_resume::AgentSessionRef::id("opencode-session").unwrap(), + ); terminal .set_hook_authority_with_session_ref( "herdr:opencode".into(), @@ -4438,19 +4746,17 @@ mod tests { ) .expect("initial session should be accepted"); - let mutation = terminal - .set_hook_authority_with_session_ref( - "herdr:opencode".into(), - "opencode".into(), - AgentState::Blocked, - Some("needs approval".into()), - crate::agent_resume::AgentSessionRef::id("nested-session"), - Some(21), - ) - .expect("state update should still be accepted"); + let mutation = terminal.set_hook_authority_with_session_ref( + "herdr:opencode".into(), + "opencode".into(), + AgentState::Blocked, + Some("needs approval".into()), + crate::agent_resume::AgentSessionRef::id("nested-session"), + Some(21), + ); - assert!(!mutation.session_ref_changed); - assert_eq!(terminal.state, AgentState::Blocked); + assert!(mutation.is_none()); + assert_eq!(terminal.state, AgentState::Working); assert_eq!( terminal .hook_authority @@ -4498,6 +4804,13 @@ mod tests { fn clearing_hook_authority_clears_session_ref() { let mut terminal = test_terminal(); let session_path = test_session_path("pi.jsonl"); + anchor_full_lifecycle_session( + &mut terminal, + Agent::Pi, + "herdr:pi", + "pi", + crate::agent_resume::AgentSessionRef::path(session_path.clone()).unwrap(), + ); terminal.set_hook_authority_with_session_ref( "herdr:pi".into(), "pi".into(), @@ -4516,28 +4829,7 @@ mod tests { } #[test] - fn release_agent_clears_session_ref() { - let mut terminal = test_terminal(); - let session_path = test_session_path("pi.jsonl"); - terminal.set_hook_authority_with_session_ref( - "herdr:pi".into(), - "pi".into(), - AgentState::Working, - None, - crate::agent_resume::AgentSessionRef::path(session_path), - Some(20), - ); - - let mutation = terminal - .release_agent_with_mutation("herdr:pi", "pi", Some(21)) - .expect("accepted release"); - - assert!(mutation.session_ref_changed); - assert!(terminal.hook_authority.is_none()); - } - - #[test] - fn agent_alias_survives_detection_uncertainty_but_not_replacement_or_release() { + fn agent_alias_survives_detection_uncertainty_and_reported_release_but_not_replacement() { let mut terminal = test_terminal(); terminal.set_detected_state(Some(Agent::Pi), AgentState::Working); terminal.set_agent_name("reviewer".into()); @@ -4549,10 +4841,37 @@ mod tests { assert!(terminal.agent_name.is_none()); terminal.set_agent_name("replacement".into()); - terminal + let mutation = terminal .release_agent_with_mutation("herdr:codex", "codex", None) .expect("detected agent release should be accepted"); - assert!(terminal.agent_name.is_none()); + assert!(!mutation.agent_released); + assert_eq!(terminal.agent_name.as_deref(), Some("replacement")); + assert_eq!(terminal.detected_agent, Some(Agent::Codex)); + } + + #[test] + fn custom_release_preserves_process_owned_agent_state() { + let mut terminal = test_terminal(); + terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle); + terminal + .set_hook_authority( + "custom:pi".into(), + "pi".into(), + AgentState::Working, + None, + Some(10), + ) + .expect("custom state should be accepted"); + + let mutation = terminal + .release_agent_with_mutation("custom:pi", "pi", Some(11)) + .expect("custom release should be accepted"); + + assert!(!mutation.agent_released); + assert!(terminal.hook_authority.is_none()); + assert_eq!(terminal.detected_agent, Some(Agent::Pi)); + assert_eq!(terminal.effective_agent_label(), Some("pi")); + assert_eq!(terminal.state, AgentState::Idle); } #[test] @@ -4608,6 +4927,14 @@ mod tests { #[test] fn accepted_same_kind_hook_owner_replacement_clears_the_alias() { let mut terminal = test_terminal(); + terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle); + anchor_full_lifecycle_session( + &mut terminal, + Agent::Pi, + "herdr:pi", + "pi", + crate::agent_resume::AgentSessionRef::path(test_session_path("first.jsonl")).unwrap(), + ); terminal .set_hook_authority_at( "herdr:pi".into(), @@ -4694,11 +5021,12 @@ mod tests { #[test] fn process_exit_clears_matching_persisted_session_ref() { let mut terminal = test_terminal(); + let session_ref = + crate::agent_resume::AgentSessionRef::path(test_session_path("pi.jsonl")).unwrap(); terminal.set_persisted_agent_session(crate::agent_resume::PersistedAgentSession { source: "herdr:pi".into(), agent: "pi".into(), - session_ref: crate::agent_resume::AgentSessionRef::path(test_session_path("pi.jsonl")) - .unwrap(), + session_ref: session_ref.clone(), }); terminal.set_detected_state(Some(Agent::Pi), AgentState::Working); @@ -4714,6 +5042,15 @@ mod tests { assert!(mutation.session_ref_changed); assert!(terminal.persisted_agent_session.is_none()); + + let delayed = terminal.set_agent_session_ref( + "herdr:pi".into(), + "pi".into(), + Some(session_ref), + Some(21), + ); + assert!(delayed.is_none()); + assert!(terminal.persisted_agent_session.is_none()); } #[test] @@ -4831,6 +5168,13 @@ mod tests { fn detected_agent_disappearance_does_not_clear_full_lifecycle_hook_session_ref() { let mut terminal = test_terminal(); terminal.set_detected_state(Some(Agent::Hermes), AgentState::Idle); + anchor_full_lifecycle_session( + &mut terminal, + Agent::Hermes, + "herdr:hermes", + "hermes", + crate::agent_resume::AgentSessionRef::id("hermes-session").unwrap(), + ); terminal.set_hook_authority_with_session_ref( "herdr:hermes".into(), "hermes".into(), @@ -4884,6 +5228,14 @@ mod tests { #[test] fn unsequenced_hook_report_is_ignored_after_source_uses_sequence() { let mut terminal = test_terminal(); + terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle); + anchor_full_lifecycle_session( + &mut terminal, + Agent::Pi, + "herdr:pi", + "pi", + crate::agent_resume::AgentSessionRef::path(test_session_path("root.jsonl")).unwrap(), + ); terminal.set_hook_authority( "herdr:pi".into(), "pi".into(), @@ -4905,26 +5257,16 @@ mod tests { } #[test] - fn stale_release_sequence_is_ignored_for_same_source() { - let mut terminal = test_terminal(); - terminal.set_hook_authority( - "herdr:pi".into(), - "pi".into(), - AgentState::Working, - None, - Some(20), - ); - - let change = terminal.release_agent("herdr:pi", "pi", Some(19)); - - assert!(change.is_none()); - assert_eq!(terminal.state, AgentState::Working); - assert!(terminal.hook_authority.is_some()); - } - - #[test] fn stale_clear_all_sequence_is_checked_against_current_authority_source() { let mut terminal = test_terminal(); + terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle); + anchor_full_lifecycle_session( + &mut terminal, + Agent::Pi, + "herdr:pi", + "pi", + crate::agent_resume::AgentSessionRef::path(test_session_path("root.jsonl")).unwrap(), + ); terminal.set_hook_authority( "herdr:pi".into(), "pi".into(), @@ -4943,6 +5285,7 @@ mod tests { #[test] fn same_sequence_from_different_sources_is_independent() { let mut terminal = test_terminal(); + terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle); terminal.set_hook_authority( "herdr:pi".into(), "pi".into(), |
