diff options
| author | JJ Liebig <jonathan.liebig@gmail.com> | 2026-09-02 18:08:10 +0200 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-09-02 18:08:10 +0200 |
| commit | 5a2dee700eeeea68267a4d16777307632f77172f (patch) | |
| tree | 3a7b2b0ead7af9cb1bfc069e5f61ce56a5f4909f /src/app | |
| parent | 987b070fbfa187e85009b45cd7e208fc6175ff6a (diff) | |
fix(windows): remove worktrees with active agent panes (#3532)
* fix(windows): remove worktrees with active agent panes
* fix(windows): defer pane restore until shutdown exits
Diffstat (limited to 'src/app')
| -rw-r--r-- | src/app/actions.rs | 55 | ||||
| -rw-r--r-- | src/app/api.rs | 196 | ||||
| -rw-r--r-- | src/app/api/worktrees.rs | 133 | ||||
| -rw-r--r-- | src/app/api/worktrees/deferred.rs | 104 | ||||
| -rw-r--r-- | src/app/mod.rs | 4 | ||||
| -rw-r--r-- | src/app/worktrees.rs | 17 |
6 files changed, 458 insertions, 51 deletions
diff --git a/src/app/actions.rs b/src/app/actions.rs index 853c9084..69b459d2 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -1579,6 +1579,7 @@ impl AppState { self.handle_pane_died(pane_id); Vec::new() } + AppEvent::WorktreeRuntimeRestoreFailed { .. } => Vec::new(), AppEvent::UpdateReady { version, install_command, @@ -1814,6 +1815,18 @@ impl AppState { where F: FnOnce(&mut crate::terminal::TerminalState) -> Option<TerminalStateMutation>, { + self.update_terminal_state_with_completion_policy(pane_id, false, update) + } + + fn update_terminal_state_with_completion_policy<F>( + &mut self, + pane_id: PaneId, + force_suppress_completion: bool, + update: F, + ) -> Option<PaneStateUpdate> + where + F: FnOnce(&mut crate::terminal::TerminalState) -> Option<TerminalStateMutation>, + { let ws_idx = self .workspaces .iter() @@ -1855,8 +1868,9 @@ impl AppState { } let agent_released = mutation.agent_released; let change = mutation.effective_state_change.or(unchanged_change)?; - let suppress_completion = change.state == AgentState::Idle - && (managed_launch_pending || suppress_acquisition_completion); + let suppress_completion = force_suppress_completion + || (change.state == AgentState::Idle + && (managed_launch_pending || suppress_acquisition_completion)); if change.previous_state != change.state { self.next_agent_state_change_seq += 1; if let Some(terminal) = self.terminals.get_mut(&terminal_id) { @@ -1903,23 +1917,28 @@ impl AppState { pub(crate) fn publish_pane_process_exit_if_agent( &mut self, pane_id: PaneId, + suppress_completion: bool, ) -> Option<PaneStateUpdate> { let observed_at = std::time::Instant::now(); - let update = self.update_terminal_state(pane_id, |terminal| { - let agent = terminal.effective_known_agent().or(terminal.detected_agent); - if agent.is_none() && !terminal.full_lifecycle_hook_authority_active() { - return None; - } - Some(terminal.set_detected_state_with_screen_signals_at( - agent, - AgentState::Idle, - false, - true, - false, - true, - observed_at, - )) - })?; + let update = self.update_terminal_state_with_completion_policy( + pane_id, + suppress_completion, + |terminal| { + let agent = terminal.effective_known_agent().or(terminal.detected_agent); + if agent.is_none() && !terminal.full_lifecycle_hook_authority_active() { + return None; + } + Some(terminal.set_detected_state_with_screen_signals_at( + agent, + AgentState::Idle, + false, + true, + false, + true, + observed_at, + )) + }, + )?; update.agent_released.then_some(update) } @@ -3997,7 +4016,7 @@ mod tests { ); let update = state - .publish_pane_process_exit_if_agent(pane_id) + .publish_pane_process_exit_if_agent(pane_id, false) .expect("process exit update"); assert!(!state.pane_is_in_active_tab(update.ws_idx, pane_id)); diff --git a/src/app/api.rs b/src/app/api.rs index 0c3b9f98..598356b0 100644 --- a/src/app/api.rs +++ b/src/app/api.rs @@ -83,6 +83,20 @@ impl App { &mut self, ev: AppEvent, ) -> Vec<crate::app::actions::PaneStateUpdate> { + let mut worktree_restore_failed = false; + let ev = match ev { + AppEvent::WorktreeRuntimeRestoreFailed { + pane_id, + operation_id, + } => { + if !self.claim_worktree_runtime_restore_failure(pane_id, operation_id) { + return Vec::new(); + } + worktree_restore_failed = true; + AppEvent::PaneDied { pane_id } + } + ev => ev, + }; if matches!( &ev, AppEvent::TerminalBell { .. } | AppEvent::ClipboardWrite { .. } @@ -146,10 +160,10 @@ impl App { } if let AppEvent::WorktreeRemoveFinished(result) = ev { - self.handle_api_worktree_remove_finished(*result); - return Vec::new(); + return self.handle_api_worktree_remove_finished(*result); } + let mut worktree_restore_updates = Vec::new(); if let AppEvent::PaneDied { pane_id } = &ev { if self .state @@ -160,19 +174,53 @@ impl App { self.close_popup_pane(); return Vec::new(); } - let previous_toast = self.state.toast.clone(); - if let Some(update) = self.state.publish_pane_process_exit_if_agent(*pane_id) { - self.sync_full_lifecycle_authority_detection_pauses(); - self.refresh_new_herdr_toast_context_for_update(&update, &previous_toast); - self.emit_pane_state_update(&update); - } - if self.runtime_exit_action(*pane_id) == RuntimeExitAction::RespawnShell - && self.respawn_shell_for_launch_pane(*pane_id) - { - self.overlay_panes.remove(pane_id); - self.render_dirty.request_generic(); - self.render_notify.notify_one(); - return Vec::new(); + if worktree_restore_failed { + worktree_restore_updates + .extend(self.publish_worktree_runtime_agent_release(*pane_id)); + } else { + let expected_exit = self + .pending_worktree_remove_runtime_exits + .get_mut(pane_id) + .map(|remaining| { + *remaining -= 1; + *remaining == 0 + }); + if let Some(remove_entry) = expected_exit { + let restore_failed = if remove_entry { + self.pending_worktree_remove_runtime_exits.remove(pane_id); + let restore_requested = self + .pending_worktree_remove_runtime_restores + .remove(pane_id) + .is_some(); + if restore_requested { + worktree_restore_updates + .extend(self.publish_worktree_runtime_agent_release(*pane_id)); + } + restore_requested && !self.respawn_shell_for_launch_pane(*pane_id, false) + } else { + false + }; + if !restore_failed { + return worktree_restore_updates; + } + } + let previous_toast = self.state.toast.clone(); + if let Some(update) = self + .state + .publish_pane_process_exit_if_agent(*pane_id, false) + { + self.sync_full_lifecycle_authority_detection_pauses(); + self.refresh_new_herdr_toast_context_for_update(&update, &previous_toast); + self.emit_pane_state_update(&update); + } + if self.runtime_exit_action(*pane_id) == RuntimeExitAction::RespawnShell + && self.respawn_shell_for_launch_pane(*pane_id, true) + { + self.overlay_panes.remove(pane_id); + self.render_dirty.request_generic(); + self.render_notify.notify_one(); + return worktree_restore_updates; + } } } @@ -249,7 +297,7 @@ impl App { }; let terminal_cwd_reported = matches!(ev, AppEvent::TerminalCwdReported { .. }); let previous_toast = self.state.toast.clone(); - let pane_updates = self.state.handle_app_event(ev); + let mut pane_updates = self.state.handle_app_event(ev); if update_ready.is_some() { self.state.latest_release_notes = crate::release_notes::load_latest(); } @@ -300,6 +348,7 @@ impl App { self.sync_toast_deadline(previous_toast); self.shutdown_detached_terminal_runtimes(); + pane_updates.extend(worktree_restore_updates); pane_updates } @@ -469,7 +518,11 @@ impl App { } } - fn respawn_shell_for_launch_pane(&mut self, pane_id: crate::layout::PaneId) -> bool { + fn respawn_shell_for_launch_pane( + &mut self, + pane_id: crate::layout::PaneId, + focus_pane: bool, + ) -> bool { let Some((ws_idx, pane_state)) = self.find_pane(pane_id) else { return false; }; @@ -517,11 +570,58 @@ impl App { if let Some(terminal) = self.state.terminals.get_mut(&terminal_id) { terminal.clear_agent_runtime_identity_after_respawn(); } - self.state.focus_pane_in_workspace(ws_idx, pane_id); + if focus_pane { + self.state.focus_pane_in_workspace(ws_idx, pane_id); + } self.schedule_session_save(); true } + pub(crate) fn claim_worktree_runtime_restore_failure( + &mut self, + pane_id: crate::layout::PaneId, + operation_id: u64, + ) -> bool { + if self.pending_worktree_remove_runtime_restores.get(&pane_id) != Some(&operation_id) { + return false; + } + self.pending_worktree_remove_runtime_restores + .remove(&pane_id); + self.pending_worktree_remove_runtime_exits.remove(&pane_id); + true + } + + pub(crate) fn publish_worktree_runtime_agent_release( + &mut self, + pane_id: crate::layout::PaneId, + ) -> Option<crate::app::actions::PaneStateUpdate> { + self.state.pending_agent_notifications.remove(&pane_id); + let previous_toast = self.state.toast.clone(); + let update = self + .state + .publish_pane_process_exit_if_agent(pane_id, true)?; + self.sync_full_lifecycle_authority_detection_pauses(); + self.refresh_new_herdr_toast_context_for_update(&update, &previous_toast); + self.emit_pane_state_update(&update); + Some(update) + } + + fn queue_worktree_runtime_restore_failed( + &self, + pane_id: crate::layout::PaneId, + operation_id: u64, + ) { + let event_tx = self.event_tx.clone(); + tokio::spawn(async move { + let _ = event_tx + .send(AppEvent::WorktreeRuntimeRestoreFailed { + pane_id, + operation_id, + }) + .await; + }); + } + pub(crate) fn emit_pane_state_update(&mut self, update: &crate::app::actions::PaneStateUpdate) { let Some(pane_id) = self.public_pane_id(update.ws_idx, update.pane_id) else { return; @@ -2204,6 +2304,66 @@ mod tests { } #[test] + fn stalled_worktree_runtime_exit_closes_pane() { + let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + let mut app = App::new( + &crate::config::Config::default(), + crate::app::AppPolicy::TEST, + None, + api_rx, + crate::api::EventHub::default(), + ); + let workspace = crate::workspace::Workspace::test_new("worktree"); + let pane_id = workspace.tabs[0].root_pane; + let terminal_id = workspace.terminal_id(pane_id).cloned().unwrap(); + app.state.workspaces = vec![workspace]; + app.state.ensure_test_terminals(); + #[cfg(windows)] + { + app.state.default_shell = "powershell.exe".into(); + app.state.shell_mode = crate::config::ShellModeConfig::NonLogin; + let terminal = app.state.terminals.get_mut(&terminal_id).unwrap(); + terminal.set_detected_state(Some(Agent::Codex), AgentState::Working); + terminal.set_detected_state_with_screen_signals_at( + Some(Agent::Codex), + AgentState::Idle, + false, + false, + false, + true, + std::time::Instant::now(), + ); + assert_eq!( + app.runtime_exit_action(pane_id), + RuntimeExitAction::RespawnShell + ); + } + app.pending_worktree_remove_runtime_exits.insert(pane_id, 1); + app.pending_worktree_remove_runtime_restores + .insert(pane_id, 8); + + app.handle_internal_event(AppEvent::WorktreeRuntimeRestoreFailed { + pane_id, + operation_id: 7, + }); + assert_eq!( + app.pending_worktree_remove_runtime_restores.get(&pane_id), + Some(&8) + ); + assert!(app.event_rx.try_recv().is_err()); + + app.handle_internal_event(AppEvent::WorktreeRuntimeRestoreFailed { + pane_id, + operation_id: 8, + }); + + assert!(app.find_pane(pane_id).is_none()); + assert!(app.terminal_runtimes.get(&terminal_id).is_none()); + assert!(app.pending_worktree_remove_runtime_exits.is_empty()); + assert!(app.pending_worktree_remove_runtime_restores.is_empty()); + } + + #[test] fn terminal_delivery_does_not_refresh_existing_targeted_toast() { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( diff --git a/src/app/api/worktrees.rs b/src/app/api/worktrees.rs index cf4ca3f9..fd7bb0fb 100644 --- a/src/app/api/worktrees.rs +++ b/src/app/api/worktrees.rs @@ -2232,6 +2232,112 @@ mod tests { let _ = std::fs::remove_dir_all(repo); } + #[tokio::test] + async fn failed_worktree_remove_preserves_and_restores_shutdown_pane() { + let checkout = unique_temp_path("api-worktree-remove-failure-checkout"); + std::fs::create_dir_all(&checkout).unwrap(); + let mut app = test_app(); + app.state.default_shell = test_shell().into(); + let mut workspace = Workspace::test_new("worktree"); + workspace.identity_cwd = checkout.clone(); + workspace.worktree_space = Some(crate::workspace::WorktreeSpaceMembership { + key: "repo-key".into(), + label: "repo".into(), + repo_root: checkout.clone(), + checkout_path: checkout.clone(), + is_linked_worktree: true, + }); + let workspace_id = workspace.id.clone(); + let pane_id = workspace.tabs[0].root_pane; + let terminal_id = workspace.terminal_id(pane_id).cloned().unwrap(); + let foreground = Workspace::test_new("foreground"); + let foreground_id = foreground.id.clone(); + app.state.workspaces = vec![workspace, foreground]; + app.state.active = Some(1); + app.state.selected = 1; + app.state.ensure_test_terminals(); + app.state + .terminals + .get_mut(&terminal_id) + .unwrap() + .set_detected_state( + Some(crate::detect::Agent::Codex), + crate::detect::AgentState::Working, + ); + app.state.toast_config.delay_seconds = 1; + app.handle_internal_event(AppEvent::StateChanged { + pane_id, + agent: Some(crate::detect::Agent::Codex), + state: crate::detect::AgentState::Blocked, + visible_blocker: true, + visible_working: false, + process_exited: false, + observed_at: std::time::Instant::now(), + }); + assert!(app.state.pending_agent_notifications.contains_key(&pane_id)); + let (runtime, _input_rx) = crate::terminal::TerminalRuntime::test_with_channel(80, 24); + app.terminal_runtimes.insert(terminal_id.clone(), runtime); + + let shutdown_panes = app.shutdown_workspace_terminal_runtimes_for_worktree_remove(0); + assert_eq!(shutdown_panes, vec![pane_id]); + + let checkout_key = crate::worktree::canonical_or_original(&checkout); + app.pending_api_worktree_removes + .insert(workspace_id.clone(), 7); + app.pending_api_worktree_remove_paths + .insert(checkout_key.clone(), 7); + let workspace_snapshot = app.workspace_info(0); + let worktree_snapshot = app + .worktree_info_for_membership(app.state.workspaces[0].worktree_space().unwrap(), None); + let (respond_to, response_rx) = response_channel(); + let pane_updates = app.handle_api_worktree_remove_finished(WorktreeRemoveResult { + workspace_id, + path: checkout.clone(), + workspace: Some(Box::new(workspace_snapshot)), + worktree: Some(Box::new(worktree_snapshot)), + forced: false, + api_request: Some(ApiWorktreeRemoveRequest { + id: "req".into(), + operation_id: 7, + checkout_key, + shutdown_panes, + respond_to, + }), + result: Err("simulated remove failure".into()), + }); + assert!(pane_updates.is_empty()); + + let response = response_rx + .recv_timeout(std::time::Duration::from_secs(2)) + .unwrap(); + let error: ErrorResponse = serde_json::from_str(&response).unwrap(); + assert_eq!(error.error.code, "worktree_remove_failed"); + assert!(app.find_pane(pane_id).is_some()); + assert!(app.terminal_runtimes.get(&terminal_id).is_none()); + assert!(app + .pending_worktree_remove_runtime_restores + .contains_key(&pane_id)); + + let pane_updates = + app.handle_internal_event_with_pane_updates(AppEvent::PaneDied { pane_id }); + assert!(matches!( + pane_updates.as_slice(), + [update] if update.agent_released && update.suppress_completion + )); + assert!(app.pending_worktree_remove_runtime_exits.is_empty()); + assert!(app.pending_worktree_remove_runtime_restores.is_empty()); + assert!(app.state.pending_agent_notifications.is_empty()); + assert!(app.terminal_runtimes.get(&terminal_id).is_some()); + assert_eq!( + app.state.active.map(|idx| &app.state.workspaces[idx].id), + Some(&foreground_id) + ); + assert_eq!(app.state.workspaces[app.state.selected].id, foreground_id); + + crate::app::api::test_support::shutdown_test_runtimes(&mut app); + let _ = std::fs::remove_dir_all(checkout); + } + #[test] fn deferred_api_background_worktree_remove_preserves_focused_workspace() { let mut app = test_app(); @@ -2253,10 +2359,15 @@ mod tests { child.worktree_space = Some(membership.clone()); let foreground = Workspace::test_new("foreground"); let child_id = child.id.clone(); + let child_pane_id = child.tabs[0].root_pane; let foreground_id = foreground.id.clone(); app.state.workspaces = vec![parent, child, foreground]; app.state.active = Some(2); app.state.selected = 2; + app.pending_worktree_remove_runtime_exits + .insert(child_pane_id, 1); + app.pending_worktree_remove_runtime_restores + .insert(child_pane_id, 7); let workspace_snapshot = app.workspace_info(1); let worktree_snapshot = app.worktree_info_for_membership(&membership, None); app.pending_api_worktree_removes.insert(child_id.clone(), 7); @@ -2264,7 +2375,7 @@ mod tests { .insert(crate::worktree::canonical_or_original(&checkout), 7); let (respond_to, response_rx) = response_channel(); - app.handle_api_worktree_remove_finished(WorktreeRemoveResult { + let _ = app.handle_api_worktree_remove_finished(WorktreeRemoveResult { workspace_id: child_id, path: checkout.clone(), workspace: Some(Box::new(workspace_snapshot)), @@ -2274,6 +2385,7 @@ mod tests { id: "req".into(), operation_id: 7, checkout_key: crate::worktree::canonical_or_original(&checkout), + shutdown_panes: vec![child_pane_id], respond_to, }), result: Ok(()), @@ -2293,10 +2405,12 @@ mod tests { Some(&foreground_id) ); assert_eq!(app.state.workspaces[app.state.selected].id, foreground_id); + assert!(app.pending_worktree_remove_runtime_exits.is_empty()); + assert!(app.pending_worktree_remove_runtime_restores.is_empty()); } - #[test] - fn deferred_api_worktree_remove_emits_removed_after_workspace_changes() { + #[tokio::test] + async fn deferred_api_worktree_remove_emits_removed_after_workspace_changes() { let event_hub = crate::api::EventHub::default(); let mut app = test_app_with_event_hub(event_hub.clone()); let checkout = PathBuf::from("/repo/herdr-issue"); @@ -2309,13 +2423,19 @@ mod tests { is_linked_worktree: true, }); let child_id = child.id.clone(); + let child_pane_id = child.tabs[0].root_pane; + let child_terminal_id = child.terminal_id(child_pane_id).cloned().unwrap(); app.state.workspaces.push(child); + app.state.ensure_test_terminals(); + app.state.terminals.get_mut(&child_terminal_id).unwrap().cwd = checkout.clone(); let workspace_snapshot = app.workspace_info(0); let worktree_snapshot = app .worktree_info_for_membership(app.state.workspaces[0].worktree_space().unwrap(), None); app.pending_api_worktree_removes.insert(child_id.clone(), 7); app.pending_api_worktree_remove_paths .insert(crate::worktree::canonical_or_original(&checkout), 7); + app.pending_worktree_remove_runtime_exits + .insert(child_pane_id, 1); app.state.workspaces[0].worktree_space = Some(crate::workspace::WorktreeSpaceMembership { key: "repo-key".into(), label: "herdr".into(), @@ -2325,7 +2445,7 @@ mod tests { }); let (respond_to, response_rx) = response_channel(); - app.handle_api_worktree_remove_finished(WorktreeRemoveResult { + let _ = app.handle_api_worktree_remove_finished(WorktreeRemoveResult { workspace_id: child_id, path: checkout.clone(), workspace: Some(Box::new(workspace_snapshot)), @@ -2335,6 +2455,7 @@ mod tests { id: "req".into(), operation_id: 7, checkout_key: crate::worktree::canonical_or_original(&checkout), + shutdown_panes: vec![child_pane_id], respond_to, }), result: Ok(()), @@ -2363,5 +2484,9 @@ mod tests { .map(|membership| membership.checkout_path.as_path()), Some(Path::new("/repo/other")) ); + assert_eq!( + app.state.terminals[&child_terminal_id].cwd, + PathBuf::from("/repo/other") + ); } } diff --git a/src/app/api/worktrees/deferred.rs b/src/app/api/worktrees/deferred.rs index 110f00c9..9adbd70c 100644 --- a/src/app/api/worktrees/deferred.rs +++ b/src/app/api/worktrees/deferred.rs @@ -1,5 +1,5 @@ use std::path::Path; -use std::time::{SystemTime, UNIX_EPOCH}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; use crate::api::schema::{ EventData, EventEnvelope, EventKind, Request, ResponseResult, WorktreeCreateParams, @@ -289,6 +289,14 @@ impl App { || self .pending_api_worktree_creates .contains_key(&checkout_key) + || self + .state + .pane_ids_for_workspace(ws_idx) + .iter() + .any(|pane_id| { + self.pending_worktree_remove_runtime_restores + .contains_key(pane_id) + }) { Self::send_api_response( respond_to, @@ -301,9 +309,12 @@ impl App { return; } - if Self::should_shutdown_workspace_terminal_runtimes_for_worktree_remove(params.force) { - self.shutdown_workspace_terminal_runtimes_for_worktree_remove(ws_idx); - } + let shutdown_panes = + if Self::should_shutdown_workspace_terminal_runtimes_for_worktree_remove(params.force) { + self.shutdown_workspace_terminal_runtimes_for_worktree_remove(ws_idx) + } else { + Vec::new() + }; let operation_id = self.next_api_worktree_operation_id(); self.pending_api_worktree_removes @@ -322,6 +333,7 @@ impl App { id, operation_id, checkout_key, + shutdown_panes, respond_to, }; let repo_root = space.repo_root; @@ -468,9 +480,9 @@ impl App { pub(crate) fn handle_api_worktree_remove_finished( &mut self, mut result: crate::events::WorktreeRemoveResult, - ) { + ) -> Vec<crate::app::actions::PaneStateUpdate> { let Some(api) = result.api_request.take() else { - return; + return Vec::new(); }; let operation_matches = self .pending_api_worktree_removes @@ -489,7 +501,7 @@ impl App { "worktree remove completed after the operation was superseded", ), ); - return; + return Vec::new(); } self.pending_api_worktree_removes .remove(&result.workspace_id); @@ -497,6 +509,11 @@ impl App { .remove(&api.checkout_key); if let Err(message) = result.result { + let pane_updates = self.restore_shutdown_worktree_panes( + &api.shutdown_panes, + api.operation_id, + &result.path, + ); let code = if !result.forced && crate::worktree::is_dirty_worktree_remove_error(&message) { "dirty_worktree_requires_force" @@ -504,7 +521,7 @@ impl App { "worktree_remove_failed" }; Self::send_api_response(api.respond_to, encode_error(api.id, code, message)); - return; + return pane_updates; } let mut workspace_id = result.workspace_id.clone(); @@ -546,6 +563,11 @@ impl App { } else if let Some(snapshot) = workspace_snapshot.as_ref() { workspace_id = snapshot.workspace_id.clone(); } + let pane_updates = self.restore_shutdown_worktree_panes( + &api.shutdown_panes, + api.operation_id, + &result.path, + ); let Some(worktree) = worktree else { Self::send_api_response( @@ -556,7 +578,7 @@ impl App { "removed worktree but lost worktree snapshot", ), ); - return; + return pane_updates; }; self.emit_worktree_removed_event( workspace_id.clone(), @@ -573,5 +595,69 @@ impl App { }, ); Self::send_api_response(api.respond_to, response); + pane_updates + } + + fn restore_shutdown_worktree_panes( + &mut self, + shutdown_panes: &[crate::layout::PaneId], + operation_id: u64, + removed_checkout: &std::path::Path, + ) -> Vec<crate::app::actions::PaneStateUpdate> { + let mut pane_updates = Vec::new(); + let removed_checkout = crate::worktree::canonical_or_original(removed_checkout); + for &pane_id in shutdown_panes { + let Some((ws_idx, terminal_id)) = self + .find_pane(pane_id) + .map(|(ws_idx, pane)| (ws_idx, pane.attached_terminal_id.clone())) + else { + self.pending_worktree_remove_runtime_exits.remove(&pane_id); + self.pending_worktree_remove_runtime_restores + .remove(&pane_id); + continue; + }; + let runtime_missing = self.terminal_runtimes.get(&terminal_id).is_none(); + if runtime_missing { + let workspace = &self.state.workspaces[ws_idx]; + let current_checkout = workspace + .worktree_space() + .map(|space| &space.checkout_path) + .unwrap_or(&workspace.identity_cwd); + if crate::worktree::canonical_or_original(current_checkout) != removed_checkout { + if let Some(terminal) = self.state.terminals.get_mut(&terminal_id) { + terminal.cwd = current_checkout.clone(); + } + } + if self + .pending_worktree_remove_runtime_exits + .contains_key(&pane_id) + { + if self + .pending_worktree_remove_runtime_restores + .insert(pane_id, operation_id) + .is_none() + { + let event_tx = self.event_tx.clone(); + tokio::spawn(async move { + tokio::time::sleep(Duration::from_secs(1)).await; + let _ = event_tx + .send(AppEvent::WorktreeRuntimeRestoreFailed { + pane_id, + operation_id, + }) + .await; + }); + } + } else { + pane_updates.extend(self.publish_worktree_runtime_agent_release(pane_id)); + if !self.respawn_shell_for_launch_pane(pane_id, false) { + self.pending_worktree_remove_runtime_restores + .insert(pane_id, operation_id); + self.queue_worktree_runtime_restore_failed(pane_id, operation_id); + } + } + } + } + pane_updates } } diff --git a/src/app/mod.rs b/src/app/mod.rs index 90ee33b8..131b9729 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -124,6 +124,8 @@ pub struct App { pub(crate) pending_api_worktree_creates: HashMap<std::path::PathBuf, u64>, pub(crate) pending_api_worktree_removes: HashMap<String, u64>, pub(crate) pending_api_worktree_remove_paths: HashMap<std::path::PathBuf, u64>, + pub(crate) pending_worktree_remove_runtime_exits: HashMap<crate::layout::PaneId, usize>, + pub(crate) pending_worktree_remove_runtime_restores: HashMap<crate::layout::PaneId, u64>, pub(crate) next_api_worktree_operation_id: u64, pub(crate) next_auto_update_check: Option<Instant>, pub(crate) next_agent_manifest_update_check: Option<Instant>, @@ -580,6 +582,8 @@ impl App { pending_api_worktree_creates: HashMap::new(), pending_api_worktree_removes: HashMap::new(), pending_api_worktree_remove_paths: HashMap::new(), + pending_worktree_remove_runtime_exits: HashMap::new(), + pending_worktree_remove_runtime_restores: HashMap::new(), next_api_worktree_operation_id: 1, next_auto_update_check: version_check_enabled .then_some(Instant::now() + AUTO_UPDATE_CHECK_INTERVAL), diff --git a/src/app/worktrees.rs b/src/app/worktrees.rs index b4ac1482..c26d5dea 100644 --- a/src/app/worktrees.rs +++ b/src/app/worktrees.rs @@ -39,15 +39,28 @@ impl App { pub(crate) fn shutdown_workspace_terminal_runtimes_for_worktree_remove( &mut self, ws_idx: usize, - ) { - for terminal_id in self.state.terminal_ids_for_workspace(ws_idx) { + ) -> Vec<crate::layout::PaneId> { + let mut shutdown_panes = Vec::new(); + for pane_id in self.state.pane_ids_for_workspace(ws_idx) { + let Some(terminal_id) = self.state.terminal_id_for_pane(ws_idx, pane_id) else { + continue; + }; + if self.terminal_runtimes.get(&terminal_id).is_none() { + continue; + } tracing::debug!( workspace_index = ws_idx, terminal_id = %terminal_id, "shutting down terminal runtime before worktree removal" ); + *self + .pending_worktree_remove_runtime_exits + .entry(pane_id) + .or_default() += 1; + shutdown_panes.push(pane_id); self.shutdown_terminal_runtime(terminal_id); } + shutdown_panes } } |
