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 = ¶ms["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}