feat(maestro): infer session activity state + foreground (Active/Idle/AwaitingInput) (#18)
ci / rust (push) Failing after 2m15s
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:
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user