diff --git a/crates/maestro-daemon/src/session.rs b/crates/maestro-daemon/src/session.rs index c2229ee..4070b24 100644 --- a/crates/maestro-daemon/src/session.rs +++ b/crates/maestro-daemon/src/session.rs @@ -597,6 +597,9 @@ impl SessionManager { status: SessionStatus::Running, created_at: Utc::now(), // Activity is unknown on adoption; the watcher fills it in. + // #104: the restart also resets the activity clock to now — + // accepted, since the daemon cannot know how long a + // recovered session had already been sitting idle. foreground: None, activity: ActivityState::Idle, last_activity_at: Utc::now(), @@ -857,6 +860,12 @@ async fn run_activity_pass(inner: &Arc) { { None } else { + // #104: stamp only when the state itself changed — never on + // foreground/cwd-only diffs, and never every tick, or the + // sidebar's duration would reset continuously and read zero. + if rec.session.activity != activity { + rec.session.activity_since = Utc::now(); + } rec.session.activity = activity; rec.session.foreground = foreground; rec.session.current_cwd = current_cwd; diff --git a/crates/maestro-daemon/tests/session_decisions.rs b/crates/maestro-daemon/tests/session_decisions.rs index 3b81634..dae0158 100644 --- a/crates/maestro-daemon/tests/session_decisions.rs +++ b/crates/maestro-daemon/tests/session_decisions.rs @@ -363,6 +363,9 @@ async fn activity_pass_emits_updated_only_when_something_changed() { // Still inside `activity::ACTIVE_WINDOW` (3s) since the spawn, so the // change here is `foreground`, not the activity state. assert_eq!(updated.activity, ActivityState::Active); + // A foreground/cwd-only change is not a state transition (#104): the + // sidebar's duration must keep counting from the original stamp. + assert_eq!(updated.activity_since, session.activity_since); // Drain anything already queued, then confirm a steady pane stays quiet. // Stays well inside the 3s window, so no Active -> Idle transition either. @@ -374,6 +377,70 @@ async fn activity_pass_emits_updated_only_when_something_changed() { ); } +/// The sidebar's duration (#104) is time in the *current* state, so +/// `activity_since` is stamped exactly when the decided state differs from +/// the stored one — once per transition. Stamping it every tick would make +/// the duration permanently read zero. +#[tokio::test] +async fn activity_transition_stamps_activity_since_once_per_transition() { + let fx = Fx::new(); + let session = fx + .manager + .create(fx.request("sleep 300")) + .await + .expect("create"); + fx.fake.set_pane_info( + session.id, + PaneInfo { + // Nothing reads from this pid: `awaiting_input` is deterministically + // false, so past `ACTIVE_WINDOW` the session decides as Idle. + pane_pid: i32::MAX, + command: "sleep".into(), + tty: "/dev/null".into(), + current_path: String::new(), + }, + ); + let mut events = fx.manager.events(); + fx.manager.start_liveness_watcher(Duration::from_millis(25)); + + let idle = wait_for_activity(&fx.manager, session.id, ActivityState::Idle).await; + assert!( + idle.activity_since >= session.activity_since + chrono::Duration::seconds(2), + "the Active -> Idle transition must stamp a fresh activity_since \ + (spawn stamp {:?}, transition stamp {:?})", + session.activity_since, + idle.activity_since + ); + + // Steady Idle across many more ticks must not re-stamp — no further + // SessionUpdated at all, or the sidebar clock would keep resetting. + while events.try_recv().is_ok() {} + tokio::time::sleep(Duration::from_millis(300)).await; + assert!( + matches!(events.try_recv(), Err(TryRecvError::Empty)), + "a steady state must not re-emit (and re-stamp) on every tick" + ); +} + +async fn wait_for_activity( + manager: &SessionManager, + id: uuid::Uuid, + want: ActivityState, +) -> maestro_protocol::Session { + let deadline = tokio::time::Instant::now() + Duration::from_secs(6); + loop { + if tokio::time::Instant::now() >= deadline { + panic!("timed out waiting for {id} to become {want:?}"); + } + if let Some(s) = manager.snapshot().await.into_iter().find(|s| s.id == id) { + if s.activity == want { + return s; + } + } + tokio::time::sleep(Duration::from_millis(20)).await; + } +} + async fn wait_for_updated( events: &mut tokio::sync::broadcast::Receiver, ) -> maestro_protocol::Session {