diff --git a/crates/tui/src/multi_pod.rs b/crates/tui/src/multi_pod.rs index 21122d94..8f811612 100644 --- a/crates/tui/src/multi_pod.rs +++ b/crates/tui/src/multi_pod.rs @@ -383,6 +383,7 @@ pub(crate) struct MultiPodApp { notice: Option, sending: bool, runtime_command: PodRuntimeCommand, + last_orchestrator_lifecycle_failure: Option, } impl MultiPodApp { @@ -397,6 +398,8 @@ impl MultiPodApp { }, ) .await?; + let last_orchestrator_lifecycle_failure = + orchestrator_lifecycle_failure_from_panel(&snapshot.panel); let mut app = Self { list: snapshot.list, panel: snapshot.panel, @@ -406,6 +409,7 @@ impl MultiPodApp { notice: None, sending: false, runtime_command, + last_orchestrator_lifecycle_failure, }; app.ensure_selection_visible(); app.ensure_composer_target_available(); @@ -439,6 +443,7 @@ impl MultiPodApp { } fn apply_reloaded_snapshot(&mut self, mut snapshot: MultiPodSnapshot) { + self.apply_orchestrator_lifecycle_memory(&mut snapshot.panel); let previous_selected_pod = self.list.selected_name.clone(); snapshot.list.selected_name = previous_selected_pod .filter(|name| { @@ -463,6 +468,35 @@ impl MultiPodApp { self.ensure_composer_target_available(); } + fn apply_orchestrator_lifecycle_memory(&mut self, panel: &mut WorkspacePanelViewModel) { + let Some(state) = panel.header.orchestrator.as_ref() else { + self.last_orchestrator_lifecycle_failure = None; + return; + }; + + match state.status { + OrchestratorPanelStatus::Unavailable => { + self.last_orchestrator_lifecycle_failure = + orchestrator_lifecycle_failure_from_panel(panel); + } + OrchestratorPanelStatus::Live + | OrchestratorPanelStatus::Spawned + | OrchestratorPanelStatus::Restored => { + self.last_orchestrator_lifecycle_failure = None; + } + OrchestratorPanelStatus::Missing | OrchestratorPanelStatus::Stopped => { + if let Some(previous) = self.last_orchestrator_lifecycle_failure.clone() { + if previous.pod_name == state.pod_name { + panel.header.orchestrator = Some(previous.clone()); + append_unique_diagnostic(panel, previous.detail.as_deref()); + } else { + self.last_orchestrator_lifecycle_failure = None; + } + } + } + } + } + fn selected_panel_row(&self) -> Option<&PanelRow> { self.selected_row .as_ref() @@ -1040,6 +1074,31 @@ struct MultiPodSnapshot { panel: WorkspacePanelViewModel, } +fn orchestrator_lifecycle_failure_from_panel( + panel: &WorkspacePanelViewModel, +) -> Option { + let state = panel.header.orchestrator.as_ref()?; + if state.status == OrchestratorPanelStatus::Unavailable && state.detail.is_some() { + Some(state.clone()) + } else { + None + } +} + +fn append_unique_diagnostic(panel: &mut WorkspacePanelViewModel, diagnostic: Option<&str>) { + let Some(diagnostic) = diagnostic else { + return; + }; + if !panel + .header + .diagnostics + .iter() + .any(|existing| existing == diagnostic) + { + panel.header.diagnostics.push(diagnostic.to_string()); + } +} + #[derive(Debug, Clone)] enum OrchestratorLifecycleMode { Ensure { runtime_command: PodRuntimeCommand }, @@ -2814,6 +2873,117 @@ mod tests { assert!(notice.contains("boom")); } + #[test] + fn multi_orchestrator_failure_persists_over_plain_observe_missing() { + let detail = + "could not spawn workspace Orchestrator: delegated scope conflicts with writer"; + let mut app = app_with_panel( + empty_test_list(), + panel_with_orchestrator(OrchestratorPanelStatus::Unavailable, Some(detail)), + ); + + app.apply_reloaded_snapshot(MultiPodSnapshot { + list: empty_test_list(), + panel: panel_with_orchestrator(OrchestratorPanelStatus::Missing, None), + }); + + let orchestrator = app.panel.header.orchestrator.as_ref().unwrap(); + assert_eq!(orchestrator.status, OrchestratorPanelStatus::Unavailable); + assert_eq!(orchestrator.detail.as_deref(), Some(detail)); + assert_eq!( + app.panel + .header + .diagnostics + .iter() + .filter(|diagnostic| diagnostic.as_str() == detail) + .count(), + 1 + ); + } + + #[test] + fn multi_orchestrator_plain_missing_remains_when_no_prior_failure_exists() { + let mut app = app_with_panel( + empty_test_list(), + panel_with_orchestrator(OrchestratorPanelStatus::Missing, None), + ); + + app.apply_reloaded_snapshot(MultiPodSnapshot { + list: empty_test_list(), + panel: panel_with_orchestrator(OrchestratorPanelStatus::Missing, None), + }); + + let orchestrator = app.panel.header.orchestrator.as_ref().unwrap(); + assert_eq!(orchestrator.status, OrchestratorPanelStatus::Missing); + assert!(orchestrator.detail.is_none()); + assert!(app.panel.header.diagnostics.is_empty()); + } + + #[test] + fn multi_orchestrator_failure_clears_after_live_lifecycle() { + let detail = + "could not spawn workspace Orchestrator: delegated scope conflicts with writer"; + let mut app = app_with_panel( + empty_test_list(), + panel_with_orchestrator(OrchestratorPanelStatus::Unavailable, Some(detail)), + ); + + app.apply_reloaded_snapshot(MultiPodSnapshot { + list: empty_test_list(), + panel: panel_with_orchestrator(OrchestratorPanelStatus::Live, None), + }); + assert_eq!( + app.panel.header.orchestrator.as_ref().unwrap().status, + OrchestratorPanelStatus::Live + ); + + app.apply_reloaded_snapshot(MultiPodSnapshot { + list: empty_test_list(), + panel: panel_with_orchestrator(OrchestratorPanelStatus::Missing, None), + }); + + let orchestrator = app.panel.header.orchestrator.as_ref().unwrap(); + assert_eq!(orchestrator.status, OrchestratorPanelStatus::Missing); + assert!(orchestrator.detail.is_none()); + } + + #[test] + fn multi_orchestrator_failure_supersedes_prior_failure() { + let old_detail = "could not spawn workspace Orchestrator: old scope conflict"; + let new_detail = "could not restore workspace Orchestrator: socket refused"; + let mut app = app_with_panel( + empty_test_list(), + panel_with_orchestrator(OrchestratorPanelStatus::Unavailable, Some(old_detail)), + ); + + app.apply_reloaded_snapshot(MultiPodSnapshot { + list: empty_test_list(), + panel: panel_with_orchestrator(OrchestratorPanelStatus::Unavailable, Some(new_detail)), + }); + app.apply_reloaded_snapshot(MultiPodSnapshot { + list: empty_test_list(), + panel: panel_with_orchestrator(OrchestratorPanelStatus::Missing, None), + }); + + let orchestrator = app.panel.header.orchestrator.as_ref().unwrap(); + assert_eq!(orchestrator.status, OrchestratorPanelStatus::Unavailable); + assert_eq!(orchestrator.detail.as_deref(), Some(new_detail)); + assert!( + !app.panel + .header + .diagnostics + .iter() + .any(|diagnostic| diagnostic == old_detail) + ); + assert!( + app.panel + .header + .diagnostics + .iter() + .any(|diagnostic| diagnostic == new_detail) + ); + } + #[tokio::test] async fn multi_poll_reload_does_not_overlap_in_flight_reload() { let mut app = test_app(vec![live_info("alpha", PodStatus::Idle)]); @@ -3567,7 +3737,28 @@ mod tests { app_with_panel(list, WorkspacePanelViewModel::empty(Path::new("test"))) } + fn empty_test_list() -> PodList { + PodList::from_sources(PodVisibilitySource::ResumePicker, vec![], vec![], None, 10) + } + + fn panel_with_orchestrator( + status: OrchestratorPanelStatus, + detail: Option<&str>, + ) -> WorkspacePanelViewModel { + let mut panel = WorkspacePanelViewModel::empty(Path::new("test")); + panel.header.orchestrator = Some(OrchestratorPanelState::new( + "test-orchestrator", + status, + detail.map(str::to_string), + )); + if let Some(detail) = detail { + panel.header.diagnostics.push(detail.to_string()); + } + panel + } + fn app_with_panel(list: PodList, panel: WorkspacePanelViewModel) -> MultiPodApp { + let last_orchestrator_lifecycle_failure = orchestrator_lifecycle_failure_from_panel(&panel); let mut app = MultiPodApp { list, panel, @@ -3577,6 +3768,7 @@ mod tests { notice: None, sending: false, runtime_command: PodRuntimeCommand::for_executable("/tmp/yoi"), + last_orchestrator_lifecycle_failure, }; app.ensure_selection_visible(); app.ensure_composer_target_available();