diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-25 15:16:15 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-25 15:16:15 +0300 |
| commit | a1543f3e248f8f5266322014ccd853e478b1dcd6 (patch) | |
| tree | f5f9f557b6051014b5772fc170edfa8129875ce0 | |
| parent | 420925d141c8805e610eb0d62d49d8b9b483d961 (diff) | |
fix: avoid pane history replay during agent restore
| -rw-r--r-- | docs/next/README.md | 2 | ||||
| -rw-r--r-- | docs/next/website/src/content/docs/persistence-remote.mdx | 2 | ||||
| -rw-r--r-- | src/persist/restore.rs | 174 |
3 files changed, 153 insertions, 25 deletions
diff --git a/docs/next/README.md b/docs/next/README.md index d2fcb7e1..28f089d6 100644 --- a/docs/next/README.md +++ b/docs/next/README.md @@ -81,7 +81,7 @@ tmux gives you persistence and panes, but it was built before agents existed. gu start herdr where the work lives. locally, run `herdr`. it starts or attaches to the background session automatically, with no socket setup. run your agents, split panes, do your work. press `ctrl+b q` to detach. close your terminal, close your laptop; your agents keep running. open a new terminal, run `herdr`, you're back. same session, same panes, same agents. if you stop the server and later start herdr again, restored panes bring back workspaces, tabs, cwd, layout, and focus. -pane screen history is off by default because pane output can include secrets, tokens, prompts, and command output. enable it with `[experimental] pane_history = true` or settings > experiments > pane screen history. when enabled, herdr writes saved pane history to `session-history.json` next to `session.json`; treat the herdr config/session directory like terminal history. +pane screen history is off by default because pane output can include secrets, tokens, prompts, and command output. enable it with `[experimental] pane_history = true` or settings > experiments > pane screen history. when enabled, herdr writes saved pane history to `session-history.json` next to `session.json`; treat the herdr config/session directory like terminal history. if native agent session restore is enabled for a pane, herdr resumes that agent session instead of replaying saved pane history. ### from anywhere diff --git a/docs/next/website/src/content/docs/persistence-remote.mdx b/docs/next/website/src/content/docs/persistence-remote.mdx index ad83e2f6..4acf0352 100644 --- a/docs/next/website/src/content/docs/persistence-remote.mdx +++ b/docs/next/website/src/content/docs/persistence-remote.mdx @@ -31,7 +31,7 @@ herdr server stop When Herdr starts again, it restores the saved workspaces, tabs, panes, cwd, layout, and focus. -Pane screen history is off by default because pane output can include secrets, tokens, prompts, and command output. Enable it from Settings > Experiments > pane screen history or with `[experimental] pane_history = true`. Herdr stores saved pane history in `session-history.json` next to `session.json`; treat the Herdr config/session directory like terminal history. +Pane screen history is off by default because pane output can include secrets, tokens, prompts, and command output. Enable it from Settings > Experiments > pane screen history or with `[experimental] pane_history = true`. Herdr stores saved pane history in `session-history.json` next to `session.json`; treat the Herdr config/session directory like terminal history. When native agent session restore is enabled and a pane has a resumable agent session reference, Herdr resumes the agent session instead of replaying saved pane history for that pane. ## Named sessions diff --git a/src/persist/restore.rs b/src/persist/restore.rs index 26446172..03bbf4e3 100644 --- a/src/persist/restore.rs +++ b/src/persist/restore.rs @@ -14,7 +14,9 @@ use crate::pane::PaneState; use crate::terminal::{TerminalId, TerminalRuntime, TerminalState}; use crate::workspace::Workspace; -use super::snapshot::{TabHistorySnapshot, WorkspaceHistorySnapshot}; +use super::snapshot::{ + PaneAgentSessionSnapshot, PaneHistorySnapshot, TabHistorySnapshot, WorkspaceHistorySnapshot, +}; use super::{ DirectionSnapshot, LayoutSnapshot, SessionHistorySnapshot, SessionSnapshot, TabSnapshot, WorkspaceSnapshot, @@ -25,6 +27,13 @@ struct AgentRestoreState<'a> { resumed_sessions: &'a mut HashSet<String>, } +struct PaneRestoreStartup<'a> { + restore_plan: Option<crate::agent_resume::AgentResumePlan>, + initial_history_ansi: Option<&'a str>, + duplicate_agent_session: bool, + reserved_agent_session: Option<String>, +} + /// Restore workspaces from a snapshot. Each pane gets a fresh shell in its saved cwd. pub fn restore( snapshot: &SessionSnapshot, @@ -216,25 +225,16 @@ fn restore_tab( let saved_agent_session = saved_pane.and_then(|p| p.agent_session.as_ref()); let saved_history = old_id.and_then(|old_id| history.and_then(|history| history.panes.get(old_id))); - let initial_history_ansi = saved_history.map(|history| history.ansi.as_str()); - let mut restore_plan = saved_agent_session - .and_then(|session| restore_plan_for_snapshot(session, agent_restore.enabled)); - let duplicate_agent_session = restore_plan.as_ref().is_some_and(|plan| { - !agent_restore - .resumed_sessions - .insert(plan.dedupe_key.clone()) - }); - if duplicate_agent_session { - restore_plan = None; - } - let initial_restore_agent = restore_plan + let startup = pane_restore_startup(saved_agent_session, saved_history, agent_restore); + let initial_restore_agent = startup + .restore_plan .as_ref() .and_then(|plan| crate::detect::parse_agent_label(&plan.agent)); - let runtime_result = if let Some(plan) = restore_plan { + let runtime_result = if let Some(plan) = startup.restore_plan { let launch = crate::agent_resume::AgentResumeLaunch { plan: &plan, - initial_history_ansi, + initial_history_ansi: startup.initial_history_ansi, }; TerminalRuntime::spawn_agent_restore( *id, @@ -258,7 +258,7 @@ fn restore_tab( scrollback_limit_bytes, crate::terminal_theme::TerminalTheme::default(), default_shell, - initial_history_ansi, + startup.initial_history_ansi, events.clone(), render_notify.clone(), render_dirty.clone(), @@ -286,9 +286,10 @@ fn restore_tab( std::time::Instant::now(), ); } - if let Some(session) = - restored_terminal_agent_session(saved_agent_session, duplicate_agent_session) - { + 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.clone())); @@ -296,6 +297,9 @@ fn restore_tab( terminals.push(terminal); } Err(e) => { + if let Some(key) = startup.reserved_agent_session.as_deref() { + agent_restore.resumed_sessions.remove(key); + } error!( tab = ?snap.custom_name, pane_id = id.raw(), @@ -346,8 +350,53 @@ fn restore_tab( )) } +fn pane_restore_startup<'a>( + session: Option<&PaneAgentSessionSnapshot>, + history: Option<&'a PaneHistorySnapshot>, + agent_restore: &mut AgentRestoreState<'_>, +) -> PaneRestoreStartup<'a> { + // Native agent resume owns the conversation history. If a pane has a + // resumable agent session and resume is enabled, do not replay saved pane + // presentation history into that terminal, even when this pane is a + // duplicate suppressed by session de-duplication. + let restore_plan = + session.and_then(|session| restore_plan_for_snapshot(session, agent_restore.enabled)); + let has_native_agent_restore = restore_plan.is_some(); + // Reserve before spawning so later panes in the same restore pass cannot + // launch the same native agent session. The caller rolls this reservation + // back if runtime spawn fails before any agent process is started. + let mut reserved_agent_session = None; + let duplicate_agent_session = restore_plan.as_ref().is_some_and(|plan| { + if agent_restore + .resumed_sessions + .insert(plan.dedupe_key.clone()) + { + reserved_agent_session = Some(plan.dedupe_key.clone()); + false + } else { + true + } + }); + let restore_plan = if duplicate_agent_session { + None + } else { + restore_plan + }; + + PaneRestoreStartup { + restore_plan, + initial_history_ansi: if has_native_agent_restore { + None + } else { + history.map(|history| history.ansi.as_str()) + }, + duplicate_agent_session, + reserved_agent_session, + } +} + fn restore_plan_for_snapshot( - session: &super::snapshot::PaneAgentSessionSnapshot, + session: &PaneAgentSessionSnapshot, resume_agents_on_restore: bool, ) -> Option<crate::agent_resume::AgentResumePlan> { if !resume_agents_on_restore { @@ -358,7 +407,7 @@ fn restore_plan_for_snapshot( } fn persisted_agent_session_from_snapshot( - session: &super::snapshot::PaneAgentSessionSnapshot, + session: &PaneAgentSessionSnapshot, ) -> Option<crate::agent_resume::PersistedAgentSession> { crate::agent_resume::session_ref_from_snapshot( &session.source, @@ -369,7 +418,7 @@ fn persisted_agent_session_from_snapshot( } fn restored_terminal_agent_session( - session: Option<&super::snapshot::PaneAgentSessionSnapshot>, + session: Option<&PaneAgentSessionSnapshot>, duplicate_agent_session: bool, ) -> Option<crate::agent_resume::PersistedAgentSession> { if duplicate_agent_session { @@ -380,7 +429,7 @@ fn restored_terminal_agent_session( #[cfg(test)] fn take_restore_plan_for_snapshot( - session: &super::snapshot::PaneAgentSessionSnapshot, + session: &PaneAgentSessionSnapshot, resume_agents_on_restore: bool, resumed_agent_sessions: &mut HashSet<String>, ) -> Option<crate::agent_resume::AgentResumePlan> { @@ -600,6 +649,85 @@ mod tests { } #[test] + fn pane_restore_startup_suppresses_history_for_native_agent_resume() { + let session = super::super::snapshot::PaneAgentSessionSnapshot { + source: "herdr:pi".into(), + agent: "pi".into(), + kind: crate::agent_resume::AgentSessionRefKind::Path, + value: "/tmp/pi-session.jsonl".into(), + }; + let history = super::super::snapshot::PaneHistorySnapshot { + ansi: "RESTORED_HISTORY\r\n".into(), + lines: 1, + }; + let mut resumed = HashSet::new(); + let mut agent_restore = AgentRestoreState { + enabled: true, + resumed_sessions: &mut resumed, + }; + + let startup = pane_restore_startup(Some(&session), Some(&history), &mut agent_restore); + + assert!(startup.restore_plan.is_some()); + assert!(startup.initial_history_ansi.is_none()); + assert!(!startup.duplicate_agent_session); + } + + #[test] + fn pane_restore_startup_suppresses_history_for_duplicate_native_agent_session() { + let session = super::super::snapshot::PaneAgentSessionSnapshot { + source: "herdr:pi".into(), + agent: "pi".into(), + kind: crate::agent_resume::AgentSessionRefKind::Path, + value: "/tmp/pi-session.jsonl".into(), + }; + let history = super::super::snapshot::PaneHistorySnapshot { + ansi: "RESTORED_HISTORY\r\n".into(), + lines: 1, + }; + let mut resumed = HashSet::new(); + let mut agent_restore = AgentRestoreState { + enabled: true, + resumed_sessions: &mut resumed, + }; + + let first = pane_restore_startup(Some(&session), Some(&history), &mut agent_restore); + let duplicate = pane_restore_startup(Some(&session), Some(&history), &mut agent_restore); + + assert!(first.restore_plan.is_some()); + assert!(first.initial_history_ansi.is_none()); + assert!(duplicate.restore_plan.is_none()); + assert!(duplicate.initial_history_ansi.is_none()); + assert!(duplicate.duplicate_agent_session); + } + + #[test] + fn pane_restore_startup_keeps_history_without_native_agent_resume() { + let session = super::super::snapshot::PaneAgentSessionSnapshot { + source: "herdr:pi".into(), + agent: "pi".into(), + kind: crate::agent_resume::AgentSessionRefKind::Path, + value: "/tmp/pi-session.jsonl".into(), + }; + let history = super::super::snapshot::PaneHistorySnapshot { + ansi: "RESTORED_HISTORY\r\n".into(), + lines: 1, + }; + let mut resumed = HashSet::new(); + let mut agent_restore = AgentRestoreState { + enabled: false, + resumed_sessions: &mut resumed, + }; + + let startup = pane_restore_startup(Some(&session), Some(&history), &mut agent_restore); + + assert!(startup.restore_plan.is_none()); + assert_eq!(startup.initial_history_ansi, Some("RESTORED_HISTORY\r\n")); + assert!(!startup.duplicate_agent_session); + assert!(resumed.is_empty()); + } + + #[test] fn restore_rehydrates_agent_session_metadata() { let session = super::super::snapshot::PaneAgentSessionSnapshot { source: "herdr:hermes".into(), |
