use std::time::Duration;
use crate::session_manager::ManagedTmuxDriver;
pub(crate) const RESUME_VERIFY_ATTEMPTS: u32 = 10;
pub(crate) const RESUME_VERIFY_INTERVAL: Duration = Duration::from_millis(500);
const DELIVERY_READ_ATTEMPTS: u32 = 3;
const DELIVERY_READ_INTERVAL: Duration = Duration::from_millis(100);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum LaunchOutcome {
Running,
NotStarted,
Unverifiable,
}
pub(crate) async fn verify_launch(
tmux: &dyn ManagedTmuxDriver,
tmux_name: &str,
attempts: u32,
interval: Duration,
) -> LaunchOutcome {
if !tmux.session_exists(tmux_name) {
return LaunchOutcome::Unverifiable;
}
for attempt in 0..attempts.max(1) {
if attempt > 0 {
tokio::time::sleep(interval).await;
}
if tmux.runtime_ready(tmux_name) {
return LaunchOutcome::Running;
}
}
LaunchOutcome::NotStarted
}
pub(crate) async fn launch_was_delivered_in(
record: &crate::session_manager::SessionRecord,
spec_dir: &std::path::Path,
attempts: u32,
interval: Duration,
) -> bool {
if record.runtime != crate::runtime::RuntimeKind::ClaudeCode {
return true;
}
let session = record.id.to_string();
let Some(launch_id) =
crate::runtime::launch_spec::LaunchSpec::read_launch_pointer_in(spec_dir, &session)
else {
return true;
};
let marker = crate::runtime::launch_spec::LaunchSpec::started_marker_in(spec_dir, &launch_id);
for attempt in 0..attempts.max(1) {
if attempt > 0 {
tokio::time::sleep(interval).await;
}
if marker.exists() {
let _ = std::fs::remove_file(&marker);
return true;
}
}
false
}
fn clear_launch_files_in(record: &crate::session_manager::SessionRecord, dir: &std::path::Path) {
let session = record.id.to_string();
if let Some(launch_id) =
crate::runtime::launch_spec::LaunchSpec::read_launch_pointer_in(dir, &session)
{
let _ = std::fs::remove_file(crate::runtime::launch_spec::LaunchSpec::started_marker_in(
dir, &launch_id,
));
}
let _ = std::fs::remove_file(crate::runtime::launch_spec::LaunchSpec::launch_pointer_in(
dir, &session,
));
}
fn not_started_message(record: &crate::session_manager::SessionRecord, delivered: bool) -> String {
if delivered {
format!(
"launch ran and failed: `tm internal-spawn-disclaimed` started in pane '{}' but no \
runtime came up — the pane printed why on its own last line (a launch spec that \
could not be read prints there); start again once that is addressed (#8233)",
record.tmux_name
)
} else {
format!(
"launch was NOT DELIVERED to pane '{}': tmux accepted the keystrokes, the runtime \
never came up, and the launch never reached `tm internal-spawn-disclaimed` — so \
the pane's shell never executed the line and the pane has nothing printed to \
explain it. Nothing was started and no `claude` is running (#8233)",
record.tmux_name
)
}
}
pub(crate) async fn record_resume_outcome(
mgr: &crate::session_manager::SessionManager,
tmux: &dyn ManagedTmuxDriver,
record: &crate::session_manager::SessionRecord,
workspace: &std::path::Path,
) -> Option<String> {
record_resume_outcome_in(
mgr,
tmux,
record,
workspace,
crate::runtime::launch_spec::LaunchSpec::root().as_deref(),
)
.await
}
pub(crate) async fn record_resume_outcome_in(
mgr: &crate::session_manager::SessionManager,
tmux: &dyn ManagedTmuxDriver,
record: &crate::session_manager::SessionRecord,
workspace: &std::path::Path,
spec_dir: Option<&std::path::Path>,
) -> Option<String> {
record_launch_outcome(mgr, tmux, record, Some(workspace), spec_dir).await
}
async fn record_launch_outcome(
mgr: &crate::session_manager::SessionManager,
tmux: &dyn ManagedTmuxDriver,
record: &crate::session_manager::SessionRecord,
workspace: Option<&std::path::Path>,
spec_dir: Option<&std::path::Path>,
) -> Option<String> {
let outcome = verify_launch(
tmux,
&record.tmux_name,
RESUME_VERIFY_ATTEMPTS,
RESUME_VERIFY_INTERVAL,
)
.await;
if outcome == LaunchOutcome::NotStarted {
let delivered = match spec_dir {
Some(dir) => {
launch_was_delivered_in(record, dir, DELIVERY_READ_ATTEMPTS, DELIVERY_READ_INTERVAL)
.await
}
None => true,
};
let msg = not_started_message(record, delivered);
tracing::error!(
id = %record.id,
name = %record.tmux_name,
workspace = %workspace.unwrap_or(std::path::Path::new("")).display(),
delivered,
"{msg}"
);
if let Err(e) = mgr.mark_errored(&record.id, &msg).await {
tracing::error!(
id = %record.id,
name = %record.tmux_name,
"could not mark a launch that never came up as errored; the record stays \
as it was and no runtime is behind it: {e}"
);
}
return Some(msg);
}
if outcome == LaunchOutcome::Running
&& let Some(dir) = spec_dir
{
clear_launch_files_in(record, dir);
}
if outcome == LaunchOutcome::Running {
let _ = mgr.clear_error_note(&record.id).await;
}
tracing::info!(
id = %record.id,
name = %record.tmux_name,
?outcome,
"managed session launch verified"
);
None
}
pub(crate) async fn record_spawn_outcome(
mgr: &crate::session_manager::SessionManager,
tmux: &dyn ManagedTmuxDriver,
record: &crate::session_manager::SessionRecord,
) -> Option<String> {
record_spawn_outcome_in(
mgr,
tmux,
record,
crate::runtime::launch_spec::LaunchSpec::root().as_deref(),
)
.await
}
pub(crate) async fn record_spawn_outcome_in(
mgr: &crate::session_manager::SessionManager,
tmux: &dyn ManagedTmuxDriver,
record: &crate::session_manager::SessionRecord,
spec_dir: Option<&std::path::Path>,
) -> Option<String> {
record_launch_outcome(mgr, tmux, record, None, spec_dir).await
}
#[cfg(test)]
mod tests {
use super::*;
use crate::session_manager::ManagedError;
struct ProbeDriver {
session_live: bool,
runtime_up: bool,
}
impl ManagedTmuxDriver for ProbeDriver {
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(if self.session_live {
vec!["tmpm-probe".to_string()]
} else {
Vec::new()
})
}
fn runtime_ready(&self, _name: &str) -> bool {
self.runtime_up
}
}
#[rustfmt::skip]
fn stub_record(runtime: crate::runtime::RuntimeKind) -> crate::session_manager::SessionRecord {
crate::session_manager::SessionRecord {
id: crate::session_manager::ManagedSessionId::new(),
tmux_name: "tmpm-probe".to_owned(),
cwd: std::path::PathBuf::from("/tmp/project"),
task: "task".into(),
state: crate::session_manager::ManagedSessionState::Active,
created_at: chrono::Utc::now(),
last_activity_at: None,
workspace_path: None, repo_url: None,
branch: None, pending_decision: None,
proposed_default: None, correlation: Default::default(),
runtime,
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(),
tmux_server: None,
worktree_owner: None,
terminal_at: None,
stop_cause: None,
kind: Default::default(),
}
}
fn plant_pointer(dir: &std::path::Path, session: &str, launch_id: &str) {
std::fs::write(
crate::runtime::launch_spec::LaunchSpec::launch_pointer_in(dir, session),
launch_id.as_bytes(),
)
.expect("plant the pointer");
}
#[tokio::test]
async fn a_delivered_launch_is_reported_as_delivered() {
let dir = tempfile::tempdir().expect("tempdir");
let record = stub_record(crate::runtime::RuntimeKind::ClaudeCode);
plant_pointer(dir.path(), &record.id.to_string(), "launch-a");
let marker =
crate::runtime::launch_spec::LaunchSpec::started_marker_in(dir.path(), "launch-a");
std::fs::write(&marker, b"").expect("plant the sentinel");
assert!(launch_was_delivered_in(&record, dir.path(), 3, TEST_INTERVAL).await);
assert!(!marker.exists(), "the sentinel must be consumed");
}
#[tokio::test]
async fn an_undelivered_launch_is_reported_as_not_delivered() {
let dir = tempfile::tempdir().expect("tempdir");
let record = stub_record(crate::runtime::RuntimeKind::ClaudeCode);
plant_pointer(dir.path(), &record.id.to_string(), "launch-a");
assert!(!launch_was_delivered_in(&record, dir.path(), 3, TEST_INTERVAL).await);
}
#[tokio::test]
async fn a_marker_from_an_earlier_launch_does_not_satisfy_a_later_one() {
let dir = tempfile::tempdir().expect("tempdir");
let record = stub_record(crate::runtime::RuntimeKind::ClaudeCode);
std::fs::write(
crate::runtime::launch_spec::LaunchSpec::started_marker_in(dir.path(), "launch-old"),
b"",
)
.expect("plant the stale sentinel");
plant_pointer(dir.path(), &record.id.to_string(), "launch-new");
assert!(
!launch_was_delivered_in(&record, dir.path(), 3, TEST_INTERVAL).await,
"a stale per-session marker must not stand in for this launch"
);
}
#[tokio::test]
async fn delivery_is_assumed_for_a_non_claude_runtime() {
let dir = tempfile::tempdir().expect("tempdir");
let record = stub_record(crate::runtime::RuntimeKind::Tcode);
plant_pointer(dir.path(), &record.id.to_string(), "launch-a");
assert!(launch_was_delivered_in(&record, dir.path(), 3, TEST_INTERVAL).await);
}
#[tokio::test]
async fn delivery_is_assumed_without_a_launch_pointer() {
let dir = tempfile::tempdir().expect("tempdir");
let record = stub_record(crate::runtime::RuntimeKind::ClaudeCode);
assert!(launch_was_delivered_in(&record, dir.path(), 3, TEST_INTERVAL).await);
}
#[tokio::test]
async fn a_relaunch_that_does_not_take_is_recorded_at_error_level() {
use tracing_subscriber::layer::SubscriberExt as _;
let dir = tempfile::tempdir().expect("tempdir");
let mgr = std::sync::Arc::new(
crate::session_manager::SessionManager::new(
dir.path(),
std::sync::Arc::new(crate::session_manager::FakeNoopTmuxDriver),
)
.await
.expect("session manager"),
);
let created = mgr
.create(
"launch-verify".into(),
Some(dir.path().to_path_buf()),
None,
None,
None,
None,
)
.await
.expect("create");
let mut record = created.clone();
record.tmux_name = "tmpm-probe".to_owned();
let driver = ProbeDriver {
session_live: true,
runtime_up: false,
};
crate::test_support::enable_event_capture();
let buffer = trusty_common::log_buffer::LogBuffer::new(64);
let subscriber = tracing_subscriber::registry().with(
trusty_common::log_buffer::LogBufferLayer::new(buffer.clone()),
);
let _guard = tracing::subscriber::set_default(subscriber);
let msg = record_spawn_outcome_in(&mgr, &driver, &record, Some(dir.path())).await;
drop(_guard);
let msg = msg.expect("a runtime that never came up must be reported");
let lines = buffer.tail(64);
let recorded = lines
.iter()
.find(|l| l.contains("no `claude` is running") || l.contains("ran and failed"))
.unwrap_or_else(|| panic!("the launch failure must be logged at all; got: {lines:?}"));
assert!(
recorded.contains("ERROR"),
"the failure must be logged at ERROR so it reaches errors.jsonl; got: {recorded}"
);
assert!(msg.contains("#8233"), "{msg}");
}
#[test]
fn not_delivered_and_ran_and_failed_are_worded_apart() {
let record = stub_record(crate::runtime::RuntimeKind::ClaudeCode);
let ran = not_started_message(&record, true);
let never = not_started_message(&record, false);
assert!(ran.contains("ran and failed"), "{ran}");
assert!(ran.contains("printed why"), "{ran}");
assert!(never.contains("NOT DELIVERED"), "{never}");
assert!(
!never.contains("printed why"),
"an undelivered launch printed nothing: {never}"
);
}
#[tokio::test]
async fn an_undelivered_launch_is_not_interrupted_while_the_runtime_may_come_up() {
struct SlowStarter {
interrupts: std::sync::atomic::AtomicUsize,
}
impl ManagedTmuxDriver for SlowStarter {
fn create_session(&self, _n: &str, _w: &str) -> Result<(), ManagedError> {
Ok(())
}
fn kill_session(&self, _n: &str) -> Result<(), ManagedError> {
Ok(())
}
fn send_line(&self, _n: &str, _t: &str) -> Result<(), ManagedError> {
Ok(())
}
fn send_interrupt(&self, _n: &str) -> Result<(), ManagedError> {
self.interrupts
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
Ok(())
}
fn send_interrupt_to_pane(&self, _n: &str, _p: &str) -> Result<(), ManagedError> {
self.interrupts
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
Ok(())
}
fn capture(&self, _n: &str, _l: usize) -> Result<String, ManagedError> {
Ok(String::new())
}
fn list_sessions(&self) -> Result<Vec<String>, ManagedError> {
Ok(vec!["tmpm-probe".to_string()])
}
fn runtime_ready(&self, _n: &str) -> bool {
true
}
}
let dir = tempfile::tempdir().expect("tempdir");
let mgr = std::sync::Arc::new(
crate::session_manager::SessionManager::new(
dir.path(),
std::sync::Arc::new(crate::session_manager::FakeNoopTmuxDriver),
)
.await
.expect("session manager"),
);
let created = mgr
.create(
"slow-start".into(),
Some(dir.path().to_path_buf()),
None,
None,
None,
None,
)
.await
.expect("create");
let mut record = created.clone();
record.tmux_name = "tmpm-probe".to_owned();
let spec_dir = tempfile::tempdir().expect("spec dir");
plant_pointer(
spec_dir.path(),
&record.id.to_string(),
"a-launch-whose-sentinel-never-lands",
);
let driver = SlowStarter {
interrupts: std::sync::atomic::AtomicUsize::new(0),
};
let verdict = record_spawn_outcome_in(&mgr, &driver, &record, Some(spec_dir.path())).await;
assert_eq!(
verdict, None,
"a runtime that came up is a successful launch, whatever the sentinel says"
);
assert_eq!(
driver.interrupts.load(std::sync::atomic::Ordering::SeqCst),
0,
"a pane whose runtime came up must never be interrupted — the pre-fix order \
killed exactly this launch on a 2 s sentinel budget"
);
assert_ne!(
mgr.get(&record.id).await.expect("record").state,
crate::session_manager::ManagedSessionState::Errored,
"and it must not be errored either"
);
}
async fn manager_with_session(
dir: &std::path::Path,
) -> (
std::sync::Arc<crate::session_manager::SessionManager>,
crate::session_manager::SessionRecord,
) {
let mgr = std::sync::Arc::new(
crate::session_manager::SessionManager::new(
dir,
std::sync::Arc::new(crate::session_manager::FakeNoopTmuxDriver),
)
.await
.expect("session manager"),
);
let created = mgr
.create(
"launch-verify".into(),
Some(dir.to_path_buf()),
None,
None,
None,
None,
)
.await
.expect("create");
let mut record = created.clone();
record.tmux_name = "tmpm-probe".to_owned();
(mgr, record)
}
#[tokio::test]
async fn record_resume_outcome_returns_the_failure_it_recorded() {
let dir = tempfile::tempdir().expect("tempdir");
let spec_dir = tempfile::tempdir().expect("spec dir");
let (mgr, record) = manager_with_session(dir.path()).await;
let driver = ProbeDriver {
session_live: true,
runtime_up: false,
};
let msg =
record_resume_outcome_in(&mgr, &driver, &record, dir.path(), Some(spec_dir.path()))
.await
.expect("a runtime that never came up must be reported to the caller");
assert!(msg.contains("#8233"), "{msg}");
let after = mgr.get(&record.id).await.expect("record");
assert_eq!(
after.state,
crate::session_manager::ManagedSessionState::Errored,
"the same call must also record the failure"
);
assert!(
after.task.contains(&msg),
"the returned message and the recorded one must be the SAME failure; got: {}",
after.task
);
}
#[tokio::test]
async fn a_failed_error_mark_is_logged_rather_than_discarded() {
use tracing_subscriber::layer::SubscriberExt as _;
let dir = tempfile::tempdir().expect("tempdir");
let spec_dir = tempfile::tempdir().expect("spec dir");
let (mgr, _real) = manager_with_session(dir.path()).await;
let orphan = stub_record(crate::runtime::RuntimeKind::ClaudeCode);
let driver = ProbeDriver {
session_live: true,
runtime_up: false,
};
crate::test_support::enable_event_capture();
let buffer = trusty_common::log_buffer::LogBuffer::new(64);
let subscriber = tracing_subscriber::registry().with(
trusty_common::log_buffer::LogBufferLayer::new(buffer.clone()),
);
let _guard = tracing::subscriber::set_default(subscriber);
let msg = record_spawn_outcome_in(&mgr, &driver, &orphan, Some(spec_dir.path())).await;
drop(_guard);
assert!(
msg.is_some(),
"the launch still failed, whatever the store did"
);
let lines = buffer.tail(64);
let recorded = lines
.iter()
.find(|l| l.contains("could not mark a launch that never came up as errored"))
.unwrap_or_else(|| {
panic!("the discarded store failure must be logged; got: {lines:?}")
});
assert!(
recorded.contains("ERROR"),
"and at ERROR, so it reaches errors.jsonl; got: {recorded}"
);
}
#[tokio::test]
async fn a_running_verdict_clears_the_sentinel_and_the_pointer() {
let dir = tempfile::tempdir().expect("tempdir");
let spec_dir = tempfile::tempdir().expect("spec dir");
let (mgr, record) = manager_with_session(dir.path()).await;
let session = record.id.to_string();
plant_pointer(spec_dir.path(), &session, "launch-live");
let marker = crate::runtime::launch_spec::LaunchSpec::started_marker_in(
spec_dir.path(),
"launch-live",
);
std::fs::write(&marker, b"").expect("plant the sentinel");
let pointer =
crate::runtime::launch_spec::LaunchSpec::launch_pointer_in(spec_dir.path(), &session);
let driver = ProbeDriver {
session_live: true,
runtime_up: true,
};
let verdict = record_spawn_outcome_in(&mgr, &driver, &record, Some(spec_dir.path())).await;
assert_eq!(
verdict, None,
"a runtime that came up is a successful launch"
);
assert!(!marker.exists(), "the sentinel must be cleared on success");
assert!(!pointer.exists(), "and so must the launch pointer");
}
const TEST_INTERVAL: Duration = Duration::from_millis(1);
#[tokio::test]
async fn verify_launch_reports_running_when_the_runtime_comes_up() {
let driver = ProbeDriver {
session_live: true,
runtime_up: true,
};
assert_eq!(
verify_launch(&driver, "tmpm-probe", 3, TEST_INTERVAL).await,
LaunchOutcome::Running
);
}
#[tokio::test]
async fn verify_launch_reports_not_started_when_the_pane_stays_bare() {
let driver = ProbeDriver {
session_live: true,
runtime_up: false,
};
assert_eq!(
verify_launch(&driver, "tmpm-probe", 3, TEST_INTERVAL).await,
LaunchOutcome::NotStarted
);
}
#[tokio::test]
async fn verify_launch_is_unverifiable_without_an_observable_session() {
let driver = ProbeDriver {
session_live: false,
runtime_up: false,
};
assert_eq!(
verify_launch(&driver, "tmpm-probe", 3, TEST_INTERVAL).await,
LaunchOutcome::Unverifiable
);
}
#[tokio::test]
async fn verify_launch_does_not_wait_when_it_cannot_observe() {
let driver = ProbeDriver {
session_live: false,
runtime_up: false,
};
let started = std::time::Instant::now();
let outcome = verify_launch(&driver, "tmpm-probe", 20, Duration::from_millis(200)).await;
assert_eq!(outcome, LaunchOutcome::Unverifiable);
assert!(
started.elapsed() < Duration::from_millis(200),
"an unobservable driver must cost no polling delay, took {:?}",
started.elapsed()
);
}
}