Skip to main content

agentmux_srv/backend/work_facts/
cross_channel.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Other AgentMux channels' agents' work facts, on this computer: every other
5//! channel's srv in the host-global registry is asked for its own agents'
6//! facts (`GET /api/v1/work-facts`), concurrently, each with its own timeout,
7//! so one dead channel never stalls the caller
8//! (docs/specs/SPEC_AGENT_OVERLAP_AWARENESS_2026_10_10.md §3.2, §3.3). Used by
9//! `WhoIsWorkingOn`, `ListConversations` and the overlap notes.
10
11use std::collections::HashMap;
12use std::time::Duration;
13
14use super::WorkFacts;
15
16/// Per-channel timeout: one dead channel must not stall the answer.
17pub const TIMEOUT: Duration = Duration::from_millis(1500);
18
19/// The AgentMux channel this srv runs in (`stable`, `dev`, ...).
20pub fn own_channel() -> String {
21    std::env::var("AGENTMUX_CHANNEL").unwrap_or_else(|_| "stable".to_string())
22}
23
24/// Another AgentMux channel's srv on this computer.
25struct Peer {
26    local_url: String,
27    auth_key: String,
28    channel: String,
29}
30
31/// Every other channel's srv in the host-global registry, once each.
32/// `own_url` is this srv's own local URL, never asked.
33fn other_channels(own_url: &str) -> Vec<Peer> {
34    let own = own_channel();
35    let Some(shared_dir) = crate::registry::resolve_shared_reactive_dir() else {
36        return Vec::new();
37    };
38    let mut seen: HashMap<String, Peer> = HashMap::new();
39    for e in crate::backend::reactive::registry::list_all_shared(&shared_dir) {
40        if e.channel == own || e.local_url == own_url || e.auth_key.is_empty() {
41            continue;
42        }
43        seen.entry(e.local_url.clone()).or_insert(Peer {
44            local_url: e.local_url,
45            auth_key: e.auth_key,
46            channel: e.channel,
47        });
48    }
49    seen.into_values().collect()
50}
51
52/// Every other channel's agents' facts, fetched concurrently, plus the
53/// channels that didn't answer in time.
54pub async fn fetch(client: &reqwest::Client, own_url: &str) -> (Vec<WorkFacts>, Vec<String>) {
55    let mut set = tokio::task::JoinSet::new();
56    for peer in other_channels(own_url) {
57        let client = client.clone();
58        set.spawn(async move {
59            let fetch = client
60                .get(format!("{}/api/v1/work-facts", peer.local_url.trim_end_matches('/')))
61                .header(agentmux_common::AUTH_KEY_HEADER, &peer.auth_key)
62                .send();
63            let body = match tokio::time::timeout(TIMEOUT, fetch).await {
64                Ok(Ok(resp)) if resp.status().is_success() => {
65                    tokio::time::timeout(TIMEOUT, resp.json::<serde_json::Value>()).await.ok().and_then(Result::ok)
66                }
67                _ => None,
68            };
69            let agents: Option<Vec<WorkFacts>> =
70                body.and_then(|b| serde_json::from_value(b.get("agents")?.clone()).ok());
71            (peer.channel, agents)
72        });
73    }
74    let (mut facts, mut unreachable) = (Vec::new(), Vec::new());
75    while let Some(res) = set.join_next().await {
76        match res {
77            Ok((_, Some(agents))) => facts.extend(agents),
78            Ok((channel, None)) => unreachable.push(channel),
79            Err(_) => {}
80        }
81    }
82    unreachable.sort();
83    (facts, unreachable)
84}