use tracing::{info, warn};
use crate::daemon::orphan_gc::{ChildLivenessProbe, PaneInfo, is_idle_shell};
use crate::daemon::tmux::TmuxDriver;
use crate::session_manager::{
ManagedSessionId, ManagedSessionState, SessionManager, SessionRecord,
};
pub fn pane_runtime_exited(pane: &PaneInfo, probe: &dyn ChildLivenessProbe) -> bool {
is_idle_shell(&pane.pane_current_command) && !probe.has_live_child(pane.pane_pid)
}
pub fn session_has_live_pane(
tmux_name: &str,
panes: &[PaneInfo],
probe: &dyn ChildLivenessProbe,
) -> bool {
panes
.iter()
.filter(|p| p.session_name == tmux_name)
.any(|p| !pane_runtime_exited(p, probe))
}
pub fn find_runtime_exited(
records: &[SessionRecord],
panes: &[PaneInfo],
probe: &dyn ChildLivenessProbe,
) -> Vec<ManagedSessionId> {
use std::collections::HashSet;
let mut present: HashSet<&str> = HashSet::new();
let mut live: HashSet<&str> = HashSet::new();
for pane in panes {
present.insert(pane.session_name.as_str());
if !pane_runtime_exited(pane, probe) {
live.insert(pane.session_name.as_str());
}
}
records
.iter()
.filter(|r| matches!(r.state, ManagedSessionState::Active))
.filter(|r| present.contains(r.tmux_name.as_str()) && !live.contains(r.tmux_name.as_str()))
.map(|r| r.id)
.collect()
}
pub async fn stop_runtime_exited(
manager: &SessionManager,
panes: &[PaneInfo],
probe: &(dyn ChildLivenessProbe + Sync),
) -> usize {
let records = manager.list().await;
let exited = find_runtime_exited(&records, panes, probe);
let mut stopped = 0usize;
for id in exited {
match manager.mark_runtime_exited_stopped(&id).await {
Ok(record) => {
stopped += 1;
info!(
id = %id,
name = %record.tmux_name,
"runtime-reap: marked managed session Stopped (runtime process exited, #1814, pane left alive #2023)"
);
}
Err(e) => warn!(
id = %id,
"runtime-reap: failed to mark managed session Stopped: {e}"
),
}
}
stopped
}
pub async fn reap_runtime_exited_managed(
manager: &SessionManager,
driver: &TmuxDriver,
probe: &(dyn ChildLivenessProbe + Sync),
) -> usize {
let panes = match driver.list_managed_panes() {
Ok(p) => p,
Err(e) => {
warn!("runtime-reap skipped — list_managed_panes failed: {e}");
return 0;
}
};
stop_runtime_exited(manager, &panes, probe).await
}
#[cfg(test)]
mod tests {
use super::*;
use crate::daemon::orphan_gc::AlwaysIdleProbe;
use crate::session_manager::{
FakeNoopTmuxDriver, ManagedError, ManagedTmuxDriver, SessionManager,
};
use std::sync::{Arc, Mutex};
#[derive(Default)]
struct KillSpyTmuxDriver {
kill_calls: Mutex<Vec<String>>,
env_set_calls: Mutex<Vec<(String, String, String)>>,
}
impl ManagedTmuxDriver for KillSpyTmuxDriver {
fn create_session(&self, _name: &str, _workdir: &str) -> Result<(), ManagedError> {
Ok(())
}
fn kill_session(&self, name: &str) -> Result<(), ManagedError> {
self.kill_calls.lock().unwrap().push(name.to_owned());
Ok(())
}
fn send_line(&self, _name: &str, _text: &str) -> Result<(), ManagedError> {
Ok(())
}
fn capture(&self, _name: &str, _lines: usize) -> Result<String, ManagedError> {
Ok(String::new())
}
fn list_sessions(&self) -> Result<Vec<String>, ManagedError> {
Ok(Vec::new())
}
fn set_environment(&self, name: &str, key: &str, value: &str) -> Result<(), ManagedError> {
self.env_set_calls.lock().unwrap().push((
name.to_owned(),
key.to_owned(),
value.to_owned(),
));
Ok(())
}
}
struct AlwaysLiveProbe;
impl ChildLivenessProbe for AlwaysLiveProbe {
fn has_live_child(&self, _pane_pid: Option<u32>) -> bool {
true
}
}
fn pane(name: &str, cmd: &str) -> PaneInfo {
PaneInfo {
session_name: name.to_string(),
pane_current_command: cmd.to_string(),
pane_pid: Some(4242),
pane_id: None,
}
}
#[test]
fn pane_runtime_exited_true_for_bare_shell() {
assert!(pane_runtime_exited(
&pane("tmpm-a", "zsh"),
&AlwaysIdleProbe
));
assert!(pane_runtime_exited(
&pane("tmpm-a", "-bash"),
&AlwaysIdleProbe
));
}
#[test]
fn pane_runtime_exited_false_for_agent() {
assert!(!pane_runtime_exited(
&pane("tmpm-a", "claude"),
&AlwaysIdleProbe
));
assert!(!pane_runtime_exited(
&pane("tmpm-a", "node"),
&AlwaysIdleProbe
));
}
#[test]
fn pane_runtime_exited_false_with_live_child() {
assert!(!pane_runtime_exited(
&pane("tmpm-a", "zsh"),
&AlwaysLiveProbe
));
}
#[test]
fn session_has_live_pane_true_when_any_pane_live() {
let panes = vec![pane("tmpm-split", "zsh"), pane("tmpm-split", "claude")];
assert!(
session_has_live_pane("tmpm-split", &panes, &AlwaysIdleProbe),
"any live pane in the session must make the whole session live"
);
}
#[test]
fn session_has_live_pane_false_when_all_panes_idle() {
let panes = vec![pane("tmpm-split", "zsh"), pane("tmpm-split", "-bash")];
assert!(
!session_has_live_pane("tmpm-split", &panes, &AlwaysIdleProbe),
"a session whose every pane is idle must not be classified as live"
);
}
#[test]
fn session_has_live_pane_false_when_no_pane_matches() {
let panes = vec![pane("some-other-session", "claude")];
assert!(
!session_has_live_pane("tmpm-gone", &panes, &AlwaysIdleProbe),
"no matching pane means nothing to protect — must fail open (not live)"
);
}
#[test]
fn session_has_live_pane_single_pane_unchanged() {
assert!(session_has_live_pane(
"tmpm-a",
&[pane("tmpm-a", "claude")],
&AlwaysIdleProbe
));
assert!(!session_has_live_pane(
"tmpm-a",
&[pane("tmpm-a", "zsh")],
&AlwaysIdleProbe
));
}
async fn seed_active(
dir: &tempfile::TempDir,
tmux_name_task: &str,
) -> (Arc<SessionManager>, ManagedSessionId) {
seed_active_with_driver(dir, tmux_name_task, Arc::new(FakeNoopTmuxDriver)).await
}
async fn seed_active_with_driver(
dir: &tempfile::TempDir,
tmux_name_task: &str,
driver: Arc<dyn ManagedTmuxDriver>,
) -> (Arc<SessionManager>, ManagedSessionId) {
let mgr = SessionManager::new(dir.path(), driver)
.await
.expect("session manager");
let mgr = Arc::new(mgr);
let record = mgr
.create(
tmux_name_task.into(),
Some(std::path::PathBuf::from("/tmp/test-runtime-reap")),
None,
None,
None,
None,
)
.await
.expect("create");
let id = record.id;
mgr.set_workspace(
&id,
std::path::PathBuf::from("/tmp/test-runtime-reap"),
ManagedSessionState::Active,
)
.await
.expect("set Active");
(mgr, id)
}
#[tokio::test]
async fn find_runtime_exited_selects_only_exited_active() {
let tmp = tempfile::TempDir::new().unwrap();
let (mgr, id) = seed_active(&tmp, "reap-me").await;
let record = mgr.get(&id).await.expect("get");
let panes = vec![pane(&record.tmux_name, "zsh")];
let exited = find_runtime_exited(&[record], &panes, &AlwaysIdleProbe);
assert_eq!(exited, vec![id]);
}
#[tokio::test]
async fn find_runtime_exited_skips_missing_pane() {
let tmp = tempfile::TempDir::new().unwrap();
let (mgr, id) = seed_active(&tmp, "gone").await;
let record = mgr.get(&id).await.expect("get");
let exited = find_runtime_exited(&[record], &[], &AlwaysIdleProbe);
assert!(exited.is_empty(), "missing pane must not be selected");
}
#[tokio::test]
async fn find_runtime_exited_skips_non_active() {
let tmp = tempfile::TempDir::new().unwrap();
let (mgr, id) = seed_active(&tmp, "already-stopped").await;
let record = mgr.get(&id).await.expect("get");
let panes = vec![pane(&record.tmux_name, "zsh")];
let mut stopped_record = record;
stopped_record.state = ManagedSessionState::Stopped;
let exited = find_runtime_exited(&[stopped_record], &panes, &AlwaysIdleProbe);
assert!(exited.is_empty(), "non-Active records must be skipped");
}
#[tokio::test]
async fn stop_runtime_exited_transitions_active_to_stopped() {
let tmp = tempfile::TempDir::new().unwrap();
let (mgr, id) = seed_active(&tmp, "exited").await;
let record = mgr.get(&id).await.expect("get");
let panes = vec![pane(&record.tmux_name, "zsh")];
let stopped = stop_runtime_exited(&mgr, &panes, &AlwaysIdleProbe).await;
assert_eq!(stopped, 1, "exactly one session must be stopped");
let after = mgr.get(&id).await.expect("get after reap");
assert_eq!(
after.state,
ManagedSessionState::Stopped,
"runtime-exited Active session must become Stopped (resumable), #1814"
);
}
#[tokio::test]
async fn stop_runtime_exited_keeps_running_session() {
let tmp = tempfile::TempDir::new().unwrap();
let (mgr, id) = seed_active(&tmp, "running").await;
let record = mgr.get(&id).await.expect("get");
let panes = vec![pane(&record.tmux_name, "claude")];
let stopped = stop_runtime_exited(&mgr, &panes, &AlwaysIdleProbe).await;
assert_eq!(stopped, 0, "a running agent session must not be stopped");
let after = mgr.get(&id).await.expect("get after reap");
assert_eq!(
after.state,
ManagedSessionState::Active,
"a session still running claude must stay Active"
);
}
#[tokio::test]
async fn stop_runtime_exited_does_not_kill_pane() {
let tmp = tempfile::TempDir::new().unwrap();
let driver = Arc::new(KillSpyTmuxDriver::default());
let (mgr, id) =
seed_active_with_driver(&tmp, "exited-no-kill", driver.clone() as Arc<_>).await;
let record = mgr.get(&id).await.expect("get");
let panes = vec![pane(&record.tmux_name, "zsh")];
let stopped = stop_runtime_exited(&mgr, &panes, &AlwaysIdleProbe).await;
assert_eq!(stopped, 1, "exactly one session must be stopped");
let after = mgr.get(&id).await.expect("get after reap");
assert_eq!(
after.state,
ManagedSessionState::Stopped,
"runtime-exited Active session must become Stopped"
);
assert!(
driver.kill_calls.lock().unwrap().is_empty(),
"runtime-exit reconcile must NEVER kill the tmux pane (#2023 A)"
);
}
#[tokio::test]
async fn stop_runtime_exited_heals_stale_pane_env() {
let tmp = tempfile::TempDir::new().unwrap();
let driver = Arc::new(KillSpyTmuxDriver::default());
let (mgr, id) =
seed_active_with_driver(&tmp, "exited-heal-env", driver.clone() as Arc<_>).await;
let record = mgr.get(&id).await.expect("get");
let panes = vec![pane(&record.tmux_name, "zsh")];
let stopped = stop_runtime_exited(&mgr, &panes, &AlwaysIdleProbe).await;
assert_eq!(stopped, 1, "exactly one session must be stopped");
let env_sets = driver.env_set_calls.lock().unwrap();
assert!(
env_sets
.iter()
.any(|(name, key, value)| name == &record.tmux_name
&& key == "TM_MANAGED_SESSION_ID"
&& value == &id.to_string()),
"runtime-exit reconcile must heal the pane's tmux env: {env_sets:?}"
);
}
}