Skip to main content

agent_exec/
delete.rs

1//! Implementation of the `delete` sub-command.
2//!
3//! Supports two modes:
4//!   - `delete <JOB_ID>`: remove one explicit job directory (non-running only).
5//!   - `delete --all [--dry-run]`: remove all terminal jobs whose persisted
6//!     `meta.json.cwd` matches the caller's current working directory.
7//!
8//! `--dry-run` may be combined with either mode to report actions without
9//! removing any directories.
10
11use anyhow::{Result, anyhow};
12use tracing::debug;
13
14use crate::jobstore::{InvalidJobState, JobDir, resolve_root};
15use crate::run::resolve_effective_cwd;
16use crate::schema::{DeleteData, DeleteJobResult, JobStatus, Response};
17
18#[cfg(unix)]
19fn pid_is_alive(pid: u32) -> bool {
20    let ret = unsafe { libc::kill(pid as libc::pid_t, 0) };
21    if ret == 0 {
22        return true;
23    }
24
25    let err = std::io::Error::last_os_error();
26    matches!(err.raw_os_error(), Some(libc::EPERM))
27}
28
29#[cfg(windows)]
30fn pid_is_alive(pid: u32) -> bool {
31    use windows::Win32::Foundation::{CloseHandle, STILL_ACTIVE};
32    use windows::Win32::System::Threading::{
33        GetExitCodeProcess, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION,
34    };
35
36    let handle = match unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid) } {
37        Ok(handle) => handle,
38        Err(_) => return false,
39    };
40
41    let mut exit_code = 0u32;
42    let ok = unsafe { GetExitCodeProcess(handle, &mut exit_code) }.is_ok();
43    unsafe {
44        let _ = CloseHandle(handle);
45    }
46    ok && exit_code == STILL_ACTIVE.0
47}
48
49#[cfg(not(any(unix, windows)))]
50fn pid_is_alive(_pid: u32) -> bool {
51    false
52}
53
54/// Options for the `delete` sub-command.
55#[derive(Debug)]
56pub struct DeleteOpts<'a> {
57    pub root: Option<&'a str>,
58    /// When `Some`, delete a single job by ID.  Mutually exclusive with `all`.
59    pub job_id: Option<&'a str>,
60    /// When true, delete all terminal jobs scoped to the caller's cwd.
61    pub all: bool,
62    /// When true, report candidates without removing any directories.
63    pub dry_run: bool,
64}
65
66/// Execute `delete`: dispatch to single-job or bulk mode.
67pub fn execute(opts: DeleteOpts) -> Result<()> {
68    let root = resolve_root(opts.root);
69    let root_str = root.display().to_string();
70
71    if let Some(job_id) = opts.job_id {
72        delete_single(&root, &root_str, job_id, opts.dry_run)
73    } else {
74        delete_all(&root, &root_str, opts.dry_run)
75    }
76}
77
78/// Delete a single explicit job by ID or unambiguous prefix.
79///
80/// Rejects running jobs with `InvalidJobState`.  Returns `JobNotFound` when
81/// the directory does not exist, and `AmbiguousJobId` when the prefix matches
82/// multiple jobs.
83fn delete_single(
84    root: &std::path::Path,
85    root_str: &str,
86    job_id: &str,
87    dry_run: bool,
88) -> Result<()> {
89    // Use JobDir::open for prefix-based resolution (exact match fast path included).
90    // Returns AmbiguousJobId if the prefix matches multiple jobs.
91    let job_dir = JobDir::open(root, job_id)?;
92    let job_path = job_dir.path;
93    // Use the resolved canonical ID in all output (never the user-supplied prefix).
94    let resolved_id = job_dir.job_id;
95
96    // Read state to determine whether the job is running.
97    let state_path = job_path.join("state.json");
98    let state_opt: Option<crate::schema::JobState> = std::fs::read(&state_path)
99        .ok()
100        .and_then(|b| serde_json::from_slice(&b).ok());
101
102    let state_str = match &state_opt {
103        Some(s) => s.status().as_str().to_string(),
104        None => "unknown".to_string(),
105    };
106
107    // Reject running jobs.
108    if state_opt
109        .as_ref()
110        .map(|s| *s.status() == JobStatus::Running)
111        .unwrap_or(false)
112    {
113        return Err(anyhow::Error::new(InvalidJobState(format!(
114            "cannot delete job {job_id}: job is currently running"
115        ))));
116    }
117
118    let (action, reason, failed_count) = if dry_run {
119        debug!(job_id, "delete: dry-run would delete job");
120        ("would_delete", "explicit_delete".to_string(), 0u64)
121    } else {
122        std::fs::remove_dir_all(&job_path).map_err(|e| {
123            anyhow!(
124                "failed to delete job directory {}: {}",
125                job_path.display(),
126                e
127            )
128        })?;
129        // Post-delete existence check: a successful remove_dir_all must leave
130        // the path absent at command completion. If anything (concurrent
131        // process, cache, race) re-materialised the directory we MUST NOT
132        // claim a successful deletion.
133        if job_path.exists() {
134            debug!(
135                job_id,
136                "delete: post-delete check found path still present; reporting failure"
137            );
138            return Err(anyhow!(
139                "delete reported success but job directory still exists: {}",
140                job_path.display()
141            ));
142        }
143        debug!(job_id, "delete: deleted job");
144        ("deleted", "explicit_delete".to_string(), 0u64)
145    };
146
147    Response::new(
148        "delete",
149        DeleteData {
150            root: root_str.to_string(),
151            dry_run,
152            cwd_scope: None,
153            deleted: if action == "deleted" { 1 } else { 0 },
154            skipped: 0,
155            out_of_scope: 0,
156            failed: failed_count,
157            jobs: vec![DeleteJobResult {
158                job_id: resolved_id,
159                state: state_str,
160                action: action.to_string(),
161                reason,
162            }],
163        },
164    )
165    .print();
166
167    Ok(())
168}
169
170/// Delete all terminal jobs whose persisted `meta.json.cwd` matches the
171/// caller's current working directory.  Running and created jobs are skipped.
172fn delete_all(root: &std::path::Path, root_str: &str, dry_run: bool) -> Result<()> {
173    let current_cwd = resolve_effective_cwd(None);
174
175    debug!(
176        root = %root_str,
177        cwd = %current_cwd,
178        dry_run,
179        "delete --all: starting"
180    );
181
182    // If root does not exist there is nothing to do.
183    if !root.exists() {
184        debug!(root = %root_str, "delete --all: root does not exist; nothing to delete");
185        Response::new(
186            "delete",
187            DeleteData {
188                root: root_str.to_string(),
189                dry_run,
190                cwd_scope: Some(current_cwd.clone()),
191                deleted: 0,
192                skipped: 0,
193                out_of_scope: 0,
194                failed: 0,
195                jobs: vec![],
196            },
197        )
198        .print();
199        return Ok(());
200    }
201
202    let read_dir = std::fs::read_dir(root)
203        .map_err(|e| anyhow!("failed to read root directory {}: {}", root_str, e))?;
204
205    let mut job_results: Vec<DeleteJobResult> = Vec::new();
206    let mut deleted_count: u64 = 0;
207    let mut skipped_count: u64 = 0;
208    let mut out_of_scope_count: u64 = 0;
209    let mut failed_count: u64 = 0;
210
211    for entry in read_dir {
212        let entry = match entry {
213            Ok(e) => e,
214            Err(e) => {
215                debug!(error = %e, "delete --all: failed to read directory entry; skipping");
216                skipped_count += 1;
217                continue;
218            }
219        };
220
221        let path = entry.path();
222        if !path.is_dir() {
223            continue;
224        }
225
226        let job_id = match path.file_name().and_then(|n| n.to_str()) {
227            Some(n) => n.to_string(),
228            None => {
229                debug!(path = %path.display(), "delete --all: cannot get dir name; skipping");
230                skipped_count += 1;
231                continue;
232            }
233        };
234
235        // Read meta.json to check cwd.
236        let meta_path = path.join("meta.json");
237        let meta: Option<crate::schema::JobMeta> = std::fs::read(&meta_path)
238            .ok()
239            .and_then(|b| serde_json::from_slice(&b).ok());
240
241        // Filter by cwd: only jobs whose persisted cwd matches the caller's cwd.
242        match meta.as_ref().and_then(|m| m.cwd.as_deref()) {
243            Some(job_cwd) if job_cwd == current_cwd => {
244                // cwd matches; proceed to state check
245            }
246            _ => {
247                debug!(
248                    job_id = %job_id,
249                    job_cwd = ?meta.as_ref().and_then(|m| m.cwd.as_deref()),
250                    current_cwd = %current_cwd,
251                    "delete --all: skipping job (cwd mismatch or absent)"
252                );
253                // Out-of-scope by cwd: counted via out_of_scope aggregate so the
254                // operator can tell "filtered before evaluation" apart from
255                // "evaluated but not deleted". Not added to per-job results to
256                // keep that array bounded for the in-scope set.
257                out_of_scope_count += 1;
258                continue;
259            }
260        }
261
262        // Read state.json to determine eligibility.
263        let state_path = path.join("state.json");
264        let state_opt: Option<crate::schema::JobState> = std::fs::read(&state_path)
265            .ok()
266            .and_then(|b| serde_json::from_slice(&b).ok());
267
268        let (state_str, status) = match &state_opt {
269            Some(s) => (s.status().as_str().to_string(), Some(s.status().clone())),
270            None => {
271                debug!(job_id = %job_id, "delete --all: state.json missing or unreadable; skipping");
272                skipped_count += 1;
273                out_of_scope_count += 1;
274                job_results.push(DeleteJobResult {
275                    job_id,
276                    state: "unknown".to_string(),
277                    action: "skipped".to_string(),
278                    reason: "state_unreadable".to_string(),
279                });
280                continue;
281            }
282        };
283
284        // Only terminal states are eligible for bulk deletion; skip created and running.
285        let is_terminal = matches!(
286            status.as_ref(),
287            Some(JobStatus::Exited) | Some(JobStatus::Killed) | Some(JobStatus::Failed)
288        );
289
290        if !is_terminal {
291            let reason = match status.as_ref() {
292                Some(JobStatus::Running) => "running",
293                Some(JobStatus::Created) => "created",
294                _ => "non_terminal",
295            };
296            debug!(job_id = %job_id, state = %state_str, "delete --all: non-terminal job; skipping");
297            skipped_count += 1;
298            out_of_scope_count += 1;
299            job_results.push(DeleteJobResult {
300                job_id,
301                state: state_str,
302                action: "skipped".to_string(),
303                reason: reason.to_string(),
304            });
305            continue;
306        }
307
308        if state_opt
309            .as_ref()
310            .and_then(|s| s.pid)
311            .is_some_and(pid_is_alive)
312        {
313            debug!(job_id = %job_id, state = %state_str, "delete --all: live pid for terminal state; skipping");
314            skipped_count += 1;
315            out_of_scope_count += 1;
316            job_results.push(DeleteJobResult {
317                job_id,
318                state: state_str,
319                action: "skipped".to_string(),
320                reason: "pid_alive".to_string(),
321            });
322            continue;
323        }
324
325        // Eligible terminal job: delete or dry-run.
326        let action = if dry_run {
327            debug!(job_id = %job_id, "delete --all: dry-run would delete");
328            "would_delete"
329        } else {
330            match std::fs::remove_dir_all(&path) {
331                Ok(()) => {
332                    // Post-delete existence check: if the path still exists
333                    // (concurrent re-creation, broken filesystem, root
334                    // misconfiguration) we MUST NOT report `deleted`.
335                    if path.exists() {
336                        debug!(
337                            job_id = %job_id,
338                            "delete --all: post-delete check found path still present; reporting failure"
339                        );
340                        skipped_count += 1;
341                        failed_count += 1;
342                        job_results.push(DeleteJobResult {
343                            job_id,
344                            state: state_str,
345                            action: "skipped".to_string(),
346                            reason: "post_delete_check_failed".to_string(),
347                        });
348                        continue;
349                    }
350                    debug!(job_id = %job_id, "delete --all: deleted");
351                    deleted_count += 1;
352                    "deleted"
353                }
354                Err(e) => {
355                    debug!(job_id = %job_id, error = %e, "delete --all: failed to delete; skipping");
356                    skipped_count += 1;
357                    failed_count += 1;
358                    job_results.push(DeleteJobResult {
359                        job_id,
360                        state: state_str,
361                        action: "skipped".to_string(),
362                        reason: format!("delete_failed: {e}"),
363                    });
364                    continue;
365                }
366            }
367        };
368
369        job_results.push(DeleteJobResult {
370            job_id,
371            state: state_str,
372            action: action.to_string(),
373            reason: "terminal_in_cwd".to_string(),
374        });
375    }
376
377    debug!(
378        deleted = deleted_count,
379        skipped = skipped_count,
380        out_of_scope = out_of_scope_count,
381        failed = failed_count,
382        "delete --all: complete"
383    );
384
385    Response::new(
386        "delete",
387        DeleteData {
388            root: root_str.to_string(),
389            dry_run,
390            cwd_scope: Some(current_cwd),
391            deleted: deleted_count,
392            skipped: skipped_count,
393            out_of_scope: out_of_scope_count,
394            failed: failed_count,
395            jobs: job_results,
396        },
397    )
398    .print();
399
400    Ok(())
401}