feat(maestro): infer session activity state + foreground (Active/Idle/AwaitingInput) (#18)
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>
This commit is contained in:
2026-06-21 14:51:08 +00:00
co-authored by Claude Opus 4.8
parent c8bf240624
commit fd80ae152a
21 changed files with 580 additions and 9 deletions
+43 -6
View File
@@ -4,7 +4,8 @@ use std::collections::HashMap;
use std::io::Write;
use maestro_protocol::{
ConnectInfo, CreateSessionRequest, DaemonInfo, Session, SessionStatus, WorktreeSpec,
ActivityState, ConnectInfo, CreateSessionRequest, DaemonInfo, Session, SessionStatus,
WorktreeSpec,
};
use serde_json::Value;
use uuid::Uuid;
@@ -126,8 +127,8 @@ fn print_session_table(out: &mut impl Write, sessions: &[Session], daemons: &[Da
writeln!(out, "\n{daemon_id} ({hostname})").ok();
writeln!(
out,
" {:<36} {:<8} {:<20} {}",
"ID", "STATUS", "LABEL", "COMMAND"
" {:<36} {:<8} {:<9} {:<20} {}",
"ID", "STATUS", "ACTIVITY", "LABEL", "COMMAND"
)
.ok();
for s in group {
@@ -140,10 +141,21 @@ fn print_session_table(out: &mut impl Write, sessions: &[Session], daemons: &[Da
SessionStatus::Running => "running",
SessionStatus::Dead => "dead",
};
let activity = match s.activity {
ActivityState::Active => "active",
ActivityState::Idle => "idle",
ActivityState::AwaitingInput => "awaiting",
};
// Show the live foreground command alongside the spawn command when
// the daemon has observed one that differs (NAP-340).
let command = match &s.foreground {
Some(fg) if fg != &s.command => format!("{} [{}]", s.command, fg),
_ => s.command.clone(),
};
writeln!(
out,
" {:<36} {:<8} {:<20} {}",
s.id, status, label, s.command
" {:<36} {:<8} {:<9} {:<20} {command}",
s.id, status, activity, label
)
.ok();
}
@@ -156,7 +168,7 @@ mod tests {
use std::path::PathBuf;
use chrono::{TimeZone, Utc};
use maestro_protocol::{DaemonInfo, Session, SessionStatus};
use maestro_protocol::{ActivityState, DaemonInfo, Session, SessionStatus};
use serde_json::Value;
use uuid::Uuid;
@@ -172,6 +184,9 @@ mod tests {
metadata: meta,
status: SessionStatus::Running,
created_at: Utc.with_ymd_and_hms(2026, 5, 25, 12, 0, 0).unwrap(),
foreground: None,
activity: Default::default(),
last_activity_at: chrono::Utc::now(),
}
}
@@ -205,6 +220,28 @@ mod tests {
assert!(s.contains("vm1 (vm1.lan)"), "{s}");
assert!(s.contains("personal/maestro"), "{s}");
assert!(s.contains("running"), "{s}");
assert!(s.contains("ACTIVITY"), "{s}");
assert!(s.contains("idle"), "{s}");
}
#[test]
fn shows_activity_and_foreground() {
let mut s1 = session("laptop", "work", "bash", 1);
s1.activity = ActivityState::AwaitingInput;
s1.foreground = Some("claude".into()); // differs from spawn command
let daemons = vec![DaemonInfo {
daemon_id: "laptop".into(),
hostname: "h".into(),
version: "0.1.0".into(),
daemon_url: "ws://x".into(),
home_dir: String::new(),
}];
let mut buf = Vec::new();
super::print_session_table(&mut buf, &[s1], &daemons);
let out = String::from_utf8(buf).unwrap();
assert!(out.contains("awaiting"), "{out}");
// Live foreground surfaces next to the spawn command.
assert!(out.contains("bash [claude]"), "{out}");
}
#[test]