use serde_json::Value;
use std::path::Path;
use trusty_mpm::core::agent::is_subagent_dispatch_tool;
use trusty_mpm::core::dispatch_isolation::{agent_is_builder, dispatch_agent};
use crate::commands::pm_guard_dispatch::{SharedTreeReply, post_shared_tree};
const BUILDER_SLOT_ROUTE: &str = "builder-slot";
pub(crate) fn dispatch_claims_a_builder_slot(tool_name: &str, tool_input: Option<&Value>) -> bool {
is_subagent_dispatch_tool(tool_name) && dispatch_agent(tool_input).is_some_and(agent_is_builder)
}
pub(crate) fn deny_reason(
agent: &str,
cap: u32,
holders: &[HolderLine],
note: &CapacityNote,
) -> String {
let running = holders
.iter()
.map(HolderLine::render)
.collect::<Vec<_>>()
.join("; ");
format!(
"Machine-wide builder cap reached (#6892): this {agent} dispatch would be builder \
{next} on a machine capped at {cap}.{measured} Already running: {running}. The cap counts \
BUILDERS across every session on this host, not per session — on 2026-08-08 several \
sessions each honouring their own limit produced six concurrent `cargo` builds and \
crashed the machine, which is why no session can see or raise its own share. \
`isolation: \"worktree\"` does not exempt a builder: it buys a separate directory, \
not separate RAM. Queue this dispatch and re-issue it when one of the agents above \
reports back, or ask the operator to raise `builders.max_concurrent` in \
`~/.trusty-mpm/config.toml` — a project's `.trusty-mpm.toml` cannot set it, by \
design. `tm doctor` lists the current holders.",
next = holders.len() + 1,
measured = note.render(cap),
)
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct CapacityNote {
pub(crate) ceiling: u32,
pub(crate) reason: String,
pub(crate) fail_closed_surface: Option<String>,
}
impl CapacityNote {
fn render(&self, cap: u32) -> String {
if self.reason.is_empty() {
return String::new();
}
let ceiling = if self.ceiling > cap {
format!(
" That {cap} is MEASURED capacity, below the configured ceiling of {} \
(`builders.max_concurrent`).",
self.ceiling
)
} else {
String::new()
};
let unreadable = match &self.fail_closed_surface {
Some(surface) => format!(
" This is a FAIL-CLOSED fallback to the fixed ceiling, not a measured \
refusal — the {surface} surface fired."
),
None => String::new(),
};
format!("{ceiling} Capacity: {}.{unreadable}", self.reason)
}
}
fn capacity_note_in(body: &Value) -> CapacityNote {
CapacityNote {
ceiling: body
.get("ceiling")
.and_then(Value::as_u64)
.and_then(|c| u32::try_from(c).ok())
.unwrap_or_default(),
reason: body
.get("capacity_reason")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
fail_closed_surface: body
.get("fail_closed_surface")
.and_then(Value::as_str)
.map(ToString::to_string),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct HolderLine {
pub(crate) agent: String,
pub(crate) elapsed_secs: i64,
}
impl HolderLine {
fn render(&self) -> String {
format!("{} (running {}m)", self.agent, self.elapsed_secs / 60)
}
}
pub(crate) fn unverifiable_deny_reason(agent: &str, detail: &str) -> String {
format!(
"Builder cap unverifiable (#6892): the daemon did not answer this guard's builder-slot \
claim — {detail}. That answer is the only thing that can say how many builders are \
already running on this machine, across every session, so admitting this {agent} \
dispatch would start a build with the count that exists to bound it never having run. \
This denies rather than allowing, which is the OPPOSITE of the shared-worktree guard's \
policy on the same failure and is deliberate: a false allow here overcommits the host, \
and a machine that goes down takes every session with it, while a false deny costs one \
dispatch. Start the daemon (`tm start`) or check it (`tm doctor`), then re-issue. \
Non-builder dispatches — research, ticketing, documentation, qa, version-control — are \
unaffected and still run: they never reach this check."
)
}
fn holders_in(body: &Value) -> Vec<HolderLine> {
body.get("holders")
.and_then(Value::as_array)
.map(|rows| {
rows.iter()
.filter_map(|row| {
Some(HolderLine {
agent: row.get("agent").and_then(Value::as_str)?.to_string(),
elapsed_secs: row
.get("elapsed_secs")
.and_then(Value::as_i64)
.unwrap_or_default(),
})
})
.collect()
})
.unwrap_or_default()
}
pub(crate) enum BuilderSlotClaim {
Admitted {
slot_path: Option<String>,
notice: Option<String>,
},
Full(u32, Vec<HolderLine>, CapacityNote),
PoolRefused(String),
NotCounted,
Unverifiable(String),
}
pub(crate) async fn claim_builder_slot(
url: &str,
session_id: &str,
cwd: &Path,
payload: &Value,
) -> BuilderSlotClaim {
match post_shared_tree(url, session_id, cwd, payload, BUILDER_SLOT_ROUTE).await {
SharedTreeReply::Answered(body) => {
let Some(claimed) = body.get("claimed").and_then(Value::as_bool) else {
return BuilderSlotClaim::Unverifiable(
"the daemon's answer carries no `claimed` verdict, so it is not this route's"
.to_string(),
);
};
if claimed {
return BuilderSlotClaim::Admitted {
slot_path: str_body_field(&body, "slot_path"),
notice: str_body_field(&body, "slot_notice"),
};
}
if let Some(detail) = str_body_field(&body, "slot_refused") {
return BuilderSlotClaim::PoolRefused(detail);
}
if body.get("ineligible").and_then(Value::as_bool) == Some(true) {
return BuilderSlotClaim::NotCounted;
}
let cap = body
.get("cap")
.and_then(Value::as_u64)
.and_then(|c| u32::try_from(c).ok())
.unwrap_or_default();
BuilderSlotClaim::Full(cap, holders_in(&body), capacity_note_in(&body))
}
SharedTreeReply::Unavailable(detail) | SharedTreeReply::Unanswered(detail) => {
BuilderSlotClaim::Unverifiable(detail)
}
}
}
fn str_body_field(body: &Value, key: &str) -> Option<String> {
body.get(key)
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
.map(str::to_string)
}
pub(crate) fn pool_refused_deny_reason(agent: &str, detail: &str) -> String {
format!(
"Builder slot directory refused (#8261): the daemon admitted this {agent} dispatch \
against the machine's builder capacity, then could not give it a private cargo target \
directory — {detail}. This is NOT a full machine and raising \
`builders.max_concurrent` will not clear it. A builder with no private directory would \
build in the shared `CARGO_TARGET_DIR` and contend on its lock with every other builder \
on this host, which is the failure #8261 exists to end, so the dispatch is refused \
instead. Fix the path named above — check that `builders.slot_pool_root` in \
`~/.trusty-mpm/config.toml` names a writable directory and that its volume has space — \
then re-issue. `tm doctor` reports the pool root in its build-environment row."
)
}
fn unclaimable_field(payload: &Value, session_id: &str) -> Option<&'static str> {
if session_id.is_empty() {
return Some("session id");
}
let has_tool_use_id = payload
.get("tool_use_id")
.and_then(Value::as_str)
.is_some_and(|s| !s.is_empty());
(!has_tool_use_id).then_some("tool_use_id")
}
fn warn_unaddressable(field: &str) {
eprintln!(
"tm hook --pm-guard: this dispatch's payload carries no {field}, so the machine-wide \
builder cap (#6892) could not be claimed for it. The cap is NOT being enforced for this \
dispatch — an uncounted builder may now be running. Every payload Claude Code emits \
carries both `session_id` and `tool_use_id`; a payload without one did not come from it. \
Allowing the dispatch."
);
}
pub(crate) async fn evaluate(
url: &str,
payload: &Value,
tool_name: &str,
tool_input: Option<&Value>,
session_id: &str,
cwd: &Path,
) -> BuilderCapVerdict {
if !dispatch_claims_a_builder_slot(tool_name, tool_input) {
return BuilderCapVerdict::Allow(None);
}
if let Some(missing) = unclaimable_field(payload, session_id) {
warn_unaddressable(missing);
return BuilderCapVerdict::Allow(None);
}
let agent = dispatch_agent(tool_input).unwrap_or("this");
match claim_builder_slot(url, session_id, cwd, payload).await {
BuilderSlotClaim::Admitted { slot_path, notice } => {
BuilderCapVerdict::Allow(slot_path.map(|dir| slot_notice(&dir)).or(notice))
}
BuilderSlotClaim::Full(cap, holders, note) => {
BuilderCapVerdict::Deny(deny_reason(agent, cap, &holders, ¬e))
}
BuilderSlotClaim::PoolRefused(detail) => {
BuilderCapVerdict::Deny(pool_refused_deny_reason(agent, &detail))
}
BuilderSlotClaim::NotCounted => {
warn_not_counted(agent);
BuilderCapVerdict::Allow(None)
}
BuilderSlotClaim::Unverifiable(detail) => {
BuilderCapVerdict::Deny(unverifiable_deny_reason(agent, &detail))
}
}
}
pub(crate) enum BuilderCapVerdict {
Deny(String),
Allow(Option<String>),
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn emit_builder_cap_or(
url: &str,
payload: &Value,
tool_name: &str,
tool_input: Option<&Value>,
session_id: &str,
hook_cwd: &Path,
allowed: &str,
) {
match evaluate(url, payload, tool_name, tool_input, session_id, hook_cwd).await {
BuilderCapVerdict::Deny(reason) => {
super::pm_guard::audit_denied_tool(url, session_id, tool_name, &reason).await;
println!(
"{}",
super::pm_guard_response::build_pm_guard_deny_response(&reason)
);
}
BuilderCapVerdict::Allow(notice) => println!(
"{}",
super::pm_guard_worktree_grant::with_additional_context(allowed, notice.as_deref())
),
}
}
fn slot_notice(dir: &str) -> String {
format!(
"Builder slot (#8261): this dispatch holds a private cargo target \
directory. Prefix `CARGO_TARGET_DIR={dir}` INLINE on every cargo \
command — an exported variable does not survive between tool calls, and \
building in the shared directory contends on its lock with every other \
builder on this machine."
)
}
fn warn_not_counted(agent: &str) {
eprintln!(
"tm hook --pm-guard: the daemon does not classify {agent} as a builder, so this dispatch \
was NOT counted against the machine-wide cap (#6892) — the machine may now be running \
one more builder than it is sized for. The usual cause is a running daemon older than \
the `tm` on PATH, built before this agent joined its bundled table; `tm restart` clears \
it. Allowing the dispatch: the daemon answered, so the count is known and this is a \
classification disagreement rather than an unverifiable cap."
);
}
#[cfg(test)]
mod tests {
use super::*;
use trusty_mpm::core::agent::SUBAGENT_DISPATCH_TOOLS;
fn input(agent: &str, isolation: Option<&str>) -> Value {
match isolation {
Some(i) => serde_json::json!({"subagent_type": agent, "isolation": i}),
None => serde_json::json!({"subagent_type": agent}),
}
}
fn holders() -> Vec<HolderLine> {
vec![
HolderLine {
agent: "rust-engineer".to_string(),
elapsed_secs: 754,
},
HolderLine {
agent: "local-ops".to_string(),
elapsed_secs: 61,
},
]
}
#[test]
fn a_builder_dispatch_is_gated() {
for tool in SUBAGENT_DISPATCH_TOOLS {
for agent in ["rust-engineer", "engineer", "local-ops"] {
assert!(
dispatch_claims_a_builder_slot(tool, Some(&input(agent, None))),
"{tool}/{agent}"
);
}
}
}
#[test]
fn an_isolated_builder_still_counts() {
assert!(dispatch_claims_a_builder_slot(
"Agent",
Some(&input("rust-engineer", Some("worktree")))
));
assert!(dispatch_claims_a_builder_slot(
"Agent",
Some(&input("rust-engineer", Some("remote")))
));
}
#[test]
fn non_builder_dispatches_are_not_gated() {
for agent in [
"research",
"ticketing",
"qa",
"documentation",
"version-control",
"code-critic",
] {
assert!(!dispatch_claims_a_builder_slot(
"Agent",
Some(&input(agent, None))
));
}
assert!(!dispatch_claims_a_builder_slot("Agent", None));
assert!(!dispatch_claims_a_builder_slot(
"Agent",
Some(&serde_json::json!({"description": "go"}))
));
}
#[test]
fn every_non_dispatch_tool_is_not_gated() {
for tool in ["Bash", "Edit", "Write", "Read", "Grep"] {
assert!(!dispatch_claims_a_builder_slot(
tool,
Some(&input("rust-engineer", None))
));
}
}
#[test]
fn deny_reason_names_every_holder_the_cap_and_the_config_key() {
let reason = deny_reason("python-engineer", 2, &holders(), &CapacityNote::default());
assert!(reason.contains("rust-engineer (running 12m)"), "{reason}");
assert!(reason.contains("local-ops (running 1m)"), "{reason}");
assert!(reason.contains("capped at 2"), "{reason}");
assert!(reason.contains("builders.max_concurrent"), "{reason}");
assert!(reason.contains("~/.trusty-mpm/config.toml"), "{reason}");
assert!(reason.contains("Queue this dispatch"), "{reason}");
}
#[test]
fn an_old_daemons_answer_renders_the_pre_8261_message() {
let note = capacity_note_in(&serde_json::json!({"cap": 2, "claimed": false}));
assert_eq!(note, CapacityNote::default());
let reason = deny_reason("python-engineer", 2, &holders(), ¬e);
assert!(!reason.contains("Capacity:"), "{reason}");
assert!(!reason.contains("MEASURED"), "{reason}");
}
#[test]
fn the_capacity_note_is_read_out_of_the_daemons_answer() {
let note = capacity_note_in(&serde_json::json!({
"ceiling": 4,
"capacity_reason": "1-minute load average 40.00 is above the threshold 32.00",
"fail_closed_surface": serde_json::Value::Null,
}));
assert_eq!(note.ceiling, 4);
assert!(note.reason.contains("40.00"));
assert_eq!(note.fail_closed_surface, None);
}
#[test]
fn deny_reason_names_the_measured_capacity_and_its_ceiling() {
let note = CapacityNote {
ceiling: 4,
reason: "1-minute load average 40.00 is above the threshold 32.00 \
(logical cores x builders.load_factor)"
.to_string(),
fail_closed_surface: None,
};
let reason = deny_reason("rust-engineer", 2, &holders(), ¬e);
assert!(reason.contains("MEASURED capacity"), "{reason}");
assert!(reason.contains("ceiling of 4"), "{reason}");
assert!(
reason.contains("40.00") && reason.contains("32.00"),
"the reading AND the limit must both appear: {reason}"
);
assert!(
!reason.contains("FAIL-CLOSED"),
"an EXCEEDED limit is not an UNREADABLE one: {reason}"
);
}
#[test]
fn deny_reason_names_an_unreadable_metric_as_such() {
let note = CapacityNote {
ceiling: 4,
reason: "builder-cap-load-read-failure — the Load reading could not be taken \
(operation not permitted, errno 1)"
.to_string(),
fail_closed_surface: Some("builder-cap-load-read-failure".to_string()),
};
let reason = deny_reason("rust-engineer", 4, &holders(), ¬e);
assert!(reason.contains("FAIL-CLOSED"), "{reason}");
assert!(reason.contains("builder-cap-load-read-failure"), "{reason}");
assert!(reason.contains("errno 1"), "{reason}");
assert!(!reason.contains("MEASURED capacity"), "{reason}");
}
#[test]
fn unverifiable_reason_names_the_failure_and_why_it_denies() {
let reason = unverifiable_deny_reason("rust-engineer", "nothing is listening at :4317");
assert!(reason.contains("cap unverifiable"), "{reason}");
assert!(reason.contains("nothing is listening at :4317"), "{reason}");
assert!(reason.contains("tm start"), "{reason}");
assert!(reason.contains("still run"), "{reason}");
}
#[test]
fn holders_are_read_out_of_the_daemons_answer() {
let body = serde_json::json!({
"cap": 2,
"claimed": false,
"holders": [
{"agent": "rust-engineer", "session": "sess-a", "elapsed_secs": 90},
{"session": "sess-b", "elapsed_secs": 10},
],
});
let rows = holders_in(&body);
assert_eq!(rows.len(), 1, "a row with no agent name is skipped");
assert_eq!(rows[0].agent, "rust-engineer");
assert_eq!(rows[0].elapsed_secs, 90);
}
fn spawn_mock_answering(status_line: &'static str, body: &'static str) -> String {
use std::io::{Read, Write};
use std::net::TcpListener;
let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
let url = format!("http://{}", listener.local_addr().expect("addr"));
std::thread::spawn(move || {
if let Ok((mut socket, _)) = listener.accept() {
let mut buf = [0u8; 4096];
let _ = socket.read(&mut buf);
let response = format!(
"HTTP/1.1 {status_line}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
let _ = socket.write_all(response.as_bytes());
}
});
url
}
fn deny(verdict: BuilderCapVerdict) -> Option<String> {
match verdict {
BuilderCapVerdict::Deny(reason) => Some(reason),
BuilderCapVerdict::Allow(_) => None,
}
}
async fn evaluate_builder_against(url: &str) -> Option<String> {
deny(
evaluate(
url,
&serde_json::json!({"tool_use_id": "toolu_X"}),
"Agent",
Some(&input("rust-engineer", None)),
"11111111-1111-1111-1111-111111111111",
Path::new("/repo"),
)
.await,
)
}
#[tokio::test]
async fn an_admitted_builder_allows_with_its_target_dir_notice() {
let url = spawn_mock_answering(
"200 OK",
r#"{"claimed":true,"cap":4,"holders":[],"slot_path":"/pool/acme/widgets/slot-0"}"#,
);
let verdict = evaluate(
&url,
&serde_json::json!({"tool_use_id": "toolu_X"}),
"Agent",
Some(&input("rust-engineer", None)),
"11111111-1111-1111-1111-111111111111",
Path::new("/repo"),
)
.await;
match verdict {
BuilderCapVerdict::Allow(Some(notice)) => {
assert!(
notice.contains("/pool/acme/widgets/slot-0"),
"the notice must name the directory: {notice}"
);
assert!(
notice.contains("CARGO_TARGET_DIR"),
"the notice must name the variable: {notice}"
);
}
BuilderCapVerdict::Allow(None) => {
panic!("an admitted builder with a slot path must carry a notice")
}
BuilderCapVerdict::Deny(reason) => panic!("must not deny: {reason}"),
}
}
#[tokio::test]
async fn a_pool_refusal_denies_naming_the_path_rather_than_the_cap() {
let url = spawn_mock_answering(
"200 OK",
r#"{"claimed":false,"ineligible":false,"cap":4,"ceiling":4,"holders":[],
"slot_refused":"could not create builder slot directory /pool/acme/widgets: Not a directory (os error 20)"}"#,
);
let reason = deny(
evaluate(
&url,
&serde_json::json!({"tool_use_id": "toolu_X"}),
"Agent",
Some(&input("rust-engineer", None)),
"11111111-1111-1111-1111-111111111111",
Path::new("/repo"),
)
.await,
)
.expect("a pool refusal denies");
assert!(
reason.contains("/pool/acme/widgets") && reason.contains("os error 20"),
"the deny must name the path and the errno: {reason}"
);
assert!(
!reason.contains("cap reached"),
"a pool refusal is not a full machine: {reason}"
);
assert!(
reason.contains("slot_pool_root"),
"the deny must name the repair that applies: {reason}"
);
}
#[tokio::test]
async fn an_admission_without_a_directory_carries_the_daemons_notice() {
let url = spawn_mock_answering(
"200 OK",
r#"{"claimed":true,"cap":4,"holders":[],
"slot_notice":"builder slot /pool/acme/widgets/slot-0 had not been seeded yet"}"#,
);
let verdict = evaluate(
&url,
&serde_json::json!({"tool_use_id": "toolu_X"}),
"Agent",
Some(&input("rust-engineer", None)),
"11111111-1111-1111-1111-111111111111",
Path::new("/repo"),
)
.await;
match verdict {
BuilderCapVerdict::Allow(Some(notice)) => assert!(
notice.contains("had not been seeded yet"),
"the daemon's notice must reach the engineer: {notice}"
),
BuilderCapVerdict::Allow(None) => {
panic!("an admission with no directory must still explain itself")
}
BuilderCapVerdict::Deny(reason) => panic!("must not deny: {reason}"),
}
}
#[tokio::test]
async fn denies_a_builder_when_the_daemon_is_down() {
let reason = evaluate_builder_against("http://127.0.0.1:1")
.await
.expect("an unverifiable cap must deny");
assert!(reason.contains("cap unverifiable"), "{reason}");
}
#[tokio::test]
async fn allows_a_research_dispatch_when_the_daemon_is_down() {
for agent in ["research", "ticketing", "documentation", "version-control"] {
let verdict = deny(
evaluate(
"http://127.0.0.1:1",
&serde_json::json!({"tool_use_id": "toolu_X"}),
"Agent",
Some(&input(agent, None)),
"11111111-1111-1111-1111-111111111111",
Path::new("/repo"),
)
.await,
);
assert!(verdict.is_none(), "{agent} must not be denied: {verdict:?}");
}
}
#[tokio::test]
async fn a_payload_with_no_session_id_allows_and_warns() {
let verdict = deny(
evaluate(
"http://127.0.0.1:1",
&serde_json::json!({"tool_use_id": "toolu_X"}),
"Agent",
Some(&input("rust-engineer", None)),
"",
Path::new("/repo"),
)
.await,
);
assert!(verdict.is_none(), "{verdict:?}");
}
#[tokio::test]
async fn a_payload_with_no_tool_use_id_allows_and_warns() {
let url = spawn_mock_answering("200 OK", r#"{"claimed":false,"cap":4,"holders":[]}"#);
let verdict = deny(
evaluate(
&url,
&serde_json::json!({"cwd": "/repo"}),
"Agent",
Some(&input("rust-engineer", None)),
"11111111-1111-1111-1111-111111111111",
Path::new("/repo"),
)
.await,
);
assert!(
verdict.is_none(),
"an unclaimable payload must not read as a full machine: {verdict:?}"
);
}
#[tokio::test]
async fn an_ineligible_answer_allows_rather_than_denying() {
let url = spawn_mock_answering(
"200 OK",
r#"{"claimed":false,"ineligible":true,"cap":4,"holders":[]}"#,
);
let verdict = evaluate_builder_against(&url).await;
assert!(verdict.is_none(), "{verdict:?}");
}
#[tokio::test]
async fn a_payload_with_a_tool_use_id_still_denies_when_full() {
let url = spawn_mock_answering(
"200 OK",
r#"{"claimed":false,"cap":1,"holders":[{"agent":"local-ops","session":"s","elapsed_secs":30}]}"#,
);
let reason = evaluate_builder_against(&url)
.await
.expect("a full machine denies");
assert!(
reason.contains("Machine-wide builder cap reached"),
"{reason}"
);
}
#[tokio::test]
async fn claim_is_unverifiable_when_the_daemon_is_unreachable() {
assert!(matches!(
claim_builder_slot(
"http://127.0.0.1:1",
"11111111-1111-1111-1111-111111111111",
Path::new("/repo"),
&serde_json::json!({"tool_use_id": "toolu_X"}),
)
.await,
BuilderSlotClaim::Unverifiable(_)
));
}
#[tokio::test]
async fn claim_is_unverifiable_when_the_daemon_answers_500() {
let url = spawn_mock_answering("500 Internal Server Error", r#"{"error":"boom"}"#);
let reason = evaluate_builder_against(&url)
.await
.expect("a 500 leaves the count unknown");
assert!(reason.contains("cap unverifiable"), "{reason}");
}
#[tokio::test]
async fn claim_is_unverifiable_when_the_route_is_absent() {
let url = spawn_mock_answering("404 Not Found", r#"{"error":"no route"}"#);
let reason = evaluate_builder_against(&url)
.await
.expect("a daemon without the route cannot bound the machine");
assert!(reason.contains("cap unverifiable"), "{reason}");
}
#[tokio::test]
async fn claim_is_unverifiable_when_the_answer_has_the_wrong_shape() {
let url = spawn_mock_answering("200 OK", r#"{"agents":[],"total":0}"#);
let reason = evaluate_builder_against(&url)
.await
.expect("a body with no `claimed` verdict leaves the count unknown");
assert!(reason.contains("cap unverifiable"), "{reason}");
assert!(!reason.contains("capped at 0"), "{reason}");
}
#[tokio::test]
async fn claim_is_unverifiable_when_the_body_does_not_parse() {
let url = spawn_mock_answering("200 OK", "not json at all");
let reason = evaluate_builder_against(&url)
.await
.expect("an unparseable answer is not an answer");
assert!(reason.contains("cap unverifiable"), "{reason}");
}
#[tokio::test]
async fn claim_is_admitted_when_the_daemon_says_so() {
let url = spawn_mock_answering("200 OK", r#"{"claimed":true,"cap":2,"holders":[]}"#);
assert!(evaluate_builder_against(&url).await.is_none());
}
#[tokio::test]
async fn denies_a_builder_when_the_machine_is_full() {
let url = spawn_mock_answering(
"200 OK",
r#"{"claimed":false,"cap":2,"holders":[
{"agent":"rust-engineer","session":"5f0e2c1a-1111-4222-8333-944445555666","elapsed_secs":600},
{"agent":"local-ops","session":"sess-b","elapsed_secs":120}]}"#,
);
let reason = evaluate_builder_against(&url)
.await
.expect("a full machine denies");
assert!(reason.contains("rust-engineer"), "{reason}");
assert!(reason.contains("local-ops"), "{reason}");
for form in [
"5f0e2c1a-1111-4222-8333-944445555666",
"5f0e2c1a111142228333944445555666",
"sess-b",
] {
assert!(!reason.contains(form), "{form} leaked: {reason}");
}
assert!(reason.contains("capped at 2"), "{reason}");
assert!(reason.contains("builders.max_concurrent"), "{reason}");
}
}