Skip to main content

onevcs/
pool.rs

1//! The pool: warm worktree slots an identity keeps between sessions.
2//!
3//! A slot is structurally a run root that survives `session close`. It lives under
4//! `<identity dir>/pool/<n>/` with its own `--shared --no-checkout` clone — never
5//! several slots on one clone, for the per-clone lock reason `workspace.rs`'s header
6//! gives — its worktree, and a `slot.json` naming the lender it borrows from. A session
7//! placed on one works in it exactly as it works in a run root: the occupancy lease,
8//! the process census, the dirty-tree preservation, the hand-back and the stray-work
9//! refusal all address the slot directory as the run root, and the record names it.
10//!
11//! What differs is the two ends. Taking a slot swaps the branch **in place** in the
12//! existing worktree, so an incremental build sees a branch diff rather than an empty
13//! tree; returning one detaches the worktree onto the base, resets it, and cleans the
14//! untracked files without touching the ignored ones — `.gitignore` being the
15//! repository's own declaration of what is build output — so the next session finds
16//! `target/`, `node_modules/` and `.venv/` still there.
17//!
18//! **A slot is idle iff no session record in state `open` names it and its maintenance
19//! claim is void** — null, or naming a process that is no longer running. That is the
20//! same record-based proof [`crate::workspace`]'s reclamation uses for run roots, and
21//! deliberately not a second liveness test: an open record whose owner exited holds its
22//! slot until `session close` or `onevcs sweep` forgets it, because a session opened
23//! from the command line has no owner process from the instant its token is printed.
24//!
25//! Sizing is the host's, per identity, in `$ONEVCS_HOME/workspaces.yml`
26//! ([`crate::workspaces`]): each host can afford a different amount of concurrent disk.
27//! The pool is lazy — a slot is cut when a session needs one and fewer than `pool`
28//! exist — so an identity that only ever has one concurrent task only ever has one
29//! slot. Past the pool, sessions overflow into disposable run roots under `runs/`
30//! exactly as before, up to `overflow`, and are then refused with
31//! [`Error::PoolExhausted`] rather than made to wait.
32
33use std::path::{Path, PathBuf};
34
35use serde::{Deserialize, Serialize};
36
37use crate::error::{self, Error, Result};
38use crate::session::{Lifecycle, SessionRequest, SessionToken};
39use crate::store::{self, Resolution};
40use crate::workspace::{self, ProcessStart, Record, Ref};
41use crate::workspaces::{self, Bound, Overrides, Resolved};
42use crate::{git, guidance, ids, lock, processes};
43
44mod maintain;
45
46pub use maintain::{
47    pool_maintain, IdentityMaintenance, IdentityOutcome, MaintainReport, SlotDisk, SlotMaintenance,
48    SlotOutcome,
49};
50
51/// The version of the slot record this build writes and reads.
52pub const SLOT_VERSION: u32 = 1;
53
54/// A stored instant, checked where it is read.
55///
56/// The check is in the conversion, so a slot record carrying a `created`, a
57/// `last_maintained` or a claim's `since` that is not a timestamp this build can order
58/// by does not deserialize at all — it is a broken slot rather than a status line
59/// carrying whatever was in the file. The shape is the one [`ids::timestamp`] writes,
60/// which is what every other stored instant in this crate is held to.
61#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
62#[serde(try_from = "String", into = "String")]
63pub(crate) struct Timestamp(String);
64
65impl TryFrom<String> for Timestamp {
66    type Error = String;
67
68    fn try_from(value: String) -> std::result::Result<Self, Self::Error> {
69        match ids::is_timestamp(&value) {
70            true => Ok(Timestamp(value)),
71            false => Err(format!(
72                "{value:?} is not a timestamp this build can order by (RFC3339, millisecond, \
73                 UTC)"
74            )),
75        }
76    }
77}
78
79impl From<Timestamp> for String {
80    fn from(stamp: Timestamp) -> Self {
81        stamp.0
82    }
83}
84
85impl Timestamp {
86    /// Now, as [`ids::timestamp`] writes it.
87    fn now() -> Self {
88        Timestamp(ids::timestamp())
89    }
90}
91
92/// What one slot records about itself, in `slot.json`.
93///
94/// The fields a maintenance verb fills — `maintaining`, `last_maintained` and
95/// `last_outcome` — are declared and read here and first written by that verb, so a
96/// consumer reading `pool status` against them meets one shape from the day slots exist.
97#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
98pub(crate) struct SlotRecord {
99    /// The schema version this record was written at.
100    pub version: u32,
101    /// The slot's number, which is its directory's name.
102    pub number: u32,
103    /// The identity key the slot belongs to.
104    pub identity: String,
105    /// The execution checkout the slot's clone borrows from — its lender. A slot is
106    /// bound to it: a session cloning from another checkout never takes this slot.
107    pub execution_checkout: PathBuf,
108    /// When the slot was cut.
109    pub created: Timestamp,
110    /// The claim the maintenance verb holds while it runs in the slot, or `null`.
111    pub maintaining: Option<Claim>,
112    /// When maintenance last finished, or `null` where none has run.
113    pub last_maintained: Option<Timestamp>,
114    /// How the last maintenance ended, or `null` where none has run.
115    pub last_outcome: Option<MaintenanceOutcome>,
116}
117
118/// The claim a maintenance run writes onto the slot it is working in.
119///
120/// Its owner *is* the process running the command, so a claim whose process is gone is
121/// void — cleared by the next reader rather than honoured — and the slot is idle again.
122#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
123pub(crate) struct Claim {
124    /// The process running the maintenance command.
125    pub pid: u32,
126    /// Its creation identity, so a later process wearing the same pid is not it.
127    pub started: ProcessStart,
128    /// When the claim was written.
129    pub since: Timestamp,
130}
131
132impl Claim {
133    /// Whether the process that wrote this claim is that same process, still running.
134    fn is_live(&self) -> bool {
135        workspace::process_started(self.pid) == Some(self.started)
136    }
137}
138
139/// How a maintenance run ended.
140#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
141#[serde(rename_all = "kebab-case")]
142pub enum MaintenanceOutcome {
143    /// The command exited zero inside its bound.
144    Succeeded,
145    /// The command exited non-zero, or was ended by a signal, inside its bound.
146    Failed {
147        /// The exit status, or `None` where a signal ended it.
148        exit: Option<i32>,
149    },
150    /// The bound elapsed first and the command was stopped.
151    TimedOut,
152}
153
154/// Where one slot is in its life, as `pool status` reports it.
155#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
156#[serde(tag = "state", rename_all = "kebab-case")]
157pub enum SlotState {
158    /// No open session names it and no live maintenance claims it: the next session
159    /// may take it.
160    Idle,
161    /// An open session record names it as its run root.
162    InUse {
163        /// The session working in it.
164        session: SessionToken,
165    },
166    /// A maintenance run whose process is still running claims it.
167    Maintaining {
168        /// The process running the maintenance command.
169        pid: u32,
170        /// When it claimed the slot, RFC3339 — checked where the record was read, and
171        /// spelled here as the amendment declares it.
172        // llmlint: ignore[invalid_states_unrepresentable] the approved amendment fixes
173        // this field as `String`, the way `Acknowledgement::recorded_at` is fixed; the
174        // value came through `Timestamp`'s conversion at the read boundary, so nothing
175        // that is not a timestamp reaches here.
176        since: String,
177    },
178    /// Its clone or worktree is missing or not a repository, or its record is
179    /// unreadable. The next session that would take it recreates it in place.
180    Broken {
181        /// What is wrong with it.
182        reason: String,
183    },
184}
185
186/// One slot, as `pool status` reports it.
187#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
188pub struct SlotStatus {
189    /// The slot's number.
190    pub number: u32,
191    /// The slot directory: the run root of every session placed on it.
192    pub path: PathBuf,
193    /// The execution checkout the slot is bound to. Empty where its record could not
194    /// be read.
195    pub execution_checkout: PathBuf,
196    /// Where it is in its life.
197    pub state: SlotState,
198    /// When maintenance last finished, RFC3339 — checked where the record was read,
199    /// and spelled here as the amendment declares it.
200    // llmlint: ignore[invalid_states_unrepresentable] the approved amendment fixes this
201    // field as `Option<String>`, the way `Acknowledgement::recorded_at` is fixed; the
202    // value came through `Timestamp`'s conversion at the read boundary.
203    pub last_maintained: Option<String>,
204    /// How the last maintenance ended.
205    pub last_outcome: Option<MaintenanceOutcome>,
206}
207
208/// How many sessions one identity can admit right now, and whether one request would
209/// be placed.
210///
211/// Advisory: it reads the same records `open` reads and applies the request's overrides
212/// over the same layering, and `open` is what decides. `admitted` is true for a request
213/// whose pinned branch an open session already holds, because that open resumes in
214/// place and places nothing.
215#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
216pub struct WorkspaceCapacity {
217    /// The identity key.
218    pub identity: String,
219    /// The pool size the request resolves to.
220    pub pool: u32,
221    /// How many slots exist, whatever their state.
222    pub slots: u32,
223    /// How many of them are idle. A broken slot is neither this nor in use — `slots`
224    /// exceeds the three counts by the broken ones — and a session still takes it,
225    /// recreating it in place, which `admits` and `admitted` count.
226    pub idle: u32,
227    /// How many an open session names.
228    pub in_use: u32,
229    /// How many a live maintenance run claims.
230    pub maintaining: u32,
231    /// The overflow bound the request resolves to.
232    pub overflow: Bound,
233    /// How many sessions of the identity are open under `runs/`, which is what the
234    /// overflow bound counts.
235    pub overflow_in_use: u32,
236    /// How many more opens the identity admits now, across its slots and its overflow.
237    pub admits: Bound,
238    /// Whether *this* request would be placed now.
239    pub admitted: bool,
240}
241
242/// What `pool status` reports: the capacity, and every slot.
243#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
244pub struct PoolStatus {
245    /// The resolved maintenance and the layer supplying it.
246    pub maintenance: crate::repository_config::MaintenanceSource,
247    /// The identity's capacity, resolved with no per-open override.
248    pub capacity: WorkspaceCapacity,
249    /// Every slot, by number.
250    pub slots: Vec<SlotStatus>,
251}
252
253/// What `pool prune` did: which slots it removed, and which it kept and why.
254#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
255pub struct PruneReport {
256    /// The slots removed, by number.
257    pub removed: Vec<u32>,
258    /// The slots kept, each with the reason.
259    pub kept: Vec<(u32, String)>,
260}
261
262/// The pool directory of one identity.
263pub(crate) fn pool_dir(identity_root: &Path) -> PathBuf {
264    identity_root.join("pool")
265}
266
267/// The lock identity that serializes placement over one identity's pool.
268///
269/// Held from the survey to the record's save, so two opens cannot both take one idle
270/// slot or both count the same overflow headroom. Short by construction — a shared
271/// clone and a checkout — and never held across the fetch, which happens before it.
272pub(crate) fn placement_identity(pool: &Path) -> String {
273    format!("pool:{}", pool.display())
274}
275
276/// Where one slot's record is written.
277fn record_path(slot: &Path) -> PathBuf {
278    slot.join("slot.json")
279}
280
281/// Read one slot's record, or say why it cannot be acted on.
282///
283/// Serde proves the shape and nothing else, and every field here is handed to git or
284/// to the filesystem afterwards — so a record that disagrees with the directory it
285/// was read from is refused here, the way a session record naming a different token
286/// than its file is: one declaring another version, numbered for another slot,
287/// belonging to another identity, or lending from a path that is not absolute is a
288/// broken slot rather than one to place a session on.
289fn read_record(
290    slot: &Path,
291    number: u32,
292    identity: &str,
293) -> std::result::Result<SlotRecord, String> {
294    let path = record_path(slot);
295    let raw = std::fs::read_to_string(&path)
296        .map_err(|e| format!("its record at {} cannot be read: {e}", path.display()))?;
297    let record: SlotRecord = serde_json::from_str(&raw)
298        .map_err(|e| format!("its record at {} is malformed: {e}", path.display()))?;
299    if record.version != SLOT_VERSION {
300        return Err(format!(
301            "its record at {} declares version {}; this build reads version {SLOT_VERSION}",
302            path.display(),
303            record.version
304        ));
305    }
306    if record.number != number {
307        return Err(format!(
308            "its record at {} is for slot {}, not for slot {number}",
309            path.display(),
310            record.number
311        ));
312    }
313    if record.identity != identity {
314        return Err(format!(
315            "its record at {} belongs to {:?}, not to {identity:?}",
316            path.display(),
317            record.identity
318        ));
319    }
320    if !record.execution_checkout.is_absolute() {
321        return Err(format!(
322            "its record at {} names an execution checkout at {}, which is not an absolute path",
323            path.display(),
324            record.execution_checkout.display()
325        ));
326    }
327    Ok(record)
328}
329
330/// Write one slot's record whole.
331fn write_record(slot: &Path, record: &SlotRecord) -> Result<()> {
332    let path = record_path(slot);
333    let json = serde_json::to_string_pretty(record).map_err(error::at("serialize", &path))?;
334    crate::home::atomic_write(&path, &format!("{json}\n"))
335}
336
337/// One slot as the survey found it.
338#[derive(Debug, Clone)]
339struct Surveyed {
340    number: u32,
341    dir: PathBuf,
342    /// Its record, where it read.
343    record: Option<SlotRecord>,
344    state: SlotState,
345}
346
347impl Surveyed {
348    fn clone_dir(&self) -> PathBuf {
349        self.dir.join("clone")
350    }
351
352    fn worktree(&self) -> PathBuf {
353        self.dir.join("worktree")
354    }
355
356    fn lender(&self) -> Option<&Path> {
357        self.record
358            .as_ref()
359            .map(|record| record.execution_checkout.as_path())
360    }
361
362    fn takeable(&self) -> bool {
363        matches!(self.state, SlotState::Idle | SlotState::Broken { .. })
364    }
365
366    fn status(&self) -> SlotStatus {
367        SlotStatus {
368            number: self.number,
369            path: self.dir.clone(),
370            execution_checkout: self.lender().map(Path::to_path_buf).unwrap_or_default(),
371            state: self.state.clone(),
372            last_maintained: self
373                .record
374                .as_ref()
375                .and_then(|record| record.last_maintained.clone())
376                .map(String::from),
377            last_outcome: self.record.as_ref().and_then(|record| record.last_outcome),
378        }
379    }
380}
381
382/// Everything one placement or one report reads about an identity's pool, read once.
383struct Survey {
384    pool: PathBuf,
385    slots: Vec<Surveyed>,
386    /// Every open record of the identity, whichever kind of root it names.
387    open: Vec<Record>,
388}
389
390impl Survey {
391    /// The open sessions of the identity placed under `runs/`, which is what the
392    /// overflow bound counts. A stale open record counts until it is closed or
393    /// forgotten, and the refusal names it with its liveness so an operator can tell.
394    fn overflow(&self) -> Vec<&Record> {
395        self.open
396            .iter()
397            .filter(|record| record.slot.is_none())
398            .collect()
399    }
400
401    fn count(&self, wanted: impl Fn(&SlotState) -> bool) -> u32 {
402        u32::try_from(self.slots.iter().filter(|slot| wanted(&slot.state)).count())
403            .unwrap_or(u32::MAX)
404    }
405}
406
407/// Read every slot of an identity, and decide the state of each.
408///
409/// A claim whose process is gone is void and is cleared here, by the reader, so a
410/// maintenance run that died leaves nothing a later reader has to reason about.
411fn survey(identity: &str, pool: &Path) -> Result<Survey> {
412    let open: Vec<Record> = workspace::all()?
413        .into_iter()
414        .filter(|record| record.identity == identity && record.state == Lifecycle::Open)
415        .collect();
416    let mut slots = Vec::new();
417    // A pool nothing has cut a slot under yet is empty; one that is there and cannot
418    // be read is refused naming it, because every answer below — idle, in use, how
419    // many exist — would otherwise be answered from a listing nobody got.
420    let entries = match std::fs::read_dir(pool) {
421        Ok(entries) => entries,
422        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
423            return Ok(Survey {
424                pool: pool.to_path_buf(),
425                slots,
426                open,
427            })
428        }
429        Err(e) => return Err(error::at("read the pool at", pool)(e)),
430    };
431    for entry in entries {
432        // llmlint: ignore[changed_behavior_has_e2e] uncovered: a listing that yields
433        // an entry it cannot even name. No interface this crate exposes can produce
434        // one — the entries here are directories this crate created — so a journey for
435        // it would be a fixture standing in for the kernel's `readdir`; it is refused
436        // rather than skipped for the reason the directory itself is.
437        let entry = entry.map_err(error::at("read the pool at", pool))?;
438        let dir = entry.path();
439        // Anything that is not a numbered directory is not a slot: a stray file an
440        // operator dropped here is neither counted nor removed.
441        let Some(number) = entry
442            .file_name()
443            .to_str()
444            .and_then(|name| name.parse::<u32>().ok())
445            .filter(|number| *number > 0)
446        else {
447            continue;
448        };
449        if !dir.is_dir() {
450            continue;
451        }
452        let (record, state) = state_of(&dir, number, identity, &open);
453        slots.push(Surveyed {
454            number,
455            dir,
456            record,
457            state,
458        });
459    }
460    slots.sort_by_key(|slot| slot.number);
461    Ok(Survey {
462        pool: pool.to_path_buf(),
463        slots,
464        open,
465    })
466}
467
468/// The state of one slot: broken, in use, maintaining, or idle — in that order, because
469/// each answer is decided by something the next would have to trust.
470fn state_of(
471    dir: &Path,
472    number: u32,
473    identity: &str,
474    open: &[Record],
475) -> (Option<SlotRecord>, SlotState) {
476    let record = match read_record(dir, number, identity) {
477        Ok(record) => record,
478        Err(reason) => return (None, SlotState::Broken { reason }),
479    };
480    let clone = dir.join("clone");
481    let worktree = dir.join("worktree");
482    if !git::is_repo(&clone) {
483        return (
484            Some(record),
485            SlotState::Broken {
486                reason: format!(
487                    "its clone at {} is missing or not a repository",
488                    clone.display()
489                ),
490            },
491        );
492    }
493    if !git::is_repo(&worktree) {
494        return (
495            Some(record),
496            SlotState::Broken {
497                reason: format!(
498                    "its worktree at {} is missing or not a repository",
499                    worktree.display()
500                ),
501            },
502        );
503    }
504    if let Some(holder) = open.iter().find(|held| held.run_root == dir) {
505        return (
506            Some(record),
507            SlotState::InUse {
508                session: SessionToken(holder.token.to_string()),
509            },
510        );
511    }
512    if let Some(claim) = record.maintaining.clone() {
513        if claim.is_live() {
514            return (
515                Some(record),
516                SlotState::Maintaining {
517                    pid: claim.pid,
518                    since: claim.since.into(),
519                },
520            );
521        }
522        // Void: the process that wrote it is gone. Cleared best-effort, because the
523        // answer is the same whether or not the write lands.
524        let mut cleared = record.clone();
525        cleared.maintaining = None;
526        let _ = write_record(dir, &cleared);
527        return (Some(cleared), SlotState::Idle);
528    }
529    (Some(record), SlotState::Idle)
530}
531
532/// The local branches of a slot's clone that its lender does not reach: the ones a
533/// hand-back could not copy, which is the only way a branch outlives a return there.
534///
535/// A clone that is not there holds nothing, which is the one negative answer that is
536/// a fact rather than a failure. One that is there and cannot be listed is `Err`,
537/// carrying git's refusal: a count nobody got is not a count of none, and what this
538/// decides is whether a slot may be removed.
539fn retained_branches(
540    clone: &Path,
541    lender: Option<&Path>,
542) -> std::result::Result<Vec<String>, String> {
543    if !git::is_repo(clone) {
544        return Ok(Vec::new());
545    }
546    let branches = git::branches(clone).map_err(|e| e.to_string())?;
547    Ok(branches
548        .into_iter()
549        .filter(|branch| {
550            let reached = match (git::tip(clone, &format!("refs/heads/{branch}")), lender) {
551                (Some(tip), Some(lender)) => git::refs_reach(lender, &tip),
552                _ => false,
553            };
554            !reached
555        })
556        .collect())
557}
558
559/// Why an idle slot is kept rather than removed, or `None` where it may go.
560///
561/// `judge` is the checkout a slot with no readable lender is judged against — the
562/// requester's execution checkout at a placement, the publication checkout at a prune
563/// — so a slot whose record is gone can still be shown to retain nothing, rather than
564/// standing broken for ever because nothing could say.
565fn keeps(slot: &Surveyed, judge: &Path) -> Option<String> {
566    match &slot.state {
567        SlotState::InUse { session } => {
568            return Some(format!("session {} is working in it", session.0));
569        }
570        SlotState::Maintaining { pid, since } => {
571            return Some(format!(
572                "a maintenance run (pid {pid}) has claimed it since {since}"
573            ));
574        }
575        SlotState::Idle | SlotState::Broken { .. } => {}
576    }
577    // llmlint: ignore[changed_behavior_has_e2e] uncovered: git declining to list the
578    // branches of a repository this crate has just read as one. No interface this
579    // crate exposes produces that, and what it would prove is that the slot is
580    // *kept*, which is the answer every other unknown here resolves to.
581    let lender = slot.lender().unwrap_or(judge);
582    let retained = match retained_branches(&slot.clone_dir(), Some(lender)) {
583        Ok(retained) => retained,
584        Err(reason) => {
585            return Some(format!(
586                "its clone could not be asked what it retains, and a count nobody got is not \
587                 a count of none: {reason}"
588            ))
589        }
590    };
591    if retained.is_empty() {
592        return None;
593    }
594    let names: Vec<&str> = retained.iter().map(String::as_str).collect();
595    Some(format!(
596        "its clone retains {} — {} its lender {} would not take, and removing the slot is \
597         what would make {} unreachable",
598        guidance::listed(&names),
599        match retained.len() {
600            1 => "a branch",
601            _ => "branches",
602        },
603        lender.display(),
604        match retained.len() {
605            1 => "it",
606            _ => "them",
607        },
608    ))
609}
610
611/// Remove one slot that is proven idle, under the exclusive take that proves nobody is
612/// inside it right now.
613fn remove(slot: &Surveyed) -> std::result::Result<(), String> {
614    let Some(_exclusive) = lock::try_exclusive(&workspace::occupancy_identity(&slot.dir))
615        .map_err(|e| e.to_string())?
616    else {
617        return Err("a command is working in it right now".to_owned());
618    };
619    std::fs::remove_dir_all(&slot.dir).map_err(|e| format!("it could not be removed: {e}"))
620}
621
622/// Shed every idle slot above the file's pool size, highest number first, keeping any
623/// that retains a branch.
624///
625/// Against the **file's** pool and never a per-process override: `--pool` on one open
626/// and `ONEVCS_POOL` in a dispatch's environment place that one session, and a
627/// per-process value that removed slots would take the warm pool down every time a
628/// node was handed one.
629fn shed(survey: &mut Survey, file_pool: u32, judge: &Path) {
630    let mut removed = Vec::new();
631    for slot in sheddable(survey, file_pool, judge) {
632        if remove(slot).is_ok() {
633            removed.push(slot.number);
634        }
635    }
636    survey.slots.retain(|slot| !removed.contains(&slot.number));
637}
638
639/// The slots a shed against `file_pool` removes, in the order it removes them:
640/// takeable ones above the pool size, highest number first, passing over any that is
641/// kept for what it retains.
642///
643/// One answer for the shed itself and for the capacity that predicts it, so the
644/// advisory read cannot count a slot as gone that the shed keeps.
645fn sheddable<'a>(survey: &'a Survey, file_pool: u32, judge: &Path) -> Vec<&'a Surveyed> {
646    let mut surplus = u32::try_from(survey.slots.len())
647        .unwrap_or(u32::MAX)
648        .saturating_sub(file_pool);
649    let mut shed = Vec::new();
650    for slot in survey.slots.iter().rev() {
651        if surplus == 0 {
652            break;
653        }
654        if !slot.takeable() || keeps(slot, judge).is_some() {
655            continue;
656        }
657        shed.push(slot);
658        surplus -= 1;
659    }
660    shed
661}
662
663/// Where a session was placed.
664#[derive(Debug)]
665pub(crate) enum Placement {
666    /// On a slot, whose directory is the session's run root.
667    Slot {
668        /// Its number.
669        number: u32,
670        /// Its directory.
671        dir: PathBuf,
672        /// How much of it the session found already there.
673        cut: Cut,
674        /// The exclusive take that keeps every other placement, prune and shed off it
675        /// until the session's record names it. Dropped by the caller after the save.
676        _held: lock::Guard,
677        /// The serialization over the whole pool, dropped with the take above.
678        _serial: lock::Guard,
679    },
680    /// Under `runs/`, exactly as every session was placed before there were slots.
681    RunRoot {
682        /// The serialization over the whole pool, held through the record's save so
683        /// two overflow opens cannot both count the same headroom. `None` where the
684        /// identity is not pooled and nothing was counted.
685        _serial: Option<lock::Guard>,
686    },
687}
688
689/// Everything a placement decision needs to know about the request.
690pub(crate) struct Ask<'a> {
691    pub resolution: &'a Resolution,
692    pub execution: &'a Path,
693    pub resolved: &'a Resolved,
694    pub pinned: Option<&'a Ref>,
695    pub identity_root: &'a Path,
696}
697
698/// Decide where a session goes, in the manager's ruled order and never waiting.
699///
700/// Surplus idle slots are shed first; then an idle slot bound to this lender — the one
701/// the pinned branch's most recent closed session used, where there is one; then a new
702/// slot at the lowest free number while fewer than `pool` exist; then a run root under
703/// `runs/` while the identity's overflow admits one; then a refusal naming every holder.
704/// A `--pool 0` skips the slots and spends the overflow, because the cap is about disk
705/// and a session that opted out of reuse still spends it.
706pub(crate) fn place(ask: &Ask<'_>) -> Result<Placement> {
707    let pool = pool_dir(ask.identity_root);
708    crate::home::ensure_dir(&pool)?;
709    let serial = lock::exclusive(&placement_identity(&pool))?;
710    let mut survey = survey(&ask.resolution.key, &pool)?;
711    shed(&mut survey, ask.resolved.file_pool, ask.execution);
712    let wanted = ask.resolved.pool.value;
713    if wanted > 0 {
714        let taken = match take_idle(&survey, ask)? {
715            Some(taken) => Some(taken),
716            None => match recreate_broken(&survey, ask)? {
717                Some(recreated) => Some(recreated),
718                None if u32::try_from(survey.slots.len()).unwrap_or(u32::MAX) < wanted => {
719                    Some(cut_new(&survey, ask)?)
720                }
721                None => None,
722            },
723        };
724        if let Some(taken) = taken {
725            return Ok(Placement::Slot {
726                number: taken.number,
727                dir: taken.dir.clone(),
728                cut: taken.cut,
729                _held: taken.held,
730                _serial: serial,
731            });
732        }
733    }
734    let overflow_in_use = u32::try_from(survey.overflow().len()).unwrap_or(u32::MAX);
735    if ask.resolved.overflow.value.admits(overflow_in_use) {
736        return Ok(Placement::RunRoot {
737            _serial: Some(serial),
738        });
739    }
740    Err(exhausted(ask, &survey))
741}
742
743/// How much of a slot a placement found already there, which is how much `open` has
744/// still to build.
745#[derive(Debug, Clone, Copy, PartialEq, Eq)]
746pub(crate) enum Cut {
747    /// Nothing: the clone and the worktree are cut from the lender.
748    Fresh,
749    /// The clone, with every ref it retains; only the worktree is cut, from that
750    /// clone — a broken slot whose clone is still a repository.
751    KeptClone,
752    /// Both, standing as the last return left them: the branch is swapped in place.
753    Warm,
754}
755
756impl Cut {
757    /// Whether the session's opening event reports the slot as cut for it rather
758    /// than taken warm: a worktree built from nothing is, whatever the clone kept.
759    pub(crate) fn created(self) -> bool {
760        self != Cut::Warm
761    }
762}
763
764/// A slot taken for a placement: its number, its directory, how much of it was
765/// there, and the exclusive take.
766struct Taken {
767    number: u32,
768    dir: PathBuf,
769    cut: Cut,
770    held: lock::Guard,
771}
772
773/// The idle slot of this lender the session takes, preferring the pinned branch's own.
774fn take_idle(survey: &Survey, ask: &Ask<'_>) -> Result<Option<Taken>> {
775    let preferred = match ask.pinned {
776        Some(branch) => last_slot_of(&ask.resolution.key, branch)?,
777        None => None,
778    };
779    let mut candidates: Vec<&Surveyed> = survey
780        .slots
781        .iter()
782        .filter(|slot| slot.state == SlotState::Idle && slot.lender() == Some(ask.execution))
783        .collect();
784    candidates.sort_by_key(|slot| (Some(slot.number) != preferred, slot.number));
785    for slot in candidates {
786        // The exclusive take is what closes the window between the survey and the
787        // record: a prune or another placement that has it is inside this slot now.
788        let Some(held) = lock::try_exclusive(&workspace::occupancy_identity(&slot.dir))? else {
789            continue;
790        };
791        // A slot whose last session closed without returning it — a publication closes
792        // the record, and `session close` returns the tree afterwards — may still have
793        // that session's worker inside; the same census the close refuses on skips it.
794        if !returned(&slot.worktree()) && !processes::holding(&slot.dir).is_empty() {
795            continue;
796        }
797        return Ok(Some(Taken {
798            number: slot.number,
799            dir: slot.dir.clone(),
800            cut: Cut::Warm,
801            held,
802        }));
803    }
804    Ok(None)
805}
806
807/// A broken slot of this lender — or of no readable lender — recreated in place,
808/// keeping whatever of it is still usable.
809///
810/// Only what is broken is rebuilt, because a slot's clone is where a branch the
811/// hand-back could not copy lives. A clone that is still a repository is kept with
812/// every ref it retains: an unreadable record is rewritten beside it, and a worktree
813/// that is missing or not a repository is cut again from that clone. Only a clone that
814/// is itself unusable — missing, or not a repository git can read — is removed, and
815/// there is nothing it could be asked to retain.
816fn recreate_broken(survey: &Survey, ask: &Ask<'_>) -> Result<Option<Taken>> {
817    for slot in &survey.slots {
818        if !matches!(slot.state, SlotState::Broken { .. }) {
819            continue;
820        }
821        let clone = slot.clone_dir();
822        let worktree = slot.worktree();
823        // The lender the slot is bound to: its record's, or — where the record could
824        // not be read — the checkout its clone borrows objects from. A slot bound to
825        // another lender is not this request's to rebuild any more than to take.
826        let lender = slot
827            .lender()
828            .map(Path::to_path_buf)
829            .or_else(|| lender_of(&clone));
830        if lender.is_some_and(|lender| lender != ask.execution) {
831            continue;
832        }
833        let Some(held) = lock::try_exclusive(&workspace::occupancy_identity(&slot.dir))? else {
834            continue;
835        };
836        let cut = if git::is_repo(&clone) {
837            if git::is_repo(&worktree) {
838                Cut::Warm
839            } else {
840                if worktree.exists() {
841                    std::fs::remove_dir_all(&worktree)
842                        .map_err(error::at("remove the broken worktree at", &worktree))?;
843                }
844                // The clone still registers the worktree it lost; a fresh `worktree
845                // add` at the same path is refused until that registration is gone.
846                git::worktree_prune(&clone)?;
847                Cut::KeptClone
848            }
849        } else {
850            std::fs::remove_dir_all(&slot.dir)
851                .map_err(error::at("remove the broken slot at", &slot.dir))?;
852            std::fs::create_dir(&slot.dir).map_err(error::at("create", &slot.dir))?;
853            Cut::Fresh
854        };
855        write_record(&slot.dir, &fresh_record(slot.number, ask))?;
856        return Ok(Some(Taken {
857            number: slot.number,
858            dir: slot.dir.clone(),
859            cut,
860            held,
861        }));
862    }
863    Ok(None)
864}
865
866/// The checkout a slot's clone borrows its objects from, read off the clone itself
867/// for a slot whose record cannot say.
868///
869/// A shared clone records its lender's object store in `objects/info/alternates`,
870/// which for a checkout is `<checkout>/.git/objects`; anything else there is not a
871/// lender this crate can name, and answers `None`.
872fn lender_of(clone: &Path) -> Option<PathBuf> {
873    let alternates = git::objects_dir(clone).ok()?.join("info/alternates");
874    let recorded = std::fs::read_to_string(alternates).ok()?;
875    let objects = PathBuf::from(recorded.lines().next()?.trim());
876    let git_dir = objects
877        .file_name()
878        .is_some_and(|name| name == "objects")
879        .then(|| objects.parent())
880        .flatten()?;
881    git_dir
882        .file_name()
883        .is_some_and(|name| name == ".git")
884        .then(|| git_dir.parent().map(Path::to_path_buf))
885        .flatten()
886}
887
888/// A new slot at the lowest free number.
889///
890/// `create_dir` rather than `create_dir_all`, so a number two placements race for is
891/// taken by exactly one of them and the other moves on to the next.
892fn cut_new(survey: &Survey, ask: &Ask<'_>) -> Result<Taken> {
893    let mut number: u32 = 1;
894    loop {
895        if survey.slots.iter().any(|slot| slot.number == number) {
896            number += 1;
897            continue;
898        }
899        let dir = survey.pool.join(number.to_string());
900        match std::fs::create_dir(&dir) {
901            Ok(()) => {}
902            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
903                number += 1;
904                continue;
905            }
906            Err(e) => return Err(error::at("create", &dir)(e)),
907        }
908        let held = lock::try_exclusive(&workspace::occupancy_identity(&dir))?.ok_or_else(|| {
909            error::invalid(format!(
910                "the new slot {} is already occupied before it was cut",
911                dir.display()
912            ))
913        })?;
914        write_record(&dir, &fresh_record(number, ask))?;
915        return Ok(Taken {
916            number,
917            dir,
918            cut: Cut::Fresh,
919            held,
920        });
921    }
922}
923
924fn fresh_record(number: u32, ask: &Ask<'_>) -> SlotRecord {
925    SlotRecord {
926        version: SLOT_VERSION,
927        number,
928        identity: ask.resolution.key.clone(),
929        execution_checkout: ask.execution.to_path_buf(),
930        created: Timestamp::now(),
931        maintaining: None,
932        last_maintained: None,
933        last_outcome: None,
934    }
935}
936
937/// The slot the most recent closed session of this identity on `branch` used, when
938/// there is one: a retry pinned to a preserved branch goes back where its predecessor
939/// built.
940fn last_slot_of(identity: &str, branch: &Ref) -> Result<Option<u32>> {
941    let mut newest: Option<(std::time::SystemTime, u32)> = None;
942    for record in workspace::all()? {
943        let Some(number) = record.slot else {
944            continue;
945        };
946        if record.identity != identity
947            || record.branch != *branch
948            || record.state != Lifecycle::Closed
949        {
950            continue;
951        }
952        let written = workspace::record_path(&record.token)
953            .ok()
954            .and_then(|path| std::fs::metadata(path).ok())
955            .and_then(|meta| meta.modified().ok())
956            .unwrap_or(std::time::UNIX_EPOCH);
957        if newest.is_none_or(|(when, _)| written > when) {
958            newest = Some((written, number));
959        }
960    }
961    Ok(newest.map(|(_, number)| number))
962}
963
964/// Whether one slot is idle now: no open session record names it and no live
965/// maintenance claims it — the one proof a placement takes a slot on.
966pub(crate) fn is_idle(identity: &str, slot: &Path) -> Result<bool> {
967    let Some(pool) = slot.parent() else {
968        return Ok(false);
969    };
970    Ok(survey(identity, pool)?
971        .slots
972        .iter()
973        .any(|surveyed| surveyed.dir == slot && surveyed.state == SlotState::Idle))
974}
975
976/// Whether a slot's worktree stands as a return leaves it: detached, and clean.
977pub(crate) fn returned(worktree: &Path) -> bool {
978    git::current_branch(worktree).is_ok_and(|branch| branch == workspace::DETACHED_HEAD)
979        && git::is_dirty(worktree).is_ok_and(|dirty| !dirty)
980}
981
982/// Why no session can be placed now: the identity, the resolved limits with their
983/// sources, and every holder — the way the close's occupancy refusal names processes.
984fn exhausted(ask: &Ask<'_>, survey: &Survey) -> Error {
985    let resolved = ask.resolved;
986    let slots = survey.slots.len();
987    let idle = survey.count(|state| *state == SlotState::Idle);
988    let in_use = survey.count(|state| matches!(state, SlotState::InUse { .. }));
989    let maintaining = survey.count(|state| matches!(state, SlotState::Maintaining { .. }));
990    let overflow = survey.overflow();
991    let mut holders: Vec<String> = Vec::new();
992    for slot in &survey.slots {
993        match &slot.state {
994            SlotState::InUse { session } => {
995                let held = survey
996                    .open
997                    .iter()
998                    .find(|record| *record.token == *session.0);
999                holders.push(match held {
1000                    Some(record) => format!("slot {}: {}", slot.number, describe(record)),
1001                    None => format!("slot {}: session {}", slot.number, session.0),
1002                });
1003            }
1004            SlotState::Maintaining { pid, since } => holders.push(format!(
1005                "slot {}: a maintenance run, pid {pid}, since {since}",
1006                slot.number
1007            )),
1008            SlotState::Idle => holders.push(format!(
1009                "slot {}: idle, bound to {}",
1010                slot.number,
1011                slot.lender()
1012                    .map(|lender| lender.display().to_string())
1013                    .unwrap_or_else(|| "(unknown)".to_owned())
1014            )),
1015            SlotState::Broken { reason } => {
1016                holders.push(format!("slot {}: broken — {reason}", slot.number));
1017            }
1018        }
1019    }
1020    for record in &overflow {
1021        holders.push(format!("under runs/: {}", describe(record)));
1022    }
1023    let file = workspaces::default_path()
1024        .map(|path| path.display().to_string())
1025        .unwrap_or_else(|_| "$ONEVCS_HOME/workspaces.yml".to_owned());
1026    Error::PoolExhausted {
1027        reason: format!(
1028            "no session of {identity} can be placed now: its pool is {pool} (from {pool_from}) — \
1029             {slots} created, {idle} idle, {in_use} in use, {maintaining} maintaining — and its \
1030             overflow is {overflow} (from {overflow_from}) with {overflow_in_use} in use. \
1031             {holding}. Close a session, raise pool or overflow for this identity in {file}, \
1032             or open this one with `--overflow unlimited`",
1033            identity = ask.resolution.key,
1034            pool = resolved.pool.value,
1035            pool_from = resolved.pool.from,
1036            overflow = resolved.overflow.value,
1037            overflow_from = resolved.overflow.from,
1038            overflow_in_use = overflow.len(),
1039            holding = match holders.is_empty() {
1040                true => "Nothing holds it".to_owned(),
1041                false => format!("Held by: {}", holders.join("; ")),
1042            },
1043        ),
1044    }
1045}
1046
1047/// One holder, as the refusal names it: token, branch, worktree, owner pid, liveness.
1048fn describe(record: &Record) -> String {
1049    format!(
1050        "session {} on {:?} in {}, opened by pid {} ({})",
1051        record.token,
1052        record.branch,
1053        record.worktree.display(),
1054        record.owner_pid,
1055        record.liveness().as_str(),
1056    )
1057}
1058
1059/// Which slot, if any, `request` resolves to as its request-level view: the identity,
1060/// its execution checkout, and the resolved policy.
1061struct Asked {
1062    resolution: Resolution,
1063    execution: PathBuf,
1064    resolved: Resolved,
1065    identity_root: PathBuf,
1066}
1067
1068fn asked(request: &SessionRequest) -> Result<Asked> {
1069    let registry = store::load()?;
1070    let resolution = store::resolve(&registry, &request.repo)?;
1071    let execution = workspace::execution_checkout(
1072        &registry,
1073        &resolution,
1074        request.execution_checkout.as_deref(),
1075    )?;
1076    let resolved = workspaces::resolve_for(
1077        &resolution,
1078        Overrides {
1079            pool: request.pool,
1080            overflow: request.overflow,
1081        },
1082    )?;
1083    let identity_root = workspace::identity_dir(&resolution.key)?;
1084    Ok(Asked {
1085        resolution,
1086        execution,
1087        resolved,
1088        identity_root,
1089    })
1090}
1091
1092/// The capacity one request meets, read the way `open` reads it.
1093fn capacity_of(asked: &Asked, survey: &Survey, request: &SessionRequest) -> WorkspaceCapacity {
1094    let resolved = &asked.resolved;
1095    let slots = u32::try_from(survey.slots.len()).unwrap_or(u32::MAX);
1096    let idle = survey.count(|state| *state == SlotState::Idle);
1097    let takeable =
1098        survey.count(|state| matches!(state, SlotState::Idle | SlotState::Broken { .. }));
1099    let in_use = survey.count(|state| matches!(state, SlotState::InUse { .. }));
1100    let maintaining = survey.count(|state| matches!(state, SlotState::Maintaining { .. }));
1101    let overflow_in_use = u32::try_from(survey.overflow().len()).unwrap_or(u32::MAX);
1102    // What the next open finds after the shed it performs first: exactly the slots
1103    // the shed would remove are gone — never one it keeps for what it retains — and
1104    // the rest stay.
1105    let shed = u32::try_from(sheddable(survey, resolved.file_pool, &asked.execution).len())
1106        .unwrap_or(u32::MAX);
1107    let takeable_after = takeable.saturating_sub(shed);
1108    let slots_after = slots.saturating_sub(shed);
1109    let can_create = resolved.pool.value.saturating_sub(slots_after);
1110    let admits = match resolved.pool.value {
1111        0 => resolved.overflow.value.headroom(overflow_in_use),
1112        _ => match resolved.overflow.value.headroom(overflow_in_use) {
1113            Bound::Unlimited => Bound::Unlimited,
1114            Bound::Bounded(room) => Bound::Bounded(
1115                takeable_after
1116                    .saturating_add(can_create)
1117                    .saturating_add(room),
1118            ),
1119        },
1120    };
1121    let resumes = request.branch.as_ref().is_some_and(|pinned| {
1122        survey.open.iter().any(|record| {
1123            *record.branch == **pinned
1124                && record.execution_checkout == asked.execution
1125                && record.run_root.is_dir()
1126                && record.clone.is_dir()
1127        })
1128    });
1129    let mine = survey.slots.iter().any(|slot| {
1130        slot.takeable() && slot.lender().is_none_or(|lender| lender == asked.execution)
1131    });
1132    let admitted = resumes
1133        || (resolved.pool.value > 0 && (mine || can_create > 0))
1134        || resolved.overflow.value.admits(overflow_in_use);
1135    WorkspaceCapacity {
1136        identity: asked.resolution.key.clone(),
1137        pool: resolved.pool.value,
1138        slots,
1139        idle,
1140        in_use,
1141        maintaining,
1142        overflow: resolved.overflow.value,
1143        overflow_in_use,
1144        admits,
1145        admitted,
1146    }
1147}
1148
1149/// How many sessions the request's identity admits right now, and whether the request
1150/// itself would be placed.
1151pub fn workspace_capacity(request: &SessionRequest) -> Result<WorkspaceCapacity> {
1152    let asked = asked(request)?;
1153    let survey = survey(&asked.resolution.key, &pool_dir(&asked.identity_root))?;
1154    Ok(capacity_of(&asked, &survey, request))
1155}
1156
1157/// The pool of one repository: its capacity with no per-open override, and every slot.
1158pub fn pool_status(repo: &str) -> Result<PoolStatus> {
1159    let request = SessionRequest {
1160        repo: repo.to_owned(),
1161        branch: None,
1162        branch_name: None,
1163        branch_prefix: None,
1164        base: None,
1165        execution_checkout: None,
1166        pool: None,
1167        overflow: None,
1168        labels: Default::default(),
1169        refuse_conflicts: false,
1170        caller_merges_base: false,
1171    };
1172    let asked = asked(&request)?;
1173    let survey = survey(&asked.resolution.key, &pool_dir(&asked.identity_root))?;
1174    Ok(PoolStatus {
1175        maintenance: crate::repository_config::resolve(&asked.resolution, &asked.resolved),
1176        capacity: capacity_of(&asked, &survey, &request),
1177        slots: survey.slots.iter().map(Surveyed::status).collect(),
1178    })
1179}
1180
1181/// Remove every slot of one repository nobody is in — idle, or broken — whose clone
1182/// retains no branch, and say why each other one was kept.
1183///
1184/// A broken slot goes the way an idle one does because it is takeable the way an idle
1185/// one is: the next open would recreate it in place, and what that recreation keeps is
1186/// exactly the retained branch that keeps a slot here.
1187pub fn pool_prune(repo: &str) -> Result<PruneReport> {
1188    let request = SessionRequest {
1189        repo: repo.to_owned(),
1190        branch: None,
1191        branch_name: None,
1192        branch_prefix: None,
1193        base: None,
1194        execution_checkout: None,
1195        pool: None,
1196        overflow: None,
1197        labels: Default::default(),
1198        refuse_conflicts: false,
1199        caller_merges_base: false,
1200    };
1201    let asked = asked(&request)?;
1202    let pool = pool_dir(&asked.identity_root);
1203    let mut report = PruneReport {
1204        removed: Vec::new(),
1205        kept: Vec::new(),
1206    };
1207    if !pool.is_dir() {
1208        return Ok(report);
1209    }
1210    // Serialized against placement, so a slot this is removing is not one an open is
1211    // in the middle of taking.
1212    let _serial = lock::exclusive(&placement_identity(&pool))?;
1213    let survey = survey(&asked.resolution.key, &pool)?;
1214    for slot in &survey.slots {
1215        if let Some(why) = keeps(slot, &asked.resolution.publication) {
1216            report.kept.push((slot.number, why));
1217            continue;
1218        }
1219        match remove(slot) {
1220            Ok(()) => report.removed.push(slot.number),
1221            Err(why) => report.kept.push((slot.number, why)),
1222        }
1223    }
1224    Ok(report)
1225}