aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-09-22 18:31:37 +0300
committerGitHub <noreply@github.com>2026-09-22 18:31:37 +0300
commit90b0e40ab31bbdde8803f17d0140c6f4b436694a (patch)
tree057467a09b53dc23bbeb246c23804d8d32d44895 /src
parentf6390752a57a5ac3256f3213af870c0b62e224d7 (diff)
fix: preserve hook agent status across live handoff (#4493)
Diffstat (limited to 'src')
-rw-r--r--src/agent_resume.rs2
-rw-r--r--src/detect/mod.rs2
-rw-r--r--src/handoff_runtime.rs2
-rw-r--r--src/pane.rs2
-rw-r--r--src/persist/restore.rs110
-rw-r--r--src/server/headless/lifecycle.rs6
-rw-r--r--src/terminal/state.rs36
7 files changed, 157 insertions, 3 deletions
diff --git a/src/agent_resume.rs b/src/agent_resume.rs
index 04e6cf63..56554dd3 100644
--- a/src/agent_resume.rs
+++ b/src/agent_resume.rs
@@ -5,7 +5,7 @@ use serde::{Deserialize, Serialize};
const MAX_SESSION_ID_LEN: usize = 512;
const MAX_SESSION_PATH_LEN: usize = 4096;
-#[derive(Debug, Clone, PartialEq, Eq)]
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AgentSessionRef {
pub kind: AgentSessionRefKind,
pub value: String,
diff --git a/src/detect/mod.rs b/src/detect/mod.rs
index 4ed32344..7fe15d11 100644
--- a/src/detect/mod.rs
+++ b/src/detect/mod.rs
@@ -7,7 +7,7 @@ pub mod manifest;
pub mod manifest_update;
/// The detected state of a terminal pane.
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum AgentState {
/// Agent finished, prompt visible, nothing happening.
Idle,
diff --git a/src/handoff_runtime.rs b/src/handoff_runtime.rs
index 29ba85f4..12481f6d 100644
--- a/src/handoff_runtime.rs
+++ b/src/handoff_runtime.rs
@@ -27,6 +27,8 @@ pub(crate) struct HandoffRuntimeState {
pub terminal_title: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub initial_history_ansi: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub agent_state: Option<crate::terminal::state::HandoffAgentState>,
}
#[cfg(unix)]
diff --git a/src/pane.rs b/src/pane.rs
index 9d75c21c..118dd3a4 100644
--- a/src/pane.rs
+++ b/src/pane.rs
@@ -2088,6 +2088,7 @@ impl PaneRuntime {
input_state: self.input_state(),
terminal_title: self.terminal_title(),
initial_history_ansi: None,
+ agent_state: None,
}
}
@@ -2298,6 +2299,7 @@ impl PaneRuntime {
input_state,
terminal_title,
initial_history_ansi,
+ agent_state: _,
} = state;
let pane_id = PaneId::from_raw(pane_id);
use std::os::fd::FromRawFd;
diff --git a/src/persist/restore.rs b/src/persist/restore.rs
index 31ee0a37..265b4874 100644
--- a/src/persist/restore.rs
+++ b/src/persist/restore.rs
@@ -561,6 +561,10 @@ fn restore_tab(
.unwrap_or_default();
let imported_runtime = old_pane_id.and_then(|old_id| imported_panes.remove(&old_id));
let was_imported = imported_runtime.is_some();
+ #[cfg(unix)]
+ let handoff_agent_state = imported_runtime
+ .as_ref()
+ .and_then(|imported| imported.state.agent_state.clone());
let pending_native_agent_restore = if was_imported {
None
} else {
@@ -693,6 +697,10 @@ fn restore_tab(
std::time::Instant::now(),
);
}
+ #[cfg(unix)]
+ if let Some(agent_state) = handoff_agent_state {
+ terminal.restore_handoff_agent_state(agent_state);
+ }
panes.insert(*id, PaneState::new(terminal_id.clone()));
terminal_runtimes.insert(terminal_id, runtime);
terminals.push(terminal);
@@ -1709,6 +1717,108 @@ mod tests {
}
#[tokio::test]
+ #[cfg(unix)]
+ async fn live_handoff_preserves_hook_status_until_next_report() {
+ for state_before_handoff in [AgentState::Working, AgentState::Blocked] {
+ let (snapshot, _) = snapshot_with_saved_pane_history();
+ let (events, _events_rx) = mpsc::channel(32);
+ let (workspaces, mut terminals, runtimes) = restore(
+ &snapshot,
+ None,
+ 24,
+ 80,
+ 4096,
+ test_restore_shell(),
+ crate::config::ShellModeConfig::NonLogin,
+ false,
+ events.clone(),
+ Arc::new(Notify::new()),
+ Arc::new(RenderSignal::new()),
+ );
+ let terminal = terminals.values_mut().next().unwrap();
+ terminal
+ .set_detected_agent_process_at(crate::detect::Agent::Pi, std::time::Instant::now());
+ terminal.set_persisted_agent_session(crate::agent_resume::PersistedAgentSession {
+ source: "herdr:pi".into(),
+ agent: "pi".into(),
+ session_ref: crate::agent_resume::AgentSessionRef::path(
+ "/var/tmp/handoff-test.jsonl",
+ )
+ .unwrap(),
+ });
+ terminal.set_hook_authority_with_session_ref(
+ "herdr:pi".into(),
+ "pi".into(),
+ state_before_handoff,
+ None,
+ Some(
+ crate::agent_resume::AgentSessionRef::path("/var/tmp/handoff-test.jsonl")
+ .unwrap(),
+ ),
+ Some(1),
+ );
+ assert_eq!(terminal.state, state_before_handoff);
+ let runtimes = crate::terminal::TerminalRuntimeRegistry::from(runtimes);
+ let snapshot = crate::persist::capture(&workspaces, &terminals, &runtimes, Some(0), 0);
+ let pane_id = workspaces[0].tabs[0].panes.keys().next().copied().unwrap();
+ let runtime = runtimes.values().next().unwrap();
+ runtime
+ .pause_handoff_reader(std::time::Duration::from_secs(2))
+ .unwrap();
+ let mut state = runtime.handoff_runtime_state(pane_id.raw());
+ state.agent_state = terminals.values().next().unwrap().handoff_agent_state();
+ let state = serde_json::from_value(serde_json::to_value(state).unwrap()).unwrap();
+ let mut imports = HashMap::from([(
+ pane_id.raw(),
+ crate::handoff_runtime::ImportedHandoffRuntime {
+ master_fd: runtime.duplicate_handoff_fd().unwrap(),
+ state,
+ },
+ )]);
+ let (_, mut restored_terminals, restored_runtimes) = restore_handoff(
+ &snapshot,
+ 4096,
+ test_restore_shell(),
+ crate::config::ShellModeConfig::NonLogin,
+ &mut imports,
+ events,
+ Arc::new(Notify::new()),
+ Arc::new(RenderSignal::new()),
+ )
+ .unwrap();
+ drop(restored_runtimes);
+ drop(runtimes);
+ let terminal = restored_terminals.values_mut().next().unwrap();
+ assert_eq!(terminal.state, state_before_handoff);
+ terminal.set_detected_state(Some(crate::detect::Agent::Pi), AgentState::Idle);
+ assert_eq!(
+ terminal.state, state_before_handoff,
+ "screen fallback must not erase the transferred hook status"
+ );
+ terminal.set_hook_authority_with_session_ref(
+ "herdr:pi".into(),
+ "pi".into(),
+ AgentState::Idle,
+ None,
+ Some(
+ crate::agent_resume::AgentSessionRef::path("/var/tmp/handoff-test.jsonl")
+ .unwrap(),
+ ),
+ Some(2),
+ );
+ assert_eq!(
+ terminal.state,
+ AgentState::Idle,
+ "the next hook report must take effect immediately"
+ );
+ assert_eq!(
+ terminal.finish_agent_process_acquisition(),
+ state_before_handoff == AgentState::Blocked
+ );
+ }
+ }
+
+ #[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/server/headless/lifecycle.rs b/src/server/headless/lifecycle.rs
index f7ad87dd..c700a9d9 100644
--- a/src/server/headless/lifecycle.rs
+++ b/src/server/headless/lifecycle.rs
@@ -83,6 +83,12 @@ impl HeadlessServer {
continue;
};
let mut handoff_runtime = runtime.handoff_runtime_state(pane_id);
+ handoff_runtime.agent_state = self
+ .app
+ .state
+ .terminals
+ .get(terminal_id)
+ .and_then(|terminal| terminal.handoff_agent_state());
let has_agent_session = self
.app
.state
diff --git a/src/terminal/state.rs b/src/terminal/state.rs
index 119916c8..fadd6e10 100644
--- a/src/terminal/state.rs
+++ b/src/terminal/state.rs
@@ -14,16 +14,25 @@ use crate::terminal::TerminalId;
mod metadata;
pub use metadata::{AgentMetadata, AgentMetadataReport, EffectivePresentation};
-#[derive(Debug, Clone, PartialEq, Eq)]
+#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct HookAuthority {
pub source: String,
pub agent_label: String,
pub state: AgentState,
pub message: Option<String>,
+ #[serde(skip, default = "Instant::now")]
pub reported_at: Instant,
pub session_ref: Option<crate::agent_resume::AgentSessionRef>,
}
+#[cfg(unix)]
+#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
+pub(crate) struct HandoffAgentState {
+ authority: HookAuthority,
+ sequence: Option<u64>,
+ acquisition_pending: bool,
+}
+
#[derive(Debug, Clone, PartialEq, Eq)]
struct SuppressedFullLifecycleHookReport {
agent_label: String,
@@ -213,6 +222,31 @@ impl TerminalState {
mutation
}
+ #[cfg(unix)]
+ pub(crate) fn handoff_agent_state(&self) -> Option<HandoffAgentState> {
+ if !self.live_full_lifecycle_hook_authority() {
+ return None;
+ }
+ let authority = self.hook_authority.as_ref()?;
+ Some(HandoffAgentState {
+ authority: authority.clone(),
+ sequence: self.hook_report_sequences.get(&authority.source).copied(),
+ acquisition_pending: self.agent_process_acquisition_pending,
+ })
+ }
+
+ #[cfg(unix)]
+ pub(crate) fn restore_handoff_agent_state(&mut self, snapshot: HandoffAgentState) {
+ if let Some(sequence) = snapshot.sequence {
+ self.hook_report_sequences
+ .insert(snapshot.authority.source.clone(), sequence);
+ }
+ self.detected_agent = crate::detect::parse_agent_label(&snapshot.authority.agent_label);
+ self.state = snapshot.authority.state;
+ self.hook_authority = Some(snapshot.authority);
+ self.agent_process_acquisition_pending = snapshot.acquisition_pending;
+ }
+
pub(crate) fn finish_agent_process_acquisition(&mut self) -> bool {
let reached_idle = self.agent_process_acquisition_pending && self.state == AgentState::Idle;
let suppress_completion = reached_idle && self.recent_agent_process_exit.is_none();