ci / rust (push) Failing after 2m15s
Add an orthogonal activity dimension to sessions, inferred entirely from
structured tmux/`/proc` state — no PTY byte-stream parsing, no work on the
attach hot path.
Protocol: `ActivityState` enum (Active/Idle/AwaitingInput) + three additive,
serde-defaulted `Session` fields (`foreground`, `activity`, `last_activity_at`)
so the snapshot/diff flow stays forward-compatible with older daemons.
Detection (piggybacks the existing ~2s liveness tick, no new timer):
- foreground command via `tmux list-panes -F '#{pane_pid} #{pane_current_command}
#{pane_tty}'`;
- quiescence from a per-`PtyBridge` last-read `Instant`, bumped in `run_reader`;
- AwaitingInput via best-effort `/proc` walk to the deepest descendant: state
`S` + a non-timer `wchan` + an stdio fd on the pane tty. (`/proc/pid/syscall`
is ptrace-gated and unreadable for own sessions, so `wchan` distinguishes a
blocking read from a plain `sleep`.)
- combine: recent output → Active; else input-blocked → AwaitingInput; else
Idle. Emit `SessionUpdated` only on (activity, foreground) change — no diff
storms at steady state.
`maestro list` gains an ACTIVITY column and shows the live foreground. Pure
combine/parse logic is unit-tested; an e2e asserts `cat` → AwaitingInput and
`sleep` → Idle with correct foreground against real tmux.
Closes #18
Closes #8
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
130 lines
4.9 KiB
Rust
130 lines
4.9 KiB
Rust
//! Best-effort `/proc` inspection for the activity watcher (NAP-340, Linux).
|
|
//!
|
|
//! Decides whether a session is **blocked waiting on the user**: walk from the
|
|
//! pane's process down to the deepest descendant (the agent/shell actually on
|
|
//! the terminal) and check it's parked waiting on terminal input.
|
|
//!
|
|
//! `/proc/<pid>/syscall` would say so exactly, but it's ptrace-gated and
|
|
//! unreadable for our own sessions on a default kernel, so we combine three
|
|
//! *unprivileged* signals instead:
|
|
//!
|
|
//! 1. process state `S` (interruptible sleep — where a blocking `read()` parks),
|
|
//! 2. its `wchan` is not a pure timer sleep (so `sleep 30`, which also sits in
|
|
//! `S` with the terminal inherited, is excluded), and
|
|
//! 3. one of its stdio fds is the pane's tty.
|
|
//!
|
|
//! Every step degrades to `false` on a read failure, so a non-Linux host or a
|
|
//! denied `/proc` read falls back to `Idle`.
|
|
|
|
/// Whether the process tree under `pane_pid` looks blocked on terminal input
|
|
/// from `tty` (e.g. `/dev/pts/3`) — the signal for `AwaitingInput`.
|
|
pub fn awaiting_input(pane_pid: i32, tty: &str) -> bool {
|
|
let leaf = deepest_descendant(pane_pid);
|
|
// Interruptible sleep is where a blocking read parks; anything else
|
|
// (running, uninterruptible) is busy, not waiting on us.
|
|
if read_state(leaf) != Some('S') {
|
|
return false;
|
|
}
|
|
// A pure timer sleep (`sleep`, `usleep`) also sits in 'S' with the terminal
|
|
// inherited — exclude it so it reads as Idle, not AwaitingInput.
|
|
if is_timer_sleep(leaf) {
|
|
return false;
|
|
}
|
|
fd_points_at_tty(leaf, tty)
|
|
}
|
|
|
|
/// Follow the (last) child link down the process tree to the deepest
|
|
/// descendant. Bounded so a pathological/cyclic tree can't loop.
|
|
fn deepest_descendant(pid: i32) -> i32 {
|
|
let mut cur = pid;
|
|
for _ in 0..64 {
|
|
match last_child(cur) {
|
|
Some(child) if child != cur => cur = child,
|
|
_ => break,
|
|
}
|
|
}
|
|
cur
|
|
}
|
|
|
|
/// The most-recently-created child of `pid`, via
|
|
/// `/proc/<pid>/task/<pid>/children` (a space-separated pid list). The last
|
|
/// entry is the newest child — typically the foreground one an agent spawned.
|
|
fn last_child(pid: i32) -> Option<i32> {
|
|
let contents = std::fs::read_to_string(format!("/proc/{pid}/task/{pid}/children")).ok()?;
|
|
contents.split_whitespace().last()?.parse().ok()
|
|
}
|
|
|
|
/// Read the process state char from `/proc/<pid>/stat`.
|
|
fn read_state(pid: i32) -> Option<char> {
|
|
parse_stat_state(&std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?)
|
|
}
|
|
|
|
/// Extract the state field from a `/proc/<pid>/stat` line. The format is
|
|
/// `pid (comm) STATE ...`; `comm` can itself contain spaces and parens, so the
|
|
/// state is the first non-space char *after the final `)`*.
|
|
fn parse_stat_state(stat: &str) -> Option<char> {
|
|
let close = stat.rfind(')')?;
|
|
stat[close + 1..].trim_start().chars().next()
|
|
}
|
|
|
|
/// Whether `pid`'s `wchan` (the kernel symbol it's blocked in) is a timer
|
|
/// sleep — `hrtimer_nanosleep`, `do_nanosleep`, etc. Those are quiescent but
|
|
/// *not* waiting on the user.
|
|
fn is_timer_sleep(pid: i32) -> bool {
|
|
std::fs::read_to_string(format!("/proc/{pid}/wchan"))
|
|
.map(|w| is_timer_wchan(&w))
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
/// Classify a `wchan` symbol as a pure timer sleep. Kept pure for testing.
|
|
fn is_timer_wchan(wchan: &str) -> bool {
|
|
wchan.contains("nanosleep")
|
|
}
|
|
|
|
/// Whether any of `pid`'s stdio fds (0/1/2) is the terminal `tty`.
|
|
fn fd_points_at_tty(pid: i32, tty: &str) -> bool {
|
|
(0..3).any(|fd| {
|
|
std::fs::read_link(format!("/proc/{pid}/fd/{fd}"))
|
|
.map(|target| target.to_string_lossy() == tty)
|
|
.unwrap_or(false)
|
|
})
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn parse_stat_state_handles_simple_and_tricky_comm() {
|
|
assert_eq!(parse_stat_state("1234 (bash) S 1 1234 ..."), Some('S'));
|
|
assert_eq!(parse_stat_state("1234 (sleep) S 1 ..."), Some('S'));
|
|
assert_eq!(parse_stat_state("99 (cat) R 1 ..."), Some('R'));
|
|
// comm with spaces and a paren — only the final ')' delimits it.
|
|
assert_eq!(parse_stat_state("42 (weird (name)) D 1 42 ..."), Some('D'));
|
|
}
|
|
|
|
#[test]
|
|
fn parse_stat_state_rejects_garbage() {
|
|
assert_eq!(parse_stat_state("not a stat line"), None);
|
|
assert_eq!(parse_stat_state("123 (comm)"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn timer_wchan_excludes_sleep_but_not_a_tty_wait() {
|
|
// `sleep` blocks here — excluded.
|
|
assert!(is_timer_wchan("hrtimer_nanosleep"));
|
|
assert!(is_timer_wchan("do_nanosleep"));
|
|
// A terminal read / poll parks here — not a timer.
|
|
assert!(!is_timer_wchan("wait_woken"));
|
|
assert!(!is_timer_wchan("n_tty_read"));
|
|
assert!(!is_timer_wchan("do_select"));
|
|
assert!(!is_timer_wchan("0"));
|
|
}
|
|
|
|
#[test]
|
|
fn awaiting_input_is_false_for_nonexistent_pid() {
|
|
// A pid that can't exist → every read fails → false (fall back to Idle).
|
|
assert!(!awaiting_input(i32::MAX, "/dev/pts/0"));
|
|
}
|
|
}
|