majordomus_cli/capability/builtin/peers.rs
1//! The `peers` module: the workers of this repository, and what they say they are working
2//! on. Nothing here writes to the repository.
3//!
4//! # The board is the repository's, gathered from every checkout's server
5//!
6//! A board is one server process's memory, and (ADR 0035) a server serves the checkout it
7//! was started in: a linked worktree is a checkout of its own, elects a server of its own,
8//! and keeps a board of its own. So the sentence every worker is bootstrapped with — "one
9//! shared server serves this repository, and every worker attached to it is visible to
10//! every other" — was true of one checkout and false of a repository, and on 2026-09-11
11//! this repository had seven live servers over one git repository, seven boards, and nine
12//! agents spread across sixty worktrees who could not see one another at all. Two of them
13//! built the same subsystem in one afternoon.
14//!
15//! `peers.list` is therefore repository-wide by default. It enumerates the checkouts git
16//! registers, exactly as `server.status` does and through the same reader, reads the lease
17//! of each, and asks every server that answers for its own board — `checkouts=this`, which
18//! is what makes the gather one hop deep and not a cycle. Its own board it reads from
19//! memory. Every peer it returns is stamped with the checkout it came from, because `p1`
20//! is the first session of every board and an unqualified id would be ambiguous the moment
21//! two boards are merged.
22//!
23//! Nothing is written and no server is contacted twice: this is a read of what already
24//! exists. There is no second authority, no registry of servers, no new port and no lease
25//! but the one each checkout already had.
26//!
27//! # What it costs, and how to not pay it
28//!
29//! One lease read per registered checkout, one probe per checkout whose lease names an
30//! address, and one more round trip per server that answers. Measured on this repository
31//! on 2026-09-11 — 118 registered checkouts, 7 live servers — the enumeration and probing
32//! `server.status` already does took 3.0s wall, and the boards are a handful of loopback
33//! round trips on top. A caller that only wants the board of the checkout it is in says
34//! `checkouts: this`, which enumerates nothing, probes nothing and reads one board out of
35//! memory.
36
37use std::path::PathBuf;
38
39use schemars::JsonSchema;
40use serde::{Deserialize, Serialize};
41
42use crate::capability::benchmark::{BenchmarkCases, CaseContext, NamedCase};
43use crate::capability::handler::{CapabilityError, Context};
44use crate::capability::model::{CapabilityKind, Exposure, Stability};
45use crate::capability::module::ModuleDescriptor;
46use crate::lease;
47use crate::peers::{overlaps_among, Announced, Overlap, Peer, PeerCheckout, PeerId};
48use crate::repository;
49use crate::{capability, module};
50
51use super::server::{self, Checkouts, ServerStanding};
52use super::{get, mcp, post};
53
54// ---------------------------------------------------------------- peers.list
55
56#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
57/// What to ask of `peers.list`: how wide the board is.
58///
59/// The same one field, the same word and the same default as
60/// [`super::server::ServerStatusInput`], because it is the same question asked of the same
61/// set of checkouts, and a caller should not have to learn it twice.
62///
63/// The evidence for that is `the_default_board_is_the_repositorys` below rather than a doc
64/// example here: this module is `pub(crate)`, so a doc test — which compiles as an external
65/// crate — cannot name the type at all.
66pub struct PeerListInput {
67 // The whole of "why `this` exists" is in this module's own documentation: kept to one
68 // line here because a field's doc is rendered into a table cell, and a paragraph
69 // break in it breaks the row (docs/generated/modules/peers.md).
70 /// Which checkouts the board covers. Absent means `repository`; `this` is the checkout the call reached and nothing else, and it is what a sibling server is asked so that a gather can never ask back.
71 #[serde(default)]
72 pub checkouts: Checkouts,
73}
74
75impl BenchmarkCases for PeerListInput {
76 fn benchmark_cases(_: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
77 // the narrow answer: a benchmark may not depend on how many worktrees the machine
78 // running it happens to have registered, nor probe servers that are not its own
79 vec![NamedCase::new(
80 "default",
81 PeerListInput {
82 checkouts: Checkouts::This,
83 },
84 )]
85 }
86}
87
88#[derive(Debug, Serialize, Deserialize, JsonSchema)]
89/// One checkout's board, as the gather found it.
90///
91/// Present for every checkout the answer covers, whether or not its board could be read,
92/// because a listing that silently drops the checkouts it could not reach is the shape of
93/// answer that made this capability wrong in the first place: a reader would see a short
94/// board and conclude nobody else is working here.
95pub struct BoardView {
96 /// The checkout.
97 #[serde(flatten)]
98 pub checkout: PeerCheckout,
99 /// Where its server stands, by the same reading `server.status` reports.
100 pub standing: ServerStanding,
101 /// The address its lease names, when it has one.
102 #[serde(skip_serializing_if = "Option::is_none")]
103 pub url: Option<String>,
104 /// How many of this board's peers are attached.
105 pub attached: usize,
106 /// Why this board is not in the answer. `None` when it is.
107 #[serde(skip_serializing_if = "Option::is_none")]
108 pub reason: Option<String>,
109}
110
111#[derive(Debug, Serialize, Deserialize, JsonSchema)]
112/// The answer of `peers.list`: every worker of this repository, from every checkout's board.
113pub struct PeerList {
114 /// How many peers are attached across every board that was read, the caller included.
115 pub count: usize,
116 #[serde(skip_serializing_if = "Option::is_none")]
117 /// The caller's own peer id, when the call came through an MCP session. A position on
118 /// this checkout's board, not a durable identity: it is handed out again after a
119 /// reconnect, and [`Peer::checkout`] is the half of a worker's identity that is not.
120 pub caller: Option<PeerId>,
121 /// The peers, this checkout's board first and then the siblings in the order git
122 /// registers them, each in its own board's attachment order. Every one carries the
123 /// checkout it belongs to, because `p1` is the first session of every board. A peer
124 /// that announced something and then went away is still here, with `attached: false`.
125 pub peers: Vec<Peer>,
126 /// Every pair of peers whose claimed scope meets, each pair once — now across
127 /// checkouts as well as within one. Empty is the ordinary case, and a reader who sees
128 /// an entry here is looking at two workers about to do the same work.
129 #[serde(default, skip_serializing_if = "Vec::is_empty")]
130 pub overlaps: Vec<Overlap>,
131 /// One entry per checkout the answer covers, reached or not.
132 pub boards: Vec<BoardView>,
133 /// Whether every board the answer covers was read. `false` when a checkout's server
134 /// could not be asked, and `BoardView::reason` says which and why — so that "nobody
135 /// else is here" is never reported when the truth is "I could not ask".
136 pub complete: bool,
137}
138
139/// One sibling server's own board, over the route the registry declares for `peers.list`.
140///
141/// Asked with `checkouts=this`, which is the whole of the cycle prevention: a server
142/// answering that question enumerates no other checkout, reads no other lease and probes
143/// no other server, so a gather is exactly one hop deep however many checkouts point at
144/// each other. There is deliberately no retry without the parameter — a server too old to
145/// know it (`Empty` refused unknown fields) would then answer its whole board, and a
146/// server of *this* version that rejected the parameter for any other reason would answer
147/// the repository, and the two of us would ask each other until a timeout. A board that
148/// cannot be read narrowly is reported unread, with the reason.
149pub(crate) fn board_of(ctx: &Context, url: &str) -> Result<Vec<Peer>, String> {
150 let path = ctx
151 .registry
152 .get("peers.list")
153 .and_then(|c| c.exposure.http.as_ref())
154 .map(|h| h.path.clone())
155 .ok_or_else(|| "peers.list declares no HTTP route to ask over".to_string())?;
156 let reply = crate::mcp::bridge::request(
157 url,
158 "GET",
159 &format!("{path}?checkouts=this"),
160 &[],
161 None,
162 lease::timings().probe_timeout,
163 )
164 .map_err(|e| format!("{url} did not answer ({e})"))?;
165 if reply.status != 200 {
166 return Err(format!(
167 "{url} answered {} for its own board (a server older than this one does not \
168 know the 'checkouts' parameter and refuses it)",
169 reply.status
170 ));
171 }
172 let value: serde_json::Value =
173 serde_json::from_str(&reply.body).map_err(|e| format!("{url} answered non-JSON ({e})"))?;
174 serde_json::from_value(value["peers"].clone())
175 .map_err(|e| format!("{url} answered a board this executable cannot read ({e})"))
176}
177
178fn peers_list(ctx: &Context, input: PeerListInput) -> Result<PeerList, CapabilityError> {
179 let root = PathBuf::from(&ctx.index.repository.root);
180 let root = root.canonicalize().unwrap_or(root);
181 let local_half = server::local_half(&root);
182 let git = repository::git_identity(&root);
183 let checkouts = server::checkouts_of(&root, git.as_ref(), input.checkouts);
184
185 let mut peers: Vec<Peer> = Vec::new();
186 let mut boards: Vec<BoardView> = Vec::with_capacity(checkouts.len());
187 let mut complete = true;
188
189 for (path, branch, _primary) in checkouts {
190 let worktree = path.canonicalize().unwrap_or(path);
191 let this_checkout = worktree == root;
192 let checkout = PeerCheckout {
193 id: repository::identity(&worktree),
194 worktree: worktree.clone(),
195 branch,
196 this_checkout,
197 };
198 // this checkout's board is in this process's memory: it is never asked over the
199 // wire, which also means the answer holds it even when this process is not the
200 // leaseholder (a superseded server still has its own peers, and they are real)
201 let (standing, url, found) = if this_checkout {
202 let (standing, _, read) = server::standing_at(&worktree, &local_half);
203 let url = read.document().and_then(|d| d.url.clone());
204 (standing, url, Ok(ctx.peers.list()))
205 } else {
206 let (standing, reason, read) = server::standing_at(&worktree, &local_half);
207 let url = read.document().and_then(|d| d.url.clone());
208 let found = match (standing, url.as_deref()) {
209 (ServerStanding::Ready | ServerStanding::Outdated, Some(url)) => board_of(ctx, url),
210 // No server, no board, and nobody attached there — which is a complete
211 // answer about that checkout and not a failure to reach one. A stale lease
212 // belongs here too: it names a process that is gone, so its board went
213 // with it. Calling that "unread" would leave `complete` false on this
214 // machine permanently, because an abandoned worktree's lease outlives it —
215 // and a flag that is always false says nothing when it matters. `standing`
216 // already tells the reader the lease is stale, and `server.status` is the
217 // capability that explains it.
218 (ServerStanding::Absent | ServerStanding::Stale, _) => Ok(Vec::new()),
219 // Starting is the genuinely unknown one: a server is binding right now and
220 // may hold peers a second from now. Brief, and reported rather than guessed.
221 _ => Err(reason.unwrap_or_else(|| {
222 format!(
223 "its server is {} and has no board to read yet",
224 standing.as_str()
225 )
226 })),
227 };
228 (standing, url, found)
229 };
230
231 let (found, reason) = match found {
232 Ok(found) => (found, None),
233 Err(why) => {
234 complete = false;
235 (Vec::new(), Some(why))
236 }
237 };
238 boards.push(BoardView {
239 standing,
240 url,
241 attached: found.iter().filter(|p| p.attached).count(),
242 reason,
243 checkout: checkout.clone(),
244 });
245 // every peer is stamped by the reader, its own included and whatever the sibling
246 // said about itself: a sibling stamps `this_checkout: true` for its own board, and
247 // from here that board is not this one
248 peers.extend(found.into_iter().map(|mut p| {
249 p.checkout = Some(checkout.clone());
250 p
251 }));
252 }
253
254 Ok(PeerList {
255 count: peers.iter().filter(|p| p.attached).count(),
256 caller: ctx.caller.clone(),
257 overlaps: overlaps_among(&peers),
258 peers,
259 boards,
260 complete,
261 })
262}
263
264// ---------------------------------------------------------------- peers.announce
265
266/// The MCP tool `peers.announce` is projected as. Named once, here, because the bridge
267/// recognises the frame that carries an announcement by this name in order to replay it
268/// after a takeover or a re-attach; the exposure below and the bridge read the same word.
269pub const ANNOUNCE_TOOL: &str = "majordomus_announce";
270
271#[derive(Debug, Serialize, Deserialize, JsonSchema)]
272#[serde(deny_unknown_fields)]
273/// The input of `peers.announce`: what the calling peer is working on.
274pub struct AnnounceInput {
275 /// One line, in the peer's words: the task, the question, the intent.
276 pub intent: String,
277 /// Repository-relative paths the peer expects to touch. Informational: other peers
278 /// read it to avoid a collision; nothing here enforces it.
279 #[serde(default)]
280 pub scope: Vec<String>,
281 /// Which of this peer's claims this is, when the peer holds more than one.
282 ///
283 /// One MCP session is not always one piece of work — a client that fans work out to
284 /// subagents shares its session with all of them — and without a name every
285 /// announcement replaces the last, so the board ends up describing whichever worker
286 /// spoke most recently and the rest of the scope silently stops being claimed. Name a
287 /// claim and it stands beside the others; announce under that name again and it is
288 /// updated. Leave it out and this is the peer's one unnamed claim, which is what a
289 /// single session announcing about itself wants.
290 #[serde(default, skip_serializing_if = "Option::is_none")]
291 pub claim: Option<String>,
292}
293
294impl BenchmarkCases for AnnounceInput {
295 fn benchmark_cases(_: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
296 vec![NamedCase::new(
297 "default",
298 AnnounceInput {
299 intent: "benchmark: announcing".into(),
300 scope: vec!["apps/majordomus-cli".into()],
301 claim: None,
302 },
303 )]
304 }
305}
306
307fn peers_announce(ctx: &Context, input: AnnounceInput) -> Result<Announced, CapabilityError> {
308 let Some(caller) = &ctx.caller else {
309 return Err(CapabilityError::Refused(
310 "announce needs an MCP session: this call came through an interface with no peer identity (call the majordomus_announce tool)".into(),
311 ));
312 };
313 if input.intent.trim().is_empty() {
314 return Err(CapabilityError::InvalidInput(
315 "argument 'intent' is required and must not be blank".into(),
316 ));
317 }
318 let scope: Vec<String> = input
319 .scope
320 .into_iter()
321 .map(|s| s.trim().to_string())
322 .filter(|s| !s.is_empty())
323 .collect();
324 let claim = input
325 .claim
326 .as_deref()
327 .map(str::trim)
328 .filter(|c| !c.is_empty());
329 ctx.peers
330 .announce_claim(caller, claim, input.intent.trim(), scope)
331 .ok_or_else(|| CapabilityError::Internal(format!("peer {caller} is not attached")))
332}
333
334/// The module.
335pub fn module() -> ModuleDescriptor {
336 module! {
337 id: "peers",
338 title: "Peers",
339 description: "The workers of this repository, named by their own initialize, and what each announced it is working on — gathered from the board of every checkout, because a server serves a checkout and a repository worked on through linked worktrees has one board per worktree. In memory; gone with the processes.",
340 stability: Stability::BehaviorallyVerified,
341 capabilities: [
342 capability! {
343 id: "peers.list",
344 title: "List peers",
345 description: "Every worker of this repository: id, the client's own name and version from its initialize, transport, when it attached, when it was last seen, what it announced, and which checkout it is attached to. A server serves one checkout, so the board of a repository worked on through linked worktrees is gathered: this checkout's board out of memory, every other checkout's from the server its lease names, asked for its own board alone. 'boards' says which checkouts were covered and 'complete' whether every one of them could be read, so a short board is never mistaken for an empty repository. 'checkouts: this' reads one board and enumerates, probes and asks nothing else. In-memory on every server; gone with the processes.",
346 input: PeerListInput,
347 output: PeerList,
348 stability: Stability::BehaviorallyVerified,
349 exposure: Exposure { mcp: mcp("majordomus_peers"), http: get("/api/v1/peers"), cli: None },
350 tags: ["peers", "coordination"],
351 handler: peers_list,
352 },
353 capability! {
354 id: "peers.announce",
355 kind: CapabilityKind::Command,
356 title: "Announce what this peer is working on",
357 description: "Tell the other peers of this shared server what the calling session is doing and which paths it expects to touch. A peer may hold several claims at once: name one with 'claim' and it stands beside the others, announce under that name again and it is updated, leave it out and this is the peer's one unnamed claim. Name your claims when one session is doing several things at once — subagents share their parent's session, so an unnamed announcement from each of them would replace the last rather than adding to it. Changes this process's memory only; the repository is never written. Needs an MCP session: over plain HTTP there is no caller.",
358 input: AnnounceInput,
359 output: Announced,
360 stability: Stability::BehaviorallyVerified,
361 exposure: Exposure { mcp: mcp(ANNOUNCE_TOOL), http: post("/api/v1/peers/announce"), cli: None },
362 tags: ["peers", "coordination"],
363 handler: peers_announce,
364 },
365 ],
366 }
367}
368
369#[cfg(test)]
370mod tests {
371 /// The default is the repository's board, and the narrow word still parses — the same
372 /// question, the same word and the same default as `server::ServerStatusInput`.
373 #[test]
374 fn the_default_board_is_the_repositorys() {
375 use super::*;
376 let asked_nothing: PeerListInput = serde_json::from_str("{}").expect("an empty object");
377 assert_eq!(
378 asked_nothing.checkouts,
379 Checkouts::Repository,
380 "the board is the repository's"
381 );
382 let narrow: PeerListInput =
383 serde_json::from_str(r#"{"checkouts":"this"}"#).expect("the narrow word");
384 assert_eq!(narrow.checkouts, Checkouts::This);
385 }
386
387 use super::*;
388
389 /// The declaration is the only place these names exist, and every projection — the MCP
390 /// tool, the HTTP route, the OpenAPI operation, the benchmark target — is derived from
391 /// it. A refactor that dropped an exposure or renamed a route would still compile, and
392 /// the suites that exercise the behaviour behind it would still pass. This is the
393 /// assertion that would not.
394 #[test]
395 fn the_declaration_yields_the_projections_it_claims() {
396 let m = module();
397 assert_eq!(m.id.as_str(), "peers");
398 let expected: &[(&str, &str, &str)] = &[
399 ("peers.list", "majordomus_peers", "/api/v1/peers"),
400 (
401 "peers.announce",
402 "majordomus_announce",
403 "/api/v1/peers/announce",
404 ),
405 ];
406 let ids: Vec<&str> = m
407 .capabilities
408 .iter()
409 .map(|e| e.capability.id.as_str())
410 .collect();
411 let want: Vec<&str> = expected.iter().map(|(id, _, _)| *id).collect();
412 assert_eq!(
413 ids, want,
414 "the module declares a different set of capabilities"
415 );
416 for (executable, (id, tool, path)) in m.capabilities.iter().zip(expected) {
417 let exposure = &executable.capability.exposure;
418 assert_eq!(
419 exposure.mcp.as_ref().and_then(|m| m.tool.as_deref()),
420 Some(*tool),
421 "{id} lost or renamed its MCP tool"
422 );
423 assert_eq!(
424 exposure.http.as_ref().map(|h| h.path.as_str()),
425 Some(*path),
426 "{id} lost or renamed its HTTP route"
427 );
428 }
429 }
430}