atagen

roundabout

public
Star0
Watch0

Commit ecc1cf8df0f5

tarball

atagen <boss@atagen.co>2026-09-27 13:43 UTC

9 files changed+317-18
daemon: hand idle cores to builds, minus what others expect to need

Grants matched the ask exactly, so a lone m build sat on 4 of 12 cores. Spare cores are cheap to give since cpu oversubscription only slows things down, but they only go spare after subtracting cores other sessions have claimed with rabt expect, and each bonus core is charged ram at the build's own per-core rate because more jobs mean more memory. Intents live in the daemon and keep it running until they lapse, and SessionEnd withdraws them early.
Modified file: README.md+6 additions−1 deletions
@@ -13,6 +13,7 @@ rabt run --size m -- cargo build
1313 rabt run --ram 6G --cores 8 --time 10m -- nix build .#thing
1414 rabt status
1515 rabt suggest -- cargo build --release
16+rabt expect --size l # building soon, save me a seat
1617 rabt watch 7 # exits when lease 7 is done or overdue
1718 rabt kill 7
1819 rabt extend 7 10m
@@ -21,7 +22,9 @@ rabt artifacts check | list | gc [--cap] | pin <path>
2122
2223 `run` exits with the build's code, 124 if `--deadline` killed it, 75 if `--max-wait` ran out.
2324
24-a granted build gets `CARGO_BUILD_JOBS`, `MAKEFLAGS`, `CMAKE_BUILD_PARALLEL_LEVEL` and a `NIX_CONFIG` with `cores`/`max-jobs` to match its grant, so it actually uses the cores it asked for and no more.
25+a granted build gets `CARGO_BUILD_JOBS`, `MAKEFLAGS`, `CMAKE_BUILD_PARALLEL_LEVEL` and a `NIX_CONFIG` with `cores`/`max-jobs` to match its grant, so it actually uses the cores it got and no more.
26+
27+the grant can be bigger than the ask. idle cores go to whoever's building, minus what other sessions have said they'll want via `rabt expect`. spare cores never hold up admission, oversubscribing cpu only slows things down, but each one is charged ram at the build's own ram per core since more jobs eat more memory. a session's build counts as an `expect` too, so agents that forget still leave a mark. `--exact` opts out.
2528
2629 ## claude code
2730
@@ -71,6 +74,8 @@ time = "5m"
7174 linger = "5s"
7275 backfill_max_time = "2m"
7376 # max_wait = "30m"
77+elastic = true # hand idle cores to whoever's building
78+intent_ttl = "20m" # how long a `rabt expect` or a build keeps its claim
7479
7580 [overrun]
7681 ram = "warn" # or "throttle", systemd-run MemoryHigh on linux
Modified file: nix/hooks.nix+1 additions
@@ -17,4 +17,5 @@ in
1717 UserPromptSubmit = [ { hooks = [ (hook "prompt-submit") ]; } ];
1818 Stop = [ { hooks = [ (hook "stop") ]; } ];
1919 SubagentStop = [ { hooks = [ (hook "subagent-stop") ]; } ];
20+ SessionEnd = [ { hooks = [ (hook "session-end") ]; } ];
2021 }
Modified file: plugin/skills/roundabout/SKILL.md+12 additions
@@ -7,6 +7,16 @@ description: use before running any build, test suite or other heavy command (ca
77
88 this box is shared with other agents and a human who'd like their video to keep playing. builds queue through roundabout, whose cli is `rabt`, and only run when there's room.
99
10+## first, say you're coming
11+
12+when you pick up a task that'll involve building, before you start editing
13+
14+```
15+rabt expect --size m
16+```
17+
18+sized like the biggest build you expect to run. idle cores get handed out to whoever's building, and this is how the others know to leave you some. it lapses 20 minutes after your last build or `expect`, so one call covers an edit-compile loop. `rabt expect --done` if the plan changes.
19+
1020 ## running something
1121
1222 ```
@@ -29,6 +39,8 @@ sizes out of the box
2939
3040 guess honestly. under-asking is how the box ooms, and an honest `--time` is what lets small jobs slip past big ones. it tells you afterwards if you were off.
3141
42+you may get more cores than you asked for when the box is quiet, `CARGO_BUILD_JOBS` and friends are set to match. `--exact` turns that off, for benchmarks and anything else that wants a fixed number.
43+
3244 ## waiting
3345
3446 a queued build prints its place and why to stderr. bash moves slow commands to the background on its own, but for anything likely over a couple of minutes
Modified file: src/cli.rs+82 additions−2 deletions
@@ -38,9 +38,26 @@ pub enum Rabt {
3838 max_wait: Option<Dur>,
3939 #[pound(long, hidden, env = "CLAUDE_CODE_SESSION_ID")]
4040 session: Option<String>,
41+ /// exactly the cores asked for, no spares
42+ #[pound(long)]
43+ exact: bool,
4144 #[pound(trailing)]
4245 cmd: Vec<String>,
4346 },
47+ /// say you'll be building soon, so others leave you room
48+ Expect {
49+ #[pound(short, long)]
50+ size: Option<String>,
51+ #[pound(long)]
52+ ram: Option<Bytes>,
53+ #[pound(long)]
54+ cores: Option<Amount<u32>>,
55+ /// changed your mind
56+ #[pound(long)]
57+ done: bool,
58+ #[pound(long, hidden, env = "CLAUDE_CODE_SESSION_ID")]
59+ session: Option<String>,
60+ },
4461 /// who's running, who's waiting
4562 Status {
4663 #[pound(long)]
@@ -64,7 +81,7 @@ pub enum Rabt {
6481 },
6582 /// claude code hook entrypoint, json on stdin
6683 Hook {
67- /// pre-tool-use post-tool-use prompt-submit stop subagent-stop
84+ /// pre-tool-use post-tool-use prompt-submit stop subagent-stop session-end
6885 event: String,
6986 },
7087 #[pound(hidden)]
@@ -198,6 +215,36 @@ fn reply(ev: io::Result<Option<Event>>) -> i32 {
198215 }
199216 }
200217
218+fn expect(cfg: &Config, session: Option<String>, done: bool, a: &run::Args) -> i32 {
219+ let Some(session) = session.filter(|s| !s.is_empty()) else {
220+ eprintln!("rabt: no CLAUDE_CODE_SESSION_ID, so nobody to expect on behalf of");
221+ return 2;
222+ };
223+ let need = if done {
224+ None
225+ } else {
226+ match run::need(cfg, a) {
227+ Ok(n) => Some(n),
228+ Err(e) => {
229+ eprintln!("rabt: {e}");
230+ return 2;
231+ }
232+ }
233+ };
234+ let mut s = match client::connect_or_spawn() {
235+ Ok(s) => s,
236+ Err(e) => {
237+ eprintln!("rabt: can't reach the daemon: {e}");
238+ return 1;
239+ }
240+ };
241+ if let Err(e) = proto::send(&mut s, &Request::Expect { session, need }) {
242+ eprintln!("rabt: {e}");
243+ return 1;
244+ }
245+ reply(Reader::new(&s).and_then(|mut r| r.recv()))
246+}
247+
201248 fn status(json: bool) -> i32 {
202249 let Ok(Some(Event::Status(st))) = ask(&Request::Status) else {
203250 if json {
@@ -229,6 +276,11 @@ fn status(json: bool) -> i32 {
229276 l.waiting.map(|r| r.to_string()).unwrap_or_default()
230277 )
231278 };
279+ let cores = if l.granted > l.need.cores {
280+ format!("{}+{}", l.need.cores, l.granted - l.need.cores)
281+ } else {
282+ l.need.cores.to_string()
283+ };
232284 println!(
233285 "{:>4} {}{} {} ram {} cores {} rss {} {}",
234286 l.id,
@@ -236,11 +288,20 @@ fn status(json: bool) -> i32 {
236288 state,
237289 Dur(l.need.time),
238290 l.need.ram,
239- l.need.cores,
291+ cores,
240292 l.rss,
241293 l.cmd.join(" ")
242294 );
243295 }
296+ for i in &st.intents {
297+ println!(
298+ " expect {} ram {} cores {}, {} left",
299+ &i.session[..i.session.len().min(8)],
300+ i.need.ram,
301+ i.need.cores,
302+ Dur::secs(i.expires_in)
303+ );
304+ }
244305 0
245306 }
246307
@@ -299,6 +360,7 @@ pub fn dispatch(cfg: &Config, cmd: Rabt) -> i32 {
299360 deadline,
300361 max_wait,
301362 session,
363+ exact,
302364 cmd,
303365 } => run::run(
304366 cfg,
@@ -311,9 +373,27 @@ pub fn dispatch(cfg: &Config, cmd: Rabt) -> i32 {
311373 deadline,
312374 max_wait,
313375 session,
376+ exact,
314377 cmd,
315378 },
316379 ),
380+ Rabt::Expect {
381+ size,
382+ ram,
383+ cores,
384+ done,
385+ session,
386+ } => expect(
387+ cfg,
388+ session,
389+ done,
390+ &run::Args {
391+ size,
392+ ram,
393+ cores,
394+ ..run::Args::default()
395+ },
396+ ),
317397 Rabt::Status { json } => status(json),
318398 Rabt::Suggest { cmd } => suggest(cfg, &cmd),
319399 Rabt::Kill { lease } => reply(ask(&Request::Kill { lease })),
Modified file: src/config.rs+4 additions
@@ -58,6 +58,8 @@ pub struct Queue {
5858 pub linger: Dur,
5959 pub backfill_max_time: Dur,
6060 pub max_wait: Option<Dur>,
61+ pub elastic: bool,
62+ pub intent_ttl: Dur,
6163 }
6264
6365 #[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)]
@@ -212,6 +214,8 @@ impl Default for Queue {
212214 linger: Dur::secs(5),
213215 backfill_max_time: Dur::secs(120),
214216 max_wait: None,
217+ elastic: true,
218+ intent_ttl: Dur::secs(1200),
215219 }
216220 }
217221 }
Modified file: src/daemon.rs+138 additions−6 deletions
@@ -19,7 +19,7 @@ use crate::{
1919 client,
2020 config::Config,
2121 paths,
22- proto::{self, Acquire, Event, LeaseId, LeaseInfo, Reader, Request, Status},
22+ proto::{self, Acquire, Event, IntentInfo, LeaseId, LeaseInfo, Reader, Request, Status},
2323 sched::{self, Capacity, Disk, FsId, Need, Queued, Reason, Running, Verdict},
2424 sys,
2525 units::Bytes,
@@ -29,6 +29,14 @@ struct Run {
2929 started: Instant,
3030 rss: Bytes,
3131 reminded: Option<Instant>,
32+ /// asked-for plus bonus. only the asked-for part counts for admission,
33+ /// oversubscribed cores just make everyone slower
34+ cores: u32,
35+}
36+
37+struct Intent {
38+ need: Need,
39+ until: Instant,
3240 }
3341
3442 struct Lease {
@@ -60,6 +68,7 @@ struct State {
6068 cfg: Config,
6169 next: LeaseId,
6270 leases: BTreeMap<LeaseId, Lease>,
71+ intents: BTreeMap<String, Intent>,
6372 cap: Capacity,
6473 baselines: BTreeMap<FsId, Bytes>,
6574 conns: usize,
@@ -80,6 +89,7 @@ impl State {
8089 cfg,
8190 next: 1,
8291 leases: BTreeMap::new(),
92+ intents: BTreeMap::new(),
8393 cap: Capacity {
8494 ram_budget: Bytes::ZERO,
8595 ram_live: Bytes::ZERO,
@@ -242,26 +252,34 @@ impl State {
242252 let cores = self.cap.cores;
243253 let mut position = 0;
244254 for (id, verdict) in verdicts {
245- let Some(lease) = self.leases.get_mut(&id) else {
246- continue;
247- };
248255 match verdict {
249256 Verdict::Admit => {
257+ let (extra, extra_ram) = self.bonus(id);
258+ let Some(lease) = self.leases.get_mut(&id) else {
259+ continue;
260+ };
261+ lease.req.need.ram += extra_ram;
262+ let granted = lease.req.need.cores.clamp(1, cores.max(1)) + extra;
250263 lease.run = Some(Run {
251264 started: Instant::now(),
252265 rss: Bytes::ZERO,
253266 reminded: None,
267+ cores: granted,
254268 });
255269 let _ = proto::send(
256270 &mut lease.out,
257271 &Event::Granted {
258272 lease: id,
259- cores: lease.req.need.cores.clamp(1, cores.max(1)),
273+ cores: granted,
274+ ram: lease.req.need.ram,
260275 heavy: lease.heavy,
261276 },
262277 );
263278 }
264279 Verdict::Wait(reason) => {
280+ let Some(lease) = self.leases.get_mut(&id) else {
281+ continue;
282+ };
265283 position += 1;
266284 lease.waiting = Some(reason);
267285 if lease.last_pos != position {
@@ -280,8 +298,91 @@ impl State {
280298 }
281299 }
282300
301+ fn held_by(&self, session: &str) -> (u32, Bytes) {
302+ self.leases
303+ .values()
304+ .filter(|l| l.req.session.as_deref() == Some(session))
305+ .fold((0, Bytes::ZERO), |(c, r), l| {
306+ (
307+ c + l.run.as_ref().map_or(l.req.need.cores, |run| run.cores),
308+ r + l.req.need.ram,
309+ )
310+ })
311+ }
312+
313+ /// idle cores the lease can have on top of its ask, minus whatever other
314+ /// sessions said they'll want and don't hold yet. capped by ram at the
315+ /// lease's own ram per core, since every extra job eats its share
316+ fn bonus(&self, id: LeaseId) -> (u32, Bytes) {
317+ let Some(lease) = self.leases.get(&id) else {
318+ return (0, Bytes::ZERO);
319+ };
320+ if lease.req.exact || !self.cfg.queue.elastic {
321+ return (0, Bytes::ZERO);
322+ }
323+ let need = lease.req.need;
324+ let (taken, queued_ram) = self.leases.iter().filter(|(k, _)| **k != id).fold(
325+ (need.cores, need.ram),
326+ |(c, r), (_, l)| match &l.run {
327+ Some(run) => (c + run.cores, r),
328+ None => (c + l.req.need.cores, r + l.req.need.ram),
329+ },
330+ );
331+ let mut spare = self.cap.cores.saturating_sub(taken);
332+ let mut ram_spare = sched::headroom(&self.running(), &self.cap)
333+ .0
334+ .saturating_sub(queued_ram);
335+ for (session, intent) in &self.intents {
336+ if lease.req.session.as_deref() == Some(session) {
337+ continue;
338+ }
339+ let (cores, ram) = self.held_by(session);
340+ spare = spare.saturating_sub(intent.need.cores.saturating_sub(cores));
341+ ram_spare = ram_spare.saturating_sub(intent.need.ram.saturating_sub(ram));
342+ }
343+ let per_core = need.ram.0 / u64::from(need.cores.max(1));
344+ let by_ram = ram_spare
345+ .0
346+ .checked_div(per_core)
347+ .map_or(spare, |n| n as u32);
348+ let extra = spare.min(by_ram);
349+ (extra, Bytes(per_core * u64::from(extra)))
350+ }
351+
352+ /// explicit intents set the size, builds only keep one alive or start a
353+ /// default one sized like themselves
354+ fn intend(&mut self, session: &str, need: Need, explicit: bool) {
355+ let until = Instant::now() + self.cfg.queue.intent_ttl.0;
356+ match self.intents.get_mut(session) {
357+ Some(i) => {
358+ i.until = until;
359+ if explicit {
360+ i.need = need;
361+ }
362+ }
363+ None => {
364+ self.intents
365+ .insert(session.to_owned(), Intent { need, until });
366+ }
367+ }
368+ }
369+
370+ fn expire_intents(&mut self) {
371+ let had = !self.intents.is_empty();
372+ let now = Instant::now();
373+ self.intents.retain(|_, i| i.until > now);
374+ if had && self.intents.is_empty() && self.leases.is_empty() {
375+ self.idle_since = now;
376+ }
377+ }
378+
283379 fn remove(&mut self, id: LeaseId) {
284380 if let Some(lease) = self.leases.remove(&id) {
381+ if let Some(s) = &lease.req.session
382+ && let Some(i) = self.intents.get_mut(s)
383+ {
384+ i.until = Instant::now() + self.cfg.queue.intent_ttl.0;
385+ }
285386 for mut w in lease.watchers {
286387 let _ = proto::send(&mut w, &Event::Fired { overdue: false });
287388 }
@@ -321,6 +422,7 @@ impl State {
321422 need: l.req.need,
322423 heavy: l.heavy,
323424 running: l.run.is_some(),
425+ granted: l.run.as_ref().map_or(0, |r| r.cores),
324426 elapsed: l.elapsed().as_secs(),
325427 rss: l.run.as_ref().map_or(Bytes::ZERO, |r| r.rss),
326428 overdue: l.overdue(grace),
@@ -333,8 +435,18 @@ impl State {
333435
334436 fn status(&self) -> Status {
335437 let (ram_free, cores_free) = sched::headroom(&self.running(), &self.cap);
438+ let now = Instant::now();
336439 Status {
337440 leases: self.info(),
441+ intents: self
442+ .intents
443+ .iter()
444+ .map(|(session, i)| IntentInfo {
445+ session: session.clone(),
446+ need: i.need,
447+ expires_in: i.until.saturating_duration_since(now).as_secs(),
448+ })
449+ .collect(),
338450 ram_free,
339451 ram_budget: self.cap.ram_budget,
340452 cores_free,
@@ -356,6 +468,9 @@ fn serve(shared: &Shared, mut stream: UnixStream) -> io::Result<()> {
356468 st.next += 1;
357469 let fs = st.fs_for(&req.cwd);
358470 let heavy = st.heavy(&req.need);
471+ if let Some(s) = req.session.clone() {
472+ st.intend(&s, req.need, false);
473+ }
359474 st.leases.insert(
360475 id,
361476 Lease {
@@ -377,6 +492,19 @@ fn serve(shared: &Shared, mut stream: UnixStream) -> io::Result<()> {
377492 lock(shared).remove(id);
378493 Ok(())
379494 }
495+ Request::Expect { session, need } => {
496+ let mut st = lock(shared);
497+ if let Some(need) = need {
498+ st.intend(&session, need, true);
499+ } else {
500+ st.intents.remove(&session);
501+ if st.intents.is_empty() && st.leases.is_empty() {
502+ st.idle_since = Instant::now();
503+ }
504+ }
505+ drop(st);
506+ proto::send(&mut stream, &Event::Ok)
507+ }
380508 Request::Status => {
381509 let status = lock(shared).status();
382510 proto::send(&mut stream, &Event::Status(status))
@@ -493,7 +621,11 @@ fn tick(shared: &Shared) {
493621 st.refresh();
494622 st.reschedule();
495623 st.fire_overdue();
496- if st.conns == 0 && st.leases.is_empty() && st.idle_since.elapsed() >= st.cfg.queue.linger.0
624+ st.expire_intents();
625+ if st.conns == 0
626+ && st.leases.is_empty()
627+ && st.intents.is_empty()
628+ && st.idle_since.elapsed() >= st.cfg.queue.linger.0
497629 {
498630 // systemd owns the socket and queues whoever knocks while we're
499631 // leaving, so there's nothing to tidy
Modified file: src/hook.rs+20 additions
@@ -249,6 +249,25 @@ fn session(input: &Input) -> String {
249249 .unwrap_or_else(|| input.session_id.clone())
250250 }
251251
252+/// both ids, since around /clear the env var and the json can disagree about
253+/// which session just ended. never spawns, a missing daemon holds no intents
254+fn session_end(input: &Input) {
255+ let mut ids = vec![session(input), input.session_id.clone()];
256+ ids.dedup();
257+ for session in ids.into_iter().filter(|s| !s.is_empty()) {
258+ if let Some(mut s) = client::connect() {
259+ let _ = proto::send(
260+ &mut s,
261+ &Request::Expect {
262+ session,
263+ need: None,
264+ },
265+ );
266+ let _ = Reader::new(&s).and_then(|mut r| r.recv::<Event>());
267+ }
268+ }
269+}
270+
252271 fn remind(event: &str, input: &Input) {
253272 let late = overdue(&session(input));
254273 if late.is_empty() {
@@ -313,6 +332,7 @@ pub fn main(cfg: &Config, event: &str) -> i32 {
313332 "prompt-submit" => remind("UserPromptSubmit", &input),
314333 "stop" => stop(cfg, "Stop", &input),
315334 "subagent-stop" => stop(cfg, "SubagentStop", &input),
335+ "session-end" => session_end(&input),
316336 other => eprintln!("rabt: unknown hook {other}"),
317337 }
318338 0
Modified file: src/proto.rs+38 additions−6 deletions
@@ -20,20 +20,40 @@ pub struct Acquire {
2020 pub cmd: Vec<String>,
2121 pub session: Option<String>,
2222 pub deadline: Option<Dur>,
23+ #[serde(default)]
24+ pub exact: bool,
2325 }
2426
2527 #[derive(Debug, Serialize, Deserialize)]
2628 #[serde(tag = "op", rename_all = "snake_case")]
2729 pub enum Request {
30+ /// `None` withdraws it
31+ Expect {
32+ session: String,
33+ need: Option<Need>,
34+ },
2835 Acquire(Acquire),
29- Started { pid: i32 },
30- Usage { rss: Bytes },
36+ Started {
37+ pid: i32,
38+ },
39+ Usage {
40+ rss: Bytes,
41+ },
3142 Done,
3243 Status,
33- Kill { lease: LeaseId },
34- Extend { lease: LeaseId, by: Dur },
35- Watch { lease: LeaseId },
36- Overdue { session: String },
44+ Kill {
45+ lease: LeaseId,
46+ },
47+ Extend {
48+ lease: LeaseId,
49+ by: Dur,
50+ },
51+ Watch {
52+ lease: LeaseId,
53+ },
54+ Overdue {
55+ session: String,
56+ },
3757 }
3858
3959 #[derive(Clone, Debug, Serialize, Deserialize)]
@@ -45,6 +65,8 @@ pub struct LeaseInfo {
4565 pub need: Need,
4666 pub heavy: bool,
4767 pub running: bool,
68+ #[serde(default)]
69+ pub granted: u32,
4870 /// seconds since queued or started, whichever it's doing
4971 pub elapsed: u64,
5072 pub rss: Bytes,
@@ -53,9 +75,17 @@ pub struct LeaseInfo {
5375 pub position: usize,
5476 }
5577
78+#[derive(Clone, Debug, Serialize, Deserialize)]
79+pub struct IntentInfo {
80+ pub session: String,
81+ pub need: Need,
82+ pub expires_in: u64,
83+}
84+
5685 #[derive(Debug, Serialize, Deserialize)]
5786 pub struct Status {
5887 pub leases: Vec<LeaseInfo>,
88+ pub intents: Vec<IntentInfo>,
5989 pub ram_free: Bytes,
6090 pub ram_budget: Bytes,
6191 pub cores_free: u32,
@@ -73,6 +103,8 @@ pub enum Event {
73103 Granted {
74104 lease: LeaseId,
75105 cores: u32,
106+ /// grows with any bonus cores, since more jobs means more ram
107+ ram: Bytes,
76108 heavy: bool,
77109 },
78110 Kill,
Modified file: src/run.rs+16 additions−3 deletions
@@ -24,6 +24,7 @@ use crate::{
2424 pub const EXIT_TIMEOUT: i32 = 124;
2525 pub const EXIT_GAVE_UP: i32 = 75;
2626
27+#[derive(Default)]
2728 pub struct Args {
2829 pub size: Option<String>,
2930 pub ram: Option<Bytes>,
@@ -33,6 +34,7 @@ pub struct Args {
3334 pub deadline: Option<Dur>,
3435 pub max_wait: Option<Dur>,
3536 pub session: Option<String>,
37+ pub exact: bool,
3638 pub cmd: Vec<String>,
3739 }
3840
@@ -49,7 +51,7 @@ fn say(msg: &str) {
4951 eprintln!("rabt: {msg}");
5052 }
5153
52-fn need(cfg: &Config, a: &Args) -> Result<Need, String> {
54+pub fn need(cfg: &Config, a: &Args) -> Result<Need, String> {
5355 let base = cfg.preset(a.size.as_deref().unwrap_or(&cfg.enforce.default_size))?;
5456 Ok(Need {
5557 ram: a.ram.unwrap_or(base.ram),
@@ -73,6 +75,7 @@ struct Grant {
7375 stream: UnixStream,
7476 lease: u64,
7577 cores: u32,
78+ ram: Bytes,
7679 }
7780
7881 fn acquire(req: &Acquire, max_wait: Option<Dur>) -> Result<Grant, i32> {
@@ -93,10 +96,17 @@ fn acquire(req: &Acquire, max_wait: Option<Dur>) -> Result<Grant, i32> {
9396 Ok(Some(Event::Granted {
9497 lease,
9598 cores,
99+ ram,
96100 heavy,
97101 })) => {
102+ let bonus = cores.saturating_sub(req.need.cores);
98103 say(&format!(
99- "lease {lease} go, cores {cores}{}",
104+ "lease {lease} go, cores {cores}{}{}",
105+ if bonus > 0 {
106+ format!(" ({bonus} spare)")
107+ } else {
108+ String::new()
109+ },
100110 if heavy { ", heavy" } else { "" }
101111 ));
102112 let _ = stream.set_read_timeout(None);
@@ -104,6 +114,7 @@ fn acquire(req: &Acquire, max_wait: Option<Dur>) -> Result<Grant, i32> {
104114 stream,
105115 lease,
106116 cores,
117+ ram,
107118 });
108119 }
109120 Ok(Some(Event::Queued {
@@ -230,7 +241,7 @@ pub fn run(cfg: &Config, a: &Args) -> i32 {
230241 ));
231242 return 2;
232243 }
233- let need = match need(cfg, a) {
244+ let mut need = match need(cfg, a) {
234245 Ok(n) => n,
235246 Err(e) => {
236247 say(&e);
@@ -258,12 +269,14 @@ pub fn run(cfg: &Config, a: &Args) -> i32 {
258269 cmd: a.cmd.clone(),
259270 session: a.session.clone(),
260271 deadline: a.deadline,
272+ exact: a.exact,
261273 };
262274
263275 let grant = match acquire(&req, a.max_wait.or(cfg.queue.max_wait)) {
264276 Ok(g) => g,
265277 Err(code) => return code,
266278 };
279+ need.ram = need.ram.max(grant.ram);
267280
268281 let mut child = match command(cfg, a, &need, &grant).spawn() {
269282 Ok(c) => c,