aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-05-29 21:01:03 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-05-29 21:01:03 +0300
commit1f2b0f71b54467a9749b0d3b6945b5322d0aa42a (patch)
tree2695c3f992f6aad89839a3e90fdfce2741092844 /src
parent9e302984ccb13a65e3e504fa7692b223f546ff84 (diff)
fix: keep restored agent panes after exit
Diffstat (limited to 'src')
-rw-r--r--src/app/api.rs53
-rw-r--r--src/persist/restore.rs137
-rw-r--r--src/terminal/state.rs16
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()