use std::collections::{BTreeSet, HashMap, HashSet};
use std::process::Command;
use std::time::Duration;
use octl_core::schema::TmuxIdentity;
use sysinfo::{Pid, System};
use crate::supervise::pid_file;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Liveness {
Alive,
Dead,
Recycled,
TmuxGone,
}
impl Liveness {
pub fn reason(self) -> &'static str {
match self {
Liveness::Alive => "alive",
Liveness::Dead => "agent-died",
Liveness::Recycled => "agent-pid-recycled",
Liveness::TmuxGone => "agent-tmux-window-gone",
}
}
}
pub fn pid_start_time(pid: u32) -> Option<u64> {
let mut sys = System::new();
sys.refresh_processes_specifics(
sysinfo::ProcessesToUpdate::Some(&[Pid::from_u32(pid)]),
true,
sysinfo::ProcessRefreshKind::new(),
);
sys.process(Pid::from_u32(pid))
.map(sysinfo::Process::start_time)
}
pub fn pid_cpu_time_centis(pid: u32) -> Option<u64> {
let out = Command::new("ps")
.args(["-o", "time=", "-p", &pid.to_string()])
.stderr(std::process::Stdio::null())
.output()
.ok()?;
if !out.status.success() {
return None;
}
parse_ps_time_centis(String::from_utf8_lossy(&out.stdout).trim())
}
fn parse_ps_time_centis(s: &str) -> Option<u64> {
let s = s.trim();
if s.is_empty() {
return None;
}
let (days, rest) = match s.split_once('-') {
Some((d, r)) => (d.parse::<u64>().ok()?, r),
None => (0u64, s),
};
let parts: Vec<&str> = rest.split(':').collect();
let (hours, mins, secs_field) = match parts.as_slice() {
[sec] => (0u64, 0u64, *sec),
[m, sec] => (0u64, m.parse().ok()?, *sec),
[h, m, sec] => (h.parse().ok()?, m.parse().ok()?, *sec),
_ => return None,
};
let (whole, centis) = match secs_field.split_once('.') {
Some((w, c)) => {
let mut c = c.to_string();
while c.len() < 2 {
c.push('0');
}
(w.parse::<u64>().ok()?, c.get(..2)?.parse::<u64>().ok()?)
}
None => (secs_field.parse::<u64>().ok()?, 0u64),
};
Some((((days * 24 + hours) * 60 + mins) * 60 + whole) * 100 + centis)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TmuxProbe {
Present,
Absent,
Unknown,
}
const TMUX_PROBE_TIMEOUT: Duration = Duration::from_secs(2);
const TIMEOUT_WARN_EVERY: u64 = 30;
#[derive(Debug, Clone)]
enum SocketWindows {
Reachable {
ids: HashSet<String>,
names: HashSet<String>,
},
Unreachable,
}
#[derive(Debug, Clone, Default)]
pub struct WatchdogTmuxSnapshot {
sockets: HashMap<Option<String>, SocketWindows>,
}
impl WatchdogTmuxSnapshot {
pub fn collect(sockets: &BTreeSet<Option<String>>) -> Self {
let bin = std::env::var("TMUX_BIN").unwrap_or_else(|_| "tmux".to_string());
Self::collect_with(sockets, &bin, TMUX_PROBE_TIMEOUT)
}
fn collect_with(sockets: &BTreeSet<Option<String>>, bin: &str, timeout: Duration) -> Self {
let mut map = HashMap::with_capacity(sockets.len());
for socket in sockets {
map.insert(
socket.clone(),
probe_socket(bin, socket.as_deref(), timeout),
);
}
Self { sockets: map }
}
fn lookup_qualified(&self, identity: &TmuxIdentity) -> TmuxProbe {
match self.sockets.get(&identity.socket) {
Some(SocketWindows::Reachable { ids, .. }) => {
if ids.contains(&identity.window_id) {
TmuxProbe::Present
} else {
TmuxProbe::Absent
}
}
Some(SocketWindows::Unreachable) | None => TmuxProbe::Unknown,
}
}
pub fn probe_verdict(&self, probe: &AgentProbe) -> Option<TmuxProbe> {
if probe.skip_tmux_check {
return None;
}
match probe.tmux_identity.as_ref() {
Some(identity) => Some(self.lookup_qualified(identity)),
None => probe
.tmux_window
.as_deref()
.map(|name| self.lookup_by_name(name)),
}
}
fn lookup_by_name(&self, window_name: &str) -> TmuxProbe {
match self.sockets.get(&None) {
Some(SocketWindows::Reachable { names, .. }) => {
if names.contains(window_name) {
TmuxProbe::Present
} else {
TmuxProbe::Absent
}
}
Some(SocketWindows::Unreachable) | None => TmuxProbe::Unknown,
}
}
}
fn probe_socket(bin: &str, socket: Option<&str>, timeout: Duration) -> SocketWindows {
let mut cmd = Command::new(bin);
if let Some(s) = socket {
cmd.args(["-S", s]);
}
cmd.args(["list-windows", "-a", "-F", "#{window_id}|#{window_name}"]);
match run_timed(cmd, timeout) {
ProbeOutcome::Ok(stdout) => {
let mut ids = HashSet::new();
let mut names = HashSet::new();
for line in stdout.split(|b| *b == b'\n') {
let line = trim_ascii(line);
if line.is_empty() {
continue;
}
match line.iter().position(|b| *b == b'|') {
Some(i) => {
let id = trim_ascii(&line[..i]);
let name = trim_ascii(&line[i + 1..]);
if !id.is_empty() {
ids.insert(String::from_utf8_lossy(id).into_owned());
}
names.insert(String::from_utf8_lossy(name).into_owned());
}
None => {
ids.insert(String::from_utf8_lossy(line).into_owned());
}
}
}
SocketWindows::Reachable { ids, names }
}
ProbeOutcome::NonZero | ProbeOutcome::SpawnErr => SocketWindows::Unreachable,
ProbeOutcome::TimedOut => {
warn_timeout_rate_limited(socket);
SocketWindows::Unreachable
}
}
}
enum ProbeOutcome {
Ok(Vec<u8>),
NonZero,
SpawnErr,
TimedOut,
}
const TMUX_OUTPUT_CAP: usize = 1 << 20;
fn run_timed(cmd: Command, timeout: Duration) -> ProbeOutcome {
match crate::proc::run_with_timeout(cmd, timeout, TMUX_OUTPUT_CAP) {
crate::proc::TimedOutcome::Exited { status, stdout, .. } => {
if status.success() {
ProbeOutcome::Ok(stdout.bytes)
} else {
ProbeOutcome::NonZero
}
}
crate::proc::TimedOutcome::TimedOut => ProbeOutcome::TimedOut,
crate::proc::TimedOutcome::SpawnErr(_) => ProbeOutcome::SpawnErr,
}
}
fn warn_timeout_rate_limited(socket: Option<&str>) {
use std::sync::atomic::{AtomicU64, Ordering};
static TIMEOUTS: AtomicU64 = AtomicU64::new(0);
let n = TIMEOUTS.fetch_add(1, Ordering::Relaxed);
if n % TIMEOUT_WARN_EVERY == 0 {
tracing::warn!(
socket = socket.unwrap_or("<default>"),
timeout_secs = TMUX_PROBE_TIMEOUT.as_secs(),
warn_every = TIMEOUT_WARN_EVERY,
"tmux list-windows probe timed out; treating socket as unreachable \
and deferring to PID liveness for its nodes this tick (warning \
rate-limited)"
);
}
}
fn trim_ascii(b: &[u8]) -> &[u8] {
let start = b.iter().position(|c| !c.is_ascii_whitespace());
let Some(start) = start else { return &[] };
let end = b.iter().rposition(|c| !c.is_ascii_whitespace()).unwrap();
&b[start..=end]
}
#[derive(Debug, Clone)]
pub struct AgentProbe {
pub pid: u32,
pub start_time: Option<u64>,
pub tmux_window: Option<String>,
pub tmux_identity: Option<TmuxIdentity>,
pub skip_tmux_check: bool,
}
impl AgentProbe {
pub fn probe_socket(&self) -> (bool, Option<String>) {
if self.skip_tmux_check {
return (false, None);
}
match &self.tmux_identity {
Some(id) => (true, id.socket.clone()),
None => (self.tmux_window.is_some(), None),
}
}
}
const MAX_LEGACY_WARN_KEYS: usize = 1024;
fn warn_legacy_bare_name_once(window_name: &str) {
use std::sync::{Mutex, OnceLock};
static WARNED: OnceLock<Mutex<HashSet<String>>> = OnceLock::new();
let mutex = WARNED.get_or_init(|| Mutex::new(HashSet::new()));
let mut guard = mutex
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let cap_hit = guard.len() >= MAX_LEGACY_WARN_KEYS;
let is_new = !cap_hit && guard.insert(window_name.to_string());
drop(guard);
if is_new {
tracing::warn!(
tmux_window = window_name,
"node has no qualified tmux identity; falling back to bare \
window-name liveness matching (registered before create.sh \
emitted the qualified fields) — this is ambiguous across sessions"
);
}
}
pub fn check_liveness(probe: &AgentProbe, tmux: &WatchdogTmuxSnapshot) -> Liveness {
if !pid_file::pid_alive(probe.pid) {
return Liveness::Dead;
}
if let Some(expected) = probe.start_time {
if let Some(actual) = pid_start_time(probe.pid) {
if expected.abs_diff(actual) > 1 {
return Liveness::Recycled;
}
}
}
if !probe.skip_tmux_check {
let probe_result = match probe.tmux_identity.as_ref() {
Some(identity) => Some(tmux.lookup_qualified(identity)),
None => probe.tmux_window.as_deref().map(|name| {
warn_legacy_bare_name_once(name);
tmux.lookup_by_name(name)
}),
};
match probe_result {
Some(TmuxProbe::Absent) => return Liveness::TmuxGone,
Some(TmuxProbe::Unknown) => {
tracing::debug!(
"tmux liveness probe inconclusive (server unreachable or \
tmux unavailable); deferring to PID liveness"
);
}
Some(TmuxProbe::Present) | None => {}
}
}
Liveness::Alive
}
pub fn check_liveness_for_lifecycle(
probe: &AgentProbe,
tmux: &WatchdogTmuxSnapshot,
interactive: bool,
) -> Liveness {
let verdict = check_liveness(probe, tmux);
if !interactive || probe.skip_tmux_check {
return verdict;
}
match verdict {
Liveness::Dead | Liveness::Recycled => match tmux.probe_verdict(probe) {
Some(TmuxProbe::Absent) => Liveness::TmuxGone,
Some(TmuxProbe::Present | TmuxProbe::Unknown) => Liveness::Alive,
None => verdict,
},
other => other,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn empty_snapshot() -> WatchdogTmuxSnapshot {
WatchdogTmuxSnapshot::default()
}
#[test]
fn parse_ps_time_centis_handles_all_shapes() {
assert_eq!(parse_ps_time_centis("0:00.04"), Some(4));
assert_eq!(parse_ps_time_centis("1:30.50"), Some(90 * 100 + 50));
assert_eq!(parse_ps_time_centis("02:05"), Some(125 * 100));
assert_eq!(parse_ps_time_centis("1:00:00"), Some(3600 * 100));
assert_eq!(parse_ps_time_centis("2-00:00:00"), Some(2 * 86400 * 100));
assert_eq!(parse_ps_time_centis("0:00.4"), Some(40));
assert_eq!(parse_ps_time_centis(" 0:01.00 "), Some(100));
assert_eq!(parse_ps_time_centis(""), None);
assert_eq!(parse_ps_time_centis("nope"), None);
assert_eq!(parse_ps_time_centis("1:2:3:4"), None);
}
#[test]
fn own_pid_has_cpu_time() {
assert!(pid_cpu_time_centis(std::process::id()).is_some());
assert_eq!(pid_cpu_time_centis(0), None);
}
#[test]
fn own_pid_has_start_time_and_is_alive() {
let pid = std::process::id();
let st = pid_start_time(pid).expect("self start_time");
let probe = AgentProbe {
pid,
start_time: Some(st),
tmux_window: None,
tmux_identity: None,
skip_tmux_check: true,
};
assert_eq!(check_liveness(&probe, &empty_snapshot()), Liveness::Alive);
}
#[test]
fn dead_pid_is_dead() {
let probe = AgentProbe {
pid: 0,
start_time: None,
tmux_window: None,
tmux_identity: None,
skip_tmux_check: true,
};
assert_eq!(check_liveness(&probe, &empty_snapshot()), Liveness::Dead);
}
#[test]
fn mismatched_start_time_detects_recycled_pid() {
let pid = std::process::id();
let probe = AgentProbe {
pid,
start_time: Some(1), tmux_window: None,
tmux_identity: None,
skip_tmux_check: true,
};
assert_eq!(
check_liveness(&probe, &empty_snapshot()),
Liveness::Recycled
);
}
#[test]
fn start_time_is_stable_across_reads() {
let pid = std::process::id();
let a = pid_start_time(pid).unwrap();
std::thread::sleep(std::time::Duration::from_millis(50));
let b = pid_start_time(pid).unwrap();
assert!(a.abs_diff(b) <= 1, "start_time drifted: {a} vs {b}");
}
use std::path::{Path, PathBuf};
use crate::harness::support::test_env;
struct EnvGuard {
key: &'static str,
old: Option<std::ffi::OsString>,
}
impl EnvGuard {
fn set(key: &'static str, value: impl AsRef<std::ffi::OsStr>) -> Self {
let old = std::env::var_os(key);
std::env::set_var(key, value);
Self { key, old }
}
}
impl Drop for EnvGuard {
fn drop(&mut self) {
match &self.old {
Some(v) => std::env::set_var(self.key, v),
None => std::env::remove_var(self.key),
}
}
}
fn chmod_exec(p: &Path) {
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(p).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(p, perms).unwrap();
}
fn fake_tmux(dir: &Path, stdout_lines: &[&str], code: i32) -> PathBuf {
let out = dir.join("stdout");
std::fs::write(&out, stdout_lines.join("\n")).unwrap();
let p = dir.join("fake-tmux.sh");
let body = format!(
"#!/bin/bash\nprintf '%s\\n' \"$@\" > {args:?}\necho x >> {inv:?}\ncat {out:?}\nexit {code}\n",
args = dir.join("args"),
inv = dir.join("invocations"),
out = out,
code = code,
);
std::fs::write(&p, body).unwrap();
chmod_exec(&p);
p
}
fn fake_tmux_slow(dir: &Path, secs: u32) -> PathBuf {
let p = dir.join("fake-tmux-slow.sh");
std::fs::write(&p, format!("#!/bin/bash\nsleep {secs}\nexit 0\n")).unwrap();
chmod_exec(&p);
p
}
fn args_lines(dir: &Path) -> Vec<String> {
std::fs::read_to_string(dir.join("args"))
.unwrap()
.lines()
.map(str::to_string)
.collect()
}
fn invocation_count(dir: &Path) -> usize {
std::fs::read_to_string(dir.join("invocations")).map_or(0, |s| s.lines().count())
}
fn id(socket: Option<&str>, session: &str, window_id: &str) -> TmuxIdentity {
TmuxIdentity {
socket: socket.map(str::to_string),
session: session.to_string(),
window_id: window_id.to_string(),
pane_id: None,
}
}
fn snapshot(sockets: &[Option<&str>]) -> WatchdogTmuxSnapshot {
let set: BTreeSet<Option<String>> = sockets.iter().map(|s| s.map(str::to_string)).collect();
WatchdogTmuxSnapshot::collect(&set)
}
#[test]
fn snapshot_parses_list_windows_fixture() {
let _g = test_env::lock();
let dir = tempfile::TempDir::new().unwrap();
let _e = EnvGuard::set(
"TMUX_BIN",
fake_tmux(
dir.path(),
&["@7|main", "@42|🚀 wt/x", "@99|other|piped\r", ""],
0,
),
);
let snap = snapshot(&[None]);
assert_eq!(
snap.lookup_qualified(&id(None, "octl", "@42")),
TmuxProbe::Present
);
assert_eq!(
snap.lookup_qualified(&id(None, "octl", "@99")),
TmuxProbe::Present
);
assert_eq!(
snap.lookup_qualified(&id(None, "octl", "@1")),
TmuxProbe::Absent
);
assert_eq!(snap.lookup_by_name("🚀 wt/x"), TmuxProbe::Present);
assert_eq!(snap.lookup_by_name("other|piped"), TmuxProbe::Present);
assert_eq!(snap.lookup_by_name("nope"), TmuxProbe::Absent);
assert_eq!(invocation_count(dir.path()), 1);
let args = args_lines(dir.path());
assert!(args.iter().any(|a| a == "-a"), "expected -a: {args:?}");
assert!(
!args.iter().any(|a| a == "-t"),
"must not session-scope: {args:?}"
);
}
#[test]
fn snapshot_passes_socket_when_recorded() {
let _g = test_env::lock();
let dir = tempfile::TempDir::new().unwrap();
let _e = EnvGuard::set("TMUX_BIN", fake_tmux(dir.path(), &["@42|win"], 0));
let snap = snapshot(&[Some("/tmp/sock")]);
assert_eq!(
snap.lookup_qualified(&id(Some("/tmp/sock"), "octl", "@42")),
TmuxProbe::Present
);
let args = args_lines(dir.path());
assert!(
args.windows(2)
.any(|w| w == ["-S".to_string(), "/tmp/sock".to_string()]),
"socket not passed: {args:?}"
);
}
#[test]
fn snapshot_no_socket_flag_for_default() {
let _g = test_env::lock();
let dir = tempfile::TempDir::new().unwrap();
let _e = EnvGuard::set("TMUX_BIN", fake_tmux(dir.path(), &["@7|a", "@99|b"], 0));
let snap = snapshot(&[None]);
assert_eq!(
snap.lookup_qualified(&id(None, "octl", "@42")),
TmuxProbe::Absent
);
assert!(
!args_lines(dir.path()).iter().any(|a| a == "-S"),
"unexpected -S with default socket"
);
}
#[test]
fn snapshot_unreachable_when_server_down() {
let _g = test_env::lock();
let dir = tempfile::TempDir::new().unwrap();
let _e = EnvGuard::set("TMUX_BIN", fake_tmux(dir.path(), &["no server running"], 1));
let snap = snapshot(&[Some("/tmp/dead-sock")]);
assert_eq!(
snap.lookup_qualified(&id(Some("/tmp/dead-sock"), "octl", "@42")),
TmuxProbe::Unknown
);
}
#[test]
fn check_liveness_uses_qualified_identity_for_tmux_gone() {
let _g = test_env::lock();
let dir = tempfile::TempDir::new().unwrap();
let _e = EnvGuard::set("TMUX_BIN", fake_tmux(dir.path(), &["@99|other"], 0));
let probe = AgentProbe {
pid: std::process::id(),
start_time: None,
tmux_window: Some("🚀 wt/x".to_string()),
tmux_identity: Some(id(Some("/tmp/sock"), "octl", "@42")),
skip_tmux_check: false,
};
let snap = snapshot(&[Some("/tmp/sock")]);
assert_eq!(check_liveness(&probe, &snap), Liveness::TmuxGone);
}
#[test]
fn check_liveness_alive_when_qualified_window_present() {
let _g = test_env::lock();
let dir = tempfile::TempDir::new().unwrap();
let _e = EnvGuard::set("TMUX_BIN", fake_tmux(dir.path(), &["@42|🚀 wt/x"], 0));
let probe = AgentProbe {
pid: std::process::id(),
start_time: None,
tmux_window: Some("🚀 wt/x".to_string()),
tmux_identity: Some(id(None, "octl", "@42")),
skip_tmux_check: false,
};
let snap = snapshot(&[None]);
assert_eq!(check_liveness(&probe, &snap), Liveness::Alive);
}
#[test]
fn check_liveness_stays_alive_when_probe_unknown() {
let _g = test_env::lock();
let dir = tempfile::TempDir::new().unwrap();
let _e = EnvGuard::set("TMUX_BIN", fake_tmux(dir.path(), &["no server"], 1));
let probe = AgentProbe {
pid: std::process::id(),
start_time: None,
tmux_window: Some("🚀 wt/x".to_string()),
tmux_identity: Some(id(Some("/tmp/dead-sock"), "octl", "@42")),
skip_tmux_check: false,
};
let snap = snapshot(&[Some("/tmp/dead-sock")]);
assert_eq!(check_liveness(&probe, &snap), Liveness::Alive);
}
#[test]
fn check_liveness_falls_back_to_bare_name_without_identity() {
let _g = test_env::lock();
let dir = tempfile::TempDir::new().unwrap();
let _e = EnvGuard::set("TMUX_BIN", fake_tmux(dir.path(), &["@3|legacy-win"], 0));
let probe = AgentProbe {
pid: std::process::id(),
start_time: None,
tmux_window: Some("legacy-win".to_string()),
tmux_identity: None,
skip_tmux_check: false,
};
let snap = snapshot(&[None]);
assert_eq!(check_liveness(&probe, &snap), Liveness::Alive);
}
#[test]
fn snapshot_is_one_invocation_per_socket_regardless_of_node_count() {
let _g = test_env::lock();
let dir = tempfile::TempDir::new().unwrap();
let _e = EnvGuard::set(
"TMUX_BIN",
fake_tmux(dir.path(), &["@1|a", "@2|b", "@3|c", "@4|d", "@5|e"], 0),
);
let snap = snapshot(&[None]);
for n in 1..=5 {
let probe = AgentProbe {
pid: std::process::id(),
start_time: None,
tmux_window: None,
tmux_identity: Some(id(None, "octl", &format!("@{n}"))),
skip_tmux_check: false,
};
assert_eq!(check_liveness(&probe, &snap), Liveness::Alive);
}
assert_eq!(
invocation_count(dir.path()),
1,
"expected a single tmux invocation for the tick"
);
}
#[test]
fn probe_timeout_yields_unreachable_and_pid_only_liveness() {
let _g = test_env::lock();
let dir = tempfile::TempDir::new().unwrap();
let bin = fake_tmux_slow(dir.path(), 30);
let sockets: BTreeSet<Option<String>> = [Some("/tmp/wedged".to_string())].into();
let snap = WatchdogTmuxSnapshot::collect_with(
&sockets,
bin.to_str().unwrap(),
Duration::from_millis(150),
);
assert_eq!(
snap.lookup_qualified(&id(Some("/tmp/wedged"), "octl", "@42")),
TmuxProbe::Unknown,
"a timed-out probe must be Unknown, never a false Absent"
);
let probe = AgentProbe {
pid: std::process::id(),
start_time: None,
tmux_window: Some("🚀 wt/x".to_string()),
tmux_identity: Some(id(Some("/tmp/wedged"), "octl", "@42")),
skip_tmux_check: false,
};
assert_eq!(check_liveness(&probe, &snap), Liveness::Alive);
}
const DEAD_PID: u32 = 0;
#[test]
fn interactive_dead_pid_with_live_window_is_alive() {
let _g = test_env::lock();
let dir = tempfile::TempDir::new().unwrap();
let _e = EnvGuard::set("TMUX_BIN", fake_tmux(dir.path(), &["@42|🚀 wt/x"], 0));
let probe = AgentProbe {
pid: DEAD_PID,
start_time: None,
tmux_window: Some("🚀 wt/x".to_string()),
tmux_identity: Some(id(None, "octl", "@42")),
skip_tmux_check: false,
};
let snap = snapshot(&[None]);
assert_eq!(check_liveness(&probe, &snap), Liveness::Dead);
assert_eq!(
check_liveness_for_lifecycle(&probe, &snap, true),
Liveness::Alive,
"interactive: a stale pid with a live window must not read agent-died"
);
assert_eq!(
check_liveness_for_lifecycle(&probe, &snap, false),
Liveness::Dead,
"autonomous: a dead fire-and-forget agent is genuinely dead"
);
}
#[test]
fn interactive_dead_pid_with_absent_window_is_tmux_gone() {
let _g = test_env::lock();
let dir = tempfile::TempDir::new().unwrap();
let _e = EnvGuard::set("TMUX_BIN", fake_tmux(dir.path(), &["@99|other"], 0));
let probe = AgentProbe {
pid: DEAD_PID,
start_time: None,
tmux_window: Some("🚀 wt/x".to_string()),
tmux_identity: Some(id(None, "octl", "@42")),
skip_tmux_check: false,
};
let snap = snapshot(&[None]);
assert_eq!(
check_liveness_for_lifecycle(&probe, &snap, true),
Liveness::TmuxGone,
"interactive: dead pid + genuinely-absent window terminalizes (streak-gated)"
);
}
#[test]
fn interactive_dead_pid_with_unknown_window_stays_alive() {
let _g = test_env::lock();
let dir = tempfile::TempDir::new().unwrap();
let _e = EnvGuard::set("TMUX_BIN", fake_tmux(dir.path(), &["no server"], 1));
let probe = AgentProbe {
pid: DEAD_PID,
start_time: None,
tmux_window: Some("🚀 wt/x".to_string()),
tmux_identity: Some(id(Some("/tmp/dead-sock"), "octl", "@42")),
skip_tmux_check: false,
};
let snap = snapshot(&[Some("/tmp/dead-sock")]);
assert_eq!(
check_liveness_for_lifecycle(&probe, &snap, true),
Liveness::Alive,
"interactive: an inconclusive tmux probe must not reap a live session"
);
}
#[test]
fn interactive_without_window_falls_back_to_pid() {
let probe = AgentProbe {
pid: DEAD_PID,
start_time: None,
tmux_window: None,
tmux_identity: None,
skip_tmux_check: true,
};
let snap = WatchdogTmuxSnapshot::default();
assert_eq!(
check_liveness_for_lifecycle(&probe, &snap, true),
Liveness::Dead,
"interactive with no window to probe falls back to the PID verdict"
);
}
#[test]
fn interactive_dead_pid_no_window_signal_keeps_pid_verdict() {
let probe = AgentProbe {
pid: DEAD_PID,
start_time: None,
tmux_window: None,
tmux_identity: None,
skip_tmux_check: false,
};
let snap = WatchdogTmuxSnapshot::default();
assert_eq!(
check_liveness_for_lifecycle(&probe, &snap, true),
Liveness::Dead,
"no window signal to re-base on → keep the authoritative PID verdict, \
never mask a dead pid as Alive"
);
}
#[test]
fn interactive_recycled_pid_with_live_window_is_alive() {
let _g = test_env::lock();
let dir = tempfile::TempDir::new().unwrap();
let _e = EnvGuard::set("TMUX_BIN", fake_tmux(dir.path(), &["@42|🚀 wt/x"], 0));
let probe = AgentProbe {
pid: std::process::id(),
start_time: Some(1), tmux_window: Some("🚀 wt/x".to_string()),
tmux_identity: Some(id(None, "octl", "@42")),
skip_tmux_check: false,
};
let snap = snapshot(&[None]);
assert_eq!(check_liveness(&probe, &snap), Liveness::Recycled);
assert_eq!(
check_liveness_for_lifecycle(&probe, &snap, true),
Liveness::Alive,
"interactive: a recycled pid with a live window must not terminalize"
);
assert_eq!(
check_liveness_for_lifecycle(&probe, &snap, false),
Liveness::Recycled,
"autonomous: a recycled pid is authoritative"
);
}
#[test]
fn interactive_live_pid_and_window_is_alive() {
let _g = test_env::lock();
let dir = tempfile::TempDir::new().unwrap();
let _e = EnvGuard::set("TMUX_BIN", fake_tmux(dir.path(), &["@42|🚀 wt/x"], 0));
let probe = AgentProbe {
pid: std::process::id(),
start_time: None,
tmux_window: Some("🚀 wt/x".to_string()),
tmux_identity: Some(id(None, "octl", "@42")),
skip_tmux_check: false,
};
let snap = snapshot(&[None]);
assert_eq!(
check_liveness_for_lifecycle(&probe, &snap, true),
Liveness::Alive
);
}
}