use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use anyhow::{Context, Result};
use bitrouter_mcp::capabilities::escalation::{
EscalationDecision, EscalationRequest, EscalationState,
};
use bitrouter_mcp::capabilities::fleet::{Fleet, PromptArgs, SpawnArgs};
use bitrouter_mcp::error::ToolError;
use bitrouter_sdk::acp::ConfigAcpRoutingTable;
use bitrouter_sdk::config::WorktreesConfig;
use bitrouter_substrate::engine::{LaunchOptions, Session};
use bitrouter_substrate::translate::{PermissionOutcome, SessionUpdateKind, select_option};
use futures::StreamExt;
use crate::fleet::truncate_utf8;
use crate::result_contract::ResultContract;
use crate::risk::Risk;
const MAX_REPLY_BYTES: usize = 32 * 1024;
const MAX_DIFF_BYTES: usize = 64 * 1024;
pub const MAX_CONCURRENT_SUBAGENTS: usize = 6;
pub enum SpendReading {
Known(u64),
Unavailable,
}
#[async_trait::async_trait]
pub trait SpendSnapshot: Send + Sync {
async fn spent_micro_usd(&self) -> SpendReading;
}
pub struct MeteringSpend {
source: crate::paths::ConfigSource,
}
impl MeteringSpend {
pub fn new(source: crate::paths::ConfigSource) -> Self {
Self { source }
}
}
#[async_trait::async_trait]
impl SpendSnapshot for MeteringSpend {
async fn spent_micro_usd(&self) -> SpendReading {
use crate::metering::reader::ReadSide;
use crate::metering::store::TimeWindow;
match crate::metering::reader::read_side(&self.source).await {
ReadSide::Absent => SpendReading::Known(0),
ReadSide::Unavailable => SpendReading::Unavailable,
ReadSide::Store(store) => match store.spend_summary(TimeWindow::Today).await {
Ok(today) => SpendReading::Known(today.spend_micro_usd),
Err(_) => SpendReading::Unavailable,
},
}
}
}
pub struct BudgetCeiling {
ceiling_micro_usd: u64,
spend: Arc<dyn SpendSnapshot>,
}
impl BudgetCeiling {
pub fn new(ceiling_micro_usd: u64, spend: Arc<dyn SpendSnapshot>) -> Self {
Self {
ceiling_micro_usd,
spend,
}
}
async fn check(&self) -> Result<()> {
let spent = match self.spend.spent_micro_usd().await {
SpendReading::Known(spent) => spent,
SpendReading::Unavailable => anyhow::bail!(
"budget ceiling {} is set, but today's machine-wide spend can't be read from \
the metering database — refusing to spawn/prompt rather than risk spending \
past the ceiling. Check the metering database (path/permissions), or restart \
the bridge without --budget-usd.",
crate::metering::fmt_usd(self.ceiling_micro_usd),
),
};
if spent >= self.ceiling_micro_usd {
anyhow::bail!(
"budget ceiling {} reached; current spend {} — raise it with --budget-usd, or \
start a fresh window (the ceiling applies to today's machine-wide spend).",
crate::metering::fmt_usd(self.ceiling_micro_usd),
crate::metering::fmt_usd(spent),
);
}
Ok(())
}
}
pub fn budget_usd_to_micro(usd: f64) -> Result<u64> {
if !usd.is_finite() || usd <= 0.0 {
anyhow::bail!("--budget-usd must be a positive dollar amount (got {usd})");
}
let micro = (usd * 1_000_000.0).round() as u64;
if micro == 0 {
anyhow::bail!(
"--budget-usd {usd} rounds to a $0 ceiling (below one micro-USD) and would refuse \
every spawn; use at least $0.000001."
);
}
Ok(micro)
}
#[cfg(test)]
struct StubSpend(std::sync::atomic::AtomicU64);
#[cfg(test)]
impl StubSpend {
fn new(micro: u64) -> Arc<Self> {
Arc::new(Self(std::sync::atomic::AtomicU64::new(micro)))
}
#[cfg(unix)]
fn set(&self, micro: u64) {
self.0.store(micro, Ordering::SeqCst);
}
}
#[cfg(test)]
#[async_trait::async_trait]
impl SpendSnapshot for StubSpend {
async fn spent_micro_usd(&self) -> SpendReading {
SpendReading::Known(self.0.load(Ordering::SeqCst))
}
}
struct Subagent {
session: Arc<Session>,
agent_id: String,
worktree: Option<PathBuf>,
branch: Option<String>,
base_ref: String,
port: Option<crate::fleet::PortLease>,
state: &'static str,
outcome: Option<serde_json::Value>,
}
struct Launched {
handle: String,
session: Arc<Session>,
task: String,
contract: Option<ResultContract>,
bootstrap_skipped: bool,
}
#[derive(Clone)]
pub struct SubstrateFleet {
inner: Arc<FleetInner>,
}
struct FleetInner {
catalog: ConfigAcpRoutingTable,
base_repo: PathBuf,
worktrees: WorktreesConfig,
allow_writes: bool,
budget: Option<BudgetCeiling>,
escalation: Option<Arc<EscalationState>>,
subagent_mcp: Vec<agent_client_protocol::schema::v1::McpServer>,
registry: tokio::sync::Mutex<Registry>,
reserving: Arc<AtomicUsize>,
#[cfg(unix)]
link: Option<Arc<TuiLink>>,
}
#[derive(Default)]
struct Registry {
agents: HashMap<String, Subagent>,
}
struct Reservation {
reserving: Arc<AtomicUsize>,
committed: bool,
}
impl Reservation {
fn claim(reserving: Arc<AtomicUsize>) -> Self {
reserving.fetch_add(1, Ordering::SeqCst);
Self {
reserving,
committed: false,
}
}
fn commit(mut self) {
self.committed = true;
}
}
impl Drop for Reservation {
fn drop(&mut self) {
if !self.committed {
self.reserving.fetch_sub(1, Ordering::SeqCst);
}
}
}
#[cfg(unix)]
struct TuiLink {
writer: tokio::sync::Mutex<tokio::net::unix::OwnedWriteHalf>,
bootstrap_approved: std::sync::atomic::AtomicBool,
pending: tokio::sync::Mutex<HashMap<u64, tokio::sync::oneshot::Sender<String>>>,
next_id: std::sync::atomic::AtomicU64,
verdicts: tokio::sync::Mutex<HashMap<String, String>>,
}
#[cfg(unix)]
impl TuiLink {
async fn connect(path: &str) -> Option<Arc<Self>> {
let stream = match tokio::net::UnixStream::connect(path).await {
Ok(s) => s,
Err(e) => {
tracing::warn!(error = %e, path, "fleet TUI socket connect failed");
return None;
}
};
let (read, write) = stream.into_split();
let link = Arc::new(TuiLink {
writer: tokio::sync::Mutex::new(write),
bootstrap_approved: std::sync::atomic::AtomicBool::new(false),
pending: tokio::sync::Mutex::new(HashMap::new()),
next_id: std::sync::atomic::AtomicU64::new(1),
verdicts: tokio::sync::Mutex::new(HashMap::new()),
});
let reader_link = Arc::clone(&link);
tokio::spawn(async move {
use tokio::io::AsyncBufReadExt;
let mut lines = tokio::io::BufReader::new(read).lines();
while let Ok(Some(line)) = lines.next_line().await {
match serde_json::from_str::<crate::fleet::TuiMsg>(&line) {
Ok(crate::fleet::TuiMsg::Hello { bootstrap_approved }) => {
reader_link
.bootstrap_approved
.store(bootstrap_approved, std::sync::atomic::Ordering::SeqCst);
}
Ok(crate::fleet::TuiMsg::BootstrapApproved) => {
reader_link
.bootstrap_approved
.store(true, std::sync::atomic::Ordering::SeqCst);
}
Ok(crate::fleet::TuiMsg::Resolve { id, outcome }) => {
if let Some(sender) = reader_link.pending.lock().await.remove(&id) {
let _ = sender.send(outcome);
}
}
Ok(crate::fleet::TuiMsg::ReviewVerdict {
handle,
verdict,
note,
}) => {
reader_link
.verdicts
.lock()
.await
.insert(handle, format!("{verdict}: {note}"));
}
Err(e) => tracing::warn!(error = %e, "unparseable fleet TUI message"),
}
}
reader_link.pending.lock().await.clear();
});
Some(link)
}
async fn send(&self, msg: &crate::fleet::BridgeMsg) {
use tokio::io::AsyncWriteExt;
if let Ok(mut line) = serde_json::to_string(msg) {
line.push('\n');
let _ = self.writer.lock().await.write_all(line.as_bytes()).await;
}
}
async fn request_permission(
&self,
handle: &str,
pending: &bitrouter_substrate::up::PendingPermission,
) -> Option<bitrouter_substrate::translate::PermissionOutcome> {
let id = self
.next_id
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
let (sender, receiver) = tokio::sync::oneshot::channel();
self.pending.lock().await.insert(id, sender);
let title = pending
.tool_call
.fields
.title
.clone()
.unwrap_or_else(|| "(unnamed)".to_string());
self.send(&crate::fleet::BridgeMsg::Permission {
id,
handle: handle.to_string(),
title,
diff: crate::fleet::wire_diff(pending),
options: crate::fleet::wire_options(pending),
})
.await;
let outcome = receiver.await.ok()?;
Some(crate::fleet::outcome_from_str(&outcome))
}
}
impl SubstrateFleet {
pub async fn connect(
catalog: ConfigAcpRoutingTable,
base_repo: PathBuf,
worktrees: WorktreesConfig,
allow_writes: bool,
budget: Option<BudgetCeiling>,
escalation: Option<Arc<EscalationState>>,
subagent_mcp: Vec<agent_client_protocol::schema::v1::McpServer>,
) -> Self {
#[cfg(unix)]
let link = match std::env::var(crate::fleet::TUI_SOCK_ENV) {
Ok(path) => TuiLink::connect(&path).await,
Err(_) => None,
};
let inner = FleetInner {
catalog,
base_repo,
worktrees,
allow_writes,
budget,
escalation,
subagent_mcp,
registry: tokio::sync::Mutex::new(Registry::default()),
reserving: Arc::new(AtomicUsize::new(0)),
#[cfg(unix)]
link,
};
Self {
inner: Arc::new(inner),
}
}
async fn reserve_and_launch(&self, args: SpawnArgs) -> Result<Launched> {
let inner = &self.inner;
if let Some(budget) = &inner.budget {
budget.check().await?;
}
let reservation = {
let reg = inner.registry.lock().await;
if reg.agents.len() + inner.reserving.load(Ordering::SeqCst) >= MAX_CONCURRENT_SUBAGENTS
{
anyhow::bail!(
"fleet at capacity: {MAX_CONCURRENT_SUBAGENTS} subagents already running. \
Integrate or close one (merge_subagent / apply_subagent / close_subagent) \
before spawning more."
);
}
Reservation::claim(Arc::clone(&inner.reserving))
};
self.launch_and_register(args, reservation).await
}
async fn run_opening_turn(&self, launched: Launched) -> Result<serde_json::Value> {
let Launched {
handle,
session,
task,
contract,
bootstrap_skipped,
} = launched;
let mut summary = self
.run_blocking_turn(&handle, session, &task, contract)
.await?;
if bootstrap_skipped {
summary["bootstrap"] = serde_json::json!(
"skipped — the worktree bootstrap hook isn't approved in the TUI yet \
(the human approves it on their first spawn there)"
);
}
Ok(summary)
}
async fn spawn_background(&self, args: SpawnArgs) -> Result<serde_json::Value> {
let launched = self.reserve_and_launch(args).await?;
let handle = launched.handle.clone();
let (agent_id, worktree, branch, _base_ref, port) = self.meta(&handle).await?;
let this = self.clone();
let runner = handle.clone();
tokio::spawn(async move {
let result = this.run_opening_turn(launched).await;
let _ = this.complete_turn(&runner, result).await;
});
Ok(serde_json::json!({
"handle": handle,
"agent": agent_id,
"state": "working",
"worktree": worktree,
"branch": branch,
"port": port,
"note": format!(
"launched and running in the background. Poll subagent_status(\"{handle}\") for \
its state, reply, and diff — state becomes \"completed\" when the turn ends; it \
also appears in the fleet rail for the human. Do not re-spawn if this seems slow: \
the subagent is already running."
),
}))
}
async fn complete_turn(
&self,
handle: &str,
result: Result<serde_json::Value>,
) -> Result<serde_json::Value> {
let (state, outcome) = match &result {
Ok(summary) => ("completed", summary.clone()),
Err(e) => (
"failed",
serde_json::json!({ "state": "failed", "error": format!("{e:#}") }),
),
};
if let Some(sub) = self.inner.registry.lock().await.agents.get_mut(handle) {
sub.outcome = Some(outcome);
}
self.set_state(handle, state).await;
result
}
async fn launch_and_register(
&self,
args: SpawnArgs,
reservation: Reservation,
) -> Result<Launched> {
let inner = &self.inner;
let isolate = args.worktree.unwrap_or(true);
let contract = args
.result_schema
.as_ref()
.map(|schema| ResultContract::from_flag(&schema.to_string()))
.transpose()?;
let port = crate::fleet::reserve_port(
&inner.base_repo,
(inner.worktrees.ports.from, inner.worktrees.ports.to),
);
let tag = crate::fleet::branch_tag(&args.agent);
let bootstrap_cmd = isolate.then(|| inner.worktrees.bootstrap.clone()).flatten();
#[cfg(unix)]
let bootstrap_gated = inner.link.as_ref().is_some_and(|l| {
!l.bootstrap_approved
.load(std::sync::atomic::Ordering::SeqCst)
});
#[cfg(not(unix))]
let bootstrap_gated = false;
let bootstrap_skipped = bootstrap_gated && bootstrap_cmd.is_some();
let options = LaunchOptions {
worktree: isolate.then(|| crate::fleet::worktree_spec(&tag)),
worktree_bootstrap: if bootstrap_gated { None } else { bootstrap_cmd },
env: crate::fleet::port_env(port.as_ref().map(|l| l.port())),
mcp_servers: inner.subagent_mcp.clone(),
..Default::default()
};
let base_ref = crate::fleet::base_head(&inner.base_repo).await;
let session = Session::launch(
&inner.catalog,
&args.agent,
inner.base_repo.clone(),
options,
)
.await
.with_context(|| format!("launching acp subagent '{}'", args.agent))?;
let record_id = session.state().record_id.clone();
let handle = crate::fleet::record16(&record_id);
let worktree = session.worktree_path().map(PathBuf::from);
let branch = worktree
.is_some()
.then(|| format!("bitrouter/{tag}-{handle}"));
let session = Arc::new(session);
spawn_auto_policy(inner, &session, handle.clone());
let port_num = port.as_ref().map(|l| l.port());
{
let mut reg = inner.registry.lock().await;
inner.reserving.fetch_sub(1, Ordering::SeqCst);
reg.agents.insert(
handle.clone(),
Subagent {
session: Arc::clone(&session),
agent_id: args.agent.clone(),
worktree: worktree.clone(),
branch: branch.clone(),
base_ref: base_ref.clone(),
port,
state: "working",
outcome: None,
},
);
reservation.commit();
}
#[cfg(unix)]
if let Some(link) = &inner.link {
link.send(&crate::fleet::BridgeMsg::Spawned {
handle: handle.clone(),
agent: args.agent.clone(),
port: port_num,
})
.await;
}
#[cfg(not(unix))]
let _ = port_num;
Ok(Launched {
handle,
session,
task: args.task,
contract,
bootstrap_skipped,
})
}
async fn prompt_prepare(&self, handle: &str) -> Result<Arc<Session>> {
if let Some(budget) = &self.inner.budget {
budget.check().await?;
}
let session = {
let mut reg = self.inner.registry.lock().await;
let sub = reg
.agents
.get_mut(handle)
.with_context(|| format!("no subagent with handle '{handle}'"))?;
if sub.state == "working" {
anyhow::bail!(
"subagent '{handle}' is still working — wait for it to reach `completed` \
(poll subagent_status) before prompting again."
);
}
sub.state = "working";
sub.outcome = None; Arc::clone(&sub.session)
};
#[cfg(unix)]
if let Some(link) = &self.inner.link {
link.verdicts.lock().await.remove(handle);
}
Ok(session)
}
async fn prompt_background(&self, args: PromptArgs) -> Result<serde_json::Value> {
let session = self.prompt_prepare(&args.handle).await?;
let this = self.clone();
let handle = args.handle.clone();
let text = args.text;
tokio::spawn(async move {
let result = this.run_blocking_turn(&handle, session, &text, None).await;
let _ = this.complete_turn(&handle, result).await;
});
Ok(serde_json::json!({
"handle": args.handle,
"state": "working",
"note": format!(
"prompt delivered — running in the background. Poll subagent_status(\"{}\") for \
the reply and diff.",
args.handle
),
}))
}
async fn run_blocking_turn(
&self,
handle: &str,
session: Arc<Session>,
text: &str,
contract: Option<ResultContract>,
) -> Result<serde_json::Value> {
let task = match &contract {
Some(c) => format!("{text}{}", c.instruction()),
None => text.to_string(),
};
let (response, reply) = collect_turn(&session, &task).await?;
let (response, result, schema_ok) = match &contract {
None => (response, None, None),
Some(c) => match c.check(&reply) {
Ok(v) => (response, Some(v), Some(true)),
Err(problem) => {
let (response, reply) =
collect_turn(&session, &c.repair_prompt(&problem)).await?;
match c.check(&reply) {
Ok(v) => (response, Some(v), Some(true)),
Err(_) => (response, Some(serde_json::Value::Null), Some(false)),
}
}
},
};
let (agent_id, worktree, branch, base_ref, port) = self.meta(handle).await?;
let diff_stat = match &worktree {
Some(wt) => crate::fleet::diff_stat(wt, &base_ref).await,
None => None,
};
let mut reply = reply;
if reply.len() > MAX_REPLY_BYTES {
truncate_utf8(&mut reply, MAX_REPLY_BYTES);
reply.push_str("\n… (truncated)");
}
let mut summary = serde_json::json!({
"handle": handle,
"agent": agent_id,
"state": "completed",
"stop_reason": response.stop_reason,
"reply": reply,
"worktree": worktree,
"branch": branch,
"port": port,
"diff_stat": diff_stat,
});
if let Some(r) = result {
summary["result"] = r;
summary["schema_ok"] = serde_json::json!(schema_ok);
}
Ok(summary)
}
async fn set_state(&self, handle: &str, state: &'static str) {
if let Some(sub) = self.inner.registry.lock().await.agents.get_mut(handle) {
sub.state = state;
}
#[cfg(unix)]
if let Some(link) = &self.inner.link {
link.send(&crate::fleet::BridgeMsg::State {
handle: handle.to_string(),
state: state.to_string(),
})
.await;
}
}
async fn meta(
&self,
handle: &str,
) -> Result<(String, Option<PathBuf>, Option<String>, String, Option<u16>)> {
let reg = self.inner.registry.lock().await;
let sub = reg
.agents
.get(handle)
.with_context(|| format!("no subagent with handle '{handle}'"))?;
Ok((
sub.agent_id.clone(),
sub.worktree.clone(),
sub.branch.clone(),
sub.base_ref.clone(),
sub.port.as_ref().map(|l| l.port()),
))
}
async fn do_status(&self, handle: Option<&str>) -> Result<serde_json::Value> {
#[cfg(unix)]
let verdicts = match &self.inner.link {
Some(link) => link.verdicts.lock().await.clone(),
None => HashMap::new(),
};
#[cfg(not(unix))]
let verdicts: HashMap<String, String> = HashMap::new();
let reg = self.inner.registry.lock().await;
match handle {
Some(h) => {
let sub = reg
.agents
.get(h)
.with_context(|| format!("no subagent with handle '{h}'"))?;
Ok(snapshot(h, sub, verdicts.get(h).map(String::as_str)).await)
}
None => {
let mut fleet = Vec::new();
for (h, sub) in reg.agents.iter() {
fleet.push(snapshot(h, sub, verdicts.get(h).map(String::as_str)).await);
}
Ok(serde_json::json!({ "fleet": fleet }))
}
}
}
async fn do_diff(&self, handle: &str) -> Result<String> {
let (_, worktree, _, base_ref, _) = self.meta(handle).await?;
let wt = worktree.context("subagent has no worktree (spawned with worktree=false)")?;
let mut diff = crate::fleet::git_stdout(&wt, &["diff", &base_ref]).await?;
let untracked =
crate::fleet::git_stdout(&wt, &["ls-files", "--others", "--exclude-standard"]).await?;
if !untracked.trim().is_empty() {
diff.push_str("\n# untracked files:\n");
diff.push_str(&untracked);
}
if diff.len() > MAX_DIFF_BYTES {
truncate_utf8(&mut diff, MAX_DIFF_BYTES);
diff.push_str("\n… (truncated)");
}
if diff.trim().is_empty() {
diff = "(no changes vs the spawn base)".to_string();
}
Ok(diff)
}
fn require_write_grant(&self, verb: &str) -> Result<()> {
if self.inner.allow_writes {
return Ok(());
}
anyhow::bail!(
"{verb} is human-gated by default: it writes to the base repository. Ask the \
human to integrate from the review queue (`bitrouter tui`), or to restart \
this bridge with `bitrouter mcp serve --backend fleet --allow-writes` to \
grant write autonomy."
)
}
async fn do_apply(&self, handle: &str) -> Result<serde_json::Value> {
self.require_write_grant("apply_subagent")?;
let reg = self.inner.registry.lock().await;
let sub = reg
.agents
.get(handle)
.with_context(|| format!("no subagent with handle '{handle}'"))?;
let wt = sub
.worktree
.as_ref()
.context("subagent has no worktree to apply from")?;
crate::fleet::apply_diff(&self.inner.base_repo, wt, &sub.base_ref)
.await
.context("applying the subagent's diff onto the base working tree")?;
Ok(serde_json::json!({
"handle": handle,
"applied": true,
"note": "changes are in the base working tree, uncommitted — the human writes the commit",
}))
}
async fn do_merge(&self, handle: &str) -> Result<serde_json::Value> {
self.require_write_grant("merge_subagent")?;
let reg = self.inner.registry.lock().await;
let sub = reg
.agents
.get(handle)
.with_context(|| format!("no subagent with handle '{handle}'"))?;
let wt = sub
.worktree
.as_ref()
.context("subagent has no worktree to merge")?;
let branch = sub.branch.as_ref().context("subagent has no branch")?;
crate::fleet::merge_branch(
&self.inner.base_repo,
wt,
branch,
"have it commit its work (prompt_subagent), or use apply_subagent to stage \
the diff uncommitted",
)
.await
.context("merging the subagent's branch (resolve conflicts in the base repo)")?;
Ok(serde_json::json!({
"handle": handle,
"merged": branch,
}))
}
async fn do_close(&self, handle: &str) -> Result<serde_json::Value> {
let mut reg = self.inner.registry.lock().await;
let sub = reg
.agents
.remove(handle)
.with_context(|| format!("no subagent with handle '{handle}'"))?;
let Subagent {
session,
agent_id,
worktree,
branch,
base_ref,
port,
state,
outcome,
} = sub;
let only = match Arc::try_unwrap(session) {
Ok(only) => only,
Err(session) => {
reg.agents.insert(
handle.to_string(),
Subagent {
session,
agent_id,
worktree,
branch,
base_ref,
port,
state,
outcome,
},
);
anyhow::bail!(
"subagent '{handle}' still has a turn in flight — wait for it to finish"
);
}
};
drop(reg);
only.shutdown()
.await
.context("shutting down the subagent session")?;
#[cfg(unix)]
if let Some(link) = &self.inner.link {
link.send(&crate::fleet::BridgeMsg::Closed {
handle: handle.to_string(),
})
.await;
}
Ok(serde_json::json!({
"handle": handle,
"closed": true,
"worktree_retained": worktree,
}))
}
}
impl SubstrateFleet {
async fn send_to_human(
&self,
msg: crate::fleet::BridgeMsg,
delivered: serde_json::Value,
) -> Result<serde_json::Value, ToolError> {
#[cfg(unix)]
if let Some(link) = &self.inner.link {
link.send(&msg).await;
let mut out = delivered;
out["delivered"] = serde_json::json!(true);
return Ok(out);
}
let _ = (&msg, &delivered);
Ok(serde_json::json!({
"delivered": false,
"note": "no human is attached (headless bridge) — nothing was shown",
}))
}
async fn ensure_handle(&self, handle: &str) -> Result<(), ToolError> {
if self.inner.registry.lock().await.agents.contains_key(handle) {
Ok(())
} else {
Err(ToolError::new(format!(
"no subagent with handle '{handle}'"
)))
}
}
}
#[async_trait::async_trait]
impl bitrouter_mcp::capabilities::human::HumanBridge for SubstrateFleet {
async fn notify(&self, message: &str) -> Result<serde_json::Value, ToolError> {
self.send_to_human(
crate::fleet::BridgeMsg::Notify {
message: message.to_string(),
},
serde_json::json!({ "notice": message }),
)
.await
}
async fn request_attach(&self, handle: &str) -> Result<serde_json::Value, ToolError> {
self.ensure_handle(handle).await?;
self.send_to_human(
crate::fleet::BridgeMsg::RequestAttach {
handle: handle.to_string(),
},
serde_json::json!({ "requested": "attach", "handle": handle }),
)
.await
}
async fn request_review(&self, handle: &str) -> Result<serde_json::Value, ToolError> {
self.ensure_handle(handle).await?;
self.send_to_human(
crate::fleet::BridgeMsg::RequestReview {
handle: handle.to_string(),
},
serde_json::json!({ "requested": "review", "handle": handle }),
)
.await
}
}
fn to_tool_error(e: anyhow::Error) -> ToolError {
ToolError::new(format!("{e:#}"))
}
#[async_trait::async_trait]
impl Fleet for SubstrateFleet {
async fn spawn(&self, args: SpawnArgs) -> Result<serde_json::Value, ToolError> {
self.spawn_background(args).await.map_err(to_tool_error)
}
async fn prompt(&self, args: PromptArgs) -> Result<serde_json::Value, ToolError> {
self.prompt_background(args).await.map_err(to_tool_error)
}
async fn status(&self, handle: Option<&str>) -> Result<serde_json::Value, ToolError> {
self.do_status(handle).await.map_err(to_tool_error)
}
async fn diff(&self, handle: &str) -> Result<String, ToolError> {
self.do_diff(handle).await.map_err(to_tool_error)
}
async fn apply(&self, handle: &str) -> Result<serde_json::Value, ToolError> {
self.do_apply(handle).await.map_err(to_tool_error)
}
async fn merge(&self, handle: &str) -> Result<serde_json::Value, ToolError> {
self.do_merge(handle).await.map_err(to_tool_error)
}
async fn close(&self, handle: &str) -> Result<serde_json::Value, ToolError> {
self.do_close(handle).await.map_err(to_tool_error)
}
}
fn spawn_auto_policy(inner: &Arc<FleetInner>, session: &Arc<Session>, handle: String) {
let mut perms = session.permissions();
let workroot = inner.base_repo.clone();
let escalation = inner.escalation.clone();
#[cfg(unix)]
let link = inner.link.clone();
tokio::spawn(async move {
while let Some(pending) = perms.next().await {
let title = pending
.tool_call
.fields
.title
.clone()
.unwrap_or_else(|| "(unnamed)".to_string());
match crate::risk::classify(&pending.tool_call.fields, &workroot) {
Risk::Low => {
tracing::info!(subagent = %handle, tool = %title, "auto-allowed (low risk)");
let selected = select_option(PermissionOutcome::AllowOnce, &pending.options);
pending.resolve(selected);
}
Risk::High => {
if let Some(escalation) = &escalation
&& escalation.can_escalate()
{
let decision = escalation
.escalate(EscalationRequest {
subagent: handle.clone(),
tool_title: title.clone(),
})
.await;
match decision {
Some(EscalationDecision::Allow) => {
tracing::info!(
subagent = %handle, tool = %title,
"allowed by the orchestrator conversation (elicitation)"
);
let selected =
select_option(PermissionOutcome::AllowOnce, &pending.options);
pending.resolve(selected);
continue;
}
Some(EscalationDecision::Deny) => {
tracing::info!(
subagent = %handle, tool = %title,
"denied by the orchestrator conversation (elicitation)"
);
drop(pending);
continue;
}
None => {}
}
}
#[cfg(unix)]
if let Some(link) = &link {
match link.request_permission(&handle, &pending).await {
Some(outcome) => {
tracing::info!(
subagent = %handle, tool = %title, outcome = ?outcome,
"resolved by the human (TUI decision queue)"
);
let selected = select_option(outcome, &pending.options);
pending.resolve(selected);
}
None => {
tracing::warn!(
subagent = %handle, tool = %title,
"TUI link lost — denied (high risk)"
);
drop(pending);
}
}
continue;
}
tracing::warn!(subagent = %handle, tool = %title, "denied (high risk, no human in the loop)");
drop(pending); }
}
}
});
}
async fn collect_turn(
session: &Session,
text: &str,
) -> Result<(agent_client_protocol::schema::v1::PromptResponse, String)> {
let mut updates = session.updates();
let mut reply = String::new();
let response = {
let prompt_future = session.prompt(text);
tokio::pin!(prompt_future);
loop {
tokio::select! {
biased;
result = &mut prompt_future => {
let response = result.context("subagent prompt failed")?;
loop {
let maybe = tokio::select! {
biased;
v = updates.next() => v,
_ = std::future::ready(()) => None,
};
match maybe {
Some(SessionUpdateKind::MessageChunk { text, .. }) => reply.push_str(&text),
Some(_) => {}
None => break,
}
}
break response;
}
maybe_update = updates.next() => {
if let Some(SessionUpdateKind::MessageChunk { text, .. }) = maybe_update {
reply.push_str(&text);
}
}
}
}
};
Ok((response, reply))
}
async fn snapshot(handle: &str, sub: &Subagent, verdict: Option<&str>) -> serde_json::Value {
let mut snap = match &sub.outcome {
Some(outcome) => outcome.clone(),
None => serde_json::json!({
"state": sub.state,
"worktree": sub.worktree,
"branch": sub.branch,
"port": sub.port.as_ref().map(|l| l.port()),
"diff_stat": match &sub.worktree {
Some(wt) => crate::fleet::diff_stat(wt, &sub.base_ref).await,
None => None,
},
}),
};
snap["handle"] = serde_json::json!(handle);
snap["agent"] = serde_json::json!(sub.agent_id);
match verdict {
Some(v) => {
snap["state"] = serde_json::json!("changes_requested");
snap["review_verdict"] = serde_json::json!(v);
}
None => {
snap["review_verdict"] = serde_json::Value::Null;
}
}
snap
}
#[cfg(all(test, unix))]
impl SubstrateFleet {
async fn do_spawn(&self, args: SpawnArgs) -> Result<serde_json::Value> {
let launched = self.reserve_and_launch(args).await?;
let handle = launched.handle.clone();
let result = self.run_opening_turn(launched).await;
self.complete_turn(&handle, result).await
}
async fn do_prompt(&self, args: PromptArgs) -> Result<serde_json::Value> {
let session = self.prompt_prepare(&args.handle).await?;
let result = self
.run_blocking_turn(&args.handle, session, &args.text, None)
.await;
self.complete_turn(&args.handle, result).await
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn write_verbs_are_gated_without_the_grant() {
let fleet = SubstrateFleet::connect(
ConfigAcpRoutingTable::from_configs(std::iter::empty()).expect("empty catalog"),
PathBuf::from("/tmp"),
WorktreesConfig::default(),
false,
None, None, Vec::new(), )
.await;
let err = fleet
.require_write_grant("merge_subagent")
.expect_err("writes must be human-gated by default");
let msg = format!("{err:#}");
assert!(msg.contains("--allow-writes"), "actionable: {msg}");
assert!(msg.contains("human-gated"), "names the policy: {msg}");
let granted = SubstrateFleet::connect(
ConfigAcpRoutingTable::from_configs(std::iter::empty()).expect("empty catalog"),
PathBuf::from("/tmp"),
WorktreesConfig::default(),
true,
None, None, Vec::new(), )
.await;
assert!(granted.require_write_grant("merge_subagent").is_ok());
}
#[tokio::test]
async fn notify_human_reports_headless_without_a_tui_link() {
use bitrouter_mcp::capabilities::human::HumanBridge;
let fleet = SubstrateFleet::connect(
ConfigAcpRoutingTable::from_configs(std::iter::empty()).expect("empty catalog"),
PathBuf::from("/tmp"),
WorktreesConfig::default(),
false,
None, None, Vec::new(), )
.await;
let out = fleet.notify("heads up").await.expect("notify ok");
assert_eq!(
out["delivered"], false,
"headless: nothing delivered: {out}"
);
assert!(
out["note"].as_str().is_some_and(|n| n.contains("no human")),
"explains why: {out}"
);
}
#[tokio::test]
async fn attach_and_review_reject_unknown_handles() {
use bitrouter_mcp::capabilities::human::HumanBridge;
let fleet = SubstrateFleet::connect(
ConfigAcpRoutingTable::from_configs(std::iter::empty()).expect("empty catalog"),
PathBuf::from("/tmp"),
WorktreesConfig::default(),
false,
None, None, Vec::new(), )
.await;
for res in [
fleet.request_attach("ffffffffffffffff").await,
fleet.request_review("ffffffffffffffff").await,
] {
let err = res.expect_err("an unknown handle must error, not ack");
assert!(
err.0.contains("ffffffffffffffff"),
"names the bad handle: {}",
err.0
);
}
}
#[test]
fn budget_usd_to_micro_scales_and_rejects_unusable() {
assert_eq!(budget_usd_to_micro(2.5).expect("scales"), 2_500_000);
assert_eq!(budget_usd_to_micro(0.000_001).expect("rounds"), 1);
assert!(
budget_usd_to_micro(0.0).is_err(),
"$0 would block every spawn"
);
assert!(
budget_usd_to_micro(0.000_000_4).is_err(),
"a sub-micro amount rounds to a $0 ceiling → every spawn blocked"
);
assert!(budget_usd_to_micro(-1.0).is_err());
assert!(budget_usd_to_micro(f64::NAN).is_err());
assert!(budget_usd_to_micro(f64::INFINITY).is_err());
}
#[tokio::test]
async fn budget_ceiling_allows_under_and_refuses_at_or_over() {
let under = BudgetCeiling::new(10_000_000, StubSpend::new(3_000_000));
under.check().await.expect("under budget proceeds");
let at = BudgetCeiling::new(10_000_000, StubSpend::new(10_000_000));
let err = at.check().await.expect_err("at the ceiling refuses");
let msg = format!("{err:#}");
assert!(msg.contains("budget ceiling"), "names the breaker: {msg}");
assert!(msg.contains("--budget-usd"), "actionable: {msg}");
}
#[tokio::test]
async fn budget_ceiling_fails_closed_when_spend_is_unreadable() {
struct Unreadable;
#[async_trait::async_trait]
impl SpendSnapshot for Unreadable {
async fn spent_micro_usd(&self) -> SpendReading {
SpendReading::Unavailable
}
}
let budget = BudgetCeiling::new(1_000_000, Arc::new(Unreadable));
let err = budget
.check()
.await
.expect_err("unreadable spend must fail closed, not proceed");
let msg = format!("{err:#}");
assert!(
msg.contains("can't be read"),
"explains why it refused: {msg}"
);
assert!(msg.contains("--budget-usd"), "actionable: {msg}");
let fresh = BudgetCeiling::new(1_000_000, StubSpend::new(0));
fresh
.check()
.await
.expect("an empty database reads as $0 and never blocks the first spawn");
}
}
#[cfg(all(test, unix))]
mod e2e_tests {
use super::*;
const WORKER_STUB: &str = r#"
while read line; do
id=$(echo "$line" | sed -n 's/.*"id":"\([^"]*\)".*/\1/p')
case "$line" in
*initialize*) printf '{"jsonrpc":"2.0","id":"%s","result":{"protocolVersion":1}}\n' "$id";;
*session/new*) wd=$(echo "$line" | sed -n 's/.*"cwd":"\([^"]*\)".*/\1/p'); cd "$wd" 2>/dev/null
printf '{"jsonrpc":"2.0","id":"%s","result":{"sessionId":"u1"}}\n' "$id";;
*session/prompt*)
echo made > made.txt
git add made.txt >/dev/null 2>&1
git -c user.email=t@t -c user.name=t commit -qm work >/dev/null 2>&1
printf '{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"u1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"done"}}}}\n'
printf '{"jsonrpc":"2.0","id":"%s","result":{"stopReason":"end_turn"}}\n' "$id";;
esac
done
"#;
const IDLE_STUB: &str = r#"
while read line; do
id=$(echo "$line" | sed -n 's/.*"id":"\([^"]*\)".*/\1/p')
case "$line" in
*initialize*) printf '{"jsonrpc":"2.0","id":"%s","result":{"protocolVersion":1}}\n' "$id";;
*session/new*) printf '{"jsonrpc":"2.0","id":"%s","result":{"sessionId":"u1"}}\n' "$id";;
*session/prompt*)
printf '{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"u1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"ok"}}}}\n'
printf '{"jsonrpc":"2.0","id":"%s","result":{"stopReason":"end_turn"}}\n' "$id";;
esac
done
"#;
const HANG_STUB: &str = r#"
while read line; do
id=$(echo "$line" | sed -n 's/.*"id":"\([^"]*\)".*/\1/p')
case "$line" in
*initialize*) printf '{"jsonrpc":"2.0","id":"%s","result":{"protocolVersion":1}}\n' "$id";;
*session/new*) printf '{"jsonrpc":"2.0","id":"%s","result":{"sessionId":"u1"}}\n' "$id";;
*session/prompt*) : ;;
esac
done
"#;
async fn poll_until_done(fleet: &SubstrateFleet, handle: &str) -> serde_json::Value {
for _ in 0..300 {
let s = fleet.do_status(Some(handle)).await.expect("status");
if s["state"] != "working" {
return s;
}
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
}
panic!("subagent {handle} never left `working`");
}
fn catalog_from(name: &str, script: &str) -> ConfigAcpRoutingTable {
let cfg = bitrouter_sdk::acp::AcpAgentConfig {
name: name.to_string(),
transport: bitrouter_sdk::acp::AcpTransport::Stdio {
command: "bash".to_string(),
args: vec!["-c".to_string(), script.to_string()],
env: HashMap::new(),
},
};
ConfigAcpRoutingTable::from_configs([(name.to_string(), cfg)]).expect("catalog")
}
fn worker_catalog() -> ConfigAcpRoutingTable {
catalog_from("stub", WORKER_STUB)
}
fn init_repo() -> tempfile::TempDir {
let d = tempfile::tempdir().expect("tempdir");
for a in [
&["init", "-q"][..],
&["config", "user.email", "t@t"],
&["config", "user.name", "t"],
] {
std::process::Command::new("git")
.current_dir(d.path())
.args(a)
.status()
.expect("git");
}
std::fs::write(d.path().join("f"), "x").expect("write");
std::process::Command::new("git")
.current_dir(d.path())
.args(["add", "."])
.status()
.expect("git");
std::process::Command::new("git")
.current_dir(d.path())
.args(["commit", "-qm", "init"])
.status()
.expect("git");
d
}
#[tokio::test]
async fn spawn_is_nonblocking_and_status_carries_the_result() {
let repo = init_repo();
let fleet = SubstrateFleet::connect(
worker_catalog(),
repo.path().to_path_buf(),
WorktreesConfig::default(),
true,
None,
None,
Vec::new(),
)
.await;
let ack = fleet
.spawn(SpawnArgs {
agent: "stub".into(),
task: "write made.txt".into(),
worktree: None,
result_schema: None,
})
.await
.expect("spawn ack");
assert_eq!(
ack["state"], "working",
"returns before the turn ends: {ack}"
);
assert!(
ack["note"]
.as_str()
.is_some_and(|n| n.contains("subagent_status")),
"the ack points the orchestrator at the poll surface: {ack}"
);
let handle = ack["handle"].as_str().expect("handle").to_string();
let status = poll_until_done(&fleet, &handle).await;
assert_eq!(status["state"], "completed");
assert_eq!(status["stop_reason"], "end_turn");
assert!(
status["reply"].as_str().is_some_and(|r| r.contains("done")),
"reply on status: {status}"
);
assert_eq!(status["diff_stat"]["files"], 1, "diff via status: {status}");
assert_eq!(status["handle"], handle);
fleet.do_close(&handle).await.expect("close");
}
#[tokio::test]
async fn prompt_is_refused_while_the_subagent_is_working() {
let repo = init_repo();
let fleet = SubstrateFleet::connect(
catalog_from("hang", HANG_STUB),
repo.path().to_path_buf(),
WorktreesConfig::default(),
true,
None,
None,
Vec::new(),
)
.await;
let ack = fleet
.spawn(SpawnArgs {
agent: "hang".into(),
task: "noop".into(),
worktree: Some(false),
result_schema: None,
})
.await
.expect("spawn ack");
let handle = ack["handle"].as_str().expect("handle").to_string();
assert_eq!(ack["state"], "working");
let err = fleet
.prompt(PromptArgs {
handle: handle.clone(),
text: "again".into(),
})
.await
.expect_err("a prompt is refused while a turn is in flight");
assert!(
format!("{err:?}").contains("still working"),
"actionable refusal: {err:?}"
);
}
#[tokio::test]
async fn spawn_review_merge_close_roundtrip() {
let repo = init_repo();
let fleet = SubstrateFleet::connect(
worker_catalog(),
repo.path().to_path_buf(),
WorktreesConfig::default(),
true, None, None, Vec::new(), )
.await;
let summary = fleet
.do_spawn(SpawnArgs {
agent: "stub".into(),
task: "write made.txt".into(),
worktree: None, result_schema: None,
})
.await
.expect("spawn");
assert_eq!(summary["state"], "completed");
assert_eq!(summary["stop_reason"], "end_turn");
assert_eq!(summary["agent"], "stub");
assert!(
summary["reply"]
.as_str()
.is_some_and(|r| r.contains("done"))
);
let handle = summary["handle"].as_str().expect("handle").to_string();
let branch = summary["branch"].as_str().expect("branch").to_string();
assert!(
branch.starts_with("bitrouter/stub-"),
"branch naming: {branch}"
);
let stat = &summary["diff_stat"];
assert_eq!(stat["files"], 1, "one file changed: {summary}");
assert_eq!(stat["adds"], 1);
let status = fleet.do_status(Some(&handle)).await.expect("status");
assert_eq!(status["state"], "completed");
let diff = fleet.do_diff(&handle).await.expect("diff");
assert!(
diff.contains("made.txt") && diff.contains("+made"),
"{diff}"
);
{
use bitrouter_mcp::capabilities::human::HumanBridge;
let attach = fleet.request_attach(&handle).await.expect("live handle ok");
assert_eq!(attach["delivered"], false, "headless: {attach}");
}
let merged = fleet.do_merge(&handle).await.expect("merge");
assert_eq!(merged["merged"], branch);
assert!(
repo.path().join("made.txt").exists(),
"subagent work landed in the base repo"
);
let closed = fleet.do_close(&handle).await.expect("close");
assert_eq!(closed["closed"], true);
let wt = closed["worktree_retained"].as_str().expect("worktree");
assert!(
std::path::Path::new(wt).exists(),
"worktree retained after close (cleanup gated on merged-or-discarded)"
);
assert!(
fleet.do_status(Some(&handle)).await.is_err(),
"closed handle no longer resolves"
);
}
#[tokio::test]
async fn apply_stages_diff_uncommitted() {
let repo = init_repo();
let fleet = SubstrateFleet::connect(
worker_catalog(),
repo.path().to_path_buf(),
WorktreesConfig::default(),
true,
None, None, Vec::new(), )
.await;
let summary = fleet
.do_spawn(SpawnArgs {
agent: "stub".into(),
task: "write made.txt".into(),
worktree: None,
result_schema: None,
})
.await
.expect("spawn");
let handle = summary["handle"].as_str().expect("handle").to_string();
let applied = fleet.do_apply(&handle).await.expect("apply");
assert_eq!(applied["applied"], true);
assert!(repo.path().join("made.txt").exists(), "diff applied");
let porcelain = std::process::Command::new("git")
.current_dir(repo.path())
.args(["status", "--porcelain"])
.output()
.expect("git status");
assert!(
String::from_utf8_lossy(&porcelain.stdout).contains("made.txt"),
"applied changes are uncommitted in the base working tree"
);
fleet.do_close(&handle).await.expect("close");
}
#[tokio::test]
async fn spawn_is_capped_at_max_concurrent() {
let repo = init_repo();
let fleet = SubstrateFleet::connect(
catalog_from("idle", IDLE_STUB),
repo.path().to_path_buf(),
WorktreesConfig::default(),
false,
None, None, Vec::new(), )
.await;
let mut handles = Vec::new();
for _ in 0..MAX_CONCURRENT_SUBAGENTS {
let summary = fleet
.do_spawn(SpawnArgs {
agent: "idle".into(),
task: "noop".into(),
worktree: Some(false),
result_schema: None,
})
.await
.expect("spawn under the cap");
handles.push(summary["handle"].as_str().expect("handle").to_string());
}
let err = fleet
.do_spawn(SpawnArgs {
agent: "idle".into(),
task: "one too many".into(),
worktree: Some(false),
result_schema: None,
})
.await
.expect_err("spawn beyond the cap is rejected");
let msg = format!("{err:#}");
assert!(msg.contains("capacity"), "actionable cap message: {msg}");
assert!(
msg.contains(&MAX_CONCURRENT_SUBAGENTS.to_string()),
"names the cap: {msg}"
);
let freed = handles.remove(0);
fleet.do_close(&freed).await.expect("close");
let summary = fleet
.do_spawn(SpawnArgs {
agent: "idle".into(),
task: "now there is room".into(),
worktree: Some(false),
result_schema: None,
})
.await
.expect("spawn after a close frees a slot");
handles.push(summary["handle"].as_str().expect("handle").to_string());
for handle in &handles {
fleet.do_close(handle).await.expect("close");
}
}
#[tokio::test]
async fn concurrent_spawns_do_not_overshoot_the_cap() {
let repo = init_repo();
let fleet = Arc::new(
SubstrateFleet::connect(
catalog_from("idle", IDLE_STUB),
repo.path().to_path_buf(),
WorktreesConfig::default(),
false,
None, None, Vec::new(), )
.await,
);
let overshoot = 2usize;
let mut tasks = Vec::new();
for i in 0..MAX_CONCURRENT_SUBAGENTS + overshoot {
let fleet = Arc::clone(&fleet);
tasks.push(tokio::spawn(async move {
fleet
.do_spawn(SpawnArgs {
agent: "idle".into(),
task: format!("task {i}"),
worktree: Some(false),
result_schema: None,
})
.await
}));
}
let mut admitted = Vec::new();
let mut rejected = 0usize;
for task in tasks {
match task.await.expect("join") {
Ok(summary) => {
admitted.push(summary["handle"].as_str().expect("handle").to_string())
}
Err(e) => {
assert!(
format!("{e:#}").contains("capacity"),
"reject reason: {e:#}"
);
rejected += 1;
}
}
}
assert_eq!(
admitted.len(),
MAX_CONCURRENT_SUBAGENTS,
"exactly the cap is admitted (no overshoot)"
);
assert_eq!(rejected, overshoot, "the excess spawns are all rejected");
let status = fleet.do_status(None).await.expect("status");
assert_eq!(
status["fleet"].as_array().expect("fleet array").len(),
MAX_CONCURRENT_SUBAGENTS
);
for handle in &admitted {
fleet.do_close(handle).await.expect("close");
}
let ports_dir = repo.path().join(".bitrouter").join("ports");
let leaked: Vec<_> = std::fs::read_dir(&ports_dir)
.map(|rd| rd.flatten().map(|e| e.path()).collect())
.unwrap_or_default();
assert!(leaked.is_empty(), "no port leases leaked: {leaked:?}");
}
#[tokio::test]
async fn tui_link_handshake_and_ndjson_round_trip() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("fleet-tui.sock");
let listener = tokio::net::UnixListener::bind(&path).expect("bind");
let link = TuiLink::connect(path.to_str().expect("utf8 path"))
.await
.expect("connect");
let (mut stream, _) = listener.accept().await.expect("accept");
use tokio::io::AsyncWriteExt;
stream
.write_all(b"{\"type\":\"hello\",\"bootstrap_approved\":true}\n")
.await
.expect("write hello");
let mut approved = false;
for _ in 0..100 {
if link
.bootstrap_approved
.load(std::sync::atomic::Ordering::SeqCst)
{
approved = true;
break;
}
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
}
assert!(approved, "Hello's policy reaches the flag");
link.send(&crate::fleet::BridgeMsg::State {
handle: "abc123".into(),
state: "working".into(),
})
.await;
use tokio::io::AsyncBufReadExt;
let mut lines = tokio::io::BufReader::new(stream).lines();
let line = tokio::time::timeout(std::time::Duration::from_secs(2), lines.next_line())
.await
.expect("no timeout")
.expect("read")
.expect("one line");
match serde_json::from_str::<crate::fleet::BridgeMsg>(&line).expect("parse") {
crate::fleet::BridgeMsg::State { handle, state } => {
assert_eq!(handle, "abc123");
assert_eq!(state, "working");
}
other => panic!("wrong message: {other:?}"),
}
}
#[tokio::test]
async fn budget_ceiling_gates_spawn_and_prompt() {
let repo = init_repo();
let spend = StubSpend::new(0);
let fleet = SubstrateFleet::connect(
catalog_from("idle", IDLE_STUB),
repo.path().to_path_buf(),
WorktreesConfig::default(),
false,
Some(BudgetCeiling::new(10_000_000, spend.clone())),
None, Vec::new(), )
.await;
let summary = fleet
.do_spawn(SpawnArgs {
agent: "idle".into(),
task: "noop".into(),
worktree: Some(false),
result_schema: None,
})
.await
.expect("under budget spawns");
let handle = summary["handle"].as_str().expect("handle").to_string();
spend.set(10_000_000);
let spawn_err = fleet
.do_spawn(SpawnArgs {
agent: "idle".into(),
task: "one too many".into(),
worktree: Some(false),
result_schema: None,
})
.await
.expect_err("over-budget spawn is refused");
assert!(
format!("{spawn_err:#}").contains("budget ceiling"),
"actionable: {spawn_err:#}"
);
let prompt_err = fleet
.do_prompt(PromptArgs {
handle: handle.clone(),
text: "again".into(),
})
.await
.expect_err("over-budget prompt is refused");
assert!(
format!("{prompt_err:#}").contains("budget ceiling"),
"actionable: {prompt_err:#}"
);
let fleet_snap = fleet.do_status(None).await.expect("status");
assert_eq!(
fleet_snap["fleet"].as_array().expect("fleet array").len(),
1,
"over-budget spawn did not launch a session"
);
fleet.do_close(&handle).await.expect("close");
}
#[tokio::test]
async fn unlimited_budget_default_never_blocks() {
let repo = init_repo();
let fleet = SubstrateFleet::connect(
catalog_from("idle", IDLE_STUB),
repo.path().to_path_buf(),
WorktreesConfig::default(),
false,
None, None, Vec::new(), )
.await;
let summary = fleet
.do_spawn(SpawnArgs {
agent: "idle".into(),
task: "noop".into(),
worktree: Some(false),
result_schema: None,
})
.await
.expect("no ceiling → spawns");
fleet
.do_close(summary["handle"].as_str().expect("handle"))
.await
.expect("close");
}
}