use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use super::ManagedTmuxDriver;
use super::manager::ManagedError;
use super::record::{ManagedSessionId, ManagedSessionState, SessionRecord};
fn workdir_candidates(record: &SessionRecord) -> [Option<&Path>; 3] {
[
record.last_cwd.as_deref(),
record.workspace_path.as_deref(),
record
.workspace_path
.is_none()
.then_some(record.cwd.as_path()),
]
}
async fn recorded_workspace_refusal(
id: &ManagedSessionId,
record: &SessionRecord,
) -> Option<ManagedError> {
let ws = record.workspace_path.as_deref()?;
match tokio::fs::try_exists(ws).await {
Ok(true) => None,
Ok(false) => Some(ManagedError::WorkspaceMissing(
id.to_string(),
ws.display().to_string(),
)),
Err(e) => Some(ManagedError::Io(std::io::Error::new(
e.kind(),
format!(
"cannot check workspace {} of session {id}: {e}; refusing to resume it in \
any other directory — restore access to that path and retry",
ws.display()
),
))),
}
}
pub(super) async fn resolve_existing_workdir(
id: &ManagedSessionId,
record: &SessionRecord,
) -> Result<PathBuf, ManagedError> {
if let Some(refusal) = recorded_workspace_refusal(id, record).await {
return Err(refusal);
}
for candidate in workdir_candidates(record).into_iter().flatten() {
if tokio::fs::try_exists(candidate).await.unwrap_or(false) {
return Ok(candidate.to_path_buf());
}
}
let missing = record.workspace_path.as_deref().unwrap_or(&record.cwd);
Err(ManagedError::WorkspaceMissing(
id.to_string(),
missing.display().to_string(),
))
}
pub(crate) async fn is_unresumable(record: &SessionRecord) -> bool {
if !matches!(
record.state,
ManagedSessionState::Stopped | ManagedSessionState::Errored
) {
return false;
}
if let Some(ws) = record.workspace_path.as_deref() {
return matches!(tokio::fs::try_exists(ws).await, Ok(false));
}
for candidate in workdir_candidates(record).into_iter().flatten() {
match tokio::fs::try_exists(candidate).await {
Ok(true) | Err(_) => return false,
Ok(false) => continue,
}
}
true
}
const PANE_CWD_DEADLINE: Duration = Duration::from_millis(300);
const PANE_CWD_POLL: Duration = Duration::from_millis(10);
pub(super) fn verify_pane_cwd(
driver: &dyn ManagedTmuxDriver,
name: &str,
expected: &Path,
) -> Result<(), ManagedError> {
verify_pane_cwd_within(driver, name, expected, PANE_CWD_DEADLINE)
}
fn verify_pane_cwd_within(
driver: &dyn ManagedTmuxDriver,
name: &str,
expected: &Path,
deadline: Duration,
) -> Result<(), ManagedError> {
let Some(mut actual) = driver.get_pane_cwd(name) else {
return Ok(());
};
let canonical = |p: &Path| p.canonicalize().unwrap_or_else(|_| p.to_path_buf());
let expected_c = canonical(expected);
let give_up = Instant::now() + deadline;
while canonical(&actual) != expected_c {
if Instant::now() >= give_up {
return Err(ManagedError::TmuxUnavailable(format!(
"tmux session '{name}' pane landed at '{}' instead of the requested workspace \
'{}' — tmux silently fell back (likely $HOME) because the target directory was \
unusable; refusing to type the resume command into a mis-rooted pane",
actual.display(),
expected.display()
)));
}
std::thread::sleep(PANE_CWD_POLL);
if let Some(next) = driver.get_pane_cwd(name) {
actual = next;
}
}
Ok(())
}
pub(super) fn create_and_verify_pane(
driver: &dyn ManagedTmuxDriver,
name: &str,
workdir: &str,
) -> Result<(), ManagedError> {
driver.create_session_exclusive(name, workdir)?;
verify_pane_cwd(driver, name, Path::new(workdir))
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use std::sync::Mutex;
use chrono::Utc;
use tempfile::TempDir;
use super::super::record::ManagedSessionState;
use super::*;
fn make_record(
cwd: PathBuf,
workspace_path: Option<PathBuf>,
last_cwd: Option<PathBuf>,
) -> SessionRecord {
SessionRecord {
id: ManagedSessionId::new(),
tmux_name: "tmpm-resume-workdir-test".into(),
cwd,
task: "test".into(),
state: ManagedSessionState::Stopped,
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: false,
workspace_owned: false,
source_id: None,
claude_session_id: None,
scrollback_path: None,
last_cwd,
deliverable_id: None,
pane_id: None,
tmux_server: None,
injection_status: Default::default(),
worktree_owner: None,
terminal_at: None,
stop_cause: None,
kind: Default::default(),
}
}
#[tokio::test]
async fn resolve_existing_workdir_prefers_first_existing() {
let last = TempDir::new().unwrap();
let workspace = TempDir::new().unwrap();
let id = ManagedSessionId::new();
let record = make_record(
last.path().to_owned(),
Some(workspace.path().to_owned()),
Some(last.path().to_owned()),
);
let resolved = resolve_existing_workdir(&id, &record)
.await
.expect("last_cwd exists, must be chosen first");
assert_eq!(resolved, last.path());
}
#[tokio::test]
async fn resolve_existing_workdir_falls_back_through_candidates() {
let workspace = TempDir::new().unwrap();
let cwd = TempDir::new().unwrap();
let id = ManagedSessionId::new();
let missing_last = PathBuf::from("/nonexistent/last-cwd-2250");
let record = make_record(
cwd.path().to_owned(),
Some(workspace.path().to_owned()),
Some(missing_last.clone()),
);
let resolved = resolve_existing_workdir(&id, &record)
.await
.expect("workspace_path exists, must be chosen over missing last_cwd");
assert_eq!(resolved, workspace.path());
let record = make_record(cwd.path().to_owned(), None, Some(missing_last));
let resolved = resolve_existing_workdir(&id, &record)
.await
.expect("no workspace recorded, cwd exists: must resume in cwd");
assert_eq!(resolved, cwd.path());
}
#[tokio::test]
async fn resolve_existing_workdir_refuses_when_recorded_workspace_is_gone() {
let main_checkout = TempDir::new().unwrap();
let id = ManagedSessionId::new();
let gone = PathBuf::from("/nonexistent/worktrees/tm-demo-01");
for last_cwd in [None, Some(main_checkout.path().to_owned())] {
let record = make_record(
main_checkout.path().to_owned(),
Some(gone.clone()),
last_cwd.clone(),
);
match resolve_existing_workdir(&id, &record).await {
Err(ManagedError::WorkspaceMissing(err_id, path)) => {
assert_eq!(err_id, id.to_string());
assert_eq!(path, gone.display().to_string());
}
other => panic!(
"last_cwd {last_cwd:?}: a gone worktree must be refused with \
WorkspaceMissing, got {other:?}"
),
}
}
}
#[tokio::test]
async fn resolve_existing_workdir_refuses_on_workspace_probe_error() {
let dir = TempDir::new().unwrap();
let not_a_dir = dir.path().join("tm-demo-01-file");
std::fs::write(¬_a_dir, b"x").expect("write stand-in file");
let unprobable = not_a_dir.join("worktree");
let main_checkout = TempDir::new().unwrap();
let id = ManagedSessionId::new();
let record = make_record(
main_checkout.path().to_owned(),
Some(unprobable.clone()),
None,
);
match resolve_existing_workdir(&id, &record).await {
Err(ManagedError::Io(e)) => {
let msg = e.to_string();
assert!(msg.contains(&unprobable.display().to_string()), "{msg}");
}
other => panic!("a workspace probe error must refuse the resume, got {other:?}"),
}
}
#[tokio::test]
async fn resolve_existing_workdir_errors_when_all_missing() {
let id = ManagedSessionId::new();
let missing_workspace = PathBuf::from("/nonexistent/workspace-2250");
let record = make_record(
PathBuf::from("/nonexistent/cwd-2250"),
Some(missing_workspace.clone()),
Some(PathBuf::from("/nonexistent/last-cwd-2250")),
);
let err = resolve_existing_workdir(&id, &record).await.expect_err(
"no candidate exists on disk — must error, not silently fall back to $HOME",
);
match err {
ManagedError::WorkspaceMissing(err_id, path) => {
assert_eq!(err_id, id.to_string());
assert_eq!(path, missing_workspace.display().to_string());
}
other => panic!("expected WorkspaceMissing, got {other:?}"),
}
}
#[tokio::test]
async fn is_unresumable_true_when_all_candidates_missing_and_stopped() {
let mut record = make_record(
PathBuf::from("/nonexistent/cwd-2595"),
Some(PathBuf::from("/nonexistent/workspace-2595")),
Some(PathBuf::from("/nonexistent/last-cwd-2595")),
);
record.state = ManagedSessionState::Stopped;
assert!(
is_unresumable(&record).await,
"stopped session with no existing candidate must be unresumable"
);
record.state = ManagedSessionState::Errored;
assert!(
is_unresumable(&record).await,
"errored session with no existing candidate must be unresumable"
);
}
#[tokio::test]
async fn is_unresumable_false_when_any_candidate_exists() {
let cwd = TempDir::new().unwrap();
let mut record = make_record(
cwd.path().to_owned(),
None,
Some(PathBuf::from("/nonexistent/last-cwd-2595")),
);
record.state = ManagedSessionState::Stopped;
assert!(
!is_unresumable(&record).await,
"a session with ANY existing candidate must not be flagged unresumable"
);
}
#[tokio::test]
async fn is_unresumable_true_when_recorded_workspace_is_gone_even_if_cwd_exists() {
let cwd = TempDir::new().unwrap();
let mut record = make_record(
cwd.path().to_owned(),
Some(PathBuf::from("/nonexistent/worktrees/tm-demo-01")),
None,
);
record.state = ManagedSessionState::Stopped;
assert!(
is_unresumable(&record).await,
"a gone recorded workspace must be unresumable even when cwd exists"
);
}
#[tokio::test]
async fn is_unresumable_false_for_non_stopped_states_even_when_all_missing() {
let mut record = make_record(
PathBuf::from("/nonexistent/cwd-2595"),
Some(PathBuf::from("/nonexistent/workspace-2595")),
Some(PathBuf::from("/nonexistent/last-cwd-2595")),
);
for state in [
ManagedSessionState::Active,
ManagedSessionState::Provisioning,
] {
record.state = state;
assert!(
!is_unresumable(&record).await,
"state {:?} must never be flagged unresumable regardless of workdir",
record.state
);
}
}
#[tokio::test]
async fn is_unresumable_fails_open_on_probe_error() {
let dir = TempDir::new().unwrap();
let not_a_dir = dir.path().join("this-is-a-file-not-a-dir");
std::fs::write(¬_a_dir, b"x").expect("write stand-in file");
let unprobable = not_a_dir.join("child-cannot-be-stat-ed");
let mut record = make_record(
PathBuf::from("/nonexistent/cwd-2595-failopen"),
None,
Some(unprobable.clone()),
);
record.state = ManagedSessionState::Stopped;
assert!(
!is_unresumable(&record).await,
"a probe error on any candidate must fail OPEN (never flag dead) — a \
transient I/O/permission/network-volume error is not proof the \
workspace is gone, and the picker's only override would otherwise \
be a destructive delete"
);
record.workspace_path = Some(unprobable);
record.last_cwd = None;
assert!(
!is_unresumable(&record).await,
"a probe error on the recorded workspace must fail OPEN too"
);
}
struct StubDriver {
reads: Mutex<Vec<Option<PathBuf>>>,
served: Mutex<usize>,
}
impl StubDriver {
fn answering(reads: Vec<Option<PathBuf>>) -> Self {
Self {
reads: Mutex::new(reads),
served: Mutex::new(0),
}
}
fn served(&self) -> usize {
*self.served.lock().unwrap()
}
}
impl ManagedTmuxDriver for StubDriver {
fn create_session(&self, _name: &str, _workdir: &str) -> Result<(), ManagedError> {
Ok(())
}
fn kill_session(&self, _name: &str) -> Result<(), ManagedError> {
Ok(())
}
fn send_line(&self, _name: &str, _text: &str) -> Result<(), ManagedError> {
Ok(())
}
fn capture(&self, _name: &str, _lines: usize) -> Result<String, ManagedError> {
Ok(String::new())
}
fn list_sessions(&self) -> Result<Vec<String>, ManagedError> {
Ok(Vec::new())
}
fn get_pane_cwd(&self, _name: &str) -> Option<PathBuf> {
*self.served.lock().unwrap() += 1;
let mut reads = self.reads.lock().unwrap();
if reads.len() > 1 {
reads.remove(0)
} else {
reads.first().cloned().flatten()
}
}
}
#[test]
fn verify_pane_cwd_ok_on_match() {
let dir = TempDir::new().unwrap();
let driver = StubDriver::answering(vec![Some(dir.path().to_path_buf())]);
assert!(verify_pane_cwd(&driver, "s", dir.path()).is_ok());
}
#[test]
fn verify_pane_cwd_ok_when_unknown() {
let dir = TempDir::new().unwrap();
let driver = StubDriver::answering(vec![None]);
assert!(verify_pane_cwd(&driver, "s", dir.path()).is_ok());
}
#[test]
fn verify_pane_cwd_errors_on_mismatch() {
let expected = TempDir::new().unwrap();
let actual = TempDir::new().unwrap();
let driver = StubDriver::answering(vec![Some(actual.path().to_path_buf())]);
let err = verify_pane_cwd(&driver, "s", expected.path())
.expect_err("pane cwd disagrees with the requested workdir — must fail loudly");
assert!(matches!(err, ManagedError::TmuxUnavailable(_)));
}
#[test]
fn verify_pane_cwd_waits_for_a_stale_cwd_to_settle() {
let expected = TempDir::new().unwrap();
let stale = TempDir::new().unwrap();
let driver = StubDriver::answering(vec![
Some(stale.path().to_path_buf()),
Some(stale.path().to_path_buf()),
Some(expected.path().to_path_buf()),
]);
verify_pane_cwd(&driver, "s", expected.path())
.expect("the pane settled at the requested workdir inside the deadline");
assert_eq!(driver.served(), 3);
}
#[test]
fn verify_pane_cwd_errors_once_the_deadline_passes() {
let expected = TempDir::new().unwrap();
let actual = TempDir::new().unwrap();
let driver = StubDriver::answering(vec![Some(actual.path().to_path_buf())]);
let started = std::time::Instant::now();
let err = verify_pane_cwd(&driver, "s", expected.path())
.expect_err("a persistent mismatch must never time out into success");
assert!(matches!(err, ManagedError::TmuxUnavailable(_)), "{err:?}");
assert!(started.elapsed() >= PANE_CWD_DEADLINE);
assert!(driver.served() > 1, "the pane cwd was read only once");
}
#[test]
fn verify_pane_cwd_keeps_the_mismatch_when_a_later_read_fails() {
let expected = TempDir::new().unwrap();
let actual = TempDir::new().unwrap();
let driver = StubDriver::answering(vec![Some(actual.path().to_path_buf()), None]);
let err = verify_pane_cwd_within(&driver, "s", expected.path(), Duration::from_millis(30))
.expect_err("a failed re-read after a mismatch must not pass");
assert!(matches!(err, ManagedError::TmuxUnavailable(_)), "{err:?}");
}
}