Skip to main content

opencode_ctl/
gc.rs

1//! `gc`: the shared startup sweep, then the pair-record collector with the dead-seen grace
2//! (spec "Pairing contract", `gc`).
3//!
4//! Every decision and every path keys on the id in the FILE NAME (`pair.<id>.json`), validated
5//! first: `pairing::read_record` and `list_records` key on it too, and this module deletes files.
6//! `gc` never opens a log: the caller (the `gc` subcommand, or `pair` with its own log) owns that.
7
8use std::path::{Path, PathBuf};
9
10use serde::Serialize;
11
12use crate::clock::Clock;
13use crate::engine::Engine;
14use crate::liveness::{side_liveness, Liveness, ProcessProbe, Side};
15use crate::pairing::{self, valid_pair_id, Record};
16use crate::sweep::{startup_sweep, STALE_FILE_MS};
17use crate::transport::Transport;
18
19/// A side `unknown` for strictly more than this counts as dead.
20pub const UNKNOWN_GRACE_MS: i64 = 60 * 60 * 1000;
21/// A pair is collected only when its `dead-seen` is strictly older than this.
22pub const DEAD_SEEN_GRACE_MS: i64 = STALE_FILE_MS;
23
24#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
25pub struct GcReport {
26    pub swept: Vec<PathBuf>,
27    pub pairs_deleted: Vec<String>,
28    pub dead_seen_written: Vec<String>,
29    pub dead_seen_cleared: Vec<String>,
30    pub skipped_service_down: bool,
31}
32
33/// What the two sides add up to.
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35enum Verdict {
36    /// Some side is `alive` or `starting`.
37    Living,
38    /// Some side is `unknown` for less than the grace and none is living: keeps the pair, changes nothing.
39    Undecided,
40    /// Every side is `dead`, `none` or unknown-past-1-h.
41    AllDead,
42}
43
44fn mtime_ms(path: &Path) -> Option<i64> {
45    let t = std::fs::metadata(path).ok()?.modified().ok()?;
46    let d = t.duration_since(std::time::UNIX_EPOCH).ok()?;
47    Some(d.as_millis() as i64)
48}
49
50fn pair_file(home: &Path, id: &str, suffix: &str) -> PathBuf {
51    home.join(format!("pair.{id}.{suffix}"))
52}
53
54/// The pid sentinel that produced an `unknown` TUI reading: the record's generation when it has a
55/// `tui-pid`, else the highest generation that has one (the rule `side_liveness` applies).
56fn tui_pid_file(home: &Path, record: &Record) -> Option<PathBuf> {
57    let id = &record.pair_id;
58    let wanted = pair_file(home, id, &format!("g{}.tui-pid", record.generation));
59    if wanted.exists() {
60        return Some(wanted);
61    }
62    let prefix = format!("pair.{id}.g");
63    std::fs::read_dir(home)
64        .ok()?
65        .flatten()
66        .filter_map(|e| {
67            let name = e.file_name().into_string().ok()?;
68            let g = name
69                .strip_prefix(&prefix)?
70                .strip_suffix(".tui-pid")?
71                .parse::<u32>()
72                .ok()?;
73            Some((g, e.path()))
74        })
75        .max_by_key(|(g, _)| *g)
76        .map(|(_, p)| p)
77}
78
79/// Age of an `unknown` side: the mtime of its sentinel, else the record's `created`.
80fn unknown_age_ms(side: Side, record: &Record, home: &Path, now_ms: i64) -> i64 {
81    let sentinel = match side {
82        Side::Tui => tui_pid_file(home, record),
83        Side::Controller => Some(pair_file(home, &record.pair_id, "claude-pid")),
84    };
85    let origin = sentinel
86        .and_then(|p| mtime_ms(&p))
87        .unwrap_or(record.created);
88    now_ms - origin
89}
90
91fn verdict(record: &Record, home: &Path, probe: &dyn ProcessProbe, now_ms: i64) -> Verdict {
92    let mut undecided = false;
93    for side in [Side::Tui, Side::Controller] {
94        match side_liveness(side, record, home, probe, now_ms) {
95            Liveness::Alive | Liveness::Starting => return Verdict::Living,
96            Liveness::Dead | Liveness::None => {}
97            Liveness::Unknown => {
98                if unknown_age_ms(side, record, home, now_ms) <= UNKNOWN_GRACE_MS {
99                    undecided = true;
100                }
101            }
102        }
103    }
104    if undecided {
105        Verdict::Undecided
106    } else {
107        Verdict::AllDead
108    }
109}
110
111/// The record behind `pair.<id>.json`, keyed on the file name: a regular file (never a symlink)
112/// that parses (`read_record` keys it on the file name's id).
113fn load_record(home: &Path, id: &str) -> Option<Record> {
114    if !valid_pair_id(id) {
115        return None;
116    }
117    let meta = std::fs::symlink_metadata(pairing::record_path(home, id)).ok()?;
118    if !meta.file_type().is_file() {
119        return None;
120    }
121    pairing::read_record(home, id).ok().flatten()
122}
123
124/// `dead-seen` content as `<epoch ms>`; absent or unparsable is `None`.
125fn read_dead_seen(home: &Path, id: &str) -> Option<i64> {
126    std::fs::read_to_string(pair_file(home, id, "dead-seen"))
127        .ok()?
128        .trim()
129        .parse()
130        .ok()
131}
132
133fn remove_dead_seen(home: &Path, id: &str) -> bool {
134    std::fs::remove_file(pair_file(home, id, "dead-seen")).is_ok()
135}
136
137fn write_dead_seen(home: &Path, id: &str, now_ms: i64) -> bool {
138    let path = pair_file(home, id, "dead-seen");
139    if path.exists() {
140        // present but unreadable: restart the grace rather than trust it
141        let _ = std::fs::remove_file(&path);
142    }
143    pairing::claim_file(&path, now_ms.to_string().as_bytes(), 0o600).unwrap_or(false)
144}
145
146/// Every `pair.<id>.*` entry must be a regular file directly in the home (no symlink, no directory).
147fn pair_files_deletable(home: &Path, id: &str) -> Option<Vec<PathBuf>> {
148    let prefix = format!("pair.{id}.");
149    let mut files = Vec::new();
150    for e in std::fs::read_dir(home).ok()?.flatten() {
151        let name = e.file_name().into_string().ok()?;
152        if !name.starts_with(&prefix) {
153            continue;
154        }
155        let path = e.path();
156        if path.parent() != Some(home) || !std::fs::symlink_metadata(&path).ok()?.is_file() {
157            return None;
158        }
159        files.push(path);
160    }
161    Some(files)
162}
163
164/// Delete every file of the pair, the record last. Nothing is deleted when any entry is not a
165/// regular file.
166fn delete_pair(home: &Path, id: &str) -> bool {
167    let record = pairing::record_path(home, id);
168    let Some(files) = pair_files_deletable(home, id) else {
169        tracing::warn!("gc: pair {id} has a non-regular entry; left alone");
170        return false;
171    };
172    for f in files.iter().filter(|f| **f != record) {
173        if let Err(e) = std::fs::remove_file(f) {
174            if e.kind() != std::io::ErrorKind::NotFound {
175                tracing::warn!("gc: cannot delete {}: {e}", f.display());
176                return false;
177            }
178        }
179    }
180    pairing::delete_pair_files(home, id).is_ok()
181}
182
183/// `session.active` read once: the set of active session ids, or `None` when it cannot be read.
184async fn active_sessions<T: Transport, C: Clock>(
185    engine: &Engine<T, C>,
186) -> Option<std::collections::HashSet<String>> {
187    let (data, _, _) = engine
188        .call_json("session.active", Vec::new(), None)
189        .await
190        .ok()?;
191    Some(data.as_object()?.keys().cloned().collect())
192}
193
194/// The sweep, then the record loop (skipped when `session.active` cannot be read).
195/// `before_delete` is the test failpoint, called after the grace has elapsed and before the
196/// liveness is re-read for the deletion decision; every real caller passes `None`.
197pub async fn gc<T: Transport, C: Clock>(
198    engine: &Engine<T, C>,
199    probe: &dyn ProcessProbe,
200    now_ms: i64,
201    before_delete: Option<&dyn Fn()>,
202) -> GcReport {
203    let home = engine.env.home.as_path();
204    let mut report = GcReport {
205        swept: startup_sweep(home, now_ms, probe).removed,
206        ..GcReport::default()
207    };
208    let Some(active) = active_sessions(engine).await else {
209        tracing::warn!("gc: session.active unreadable; no pair is touched");
210        report.skipped_service_down = true;
211        return report;
212    };
213    for id in pairing::record_ids(home) {
214        let Some(record) = load_record(home, &id) else {
215            continue;
216        };
217        match verdict(&record, home, probe, now_ms) {
218            Verdict::Living => {
219                if remove_dead_seen(home, &id) {
220                    tracing::info!("gc: pair {id} is alive again; dead-seen cleared");
221                    report.dead_seen_cleared.push(id);
222                }
223            }
224            Verdict::Undecided => {}
225            Verdict::AllDead => {
226                let Some(seen) = read_dead_seen(home, &id) else {
227                    if write_dead_seen(home, &id, now_ms) {
228                        tracing::info!("gc: pair {id} reads dead; dead-seen written");
229                        report.dead_seen_written.push(id);
230                    }
231                    continue;
232                };
233                if now_ms - seen <= DEAD_SEEN_GRACE_MS || active.contains(&record.session_id) {
234                    continue;
235                }
236                if let Some(f) = before_delete {
237                    f();
238                }
239                // Re-read everything for the decision: record, liveness, the grace file.
240                let Some(record) = load_record(home, &id) else {
241                    continue;
242                };
243                match verdict(&record, home, probe, now_ms) {
244                    Verdict::Living => {
245                        if remove_dead_seen(home, &id) {
246                            tracing::info!(
247                                "gc: pair {id} revived before deletion; dead-seen cleared"
248                            );
249                            report.dead_seen_cleared.push(id);
250                        }
251                    }
252                    Verdict::Undecided => {}
253                    Verdict::AllDead => {
254                        let still_old = read_dead_seen(home, &id)
255                            .is_some_and(|s| now_ms - s > DEAD_SEEN_GRACE_MS);
256                        if still_old
257                            && !active.contains(&record.session_id)
258                            && delete_pair(home, &id)
259                        {
260                            tracing::info!("gc: pair {id} deleted");
261                            report.pairs_deleted.push(id);
262                        }
263                    }
264                }
265            }
266        }
267    }
268    report
269}