Skip to main content

opencode_ctl/
sweep.rs

1//! The startup sweep: the one list of housekeeping that `serve` runs at startup and `gc` reuses
2//! (spec "gc"). Every failure is collected, never fatal.
3
4use std::path::{Path, PathBuf};
5
6use crate::created_set::{CreatedSet, SWEEP_MAX_AGE_MS};
7use crate::liveness::{self, sentinel_alive, side_liveness, Liveness, ProcessProbe, Side};
8use crate::pairing::{self, valid_pair_id};
9
10/// Sentinel files and claim temps are removed when untouched for this long.
11pub const STALE_FILE_MS: i64 = 24 * 60 * 60 * 1000;
12
13#[derive(Debug, Clone, Default)]
14pub struct SweepReport {
15    pub removed: Vec<PathBuf>,
16    pub errors: Vec<String>,
17}
18
19impl SweepReport {
20    fn remove_file(&mut self, path: &Path) {
21        match std::fs::remove_file(path) {
22            Ok(()) => self.removed.push(path.to_path_buf()),
23            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
24            Err(e) => self.errors.push(format!("{}: {e}", path.display())),
25        }
26    }
27}
28
29fn age_ms(path: &Path, now_ms: i64) -> Option<i64> {
30    let t = std::fs::metadata(path).ok()?.modified().ok()?;
31    let ms = t.duration_since(std::time::UNIX_EPOCH).ok()?.as_millis() as i64;
32    Some(now_ms - ms)
33}
34
35/// The pair's record is gone, or both its sides read `dead` or `none`. The record is looked up by
36/// the id in the FILE NAME, and liveness is derived for that id even if the record's contents
37/// disagree. An unreadable record is not "dead".
38pub fn pair_is_dead(home: &Path, id: &str, now_ms: i64, probe: &dyn ProcessProbe) -> bool {
39    match pairing::read_record(home, id) {
40        Ok(None) => !pairing::record_path(home, id).exists(),
41        Ok(Some(mut r)) => {
42            r.pair_id = id.to_string();
43            [Side::Tui, Side::Controller].into_iter().all(|side| {
44                matches!(
45                    side_liveness(side, &r, home, probe, now_ms),
46                    Liveness::Dead | Liveness::None
47                )
48            })
49        }
50        Err(_) => false,
51    }
52}
53
54/// `pair.<id>.<rest>` -> (id, rest) for a valid id.
55fn pair_file_parts(name: &str) -> Option<(&str, &str)> {
56    let rest = name.strip_prefix("pair.")?;
57    let (id, rest) = rest.split_once('.')?;
58    valid_pair_id(id).then_some((id, rest))
59}
60
61fn is_sentinel_name(rest: &str) -> bool {
62    rest == "claude-pid"
63        || rest == "claude-exited"
64        || (rest.starts_with('g') && (rest.ends_with(".tui-pid") || rest.ends_with(".tui-exited")))
65}
66
67/// Windows launcher leftover: `opencode-pair.exe.old` under the launcher install directory.
68pub fn sweep_old_launcher(dir: &Path, report: &mut SweepReport) {
69    let old = dir.join("opencode-pair.exe.old");
70    if old.exists() {
71        report.remove_file(&old);
72    }
73}
74
75/// Log retention, dead stdout dirs, aged `created.*`, `resume-lock` files whose owner is dead,
76/// `opencode-pair.exe.old` (Windows), sentinel files without a record older than 24 h and
77/// `*.tmp-*` claim temps older than 24 h.
78pub fn startup_sweep(home: &Path, now_ms: i64, probe: &dyn ProcessProbe) -> SweepReport {
79    let mut report = SweepReport::default();
80    report.removed.extend(crate::logging::retain(
81        &home.join("logs"),
82        now_ms,
83        probe,
84        &|id| pair_is_dead(home, id, now_ms, probe),
85    ));
86    report
87        .removed
88        .extend(liveness::sweep_stdout_dirs(home, probe));
89    report
90        .removed
91        .extend(CreatedSet::new(home.to_path_buf()).sweep(now_ms, SWEEP_MAX_AGE_MS));
92
93    if let Ok(entries) = std::fs::read_dir(home) {
94        for entry in entries.flatten() {
95            let path = entry.path();
96            let name = entry.file_name();
97            let Some(name) = name.to_str() else { continue };
98            if !path.is_file() {
99                continue;
100            }
101            if name.contains(".tmp-") {
102                if age_ms(&path, now_ms).is_some_and(|a| a > STALE_FILE_MS) {
103                    report.remove_file(&path);
104                }
105                continue;
106            }
107            let Some((id, rest)) = pair_file_parts(name) else {
108                continue;
109            };
110            if rest == "resume-lock" {
111                // An empty or unparsable lock may be mid-creation: left alone.
112                if pairing::read_sentinel(&path).is_some_and(|s| !sentinel_alive(&s, probe)) {
113                    report.remove_file(&path);
114                }
115            } else if is_sentinel_name(rest)
116                && !pairing::record_path(home, id).exists()
117                && age_ms(&path, now_ms).is_some_and(|a| a > STALE_FILE_MS)
118            {
119                report.remove_file(&path);
120            }
121        }
122    }
123
124    #[cfg(windows)]
125    if let Some(local) = std::env::var_os("LOCALAPPDATA").filter(|v| !v.is_empty()) {
126        sweep_old_launcher(&PathBuf::from(local).join("opencode-ctl"), &mut report);
127    }
128    report
129}