use std::collections::HashMap;
use std::sync::{LazyLock, Mutex};
use tokio_util::sync::CancellationToken;
use crate::registry::ParentKey;
use crate::util::UnwrapPoison;
static RESEARCH_CANCELS: LazyLock<ResearchCancelRegistry> =
LazyLock::new(ResearchCancelRegistry::default);
#[derive(Default)]
struct ResearchCancelRegistry {
inner: Mutex<HashMap<String, CancellationToken>>,
}
impl ResearchCancelRegistry {
fn register(&'static self, job_id: &str) -> ResearchCancelGuard {
let token = CancellationToken::new();
self.inner
.lock()
.unwrap_poison()
.insert(job_id.to_string(), token.clone());
ResearchCancelGuard {
job_id: job_id.to_string(),
registry: self,
}
}
fn cancel(&self, job_id: &str) {
let map = self.inner.lock().unwrap_poison();
if let Some(token) = map.get(job_id) {
token.cancel();
}
}
fn is_cancelled(&self, job_id: &str) -> bool {
self.inner
.lock()
.unwrap_poison()
.get(job_id)
.is_some_and(CancellationToken::is_cancelled)
}
fn unregister(&self, job_id: &str) {
self.inner.lock().unwrap_poison().remove(job_id);
}
}
pub(crate) struct ResearchCancelGuard {
job_id: String,
registry: &'static ResearchCancelRegistry,
}
impl Drop for ResearchCancelGuard {
fn drop(&mut self) {
self.registry.unregister(&self.job_id);
}
}
pub(crate) fn register(job_id: &str) -> ResearchCancelGuard {
RESEARCH_CANCELS.register(job_id)
}
pub(crate) fn cancel(job_id: &str) {
RESEARCH_CANCELS.cancel(job_id);
}
pub(crate) fn is_cancelled(job_id: &str) -> bool {
RESEARCH_CANCELS.is_cancelled(job_id)
}
pub(crate) async fn cancel_research_run(job_id: &str) -> Result<(), String> {
cancel(job_id);
crate::registry::AGENT_REGISTRY.cancel_by_parent_key(&ParentKey::Research(job_id.to_string()));
crate::call_registry::NON_AGENT_CALLS
.remove_by_parent_key(&ParentKey::Research(job_id.to_string()));
sweep_cancelled_run(job_id).await
}
pub(crate) async fn sweep_cancelled_run(job_id: &str) -> Result<(), String> {
let conn = &crate::session::store().conn;
let tx = conn.begin_tx().await.map_err(|e| format!("{e:#}"))?;
let outcome: anyhow::Result<()> = async {
tx.execute(
"DELETE FROM pending_jobs WHERE id = ?1",
crate::turso::params![job_id],
)
.await?;
tx.execute(
"DELETE FROM jobs WHERE id = ?1",
crate::turso::params![job_id],
)
.await?;
Ok(())
}
.await;
match outcome {
Ok(()) => tx.commit().await.map_err(|e| format!("{e:#}"))?,
Err(e) => return Err(format!("{e:#}")),
}
crate::research_cleanup::release_run_folder(job_id).await;
delete_results_archive(job_id).await;
Ok(())
}
async fn delete_results_archive(job_id: &str) {
let root = crate::config::CONFIG
.try_storage_root()
.or_else(|| crate::config::default_config_dir().ok());
let Some(root) = root else {
return;
};
let path = root
.join("research")
.join("results")
.join(format!("{job_id}.md"));
match tokio::fs::remove_file(&path).await {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => {
tracing::warn!(job = %job_id, error = %e, "results.md archive deletion failed — left on disk");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::util::test::{JobRowBuilder, retry_tests_lock};
#[tokio::test]
async fn cancel_fires_signal_and_guard_removes_entry_on_drop() {
let _lock = retry_tests_lock();
cancel("nope");
assert!(!is_cancelled("nope"));
let guard = register("run_signal_1");
assert!(!is_cancelled("run_signal_1"));
cancel("run_signal_1");
assert!(is_cancelled("run_signal_1"), "fired signal stays visible");
cancel("run_signal_1");
assert!(is_cancelled("run_signal_1"));
drop(guard);
assert!(
!is_cancelled("run_signal_1"),
"guard drop removes the entry"
);
}
#[tokio::test]
#[expect(clippy::await_holding_lock)] async fn sweep_removes_rows_folder_and_archive_for_mid_run_cancel() {
let _lock = retry_tests_lock();
crate::util::test::init_management_test_stores().await;
let job_id = "research_cancel_midrun_1";
let conn = &crate::session::store().conn;
let now = crate::turso::now();
JobRowBuilder::new(conn, job_id, "research", "assistant", "ws")
.task("q")
.user_name("u")
.channel("telegram")
.timestamps(now.clone())
.insert()
.await
.unwrap();
conn.execute(
"INSERT INTO research_jobs (id, state) VALUES (?1, '{}')",
crate::turso::params![job_id],
)
.await
.unwrap();
conn.execute(
"INSERT INTO pending_jobs (id, envelope, target_agent_id, created_at) \
VALUES (?1, ?2, 'manager_ws', ?3)",
crate::turso::params![
job_id,
r#"{"content":"report","workspace_name":"ws","user_name":"u","channel":"telegram","kind":"ResearchResult","role":"assistant","reply_target":null,"pending_job_id":null}"#,
now
],
)
.await
.unwrap();
let run_root = crate::research_cleanup::ensure_run_root(job_id).await;
tokio::fs::write(run_root.join("commands.dump"), "shell cmd")
.await
.unwrap();
let root = crate::config::CONFIG
.try_storage_root()
.or_else(|| crate::config::default_config_dir().ok())
.unwrap();
let archive = root
.join("research")
.join("results")
.join(format!("{job_id}.md"));
tokio::fs::create_dir_all(archive.parent().unwrap())
.await
.unwrap();
tokio::fs::write(&archive, "archive").await.unwrap();
sweep_cancelled_run(job_id).await.unwrap();
let jobs = conn
.query(
"SELECT id FROM jobs WHERE id = ?1",
crate::turso::params![job_id],
)
.await
.unwrap();
assert!(jobs.is_empty(), "jobs row removed");
let pending = conn
.query(
"SELECT id FROM pending_jobs WHERE id = ?1",
crate::turso::params![job_id],
)
.await
.unwrap();
assert!(pending.is_empty(), "pending row removed — no boot replay");
assert!(!run_root.exists(), "run folder removed");
assert!(!archive.exists(), "results.md archive removed");
}
#[tokio::test]
#[expect(clippy::await_holding_lock)] async fn sweep_removes_pending_and_cleanup_rows_for_terminalized_run() {
let _lock = retry_tests_lock();
crate::util::test::init_management_test_stores().await;
let job_id = "research_cancel_terminal_1";
let conn = &crate::session::store().conn;
let now = crate::turso::now();
JobRowBuilder::new(conn, job_id, "research_cleanup", "sanitation", "ws")
.task("cleanup prompt")
.user_name("")
.channel("")
.timestamps(now.clone())
.insert()
.await
.unwrap();
conn.execute(
"INSERT INTO pending_jobs (id, envelope, target_agent_id, created_at) \
VALUES (?1, ?2, 'manager_ws', ?3)",
crate::turso::params![
job_id,
r#"{"content":"report","workspace_name":"ws","user_name":"u","channel":"telegram","kind":"ResearchResult","role":"assistant","reply_target":null,"pending_job_id":null}"#,
now
],
)
.await
.unwrap();
let run_root = crate::research_cleanup::ensure_run_root(job_id).await;
let root = crate::config::CONFIG
.try_storage_root()
.or_else(|| crate::config::default_config_dir().ok())
.unwrap();
let archive = root
.join("research")
.join("results")
.join(format!("{job_id}.md"));
tokio::fs::create_dir_all(archive.parent().unwrap())
.await
.unwrap();
tokio::fs::write(&archive, "archive").await.unwrap();
sweep_cancelled_run(job_id).await.unwrap();
let jobs = conn
.query(
"SELECT id FROM jobs WHERE id = ?1",
crate::turso::params![job_id],
)
.await
.unwrap();
assert!(jobs.is_empty(), "cleanup job row removed");
let pending = conn
.query(
"SELECT id FROM pending_jobs WHERE id = ?1",
crate::turso::params![job_id],
)
.await
.unwrap();
assert!(pending.is_empty(), "pending report row removed");
assert!(!run_root.exists());
assert!(!archive.exists());
}
#[tokio::test]
#[expect(clippy::await_holding_lock)] async fn sweep_is_idempotent_and_double_release_safe() {
let _lock = retry_tests_lock();
crate::util::test::init_management_test_stores().await;
let job_id = "research_cancel_double_1";
sweep_cancelled_run(job_id).await.unwrap();
sweep_cancelled_run(job_id).await.unwrap();
}
#[tokio::test]
#[expect(clippy::await_holding_lock)] async fn complete_durable_job_rolls_back_when_run_cancelled() {
let _lock = retry_tests_lock();
crate::util::test::init_management_test_stores().await;
let job_id = "research_cancel_complete_1";
let ws = crate::workspace::test_ws("/tmp/test_ws_research_cancel_complete");
crate::jobs::spawn_job(
&crate::session::store().conn,
job_id,
"q",
&ws.name,
"caller-user",
"telegram",
crate::Role::Assistant,
&[],
&crate::jobs::SpawnChild::Research,
)
.await
.unwrap();
let _guard = register(job_id);
cancel(job_id);
let envelope = crate::jobs::complete_durable_job(
job_id,
"report".to_string(),
crate::message_router::JobKind::ResearchResult,
crate::Role::Assistant,
"caller-user",
"telegram",
&ws.name,
)
.await;
let conn = &crate::session::store().conn;
let pending = conn
.query(
"SELECT id FROM pending_jobs WHERE id = ?1",
crate::turso::params![job_id],
)
.await
.unwrap();
assert!(
pending.is_empty(),
"rolled-back completion leaves no pending row"
);
let jobs = conn
.query(
"SELECT id FROM jobs WHERE id = ?1",
crate::turso::params![job_id],
)
.await
.unwrap();
assert_eq!(
jobs.len(),
1,
"job row survives the rollback — the sweep deletes it"
);
assert!(
envelope.pending_job_id.is_some(),
"the caller gate (is_cancelled) decides routing"
);
}
}