diff options
| author | Dillon Mulroy <dillon.mulroy@gmail.com> | 2026-07-08 18:55:14 -0400 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-07-09 01:55:14 +0300 |
| commit | adbaae68daee7577b2dc93bd12507bfb67a2ca84 (patch) | |
| tree | 5a1d2e2e1a29bfa85d46f794f17eb87759ff8324 /src/terminal/state.rs | |
| parent | b03f033d0930ff64f94a09edea16b89af189e632 (diff) | |
fix: reanchor pi status after session replacement (#1189)
* fix: reanchor pi status after session replacement
refs #943
* fix: guard active pi sessions from nested startup
refs #943
* fix: serialize pi session replacement reports
refs #943
Diffstat (limited to 'src/terminal/state.rs')
| -rw-r--r-- | src/terminal/state.rs | 226 |
1 files changed, 226 insertions, 0 deletions
diff --git a/src/terminal/state.rs b/src/terminal/state.rs index 2b9ef4ad..2b17404a 100644 --- a/src/terminal/state.rs +++ b/src/terminal/state.rs @@ -797,6 +797,26 @@ impl TerminalState { } } + fn forget_stale_full_lifecycle_hook_session( + &mut self, + source: &str, + agent_label: &str, + session_ref: &crate::agent_resume::AgentSessionRef, + ) { + let remove_source = self + .stale_full_lifecycle_hook_sessions + .get_mut(source) + .is_some_and(|stale_sessions| { + stale_sessions.retain(|stale| { + stale.agent_label != agent_label || &stale.session_ref != session_ref + }); + stale_sessions.is_empty() + }); + if remove_source { + self.stale_full_lifecycle_hook_sessions.remove(source); + } + } + fn detected_state_observed_before_release_suppression( &self, detected_agent: Option<Agent>, @@ -912,6 +932,7 @@ impl TerminalState { "codex", Some("startup" | "clear" | "resume" | "compact") ) | ("herdr:opencode", "opencode", Some("new")) + | ("herdr:pi", "pi", Some("new" | "resume" | "fork")) | ( "herdr:omp", "omp", @@ -1001,6 +1022,9 @@ 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(); + if session_replacement_allowed || foreground_takeover_allowed { + self.forget_stale_full_lifecycle_hook_session(&source, &agent_label, &session_ref); + } if let Some(replaced_hook_session) = replaced_hook_session { self.remember_stale_full_lifecycle_hook_session( source.clone(), @@ -1489,6 +1513,208 @@ mod tests { } #[test] + fn pi_session_replacement_reports_reanchor_full_lifecycle_authority() { + for reason in ["new", "resume", "fork"] { + let mut terminal = test_terminal(); + let old_session = test_session_path(&format!("pi-{reason}-old.jsonl")); + let new_session = test_session_path(&format!("pi-{reason}-new.jsonl")); + terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle); + terminal.set_hook_authority_with_session_ref( + "herdr:pi".into(), + "pi".into(), + AgentState::Idle, + None, + None, + crate::agent_resume::AgentSessionRef::path(old_session), + Some(10), + ); + + let session_report = terminal.set_agent_session_ref_for_session_start( + "herdr:pi".into(), + "pi".into(), + crate::agent_resume::AgentSessionRef::path(new_session.clone()), + Some(11), + Some(reason.into()), + ); + + assert!( + session_report.is_some(), + "{reason} should replace the previous Pi session" + ); + assert!(terminal.hook_authority.is_none()); + + let working = terminal.set_hook_authority_with_session_ref( + "herdr:pi".into(), + "pi".into(), + AgentState::Working, + None, + None, + crate::agent_resume::AgentSessionRef::path(new_session.clone()), + Some(12), + ); + + assert!( + working.is_some(), + "{reason} should accept working for the replacement session" + ); + assert_eq!(terminal.state, AgentState::Working); + assert_eq!( + terminal.hook_authority.as_ref().unwrap().session_ref, + crate::agent_resume::AgentSessionRef::path(new_session) + ); + } + } + + #[test] + fn pi_resume_reactivates_a_previously_stale_session() { + let mut terminal = test_terminal(); + let session_a = test_session_path("pi-session-a.jsonl"); + let session_b = test_session_path("pi-session-b.jsonl"); + terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle); + terminal.set_hook_authority_with_session_ref( + "herdr:pi".into(), + "pi".into(), + AgentState::Idle, + None, + None, + crate::agent_resume::AgentSessionRef::path(session_a.clone()), + Some(10), + ); + + terminal.set_agent_session_ref_for_session_start( + "herdr:pi".into(), + "pi".into(), + crate::agent_resume::AgentSessionRef::path(session_b.clone()), + Some(11), + Some("new".into()), + ); + terminal.set_hook_authority_with_session_ref( + "herdr:pi".into(), + "pi".into(), + AgentState::Idle, + None, + None, + crate::agent_resume::AgentSessionRef::path(session_b.clone()), + Some(12), + ); + + let resumed = terminal.set_agent_session_ref_for_session_start( + "herdr:pi".into(), + "pi".into(), + crate::agent_resume::AgentSessionRef::path(session_a.clone()), + Some(13), + Some("resume".into()), + ); + let working = 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(14), + ); + + assert!(resumed.is_some()); + assert!(working.is_some()); + assert_eq!(terminal.state, AgentState::Working); + assert_eq!( + terminal.hook_authority.as_ref().unwrap().session_ref, + crate::agent_resume::AgentSessionRef::path(session_a) + ); + + let late_session_b = terminal.set_hook_authority_with_session_ref( + "herdr:pi".into(), + "pi".into(), + AgentState::Idle, + None, + None, + crate::agent_resume::AgentSessionRef::path(session_b), + Some(15), + ); + assert!(late_session_b.is_none()); + assert_eq!(terminal.state, AgentState::Working); + } + + #[test] + fn pi_startup_adopts_persisted_session_without_live_authority() { + let mut terminal = test_terminal(); + let old_session = test_session_path("pi-startup-old.jsonl"); + let new_session = test_session_path("pi-startup-new.jsonl"); + terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle); + terminal.set_persisted_agent_session(crate::agent_resume::PersistedAgentSession { + source: "herdr:pi".into(), + agent: "pi".into(), + session_ref: crate::agent_resume::AgentSessionRef::path(old_session) + .expect("test session path should be valid"), + }); + + let startup = terminal.set_agent_session_ref_for_session_start( + "herdr:pi".into(), + "pi".into(), + crate::agent_resume::AgentSessionRef::path(new_session.clone()), + Some(11), + Some("startup".into()), + ); + + assert!(startup.is_some()); + assert_eq!( + terminal.current_session_identity_for_persistence(), + Some(( + "herdr:pi".into(), + "pi".into(), + crate::agent_resume::AgentSessionRefKind::Path, + new_session, + )) + ); + } + + #[test] + fn pi_non_replacement_reports_preserve_full_lifecycle_authority() { + for reason in [None, Some("reload"), Some("startup")] { + let mut terminal = test_terminal(); + 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); + terminal.set_hook_authority_with_session_ref( + "herdr:pi".into(), + "pi".into(), + AgentState::Idle, + None, + None, + crate::agent_resume::AgentSessionRef::path(old_session.clone()), + Some(10), + ); + + let session_report = terminal.set_agent_session_ref_for_session_start( + "herdr:pi".into(), + "pi".into(), + crate::agent_resume::AgentSessionRef::path(new_session.clone()), + Some(11), + reason.map(str::to_string), + ); + let working = terminal.set_hook_authority_with_session_ref( + "herdr:pi".into(), + "pi".into(), + AgentState::Working, + None, + None, + crate::agent_resume::AgentSessionRef::path(new_session), + Some(12), + ); + + assert!(session_report.is_none()); + assert!(working.is_none()); + assert_eq!(terminal.state, AgentState::Idle); + assert_eq!( + terminal.hook_authority.as_ref().unwrap().session_ref, + crate::agent_resume::AgentSessionRef::path(old_session), + "{reason:?} must not replace the current Pi session" + ); + } + } + + #[test] fn omp_resume_session_report_reanchors_full_lifecycle_authority() { let mut terminal = test_terminal(); let old_session = test_session_path("omp-old.jsonl"); |
