marver 0.0.28

A TUI workspace for AI agent sessions: tmux orchestration, git worktree management, and repo control in one place.
Documentation
//! Setting a task aside, and picking it back up.
//!
//! ```text
//! queued            ──pause──▶  paused  ──resume──▶  queued
//! running | blocked ──pause──▶  paused  ──resume──▶  running
//! ```
//!
//! A task that never launched is held out of the queue; one with a live agent is
//! interrupted and its slot released. Which one it is comes from whether the
//! task has a tmux session, so nothing can drift out of step the way a
//! remembered `resume_state` column could.
//!
//! No hook confirms an interrupt landed — Claude Code reports what an agent
//! does, not what is done to it — so pausing *asserts* a state. That is why
//! resuming types a real prompt into the session: an idle agent behind a task
//! marver believes is running would never produce the `Stop` that ends it.

use chrono::{DateTime, Utc};

use crate::agent;
use crate::domain::{Task, TaskState};
use crate::store::{Store, Transition};
use crate::tmux::Tmux;

#[derive(Debug, thiserror::Error)]
pub enum Error {
    #[error(transparent)]
    Store(#[from] crate::store::Error),
    #[error(transparent)]
    Agent(#[from] crate::agent::Error),
    #[error(transparent)]
    Tmux(#[from] crate::tmux::Error),
    #[error("a {0} task cannot be paused")]
    NotPausable(TaskState),
    #[error("task {0} is not paused")]
    NotPaused(i64),
}

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

/// What is typed into a resumed agent's session.
pub const RESUME_PROMPT: &str = "continue";

/// The states a task can be paused from.
pub const PAUSABLE: &[TaskState] = &[TaskState::Queued, TaskState::Running, TaskState::Blocked];

/// Set a task aside.
pub fn pause(store: &mut Store, tmux: &Tmux, task: &Task, now: DateTime<Utc>) -> Result<Task> {
    // Re-read before deciding. The caller's `Task` is a snapshot from a frame
    // ago, and the interesting gap is the launch: a task queued when the row
    // was read may be running with a session by now, and deciding "no session,
    // nothing to interrupt" from the old copy left a live agent working behind
    // a task marked paused — with its slot handed back to the scheduler.
    let task = &store.get_task(task.id)?;
    if !PAUSABLE.contains(&task.state) {
        return Err(Error::NotPausable(task.state));
    }
    if task.session_name.is_some() {
        // `Escape` is what stops Claude mid-turn.
        agent::press(tmux, task, "Escape")?;
    }
    // `transition_from`, naming the state this decision was made in: a row that
    // moved between the read above and here is refused rather than overwritten.
    Ok(store.transition_from(task.id, task.state, TaskState::Paused, Transition::Plain, now)?)
}

/// Pick a task back up.
pub fn resume(store: &mut Store, tmux: &Tmux, task: &Task, now: DateTime<Utc>) -> Result<Task> {
    if task.state != TaskState::Paused {
        return Err(Error::NotPaused(task.id));
    }
    // Whether there is a session to type into, not whether a name was once
    // written down. A paused task whose agent has since died has a name and no
    // session: it took the live branch, failed to type into nothing, and could
    // not leave `paused` by any route the user could see.
    let live = match task.session_name.as_deref() {
        None => false,
        Some(name) => tmux.session_exists(name)?,
    };
    if !live {
        // Never launched, or nothing left of what did.
        return Ok(store.transition(task.id, TaskState::Queued, Transition::Plain, now)?);
    }

    // Typed before the transition, for the same reason the interrupt is: a
    // task reported as running while its agent sits idle is the one outcome
    // with no way back, since nothing but a hook will move it again.
    agent::say(tmux, task, RESUME_PROMPT)?;

    Ok(store.transition(task.id, TaskState::Running, Transition::Plain, now)?)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::domain::{BlockedKind, Repo};
    use crate::git::testing::init_repo;
    use crate::launcher::{Launcher, testing::stub_agent};
    use crate::store::BlockedInfo;
    use crate::tmux::{self, testing::TestServer};
    use crate::worktree::WorktreeManager;
    use std::path::PathBuf;
    use tempfile::TempDir;

    fn at(secs: i64) -> DateTime<Utc> {
        DateTime::from_timestamp(secs, 0).expect("valid timestamp")
    }

    struct Fixture {
        tmp: TempDir,
        server: TestServer,
        store: Store,
        launcher: Launcher,
        repos_dir: PathBuf,
    }

    impl Fixture {
        fn new() -> Self {
            let tmp = TempDir::new().unwrap();
            let server = TestServer::new();
            let repos_dir = tmp.path().join("repos");
            std::fs::create_dir_all(&repos_dir).unwrap();
            let launcher = Launcher::new(
                server.tmux.clone(),
                WorktreeManager::new(tmp.path().join("tasks")),
                PathBuf::from("/usr/local/bin/marver"),
                tmp.path().join("hooks.sock"),
            )
            .harness(stub_agent(tmp.path()));
            Self {
                store: Store::open_in_memory().unwrap(),
                launcher,
                server,
                repos_dir,
                tmp,
            }
        }

        fn repo(&self, name: &str) -> Repo {
            let path = self.repos_dir.join(name);
            init_repo(&path, "main");
            self.store.upsert_repo(&path, name, at(0)).unwrap()
        }

        /// A queued task, owning nothing yet.
        fn queued(&mut self, title: &str) -> Task {
            let repo = self.repo(title);
            let root = self.tmp.path().join("tasks");
            self.store
                .create_task(title, "do the thing", &root, &[repo.id], at(0))
                .unwrap()
        }

        /// A task with a real agent in a real tmux session.
        fn running(&mut self, title: &str) -> Task {
            let task = self.queued(title);
            self.launcher.launch(&mut self.store, &task, at(1)).unwrap();
            self.store.get_task(task.id).unwrap()
        }

        /// An owned handle, so a test can hold it across a `&mut store`.
        fn tmux(&self) -> Tmux {
            self.server.tmux.clone()
        }
    }

    #[test]
    fn a_queued_task_is_held_without_touching_tmux() {
        let mut fx = Fixture::new();
        let tmux = fx.tmux();
        let task = fx.queued("not yet");

        let paused = pause(&mut fx.store, &tmux, &task, at(2)).unwrap();

        assert_eq!(paused.state, TaskState::Paused);
        assert!(paused.session_name.is_none(), "nothing was started");
    }

    #[test]
    fn a_held_task_returns_to_the_queue() {
        let mut fx = Fixture::new();
        let tmux = fx.tmux();
        let task = fx.queued("not yet");
        let task = pause(&mut fx.store, &tmux, &task, at(2)).unwrap();

        let resumed = resume(&mut fx.store, &tmux, &task, at(3)).unwrap();

        assert_eq!(
            resumed.state,
            TaskState::Queued,
            "a task that never launched has nothing to run"
        );
    }

    #[test]
    fn a_working_task_is_interrupted_and_returns_to_running() {
        let mut fx = Fixture::new();
        let tmux = fx.tmux();
        let task = fx.running("working");
        assert_eq!(task.state, TaskState::Queued, "launch does not transition");
        let task = fx
            .store
            .transition(task.id, TaskState::Running, Transition::Plain, at(2))
            .unwrap();

        let paused = pause(&mut fx.store, &tmux, &task, at(3)).unwrap();
        assert_eq!(paused.state, TaskState::Paused);
        assert!(
            tmux.session_exists(&tmux::session_name(None, task.id))
                .unwrap(),
            "pausing must not kill the session it means to return to"
        );

        let resumed = resume(&mut fx.store, &tmux, &paused, at(4)).unwrap();
        assert_eq!(
            resumed.state,
            TaskState::Running,
            "a task with a session resumes into it"
        );
    }

    #[test]
    fn a_blocked_task_can_be_set_aside_instead_of_answered() {
        // The slot is the point: blocked holds one, so a task waiting on a
        // question the user is not ready to answer would otherwise keep the
        // queue behind it.
        let mut fx = Fixture::new();
        let tmux = fx.tmux();
        let task = fx.running("asking");
        fx.store
            .transition(task.id, TaskState::Running, Transition::Plain, at(2))
            .unwrap();
        let task = fx
            .store
            .transition(
                task.id,
                TaskState::Blocked,
                Transition::Blocked(BlockedInfo::new(BlockedKind::Question)),
                at(3),
            )
            .unwrap();

        let paused = pause(&mut fx.store, &tmux, &task, at(4)).unwrap();

        assert_eq!(paused.state, TaskState::Paused);
        assert!(
            paused.blocked_kind.is_none(),
            "the question is no longer outstanding, so its details go with it"
        );
    }

    #[test]
    fn a_finished_task_cannot_be_paused() {
        let mut fx = Fixture::new();
        let tmux = fx.tmux();
        let task = fx.queued("done");
        fx.store
            .transition(task.id, TaskState::Cancelled, Transition::Plain, at(2))
            .unwrap();
        let task = fx.store.get_task(task.id).unwrap();

        assert!(matches!(
            pause(&mut fx.store, &tmux, &task, at(3)),
            Err(Error::NotPausable(TaskState::Cancelled))
        ));
    }

    #[test]
    fn pausing_a_task_whose_session_died_leaves_the_state_alone() {
        // The interrupt comes first on purpose.
        let mut fx = Fixture::new();
        let tmux = fx.tmux();
        let task = fx.running("doomed");
        let task = fx
            .store
            .transition(task.id, TaskState::Running, Transition::Plain, at(2))
            .unwrap();
        fx.tmux()
            .kill_session(&tmux::session_name(None, task.id))
            .unwrap();

        let err = pause(&mut fx.store, &tmux, &task, at(3));

        assert!(
            matches!(err, Err(Error::Agent(agent::Error::NoSession { .. }))),
            "{err:?}"
        );
        assert_eq!(
            fx.store.get_task(task.id).unwrap().state,
            TaskState::Running,
            "the state must not move when the agent was never told"
        );
    }

    #[test]
    fn resuming_types_a_prompt_rather_than_only_setting_the_state() {
        // Nothing hooks an interrupt, so the agent is idle and no `Stop` is
        // coming.
        let mut fx = Fixture::new();
        let tmux = fx.tmux();
        let task = fx.running("working");
        let task = fx
            .store
            .transition(task.id, TaskState::Running, Transition::Plain, at(2))
            .unwrap();
        let paused = pause(&mut fx.store, &tmux, &task, at(3)).unwrap();

        resume(&mut fx.store, &tmux, &paused, at(4)).unwrap();

        let session = tmux::session_name(None, task.id);
        let pane = tmux.list_panes(&session).unwrap();
        let mut seen = String::new();
        for _ in 0..60 {
            seen = tmux.capture_pane(&pane[0]).unwrap();
            if seen.contains(RESUME_PROMPT) {
                return;
            }
            std::thread::sleep(std::time::Duration::from_millis(50));
        }
        panic!("the resume prompt never reached the agent: {seen:?}");
    }

    #[test]
    fn resuming_something_that_is_not_paused_is_refused() {
        let mut fx = Fixture::new();
        let tmux = fx.tmux();
        let task = fx.queued("queued");
        assert!(matches!(
            resume(&mut fx.store, &tmux, &task, at(2)),
            Err(Error::NotPaused(_))
        ));
    }
}