use super::error::Result;
use super::hooks::{HookKind, run_hooks};
use super::state::{self, PaneState};
use super::tmux::Tmux;
#[derive(Debug, Clone, PartialEq)]
pub struct DeadPane {
pub window: String,
pub index: usize,
pub cwd: String,
pub command: String,
pub task: Option<String>,
}
pub fn parse_dead_panes(listing: &str, pod: &state::PodState) -> Vec<DeadPane> {
let mut dead = Vec::new();
for line in listing.lines() {
let parts: Vec<&str> = line.split('|').collect();
if parts.len() < 3 || parts[2] != "1" {
continue;
}
let window_name = parts[0];
let Ok(pane_index) = parts[1].parse::<usize>() else {
continue;
};
let Some(window) = pod.windows.iter().find(|w| w.name == window_name) else {
continue;
};
if window.panes.is_empty() {
continue;
}
let want = pane_index
.saturating_sub(1)
.min(window.panes.len().saturating_sub(1));
let Some(record) = window.panes.get(want) else {
continue;
};
let PaneState {
cwd, command, task, ..
} = record;
dead.push(DeadPane {
window: window_name.to_string(),
index: pane_index,
cwd: cwd.clone(),
command: command.clone(),
task: task.clone(),
});
}
dead
}
pub fn pane_recover(session: &str) -> Result<()> {
match disposition(&super::journal::append_recovery(session)?) {
Disposition::Deferred => {
tracing::info!("pane-recover: adoption in flight, deferred recovery for '{session}'");
Ok(())
}
Disposition::RecoverInline => pane_recover_inline(session),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Disposition {
Deferred,
RecoverInline,
}
fn disposition(outcome: &super::journal::AppendOutcome) -> Disposition {
use super::journal::AppendOutcome;
match outcome {
AppendOutcome::Appended | AppendOutcome::AlreadyPresent(_) => Disposition::Deferred,
AppendOutcome::NotAdopting => Disposition::RecoverInline,
}
}
pub fn pane_recover_inline(session: &str) -> Result<()> {
let Some(pod_state) = state::pod(session)? else {
tracing::warn!("pane-recover: no state for pod '{session}'");
return Ok(());
};
match Tmux::session_existence(session) {
super::tmux::Existence::ConfirmedAbsent => {
tracing::warn!(
"pane-recover: session '{session}' no longer exists; nothing to recover"
);
return Ok(());
}
super::tmux::Existence::Error(why) => {
return Err(super::error::PodError::Other(anyhow::anyhow!(
"pane-recover: cannot determine whether session '{session}' exists \
(tmux server unreachable): {why}"
)));
}
super::tmux::Existence::Present => {}
}
let listing = Tmux::list_panes_for(session)?;
for pane in parse_dead_panes(&listing, &pod_state) {
tracing::info!(
"pane-recover: found dead pane {}.{}",
pane.window,
pane.index
);
let target = format!("{session}:{}.{}", pane.window, pane.index);
let _ = Tmux::run(&["respawn-pane", "-k", "-t", &target, "-c", &pane.cwd]);
if !pane.command.is_empty() {
let _ = Tmux::run(&["send-keys", "-t", &target, &pane.command, "Enter"]);
}
if let Some(task_id) = &pane.task {
if let Err(error) = record_task_recovery(session, task_id, &pane) {
tracing::warn!("pane-recover: could not record recovery for {task_id}: {error}");
}
}
}
if !pod_state.on_pane_dead.is_empty() {
run_hooks(HookKind::OnPaneDead, &pod_state.on_pane_dead, session)?;
}
Ok(())
}
fn record_task_recovery(session: &str, task_id: &str, pane: &DeadPane) -> anyhow::Result<()> {
let forge_dir = std::path::Path::new(&pane.cwd).join(".forge");
if !forge_dir.is_dir() {
return Ok(());
}
let logger = crate::core::event::EventLogger::new(&forge_dir);
let event = crate::core::event::ForgeEvent::new(
crate::core::event::EventType::PaneRecovered,
format!(
"pane {}.{} in pod '{session}' died and was respawned; task re-claimed",
pane.window, pane.index
),
)
.with_task(task_id)
.with_metadata(serde_json::json!({
"pod": session,
"window": pane.window,
"pane_index": pane.index,
"command": pane.command,
}));
logger.log(&event)?;
Ok(())
}
pub fn after_detach(session: &str) -> Result<()> {
let Some(pod_state) = state::pod(session)? else {
return Ok(());
};
if pod_state.after_detach.is_empty() {
return Ok(());
}
run_hooks(HookKind::AfterDetach, &pod_state.after_detach, session)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::pod::state::{PodState, WindowState};
fn pod_with(panes: Vec<PaneState>) -> PodState {
PodState {
status: "running".into(),
started_at: "2026-07-18T00:00:00Z".into(),
source_path: "/tmp/p.yaml".into(),
windows: vec![WindowState {
name: "main".into(),
panes,
}],
on_pane_dead: vec![],
after_detach: vec![],
}
}
fn pane(cwd: &str, command: &str, task: Option<&str>) -> PaneState {
PaneState {
index: 0,
cwd: cwd.into(),
command: command.into(),
task: task.map(String::from),
}
}
#[test]
fn only_panes_marked_dead_are_recovered() {
let pod = pod_with(vec![pane("/a", "cmd-a", None)]);
let dead = parse_dead_panes("main|1|0\nmain|1|1\n", &pod);
assert_eq!(dead.len(), 1, "a live pane must not be respawned");
assert_eq!(dead[0].command, "cmd-a");
}
#[test]
fn tmux_base_index_maps_to_the_stored_declaration_order() {
let pod = pod_with(vec![
pane("/first", "cmd-first", None),
pane("/second", "cmd-second", None),
]);
let dead = parse_dead_panes("main|2|1\n", &pod);
assert_eq!(dead[0].cwd, "/second");
assert_eq!(dead[0].command, "cmd-second");
}
#[test]
fn out_of_range_index_clamps_instead_of_panicking() {
let pod = pod_with(vec![pane("/only", "cmd", None)]);
let dead = parse_dead_panes("main|99|1\n", &pod);
assert_eq!(dead.len(), 1);
assert_eq!(dead[0].cwd, "/only");
}
#[test]
fn unknown_window_and_malformed_lines_are_skipped() {
let pod = pod_with(vec![pane("/a", "cmd", None)]);
assert!(parse_dead_panes("ghost|1|1\n", &pod).is_empty());
assert!(parse_dead_panes("garbage\n", &pod).is_empty());
assert!(
parse_dead_panes("main|notanumber|1\n", &pod).is_empty(),
"a non-numeric index must be skipped, not panic"
);
assert!(parse_dead_panes("", &pod).is_empty());
}
#[test]
fn disposition_routes_notadopting_to_inline_recovery() {
use super::super::journal::AppendOutcome;
use super::super::journal::StepState;
assert_eq!(
disposition(&AppendOutcome::NotAdopting),
Disposition::RecoverInline
);
assert_eq!(disposition(&AppendOutcome::Appended), Disposition::Deferred);
assert_eq!(
disposition(&AppendOutcome::AlreadyPresent(StepState::Pending)),
Disposition::Deferred
);
}
#[test]
fn task_binding_is_carried_through_recovery() {
let pod = pod_with(vec![pane("/proj", "ccyolo", Some("T-042"))]);
let dead = parse_dead_panes("main|1|1\n", &pod);
assert_eq!(dead[0].task.as_deref(), Some("T-042"));
}
#[test]
fn pane_without_a_task_binding_recovers_with_no_task() {
let pod = pod_with(vec![pane("/proj", "htop", None)]);
let dead = parse_dead_panes("main|1|1\n", &pod);
assert_eq!(dead[0].task, None);
}
#[test]
fn recording_into_a_non_forge_directory_is_inert_not_an_error() {
let dir = std::env::temp_dir().join(format!("forge-pod-norecord-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let p = DeadPane {
window: "main".into(),
index: 1,
cwd: dir.display().to_string(),
command: "x".into(),
task: Some("T-1".into()),
};
assert!(
record_task_recovery("pod", "T-1", &p).is_ok(),
"a pane outside a forge project must not error"
);
assert!(!dir.join(".forge").exists(), "and must not create .forge");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn recording_appends_a_pane_recovered_event() {
let dir = std::env::temp_dir().join(format!("forge-pod-record-{}", std::process::id()));
let forge = dir.join(".forge");
std::fs::create_dir_all(&forge).unwrap();
let p = DeadPane {
window: "main".into(),
index: 1,
cwd: dir.display().to_string(),
command: "ccyolo".into(),
task: Some("T-042".into()),
};
record_task_recovery("mypod", "T-042", &p).expect("record");
let log = std::fs::read_to_string(forge.join("events.jsonl")).expect("events written");
assert!(log.contains("pane_recovered"), "{log}");
assert!(log.contains("T-042"), "{log}");
assert!(log.contains("mypod"), "{log}");
let _ = std::fs::remove_dir_all(&dir);
}
}