Skip to main content

agent_exec/
gc.rs

1//! Implementation of the `gc` sub-command and shared auto-GC engine.
2
3use anyhow::{Result, anyhow};
4use serde::{Deserialize, Serialize};
5use std::path::{Path, PathBuf};
6use tracing::{debug, info, warn};
7
8use crate::jobstore::resolve_root;
9use crate::schema::{GcData, GcJobResult, JobState, JobStatus, Response};
10
11const DEFAULT_OLDER_THAN: &str = "30d";
12const DEFAULT_AUTO_SCAN_LIMIT: usize = 200;
13const DEFAULT_AUTO_DELETE_LIMIT: usize = 20;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum GcMode {
17    Manual,
18    Automatic,
19}
20
21#[derive(Debug, Clone)]
22pub struct GcPolicy {
23    pub older_than: String,
24    pub max_jobs: Option<usize>,
25    pub max_bytes: Option<u64>,
26    pub dry_run: bool,
27    pub mode: GcMode,
28    pub scan_limit: Option<usize>,
29    pub delete_limit: Option<usize>,
30}
31
32#[derive(Debug)]
33pub struct GcOpts<'a> {
34    pub root: Option<&'a str>,
35    pub older_than: Option<&'a str>,
36    pub max_jobs: Option<u64>,
37    pub max_bytes: Option<u64>,
38    pub dry_run: bool,
39}
40
41#[derive(Debug, Clone)]
42struct Candidate {
43    job_id: String,
44    path: PathBuf,
45    status: JobStatus,
46    gc_ts: String,
47    bytes: u64,
48    reasons: Vec<&'static str>,
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct AutoGcConfig {
53    pub enabled: bool,
54    pub older_than: String,
55    pub max_jobs: Option<usize>,
56    pub max_bytes: Option<u64>,
57    pub scan_limit: usize,
58    pub delete_limit: usize,
59}
60
61impl Default for AutoGcConfig {
62    fn default() -> Self {
63        Self {
64            enabled: true,
65            older_than: DEFAULT_OLDER_THAN.to_string(),
66            max_jobs: None,
67            max_bytes: None,
68            scan_limit: DEFAULT_AUTO_SCAN_LIMIT,
69            delete_limit: DEFAULT_AUTO_DELETE_LIMIT,
70        }
71    }
72}
73
74pub fn execute(opts: GcOpts) -> Result<()> {
75    let root = resolve_root(opts.root);
76    let root_str = root.display().to_string();
77
78    let (older_than_str, older_than_source) = match opts.older_than {
79        Some(s) => (s.to_string(), "flag".to_string()),
80        None => (DEFAULT_OLDER_THAN.to_string(), "default".to_string()),
81    };
82
83    let max_jobs = opts
84        .max_jobs
85        .map(|v| usize::try_from(v).map_err(|_| anyhow!("invalid --max-jobs: {v}")))
86        .transpose()?;
87
88    let policy = GcPolicy {
89        older_than: older_than_str.clone(),
90        max_jobs,
91        max_bytes: opts.max_bytes,
92        dry_run: opts.dry_run,
93        mode: GcMode::Manual,
94        scan_limit: None,
95        delete_limit: None,
96    };
97
98    let outcome = run_gc(&root, &policy)?;
99
100    Response::new(
101        "gc",
102        GcData {
103            root: root_str,
104            dry_run: opts.dry_run,
105            older_than: older_than_str,
106            older_than_source,
107            deleted: outcome.deleted,
108            skipped: outcome.skipped,
109            out_of_scope: outcome.out_of_scope,
110            failed: outcome.failed,
111            freed_bytes: outcome.freed_bytes,
112            scanned_dirs: outcome.scanned_dirs,
113            candidate_count: outcome.candidate_count,
114            jobs: outcome.jobs,
115        },
116    )
117    .print();
118
119    Ok(())
120}
121
122pub fn maybe_run_auto_gc(root: &Path, cfg: &AutoGcConfig) {
123    if !cfg.enabled {
124        debug!("auto-gc disabled");
125        return;
126    }
127
128    let policy = GcPolicy {
129        older_than: cfg.older_than.clone(),
130        max_jobs: cfg.max_jobs,
131        max_bytes: cfg.max_bytes,
132        dry_run: false,
133        mode: GcMode::Automatic,
134        scan_limit: Some(cfg.scan_limit),
135        delete_limit: Some(cfg.delete_limit),
136    };
137
138    if let Err(e) = run_gc_with_lock(root, &policy) {
139        warn!(error = %e, "auto-gc failed (best-effort)");
140    }
141}
142
143#[derive(Debug)]
144struct GcOutcome {
145    deleted: u64,
146    skipped: u64,
147    out_of_scope: u64,
148    failed: u64,
149    freed_bytes: u64,
150    scanned_dirs: u64,
151    candidate_count: u64,
152    jobs: Vec<GcJobResult>,
153}
154
155fn run_gc_with_lock(root: &Path, policy: &GcPolicy) -> Result<GcOutcome> {
156    if policy.mode == GcMode::Manual {
157        return run_gc(root, policy);
158    }
159
160    let lock_path = root.join(".gc.lock");
161    let lock = std::fs::OpenOptions::new()
162        .write(true)
163        .create_new(true)
164        .open(&lock_path);
165
166    let lock_file = match lock {
167        Ok(f) => f,
168        Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
169            debug!(path = %lock_path.display(), "auto-gc lock already held; skipping");
170            return Ok(empty_outcome());
171        }
172        Err(e) => return Err(anyhow!("create auto-gc lock {}: {e}", lock_path.display())),
173    };
174
175    let result = run_gc(root, policy);
176    drop(lock_file);
177    let _ = std::fs::remove_file(&lock_path);
178    result
179}
180
181fn empty_outcome() -> GcOutcome {
182    GcOutcome {
183        deleted: 0,
184        skipped: 0,
185        out_of_scope: 0,
186        failed: 0,
187        freed_bytes: 0,
188        scanned_dirs: 0,
189        candidate_count: 0,
190        jobs: vec![],
191    }
192}
193
194fn run_gc(root: &Path, policy: &GcPolicy) -> Result<GcOutcome> {
195    if !root.exists() {
196        return Ok(empty_outcome());
197    }
198
199    let retention_secs = parse_duration(&policy.older_than).ok_or_else(|| {
200        anyhow!(
201            "invalid duration: {}; expected formats: 30d, 24h, 60m, 3600s",
202            policy.older_than
203        )
204    })?;
205
206    let now_secs = std::time::SystemTime::now()
207        .duration_since(std::time::UNIX_EPOCH)
208        .unwrap_or_default()
209        .as_secs();
210    let cutoff_secs = now_secs.saturating_sub(retention_secs);
211    let cutoff = format_rfc3339(cutoff_secs);
212
213    let mut scanned_dirs = 0u64;
214    let mut out_of_scope = 0u64;
215    let mut skipped = 0u64;
216    let mut failed = 0u64;
217
218    let mut results = Vec::new();
219    let mut candidates = Vec::<Candidate>::new();
220
221    let read_dir = std::fs::read_dir(root)
222        .map_err(|e| anyhow!("failed to read root directory {}: {}", root.display(), e))?;
223
224    for entry in read_dir {
225        let entry = match entry {
226            Ok(v) => v,
227            Err(e) => {
228                skipped += 1;
229                failed += 1;
230                warn!(error = %e, "gc: failed to read directory entry");
231                continue;
232            }
233        };
234
235        let path = entry.path();
236        if !path.is_dir() {
237            continue;
238        }
239
240        scanned_dirs += 1;
241        if let Some(limit) = policy.scan_limit
242            && scanned_dirs as usize > limit
243        {
244            break;
245        }
246
247        let job_id = match path.file_name().and_then(|n| n.to_str()) {
248            Some(s) => s.to_string(),
249            None => {
250                skipped += 1;
251                out_of_scope += 1;
252                continue;
253            }
254        };
255
256        let state_path = path.join("state.json");
257        let state = match std::fs::read(&state_path)
258            .ok()
259            .and_then(|b| serde_json::from_slice::<JobState>(&b).ok())
260        {
261            Some(s) => s,
262            None => {
263                skipped += 1;
264                out_of_scope += 1;
265                results.push(GcJobResult {
266                    job_id,
267                    state: "unknown".to_string(),
268                    action: "skipped".to_string(),
269                    reason: "state_unreadable".to_string(),
270                    bytes: 0,
271                });
272                continue;
273            }
274        };
275
276        let status = state.status().clone();
277        if matches!(status, JobStatus::Running | JobStatus::Created) {
278            skipped += 1;
279            out_of_scope += 1;
280            results.push(GcJobResult {
281                job_id,
282                state: status.as_str().to_string(),
283                action: "skipped".to_string(),
284                reason: "active_job".to_string(),
285                bytes: 0,
286            });
287            continue;
288        }
289
290        if !matches!(
291            status,
292            JobStatus::Exited | JobStatus::Killed | JobStatus::Failed
293        ) {
294            skipped += 1;
295            out_of_scope += 1;
296            results.push(GcJobResult {
297                job_id,
298                state: status.as_str().to_string(),
299                action: "skipped".to_string(),
300                reason: "non_terminal_status".to_string(),
301                bytes: 0,
302            });
303            continue;
304        }
305
306        let gc_ts = state
307            .finished_at
308            .as_deref()
309            .or(Some(state.updated_at.as_str()))
310            .unwrap_or_default()
311            .to_string();
312
313        if gc_ts.is_empty() {
314            skipped += 1;
315            out_of_scope += 1;
316            results.push(GcJobResult {
317                job_id,
318                state: status.as_str().to_string(),
319                action: "skipped".to_string(),
320                reason: "no_timestamp".to_string(),
321                bytes: 0,
322            });
323            continue;
324        }
325
326        if !is_older_than(&gc_ts, &cutoff) {
327            skipped += 1;
328            out_of_scope += 1;
329            results.push(GcJobResult {
330                job_id,
331                state: status.as_str().to_string(),
332                action: "skipped".to_string(),
333                reason: "too_recent".to_string(),
334                bytes: 0,
335            });
336            continue;
337        }
338
339        let bytes = dir_size_bytes(&path);
340        candidates.push(Candidate {
341            job_id,
342            path,
343            status,
344            gc_ts,
345            bytes,
346            reasons: vec!["older_than"],
347        });
348    }
349
350    candidates.sort_by(|a, b| a.gc_ts.cmp(&b.gc_ts)); // oldest first
351
352    if let Some(max_jobs) = policy.max_jobs
353        && candidates.len() > max_jobs
354    {
355        // keep newest max_jobs, mark older ones for count pressure
356        let cut = candidates.len() - max_jobs;
357        for c in &mut candidates[..cut] {
358            c.reasons.push("max_jobs");
359        }
360        for c in &mut candidates[cut..] {
361            c.reasons.retain(|r| *r != "older_than");
362        }
363    }
364
365    if let Some(max_bytes) = policy.max_bytes {
366        let mut all_terminal_total = candidates.iter().map(|c| c.bytes).sum::<u64>();
367        if all_terminal_total > max_bytes {
368            for c in &mut candidates {
369                if all_terminal_total <= max_bytes {
370                    break;
371                }
372                if !c.reasons.contains(&"max_bytes") {
373                    c.reasons.push("max_bytes");
374                }
375                all_terminal_total = all_terminal_total.saturating_sub(c.bytes);
376            }
377        }
378    }
379
380    let mut selected = Vec::new();
381    for c in candidates {
382        if c.reasons.is_empty() {
383            skipped += 1;
384            out_of_scope += 1;
385            results.push(GcJobResult {
386                job_id: c.job_id,
387                state: c.status.as_str().to_string(),
388                action: "skipped".to_string(),
389                reason: "policy_not_matched".to_string(),
390                bytes: c.bytes,
391            });
392            continue;
393        }
394        selected.push(c);
395    }
396
397    let candidate_count = selected.len() as u64;
398    let mut deleted = 0u64;
399    let mut freed_bytes = 0u64;
400    let mut deletions = 0usize;
401
402    for c in selected {
403        if let Some(limit) = policy.delete_limit
404            && deletions >= limit
405        {
406            skipped += 1;
407            out_of_scope += 1;
408            results.push(GcJobResult {
409                job_id: c.job_id,
410                state: c.status.as_str().to_string(),
411                action: "skipped".to_string(),
412                reason: "delete_budget_exhausted".to_string(),
413                bytes: c.bytes,
414            });
415            continue;
416        }
417
418        let reason = c.reasons.join("+");
419
420        if policy.dry_run {
421            freed_bytes = freed_bytes.saturating_add(c.bytes);
422            results.push(GcJobResult {
423                job_id: c.job_id,
424                state: c.status.as_str().to_string(),
425                action: "would_delete".to_string(),
426                reason,
427                bytes: c.bytes,
428            });
429            continue;
430        }
431
432        match std::fs::remove_dir_all(&c.path) {
433            Ok(()) => {
434                if c.path.exists() {
435                    skipped += 1;
436                    failed += 1;
437                    results.push(GcJobResult {
438                        job_id: c.job_id,
439                        state: c.status.as_str().to_string(),
440                        action: "skipped".to_string(),
441                        reason: "post_delete_check_failed".to_string(),
442                        bytes: c.bytes,
443                    });
444                } else {
445                    deletions += 1;
446                    deleted += 1;
447                    freed_bytes = freed_bytes.saturating_add(c.bytes);
448                    results.push(GcJobResult {
449                        job_id: c.job_id,
450                        state: c.status.as_str().to_string(),
451                        action: "deleted".to_string(),
452                        reason,
453                        bytes: c.bytes,
454                    });
455                }
456            }
457            Err(e) => {
458                skipped += 1;
459                failed += 1;
460                results.push(GcJobResult {
461                    job_id: c.job_id,
462                    state: c.status.as_str().to_string(),
463                    action: "skipped".to_string(),
464                    reason: format!("delete_failed: {e}"),
465                    bytes: c.bytes,
466                });
467            }
468        }
469    }
470
471    info!(
472        mode = ?policy.mode,
473        deleted,
474        skipped,
475        out_of_scope,
476        failed,
477        freed_bytes,
478        scanned_dirs,
479        candidate_count,
480        "gc complete"
481    );
482
483    Ok(GcOutcome {
484        deleted,
485        skipped,
486        out_of_scope,
487        failed,
488        freed_bytes,
489        scanned_dirs,
490        candidate_count,
491        jobs: results,
492    })
493}
494
495pub fn parse_duration(s: &str) -> Option<u64> {
496    let s = s.trim();
497    if let Some(n) = s.strip_suffix('d') {
498        n.parse::<u64>().ok().map(|v| v * 86_400)
499    } else if let Some(n) = s.strip_suffix('h') {
500        n.parse::<u64>().ok().map(|v| v * 3_600)
501    } else if let Some(n) = s.strip_suffix('m') {
502        n.parse::<u64>().ok().map(|v| v * 60)
503    } else if let Some(n) = s.strip_suffix('s') {
504        n.parse::<u64>().ok()
505    } else {
506        s.parse::<u64>().ok()
507    }
508}
509
510fn is_older_than(ts: &str, cutoff: &str) -> bool {
511    let ts_prefix = &ts[..ts.len().min(19)];
512    let cutoff_prefix = &cutoff[..cutoff.len().min(19)];
513    ts_prefix < cutoff_prefix
514}
515
516pub fn dir_size_bytes(path: &Path) -> u64 {
517    let mut total = 0u64;
518    let Ok(entries) = std::fs::read_dir(path) else {
519        return 0;
520    };
521    for entry in entries.flatten() {
522        let p = entry.path();
523        if let Ok(meta) = p.metadata() {
524            if meta.is_file() {
525                total += meta.len();
526            } else if meta.is_dir() {
527                total += dir_size_bytes(&p);
528            }
529        }
530    }
531    total
532}
533
534fn format_rfc3339(secs: u64) -> String {
535    let mut s = secs;
536    let seconds = s % 60;
537    s /= 60;
538    let minutes = s % 60;
539    s /= 60;
540    let hours = s % 24;
541    s /= 24;
542
543    let mut days = s;
544    let mut year = 1970u64;
545    loop {
546        let days_in_year = if is_leap(year) { 366 } else { 365 };
547        if days < days_in_year {
548            break;
549        }
550        days -= days_in_year;
551        year += 1;
552    }
553
554    let leap = is_leap(year);
555    let month_days: [u64; 12] = [
556        31,
557        if leap { 29 } else { 28 },
558        31,
559        30,
560        31,
561        30,
562        31,
563        31,
564        30,
565        31,
566        30,
567        31,
568    ];
569    let mut month = 0usize;
570    for (i, &d) in month_days.iter().enumerate() {
571        if days < d {
572            month = i;
573            break;
574        }
575        days -= d;
576    }
577    let day = days + 1;
578
579    format!(
580        "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
581        year,
582        month + 1,
583        day,
584        hours,
585        minutes,
586        seconds
587    )
588}
589
590fn is_leap(year: u64) -> bool {
591    (year.is_multiple_of(4) && !year.is_multiple_of(100)) || year.is_multiple_of(400)
592}
593
594#[cfg(test)]
595mod tests {
596    use super::*;
597
598    #[test]
599    fn parse_duration_days() {
600        assert_eq!(parse_duration("30d"), Some(30 * 86_400));
601    }
602
603    #[test]
604    fn parse_duration_invalid() {
605        assert!(parse_duration("abc").is_none());
606    }
607
608    #[test]
609    fn older_than_logic() {
610        assert!(is_older_than(
611            "2020-01-01T00:00:00Z",
612            "2024-01-01T00:00:00Z"
613        ));
614        assert!(!is_older_than(
615            "2024-01-01T00:00:00Z",
616            "2024-01-01T00:00:00Z"
617        ));
618    }
619}