From 5a2dee700eeeea68267a4d16777307632f77172f Mon Sep 17 00:00:00 2001 From: JJ Liebig Date: Wed, 2 Sep 2026 18:08:10 +0200 Subject: 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 --- src/server/headless/notifications.rs | 63 +++++++++++++--- src/server/headless/tests/mod.rs | 142 ++++++++++++++++++++++++++++++++++- 2 files changed, 190 insertions(+), 15 deletions(-) (limited to 'src/server') diff --git a/src/server/headless/notifications.rs b/src/server/headless/notifications.rs index 92020d63..6764dd2d 100644 --- a/src/server/headless/notifications.rs +++ b/src/server/headless/notifications.rs @@ -627,15 +627,43 @@ impl HeadlessServer { } changed } - AppEvent::WorktreeRemoveFinished(_) => { + AppEvent::WorktreeRemoveFinished(result) => { let focus_before = self.shell_focus_targets(); let focused_tabs_before = self.focused_shell_tabs(); - let changed = self.app.handle_internal_event_with_render_impact(ev); + let shutdown_terminals = result + .api_request + .as_ref() + .map(|request| { + request + .shutdown_panes + .iter() + .filter_map(|pane_id| { + self.app.find_pane(*pane_id).map(|(_, pane)| { + (*pane_id, pane.attached_terminal_id.to_string()) + }) + }) + .collect::>() + }) + .unwrap_or_default(); + let pane_updates = self.app.handle_internal_event_with_pane_updates(ev); + for update in &pane_updates { + self.forward_semantic_agent_notification(update); + self.forward_pane_state_update_notifications_to_clients(update); + } self.reconcile_client_shell_locations(); self.finish_shell_location_reconciliation(focus_before, &focused_tabs_before); - changed + for (pane_id, terminal_id) in shutdown_terminals { + if self.app.find_pane(pane_id).is_none() { + self.shutdown_terminal_stream_clients( + &terminal_id, + format!("terminal {terminal_id} exited"), + ); + } + } + true } - AppEvent::PaneDied { pane_id } => { + AppEvent::PaneDied { pane_id } + | AppEvent::WorktreeRuntimeRestoreFailed { pane_id, .. } => { let focus_before = self.shell_focus_targets(); let focused_tabs_before = self.focused_shell_tabs(); let pane_id_val = *pane_id; @@ -646,17 +674,28 @@ impl HeadlessServer { .map(|pane| pane.attached_terminal_id.to_string()) }) }); - if let Some(update) = self - .app - .state - .publish_pane_process_exit_if_agent(pane_id_val) + if matches!(&ev, AppEvent::PaneDied { .. }) + && !self + .app + .pending_worktree_remove_runtime_exits + .contains_key(&pane_id_val) { - self.app.emit_pane_state_update(&update); - self.forward_semantic_agent_notification(&update); - self.forward_pane_state_update_notifications_to_clients(&update); + if let Some(update) = self + .app + .state + .publish_pane_process_exit_if_agent(pane_id_val, false) + { + self.app.emit_pane_state_update(&update); + self.forward_semantic_agent_notification(&update); + self.forward_pane_state_update_notifications_to_clients(&update); + } } - self.app.handle_internal_event(ev); + let pane_updates = self.app.handle_internal_event_with_pane_updates(ev); + for update in &pane_updates { + self.forward_semantic_agent_notification(update); + self.forward_pane_state_update_notifications_to_clients(update); + } self.reconcile_client_shell_locations(); self.finish_shell_location_reconciliation(focus_before, &focused_tabs_before); diff --git a/src/server/headless/tests/mod.rs b/src/server/headless/tests/mod.rs index 2fb6fd55..29a4cfbf 100644 --- a/src/server/headless/tests/mod.rs +++ b/src/server/headless/tests/mod.rs @@ -3468,7 +3468,7 @@ async fn pane_death_reconciles_each_client_view_and_focus() { } #[test] -fn terminal_attach_client_exits_when_attached_pane_dies() { +fn terminal_attach_client_exits_when_worktree_runtime_restore_fails() { let mut server = test_headless_server(); let workspace = crate::workspace::Workspace::test_new("attached"); let pane_id = workspace.tabs[0].root_pane; @@ -3478,7 +3478,18 @@ fn terminal_attach_client_exits_when_attached_pane_dies() { .pane_state(pane_id) .expect("pane") .attached_terminal_id - .to_string(); + .clone(); + server + .app + .state + .terminals + .get_mut(&terminal_id) + .unwrap() + .set_detected_state( + Some(crate::detect::Agent::Codex), + crate::detect::AgentState::Working, + ); + let terminal_id = terminal_id.to_string(); let (writer, control_rx, _render_rx) = test_client_writer(); assert!(!server.handle_server_event(ServerEvent::ClientConnected { @@ -3498,8 +3509,21 @@ fn terminal_attach_client_exits_when_attached_pane_dies() { }) ); assert_eq!(server.terminal_attach_owners.get(&terminal_id), Some(&7)); + server + .app + .pending_worktree_remove_runtime_exits + .insert(pane_id, 1); + server + .app + .pending_worktree_remove_runtime_restores + .insert(pane_id, 7); - assert!(server.handle_internal_event_with_forwarding(AppEvent::PaneDied { pane_id })); + assert!( + server.handle_internal_event_with_forwarding(AppEvent::WorktreeRuntimeRestoreFailed { + pane_id, + operation_id: 7, + }) + ); assert!(!server.clients.contains_key(&7)); assert!(!server.terminal_attach_owners.contains_key(&terminal_id)); @@ -3507,6 +3531,118 @@ fn terminal_attach_client_exits_when_attached_pane_dies() { assert_eq!(reason, Some(format!("terminal {terminal_id} exited"))); } +#[test] +fn terminal_attach_client_exits_when_worktree_remove_succeeds() { + let mut server = test_headless_server(); + let checkout = PathBuf::from("/repo/herdr-issue"); + let parent = crate::workspace::Workspace::test_new("parent"); + let mut workspace = crate::workspace::Workspace::test_new("worktree"); + workspace.worktree_space = Some(crate::workspace::WorktreeSpaceMembership { + key: "repo-key".into(), + label: "herdr".into(), + repo_root: "/repo/herdr".into(), + 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(); + server.app.state.workspaces = vec![parent, workspace]; + server.app.state.ensure_test_terminals(); + server.app.state.active = Some(1); + server.app.state.selected = 1; + let checkout_key = crate::worktree::canonical_or_original(&checkout); + server + .app + .pending_api_worktree_removes + .insert(workspace_id.clone(), 7); + server + .app + .pending_api_worktree_remove_paths + .insert(checkout_key.clone(), 7); + server + .app + .pending_worktree_remove_runtime_exits + .insert(pane_id, 1); + let terminal_id = terminal_id.to_string(); + let (writer, control_rx, _render_rx) = test_client_writer(); + + assert!(!server.handle_server_event(ServerEvent::ClientConnected { + client_id: 7, + cols: 80, + rows: 24, + cell_width_px: 0, + cell_height_px: 0, + pixel_mouse: false, + writer, + })); + assert!( + server.handle_server_event(ServerEvent::ClientAttachTerminal { + client_id: 7, + terminal_id: terminal_id.clone(), + takeover: false, + }) + ); + let (respond_to, _response_rx) = std::sync::mpsc::channel(); + + assert!( + server.handle_internal_event_with_forwarding(AppEvent::WorktreeRemoveFinished(Box::new( + crate::events::WorktreeRemoveResult { + workspace_id, + path: checkout, + workspace: None, + worktree: None, + forced: true, + api_request: Some(crate::events::ApiWorktreeRemoveRequest { + id: "req".into(), + operation_id: 7, + checkout_key, + shutdown_panes: vec![pane_id], + respond_to, + }), + result: Ok(()), + } + ))) + ); + + assert!(!server.clients.contains_key(&7)); + assert!(!server.terminal_attach_owners.contains_key(&terminal_id)); + let reason = read_server_shutdown_reason(control_rx.recv().expect("shutdown message")); + assert_eq!(reason, Some(format!("terminal {terminal_id} exited"))); +} + +#[test] +fn expected_worktree_runtime_exit_does_not_release_agent() { + let mut server = test_headless_server(); + 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(); + server.app.state.workspaces = vec![workspace]; + server.app.state.ensure_test_terminals(); + server + .app + .state + .terminals + .get_mut(&terminal_id) + .unwrap() + .set_detected_state( + Some(crate::detect::Agent::Codex), + crate::detect::AgentState::Working, + ); + server + .app + .pending_worktree_remove_runtime_exits + .insert(pane_id, 1); + + assert!(server.handle_internal_event_with_forwarding(AppEvent::PaneDied { pane_id })); + + assert_eq!( + server.app.state.terminals[&terminal_id].state, + crate::detect::AgentState::Working + ); + assert!(server.app.find_pane(pane_id).is_some()); +} + #[test] fn terminal_attach_scroll_moves_attached_runtime_viewport() { let rt = tokio::runtime::Builder::new_current_thread() -- cgit v1.2.3