mirror of
https://github.com/herdrdev/herdr.git
synced 2026-11-06 08:06:30 +00:00
Merge branch 'master' into fix/codex-completion-popup
This commit is contained in:
@@ -147,6 +147,8 @@ To opt into experimental live server handoff for supported running sessions, run
|
||||
herdr update --handoff
|
||||
```
|
||||
|
||||
When both servers support agent-status transfer, live handoff preserves hook-reported agent status until the next report arrives. Handoff from an older server can temporarily show a running agent as idle.
|
||||
|
||||
Live handoff does not apply to Homebrew, mise, or Nix package-manager updates. For those installs, update with the package manager, then run Herdr again to reconnect with the updated client. The compatible old server keeps running. Restart it later with `herdr server stop` or `herdr session stop <name>` only when you want server-side changes from the new release.
|
||||
|
||||
## Requirements
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
@@ -1708,6 +1716,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();
|
||||
|
||||
@@ -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
|
||||
|
||||
+35
-1
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user