marver 0.0.19

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.
//!
//! Pausing means two different things depending on where a task is, and the
//! point of the state is that the user does not have to care which:
//!
//! ```text
//! queued            ──pause──▶  paused  ──resume──▶  queued
//! running | blocked ──pause──▶  paused  ──resume──▶  running
//! ```
//!
//! A task that never launched is simply held out of the queue — nothing to
//! interrupt, nothing to restart. One with a live agent is interrupted, and its
//! concurrency slot is released so something else can run.
//!
//! **Where it goes back to is not stored.** A task paused before launching has
//! no tmux session and one paused mid-work does, which answers the question
//! exactly and cannot drift out of step with reality the way a remembered
//! `resume_state` column could.
//!
//! The uncomfortable part, stated plainly: no hook tells marver that an
//! interrupt landed. Claude Code reports what the agent does, not what is done
//! to it. Pausing therefore *asserts* a state rather than observing one, which
//! is why resuming types a real prompt into the session instead of only setting
//! the state back — an idle agent that marver believes is running would never
//! produce the `Stop` that ends the task, and it would sit there for ever.

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("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.
///
/// A prompt, not a signal: the agent reads it as the user asking it to carry on,
/// because that is the only vocabulary it has. Sending nothing would leave an
/// idle agent behind a task marver has moved back to `running`.
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.
///
/// A queued task is held. A working one is interrupted first — `Escape` is what
/// stops Claude mid-turn — and only then moved, so a failure to reach the
/// session leaves the state untouched rather than claiming a pause that never
/// reached the agent.
pub fn pause(store: &mut Store, tmux: &Tmux, task: &Task, now: DateTime<Utc>) -> Result<Task> {
    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")?;
    }
    Ok(store.transition(task.id, TaskState::Paused, Transition::Plain, now)?)
}

/// Pick a task back up.
///
/// Back to `queued` if it never launched, and to `running` if it did — the
/// session is the evidence, so nothing has to have been remembered.
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));
    }
    if task.session_name.is_none() {
        // Never launched. The scheduler picks it up again on its own terms,
        // subject to the cap like anything else in the queue.
        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"),
            )
            .agent(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(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. Reporting a pause that never
        // reached the agent would be a lie the user acts on.
        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(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. A resume that only flipped the state would leave the task
        // running for ever behind an agent doing nothing.
        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(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(_))
        ));
    }
}