Skip to main content

mecha_core/
permit.rs

1//! How many background runs may hold the model at once — as files in a
2//! directory.
3//!
4//! **A latency control, and the measurement says so.** The scarce resource is
5//! a scheduling seat on llama-server, not memory: `-c` is divided across
6//! slots and committed at startup, so an idle conversation costs no extra
7//! VRAM. Against `-np 4` on 2026-08-26, six concurrent conversations reached
8//! **1.67×** the throughput of one while each turn took **3.6×** as long, and
9//! four reached 1.58× at 2.5×. Throughput saturates at the seat count, so a
10//! fifth concurrent conversation is close to pure loss — no more work done,
11//! everybody waiting longer.
12//!
13//! **It is deliberately not about the prefix cache.** The obvious argument —
14//! bound conversations so they stop evicting each other's prefix — was
15//! measured and refuted in the same run: six conversations on four slots
16//! re-prefilled 31 tokens per turn after the first, never a transcript.
17//! `-cram` already handles that, and anything validating this module on
18//! prefix reuse will find no effect, because there is none. Judge it on
19//! per-turn latency.
20//!
21//! **Files, because the contenders are separate processes.** A delegation is
22//! a chat session inside `mecha serve` or a detached `mecha tasks work`
23//! child, and they share nothing but the filesystem — so an in-process
24//! semaphore (`batch.rs`'s shape, and what the design originally called for)
25//! would bound each process separately and none of them together. This is
26//! [`crate::runmarker`]'s mechanism asked a different question: *may I
27//! start*, rather than *am I running*. Its four rules carry over unchanged,
28//! and the load-bearing one is the pid range check in
29//! [`crate::process_alive`] — `kill(-1, 0)` succeeds, so a naive liveness
30//! test would report every dead holder as alive and leak the pool shut.
31//!
32//! **Reserve, never preempt.** The owner must not queue behind delegations,
33//! and the way to guarantee that is to leave a seat empty rather than to kill
34//! something occupying one: a request in flight cannot be preempted anyway,
35//! and cancelling a run to make room throws away a partial turn to save
36//! latency the reserve already saved. So interactive work — a chat turn, a
37//! voice call, a Slack thread — **never takes a permit at all**. It is not
38//! admitted; it is simply not counted, which is the same thing done without a
39//! mechanism that could fail closed against the person the system is for.
40
41use anyhow::Result;
42use chrono::{DateTime, Utc};
43use serde::{Deserialize, Serialize};
44use std::path::{Path, PathBuf};
45
46/// Background runs allowed at once, against `-np 4`.
47///
48/// One seat short of the server's, so the owner's turn never queues. Measured
49/// rather than chosen: see the module doc and the measurement record.
50pub const DEFAULT_BACKGROUND_PERMITS: usize = 3;
51
52/// A held seat: who has it, since when, and what for.
53#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct Permit {
55    pub pid: u32,
56    pub taken_at: DateTime<Utc>,
57    /// What the holder is doing, for a human reading `mecha doctor` or a
58    /// refusal. Never matched on.
59    #[serde(default, skip_serializing_if = "Option::is_none")]
60    pub what: Option<String>,
61}
62
63/// The pool, as a directory.
64pub struct Permits {
65    dir: PathBuf,
66    capacity: usize,
67}
68
69/// A held permit, released when dropped.
70///
71/// **Released on drop rather than by a call**, because every early return in
72/// a run is a place a release would be forgotten — and a leaked permit is
73/// invisible until the pool is full, at which point the symptom is that
74/// nothing starts and nothing says why. The `Drop` still cannot run on a
75/// SIGKILL, which is what the pid check is for: the next caller reclaims it.
76pub struct Held {
77    path: PathBuf,
78}
79
80impl Drop for Held {
81    fn drop(&mut self) {
82        let _ = std::fs::remove_file(&self.path);
83    }
84}
85
86impl Permits {
87    pub fn new(dir: impl Into<PathBuf>, capacity: usize) -> Self {
88        Permits {
89            dir: dir.into(),
90            capacity,
91        }
92    }
93
94    pub fn dir(&self) -> &Path {
95        &self.dir
96    }
97
98    /// Live holders, sweeping any whose process is gone.
99    ///
100    /// The sweep is what makes a crash cost one stale file rather than a
101    /// permanently smaller pool — `runmarker`'s rule, and the reason this is
102    /// a read that also writes.
103    pub fn live(&self) -> Vec<Permit> {
104        let Ok(entries) = std::fs::read_dir(&self.dir) else {
105            return Vec::new();
106        };
107        let mut held = Vec::new();
108        for entry in entries.flatten() {
109            let path = entry.path();
110            if path.extension().and_then(|e| e.to_str()) != Some("permit") {
111                continue;
112            }
113            let parsed = std::fs::read_to_string(&path)
114                .ok()
115                .and_then(|t| serde_json::from_str::<Permit>(&t).ok());
116            match parsed {
117                Some(p) if crate::process_alive(p.pid) => held.push(p),
118                // Gone, or unreadable. Both are swept: a permit file nothing
119                // can parse is holding a seat for nobody, which is worse than
120                // losing the record of who had it.
121                _ => {
122                    let _ = std::fs::remove_file(&path);
123                }
124            }
125        }
126        held
127    }
128
129    /// Take a seat, or say who has them.
130    ///
131    /// **Never blocks.** A caller that waits is a caller holding a slot in
132    /// some *other* queue — a web request, a Slack ack with three seconds to
133    /// answer in — so this reports the refusal and lets the caller decide,
134    /// which for a delegation means telling the owner it is queued rather
135    /// than freezing the tap they just used.
136    ///
137    /// The race is real and deliberately unguarded: two callers can both see
138    /// a free seat and both take it. The cost is one extra concurrent run
139    /// against a soft latency target, and the alternative is a lock held
140    /// across process boundaries for the length of an agent run — which is
141    /// the thing `runmarker` refuses to do with the trigger flock, for the
142    /// same reason. Over-admitting by one occasionally is cheaper than a
143    /// stuck pool.
144    pub fn take(&self, what: &str) -> Result<Result<Held, Vec<Permit>>> {
145        let held = self.live();
146        if held.len() >= self.capacity {
147            return Ok(Err(held));
148        }
149        crate::create_private_dir(&self.dir)?;
150        let permit = Permit {
151            pid: std::process::id(),
152            taken_at: Utc::now(),
153            what: (!what.is_empty()).then(|| what.to_string()),
154        };
155        let path = self.dir.join(format!("{}.permit", std::process::id()));
156        std::fs::write(&path, serde_json::to_string_pretty(&permit)?)?;
157        Ok(Ok(Held { path }))
158    }
159
160    pub fn capacity(&self) -> usize {
161        self.capacity
162    }
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168
169    fn scratch(name: &str) -> PathBuf {
170        let dir = std::env::temp_dir().join(format!(
171            "mecha-permit-{name}-{}-{:?}",
172            std::process::id(),
173            std::thread::current().id()
174        ));
175        let _ = std::fs::remove_dir_all(&dir);
176        dir
177    }
178
179    #[test]
180    fn a_permit_is_held_until_dropped() {
181        let p = Permits::new(scratch("basic"), 2);
182        assert!(p.live().is_empty());
183        let one = p.take("task-a").unwrap().expect("a free seat");
184        assert_eq!(p.live().len(), 1);
185        drop(one);
186        assert!(p.live().is_empty(), "released on drop, not by a call");
187    }
188
189    /// The refusal names who is holding, because "queued" with no reason is
190    /// the shape a person cannot act on.
191    #[test]
192    fn a_full_pool_refuses_and_says_who_has_it() {
193        let p = Permits::new(scratch("full"), 1);
194        let _one = p.take("task-a").unwrap().expect("a free seat");
195        match p.take("task-b").unwrap() {
196            Ok(_) => panic!("capacity ignored"),
197            Err(held) => {
198                assert_eq!(held.len(), 1);
199                assert_eq!(held[0].what.as_deref(), Some("task-a"));
200            }
201        }
202    }
203
204    /// A holder killed outright cannot run its own `Drop`, so the pool would
205    /// shrink by one for the life of the machine. This is the check that
206    /// rests on the pid range guard: `kill(-1, 0)` succeeds, and a naive
207    /// liveness test would call every dead holder alive.
208    #[test]
209    fn a_permit_whose_process_is_gone_is_reclaimed() {
210        let p = Permits::new(scratch("dead"), 1);
211        crate::create_private_dir(p.dir()).unwrap();
212        std::fs::write(
213            p.dir().join("0.permit"),
214            serde_json::json!({"pid": 0, "taken_at": Utc::now().to_rfc3339()}).to_string(),
215        )
216        .unwrap();
217        assert!(p.live().is_empty(), "a dead pid is not a holder");
218        assert!(
219            p.take("task-a").unwrap().is_ok(),
220            "and its seat is available again"
221        );
222    }
223
224    /// A file nothing can parse holds a seat for nobody, which is worse than
225    /// losing the record of who had it.
226    #[test]
227    fn an_unreadable_permit_is_swept_rather_than_counted() {
228        let p = Permits::new(scratch("junk"), 1);
229        crate::create_private_dir(p.dir()).unwrap();
230        std::fs::write(p.dir().join("x.permit"), "{not json").unwrap();
231        assert!(p.live().is_empty());
232        assert!(p.take("task-a").unwrap().is_ok());
233    }
234
235    /// Anything that is not a permit is not a permit — one stray `.DS_Store`
236    /// must not read as a held seat, which is `ThreadStore`'s lesson one
237    /// store over.
238    #[test]
239    fn a_stray_file_is_not_a_holder() {
240        let p = Permits::new(scratch("stray"), 1);
241        crate::create_private_dir(p.dir()).unwrap();
242        std::fs::write(p.dir().join(".DS_Store"), "junk").unwrap();
243        assert!(p.live().is_empty());
244        assert!(p.take("task-a").unwrap().is_ok());
245    }
246}