aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/app/actions.rs55
-rw-r--r--src/app/api.rs196
-rw-r--r--src/app/api/worktrees.rs133
-rw-r--r--src/app/api/worktrees/deferred.rs104
-rw-r--r--src/app/mod.rs4
-rw-r--r--src/app/worktrees.rs17
-rw-r--r--src/events.rs3
-rw-r--r--src/server/headless/notifications.rs63
-rw-r--r--src/server/headless/tests/mod.rs142
9 files changed, 651 insertions, 66 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
}
}
diff --git a/src/events.rs b/src/events.rs
index d17ef60b..2901e493 100644
--- a/src/events.rs
+++ b/src/events.rs
@@ -37,6 +37,7 @@ pub struct ApiWorktreeRemoveRequest {
pub id: String,
pub operation_id: u64,
pub checkout_key: std::path::PathBuf,
+ pub shutdown_panes: Vec<crate::layout::PaneId>,
pub respond_to: std::sync::mpsc::Sender<String>,
}
@@ -56,6 +57,8 @@ pub struct WorktreeRemoveResult {
pub enum AppEvent {
/// A pane's child process exited.
PaneDied { pane_id: PaneId },
+ /// A worktree-removal runtime could not be restored normally.
+ WorktreeRuntimeRestoreFailed { pane_id: PaneId, operation_id: u64 },
/// Process detection identified an agent before its screen state was confirmed.
AgentProcessDetected {
pane_id: PaneId,
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::<Vec<_>>()
+ })
+ .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));
@@ -3508,6 +3532,118 @@ fn terminal_attach_client_exits_when_attached_pane_dies() {
}
#[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()
.enable_all()