marver 0.0.28

A TUI workspace for AI agent sessions: tmux orchestration, git worktree management, and repo control in one place.
Documentation
//! Putting a finished task away, and getting it back.

use chrono::{DateTime, Utc};

use crate::domain::Task;
use crate::store::Store;
use crate::tmux::Tmux;

#[derive(Debug, thiserror::Error)]
pub enum Error {
    #[error(transparent)]
    Store(#[from] crate::store::Error),
    #[error("could not kill the session of task {id}: {source}")]
    Session {
        id: i64,
        #[source]
        source: crate::tmux::Error,
    },
}

pub type Result<T> = std::result::Result<T, Error>;

/// Kill what is left of a finished task and take it off the list.
pub fn archive(store: &Store, tmux: &Tmux, task: &Task, now: DateTime<Utc>) -> Result<Task> {
    if let Some(session) = &task.session_name {
        // Asked rather than assumed. A tmux that cannot be reached used to read
        // as a session already gone, which took the task off the list while its
        // agent was still working — the thing this refuses to do.
        let live = tmux
            .session_exists(session)
            .map_err(|source| Error::Session {
                id: task.id,
                source,
            })?;
        // The ownership check is the same one `shut_down` makes: a session that
        // is not working in this task's workspace is not this one's to kill.
        if live && crate::agent::owns_session(tmux, task, session) {
            tmux.kill_session(session)
                .map_err(|source| Error::Session {
                    id: task.id,
                    source,
                })?;
            // Recorded, so nothing looks for it again — the daemon's reap asks
            // this same question on every pass.
            store.clear_session_name(task.id, now)?;
        }
    }
    Ok(store.set_task_archived(task.id, true, now)?)
}

/// Put an archived task back on the list.
pub fn restore(store: &Store, task: &Task, now: DateTime<Utc>) -> Result<Task> {
    Ok(store.set_task_archived(task.id, false, now)?)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::domain::TaskState;
    use crate::store::Transition;
    use crate::tmux::{self, testing::TestServer};
    use std::path::Path;
    use tempfile::TempDir;

    fn at(secs: i64) -> DateTime<Utc> {
        DateTime::from_timestamp(secs, 0).unwrap()
    }

    fn store_with_task(state: TaskState) -> (Store, Task) {
        store_with_task_in(state, Path::new("/tmp/tasks"))
    }

    /// A task whose workspace is under `root`, for the tests that need a
    /// session sitting in it.
    fn store_with_task_in(state: TaskState, root: &Path) -> (Store, Task) {
        let mut store = Store::open_in_memory().unwrap();
        let task = store
            .create_task("a task", "do it", root, &[], at(0))
            .unwrap();
        if state != TaskState::Queued {
            store
                .transition(task.id, TaskState::Cancelled, Transition::Plain, at(1))
                .unwrap();
        }
        let task = store.get_task(task.id).unwrap();
        (store, task)
    }

    #[test]
    fn a_finished_task_leaves_the_list_and_keeps_how_it_ended() {
        let (store, task) = store_with_task(TaskState::Cancelled);

        let archived = archive(&store, &Tmux::new(), &task, at(2)).unwrap();

        assert!(archived.is_archived());
        assert_eq!(archived.archived_at, Some(at(2)));
        assert_eq!(
            archived.state,
            TaskState::Cancelled,
            "the archive still says how it ended"
        );
    }

    #[test]
    fn a_task_that_is_still_going_cannot_be_hidden() {
        // Hiding a live task is a way to lose an agent.
        let (store, task) = store_with_task(TaskState::Queued);

        let err = archive(&store, &Tmux::new(), &task, at(2)).unwrap_err();

        assert!(
            err.to_string().contains("still going"),
            "{err} should say why"
        );
        assert!(!store.get_task(task.id).unwrap().is_archived());
    }

    #[test]
    fn archiving_kills_the_session_and_forgets_it() {
        let server = TestServer::new();
        let dir = TempDir::new().unwrap();
        let (store, task) = store_with_task_in(TaskState::Cancelled, &dir.path().join("tasks"));
        std::fs::create_dir_all(&task.workspace_dir).unwrap();
        let session = tmux::session_name(None, task.id);
        server
            .tmux
            .new_session(&session, &task.workspace_dir, tmux::DEFAULT_SIZE)
            .unwrap();
        store.set_session_name(task.id, &session, at(1)).unwrap();
        let task = store.get_task(task.id).unwrap();

        let archived = archive(&store, &server.tmux, &task, at(2)).unwrap();

        assert!(!server.tmux.has_session(&session), "the agent is gone");
        assert_eq!(
            archived.session_name, None,
            "and nothing goes looking for it again"
        );
        assert!(archived.is_archived());
    }

    #[test]
    fn archiving_spares_a_session_that_only_shares_the_name() {
        // Sessions are named from the task id alone and one tmux server serves
        // the machine, so another data directory's task 1 answers to this name.
        let server = TestServer::new();
        let dir = TempDir::new().unwrap();
        let (store, task) = store_with_task_in(TaskState::Cancelled, &dir.path().join("tasks"));
        let elsewhere = dir.path().join("someone-else");
        std::fs::create_dir_all(&elsewhere).unwrap();
        let session = tmux::session_name(None, task.id);
        server
            .tmux
            .new_session(&session, &elsewhere, tmux::DEFAULT_SIZE)
            .unwrap();
        store.set_session_name(task.id, &session, at(1)).unwrap();
        let task = store.get_task(task.id).unwrap();

        let archived = archive(&store, &server.tmux, &task, at(2)).unwrap();

        assert!(
            server.tmux.has_session(&session),
            "a session marver did not open must survive being archived over"
        );
        assert!(archived.is_archived());
    }

    #[test]
    fn a_session_that_will_not_die_leaves_the_task_on_the_list() {
        // The row must not go quiet while its agent keeps running.
        let server = TestServer::new();
        let (store, task) = store_with_task(TaskState::Cancelled);
        store
            .set_session_name(task.id, "never-existed", at(1))
            .unwrap();
        let task = store.get_task(task.id).unwrap();

        // No such session, so nothing to kill and nothing to fail: the task is
        // archived and its dead name forgotten only if it was really there.
        let archived = archive(&store, &server.tmux, &task, at(2)).unwrap();
        assert!(archived.is_archived());
        assert_eq!(
            archived.session_name.as_deref(),
            Some("never-existed"),
            "a name for a session that is not there is left as it was"
        );
    }

    #[test]
    fn restoring_puts_it_back() {
        let (store, task) = store_with_task(TaskState::Cancelled);
        let archived = archive(&store, &Tmux::new(), &task, at(2)).unwrap();

        let back = restore(&store, &archived, at(3)).unwrap();

        assert!(!back.is_archived());
        assert_eq!(back.state, TaskState::Cancelled);
    }

    #[test]
    fn archiving_twice_keeps_the_date_it_first_went_away() {
        let (store, task) = store_with_task(TaskState::Cancelled);
        let first = archive(&store, &Tmux::new(), &task, at(2)).unwrap();

        let again = archive(&store, &Tmux::new(), &first, at(9)).unwrap();

        assert_eq!(again.archived_at, Some(at(2)));
    }
}