Skip to main content

agentmux_srv/backend/
swarm_remote.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! The agents of other AgentMux instances, for the Swarm's sections below this
5//! instance's own tree (`docs/specs/SPEC_SWARM_OTHER_HOSTS_AND_CHANNELS_2026_10_02.md`).
6//!
7//! Phase 2 adds LAN hosts, from the LAN discovery peer list (mDNS and the UDP
8//! route): one host per hostname, one channel per advertising instance, names
9//! from each peer's `agents` list.
10//!
11//! Phase 1: other channels on this machine, read from the host-global shared
12//! registry (`reactive::registry::list_all_shared`), the same source the
13//! `/agentmux/discovery` endpoint's `host.cross_channel` uses. Names only: the
14//! registry carries no status, provider or summary. LAN hosts and cloud installs
15//! are later phases and arrive as more `RemoteHost`s with their own `tier`.
16//!
17//! Phase 4 adds each agent's state (working, waiting, idle, ...) where its
18//! instance reports one: a LAN peer's from the `agent-names` poll the discovery
19//! loop already makes, another channel's from the same route asked over
20//! loopback ([`channel_statuses`]). With it, by the owner's decision of
21//! 2026-10-13, each agent's summary line where its instance shares one
22//! (`backend::agent_line`).
23//!
24//! Nothing here carries a credential to the frontend: the registry's `auth_key`
25//! and `local_url` are dropped.
26
27use serde::Serialize;
28
29use std::collections::{BTreeMap, HashMap, HashSet};
30use std::net::IpAddr;
31
32use crate::backend::agent_state::AgentState;
33use crate::backend::lan_discovery::LanInstance;
34use crate::backend::reactive::registry::AgentEntry;
35
36/// A registry entry not rewritten for this long is shown as stale. The registry
37/// heartbeat rewrites every live agent's entry every 20 s, so this is three
38/// missed beats.
39pub const STALE_AFTER_MS: u64 = 60_000;
40
41/// Past this an entry is not shown at all. The registry's own startup sweep only
42/// drops entries older than 4 h, and a crashed channel should not look present
43/// for that long.
44pub const HIDE_AFTER_MS: u64 = 10 * 60_000;
45
46#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
47pub struct RemoteAgent {
48    pub name: String,
49    /// The agent's block in its own instance. Not usable from this one; kept so a
50    /// later phase can address it (the cross-channel fleet forwarder does).
51    pub block_id: String,
52    /// What its instance last said it is doing; absent when that instance
53    /// gave no state (a build from before it did, or a terminal pane).
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub status: Option<AgentState>,
56    /// Its summary line as its instance last shared it; absent when that
57    /// instance shared none (an older build, or `swarm:sharesummaries` off).
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub line: Option<String>,
60}
61
62/// What one channel of this machine said about its agents, by lower-cased name.
63#[derive(Debug, Clone, Default, PartialEq, Eq)]
64pub struct ChannelReport {
65    pub status: BTreeMap<String, AgentState>,
66    pub lines: BTreeMap<String, String>,
67}
68
69/// Each channel's report, by channel.
70pub type ChannelStatuses = HashMap<String, ChannelReport>;
71
72#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
73pub struct RemoteChannel {
74    pub channel: String,
75    /// Newest `updated_at` among the channel's entries, Unix ms.
76    pub seen_at_ms: u64,
77    /// No entry of this channel rewritten within [`STALE_AFTER_MS`].
78    pub stale: bool,
79    /// The instance's operating system (`windows`, `macos`, `linux`), for the
80    /// platform tag. Empty when unknown: a peer from before it was advertised.
81    /// Self-reported and display only (`host_os`).
82    pub os: String,
83    /// Sorted by name, case-insensitively.
84    pub agents: Vec<RemoteAgent>,
85}
86
87#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
88pub struct RemoteHost {
89    /// Stable id for UI state (collapse): `host:<hostname>` for this machine.
90    pub host_id: String,
91    pub display_name: String,
92    /// How it was found: `host` (this machine), later `lan` or `cloud`.
93    pub tier: &'static str,
94    /// Sorted by channel name.
95    pub channels: Vec<RemoteChannel>,
96}
97
98/// Response of `swarm.other-instances`.
99#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
100pub struct SwarmOtherInstancesResult {
101    /// This machine's name and this instance's channel, for the naming rule
102    /// ("name the channel only when a host has more than one").
103    pub hostname: String,
104    pub channel: String,
105    pub hosts: Vec<RemoteHost>,
106}
107
108/// Group the shared registry's entries into this machine's other channels.
109///
110/// Skips this instance's own channel and URL (a stale self-entry after a crash),
111/// entries past [`HIDE_AFTER_MS`], and entries with no channel (per-channel
112/// registry rows, which never belong in the shared directory). An agent listed
113/// twice in one channel (two files racing) appears once.
114pub fn other_channels(
115    entries: &[AgentEntry],
116    statuses: &ChannelStatuses,
117    own_channel: &str,
118    own_url: &str,
119    now_ms: u64,
120) -> Vec<RemoteChannel> {
121    use std::collections::BTreeMap;
122    let mut by_channel: BTreeMap<String, (u64, BTreeMap<String, RemoteAgent>)> = BTreeMap::new();
123    for e in entries {
124        if e.channel.is_empty()
125            || e.channel == own_channel
126            || (!own_url.is_empty() && e.local_url == own_url)
127        {
128            continue;
129        }
130        if now_ms.saturating_sub(e.updated_at) > HIDE_AFTER_MS {
131            continue;
132        }
133        let slot = by_channel.entry(e.channel.clone()).or_default();
134        slot.0 = slot.0.max(e.updated_at);
135        slot.1
136            .entry(e.agent_id.to_lowercase())
137            .or_insert_with(|| {
138                let report = statuses.get(&e.channel);
139                let key = e.agent_id.to_lowercase();
140                RemoteAgent {
141                    name: e.agent_id.clone(),
142                    block_id: e.block_id.clone(),
143                    status: report.and_then(|r| r.status.get(&key)).copied(),
144                    line: report.and_then(|r| r.lines.get(&key)).cloned(),
145                }
146            });
147    }
148    by_channel
149        .into_iter()
150        .map(|(channel, (seen_at_ms, agents))| RemoteChannel {
151            channel,
152            seen_at_ms,
153            stale: now_ms.saturating_sub(seen_at_ms) > STALE_AFTER_MS,
154            // Every channel on this machine runs on its operating system.
155            os: crate::backend::host_os::local_os(),
156            agents: agents.into_values().collect(),
157        })
158        .collect()
159}
160
161fn fnv1a32(text: &str) -> u32 {
162    text.bytes().fold(0x811c_9dc5u32, |h, b| {
163        (h ^ u32::from(b)).wrapping_mul(0x0100_0193)
164    })
165}
166
167/// A host's instances as channels, with a channel name that appears more than
168/// once suffixed by its port ("stable :29702"), so the sections stay apart.
169fn label_repeated_channels(by_instance: BTreeMap<String, RemoteChannel>) -> Vec<RemoteChannel> {
170    let mut count: BTreeMap<String, usize> = BTreeMap::new();
171    for c in by_instance.values() {
172        *count.entry(c.channel.clone()).or_default() += 1;
173    }
174    by_instance
175        .into_iter()
176        .map(|(key, mut c)| {
177            if count[&c.channel] > 1 {
178                if let Some((_, port)) = key.rsplit_once('@') {
179                    c.channel = format!("{} :{port}", c.channel);
180                }
181            }
182            c
183        })
184        .collect()
185}
186
187/// LAN hosts from the discovery peer list. A peer at one of this machine's own
188/// addresses is another channel on this host, which the registry already lists
189/// (with more detail), so it is left out here. A peer that did not advertise its
190/// channel (an older build) is labelled by its port, so two of them on one host
191/// stay apart.
192pub fn lan_hosts(
193    peers: &[LanInstance],
194    own_addrs: &HashSet<IpAddr>,
195    now_ms: u64,
196) -> Vec<RemoteHost> {
197    let mut hosts: BTreeMap<String, (String, BTreeMap<String, RemoteChannel>)> = BTreeMap::new();
198    for p in peers {
199        if p.address
200            .parse::<IpAddr>()
201            .is_ok_and(|a| own_addrs.contains(&a))
202        {
203            continue;
204        }
205        // The address is the grouping key only, never a label: the answer
206        // carries no addresses (Codex P2 on #4241).
207        let display = if p.hostname.trim().is_empty() {
208            "unnamed host".to_string()
209        } else {
210            p.hostname.trim().to_string()
211        };
212        let channel = if p.channel.is_empty() {
213            format!(":{}", p.port)
214        } else {
215            p.channel.clone()
216        };
217        // Heard from by mDNS or answered a poll, whichever is later.
218        let seen_at_ms = p.last_seen.max(p.last_polled_ok).saturating_mul(1000);
219        let mut agents: Vec<RemoteAgent> = p
220            .agents
221            .iter()
222            .map(|name| RemoteAgent {
223                name: name.clone(),
224                block_id: String::new(),
225                status: p.agent_status.get(&name.to_lowercase()).copied(),
226                line: p.agent_lines.get(&name.to_lowercase()).cloned(),
227            })
228            .collect();
229        agents.sort_by_key(|a| a.name.to_lowercase());
230        agents.dedup_by(|a, b| a.name.eq_ignore_ascii_case(&b.name));
231        // A machine is its hostname AND its address: two machines that share a
232        // name (cloned VMs both called `ubuntu`) must not merge, or one would hide
233        // the other's agents (Codex P2 on #4241). Two channels on one machine
234        // share the address, so they still group.
235        let host = hosts
236            .entry(format!("{}@{}", display.to_lowercase(), p.address))
237            .or_insert_with(|| (display.clone(), BTreeMap::new()));
238        // One entry per instance, which on a machine is its port: two records
239        // for the same instance (mDNS and UDP before they merge) keep the fresher
240        // one, while two instances on one channel (an installed and a portable
241        // build, say) both stay (Codex P2 on #4241).
242        let instance_key = format!("{channel}@{}", p.port);
243        let keep = host
244            .1
245            .get(&instance_key)
246            .is_none_or(|c| c.seen_at_ms < seen_at_ms);
247        if keep {
248            host.1.insert(
249                instance_key,
250                RemoteChannel {
251                    channel,
252                    seen_at_ms,
253                    stale: now_ms.saturating_sub(seen_at_ms) > STALE_AFTER_MS,
254                    os: p.os.clone(),
255                    agents,
256                },
257            );
258        }
259    }
260    // A name shared by several machines gets a number per machine, in key
261    // (address) order, so the sections can be told apart without showing an
262    // address; a unique name stays bare.
263    let mut name_count: BTreeMap<String, usize> = BTreeMap::new();
264    for (display, _) in hosts.values() {
265        *name_count.entry(display.to_lowercase()).or_default() += 1;
266    }
267    let mut name_seen: BTreeMap<String, usize> = BTreeMap::new();
268    hosts
269        .into_iter()
270        .map(|(key, (display, channels))| {
271            // Stable for the UI's collapse state, without putting the address
272            // in the answer: a short hash of name and address.
273            let host_id = format!("lan:{}#{:08x}", display.to_lowercase(), fnv1a32(&key));
274            let lower = display.to_lowercase();
275            let display_name = if name_count[&lower] > 1 {
276                let n = name_seen.entry(lower).or_default();
277                *n += 1;
278                format!("{display} ({n})")
279            } else {
280                display
281            };
282            RemoteHost {
283                host_id,
284                display_name,
285                tier: "lan",
286                channels: label_repeated_channels(channels),
287            }
288        })
289        .collect()
290}
291
292/// How long another channel on this machine gets to say what its agents are
293/// doing. It is loopback, so one that takes longer is hung, and the Swarm's poll
294/// shouldn't wait on it: its rows show names only until it answers.
295const CHANNEL_STATUS_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(1500);
296
297/// One instance to ask for its agents' states: `(channel, local_url, auth_key)`.
298#[derive(Debug, Clone, PartialEq, Eq)]
299pub struct ChannelStatusTarget {
300    pub channel: String,
301    pub local_url: String,
302    pub auth_key: String,
303}
304
305fn is_loopback_url(url: &str) -> bool {
306    ["http://127.0.0.1:", "http://localhost:", "http://[::1]:"]
307        .iter()
308        .any(|prefix| url.starts_with(prefix))
309}
310
311/// The instances whose agents [`other_channels`] lists and that are worth
312/// asking: one per `local_url`, loopback only (as the cross-channel fleet
313/// forwarder requires), never this instance, and not a stale one, whose rows
314/// are dimmed and show no state.
315///
316/// Each instance is asked with its freshest entry's key: an instance that
317/// restarted on the same URL has a new per-launch key, while its older entries
318/// keep the old one until they are rewritten or age out (#4703).
319pub fn channel_status_targets(
320    entries: &[AgentEntry],
321    own_channel: &str,
322    own_url: &str,
323    now_ms: u64,
324) -> Vec<ChannelStatusTarget> {
325    let mut by_url: BTreeMap<&str, &AgentEntry> = BTreeMap::new();
326    for e in entries {
327        if e.channel.is_empty()
328            || e.channel == own_channel
329            || e.local_url == own_url
330            || !is_loopback_url(&e.local_url)
331            || now_ms.saturating_sub(e.updated_at) > STALE_AFTER_MS
332        {
333            continue;
334        }
335        let slot = by_url.entry(&e.local_url).or_insert(e);
336        if e.updated_at > slot.updated_at {
337            *slot = e;
338        }
339    }
340    by_url
341        .into_values()
342        .map(|e| ChannelStatusTarget {
343            channel: e.channel.clone(),
344            local_url: e.local_url.clone(),
345            auth_key: e.auth_key.clone(),
346        })
347        .collect()
348}
349
350/// Asks each target's `agent-names` route, concurrently, for its agents'
351/// states and lines. A target that fails, times out or answers badly
352/// contributes nothing.
353pub async fn channel_statuses(
354    http: &reqwest::Client,
355    targets: &[ChannelStatusTarget],
356) -> ChannelStatuses {
357    let answers = futures_util::future::join_all(targets.iter().map(|t| async move {
358        let resp = http
359            .get(format!("{}/agentmux/reactive/agent-names", t.local_url))
360            .header(agentmux_common::AUTH_KEY_HEADER, &t.auth_key)
361            .timeout(CHANNEL_STATUS_TIMEOUT)
362            .send()
363            .await
364            .ok()
365            .filter(|r| r.status().is_success())?;
366        let body = crate::backend::lan_discovery::LanDiscovery::read_body_capped(
367            resp,
368            crate::backend::lan_discovery::LAN_AGENT_NAMES_MAX_RESPONSE_BYTES,
369        )
370        .await
371        .ok()?;
372        let value: serde_json::Value = serde_json::from_slice(&body).ok()?;
373        Some((
374            t.channel.clone(),
375            crate::backend::lan_discovery::parse_agent_names_body(&value),
376        ))
377    }))
378    .await;
379    let mut out = ChannelStatuses::new();
380    for (channel, peer) in answers.into_iter().flatten() {
381        let report = out.entry(channel).or_default();
382        report.status.extend(peer.status);
383        report.lines.extend(peer.lines);
384    }
385    out
386}
387
388/// The full answer: this machine's other channels as one host (absent when there
389/// are none), then the LAN hosts.
390pub fn snapshot(
391    entries: &[AgentEntry],
392    statuses: &ChannelStatuses,
393    lan: &[LanInstance],
394    own_addrs: &HashSet<IpAddr>,
395    hostname: &str,
396    own_channel: &str,
397    own_url: &str,
398    now_ms: u64,
399) -> SwarmOtherInstancesResult {
400    let channels = other_channels(entries, statuses, own_channel, own_url, now_ms);
401    let mut hosts = Vec::new();
402    if !channels.is_empty() {
403        hosts.push(RemoteHost {
404            host_id: format!("host:{hostname}"),
405            display_name: hostname.to_string(),
406            tier: "host",
407            channels,
408        });
409    }
410    hosts.extend(lan_hosts(lan, own_addrs, now_ms));
411    SwarmOtherInstancesResult {
412        hostname: hostname.to_string(),
413        channel: own_channel.to_string(),
414        hosts,
415    }
416}
417
418#[cfg(test)]
419mod tests {
420    use super::*;
421
422    const NOW: u64 = 1_000_000_000;
423
424    fn entry(name: &str, channel: &str, age_ms: u64) -> AgentEntry {
425        AgentEntry {
426            agent_id: name.to_string(),
427            local_url: format!("http://127.0.0.1:{}", 29700 + channel.len()),
428            block_id: format!("blk-{name}"),
429            pid: 1,
430            updated_at: NOW - age_ms,
431            auth_key: "secret".to_string(),
432            channel: channel.to_string(),
433            registration_nonce: 0,
434            jekt_public_key: String::new(),
435            uid: String::new(),
436            uid_public_key: String::new(),
437        }
438    }
439
440    #[test]
441    fn groups_other_channels_and_leaves_this_one_out() {
442        let entries = [
443            entry("Korp", "dev-fix-lan", 5_000),
444            entry("Loap", "dev-fix-lan", 1_000),
445            entry("AgentX", "stable", 0),
446        ];
447        let chans = other_channels(&entries, &ChannelStatuses::new(), "stable", "", NOW);
448        assert_eq!(chans.len(), 1);
449        assert_eq!(chans[0].channel, "dev-fix-lan");
450        assert_eq!(
451            chans[0]
452                .agents
453                .iter()
454                .map(|a| a.name.as_str())
455                .collect::<Vec<_>>(),
456            ["Korp", "Loap"]
457        );
458        assert_eq!(chans[0].seen_at_ms, NOW - 1_000, "newest entry");
459        assert!(!chans[0].stale);
460    }
461
462    #[test]
463    fn a_stale_self_entry_by_url_is_left_out() {
464        let mut me = entry("AgentX", "old-name", 0);
465        me.local_url = "http://127.0.0.1:29706".to_string();
466        assert!(other_channels(
467            &[me],
468            &ChannelStatuses::new(),
469            "stable",
470            "http://127.0.0.1:29706",
471            NOW
472        )
473        .is_empty());
474    }
475
476    #[test]
477    fn freshness_marks_stale_then_hides() {
478        let stale = other_channels(
479            &[entry("Korp", "dev", STALE_AFTER_MS + 1)],
480            &ChannelStatuses::new(),
481            "stable",
482            "",
483            NOW,
484        );
485        assert!(stale[0].stale);
486        assert!(other_channels(
487            &[entry("Korp", "dev", HIDE_AFTER_MS + 1)],
488            &ChannelStatuses::new(),
489            "stable",
490            "",
491            NOW
492        )
493        .is_empty());
494    }
495
496    #[test]
497    fn an_agent_listed_twice_in_a_channel_appears_once() {
498        let chans = other_channels(
499            &[entry("Korp", "dev", 0), entry("korp", "dev", 10)],
500            &ChannelStatuses::new(),
501            "stable",
502            "",
503            NOW,
504        );
505        assert_eq!(chans[0].agents.len(), 1);
506    }
507
508    #[test]
509    fn entries_without_a_channel_are_not_another_channel() {
510        assert!(other_channels(
511            &[entry("Korp", "", 0)],
512            &ChannelStatuses::new(),
513            "stable",
514            "",
515            NOW
516        )
517        .is_empty());
518    }
519
520    #[test]
521    fn the_snapshot_names_this_machine_and_carries_no_credentials() {
522        let snap = snapshot(
523            &[entry("Korp", "dev", 0)],
524            &ChannelStatuses::new(),
525            &[],
526            &HashSet::new(),
527            "narko",
528            "stable",
529            "",
530            NOW,
531        );
532        assert_eq!(
533            (snap.hostname.as_str(), snap.channel.as_str()),
534            ("narko", "stable")
535        );
536        assert_eq!(snap.hosts.len(), 1);
537        assert_eq!(
538            (snap.hosts[0].display_name.as_str(), snap.hosts[0].tier),
539            ("narko", "host")
540        );
541        let json = serde_json::to_string(&snap).unwrap();
542        assert!(
543            !json.contains("secret") && !json.contains("local_url") && !json.contains("127.0.0.1"),
544            "{json}"
545        );
546        // No other channel: no host section at all.
547        assert!(snapshot(
548            &[],
549            &ChannelStatuses::new(),
550            &[],
551            &HashSet::new(),
552            "narko",
553            "stable",
554            "",
555            NOW
556        )
557        .hosts
558        .is_empty());
559    }
560
561    fn peer(
562        host: &str,
563        channel: &str,
564        addr: &str,
565        port: u16,
566        agents: &[&str],
567        age_secs: u64,
568    ) -> LanInstance {
569        LanInstance {
570            instance_id: format!("{host}-{port}"),
571            hostname: host.to_string(),
572            version: "0.59.5".to_string(),
573            channel: channel.to_string(),
574            os: String::new(),
575            address: addr.to_string(),
576            port,
577            auth_key: "lan-secret".to_string(),
578            agents: agents.iter().map(|a| a.to_string()).collect(),
579            agent_status: Default::default(),
580            agent_lines: Default::default(),
581            first_seen: 0,
582            last_seen: NOW / 1000 - age_secs,
583            last_polled_ok: 0,
584            other_ttl_secs: 4500,
585        }
586    }
587
588    #[test]
589    fn lan_peers_group_into_hosts_with_a_channel_each() {
590        let peers = [
591            peer("Area54", "stable", "10.0.0.26", 29700, &["Manoz"], 5),
592            peer(
593                "starpower",
594                "stable",
595                "10.0.0.195",
596                29700,
597                &["Opaz", "korp"],
598                5,
599            ),
600            peer("starpower", "dev", "10.0.0.195", 29702, &["Loap"], 5),
601        ];
602        let hosts = lan_hosts(&peers, &HashSet::new(), NOW);
603        let shape: Vec<(&str, &str, usize)> = hosts
604            .iter()
605            .map(|h| (h.display_name.as_str(), h.tier, h.channels.len()))
606            .collect();
607        assert_eq!(shape, [("Area54", "lan", 1), ("starpower", "lan", 2)]);
608        let chans: Vec<&str> = hosts[1]
609            .channels
610            .iter()
611            .map(|c| c.channel.as_str())
612            .collect();
613        assert_eq!(chans, ["dev", "stable"]);
614        let names: Vec<&str> = hosts[1].channels[1]
615            .agents
616            .iter()
617            .map(|a| a.name.as_str())
618            .collect();
619        assert_eq!(names, ["korp", "Opaz"]);
620    }
621
622    /// Codex P2 on #4241: cloned VMs that share a hostname are two machines.
623    #[test]
624    fn two_machines_with_the_same_hostname_stay_apart() {
625        let peers = [
626            peer("ubuntu", "stable", "10.0.0.40", 29700, &["A"], 5),
627            peer("ubuntu", "stable", "10.0.0.41", 29700, &["B"], 5),
628        ];
629        let hosts = lan_hosts(&peers, &HashSet::new(), NOW);
630        assert_eq!(hosts.len(), 2, "neither hides the other");
631        let names: Vec<&str> = hosts.iter().map(|h| h.display_name.as_str()).collect();
632        assert_eq!(names, ["ubuntu (1)", "ubuntu (2)"]);
633        // And still no address anywhere in the answer (Codex P2 on #4241).
634        let json = serde_json::to_string(&hosts).unwrap();
635        assert!(!json.contains("10.0.0.4"), "{json}");
636        assert_ne!(hosts[0].host_id, hosts[1].host_id);
637        let agents: Vec<&str> = hosts
638            .iter()
639            .map(|h| h.channels[0].agents[0].name.as_str())
640            .collect();
641        assert_eq!(agents, ["A", "B"]);
642    }
643
644    #[test]
645    fn a_lan_peer_at_one_of_our_own_addresses_is_left_to_the_registry() {
646        let own: HashSet<IpAddr> = ["10.0.0.230".parse().unwrap()].into_iter().collect();
647        let peers = [peer("narko", "dev", "10.0.0.230", 29702, &["Loap"], 5)];
648        assert!(lan_hosts(&peers, &own, NOW).is_empty());
649    }
650
651    #[test]
652    fn a_peer_without_a_channel_is_labelled_by_its_port() {
653        let peers = [
654            peer("old", "", "10.0.0.9", 29700, &["A"], 5),
655            peer("old", "", "10.0.0.9", 29701, &["B"], 5),
656        ];
657        let hosts = lan_hosts(&peers, &HashSet::new(), NOW);
658        let chans: Vec<&str> = hosts[0]
659            .channels
660            .iter()
661            .map(|c| c.channel.as_str())
662            .collect();
663        assert_eq!(chans, [":29700", ":29701"]);
664    }
665
666    #[test]
667    fn a_quiet_lan_peer_is_stale_and_no_lan_key_or_address_leaves() {
668        let quiet = [peer(
669            "Area54",
670            "stable",
671            "10.0.0.26",
672            29700,
673            &["Manoz"],
674            90,
675        )];
676        assert!(lan_hosts(&quiet, &HashSet::new(), NOW)[0].channels[0].stale);
677        let fresh = [peer(
678            "Area54",
679            "stable",
680            "10.0.0.26",
681            29700,
682            &["Manoz"],
683            5,
684        )];
685        let snap = snapshot(
686            &[],
687            &ChannelStatuses::new(),
688            &fresh,
689            &HashSet::new(),
690            "narko",
691            "stable",
692            "",
693            NOW,
694        );
695        let json = serde_json::to_string(&snap).unwrap();
696        assert!(
697            !json.contains("lan-secret") && !json.contains("10.0.0.26"),
698            "{json}"
699        );
700    }
701
702    /// Codex P1 on #4241: an mDNS-only peer is re-resolved tens of minutes apart,
703    /// so a peer that answers the agent-name poll is live, whatever `last_seen` says.
704    #[test]
705    fn a_peer_that_answers_polls_is_not_stale() {
706        let mut p = peer("Area54", "stable", "10.0.0.26", 29700, &["Manoz"], 1800);
707        assert!(lan_hosts(std::slice::from_ref(&p), &HashSet::new(), NOW)[0].channels[0].stale);
708        p.last_polled_ok = NOW / 1000 - 10;
709        assert!(!lan_hosts(&[p], &HashSet::new(), NOW)[0].channels[0].stale);
710    }
711
712    #[test]
713    fn a_host_with_no_name_is_not_labelled_by_its_address() {
714        let hosts = lan_hosts(
715            &[peer("", "stable", "10.0.0.77", 29700, &["A"], 5)],
716            &HashSet::new(),
717            NOW,
718        );
719        assert_eq!(hosts[0].display_name, "unnamed host");
720    }
721
722    /// Codex P2 on #4241: two instances on one machine and channel (an installed
723    /// and a portable build) host different agents; neither may replace the other.
724    #[test]
725    fn two_instances_on_one_channel_both_stay() {
726        let peers = [
727            peer("narko2", "stable", "10.0.0.50", 29700, &["A"], 5),
728            peer("narko2", "stable", "10.0.0.50", 29702, &["B"], 5),
729        ];
730        let hosts = lan_hosts(&peers, &HashSet::new(), NOW);
731        assert_eq!(hosts.len(), 1);
732        let chans: Vec<&str> = hosts[0]
733            .channels
734            .iter()
735            .map(|c| c.channel.as_str())
736            .collect();
737        assert_eq!(chans, ["stable :29700", "stable :29702"]);
738    }
739
740    #[test]
741    fn the_same_instance_seen_twice_is_listed_once() {
742        let older = peer("Area54", "stable", "10.0.0.26", 29700, &["Old"], 50);
743        let newer = peer("Area54", "stable", "10.0.0.26", 29700, &["New"], 5);
744        let hosts = lan_hosts(&[older, newer], &HashSet::new(), NOW);
745        assert_eq!(hosts[0].channels.len(), 1);
746        assert_eq!(hosts[0].channels[0].channel, "stable");
747        assert_eq!(hosts[0].channels[0].agents[0].name, "New");
748    }
749
750    // The platform tag (SPEC_SWARM_REMOTE_AGENTS_PLATFORM_TAG_AND_SELECTION_2026_10_03.md §3).
751    #[test]
752    fn this_machines_other_channels_carry_this_machines_platform() {
753        let chans = other_channels(
754            &[entry("Loap", "dev-fix", 0)],
755            &ChannelStatuses::new(),
756            "stable",
757            "",
758            NOW,
759        );
760        assert_eq!(chans[0].os, crate::backend::host_os::local_os());
761    }
762
763    #[test]
764    fn a_lan_peer_carries_the_platform_it_advertised_and_none_when_it_did_not() {
765        let mut mac = peer("Area54", "stable", "10.0.0.26", 29700, &["Manoz"], 5);
766        mac.os = "macos".to_string();
767        let old = peer("starpower", "stable", "10.0.0.27", 29700, &["Opaz"], 5);
768        let hosts = lan_hosts(&[mac, old], &HashSet::new(), NOW);
769        let os_of = |name: &str| {
770            hosts
771                .iter()
772                .find(|h| h.display_name == name)
773                .unwrap()
774                .channels[0]
775                .os
776                .clone()
777        };
778        assert_eq!(os_of("Area54"), "macos");
779        assert_eq!(os_of("starpower"), "");
780    }
781
782    #[test]
783    fn two_instances_on_one_host_keep_their_own_platform() {
784        // A Windows machine that also runs an instance inside WSL.
785        let mut win = peer("narko2", "stable", "10.0.0.30", 29700, &["A"], 5);
786        win.os = "windows".to_string();
787        let mut wsl = peer("narko2", "stable", "10.0.0.30", 29702, &["B"], 5);
788        wsl.os = "linux".to_string();
789        let hosts = lan_hosts(&[win, wsl], &HashSet::new(), NOW);
790        let mut oses: Vec<String> = hosts[0].channels.iter().map(|c| c.os.clone()).collect();
791        oses.sort();
792        assert_eq!(oses, vec!["linux".to_string(), "windows".to_string()]);
793    }
794
795    // Phase 4: each agent's state (SPEC_SWARM_OTHER_HOSTS_AND_CHANNELS_2026_10_02.md §3.4).
796    #[test]
797    fn another_channels_agents_carry_the_state_that_channel_reported() {
798        let report = ChannelReport {
799            status: [("korp".to_string(), AgentState::Waiting)].into_iter().collect(),
800            lines: [("korp".to_string(), "Fixing the login race".to_string())].into_iter().collect(),
801        };
802        let statuses: ChannelStatuses = [("dev".to_string(), report)].into_iter().collect();
803        let chans = other_channels(
804            &[entry("Korp", "dev", 0), entry("Loap", "dev", 0)],
805            &statuses,
806            "stable",
807            "",
808            NOW,
809        );
810        let got: Vec<(&str, Option<AgentState>)> = chans[0]
811            .agents
812            .iter()
813            .map(|a| (a.name.as_str(), a.status))
814            .collect();
815        assert_eq!(got, [("Korp", Some(AgentState::Waiting)), ("Loap", None)]);
816        // An agent with no state has no `status` key at all.
817        let json = serde_json::to_value(&chans[0].agents).unwrap();
818        assert_eq!(json[0]["status"], "waiting");
819        assert!(json[1].get("status").is_none(), "{json}");
820        // The same for the line.
821        assert_eq!(json[0]["line"], "Fixing the login race");
822        assert!(json[1].get("line").is_none(), "{json}");
823    }
824
825    #[test]
826    fn a_lan_peers_agents_carry_the_state_it_reported() {
827        let mut p = peer(
828            "Area54",
829            "stable",
830            "10.0.0.26",
831            29700,
832            &["Manoz", "Opaz"],
833            5,
834        );
835        p.agent_status.insert("manoz".into(), AgentState::Working);
836        p.agent_lines.insert("manoz".into(), "Fixing the login race".into());
837        let hosts = lan_hosts(&[p], &HashSet::new(), NOW);
838        let got: Vec<(Option<AgentState>, Option<&str>)> = hosts[0].channels[0]
839            .agents
840            .iter()
841            .map(|a| (a.status, a.line.as_deref()))
842            .collect();
843        assert_eq!(
844            got,
845            [(Some(AgentState::Working), Some("Fixing the login race")), (None, None)]
846        );
847    }
848
849    #[test]
850    fn status_targets_are_live_loopback_instances_other_than_this_one() {
851        let at = |name: &str, channel: &str, url: &str, age_ms: u64| {
852            let mut e = entry(name, channel, age_ms);
853            e.local_url = url.to_string();
854            e
855        };
856        let entries = [
857            at("Korp", "dev", "http://127.0.0.1:29702", 0),
858            at("Loap", "dev", "http://127.0.0.1:29702", 0),
859            at("Old", "gone", "http://127.0.0.1:29703", STALE_AFTER_MS + 1),
860            at("Far", "lan", "http://10.0.0.9:29700", 0),
861            at("Me", "renamed", "http://127.0.0.1:29700", 0),
862            at("Self", "stable", "http://127.0.0.1:29701", 0),
863            at("Nochan", "", "http://127.0.0.1:29704", 0),
864            at("Six", "v6", "http://[::1]:29705", 0),
865        ];
866        let targets = channel_status_targets(&entries, "stable", "http://127.0.0.1:29700", NOW);
867        let urls: Vec<(&str, &str)> = targets
868            .iter()
869            .map(|t| (t.channel.as_str(), t.local_url.as_str()))
870            .collect();
871        assert_eq!(
872            urls,
873            [
874                ("dev", "http://127.0.0.1:29702"),
875                ("v6", "http://[::1]:29705")
876            ],
877            "one per instance; not stale, not off-machine, not this instance"
878        );
879        assert_eq!(targets[0].auth_key, "secret");
880    }
881
882    /// An instance that restarted on the same URL has a new key, and its older
883    /// entries keep the old one for a while. Whatever order the registry lists
884    /// them in, the freshest entry's key is used (#4703).
885    #[test]
886    fn a_restarted_instance_is_asked_with_its_newest_key() {
887        let at = |name: &str, age_ms: u64, key: &str| {
888            let mut e = entry(name, "dev", age_ms);
889            e.local_url = "http://127.0.0.1:29702".to_string();
890            e.auth_key = key.to_string();
891            e
892        };
893        let entries = [
894            at("Korp", 30_000, "old-key"),
895            at("Loap", 1_000, "new-key"),
896            at("Opaz", 20_000, "old-key"),
897        ];
898        for order in [entries.to_vec(), entries.iter().rev().cloned().collect()] {
899            let targets = channel_status_targets(&order, "stable", "", NOW);
900            assert_eq!(targets.len(), 1);
901            assert_eq!(targets[0].auth_key, "new-key");
902        }
903    }
904
905    /// One-shot HTTP server answering any request with `body`, and the request it
906    /// received.
907    async fn serve_once(body: &'static str) -> (String, tokio::sync::oneshot::Receiver<String>) {
908        use tokio::io::{AsyncReadExt, AsyncWriteExt};
909        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
910        let url = format!("http://{}", listener.local_addr().unwrap());
911        let (tx, rx) = tokio::sync::oneshot::channel();
912        tokio::spawn(async move {
913            let Ok((mut stream, _)) = listener.accept().await else {
914                return;
915            };
916            let mut buf = [0u8; 4096];
917            let n = stream.read(&mut buf).await.unwrap_or(0);
918            let _ = tx.send(String::from_utf8_lossy(&buf[..n]).into_owned());
919            let response = format!(
920                "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
921                body.len()
922            );
923            let _ = stream.write_all(response.as_bytes()).await;
924        });
925        (url, rx)
926    }
927
928    #[tokio::test]
929    async fn channel_statuses_asks_each_instance_and_skips_one_that_fails() {
930        let (url, request) = serve_once(
931            r#"{"agents":["Korp"],"now_ms":1,"agent_status":{"Korp":{"state":"working","since_ms":1}},"agent_lines":{"Korp":"Building"}}"#,
932        )
933        .await;
934        let targets = [
935            ChannelStatusTarget {
936                channel: "dev".into(),
937                local_url: url,
938                auth_key: "chan-key".into(),
939            },
940            // Nothing listens on the discard port.
941            ChannelStatusTarget {
942                channel: "dead".into(),
943                local_url: "http://127.0.0.1:9".into(),
944                auth_key: "k".into(),
945            },
946        ];
947        let statuses = channel_statuses(&reqwest::Client::new(), &targets).await;
948        assert_eq!(statuses.len(), 1, "{statuses:?}");
949        assert_eq!(statuses["dev"].status.get("korp"), Some(&AgentState::Working));
950        assert_eq!(statuses["dev"].lines.get("korp").map(String::as_str), Some("Building"));
951        let request = request.await.unwrap().to_lowercase();
952        assert!(
953            request.starts_with("get /agentmux/reactive/agent-names "),
954            "{request}"
955        );
956        assert!(
957            request.contains(&format!(
958                "{}: chan-key",
959                agentmux_common::AUTH_KEY_HEADER.to_lowercase()
960            )),
961            "{request}"
962        );
963    }
964}