use std::collections::{BTreeMap, BTreeSet};
use std::path::Path;
use std::process::Command;
use std::time::Duration;
use tracing::{info, warn};
use octl_core::schema::TmuxIdentity;
use octl_core::{read_node_opt, NodeId, RunLock, RunPaths, Status};
use crate::run::from_core;
const PIPE_PANE_TIMEOUT: Duration = Duration::from_secs(5);
const PIPE_PANE_OUTPUT_CAP: usize = 8 * 1024;
const MAX_CAPTURE_ATTEMPTS: u32 = 10;
const CAPTURE_MAX_BYTES: u64 = 64 * 1024 * 1024;
fn tmux_bin() -> String {
std::env::var("TMUX_BIN").unwrap_or_else(|_| "tmux".to_string())
}
pub(crate) fn capture_tick(
paths: &RunPaths,
armed: &mut BTreeSet<String>,
attempts: &mut BTreeMap<String, u32>,
) {
capture_tick_with(paths, armed, attempts, &tmux_bin());
}
fn capture_tick_with(
paths: &RunPaths,
armed: &mut BTreeSet<String>,
attempts: &mut BTreeMap<String, u32>,
tmux: &str,
) {
let mut targets: Vec<(String, TmuxIdentity)> = Vec::new();
let scan = RunLock::with_shared_lock(&paths.lock(), || {
let entries = match std::fs::read_dir(paths.nodes_dir()) {
Ok(v) => v,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(e) => return Err(octl_core::Error::io(paths.nodes_dir(), e)),
};
for entry in entries.flatten() {
let p = entry.path();
if p.extension().and_then(|s| s.to_str()) != Some("json") {
continue;
}
let Some(node_id) = p.file_stem().and_then(|s| s.to_str()).map(str::to_string) else {
continue;
};
if armed.contains(&node_id)
|| attempts
.get(&node_id)
.is_some_and(|a| *a >= MAX_CAPTURE_ATTEMPTS)
{
continue;
}
let Ok(nid) = NodeId::parse_str(&node_id) else {
continue;
};
let Ok(Some(n)) = read_node_opt(paths, &nid) else {
continue;
};
if matches!(n.status, Status::Done | Status::Failed | Status::Cancelled) {
continue;
}
let Some(identity) = n.tmux_identity.clone() else {
continue;
};
targets.push((node_id, identity));
}
Ok(())
});
if let Err(e) = scan.map_err(from_core) {
warn!(
target: "orchestratectl::supervise",
error = %e.message,
"agent-log capture scan failed (continuing)"
);
return;
}
let log_path = paths.agent_log();
for (node_id, identity) in targets {
let attempt_no = attempts.get(&node_id).copied().unwrap_or(0) + 1;
if setup_pipe_pane(tmux, &identity, &log_path, &node_id) {
armed.insert(node_id.clone());
attempts.remove(&node_id);
} else {
attempts.insert(node_id, attempt_no);
}
}
}
fn setup_pipe_pane(tmux: &str, identity: &TmuxIdentity, log_path: &Path, node_id: &str) -> bool {
let cmd = pipe_pane_command(tmux, identity, log_path);
match run_pipe_pane(cmd) {
Ok(()) => {
info!(
target: "orchestratectl::supervise",
node = node_id,
window = %identity.window_id,
pane = %identity.capture_target(),
log = %log_path.display(),
"agent-log capture armed"
);
true
}
Err(detail) => {
warn!(
target: "orchestratectl::supervise",
node = node_id,
window = %identity.window_id,
pane = %identity.capture_target(),
detail = %detail,
"agent-log capture could not be set up (will retry; continuing without it)"
);
false
}
}
}
fn pipe_pane_command(tmux: &str, identity: &TmuxIdentity, log_path: &Path) -> Command {
let mut cmd = Command::new(tmux);
if let Some(socket) = identity.socket.as_deref() {
cmd.args(["-S", socket]);
}
cmd.args(["pipe-pane", "-O", "-t", identity.capture_target()]);
let shell = format!(
"head -c {CAPTURE_MAX_BYTES} >> {}",
shell_single_quote(&log_path.to_string_lossy())
);
cmd.arg(shell);
cmd
}
fn run_pipe_pane(cmd: Command) -> Result<(), String> {
match crate::proc::run_with_timeout(cmd, PIPE_PANE_TIMEOUT, PIPE_PANE_OUTPUT_CAP) {
crate::proc::TimedOutcome::Exited { status, stderr, .. } if status.success() => {
let _ = stderr;
Ok(())
}
crate::proc::TimedOutcome::Exited { status, stderr, .. } => {
let detail = String::from_utf8_lossy(&stderr.bytes).trim().to_string();
Err(if detail.is_empty() {
format!("non-zero exit {:?}", status.code())
} else {
detail
})
}
crate::proc::TimedOutcome::TimedOut => {
Err(format!("timed out after {PIPE_PANE_TIMEOUT:?}"))
}
crate::proc::TimedOutcome::SpawnErr(e) => Err(format!("spawn failed: {e}")),
}
}
fn shell_single_quote(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
out.push('\'');
for ch in s.chars() {
if ch == '\'' {
out.push_str("'\\''");
} else {
out.push(ch);
}
}
out.push('\'');
out
}
#[cfg(test)]
mod tests {
use super::*;
use octl_core::append_and_apply_event;
use serde_json::json;
use std::path::PathBuf;
use tempfile::TempDir;
fn id(socket: Option<&str>, window_id: &str) -> TmuxIdentity {
TmuxIdentity {
socket: socket.map(str::to_string),
session: "octl".to_string(),
window_id: window_id.to_string(),
pane_id: None,
}
}
fn id_with_pane(socket: Option<&str>, window_id: &str, pane_id: &str) -> TmuxIdentity {
TmuxIdentity {
pane_id: Some(pane_id.to_string()),
..id(socket, window_id)
}
}
fn argv(cmd: &Command) -> Vec<String> {
cmd.get_args()
.map(|a| a.to_string_lossy().into_owned())
.collect()
}
#[test]
fn command_includes_socket_window_and_append_target() {
let identity = id(Some("/private/tmp/tmux-501/default"), "@42");
let log = PathBuf::from("/home/x/.orchestratectl/runs/abc/agent.log");
let cmd = pipe_pane_command("tmux", &identity, &log);
assert_eq!(cmd.get_program().to_string_lossy(), "tmux");
assert_eq!(
argv(&cmd),
vec![
"-S".to_string(),
"/private/tmp/tmux-501/default".to_string(),
"pipe-pane".to_string(),
"-O".to_string(),
"-t".to_string(),
"@42".to_string(),
format!(
"head -c {CAPTURE_MAX_BYTES} >> \
'/home/x/.orchestratectl/runs/abc/agent.log'"
),
]
);
}
#[test]
fn command_omits_socket_flag_when_absent() {
let identity = id(None, "@7");
let log = PathBuf::from("/runs/z/agent.log");
let cmd = pipe_pane_command("tmux", &identity, &log);
let args = argv(&cmd);
assert!(!args.iter().any(|a| a == "-S"), "no -S flag: {args:?}");
assert_eq!(args[0], "pipe-pane");
assert_eq!(args[1], "-O");
assert_eq!(args[2], "-t");
assert_eq!(args[3], "@7");
assert_eq!(
args[4],
format!("head -c {CAPTURE_MAX_BYTES} >> '/runs/z/agent.log'")
);
}
#[test]
fn command_targets_recorded_pane_id_over_window_id() {
let identity = id_with_pane(None, "@42", "%7");
let log = PathBuf::from("/runs/z/agent.log");
let cmd = pipe_pane_command("tmux", &identity, &log);
let args = argv(&cmd);
assert_eq!(args[2], "-t");
assert_eq!(args[3], "%7", "targets pane_id, not window_id: {args:?}");
assert!(
!args.iter().any(|a| a == "@42"),
"window_id must not appear as the target when a pane_id is recorded: {args:?}"
);
}
#[test]
fn command_falls_back_to_window_id_when_pane_id_absent() {
let identity = id(None, "@9");
let cmd = pipe_pane_command("tmux", &identity, &PathBuf::from("/runs/z/agent.log"));
let args = argv(&cmd);
assert_eq!(args[2], "-t");
assert_eq!(args[3], "@9", "falls back to window_id: {args:?}");
}
#[test]
fn capture_target_prefers_pane_id() {
assert_eq!(id_with_pane(None, "@42", "%7").capture_target(), "%7");
assert_eq!(id(None, "@42").capture_target(), "@42");
}
#[test]
fn shell_quote_escapes_embedded_single_quote() {
assert_eq!(shell_single_quote("a"), "'a'");
assert_eq!(shell_single_quote("a'b"), "'a'\\''b'");
assert_eq!(
shell_single_quote("/tmp/it's here/agent.log"),
"'/tmp/it'\\''s here/agent.log'"
);
}
#[test]
fn run_pipe_pane_reports_spawn_failure_leniently() {
let cmd = Command::new("/nonexistent/definitely/not/tmux");
let err = run_pipe_pane(cmd).unwrap_err();
assert!(err.starts_with("spawn failed:"), "err={err:?}");
}
fn fake_tmux(dir: &Path) -> String {
use std::os::unix::fs::PermissionsExt as _;
let p = dir.join("fake-tmux.sh");
let log = dir.join("tmux.log");
let script = format!(
"#!/bin/bash\nprintf '%s ' \"$@\" >> '{log}'\nprintf '\\n' >> '{log}'\nexit 0\n",
log = log.display(),
);
std::fs::write(&p, &script).unwrap();
std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap();
p.to_str().unwrap().to_string()
}
fn fresh_run(tmp: &TempDir) -> RunPaths {
let run_id = "01jxsnap000000000000000000";
let dir = tmp.path().join(run_id);
std::fs::create_dir_all(&dir).unwrap();
RunPaths::new(dir, run_id).unwrap()
}
#[test]
fn capture_tick_arms_node_once_and_targets_agent_log() {
let tmp = TempDir::new().unwrap();
let paths = fresh_run(&tmp);
append_and_apply_event(
&paths,
"run.created",
None,
None,
json!({ "kind": "spinoff", "lifecycle": "autonomous", "title": "t" }),
)
.unwrap();
append_and_apply_event(
&paths,
"node.created",
Some(&NodeId::parse_str("n-0001").unwrap()),
None,
json!({
"kind": "spinoff",
"tmux_socket": "/private/tmp/tmux-501/default",
"tmux_session": "headless",
"tmux_window_id": "@42",
}),
)
.unwrap();
let tmux = fake_tmux(tmp.path());
let mut armed = BTreeSet::new();
let mut attempts = BTreeMap::new();
capture_tick_with(&paths, &mut armed, &mut attempts, &tmux);
assert!(armed.contains("n-0001"), "node marked armed");
assert!(
!attempts.contains_key("n-0001"),
"success clears the attempt counter"
);
let log = std::fs::read_to_string(tmp.path().join("tmux.log")).unwrap();
let expected_target = format!(
"head -c {CAPTURE_MAX_BYTES} >> '{}'",
paths.agent_log().display()
);
assert!(
log.contains("-S /private/tmp/tmux-501/default"),
"socket: {log:?}"
);
assert!(
log.contains("pipe-pane -O -t @42"),
"pipe-pane target: {log:?}"
);
assert!(log.contains(&expected_target), "append target: {log:?}");
let invocations_before = log.lines().count();
capture_tick_with(&paths, &mut armed, &mut attempts, &tmux);
let log2 = std::fs::read_to_string(tmp.path().join("tmux.log")).unwrap();
assert_eq!(
log2.lines().count(),
invocations_before,
"already-armed node must not be re-armed: {log2:?}"
);
}
#[test]
fn capture_tick_targets_recorded_pane_id_end_to_end() {
let tmp = TempDir::new().unwrap();
let paths = fresh_run(&tmp);
append_and_apply_event(
&paths,
"run.created",
None,
None,
json!({ "kind": "spinoff", "lifecycle": "autonomous", "title": "t" }),
)
.unwrap();
append_and_apply_event(
&paths,
"node.created",
Some(&NodeId::parse_str("n-0001").unwrap()),
None,
json!({
"kind": "spinoff",
"tmux_session": "headless",
"tmux_window_id": "@42",
"tmux_pane_id": "%7",
}),
)
.unwrap();
let tmux = fake_tmux(tmp.path());
let mut armed = BTreeSet::new();
let mut attempts = BTreeMap::new();
capture_tick_with(&paths, &mut armed, &mut attempts, &tmux);
assert!(armed.contains("n-0001"), "node marked armed");
let log = std::fs::read_to_string(tmp.path().join("tmux.log")).unwrap();
assert!(
log.contains("pipe-pane -O -t %7"),
"targets the recorded pane_id: {log:?}"
);
assert!(
!log.contains("-t @42"),
"window_id must not be the target when a pane_id was recorded: {log:?}"
);
}
#[test]
fn capture_tick_skips_terminal_node() {
let tmp = TempDir::new().unwrap();
let paths = fresh_run(&tmp);
append_and_apply_event(
&paths,
"run.created",
None,
None,
json!({ "kind": "spinoff", "lifecycle": "autonomous", "title": "t" }),
)
.unwrap();
let node = NodeId::parse_str("n-0001").unwrap();
append_and_apply_event(
&paths,
"node.created",
Some(&node),
None,
json!({ "kind": "spinoff", "tmux_session": "headless", "tmux_window_id": "@42" }),
)
.unwrap();
append_and_apply_event(
&paths,
"node.report",
Some(&node),
None,
json!({ "success": true, "via": "explicit-merge" }),
)
.unwrap();
let tmux = fake_tmux(tmp.path());
let mut armed = BTreeSet::new();
let mut attempts = BTreeMap::new();
capture_tick_with(&paths, &mut armed, &mut attempts, &tmux);
assert!(armed.is_empty(), "terminal node not armed");
assert!(
!tmp.path().join("tmux.log").exists(),
"no tmux invocation for a terminal node"
);
}
#[test]
fn capture_tick_leaves_node_without_identity_for_retry() {
let tmp = TempDir::new().unwrap();
let paths = fresh_run(&tmp);
append_and_apply_event(
&paths,
"run.created",
None,
None,
json!({ "kind": "spinoff", "lifecycle": "autonomous", "title": "t" }),
)
.unwrap();
append_and_apply_event(
&paths,
"node.created",
Some(&NodeId::parse_str("n-0001").unwrap()),
None,
json!({ "kind": "spinoff" }),
)
.unwrap();
let tmux = fake_tmux(tmp.path());
let mut armed = BTreeSet::new();
let mut attempts = BTreeMap::new();
capture_tick_with(&paths, &mut armed, &mut attempts, &tmux);
assert!(
armed.is_empty(),
"no identity → not armed (retry on a later tick)"
);
assert!(
!attempts.contains_key("n-0001"),
"a node that never reached pipe-pane must not burn a retry attempt"
);
}
#[test]
fn capture_tick_retries_then_gives_up_on_persistent_failure() {
use std::os::unix::fs::PermissionsExt as _;
let tmp = TempDir::new().unwrap();
let paths = fresh_run(&tmp);
append_and_apply_event(
&paths,
"run.created",
None,
None,
json!({ "kind": "spinoff", "lifecycle": "autonomous", "title": "t" }),
)
.unwrap();
append_and_apply_event(
&paths,
"node.created",
Some(&NodeId::parse_str("n-0001").unwrap()),
None,
json!({ "kind": "spinoff", "tmux_session": "headless", "tmux_window_id": "@42" }),
)
.unwrap();
let failing = tmp.path().join("failing-tmux.sh");
let log = tmp.path().join("tmux.log");
std::fs::write(
&failing,
format!(
"#!/bin/bash\nprintf 'call\\n' >> '{}'\necho 'no server' >&2\nexit 1\n",
log.display()
),
)
.unwrap();
std::fs::set_permissions(&failing, std::fs::Permissions::from_mode(0o755)).unwrap();
let tmux = failing.to_str().unwrap();
let mut armed = BTreeSet::new();
let mut attempts = BTreeMap::new();
for _ in 0..(MAX_CAPTURE_ATTEMPTS + 3) {
capture_tick_with(&paths, &mut armed, &mut attempts, tmux);
}
assert!(armed.is_empty(), "persistent failure never arms");
assert_eq!(
attempts.get("n-0001").copied(),
Some(MAX_CAPTURE_ATTEMPTS),
"attempts cap at the budget"
);
let calls = std::fs::read_to_string(&log).unwrap().lines().count();
assert_eq!(
calls, MAX_CAPTURE_ATTEMPTS as usize,
"pipe-pane is invoked exactly MAX_CAPTURE_ATTEMPTS times, then never again"
);
}
#[test]
fn run_pipe_pane_times_out_on_a_wedged_tmux() {
use std::os::unix::fs::PermissionsExt as _;
let tmp = TempDir::new().unwrap();
let hang = tmp.path().join("hang-tmux.sh");
std::fs::write(&hang, "#!/bin/bash\nsleep 60\n").unwrap();
std::fs::set_permissions(&hang, std::fs::Permissions::from_mode(0o755)).unwrap();
let start = std::time::Instant::now();
let err = run_pipe_pane(Command::new(&hang)).unwrap_err();
let elapsed = start.elapsed();
assert!(err.starts_with("timed out"), "err={err:?}");
assert!(
elapsed < PIPE_PANE_TIMEOUT + Duration::from_secs(3),
"returned in {elapsed:?}, expected ~{PIPE_PANE_TIMEOUT:?}"
);
}
}