use crate::Workspace;
use crate::agent::registry::ParentKey;
use crate::research_cleanup::COMMAND_DUMP_FILE;
pub(crate) async fn cancel_research_run(job_id: &str) {
crate::round_cancel::cancel(job_id);
crate::agent::registry::AGENT_REGISTRY
.cancel_by_parent_key(&ParentKey::Research(job_id.to_string()));
crate::agent::registry::NON_AGENT_CALLS
.remove_by_parent_key(&ParentKey::Research(job_id.to_string()));
if !crate::round_cancel::is_registered(job_id)
&& let Err(e) = sweep_cancelled_run(job_id).await
{
tracing::warn!(job = %job_id, error = %e, "cancel sweep failed — durable rows left for boot resume");
}
}
pub(crate) async fn sweep_cancelled_run(job_id: &str) -> Result<(), String> {
let conn = &crate::session::store().conn;
crate::jobs::delete_round_rows(conn, job_id)
.await
.map_err(|e| format!("{e:#}"))?;
if crate::research_cleanup::command_dump_exists(job_id).await {
tracing::warn!(
job = %job_id,
"run folder has a command dump — cleanup intent present, folder NOT released (left for the cleanup tail / periodic temp cleaner)"
);
} else {
crate::research_cleanup::release_run_folder(job_id).await;
}
delete_results_archive(job_id).await;
Ok(())
}
pub(crate) async fn hand_off_cancelled_run(
job_id: &str,
ws: &Workspace,
question: &str,
) -> Result<(), String> {
if let Some(prompt) = hand_off_rows(job_id, ws).await? {
crate::research_cleanup::spawn_cleanup_agent(job_id, question, ws, &prompt);
}
Ok(())
}
async fn hand_off_rows(job_id: &str, ws: &Workspace) -> Result<Option<String>, String> {
let conn = &crate::session::store().conn;
if crate::research_cleanup::research_cleanup_row_exists(conn, job_id)
.await
.map_err(|e| format!("{e:#}"))?
{
if let Err(e) = conn
.execute(
"DELETE FROM pending_jobs WHERE id = ?1",
crate::db::params![job_id],
)
.await
{
tracing::warn!(job = %job_id, error = %e, "pending_jobs removal failed during cancel handoff");
}
delete_results_archive(job_id).await;
return Ok(None);
}
if !crate::research_cleanup::command_dump_exists(job_id).await {
sweep_cancelled_run(job_id).await?;
return Ok(None);
}
let run_root = crate::research_cleanup::canonical_run_root(job_id)
.await
.map_err(|e| format!("{e:#}"))?;
let dump_path = run_root.join(COMMAND_DUMP_FILE);
let prompt = crate::research_cleanup::build_cleanup_prompt(job_id, &run_root, &dump_path, ws);
crate::jobs::transition_research_to_cleanup(conn, job_id, &prompt, &ws.name)
.await
.map_err(|e| format!("{e:#}"))?;
delete_results_archive(job_id).await;
Ok(Some(prompt))
}
async fn delete_results_archive(job_id: &str) {
let Some(path) = crate::research_cleanup::results_archive_path(job_id) else {
return;
};
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::jobs::SpawnChild;
use crate::util::test::JobRowBuilder;
use std::path::PathBuf;
enum Folder {
Empty,
Scratch,
Dump,
}
enum Report {
None,
Persisted,
}
struct Seeded {
id: &'static str,
root: PathBuf,
archive: PathBuf,
}
impl Seeded {
async fn research(id: &'static str, folder: Folder, report: Report) -> Self {
crate::util::test::init_management_test_stores().await;
crate::jobs::spawn_job(
&crate::session::store().conn,
id,
"question?",
"ws",
"u",
"telegram",
crate::Role::Assistant,
&[],
&SpawnChild::Research,
None,
None,
None,
)
.await
.unwrap();
Self::seed(id, folder, report).await
}
async fn research_without_child(id: &'static str, folder: Folder, report: Report) -> Self {
crate::util::test::init_management_test_stores().await;
JobRowBuilder::new(
&crate::session::store().conn,
id,
"research",
"assistant",
"ws",
)
.task("question?")
.user_name("u")
.channel("telegram")
.timestamps(crate::db::now())
.insert()
.await
.unwrap();
Self::seed(id, folder, report).await
}
async fn terminalized(id: &'static str, folder: Folder, report: Report) -> Self {
crate::util::test::init_management_test_stores().await;
crate::jobs::spawn_job(
&crate::session::store().conn,
id,
"cleanup prompt",
"ws",
"",
"",
crate::Role::Sanitation,
&[],
&SpawnChild::ResearchCleanup,
None,
None,
None,
)
.await
.unwrap();
Self::seed(id, folder, report).await
}
async fn seed(id: &'static str, folder: Folder, report: Report) -> Self {
let root = crate::research_cleanup::ensure_run_root(id).await;
match folder {
Folder::Empty => {}
Folder::Scratch => {
tokio::fs::write(root.join("scratch.txt"), "scratch")
.await
.unwrap();
}
Folder::Dump => {
tokio::fs::write(root.join(COMMAND_DUMP_FILE), "shell cmd")
.await
.unwrap();
}
}
let archive = crate::research_cleanup::results_archive_path(id).unwrap();
if matches!(report, Report::Persisted) {
crate::agent::message_router::persist_pending(
&crate::agent::message_router::AgentJob {
content: "report".to_string(),
workspace_name: "ws".to_string(),
user_name: "u".to_string(),
channel: "telegram".to_string(),
kind: crate::agent::message_router::MessageKind::ResearchResult,
role: crate::Role::Assistant,
reply_target: None,
pending_job_id: None,
originating_workspace: None,
custom_role: None,
reply_to_custom: None,
},
id.to_string(),
)
.await
.unwrap();
tokio::fs::create_dir_all(archive.parent().unwrap())
.await
.unwrap();
tokio::fs::write(&archive, "archive").await.unwrap();
}
Self { id, root, archive }
}
async fn rows(&self, table: &str) -> usize {
crate::session::store()
.conn
.query(
&format!("SELECT id FROM {table} WHERE id = ?1"),
crate::db::params![self.id],
)
.await
.unwrap()
.len()
}
}
#[tokio::test]
async fn sweep_removes_rows_folder_and_archive_for_no_dump_run() {
let run = Seeded::research(
"research_cancel_midrun_1",
Folder::Scratch,
Report::Persisted,
)
.await;
sweep_cancelled_run(run.id).await.unwrap();
assert_eq!(run.rows("jobs").await, 0, "jobs row removed");
assert_eq!(
run.rows("research_jobs").await,
0,
"research_jobs child row removed by cascade"
);
assert_eq!(
run.rows("pending_jobs").await,
0,
"pending row removed — no boot replay"
);
assert!(!run.root.exists(), "run folder removed");
assert!(!run.archive.exists(), "results.md archive removed");
}
#[tokio::test]
async fn sweep_removes_pending_and_cleanup_rows_for_terminalized_run() {
let run = Seeded::terminalized(
"research_cancel_terminal_1",
Folder::Empty,
Report::Persisted,
)
.await;
sweep_cancelled_run(run.id).await.unwrap();
assert_eq!(run.rows("jobs").await, 0, "cleanup job row removed");
assert_eq!(
run.rows("pending_jobs").await,
0,
"pending report row removed"
);
assert!(!run.root.exists());
assert!(!run.archive.exists());
}
#[tokio::test]
async fn sweep_never_releases_folder_with_command_dump() {
let run = Seeded::research_without_child(
"research_cancel_dump_guard_1",
Folder::Dump,
Report::Persisted,
)
.await;
sweep_cancelled_run(run.id).await.unwrap();
assert_eq!(run.rows("jobs").await, 0, "jobs row removed");
assert_eq!(run.rows("pending_jobs").await, 0, "pending row removed");
assert!(
run.root.exists(),
"run folder held — cleanup intent present"
);
assert!(
run.root.join(COMMAND_DUMP_FILE).exists(),
"command dump survives"
);
assert!(!run.archive.exists(), "results.md archive removed");
}
#[tokio::test]
async fn cancel_handoff_transitions_rows_and_holds_folder() {
let run = Seeded::research(
"research_handoff_transition_1",
Folder::Dump,
Report::Persisted,
)
.await;
let conn = &crate::session::store().conn;
let ws = crate::workspace::test_ws("/tmp/test_ws_handoff_transition");
let prompt = hand_off_rows(run.id, &ws).await.unwrap();
let prompt = prompt.expect("handoff transitions to cleanup and returns the prompt");
let row = conn
.query(
"SELECT kind, role, task FROM jobs WHERE id = ?1",
crate::db::params![run.id],
)
.await
.unwrap();
assert_eq!(row.len(), 1, "single jobs row after transition");
assert_eq!(
row[0].get::<String>(0).unwrap(),
"research_cleanup",
"kind transitioned to research_cleanup"
);
assert_eq!(
row[0].get::<String>(1).unwrap(),
"sanitation",
"role is sanitation"
);
assert_eq!(
row[0].get::<String>(2).unwrap(),
prompt,
"task is the returned cleanup prompt"
);
let roster = conn
.query(
"SELECT agent_id FROM agents WHERE job_id = ?1",
crate::db::params![run.id],
)
.await
.unwrap();
assert_eq!(roster.len(), 1, "single cleanup roster row");
assert_eq!(
roster[0].get::<String>(0).unwrap(),
format!("cleanup_{}", run.id),
"cleanup agent roster row present"
);
assert_eq!(
run.rows("pending_jobs").await,
0,
"pending envelope dropped"
);
assert_eq!(
run.rows("research_jobs").await,
0,
"research_jobs child row removed by cascade"
);
assert!(run.root.exists(), "run folder held for the cleanup tail");
assert!(
run.root.join(COMMAND_DUMP_FILE).exists(),
"command dump survives — cleanup intent preserved"
);
assert!(!run.archive.exists(), "results.md archive removed");
}
#[tokio::test]
async fn cancel_handoff_without_dump_sweeps() {
let run =
Seeded::research("research_handoff_no_dump_1", Folder::Scratch, Report::None).await;
let ws = crate::workspace::test_ws("/tmp/test_ws_handoff_no_dump");
assert!(
hand_off_rows(run.id, &ws).await.unwrap().is_none(),
"no dump → no cleanup prompt"
);
assert_eq!(run.rows("jobs").await, 0, "research row swept");
assert!(!run.root.exists(), "dump-less folder released");
}
#[tokio::test]
async fn cancel_research_run_sweeps_unregistered_run() {
let run = Seeded::research(
"research_cancel_unregistered_1",
Folder::Scratch,
Report::None,
)
.await;
cancel_research_run(run.id).await;
assert_eq!(
run.rows("jobs").await,
0,
"research row swept via the unregistered path"
);
assert!(!run.root.exists(), "dump-less folder released");
}
#[tokio::test]
async fn sweep_is_idempotent_and_double_release_safe() {
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]
async fn complete_durable_job_rolls_back_when_run_cancelled() {
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,
None,
None,
None,
)
.await
.unwrap();
let _guard = crate::round_cancel::register(job_id, None);
crate::round_cancel::cancel(job_id);
let envelope = crate::jobs::complete_durable_job(
job_id,
"report".to_string(),
crate::agent::message_router::MessageKind::ResearchResult,
crate::Role::Assistant,
"caller-user",
"telegram",
&ws.name,
None,
)
.await;
let conn = &crate::session::store().conn;
let pending = conn
.query(
"SELECT id FROM pending_jobs WHERE id = ?1",
crate::db::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::db::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"
);
}
}