255 lines
8.3 KiB
Rust
255 lines
8.3 KiB
Rust
//! Activity-state inference combine logic (NAP-340).
|
|
//!
|
|
//! Pure decision from three structured signals — PTY quiescence, foreground
|
|
//! process, and `/proc` block-state — so it's unit-testable without tmux or a
|
|
//! live session. The orchestration (gathering the signals each liveness tick
|
|
//! and emitting `SessionUpdated` on change) lives in [`crate::session`].
|
|
|
|
use std::time::Duration;
|
|
|
|
use maestro_protocol::ActivityState;
|
|
|
|
/// PTY output more recent than this keeps a session `Active`. It must exceed the
|
|
/// liveness tick (~2s, [`crate::session::DEFAULT_LIVENESS_INTERVAL`]) so a
|
|
/// session emitting a chunk every tick stays `Active` instead of flapping
|
|
/// Active↔Idle. This window *is* the debounce the issue calls for.
|
|
pub const ACTIVE_WINDOW: Duration = Duration::from_secs(3);
|
|
|
|
/// How long an agent-reported state (#105) stays in force before inference
|
|
/// resumes. Long enough for a working agent's heartbeat to hold between
|
|
/// reports, short enough that an agent dying mid-run cannot pin its session
|
|
/// at `Error` forever.
|
|
pub const REPORT_TTL: Duration = Duration::from_secs(300);
|
|
|
|
/// Server-side cap (#105) on a reported `detail`, in characters. The meta
|
|
/// line renders it inline, so an unbounded string would own the whole card.
|
|
pub const MAX_REPORT_DETAIL_CHARS: usize = 256;
|
|
|
|
/// An agent-reported activity state (#105) still within its TTL. While one
|
|
/// is [`Self::in_force`], it outranks [`decide`]'s inference; when it
|
|
/// lapses, inference resumes and any reported detail is cleared with it.
|
|
#[derive(Debug, Clone)]
|
|
pub struct ReportedActivity {
|
|
pub state: ActivityState,
|
|
pub expires_at: std::time::Instant,
|
|
}
|
|
|
|
impl ReportedActivity {
|
|
pub fn new(state: ActivityState, ttl: Duration) -> Self {
|
|
Self {
|
|
state,
|
|
expires_at: std::time::Instant::now() + ttl,
|
|
}
|
|
}
|
|
|
|
pub fn in_force(&self, now: std::time::Instant) -> bool {
|
|
now < self.expires_at
|
|
}
|
|
}
|
|
|
|
/// Cap a reported detail to [`MAX_REPORT_DETAIL_CHARS`] characters,
|
|
/// char-boundary safe.
|
|
pub fn truncate_detail(detail: Option<String>) -> Option<String> {
|
|
detail.map(|d| d.chars().take(MAX_REPORT_DETAIL_CHARS).collect())
|
|
}
|
|
|
|
/// Combine the observed signals into an [`ActivityState`]:
|
|
/// recent output ⇒ `Active`; else blocked on a terminal read ⇒ `AwaitingInput`;
|
|
/// else `Idle`.
|
|
pub fn decide(idle_for: Duration, awaiting_input: bool) -> ActivityState {
|
|
if idle_for < ACTIVE_WINDOW {
|
|
ActivityState::Active
|
|
} else if awaiting_input {
|
|
ActivityState::AwaitingInput
|
|
} else {
|
|
ActivityState::Idle
|
|
}
|
|
}
|
|
|
|
/// How long a session's pane must be continuously focused (a client's pane,
|
|
/// not a sidebar highlight) before an `AwaitingInput` glow clears: the
|
|
/// operator has looked, so the rail stops asking for attention.
|
|
pub const FOCUS_CLEAR_HOLD: Duration = Duration::from_secs(1);
|
|
|
|
/// Downgrade `raw` from `AwaitingInput` to `Idle` once the pane has been
|
|
/// focused for at least [`FOCUS_CLEAR_HOLD`].
|
|
///
|
|
/// `suppressed` is the session's sticky acknowledgement, threaded in and
|
|
/// mutated in place: once set, it holds even after the pane loses focus, so
|
|
/// the state stays `Idle` until the session does new work. Only a fresh
|
|
/// `Active` — the session actually running something again — resets it, so
|
|
/// the *next* time inference lands on `AwaitingInput` it glows again. Losing
|
|
/// and regaining focus alone never re-arms it.
|
|
pub fn apply_focus_suppression(
|
|
raw: ActivityState,
|
|
focused_since: Option<std::time::Instant>,
|
|
now: std::time::Instant,
|
|
suppressed: &mut bool,
|
|
) -> ActivityState {
|
|
if raw == ActivityState::Active {
|
|
*suppressed = false;
|
|
}
|
|
if raw != ActivityState::AwaitingInput {
|
|
return raw;
|
|
}
|
|
if let Some(since) = focused_since {
|
|
if now.saturating_duration_since(since) >= FOCUS_CLEAR_HOLD {
|
|
*suppressed = true;
|
|
}
|
|
}
|
|
if *suppressed {
|
|
ActivityState::Idle
|
|
} else {
|
|
raw
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn recent_output_is_active_regardless_of_block_state() {
|
|
assert_eq!(
|
|
decide(Duration::from_millis(100), false),
|
|
ActivityState::Active
|
|
);
|
|
assert_eq!(
|
|
decide(Duration::from_millis(100), true),
|
|
ActivityState::Active
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn quiescent_and_blocked_is_awaiting_input() {
|
|
assert_eq!(
|
|
decide(ACTIVE_WINDOW + Duration::from_secs(1), true),
|
|
ActivityState::AwaitingInput
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn quiescent_and_not_blocked_is_idle() {
|
|
assert_eq!(
|
|
decide(ACTIVE_WINDOW + Duration::from_secs(1), false),
|
|
ActivityState::Idle
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn report_is_in_force_until_exactly_its_expiry() {
|
|
let ttl = Duration::from_secs(300);
|
|
let report = ReportedActivity::new(ActivityState::Error, ttl);
|
|
assert!(report.in_force(report.expires_at - Duration::from_millis(1)));
|
|
assert!(!report.in_force(report.expires_at));
|
|
}
|
|
|
|
#[test]
|
|
fn detail_truncates_to_the_cap_without_panic_on_multibyte_input() {
|
|
let long = "é".repeat(MAX_REPORT_DETAIL_CHARS + 10);
|
|
let capped = truncate_detail(Some(long)).unwrap();
|
|
assert_eq!(capped.chars().count(), MAX_REPORT_DETAIL_CHARS);
|
|
|
|
assert_eq!(truncate_detail(None), None);
|
|
assert_eq!(
|
|
truncate_detail(Some("short".into())).as_deref(),
|
|
Some("short")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn awaiting_input_glows_until_focus_hold_is_reached() {
|
|
let now = std::time::Instant::now();
|
|
let mut suppressed = false;
|
|
let since = now - (FOCUS_CLEAR_HOLD - Duration::from_millis(200));
|
|
assert_eq!(
|
|
apply_focus_suppression(
|
|
ActivityState::AwaitingInput,
|
|
Some(since),
|
|
now,
|
|
&mut suppressed
|
|
),
|
|
ActivityState::AwaitingInput
|
|
);
|
|
assert!(!suppressed);
|
|
}
|
|
|
|
#[test]
|
|
fn awaiting_input_clears_once_focus_hold_is_reached() {
|
|
let now = std::time::Instant::now();
|
|
let mut suppressed = false;
|
|
let since = now - FOCUS_CLEAR_HOLD;
|
|
assert_eq!(
|
|
apply_focus_suppression(
|
|
ActivityState::AwaitingInput,
|
|
Some(since),
|
|
now,
|
|
&mut suppressed
|
|
),
|
|
ActivityState::Idle
|
|
);
|
|
assert!(suppressed);
|
|
}
|
|
|
|
#[test]
|
|
fn unfocused_awaiting_input_never_clears() {
|
|
let now = std::time::Instant::now();
|
|
let mut suppressed = false;
|
|
assert_eq!(
|
|
apply_focus_suppression(ActivityState::AwaitingInput, None, now, &mut suppressed),
|
|
ActivityState::AwaitingInput
|
|
);
|
|
assert!(!suppressed);
|
|
}
|
|
|
|
#[test]
|
|
fn suppression_survives_losing_focus() {
|
|
let now = std::time::Instant::now();
|
|
let mut suppressed = true;
|
|
// Focus dropped (`None`) but the session hasn't done new work.
|
|
assert_eq!(
|
|
apply_focus_suppression(ActivityState::AwaitingInput, None, now, &mut suppressed),
|
|
ActivityState::Idle
|
|
);
|
|
assert!(suppressed);
|
|
}
|
|
|
|
#[test]
|
|
fn a_fresh_active_cycle_clears_the_suppression() {
|
|
let now = std::time::Instant::now();
|
|
let mut suppressed = true;
|
|
assert_eq!(
|
|
apply_focus_suppression(ActivityState::Active, None, now, &mut suppressed),
|
|
ActivityState::Active
|
|
);
|
|
assert!(!suppressed);
|
|
|
|
// Blocked again, freshly focused: glows until the hold is reached again.
|
|
assert_eq!(
|
|
apply_focus_suppression(
|
|
ActivityState::AwaitingInput,
|
|
Some(now),
|
|
now,
|
|
&mut suppressed
|
|
),
|
|
ActivityState::AwaitingInput
|
|
);
|
|
assert!(!suppressed);
|
|
}
|
|
|
|
#[test]
|
|
fn idle_and_error_pass_through_untouched() {
|
|
let now = std::time::Instant::now();
|
|
let mut suppressed = false;
|
|
assert_eq!(
|
|
apply_focus_suppression(ActivityState::Idle, Some(now), now, &mut suppressed),
|
|
ActivityState::Idle
|
|
);
|
|
assert_eq!(
|
|
apply_focus_suppression(ActivityState::Error, Some(now), now, &mut suppressed),
|
|
ActivityState::Error
|
|
);
|
|
assert!(!suppressed);
|
|
}
|
|
}
|