use std::sync::Arc;
use chrono::Utc;
use tempfile::TempDir;
use super::record::{ManagedSessionId, ManagedSessionState, SessionRecord, StopCause};
use super::tests::FakeTmuxDriver;
use crate::session_manager::SessionManager;
use crate::test_support::hermetic_temp_dir;
fn record_at(
tmux_name: &str,
workspace: &TempDir,
state: ManagedSessionState,
stop_cause: Option<StopCause>,
) -> SessionRecord {
SessionRecord {
id: ManagedSessionId::new(),
tmux_name: tmux_name.into(),
cwd: workspace.path().to_path_buf(),
task: "stop-cause fixture".into(),
state,
created_at: Utc::now(),
last_activity_at: None,
workspace_path: Some(workspace.path().to_path_buf()),
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,
terminal_at: None,
stop_cause,
}
}
async fn manager_with(
data_dir: &TempDir,
record: SessionRecord,
) -> (SessionManager, Arc<FakeTmuxDriver>) {
let fake = FakeTmuxDriver::new();
let mgr = SessionManager::new(data_dir.path(), fake.clone())
.await
.expect("manager");
mgr.store.write().await.upsert(record).await.expect("seed");
(mgr, fake)
}
#[test]
fn only_a_stop_nobody_asked_for_is_auto_resumable() {
let ws = hermetic_temp_dir();
let cases = [
(ManagedSessionState::Stopped, None, true),
(
ManagedSessionState::Stopped,
Some(StopCause::Unexpected),
true,
),
(
ManagedSessionState::Stopped,
Some(StopCause::Deliberate),
false,
),
(ManagedSessionState::Active, None, false),
(ManagedSessionState::Provisioning, None, false),
(ManagedSessionState::Errored, None, false),
(ManagedSessionState::Decommissioned, None, false),
(
ManagedSessionState::Deleted,
Some(StopCause::Unexpected),
false,
),
];
for (state, cause, expected) in cases {
let record = record_at("tm-matrix", &ws, state.clone(), cause);
assert_eq!(
record.is_auto_resumable(),
expected,
"state {state:?} with cause {cause:?} must read auto-resumable = {expected}"
);
}
}
#[test]
fn legacy_record_without_stop_cause_deserializes_as_auto_resumable() {
let ws = hermetic_temp_dir();
let mut record = record_at("tm-legacy", &ws, ManagedSessionState::Stopped, None);
record.stop_cause = Some(StopCause::Deliberate);
let mut json: serde_json::Value = serde_json::to_value(&record).expect("serialize");
assert!(
json.get("stop_cause").is_some(),
"the field must round-trip when it is set"
);
json.as_object_mut()
.expect("object")
.remove("stop_cause")
.expect("field present before removal");
let legacy: SessionRecord = serde_json::from_value(json).expect(
"a record written before stop_cause existed must still deserialize — the daemon reads \
the whole store on boot",
);
assert_eq!(legacy.stop_cause, None);
assert!(
legacy.is_auto_resumable(),
"a legacy stopped record must keep the auto-resume behavior it already had"
);
}
#[tokio::test]
async fn stop_records_deliberate_cause() {
let ws = hermetic_temp_dir();
let record = record_at("tm-stopped", &ws, ManagedSessionState::Active, None);
let id = record.id;
let data = hermetic_temp_dir();
let (mgr, _fake) = manager_with(&data, record).await;
let stopped = mgr.stop(&id).await.expect("stop");
assert_eq!(stopped.state, ManagedSessionState::Stopped);
assert_eq!(stopped.stop_cause, Some(StopCause::Deliberate));
assert!(
!stopped.is_auto_resumable(),
"an explicit stop that an automatic resume can undo is the #6194 defect"
);
assert_eq!(
mgr.get(&id).await.expect("reload").stop_cause,
Some(StopCause::Deliberate)
);
}
#[tokio::test]
async fn runtime_exit_records_unexpected_cause() {
let ws = hermetic_temp_dir();
let record = record_at("tm-exited", &ws, ManagedSessionState::Active, None);
let id = record.id;
let data = hermetic_temp_dir();
let (mgr, _fake) = manager_with(&data, record).await;
let stopped = mgr
.mark_runtime_exited_stopped(&id)
.await
.expect("runtime-exit reconcile");
assert_eq!(stopped.state, ManagedSessionState::Stopped);
assert_eq!(stopped.stop_cause, Some(StopCause::Unexpected));
assert!(stopped.is_auto_resumable());
}
#[tokio::test]
async fn resume_clears_the_stop_cause() {
let ws = hermetic_temp_dir();
let record = record_at(
"tm-resumed",
&ws,
ManagedSessionState::Stopped,
Some(StopCause::Deliberate),
);
let id = record.id;
let data = hermetic_temp_dir();
let (mgr, _fake) = manager_with(&data, record).await;
let resumed = mgr.resume(&id).await.expect(
"an explicit resume must still revive a deliberately stopped session — only the \
automatic paths are gated",
);
assert_eq!(resumed.state, ManagedSessionState::Active);
assert_eq!(resumed.stop_cause, None);
}
#[tokio::test]
async fn boot_reconcile_never_requeues_a_deliberately_stopped_session() {
let ws = hermetic_temp_dir();
let record = record_at(
"tm-killed",
&ws,
ManagedSessionState::Stopped,
Some(StopCause::Deliberate),
);
let id = record.id;
let data = hermetic_temp_dir();
let (mgr, fake) = manager_with(&data, record).await;
mgr.reconcile_on_boot(true).await.expect("reconcile");
let created = fake.create_cwd_calls.lock().unwrap().clone();
assert!(
created.is_empty(),
"boot reconciliation respawned a session the operator had killed: {created:?}"
);
let after = mgr.get(&id).await.expect("reload");
assert_eq!(after.state, ManagedSessionState::Stopped);
assert_eq!(
after.stop_cause,
Some(StopCause::Deliberate),
"the gone arm must keep a cause an earlier stop already recorded, not overwrite it"
);
}
#[tokio::test]
async fn boot_reconcile_still_auto_resumes_a_session_lost_with_the_daemon() {
let ws = hermetic_temp_dir();
let record = record_at("tm-rebooted", &ws, ManagedSessionState::Active, None);
let id = record.id;
let data = hermetic_temp_dir();
let (mgr, fake) = manager_with(&data, record).await;
let report = mgr.reconcile_on_boot(true).await.expect("reconcile");
assert_eq!(report.stopped, vec![id.to_string()]);
let created = fake.create_cwd_calls.lock().unwrap().clone();
assert_eq!(
created.len(),
1,
"an unattributed disappearance is the post-reboot restore auto-resume exists for: \
{created:?}"
);
assert_eq!(created[0].1, ws.path().to_string_lossy());
let after = mgr.get(&id).await.expect("reload");
assert_eq!(after.state, ManagedSessionState::Active);
assert_eq!(
after.stop_cause, None,
"a resumed session carries no stop cause"
);
}