use std::sync::Arc;
use axum::{
extract::{Path as AxumPath, Query, State},
response::IntoResponse,
};
use serde::Deserialize;
use crate::core::own_binary_names::OWN_BINARY_NAMES;
use crate::daemon::orphan_gc::{ChildLivenessProbe, PaneInfo, ProcessTreeProbe, is_idle_shell};
use crate::daemon::rpc::managed::outcome::RouteOutcome;
use crate::daemon::runtime_reap::{find_runtime_exited, session_has_live_pane};
use crate::daemon::state::DaemonState;
use crate::session_manager::{
ManagedError, ManagedSessionId, ManagedSessionState, SessionManager, SessionRecord,
};
use super::{parse_id, record_to_summary};
#[derive(Debug, Default, Deserialize)]
pub struct ReactivateQuery {
#[serde(default)]
pub caller_pane_id: Option<String>,
#[serde(default)]
pub pane_confirmed_dead: bool,
}
pub async fn reactivate_managed_session(
State(state): State<Arc<DaemonState>>,
AxumPath(id_str): AxumPath<String>,
Query(params): Query<ReactivateQuery>,
) -> impl IntoResponse {
reactivate_core(&state, &id_str, ¶ms).await }
pub(crate) async fn reactivate_core(
state: &Arc<DaemonState>,
id_str: &str,
params: &ReactivateQuery,
) -> RouteOutcome {
let id = match parse_id(id_str) {
Ok(id) => id,
Err((code, msg)) => return RouteOutcome::text(code.as_u16(), msg),
};
let mgr = state.session_manager().await;
if let Some(why) = caller_pane_refusal(&mgr, &id, params.caller_pane_id.as_deref()).await {
return RouteOutcome::text(409, why);
}
match mgr.mark_reactivated(&id).await {
Ok(record) => RouteOutcome::ok(&record_to_summary(&record)),
Err(ManagedError::SessionNotFound(_)) => {
RouteOutcome::text(404, format!("session {id_str} not found"))
}
Err(ManagedError::InvalidState(_, reason)) => match reconcile_stale_active_then_reactivate(
&mgr,
&id,
params.caller_pane_id.as_deref(),
params.pane_confirmed_dead,
)
.await
{
Some(record) => RouteOutcome::ok(&record_to_summary(&record)),
None => RouteOutcome::text(409, reason),
},
Err(e) => RouteOutcome::text(500, e.to_string()),
}
}
async fn caller_pane_refusal(
mgr: &SessionManager,
id: &ManagedSessionId,
caller_pane_id: Option<&str>,
) -> Option<String> {
let caller = caller_pane_id.filter(|p| !p.is_empty())?;
let record = mgr.get(id).await.ok()?;
if record.pane_id.as_deref() != Some(caller) {
return None;
}
let pane = match mgr.owned_pane(id, &record) {
Ok(pane) => pane,
Err(e) => return Some(e.to_string()),
};
match mgr.pane_foreground_command(&pane) {
Ok(command) if caller_pane_is_idle(&command) => None,
Ok(command) => Some(format!(
"refusing to reactivate session {id} in place: pane {pane} runs `{command}` \
in the foreground, so its agent is still live"
)),
Err(e) => Some(format!(
"refusing to reactivate session {id} in place: the foreground command of \
pane {pane} could not be read: {e}"
)),
}
}
fn caller_pane_is_idle(command: &str) -> bool {
let command = command.trim();
OWN_BINARY_NAMES.contains(&command) || is_idle_shell(command)
}
async fn reconcile_stale_active_then_reactivate(
mgr: &SessionManager,
id: &ManagedSessionId,
caller_pane_id: Option<&str>,
pane_confirmed_dead: bool,
) -> Option<SessionRecord> {
let record = mgr.get(id).await.ok()?;
let panes = crate::daemon::tmux::TmuxDriver::discover()
.ok()?
.list_managed_panes()
.ok()?;
if !should_reconcile_stale_active(
&record,
&panes,
caller_pane_id,
pane_confirmed_dead,
&ProcessTreeProbe,
) {
return None;
}
mgr.mark_runtime_exited_stopped(id).await.ok()?;
mgr.mark_reactivated(id).await.ok()
}
pub(crate) fn should_reconcile_stale_active(
record: &SessionRecord,
panes: &[PaneInfo],
caller_pane_id: Option<&str>,
caller_pane_confirmed_dead: bool,
probe: &dyn ChildLivenessProbe,
) -> bool {
if record.state != ManagedSessionState::Active {
return false;
}
let neutralized = neutralize_caller_pane(panes, caller_pane_id);
if caller_pane_confirmed_dead && caller_pane_id.is_some_and(|s| !s.is_empty()) {
return !session_has_live_pane(&record.tmux_name, &neutralized, probe);
}
!find_runtime_exited(std::slice::from_ref(record), &neutralized, probe).is_empty()
}
fn neutralize_caller_pane(panes: &[PaneInfo], caller_pane_id: Option<&str>) -> Vec<PaneInfo> {
let Some(caller) = caller_pane_id.filter(|s| !s.is_empty()) else {
return panes.to_vec();
};
panes
.iter()
.map(|p| {
if p.pane_id.as_deref() == Some(caller) && caller_pane_is_idle(&p.pane_current_command)
{
PaneInfo {
pane_current_command: "zsh".to_string(),
pane_pid: None,
..p.clone()
}
} else {
p.clone()
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::daemon::orphan_gc::AlwaysIdleProbe;
use chrono::Utc;
use std::path::PathBuf;
fn active_record(tmux_name: &str) -> SessionRecord {
SessionRecord {
id: ManagedSessionId::new(),
tmux_name: tmux_name.to_string(),
cwd: PathBuf::from("/tmp/test"),
task: "test".into(),
state: ManagedSessionState::Active,
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,
tmux_server: None,
injection_status: Default::default(),
worktree_owner: None,
terminal_at: None,
stop_cause: None,
kind: Default::default(),
}
}
fn pane(name: &str, cmd: &str) -> PaneInfo {
PaneInfo {
session_name: name.to_string(),
pane_current_command: cmd.to_string(),
pane_pid: Some(4242),
pane_id: None,
pane_current_path: None,
}
}
fn pane_with_id(name: &str, cmd: &str, pane_id: &str) -> PaneInfo {
PaneInfo {
session_name: name.to_string(),
pane_current_command: cmd.to_string(),
pane_pid: Some(4242),
pane_id: Some(pane_id.to_string()),
pane_current_path: None,
}
}
#[test]
fn should_reconcile_stale_active_true_when_active_and_idle() {
let record = active_record("tm-demo");
let panes = vec![pane("tm-demo", "zsh")];
assert!(should_reconcile_stale_active(
&record,
&panes,
None,
false,
&AlwaysIdleProbe
));
}
#[test]
fn should_reconcile_stale_active_false_when_pane_still_live() {
let record = active_record("tm-demo");
let panes = vec![pane("tm-demo", "claude")];
assert!(!should_reconcile_stale_active(
&record,
&panes,
None,
false,
&AlwaysIdleProbe
));
}
#[test]
fn should_reconcile_stale_active_false_when_sibling_pane_live() {
let record = active_record("tm-demo");
let panes = vec![pane("tm-demo", "zsh"), pane("tm-demo", "claude")];
assert!(!should_reconcile_stale_active(
&record,
&panes,
None,
false,
&AlwaysIdleProbe
));
}
#[test]
fn should_reconcile_stale_active_false_when_pane_missing() {
let record = active_record("tm-demo");
assert!(!should_reconcile_stale_active(
&record,
&[],
None,
false,
&AlwaysIdleProbe
));
}
#[test]
fn should_reconcile_stale_active_false_when_not_active() {
for state in [
ManagedSessionState::Provisioning,
ManagedSessionState::Errored,
ManagedSessionState::Decommissioned,
] {
let mut record = active_record("tm-demo");
record.state = state.clone();
let panes = vec![pane("tm-demo", "zsh")];
assert!(
!should_reconcile_stale_active(&record, &panes, None, false, &AlwaysIdleProbe),
"state {state} must not be reconciled"
);
}
}
#[test]
fn should_reconcile_stale_active_true_when_only_caller_pane_busy() {
let record = active_record("tm-cto-01");
let panes = vec![pane_with_id("tm-cto-01", "tm", "%3")];
assert!(
!should_reconcile_stale_active(&record, &panes, None, false, &AlwaysIdleProbe),
"pre-#2789 behavior (no caller_pane_id): the caller's own `tm` pane \
is still (wrongly) seen as live"
);
assert!(
should_reconcile_stale_active(&record, &panes, Some("%3"), false, &AlwaysIdleProbe),
"#2789: naming the caller's own pane must let its session reconcile"
);
}
#[test]
fn should_reconcile_stale_active_false_when_sibling_pane_live_despite_caller() {
let record = active_record("tm-cto-01");
let panes = vec![
pane_with_id("tm-cto-01", "tm", "%3"), pane_with_id("tm-cto-01", "claude", "%4"), ];
assert!(
!should_reconcile_stale_active(&record, &panes, Some("%3"), false, &AlwaysIdleProbe),
"a live sibling pane must still block reconcile even when the \
caller's own pane is excluded"
);
}
#[test]
fn should_reconcile_stale_active_ignores_unmatched_caller_pane_id() {
let record = active_record("tm-demo");
let panes = vec![pane_with_id("tm-demo", "claude", "%1")];
assert!(
!should_reconcile_stale_active(&record, &panes, Some("%99"), false, &AlwaysIdleProbe),
"an unmatched caller_pane_id must not force any pane idle"
);
}
#[test]
fn should_reconcile_stale_active_true_when_confirmed_dead_and_pane_missing() {
let record = active_record("tm-mcp-services-01");
assert!(
!should_reconcile_stale_active(&record, &[], Some("%5"), false, &AlwaysIdleProbe),
"without proof-of-death, a missing pane keeps the ordinary 409"
);
assert!(
should_reconcile_stale_active(&record, &[], Some("%5"), true, &AlwaysIdleProbe),
"#2794: a pane-confirmed-dead caller reconciles even when \
list-panes omits its pane"
);
}
#[test]
fn should_reconcile_stale_active_true_when_confirmed_dead_and_caller_pane_busy() {
let record = active_record("tm-mcp-services-01");
let panes = vec![pane_with_id("tm-mcp-services-01", "tm", "%5")];
assert!(
should_reconcile_stale_active(&record, &panes, Some("%5"), true, &AlwaysIdleProbe),
"#2794: pane-confirmed-dead reconciles its own busy `tm` pane"
);
}
#[test]
fn should_reconcile_stale_active_false_when_confirmed_dead_but_sibling_live() {
let record = active_record("tm-mcp-services-01");
let panes = vec![
pane_with_id("tm-mcp-services-01", "tm", "%5"), pane_with_id("tm-mcp-services-01", "claude", "%6"), ];
assert!(
!should_reconcile_stale_active(&record, &panes, Some("%5"), true, &AlwaysIdleProbe),
"a live sibling window must still block reconcile despite proof-of-death"
);
}
#[test]
fn should_reconcile_stale_active_ignores_confirmed_dead_without_caller_pane() {
let record = active_record("tm-demo");
let panes = vec![pane("tm-demo", "claude")];
assert!(
!should_reconcile_stale_active(&record, &panes, None, true, &AlwaysIdleProbe),
"pane_confirmed_dead without a caller_pane_id must not reconcile a live pane"
);
assert!(
!should_reconcile_stale_active(&record, &panes, Some(""), true, &AlwaysIdleProbe),
"a blank caller_pane_id must not trigger the proof-of-death shortcut"
);
}
#[test]
fn should_reconcile_stale_active_false_when_caller_pane_runs_a_live_agent() {
let record = active_record("tm-demo");
for cmd in ["claude", "node", "2.1.3"] {
let panes = vec![pane_with_id("tm-demo", cmd, "%5")];
assert!(
!should_reconcile_stale_active(&record, &panes, Some("%5"), true, &AlwaysIdleProbe),
"a caller pane running `{cmd}` was reconciled"
);
}
}
#[test]
fn should_reconcile_stale_active_false_when_unconfirmed_caller_pane_runs_a_live_agent() {
let record = active_record("tm-demo");
for cmd in ["claude", "node", "2.1.3"] {
let panes = vec![pane_with_id("tm-demo", cmd, "%5")];
assert!(
!should_reconcile_stale_active(
&record,
&panes,
Some("%5"),
false,
&AlwaysIdleProbe
),
"a caller pane running `{cmd}` was reconciled"
);
}
}
}