diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-29 21:01:03 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-29 21:01:03 +0300 |
| commit | 1f2b0f71b54467a9749b0d3b6945b5322d0aa42a (patch) | |
| tree | 2695c3f992f6aad89839a3e90fdfce2741092844 /src | |
| parent | 9e302984ccb13a65e3e504fa7692b223f546ff84 (diff) | |
fix: keep restored agent panes after exit
Diffstat (limited to 'src')
| -rw-r--r-- | src/app/api.rs | 53 | ||||
| -rw-r--r-- | src/persist/restore.rs | 137 | ||||
| -rw-r--r-- | src/terminal/state.rs | 16 |
3 files changed, 200 insertions, 6 deletions
diff --git a/src/app/api.rs b/src/app/api.rs index e69ab0eb..df20cd34 100644 --- a/src/app/api.rs +++ b/src/app/api.rs @@ -330,9 +330,7 @@ impl App { self.terminal_runtimes.insert(terminal_id.clone(), runtime); if let Some(terminal) = self.state.terminals.get_mut(&terminal_id) { - terminal.launch_argv = None; - terminal.respawn_shell_on_exit = false; - terminal.clear_agent_name(); + terminal.clear_agent_runtime_identity_after_respawn(); } self.state.focus_pane_in_workspace(ws_idx, pane_id); self.schedule_session_save(); @@ -690,6 +688,55 @@ mod tests { let _ = std::fs::remove_dir_all(temp_root); } + #[tokio::test] + async fn pane_died_respawns_shell_and_clears_restored_agent_session() { + let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + let mut app = App::new( + &crate::config::Config::default(), + true, + None, + api_rx, + crate::api::EventHub::default(), + ); + let workspace = crate::workspace::Workspace::test_new("restored"); + 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(); + let terminal = app + .state + .terminals + .get_mut(&terminal_id) + .expect("test terminal should exist"); + terminal.respawn_shell_on_exit = true; + terminal.set_agent_name("codex".into()); + terminal.set_persisted_agent_session(crate::agent_resume::PersistedAgentSession { + source: "herdr:codex".into(), + agent: "codex".into(), + session_ref: crate::agent_resume::AgentSessionRef::id("codex-session") + .expect("test session id should be valid"), + }); + + app.handle_internal_event(AppEvent::PaneDied { pane_id }); + + assert!( + app.find_pane(pane_id).is_some(), + "respawnable agent pane should stay attached after the agent process exits" + ); + let terminal = app + .state + .terminals + .get(&terminal_id) + .expect("terminal should survive respawn"); + assert!(!terminal.respawn_shell_on_exit); + assert!(terminal.persisted_agent_session.is_none()); + assert!(terminal.agent_name.is_none()); + + for (_, runtime) in app.terminal_runtimes.drain() { + runtime.shutdown(); + } + } + #[test] fn terminal_delivery_does_not_refresh_existing_targeted_toast() { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); diff --git a/src/persist/restore.rs b/src/persist/restore.rs index 26949cdd..916478b6 100644 --- a/src/persist/restore.rs +++ b/src/persist/restore.rs @@ -416,6 +416,7 @@ fn restore_tab( let old_pane_id = reverse_id_map.get(id).copied(); let imported_runtime = old_pane_id.and_then(|old_id| imported_panes.remove(&old_id)); let was_imported = imported_runtime.is_some(); + let was_native_agent_restore = !was_imported && startup.restore_plan.is_some(); let runtime_result = if let Some(imported) = imported_runtime { TerminalRuntime::from_handoff_fd( crate::handoff_runtime::ImportedHandoffRuntime { @@ -465,7 +466,9 @@ fn restore_tab( Ok(runtime) => { let terminal_id = TerminalId::alloc(); let mut terminal = TerminalState::new(terminal_id.clone(), cwd.clone()); - if was_imported { + if was_native_agent_restore { + terminal = terminal.with_respawn_shell_on_exit(); + } else if was_imported { if let Some(argv) = saved_launch_argv { terminal = terminal.with_launch_argv(argv).with_respawn_shell_on_exit(); } @@ -748,6 +751,30 @@ fn collect_ids_inner(node: &Node, ids: &mut Vec<PaneId>) { #[cfg(test)] mod tests { use super::*; + use std::ffi::OsString; + + struct EnvVarGuard { + key: &'static str, + previous: Option<OsString>, + } + + impl EnvVarGuard { + fn set(key: &'static str, value: OsString) -> Self { + let previous = std::env::var_os(key); + std::env::set_var(key, value); + Self { key, previous } + } + } + + impl Drop for EnvVarGuard { + fn drop(&mut self) { + if let Some(previous) = self.previous.take() { + std::env::set_var(self.key, previous); + } else { + std::env::remove_var(self.key); + } + } + } #[test] fn capture_and_restore_node_round_trip() { @@ -1032,10 +1059,17 @@ mod tests { Arc::new(AtomicBool::new(false)), ); - let session = terminals + let terminal = terminals .values() .next() - .and_then(|terminal| terminal.persisted_agent_session.as_ref()) + .expect("restored terminal should exist"); + assert!( + !terminal.respawn_shell_on_exit, + "agent sessions should not use native restore lifecycle when resume_agents_on_restore is disabled" + ); + let session = terminal + .persisted_agent_session + .as_ref() .expect("persisted agent session should survive restore"); assert_eq!(session.source, "herdr:opencode"); assert_eq!(session.agent, "opencode"); @@ -1043,6 +1077,103 @@ mod tests { } #[tokio::test] + #[cfg(unix)] + async fn native_agent_restore_marks_terminal_for_shell_respawn() { + use std::os::unix::fs::PermissionsExt; + + let _lock = crate::integration::integration_env_lock(); + let cwd = std::env::current_dir().unwrap(); + let base = std::env::temp_dir().join(format!( + "herdr-agent-restore-respawn-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let bin = base.join("bin"); + std::fs::create_dir_all(&bin).unwrap(); + let codex = bin.join("codex"); + std::fs::write(&codex, "#!/bin/sh\nexit 0\n").unwrap(); + std::fs::set_permissions(&codex, std::fs::Permissions::from_mode(0o755)).unwrap(); + let path = match std::env::var_os("PATH") { + Some(path) => std::env::join_paths( + std::iter::once(bin.as_os_str().to_owned()) + .chain(std::env::split_paths(&path).map(|path| path.into_os_string())), + ) + .unwrap(), + None => bin.as_os_str().to_owned(), + }; + let _path_guard = EnvVarGuard::set("PATH", path); + let snapshot = SessionSnapshot { + version: super::super::snapshot::SNAPSHOT_VERSION, + workspaces: vec![WorkspaceSnapshot { + id: Some("workspace".into()), + custom_name: None, + identity_cwd: cwd.clone(), + worktree_space: None, + tabs: vec![TabSnapshot { + custom_name: None, + layout: LayoutSnapshot::Pane(0), + panes: HashMap::from([( + 0, + super::super::snapshot::PaneSnapshot { + cwd, + label: None, + agent_name: None, + agent_session: Some(super::super::snapshot::PaneAgentSessionSnapshot { + source: "herdr:codex".into(), + agent: "codex".into(), + kind: crate::agent_resume::AgentSessionRefKind::Id, + value: "codex-session".into(), + }), + launch_argv: None, + }, + )]), + zoomed: false, + focused: Some(0), + root_pane: Some(0), + }], + active_tab: 0, + }], + active: Some(0), + selected: 0, + agent_panel_scope: Default::default(), + sidebar_width: None, + sidebar_section_split: None, + collapsed_space_keys: Default::default(), + }; + let (events, _event_rx) = mpsc::channel(4); + + let (_workspaces, terminals, runtimes) = restore( + &snapshot, + None, + 24, + 80, + 0, + "/bin/sh", + true, + events, + Arc::new(Notify::new()), + Arc::new(AtomicBool::new(false)), + ); + + let terminal = terminals + .values() + .next() + .expect("native agent restore should create terminal state"); + assert!( + terminal.respawn_shell_on_exit, + "restored native agent panes should keep the pane open after the resume process exits" + ); + + for runtime in runtimes.into_values() { + runtime.shutdown(); + } + let _ = std::fs::remove_dir_all(base); + } + + #[tokio::test] async fn restore_seeds_saved_pane_history_into_runtime() { let (snapshot, history) = snapshot_with_saved_pane_history(); let (events, _events_rx) = mpsc::channel(8); diff --git a/src/terminal/state.rs b/src/terminal/state.rs index f061e318..c1d17db5 100644 --- a/src/terminal/state.rs +++ b/src/terminal/state.rs @@ -641,6 +641,22 @@ impl TerminalState { self.agent_name = None; } + pub fn clear_agent_runtime_identity_after_respawn(&mut self) { + self.detected_agent = None; + self.fallback_state = AgentState::Unknown; + self.fallback_visible_blocker = false; + self.fallback_visible_idle = false; + self.fallback_visible_working = false; + self.fallback_observed_at = None; + self.stale_hook_idle_since = None; + self.hook_authority = None; + self.persisted_agent_session = None; + self.agent_metadata.clear(); + self.launch_argv = None; + self.respawn_shell_on_exit = false; + self.clear_agent_name(); + } + pub fn is_agent_terminal(&self) -> bool { self.agent_name.is_some() || self.effective_agent_label().is_some() |
