use anyhow::Context as _;
use super::guided_resume::ResumeAction;
const PROBE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2);
const MANAGED_SESSION_ID_ENV: &str = "TM_MANAGED_SESSION_ID";
const FETCH_RETRY_BUDGET: std::time::Duration = std::time::Duration::from_millis(400);
const FETCH_RETRY_INTERVAL: std::time::Duration = std::time::Duration::from_millis(80);
fn read_env_managed_session_id() -> Option<String> {
std::env::var(MANAGED_SESSION_ID_ENV)
.ok()
.filter(|v| !v.trim().is_empty())
}
fn read_tmux_env_managed_session_id() -> Option<String> {
let inside_tmux = std::env::var("TMUX")
.map(|v| !v.is_empty())
.unwrap_or(false);
if !inside_tmux {
return None;
}
let output = std::process::Command::new("tmux")
.args(["show-environment", MANAGED_SESSION_ID_ENV])
.output()
.ok()?;
if !output.status.success() {
return None;
}
parse_show_environment_value(&String::from_utf8_lossy(&output.stdout))
}
fn parse_show_environment_value(stdout: &str) -> Option<String> {
let line = stdout.lines().next()?;
let value = line.strip_prefix(&format!("{MANAGED_SESSION_ID_ENV}="))?;
let trimmed = value.trim();
(!trimmed.is_empty()).then(|| trimmed.to_string())
}
pub(crate) fn plan_inplace(
env_session_id: Option<&str>,
record_state: Option<&str>,
) -> Option<ResumeAction> {
(env_session_id.is_some() && record_state == Some("stopped")).then_some(ResumeAction::InPlace)
}
async fn fetch_managed_session(
client: &reqwest::Client,
url: &str,
id: &str,
) -> Option<trusty_mpm::client::ManagedSessionSummary> {
let resp = client
.get(format!("{url}/api/v1/sessions/managed/{id}"))
.timeout(PROBE_TIMEOUT)
.send()
.await
.ok()?;
if !resp.status().is_success() {
return None;
}
resp.json().await.ok()
}
async fn fetch_managed_session_until_stopped(
client: &reqwest::Client,
url: &str,
id: &str,
) -> Option<trusty_mpm::client::ManagedSessionSummary> {
let start = std::time::Instant::now();
loop {
let record = fetch_managed_session(client, url, id).await;
if record.as_ref().map(|r| r.state.as_str()) == Some("stopped") {
return record;
}
if start.elapsed() >= FETCH_RETRY_BUDGET {
return record;
}
tokio::time::sleep(FETCH_RETRY_INTERVAL).await;
}
}
async fn reactivate_managed_session(client: &reqwest::Client, url: &str, id: &str) -> bool {
let result = client
.post(format!("{url}/api/v1/sessions/managed/{id}/reactivate"))
.timeout(PROBE_TIMEOUT)
.send()
.await;
match result {
Ok(resp) if resp.status().is_success() => true,
Ok(resp) => {
let status = resp.status();
eprintln!(
"tm: daemon refused to reactivate session {id} ({status}) — \
aborting in-place relaunch, falling back to the guided picker"
);
false
}
Err(e) => {
eprintln!(
"tm: could not reach daemon to reactivate session {id}: {e} — \
aborting in-place relaunch, falling back to the guided picker"
);
false
}
}
}
#[cfg(unix)]
fn exec_claude_in_place(mut cmd: std::process::Command) -> anyhow::Result<()> {
use std::os::unix::process::CommandExt as _;
let err = cmd.exec();
Err(anyhow::anyhow!("failed to exec claude in place: {err}"))
}
#[cfg(not(unix))]
fn exec_claude_in_place(mut cmd: std::process::Command) -> anyhow::Result<()> {
let status = cmd.status().context("failed to run claude")?;
std::process::exit(status.code().unwrap_or(1));
}
enum InPlaceOutcome {
Result(anyhow::Result<()>),
FallThrough,
}
async fn run_inplace_relaunch(
client: &reqwest::Client,
url: &str,
id: &str,
record: trusty_mpm::client::ManagedSessionSummary,
) -> InPlaceOutcome {
eprintln!("tm: this pane belongs to managed session {id} — relaunching in place…");
let cwd = match record
.workspace_path
.or(record.cwd)
.map(std::path::PathBuf::from)
.or_else(|| std::env::current_dir().ok())
.context("cannot resolve a working directory for the in-place relaunch")
{
Ok(cwd) => cwd,
Err(e) => return InPlaceOutcome::Result(Err(e)),
};
let resume = match trusty_mpm::runtime::build_inplace_resume_command(
&cwd,
record.claude_session_id.as_deref(),
) {
Ok(resume) => resume,
Err(e) => {
return InPlaceOutcome::Result(Err(anyhow::anyhow!(
"cannot build in-place relaunch command: {e}"
)));
}
};
if !reactivate_managed_session(client, url, id).await {
return InPlaceOutcome::FallThrough;
}
let mut cmd = std::process::Command::new(&resume.claude_bin);
cmd.args(&resume.args)
.current_dir(&cwd)
.env_remove("ANTHROPIC_API_KEY");
if let Some(dir) = &resume.config_dir {
cmd.env("CLAUDE_CONFIG_DIR", dir);
}
InPlaceOutcome::Result(exec_claude_in_place(cmd))
}
pub(crate) async fn try_inplace_relaunch(
client: &reqwest::Client,
url: &str,
) -> Option<anyhow::Result<()>> {
let env_id = match read_env_managed_session_id() {
Some(id) => id,
None => match read_tmux_env_managed_session_id() {
Some(id) => id,
None => {
tracing::debug!(
"tm: in-place relaunch gate: TM_MANAGED_SESSION_ID absent from both \
process env and tmux session env — falling through to guided default"
);
return None;
}
},
};
let record = fetch_managed_session_until_stopped(client, url, &env_id).await;
let record_state = record.as_ref().map(|r| r.state.as_str());
match plan_inplace(Some(&env_id), record_state) {
Some(ResumeAction::InPlace) => {
let record = record.expect("plan_inplace only selects InPlace when record is Some");
match run_inplace_relaunch(client, url, &env_id, record).await {
InPlaceOutcome::Result(r) => Some(r),
InPlaceOutcome::FallThrough => {
tracing::debug!(
id = %env_id,
"tm: in-place relaunch gate: daemon reactivate refused/failed — \
falling through to guided default"
);
None
}
}
}
_ => {
tracing::debug!(
id = %env_id,
state = ?record_state,
"tm: in-place relaunch gate: session id did not resolve to a known, \
Stopped managed session — falling through to guided default"
);
None
}
}
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
use super::*;
const TEST_ID: &str = "11111111-2222-3333-4444-555555555555";
async fn spawn_mock(status_line: &'static str) -> (String, Arc<AtomicUsize>) {
let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind");
let addr = listener.local_addr().expect("addr");
let hits = Arc::new(AtomicUsize::new(0));
let hits_task = hits.clone();
tokio::spawn(async move {
loop {
let Ok((mut sock, _)) = listener.accept().await else {
break;
};
hits_task.fetch_add(1, Ordering::SeqCst);
read_full_request(&mut sock).await;
let body = "{}";
let resp = format!(
"{status_line}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(),
body
);
let _ = sock.write_all(resp.as_bytes()).await;
let _ = sock.shutdown().await;
}
});
(format!("http://{addr}"), hits)
}
async fn read_full_request(sock: &mut TcpStream) {
let mut buf: Vec<u8> = Vec::with_capacity(4096);
let mut chunk = [0u8; 4096];
let header_end = loop {
if let Some(pos) = buf.windows(4).position(|w| w == b"\r\n\r\n") {
break pos + 4;
}
match sock.read(&mut chunk).await {
Ok(0) => return,
Ok(n) => buf.extend_from_slice(&chunk[..n]),
Err(_) => return,
}
};
let content_len: usize = String::from_utf8_lossy(&buf[..header_end])
.split("\r\n")
.find_map(|line| {
let (name, value) = line.split_once(':')?;
name.trim()
.eq_ignore_ascii_case("content-length")
.then(|| value.trim().parse().ok())?
})
.unwrap_or(0);
let want_total = header_end + content_len;
while buf.len() < want_total {
match sock.read(&mut chunk).await {
Ok(0) => return,
Ok(n) => buf.extend_from_slice(&chunk[..n]),
Err(_) => return,
}
}
}
async fn spawn_state_mock(states: Vec<&'static str>) -> (String, Arc<AtomicUsize>) {
let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind");
let addr = listener.local_addr().expect("addr");
let hits = Arc::new(AtomicUsize::new(0));
let hits_task = hits.clone();
tokio::spawn(async move {
loop {
let Ok((mut sock, _)) = listener.accept().await else {
break;
};
let n = hits_task.fetch_add(1, Ordering::SeqCst);
read_full_request(&mut sock).await;
let idx = n.min(states.len().saturating_sub(1));
let state = states.get(idx).copied().unwrap_or("active");
let body = format!(r#"{{"id":"{TEST_ID}","name":"tm-test","state":"{state}"}}"#);
let resp = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(),
body
);
let _ = sock.write_all(resp.as_bytes()).await;
let _ = sock.shutdown().await;
}
});
(format!("http://{addr}"), hits)
}
#[tokio::test]
async fn fetch_until_stopped_returns_immediately_when_already_stopped() {
let (url, hits) = spawn_state_mock(vec!["stopped"]).await;
let client = reqwest::Client::new();
let start = std::time::Instant::now();
let record = fetch_managed_session_until_stopped(&client, &url, TEST_ID).await;
assert_eq!(record.map(|r| r.state), Some("stopped".to_string()));
assert_eq!(
hits.load(Ordering::SeqCst),
1,
"must not retry once the first fetch already reads stopped"
);
assert!(
start.elapsed() < FETCH_RETRY_BUDGET,
"must return promptly, not wait out the whole retry budget"
);
}
#[tokio::test]
async fn fetch_until_stopped_retries_past_transitioning_state() {
let (url, hits) = spawn_state_mock(vec!["active", "active", "stopped"]).await;
let client = reqwest::Client::new();
let record = fetch_managed_session_until_stopped(&client, &url, TEST_ID).await;
assert_eq!(record.map(|r| r.state), Some("stopped".to_string()));
assert!(
hits.load(Ordering::SeqCst) >= 3,
"must retry past the transitioning reads before accepting stopped"
);
}
#[tokio::test]
async fn fetch_until_stopped_gives_up_after_budget_when_never_stopped() {
let (url, hits) = spawn_state_mock(vec!["active"]).await;
let client = reqwest::Client::new();
let start = std::time::Instant::now();
let record = fetch_managed_session_until_stopped(&client, &url, TEST_ID).await;
let elapsed = start.elapsed();
assert_eq!(record.map(|r| r.state), Some("active".to_string()));
assert!(
elapsed >= FETCH_RETRY_BUDGET,
"must not give up before the retry budget elapses"
);
assert!(
elapsed < FETCH_RETRY_BUDGET + FETCH_RETRY_INTERVAL * 3,
"must not overrun the budget by more than a poll or two"
);
assert!(
hits.load(Ordering::SeqCst) > 1,
"must have retried at least once before giving up"
);
}
#[test]
fn parse_show_environment_value_extracts_id() {
assert_eq!(
parse_show_environment_value(
"TM_MANAGED_SESSION_ID=11111111-2222-3333-4444-555555555555\n"
),
Some("11111111-2222-3333-4444-555555555555".to_string())
);
}
#[test]
fn parse_show_environment_value_none_when_unset() {
assert_eq!(
parse_show_environment_value("-TM_MANAGED_SESSION_ID\n"),
None
);
}
#[test]
fn parse_show_environment_value_none_when_empty() {
assert_eq!(parse_show_environment_value(""), None);
assert_eq!(
parse_show_environment_value("TM_MANAGED_SESSION_ID=\n"),
None
);
}
#[test]
fn plan_inplace_selected_when_env_set_and_stopped() {
assert_eq!(
plan_inplace(Some(TEST_ID), Some("stopped")),
Some(ResumeAction::InPlace)
);
}
#[test]
fn plan_inplace_none_when_env_absent() {
assert_eq!(plan_inplace(None, Some("stopped")), None);
assert_eq!(plan_inplace(None, None), None);
}
#[test]
fn plan_inplace_none_when_env_set_but_unresolved() {
assert_eq!(plan_inplace(Some(TEST_ID), None), None);
}
#[test]
fn plan_inplace_none_when_resolved_but_not_stopped() {
for state in ["active", "errored", "provisioning", "decommissioned"] {
assert_eq!(
plan_inplace(Some(TEST_ID), Some(state)),
None,
"state '{state}' must not select InPlace"
);
}
}
#[tokio::test]
async fn reactivate_confirms_success_on_2xx() {
let (url, hits) = spawn_mock("HTTP/1.1 200 OK").await;
let client = reqwest::Client::new();
let ok = reactivate_managed_session(&client, &url, TEST_ID).await;
assert!(ok, "a 2xx response must confirm reactivation");
assert_eq!(hits.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn reactivate_aborts_on_409_conflict() {
let (url, _hits) = spawn_mock("HTTP/1.1 409 Conflict").await;
let client = reqwest::Client::new();
let ok = reactivate_managed_session(&client, &url, TEST_ID).await;
assert!(!ok, "409 must NOT be treated as a confirmed reactivate");
}
#[tokio::test]
async fn reactivate_aborts_on_404_not_found() {
let (url, _hits) = spawn_mock("HTTP/1.1 404 Not Found").await;
let client = reqwest::Client::new();
let ok = reactivate_managed_session(&client, &url, TEST_ID).await;
assert!(!ok, "404 must NOT be treated as a confirmed reactivate");
}
#[tokio::test]
async fn reactivate_aborts_on_unreachable_daemon() {
let client = reqwest::Client::new();
let ok = reactivate_managed_session(&client, "http://127.0.0.1:1", TEST_ID).await;
assert!(
!ok,
"an unreachable daemon must NOT be treated as a confirmed reactivate"
);
}
#[tokio::test]
async fn run_inplace_relaunch_never_reactivates_when_command_build_fails() {
let tmp = tempfile::tempdir().expect("tempdir");
if trusty_mpm::runtime::build_inplace_resume_command(tmp.path(), None).is_ok() {
return;
}
let (url, hits) = spawn_mock("HTTP/1.1 200 OK").await;
let record = trusty_mpm::client::ManagedSessionSummary {
id: TEST_ID.to_string(),
name: "tm-test".to_string(),
state: "stopped".to_string(),
workspace_path: Some(tmp.path().to_string_lossy().to_string()),
repo_url: None,
branch: None,
created_at: None,
last_activity_at: None,
pending_decision: None,
proposed_default: None,
source_id: None,
task: None,
cwd: None,
claude_session_id: None,
};
let client = reqwest::Client::new();
let outcome = run_inplace_relaunch(&client, &url, TEST_ID, record).await;
assert!(
matches!(outcome, InPlaceOutcome::Result(Err(_))),
"a command-build failure must surface as a hard error, not FallThrough"
);
assert_eq!(
hits.load(Ordering::SeqCst),
0,
"reactivate must NEVER be called when command resolution fails first (#2027)"
);
}
}