use crate::scroll_buffer::ScrollBuffer;
use crate::tui_output;
use koda_core::tools::bg_task_tools::TaskId;
pub(crate) fn handle_cancel_background_task(
buffer: &mut ScrollBuffer,
bg_agents: &koda_core::child_agent::ChildAgentRegistry,
bg_processes: &koda_core::tools::bg_process::BgRegistry,
child_activity: &mut crate::child_activity::ChildActivityTracker,
task_id: Option<TaskId>,
) {
let Some(id) = task_id else {
tui_output::warn_msg(
buffer,
"Usage: /cancel <agent:id|process:id> (ids are visible in the live bg-activity overlay)".into(),
);
return;
};
match id {
TaskId::Agent(n) => {
if bg_agents.cancel(n) {
child_activity.mark_cancelling(n);
tui_output::ok_msg(
buffer,
format!("Cancellation requested for agent:{n}. Result will inject shortly."),
);
} else {
tui_output::warn_msg(
buffer,
format!(
"No background sub-agent with id agent:{n}. Check the bg-activity overlay for active task ids."
),
);
}
}
TaskId::Process(n) => {
if bg_processes.kill(n) {
tui_output::ok_msg(
buffer,
format!("SIGTERM sent to process:{n}. It should exit shortly."),
);
} else {
tui_output::warn_msg(
buffer,
format!(
"No background process with id process:{n}. Check the bg-activity overlay for active task ids."
),
);
}
}
}
}
pub(crate) fn cancel_all_bg_work(
buffer: &mut ScrollBuffer,
bg_agents: &koda_core::child_agent::ChildAgentRegistry,
bg_processes: &koda_core::tools::bg_process::BgRegistry,
child_activity: &mut crate::child_activity::ChildActivityTracker,
) -> (usize, usize) {
use koda_core::child_agent::AgentStatus;
let agent_ids: Vec<u32> = bg_agents
.snapshot()
.into_iter()
.filter(|s| matches!(s.status, AgentStatus::Pending | AgentStatus::Running { .. }))
.map(|s| s.task_id)
.collect();
let proc_ids: Vec<u32> = bg_processes
.snapshot()
.into_iter()
.filter(|s| {
matches!(
s.status,
koda_core::tools::bg_process::BgProcessStatus::Running
)
})
.map(|s| s.pid)
.collect();
let agents_cancelled = agent_ids
.iter()
.filter(|id| {
let cancelled = bg_agents.cancel(**id);
if cancelled {
child_activity.mark_cancelling(**id);
}
cancelled
})
.count();
let processes_killed = proc_ids
.iter()
.filter(|pid| bg_processes.kill(**pid))
.count();
if agents_cancelled + processes_killed > 0 {
let parts: Vec<String> = [
(agents_cancelled > 0).then(|| format!("{agents_cancelled} agent(s)")),
(processes_killed > 0).then(|| format!("{processes_killed} process(es)")),
]
.into_iter()
.flatten()
.collect();
tui_output::warn_msg(
buffer,
format!("\u{26d4} Cancelled {} background work", parts.join(" + ")),
);
}
(agents_cancelled, processes_killed)
}
pub(crate) fn active_bg_count(
bg_agents: &koda_core::child_agent::ChildAgentRegistry,
bg_processes: &koda_core::tools::bg_process::BgRegistry,
) -> usize {
use koda_core::child_agent::AgentStatus;
use koda_core::tools::bg_process::BgProcessStatus;
let active_agents = bg_agents
.snapshot()
.into_iter()
.filter(|s| matches!(s.status, AgentStatus::Pending | AgentStatus::Running { .. }))
.count();
let active_procs = bg_processes
.snapshot()
.into_iter()
.filter(|s| matches!(s.status, BgProcessStatus::Running))
.count();
active_agents + active_procs
}
#[cfg(test)]
mod tests {
use super::*;
use koda_core::child_agent::{AgentStatus, BgPayload, ChildAgentRegistry};
use koda_core::tools::bg_process::BgRegistry;
use tokio::sync::{oneshot, watch};
use tokio_util::sync::CancellationToken;
fn buffer_text(buffer: &ScrollBuffer) -> String {
buffer
.all_lines()
.map(|line| {
line.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n")
}
fn register_entry(
reg: &ChildAgentRegistry,
agent_name: &str,
prompt: &str,
) -> (
u32,
oneshot::Sender<Result<BgPayload, BgPayload>>,
watch::Sender<AgentStatus>,
CancellationToken,
) {
let parent = CancellationToken::new();
let r = reg.reserve(&parent, None, true);
let task_id = r.task_id;
let tx = r.tx;
let status_tx = r.status_tx;
let observer = r.cancel.clone();
let noop = tokio::spawn(async {});
reg.attach(
task_id,
agent_name,
prompt,
r.rx,
r.cancel,
r.status_rx,
None,
true,
None,
noop,
);
(task_id, tx, status_tx, observer)
}
fn spawn_sleep_in_registry(reg: &BgRegistry) -> u32 {
let mut cmd = tokio::process::Command::new("sleep");
cmd.arg("60");
let child = cmd.spawn().expect("spawn sleep");
let pid = child.id().expect("sleep should have a pid before exit");
reg.insert(pid, "sleep 60".to_string(), child, None);
pid
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn cancel_known_agent_id_reports_success_and_fires_token() {
let mut buf = ScrollBuffer::new(64);
let reg = ChildAgentRegistry::new();
let procs = BgRegistry::new();
let (task_id, _tx, _status_tx, observer) = register_entry(®, "explore", "x");
handle_cancel_background_task(
&mut buf,
®,
&procs,
&mut crate::child_activity::ChildActivityTracker::default(),
Some(TaskId::Agent(task_id)),
);
let text = buffer_text(&buf);
assert!(
text.contains(&format!("agent:{task_id}")),
"success message should mention the prefixed id, got: {text}"
);
assert!(
observer.is_cancelled(),
"the cancel token should have been fired"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn cancel_known_process_id_kills_and_reports_success() {
let mut buf = ScrollBuffer::new(64);
let reg = ChildAgentRegistry::new();
let procs = BgRegistry::new();
let pid = spawn_sleep_in_registry(&procs);
handle_cancel_background_task(
&mut buf,
®,
&procs,
&mut crate::child_activity::ChildActivityTracker::default(),
Some(TaskId::Process(pid)),
);
let text = buffer_text(&buf);
assert!(
text.contains(&format!("process:{pid}")),
"success message should mention the prefixed id, got: {text}"
);
assert!(
text.to_lowercase().contains("sigterm") || text.to_lowercase().contains("exit"),
"expected SIGTERM acknowledgement, got: {text}"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn cancel_unknown_agent_id_reports_helpful_error() {
let mut buf = ScrollBuffer::new(64);
let reg = ChildAgentRegistry::new();
let procs = BgRegistry::new();
handle_cancel_background_task(
&mut buf,
®,
&procs,
&mut crate::child_activity::ChildActivityTracker::default(),
Some(TaskId::Agent(999)),
);
let text = buffer_text(&buf);
assert!(
text.contains("agent:999") && text.contains("bg-activity overlay"),
"warn should name the missing prefixed id and point to the bg-activity overlay, got: {text}"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn cancel_unknown_process_id_reports_helpful_error() {
let mut buf = ScrollBuffer::new(64);
let reg = ChildAgentRegistry::new();
let procs = BgRegistry::new();
handle_cancel_background_task(
&mut buf,
®,
&procs,
&mut crate::child_activity::ChildActivityTracker::default(),
Some(TaskId::Process(999_999)),
);
let text = buffer_text(&buf);
assert!(
text.contains("process:999999") && text.contains("bg-activity overlay"),
"warn should name the missing prefixed id and point to the bg-activity overlay, got: {text}"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn cancel_none_id_renders_usage_with_both_prefixes() {
let mut buf = ScrollBuffer::new(64);
let reg = ChildAgentRegistry::new();
let procs = BgRegistry::new();
handle_cancel_background_task(
&mut buf,
®,
&procs,
&mut crate::child_activity::ChildActivityTracker::default(),
None,
);
let text = buffer_text(&buf);
assert!(
text.contains("Usage:") && text.contains("agent:") && text.contains("process:"),
"None id should render a Usage: line with both prefixes, got: {text}"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn cancel_all_bg_work_empty_is_silent() {
let mut buf = ScrollBuffer::new(64);
let reg = ChildAgentRegistry::new();
let procs = BgRegistry::new();
let (a, p) = cancel_all_bg_work(
&mut buf,
®,
&procs,
&mut crate::child_activity::ChildActivityTracker::default(),
);
assert_eq!((a, p), (0, 0));
assert!(
buffer_text(&buf).is_empty(),
"empty registries must not emit a status line"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn cancel_all_bg_work_cancels_every_running_agent() {
let mut buf = ScrollBuffer::new(64);
let reg = ChildAgentRegistry::new();
let procs = BgRegistry::new();
let (_id1, _tx1, status1, observer1) = register_entry(®, "explore", "prompt 1");
let (_id2, _tx2, status2, observer2) = register_entry(®, "task", "prompt 2");
status1.send(AgentStatus::Running { iter: 1 }).unwrap();
status2.send(AgentStatus::Running { iter: 1 }).unwrap();
let (a, p) = cancel_all_bg_work(
&mut buf,
®,
&procs,
&mut crate::child_activity::ChildActivityTracker::default(),
);
assert_eq!((a, p), (2, 0));
assert!(observer1.is_cancelled(), "agent 1 should observe cascade");
assert!(observer2.is_cancelled(), "agent 2 should observe cascade");
let text = buffer_text(&buf);
assert!(
text.contains("Cancelled") && text.contains("2 agent(s)"),
"summary line should report agent count, got: {text}"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn cancel_all_bg_work_handles_agents_and_processes() {
let mut buf = ScrollBuffer::new(64);
let reg = ChildAgentRegistry::new();
let procs = BgRegistry::new();
let (_id, _tx, status, observer) = register_entry(®, "explore", "hi");
status.send(AgentStatus::Running { iter: 1 }).unwrap();
let _pid = spawn_sleep_in_registry(&procs);
let (a, p) = cancel_all_bg_work(
&mut buf,
®,
&procs,
&mut crate::child_activity::ChildActivityTracker::default(),
);
assert_eq!((a, p), (1, 1));
assert!(observer.is_cancelled());
let text = buffer_text(&buf);
assert!(
text.contains("1 agent(s)") && text.contains("1 process(es)"),
"summary should mention both kinds, got: {text}"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn cancel_all_bg_work_skips_completed_entries() {
let mut buf = ScrollBuffer::new(64);
let reg = ChildAgentRegistry::new();
let procs = BgRegistry::new();
let (_id, tx, status, observer) = register_entry(®, "explore", "hi");
status
.send(AgentStatus::Completed {
summary: "done".to_string(),
})
.unwrap();
let _ = tx.send(Ok(("done".to_string(), vec![])));
let (a, p) = cancel_all_bg_work(
&mut buf,
®,
&procs,
&mut crate::child_activity::ChildActivityTracker::default(),
);
assert_eq!((a, p), (0, 0));
assert!(
!observer.is_cancelled(),
"completed entries must not have their cancel token fired"
);
assert!(buffer_text(&buf).is_empty());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn active_bg_count_excludes_completed() {
let reg = ChildAgentRegistry::new();
let procs = BgRegistry::new();
assert_eq!(active_bg_count(®, &procs), 0);
let (_id1, _tx1, s1, _obs1) = register_entry(®, "a", "p1");
s1.send(AgentStatus::Running { iter: 1 }).unwrap();
let (_id2, tx2, s2, _obs2) = register_entry(®, "b", "p2");
s2.send(AgentStatus::Completed {
summary: "x".to_string(),
})
.unwrap();
let _ = tx2.send(Ok(("x".to_string(), vec![])));
assert_eq!(
active_bg_count(®, &procs),
1,
"only the running agent should count"
);
}
}