Skip to main content

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}