Add BrokerPort: loopd asks brokerd for every tool call

Implemented-By: OpenCode session (model recorded in docs/implementer-log.md)
This commit is contained in:
2026-09-20 19:40:35 -07:00
parent 1ceaa36b9b
commit 469be2c0a1
13 changed files with 1142 additions and 15 deletions
+220
View File
@@ -0,0 +1,220 @@
//! The tool port to `brokerd`: one connection per call, a deadline per frame, and plain failures.
//!
//! Whatever goes wrong reaching `brokerd` or reading its frames, the model gets a plain failure and
//! the turn goes on. Every failure prints one line ending at `#broker-unavailable`.
use std::os::unix::net::UnixStream;
use std::path::PathBuf;
use std::time::{Duration, Instant};
use crate::tools::{Pending, ToolPort};
use proto::{
Envelope, FrameError, Message, PROTOCOL_VERSION, Timestamp, ToolRequest, ToolResponse,
read_frame, write_frame,
};
/// The failure every outage reaches the model as.
pub const UNAVAILABLE: &str = "the tool broker is unavailable";
/// What the model is told when no broker is configured.
pub const NOT_CONFIGURED: &str = "no tool broker is configured";
/// The failure a request too big for one frame reaches.
pub const TOO_LARGE: &str = "the request is too large for the tool broker";
/// The runbook entry every outage line ends with.
pub const POINTER: &str = "see docs/runbook.md#broker-unavailable";
/// "loopd: {UNAVAILABLE}: {why}; {POINTER}"
pub fn unavailable_line(why: &str) -> String {
format!("loopd: {UNAVAILABLE}: {why}; {POINTER}")
}
/// "loopd: {NOT_CONFIGURED}: every tool call except the core ones will fail; {POINTER}"
pub fn not_configured_line() -> String {
format!("loopd: {NOT_CONFIGURED}: every tool call except the core ones will fail; {POINTER}")
}
/// Reads through `stream`, but only what is left before `until`: a read past the deadline fails
/// with a timeout, so a peer that sends one byte at a time cannot hold the turn.
struct Deadline<'a> {
stream: &'a UnixStream,
until: Instant,
}
impl std::io::Read for Deadline<'_> {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
let left = self.until.saturating_duration_since(Instant::now());
if left.is_zero() {
return Err(std::io::Error::from(std::io::ErrorKind::TimedOut));
}
self.stream.set_read_timeout(Some(left))?;
let mut stream = self.stream; // `Read` is implemented for `&UnixStream`
stream.read(buf)
}
}
/// The tool port to `brokerd`. One connection per call, with a deadline per frame.
pub struct BrokerPort {
socket: PathBuf,
timeout: Duration,
log: Box<dyn Fn(&str) + Send + Sync>,
}
impl BrokerPort {
pub fn new(socket: PathBuf, timeout: Duration) -> BrokerPort {
BrokerPort {
socket,
timeout,
log: Box::new(|l| eprintln!("{l}")),
}
}
pub fn with_log(
socket: PathBuf,
timeout: Duration,
log: Box<dyn Fn(&str) + Send + Sync>,
) -> BrokerPort {
BrokerPort {
socket,
timeout,
log,
}
}
}
impl ToolPort for BrokerPort {
fn call(&self, request: &ToolRequest, on_pending: &mut dyn FnMut(&Pending)) -> ToolResponse {
let want = request.call.0;
// The first deadline, taken before connecting.
let mut until = match Instant::now().checked_add(self.timeout) {
Some(until) => until,
None => return self.unavailable("the timeout is too large"),
};
// Exit 2.
let mut stream = match UnixStream::connect(&self.socket) {
Ok(stream) => stream,
Err(e) => {
return self
.unavailable(&format!("cannot connect to {}: {e}", self.socket.display()));
}
};
// Exit 3. `set_write_timeout` fails for a zero timeout.
if let Err(e) = stream.set_write_timeout(Some(self.timeout)) {
return self.unavailable(&format!("cannot set a timeout: {e}"));
}
// Exit 4. One request frame; the broker is fine if it is too large to send.
let request_frame = Envelope {
v: PROTOCOL_VERSION,
id: want,
r#final: true,
msg: Message::ToolRequest(request.clone()),
};
if let Err(e) = write_frame(&mut stream, &request_frame) {
return match e {
FrameError::TooLarge(_) => ToolResponse::Failed {
message: TOO_LARGE.to_string(),
},
_ => self.unavailable(&format!("cannot send the request: {e}")),
};
}
// Exit 5-11, once per frame. A pending frame is reported, then we read again.
let mut seen_pending = false;
loop {
let mut deadline = Deadline {
stream: &stream,
until,
};
let env = match read_frame(&mut deadline) {
Ok(env) => env,
// Exit 5.
Err(FrameError::Closed) => {
return self.unavailable("the connection closed before the final answer");
}
Err(FrameError::Io(e))
if e.kind() == std::io::ErrorKind::WouldBlock
|| e.kind() == std::io::ErrorKind::TimedOut =>
{
return self.unavailable("no answer in time");
}
Err(e) => return self.unavailable(&format!("bad frame: {e}")),
};
// Exit 6.
if env.id != want {
return self
.unavailable(&format!("an answer for request {}, not {}", env.id, want));
}
let r#final = env.r#final;
// Exit 7.
let resp = match env.msg {
Message::ToolResponse(resp) => resp,
Message::Error(e) => {
return self
.unavailable(&format!("the broker reported an error: {}", e.detail));
}
_ => return self.unavailable("an unexpected message"),
};
match resp {
// Exit 8.
ToolResponse::PendingApproval { approval, expires } => {
if r#final {
return self.unavailable("a pending frame marked final");
}
// Exit 10.
if seen_pending {
return self.unavailable("a second pending frame");
}
let wait = expires
.unix_millis()
.saturating_sub(Timestamp::now().unix_millis());
until = match Instant::now().checked_add(Duration::from_millis(wait)) {
Some(until) => until,
None => return self.unavailable("an expiry too far away"),
};
until = match until.checked_add(self.timeout) {
Some(until) => until,
None => return self.unavailable("an expiry too far away"),
};
seen_pending = true;
on_pending(&Pending { approval, expires });
}
// Exit 9 and 11.
ToolResponse::Result { .. }
| ToolResponse::Failed { .. }
| ToolResponse::Denied { .. } => {
if r#final {
return resp;
}
return self.unavailable("an answer not marked final");
}
}
}
}
}
/// The one failure no outage reaches the model as, and the one line printed with it.
impl BrokerPort {
fn unavailable(&self, why: &str) -> ToolResponse {
(self.log)(&unavailable_line(why));
ToolResponse::Failed {
message: UNAVAILABLE.to_string(),
}
}
}
/// The port used when no broker is configured: every call fails with `NOT_CONFIGURED` and prints
/// nothing.
pub struct NoBroker;
impl ToolPort for NoBroker {
fn call(&self, _request: &ToolRequest, _on_pending: &mut dyn FnMut(&Pending)) -> ToolResponse {
ToolResponse::Failed {
message: NOT_CONFIGURED.to_string(),
}
}
}