🐛 fix: clean cancelled jobs immediately

Cancelled jobs are now cleaned without requiring `--all` flag
or waiting for the 24-hour age threshold. Since the user explicitly
cancelled these jobs, they should be cleaned immediately.
This commit is contained in:
Andreas Bigger
2025-12-29 17:30:35 -05:00
parent bafc68255c
commit 219b2a0e1d
+83 -8
View File
@@ -1,6 +1,7 @@
//! Job management commands (pause, resume, kill, clean).
use anyhow::{Context, Result};
use inquire::Select;
use paracas_daemon::{DaemonSpawner, DownloadJob, JobStatus, StateManager};
/// Pause a running job by sending SIGSTOP to its process.
@@ -166,9 +167,11 @@ pub(crate) fn clean_jobs(state: &StateManager, all: bool) -> Result<()> {
let should_clean = if all {
job.is_finished()
} else {
// By default, only clean jobs older than 24 hours that are finished
// Cancelled jobs are always cleaned (user explicitly cancelled them)
// Completed/Failed jobs are only cleaned if older than 24 hours
let is_cancelled = job.status == JobStatus::Cancelled;
let is_old = job.created_at < chrono::Utc::now() - chrono::Duration::hours(24);
is_old && job.is_finished()
is_cancelled || (is_old && job.is_finished())
};
if should_clean {
@@ -186,6 +189,65 @@ pub(crate) fn clean_jobs(state: &StateManager, all: bool) -> Result<()> {
Ok(())
}
/// Prompt user to select a job from available jobs filtered by the given statuses.
fn prompt_job_selection(
state: &StateManager,
action_name: &str,
filter_statuses: &[JobStatus],
) -> Result<String> {
let jobs = state.list_jobs()?;
let filtered: Vec<_> = jobs
.into_iter()
.filter(|job| filter_statuses.contains(&job.status))
.collect();
if filtered.is_empty() {
let status_names: Vec<_> = filter_statuses.iter().map(|s| format!("{s:?}")).collect();
anyhow::bail!(
"No jobs with status {} found to {}.",
status_names.join(" or "),
action_name
);
}
let options: Vec<String> = filtered
.iter()
.map(|job| {
let instruments: Vec<_> = job.tasks.iter().map(|t| t.instrument_id.as_str()).collect();
let instruments_display = if instruments.len() > 3 {
format!(
"{}, ... (+{} more)",
instruments[..3].join(", "),
instruments.len() - 3
)
} else {
instruments.join(", ")
};
format!(
"{} | {:?} | {:.1}% | {}",
job.id,
job.status,
job.progress_percent(),
instruments_display
)
})
.collect();
let selection = Select::new(&format!("Select a job to {action_name}:"), options)
.prompt()
.context("Job selection cancelled")?;
// Extract the job ID from the selection (first part before " | ")
let job_id = selection
.split(" | ")
.next()
.context("Failed to parse job selection")?
.to_string();
Ok(job_id)
}
/// Execute the job management command.
pub(crate) fn job_command(action: &str, job_id: Option<&str>, all: bool) -> Result<()> {
let state_manager =
@@ -193,16 +255,29 @@ pub(crate) fn job_command(action: &str, job_id: Option<&str>, all: bool) -> Resu
match action {
"pause" => {
let id = job_id.context("Job ID required for pause")?;
pause_job(&state_manager, id)
let id = match job_id {
Some(id) => id.to_string(),
None => prompt_job_selection(&state_manager, "pause", &[JobStatus::Running])?,
};
pause_job(&state_manager, &id)
}
"resume" => {
let id = job_id.context("Job ID required for resume")?;
resume_job(&state_manager, id)
let id = match job_id {
Some(id) => id.to_string(),
None => prompt_job_selection(&state_manager, "resume", &[JobStatus::Paused])?,
};
resume_job(&state_manager, &id)
}
"kill" => {
let id = job_id.context("Job ID required for kill")?;
kill_job(&state_manager, id)
let id = match job_id {
Some(id) => id.to_string(),
None => prompt_job_selection(
&state_manager,
"kill",
&[JobStatus::Running, JobStatus::Pending, JobStatus::Paused],
)?,
};
kill_job(&state_manager, &id)
}
"clean" => clean_jobs(&state_manager, all),
_ => anyhow::bail!("Unknown action: {}", action),