Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/next/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
- Custom themes can now define separate light and dark color overrides when automatic theme switching is enabled. (#837, thanks @aneym)

### Fixed
- Panes working inside an activated conda environment or virtualenv now come back into it after a session restore, including when an agent is resumed into the pane. (#2367)
- Running named servers now activate remote agent-detection manifests downloaded by another server, preventing stale agent states and `agent explain` output until restart. (#2711)
- New lifecycle event subscriptions now stream only events emitted after subscription begins instead of replaying retained history. (#1270)
- Windows users whose endpoint security blocks the fileless PowerShell install command can now use a local `install.cmd` bootstrap; installer downloads use `curl.exe` while preserving package checksum verification. (#2751)
Expand Down
8 changes: 8 additions & 0 deletions src/app/agent_resume.rs
Original file line number Diff line number Diff line change
Expand Up @@ -226,9 +226,17 @@ impl App {
);
return false;
};
// Resume runs the agent from a freshly spawned shell, so it needs the
// pane's interpreter environment for the same reason restore does.
let virtual_env = self
.state
.terminals
.get(&terminal_id)
.and_then(|terminal| terminal.virtual_env.clone());
let Some(launch_env) = self
.find_pane(pane_id)
.and_then(|(ws_idx, _)| self.pane_launch_env(ws_idx, pane_id, Vec::new()))
.map(|launch_env| launch_env.with_virtual_env(virtual_env))
else {
return false;
};
Expand Down
85 changes: 85 additions & 0 deletions src/pane.rs
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ fn apply_pane_terminal_env(cmd: &mut CommandBuilder) {
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct PaneLaunchEnv {
extra: Vec<(String, String)>,
virtual_env: Option<crate::platform::VirtualEnvActivation>,
identity: PaneLaunchIdentity,
}

Expand All @@ -103,10 +104,24 @@ impl PaneLaunchEnv {
pub(crate) fn from_extra(extra: Vec<(String, String)>) -> Self {
Self {
extra,
virtual_env: None,
identity: PaneLaunchIdentity::Inherit,
}
}

/// Re-enter an interpreter environment in the spawned process.
///
/// The activation is kept whole rather than expanded into `extra` here
/// because its `PATH` has to be built from the `PATH` the process actually
/// ends up with, which is not known until every other override is applied.
pub(crate) fn with_virtual_env(
mut self,
virtual_env: Option<crate::platform::VirtualEnvActivation>,
) -> Self {
self.virtual_env = virtual_env;
self
}

pub(crate) fn with_identity(
mut self,
workspace_id: String,
Expand All @@ -132,6 +147,15 @@ fn apply_pane_launch_env(cmd: &mut CommandBuilder, launch_env: &PaneLaunchEnv) {
for (key, value) in &launch_env.extra {
cmd.env(key, value);
}
if let Some(activation) = &launch_env.virtual_env {
// The builder starts from the server's environment, so this reads the
// PATH the pane would otherwise launch with, including anything set
// above. The activation goes in front of that value rather than
// replacing it.
for (key, value) in activation.launch_env(cmd.get_env("PATH")) {
cmd.env(key, value);
}
}
cmd.env(crate::HERDR_ENV_VAR, crate::HERDR_ENV_VALUE);
crate::integration::apply_pane_base_env(cmd);
crate::platform::apply_pane_runtime_marker(cmd);
Expand Down Expand Up @@ -3011,6 +3035,32 @@ impl PaneRuntime {
None
}
}

/// Interpreter environment the pane is currently working in, if any.
///
/// A shell mutates its own environment in place when it activates one, and
/// that mutation is not visible from outside the process. What is visible
/// is the environment a process was launched with, so this reads the
/// foreground process group leader — the command the shell started, which
/// carries any activation that was in effect when it began.
///
/// Session saves call this for every pane, so it stays at one process
/// lookup plus one environment read. In particular it does not ask the PTY
/// actor for the foreground group: that is a round trip which blocks while
/// the reader is paused for a handoff.
pub fn foreground_virtual_env(&self) -> Option<crate::platform::VirtualEnvActivation> {
let pid = self.child_pid.load(Ordering::Acquire);
if pid == 0 {
return None;
}
let foreground = crate::platform::foreground_process_group_id(pid)?;
// An idle prompt leaves the shell itself in the foreground, and the
// shell's own activation is the part that cannot be read.
if foreground == pid {
return None;
Comment on lines +3056 to +3060

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Preserve activation at an idle shell prompt.

Line 3059 returns None when the shell is foreground. After a user activates Conda or a venv and returns to the prompt, normal session capture takes this branch. src/workspace/tab.rs:598 then receives no activation, so the restored pane loses the environment.

Record an activation state that remains available at the prompt, such as through shell integration, instead of treating an idle shell as having no activation.

}
crate::platform::process_virtual_env(foreground)
}
}

#[cfg(test)]
Expand Down Expand Up @@ -3092,6 +3142,41 @@ impl PaneRuntime {
mod tests {
use super::*;

#[test]
fn pane_launch_env_builds_the_activation_path_on_the_effective_launch_path() {
// Anything that sets PATH ahead of the activation has to survive it,
// so the activation is composed against the value the pane would have
// launched with rather than against the server's own PATH.
let mut cmd = CommandBuilder::new("shell");
let activation = crate::platform::VirtualEnvActivation {
kind: crate::platform::VirtualEnvKind::Conda,
prefix: "/opt/conda/envs/web".into(),
name: Some("web".to_string()),
};
let inherited = std::path::PathBuf::from("/refreshed/bin");
let launch_env = PaneLaunchEnv::from_extra(vec![(
"PATH".to_string(),
inherited.to_string_lossy().into_owned(),
)])
.with_virtual_env(Some(activation.clone()));

apply_pane_launch_env(&mut cmd, &launch_env);

// The directory list is the host's, so it is taken from the activation
// rather than spelled out; what this pins is the order.
let expected = activation
.path_entries()
.into_iter()
.chain(std::iter::once(inherited))
.collect::<Vec<_>>();
let path = cmd.get_env("PATH").expect("expected PATH");
assert_eq!(std::env::split_paths(path).collect::<Vec<_>>(), expected);
assert_eq!(
cmd.get_env("CONDA_PREFIX"),
Some(std::ffi::OsStr::new("/opt/conda/envs/web"))
);
}
Comment on lines +3145 to +3178

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Gate this Unix-specific test.

On Windows, Conda activation adds the prefix and five Windows-specific directories. This assertion expects only <prefix>/bin and /refreshed/bin. Windows test runs will fail.

Add #[cfg(not(windows))] to this test, or add a Windows-specific expected path list.

As per coding guidelines: “Rust platform-specific code must be compile-gated.”

Source: Coding guidelines


#[test]
fn pane_launch_env_removes_outer_codex_thread_id() {
let mut cmd = CommandBuilder::new("shell");
Expand Down
126 changes: 124 additions & 2 deletions src/persist/restore.rs
Original file line number Diff line number Diff line change
Expand Up @@ -517,15 +517,36 @@ fn restore_tab(
let public_pane_id = old_pane_id
.and_then(|old_id| public_pane_ids_by_old_raw.get(&old_id))
.map(String::as_str);
// The pane comes back from a shell that never activated anything, so
// re-enter the interpreter environment it was working in before.
//
// An environment that has since been removed is dropped rather than
// re-entered. Pointing PATH at a missing directory while also telling
// conda not to fall back to base would leave the pane with no working
// interpreter, which is worse than restoring without one.
let restored_virtual_env = saved_pane
.and_then(|pane| pane.virtual_env.as_ref())
.and_then(|snapshot| snapshot.to_activation())
.filter(|activation| {
let exists = activation.prefix.is_dir();
if !exists {
warn!(
prefix = %activation.prefix.display(),
"saved pane environment is gone; restoring without it"
);
}
exists
});
let launch_env = public_pane_id
.map(|pane_id| {
PaneLaunchEnv::from_extra(Vec::new()).with_identity(
PaneLaunchEnv::default().with_identity(
workspace_id.to_string(),
crate::workspace::public_tab_id_for_number(workspace_id, number),
pane_id.to_string(),
)
})
.unwrap_or_default();
.unwrap_or_default()
.with_virtual_env(restored_virtual_env.clone());
let imported_runtime = old_pane_id.and_then(|old_id| imported_panes.remove(&old_id));
let was_imported = imported_runtime.is_some();
let pending_native_agent_restore = if was_imported {
Expand All @@ -537,6 +558,7 @@ fn restore_tab(
let terminal_id = TerminalId::alloc();
let mut terminal = TerminalState::new(terminal_id.clone(), cwd.clone())
.with_pending_agent_resume_plan(plan);
terminal.virtual_env = restored_virtual_env;
if let Some(label) = saved_label {
terminal.set_manual_label(label);
}
Expand Down Expand Up @@ -629,6 +651,7 @@ fn restore_tab(
Ok(runtime) => {
let terminal_id = TerminalId::alloc();
let mut terminal = TerminalState::new(terminal_id.clone(), cwd.clone());
terminal.virtual_env = restored_virtual_env;
if was_imported {
if let Some(argv) = saved_launch_argv {
terminal = terminal.with_launch_argv(argv).with_respawn_shell_on_exit();
Expand Down Expand Up @@ -1198,6 +1221,7 @@ mod tests {
value: "opencode-session".into(),
}),
launch_argv: None,
virtual_env: None,
},
)]),
zoomed: false,
Expand Down Expand Up @@ -1279,6 +1303,7 @@ mod tests {
managed_agent_kind: None,
agent_session: None,
launch_argv: None,
virtual_env: None,
},
),
(
Expand All @@ -1290,6 +1315,7 @@ mod tests {
managed_agent_kind: None,
agent_session: None,
launch_argv: None,
virtual_env: None,
},
),
]),
Expand Down Expand Up @@ -1343,6 +1369,7 @@ mod tests {
managed_agent_kind: None,
agent_session: None,
launch_argv: None,
virtual_env: None,
},
)
};
Expand All @@ -1358,6 +1385,7 @@ mod tests {
value: "codex-session".into(),
}),
launch_argv: None,
virtual_env: None,
};
let snapshot = SessionSnapshot {
version: super::super::snapshot::SNAPSHOT_VERSION,
Expand Down Expand Up @@ -1441,6 +1469,98 @@ mod tests {
.all(|detail| detail.pane_id != agent_pane));
}

fn restore_pane_with_virtual_env(
prefix: &std::path::Path,
) -> Option<crate::platform::VirtualEnvActivation> {
let cwd = std::env::current_dir().unwrap();
let snapshot = SessionSnapshot {
version: super::super::snapshot::SNAPSHOT_VERSION,
workspaces: vec![WorkspaceSnapshot {
id: Some("w1".into()),
custom_name: None,
identity_cwd: cwd.clone(),
worktree_space: None,
public_pane_numbers: HashMap::from([(10, 1)]),
next_public_pane_number: 2,
public_tab_numbers: vec![1],
next_public_tab_number: 2,
tabs: vec![TabSnapshot {
custom_name: None,
layout: LayoutSnapshot::Pane(10),
panes: HashMap::from([(
10,
super::super::snapshot::PaneSnapshot {
cwd: cwd.clone(),
label: None,
agent_name: None,
managed_agent_kind: None,
agent_session: None,
launch_argv: None,
virtual_env: Some(super::super::snapshot::PaneVirtualEnvSnapshot {
kind: "conda".into(),
prefix: prefix.to_path_buf(),
name: Some("web".into()),
}),
},
)]),
zoomed: false,
focused: Some(10),
root_pane: Some(10),
}],
active_tab: 0,
}],
active: Some(0),
selected: 0,
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,
test_restore_shell(),
crate::config::ShellModeConfig::NonLogin,
false,
events,
Arc::new(Notify::new()),
Arc::new(RenderSignal::new()),
);

let workspace = workspaces.first().expect("workspace should restore");
let pane = workspace.tabs[0].root_pane;
let terminal_id = &workspace.tabs[0].panes[&pane].attached_terminal_id;
terminals[terminal_id].virtual_env.clone()
}

#[tokio::test]
async fn cold_restore_carries_the_saved_virtual_env_onto_the_terminal() {
let prefix = std::env::current_dir().unwrap();

let activation = restore_pane_with_virtual_env(&prefix)
.expect("restored pane should carry its environment");

assert_eq!(activation.kind, crate::platform::VirtualEnvKind::Conda);
assert_eq!(activation.prefix, prefix);
assert_eq!(activation.name.as_deref(), Some("web"));
}

#[tokio::test]
async fn cold_restore_drops_a_virtual_env_that_no_longer_exists() {
// Re-entering a deleted environment would put a missing directory on
// PATH and suppress conda's fallback to base, leaving the pane with no
// interpreter at all.
let prefix = std::env::current_dir()
.unwrap()
.join("herdr-environment-that-does-not-exist");

assert!(restore_pane_with_virtual_env(&prefix).is_none());
}

#[test]
fn legacy_restore_precomputes_missing_public_pane_numbers() {
let cwd = std::env::current_dir().unwrap();
Expand Down Expand Up @@ -1509,6 +1629,7 @@ mod tests {
value: "codex-session".into(),
}),
launch_argv: None,
virtual_env: None,
},
)]),
zoomed: false,
Expand Down Expand Up @@ -1670,6 +1791,7 @@ mod tests {
managed_agent_kind: None,
agent_session: None,
launch_argv: None,
virtual_env: None,
},
);
let history = SessionHistorySnapshot {
Expand Down
Loading
Loading