magi-code 0.80.1

Repository-aware CLI coding agent for terminal work
Documentation
//! Wall time spent in foreground work. Children share the parent's interval, not a sum.
use std::{
    path::PathBuf,
    sync::{Arc, Mutex},
    time::{Duration, Instant},
};

#[derive(Debug, Default)]
struct ActiveTime {
    completed: Duration,
    started: Option<Instant>,
}

impl ActiveTime {
    fn elapsed(&self, now: Instant) -> Duration {
        self.completed.saturating_add(
            self.started
                .map_or(Duration::ZERO, |start| now.saturating_duration_since(start)),
        )
    }

    fn finish(&mut self, now: Instant) -> Duration {
        self.completed = self.elapsed(now);
        self.started = None;
        self.completed
    }
}

#[derive(Debug, Clone, Default)]
pub(crate) struct SessionActiveTime(Arc<Mutex<ActiveTime>>);

impl SessionActiveTime {
    pub(crate) fn from_events(events: &[super::SessionEvent]) -> Self {
        let millis = events
            .iter()
            .filter_map(active_time_millis)
            .max()
            .unwrap_or(0);
        Self(Arc::new(Mutex::new(ActiveTime {
            completed: Duration::from_millis(millis),
            started: None,
        })))
    }

    pub(crate) fn elapsed(&self, now: Instant) -> Duration {
        self.0
            .lock()
            .unwrap_or_else(|error| error.into_inner())
            .elapsed(now)
    }

    pub(crate) fn is_active(&self) -> bool {
        self.0
            .lock()
            .unwrap_or_else(|error| error.into_inner())
            .started
            .is_some()
    }

    /// Called by the single foreground worker. No child starts another interval.
    pub(crate) fn start(
        &self,
        session: Option<super::Session>,
        cwd: PathBuf,
        report_error: impl Fn(String) + Send + 'static,
    ) -> ActiveTimeGuard {
        self.0
            .lock()
            .unwrap_or_else(|error| error.into_inner())
            .started = Some(Instant::now());
        ActiveTimeGuard {
            clock: self.clone(),
            session,
            cwd,
            report_error: Box::new(report_error),
        }
    }
}

pub(crate) fn active_time_millis(event: &super::SessionEvent) -> Option<u64> {
    matches!(
        event.kind(),
        Some(super::SessionEventKind::SessionActiveTime | super::SessionEventKind::Compaction)
    )
    .then(|| {
        event
            .payload
            .get("session_active_ms")
            .and_then(serde_json::Value::as_u64)
    })
    .flatten()
}

/// Drop runs on success, cancellation, early error, and unwind, outside the UI thread.
pub(crate) struct ActiveTimeGuard {
    clock: SessionActiveTime,
    session: Option<super::Session>,
    cwd: PathBuf,
    report_error: Box<dyn Fn(String) + Send>,
}

impl Drop for ActiveTimeGuard {
    fn drop(&mut self) {
        let elapsed = self
            .clock
            .0
            .lock()
            .unwrap_or_else(|error| error.into_inner())
            .finish(Instant::now());
        let millis = u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX);
        if let Err(error) = super::record_session_event(
            self.session.as_ref(),
            &self.cwd,
            super::SessionEventKind::SessionActiveTime,
            serde_json::json!({"session_active_ms": millis}),
        ) {
            (self.report_error)(format!("could not save session active time: {error}"));
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn active_time_excludes_idle_and_counts_parent_interval_once() {
        let start = Instant::now();
        let mut time = ActiveTime::default();
        assert_eq!(
            time.elapsed(start + Duration::from_secs(90)),
            Duration::ZERO
        );
        time.started = Some(start + Duration::from_secs(90));
        // Simultaneous tools and children do not contribute separate durations.
        assert_eq!(
            time.finish(start + Duration::from_secs(110)),
            Duration::from_secs(20)
        );
        assert_eq!(
            time.elapsed(start + Duration::from_secs(200)),
            Duration::from_secs(20)
        );
        time.started = Some(start + Duration::from_secs(200));
        assert_eq!(
            time.finish(start + Duration::from_secs(205)),
            Duration::from_secs(25)
        );
    }

    #[test]
    fn active_time_worker_finish_persists_and_rotation_preserves_resume_total() {
        let temp = tempfile::TempDir::new().unwrap();
        let manager = super::super::SessionManager::new(temp.path().join("sessions"));
        let session = manager.create().unwrap();
        let clock = SessionActiveTime::default();
        let guard = clock.start(Some(session.clone()), temp.path().to_owned(), |error| {
            panic!("{error}")
        });
        clock.0.lock().unwrap().started = Some(Instant::now() - Duration::from_secs(858));
        drop(guard);
        assert!(!clock.is_active());
        let events = session.read_events().unwrap();
        let resumed = SessionActiveTime::from_events(&events);
        assert_eq!(resumed.elapsed(Instant::now()).as_secs(), 858);
        let bytes = std::fs::read(session.path()).unwrap();
        let payload =
            super::super::usage::checkpoint_usage(bytes.as_slice(), bytes.len() as u64).unwrap();
        let checkpoint = super::super::SessionEvent::new_kind(
            super::super::SessionEventKind::Compaction,
            session.id().to_string(),
            temp.path().to_owned(),
            payload,
        );
        let rotated = SessionActiveTime::from_events(&[checkpoint]);
        assert_eq!(
            rotated.elapsed(Instant::now()),
            resumed.elapsed(Instant::now())
        );
    }

    #[test]
    fn active_time_resume_restores_total_without_restart_idle() {
        let event = |kind, millis| {
            super::super::SessionEvent::new_kind(
                kind,
                "test".into(),
                PathBuf::from("."),
                serde_json::json!({"session_active_ms": millis}),
            )
        };
        let clock = SessionActiveTime::from_events(&[
            event(super::super::SessionEventKind::Compaction, 858_000),
            event(super::super::SessionEventKind::SessionActiveTime, 860_250),
        ]);
        assert!(!clock.is_active());
        assert_eq!(
            clock.elapsed(Instant::now() + Duration::from_secs(3600)),
            Duration::from_millis(860_250)
        );
    }
}