use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Mutex;
use chrono::Utc;
use tempfile::TempDir;
use super::manager::{ManagedError, ManagedTmuxDriver, SessionManager};
use super::record::{ManagedSessionId, ManagedSessionState, SessionRecord};
use std::sync::Arc;
pub struct FakeTmuxDriver {
sessions: Mutex<HashMap<String, String>>,
pub send_calls: Mutex<Vec<(String, String)>>,
pub kill_calls: Mutex<Vec<String>>,
pub capture_responses: Mutex<HashMap<String, String>>,
pub seeded_names: Mutex<Vec<String>>,
pub create_cwd_calls: Mutex<Vec<(String, String)>>,
pub graceful_stop_calls: Mutex<Vec<(String, Option<u32>)>>,
pub interrupt_calls: Mutex<Vec<String>>,
pub pane_cwd_override: Mutex<Option<PathBuf>>,
pub pane_id_override: Mutex<Option<String>>,
pub pane_exists_override: Mutex<Option<bool>>,
pub pane_send_calls: Mutex<Vec<(String, String, String)>>,
pub pane_literal_calls: Mutex<Vec<(String, String, String)>>,
pub pane_interrupt_calls: Mutex<Vec<(String, String)>>,
pub pane_capture_calls: Mutex<Vec<(String, String)>>,
pub rename_calls: Mutex<Vec<(String, String)>>,
pub attached_names: Mutex<Vec<String>>,
pub ensure_server_up_calls: Mutex<u32>,
pub ensure_server_up_should_fail: Mutex<bool>,
}
impl FakeTmuxDriver {
pub fn new() -> Arc<Self> {
Arc::new(Self {
sessions: Mutex::new(HashMap::new()),
send_calls: Mutex::new(Vec::new()),
kill_calls: Mutex::new(Vec::new()),
capture_responses: Mutex::new(HashMap::new()),
seeded_names: Mutex::new(Vec::new()),
create_cwd_calls: Mutex::new(Vec::new()),
graceful_stop_calls: Mutex::new(Vec::new()),
interrupt_calls: Mutex::new(Vec::new()),
pane_cwd_override: Mutex::new(None),
pane_id_override: Mutex::new(None),
pane_exists_override: Mutex::new(None),
pane_send_calls: Mutex::new(Vec::new()),
pane_literal_calls: Mutex::new(Vec::new()),
pane_interrupt_calls: Mutex::new(Vec::new()),
pane_capture_calls: Mutex::new(Vec::new()),
rename_calls: Mutex::new(Vec::new()),
attached_names: Mutex::new(Vec::new()),
ensure_server_up_calls: Mutex::new(0),
ensure_server_up_should_fail: Mutex::new(false),
})
}
}
impl ManagedTmuxDriver for FakeTmuxDriver {
fn ensure_server_up(&self) -> Result<(), ManagedError> {
*self.ensure_server_up_calls.lock().unwrap() += 1;
if *self.ensure_server_up_should_fail.lock().unwrap() {
return Err(ManagedError::TmuxUnavailable(
"fake: tmux server absent and could not be started".into(),
));
}
Ok(())
}
fn create_session(&self, name: &str, workdir: &str) -> Result<(), ManagedError> {
self.sessions
.lock()
.unwrap()
.insert(name.to_owned(), workdir.to_owned());
self.create_cwd_calls
.lock()
.unwrap()
.push((name.to_owned(), workdir.to_owned()));
Ok(())
}
fn kill_session(&self, name: &str) -> Result<(), ManagedError> {
self.kill_calls.lock().unwrap().push(name.to_owned());
self.sessions.lock().unwrap().remove(name);
Ok(())
}
fn rename_session(&self, old: &str, new: &str) -> Result<(), ManagedError> {
self.rename_calls
.lock()
.unwrap()
.push((old.to_owned(), new.to_owned()));
let mut sessions = self.sessions.lock().unwrap();
if let Some(workdir) = sessions.remove(old) {
sessions.insert(new.to_owned(), workdir);
}
Ok(())
}
fn attached_session_names(&self) -> Vec<String> {
self.attached_names.lock().unwrap().clone()
}
fn send_line(&self, name: &str, text: &str) -> Result<(), ManagedError> {
self.send_calls
.lock()
.unwrap()
.push((name.to_owned(), text.to_owned()));
Ok(())
}
fn capture(&self, name: &str, _lines: usize) -> Result<String, ManagedError> {
Ok(self
.capture_responses
.lock()
.unwrap()
.get(name)
.cloned()
.unwrap_or_default())
}
fn list_sessions(&self) -> Result<Vec<String>, ManagedError> {
let mut names: Vec<String> = self.sessions.lock().unwrap().keys().cloned().collect();
names.extend(self.seeded_names.lock().unwrap().iter().cloned());
Ok(names)
}
fn graceful_stop(&self, name: &str, claude_pid: Option<u32>) -> Result<(), ManagedError> {
self.graceful_stop_calls
.lock()
.unwrap()
.push((name.to_owned(), claude_pid));
self.kill_session(name)
}
fn send_interrupt(&self, name: &str) -> Result<(), ManagedError> {
self.interrupt_calls.lock().unwrap().push(name.to_owned());
Ok(())
}
fn get_pane_cwd(&self, _name: &str) -> Option<PathBuf> {
self.pane_cwd_override.lock().unwrap().clone()
}
fn get_pane_id(&self, _name: &str) -> Option<String> {
self.pane_id_override.lock().unwrap().clone()
}
fn pane_exists(&self, _name: &str, _pane_id: &str) -> bool {
self.pane_exists_override.lock().unwrap().unwrap_or(true)
}
fn send_line_to_pane(&self, name: &str, pane_id: &str, text: &str) -> Result<(), ManagedError> {
self.pane_send_calls.lock().unwrap().push((
name.to_owned(),
pane_id.to_owned(),
text.to_owned(),
));
Ok(())
}
fn send_keys_literal(&self, name: &str, text: &str) -> Result<(), ManagedError> {
self.send_calls
.lock()
.unwrap()
.push((name.to_owned(), text.to_owned()));
Ok(())
}
fn send_keys_literal_to_pane(
&self,
name: &str,
pane_id: &str,
text: &str,
) -> Result<(), ManagedError> {
self.pane_literal_calls.lock().unwrap().push((
name.to_owned(),
pane_id.to_owned(),
text.to_owned(),
));
Ok(())
}
fn send_interrupt_to_pane(&self, name: &str, pane_id: &str) -> Result<(), ManagedError> {
self.pane_interrupt_calls
.lock()
.unwrap()
.push((name.to_owned(), pane_id.to_owned()));
Ok(())
}
fn capture_pane(
&self,
name: &str,
pane_id: &str,
_lines: usize,
) -> Result<String, ManagedError> {
self.pane_capture_calls
.lock()
.unwrap()
.push((name.to_owned(), pane_id.to_owned()));
Ok(self
.capture_responses
.lock()
.unwrap()
.get(pane_id)
.cloned()
.unwrap_or_default())
}
}
pub(super) async fn make_manager(dir: &TempDir) -> (SessionManager, Arc<FakeTmuxDriver>) {
let fake = FakeTmuxDriver::new();
let mgr = SessionManager::new(dir.path(), fake.clone())
.await
.expect("manager");
(mgr, fake)
}
#[tokio::test]
async fn manager_create_record() {
let dir = crate::test_support::hermetic_temp_dir();
let (mgr, _fake) = make_manager(&dir).await;
let record = mgr
.create(
"implement OAuth2".into(),
Some(PathBuf::from("/tmp/wt1")),
None,
None,
None,
None,
)
.await
.expect("create");
assert!(
record.tmux_name.starts_with("tm-"),
"tmux_name has prefix: {}",
record.tmux_name
);
assert_eq!(record.state, ManagedSessionState::Provisioning);
assert_eq!(record.task, "implement OAuth2");
let listed = mgr.list().await;
assert_eq!(listed.len(), 1);
assert_eq!(listed[0].id, record.id);
}
#[tokio::test]
async fn manager_known_tmux_names_collects_all() {
let dir = crate::test_support::hermetic_temp_dir();
let (mgr, _fake) = make_manager(&dir).await;
let r1 = mgr
.create(
"task one".into(),
Some(PathBuf::from("/tmp/k1")),
None,
None,
None,
None,
)
.await
.expect("create 1");
let r2 = mgr
.create(
"task two".into(),
Some(PathBuf::from("/tmp/k2")),
None,
None,
None,
None,
)
.await
.expect("create 2");
let names = mgr
.known_tmux_names()
.await
.expect("store read should succeed on the happy path");
assert!(names.contains(&r1.tmux_name), "missing {}", r1.tmux_name);
assert!(names.contains(&r2.tmux_name), "missing {}", r2.tmux_name);
assert_eq!(names.len(), 2);
}
#[tokio::test]
async fn manager_create_success_does_not_reap_session() {
let dir = crate::test_support::hermetic_temp_dir();
let (mgr, fake) = make_manager(&dir).await;
let record = mgr
.create(
"keep me alive".into(),
Some(PathBuf::from("/tmp/keepalive")),
None,
None,
None,
None,
)
.await
.expect("create");
assert!(
fake.kill_calls.lock().unwrap().is_empty(),
"a successful create must NOT kill the session it created (guard disarmed); \
kills seen: {:?}",
fake.kill_calls.lock().unwrap()
);
assert_eq!(mgr.get(&record.id).await.unwrap().id, record.id);
}
#[tokio::test]
async fn manager_create_store_failure_reaps_orphan() {
let dir = crate::test_support::hermetic_temp_dir();
let (mgr, fake) = make_manager(&dir).await;
let store_path = dir.path().join("sessions.json");
let _ = std::fs::remove_file(&store_path);
std::fs::create_dir_all(store_path.join("not-empty")).expect("make sessions.json a dir");
let err = mgr
.create(
"doomed".into(),
Some(PathBuf::from("/tmp/doomed-ws")),
None,
None,
None,
None,
)
.await
.expect_err("create must fail when the store write fails");
assert!(
matches!(err, ManagedError::Store(_)),
"store-write failure must surface as a Store error, got {err:?}"
);
let kills = fake.kill_calls.lock().unwrap();
assert_eq!(
kills.len(),
1,
"exactly one orphaned tmux session must be reaped; kills: {kills:?}"
);
assert!(
kills[0].starts_with("tm-"),
"the reaped session is the one just created (tm- prefix): {}",
kills[0]
);
}
#[tokio::test]
async fn manager_name_hint_overrides() {
let dir = crate::test_support::hermetic_temp_dir();
let (mgr, _fake) = make_manager(&dir).await;
let record = mgr
.create(
"task".into(),
None,
Some("ticket-1234".into()),
None,
None,
None,
)
.await
.expect("create");
assert_eq!(record.tmux_name, "tm-ticket-1234-01");
}
#[tokio::test]
async fn manager_stop_keeps_workspace() {
let dir = crate::test_support::hermetic_temp_dir();
let workspace_dir = crate::test_support::hermetic_temp_dir();
let (mgr, fake) = make_manager(&dir).await;
let record = mgr
.create(
"task".into(),
Some(workspace_dir.path().to_owned()),
None,
Some(workspace_dir.path().to_owned()),
None,
None,
)
.await
.expect("create");
let stopped = mgr.stop(&record.id).await.expect("stop");
assert_eq!(stopped.state, ManagedSessionState::Stopped);
assert!(fake.kill_calls.lock().unwrap().contains(&record.tmux_name));
assert!(
workspace_dir.path().exists(),
"workspace dir must survive a stop; it is still on disk for resume"
);
let after = mgr.get(&record.id).await.unwrap();
assert_eq!(after.state, ManagedSessionState::Stopped);
assert!(
after.workspace_path.is_some(),
"workspace_path must be preserved in the record after stop"
);
}
#[tokio::test]
async fn manager_resume_respawns_in_existing_workspace() {
let dir = crate::test_support::hermetic_temp_dir();
let workspace_dir = crate::test_support::hermetic_temp_dir();
let (mgr, fake) = make_manager(&dir).await;
let workspace_path = workspace_dir.path().to_owned();
let record = mgr
.create(
"task".into(),
Some(workspace_path.clone()),
Some("my-session".into()),
Some(workspace_path.clone()),
Some("https://github.com/owner/repo".into()),
Some("main".into()),
)
.await
.expect("create");
mgr.stop(&record.id).await.expect("stop");
let creates_before = fake.create_cwd_calls.lock().unwrap().len();
let resumed = mgr.resume(&record.id).await.expect("resume");
assert_eq!(resumed.state, ManagedSessionState::Active);
let (create_len, resume_cwd) = {
let create_calls = fake.create_cwd_calls.lock().unwrap();
let len = create_calls.len();
let cwd = create_calls
.get(creates_before)
.map(|c| c.1.clone())
.unwrap_or_default();
(len, cwd)
};
assert!(
create_len > creates_before,
"resume must issue a new tmux create_session call"
);
assert_eq!(
resume_cwd,
workspace_path.to_string_lossy().to_string(),
"resume must use the EXISTING workspace path as cwd, not re-clone"
);
let after = mgr.get(&record.id).await.unwrap();
assert_eq!(
after.workspace_path.as_deref(),
Some(workspace_path.as_path()),
"workspace_path must be preserved after resume (no re-clone)"
);
}
#[tokio::test]
async fn manager_decommission_removes_workspace() {
let dir = crate::test_support::hermetic_temp_dir();
let (mgr, _fake) = make_manager(&dir).await;
let managed_root = crate::test_support::hermetic_temp_dir();
let workspace_path = managed_root
.path()
.join("owner")
.join("repo")
.join("abc-session-id");
std::fs::create_dir_all(&workspace_path).unwrap();
std::fs::write(workspace_path.join("sentinel.txt"), "exists").unwrap();
let record = mgr
.create_with_id(
ManagedSessionId::new(),
"task".into(),
Some(workspace_path.clone()),
None,
Some(workspace_path.clone()),
None,
None,
crate::runtime::RuntimeKind::default(),
false,
true, )
.await
.expect("create");
let (tombstone, workspace_removed) = mgr
.decommission_with_root(&record.id, managed_root.path(), None)
.await
.expect("decommission");
assert!(
workspace_removed,
"workspace_removed must be true for an owned, existing workspace"
);
assert_eq!(tombstone.state, ManagedSessionState::Decommissioned);
assert!(
tombstone.workspace_path.is_none(),
"workspace_path must be None after decommission (workspace was deleted)"
);
assert!(
!workspace_path.exists(),
"workspace directory must be removed from disk after decommission"
);
let after = mgr.get(&record.id).await.unwrap();
assert_eq!(after.state, ManagedSessionState::Decommissioned);
assert!(after.workspace_path.is_none());
}
fn make_active_test_record(tmux_name: &str, task: &str, ws_path: &str) -> SessionRecord {
SessionRecord {
id: ManagedSessionId::new(),
tmux_name: tmux_name.into(),
cwd: PathBuf::from("/tmp"),
task: task.into(),
state: ManagedSessionState::Active,
created_at: Utc::now(),
last_activity_at: None,
workspace_path: Some(PathBuf::from(ws_path)),
repo_url: None,
branch: None,
pending_decision: None,
proposed_default: None,
correlation: Default::default(),
runtime: Default::default(),
ephemeral: false,
workspace_owned: false,
source_id: None,
claude_session_id: None,
scrollback_path: None,
last_cwd: None,
deliverable_id: None,
pane_id: None,
injection_status: Default::default(),
worktree_owner: None,
}
}
#[tokio::test]
async fn manager_reconcile_gone_tmux_yields_stopped() {
let dir = crate::test_support::hermetic_temp_dir();
let fake = FakeTmuxDriver::new();
fake.seeded_names
.lock()
.unwrap()
.push("tmpm-live-session".into());
let mgr = SessionManager::new(dir.path(), fake.clone()).await.unwrap();
let live_record = make_active_test_record("tmpm-live-session", "live task", "/tmp/live-ws");
let rebooted_record =
make_active_test_record("tmpm-rebooted-session", "rebooted task", "/tmp/rebooted-ws");
{
let mut store = mgr.store.write().await;
store.upsert(live_record.clone()).await.unwrap();
store.upsert(rebooted_record.clone()).await.unwrap();
}
let report = mgr.reconcile_on_boot(false).await.expect("reconcile");
assert!(report.adopted.contains(&"tmpm-live-session".to_string()));
assert!(
report.stopped.contains(&rebooted_record.id.to_string()),
"gone session must be in report.stopped; report: {:?}",
report
);
let live = mgr.get(&live_record.id).await.unwrap();
assert_eq!(live.state, ManagedSessionState::Active);
let rebooted = mgr.get(&rebooted_record.id).await.unwrap();
assert_eq!(
rebooted.state,
ManagedSessionState::Stopped,
"gone-tmux session must be Stopped (resumable), not Orphaned or Dead"
);
assert!(
rebooted.workspace_path.is_some(),
"workspace_path must be preserved after reconcile→Stopped"
);
}
#[tokio::test]
async fn manager_reconcile_skips_decommissioned() {
let dir = crate::test_support::hermetic_temp_dir();
let fake = FakeTmuxDriver::new();
let mgr = SessionManager::new(dir.path(), fake.clone()).await.unwrap();
let tombstone = SessionRecord {
id: ManagedSessionId::new(),
tmux_name: "tmpm-decomm".into(),
cwd: PathBuf::from("/tmp"),
task: "done task".into(),
state: ManagedSessionState::Decommissioned,
created_at: Utc::now(),
last_activity_at: None,
workspace_path: None,
repo_url: None,
branch: None,
pending_decision: None,
proposed_default: None,
correlation: Default::default(),
runtime: Default::default(),
ephemeral: false,
workspace_owned: false,
source_id: None,
claude_session_id: None,
scrollback_path: None,
last_cwd: None,
deliverable_id: None,
pane_id: None,
injection_status: Default::default(),
worktree_owner: None,
};
{
let mut store = mgr.store.write().await;
store.upsert(tombstone.clone()).await.unwrap();
}
let report = mgr.reconcile_on_boot(false).await.expect("reconcile");
assert!(!report.adopted.contains(&tombstone.tmux_name));
assert!(!report.stopped.contains(&tombstone.id.to_string()));
let after = mgr.get(&tombstone.id).await.unwrap();
assert_eq!(after.state, ManagedSessionState::Decommissioned);
}
#[tokio::test]
async fn manager_send_input() {
let dir = crate::test_support::hermetic_temp_dir();
let (mgr, fake) = make_manager(&dir).await;
let record = mgr
.create(
"task".into(),
Some(PathBuf::from("/tmp/x")),
None,
None,
None,
None,
)
.await
.expect("create");
{
let mut store = mgr.store.write().await;
let mut r = store.get(&record.id).await.unwrap();
r.state = ManagedSessionState::Active;
store.upsert(r).await.unwrap();
}
mgr.send_input(&record.id, "hello from test")
.await
.expect("send");
let calls = fake.send_calls.lock().unwrap();
assert!(calls.iter().any(|(_, text)| text == "hello from test"));
}
#[tokio::test]
async fn manager_send_input_rejected_for_stopped_and_decommissioned() {
let dir = crate::test_support::hermetic_temp_dir();
let (mgr, _fake) = make_manager(&dir).await;
let record = mgr
.create(
"task".into(),
Some(PathBuf::from("/tmp/x")),
None,
None,
None,
None,
)
.await
.expect("create");
{
let mut store = mgr.store.write().await;
let mut r = store.get(&record.id).await.unwrap();
r.state = ManagedSessionState::Stopped;
store.upsert(r).await.unwrap();
}
let result = mgr.send_input(&record.id, "test").await;
assert!(result.is_err(), "send_input must fail for Stopped sessions");
{
let mut store = mgr.store.write().await;
let mut r = store.get(&record.id).await.unwrap();
r.state = ManagedSessionState::Decommissioned;
store.upsert(r).await.unwrap();
}
let result = mgr.send_input(&record.id, "test").await;
assert!(
result.is_err(),
"send_input must fail for Decommissioned sessions"
);
}
#[tokio::test]
async fn manager_env_scrub_command_sent() {
let dir = crate::test_support::hermetic_temp_dir();
let fake = FakeTmuxDriver::new();
let mgr = SessionManager::new(dir.path(), fake.clone()).await.unwrap();
let record = mgr
.create(
"task".into(),
Some(PathBuf::from("/tmp/x")),
None,
None,
None,
None,
)
.await
.expect("create");
let scrubbed_cmd = "env -u ANTHROPIC_API_KEY claude";
mgr.send_input(
&{
let mut store = mgr.store.write().await;
let mut r = store.get(&record.id).await.unwrap();
r.state = ManagedSessionState::Active;
store.upsert(r).await.unwrap();
record.id
},
scrubbed_cmd,
)
.await
.expect("send");
let calls = fake.send_calls.lock().unwrap();
let found = calls
.iter()
.any(|(_, cmd)| cmd.contains("env -u ANTHROPIC_API_KEY claude"));
assert!(found, "env scrub command must be sent; calls: {calls:?}");
}
#[tokio::test]
async fn manager_answer_decision() {
let dir = crate::test_support::hermetic_temp_dir();
let (mgr, fake) = make_manager(&dir).await;
let record = mgr
.create(
"task".into(),
Some(PathBuf::from("/tmp/x")),
None,
None,
None,
None,
)
.await
.expect("create");
{
let mut store = mgr.store.write().await;
let mut r = store.get(&record.id).await.unwrap();
r.pending_decision = Some("merge or rebase?".into());
r.proposed_default = Some("rebase".into());
store.upsert(r).await.unwrap();
}
mgr.answer_decision(&record.id, "rebase")
.await
.expect("answer");
let injected = {
let calls = fake.send_calls.lock().unwrap();
calls.iter().any(|(_, text)| text == "rebase")
};
assert!(injected);
let after = mgr.get(&record.id).await.unwrap();
assert!(after.pending_decision.is_none());
assert!(after.proposed_default.is_none());
}
#[tokio::test]
async fn spawn_session_tmux_cwd_is_workspace() {
use crate::session_manager::record::ManagedSessionId;
let store_dir = crate::test_support::hermetic_temp_dir();
let workspace_root = crate::test_support::hermetic_temp_dir();
let fake = FakeTmuxDriver::new();
let mgr = SessionManager::new(store_dir.path(), fake.clone())
.await
.expect("manager");
let session_id = ManagedSessionId::new();
let provisioner = crate::provisioner::WorkspaceProvisioner::without_prepare(
crate::provisioner::FakeGitBackend::new(),
workspace_root.path().to_owned(),
);
let prepared = provisioner
.provision(&session_id, "https://github.com/owner/repo", "main", "task")
.expect("provision");
let workspace_path = prepared.path.clone();
let record = mgr
.create_with_id(
session_id,
"task".into(),
Some(workspace_path.clone()),
None,
Some(workspace_path.clone()),
Some("https://github.com/owner/repo".into()),
Some("main".into()),
crate::runtime::RuntimeKind::default(),
false,
false,
)
.await
.expect("create_with_id");
let cwd_calls = fake.create_cwd_calls.lock().unwrap();
assert_eq!(
cwd_calls.len(),
1,
"exactly one tmux session must be created"
);
let (session_name, cwd) = &cwd_calls[0];
assert_eq!(
session_name, &record.tmux_name,
"session name must match the record"
);
assert_eq!(
cwd,
&workspace_path.to_string_lossy().to_string(),
"tmux session cwd must equal the provisioned workspace path"
);
let home = dirs::home_dir()
.map(|h| h.to_string_lossy().to_string())
.unwrap_or_default();
assert_ne!(
cwd, &home,
"tmux session cwd must NOT be $HOME (workspace-isolation regression)"
);
assert_ne!(
cwd, "/tmp",
"tmux session cwd must NOT be /tmp (workspace-isolation regression)"
);
assert!(
workspace_path.starts_with(workspace_root.path()),
"workspace must be under the mpm workspace root"
);
}
#[tokio::test]
async fn manager_create_defaults_runtime_to_claude_code() {
let dir = crate::test_support::hermetic_temp_dir();
let (mgr, _fake) = make_manager(&dir).await;
let record = mgr
.create(
"task".into(),
Some(PathBuf::from("/tmp/wt-d")),
None,
None,
None,
None,
)
.await
.expect("create");
assert_eq!(record.runtime, crate::runtime::RuntimeKind::ClaudeCode);
let reloaded = mgr.get(&record.id).await.expect("get");
assert_eq!(reloaded.runtime, crate::runtime::RuntimeKind::ClaudeCode);
}
#[tokio::test]
async fn manager_create_persists_runtime() {
let dir = crate::test_support::hermetic_temp_dir();
let (mgr, _fake) = make_manager(&dir).await;
let record = mgr
.create_with_id(
ManagedSessionId::new(),
"task".into(),
Some(PathBuf::from("/tmp/wt-t")),
None,
None,
None,
None,
crate::runtime::RuntimeKind::Tcode,
false,
false,
)
.await
.expect("create_with_id");
assert_eq!(record.runtime, crate::runtime::RuntimeKind::Tcode);
let reloaded = mgr.get(&record.id).await.expect("get");
assert_eq!(
reloaded.runtime,
crate::runtime::RuntimeKind::Tcode,
"runtime must survive persistence so resume re-spawns the same backend"
);
}
#[tokio::test]
async fn manager_get_reflects_out_of_process_write() {
let dir = crate::test_support::hermetic_temp_dir();
let workspace_dir = crate::test_support::hermetic_temp_dir();
let (mgr_a, _fake_a) = make_manager(&dir).await;
let (mgr_b, fake_b) = make_manager(&dir).await;
let record = mgr_a
.create(
"shared-state task".into(),
Some(workspace_dir.path().to_owned()),
None,
None,
None,
None,
)
.await
.expect("create");
let id = record.id;
mgr_a.stop(&id).await.expect("stop");
let before = mgr_a.get(&id).await.expect("get before");
assert_eq!(
before.state,
ManagedSessionState::Stopped,
"precondition: manager A sees the session as Stopped"
);
fake_b.seeded_names.lock().unwrap().clear();
mgr_b.resume(&id).await.expect("supervisor resume");
let after = mgr_a.get(&id).await.expect("get after");
assert_eq!(
after.state,
ManagedSessionState::Active,
"manager A must reflect the out-of-process resume written by manager B"
);
let listed = mgr_a.list().await;
let found = listed
.iter()
.find(|r| r.id == id)
.expect("session present in list");
assert_eq!(
found.state,
ManagedSessionState::Active,
"manager A's list must also reflect the out-of-process write"
);
}
pub(super) fn corrupt_store_file(mgr: &SessionManager) {
let path = mgr.data_dir().join("sessions.json");
std::fs::write(&path, b"{ this is not valid json ]").expect("corrupt store file");
}
#[tokio::test]
async fn manager_list_returns_last_known_on_reload_error() {
let dir = crate::test_support::hermetic_temp_dir();
let (mgr, _fake) = make_manager(&dir).await;
let record = mgr
.create(
"fleet-visibility task".into(),
Some(PathBuf::from("/tmp/wt-lastknown")),
None,
None,
None,
None,
)
.await
.expect("create");
let id = record.id;
assert_eq!(
mgr.list().await.len(),
1,
"precondition: one session listed"
);
corrupt_store_file(&mgr);
let listed = mgr.list().await;
assert_eq!(
listed.len(),
1,
"list() must return the last-known set on reload error, not empty: {listed:?}"
);
assert_eq!(
listed[0].id, id,
"the last-known record must be the one we created"
);
}
pub(super) async fn seed_record(
mgr: &SessionManager,
root: &TempDir,
id: ManagedSessionId,
state: ManagedSessionState,
ephemeral: bool,
) -> PathBuf {
let ws = root.path().join(format!("ws-{id}"));
let workspace_path = if state == ManagedSessionState::Decommissioned {
None
} else {
std::fs::create_dir_all(&ws).expect("mk ws");
Some(ws.clone())
};
let tmux_name = format!("tmpm-seed-{id}");
let seeds_live_tmux = matches!(
state,
ManagedSessionState::Active | ManagedSessionState::Provisioning
);
let record = SessionRecord {
id,
tmux_name: tmux_name.clone(),
cwd: root.path().to_path_buf(),
task: "seed".into(),
state,
created_at: Utc::now(),
last_activity_at: None,
workspace_path,
repo_url: None,
branch: None,
pending_decision: None,
proposed_default: None,
correlation: Default::default(),
runtime: Default::default(),
ephemeral,
workspace_owned: false,
source_id: None,
claude_session_id: None,
scrollback_path: None,
last_cwd: None,
deliverable_id: None,
pane_id: None,
injection_status: Default::default(),
worktree_owner: None,
};
if seeds_live_tmux {
mgr.tmux
.create_session(&tmux_name, &root.path().to_string_lossy())
.expect("seed: register live tmux session");
}
mgr.store
.write()
.await
.upsert(record)
.await
.expect("seed upsert");
ws
}
#[tokio::test]
async fn manager_create_persists_ephemeral_flag() {
let dir = crate::test_support::hermetic_temp_dir();
let (mgr, _fake) = make_manager(&dir).await;
let eph = mgr
.create_with_id(
ManagedSessionId::new(),
"ephemeral task".into(),
Some(PathBuf::from("/tmp/eph")),
None,
None,
None,
None,
crate::runtime::RuntimeKind::default(),
true,
false,
)
.await
.expect("create ephemeral");
let durable = mgr
.create_with_id(
ManagedSessionId::new(),
"durable task".into(),
Some(PathBuf::from("/tmp/dur")),
None,
None,
None,
None,
crate::runtime::RuntimeKind::default(),
false,
false,
)
.await
.expect("create durable");
assert!(
mgr.get(&eph.id).await.unwrap().ephemeral,
"ephemeral flag persisted"
);
assert!(
!mgr.get(&durable.id).await.unwrap().ephemeral,
"durable stays false"
);
}
#[tokio::test]
async fn decommission_all_ephemeral_ignores_non_ephemeral() {
let dir = crate::test_support::hermetic_temp_dir();
let (mgr, _fake) = make_manager(&dir).await;
let eph_active = ManagedSessionId::new();
let eph_stopped = ManagedSessionId::new();
let dur_active = ManagedSessionId::new();
let dur_stopped = ManagedSessionId::new();
seed_record(&mgr, &dir, eph_active, ManagedSessionState::Active, true).await;
seed_record(&mgr, &dir, eph_stopped, ManagedSessionState::Stopped, true).await;
seed_record(&mgr, &dir, dur_active, ManagedSessionState::Active, false).await;
seed_record(&mgr, &dir, dur_stopped, ManagedSessionState::Stopped, false).await;
let count = mgr
.decommission_all_ephemeral()
.await
.expect("bulk teardown");
assert_eq!(count, 2, "exactly the two ephemeral sessions are torn down");
assert_eq!(
mgr.get(&eph_active).await.unwrap().state,
ManagedSessionState::Decommissioned
);
assert_eq!(
mgr.get(&eph_stopped).await.unwrap().state,
ManagedSessionState::Decommissioned
);
assert_eq!(
mgr.get(&dur_active).await.unwrap().state,
ManagedSessionState::Active
);
assert_eq!(
mgr.get(&dur_stopped).await.unwrap().state,
ManagedSessionState::Stopped
);
}
#[tokio::test]
async fn prune_by_state_never_touches_active() {
let dir = crate::test_support::hermetic_temp_dir();
let (mgr, _fake) = make_manager(&dir).await;
let active = ManagedSessionId::new();
let stopped = ManagedSessionId::new();
seed_record(&mgr, &dir, active, ManagedSessionState::Active, false).await;
seed_record(&mgr, &dir, stopped, ManagedSessionState::Stopped, false).await;
let outcome = mgr
.prune_managed(
crate::session_manager::PruneFilter::Stopped,
false,
false,
None,
)
.await
.expect("prune stopped");
assert_eq!(outcome.count(), 1, "only the Stopped session is pruned");
assert_eq!(
mgr.get(&active).await.unwrap().state,
ManagedSessionState::Active,
"the Active session must be untouched"
);
assert_eq!(
mgr.get(&stopped).await.unwrap().state,
ManagedSessionState::Decommissioned
);
}
#[tokio::test]
async fn prune_decommissioned_compacts() {
let dir = crate::test_support::hermetic_temp_dir();
let (mgr, _fake) = make_manager(&dir).await;
let t1 = ManagedSessionId::new();
let t2 = ManagedSessionId::new();
let stopped = ManagedSessionId::new();
seed_record(&mgr, &dir, t1, ManagedSessionState::Decommissioned, false).await;
seed_record(&mgr, &dir, t2, ManagedSessionState::Decommissioned, false).await;
seed_record(&mgr, &dir, stopped, ManagedSessionState::Stopped, false).await;
let outcome = mgr
.prune_managed(
crate::session_manager::PruneFilter::Decommissioned,
false,
false,
None,
)
.await
.expect("compact");
assert_eq!(outcome.count(), 2, "both tombstones compacted");
assert!(
outcome
.sessions
.iter()
.all(|s| s.action == crate::session_manager::PruneAction::Removed),
"decommissioned prune reports Removed"
);
assert!(matches!(
mgr.get(&t1).await,
Err(ManagedError::SessionNotFound(_))
));
assert!(matches!(
mgr.get(&t2).await,
Err(ManagedError::SessionNotFound(_))
));
assert_eq!(
mgr.list().await.len(),
1,
"only the Stopped session remains"
);
}
#[tokio::test]
async fn prune_deleted_compacts() {
let dir = crate::test_support::hermetic_temp_dir();
let (mgr, _fake) = make_manager(&dir).await;
let d1 = ManagedSessionId::new();
let d2 = ManagedSessionId::new();
let stopped = ManagedSessionId::new();
seed_record(&mgr, &dir, d1, ManagedSessionState::Deleted, false).await;
seed_record(&mgr, &dir, d2, ManagedSessionState::Deleted, false).await;
seed_record(&mgr, &dir, stopped, ManagedSessionState::Stopped, false).await;
let outcome = mgr
.prune_managed(
crate::session_manager::PruneFilter::Deleted,
false,
false,
None,
)
.await
.expect("compact deleted");
assert_eq!(outcome.count(), 2, "both deleted tombstones compacted");
assert!(
outcome
.sessions
.iter()
.all(|s| s.action == crate::session_manager::PruneAction::Removed),
"deleted prune reports Removed"
);
assert!(matches!(
mgr.get(&d1).await,
Err(ManagedError::SessionNotFound(_))
));
assert_eq!(
mgr.list().await.len(),
1,
"only the Stopped session remains"
);
}
#[tokio::test]
async fn prune_all_targets_non_running() {
let dir = crate::test_support::hermetic_temp_dir();
let (mgr, _fake) = make_manager(&dir).await;
let active = ManagedSessionId::new();
let stopped = ManagedSessionId::new();
let errored = ManagedSessionId::new();
let tomb = ManagedSessionId::new();
seed_record(&mgr, &dir, active, ManagedSessionState::Active, false).await;
seed_record(&mgr, &dir, stopped, ManagedSessionState::Stopped, false).await;
seed_record(&mgr, &dir, errored, ManagedSessionState::Errored, false).await;
seed_record(&mgr, &dir, tomb, ManagedSessionState::Decommissioned, false).await;
let outcome = mgr
.prune_managed(crate::session_manager::PruneFilter::All, false, false, None)
.await
.expect("prune all");
assert_eq!(
outcome.count(),
3,
"stopped + errored + tombstone (not active)"
);
assert_eq!(
mgr.get(&active).await.unwrap().state,
ManagedSessionState::Active,
"running session is never touched by All"
);
assert_eq!(
mgr.get(&stopped).await.unwrap().state,
ManagedSessionState::Decommissioned
);
assert_eq!(
mgr.get(&errored).await.unwrap().state,
ManagedSessionState::Decommissioned
);
assert!(matches!(
mgr.get(&tomb).await,
Err(ManagedError::SessionNotFound(_))
));
}
#[tokio::test]
async fn prune_dry_run_reports_without_mutating() {
let dir = crate::test_support::hermetic_temp_dir();
let (mgr, _fake) = make_manager(&dir).await;
let stopped = ManagedSessionId::new();
seed_record(&mgr, &dir, stopped, ManagedSessionState::Stopped, false).await;
let outcome = mgr
.prune_managed(
crate::session_manager::PruneFilter::Stopped,
true,
false,
None,
)
.await
.expect("dry run");
assert!(outcome.dry_run, "outcome flagged dry_run");
assert_eq!(outcome.count(), 1, "candidate reported");
assert_eq!(
mgr.get(&stopped).await.unwrap().state,
ManagedSessionState::Stopped,
"dry run must not mutate the record"
);
}
#[test]
fn prune_filter_parse_round_trip() {
use crate::session_manager::PruneFilter;
for f in [
PruneFilter::Ephemeral,
PruneFilter::Stopped,
PruneFilter::Decommissioned,
PruneFilter::Deleted,
PruneFilter::All,
] {
assert_eq!(PruneFilter::parse(f.as_str()).unwrap(), f);
}
assert_eq!(
PruneFilter::parse("EPHEMERAL ").unwrap(),
PruneFilter::Ephemeral
);
assert!(PruneFilter::parse("bogus").is_err());
}
#[tokio::test]
async fn prune_outcome_serializes() {
let dir = crate::test_support::hermetic_temp_dir();
let (mgr, _fake) = make_manager(&dir).await;
let id = ManagedSessionId::new();
seed_record(&mgr, &dir, id, ManagedSessionState::Stopped, true).await;
let outcome = mgr
.prune_managed(
crate::session_manager::PruneFilter::Ephemeral,
true,
false,
None,
)
.await
.expect("dry run");
let v = serde_json::to_value(&outcome).expect("serialize outcome");
assert_eq!(v["dry_run"], serde_json::json!(true));
assert_eq!(v["filter"], serde_json::json!("ephemeral"));
assert_eq!(
v["sessions"][0]["action"],
serde_json::json!("decommissioned")
);
}
#[tokio::test]
async fn compact_record_removes_from_store() {
let dir = crate::test_support::hermetic_temp_dir();
let (mgr, _fake) = make_manager(&dir).await;
let id = ManagedSessionId::new();
seed_record(&mgr, &dir, id, ManagedSessionState::Decommissioned, false).await;
mgr.compact_record(&id).await.expect("compact");
assert!(matches!(
mgr.get(&id).await,
Err(ManagedError::SessionNotFound(_))
));
}
#[tokio::test]
async fn reap_aged_ephemeral_picks_old_ephemeral_only() {
let dir = crate::test_support::hermetic_temp_dir();
let (mgr, _fake) = make_manager(&dir).await;
async fn seed_aged(
mgr: &SessionManager,
root: &TempDir,
id: ManagedSessionId,
created_at: chrono::DateTime<Utc>,
ephemeral: bool,
) {
let ws = root.path().join(format!("aged-{id}"));
std::fs::create_dir_all(&ws).expect("mk ws");
let record = SessionRecord {
id,
tmux_name: format!("tmpm-aged-{id}"),
cwd: root.path().to_path_buf(),
task: "aged".into(),
state: ManagedSessionState::Active,
created_at,
last_activity_at: None,
workspace_path: Some(ws),
repo_url: None,
branch: None,
pending_decision: None,
proposed_default: None,
correlation: Default::default(),
runtime: Default::default(),
ephemeral,
workspace_owned: false,
source_id: None,
claude_session_id: None,
scrollback_path: None,
last_cwd: None,
deliverable_id: None,
pane_id: None,
injection_status: Default::default(),
worktree_owner: None,
};
mgr.store.write().await.upsert(record).await.expect("seed");
}
let old_eph = ManagedSessionId::new();
let young_eph = ManagedSessionId::new();
let old_durable = ManagedSessionId::new();
let two_hours_ago = Utc::now() - chrono::Duration::hours(2);
let now = Utc::now();
seed_aged(&mgr, &dir, old_eph, two_hours_ago, true).await;
seed_aged(&mgr, &dir, young_eph, now, true).await;
seed_aged(&mgr, &dir, old_durable, two_hours_ago, false).await;
let reaped = mgr
.reap_aged_ephemeral(chrono::Duration::hours(1))
.await
.expect("reap");
assert_eq!(reaped, 1, "only the OLD ephemeral session is reaped");
assert_eq!(
mgr.get(&old_eph).await.unwrap().state,
ManagedSessionState::Decommissioned,
"old ephemeral was reaped"
);
assert_eq!(
mgr.get(&young_eph).await.unwrap().state,
ManagedSessionState::Active,
"young ephemeral is below the age threshold"
);
assert_eq!(
mgr.get(&old_durable).await.unwrap().state,
ManagedSessionState::Active,
"a non-ephemeral session is NEVER reaped by age"
);
}
#[tokio::test]
async fn manager_decommission_unowned_skips_deletion() {
let dir = crate::test_support::hermetic_temp_dir();
let (mgr, _fake) = make_manager(&dir).await;
let real_user_repo = crate::test_support::hermetic_temp_dir();
let repo_path = real_user_repo.path().to_owned();
std::fs::write(repo_path.join("important_file.txt"), "do not delete").unwrap();
let id = ManagedSessionId::new();
let record = SessionRecord {
id,
tmux_name: format!("tmpm-local-{id}"),
cwd: repo_path.clone(),
task: "local task".into(),
state: ManagedSessionState::Stopped,
created_at: Utc::now(),
last_activity_at: None,
workspace_path: Some(repo_path.clone()),
repo_url: None,
branch: None,
pending_decision: None,
proposed_default: None,
correlation: Default::default(),
runtime: Default::default(),
ephemeral: false,
workspace_owned: false,
source_id: None,
claude_session_id: None,
scrollback_path: None,
last_cwd: None,
deliverable_id: None,
pane_id: None,
injection_status: Default::default(),
worktree_owner: None,
};
mgr.store.write().await.upsert(record).await.unwrap();
let (tombstone, workspace_removed) = mgr
.decommission(&id, None)
.await
.expect("decommission of an unowned record must succeed (skip deletion, not error)");
assert!(
!workspace_removed,
"workspace_removed must be false for an unowned (adopted/local-path) workspace"
);
assert_eq!(
tombstone.state,
ManagedSessionState::Decommissioned,
"record state must be Decommissioned"
);
assert!(
repo_path.exists(),
"the unowned workspace directory must NOT be deleted by decommission (#1511)"
);
assert!(
repo_path.join("important_file.txt").exists(),
"the sentinel file inside the unowned workspace must still exist"
);
}
#[tokio::test]
async fn workspace_owned_flag_round_trips_via_set() {
let dir = crate::test_support::hermetic_temp_dir();
let (mgr, _fake) = make_manager(&dir).await;
let record = mgr
.create(
"clone task".into(),
Some(PathBuf::from("/tmp/ws")),
None,
Some(PathBuf::from("/tmp/ws")),
Some("https://github.com/owner/repo".into()),
Some("main".into()),
)
.await
.expect("create");
assert!(
!record.workspace_owned,
"a freshly created record must default to workspace_owned = false"
);
mgr.set_workspace_owned(&record.id, true)
.await
.expect("set_workspace_owned");
let updated = mgr
.get(&record.id)
.await
.expect("get after set_workspace_owned");
assert!(
updated.workspace_owned,
"workspace_owned must be true after set_workspace_owned(true)"
);
}
#[tokio::test]
async fn fake_driver_graceful_stop_with_pid() {
let dir = crate::test_support::hermetic_temp_dir();
let (mgr, fake) = make_manager(&dir).await;
mgr.create(
"task".into(),
Some(PathBuf::from("/tmp/wt-graceful")),
Some("graceful-test".into()),
None,
None,
None,
)
.await
.expect("create");
let tmux_name = "tmpm-graceful-test";
let pid: u32 = 99_999; fake.graceful_stop(tmux_name, Some(pid))
.expect("graceful_stop");
let kills = fake.kill_calls.lock().unwrap();
assert!(
kills.iter().any(|n| n == tmux_name),
"kill_session must be called as the final step of graceful_stop"
);
let gs_calls = fake.graceful_stop_calls.lock().unwrap();
assert!(
gs_calls
.iter()
.any(|(n, p)| n == tmux_name && *p == Some(pid)),
"graceful_stop_calls must record (name, Some(pid))"
);
}
#[tokio::test]
async fn fake_driver_graceful_stop_without_pid() {
let dir = crate::test_support::hermetic_temp_dir();
let (mgr, fake) = make_manager(&dir).await;
mgr.create(
"task".into(),
Some(PathBuf::from("/tmp/wt-nopid")),
Some("no-pid-test".into()),
None,
None,
None,
)
.await
.expect("create");
let tmux_name = "tmpm-no-pid-test";
fake.graceful_stop(tmux_name, None)
.expect("graceful_stop without pid");
let kills = fake.kill_calls.lock().unwrap();
assert!(
kills.iter().any(|n| n == tmux_name),
"kill_session must be called even when no PID is known"
);
let gs_calls = fake.graceful_stop_calls.lock().unwrap();
assert!(
gs_calls.iter().any(|(n, p)| n == tmux_name && p.is_none()),
"graceful_stop_calls must record (name, None) when no pid supplied"
);
}
#[tokio::test]
async fn shutdown_calls_graceful_stop_for_active_sessions() {
let dir = crate::test_support::hermetic_temp_dir();
let (mgr, fake) = make_manager(&dir).await;
let record = mgr
.create(
"shutdown-integration-task".into(),
Some(PathBuf::from("/tmp/wt-shutdown")),
Some("shutdown-integ".into()),
None,
None,
None,
)
.await
.expect("create");
{
let mut store = mgr.store.write().await;
let mut r = record.clone();
r.state = ManagedSessionState::Active;
store.upsert(r).await.expect("upsert active");
}
let tmux_name = record.tmux_name.clone();
mgr.shutdown().await;
let gs_calls = fake.graceful_stop_calls.lock().unwrap();
assert!(
gs_calls.iter().any(|(n, _)| n == &tmux_name),
"shutdown must call graceful_stop for every Active session; \
expected {tmux_name} in graceful_stop_calls but got {gs_calls:?}"
);
let kills = fake.kill_calls.lock().unwrap();
assert!(
kills.iter().any(|n| n == &tmux_name),
"shutdown must ultimately kill_session for every Active session"
);
}
#[tokio::test]
async fn claude_session_id_persists_on_session() {
let dir = crate::test_support::hermetic_temp_dir();
let (mgr, _fake) = make_manager(&dir).await;
let record = mgr
.create(
"task".into(),
Some(PathBuf::from("/tmp/wt")),
None,
None,
None,
None,
)
.await
.expect("create");
mgr.set_claude_session_id(&record.id, "uuid-abc-123")
.await
.expect("set_claude_session_id");
let reloaded = mgr.get(&record.id).await.expect("get after set");
assert_eq!(
reloaded.claude_session_id.as_deref(),
Some("uuid-abc-123"),
"claude_session_id must survive a store reload (#1744)"
);
}