From 36a4608603a82dbf32ec8a013dd47047cf02c6a3 Mon Sep 17 00:00:00 2001 From: Ogulcan Celik Date: Mon, 1 Jun 2026 23:31:53 +0300 Subject: fix: restore agents through pane shells --- src/agent_resume.rs | 6 - src/app/agent_resume.rs | 587 ++++++++++++++++++++++++++++++++++++++++++++++ src/app/input/terminal.rs | 2 +- src/app/mod.rs | 13 +- src/app/runtime.rs | 98 +++++++- src/config/model.rs | 20 +- src/main.rs | 2 +- src/pane.rs | 155 ------------ src/persist/restore.rs | 153 ++++++------ src/server/headless.rs | 233 +++++++++++++++++- src/terminal/runtime.rs | 27 --- src/terminal/state.rs | 33 +++ 12 files changed, 1044 insertions(+), 285 deletions(-) create mode 100644 src/app/agent_resume.rs (limited to 'src') diff --git a/src/agent_resume.rs b/src/agent_resume.rs index 7728baf4..8f450088 100644 --- a/src/agent_resume.rs +++ b/src/agent_resume.rs @@ -25,12 +25,6 @@ pub struct AgentResumePlan { pub dedupe_key: String, } -#[derive(Debug, Clone, Copy)] -pub struct AgentResumeLaunch<'a> { - pub plan: &'a AgentResumePlan, - pub initial_history_ansi: Option<&'a str>, -} - #[derive(Debug, Clone, PartialEq, Eq)] pub struct PersistedAgentSession { pub source: String, diff --git a/src/app/agent_resume.rs b/src/app/agent_resume.rs new file mode 100644 index 00000000..3523da05 --- /dev/null +++ b/src/app/agent_resume.rs @@ -0,0 +1,587 @@ +use std::time::Instant; + +use bytes::Bytes; + +use super::App; + +struct PendingAgentResumeCandidate { + pane_id: crate::layout::PaneId, + terminal_id: crate::terminal::TerminalId, + cwd: std::path::PathBuf, + plan: crate::agent_resume::AgentResumePlan, + rows: u16, + cols: u16, +} + +impl App { + pub(crate) fn has_pending_agent_resumes(&self) -> bool { + self.state + .terminals + .values() + .any(|terminal| terminal.pending_agent_resume_plan.is_some()) + } + + pub(crate) fn sync_pending_agent_resume_deadline(&mut self, now: Instant) { + if !self.has_pending_agent_resumes() { + self.pending_agent_resume_deadline = None; + return; + } + if self.pending_agent_resume_candidates().is_empty() { + self.pending_agent_resume_deadline = None; + return; + } + self.pending_agent_resume_deadline + .get_or_insert(now + super::PENDING_AGENT_RESUME_THEME_WAIT); + } + + pub(crate) fn pending_agent_resume_due(&self, now: Instant) -> bool { + self.pending_agent_resume_deadline + .is_some_and(|deadline| now >= deadline) + } + + pub(crate) fn start_pending_agent_resumes(&mut self, allow_empty_theme: bool) -> bool { + let pending = self.pending_agent_resume_candidates(); + let mut changed = false; + for PendingAgentResumeCandidate { + pane_id, + terminal_id, + cwd, + plan, + rows, + cols, + } in pending + { + if self.terminal_runtimes.get(&terminal_id).is_some() { + continue; + } + changed |= self.start_pending_agent_resume( + pane_id, + terminal_id, + cwd, + plan, + rows, + cols, + allow_empty_theme, + ); + } + + if changed { + self.schedule_session_save(); + } + if !self.has_pending_agent_resumes() || self.pending_agent_resume_candidates().is_empty() { + self.pending_agent_resume_deadline = None; + } + changed + } + + fn pending_agent_resume_candidates(&self) -> Vec { + let Some(ws_idx) = self.state.active else { + return Vec::new(); + }; + let Some(ws) = self.state.workspaces.get(ws_idx) else { + return Vec::new(); + }; + let Some(tab) = ws.tabs.get(ws.active_tab) else { + return Vec::new(); + }; + + let mut pending = Vec::new(); + for pane_id in tab.layout.pane_ids() { + let Some(pane) = tab.panes.get(&pane_id) else { + continue; + }; + if self + .terminal_runtimes + .get(&pane.attached_terminal_id) + .is_some() + { + continue; + } + let Some(info) = self + .state + .view + .pane_infos + .iter() + .find(|info| info.id == pane_id) + else { + continue; + }; + let Some(terminal) = self.state.terminals.get(&pane.attached_terminal_id) else { + continue; + }; + let Some(plan) = terminal.pending_agent_resume_plan.clone() else { + continue; + }; + pending.push(PendingAgentResumeCandidate { + pane_id, + terminal_id: pane.attached_terminal_id.clone(), + cwd: terminal.cwd.clone(), + plan, + rows: info.inner_rect.height, + cols: info.inner_rect.width, + }); + } + pending + } + + pub(crate) fn start_pending_agent_resume_for_terminal( + &mut self, + terminal_id: &crate::terminal::TerminalId, + rows: u16, + cols: u16, + allow_empty_theme: bool, + ) -> bool { + if self.terminal_runtimes.get(terminal_id).is_some() { + return false; + } + let Some((pane_id, cwd, plan)) = self.state.workspaces.iter().find_map(|ws| { + ws.tabs.iter().find_map(|tab| { + tab.layout.pane_ids().into_iter().find_map(|pane_id| { + let pane = tab.panes.get(&pane_id)?; + if &pane.attached_terminal_id != terminal_id { + return None; + } + let terminal = self.state.terminals.get(terminal_id)?; + Some(( + pane_id, + terminal.cwd.clone(), + terminal.pending_agent_resume_plan.clone()?, + )) + }) + }) + }) else { + return false; + }; + + let changed = self.start_pending_agent_resume( + pane_id, + terminal_id.clone(), + cwd, + plan, + rows, + cols, + allow_empty_theme, + ); + if changed { + self.schedule_session_save(); + } + if !self.has_pending_agent_resumes() { + self.pending_agent_resume_deadline = None; + } + changed + } + + fn start_pending_agent_resume( + &mut self, + pane_id: crate::layout::PaneId, + terminal_id: crate::terminal::TerminalId, + cwd: std::path::PathBuf, + plan: crate::agent_resume::AgentResumePlan, + rows: u16, + cols: u16, + allow_empty_theme: bool, + ) -> bool { + let host_terminal_theme = self.state.host_terminal_theme; + if host_terminal_theme.is_empty() && !allow_empty_theme { + return false; + } + + let Some(resume_command) = shell_command_from_argv(&plan.argv) else { + tracing::warn!( + pane = pane_id.raw(), + terminal = %terminal_id, + agent = %plan.agent, + "failed to start deferred agent resume with empty argv" + ); + return false; + }; + + let runtime = match crate::terminal::TerminalRuntime::spawn( + pane_id, + rows, + cols, + cwd, + self.state.pane_scrollback_limit_bytes, + host_terminal_theme, + crate::pane::PaneShellConfig::new(&self.state.default_shell, self.state.shell_mode), + self.event_tx.clone(), + self.render_notify.clone(), + self.render_dirty.clone(), + ) { + Ok(runtime) => runtime, + Err(err) => { + tracing::warn!( + pane = pane_id.raw(), + terminal = %terminal_id, + agent = %plan.agent, + err = %err, + "failed to start shell for deferred agent resume" + ); + if let Some(terminal) = self.state.terminals.get_mut(&terminal_id) { + terminal.clear_agent_runtime_identity_after_respawn(); + } + return false; + } + }; + + let mut input = resume_command; + input.push('\r'); + if let Err(err) = runtime.try_send_bytes(Bytes::from(input)) { + tracing::warn!( + pane = pane_id.raw(), + terminal = %terminal_id, + agent = %plan.agent, + err = %err, + "failed to send deferred agent resume command to shell" + ); + runtime.shutdown(); + return false; + } + + self.terminal_runtimes.insert(terminal_id.clone(), runtime); + if let Some(terminal) = self.state.terminals.get_mut(&terminal_id) { + terminal.pending_agent_resume_plan = None; + terminal.respawn_shell_on_exit = false; + } + true + } +} + +fn shell_command_from_argv(argv: &[String]) -> Option { + let mut parts = argv.iter(); + let first = shell_quote(parts.next()?); + let mut command = first; + for part in parts { + command.push(' '); + command.push_str(&shell_quote(part)); + } + Some(command) +} + +fn shell_quote(value: &str) -> String { + if value.is_empty() { + return "''".to_string(); + } + if value.bytes().all(|byte| { + byte.is_ascii_alphanumeric() + || matches!( + byte, + b'_' | b'-' | b'.' | b'/' | b':' | b'@' | b'%' | b'+' | b'=' + ) + }) { + return value.to_string(); + } + format!("'{}'", value.replace('\'', "'\\''")) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_app() -> App { + let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + App::new( + &crate::config::Config::default(), + true, + None, + api_rx, + crate::api::EventHub::default(), + ) + } + + #[tokio::test] + async fn pending_agent_resume_waits_for_host_theme_before_launch() { + let mut app = test_app(); + 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(); + let pane_infos = workspace.tabs[0] + .layout + .panes(ratatui::layout::Rect::new(0, 0, 100, 30)); + app.state.workspaces = vec![workspace]; + app.state.active = Some(0); + app.state.ensure_test_terminals(); + app.state.view.pane_infos = pane_infos; + let terminal = app + .state + .terminals + .get_mut(&terminal_id) + .expect("test terminal should exist"); + terminal.pending_agent_resume_plan = Some(crate::agent_resume::AgentResumePlan { + agent: "codex".into(), + argv: vec![ + "/bin/sh".into(), + "-c".into(), + "printf '%s' 'restored agent: shell quoted | marker'; sleep 5".into(), + ], + dedupe_key: "herdr:codex\0codex\0Id\0codex-session".into(), + }); + + assert!(!app.start_pending_agent_resumes(false)); + assert!(app.terminal_runtimes.get(&terminal_id).is_none()); + + app.state.host_terminal_theme = crate::terminal_theme::TerminalTheme { + foreground: Some(crate::terminal_theme::RgbColor { + r: 220, + g: 220, + b: 220, + }), + background: Some(crate::terminal_theme::RgbColor { + r: 20, + g: 20, + b: 20, + }), + }; + + assert!(app.start_pending_agent_resumes(false)); + assert!(app.terminal_runtimes.get(&terminal_id).is_some()); + let terminal = app + .state + .terminals + .get(&terminal_id) + .expect("terminal should survive launch"); + assert!(terminal.pending_agent_resume_plan.is_none()); + assert!(!terminal.respawn_shell_on_exit); + + let runtime = app + .terminal_runtimes + .get(&terminal_id) + .expect("pending resume should leave a shell runtime"); + let marker = "restored agent: shell quoted | marker"; + for _ in 0..20 { + if runtime + .snapshot_history() + .is_some_and(|text| text.contains(marker)) + { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + } + assert!( + runtime + .snapshot_history() + .expect("runtime should expose terminal history") + .contains(marker), + "deferred restore should inject the resume argv into the restored shell" + ); + + for (_, runtime) in app.terminal_runtimes.drain() { + runtime.shutdown(); + } + } + + #[tokio::test] + async fn pending_agent_resume_can_launch_after_theme_wait_expires() { + let mut app = test_app(); + 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.view.pane_infos = workspace.tabs[0] + .layout + .panes(ratatui::layout::Rect::new(0, 0, 100, 30)); + app.state.workspaces = vec![workspace]; + app.state.active = Some(0); + app.state.ensure_test_terminals(); + app.state + .terminals + .get_mut(&terminal_id) + .expect("test terminal should exist") + .pending_agent_resume_plan = Some(crate::agent_resume::AgentResumePlan { + agent: "codex".into(), + argv: vec!["/bin/sh".into(), "-c".into(), "sleep 5".into()], + dedupe_key: "herdr:codex\0codex\0Id\0codex-session".into(), + }); + + app.sync_pending_agent_resume_deadline(std::time::Instant::now()); + assert!(!app.start_pending_agent_resumes(false)); + assert!(app.start_pending_agent_resumes(true)); + assert!(app.terminal_runtimes.get(&terminal_id).is_some()); + + for (_, runtime) in app.terminal_runtimes.drain() { + runtime.shutdown(); + } + } + + #[tokio::test] + async fn pending_agent_resume_skips_hidden_panes_without_visible_geometry() { + let mut app = test_app(); + let active_workspace = crate::workspace::Workspace::test_new("active"); + let active_pane = active_workspace.tabs[0].root_pane; + let active_terminal = active_workspace.terminal_id(active_pane).cloned().unwrap(); + let hidden_workspace = crate::workspace::Workspace::test_new("hidden"); + let hidden_pane = hidden_workspace.tabs[0].root_pane; + let hidden_terminal = hidden_workspace.terminal_id(hidden_pane).cloned().unwrap(); + app.state.view.pane_infos = active_workspace.tabs[0] + .layout + .panes(ratatui::layout::Rect::new(0, 0, 100, 30)); + app.state.workspaces = vec![active_workspace, hidden_workspace]; + app.state.active = Some(0); + app.state.ensure_test_terminals(); + app.state.host_terminal_theme = crate::terminal_theme::TerminalTheme { + foreground: Some(crate::terminal_theme::RgbColor { + r: 220, + g: 220, + b: 220, + }), + background: Some(crate::terminal_theme::RgbColor { + r: 20, + g: 20, + b: 20, + }), + }; + for terminal_id in [&active_terminal, &hidden_terminal] { + app.state + .terminals + .get_mut(terminal_id) + .expect("test terminal should exist") + .pending_agent_resume_plan = Some(crate::agent_resume::AgentResumePlan { + agent: "codex".into(), + argv: vec!["/bin/sh".into(), "-c".into(), "sleep 5".into()], + dedupe_key: format!("herdr:codex\0codex\0Id\0{terminal_id}"), + }); + } + app.pending_agent_resume_deadline = + Some(std::time::Instant::now() - std::time::Duration::from_millis(1)); + + assert!(app.start_pending_agent_resumes(false)); + assert!(app.terminal_runtimes.get(&active_terminal).is_some()); + assert!(app.terminal_runtimes.get(&hidden_terminal).is_none()); + assert!( + app.pending_agent_resume_deadline.is_none(), + "hidden-only pending resumes should not keep an expired wakeup deadline active" + ); + assert!( + app.state + .terminals + .get(&hidden_terminal) + .expect("hidden terminal should still exist") + .pending_agent_resume_plan + .is_some(), + "hidden restored panes should wait until their tab has computed geometry" + ); + + for (_, runtime) in app.terminal_runtimes.drain() { + runtime.shutdown(); + } + } + + #[tokio::test] + async fn pending_agent_resume_ignores_stale_geometry_from_previous_active_view() { + let mut app = test_app(); + let previous_workspace = crate::workspace::Workspace::test_new("previous"); + let previous_pane = previous_workspace.tabs[0].root_pane; + let previous_terminal = previous_workspace + .terminal_id(previous_pane) + .cloned() + .unwrap(); + let current_workspace = crate::workspace::Workspace::test_new("current"); + app.state.view.pane_infos = previous_workspace.tabs[0] + .layout + .panes(ratatui::layout::Rect::new(0, 0, 100, 30)); + app.state.workspaces = vec![previous_workspace, current_workspace]; + app.state.active = Some(1); + app.state.ensure_test_terminals(); + app.state.host_terminal_theme = crate::terminal_theme::TerminalTheme { + foreground: Some(crate::terminal_theme::RgbColor { + r: 220, + g: 220, + b: 220, + }), + background: Some(crate::terminal_theme::RgbColor { + r: 20, + g: 20, + b: 20, + }), + }; + app.state + .terminals + .get_mut(&previous_terminal) + .expect("test terminal should exist") + .pending_agent_resume_plan = Some(crate::agent_resume::AgentResumePlan { + agent: "codex".into(), + argv: vec!["/bin/sh".into(), "-c".into(), "sleep 5".into()], + dedupe_key: "herdr:codex\0codex\0Id\0codex-session".into(), + }); + + app.sync_pending_agent_resume_deadline(std::time::Instant::now()); + assert!(app.pending_agent_resume_deadline.is_none()); + assert!(!app.start_pending_agent_resumes(false)); + assert!(app.terminal_runtimes.get(&previous_terminal).is_none()); + assert!( + app.state + .terminals + .get(&previous_terminal) + .expect("previous terminal should still exist") + .pending_agent_resume_plan + .is_some(), + "a pane hidden by navigation should wait for a fresh visible geometry snapshot" + ); + } + + #[tokio::test] + async fn pending_agent_resume_launches_with_inner_rect_size() { + let mut app = test_app(); + let mut workspace = crate::workspace::Workspace::test_new("split"); + let pane_id = workspace.test_split(ratatui::layout::Direction::Horizontal); + let terminal_id = workspace.terminal_id(pane_id).cloned().unwrap(); + app.state.view.pane_infos = vec![crate::layout::PaneInfo { + id: pane_id, + rect: ratatui::layout::Rect::new(0, 0, 100, 30), + inner_rect: ratatui::layout::Rect::new(1, 1, 98, 28), + scrollbar_rect: None, + is_focused: true, + }]; + app.state.workspaces = vec![workspace]; + app.state.active = Some(0); + app.state.ensure_test_terminals(); + app.state.host_terminal_theme = crate::terminal_theme::TerminalTheme { + foreground: Some(crate::terminal_theme::RgbColor { + r: 220, + g: 220, + b: 220, + }), + background: Some(crate::terminal_theme::RgbColor { + r: 20, + g: 20, + b: 20, + }), + }; + app.state + .terminals + .get_mut(&terminal_id) + .expect("test terminal should exist") + .pending_agent_resume_plan = Some(crate::agent_resume::AgentResumePlan { + agent: "codex".into(), + argv: vec!["/bin/sh".into(), "-c".into(), "sleep 5".into()], + dedupe_key: "herdr:codex\0codex\0Id\0codex-session".into(), + }); + + assert!(app.start_pending_agent_resumes(false)); + assert_eq!( + app.terminal_runtimes + .get(&terminal_id) + .expect("pending resume should launch") + .current_size(), + (28, 98) + ); + + for (_, runtime) in app.terminal_runtimes.drain() { + runtime.shutdown(); + } + } + + #[test] + fn shell_command_from_argv_quotes_resume_arguments() { + let argv = vec![ + "claude".to_string(), + "--resume".to_string(), + "session with ' quote".to_string(), + ]; + + assert_eq!( + shell_command_from_argv(&argv).as_deref(), + Some("claude --resume 'session with '\\'' quote'") + ); + assert_eq!(shell_command_from_argv(&[]), None); + } +} diff --git a/src/app/input/terminal.rs b/src/app/input/terminal.rs index 401a981d..99ae3bf1 100644 --- a/src/app/input/terminal.rs +++ b/src/app/input/terminal.rs @@ -519,7 +519,7 @@ mod tests { let deadline = app .selection_highlight_clear_deadline .expect("highlight clear deadline"); - assert!(app.handle_scheduled_tasks(deadline + std::time::Duration::from_millis(1))); + assert!(app.handle_scheduled_tasks(deadline + std::time::Duration::from_millis(1), false)); assert!(app.state.selection.is_none()); } diff --git a/src/app/mod.rs b/src/app/mod.rs index 0dcd0372..5a39b8e2 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -5,6 +5,7 @@ //! - `input.rs` — key/mouse → action translation pub(crate) mod actions; +mod agent_resume; mod agents; mod api; mod api_helpers; @@ -34,6 +35,7 @@ pub(crate) const SELECTION_AUTOSCROLL_INTERVAL: Duration = Duration::from_millis const RESIZE_POLL_INTERVAL: Duration = Duration::from_millis(100); const GIT_REMOTE_STATUS_REFRESH_INTERVAL: Duration = Duration::from_millis(1500); const AUTO_UPDATE_CHECK_INTERVAL: Duration = Duration::from_secs(30 * 60); +const PENDING_AGENT_RESUME_THEME_WAIT: Duration = Duration::from_millis(750); const SESSION_SAVE_DEBOUNCE: Duration = Duration::from_secs(5); const SIDEBAR_DOUBLE_CLICK_WINDOW: Duration = Duration::from_millis(350); const PANE_DOUBLE_CLICK_WINDOW: Duration = Duration::from_millis(350); @@ -105,6 +107,7 @@ pub struct App { pub(crate) next_animation_tick: Option, pub(crate) next_auto_update_check: Option, pub(crate) agent_metadata_deadline: Option, + pub(crate) pending_agent_resume_deadline: Option, pub(crate) selection_autoscroll_deadline: Option, pub(crate) selection_highlight_clear_deadline: Option, pub(crate) session_save_deadline: Option, @@ -576,6 +579,7 @@ impl App { next_auto_update_check: auto_updates_enabled(no_session) .then_some(Instant::now() + AUTO_UPDATE_CHECK_INTERVAL), agent_metadata_deadline: None, + pending_agent_resume_deadline: None, session_save_deadline: None, selection_autoscroll_deadline: None, selection_highlight_clear_deadline: None, @@ -698,7 +702,7 @@ impl App { self.sync_session_save_schedule(); let now = Instant::now(); - if self.handle_scheduled_tasks(now) { + if self.handle_scheduled_tasks(now, needs_render) { needs_render = true; } @@ -813,6 +817,11 @@ impl App { cell_size, )?; } + self.sync_pending_agent_resume_deadline(now); + if self.start_pending_agent_resumes(self.pending_agent_resume_due(now)) { + self.render_dirty.store(true, Ordering::Release); + self.render_notify.notify_one(); + } self.last_render_at = Some(now); needs_render = false; continue; @@ -3000,7 +3009,7 @@ mod tests { let mut app = test_app(); app.session_save_deadline = Some(Instant::now() - Duration::from_secs(1)); - app.handle_scheduled_tasks(Instant::now()); + app.handle_scheduled_tasks(Instant::now(), false); assert!(app.session_save_deadline.is_none()); } diff --git a/src/app/runtime.rs b/src/app/runtime.rs index ad90a267..4ddcde6c 100644 --- a/src/app/runtime.rs +++ b/src/app/runtime.rs @@ -165,13 +165,15 @@ impl App { false } - pub(crate) fn handle_scheduled_tasks(&mut self, now: Instant) -> bool { + pub(crate) fn handle_scheduled_tasks(&mut self, now: Instant, geometry_dirty: bool) -> bool { let mut changed = false; + let mut resized = false; self.sync_animation_timer(now); if now >= self.next_resize_poll { - changed |= self.handle_resize_poll(); + resized = self.handle_resize_poll(); + changed |= resized; self.next_resize_poll = now + RESIZE_POLL_INTERVAL; } @@ -247,6 +249,12 @@ impl App { changed = true; } + if geometry_dirty || resized { + self.pending_agent_resume_deadline = None; + } else { + self.sync_pending_agent_resume_deadline(now); + changed |= self.start_pending_agent_resumes(self.pending_agent_resume_due(now)); + } self.sync_animation_timer(now); changed } @@ -496,6 +504,7 @@ impl App { .flatten(), self.next_auto_update_check, self.agent_metadata_deadline, + self.pending_agent_resume_deadline, self.session_save_deadline, self.selection_autoscroll_deadline, self.selection_highlight_clear_deadline, @@ -914,4 +923,89 @@ mod tests { assert!(app.state.selection_autoscroll.is_none()); assert!(app.selection_autoscroll_deadline.is_none()); } + + #[tokio::test] + async fn raw_input_batch_does_not_start_pending_agent_resume_before_render() { + let (mut app, pane_id) = test_app_with_pane(); + app.state.ensure_test_terminals(); + let terminal_id = app.state.workspaces[0] + .terminal_id(pane_id) + .cloned() + .expect("test pane should have a terminal"); + app.state + .terminals + .get_mut(&terminal_id) + .expect("test terminal should exist") + .pending_agent_resume_plan = Some(crate::agent_resume::AgentResumePlan { + agent: "codex".into(), + argv: vec!["/bin/sh".into(), "-c".into(), "sleep 5".into()], + dedupe_key: "herdr:codex\0codex\0Id\0codex-session".into(), + }); + + assert!( + app.handle_raw_input_batch(crate::raw_input::RawInputEvent::HostDefaultColor { + kind: crate::terminal_theme::DefaultColorKind::Foreground, + color: crate::terminal_theme::RgbColor { + r: 220, + g: 220, + b: 220, + }, + }) + .await + ); + assert!( + app.terminal_runtimes.get(&terminal_id).is_none(), + "raw input can mutate active geometry; pending resumes must wait for render to refresh pane_infos" + ); + assert!(app + .state + .terminals + .get(&terminal_id) + .expect("test terminal should still exist") + .pending_agent_resume_plan + .is_some()); + } + + #[tokio::test] + async fn scheduled_tasks_do_not_start_pending_agent_resume_when_geometry_dirty() { + let (mut app, pane_id) = test_app_with_pane(); + app.state.ensure_test_terminals(); + app.state.host_terminal_theme = crate::terminal_theme::TerminalTheme { + foreground: Some(crate::terminal_theme::RgbColor { + r: 220, + g: 220, + b: 220, + }), + background: Some(crate::terminal_theme::RgbColor { + r: 20, + g: 20, + b: 20, + }), + }; + let terminal_id = app.state.workspaces[0] + .terminal_id(pane_id) + .cloned() + .expect("test pane should have a terminal"); + app.state + .terminals + .get_mut(&terminal_id) + .expect("test terminal should exist") + .pending_agent_resume_plan = Some(crate::agent_resume::AgentResumePlan { + agent: "codex".into(), + argv: vec!["/bin/sh".into(), "-c".into(), "sleep 5".into()], + dedupe_key: "herdr:codex\0codex\0Id\0codex-session".into(), + }); + app.pending_agent_resume_deadline = Some(Instant::now() - Duration::from_millis(1)); + + assert!(!app.handle_scheduled_tasks(Instant::now(), true)); + assert!(app.terminal_runtimes.get(&terminal_id).is_none()); + assert!(app + .state + .terminals + .get(&terminal_id) + .expect("test terminal should still exist") + .pending_agent_resume_plan + .is_some()); + assert!(app.pending_agent_resume_deadline.is_none()); + } } diff --git a/src/config/model.rs b/src/config/model.rs index dd247d83..f815fb8d 100644 --- a/src/config/model.rs +++ b/src/config/model.rs @@ -137,14 +137,22 @@ pub struct TerminalConfig { pub new_cwd: NewTerminalCwdConfig, } -#[derive(Debug, Default, Deserialize)] +#[derive(Debug, Deserialize)] #[serde(default)] pub struct SessionConfig { /// Resume supported AI-agent panes into their native conversation sessions - /// when restoring a Herdr session. Default: false. + /// when restoring a Herdr session. Default: true. pub resume_agents_on_restore: bool, } +impl Default for SessionConfig { + fn default() -> Self { + Self { + resume_agents_on_restore: true, + } + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] #[serde(rename_all = "snake_case")] pub enum ConfigReloadStatus { @@ -627,16 +635,16 @@ new_cwd = "~/Projects" } #[test] - fn resume_agents_on_restore_defaults_off_and_parses() { + fn resume_agents_on_restore_defaults_on_and_parses() { let default_config = Config::default(); - assert!(!default_config.session.resume_agents_on_restore); + assert!(default_config.session.resume_agents_on_restore); let toml = r#" [session] -resume_agents_on_restore = true +resume_agents_on_restore = false "#; let config: Config = toml::from_str(toml).unwrap(); - assert!(config.session.resume_agents_on_restore); + assert!(!config.session.resume_agents_on_restore); } #[test] diff --git a/src/main.rs b/src/main.rs index 02dabda6..c37e870a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -246,7 +246,7 @@ const DEFAULT_CONFIG: &str = r##"# herdr configuration [session] # Resume supported AI-agent panes into their native conversation sessions after # a Herdr server restart. Requires official integrations that report session refs. -# resume_agents_on_restore = false +# resume_agents_on_restore = true [remote] # Whether herdr manages the ssh config used for the `herdr --remote` bridge. diff --git a/src/pane.rs b/src/pane.rs index 94daebc0..35b2318e 100644 --- a/src/pane.rs +++ b/src/pane.rs @@ -1149,51 +1149,6 @@ impl PaneRuntime { ) } - pub fn spawn_agent_restore( - pane_id: PaneId, - rows: u16, - cols: u16, - cwd: std::path::PathBuf, - launch: crate::agent_resume::AgentResumeLaunch<'_>, - scrollback_limit_bytes: usize, - host_terminal_theme: crate::terminal_theme::TerminalTheme, - events: mpsc::Sender, - render_notify: Arc, - render_dirty: Arc, - ) -> std::io::Result { - let Some((program, args)) = launch.plan.argv.split_first() else { - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidInput, - "restore argv must not be empty", - )); - }; - - let mut cmd = CommandBuilder::new(program); - for arg in args { - cmd.arg(arg); - } - cmd.cwd(cwd); - cmd.env(crate::HERDR_ENV_VAR, crate::HERDR_ENV_VALUE); - apply_pane_terminal_env(&mut cmd); - crate::integration::apply_pane_env(&mut cmd, pane_id); - Self::spawn_command_builder( - pane_id, - rows, - cols, - scrollback_limit_bytes, - host_terminal_theme, - events, - render_notify, - render_dirty, - cmd, - "failed to spawn agent restore pane", - SpawnInitialState { - detected_agent: crate::detect::parse_agent_label(&launch.plan.agent), - history_ansi: launch.initial_history_ansi, - }, - ) - } - #[cfg(unix)] pub fn from_handoff_fd( import: crate::handoff_runtime::ImportedHandoffRuntime, @@ -2359,116 +2314,6 @@ mod tests { assert!(truncated.is_empty()); } - fn process_command_name(pid: u32) -> Option { - let output = std::process::Command::new("ps") - .args(["-p", &pid.to_string(), "-o", "comm="]) - .output() - .ok()?; - if !output.status.success() { - return None; - } - let command = String::from_utf8_lossy(&output.stdout).trim().to_string(); - (!command.is_empty()).then_some(command) - } - - async fn wait_for_child_pid(runtime: &PaneRuntime) -> u32 { - let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(2); - while tokio::time::Instant::now() < deadline { - let pid = runtime.child_pid.load(Ordering::Acquire); - if pid != 0 { - return pid; - } - tokio::time::sleep(std::time::Duration::from_millis(10)).await; - } - panic!("child pid was not published"); - } - - #[tokio::test] - async fn spawn_agent_restore_uses_restore_command_as_pane_child() { - let (events, _event_rx) = mpsc::channel(4); - let plan = crate::agent_resume::AgentResumePlan { - agent: "codex".into(), - argv: vec!["/bin/cat".into()], - dedupe_key: "test".into(), - }; - let runtime = PaneRuntime::spawn_agent_restore( - PaneId::from_raw(7), - 24, - 80, - std::env::current_dir().unwrap(), - crate::agent_resume::AgentResumeLaunch { - plan: &plan, - initial_history_ansi: None, - }, - 0, - crate::terminal_theme::TerminalTheme::default(), - events, - Arc::new(Notify::new()), - Arc::new(AtomicBool::new(false)), - ) - .unwrap(); - - let pid = wait_for_child_pid(&runtime).await; - let command = process_command_name(pid).expect("child process should be visible to ps"); - - assert!( - command.ends_with("cat"), - "restore command should be the pane child, got {command:?}" - ); - assert!( - !command.ends_with("sh"), - "restore must not keep a shell wrapper as the pane child" - ); - - runtime.shutdown(); - } - - #[tokio::test] - async fn spawn_agent_restore_reports_pane_death_after_early_failure() { - let (events, mut event_rx) = mpsc::channel(8); - let plan = crate::agent_resume::AgentResumePlan { - agent: "codex".into(), - argv: vec!["/bin/sh".into(), "-c".into(), "exit 7".into()], - dedupe_key: "test".into(), - }; - let runtime = PaneRuntime::spawn_agent_restore( - PaneId::from_raw(7), - 24, - 80, - std::env::current_dir().unwrap(), - crate::agent_resume::AgentResumeLaunch { - plan: &plan, - initial_history_ansi: None, - }, - 0, - crate::terminal_theme::TerminalTheme::default(), - events, - Arc::new(Notify::new()), - Arc::new(AtomicBool::new(false)), - ) - .unwrap(); - - let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(2); - let mut died = false; - while tokio::time::Instant::now() < deadline { - let Some(event) = tokio::time::timeout( - deadline.saturating_duration_since(tokio::time::Instant::now()), - event_rx.recv(), - ) - .await - .expect("pane death event should arrive") else { - break; - }; - if matches!(event, AppEvent::PaneDied { pane_id } if pane_id == PaneId::from_raw(7)) { - died = true; - break; - } - } - - assert!(died, "failed direct agent restore should report pane death"); - runtime.shutdown(); - } - #[tokio::test] async fn focus_events_are_forwarded_when_enabled() { let (tx, mut rx) = mpsc::channel(4); diff --git a/src/persist/restore.rs b/src/persist/restore.rs index dc48efe6..93041017 100644 --- a/src/persist/restore.rs +++ b/src/persist/restore.rs @@ -108,7 +108,7 @@ pub fn restore_handoff( 80, scrollback_limit_bytes, crate::pane::PaneShellConfig::new(default_shell, shell_mode), - false, + true, imports, events, render_notify, @@ -418,7 +418,43 @@ 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 pending_native_agent_restore = if was_imported { + None + } else { + startup.restore_plan.clone() + }; + if let Some(plan) = pending_native_agent_restore { + let terminal_id = TerminalId::alloc(); + let mut terminal = TerminalState::new(terminal_id.clone(), cwd.clone()) + .with_pending_agent_resume_plan(plan); + if let Some(label) = saved_label { + terminal.set_manual_label(label); + } + if let Some(agent_name) = saved_agent_name { + terminal.set_agent_name(agent_name); + } + if let Some(agent) = initial_restore_agent { + let _ = terminal.set_detected_state_with_screen_signals_at( + Some(agent), + AgentState::Idle, + false, + false, + false, + false, + std::time::Instant::now(), + ); + } + if let Some(session) = restored_terminal_agent_session( + saved_agent_session, + startup.duplicate_agent_session, + ) { + terminal.set_persisted_agent_session(session); + } + panes.insert(*id, PaneState::new(terminal_id)); + terminals.push(terminal); + continue; + } + let runtime_result = if let Some(imported) = imported_runtime { TerminalRuntime::from_handoff_fd( crate::handoff_runtime::ImportedHandoffRuntime { @@ -431,23 +467,6 @@ fn restore_tab( runtime_context.render_notify.clone(), runtime_context.render_dirty.clone(), ) - } else if let Some(plan) = startup.restore_plan { - let launch = crate::agent_resume::AgentResumeLaunch { - plan: &plan, - initial_history_ansi: startup.initial_history_ansi, - }; - TerminalRuntime::spawn_agent_restore( - *id, - rows, - cols, - cwd.clone(), - launch, - runtime_context.scrollback_limit_bytes, - crate::terminal_theme::TerminalTheme::default(), - runtime_context.events.clone(), - runtime_context.render_notify.clone(), - runtime_context.render_dirty.clone(), - ) } else { TerminalRuntime::spawn_with_initial_history( *id, @@ -468,9 +487,7 @@ fn restore_tab( Ok(runtime) => { let terminal_id = TerminalId::alloc(); let mut terminal = TerminalState::new(terminal_id.clone(), cwd.clone()); - if was_native_agent_restore { - terminal = terminal.with_respawn_shell_on_exit(); - } else if was_imported { + if was_imported { if let Some(argv) = saved_launch_argv { terminal = terminal.with_launch_argv(argv).with_respawn_shell_on_exit(); } @@ -753,30 +770,6 @@ fn collect_ids_inner(node: &Node, ids: &mut Vec) { #[cfg(test)] mod tests { use super::*; - use std::ffi::OsString; - - struct EnvVarGuard { - key: &'static str, - previous: Option, - } - - 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() { @@ -1081,33 +1074,8 @@ 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(); + async fn native_agent_restore_defers_runtime_launch() { 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 { @@ -1167,14 +1135,41 @@ mod tests { .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" + terminal.pending_agent_resume_plan.is_some(), + "restored native agent panes should defer resume until client terminal context is known" + ); + assert!( + !terminal.respawn_shell_on_exit, + "deferred agent resume should not use native restore lifecycle before launch" + ); + assert!( + runtimes.is_empty(), + "native agent restore should not spawn a fallback-size runtime during snapshot restore" + ); + let mut imports = HashMap::new(); + let (_handoff_workspaces, handoff_terminals, handoff_runtimes) = restore_handoff( + &snapshot, + 0, + "/bin/sh", + crate::config::ShellModeConfig::NonLogin, + &mut imports, + mpsc::channel(4).0, + Arc::new(Notify::new()), + Arc::new(AtomicBool::new(false)), + ) + .expect("handoff restore should preserve pending native agent resume"); + let handoff_terminal = handoff_terminals + .values() + .next() + .expect("handoff restore should create terminal state"); + assert!( + handoff_terminal.pending_agent_resume_plan.is_some(), + "handoff restore should preserve pending native agent resume intent" + ); + assert!( + handoff_runtimes.is_empty(), + "handoff restore should not replace pending native agent resume with a shell runtime" ); - - for runtime in runtimes.into_values() { - runtime.shutdown(); - } - let _ = std::fs::remove_dir_all(base); } #[tokio::test] diff --git a/src/server/headless.rs b/src/server/headless.rs index 2bf8fa15..b405e887 100644 --- a/src/server/headless.rs +++ b/src/server/headless.rs @@ -336,7 +336,7 @@ impl HeadlessServer { // 6. Handle scheduled tasks. let now = Instant::now(); - if self.handle_scheduled_tasks_headless(now) { + if self.handle_scheduled_tasks_headless(now, needs_render) { needs_render = true; } @@ -490,6 +490,17 @@ impl HeadlessServer { } fn resize_shared_runtime_to_effective_size(&mut self) { + self.resize_shared_runtime_to_effective_size_with_pending_agent_resumes(true); + } + + fn resize_shared_runtime_to_effective_size_before_input(&mut self) { + self.resize_shared_runtime_to_effective_size_with_pending_agent_resumes(false); + } + + fn resize_shared_runtime_to_effective_size_with_pending_agent_resumes( + &mut self, + start_pending_agent_resumes: bool, + ) { if self.foreground_client_id.is_none() { return; } @@ -522,6 +533,20 @@ impl HeadlessServer { for client in self.clients.values_mut() { client.request_full_redraw(); } + if !start_pending_agent_resumes { + self.app.pending_agent_resume_deadline = None; + return; + } + let now = Instant::now(); + self.app.sync_pending_agent_resume_deadline(now); + if self + .app + .start_pending_agent_resumes(self.app.pending_agent_resume_due(now)) + { + for client in self.clients.values_mut() { + client.request_full_redraw(); + } + } } fn sync_foreground_client_state(&mut self) { @@ -998,7 +1023,11 @@ impl HeadlessServer { } if self.foreground_client_id == Some(client_id) { - self.app.set_host_terminal_theme(client.host_terminal_theme) + let changed = self.app.set_host_terminal_theme(client.host_terminal_theme); + if changed { + self.resize_shared_runtime_to_effective_size_before_input(); + } + changed } else { false } @@ -1092,7 +1121,7 @@ impl HeadlessServer { let foreground_changed = self.promote_client_to_foreground(client_id); if foreground_changed { - self.resize_shared_runtime_to_effective_size(); + self.resize_shared_runtime_to_effective_size_before_input(); } if let Some(client) = self.clients.get_mut(&client_id) { client.request_semantic_redraw_after_input(); @@ -1650,6 +1679,8 @@ impl HeadlessServer { .state .direct_attach_resize_locks .insert(real_terminal_id.clone()); + self.app + .start_pending_agent_resume_for_terminal(&real_terminal_id, rows, cols, true); if let Some(runtime) = self.app.terminal_runtimes.get(&real_terminal_id) { runtime.resize(rows, cols, cell_size.width_px, cell_size.height_px); } @@ -1798,7 +1829,7 @@ impl HeadlessServer { false }; if foreground_changed { - self.resize_shared_runtime_to_effective_size(); + self.resize_shared_runtime_to_effective_size_before_input(); } let theme_changed = self.update_client_host_theme_from_events(client_id, &events); self.app @@ -2446,7 +2477,7 @@ impl HeadlessServer { /// /// Similar to `App::handle_scheduled_tasks` but without resize polling /// (the server doesn't have a terminal to resize). - fn handle_scheduled_tasks_headless(&mut self, now: Instant) -> bool { + fn handle_scheduled_tasks_headless(&mut self, now: Instant, geometry_dirty: bool) -> bool { let mut changed = false; self.app.sync_headless_animation_timer(now); @@ -2544,6 +2575,14 @@ impl HeadlessServer { changed = true; } + if geometry_dirty || self.foreground_client_id.is_none() { + self.app.pending_agent_resume_deadline = None; + } else { + self.app.sync_pending_agent_resume_deadline(now); + changed |= self + .app + .start_pending_agent_resumes(self.app.pending_agent_resume_due(now)); + } self.app.sync_headless_animation_timer(now); changed } @@ -3691,7 +3730,7 @@ next_tab = "" Some("short lived") ); - assert!(server.handle_scheduled_tasks_headless(deadline + Duration::from_millis(1))); + assert!(server.handle_scheduled_tasks_headless(deadline + Duration::from_millis(1), false)); assert_eq!(server.app.agent_metadata_deadline, None); assert_eq!( @@ -3721,6 +3760,188 @@ next_tab = "" })); } + #[tokio::test] + async fn headless_scheduled_tasks_do_not_start_pending_agent_resume_when_geometry_dirty() { + let mut server = test_headless_server(); + 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(); + server.app.state.view.pane_infos = workspace.tabs[0] + .layout + .panes(ratatui::layout::Rect::new(0, 0, 100, 30)); + server.app.state.workspaces = vec![workspace]; + server.app.state.active = Some(0); + server.app.state.ensure_test_terminals(); + server.clients.insert( + 1, + ClientConnection::new( + (100, 30), + crate::kitty_graphics::HostCellSize::default(), + server.app.state.host_terminal_theme, + Some(true), + 1, + RenderEncoding::SemanticFrame, + None, + ), + ); + server.foreground_client_id = Some(1); + server.effective_size = (100, 30); + server.app.state.host_terminal_theme = crate::terminal_theme::TerminalTheme { + foreground: Some(crate::terminal_theme::RgbColor { + r: 220, + g: 220, + b: 220, + }), + background: Some(crate::terminal_theme::RgbColor { + r: 20, + g: 20, + b: 20, + }), + }; + server + .app + .state + .terminals + .get_mut(&terminal_id) + .expect("test terminal should exist") + .pending_agent_resume_plan = Some(crate::agent_resume::AgentResumePlan { + agent: "codex".into(), + argv: vec!["/bin/sh".into(), "-c".into(), "sleep 5".into()], + dedupe_key: "herdr:codex\0codex\0Id\0codex-session".into(), + }); + server.app.pending_agent_resume_deadline = Some(Instant::now() - Duration::from_millis(1)); + + assert!(!server.handle_scheduled_tasks_headless(Instant::now(), true)); + assert!(server.app.terminal_runtimes.get(&terminal_id).is_none()); + assert!(server + .app + .state + .terminals + .get(&terminal_id) + .expect("test terminal should still exist") + .pending_agent_resume_plan + .is_some()); + assert!(server.app.pending_agent_resume_deadline.is_none()); + } + + #[tokio::test] + async fn headless_scheduled_tasks_do_not_start_pending_agent_resume_without_foreground_client() + { + let mut server = test_headless_server(); + 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(); + server.app.state.view.pane_infos = workspace.tabs[0] + .layout + .panes(ratatui::layout::Rect::new(0, 0, 80, 24)); + server.app.state.workspaces = vec![workspace]; + server.app.state.active = Some(0); + server.app.state.ensure_test_terminals(); + server.foreground_client_id = None; + server.effective_size = (80, 24); + server.app.state.host_terminal_theme = crate::terminal_theme::TerminalTheme { + foreground: Some(crate::terminal_theme::RgbColor { + r: 220, + g: 220, + b: 220, + }), + background: Some(crate::terminal_theme::RgbColor { + r: 20, + g: 20, + b: 20, + }), + }; + server + .app + .state + .terminals + .get_mut(&terminal_id) + .expect("test terminal should exist") + .pending_agent_resume_plan = Some(crate::agent_resume::AgentResumePlan { + agent: "codex".into(), + argv: vec!["/bin/sh".into(), "-c".into(), "sleep 5".into()], + dedupe_key: "herdr:codex\0codex\0Id\0codex-session".into(), + }); + server.app.pending_agent_resume_deadline = Some(Instant::now() - Duration::from_millis(1)); + + assert!(!server.handle_scheduled_tasks_headless(Instant::now(), false)); + assert!(server.app.terminal_runtimes.get(&terminal_id).is_none()); + assert!(server + .app + .state + .terminals + .get(&terminal_id) + .expect("test terminal should still exist") + .pending_agent_resume_plan + .is_some()); + assert!(server.app.pending_agent_resume_deadline.is_none()); + } + + #[tokio::test] + async fn headless_pre_input_resize_does_not_start_pending_agent_resume() { + let mut server = test_headless_server(); + 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(); + server.app.state.view.pane_infos = workspace.tabs[0] + .layout + .panes(ratatui::layout::Rect::new(0, 0, 100, 30)); + server.app.state.workspaces = vec![workspace]; + server.app.state.active = Some(0); + server.app.state.ensure_test_terminals(); + server.clients.insert( + 1, + ClientConnection::new( + (100, 30), + crate::kitty_graphics::HostCellSize::default(), + server.app.state.host_terminal_theme, + Some(true), + 1, + RenderEncoding::SemanticFrame, + None, + ), + ); + server.foreground_client_id = Some(1); + server.effective_size = (100, 30); + server.app.state.host_terminal_theme = crate::terminal_theme::TerminalTheme { + foreground: Some(crate::terminal_theme::RgbColor { + r: 220, + g: 220, + b: 220, + }), + background: Some(crate::terminal_theme::RgbColor { + r: 20, + g: 20, + b: 20, + }), + }; + server + .app + .state + .terminals + .get_mut(&terminal_id) + .expect("test terminal should exist") + .pending_agent_resume_plan = Some(crate::agent_resume::AgentResumePlan { + agent: "codex".into(), + argv: vec!["/bin/sh".into(), "-c".into(), "sleep 5".into()], + dedupe_key: "herdr:codex\0codex\0Id\0codex-session".into(), + }); + server.app.pending_agent_resume_deadline = Some(Instant::now() - Duration::from_millis(1)); + + server.resize_shared_runtime_to_effective_size_before_input(); + + assert!(server.app.terminal_runtimes.get(&terminal_id).is_none()); + assert!(server + .app + .state + .terminals + .get(&terminal_id) + .expect("test terminal should still exist") + .pending_agent_resume_plan + .is_some()); + assert!(server.app.pending_agent_resume_deadline.is_none()); + } + #[test] fn virtual_render_produces_nonempty_buffer() { let mut state = AppState::test_new(); diff --git a/src/terminal/runtime.rs b/src/terminal/runtime.rs index c7bb8810..0fefe037 100644 --- a/src/terminal/runtime.rs +++ b/src/terminal/runtime.rs @@ -189,33 +189,6 @@ impl TerminalRuntime { .map(Self) } - pub fn spawn_agent_restore( - pane_id: PaneId, - rows: u16, - cols: u16, - cwd: std::path::PathBuf, - launch: crate::agent_resume::AgentResumeLaunch<'_>, - scrollback_limit_bytes: usize, - host_terminal_theme: crate::terminal_theme::TerminalTheme, - events: mpsc::Sender, - render_notify: Arc, - render_dirty: Arc, - ) -> std::io::Result { - crate::pane::PaneRuntime::spawn_agent_restore( - pane_id, - rows, - cols, - cwd, - launch, - scrollback_limit_bytes, - host_terminal_theme, - events, - render_notify, - render_dirty, - ) - .map(Self) - } - pub fn apply_host_terminal_theme(&self, theme: crate::terminal_theme::TerminalTheme) { self.0.apply_host_terminal_theme(theme); } diff --git a/src/terminal/state.rs b/src/terminal/state.rs index ac7c4380..09c5812a 100644 --- a/src/terminal/state.rs +++ b/src/terminal/state.rs @@ -73,6 +73,7 @@ pub struct TerminalState { pub revision: u64, pub launch_argv: Option>, pub respawn_shell_on_exit: bool, + pub pending_agent_resume_plan: Option, } impl TerminalState { @@ -98,6 +99,7 @@ impl TerminalState { revision: 0, launch_argv: None, respawn_shell_on_exit: false, + pending_agent_resume_plan: None, } } @@ -111,6 +113,14 @@ impl TerminalState { self } + pub fn with_pending_agent_resume_plan( + mut self, + plan: crate::agent_resume::AgentResumePlan, + ) -> Self { + self.pending_agent_resume_plan = Some(plan); + self + } + #[cfg(test)] pub fn set_detected_state( &mut self, @@ -667,8 +677,10 @@ impl TerminalState { self.hook_authority = None; self.persisted_agent_session = None; self.agent_metadata.clear(); + self.state = AgentState::Unknown; self.launch_argv = None; self.respawn_shell_on_exit = false; + self.pending_agent_resume_plan = None; self.clear_agent_name(); } @@ -1865,6 +1877,27 @@ mod tests { assert!(terminal.persisted_agent_session.is_none()); } + #[test] + fn respawn_cleanup_resets_restored_agent_status() { + let mut terminal = test_terminal(); + 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").unwrap(), + }); + terminal.set_detected_state(Some(Agent::Codex), AgentState::Idle); + + terminal.clear_agent_runtime_identity_after_respawn(); + + assert_eq!(terminal.state, AgentState::Unknown); + assert!(terminal.detected_agent.is_none()); + assert!(terminal.agent_name.is_none()); + assert!(terminal.persisted_agent_session.is_none()); + assert!(!terminal.respawn_shell_on_exit); + } + #[test] fn detected_conflict_clears_session_ref() { let mut terminal = test_terminal(); -- cgit v1.2.3