This repository has been archived on 2026-10-08. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
naps62-yoloandClaude Opus 4.8 fd80ae152a
ci / rust (push) Failing after 2m15s
feat(maestro): infer session activity state + foreground (Active/Idle/AwaitingInput) (#18)
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>
2026-06-21 14:51:08 +00:00

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"));
}
}