pub mod capture;
pub mod cleanup;
pub mod notify;
pub mod pid_file;
pub mod reducer;
pub mod state;
pub mod tail;
pub mod watchdog;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicI32, Ordering};
use std::time::{Duration, Instant};
use chrono::{DateTime, Utc};
use clap::Args as ClapArgs;
use serde::Serialize;
use serde_json::{json, Value};
use tracing::{info, warn};
use octl_core::{
append_and_apply_event, append_and_apply_unlocked, read_manifest_opt, read_node_opt, Lifecycle,
Node, NodeId, RunLock, RunPaths, Status,
};
use crate::error::{CliError, ExitKind};
use crate::output::{self, OutputFormat, OutputSpec};
use crate::run::{from_core, parse_run_id, run_paths, supervisor_readiness};
const TAIL_TICK: Duration = Duration::from_millis(500);
const WATCHDOG_TICK: Duration = Duration::from_secs(1);
const CHILD_DIR_WAIT: Duration = Duration::from_secs(5);
const CHILD_SPAWN_DEADLINE: Duration = Duration::from_secs(10);
const CHILD_SPAWN_DEADLINE_ENV: &str = "OCTL_CHILD_SPAWN_DEADLINE_SECS";
const CHILD_RETRY_BASE_BACKOFF: Duration = Duration::from_secs(2);
const CHILD_RETRY_MAX_BACKOFF: Duration = Duration::from_secs(30);
const CHILD_SPAWN_MAX_ATTEMPTS: u32 = 5;
fn child_spawn_deadline() -> Duration {
match std::env::var(CHILD_SPAWN_DEADLINE_ENV) {
Ok(v) => v
.trim()
.parse::<u64>()
.map_or(CHILD_SPAWN_DEADLINE, Duration::from_secs),
Err(_) => CHILD_SPAWN_DEADLINE,
}
}
const AGENT_RETRY_MAX_ATTEMPTS: u32 = 3;
const AGENT_RETRY_MAX_ATTEMPTS_ENV: &str = "OCTL_AGENT_RETRY_MAX_ATTEMPTS";
const AGENT_RETRY_BASE_BACKOFF: Duration = Duration::from_secs(10);
const AGENT_RETRY_MAX_BACKOFF: Duration = Duration::from_secs(120);
const AGENT_RETRY_BACKOFF_ENV: &str = "OCTL_AGENT_RETRY_BACKOFF_SECS";
const AGENT_RESPAWN_MAX_FAILURES: u32 = 3;
const AGENT_RESPAWN_MAX_FAILURES_ENV: &str = "OCTL_AGENT_RESPAWN_MAX_FAILURES";
fn agent_respawn_max_failures() -> u32 {
match std::env::var(AGENT_RESPAWN_MAX_FAILURES_ENV) {
Ok(v) => v
.trim()
.parse::<u32>()
.map_or(AGENT_RESPAWN_MAX_FAILURES, |n| n),
Err(_) => AGENT_RESPAWN_MAX_FAILURES,
}
}
fn agent_retry_max_attempts() -> u32 {
match std::env::var(AGENT_RETRY_MAX_ATTEMPTS_ENV) {
Ok(v) => v
.trim()
.parse::<u32>()
.map_or(AGENT_RETRY_MAX_ATTEMPTS, |n| n),
Err(_) => AGENT_RETRY_MAX_ATTEMPTS,
}
}
fn agent_retry_backoff(attempt: u32) -> Duration {
let base_secs = match std::env::var(AGENT_RETRY_BACKOFF_ENV) {
Ok(v) => v
.trim()
.parse::<u64>()
.unwrap_or_else(|_| AGENT_RETRY_BASE_BACKOFF.as_secs()),
Err(_) => AGENT_RETRY_BASE_BACKOFF.as_secs(),
};
let shift = attempt.saturating_sub(1).min(5);
let secs = base_secs
.saturating_mul(1u64 << shift)
.min(AGENT_RETRY_MAX_BACKOFF.as_secs());
Duration::from_secs(secs)
}
const SELF_TERMINATE_TICKS: u32 = 3;
const NO_WORKER_TICKS: u32 = 3;
const NO_WORKER_GRACE: Duration = Duration::from_secs(900);
const NO_WORKER_GRACE_ENV: &str = "OCTL_NO_WORKER_GRACE_SECS";
fn no_worker_grace() -> Duration {
match std::env::var(NO_WORKER_GRACE_ENV) {
Ok(v) => v
.trim()
.parse::<u64>()
.map_or(NO_WORKER_GRACE, Duration::from_secs),
Err(_) => NO_WORKER_GRACE,
}
}
const NO_WORKER_REASON: &str = "no-worker-node";
const IDLE_UNMERGED_THRESHOLD: Duration = Duration::from_secs(1800);
const IDLE_UNMERGED_ENV: &str = "OCTL_IDLE_UNMERGED_SECS";
const IDLE_UNMERGED_REASON: &str = "agent-idle-unmerged";
fn idle_unmerged_threshold() -> Duration {
match std::env::var(IDLE_UNMERGED_ENV) {
Ok(v) => v
.trim()
.parse::<u64>()
.map_or(IDLE_UNMERGED_THRESHOLD, Duration::from_secs),
Err(_) => IDLE_UNMERGED_THRESHOLD,
}
}
fn cpu_activity_clock(
map: &mut std::collections::BTreeMap<String, (u64, i64)>,
node_id: &str,
cpu_now: Option<u64>,
now_unix: i64,
) -> Option<i64> {
let cpu = cpu_now?;
let entry = map.entry(node_id.to_string()).or_insert((cpu, now_unix));
if entry.0 != cpu {
*entry = (cpu, now_unix);
}
Some(entry.1)
}
const NOTIFY_MAX_ATTEMPTS: u32 = 5;
const WATCHDOG_SPAWN_GRACE: Duration = Duration::from_secs(5);
const SPAWN_GRACE_ENV: &str = "OCTL_WATCHDOG_GRACE_SECS";
const DROPPED_WARN_INTERVAL: Duration = Duration::from_secs(60);
static SIGNAL_RECEIVED: AtomicI32 = AtomicI32::new(0);
extern "C" fn handle_term_signal(sig: libc::c_int) {
let _ = SIGNAL_RECEIVED.compare_exchange(0, sig, Ordering::SeqCst, Ordering::SeqCst);
}
fn install_signal_handlers() -> Result<(), CliError> {
unsafe {
let mut sa: libc::sigaction = std::mem::zeroed();
sa.sa_sigaction = handle_term_signal as extern "C" fn(libc::c_int) as usize;
libc::sigemptyset(&raw mut sa.sa_mask);
libc::sigaddset(&raw mut sa.sa_mask, libc::SIGINT);
libc::sigaddset(&raw mut sa.sa_mask, libc::SIGTERM);
sa.sa_flags = libc::SA_RESTART;
for sig in [libc::SIGINT, libc::SIGTERM] {
if libc::sigaction(sig, &raw const sa, std::ptr::null_mut()) != 0 {
let err = std::io::Error::last_os_error();
return Err(CliError::system(
"signal_install_failed",
format!("sigaction({sig}) failed: {err}"),
));
}
}
}
Ok(())
}
#[derive(ClapArgs, Debug)]
pub struct SuperviseArgs {
pub run_id: String,
#[arg(long)]
pub once: bool,
#[arg(long)]
pub max_iter: Option<u32>,
#[arg(long)]
pub no_quarantine_corrupt_lines: bool,
}
struct SupervisorBoot {
root: PathBuf,
paths: RunPaths,
pid_path: PathBuf,
our_pid: u32,
state: state::SupervisorState,
own_tail: tail::EventTail,
child_tails: std::collections::BTreeMap<String, ChildTracking>,
}
fn boot_supervisor(run_id: &str) -> Result<SupervisorBoot, CliError> {
let root = crate::home::root_dir()?;
let paths = run_paths(root.as_path(), run_id)?;
if read_manifest_opt(&paths).map_err(from_core)?.is_none() {
return Err(CliError {
kind: ExitKind::User,
code: "run_not_found".into(),
message: format!("no run with id {run_id}"),
invalid_value: Some(run_id.to_string()),
expected: None,
});
}
let pid_path = paths.supervisor_pid();
SIGNAL_RECEIVED.store(0, Ordering::SeqCst);
install_signal_handlers()?;
let our_pid = std::process::id();
pid_file::claim_pid_atomic(&paths, our_pid)?;
let assemble = || -> Result<(state::SupervisorState, tail::EventTail, _), CliError> {
info!(
target: "orchestratectl::supervise",
run_id = %run_id,
pid = our_pid,
"supervisor started"
);
let _ = append_and_apply_event(
&paths,
"supervisor.started",
None,
None,
json!({"pid": our_pid}),
)
.map_err(from_core);
let state = state::load(&paths.root)?;
let own_tail = tail::EventTail::new(paths.events(), state.last_seq_own);
let mut child_tails: std::collections::BTreeMap<String, ChildTracking> =
std::collections::BTreeMap::new();
for (cid, parent_node_id) in discover_children(&paths) {
let child_paths = run_paths(&root, &cid)?;
let seq = state
.last_processed_report_seq_by_child
.get(&cid)
.copied()
.unwrap_or(0);
child_tails.insert(
cid.clone(),
ChildTracking {
parent_node_id,
tail: tail::EventTail::new(child_paths.events(), seq),
terminal: false,
},
);
}
Ok((state, own_tail, child_tails))
};
let (state, own_tail, child_tails) = match assemble() {
Ok(v) => v,
Err(e) => {
pid_file::remove_if_owner(&pid_path, our_pid);
return Err(e);
}
};
Ok(SupervisorBoot {
root,
paths,
pid_path,
our_pid,
state,
own_tail,
child_tails,
})
}
pub fn dispatch(
args: SuperviseArgs,
spec: &OutputSpec,
warnings: &[String],
) -> Result<(), CliError> {
let run_id = args.run_id.clone();
let mut readiness = supervisor_readiness::ReadinessReporter::from_env();
let boot = match boot_supervisor(&run_id) {
Ok(b) => {
if SIGNAL_RECEIVED.load(Ordering::SeqCst) != 0 {
readiness.error(
"terminated_during_boot",
"termination signal received during supervisor boot",
);
pid_file::remove_if_owner(&b.pid_path, b.our_pid);
return Err(CliError::system(
"terminated_during_boot",
format!(
"supervisor for run {run_id} received a termination signal during boot"
),
));
}
readiness.ready(b.our_pid);
b
}
Err(e) => {
readiness.error(&e.code, &e.message);
return Err(e);
}
};
let SupervisorBoot {
root,
paths,
pid_path,
our_pid,
mut state,
mut own_tail,
mut child_tails,
} = boot;
let mut half_state_streak: std::collections::BTreeMap<String, u32> =
std::collections::BTreeMap::new();
let mut retry_states: std::collections::BTreeMap<String, RetryPark> =
std::collections::BTreeMap::new();
let mut cpu_activity: std::collections::BTreeMap<String, (u64, i64)> =
std::collections::BTreeMap::new();
let mut capture_attempts: std::collections::BTreeMap<String, u32> =
std::collections::BTreeMap::new();
state.spawned_children.retain(|_, pid| *pid != 0);
let mut child_spawns = reseed_child_spawns(
&root,
child_tails.keys().map(String::as_str),
&state,
Instant::now(),
);
let mut manifest_missing_streak: u32 = 0;
let mut no_worker_streak: u32 = 0;
let mut spawn_failed_terminal = false;
let mut cleaned = false;
let mut notified = false;
let mut notify_attempts: u32 = 0;
let mut last_dropped_warned: u64 = 0;
let mut last_dropped_warn_at: Option<Instant> = None;
let quarantine = !args.no_quarantine_corrupt_lines;
let mut iter: u32 = 0;
let exit_reason: &'static str = loop {
if SIGNAL_RECEIVED.load(Ordering::SeqCst) != 0 {
break "signal";
}
match paths.manifest().try_exists() {
Ok(false) => {
manifest_missing_streak += 1;
if manifest_missing_streak >= SELF_TERMINATE_TICKS {
break "run-dir-vanished";
}
std::thread::sleep(WATCHDOG_TICK);
continue;
}
Ok(true) | Err(_) => manifest_missing_streak = 0,
}
if let Some(max) = args.max_iter {
if iter >= max {
break "test-bounded-exit";
}
}
iter += 1;
let own_events = match own_tail.poll() {
Ok(v) => v,
Err(e) => {
warn!(target: "orchestratectl::supervise", error = %e.message, "own tail failed");
Vec::new()
}
};
for ev in own_events {
state.last_seq_own = ev.seq;
match ev.kind.as_str() {
"child.spawned" => {
let child_run_id = ev
.data
.get("child_run_id")
.and_then(Value::as_str)
.map(str::to_string);
let Some(child_run_id) = child_run_id else {
warn!(
target: "orchestratectl::supervise",
seq = ev.seq,
"child.spawned missing child_run_id; skipping"
);
continue;
};
let Ok(child_run_id) = parse_run_id(&child_run_id).map(|r| r.to_string())
else {
warn!(
target: "orchestratectl::supervise",
seq = ev.seq,
child = %child_run_id,
"child.spawned has unsafe child_run_id; skipping"
);
continue;
};
let Some(parent_node_id) = ev.node_id.clone() else {
warn!(
target: "orchestratectl::supervise",
seq = ev.seq,
child = %child_run_id,
"child.spawned missing node_id; skipping"
);
continue;
};
let child_events = run_paths(&root, &child_run_id)?.events();
let seq = state
.last_processed_report_seq_by_child
.get(&child_run_id)
.copied()
.unwrap_or(0);
child_tails
.entry(child_run_id.clone())
.or_insert_with(|| ChildTracking {
parent_node_id: parent_node_id.to_string(),
tail: tail::EventTail::new(child_events, seq),
terminal: false,
});
if state.spawned_children.contains_key(&child_run_id)
|| child_spawns.contains_key(&child_run_id)
{
continue;
}
match fork_child_supervisor(&root, &child_run_id) {
Ok(()) => {
child_spawns.insert(
child_run_id.clone(),
ChildSpawn::Starting {
since: Instant::now(),
attempts: 1,
},
);
}
Err(e) => {
warn!(
target: "orchestratectl::supervise",
child = %child_run_id,
error = %e.message,
"child fork failed (tail still open; will retry under bounded policy)"
);
let _ = append_and_apply_event(
&paths,
"child.spawn_failed",
ev.node_id.as_ref(),
None,
json!({
"child_run_id": child_run_id,
"reason": e.message,
"attempts": 1,
}),
);
child_spawns.insert(
child_run_id.clone(),
ChildSpawn::Failed {
attempts: 1,
retry_at: Instant::now() + child_retry_backoff(1),
},
);
}
}
}
"run.status" => {
if let Some(s) = ev.data.get("status").and_then(Value::as_str) {
if matches!(s, "done" | "failed" | "cancelled") {
let _ = state::save(&paths.root, &state);
}
}
}
_ => {}
}
}
report_corrupt_line(&mut own_tail, &paths, &paths, quarantine, "own");
reconcile_child_spawns(&root, &paths, &mut child_spawns, &mut state, Instant::now());
let child_ids: Vec<String> = child_tails.keys().cloned().collect();
for cid in child_ids {
let entry = child_tails.get_mut(&cid).unwrap();
if entry.terminal {
continue;
}
let evs = match entry.tail.poll() {
Ok(v) => v,
Err(e) => {
warn!(
target: "orchestratectl::supervise",
child = %cid,
error = %e.message,
"child tail failed"
);
continue;
}
};
for ev in evs {
state.last_seq_by_child.insert(cid.clone(), ev.seq);
match ev.kind.as_str() {
"node.report" => {
let child_node_id = ev
.node_id
.as_ref()
.map_or("n-0001", NodeId::as_str)
.to_string();
let parent_node_id = entry.parent_node_id.clone();
match reducer::process_node_report(
&paths,
&parent_node_id,
&cid,
&child_node_id,
ev.seq,
&ev.data,
&mut state,
) {
Ok(Some(c)) => {
info!(
target: "orchestratectl::supervise",
child = %cid,
seq = ev.seq,
discussions = c.emitted_discussions.len(),
spinoffs = c.emitted_spinoffs.len(),
skipped = c.skipped_already_present,
"consumed node.report"
);
entry.terminal = true;
}
Ok(None) => {
entry.terminal = true;
}
Err(e) => {
warn!(
target: "orchestratectl::supervise",
child = %cid,
seq = ev.seq,
error = %e.message,
"node.report consumption failed; will retry"
);
let rewind_to = ev.seq.saturating_sub(1);
let p = entry.tail.path().to_path_buf();
entry.tail = tail::EventTail::new(p, rewind_to);
state.last_seq_by_child.insert(cid.clone(), rewind_to);
entry.terminal = false;
break;
}
}
}
"run.status" => {
if let Some(s) = ev.data.get("status").and_then(Value::as_str) {
if matches!(s, "done" | "failed" | "cancelled") {
entry.terminal = true;
}
}
}
_ => {}
}
}
let child_owner = run_paths(&root, &cid).ok();
let (owner, q) = match child_owner.as_ref() {
Some(cp) => (cp, quarantine),
None => (&paths, false),
};
report_corrupt_line(&mut entry.tail, &paths, owner, q, &cid);
}
capture::capture_tick(&paths, &mut state.captured_armed, &mut capture_attempts);
if let Err(e) = watchdog_tick(
&paths,
&mut half_state_streak,
&mut retry_states,
&mut cpu_activity,
) {
warn!(
target: "orchestratectl::supervise",
error = %e.message,
"watchdog tick failed"
);
}
if !spawn_failed_terminal {
let unlocked = read_manifest_opt(&paths).ok().flatten();
let old_enough = |m: &octl_core::Manifest| {
(Utc::now() - m.created_at)
.to_std()
.is_ok_and(|age| age >= no_worker_grace())
};
let candidate = unlocked.as_ref().is_some_and(|m| {
!m.status.is_terminal()
&& m.node_count == 0
&& child_tails.is_empty()
&& state.spawned_children.is_empty()
&& old_enough(m)
});
if candidate {
no_worker_streak += 1;
} else {
no_worker_streak = 0;
}
if no_worker_streak >= NO_WORKER_TICKS {
match RunLock::acquire(&paths.lock()) {
Ok(guard) => {
let fresh = read_manifest_opt(&paths).ok().flatten();
let still_no_worker = fresh.as_ref().is_some_and(|m| {
!m.status.is_terminal()
&& m.node_count == 0
&& child_tails.is_empty()
&& state.spawned_children.is_empty()
});
if still_no_worker {
let key = format!("supervisor-no-worker:{run_id}:run-status");
let lock = guard.witness();
match append_and_apply_unlocked(
&lock,
&paths,
"run.status",
None,
Some(&key),
json!({ "status": "failed", "reason": NO_WORKER_REASON }),
) {
Ok(_) => {
warn!(
target: "orchestratectl::supervise",
run_id = %run_id,
reason = NO_WORKER_REASON,
"run has no worker node and no children past the create \
window; terminalizing as failed (supervisor_spawn_failed)"
);
spawn_failed_terminal = true;
}
Err(e) => {
warn!(
target: "orchestratectl::supervise",
error = %e,
"failed to record no-worker terminal run.status; will retry next tick"
);
}
}
} else {
no_worker_streak = 0;
}
drop(guard);
}
Err(e) => {
warn!(
target: "orchestratectl::supervise",
error = %e,
"could not lock run to terminalize no-worker; will retry next tick"
);
}
}
}
}
let children_all_terminal = child_tails.values().all(|t| t.terminal);
if let Some(status) = cleanup::rollup_status(&paths, children_all_terminal) {
let status_str = match status {
Status::Done => "done",
_ => "failed",
};
let key = format!("supervisor-rollup:{run_id}:run-status");
if let Err(e) = append_and_apply_event(
&paths,
"run.status",
None,
Some(&key),
json!({ "status": status_str }),
) {
warn!(
target: "orchestratectl::supervise",
error = %e,
"failed to record terminal run.status; will retry next tick"
);
} else {
info!(
target: "orchestratectl::supervise",
run_id = %run_id,
status = status_str,
"rolled run up to terminal status from terminal node(s)"
);
}
}
if !cleaned || !notified {
if let Ok(Some(m)) = read_manifest_opt(&paths) {
if m.status.is_terminal() {
if !notified {
notify_attempts += 1;
notified = notify::maybe_fire(
&paths,
&run_id,
m.notify_cmd.as_deref(),
m.status,
crate::run::kind_kebab(m.kind),
&m.title,
);
if !notified && notify_attempts >= NOTIFY_MAX_ATTEMPTS {
warn!(
target: "orchestratectl::supervise",
run_id = %run_id,
attempts = notify_attempts,
"giving up on completion notify hook after repeated marker-append failures"
);
notified = true;
}
}
let warranted = m.kind.lifecycle() == Lifecycle::Autonomous
|| cleanup::any_node_merged_explicitly(&paths);
if !cleaned && warranted {
cleanup::cleanup_terminal_nodes(&paths);
cleanup::cleanup_managed_session(&paths);
}
cleaned = true;
}
}
}
maybe_warn_dropped(
crate::cli::dropped_log_events(),
Instant::now(),
&mut last_dropped_warned,
&mut last_dropped_warn_at,
);
let _ = state::save(&paths.root, &state);
if args.once {
break "test-bounded-exit";
}
if notified && all_work_done(&paths, &child_tails) {
break if spawn_failed_terminal {
"supervisor-spawn-failed"
} else {
"work-complete"
};
}
std::thread::sleep(if iter % 2 == 0 {
TAIL_TICK
} else {
WATCHDOG_TICK
});
};
let _ = state::save(&paths.root, &state);
let signal_num = SIGNAL_RECEIVED.load(Ordering::SeqCst);
let signal_name = match signal_num {
libc::SIGINT => Some("SIGINT"),
libc::SIGTERM => Some("SIGTERM"),
_ => None,
};
if exit_reason == "run-dir-vanished" {
warn!(
target: "orchestratectl::supervise",
run_id = %run_id,
pid = our_pid,
"run dir vanished; supervisor self-terminating"
);
let child_ids: std::collections::BTreeSet<&str> = state
.spawned_children
.keys()
.map(String::as_str)
.chain(child_tails.keys().map(String::as_str))
.collect();
signal_children_term(&root, child_ids.into_iter());
if paths.events().exists() {
let _ = append_and_apply_event(
&paths,
"supervisor.self-terminated",
None,
None,
json!({"pid": our_pid, "reason": "run-dir-vanished"}),
)
.map_err(from_core);
}
} else {
let exited_data = match signal_name {
Some(name) => json!({"pid": our_pid, "reason": "signal", "signal": name}),
None => json!({"pid": our_pid, "reason": exit_reason}),
};
let _ = append_and_apply_event(&paths, "supervisor.exited", None, None, exited_data)
.map_err(from_core);
}
pid_file::remove_if_owner(&pid_path, our_pid);
#[derive(Serialize)]
struct ExitedPayload<'a> {
run_id: &'a str,
pid: u32,
reason: &'a str,
iterations: u32,
}
let payload = ExitedPayload {
run_id: &run_id,
pid: our_pid,
reason: exit_reason,
iterations: iter,
};
match spec.format {
OutputFormat::Json | OutputFormat::Jsonl => {
output::emit_envelope(&payload, spec, warnings)?;
}
OutputFormat::Text => {
println!(
"supervisor exited run={run_id} pid={our_pid} reason={exit_reason} iter={iter}"
);
output::emit_text_warnings(warnings);
}
}
if signal_num != 0 {
use std::io::Write as _;
info!(
target: "orchestratectl::supervise",
run_id = %run_id,
pid = our_pid,
signal = signal_name.unwrap_or("unknown"),
"supervisor received termination signal; flushing logs and exiting"
);
let _ = std::io::stdout().flush();
crate::cli::flush_logs();
let code = if signal_num == libc::SIGINT { 130 } else { 143 };
std::process::exit(code);
}
Ok(())
}
fn signal_children_term<'a>(root: &Path, child_run_ids: impl Iterator<Item = &'a str>) {
for child_run_id in child_run_ids {
let Ok(child_paths) = run_paths(root, child_run_id) else {
continue;
};
let Some((pid, start_time)) = pid_file::read_pid_record(&child_paths.supervisor_pid())
else {
continue;
};
let Some(pid_t) = pid_file::to_pid_t(pid) else {
continue;
};
if !pid_file::pid_live_with_identity(pid, start_time) {
continue;
}
unsafe {
libc::kill(pid_t, libc::SIGTERM);
}
info!(
target: "orchestratectl::supervise",
child = %child_run_id,
pid,
"sent SIGTERM to child supervisor (parent shutting down on run-dir-vanished)"
);
}
}
struct ChildTracking {
parent_node_id: String,
tail: tail::EventTail,
terminal: bool,
}
#[derive(Debug, Clone)]
enum ChildSpawn {
Starting { since: Instant, attempts: u32 },
Failed { attempts: u32, retry_at: Instant },
}
#[derive(Debug, PartialEq, Eq)]
enum SpawnAction {
Confirm(u32),
MarkFailed,
Retry,
Wait,
}
fn child_spawn_action(
st: &ChildSpawn,
confirmed_pid: Option<u32>,
now: Instant,
deadline: Duration,
max_attempts: u32,
) -> SpawnAction {
if let Some(pid) = confirmed_pid {
return SpawnAction::Confirm(pid);
}
match st {
ChildSpawn::Starting { since, .. } => {
if now.saturating_duration_since(*since) >= deadline {
SpawnAction::MarkFailed
} else {
SpawnAction::Wait
}
}
ChildSpawn::Failed { attempts, retry_at } => {
if *attempts >= max_attempts {
SpawnAction::Wait
} else if now >= *retry_at {
SpawnAction::Retry
} else {
SpawnAction::Wait
}
}
}
}
fn child_retry_backoff(attempts: u32) -> Duration {
let shift = attempts.saturating_sub(1).min(5);
(CHILD_RETRY_BASE_BACKOFF * (1u32 << shift)).min(CHILD_RETRY_MAX_BACKOFF)
}
fn child_spawn_attempts(st: &ChildSpawn) -> u32 {
match st {
ChildSpawn::Starting { attempts, .. } | ChildSpawn::Failed { attempts, .. } => *attempts,
}
}
fn reseed_child_spawns<'a>(
root: &Path,
child_ids: impl Iterator<Item = &'a str>,
state: &state::SupervisorState,
now: Instant,
) -> std::collections::BTreeMap<String, ChildSpawn> {
let mut out = std::collections::BTreeMap::new();
for cid in child_ids {
if state.spawned_children.contains_key(cid) {
continue;
}
let child_terminal = run_paths(root, cid)
.ok()
.and_then(|cp| read_manifest_opt(&cp).ok().flatten())
.is_some_and(|m| m.status.is_terminal());
if child_terminal {
continue;
}
out.insert(
cid.to_string(),
ChildSpawn::Starting {
since: now,
attempts: 1,
},
);
}
out
}
fn reconcile_child_spawns(
root: &Path,
parent_paths: &RunPaths,
child_spawns: &mut std::collections::BTreeMap<String, ChildSpawn>,
state: &mut state::SupervisorState,
now: Instant,
) {
let deadline = child_spawn_deadline();
let cids: Vec<String> = child_spawns.keys().cloned().collect();
for cid in cids {
let confirmed_pid = run_paths(root, &cid)
.ok()
.and_then(|cp| crate::run::supervisor_spawn::read_live_recorded_pid(&cp));
let attempts = child_spawn_attempts(&child_spawns[&cid]);
match child_spawn_action(
&child_spawns[&cid],
confirmed_pid,
now,
deadline,
CHILD_SPAWN_MAX_ATTEMPTS,
) {
SpawnAction::Confirm(pid) => {
record_child_attached(root, &cid, parent_paths, pid);
state.spawned_children.insert(cid.clone(), pid);
child_spawns.remove(&cid);
info!(
target: "orchestratectl::supervise",
child = %cid,
pid,
attempts,
"child supervisor confirmed running"
);
}
SpawnAction::MarkFailed => {
let exhausted = attempts >= CHILD_SPAWN_MAX_ATTEMPTS;
if exhausted {
warn!(
target: "orchestratectl::supervise",
child = %cid,
attempts,
"child supervisor never confirmed a pid; retry budget exhausted, giving up"
);
} else {
warn!(
target: "orchestratectl::supervise",
child = %cid,
attempts,
"child supervisor did not confirm a pid within the deadline; scheduling retry"
);
}
let _ = append_and_apply_event(
parent_paths,
"child.spawn_failed",
None,
None,
json!({
"child_run_id": cid,
"reason": "no identity-verified pid within CHILD_SPAWN_DEADLINE",
"attempts": attempts,
"final": exhausted,
}),
);
child_spawns.insert(
cid.clone(),
ChildSpawn::Failed {
attempts,
retry_at: now + child_retry_backoff(attempts),
},
);
}
SpawnAction::Retry => {
let next = attempts + 1;
match fork_child_supervisor(root, &cid) {
Ok(()) => {
info!(
target: "orchestratectl::supervise",
child = %cid,
attempt = next,
"re-forked child supervisor after a failed boot"
);
child_spawns.insert(
cid.clone(),
ChildSpawn::Starting {
since: now,
attempts: next,
},
);
}
Err(e) => {
warn!(
target: "orchestratectl::supervise",
child = %cid,
attempt = next,
error = %e.message,
"child re-fork failed; backing off"
);
let _ = append_and_apply_event(
parent_paths,
"child.spawn_failed",
None,
None,
json!({
"child_run_id": cid,
"reason": e.message,
"attempts": next,
}),
);
child_spawns.insert(
cid.clone(),
ChildSpawn::Failed {
attempts: next,
retry_at: now + child_retry_backoff(next),
},
);
}
}
}
SpawnAction::Wait => {}
}
}
}
fn fork_child_supervisor(root: &Path, child_run_id: &str) -> Result<(), CliError> {
let child_rid = parse_run_id(child_run_id)?;
let child_dir = octl_core::run_dir(root, &child_rid);
let deadline = Instant::now() + CHILD_DIR_WAIT;
while !child_dir.join("manifest.json").exists() {
if Instant::now() >= deadline {
return Err(CliError::system(
"child_dir_missing",
format!(
"child run dir {} did not appear within {:?}",
child_dir.display(),
CHILD_DIR_WAIT
),
));
}
std::thread::sleep(Duration::from_millis(100));
}
let stderr_path: PathBuf = child_dir.join("supervisor.stderr.log");
let mut cmd =
crate::run::supervisor_spawn::detached_supervise_command(child_run_id, &stderr_path, None)?;
cmd.env_remove("RUST_LOG_NOSPAWN");
crate::run::supervisor_spawn::spawn_and_reap(&mut cmd, child_run_id)?;
info!(
target: "orchestratectl::supervise",
child = %child_run_id,
"forked child supervisor (pid confirmation deferred to reconcile)"
);
Ok(())
}
fn record_child_attached(root: &Path, child_run_id: &str, parent_paths: &RunPaths, pid: u32) {
match run_paths(root, child_run_id) {
Ok(child_paths) => {
let root_node = NodeId::parse_str("n-0001").expect("n-0001 is a valid node id");
if let Err(e) = append_and_apply_event(
&child_paths,
"supervisor.attached",
Some(&root_node),
None,
json!({ "pid": pid }),
) {
warn!(
target: "orchestratectl::supervise",
child = %child_run_id,
error = %e,
"could not record supervisor.attached on child run"
);
}
}
Err(e) => warn!(
target: "orchestratectl::supervise",
child = %child_run_id,
error = %e.message,
"could not resolve child run paths to record supervisor.attached (parent record still emitted)"
),
}
if let Err(e) = append_and_apply_event(
parent_paths,
"child.supervisor_attached",
None,
None,
json!({"child_run_id": child_run_id, "supervisor_pid": pid}),
) {
warn!(
target: "orchestratectl::supervise",
child = %child_run_id,
error = %e,
"could not record child.supervisor_attached on parent run"
);
}
}
fn discover_children(paths: &RunPaths) -> std::collections::BTreeMap<String, String> {
RunLock::with_shared_lock(&paths.lock(), || {
let mut out = std::collections::BTreeMap::new();
let Ok(entries) = std::fs::read_dir(paths.nodes_dir()) else {
return Ok(out);
};
for e in entries.flatten() {
let p = e.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;
};
let Ok(nid) = NodeId::parse_str(&node_id) else {
continue;
};
if let Ok(Some(n)) = read_node_opt(paths, &nid) {
for c in &n.children {
out.entry(c.run_id.to_string())
.or_insert_with(|| node_id.clone());
}
}
}
Ok(out)
})
.unwrap_or_default()
}
fn report_corrupt_line(
tail: &mut tail::EventTail,
own: &RunPaths,
log_owner: &RunPaths,
quarantine: bool,
source: &str,
) {
let Some(c) = tail.take_new_corrupt() else {
return;
};
warn!(
target: "orchestratectl::supervise",
source = %source,
byte_offset = c.byte_offset,
excerpt = %c.line_excerpt,
quarantine,
"corrupt event-log line detected; continuing tail"
);
if quarantine {
let ts = Utc::now().format("%Y%m%dT%H%M%SZ").to_string();
match octl_core::quarantine_corrupt_lines(log_owner, &ts) {
Ok(Some(q)) => {
tail.restart();
info!(
target: "orchestratectl::supervise",
source = %source,
backup = %q.backup_path.display(),
removed = q.removed_byte_offsets.len(),
"quarantined corrupt event-log line(s)"
);
if let Err(e) = append_and_apply_event(
own,
"supervisor.event_log_quarantined",
None,
None,
json!({
"backup_path": q.backup_path.display().to_string(),
"removed_byte_offsets": q.removed_byte_offsets,
"source": source,
}),
) {
warn!(
target: "orchestratectl::supervise",
source = %source,
error = %e,
"failed to persist quarantine diagnostic (log already healed)"
);
}
return;
}
Ok(None) => {
}
Err(e) => {
warn!(
target: "orchestratectl::supervise",
source = %source,
error = %e,
"quarantine failed; falling back to in-memory skip"
);
}
}
}
if let Err(e) = append_and_apply_event(
own,
"supervisor.event_log_skipped_line",
None,
None,
json!({
"byte_offset": c.byte_offset,
"line_excerpt": c.line_excerpt,
"source": source,
}),
) {
warn!(
target: "orchestratectl::supervise",
source = %source,
byte_offset = c.byte_offset,
error = %e,
"failed to persist corrupt-line diagnostic (advanced past it anyway)"
);
}
}
fn maybe_warn_dropped(
current: u64,
now: Instant,
last_count: &mut u64,
last_at: &mut Option<Instant>,
) -> bool {
if current <= *last_count {
return false;
}
let due = last_at.is_none_or(|t| now.saturating_duration_since(t) >= DROPPED_WARN_INTERVAL);
if !due {
return false;
}
let newly_dropped = current - *last_count;
warn!(
target: "orchestratectl::supervise",
dropped = current,
newly_dropped,
"log events dropped due to buffer overflow (lossy non-blocking appender under sustained back-pressure)"
);
eprintln!(
"warning: {current} log events dropped due to buffer overflow \
({newly_dropped} new since last warning)"
);
*last_count = current;
*last_at = Some(now);
true
}
fn all_work_done(
paths: &RunPaths,
child_tails: &std::collections::BTreeMap<String, ChildTracking>,
) -> bool {
let Ok(Some(m)) = read_manifest_opt(paths) else {
return false;
};
if !matches!(m.status, Status::Done | Status::Failed | Status::Cancelled) {
return false;
}
child_tails.values().all(|t| t.terminal)
}
const HALF_STATE_TICKS: u32 = 3;
fn spawn_grace() -> Duration {
match std::env::var(SPAWN_GRACE_ENV) {
Ok(v) => match v.trim().parse::<u64>() {
Ok(secs) => Duration::from_secs(secs),
Err(_) => WATCHDOG_SPAWN_GRACE,
},
Err(_) => WATCHDOG_SPAWN_GRACE,
}
}
fn within_spawn_grace(
started_at: Option<DateTime<Utc>>,
now: DateTime<Utc>,
grace: Duration,
) -> bool {
let Some(started_at) = started_at else {
return false;
};
match (now - started_at).to_std() {
Ok(age) => age < grace,
Err(_) => true,
}
}
struct RetryPark {
attempt: u32,
retry_at: Instant,
reason: String,
spawn_failures: u32,
}
fn retry_eligible_kind(n: &Node) -> bool {
n.kind.lifecycle() == Lifecycle::Autonomous
&& n.kind.is_autonomous_single_node_worker()
&& n.parent_node_id.is_none()
}
fn reconcile_agent_retries(
paths: &RunPaths,
retry_states: &mut std::collections::BTreeMap<String, RetryPark>,
now: Instant,
) {
let due: Vec<String> = retry_states
.iter()
.filter(|(_, p)| now >= p.retry_at)
.map(|(k, _)| k.clone())
.collect();
if due.is_empty() {
return;
}
let git = cleanup::git_bin();
let tmux = cleanup::tmux_bin();
for node_id in due {
let Ok(nid) = NodeId::parse_str(&node_id) else {
retry_states.remove(&node_id);
continue;
};
let guard = match RunLock::acquire(&paths.lock()) {
Ok(g) => g,
Err(e) => {
warn!(
target: "orchestratectl::supervise",
node = %node_id, error = %e,
"could not lock run to reconcile retry; will retry next tick"
);
continue;
}
};
let node = read_node_opt(paths, &nid).ok().flatten();
let proceed = node.as_ref().is_some_and(|n| {
!matches!(n.status, Status::Done | Status::Failed | Status::Cancelled)
&& retry_eligible_kind(n)
&& cleanup::node_is_empty_handed(paths, n, &git)
});
if !proceed {
info!(
target: "orchestratectl::supervise",
node = %node_id,
"retry park no longer applies (terminal / not empty-handed); dropping"
);
retry_states.remove(&node_id);
drop(guard);
continue;
}
let node = node.expect("proceed implies Some");
let manifest = read_manifest_opt(paths).ok().flatten();
drop(guard);
let Some(manifest) = manifest else {
warn!(
target: "orchestratectl::supervise",
node = %node_id, "manifest unreadable during retry; will retry next tick"
);
continue;
};
let attempt = retry_states.get(&node_id).map_or(1, |p| p.attempt);
let reason = retry_states
.get(&node_id)
.map_or_else(|| "agent-died".to_string(), |p| p.reason.clone());
let outcome = respawn_agent(paths, &node, &manifest, attempt);
let spawn = match outcome {
Ok(s) => s,
Err(e) => {
let failures = retry_states
.get(&node_id)
.map_or(1, |p| p.spawn_failures + 1);
if failures >= agent_respawn_max_failures() {
warn!(
target: "orchestratectl::supervise",
node = %node_id, error = %e.message, failures,
"re-spawn failed repeatedly; terminalizing run failed"
);
if terminalize_respawn_failure(paths, &nid, &node_id, attempt, &e.message) {
retry_states.remove(&node_id);
}
} else if let Some(park) = retry_states.get_mut(&node_id) {
warn!(
target: "orchestratectl::supervise",
node = %node_id, error = %e.message, failures,
"re-spawn failed; backing off and rescheduling"
);
park.spawn_failures = failures;
park.retry_at = now + agent_retry_backoff(attempt);
}
continue;
}
};
let base_sha = crate::run::create::capture_base_sha(&spawn.worktree_path);
let guard = match RunLock::acquire(&paths.lock()) {
Ok(g) => g,
Err(e) => {
warn!(
target: "orchestratectl::supervise",
node = %node_id, error = %e,
"could not lock run to record node.retry; tearing down fresh spawn, will retry"
);
teardown_respawn_outcome(&spawn, manifest.source_repo.as_deref(), &tmux, &git);
continue;
}
};
let recheck = read_node_opt(paths, &nid).ok().flatten();
let still_retryable = recheck.as_ref().is_some_and(|n| {
!matches!(n.status, Status::Done | Status::Failed | Status::Cancelled)
&& cleanup::node_is_empty_handed(paths, n, &git)
});
if !still_retryable {
let terminal = recheck.as_ref().is_some_and(|n| {
matches!(n.status, Status::Done | Status::Failed | Status::Cancelled)
});
warn!(
target: "orchestratectl::supervise",
node = %node_id, terminal,
"node no longer retryable after re-spawn (terminal or committed work appeared); \
tearing down fresh spawn, dropping park"
);
drop(guard);
teardown_respawn_outcome(&spawn, manifest.source_repo.as_deref(), &tmux, &git);
retry_states.remove(&node_id);
continue;
}
let data = json!({
"attempt": attempt,
"reason": reason,
"branch": spawn.branch,
"base_sha": base_sha,
"worktree_path": spawn.worktree_path,
"tmux_window": spawn.tmux_window,
"tmux_socket": spawn.tmux_socket,
"tmux_session": spawn.tmux_session,
"tmux_window_id": spawn.tmux_window_id,
"agent_pid": spawn.agent_pid,
});
let lock = guard.witness();
if let Err(e) =
append_and_apply_unlocked(&lock, paths, "node.retry", Some(&nid), None, data)
{
warn!(
target: "orchestratectl::supervise",
node = %node_id, error = %e,
"record node.retry failed; tearing down fresh spawn, leaving park to re-fire"
);
drop(guard);
teardown_respawn_outcome(&spawn, manifest.source_repo.as_deref(), &tmux, &git);
continue;
}
drop(guard);
cleanup::cleanup_node(paths, &node, &tmux, &git);
info!(
target: "orchestratectl::supervise",
node = %node_id, attempt, branch = %spawn.branch,
"re-spawned empty-handed worker on fresh worktree at source branch"
);
retry_states.remove(&node_id);
}
}
fn teardown_respawn_outcome(spawn: &RespawnOutcome, repo: Option<&str>, tmux: &str, git: &str) {
use std::process::{Command, Stdio};
if spawn.agent_pid > 0 {
let _ = Command::new("kill")
.arg(spawn.agent_pid.to_string())
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
}
if !spawn.tmux_window.is_empty() {
let _ = Command::new(tmux)
.args(["kill-window", "-t", &spawn.tmux_window])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
}
let Some(repo) = repo.filter(|s| !s.is_empty()) else {
return;
};
let _ = Command::new(git)
.args([
"-C",
repo,
"worktree",
"remove",
"--force",
&spawn.worktree_path,
])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
if !spawn.branch.is_empty() {
let _ = Command::new(git)
.args(["-C", repo, "branch", "-D", "--", &spawn.branch])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
}
}
struct RespawnOutcome {
branch: String,
worktree_path: String,
tmux_window: String,
tmux_socket: Option<String>,
tmux_session: Option<String>,
tmux_window_id: Option<String>,
agent_pid: i64,
}
const AGENT_RESPAWN_STARTUP_TIMEOUT: u32 = 90;
fn respawn_agent(
paths: &RunPaths,
node: &Node,
manifest: &octl_core::Manifest,
attempt: u32,
) -> Result<RespawnOutcome, CliError> {
let prompt_path = paths.root.join("prompt.md");
if !prompt_path.exists() {
return Err(CliError::system(
"respawn_prompt_missing",
format!(
"prompt file {} not found for re-spawn",
prompt_path.display()
),
));
}
let prompt_path = prompt_path.canonicalize().unwrap_or(prompt_path);
let source_branch = manifest.source_branch.as_deref();
let source_repo = manifest.source_repo.as_deref().map(std::path::Path::new);
let branch = retry_branch_name(node.branch.as_deref(), attempt);
let req = crate::run::spawn::SpawnRequest {
kind: crate::run::kind_kebab(node.kind),
branch: &branch,
prompt_file: &prompt_path,
layout: None,
no_hooks: false,
keep_tmux_on_error: false,
parent_session: manifest.managed_tmux_session.as_deref(),
agent_startup_timeout: AGENT_RESPAWN_STARTUP_TIMEOUT,
source_branch,
cwd: source_repo,
};
let outcome = crate::run::spawn::run_create_sh_with_tmux_retry(&req)?;
crate::run::spawn::verify_agent_pid(outcome.agent_pid_hint)?;
Ok(RespawnOutcome {
branch: outcome.branch,
worktree_path: outcome.worktree_path,
tmux_window: outcome.tmux_window,
tmux_socket: outcome.tmux_socket,
tmux_session: outcome.tmux_session,
tmux_window_id: outcome.tmux_window_id,
agent_pid: outcome.agent_pid_hint,
})
}
fn retry_branch_name(prior: Option<&str>, attempt: u32) -> String {
let stem = prior
.map(str::trim)
.filter(|s| !s.is_empty())
.unwrap_or("wt/retry");
let base = strip_retry_suffix(stem);
format!("{base}-r{attempt}")
}
fn strip_retry_suffix(branch: &str) -> &str {
if let Some(idx) = branch.rfind("-r") {
let suffix = &branch[idx + 2..];
if !suffix.is_empty() && suffix.bytes().all(|b| b.is_ascii_digit()) {
return &branch[..idx];
}
}
branch
}
#[must_use]
fn terminalize_respawn_failure(
paths: &RunPaths,
nid: &NodeId,
node_id: &str,
attempt: u32,
err: &str,
) -> bool {
let guard = match RunLock::acquire(&paths.lock()) {
Ok(g) => g,
Err(e) => {
warn!(
target: "orchestratectl::supervise",
node = %node_id, error = %e,
"could not lock run to terminalize failed re-spawn; will retry next tick"
);
return false;
}
};
let still_live = read_node_opt(paths, nid)
.ok()
.flatten()
.is_some_and(|n| !matches!(n.status, Status::Done | Status::Failed | Status::Cancelled));
if !still_live {
drop(guard);
return true;
}
let data = json!({
"success": false,
"failed": true,
"cancelled": false,
"reason": "agent-respawn-failed",
"summary": format!(
"Node {node_id} died empty-handed; auto-retry re-spawn failed after {attempt} attempt(s): {err}"
),
"discussion_items": [],
"spinoff_proposals": [],
"wrap_up_recommendations": [],
"retry_attempts": attempt.saturating_sub(1),
});
let lock = guard.witness();
let ok = match append_and_apply_unlocked(&lock, paths, "node.report", Some(nid), None, data) {
Ok(_) => true,
Err(e) => {
warn!(
target: "orchestratectl::supervise",
node = %node_id, error = %e,
"synthesize failed re-spawn report failed; will retry next tick"
);
false
}
};
drop(guard);
ok
}
fn watchdog_tick(
paths: &RunPaths,
half_state_streak: &mut std::collections::BTreeMap<String, u32>,
retry_states: &mut std::collections::BTreeMap<String, RetryPark>,
cpu_activity: &mut std::collections::BTreeMap<String, (u64, i64)>,
) -> Result<(), CliError> {
let now = Utc::now();
let now_instant = Instant::now();
let grace = spawn_grace();
let mut tmux_gone_this_tick: std::collections::BTreeSet<String> =
std::collections::BTreeSet::new();
let mut candidates: Vec<(String, NodeId, Node, watchdog::AgentProbe)> = Vec::new();
let mut sockets: std::collections::BTreeSet<Option<String>> = std::collections::BTreeSet::new();
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;
};
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;
}
if retry_states.contains_key(&node_id) {
continue;
}
if within_spawn_grace(n.started_at, now, grace) {
continue;
}
let Some(pid) = n.agent_pid else { continue };
let probe = watchdog::AgentProbe {
pid: pid as u32,
start_time: n.agent_pid_start_time.map(|t| t.timestamp().max(0) as u64),
tmux_window: n.tmux_window.clone(),
tmux_identity: n.tmux_identity.clone(),
skip_tmux_check: n.tmux_identity.is_none() && n.tmux_window.is_none(),
};
let (probes_tmux, socket) = probe.probe_socket();
if probes_tmux {
sockets.insert(socket);
}
candidates.push((node_id, nid, n, probe));
}
Ok(())
})
.map_err(from_core)?;
let tmux_snapshot = watchdog::WatchdogTmuxSnapshot::collect(&sockets);
let git = cleanup::git_bin();
let scanned_ids: std::collections::BTreeSet<String> =
candidates.iter().map(|(id, ..)| id.clone()).collect();
for (node_id, nid, n, probe) in candidates {
let interactive = n.kind.lifecycle() == Lifecycle::Interactive;
let v = watchdog::check_liveness_for_lifecycle(&probe, &tmux_snapshot, interactive);
let commit = match v {
watchdog::Liveness::Alive => false,
watchdog::Liveness::Dead | watchdog::Liveness::Recycled => true,
watchdog::Liveness::TmuxGone => {
tmux_gone_this_tick.insert(node_id.clone());
let c = half_state_streak.entry(node_id.clone()).or_insert(0);
*c += 1;
*c >= HALF_STATE_TICKS
}
};
let reconcile_eligible =
n.kind.lifecycle() == Lifecycle::Autonomous && n.last_report.is_none();
let reconcile_probe =
reconcile_eligible && cleanup::node_branch_merged_to_source(paths, &n, &git);
if n.last_report.is_none() && (reconcile_probe || commit) {
let guard = match RunLock::acquire(&paths.lock()) {
Ok(g) => g,
Err(e) => {
warn!(
target: "orchestratectl::supervise",
node = %node_id,
error = %e,
"watchdog could not lock run to synthesize report"
);
continue;
}
};
let fresh = read_node_opt(paths, &nid).ok().flatten();
let still_synthesizable = fresh.as_ref().is_some_and(|n| {
n.last_report.is_none()
&& !matches!(n.status, Status::Done | Status::Failed | Status::Cancelled)
});
if !still_synthesizable {
tracing::debug!(
target: "orchestratectl::supervise",
node = %node_id,
"watchdog deferred to live report"
);
drop(guard);
continue;
}
let reconciled = reconcile_probe
&& fresh
.as_ref()
.is_some_and(|f| cleanup::node_branch_merged_to_source(paths, f, &git));
if !reconciled && !commit {
tracing::debug!(
target: "orchestratectl::supervise",
node = %node_id,
"reconcile no longer holds under lock; leaving live node alone"
);
drop(guard);
continue;
}
if !reconciled && matches!(v, watchdog::Liveness::Dead) {
if let Some(f) = fresh.as_ref() {
if retry_eligible_kind(f) && cleanup::node_is_empty_handed(paths, f, &git) {
let attempts = f.retry_attempts;
let max = agent_retry_max_attempts();
if attempts < max {
let attempt = attempts + 1;
let backoff = agent_retry_backoff(attempt);
info!(
target: "orchestratectl::supervise",
node = %node_id,
attempt,
backoff_secs = backoff.as_secs(),
"empty-handed agent-died on autonomous worker; parking for bounded auto-retry"
);
retry_states.insert(
node_id.clone(),
RetryPark {
attempt,
retry_at: now_instant + backoff,
reason: v.reason().to_string(),
spawn_failures: 0,
},
);
drop(guard);
continue;
}
info!(
target: "orchestratectl::supervise",
node = %node_id,
attempts,
max,
"empty-handed agent-died but retry budget exhausted; terminalizing failed"
);
}
}
}
let data = if reconciled {
info!(
target: "orchestratectl::supervise",
node = %node_id,
"branch already merged into source; reconciling run to success (lost terminal report)"
);
json!({
"success": true,
"failed": false,
"cancelled": false,
"via": cleanup::VIA_MERGE_RECONCILED,
"reason": "branch-merged-to-source",
"summary": format!(
"Node {} branch already merged into source; supervisor reconciled run to success (agent's terminal report was lost).",
node_id
),
"discussion_items": [],
"spinoff_proposals": [],
"wrap_up_recommendations": [],
})
} else {
let recoverability = fresh
.as_ref()
.and_then(|f| cleanup::node_recoverability(paths, f, &git));
if let Some(r) = &recoverability {
info!(
target: "orchestratectl::supervise",
node = %node_id,
branch = %r.branch,
unmerged_commits = r.unmerged_commits,
merges_cleanly = r.merges_cleanly,
"agent died leaving unmerged commits; stamping recoverability signal into failed report"
);
}
let mut data = json!({
"success": false,
"failed": true,
"cancelled": false,
"reason": v.reason(),
"summary": format!("Agent for node {} stopped responding: {}", node_id, v.reason()),
"discussion_items": [],
"spinoff_proposals": [],
"wrap_up_recommendations": [],
});
if let Some(r) = recoverability {
if let Some(obj) = data.as_object_mut() {
obj.insert("recoverable_work".to_string(), r.to_report_value());
}
}
let retried = fresh.as_ref().map_or(0, |f| f.retry_attempts);
if retried > 0 {
if let Some(obj) = data.as_object_mut() {
obj.insert("retry_attempts".to_string(), json!(retried));
}
}
data
};
let lock = guard.witness();
if let Err(e) =
append_and_apply_unlocked(&lock, paths, "node.report", Some(&nid), None, data)
{
warn!(
target: "orchestratectl::supervise",
node = %node_id,
error = %e,
"synthesize node.report failed"
);
}
drop(guard);
}
if matches!(v, watchdog::Liveness::Alive)
&& !reconcile_probe
&& n.last_report.is_none()
&& n.kind.lifecycle() == Lifecycle::Autonomous
{
let threshold = idle_unmerged_threshold().as_secs() as i64;
let cpu_now = n.agent_pid.and_then(|p| {
u32::try_from(p)
.ok()
.and_then(watchdog::pid_cpu_time_centis)
});
let cpu_clock = cpu_activity_clock(cpu_activity, &node_id, cpu_now, now.timestamp());
if cleanup::node_idle_unmerged(paths, &n, &git, now.timestamp(), threshold, cpu_clock)
.is_some()
{
let guard = match RunLock::acquire(&paths.lock()) {
Ok(g) => g,
Err(e) => {
warn!(
target: "orchestratectl::supervise",
node = %node_id,
error = %e,
"watchdog could not lock run to synthesize idle-unmerged report"
);
continue;
}
};
let fresh = read_node_opt(paths, &nid).ok().flatten();
let still_unreported = fresh.as_ref().is_some_and(|f| {
f.last_report.is_none()
&& !matches!(f.status, Status::Done | Status::Failed | Status::Cancelled)
});
let idle = fresh.as_ref().filter(|_| still_unreported).and_then(|f| {
cleanup::node_idle_unmerged(
paths,
f,
&git,
now.timestamp(),
threshold,
cpu_clock,
)
});
let Some(idle) = idle else {
tracing::debug!(
target: "orchestratectl::supervise",
node = %node_id,
"idle-unmerged no longer holds under lock; leaving live node alone"
);
drop(guard);
continue;
};
info!(
target: "orchestratectl::supervise",
node = %node_id,
branch = %idle.recoverability.branch,
unmerged_commits = idle.recoverability.unmerged_commits,
merges_cleanly = idle.recoverability.merges_cleanly,
idle_secs = idle.idle_secs,
"autonomous agent committed but never merged and has gone idle; terminalizing run to recoverable failed"
);
let salvage = if idle.recoverability.merges_cleanly {
"land it with `run merge`"
} else {
"resolve conflicts against source, then `run merge` (see recoverable_work)"
};
let mut data = json!({
"success": false,
"failed": true,
"cancelled": false,
"reason": IDLE_UNMERGED_REASON,
"summary": format!(
"Agent for node {} committed work but never called `run merge` and has been idle {}s; supervisor terminalized the run recoverable ({}).",
node_id, idle.idle_secs, salvage
),
"discussion_items": [],
"spinoff_proposals": [],
"wrap_up_recommendations": [],
});
if let Some(obj) = data.as_object_mut() {
obj.insert(
"recoverable_work".to_string(),
idle.recoverability.to_report_value(),
);
}
let lock = guard.witness();
if let Err(e) =
append_and_apply_unlocked(&lock, paths, "node.report", Some(&nid), None, data)
{
warn!(
target: "orchestratectl::supervise",
node = %node_id,
error = %e,
"synthesize idle-unmerged node.report failed"
);
}
drop(guard);
}
}
}
reconcile_agent_retries(paths, retry_states, now_instant);
half_state_streak.retain(|k, _| tmux_gone_this_tick.contains(k));
cpu_activity.retain(|k, _| scanned_ids.contains(k));
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn maybe_warn_dropped_rate_limits_on_increase() {
let t0 = Instant::now();
let mut last_count = 0u64;
let mut last_at: Option<Instant> = None;
assert!(maybe_warn_dropped(5, t0, &mut last_count, &mut last_at));
assert_eq!(last_count, 5);
assert_eq!(last_at, Some(t0));
assert!(!maybe_warn_dropped(
9,
t0 + Duration::from_secs(30),
&mut last_count,
&mut last_at
));
assert_eq!(last_count, 5);
assert_eq!(last_at, Some(t0));
let t1 = t0 + DROPPED_WARN_INTERVAL + Duration::from_secs(1);
assert!(maybe_warn_dropped(9, t1, &mut last_count, &mut last_at));
assert_eq!(last_count, 9);
assert_eq!(last_at, Some(t1));
assert!(!maybe_warn_dropped(
9,
t1 + Duration::from_secs(600),
&mut last_count,
&mut last_at
));
assert_eq!(last_count, 9);
}
#[test]
fn within_spawn_grace_boundaries() {
let grace = Duration::from_secs(5);
let created = "2026-06-28T12:00:00Z".parse::<DateTime<Utc>>().unwrap();
let now = created + chrono::Duration::seconds(1);
assert!(within_spawn_grace(Some(created), now, grace));
let now = created + chrono::Duration::milliseconds(4_999);
assert!(within_spawn_grace(Some(created), now, grace));
let now = created + chrono::Duration::seconds(5);
assert!(!within_spawn_grace(Some(created), now, grace));
let now = created + chrono::Duration::seconds(60);
assert!(!within_spawn_grace(Some(created), now, grace));
let now = created + chrono::Duration::seconds(1);
assert!(!within_spawn_grace(None, now, grace));
let now = created - chrono::Duration::seconds(1);
assert!(within_spawn_grace(Some(created), now, grace));
let now = created + chrono::Duration::milliseconds(1);
assert!(!within_spawn_grace(Some(created), now, Duration::ZERO));
}
#[test]
fn child_spawn_action_state_machine() {
let t0 = Instant::now();
let deadline = Duration::from_secs(10);
let max = 3;
let starting = ChildSpawn::Starting {
since: t0,
attempts: 1,
};
assert_eq!(
child_spawn_action(&starting, None, t0 + Duration::from_secs(1), deadline, max),
SpawnAction::Wait
);
assert_eq!(
child_spawn_action(&starting, None, t0 + deadline, deadline, max),
SpawnAction::MarkFailed
);
assert_eq!(
child_spawn_action(
&starting,
Some(4321),
t0 + Duration::from_secs(1),
deadline,
max
),
SpawnAction::Confirm(4321)
);
let failed = ChildSpawn::Failed {
attempts: 1,
retry_at: t0 + Duration::from_secs(5),
};
assert_eq!(
child_spawn_action(&failed, None, t0 + Duration::from_secs(1), deadline, max),
SpawnAction::Wait
);
assert_eq!(
child_spawn_action(&failed, None, t0 + Duration::from_secs(5), deadline, max),
SpawnAction::Retry
);
assert_eq!(
child_spawn_action(
&failed,
Some(99),
t0 + Duration::from_secs(5),
deadline,
max
),
SpawnAction::Confirm(99)
);
let exhausted = ChildSpawn::Failed {
attempts: max,
retry_at: t0,
};
assert_eq!(
child_spawn_action(
&exhausted,
None,
t0 + Duration::from_secs(100),
deadline,
max
),
SpawnAction::Wait
);
assert_eq!(
child_spawn_action(
&exhausted,
Some(7),
t0 + Duration::from_secs(100),
deadline,
max
),
SpawnAction::Confirm(7)
);
}
#[test]
fn child_retry_backoff_is_bounded() {
assert_eq!(child_retry_backoff(1), CHILD_RETRY_BASE_BACKOFF);
assert!(child_retry_backoff(2) >= child_retry_backoff(1));
assert!(child_retry_backoff(3) >= child_retry_backoff(2));
assert_eq!(child_retry_backoff(100), CHILD_RETRY_MAX_BACKOFF);
for a in 1..50 {
assert!(child_retry_backoff(a) <= CHILD_RETRY_MAX_BACKOFF);
}
}
#[test]
#[serial_test::serial(octl_watchdog_grace)]
fn agent_retry_backoff_is_bounded_and_monotone() {
let _lock = GRACE_ENV_LOCK.lock().unwrap();
let prior = std::env::var_os(AGENT_RETRY_BACKOFF_ENV);
std::env::remove_var(AGENT_RETRY_BACKOFF_ENV);
assert_eq!(agent_retry_backoff(1), AGENT_RETRY_BASE_BACKOFF);
assert!(agent_retry_backoff(2) >= agent_retry_backoff(1));
assert_eq!(agent_retry_backoff(100), AGENT_RETRY_MAX_BACKOFF);
for a in 1..50 {
assert!(agent_retry_backoff(a) <= AGENT_RETRY_MAX_BACKOFF);
}
std::env::set_var(AGENT_RETRY_BACKOFF_ENV, u64::MAX.to_string());
assert_eq!(agent_retry_backoff(6), AGENT_RETRY_MAX_BACKOFF);
match prior {
Some(v) => std::env::set_var(AGENT_RETRY_BACKOFF_ENV, v),
None => std::env::remove_var(AGENT_RETRY_BACKOFF_ENV),
}
}
#[test]
fn retry_branch_name_appends_and_does_not_accumulate_suffix() {
assert_eq!(retry_branch_name(Some("wt/foo"), 1), "wt/foo-r1");
assert_eq!(retry_branch_name(Some("wt/foo-r1"), 2), "wt/foo-r2");
assert_eq!(retry_branch_name(Some("wt/foo-r2"), 3), "wt/foo-r3");
assert_eq!(retry_branch_name(Some("wt/re-run"), 1), "wt/re-run-r1");
assert_eq!(retry_branch_name(None, 1), "wt/retry-r1");
assert_eq!(retry_branch_name(Some(""), 2), "wt/retry-r2");
}
fn minimal_node(kind: octl_core::Kind) -> Node {
Node {
schema_version: 1,
node_id: NodeId::parse_str("n-0001").unwrap(),
run_id: octl_core::RunId::parse_str("01jxwd0000000000000000000w").unwrap(),
parent_node_id: None,
kind,
status: Status::Running,
task: None,
worktree_path: Some("/tmp/wt".to_string()),
branch: Some("wt/foo".to_string()),
base_sha: None,
tmux_window: None,
tmux_identity: None,
agent_pid: Some(4242),
agent_pid_start_time: None,
supervisor_pid: None,
children: Vec::new(),
started_at: None,
updated_at: Utc::now(),
last_report: None,
last_processed_report_seq_by_child: serde_json::Map::default(),
retry_attempts: 0,
}
}
#[test]
fn retry_eligible_kind_matches_autonomous_single_node_workers() {
use octl_core::Kind;
let mut n = minimal_node(Kind::Spinoff);
assert!(
retry_eligible_kind(&n),
"top-level autonomous spinoff is eligible"
);
n.parent_node_id = Some(NodeId::parse_str("n-0002").unwrap());
assert!(!retry_eligible_kind(&n), "a DAG child is not eligible");
let code = minimal_node(Kind::Code);
assert!(!retry_eligible_kind(&code));
}
#[test]
fn reconcile_never_records_unconfirmed_child_and_schedules_retry() {
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path().to_path_buf();
let parent_id = "01jxsnap000000000000000000";
let parent_dir = root.join(parent_id);
std::fs::create_dir_all(&parent_dir).unwrap();
let parent_paths = RunPaths::new(parent_dir, parent_id).unwrap();
append_and_apply_event(
&parent_paths,
"run.created",
None,
None,
json!({ "kind": "orchestrate", "lifecycle": "autonomous", "title": "drive" }),
)
.unwrap();
let child_id = "01jxsnap000000000000000042".to_string();
let base = Instant::now();
let mut child_spawns = std::collections::BTreeMap::new();
child_spawns.insert(
child_id.clone(),
ChildSpawn::Starting {
since: base,
attempts: 1,
},
);
let mut state = state::SupervisorState::default();
let now = base + CHILD_SPAWN_DEADLINE + Duration::from_secs(1);
reconcile_child_spawns(&root, &parent_paths, &mut child_spawns, &mut state, now);
assert!(
state.spawned_children.is_empty(),
"unconfirmed child (pid 0) must never enter spawned_children"
);
assert!(
matches!(
child_spawns.get(&child_id),
Some(ChildSpawn::Failed { attempts: 1, .. })
),
"expected Failed{{attempts:1}}, got {:?}",
child_spawns.get(&child_id)
);
let raw = std::fs::read_to_string(parent_paths.events()).unwrap();
assert!(
raw.contains("child.spawn_failed"),
"reconcile must record child.spawn_failed on the parent log"
);
}
#[test]
fn reseed_child_spawns_recovers_unconfirmed_children_only() {
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path();
let now = Instant::now();
let make_child = |id: &str| -> RunPaths {
let paths = run_paths(root, id).unwrap();
std::fs::create_dir_all(octl_core::run_dir(root, &parse_run_id(id).unwrap())).unwrap();
append_and_apply_event(
&paths,
"run.created",
None,
None,
json!({ "kind": "spinoff", "lifecycle": "autonomous", "title": id }),
)
.unwrap();
paths
};
let pending = "01jxsnap000000000000000001";
make_child(pending);
let done = "01jxsnap000000000000000002";
let done_paths = make_child(done);
append_and_apply_event(
&done_paths,
"run.status",
None,
None,
json!({ "status": "done" }),
)
.unwrap();
let confirmed = "01jxsnap000000000000000003".to_string();
let mut state = state::SupervisorState::default();
state.spawned_children.insert(confirmed.clone(), 4242);
let ids = [pending, done, confirmed.as_str()];
let seeded = reseed_child_spawns(root, ids.iter().copied(), &state, now);
assert!(
matches!(
seeded.get(pending),
Some(ChildSpawn::Starting { attempts: 1, .. })
),
"an unconfirmed non-terminal child must be re-seeded as Starting"
);
assert!(
!seeded.contains_key(done),
"a terminal child needs no supervisor and must not be re-seeded"
);
assert!(
!seeded.contains_key(&confirmed),
"a confirmed-running child must not be re-seeded (would double-track)"
);
}
#[test]
fn reconcile_marks_final_failure_on_budget_exhaustion() {
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path().to_path_buf();
let parent_id = "01jxsnap000000000000000000";
let parent_dir = root.join(parent_id);
std::fs::create_dir_all(&parent_dir).unwrap();
let parent_paths = RunPaths::new(parent_dir, parent_id).unwrap();
append_and_apply_event(
&parent_paths,
"run.created",
None,
None,
json!({ "kind": "orchestrate", "lifecycle": "autonomous", "title": "drive" }),
)
.unwrap();
let child_id = "01jxsnap000000000000000099".to_string();
let base = Instant::now();
let now = base + CHILD_SPAWN_DEADLINE + Duration::from_secs(1);
let mut child_spawns = std::collections::BTreeMap::new();
child_spawns.insert(
child_id.clone(),
ChildSpawn::Starting {
since: base,
attempts: CHILD_SPAWN_MAX_ATTEMPTS,
},
);
let mut state = state::SupervisorState::default();
reconcile_child_spawns(&root, &parent_paths, &mut child_spawns, &mut state, now);
assert!(state.spawned_children.is_empty());
let raw = std::fs::read_to_string(parent_paths.events()).unwrap();
assert!(
raw.contains("\"final\":true"),
"the exhausting failure must be recorded as final; log was: {raw}"
);
}
use std::process::{Command as PCommand, Stdio};
use std::sync::Mutex;
static GRACE_ENV_LOCK: Mutex<()> = Mutex::new(());
struct GraceGuard(Option<std::ffi::OsString>);
impl GraceGuard {
fn zero() -> Self {
let old = std::env::var_os(SPAWN_GRACE_ENV);
std::env::set_var(SPAWN_GRACE_ENV, "0");
Self(old)
}
}
impl Drop for GraceGuard {
fn drop(&mut self) {
match &self.0 {
Some(v) => std::env::set_var(SPAWN_GRACE_ENV, v),
None => std::env::remove_var(SPAWN_GRACE_ENV),
}
}
}
fn tgit(cwd: &Path, args: &[&str]) {
let ok = PCommand::new("git")
.current_dir(cwd)
.args(args)
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.unwrap()
.success();
assert!(ok, "git {args:?} failed in {cwd:?}");
}
fn trev(repo: &Path, r: &str) -> String {
let out = PCommand::new("git")
.current_dir(repo)
.args(["rev-parse", r])
.output()
.unwrap();
String::from_utf8_lossy(&out.stdout).trim().to_string()
}
fn tbranch_exists(repo: &Path, branch: &str) -> bool {
PCommand::new("git")
.current_dir(repo)
.args(["rev-parse", "--verify", "--quiet", branch])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.unwrap()
.success()
}
fn init_merged_repo(tmp: &tempfile::TempDir) -> (PathBuf, PathBuf, String) {
let repo = tmp.path().join("repo");
std::fs::create_dir_all(&repo).unwrap();
tgit(&repo, &["init", "-q", "-b", "main"]);
tgit(&repo, &["config", "user.email", "t@example.com"]);
tgit(&repo, &["config", "user.name", "t"]);
std::fs::write(repo.join("README"), "x").unwrap();
tgit(&repo, &["add", "-A"]);
tgit(&repo, &["commit", "-qm", "init"]);
let base = trev(&repo, "main");
let wt = tmp.path().join("wt");
tgit(
&repo,
&[
"worktree",
"add",
"-q",
"-b",
"wt/foo",
wt.to_str().unwrap(),
],
);
std::fs::write(wt.join("fix.rs"), "work").unwrap();
tgit(&wt, &["add", "-A"]);
tgit(&wt, &["commit", "-qm", "agent work"]);
tgit(&repo, &["merge", "--ff-only", "wt/foo"]); (repo, wt, base)
}
fn setup_merged_run(
tmp: &tempfile::TempDir,
repo: &Path,
wt: &Path,
base: &str,
agent_pid: i32,
) -> RunPaths {
let run_id = "01jxwd0000000000000000000w";
let dir = tmp.path().join(run_id);
std::fs::create_dir_all(&dir).unwrap();
let paths = RunPaths::new(dir, run_id).unwrap();
append_and_apply_event(
&paths,
"run.created",
None,
None,
json!({
"kind": "spinoff",
"lifecycle": "autonomous",
"title": "t",
"source_repo": repo.to_str().unwrap(),
"source_branch": "main",
}),
)
.unwrap();
append_and_apply_event(
&paths,
"node.created",
Some(&NodeId::parse_str("n-0001").unwrap()),
None,
json!({
"kind": "spinoff",
"branch": "wt/foo",
"base_sha": base,
"worktree_path": wt.to_str().unwrap(),
"agent_pid": agent_pid,
}),
)
.unwrap();
paths
}
fn n0001(paths: &RunPaths) -> Node {
read_node_opt(paths, &NodeId::parse_str("n-0001").unwrap())
.unwrap()
.unwrap()
}
#[test]
#[serial_test::serial(octl_watchdog_grace)]
fn watchdog_reconciles_merged_branch_on_agent_death() {
let _lock = GRACE_ENV_LOCK.lock().unwrap();
let _grace = GraceGuard::zero();
let tmp = tempfile::TempDir::new().unwrap();
let (repo, wt, base) = init_merged_repo(&tmp);
let dead = PCommand::new("true").spawn().unwrap();
let dead_pid = dead.id() as i32;
let mut dead = dead;
dead.wait().unwrap();
let paths = setup_merged_run(&tmp, &repo, &wt, &base, dead_pid);
let mut streak = std::collections::BTreeMap::new();
watchdog_tick(
&paths,
&mut streak,
&mut std::collections::BTreeMap::new(),
&mut std::collections::BTreeMap::new(),
)
.unwrap();
let n = n0001(&paths);
let report = n
.last_report
.expect("a terminal report must be synthesized");
assert_eq!(
report["success"], true,
"a merged branch reconciles to SUCCESS, not agent-died failure"
);
assert_eq!(report["via"], "merge-reconciled");
assert_ne!(
report["reason"], "agent-died",
"must not be a false failure"
);
assert!(n.status.is_terminal());
if let Some(status) = cleanup::rollup_status(&paths, true) {
let s = if status == Status::Done {
"done"
} else {
"failed"
};
append_and_apply_event(&paths, "run.status", None, None, json!({ "status": s }))
.unwrap();
}
cleanup::cleanup_terminal_nodes(&paths);
assert_eq!(
read_manifest_opt(&paths).unwrap().unwrap().status,
Status::Done,
"reconciled run rolls up to Done"
);
assert!(!wt.exists(), "merged worktree is torn down");
assert!(!tbranch_exists(&repo, "wt/foo"), "merged branch is deleted");
let preserved = std::fs::read_to_string(paths.events())
.unwrap()
.lines()
.any(|l| l.contains("cleanup.branch_preserved"));
assert!(
!preserved,
"a merged branch must NEVER be reported preserved"
);
}
#[test]
#[serial_test::serial(octl_watchdog_grace)]
fn watchdog_reconciles_merged_branch_while_agent_alive() {
let _lock = GRACE_ENV_LOCK.lock().unwrap();
let _grace = GraceGuard::zero();
let tmp = tempfile::TempDir::new().unwrap();
let (repo, wt, base) = init_merged_repo(&tmp);
let mut alive = PCommand::new("sleep").arg("30").spawn().unwrap();
let alive_pid = alive.id() as i32;
let paths = setup_merged_run(&tmp, &repo, &wt, &base, alive_pid);
assert!(pid_file::pid_alive(alive_pid as u32), "agent must be alive");
let mut streak = std::collections::BTreeMap::new();
watchdog_tick(
&paths,
&mut streak,
&mut std::collections::BTreeMap::new(),
&mut std::collections::BTreeMap::new(),
)
.unwrap();
let n = n0001(&paths);
let report = n
.last_report
.expect("alive-but-merged must still reconcile to a terminal report");
assert_eq!(report["success"], true);
assert_eq!(report["via"], "merge-reconciled");
assert!(n.status.is_terminal(), "run no longer strands at pending");
let _ = alive.kill();
let _ = alive.wait();
}
#[test]
#[serial_test::serial(octl_watchdog_grace)]
fn watchdog_leaves_live_agent_with_dirty_worktree_alone() {
let _lock = GRACE_ENV_LOCK.lock().unwrap();
let _grace = GraceGuard::zero();
let tmp = tempfile::TempDir::new().unwrap();
let (repo, wt, base) = init_merged_repo(&tmp);
std::fs::write(wt.join("still-working.rs"), "wip").unwrap();
let mut alive = PCommand::new("sleep").arg("30").spawn().unwrap();
let alive_pid = alive.id() as i32;
let paths = setup_merged_run(&tmp, &repo, &wt, &base, alive_pid);
let mut streak = std::collections::BTreeMap::new();
watchdog_tick(
&paths,
&mut streak,
&mut std::collections::BTreeMap::new(),
&mut std::collections::BTreeMap::new(),
)
.unwrap();
let n = n0001(&paths);
assert!(
n.last_report.is_none(),
"a live agent with uncommitted work must NOT be reconciled/terminalized"
);
assert!(!n.status.is_terminal(), "node stays live");
assert!(wt.exists(), "the worktree with live work is untouched");
let _ = alive.kill();
let _ = alive.wait();
}
fn init_unmerged_repo_at(
tmp: &tempfile::TempDir,
commit_date: Option<&str>,
) -> (PathBuf, PathBuf, String) {
let repo = tmp.path().join("repo");
std::fs::create_dir_all(&repo).unwrap();
tgit(&repo, &["init", "-q", "-b", "main"]);
tgit(&repo, &["config", "user.email", "t@example.com"]);
tgit(&repo, &["config", "user.name", "t"]);
std::fs::write(repo.join("README"), "x").unwrap();
tgit(&repo, &["add", "-A"]);
tgit(&repo, &["commit", "-qm", "init"]);
let base = trev(&repo, "main");
let wt = tmp.path().join("wt");
tgit(
&repo,
&[
"worktree",
"add",
"-q",
"-b",
"wt/foo",
wt.to_str().unwrap(),
],
);
std::fs::write(wt.join("fix.rs"), "work").unwrap();
tgit(&wt, &["add", "-A"]);
let mut commit = PCommand::new("git");
commit
.current_dir(&wt)
.args(["commit", "-qm", "agent work"])
.stdout(Stdio::null())
.stderr(Stdio::null());
if let Some(d) = commit_date {
commit
.env("GIT_AUTHOR_DATE", d)
.env("GIT_COMMITTER_DATE", d);
}
assert!(commit.status().unwrap().success(), "commit failed");
(repo, wt, base)
}
fn init_unmerged_repo(tmp: &tempfile::TempDir) -> (PathBuf, PathBuf, String) {
init_unmerged_repo_at(tmp, None)
}
fn init_conflicting_unmerged_repo(tmp: &tempfile::TempDir) -> (PathBuf, PathBuf, String) {
let (repo, wt, base) = init_unmerged_repo(tmp);
std::fs::write(repo.join("fix.rs"), "different").unwrap();
tgit(&repo, &["add", "-A"]);
tgit(&repo, &["commit", "-qm", "conflicting main change"]);
(repo, wt, base)
}
fn setup_unmerged_run(
tmp: &tempfile::TempDir,
repo: &Path,
wt: &Path,
base: &str,
agent_pid: i32,
kind: &str,
lifecycle: &str,
) -> RunPaths {
let run_id = "01jxwd0000000000000000000w";
let dir = tmp.path().join(run_id);
std::fs::create_dir_all(&dir).unwrap();
let paths = RunPaths::new(dir, run_id).unwrap();
append_and_apply_event(
&paths,
"run.created",
None,
None,
json!({
"kind": kind,
"lifecycle": lifecycle,
"title": "t",
"source_repo": repo.to_str().unwrap(),
"source_branch": "main",
}),
)
.unwrap();
append_and_apply_event(
&paths,
"node.created",
Some(&NodeId::parse_str("n-0001").unwrap()),
None,
json!({
"kind": kind,
"branch": "wt/foo",
"base_sha": base,
"worktree_path": wt.to_str().unwrap(),
"agent_pid": agent_pid,
}),
)
.unwrap();
paths
}
#[test]
#[serial_test::serial(octl_watchdog_grace)]
fn watchdog_terminalizes_idle_unmerged_autonomous_as_recoverable() {
let _lock = GRACE_ENV_LOCK.lock().unwrap();
let _grace = GraceGuard::zero();
let _idle = EnvGuard::set(IDLE_UNMERGED_ENV, "0");
let tmp = tempfile::TempDir::new().unwrap();
let (repo, wt, base) = init_unmerged_repo(&tmp);
let mut alive = PCommand::new("sleep").arg("30").spawn().unwrap();
let alive_pid = alive.id() as i32;
let paths = setup_unmerged_run(&tmp, &repo, &wt, &base, alive_pid, "spinoff", "autonomous");
assert!(pid_file::pid_alive(alive_pid as u32), "agent must be alive");
let mut streak = std::collections::BTreeMap::new();
watchdog_tick(
&paths,
&mut streak,
&mut std::collections::BTreeMap::new(),
&mut std::collections::BTreeMap::new(),
)
.unwrap();
let n = n0001(&paths);
let report = n
.last_report
.expect("idle-unmerged autonomous run must be terminalized, not left pending");
assert_eq!(
report["success"], false,
"complete-but-unlanded is a failure"
);
assert_eq!(
report["reason"], "agent-idle-unmerged",
"reason distinguishes this from agent-died and blocked handoffs"
);
let rec = &report["recoverable_work"];
assert_eq!(rec["recoverable"], true);
assert_eq!(rec["unmerged_commits"], 1);
assert_eq!(rec["merges_cleanly"], true);
assert_eq!(rec["branch"], "wt/foo");
assert!(n.status.is_terminal(), "run no longer strands at pending");
assert!(report.get("via").is_none(), "recoverable, not a merge");
let _ = alive.kill();
let _ = alive.wait();
}
#[test]
#[serial_test::serial(octl_watchdog_grace)]
fn watchdog_exempts_interactive_idle_unmerged() {
let _lock = GRACE_ENV_LOCK.lock().unwrap();
let _grace = GraceGuard::zero();
let _idle = EnvGuard::set(IDLE_UNMERGED_ENV, "0");
let tmp = tempfile::TempDir::new().unwrap();
let (repo, wt, base) = init_unmerged_repo(&tmp);
let mut alive = PCommand::new("sleep").arg("30").spawn().unwrap();
let alive_pid = alive.id() as i32;
let paths = setup_unmerged_run(&tmp, &repo, &wt, &base, alive_pid, "code", "interactive");
let mut streak = std::collections::BTreeMap::new();
watchdog_tick(
&paths,
&mut streak,
&mut std::collections::BTreeMap::new(),
&mut std::collections::BTreeMap::new(),
)
.unwrap();
let n = n0001(&paths);
assert!(
n.last_report.is_none(),
"an interactive run must keep idling for the human merge, not be terminalized"
);
assert!(!n.status.is_terminal(), "node stays live");
let _ = alive.kill();
let _ = alive.wait();
}
#[test]
#[serial_test::serial(octl_watchdog_grace)]
fn watchdog_does_not_trip_long_working_agent_with_fresh_commit() {
let _lock = GRACE_ENV_LOCK.lock().unwrap();
let _grace = GraceGuard::zero();
let _idle = EnvGuard::set(IDLE_UNMERGED_ENV, "3600");
let tmp = tempfile::TempDir::new().unwrap();
let (repo, wt, base) = init_unmerged_repo(&tmp);
let mut alive = PCommand::new("sleep").arg("30").spawn().unwrap();
let alive_pid = alive.id() as i32;
let paths = setup_unmerged_run(&tmp, &repo, &wt, &base, alive_pid, "spinoff", "autonomous");
let mut streak = std::collections::BTreeMap::new();
watchdog_tick(
&paths,
&mut streak,
&mut std::collections::BTreeMap::new(),
&mut std::collections::BTreeMap::new(),
)
.unwrap();
let n = n0001(&paths);
assert!(
n.last_report.is_none(),
"a still-working agent (fresh commit) must NOT be terminalized"
);
assert!(!n.status.is_terminal(), "node stays live");
let _ = alive.kill();
let _ = alive.wait();
}
#[test]
#[serial_test::serial(octl_watchdog_grace)]
fn watchdog_does_not_trip_when_pane_active_despite_stale_commit() {
let _lock = GRACE_ENV_LOCK.lock().unwrap();
let _grace = GraceGuard::zero();
let _idle = EnvGuard::set(IDLE_UNMERGED_ENV, "3600");
let tmp = tempfile::TempDir::new().unwrap();
let (repo, wt, base) = init_unmerged_repo_at(&tmp, Some("2020-01-01T00:00:00 +0000"));
let mut alive = PCommand::new("sleep").arg("30").spawn().unwrap();
let alive_pid = alive.id() as i32;
let paths = setup_unmerged_run(&tmp, &repo, &wt, &base, alive_pid, "spinoff", "autonomous");
std::fs::write(paths.agent_log(), b"...streaming model output...").unwrap();
let mut streak = std::collections::BTreeMap::new();
watchdog_tick(
&paths,
&mut streak,
&mut std::collections::BTreeMap::new(),
&mut std::collections::BTreeMap::new(),
)
.unwrap();
let n = n0001(&paths);
assert!(
n.last_report.is_none(),
"a fresh pane transcript must hold off the net despite a stale commit"
);
assert!(!n.status.is_terminal(), "node stays live");
let _ = alive.kill();
let _ = alive.wait();
}
#[test]
#[serial_test::serial(octl_watchdog_grace)]
fn watchdog_terminalizes_idle_unmerged_conflicting_as_recoverable_false() {
let _lock = GRACE_ENV_LOCK.lock().unwrap();
let _grace = GraceGuard::zero();
let _idle = EnvGuard::set(IDLE_UNMERGED_ENV, "0");
let tmp = tempfile::TempDir::new().unwrap();
let (repo, wt, base) = init_conflicting_unmerged_repo(&tmp);
let mut alive = PCommand::new("sleep").arg("30").spawn().unwrap();
let alive_pid = alive.id() as i32;
let paths = setup_unmerged_run(&tmp, &repo, &wt, &base, alive_pid, "spinoff", "autonomous");
let mut streak = std::collections::BTreeMap::new();
watchdog_tick(
&paths,
&mut streak,
&mut std::collections::BTreeMap::new(),
&mut std::collections::BTreeMap::new(),
)
.unwrap();
let n = n0001(&paths);
let report = n
.last_report
.expect("a conflicting idle-unmerged run must still be terminalized, not left pending");
assert_eq!(report["success"], false);
assert_eq!(report["reason"], "agent-idle-unmerged");
let rec = &report["recoverable_work"];
assert_eq!(
rec["recoverable"], false,
"a conflicting branch is not cleanly recoverable"
);
assert_eq!(rec["merges_cleanly"], false);
assert_eq!(rec["unmerged_commits"], 1);
assert!(n.status.is_terminal(), "run no longer strands at pending");
assert!(
report.get("via").is_none(),
"recoverable, not a merge → cleanup preserves the branch for resolution"
);
let _ = alive.kill();
let _ = alive.wait();
}
#[test]
fn node_idle_unmerged_cpu_clock_vetoes_busy_but_silent() {
let tmp = tempfile::TempDir::new().unwrap();
let (repo, wt, base) = init_unmerged_repo_at(&tmp, Some("2020-01-01T00:00:00 +0000"));
let paths = setup_unmerged_run(&tmp, &repo, &wt, &base, 1, "spinoff", "autonomous");
let n = n0001(&paths);
let git = cleanup::git_bin();
let now = 4_000_000_000i64;
assert!(
cleanup::node_idle_unmerged(&paths, &n, &git, now, 1800, None).is_some(),
"stale commit alone should trip the net"
);
assert!(
cleanup::node_idle_unmerged(&paths, &n, &git, now, 1800, Some(now)).is_none(),
"a fresh CPU-activity clock must veto the idle verdict"
);
let _ = repo;
let _ = wt;
}
#[test]
fn cpu_activity_clock_tracks_changes() {
let mut map = std::collections::BTreeMap::new();
assert_eq!(cpu_activity_clock(&mut map, "n", Some(50), 100), Some(100));
assert_eq!(cpu_activity_clock(&mut map, "n", Some(50), 200), Some(100));
assert_eq!(cpu_activity_clock(&mut map, "n", Some(60), 300), Some(300));
assert_eq!(cpu_activity_clock(&mut map, "n", Some(60), 400), Some(300));
assert_eq!(cpu_activity_clock(&mut map, "n", None, 500), None);
}
struct EnvGuard {
key: &'static str,
old: Option<std::ffi::OsString>,
}
impl EnvGuard {
fn set(key: &'static str, val: &str) -> Self {
let old = std::env::var_os(key);
std::env::set_var(key, val);
Self { key, old }
}
}
impl Drop for EnvGuard {
fn drop(&mut self) {
match &self.old {
Some(v) => std::env::set_var(self.key, v),
None => std::env::remove_var(self.key),
}
}
}
fn init_empty_handed_repo(tmp: &tempfile::TempDir) -> (PathBuf, PathBuf, String) {
let repo = tmp.path().join("repo");
std::fs::create_dir_all(&repo).unwrap();
tgit(&repo, &["init", "-q", "-b", "main"]);
tgit(&repo, &["config", "user.email", "t@example.com"]);
tgit(&repo, &["config", "user.name", "t"]);
std::fs::write(repo.join("README"), "x").unwrap();
tgit(&repo, &["add", "-A"]);
tgit(&repo, &["commit", "-qm", "init"]);
let base = trev(&repo, "main");
let wt = tmp.path().join("wt");
tgit(
&repo,
&[
"worktree",
"add",
"-q",
"-b",
"wt/foo",
wt.to_str().unwrap(),
"main",
],
);
(repo, wt, base)
}
fn init_committed_repo(tmp: &tempfile::TempDir) -> (PathBuf, PathBuf, String) {
let (repo, wt, base) = init_empty_handed_repo(tmp);
std::fs::write(wt.join("fix.rs"), "work").unwrap();
tgit(&wt, &["add", "-A"]);
tgit(&wt, &["commit", "-qm", "agent work"]);
(repo, wt, base)
}
fn setup_autonomous_run(
tmp: &tempfile::TempDir,
repo: &Path,
wt: &Path,
base: &str,
agent_pid: i32,
) -> RunPaths {
let run_id = "01jxwd0000000000000000000w";
let dir = tmp.path().join(run_id);
std::fs::create_dir_all(&dir).unwrap();
let paths = RunPaths::new(dir, run_id).unwrap();
append_and_apply_event(
&paths,
"run.created",
None,
None,
json!({
"kind": "spinoff",
"lifecycle": "autonomous",
"title": "t",
"source_repo": repo.to_str().unwrap(),
"source_branch": "main",
}),
)
.unwrap();
append_and_apply_event(
&paths,
"node.created",
Some(&NodeId::parse_str("n-0001").unwrap()),
None,
json!({
"kind": "spinoff",
"branch": "wt/foo",
"base_sha": base,
"worktree_path": wt.to_str().unwrap(),
"agent_pid": agent_pid,
}),
)
.unwrap();
paths
}
fn write_respawn_stub(scratch: &Path, repo: &Path, wt_root: &Path) -> PathBuf {
let p = scratch.join("respawn-create.sh");
let body = format!(
r#"#!/bin/bash
set -e
base=main
args=("$@")
for ((i=0; i<${{#args[@]}}; i++)); do
if [ "${{args[$i]}}" = "--base" ]; then base="${{args[$((i+1))]}}"; fi
done
branch="${{args[$((${{#args[@]}}-2))]}}"
safe=$(printf '%s' "$branch" | tr '/' '_')
wt="{wt_root}/$safe"
git -C "{repo}" worktree add -q -b "$branch" "$wt" "$base" >/dev/null 2>&1
bash -c 'exec sleep 120' </dev/null >/dev/null 2>&1 &
agent_pid=$!
cat <<EOF
{{"schema_version":1,"type":"spinoff","branch":"$branch","worktree_path":"$wt","tmux_window":"$branch","agent_pid_hint":$agent_pid,"tmux_socket":null,"tmux_session":null,"tmux_window_id":null}}
EOF
"#,
wt_root = wt_root.display(),
repo = repo.display(),
);
std::fs::write(&p, body).unwrap();
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(&p).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&p, perms).unwrap();
p
}
#[test]
#[serial_test::serial(octl_watchdog_grace)]
fn empty_handed_death_retries_and_then_succeeds() {
let _lock = GRACE_ENV_LOCK.lock().unwrap();
let _create_lock = crate::run::spawn::tests::ENV_LOCK.lock().unwrap();
let _grace = GraceGuard::zero();
let _backoff = EnvGuard::set(AGENT_RETRY_BACKOFF_ENV, "0");
let _tmux = EnvGuard::set("TMUX_BIN", "/nonexistent/tmux");
let tmp = tempfile::TempDir::new().unwrap();
let (repo, wt, base) = init_empty_handed_repo(&tmp);
let dead = PCommand::new("true").spawn().unwrap();
let dead_pid = dead.id() as i32;
let mut dead = dead;
dead.wait().unwrap();
let paths = setup_autonomous_run(&tmp, &repo, &wt, &base, dead_pid);
std::fs::write(paths.root.join("prompt.md"), "do the thing").unwrap();
let wt_root = tmp.path().join("respawn-wts");
std::fs::create_dir_all(&wt_root).unwrap();
let stub = write_respawn_stub(tmp.path(), &repo, &wt_root);
let _create = EnvGuard::set("OCTL_CREATE_SH", stub.to_str().unwrap());
let mut streak = std::collections::BTreeMap::new();
let mut retries = std::collections::BTreeMap::new();
watchdog_tick(
&paths,
&mut streak,
&mut retries,
&mut std::collections::BTreeMap::new(),
)
.unwrap();
let n = n0001(&paths);
assert!(
!n.status.is_terminal(),
"a retried node is NOT terminalized"
);
assert_eq!(n.retry_attempts, 1, "one retry recorded on the node");
assert_eq!(
n.branch.as_deref(),
Some("wt/foo-r1"),
"rewired to fresh branch"
);
assert!(
retries.is_empty(),
"park cleared after a successful re-spawn"
);
let has_retry_event = std::fs::read_to_string(paths.events())
.unwrap()
.lines()
.any(|l| l.contains("\"node.retry\""));
assert!(has_retry_event, "a durable node.retry event is emitted");
let new_pid = n.agent_pid.expect("re-spawned agent pid");
assert!(
pid_file::pid_alive(new_pid as u32),
"the re-spawned agent is alive"
);
assert!(
!tbranch_exists(&repo, "wt/foo"),
"the stale empty-handed branch is torn down"
);
let new_wt = PathBuf::from(n.worktree_path.clone().unwrap());
std::fs::write(new_wt.join("fix.rs"), "done").unwrap();
tgit(&new_wt, &["add", "-A"]);
tgit(&new_wt, &["commit", "-qm", "retried work"]);
tgit(&repo, &["merge", "--ff-only", "wt/foo-r1"]);
watchdog_tick(
&paths,
&mut streak,
&mut retries,
&mut std::collections::BTreeMap::new(),
)
.unwrap();
let n2 = n0001(&paths);
let report = n2.last_report.expect("terminal report after merge");
assert_eq!(
report["success"], true,
"the re-spawned worker's merge reconciles to success"
);
assert!(n2.status.is_terminal());
let _ = PCommand::new("kill").arg(new_pid.to_string()).status();
}
#[test]
#[serial_test::serial(octl_watchdog_grace)]
fn empty_handed_death_terminalizes_failed_after_max_attempts() {
let _lock = GRACE_ENV_LOCK.lock().unwrap();
let _grace = GraceGuard::zero();
let _max = EnvGuard::set(AGENT_RETRY_MAX_ATTEMPTS_ENV, "1");
let _backoff = EnvGuard::set(AGENT_RETRY_BACKOFF_ENV, "0");
let tmp = tempfile::TempDir::new().unwrap();
let (repo, wt, base) = init_empty_handed_repo(&tmp);
let dead = PCommand::new("true").spawn().unwrap();
let dead_pid = dead.id() as i32;
let mut dead = dead;
dead.wait().unwrap();
let paths = setup_autonomous_run(&tmp, &repo, &wt, &base, dead_pid);
let nid = NodeId::parse_str("n-0001").unwrap();
append_and_apply_event(
&paths,
"node.retry",
Some(&nid),
None,
json!({
"attempt": 1,
"reason": "agent-died",
"branch": "wt/foo",
"base_sha": base,
"worktree_path": wt.to_str().unwrap(),
"agent_pid": dead_pid,
}),
)
.unwrap();
assert_eq!(n0001(&paths).retry_attempts, 1, "pre-staged at the budget");
let mut streak = std::collections::BTreeMap::new();
let mut retries = std::collections::BTreeMap::new();
watchdog_tick(
&paths,
&mut streak,
&mut retries,
&mut std::collections::BTreeMap::new(),
)
.unwrap();
let n = n0001(&paths);
let report = n.last_report.expect("terminal failed report");
assert_eq!(report["success"], false, "exhausted budget → failed");
assert_eq!(report["reason"], "agent-died");
assert_eq!(
report["retry_attempts"], 1,
"the failed report records how many retries preceded it"
);
assert!(
n.status.is_terminal(),
"run is terminalized, not respun forever"
);
assert!(
retries.is_empty(),
"no park scheduled once the budget is exhausted"
);
}
#[test]
#[serial_test::serial(octl_watchdog_grace)]
fn committed_work_death_is_not_retried_and_preserves_salvage_signal() {
let _lock = GRACE_ENV_LOCK.lock().unwrap();
let _grace = GraceGuard::zero();
let _backoff = EnvGuard::set(AGENT_RETRY_BACKOFF_ENV, "0");
let _tmux = EnvGuard::set("TMUX_BIN", "/nonexistent/tmux");
let tmp = tempfile::TempDir::new().unwrap();
let (repo, wt, base) = init_committed_repo(&tmp);
let dead = PCommand::new("true").spawn().unwrap();
let dead_pid = dead.id() as i32;
let mut dead = dead;
dead.wait().unwrap();
let paths = setup_autonomous_run(&tmp, &repo, &wt, &base, dead_pid);
let mut streak = std::collections::BTreeMap::new();
let mut retries = std::collections::BTreeMap::new();
watchdog_tick(
&paths,
&mut streak,
&mut retries,
&mut std::collections::BTreeMap::new(),
)
.unwrap();
assert!(
retries.is_empty(),
"committed work is NOT parked for retry (salvage wins)"
);
let n = n0001(&paths);
let report = n.last_report.expect("terminal failed report");
assert_eq!(report["success"], false);
assert!(
report.get("recoverable_work").is_some(),
"the salvage signal is preserved on the failed report"
);
assert!(
report.get("retry_attempts").is_none(),
"a committed-work death is not a retry failure"
);
assert_eq!(n.retry_attempts, 0, "no retry was attempted");
assert!(
tbranch_exists(&repo, "wt/foo"),
"committed branch preserved"
);
assert!(wt.exists(), "committed worktree preserved");
}
fn write_failing_stub(scratch: &Path) -> PathBuf {
let p = scratch.join("failing-create.sh");
let body = "#!/bin/bash\n\
echo '{\"schema_version\":1,\"error\":{\"code\":\"stub-boom\",\"message\":\"stub always fails\"}}' >&2\n\
exit 1\n";
std::fs::write(&p, body).unwrap();
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(&p).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&p, perms).unwrap();
p
}
#[test]
#[serial_test::serial(octl_watchdog_grace)]
fn respawn_infrastructure_failure_terminalizes_after_budget() {
let _lock = GRACE_ENV_LOCK.lock().unwrap();
let _create_lock = crate::run::spawn::tests::ENV_LOCK.lock().unwrap();
let _grace = GraceGuard::zero();
let _backoff = EnvGuard::set(AGENT_RETRY_BACKOFF_ENV, "0");
let _failures = EnvGuard::set(AGENT_RESPAWN_MAX_FAILURES_ENV, "2");
let _tmux = EnvGuard::set("TMUX_BIN", "/nonexistent/tmux");
let tmp = tempfile::TempDir::new().unwrap();
let (repo, wt, base) = init_empty_handed_repo(&tmp);
let dead = PCommand::new("true").spawn().unwrap();
let dead_pid = dead.id() as i32;
let mut dead = dead;
dead.wait().unwrap();
let paths = setup_autonomous_run(&tmp, &repo, &wt, &base, dead_pid);
std::fs::write(paths.root.join("prompt.md"), "do the thing").unwrap();
let stub = write_failing_stub(tmp.path());
let _create = EnvGuard::set("OCTL_CREATE_SH", stub.to_str().unwrap());
let mut streak = std::collections::BTreeMap::new();
let mut retries = std::collections::BTreeMap::new();
watchdog_tick(
&paths,
&mut streak,
&mut retries,
&mut std::collections::BTreeMap::new(),
)
.unwrap();
assert!(
!n0001(&paths).status.is_terminal(),
"one failure is not terminal"
);
assert!(
retries.contains_key("n-0001"),
"park retained after 1 failure"
);
assert!(
tbranch_exists(&repo, "wt/foo"),
"stale branch survives a failed spawn"
);
watchdog_tick(
&paths,
&mut streak,
&mut retries,
&mut std::collections::BTreeMap::new(),
)
.unwrap();
let n = n0001(&paths);
let report = n.last_report.expect("terminal failed report after budget");
assert_eq!(report["success"], false);
assert_eq!(report["reason"], "agent-respawn-failed");
assert!(
n.status.is_terminal(),
"run terminalizes, never loops forever"
);
assert!(retries.is_empty(), "park dropped once terminalized");
}
#[test]
#[serial_test::serial(octl_watchdog_grace)]
fn dirty_worktree_death_is_not_retried() {
let _lock = GRACE_ENV_LOCK.lock().unwrap();
let _grace = GraceGuard::zero();
let _backoff = EnvGuard::set(AGENT_RETRY_BACKOFF_ENV, "0");
let _tmux = EnvGuard::set("TMUX_BIN", "/nonexistent/tmux");
let tmp = tempfile::TempDir::new().unwrap();
let (repo, wt, base) = init_empty_handed_repo(&tmp);
std::fs::write(wt.join("scratch.rs"), "wip, not committed").unwrap();
let dead = PCommand::new("true").spawn().unwrap();
let dead_pid = dead.id() as i32;
let mut dead = dead;
dead.wait().unwrap();
let paths = setup_autonomous_run(&tmp, &repo, &wt, &base, dead_pid);
let mut streak = std::collections::BTreeMap::new();
let mut retries = std::collections::BTreeMap::new();
watchdog_tick(
&paths,
&mut streak,
&mut retries,
&mut std::collections::BTreeMap::new(),
)
.unwrap();
assert!(
retries.is_empty(),
"a dirty worktree is NOT parked for retry"
);
let n = n0001(&paths);
let report = n.last_report.expect("terminal failed report");
assert_eq!(report["success"], false);
assert_eq!(report["reason"], "agent-died");
assert_eq!(n.retry_attempts, 0, "no retry attempted");
assert!(
wt.join("scratch.rs").exists(),
"uncommitted work not destroyed"
);
}
}