Commit ecc1cf8df0f5
tarball9 files changed+317-18
@@ -13,6 +13,7 @@ rabt run --size m -- cargo build
1313
rabt run --ram 6G --cores 8 --time 10m -- nix build .#thing1414
rabt status1515
rabt suggest -- cargo build --release16
+rabt expect --size l # building soon, save me a seat1617
rabt watch 7 # exits when lease 7 is done or overdue1718
rabt kill 71819
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 code2730
@@ -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 building78
+intent_ttl = "20m" # how long a `rabt expect` or a build keeps its claim7479
7580
[overrun]7681
ram = "warn" # or "throttle", systemd-run MemoryHigh on linux@@ -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
}@@ -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 coming11
+12
+when you pick up a task that'll involve building, before you start editing13
+14
+```15
+rabt expect --size m16
+```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 something1121
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
## waiting3345
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@@ -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 spares42
+ #[pound(long)]43
+ exact: bool,4144
#[pound(trailing)]4245
cmd: Vec<String>,4346
},47
+ /// say you'll be building soon, so others leave you room48
+ 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 mind56
+ #[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 waiting4562
Status {4663
#[pound(long)]@@ -64,7 +81,7 @@ pub enum Rabt {
6481
},6582
/// claude code hook entrypoint, json on stdin6683
Hook {67
- /// pre-tool-use post-tool-use prompt-submit stop subagent-stop84
+ /// pre-tool-use post-tool-use prompt-submit stop subagent-stop session-end6885
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
+ None225
+ } 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
0245306
}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 })),@@ -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
}@@ -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 slower34
+ 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.leases303
+ .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 other314
+ /// sessions said they'll want and don't hold yet. capped by ram at the315
+ /// lease's own ram per core, since every extra job eats its share316
+ 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
+ .0334
+ .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_spare345
+ .0346
+ .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 a353
+ /// default one sized like themselves354
+ 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.intents365
+ .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.session382
+ && 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: self442
+ .intents443
+ .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.0624
+ st.expire_intents();625
+ if st.conns == 0626
+ && st.leases.is_empty()627
+ && st.intents.is_empty()628
+ && st.idle_since.elapsed() >= st.cfg.queue.linger.0497629
{498630
// systemd owns the socket and queues whoever knocks while we're499631
// leaving, so there's nothing to tidy@@ -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 about253
+/// which session just ended. never spawns, a missing daemon holds no intents254
+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@@ -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 it31
+ 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 doing4971
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 ram107
+ ram: Bytes,76108
heavy: bool,77109
},78110
Kill,@@ -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,