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}