Skip to main content

magi/
cache.rs

1//! Durable ownership of the shared Cargo build cache (`CARGO_TARGET_DIR`).
2//!
3//! One cache, many borrowers: an implement wave's candidates, a review
4//! round's E2E, the final gate, a human's `magi review` invoked by hand — all
5//! of them may point `CARGO_TARGET_DIR` at the same directory, sometimes from
6//! *different* worktrees, sometimes from a different `magi run` entirely. Two
7//! failures came out of sharing it with no ownership record at all:
8//!
9//! - A read-only reviewer seat that inherited `CARGO_TARGET_DIR` tried to
10//!   write into it, was refused by its own sandbox, and reported the refusal
11//!   as a defect in the code under review rather than a property of its own
12//!   seat. See `graph::wave`, which now only hands the variable to a seat
13//!   that [`crate::agent::Invocation::allow_write`] actually permits to use.
14//! - Two source trees building the same package name/version into one
15//!   `CARGO_TARGET_DIR` in sequence can leave a stale artifact from the
16//!   *older* worktree looking fresh to Cargo, and a later `cargo test` runs
17//!   binaries compiled from source nobody is looking at. [`ensure_fresh`]
18//!   selectively `cargo clean -p`s the workspace's own packages — never the
19//!   downloaded dependency graph — the moment the recorded source identity
20//!   for a cache directory changes.
21//!
22//! Both are consequences of one missing fact: *who is using this cache right
23//! now, and against which source*. This module is that fact, made durable
24//! (a JSON file per cache directory, surviving a process restart) and cheap
25//! to ask (`classify`, `in_use`, `inventory`).
26//!
27//! ## Reclaiming a dead owner
28//!
29//! Liveness is [`crate::proc::pid_alive`] — the same conservative check
30//! [`crate::daemon::sweep_stale_claims`] uses for the queue's own claim
31//! locks: every uncertain outcome reads as alive, and a lease whose file
32//! cannot even be parsed is never reclaimed automatically ([`Status::Unknown`]).
33//! This is deliberately the same policy as the queue's `.lock` files, not a
34//! new one — a bare lock file was never trusted alone there either; it is
35//! always paired with a liveness check.
36//!
37//! Process cleanup additionally holds an OS output lease through termination of
38//! managed tool descendants. Admission and destructive maintenance check that
39//! lease too: reclaiming a dead JSON owner alone does not permit deleting or
40//! reusing outputs still owned by cleanup.
41
42use std::path::{Path, PathBuf};
43use std::time::Duration;
44
45use anyhow::{Context as _, Result, bail};
46use jiff::Timestamp;
47use serde::{Deserialize, Serialize};
48
49use crate::proc::{self, Quiet as _};
50
51/// Who is holding, or wants, a cache directory.
52#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
53pub struct Owner {
54    /// The run this borrow belongs to.
55    pub run: String,
56    /// The graph node, e.g. `implement`, `review`, `gate`.
57    pub node: String,
58    /// Seat key, or a fixed label for a borrow that is not seat-shaped
59    /// (`"e2e"`, `"gate"`, `"janitor"`).
60    pub seat: String,
61    /// Process id of the magi process holding the lease — never the spawned
62    /// agent CLI or `cargo` child, both of which end well before this
63    /// process's own async task returns and releases the guard.
64    pub pid: u32,
65    /// The worktree the borrower is building from, for the report and for
66    /// [`ensure_fresh`]'s identity comparison.
67    pub worktree: String,
68    /// The commit the borrower is building, same purpose as `worktree`.
69    pub head: String,
70}
71
72impl Owner {
73    /// An owner for the current process.
74    #[must_use]
75    pub fn here(run: &str, node: &str, seat: &str, worktree: &Path, head: &str) -> Owner {
76        Owner {
77            run: run.to_owned(),
78            node: node.to_owned(),
79            seat: seat.to_owned(),
80            pid: std::process::id(),
81            worktree: worktree.display().to_string(),
82            head: head.to_owned(),
83        }
84    }
85}
86
87/// One held lease, as written to disk.
88#[derive(Debug, Clone, Serialize, Deserialize)]
89struct LeaseFile {
90    /// The literal cache path, kept alongside the owner so [`inventory`] can
91    /// report it without reversing the filename — the filename is a lossy
92    /// slug, not the path itself.
93    cache_dir: String,
94    owner: Owner,
95    acquired_at: Timestamp,
96}
97
98/// Where a cache directory's classification lands.
99#[derive(Debug, Clone, PartialEq, Eq)]
100pub enum Status {
101    /// No lease file, or one whose owner is confirmed gone.
102    Free,
103    /// A live owner holds it.
104    Active(Owner),
105    /// A lease file names an owner whose pid is confirmed gone. Reclaimable.
106    Stale(Owner),
107    /// A lease file exists but could not be trusted — unreadable or
108    /// unparseable. Never reclaimed automatically; a human decides.
109    Unknown,
110}
111
112/// Why [`try_acquire`] did not hand back a [`Guard`].
113#[derive(Debug, Clone, PartialEq, Eq)]
114pub enum Busy {
115    /// Held by a confirmed-live owner.
116    Active(Owner),
117    /// A lease is present but could not be trusted.
118    Unknown,
119    /// Lost a race with another acquirer; the caller should just try again.
120    Contended,
121    /// A process cleanup owner still protects the cache outputs.
122    Outputs,
123}
124
125impl Busy {
126    /// One line for an event log or an error message.
127    #[must_use]
128    pub fn describe(&self) -> String {
129        match self {
130            Busy::Active(o) => format!(
131                "held by run {} node {} seat {} (pid {})",
132                o.run, o.node, o.seat, o.pid
133            ),
134            Busy::Unknown => {
135                "an unreadable lease is present; refusing to guess who holds it".to_owned()
136            }
137            Busy::Outputs => {
138                "cache outputs are still owned by running tools or process cleanup".to_owned()
139            }
140            Busy::Contended => "lost a race for the lease; retrying".to_owned(),
141        }
142    }
143}
144
145/// A held lease. Releases on drop, so a panicking or early-returning caller
146/// never leaves the cache permanently marked busy — the same guarantee
147/// [`crate::queue::Claim`] gives the task queue's own lock file.
148#[derive(Debug)]
149pub struct Guard {
150    path: PathBuf,
151    released: bool,
152    outputs: Option<crate::retention::OutputLease>,
153}
154
155impl Guard {
156    fn new(path: PathBuf) -> Guard {
157        Guard {
158            path,
159            released: false,
160            outputs: None,
161        }
162    }
163
164    /// Release explicitly. Equivalent to dropping the guard; spelled out for
165    /// call sites where "the build finished, release now" reads better than
166    /// waiting for scope exit.
167    pub fn release(mut self) {
168        self.do_release();
169    }
170
171    fn do_release(&mut self) {
172        if !self.released {
173            let _ = std::fs::remove_file(&self.path);
174            self.released = true;
175        }
176    }
177}
178
179impl Drop for Guard {
180    fn drop(&mut self) {
181        self.do_release();
182    }
183}
184
185/// The directory leases live under, inside the magi home — never inside the
186/// cache directory itself, so [`crate::disk::prune_dir`]'s oldest-first sweep
187/// of the cache can never delete a lease file it does not know exists.
188fn leases_dir(home: &Path) -> PathBuf {
189    home.join("cache-leases")
190}
191
192/// A stable, filesystem-safe name for one cache directory's lease file.
193/// Human-legible prefix (so a directory listing is self-explanatory) plus a
194/// hash suffix (so two paths that sanitize to the same prefix — unlikely,
195/// but not impossible on a long path — never collide).
196fn slug(cache_dir: &Path) -> String {
197    let norm = normalize(cache_dir);
198    let mut readable: String = norm
199        .chars()
200        .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
201        .collect();
202    readable.truncate(80);
203    use std::hash::{Hash, Hasher};
204    let mut hasher = std::collections::hash_map::DefaultHasher::new();
205    norm.hash(&mut hasher);
206    format!("{readable}-{:08x}", hasher.finish() as u32)
207}
208
209/// Best-effort canonical form of a cache path, for hashing only — never
210/// shown to a human, who gets the literal path out of the lease file's own
211/// `cache_dir` field instead. Falls back to the literal string when the
212/// directory does not exist yet, so a lease can be taken before the first
213/// build creates it.
214fn normalize(p: &Path) -> String {
215    std::fs::canonicalize(p)
216        .map(|p| p.display().to_string())
217        .unwrap_or_else(|_| p.display().to_string())
218        .replace('\\', "/")
219        .to_ascii_lowercase()
220}
221
222fn lease_path(home: &Path, cache_dir: &Path) -> PathBuf {
223    leases_dir(home).join(format!("{}.json", slug(cache_dir)))
224}
225
226fn identity_path(home: &Path, cache_dir: &Path) -> PathBuf {
227    leases_dir(home).join(format!("{}.identity.json", slug(cache_dir)))
228}
229
230fn catalog_path(home: &Path, cache_dir: &Path) -> PathBuf {
231    leases_dir(home).join(format!("{}.catalog.json", slug(cache_dir)))
232}
233
234/// A cache directory magi has leased before, remembered past the point its
235/// lease file is gone.
236///
237/// A lease file's whole reason to exist is contention, and [`Guard::release`]
238/// deletes it the moment nobody holds it — correctly, since a stale lock file
239/// left lying around is exactly the bug this module exists to avoid repeating
240/// (see the module docs on the queue's own `.lock` files). But that means a
241/// cache directory nobody is actively borrowing is, from a lease file alone,
242/// indistinguishable from one magi never touched: both read as
243/// [`Status::Free`]. `magi-land6`, `magi-land7` and `magi-landtimedout` — idle
244/// Cargo caches worth tens of gigabytes each, found only by a manual sweep of
245/// `Temp` during the 2026-09-12 recovery — are exactly this: real, orphaned,
246/// magi-managed caches that [`inventory`] could not see because nothing was
247/// still holding them. This record is what closes that gap: written on every
248/// successful acquire, never deleted by [`Guard`], so a cleanup surface can
249/// still find and size a cache days after the last borrower let go of it.
250#[derive(Debug, Clone, Serialize, Deserialize)]
251struct CatalogRecord {
252    cache_dir: String,
253    last_owner: Owner,
254    last_used_at: Timestamp,
255}
256
257/// Remember `cache_dir` as one magi has leased, for [`inventory`] to find
258/// after the lease itself is gone. Best-effort: a write that fails costs a
259/// future cleanup listing one entry, never the acquire this rides along
260/// with — an operator missing an inventory row is a much smaller problem than
261/// a build that failed to get its cache.
262fn record_catalog(home: &Path, cache_dir: &Path, owner: &Owner) {
263    let path = catalog_path(home, cache_dir);
264    let record = CatalogRecord {
265        cache_dir: cache_dir.display().to_string(),
266        last_owner: owner.clone(),
267        last_used_at: Timestamp::now(),
268    };
269    let Ok(body) = serde_json::to_string_pretty(&record) else {
270        return;
271    };
272    let tmp = path.with_extension("json.tmp");
273    if std::fs::write(&tmp, &body).is_ok() {
274        let _ = std::fs::rename(&tmp, &path);
275    }
276}
277
278/// Read whatever JSON is at `path` as a [`CatalogRecord`], or `None` when it
279/// is missing or does not parse — an unreadable catalog entry costs
280/// [`inventory`] one row, not a wrong answer about who owns anything, since
281/// ownership is always decided from the lease file, never the catalog.
282fn read_catalog(path: &Path) -> Option<CatalogRecord> {
283    let body = std::fs::read_to_string(path).ok()?;
284    serde_json::from_str(&body).ok()
285}
286
287/// Read whatever JSON is at `path` as a [`LeaseFile`], or `None` when it is
288/// missing or does not parse.
289fn read_lease(path: &Path) -> Option<LeaseFile> {
290    let body = std::fs::read_to_string(path).ok()?;
291    serde_json::from_str(&body).ok()
292}
293
294/// Best-effort read of just the `cache_dir` field, for [`inventory`] to
295/// report a path even when the rest of the record — the owner — does not
296/// parse. A corrupt lease is still evidence of *which* directory is in a
297/// state nobody can vouch for.
298fn peek_cache_dir(path: &Path) -> Option<String> {
299    let body = std::fs::read_to_string(path).ok()?;
300    let value: serde_json::Value = serde_json::from_str(&body).ok()?;
301    value
302        .get("cache_dir")
303        .and_then(|v| v.as_str())
304        .map(str::to_owned)
305}
306
307/// Classify the lease at `path`, using the real process-liveness query.
308fn classify(path: &Path) -> Status {
309    classify_with(path, proc::pid_alive)
310}
311
312/// [`classify`] with its process-liveness query supplied by the caller —
313/// mirrors [`crate::daemon::sweep_stale_claims_with`], which exists for the
314/// identical reason: a real "confirmed dead" pid cannot be produced portably
315/// from a test (an out-of-range value reads as *unavailable*, not dead, to
316/// `tasklist`/`kill -0`, and the conservative policy those already apply -
317/// correctly - treats an unavailable query as alive). Production code always
318/// goes through [`classify`]; this is what a test calls directly to assert
319/// the classification logic against an injected answer instead.
320fn classify_with<F: Fn(u32) -> bool>(path: &Path, alive: F) -> Status {
321    if !path.exists() {
322        return Status::Free;
323    }
324    let Some(lease) = read_lease(path) else {
325        return Status::Unknown;
326    };
327    let this_process = std::process::id();
328    if lease.owner.pid == this_process || alive(lease.owner.pid) {
329        Status::Active(lease.owner)
330    } else {
331        Status::Stale(lease.owner)
332    }
333}
334
335/// Write a brand-new lease file, atomically — `create_new` refuses to
336/// overwrite an existing one, exactly like [`crate::queue::Queue::claim`]'s
337/// task lock, which this mirrors on purpose rather than inventing a second
338/// exclusion primitive in the same codebase.
339fn write_new(
340    path: &Path,
341    cache_dir: &Path,
342    owner: &Owner,
343    retry: crate::lock_file::Retry,
344) -> std::io::Result<()> {
345    write_lease(retry.create(path)?, cache_dir, owner)
346}
347
348fn write_lease(mut f: std::fs::File, cache_dir: &Path, owner: &Owner) -> std::io::Result<()> {
349    use std::io::Write as _;
350    let lease = LeaseFile {
351        cache_dir: cache_dir.display().to_string(),
352        owner: owner.clone(),
353        acquired_at: Timestamp::now(),
354    };
355    let body = serde_json::to_string_pretty(&lease).unwrap_or_default();
356    f.write_all(body.as_bytes())?;
357    Ok(())
358}
359
360/// What [`try_acquire`] returned.
361pub enum AcquireOutcome {
362    /// The lease is now held by the caller.
363    Acquired(Guard),
364    /// Somebody else has it, or its state could not be trusted.
365    Busy(Busy),
366}
367
368/// Take the lease for `cache_dir` if nobody live holds it, reclaiming a
369/// stale one first. One attempt — a caller that wants to wait uses
370/// [`wait_for`], which is this in a loop bounded by a budget.
371pub fn try_acquire(home: &Path, cache_dir: &Path, owner: &Owner) -> Result<AcquireOutcome> {
372    let Ok(_outputs) = crate::retention::OutputLease::cache(home, cache_dir, true) else {
373        return Ok(AcquireOutcome::Busy(Busy::Outputs));
374    };
375    try_acquire_with(home, cache_dir, owner, proc::pid_alive)
376}
377
378/// Take both cache ownership and exclusive output ownership for a destructive
379/// maintenance operation. Refuses while process cleanup still owns descendants.
380pub fn try_acquire_maintenance(
381    home: &Path,
382    cache_dir: &Path,
383    owner: &Owner,
384) -> Result<AcquireOutcome> {
385    let Ok(outputs) = crate::retention::OutputLease::cache(home, cache_dir, true) else {
386        return Ok(AcquireOutcome::Busy(Busy::Outputs));
387    };
388    match try_acquire_with(home, cache_dir, owner, proc::pid_alive)? {
389        AcquireOutcome::Acquired(mut guard) => {
390            guard.outputs = Some(outputs);
391            Ok(AcquireOutcome::Acquired(guard))
392        }
393        busy => Ok(busy),
394    }
395}
396
397/// [`try_acquire`] with its process-liveness query supplied by the caller —
398/// see [`classify_with`] for why this split exists.
399fn try_acquire_with<F: Fn(u32) -> bool + Copy>(
400    home: &Path,
401    cache_dir: &Path,
402    owner: &Owner,
403    alive: F,
404) -> Result<AcquireOutcome> {
405    try_acquire_before(
406        home,
407        cache_dir,
408        owner,
409        alive,
410        crate::lock_file::Retry::new(None),
411    )
412}
413
414fn try_acquire_before<F: Fn(u32) -> bool + Copy>(
415    home: &Path,
416    cache_dir: &Path,
417    owner: &Owner,
418    alive: F,
419    retry: crate::lock_file::Retry,
420) -> Result<AcquireOutcome> {
421    let dir = leases_dir(home);
422    std::fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?;
423    let path = dir.join(format!("{}.json", slug(cache_dir)));
424
425    // Two attempts: the first is the common case (free, or held by someone
426    // live); the second only runs after reclaiming a confirmed-stale lease,
427    // so this can never loop more than once per call — a caller that keeps
428    // losing the race to genuinely live contenders is exactly what `Busy`
429    // reports back, not something this function spins on.
430    for _ in 0..2 {
431        let result = write_new(&path, cache_dir, owner, retry);
432        if let Some(outcome) = acquire_result(home, &path, cache_dir, owner, alive, result)? {
433            return Ok(outcome);
434        }
435    }
436    Ok(AcquireOutcome::Busy(Busy::Contended))
437}
438
439// Shared stale/conflict handling; only opening the file is retried.
440fn acquire_result(
441    home: &Path,
442    path: &Path,
443    cache_dir: &Path,
444    owner: &Owner,
445    alive: impl Fn(u32) -> bool + Copy,
446    result: std::io::Result<()>,
447) -> Result<Option<AcquireOutcome>> {
448    match result {
449        Ok(()) => {
450            record_catalog(home, cache_dir, owner);
451            return Ok(Some(AcquireOutcome::Acquired(Guard::new(path.to_owned()))));
452        }
453        Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {}
454        Err(e) => return Err(e).with_context(|| format!("create {}", path.display())),
455    }
456    match classify_with(path, alive) {
457        Status::Free => {}
458        Status::Stale(_) => {
459            let _ = std::fs::remove_file(path);
460        }
461        Status::Active(o) => return Ok(Some(AcquireOutcome::Busy(Busy::Active(o)))),
462        Status::Unknown => return Ok(Some(AcquireOutcome::Busy(Busy::Unknown))),
463    }
464    Ok(None)
465}
466
467async fn try_acquire_async(
468    home: &Path,
469    cache_dir: &Path,
470    owner: &Owner,
471    retry: crate::lock_file::Retry,
472    cancel: &(dyn Fn() -> bool + Sync),
473) -> Result<Option<AcquireOutcome>> {
474    let Ok(_outputs) = crate::retention::OutputLease::cache(home, cache_dir, true) else {
475        return Ok(Some(AcquireOutcome::Busy(Busy::Outputs)));
476    };
477    let dir = leases_dir(home);
478    std::fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?;
479    let path = dir.join(format!("{}.json", slug(cache_dir)));
480    for _ in 0..2 {
481        let opened = retry
482            .run_async_unless(
483                || {
484                    std::fs::OpenOptions::new()
485                        .write(true)
486                        .create_new(true)
487                        .open(&path)
488                },
489                cancel,
490            )
491            .await;
492        let result = match opened {
493            Ok(Some(file)) => write_lease(file, cache_dir, owner),
494            Ok(None) => return Ok(None),
495            Err(error) => Err(error),
496        };
497        if let Some(outcome) =
498            acquire_result(home, &path, cache_dir, owner, proc::pid_alive, result)?
499        {
500            return Ok(Some(outcome));
501        }
502    }
503    Ok(Some(AcquireOutcome::Busy(Busy::Contended)))
504}
505
506/// Is `cache_dir` in use right now — held by a live owner, or in a state
507/// nobody can vouch for? The conservative half of the janitor's prune guard:
508/// [`Status::Unknown`] refuses exactly like [`Status::Active`], because a
509/// lease this process cannot read is not evidence the directory is free.
510#[must_use]
511pub fn in_use(home: &Path, cache_dir: &Path) -> bool {
512    let path = lease_path(home, cache_dir);
513    matches!(classify(&path), Status::Active(_) | Status::Unknown)
514        || crate::retention::OutputLease::cache(home, cache_dir, true).is_err()
515}
516
517/// Prefix of a resource machine hold. `triage` uses it when rechecking
518/// availability; the prefix alone does not establish an elapsed timeout.
519pub const RESOURCE_HOLD: &str = "[resource] ";
520
521/// A proven elapsed cache wait, distinct from cancellation and I/O errors.
522#[derive(Debug)]
523pub struct WaitTimeout(pub String);
524
525impl std::fmt::Display for WaitTimeout {
526    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
527        f.write_str(&self.0)
528    }
529}
530
531impl std::error::Error for WaitTimeout {}
532
533/// Acquire the lease for `cache_dir`, waiting out contention rather than
534/// failing on the first busy owner — but never past `budget`, and the wait
535/// comes out of that same budget rather than a second, unbounded one. A
536/// caller already has a node timeout; this is that timeout, not a new clock
537/// next to it, which is what keeps a wait from becoming the "無期限待機"
538/// AGENTS.md's own build-cache section warns is never acceptable. The error
539/// on timeout names who is still holding it, for the event this gets logged
540/// into.
541pub async fn wait_for(
542    home: &Path,
543    cache_dir: &Path,
544    owner: &Owner,
545    budget: Duration,
546    poll: Duration,
547) -> Result<Guard> {
548    match wait_for_unless(home, cache_dir, owner, budget, poll, &|| false).await? {
549        Some(g) => Ok(g),
550        None => bail!("the wait for the build cache was cancelled"),
551    }
552}
553
554/// [`wait_for`] that also gives up, with `Ok(None)`, as soon as `cancel`
555/// returns true. `cancel` is checked at each contention poll and before and
556/// after delete-pending sleeps, so a park request does not wait out the budget. The
557/// lease is never held on that path: nothing was acquired.
558pub async fn wait_for_unless(
559    home: &Path,
560    cache_dir: &Path,
561    owner: &Owner,
562    budget: Duration,
563    poll: Duration,
564    cancel: &(dyn Fn() -> bool + Sync),
565) -> Result<Option<Guard>> {
566    let start = std::time::Instant::now();
567    // Delete-pending retries share the whole resource wait budget; the helper
568    // checks cancellation before and after each retry sleep.
569    let retry = crate::lock_file::Retry::new(Some(start + budget));
570    loop {
571        let Some(acquired) = try_acquire_async(home, cache_dir, owner, retry, cancel).await? else {
572            return Ok(None);
573        };
574        match acquired {
575            AcquireOutcome::Acquired(g) => return Ok(Some(g)),
576            AcquireOutcome::Busy(busy) => {
577                if cancel() {
578                    return Ok(None);
579                }
580                let elapsed = start.elapsed();
581                if elapsed >= budget {
582                    return Err(WaitTimeout(format!(
583                        "timed out after {}s waiting for the build cache at {} ({})",
584                        budget.as_secs(),
585                        cache_dir.display(),
586                        busy.describe()
587                    ))
588                    .into());
589                }
590                tokio::time::sleep(poll.min(budget - elapsed)).await;
591            }
592        }
593    }
594}
595
596/// One cache directory's state, for `a0fc`'s capacity/cleanup inventory (and
597/// `magi cache show`, eventually). Every lease file on disk is reported —
598/// including ones this process itself does not want to touch — because the
599/// question this answers is "what is registered right now", not "what can I
600/// safely act on".
601#[derive(Debug, Clone)]
602pub struct Entry {
603    /// The literal cache path this entry describes.
604    pub cache_dir: String,
605    /// What [`classify`] made of the lease registered against it.
606    pub status: EntryStatus,
607}
608
609/// [`Entry::status`]'s possible values.
610#[derive(Debug, Clone)]
611pub enum EntryStatus {
612    /// Held by a confirmed-live owner.
613    Active(Owner),
614    /// Present, but the owner is confirmed gone — safe to reclaim.
615    Stale(Owner),
616    /// Present, but unreadable — never assume safe to reclaim.
617    Unknown,
618    /// No live lease right now — the borrower released it cleanly — but the
619    /// catalog remembers this path was leased before, by the owner named
620    /// here. Safe to reclaim: nothing holds it, and nothing has to guess that
621    /// from the lease file's mere absence, which is indistinguishable from a
622    /// path magi never touched (see [`CatalogRecord`]'s doc).
623    Idle(Owner),
624    /// Tools or their independent cleanup owner retain an OS output lease.
625    /// The attached owner is the last recorded cache borrower.
626    Outputs(Owner),
627}
628
629/// Every cache directory magi knows about under `home`, whether or not
630/// anything holds it right now. A lease file reports the current borrower;
631/// once released it is gone (by design — see [`Guard::release`]), so a
632/// directory nobody is borrowing is reported from the catalog instead,
633/// carrying its last known owner rather than nothing at all. There is
634/// nothing here for a directory this process has never leased: `inventory`
635/// answers "what has magi registered", not "what looks like a build cache".
636#[must_use]
637pub fn inventory(home: &Path) -> Vec<Entry> {
638    let mut entries = inventory_with(home, proc::pid_alive);
639    for entry in &mut entries {
640        if let EntryStatus::Idle(owner) | EntryStatus::Stale(owner) = &entry.status
641            && crate::retention::OutputLease::cache(home, Path::new(&entry.cache_dir), true)
642                .is_err()
643        {
644            entry.status = EntryStatus::Outputs(owner.clone());
645        }
646    }
647    entries
648}
649
650/// [`inventory`] with its process-liveness query supplied by the caller —
651/// see [`classify_with`] for why this split exists.
652fn inventory_with<F: Fn(u32) -> bool + Copy>(home: &Path, alive: F) -> Vec<Entry> {
653    let dir = leases_dir(home);
654    let Ok(rd) = std::fs::read_dir(&dir) else {
655        return Vec::new();
656    };
657    let mut out = Vec::new();
658    for entry in rd.flatten() {
659        let path = entry.path();
660        let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
661            continue;
662        };
663        if !name.ends_with(".json")
664            || name.ends_with(".identity.json")
665            || name.ends_with(".catalog.json")
666        {
667            continue;
668        }
669        let status = match classify_with(&path, alive) {
670            Status::Free => continue,
671            Status::Active(o) => EntryStatus::Active(o),
672            Status::Stale(o) => EntryStatus::Stale(o),
673            Status::Unknown => EntryStatus::Unknown,
674        };
675        // A lease so corrupted that not even its `cache_dir` field can be
676        // read still has to be findable: the lease file's own name is a
677        // one-way slug of the path it was for, so the file itself - not the
678        // path it can no longer name - is what a human or `a0fc` gets
679        // pointed at.
680        let cache_dir = peek_cache_dir(&path)
681            .unwrap_or_else(|| format!("(unreadable lease file: {})", path.display()));
682        out.push(Entry { cache_dir, status });
683    }
684
685    // Second pass: a cache directory whose lease was cleanly released is
686    // still registered, in the catalog, as long as its lease file has not
687    // been reacquired since. Skip anything the first pass already reported —
688    // a live or stale lease always outranks a catalog record, which only
689    // ever describes the *last* borrower, not the current one.
690    if let Ok(rd) = std::fs::read_dir(&dir) {
691        for entry in rd.flatten() {
692            let path = entry.path();
693            let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
694                continue;
695            };
696            let Some(stem) = name.strip_suffix(".catalog.json") else {
697                continue;
698            };
699            if dir.join(format!("{stem}.json")).exists() {
700                continue;
701            }
702            if let Some(record) = read_catalog(&path) {
703                out.push(Entry {
704                    cache_dir: record.cache_dir,
705                    status: EntryStatus::Idle(record.last_owner),
706                });
707            }
708        }
709    }
710
711    out.sort_by(|a, b| a.cache_dir.cmp(&b.cache_dir));
712    out
713}
714
715/// Prune `cache_dir` to `limit`, but only while holding the lease — a prune
716/// that raced a live build would delete files a compile in flight still
717/// needs, on top of confusing whatever built them about why its own output
718/// vanished. `Ok(None)` when the cache is in use right now; that is not an
719/// error, it is the janitor's next pass catching it once the borrower is
720/// done. Never touches a lease file itself — they live outside `cache_dir`
721/// (see [`leases_dir`]), so [`crate::disk::prune_dir`]'s own sweep can never
722/// reach one.
723pub fn maintenance_prune(
724    home: &Path,
725    cache_dir: &Path,
726    limit: u64,
727) -> Result<Option<crate::disk::Prune>> {
728    let owner = Owner {
729        run: "maintenance".to_owned(),
730        node: "prune".to_owned(),
731        seat: "janitor".to_owned(),
732        pid: std::process::id(),
733        worktree: String::new(),
734        head: String::new(),
735    };
736    match try_acquire_maintenance(home, cache_dir, &owner)? {
737        AcquireOutcome::Busy(_) => Ok(None),
738        AcquireOutcome::Acquired(guard) => {
739            let _cargo = crate::retention::cargo_locks(cache_dir)?;
740            let result = crate::disk::prune_dir(cache_dir, limit)?;
741            guard.release();
742            Ok(Some(result))
743        }
744    }
745}
746
747/// The source a build against a cache directory was last known to come from.
748/// Compared by [`needs_refresh`] on every acquire, so a cache directory that
749/// only ever sees one worktree/head pair never pays a clean it does not need.
750#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
751pub struct Identity {
752    /// The worktree the build ran from.
753    pub worktree: String,
754    /// The commit it built.
755    pub head: String,
756}
757
758impl Identity {
759    /// Build an identity from a worktree path and the commit it is at.
760    #[must_use]
761    pub fn new(worktree: &Path, head: &str) -> Identity {
762        Identity {
763            worktree: worktree.display().to_string(),
764            head: head.to_owned(),
765        }
766    }
767}
768
769/// Has the source building against `cache_dir` changed since the last record?
770/// True (needs a refresh) whenever nothing was ever recorded — the
771/// conservative default for a cache directory this process has not tracked
772/// before.
773#[must_use]
774pub fn needs_refresh(home: &Path, cache_dir: &Path, current: &Identity) -> bool {
775    let path = identity_path(home, cache_dir);
776    let Ok(body) = std::fs::read_to_string(path) else {
777        return true;
778    };
779    match serde_json::from_str::<Identity>(&body) {
780        Ok(recorded) => &recorded != current,
781        Err(_) => true,
782    }
783}
784
785/// Persist `identity` as the last known source for `cache_dir`.
786pub fn record_identity(home: &Path, cache_dir: &Path, identity: &Identity) -> Result<()> {
787    let path = identity_path(home, cache_dir);
788    if let Some(parent) = path.parent() {
789        std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
790    }
791    let body = serde_json::to_string_pretty(identity).context("serialize cache identity")?;
792    let tmp = path.with_extension("json.tmp");
793    std::fs::write(&tmp, &body).with_context(|| format!("write {}", tmp.display()))?;
794    std::fs::rename(&tmp, &path).with_context(|| format!("replace {}", path.display()))?;
795    Ok(())
796}
797
798/// Forget the recorded source identity for `cache_dir`, so the next
799/// [`ensure_fresh`] call cannot skip its clean on the strength of a stale
800/// match.
801///
802/// For a builder that does not itself have one coherent (worktree, head) to
803/// record - an implement or fix wave, where several different worktrees
804/// deliberately share the cache concurrently - there is nothing correct to
805/// write in place of the old identity. Removing the record is still correct:
806/// it costs the next tracked caller ([`ensure_fresh`] at `e2e`/`gate`) one
807/// clean it might not have strictly needed, in exchange for never trusting a
808/// match against a write this module never observed. Best-effort: a stale
809/// record surviving a failed removal is no worse than the record this
810/// replaces.
811pub fn invalidate_identity(home: &Path, cache_dir: &Path) {
812    let _ = std::fs::remove_file(identity_path(home, cache_dir));
813}
814
815/// Parse `cargo metadata --no-deps`'s JSON for the names of packages defined
816/// in the workspace itself — never a dependency, which is exactly the
817/// distinction that keeps a freshness fix from also discarding a downloaded
818/// crate's compiled artifacts on every worktree switch. Pure, so the parse
819/// is tested against fixture text without a `cargo` on the test machine.
820#[must_use]
821pub fn parse_workspace_package_names(metadata_json: &str) -> Vec<String> {
822    let Ok(value) = serde_json::from_str::<serde_json::Value>(metadata_json) else {
823        return Vec::new();
824    };
825    value
826        .get("packages")
827        .and_then(|p| p.as_array())
828        .map(|packages| {
829            packages
830                .iter()
831                .filter_map(|p| p.get("name").and_then(|n| n.as_str()))
832                .map(str::to_owned)
833                .collect()
834        })
835        .unwrap_or_default()
836}
837
838/// Every `--manifest-path` the given verify commands pass, in order of first
839/// appearance, without duplicates. Understands `--manifest-path X` and
840/// `--manifest-path=X`, with `'` / `"` quoting. Pure.
841#[must_use]
842pub fn extract_manifest_paths(commands: &[String]) -> Vec<PathBuf> {
843    const KEY: &str = "--manifest-path";
844    let mut out: Vec<PathBuf> = Vec::new();
845    for command in commands {
846        let mut rest = command.as_str();
847        while let Some(at) = rest.find(KEY) {
848            rest = &rest[at + KEY.len()..];
849            let value = if let Some(r) = rest.strip_prefix('=') {
850                r
851            } else if rest.starts_with(char::is_whitespace) {
852                rest.trim_start()
853            } else {
854                continue;
855            };
856            let value = if let Some(s) = value.strip_prefix('\'') {
857                s.split('\'').next().unwrap_or("")
858            } else if let Some(s) = value.strip_prefix('"') {
859                s.split('"').next().unwrap_or("")
860            } else {
861                let end = value.find(char::is_whitespace).unwrap_or(value.len());
862                &value[..end]
863            };
864            if !value.is_empty() {
865                let path = PathBuf::from(value);
866                if !out.contains(&path) {
867                    out.push(path);
868                }
869            }
870        }
871    }
872    out
873}
874
875/// Where cargo is asked about the workspace's own packages.
876#[derive(Debug, Clone, PartialEq, Eq)]
877pub enum Target {
878    /// The worktree root, as before (no `--manifest-path`).
879    Root,
880    /// A manifest named by a verify command, resolved against the worktree.
881    Manifest(PathBuf),
882}
883
884/// Decide which manifests `cargo metadata` / `cargo clean -p` run against: the
885/// root when it has a `Cargo.toml`, plus every verify-named manifest that
886/// exists. Nothing at all is a valid answer (no crate, or one the commands do
887/// not name). Pure apart from `exists`.
888fn plan_targets(
889    worktree: &Path,
890    manifests: &[PathBuf],
891    exists: impl Fn(&Path) -> Result<bool>,
892) -> Result<Vec<Target>> {
893    let mut targets = Vec::new();
894    if exists(&worktree.join("Cargo.toml"))? {
895        targets.push(Target::Root);
896    }
897    for m in manifests {
898        let full = if m.is_absolute() {
899            m.clone()
900        } else {
901            worktree.join(m)
902        };
903        if exists(&full)? {
904            targets.push(Target::Manifest(full));
905        } else {
906            tracing::warn!(
907                manifest = %m.display(),
908                "build cache: --manifest-path from a verify command does not exist; skipped"
909            );
910        }
911    }
912    Ok(targets)
913}
914
915/// Existence of a file: only NotFound counts as "no"; any other I/O error
916/// propagates so an unreadable tree is never mistaken for an empty one.
917fn file_exists(path: &Path) -> Result<bool> {
918    match std::fs::metadata(path) {
919        Ok(m) => Ok(m.is_file()),
920        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
921        Err(e) => Err(e).with_context(|| format!("stat {}", path.display())),
922    }
923}
924
925/// Selectively invalidate the workspace's own compiled artifacts in
926/// `cache_dir` — `cargo clean -p <name> --target-dir <cache_dir>` for every
927/// package `cargo metadata` reports as local to `worktree`, never a bare
928/// `cargo clean` (which would throw away every dependency's compile too).
929/// Returns `None` when there is no manifest to ask (no cargo is spawned).
930/// Real subprocess execution: never called from a test, only from
931/// [`ensure_fresh`] in the running binary.
932fn refresh_stale_packages(
933    worktree: &Path,
934    cache_dir: &Path,
935    manifests: &[PathBuf],
936) -> Result<Option<Vec<String>>> {
937    let targets = plan_targets(worktree, manifests, file_exists)?;
938    if targets.is_empty() {
939        return Ok(None);
940    }
941    let mut all = Vec::new();
942    let mut failed = Vec::new();
943    for target in &targets {
944        let manifest_arg = match target {
945            Target::Root => None,
946            Target::Manifest(m) => Some(m),
947        };
948        let mut cmd = std::process::Command::new("cargo");
949        cmd.args(["metadata", "--no-deps", "--format-version", "1"]);
950        if let Some(m) = manifest_arg {
951            cmd.arg("--manifest-path").arg(m);
952        }
953        let meta = cmd
954            .current_dir(worktree)
955            .quiet()
956            .output()
957            .context("run `cargo metadata`")?;
958        if !meta.status.success() {
959            bail!(
960                "cargo metadata failed: {}",
961                String::from_utf8_lossy(&meta.stderr)
962            );
963        }
964        let names = parse_workspace_package_names(&String::from_utf8_lossy(&meta.stdout));
965        for name in &names {
966            let mut clean = std::process::Command::new("cargo");
967            clean.arg("clean").arg("-p").arg(name);
968            if let Some(m) = manifest_arg {
969                clean.arg("--manifest-path").arg(m);
970            }
971            let out = clean
972                .arg("--target-dir")
973                .arg(cache_dir)
974                .current_dir(worktree)
975                .quiet()
976                .output()
977                .with_context(|| format!("cargo clean -p {name}"))?;
978            if !out.status.success() {
979                // A failure here — a Windows test executable still holding its
980                // own file open is the case the evidence log records — means the
981                // stale artifact this was meant to remove may still be sitting
982                // in `cache_dir`. Collecting it rather than only warning is what
983                // lets `ensure_fresh` refuse to record the new identity: the
984                // next reuse must not be told this cache is confirmed to match
985                // `identity` when a piece of the *previous* one could not be
986                // proven gone.
987                failed.push(format!(
988                    "{name}: {}",
989                    String::from_utf8_lossy(&out.stderr).trim()
990                ));
991            }
992        }
993        for n in names {
994            if !all.contains(&n) {
995                all.push(n);
996            }
997        }
998    }
999    if !failed.is_empty() {
1000        bail!(
1001            "cargo clean -p failed for {} package(s): {}",
1002            failed.len(),
1003            failed.join("; ")
1004        );
1005    }
1006    Ok(Some(all))
1007}
1008
1009/// What cargo itself writes into a target directory it creates.
1010const CACHEDIR_TAG: &str = "Signature: 8a477f597d28d172789f06886806bc55\n\
1011# This file is a cache directory tag created by cargo.\n\
1012# For information about cache directory tags see https://bford.info/cachedir/\n";
1013
1014/// Recreate `CACHEDIR.TAG` in a cargo target directory that lost it, so
1015/// `cargo clean -p` stops refusing. Returns whether a tag was written.
1016///
1017/// Heals caches an older prune already broke. Safe because the caller holds the
1018/// lease, `cache_dir` is the directory magi itself handed cargo as its target,
1019/// and it is only done when `.rustc_info.json` proves cargo has built there — a
1020/// misconfigured path is never dressed up as a cargo target. An existing tag is
1021/// never touched (`create_new`), and a missing directory is left for cargo.
1022fn restore_cachedir_tag(cache_dir: &Path) -> Result<bool> {
1023    use std::io::Write as _;
1024    if !cache_dir.is_dir() || !cache_dir.join(".rustc_info.json").is_file() {
1025        return Ok(false);
1026    }
1027    let tag = cache_dir.join("CACHEDIR.TAG");
1028    match std::fs::OpenOptions::new()
1029        .write(true)
1030        .create_new(true)
1031        .open(&tag)
1032    {
1033        Ok(mut f) => {
1034            f.write_all(CACHEDIR_TAG.as_bytes())
1035                .with_context(|| format!("write {}", tag.display()))?;
1036            Ok(true)
1037        }
1038        Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Ok(false),
1039        Err(e) => Err(e).with_context(|| format!("create {}", tag.display())),
1040    }
1041}
1042
1043/// Guarantee that a build against `cache_dir` from `(worktree, head)` never
1044/// silently reuses another source's compiled output: clean the workspace's
1045/// own packages out of the cache when the recorded identity disagrees, then
1046/// record the new one. A no-op — no subprocess spawned — when the identity
1047/// already matches, which is the common case once a cache directory settles
1048/// on one worktree for a while.
1049///
1050/// Called with the lease already held: this is a mutation of the cache
1051/// directory's contents, and it must never race a concurrent build the same
1052/// way a plain `cargo clean` run by hand would not.
1053pub fn ensure_fresh(
1054    home: &Path,
1055    cache_dir: &Path,
1056    identity: &Identity,
1057    manifests: &[PathBuf],
1058) -> Result<()> {
1059    match restore_cachedir_tag(cache_dir) {
1060        Ok(true) => tracing::info!(
1061            cache = %cache_dir.display(),
1062            "build cache: restored a missing CACHEDIR.TAG"
1063        ),
1064        Ok(false) => {}
1065        // Not fatal: `cargo clean -p` below reports the real consequence.
1066        Err(e) => tracing::warn!(error = %e, "build cache: could not restore CACHEDIR.TAG"),
1067    }
1068    if needs_refresh(home, cache_dir, identity) {
1069        let worktree = PathBuf::from(&identity.worktree);
1070        match refresh_stale_packages(&worktree, cache_dir, manifests)? {
1071            Some(cleaned) => tracing::info!(
1072                ?cleaned,
1073                cache = %cache_dir.display(),
1074                "build cache: source identity changed; cleaned the workspace's own packages before reuse"
1075            ),
1076            None => {
1077                // No Cargo.toml at the root and none named by a verify
1078                // command: there is nothing to clean by name, and a crate
1079                // we could not locate may have left stale output behind, so
1080                // the identity is not vouched for either.
1081                tracing::warn!(
1082                    cache = %cache_dir.display(),
1083                    "build cache: no Cargo manifest found for the freshness check; skipping the package clean"
1084                );
1085                invalidate_identity(home, cache_dir);
1086                return Ok(());
1087            }
1088        }
1089    }
1090    record_identity(home, cache_dir, identity)
1091}
1092
1093#[cfg(test)]
1094mod tests {
1095    use super::*;
1096
1097    fn owner(pid: u32) -> Owner {
1098        Owner {
1099            run: "r1".to_owned(),
1100            node: "gate".to_owned(),
1101            seat: "gate".to_owned(),
1102            pid,
1103            worktree: "/w".to_owned(),
1104            head: "deadbeef".to_owned(),
1105        }
1106    }
1107
1108    #[test]
1109    fn a_missing_cachedir_tag_is_restored_only_for_a_cargo_target() {
1110        let t = tempfile::TempDir::new().expect("temp");
1111        let cache = t.path().join("cache");
1112        assert!(!restore_cachedir_tag(&cache).expect("missing dir"));
1113        assert!(!cache.exists(), "a missing directory is left for cargo");
1114
1115        std::fs::create_dir_all(&cache).expect("mkdir");
1116        assert!(!restore_cachedir_tag(&cache).expect("no rustc info"));
1117        assert!(!cache.join("CACHEDIR.TAG").exists());
1118
1119        std::fs::write(cache.join(".rustc_info.json"), "{}").expect("info");
1120        assert!(restore_cachedir_tag(&cache).expect("restore"));
1121        let body = std::fs::read_to_string(cache.join("CACHEDIR.TAG")).expect("tag");
1122        assert_eq!(
1123            body.lines().next(),
1124            Some("Signature: 8a477f597d28d172789f06886806bc55")
1125        );
1126
1127        std::fs::write(cache.join("CACHEDIR.TAG"), "custom").expect("custom");
1128        assert!(!restore_cachedir_tag(&cache).expect("existing"));
1129        assert_eq!(
1130            std::fs::read_to_string(cache.join("CACHEDIR.TAG")).expect("tag"),
1131            "custom"
1132        );
1133    }
1134
1135    #[test]
1136    fn an_uncontended_lease_is_acquired_and_freed_on_release() {
1137        let home = tempfile::TempDir::new().expect("temp");
1138        let cache = home.path().join("cache");
1139        let this = std::process::id();
1140        match try_acquire(home.path(), &cache, &owner(this)).expect("acquire") {
1141            AcquireOutcome::Acquired(g) => {
1142                assert!(in_use(home.path(), &cache), "held while the guard lives");
1143                g.release();
1144            }
1145            AcquireOutcome::Busy(b) => panic!("unexpectedly busy: {b:?}"),
1146        }
1147        assert!(!in_use(home.path(), &cache), "freed after release");
1148    }
1149
1150    #[test]
1151    fn a_lease_held_by_a_live_pid_is_reported_active_and_refuses_a_second_acquire() {
1152        let home = tempfile::TempDir::new().expect("temp");
1153        let cache = home.path().join("cache");
1154        let this = std::process::id();
1155        // Acquire as one owner, then try again as a different one — same
1156        // live pid (this test process), different run/seat, which is exactly
1157        // "another owner, still alive" without needing to fork a process.
1158        let _first =
1159            try_acquire(home.path(), &cache, &owner(this)).expect("first acquire succeeds");
1160        let mut second_owner = owner(this);
1161        second_owner.run = "r2".to_owned();
1162        match try_acquire(home.path(), &cache, &second_owner).expect("no io error") {
1163            AcquireOutcome::Busy(Busy::Active(held_by)) => assert_eq!(held_by.run, "r1"),
1164            other => panic!("expected Busy::Active, got a different outcome: {other:?}"),
1165        }
1166        assert!(in_use(home.path(), &cache));
1167    }
1168
1169    impl std::fmt::Debug for AcquireOutcome {
1170        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1171            match self {
1172                AcquireOutcome::Acquired(_) => write!(f, "Acquired"),
1173                AcquireOutcome::Busy(b) => write!(f, "Busy({b:?})"),
1174            }
1175        }
1176    }
1177
1178    #[test]
1179    fn a_lease_whose_pid_is_gone_is_stale_and_reclaimed_by_the_next_acquirer() {
1180        let home = tempfile::TempDir::new().expect("temp");
1181        let cache = home.path().join("cache");
1182        // Liveness is injected rather than asked of the real OS - a "known
1183        // dead" pid cannot be produced portably (see `classify_with`'s doc):
1184        // an out-of-range value reads as *unavailable* to `tasklist`, not
1185        // dead, and the conservative policy then reports it alive, which is
1186        // exactly what made this test flaky before this fix.
1187        let dead_owner = owner(999_999);
1188        let path = lease_path(home.path(), &cache);
1189        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1190        write_new(
1191            &path,
1192            &cache,
1193            &dead_owner,
1194            crate::lock_file::Retry::new(None),
1195        )
1196        .expect("seed a stale lease");
1197        assert_eq!(
1198            classify_with(&path, |_| false),
1199            Status::Stale(dead_owner.clone())
1200        );
1201
1202        match try_acquire_with(home.path(), &cache, &owner(std::process::id()), |_| false)
1203            .expect("acquire")
1204        {
1205            AcquireOutcome::Acquired(_) => {}
1206            other => panic!("stale lease should have been reclaimed: {other:?}"),
1207        }
1208    }
1209
1210    #[test]
1211    fn an_unreadable_lease_is_unknown_and_never_reclaimed() {
1212        let home = tempfile::TempDir::new().expect("temp");
1213        let cache = home.path().join("cache");
1214        let path = lease_path(home.path(), &cache);
1215        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1216        std::fs::write(&path, b"not json").unwrap();
1217        assert_eq!(classify(&path), Status::Unknown);
1218        assert!(in_use(home.path(), &cache), "unknown counts as in use");
1219        match try_acquire(home.path(), &cache, &owner(std::process::id())).expect("no io error") {
1220            AcquireOutcome::Busy(Busy::Unknown) => {}
1221            other => panic!("expected Busy::Unknown, got {other:?}"),
1222        }
1223    }
1224
1225    #[tokio::test]
1226    async fn waiting_for_a_busy_lease_times_out_within_its_own_budget() {
1227        let home = tempfile::TempDir::new().expect("temp");
1228        let cache = home.path().join("cache");
1229        let _held = try_acquire(home.path(), &cache, &owner(std::process::id()))
1230            .expect("acquire")
1231            .pipe();
1232        let mut waiter = owner(std::process::id());
1233        waiter.run = "r2".to_owned();
1234        let started = std::time::Instant::now();
1235        let err = wait_for(
1236            home.path(),
1237            &cache,
1238            &waiter,
1239            Duration::from_millis(150),
1240            Duration::from_millis(20),
1241        )
1242        .await
1243        .expect_err("still held, must time out");
1244        assert!(started.elapsed() < Duration::from_secs(2), "bounded wait");
1245        assert!(
1246            err.to_string().contains("r1"),
1247            "names the current holder: {err}"
1248        );
1249    }
1250
1251    #[tokio::test]
1252    async fn a_wait_succeeds_as_soon_as_the_lease_is_released() {
1253        let home = tempfile::TempDir::new().expect("temp");
1254        let cache = home.path().join("cache");
1255        let guard =
1256            match try_acquire(home.path(), &cache, &owner(std::process::id())).expect("acquire") {
1257                AcquireOutcome::Acquired(g) => g,
1258                AcquireOutcome::Busy(b) => panic!("unexpectedly busy: {b:?}"),
1259            };
1260        let home_path = home.path().to_path_buf();
1261        let cache_path = cache.clone();
1262        let mut waiter = owner(std::process::id());
1263        waiter.run = "r2".to_owned();
1264        let wait = tokio::spawn(async move {
1265            wait_for(
1266                &home_path,
1267                &cache_path,
1268                &waiter,
1269                Duration::from_secs(5),
1270                Duration::from_millis(10),
1271            )
1272            .await
1273        });
1274        tokio::time::sleep(Duration::from_millis(50)).await;
1275        guard.release();
1276        let acquired = wait.await.expect("task").expect("acquire after release");
1277        acquired.release();
1278    }
1279
1280    #[test]
1281    fn inventory_reports_active_stale_and_unknown_but_not_free() {
1282        let home = tempfile::TempDir::new().expect("temp");
1283        let active_cache = home.path().join("active");
1284        let stale_cache = home.path().join("stale");
1285        let unknown_cache = home.path().join("unknown");
1286
1287        let _held =
1288            try_acquire(home.path(), &active_cache, &owner(std::process::id())).expect("acquire");
1289        let stale_path = lease_path(home.path(), &stale_cache);
1290        std::fs::create_dir_all(stale_path.parent().unwrap()).unwrap();
1291        write_new(
1292            &stale_path,
1293            &stale_cache,
1294            &owner(999_999),
1295            crate::lock_file::Retry::new(None),
1296        )
1297        .unwrap();
1298        let unknown_path = lease_path(home.path(), &unknown_cache);
1299        std::fs::write(&unknown_path, b"garbage").unwrap();
1300
1301        // Liveness injected as `false` for everyone but this test process -
1302        // see `a_lease_whose_pid_is_gone_is_stale_and_reclaimed_by_the_next_acquirer`
1303        // for why the real OS query cannot portably produce a "confirmed
1304        // dead" answer.
1305        let entries = inventory_with(home.path(), |pid| pid == std::process::id());
1306        assert_eq!(entries.len(), 3, "{entries:?}");
1307        let by_dir = |dir: &Path| {
1308            entries
1309                .iter()
1310                .find(|e| e.cache_dir == dir.display().to_string())
1311                .unwrap_or_else(|| panic!("no entry for {}", dir.display()))
1312        };
1313        assert!(matches!(
1314            by_dir(&active_cache).status,
1315            EntryStatus::Active(_)
1316        ));
1317        assert!(matches!(by_dir(&stale_cache).status, EntryStatus::Stale(_)));
1318        // A lease this corrupted cannot name its own `cache_dir` - the point
1319        // of this third case - so it is found by status instead of by path,
1320        // and its reported path must still point somewhere a human can act
1321        // on: the lease file itself.
1322        let unknown = entries
1323            .iter()
1324            .find(|e| matches!(e.status, EntryStatus::Unknown))
1325            .unwrap_or_else(|| panic!("no Unknown entry: {entries:?}"));
1326        assert!(
1327            unknown
1328                .cache_dir
1329                .contains(&unknown_path.display().to_string()),
1330            "{unknown:?}"
1331        );
1332    }
1333
1334    #[test]
1335    fn a_released_lease_is_reported_idle_from_the_catalog_not_dropped_entirely() {
1336        let home = tempfile::TempDir::new().expect("temp");
1337        let cache = home.path().join("cache");
1338        let this = std::process::id();
1339
1340        match try_acquire(home.path(), &cache, &owner(this)).expect("acquire") {
1341            AcquireOutcome::Acquired(g) => g.release(),
1342            AcquireOutcome::Busy(b) => panic!("unexpectedly busy: {b:?}"),
1343        }
1344
1345        // The lease file is gone - `in_use` reads this path as free - but the
1346        // catalog written on acquire must still name it as a cache magi once
1347        // leased, exactly the gap the 2026-09-12 recovery evidence found:
1348        // an orphaned cache indistinguishable from one that never existed.
1349        assert!(!in_use(home.path(), &cache));
1350        let entries = inventory_with(home.path(), |pid| pid == this);
1351        let entry = entries
1352            .iter()
1353            .find(|e| e.cache_dir == cache.display().to_string())
1354            .unwrap_or_else(|| panic!("no entry for a released cache: {entries:?}"));
1355        match &entry.status {
1356            EntryStatus::Idle(o) => assert_eq!(o.run, "r1"),
1357            other => panic!("expected Idle, got {other:?}"),
1358        }
1359    }
1360
1361    #[test]
1362    fn reacquiring_a_released_cache_reports_active_not_idle() {
1363        let home = tempfile::TempDir::new().expect("temp");
1364        let cache = home.path().join("cache");
1365        let this = std::process::id();
1366        match try_acquire(home.path(), &cache, &owner(this)).expect("acquire") {
1367            AcquireOutcome::Acquired(g) => g.release(),
1368            AcquireOutcome::Busy(b) => panic!("unexpectedly busy: {b:?}"),
1369        }
1370        let _held = try_acquire(home.path(), &cache, &owner(this)).expect("reacquire");
1371        let entries = inventory_with(home.path(), |pid| pid == this);
1372        assert_eq!(
1373            entries.len(),
1374            1,
1375            "the catalog row must not duplicate the live lease: {entries:?}"
1376        );
1377        assert!(matches!(entries[0].status, EntryStatus::Active(_)));
1378    }
1379
1380    #[test]
1381    fn maintenance_prune_refuses_a_cache_a_live_owner_holds() {
1382        let home = tempfile::TempDir::new().expect("temp");
1383        let cache = home.path().join("cache");
1384        std::fs::create_dir_all(&cache).unwrap();
1385        std::fs::write(cache.join("big"), vec![0u8; 100]).unwrap();
1386        let _held = try_acquire(home.path(), &cache, &owner(std::process::id())).expect("acquire");
1387
1388        let result = maintenance_prune(home.path(), &cache, 1).expect("no io error");
1389        assert!(
1390            result.is_none(),
1391            "must not prune while a live owner holds it"
1392        );
1393        assert!(cache.join("big").exists(), "nothing was deleted");
1394    }
1395
1396    #[test]
1397    fn maintenance_prune_acts_once_the_cache_is_free_and_releases_after() {
1398        let home = tempfile::TempDir::new().expect("temp");
1399        let cache = home.path().join("cache");
1400        std::fs::create_dir_all(&cache).unwrap();
1401        std::fs::write(cache.join("big"), vec![0u8; 100]).unwrap();
1402
1403        let pruned = maintenance_prune(home.path(), &cache, 1)
1404            .expect("no io error")
1405            .expect("cache was free");
1406        assert!(pruned.freed > 0);
1407        assert!(
1408            !in_use(home.path(), &cache),
1409            "the maintenance lease was released"
1410        );
1411    }
1412
1413    #[test]
1414    fn identity_drift_is_detected_once_and_then_settles() {
1415        let home = tempfile::TempDir::new().expect("temp");
1416        let cache = home.path().join("cache");
1417        let a = Identity {
1418            worktree: "/w/a".to_owned(),
1419            head: "aaaa".to_owned(),
1420        };
1421        let b = Identity {
1422            worktree: "/w/b".to_owned(),
1423            head: "bbbb".to_owned(),
1424        };
1425        assert!(
1426            needs_refresh(home.path(), &cache, &a),
1427            "nothing recorded yet"
1428        );
1429        record_identity(home.path(), &cache, &a).expect("record");
1430        assert!(
1431            !needs_refresh(home.path(), &cache, &a),
1432            "same identity, no refresh needed"
1433        );
1434        assert!(needs_refresh(home.path(), &cache, &b), "different source");
1435        record_identity(home.path(), &cache, &b).expect("record");
1436        assert!(!needs_refresh(home.path(), &cache, &b));
1437    }
1438
1439    #[test]
1440    fn invalidating_forgets_a_recorded_identity_so_the_next_check_refreshes() {
1441        let home = tempfile::TempDir::new().expect("temp");
1442        let cache = home.path().join("cache");
1443        let a = Identity {
1444            worktree: "/w/a".to_owned(),
1445            head: "aaaa".to_owned(),
1446        };
1447        record_identity(home.path(), &cache, &a).expect("record");
1448        assert!(!needs_refresh(home.path(), &cache, &a));
1449
1450        // An untracked writer (an implement/fix wave, which shares the cache
1451        // across several worktrees at once and so has no single identity of
1452        // its own to record) touched the cache in between; the next tracked
1453        // caller must not trust the old match anymore.
1454        invalidate_identity(home.path(), &cache);
1455        assert!(
1456            needs_refresh(home.path(), &cache, &a),
1457            "invalidation must not be skippable by asking about the same identity again"
1458        );
1459
1460        // Invalidating a cache directory nothing ever recorded is a no-op,
1461        // not an error.
1462        invalidate_identity(home.path(), &home.path().join("never-recorded"));
1463    }
1464
1465    #[test]
1466    fn workspace_package_names_are_read_from_cargo_metadata_json() {
1467        let fixture = r#"{
1468            "packages": [
1469                {"name": "magi", "version": "0.1.0"},
1470                {"name": "magi-cli", "version": "0.1.0"}
1471            ],
1472            "workspace_members": []
1473        }"#;
1474        let mut names = parse_workspace_package_names(fixture);
1475        names.sort();
1476        assert_eq!(names, vec!["magi".to_owned(), "magi-cli".to_owned()]);
1477        assert_eq!(
1478            parse_workspace_package_names("not json"),
1479            Vec::<String>::new()
1480        );
1481        assert_eq!(parse_workspace_package_names("{}"), Vec::<String>::new());
1482    }
1483
1484    #[test]
1485    fn slugs_are_stable_and_filesystem_safe() {
1486        let a = slug(Path::new(r"C:\Users\op\Temp\magi-target"));
1487        let b = slug(Path::new(r"C:\Users\op\Temp\magi-target"));
1488        assert_eq!(a, b, "same input, same slug");
1489        assert!(
1490            a.chars()
1491                .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'),
1492            "filesystem-safe: {a}"
1493        );
1494    }
1495
1496    #[test]
1497    fn busy_active_describes_the_holder() {
1498        let b = Busy::Active(owner(123));
1499        let s = b.describe();
1500        assert!(
1501            s.contains("r1") && s.contains("gate") && s.contains("123"),
1502            "{s}"
1503        );
1504    }
1505
1506    trait Pipe: Sized {
1507        fn pipe(self) -> Guard;
1508    }
1509    impl Pipe for AcquireOutcome {
1510        fn pipe(self) -> Guard {
1511            match self {
1512                AcquireOutcome::Acquired(g) => g,
1513                AcquireOutcome::Busy(b) => panic!("expected Acquired, got Busy({b:?})"),
1514            }
1515        }
1516    }
1517
1518    fn cmds(v: &[&str]) -> Vec<String> {
1519        v.iter().map(|s| (*s).to_owned()).collect()
1520    }
1521
1522    #[test]
1523    fn manifest_paths_are_read_in_every_spelling() {
1524        let got = extract_manifest_paths(&cmds(&[
1525            "cargo test --manifest-path tools/a/Cargo.toml",
1526            "cargo build --manifest-path=tools/b/Cargo.toml --release",
1527            "cargo fmt --manifest-path 'q q/Cargo.toml'",
1528            "cargo clippy --manifest-path \"d/Cargo.toml\" && cargo test --manifest-path tools/a/Cargo.toml",
1529        ]));
1530        let want: Vec<PathBuf> = [
1531            "tools/a/Cargo.toml",
1532            "tools/b/Cargo.toml",
1533            "q q/Cargo.toml",
1534            "d/Cargo.toml",
1535        ]
1536        .iter()
1537        .map(PathBuf::from)
1538        .collect();
1539        assert_eq!(got, want);
1540        assert!(extract_manifest_paths(&cmds(&["cargo test", "cd x && cargo test"])).is_empty());
1541    }
1542
1543    #[test]
1544    fn plan_targets_covers_blog_root_both_and_none() {
1545        let w = Path::new("/w");
1546        let only = |set: &'static [&'static str]| {
1547            move |p: &Path| Ok(set.iter().any(|s| p == Path::new(s)))
1548        };
1549        let m = vec![PathBuf::from("tools/postprocess/Cargo.toml")];
1550        let blog = plan_targets(w, &m, only(&["/w/tools/postprocess/Cargo.toml"])).unwrap();
1551        assert_eq!(
1552            blog,
1553            vec![Target::Manifest(PathBuf::from(
1554                "/w/tools/postprocess/Cargo.toml"
1555            ))]
1556        );
1557        let root = plan_targets(w, &[], only(&["/w/Cargo.toml"])).unwrap();
1558        assert_eq!(root, vec![Target::Root]);
1559        let both = plan_targets(
1560            w,
1561            &m,
1562            only(&["/w/Cargo.toml", "/w/tools/postprocess/Cargo.toml"]),
1563        )
1564        .unwrap();
1565        assert_eq!(both.len(), 2);
1566        assert_eq!(both[0], Target::Root);
1567        assert!(plan_targets(w, &m, only(&[])).unwrap().is_empty());
1568        assert!(plan_targets(w, &[], only(&[])).unwrap().is_empty());
1569    }
1570
1571    #[test]
1572    fn a_repo_without_a_reachable_manifest_passes_untouched() {
1573        let home = tempfile::TempDir::new().expect("temp");
1574        let wt = tempfile::TempDir::new().expect("temp");
1575        // Only a nested crate, which no verify command names.
1576        std::fs::create_dir_all(wt.path().join("sub")).unwrap();
1577        std::fs::write(wt.path().join("sub/Cargo.toml"), "").unwrap();
1578        let cache = home.path().join("cache");
1579        std::fs::create_dir_all(&cache).unwrap();
1580        std::fs::write(cache.join("artifact"), "x").unwrap();
1581        let old = Identity {
1582            worktree: "/w/old".to_owned(),
1583            head: "old".to_owned(),
1584        };
1585        record_identity(home.path(), &cache, &old).unwrap();
1586        let id = Identity::new(wt.path(), "head");
1587        ensure_fresh(home.path(), &cache, &id, &[]).expect("must not fail");
1588        assert_eq!(
1589            std::fs::read_to_string(cache.join("artifact")).unwrap(),
1590            "x"
1591        );
1592        assert!(
1593            needs_refresh(home.path(), &cache, &old),
1594            "identity was invalidated, not recorded"
1595        );
1596        assert!(needs_refresh(home.path(), &cache, &id));
1597    }
1598}