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>;
pub const RESUME_PROMPT: &str = "continue";
pub const PAUSABLE: &[TaskState] = &[TaskState::Queued, TaskState::Running, TaskState::Blocked];
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() {
agent::press(tmux, task, "Escape")?;
}
Ok(store.transition(task.id, TaskState::Paused, Transition::Plain, now)?)
}
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() {
return Ok(store.transition(task.id, TaskState::Queued, Transition::Plain, now)?);
}
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()
}
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()
}
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()
}
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() {
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() {
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() {
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(_))
));
}
}