Task files, the files they copy in (byte-identical to the reference on m3a-ref), each area's check record, and a README with the per-task table of what each check exposed. The handoff note is done with. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
8.3 KiB
M3a task 17: BrokerPort, [broker] config and the runbook pointers
Branch: m3a (run git switch m3a; git status --short must be empty, otherwise stop)
Commit subject: Add BrokerPort: loopd asks brokerd for every tool call
Goal
loopd serve sends every tool call to brokerd over broker.sock. Whatever goes wrong there, the
model gets a plain failure and the turn goes on. Every fail-closed message loopd prints ends with
its entry in docs/runbook.md.
Files
- Copy:
crates/loopd/tests/broker_port.rs,broker_port_bad.rs,pointers.rs,config.rs,device.rs, andcrates/loopd/tests/support/broker.rs - Create:
crates/loopd/src/broker_port.rs(addpub mod broker_port;tolib.rs) - Modify:
crates/loopd/src/config.rs,main.rs,session.rs,baseline.rs,docs/implementer-log.md
Interfaces
// config.rs: same style as `Loop`. `Config` gains `#[serde(default)] pub broker: Broker`.
#[serde(deny_unknown_fields, default)]
pub struct Broker { pub socket: Option<std::path::PathBuf>, pub timeout_ms: u64 }
// Default: socket None, timeout_ms 120_000. No default path: absent means no broker.
// broker_port.rs
pub const UNAVAILABLE: &str = "the tool broker is unavailable";
pub const NOT_CONFIGURED: &str = "no tool broker is configured";
pub const TOO_LARGE: &str = "the request is too large for the tool broker";
pub const POINTER: &str = "see docs/runbook.md#broker-unavailable";
/// "loopd: {UNAVAILABLE}: {why}; {POINTER}"
pub fn unavailable_line(why: &str) -> String;
/// "loopd: {NOT_CONFIGURED}: every tool call except the core ones will fail; {POINTER}"
pub fn not_configured_line() -> String;
pub struct BrokerPort { /* socket: PathBuf, timeout: Duration, log: Box<dyn Fn(&str) + Send + Sync> */ }
impl BrokerPort {
pub fn new(socket: PathBuf, timeout: Duration) -> BrokerPort; // log = |l| eprintln!("{l}")
pub fn with_log(socket: PathBuf, timeout: Duration, log: Box<dyn Fn(&str) + Send + Sync>) -> BrokerPort;
}
impl ToolPort for BrokerPort { /* below */ }
pub struct NoBroker; // ToolPort: always Failed { NOT_CONFIGURED }, prints nothing
The protocol
One connection per call. loopd sends one frame: v: PROTOCOL_VERSION, id = request.call.0,
final: true, Message::ToolRequest(request.clone()). brokerd answers with frames carrying the
same id: optionally one ToolResponse::PendingApproval with final: false, then exactly one
Result, Failed or Denied with final: true, and closes.
The timeout is a deadline, not a per-read timeout
A read timeout set once lets a peer that sends one byte now and then hold the turn for ever. Read
through this, which allows each read only what is left:
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)
}
}
Verified in the std docs of Rust 1.98.1: set_read_timeout and set_write_timeout "An Err is
returned if the zero Duration is passed to this method", hence the is_zero check first. A read
that times out fails with kind WouldBlock on Linux (TimedOut elsewhere); treat both as a
timeout, as http.rs does. Instant::checked_add(Duration) -> Option<Instant>.
- First deadline:
Instant::now().checked_add(timeout), taken before connecting. - After a pending frame:
wait = expires.unix_millis().saturating_sub(Timestamp::now().unix_millis()), thenInstant::now().checked_add(Duration::from_millis(wait))and.checked_add(timeout)on that. Anexpiresin the past leavestimeout.expirescomes from a peer: never add withoutchecked_add.
What call does. Every exit is listed; there are no others
Exits marked U do the same three things: call log exactly once with
unavailable_line(why), return ToolResponse::Failed { message: UNAVAILABLE.to_string() }, and
drop the stream. Write one helper for it and use it at every U. Never panic, never return
PendingApproval.
- First deadline is
None→ U, whythe timeout is too large. UnixStream::connect(&socket)fails → U,cannot connect to {socket}: {e}.set_write_timeout(Some(timeout))fails (it does for a zero timeout) → U,cannot set a timeout: {e}.write_framefails withFrameError::TooLarge(_)→ returnFailed { TOO_LARGE }and do not calllog: the broker is fine. Any otherwrite_frameerror → U,cannot send the request: {e}.read_frame(&mut Deadline { .. })fails.FrameError::Closed→ U,the connection closed before the final answer.FrameError::Io(e)with kindWouldBlockorTimedOut→ U,no answer in time. Any other error (includingTooLargehere) → U,bad frame: {e}.- The frame's
idis not the request's → U,an answer for request {got}, not {want}. msgisMessage::Error(e)→ U,the broker reported an error: {detail}. Any other message that is notMessage::ToolResponse→ U,an unexpected message.PendingApprovalwithfinal: true→ U,a pending frame marked final.Result,FailedorDeniedwithfinal: false→ U,an answer not marked final.PendingApprovalwithfinal: false: if one was already seen → U,a second pending frame. Otherwise compute the new deadline (None→ U,an expiry too far away), callon_pending(&Pending { approval, expires })once, and go back to step 5 with the new deadline.Result,FailedorDeniedwithfinal: true→ return it unchanged. Nothing is logged: a denial is not an outage.
Steps 5 to 10 apply to the frame after a pending frame exactly as to the first.
Wiring and pointers
main.rs,run_serve:Some(path)→BrokerPort::new(path.clone(), Duration::from_millis(cfg.broker.timeout_ms));None→eprintln!("{}", not_configured_line())once, thenNoBroker.FakeToolsis no longer used bymain.rs. A socket that does not exist yet is not a startup error:loopdconnects per call.main.rs,run_selftest_check: the failure line becomesselftest: FAILED: {e}; see docs/runbook.md#loopd-selftest-failed. It serves both commands.session.rs: onlySessionError::Tornchanges, to{path}:{line}: {why}; see docs/runbook.md#session-log-damaged.baseline.rs: addBaselineError::Core(PathBuf, std::io::Error), displayed as{path}: {err}; see docs/runbook.md#core-memory-unreadable, and return it (notRead) for an unreadablememory/core.md.Readstays as it is forsystem.md.
Steps
- 1. Copy.
git switch m3a, thencp docs/plans/M3a/files/crates/loopd/tests/{broker_port,broker_port_bad,pointers,config,device}.rs crates/loopd/tests/andcp docs/plans/M3a/files/crates/loopd/tests/support/broker.rs crates/loopd/tests/support/ - 2. See the tests fail.
cargo test -p loopd --test broker_port. Expected: no compile. - 3. Write
config.rs, thenbroker_port.rs, then the wiring and pointers.cargo fmt --all. - 4. See the tests pass.
cargo test -p loopd --test broker_port --test broker_port_bad --test pointers --test config, five times in a row. Expected every time:15 passed,2 passed,7 passed,11 passed.broker_porttakes a few seconds: it waits for real timeouts. - 5. Run the gate.
make gate. Expected last line:gate: ok. - 6. Log and commit.
git add crates/loopd docs/implementer-log.md && git commit
Done when
- Step 4's counts, five runs in a row, and
make gateprintsgate: ok. - You have checked each of the eleven exits above against your code, one by one, and the log's Notes say so.
Stop and report if
- A test needs the port to keep a connection open between calls, or to retry.
the_timeout_is_a_deadline_for_the_frame_not_for_each_readfails although you read throughDeadline. Do not loosen it.device.rsdoes not compile. Do not run it: it needs straylight and is the owner's to run.