Skip to main content

majordomus_cli/
peers.rs

1//! Peers: the clients attached to one shared server. Every MCP session, the owner's
2//! stdio one and every one that arrived over HTTP, is a peer from the moment it attaches;
3//! `initialize` gives it a name (the client's own `clientInfo`), and `peers.announce`
4//! lets it say what it is working on. The board lives in the server's memory and nowhere
5//! else: it ends with the process, and nothing here touches the repository.
6//!
7//! # Why it is in memory and stays there
8//!
9//! A peer board is about *now*: who is attached, what they said they are doing. Writing it
10//! into the repository would make it a record of something that has already stopped being
11//! true, and the layer's records are for what survives the session. The board ends with the
12//! process, which is exactly right for a fact about the process.
13//!
14//! # What a peer may say
15//!
16//! An announcement is informational and enforces nothing. Its whole purpose is that another
17//! agent working in the same checkout can read it and choose not to collide — the
18//! coordination is between the peers, not imposed by the server.
19//!
20//! A peer may hold **several claims at once**, each under a name of its own. One connection
21//! is not always one piece of work: a session that fans work out to subagents shares its MCP
22//! session with all of them, so they are one peer here while being several workers there.
23//! Before named claims the board could hold only the last thing said, and each announcement
24//! silently erased the one before it — the other peers stopped being told about scope that
25//! was still very much being written. Announcing under the same name updates that claim;
26//! announcing under a new one leaves the others standing; announcing without a name is the
27//! peer's one unnamed claim, which is all a single session ever needs.
28//!
29//! ```
30//! use majordomus_cli::peers::{ClientInfo, PeerBoard, Transport};
31//!
32//! let board = PeerBoard::new();
33//! let id = board.attach(Transport::Stdio);
34//! board.identify(&id, ClientInfo::unknown());
35//!
36//! let announced = board
37//!     .announce(&id, "landing the quality gate", vec!["apps/majordomus-cli".into()])
38//!     .expect("the peer is attached");
39//! assert_eq!(
40//!     announced.peer.announcement.as_ref().map(|a| a.intent.as_str()),
41//!     Some("landing the quality gate")
42//! );
43//! assert_eq!(board.list().len(), 1);
44//!
45//! // a session that ends is no longer counted; what it announced stays readable, so the
46//! // next worker can still see the claim and avoid it
47//! board.detach(&id);
48//! assert_eq!(board.len(), 0);
49//! assert!(!board.list()[0].attached);
50//! ```
51
52use std::collections::BTreeMap;
53use std::fmt;
54use std::path::PathBuf;
55use std::sync::atomic::{AtomicU64, Ordering};
56use std::sync::Mutex;
57use std::time::{Instant, SystemTime, UNIX_EPOCH};
58
59use schemars::JsonSchema;
60use serde::{Deserialize, Serialize};
61
62/// A peer's identity for the life of the server: `p1`, `p2`, ... in attachment order.
63/// `p1` is the session that started the server.
64#[derive(
65    Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema,
66)]
67#[serde(transparent)]
68pub struct PeerId(String);
69
70impl PeerId {
71    fn new(seq: u64) -> Self {
72        PeerId(format!("p{seq}"))
73    }
74
75    /// The id as text.
76    pub fn as_str(&self) -> &str {
77        &self.0
78    }
79
80    fn seq(&self) -> u64 {
81        self.0[1..].parse().unwrap_or(u64::MAX)
82    }
83}
84
85impl fmt::Display for PeerId {
86    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
87        f.write_str(&self.0)
88    }
89}
90
91/// What a client said about itself in `initialize`.
92#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
93pub struct ClientInfo {
94    /// `clientInfo.name`: `claude-code`, `codex`, `gemini-cli`, whatever the client sends.
95    pub name: String,
96    /// `clientInfo.version`.
97    pub version: String,
98    // `default` as well as `skip_serializing_if`: a board is now read back off the wire
99    // from a sibling checkout's server, and a field that is skipped when absent must also
100    // be accepted when absent, or the peer that omitted it makes the whole board unreadable.
101    #[serde(default, skip_serializing_if = "Option::is_none")]
102    /// `clientInfo.title`, when the client sends one.
103    pub title: Option<String>,
104}
105
106impl ClientInfo {
107    /// A session that has attached and not yet initialised.
108    pub fn unknown() -> Self {
109        ClientInfo {
110            name: "(not initialized)".into(),
111            version: String::new(),
112            title: None,
113        }
114    }
115
116    /// Read `clientInfo` out of `initialize` params; `unknown` when it is missing.
117    pub fn from_initialize(params: &serde_json::Value) -> Self {
118        let info = &params["clientInfo"];
119        let name = info["name"].as_str().unwrap_or("").trim();
120        if name.is_empty() {
121            return Self::unknown();
122        }
123        ClientInfo {
124            name: name.to_string(),
125            version: info["version"].as_str().unwrap_or("").to_string(),
126            title: info["title"].as_str().map(str::to_string),
127        }
128    }
129}
130
131/// How a peer reached the server.
132#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
133#[serde(rename_all = "lowercase")]
134pub enum Transport {
135    /// The owner's own client, on the process's stdin and stdout.
136    Stdio,
137    /// MCP over HTTP at `/mcp`: a `majordomus mcp` bridge, or a client speaking it directly.
138    Http,
139}
140
141/// One claim a peer has made: what it is working on, and where.
142///
143/// A peer may hold several. The `name` is what tells them apart and what makes announcing
144/// again an update rather than a second claim: announce under the same name and the claim
145/// is replaced, announce under a new one and the peer now holds both. A peer that never
146/// names anything holds exactly one claim, the unnamed one, which is what a single session
147/// announcing about itself has always had.
148#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
149pub struct Announcement {
150    /// What this claim is called, when the peer named it. Absent for a peer's unnamed claim.
151    #[serde(default, skip_serializing_if = "Option::is_none")]
152    pub name: Option<String>,
153    /// One line: the task or intent, in the peer's words.
154    pub intent: String,
155    /// Repository-relative paths the peer expects to touch; informational, never enforced here.
156    pub scope: Vec<String>,
157    /// When it was announced, RFC 3339, UTC.
158    pub at: String,
159}
160
161/// The key a claim is held under: its name, or the empty string for the unnamed claim.
162///
163/// Private, because the emptiness is an implementation of "unnamed" and not something a
164/// reader of the board should ever see: [`Announcement::name`] is `None` there.
165fn claim_key(name: Option<&str>) -> String {
166    name.map(str::trim)
167        .filter(|n| !n.is_empty())
168        .unwrap_or("")
169        .to_string()
170}
171
172/// Which checkout of the repository a peer is attached to.
173///
174/// A board is one process's memory and a server serves one checkout, so a peer read from
175/// a sibling checkout's server and a peer of this one are otherwise indistinguishable —
176/// and worse than indistinguishable, because the ids collide: `p1` is the first session
177/// of *every* board, so a repository-wide listing without this field would hold several
178/// peers called `p1` and no way to tell them apart. This is the qualification. The
179/// worktree path is the durable half of a worker's identity: the peer id is a position on
180/// one board and is handed out again after a reconnect (`p3` this morning, `p1` after the
181/// bridge re-attached), while the checkout a worker is working in outlives its connection.
182///
183/// Absent on a peer a server reports about its own board without being asked which
184/// checkout that is — `peers.announce` answers about the announcing session and the
185/// answer is this server's by construction. The repository-wide listing stamps every
186/// peer it gathers, its own included, so that no entry of that answer is unqualified.
187///
188/// ```
189/// use majordomus_cli::peers::PeerCheckout;
190/// let c = PeerCheckout {
191///     id: "b8293f11".into(),
192///     worktree: "/repo-wt/feature/x".into(),
193///     branch: Some("feature/x".into()),
194///     this_checkout: false,
195/// };
196/// let v = serde_json::to_value(&c).unwrap();
197/// assert_eq!(v["branch"], "feature/x");
198/// assert_eq!(v["this_checkout"], false);
199/// ```
200#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
201pub struct PeerCheckout {
202    /// The checkout's identity digest, the same value `server.status` reports as
203    /// `checkout_id`. One per checkout, never a path, and stable across a restart.
204    pub id: String,
205    /// Where the checkout is. The one thing on this board that a human can act on
206    /// directly, and the one thing that survives a reconnect.
207    pub worktree: PathBuf,
208    /// The branch checked out there, when it could be read.
209    #[serde(default, skip_serializing_if = "Option::is_none")]
210    pub branch: Option<String>,
211    /// Whether this is the checkout of the server answering the call.
212    pub this_checkout: bool,
213}
214
215/// One peer as the board lists it.
216#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
217pub struct Peer {
218    /// `p1`, `p2`, ...
219    pub id: PeerId,
220    /// The client behind it.
221    pub client: ClientInfo,
222    /// How it is attached.
223    pub transport: Transport,
224    /// When it attached, RFC 3339, UTC.
225    pub connected_at: String,
226    /// Seconds since its last message.
227    pub last_seen_seconds_ago: u64,
228    /// Whether the session is still attached. A peer that announced something and then
229    /// went away is kept and listed with `attached: false`: what it said it was working on
230    /// outlives the connection that said it, because the work does.
231    pub attached: bool,
232    /// Every claim this peer holds, in name order, the unnamed one first. Empty for a peer
233    /// that has announced nothing.
234    ///
235    /// One connection may be doing several things at once — a session that fans work out to
236    /// subagents shares its MCP session with all of them — and before this field the board
237    /// could hold only the last thing said, so each announcement silently erased the one
238    /// before it and the other peers stopped seeing the rest of the scope.
239    #[serde(default, skip_serializing_if = "Vec::is_empty")]
240    pub claims: Vec<Announcement>,
241    // `default` as well as `skip_serializing_if`, for the reason on `ClientInfo::title`:
242    // a peer that has attached and not yet announced serializes without this field, and
243    // before the board was gathered off the wire nothing ever read one back.
244    #[serde(default, skip_serializing_if = "Option::is_none")]
245    /// The most recently made of [`Peer::claims`], for a reader that wants one line rather
246    /// than all of them. Derived from `claims` and never a second account of it: a peer
247    /// holding one claim reports the same thing in both, which is what every peer that does
248    /// not name its claims reports.
249    pub announcement: Option<Announcement>,
250    /// Which checkout of the repository this peer is attached to; see [`PeerCheckout`].
251    ///
252    /// The board itself never sets it — a board does not know where it is — and it is
253    /// stamped by the reader that gathered this peer, which is the one thing that knows
254    /// which checkout's server it asked.
255    #[serde(default, skip_serializing_if = "Option::is_none")]
256    pub checkout: Option<PeerCheckout>,
257}
258
259/// Two peers that claimed the same ground.
260///
261/// Reported when the second of them announces, so that a collision is known at the moment
262/// it is created rather than discovered afterwards in the history of a branch.
263#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
264pub struct Overlap {
265    /// The other peer.
266    pub peer: PeerId,
267    /// Whether that peer is still attached. An overlap with a departed peer is a weaker
268    /// signal than one with a live session, and the reader is told which it is.
269    pub attached: bool,
270    /// What it said it was doing.
271    pub intent: String,
272    /// The claims that meet: one line per pair, `yours` and `theirs`.
273    pub paths: Vec<OverlapPath>,
274    /// Which checkout the other peer is attached to, when the reader knew.
275    ///
276    /// The overlap that matters most is the one nobody could see before: two workers in
277    /// two worktrees of one repository, each reading a board that held only itself, both
278    /// claiming `apps/majordomus-cli/src`. Naming the checkout is what turns "p1 is on
279    /// your ground" into something a reader can act on when `p1` is not on this board.
280    #[serde(default, skip_serializing_if = "Option::is_none")]
281    pub checkout: Option<PeerCheckout>,
282}
283
284/// One pair of claims that contain one another.
285#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
286pub struct OverlapPath {
287    /// The path the announcing peer claimed.
288    pub yours: String,
289    /// The path the other peer claimed.
290    pub theirs: String,
291}
292
293/// What `announce` answers: the peer as recorded, and who else is on that ground.
294#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
295pub struct Announced {
296    /// The peer, with the announcement it just made.
297    #[serde(flatten)]
298    pub peer: Peer,
299    /// Every other peer whose claimed scope meets this one. Empty is the ordinary case.
300    #[serde(default, skip_serializing_if = "Vec::is_empty")]
301    pub overlaps: Vec<Overlap>,
302    /// Paths the claim this one replaced had, and this one does not.
303    ///
304    /// Announcing under a name a peer already used replaces that claim, which is what an
305    /// update is. When the replacement is narrower, the peer has just stopped claiming
306    /// ground it held a moment ago, and the other peers stop being warned off it. That is
307    /// occasionally what was meant and is otherwise a mistake nobody would ever see, so it
308    /// is said here, at the moment it happens, rather than left to be discovered in a
309    /// collision later. Empty is the ordinary case.
310    #[serde(default, skip_serializing_if = "Vec::is_empty")]
311    pub released: Vec<String>,
312}
313
314/// Do two claimed paths meet? Equal, or one inside the other.
315///
316/// Repository-relative, `/`-separated, trailing slashes ignored. `apps` contains
317/// `apps/majordomus-cli`; `app` does not, because a claim is a path and not a prefix of a
318/// string.
319///
320/// ```
321/// use majordomus_cli::peers::claims_meet;
322/// assert!(claims_meet("apps", "apps/majordomus-cli"));
323/// assert!(claims_meet("apps/majordomus-cli", "apps"));
324/// assert!(claims_meet("docs/", "docs"));
325/// assert!(!claims_meet("app", "apps/majordomus-cli"));
326/// assert!(!claims_meet("docs", "site"));
327/// ```
328pub fn claims_meet(a: &str, b: &str) -> bool {
329    let (a, b) = (
330        a.trim().trim_end_matches('/'),
331        b.trim().trim_end_matches('/'),
332    );
333    if a.is_empty() || b.is_empty() {
334        return false;
335    }
336    a == b || b.starts_with(&format!("{a}/")) || a.starts_with(&format!("{b}/"))
337}
338
339struct Slot {
340    client: ClientInfo,
341    transport: Transport,
342    connected_at: SystemTime,
343    last_seen: Instant,
344    departed: bool,
345    /// The peer's claims, by [`claim_key`]. A `BTreeMap` rather than a `Vec` because
346    /// announcing under a name a peer already used must replace that claim and not append
347    /// a second one — idempotence is the map, not a search.
348    claims: BTreeMap<String, Announcement>,
349}
350
351impl Slot {
352    /// The claims, in name order, the unnamed one first.
353    fn claims(&self) -> Vec<Announcement> {
354        self.claims.values().cloned().collect()
355    }
356
357    /// The most recently made claim, which is what a one-line reader wants.
358    fn latest(&self) -> Option<Announcement> {
359        self.claims.values().max_by(|a, b| a.at.cmp(&b.at)).cloned()
360    }
361}
362
363/// How many departed peers the board keeps. A retained announcement is the whole point —
364/// a long session that reconnects must not vanish from the board — but a server that ran
365/// all day must not accumulate every connection it ever saw either, so the oldest departed
366/// slot is dropped past this. Attached peers are never dropped.
367const RETAINED: usize = 32;
368
369/// The peers of one server process.
370#[derive(Default)]
371pub struct PeerBoard {
372    next: AtomicU64,
373    slots: Mutex<BTreeMap<u64, Slot>>,
374}
375
376impl fmt::Debug for PeerBoard {
377    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
378        f.debug_struct("PeerBoard")
379            .field("peers", &self.len())
380            .finish()
381    }
382}
383
384impl PeerBoard {
385    /// An empty board.
386    pub fn new() -> Self {
387        Self::default()
388    }
389
390    /// A session attached; it has no name until [`PeerBoard::identify`].
391    ///
392    /// ```
393    /// use majordomus_cli::peers::{PeerBoard, Transport};
394    /// let board = PeerBoard::new();
395    /// let a = board.attach(Transport::Stdio);
396    /// let b = board.attach(Transport::Http);
397    /// assert_eq!((a.as_str(), b.as_str()), ("p1", "p2"));
398    /// board.detach(&a);
399    /// assert_eq!(board.list().len(), 1);
400    /// ```
401    pub fn attach(&self, transport: Transport) -> PeerId {
402        let seq = self.next.fetch_add(1, Ordering::SeqCst) + 1;
403        self.lock().insert(
404            seq,
405            Slot {
406                client: ClientInfo::unknown(),
407                transport,
408                connected_at: SystemTime::now(),
409                last_seen: Instant::now(),
410                departed: false,
411                claims: BTreeMap::new(),
412            },
413        );
414        PeerId::new(seq)
415    }
416
417    /// The client behind a peer, from its `initialize`.
418    pub fn identify(&self, id: &PeerId, client: ClientInfo) {
419        if let Some(s) = self.lock().get_mut(&id.seq()) {
420            s.client = client;
421            s.last_seen = Instant::now();
422        }
423    }
424
425    /// A message arrived from this peer.
426    pub fn touch(&self, id: &PeerId) {
427        if let Some(s) = self.lock().get_mut(&id.seq()) {
428            s.last_seen = Instant::now();
429        }
430    }
431
432    /// Record a peer's unnamed claim, and answer with who else is on that ground.
433    ///
434    /// This is the whole of what a session announcing about itself needs, and it is what
435    /// announcing has always meant: the peer holds one claim and each announcement replaces
436    /// it. A connection doing several things at once wants [`PeerBoard::announce_claim`].
437    ///
438    /// `None` when the id is not attached. The overlap is computed here, under the same
439    /// lock as the write, so that two peers announcing at once cannot both be told they
440    /// are alone.
441    ///
442    /// ```
443    /// use majordomus_cli::peers::{PeerBoard, Transport};
444    /// let board = PeerBoard::new();
445    /// let a = board.attach(Transport::Stdio);
446    /// let b = board.attach(Transport::Http);
447    /// board.announce(&a, "the ordering rule", vec!["apps/majordomus-cli/src".into()]);
448    /// let second = board
449    ///     .announce(&b, "the release model", vec!["apps/majordomus-cli/src/release".into()])
450    ///     .unwrap();
451    /// assert_eq!(second.overlaps.len(), 1, "a claim inside another claim is an overlap");
452    /// assert_eq!(second.overlaps[0].intent, "the ordering rule");
453    ///
454    /// // announcing again, narrower: the peer is told what it stopped claiming
455    /// let again = board.announce(&b, "the release model", vec![]).unwrap();
456    /// assert_eq!(again.released, vec!["apps/majordomus-cli/src/release".to_string()]);
457    /// ```
458    pub fn announce(&self, id: &PeerId, intent: &str, scope: Vec<String>) -> Option<Announced> {
459        self.announce_claim(id, None, intent, scope)
460    }
461
462    /// The same, naming which of the peer's claims this is.
463    ///
464    /// `None` is [`PeerBoard::announce`]: the peer's one unnamed claim. Every other value
465    /// is a claim of its own, held beside the rest until it is announced again under the
466    /// same name.
467    ///
468    /// ```
469    /// use majordomus_cli::peers::{PeerBoard, Transport};
470    /// let board = PeerBoard::new();
471    /// let fleet = board.attach(Transport::Http);
472    /// board.announce(&fleet, "the whole mandate", vec!["docs".into()]);
473    /// let named = board
474    ///     .announce_claim(&fleet, Some("worker-a"), "the guard", vec![".claude".into()])
475    ///     .unwrap();
476    /// assert_eq!(named.peer.claims.len(), 2, "the unnamed claim still stands");
477    /// assert_eq!(named.peer.claims[1].name.as_deref(), Some("worker-a"));
478    /// ```
479    pub fn announce_claim(
480        &self,
481        id: &PeerId,
482        claim: Option<&str>,
483        intent: &str,
484        scope: Vec<String>,
485    ) -> Option<Announced> {
486        let mut slots = self.lock();
487        if !slots.contains_key(&id.seq()) {
488            return None;
489        }
490        let overlaps = overlaps_against(&slots, id.seq(), &scope);
491        let key = claim_key(claim);
492        let s = slots.get_mut(&id.seq())?;
493        s.last_seen = Instant::now();
494        s.departed = false;
495        let released = s
496            .claims
497            .get(&key)
498            .map(|old| {
499                old.scope
500                    .iter()
501                    .filter(|held| !scope.iter().any(|now| claims_meet(held, now)))
502                    .cloned()
503                    .collect()
504            })
505            .unwrap_or_default();
506        s.claims.insert(
507            key.clone(),
508            Announcement {
509                name: (!key.is_empty()).then(|| key.clone()),
510                intent: intent.to_string(),
511                scope,
512                at: rfc3339(SystemTime::now()),
513            },
514        );
515        Some(Announced {
516            peer: peer(id.seq(), s),
517            overlaps,
518            released,
519        })
520    }
521
522    /// Every pair of claims that meet, each pair once, in peer order.
523    ///
524    /// A peer holding several claims is asked about each of them, so two connections that
525    /// collide on two different claims are reported twice, with the intent of each. That is
526    /// the answer a reader wants: it is not "these two sessions overlap" but "these two
527    /// pieces of work overlap", and the intent says which.
528    pub fn overlaps(&self) -> Vec<Overlap> {
529        overlaps_among(&self.list())
530    }
531
532    /// One peer, as listed.
533    pub fn get(&self, id: &PeerId) -> Option<Peer> {
534        self.lock().get(&id.seq()).map(|s| peer(id.seq(), s))
535    }
536
537    /// The session ended.
538    ///
539    /// A peer that announced something is kept and marked departed rather than removed:
540    /// what a session said it was working on is a fact about the repository, and it does
541    /// not stop being true because a socket closed. A peer that never announced leaves
542    /// nothing behind. The oldest departed slot past `RETAINED` is dropped.
543    ///
544    /// ```
545    /// use majordomus_cli::peers::{PeerBoard, Transport};
546    /// let board = PeerBoard::new();
547    /// let quiet = board.attach(Transport::Http);
548    /// let spoken = board.attach(Transport::Http);
549    /// board.announce(&spoken, "the ordering rule", vec!["apps".into()]);
550    /// board.detach(&quiet);
551    /// board.detach(&spoken);
552    /// assert_eq!(board.len(), 0, "neither is attached any more");
553    /// let listed = board.list();
554    /// assert_eq!(listed.len(), 1, "the one that said something is still on the board");
555    /// assert!(!listed[0].attached);
556    /// assert_eq!(listed[0].announcement.as_ref().unwrap().intent, "the ordering rule");
557    /// ```
558    pub fn detach(&self, id: &PeerId) {
559        let mut slots = self.lock();
560        match slots.get_mut(&id.seq()) {
561            Some(s) if !s.claims.is_empty() => s.departed = true,
562            _ => {
563                slots.remove(&id.seq());
564                return;
565            }
566        }
567        let departed: Vec<u64> = slots
568            .iter()
569            .filter(|(_, s)| s.departed)
570            .map(|(seq, _)| *seq)
571            .collect();
572        for seq in departed
573            .iter()
574            .take(departed.len().saturating_sub(RETAINED))
575        {
576            slots.remove(seq);
577        }
578    }
579
580    /// Every peer, in attachment order.
581    pub fn list(&self) -> Vec<Peer> {
582        self.lock().iter().map(|(seq, s)| peer(*seq, s)).collect()
583    }
584
585    /// How many peers are attached. A departed peer whose announcement the board kept is
586    /// listed but not counted: the count is what the lifecycle asks, and the lifecycle
587    /// asks who is here now.
588    pub fn len(&self) -> usize {
589        self.lock().values().filter(|s| !s.departed).count()
590    }
591
592    /// Nobody attached?
593    pub fn is_empty(&self) -> bool {
594        self.len() == 0
595    }
596
597    /// The names of every peer, one line, for a log or the `initialize` instructions.
598    pub fn summary(&self) -> String {
599        let peers = self.list();
600        if peers.is_empty() {
601            return "none".into();
602        }
603        peers
604            .iter()
605            .map(|p| {
606                if p.claims.is_empty() {
607                    return format!("{} {} ({:?})", p.id, p.client.name, p.transport);
608                }
609                let claims = p
610                    .claims
611                    .iter()
612                    .map(|a| match &a.name {
613                        Some(n) => format!("{n}: {}", a.intent),
614                        None => a.intent.clone(),
615                    })
616                    .collect::<Vec<_>>()
617                    .join(" | ");
618                format!("{} {} ({:?}: {claims})", p.id, p.client.name, p.transport)
619            })
620            .collect::<Vec<_>>()
621            .join("; ")
622    }
623
624    fn lock(&self) -> std::sync::MutexGuard<'_, BTreeMap<u64, Slot>> {
625        self.slots.lock().unwrap_or_else(|e| e.into_inner())
626    }
627}
628
629fn peer(seq: u64, s: &Slot) -> Peer {
630    Peer {
631        id: PeerId::new(seq),
632        client: s.client.clone(),
633        transport: s.transport,
634        connected_at: rfc3339(s.connected_at),
635        last_seen_seconds_ago: s.last_seen.elapsed().as_secs(),
636        attached: !s.departed,
637        claims: s.claims(),
638        announcement: s.latest(),
639        // a board does not know which checkout it is the board of; whoever gathered this
640        // peer stamps it (`peers.list`), because that is the reader that asked a server
641        checkout: None,
642    }
643}
644
645/// Every pair of claims that meet among an arbitrary set of peers, each pair once.
646///
647/// The listing side of the overlap question, and the only account of it: [`PeerBoard::overlaps`]
648/// is this function over its own peers, and the repository-wide listing is this function
649/// over the peers of every checkout's board. They cannot drift into two answers, and a
650/// collision between two worktrees is found by exactly the algorithm that found a
651/// collision between two sessions of one — which is the whole point, because until the
652/// board became repository-wide the second kind was the only kind anyone could see.
653///
654/// A pair is reported once, against the later peer of the two, naming the earlier one:
655/// `yours` is the later peer's path and `theirs` the earlier's, and [`Overlap::peer`]
656/// with [`Overlap::checkout`] says who and where the earlier one is. Peers are paired by
657/// position, never by [`PeerId`], because ids are unique to one board and a gathered list
658/// holds a `p1` per checkout.
659///
660/// ```
661/// use majordomus_cli::peers::{overlaps_among, PeerBoard, Transport};
662/// let board = PeerBoard::new();
663/// let a = board.attach(Transport::Stdio);
664/// let b = board.attach(Transport::Http);
665/// board.announce(&a, "the ordering rule", vec!["apps/majordomus-cli/src".into()]);
666/// board.announce(&b, "the release model", vec!["apps/majordomus-cli/src/release".into()]);
667/// let found = overlaps_among(&board.list());
668/// assert_eq!(found.len(), 1);
669/// assert_eq!(found[0].peer.as_str(), "p1", "reported against the later peer, naming the earlier");
670/// assert_eq!(found[0].paths[0].yours, "apps/majordomus-cli/src/release");
671/// assert_eq!(found[0].paths[0].theirs, "apps/majordomus-cli/src");
672/// // and it is the same answer the board gives about itself
673/// assert_eq!(board.overlaps(), found);
674/// ```
675pub fn overlaps_among(peers: &[Peer]) -> Vec<Overlap> {
676    let mut out = Vec::new();
677    for (i, later) in peers.iter().enumerate() {
678        for mine in &later.claims {
679            for earlier in peers.iter().take(i) {
680                for theirs in &earlier.claims {
681                    let mut paths: Vec<OverlapPath> = Vec::new();
682                    for yours in &mine.scope {
683                        for their in &theirs.scope {
684                            if claims_meet(yours, their) {
685                                paths.push(OverlapPath {
686                                    yours: yours.clone(),
687                                    theirs: their.clone(),
688                                });
689                            }
690                        }
691                    }
692                    if !paths.is_empty() {
693                        out.push(Overlap {
694                            peer: earlier.id.clone(),
695                            attached: earlier.attached,
696                            intent: theirs.intent.clone(),
697                            paths,
698                            checkout: earlier.checkout.clone(),
699                        });
700                    }
701                }
702            }
703        }
704    }
705    out
706}
707
708/// Every claim of every peer other than `seq` whose scope meets `scope`.
709///
710/// One entry per claim, not per peer: a connection holding two claims that both meet this
711/// scope is two entries, because they are two pieces of work and the reader needs the
712/// intent of each to know which one to talk about.
713fn overlaps_against(slots: &BTreeMap<u64, Slot>, seq: u64, scope: &[String]) -> Vec<Overlap> {
714    let mut out = Vec::new();
715    for (other, s) in slots.iter() {
716        if *other == seq {
717            continue;
718        }
719        for theirs in s.claims.values() {
720            let mut paths: Vec<OverlapPath> = Vec::new();
721            for mine in scope {
722                for path in &theirs.scope {
723                    if claims_meet(mine, path) {
724                        paths.push(OverlapPath {
725                            yours: mine.clone(),
726                            theirs: path.clone(),
727                        });
728                    }
729                }
730            }
731            if !paths.is_empty() {
732                out.push(Overlap {
733                    peer: PeerId::new(*other),
734                    attached: !s.departed,
735                    intent: theirs.intent.clone(),
736                    paths,
737                    // this board's own peers: the caller is on this checkout too, and the
738                    // one answer that gathers several boards stamps them all itself
739                    checkout: None,
740                });
741            }
742        }
743    }
744    out
745}
746
747/// Seconds since the epoch of `YYYY-MM-DDTHH:MM:SSZ`, the one shape [`rfc3339`] writes, and
748/// its inverse. Anything else is `None`: a reader that guessed at other shapes would turn a
749/// malformed time into a plausible one.
750///
751/// ```
752/// use std::time::{Duration, UNIX_EPOCH};
753/// use majordomus_cli::peers::{parse_rfc3339, rfc3339};
754/// assert_eq!(parse_rfc3339("1970-01-01T00:00:00Z"), Some(0));
755/// let t = UNIX_EPOCH + Duration::from_secs(1_788_000_000);
756/// assert_eq!(parse_rfc3339(&rfc3339(t)), Some(1_788_000_000));
757/// assert_eq!(parse_rfc3339("2026-08-29T10:40:00+02:00"), None);
758/// ```
759pub fn parse_rfc3339(s: &str) -> Option<u64> {
760    let b = s.as_bytes();
761    if b.len() != 20 || b[19] != b'Z' {
762        return None;
763    }
764    let n = |r: std::ops::Range<usize>| s.get(r)?.parse::<i64>().ok();
765    let (y, mo, d) = (n(0..4)?, n(5..7)?, n(8..10)?);
766    let (h, mi, se) = (n(11..13)?, n(14..16)?, n(17..19)?);
767    // days-from-civil, Howard Hinnant's algorithm: the inverse of the one rfc3339 uses
768    let y = if mo <= 2 { y - 1 } else { y };
769    let era = y.div_euclid(400);
770    let yoe = y - era * 400;
771    let doy = (153 * (if mo > 2 { mo - 3 } else { mo + 9 }) + 2) / 5 + d - 1;
772    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
773    let days = era * 146_097 + doe - 719_468;
774    u64::try_from(days * 86_400 + h * 3600 + mi * 60 + se).ok()
775}
776
777/// A `SystemTime` as RFC 3339 in UTC, to the second (`2026-09-05T12:34:56Z`).
778///
779/// ```
780/// use std::time::{Duration, UNIX_EPOCH};
781/// use majordomus_cli::peers::rfc3339;
782/// assert_eq!(rfc3339(UNIX_EPOCH), "1970-01-01T00:00:00Z");
783/// assert_eq!(rfc3339(UNIX_EPOCH + Duration::from_secs(1_788_000_000)), "2026-08-29T10:40:00Z");
784/// ```
785pub fn rfc3339(t: SystemTime) -> String {
786    let secs = t
787        .duration_since(UNIX_EPOCH)
788        .map(|d| d.as_secs())
789        .unwrap_or(0);
790    let days = secs / 86_400;
791    let rem = secs % 86_400;
792    let (h, m, s) = (rem / 3600, (rem % 3600) / 60, rem % 60);
793    // civil-from-days, Howard Hinnant's algorithm
794    let z = days as i64 + 719_468;
795    let era = z.div_euclid(146_097);
796    let doe = z.rem_euclid(146_097);
797    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
798    let y = yoe + era * 400;
799    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
800    let mp = (5 * doy + 2) / 153;
801    let d = doy - (153 * mp + 2) / 5 + 1;
802    let mth = if mp < 10 { mp + 3 } else { mp - 9 };
803    let y = if mth <= 2 { y + 1 } else { y };
804    format!("{y:04}-{mth:02}-{d:02}T{h:02}:{m:02}:{s:02}Z")
805}
806
807/// The inverse of [`rfc3339`]: `2026-08-29T10:40:00Z` back to Unix seconds.
808///
809/// It lives beside the formatter because the two are one thing, and a repository that had
810/// the one without the other grew a second parser every time somebody needed to compare two
811/// recorded instants. Strict about the shape it accepts — exactly `YYYY-MM-DDTHH:MM:SSZ`,
812/// which is the only shape this tool writes — because a lenient parser that guessed at a
813/// half-recognised string would answer `None` for a *malformed* timestamp and a plausible
814/// number for a *different* one, and only the first of those is safe.
815///
816/// `None` is the honest answer for anything else, and the caller's job is to report it as
817/// unjudgeable rather than as fresh: an episode whose evidence cannot be read as a time is
818/// one a recovery sweep must skip and count, never one it may sweep.
819///
820/// ```
821/// use majordomus_cli::peers::{epoch_seconds, rfc3339};
822/// use std::time::{Duration, UNIX_EPOCH};
823///
824/// assert_eq!(epoch_seconds("1970-01-01T00:00:00Z"), Some(0));
825/// assert_eq!(epoch_seconds("2026-08-29T10:40:00Z"), Some(1_788_000_000));
826/// assert_eq!(epoch_seconds("not a timestamp"), None);
827/// assert_eq!(epoch_seconds("2026-08-29 10:40:00Z"), None, "the T is not optional");
828/// assert_eq!(epoch_seconds("2026-08-29T10:40:00+02:00"), None, "UTC or nothing");
829///
830/// // and it round-trips with the formatter it is the inverse of
831/// for secs in [0u64, 1, 1_788_000_000, 2_000_000_000] {
832///     let text = rfc3339(UNIX_EPOCH + Duration::from_secs(secs));
833///     assert_eq!(epoch_seconds(&text), Some(secs as i64), "{text}");
834/// }
835/// ```
836pub fn epoch_seconds(ts: &str) -> Option<i64> {
837    let b = ts.as_bytes();
838    if b.len() != 20
839        || b[4] != b'-'
840        || b[7] != b'-'
841        || b[10] != b'T'
842        || b[13] != b':'
843        || b[16] != b':'
844        || b[19] != b'Z'
845    {
846        return None;
847    }
848    let n = |from: usize, to: usize| -> Option<i64> {
849        let part = ts.get(from..to)?;
850        part.bytes().all(|c| c.is_ascii_digit()).then_some(())?;
851        part.parse::<i64>().ok()
852    };
853    let (y, m, d) = (n(0, 4)?, n(5, 7)?, n(8, 10)?);
854    let (hh, mm, ss) = (n(11, 13)?, n(14, 16)?, n(17, 19)?);
855    if !(1..=12).contains(&m) || !(1..=31).contains(&d) || hh > 23 || mm > 59 || ss > 60 {
856        return None;
857    }
858    // days-from-civil, Howard Hinnant's algorithm — the inverse of the civil-from-days
859    // above, so the two agree by construction rather than by two tables being kept level.
860    let y = if m <= 2 { y - 1 } else { y };
861    let era = y.div_euclid(400);
862    let yoe = y - era * 400;
863    let mp = if m > 2 { m - 3 } else { m + 9 };
864    let doy = (153 * mp + 2) / 5 + d - 1;
865    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
866    let days = era * 146_097 + doe - 719_468;
867    Some(days * 86_400 + hh * 3600 + mm * 60 + ss)
868}
869
870#[cfg(test)]
871mod tests {
872    use super::*;
873
874    /// A board is now read back off the wire: `peers.list` gathers a sibling checkout's
875    /// board from that checkout's own server, and `serde` parses the answer into these
876    /// types. Every shape a board can serialise must therefore parse again — including the
877    /// two nothing had ever read back before the gather existed: a peer that has attached
878    /// and not yet announced (no `announcement`), and a client that sent no `title`. Both
879    /// are skipped when absent, and a field that is skipped must also be defaulted, or one
880    /// silent peer makes an entire sibling board unreadable and the answer `complete:
881    /// false` — which is the failure this decision exists to remove, arriving by another
882    /// door. Caught in review of ADR 0044, before it ever ran.
883    #[test]
884    fn a_board_survives_the_wire_including_a_peer_that_said_nothing() {
885        let board = PeerBoard::new();
886        let quiet = board.attach(Transport::Http);
887        let listed = board.list();
888        let text = serde_json::to_string(&listed[0]).expect("a peer serialises");
889        assert!(
890            !text.contains("announcement"),
891            "a peer that said nothing carries no announcement: {text}"
892        );
893        assert!(
894            !text.contains("title"),
895            "and no title, because its client sent none: {text}"
896        );
897        let back: Peer = serde_json::from_str(&text).expect("a silent peer must read back");
898        assert_eq!(back, listed[0]);
899        assert!(
900            back.checkout.is_none(),
901            "a board does not know which checkout it is the board of"
902        );
903
904        // and the shape a peer that has said everything has
905        board.announce(&quiet, "the ordering rule", vec!["apps".into()]);
906        let spoken = board.list().remove(0);
907        let text = serde_json::to_string(&spoken).unwrap();
908        assert_eq!(serde_json::from_str::<Peer>(&text).unwrap(), spoken);
909
910        // the whole answer, as a sibling would send it and the gather would read it
911        let whole = serde_json::json!({ "count": 1, "peers": board.list() });
912        let peers: Vec<Peer> = serde_json::from_value(whole["peers"].clone())
913            .expect("the array the gather reads out of a sibling's answer");
914        assert_eq!(peers, board.list());
915    }
916
917    fn board_with(claims: &[(&str, &[&str])]) -> (PeerBoard, Vec<PeerId>) {
918        let board = PeerBoard::new();
919        let mut ids = Vec::new();
920        for (intent, scope) in claims {
921            let id = board.attach(Transport::Http);
922            board.announce(&id, intent, scope.iter().map(|s| s.to_string()).collect());
923            ids.push(id);
924        }
925        (board, ids)
926    }
927
928    #[test]
929    fn a_claim_meets_one_that_contains_it_and_not_one_that_merely_starts_the_same() {
930        assert!(claims_meet("apps", "apps/majordomus-cli/src/order.rs"));
931        assert!(claims_meet("apps/majordomus-cli/src/order.rs", "apps"));
932        assert!(claims_meet("docs", "docs"));
933        assert!(
934            !claims_meet("app", "apps"),
935            "a claim is a path, not a string prefix"
936        );
937        assert!(!claims_meet(
938            "apps/majordomus-cli/src/order.rs",
939            "apps/majordomus-cli/src/peers.rs"
940        ));
941        assert!(!claims_meet("", "apps"), "nothing claimed meets nothing");
942        assert!(!claims_meet("  ", "apps"));
943    }
944
945    #[test]
946    fn a_trailing_slash_is_the_same_claim() {
947        assert!(claims_meet("docs/", "docs"));
948        assert!(claims_meet("docs", "docs/"));
949        assert!(claims_meet("apps/", "apps/majordomus-cli"));
950    }
951
952    #[test]
953    fn announcing_over_another_peers_ground_says_whose_it_is() {
954        let (board, ids) = board_with(&[("the ordering rule", &["apps/majordomus-cli/src"])]);
955        let mine = board.attach(Transport::Http);
956        let answer = board
957            .announce(
958                &mine,
959                "the release model",
960                vec!["apps/majordomus-cli/src/release".into()],
961            )
962            .expect("the peer is attached");
963
964        assert_eq!(answer.overlaps.len(), 1);
965        let hit = &answer.overlaps[0];
966        assert_eq!(hit.peer, ids[0]);
967        assert_eq!(hit.intent, "the ordering rule");
968        assert!(hit.attached);
969        assert_eq!(hit.paths.len(), 1);
970        assert_eq!(hit.paths[0].yours, "apps/majordomus-cli/src/release");
971        assert_eq!(hit.paths[0].theirs, "apps/majordomus-cli/src");
972    }
973
974    #[test]
975    fn a_peer_that_claims_ground_nobody_holds_is_told_it_is_alone() {
976        let (board, _) = board_with(&[("the ordering rule", &["apps/majordomus-cli/src"])]);
977        let mine = board.attach(Transport::Http);
978        let answer = board
979            .announce(&mine, "the site", vec!["site/templates".into()])
980            .unwrap();
981        assert!(answer.overlaps.is_empty(), "{:?}", answer.overlaps);
982    }
983
984    #[test]
985    fn an_overlap_names_every_pair_of_paths_that_meet_and_no_others() {
986        let (board, _) = board_with(&[("theirs", &["docs", "site", "test/cases"])]);
987        let mine = board.attach(Transport::Http);
988        let answer = board
989            .announce(
990                &mine,
991                "mine",
992                vec!["docs/MCP.md".into(), "apps".into(), "site".into()],
993            )
994            .unwrap();
995        let pairs: Vec<(String, String)> = answer.overlaps[0]
996            .paths
997            .iter()
998            .map(|p| (p.yours.clone(), p.theirs.clone()))
999            .collect();
1000        assert_eq!(
1001            pairs,
1002            vec![
1003                ("docs/MCP.md".to_string(), "docs".to_string()),
1004                ("site".to_string(), "site".to_string()),
1005            ],
1006            "apps meets nothing they claimed, and test/cases meets nothing I claimed"
1007        );
1008    }
1009
1010    #[test]
1011    fn an_announcement_outlives_the_connection_that_made_it() {
1012        // The defect this exists for: a session that reconnects used to vanish from the
1013        // board with everything it had said, and nobody — including the session itself —
1014        // was told. What it said it was working on is a fact about the repository.
1015        let board = PeerBoard::new();
1016        let first = board.attach(Transport::Http);
1017        board.announce(&first, "the ordering rule", vec!["apps".into()]);
1018        board.detach(&first);
1019
1020        let listed = board.list();
1021        assert_eq!(listed.len(), 1);
1022        assert!(!listed[0].attached, "it is listed as gone, not as present");
1023        assert_eq!(
1024            listed[0].announcement.as_ref().unwrap().intent,
1025            "the ordering rule"
1026        );
1027        assert_eq!(board.len(), 0, "the lifecycle counts who is here now");
1028
1029        // and the ground it claimed is still defended
1030        let second = board.attach(Transport::Http);
1031        let answer = board
1032            .announce(
1033                &second,
1034                "something else",
1035                vec!["apps/majordomus-cli".into()],
1036            )
1037            .unwrap();
1038        assert_eq!(answer.overlaps.len(), 1);
1039        assert!(
1040            !answer.overlaps[0].attached,
1041            "the reader is told the holder is gone"
1042        );
1043    }
1044
1045    #[test]
1046    fn a_peer_that_said_nothing_leaves_nothing_behind() {
1047        let board = PeerBoard::new();
1048        let quiet = board.attach(Transport::Http);
1049        board.detach(&quiet);
1050        assert!(board.list().is_empty(), "a connection is not news");
1051    }
1052
1053    #[test]
1054    fn a_returning_peer_that_announces_again_is_attached_once_more() {
1055        let board = PeerBoard::new();
1056        let id = board.attach(Transport::Http);
1057        board.announce(&id, "first", vec!["apps".into()]);
1058        board.detach(&id);
1059        assert!(!board.list()[0].attached);
1060        board.announce(&id, "second", vec!["apps".into()]);
1061        assert!(board.list()[0].attached, "it spoke, so it is here");
1062        assert_eq!(board.len(), 1);
1063    }
1064
1065    #[test]
1066    fn the_board_keeps_a_bounded_number_of_departed_peers() {
1067        let board = PeerBoard::new();
1068        for i in 0..RETAINED + 8 {
1069            let id = board.attach(Transport::Http);
1070            board.announce(&id, &format!("intent {i}"), vec!["apps".into()]);
1071            board.detach(&id);
1072        }
1073        assert_eq!(
1074            board.list().len(),
1075            RETAINED,
1076            "a server that ran all day is not a museum"
1077        );
1078        let kept: Vec<String> = board
1079            .list()
1080            .iter()
1081            .filter_map(|p| p.announcement.as_ref().map(|a| a.intent.clone()))
1082            .collect();
1083        assert!(
1084            kept.contains(&format!("intent {}", RETAINED + 7)),
1085            "the newest is kept"
1086        );
1087        assert!(
1088            !kept.contains(&"intent 0".to_string()),
1089            "the oldest is dropped"
1090        );
1091    }
1092
1093    #[test]
1094    fn an_attached_peer_is_never_dropped_to_make_room() {
1095        let board = PeerBoard::new();
1096        let live = board.attach(Transport::Http);
1097        board.announce(&live, "still working", vec!["apps".into()]);
1098        for i in 0..RETAINED + 4 {
1099            let id = board.attach(Transport::Http);
1100            board.announce(&id, &format!("gone {i}"), vec!["site".into()]);
1101            board.detach(&id);
1102        }
1103        assert!(
1104            board.list().iter().any(|p| p.id == live && p.attached),
1105            "the live session was evicted by departed ones"
1106        );
1107        assert_eq!(board.len(), 1);
1108    }
1109
1110    #[test]
1111    fn every_colliding_pair_is_reported_once_by_the_board() {
1112        let (board, _) = board_with(&[
1113            ("a", &["apps/majordomus-cli/src"]),
1114            ("b", &["apps/majordomus-cli/src/release"]),
1115            ("c", &["site"]),
1116        ]);
1117        let overlaps = board.overlaps();
1118        assert_eq!(
1119            overlaps.len(),
1120            1,
1121            "one pair meets, and it is named once: {overlaps:?}"
1122        );
1123        assert_eq!(
1124            overlaps[0].intent, "a",
1125            "the pair is reported against the later peer"
1126        );
1127    }
1128
1129    #[test]
1130    fn announcing_as_a_peer_that_is_not_on_the_board_answers_nothing() {
1131        let board = PeerBoard::new();
1132        assert!(board.announce(&PeerId::new(99), "hello", vec![]).is_none());
1133    }
1134
1135    /// One MCP session is not always one piece of work. A client that fans work out to
1136    /// subagents shares its session with them, so they arrive here as one peer; before named
1137    /// claims each announcement replaced the last, and the board ended up describing
1138    /// whichever worker spoke most recently while the rest of the scope silently stopped
1139    /// being claimed. This is that failure, and its absence.
1140    #[test]
1141    fn a_peer_holds_every_claim_it_names() {
1142        let board = PeerBoard::new();
1143        let fleet = board.attach(Transport::Http);
1144
1145        board.announce(&fleet, "the whole mandate", vec!["docs".into()]);
1146        board.announce_claim(
1147            &fleet,
1148            Some("worker-a"),
1149            "the guard",
1150            vec![".claude".into()],
1151        );
1152        let third = board
1153            .announce_claim(&fleet, Some("worker-b"), "the cockpit", vec!["site".into()])
1154            .unwrap();
1155
1156        assert_eq!(
1157            third.peer.claims.len(),
1158            3,
1159            "three claims, not the newest one"
1160        );
1161        let intents: Vec<&str> = third
1162            .peer
1163            .claims
1164            .iter()
1165            .map(|c| c.intent.as_str())
1166            .collect();
1167        assert_eq!(
1168            intents,
1169            vec!["the whole mandate", "the guard", "the cockpit"],
1170            "the unnamed claim first, then the named ones in name order"
1171        );
1172        assert_eq!(
1173            third.peer.claims[0].name, None,
1174            "the unnamed claim is unnamed"
1175        );
1176        assert_eq!(third.peer.claims[1].name.as_deref(), Some("worker-a"));
1177
1178        // and every one of them is still ground another peer is warned off
1179        let other = board.attach(Transport::Http);
1180        let answer = board
1181            .announce(
1182                &other,
1183                "unrelated",
1184                vec![".claude/hooks".into(), "site".into()],
1185            )
1186            .unwrap();
1187        let met: Vec<&str> = answer.overlaps.iter().map(|o| o.intent.as_str()).collect();
1188        assert_eq!(
1189            met,
1190            vec!["the guard", "the cockpit"],
1191            "a claim of a peer is an overlap whichever of its claims it is"
1192        );
1193    }
1194
1195    /// Announcing under a name the peer already used is an update, not a second claim — and
1196    /// when the update is narrower the peer is told which ground it just stopped holding,
1197    /// because nobody would otherwise ever see it happen.
1198    #[test]
1199    fn re_announcing_a_name_updates_it_and_says_what_it_released() {
1200        let board = PeerBoard::new();
1201        let peer = board.attach(Transport::Http);
1202        board.announce_claim(
1203            &peer,
1204            Some("worker-a"),
1205            "the guard",
1206            vec![".claude".into(), "scripts/ci".into()],
1207        );
1208        let again = board
1209            .announce_claim(
1210                &peer,
1211                Some("worker-a"),
1212                "the guard, narrowed",
1213                vec![".claude".into()],
1214            )
1215            .unwrap();
1216
1217        assert_eq!(
1218            again.peer.claims.len(),
1219            1,
1220            "the same name is the same claim"
1221        );
1222        assert_eq!(again.peer.claims[0].intent, "the guard, narrowed");
1223        assert_eq!(
1224            again.released,
1225            vec!["scripts/ci".to_string()],
1226            "the path it stopped claiming is named at the moment it stops"
1227        );
1228
1229        // widening releases nothing, and neither does re-stating the same scope
1230        let wider = board
1231            .announce_claim(
1232                &peer,
1233                Some("worker-a"),
1234                "the guard, wider",
1235                vec![".claude".into(), "scripts/ci".into()],
1236            )
1237            .unwrap();
1238        assert!(wider.released.is_empty());
1239        let same = board
1240            .announce_claim(
1241                &peer,
1242                Some("worker-a"),
1243                "the guard, wider",
1244                vec![".claude".into(), "scripts/ci".into()],
1245            )
1246            .unwrap();
1247        assert!(
1248            same.released.is_empty(),
1249            "announcing the same thing releases nothing"
1250        );
1251
1252        // a claim inside the one it replaces is not a release: the ground is still held
1253        let inside = board
1254            .announce_claim(
1255                &peer,
1256                Some("worker-a"),
1257                "deeper",
1258                vec![".claude/hooks".into()],
1259            )
1260            .unwrap();
1261        assert_eq!(
1262            inside.released,
1263            vec!["scripts/ci".to_string()],
1264            ".claude is still covered by .claude/hooks; scripts/ci is not covered by anything"
1265        );
1266    }
1267
1268    /// A peer that announced anything at all is kept on the board when its session ends —
1269    /// including one whose only claims are named ones, which the departure test's unnamed
1270    /// announcement would not have caught.
1271    #[test]
1272    fn a_peer_with_only_named_claims_outlives_its_session() {
1273        let board = PeerBoard::new();
1274        let peer = board.attach(Transport::Http);
1275        board.announce_claim(&peer, Some("worker-a"), "the guard", vec![".claude".into()]);
1276        board.detach(&peer);
1277        let listed = board.list();
1278        assert_eq!(
1279            listed.len(),
1280            1,
1281            "what it said it was working on outlives the socket"
1282        );
1283        assert!(!listed[0].attached);
1284        assert_eq!(listed[0].claims.len(), 1);
1285        assert_eq!(listed[0].claims[0].name.as_deref(), Some("worker-a"));
1286    }
1287
1288    /// The one-line summary an `initialize` hands a new client names every claim, so a client
1289    /// arriving into a fleet of fanned-out workers sees all of them and not just the last.
1290    #[test]
1291    fn the_summary_names_every_claim() {
1292        let board = PeerBoard::new();
1293        let peer = board.attach(Transport::Http);
1294        board.announce(&peer, "the whole mandate", vec![]);
1295        board.announce_claim(&peer, Some("worker-a"), "the guard", vec![]);
1296        let line = board.summary();
1297        assert!(line.contains("the whole mandate"), "{line}");
1298        assert!(line.contains("worker-a: the guard"), "{line}");
1299    }
1300}