marver 0.0.20

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.
//!
//! Cancelling a task stops it; archiving one stops *looking* at it. They are
//! two presses of the same key because they are the same intent arriving twice
//! — the first says "not this", the second says "and take it off my screen" —
//! and because the second is only offered once the first has happened.
//!
//! Two things happen, in this order:
//!
//! 1. **The tmux session is killed**, if it is still there. The daemon reaps
//!    the sessions of finished tasks on its own pass, but only while it is
//!    running and only within a tick, and a row that has just left the list is
//!    the worst moment to leave an agent alive behind it. Doing it here means
//!    archiving is also the answer to "that session is still open and I have no
//!    daemon".
//! 2. **The row is marked archived**, which is what takes it off the list.
//!
//! **The worktrees are left alone.** `git worktree remove --force` discards
//! uncommitted changes, and losing an agent's unreviewed work as a side effect
//! of tidying a list would be far worse than a directory left on disk. That is
//! `marver cleanup`, which is a separate act with its own flags — and it still
//! finds these tasks afterwards, because archiving changes nothing about the
//! state it selects on.
//!
//! If the session cannot be killed the task is **not** archived and the failure
//! is reported. Hiding the row while its agent kept running would be the one
//! outcome nobody could have deduced from what they pressed.

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.
///
/// Refused for a task that has not finished — see [`Store::set_task_archived`].
pub fn archive(store: &Store, tmux: &Tmux, task: &Task, now: DateTime<Utc>) -> Result<Task> {
    if let Some(session) = &task.session_name
        && tmux.has_session(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.
///
/// Only the row moves. Its session was killed when it was archived and is not
/// coming back, which is the same thing that is true of any finished task.
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) {
        let mut store = Store::open_in_memory().unwrap();
        let task = store
            .create_task("a task", "do it", Path::new("/tmp/tasks"), &[], 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 (store, task) = store_with_task(TaskState::Cancelled);
        let dir = TempDir::new().unwrap();
        let session = tmux::session_name(task.id);
        server
            .tmux
            .new_session(&session, dir.path(), 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 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)));
    }
}