Skip to main content

agentmux_srv/muxbus/
cloud_subscriber.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Cloud push subscription — replaces per-agent polling with a single
5//! sidecar-level WebSocket to muxbus-ws.agentmux.ai.
6//!
7//! When MUXBUS_TOKEN is present, the subscriber:
8//!   1. Opens wss://muxbus-ws.agentmux.ai with the bearer token.
9//!   2. Listens for { type: "inject_available" } broadcast wake signals (zero metadata).
10//!   3. On each signal, pulls pending injections for every locally-registered
11//!      agent: one POST /reactive/pending-batch for all of them, or one
12//!      GET /reactive/pending/:id per agent on a relay without the batch route.
13//!   4. Claims all pending injections via REST POST /reactive/ack (atomic
14//!      pending->delivered transition server-side) *before* delivering — only
15//!      ids this call actually won the claim on get delivered via
16//!      ReactiveHandler. A claimed injection that fails local delivery is
17//!      released back to pending via REST POST /reactive/release so it's
18//!      retried, rather than silently dropped.
19//!   5. Reconnects with exponential back-off on any disconnect.
20//!
21//! The server broadcasts to ALL connected sidecars on every injection, so a subscriber
22//! cannot correlate the signal to any particular agent, account, or timing.
23//!
24//! Agents register/unregister via add_agent()/remove_agent().
25//! No MUXBUS_TOKEN → subscriber stays idle.
26
27use std::collections::HashSet;
28use std::sync::{Arc, Mutex, OnceLock};
29use std::time::Duration;
30
31use futures_util::{SinkExt, StreamExt};
32use serde::{Deserialize, Serialize};
33use tokio::sync::mpsc;
34use tokio_tungstenite::{connect_async_tls_with_config, tungstenite::Message};
35
36use crate::backend::reactive::handler::get_global_handler;
37use crate::backend::reactive::types::InjectionRequest;
38use crate::backend::storage::store::Store;
39use crate::broker::RefreshErrorKind;
40use crate::muxbus::delivery_status;
41
42mod delivery;
43use delivery::{claim_and_deliver, PendingResp};
44
45// The relay's WebSocket endpoint has its own domain, and the handshake is at
46// the domain root: no path suffix. The relay side is designed in the private
47// cloud repo.
48/// Fallback when the cloud publishes no settings (`discovery::ws_url`).
49pub(crate) const MUXBUS_WS_URL: &str = "wss://muxbus-ws.agentmux.ai";
50pub(crate) const MUXBUS_REST_URL: &str = "https://muxbus.agentmux.ai";
51const RECONNECT_DELAY_SECS: u64 = 5;
52const MAX_RECONNECT_DELAY_SECS: u64 = 60;
53// How often a loop parked on a stale sign-in re-reads the stored credential
54// (no network beyond the cached discovery document) in case another channel
55// sharing the store signed in.
56const STALE_RECHECK_SECS: u64 = 60;
57// The relay drops a WebSocket that has been silent in both directions for
58// 10 minutes and sends no keepalive of its own. Ping well under that so a
59// quiet connection (no inject_available traffic) survives indefinitely. The
60// ping is an app-level ClientMsg::Ping frame, not a WS-protocol-level
61// Message::Ping — raw protocol ping/pong control frames are not reliably
62// relayed, so a normal data frame is what actually keeps the connection
63// alive in production (see ClientMsg::Ping's doc comment).
64const CLIENT_PING_INTERVAL_SECS: u64 = 240;
65// How often the broker's background sweep re-checks the stored MuxBus
66// credential and proactively refreshes it if it's nearing expiry — now the
67// SOLE proactive-refresh trigger (see `run_loop`'s registration call and the
68// broker module doc); replaces what used to be two independent, uncoordinated
69// checks (the outer reconnect loop's `load_valid_token`, and an inline check
70// on every WS ping tick). Cheap (one DB read) when not actually stale, so a
71// short interval is fine.
72const BROKER_SWEEP_INTERVAL_SECS: u64 = 60;
73
74// Max age (seconds) a reagent-signed WAN jekt's `reagent_ts_secs` may be
75// from "now" and still verify — anti-replay, same purpose as
76// server/reactive.rs's JEKT_SIG_MAX_AGE_SECS for host-tier (reagentx P1 on
77// PR #2570). Wider than host-tier's 300s because this covers real network
78// delivery latency, not a same-machine call — matches the review
79// notifications' own delivery window.
80const REAGENT_SIG_MAX_AGE_SECS: i64 = 600;
81
82use agentmux_common::time::now_ms;
83use agentmux_common::time::now_secs as now_unix_secs;
84
85/// Is `ts_secs` (a reagent signature's claimed signing time) within
86/// `REAGENT_SIG_MAX_AGE_SECS` of `now`? Extracted as a pure function (takes
87/// `now` explicitly rather than calling `now_unix_secs()` itself) so it's
88/// unit-testable without mocking the system clock. `ts_secs <= 0` is never
89/// fresh — a zero/negative timestamp only happens for a malformed or
90/// missing claim, never a genuine signature.
91fn reagent_sig_is_fresh(ts_secs: i64, now: i64) -> bool {
92    ts_secs > 0 && (now - ts_secs).abs() <= REAGENT_SIG_MAX_AGE_SECS
93}
94
95// ── Wire protocol ─────────────────────────────────────────────────────────────
96
97#[derive(Serialize)]
98#[serde(tag = "type", rename_all = "snake_case")]
99enum ClientMsg {
100    Subscribe { agents: Vec<String> },
101    #[serde(rename = "subscribe:add")]
102    SubscribeAdd { agents: Vec<String> },
103    #[serde(rename = "subscribe:remove")]
104    SubscribeRemove { agents: Vec<String> },
105    // Ack removed — ACK is sent via REST POST /reactive/ack, not WS
106    // App-level keepalive — see CLIENT_PING_INTERVAL_SECS. Raw WS-protocol
107    // Ping/Pong control frames are not reliably relayed in production, so the
108    // keepalive must be a normal data frame the relay can see and reply to.
109    Ping,
110}
111
112#[derive(Deserialize)]
113#[serde(tag = "type", rename_all = "snake_case")]
114enum ServerMsg {
115    // Zero-metadata wake signal. The sidecar polls all its registered agents on receipt.
116    // No agent_id, no injection_id — subscribers gain no information from this frame.
117    InjectAvailable,
118    Subscribed {
119        #[allow(dead_code)]
120        agents: Vec<String>,
121    },
122    Pong,
123    Error {
124        message: String,
125    },
126    Evicted {
127        #[allow(dead_code)]
128        agents: Vec<String>,
129    },
130    #[serde(other)]
131    Unknown,
132}
133
134// ── Control channel ───────────────────────────────────────────────────────────
135
136enum CtrlMsg {
137    AddAgent(String),
138    RemoveAgent(String),
139    ReloadToken,
140}
141
142// ── Public struct ─────────────────────────────────────────────────────────────
143
144pub struct CloudSubscriber {
145    ctrl_tx: mpsc::UnboundedSender<CtrlMsg>,
146    /// In-memory copy of subscribed agents, kept in sync with the WS loop
147    /// so add_agent/remove_agent can be called before the WS connects.
148    agents: Arc<Mutex<HashSet<String>>>,
149}
150
151static GLOBAL_SUBSCRIBER: OnceLock<CloudSubscriber> = OnceLock::new();
152
153pub fn get_global_subscriber() -> Option<&'static CloudSubscriber> {
154    GLOBAL_SUBSCRIBER.get()
155}
156
157impl CloudSubscriber {
158    /// Initialize the global subscriber and start the background WS loop.
159    /// Should be called once at startup. No-op if already initialized.
160    pub fn init_global(mstore: Arc<Store>) {
161        let (ctrl_tx, ctrl_rx) = mpsc::unbounded_channel::<CtrlMsg>();
162        let agents = Arc::new(Mutex::new(HashSet::<String>::new()));
163        // Agents that registered before this subscriber existed (an isolated
164        // channel creates it lazily at `muxbus.login`) were never told to it.
165        let handler = get_global_handler();
166        let registered: Vec<String> = handler
167            .list_agents()
168            .into_iter()
169            .map(|r| r.agent_id)
170            .collect();
171        let seed = agents_to_seed(&registered, |a| handler.has_live_name(a));
172        if !seed.is_empty() {
173            tracing::info!(
174                count = seed.len(),
175                "cloud_subscriber: subscribing agents that registered before it started"
176            );
177            agents.lock().unwrap().extend(seed);
178        }
179        let subscriber = CloudSubscriber {
180            ctrl_tx,
181            agents: agents.clone(),
182        };
183        if GLOBAL_SUBSCRIBER.set(subscriber).is_err() {
184            return; // already initialized
185        }
186        tokio::spawn(run_loop(mstore, agents, ctrl_rx));
187    }
188
189    /// Notify the WS loop that a new agent is registered locally.
190    /// Normalizes to lowercase and skips the WS send if already subscribed,
191    /// so calling this on every COMMAND_AGENT_INPUT turn is safe.
192    pub fn add_agent(&self, agent_id: &str) {
193        let key = agent_id.to_lowercase();
194        let mut agents = self.agents.lock().unwrap();
195        if agents.contains(&key) {
196            return;
197        }
198        agents.insert(key.clone());
199        drop(agents);
200        let _ = self.ctrl_tx.send(CtrlMsg::AddAgent(key));
201    }
202
203    /// Notify the WS loop that an agent has been unregistered.
204    pub fn remove_agent(&self, agent_id: &str) {
205        let key = agent_id.to_lowercase();
206        self.agents.lock().unwrap().remove(&key);
207        let _ = self.ctrl_tx.send(CtrlMsg::RemoveAgent(key));
208    }
209
210    /// Unsubscribe `agent_id` if no live block holds it (`is_live`), and
211    /// say whether it did. The check, the removal and the `RemoveAgent`
212    /// send all happen under the `agents` lock, which `add_agent` also
213    /// takes: a respawn either registered first (the check sees it live and
214    /// nothing is dropped) or its `add_agent` waits, then finds the key gone
215    /// and queues `AddAgent` after this `RemoveAgent` — the WS loop applies
216    /// them in order and it ends subscribed (Codex P2 ×2 on #3897).
217    /// `is_live` must not take the `agents` lock; it may take the reactive
218    /// handler's (which never takes this one).
219    pub fn drop_if_orphaned(&self, agent_id: &str, is_live: impl Fn(&str) -> bool) -> bool {
220        let key = agent_id.to_lowercase();
221        let mut agents = self.agents.lock().unwrap();
222        if !agents.contains(&key) || is_live(&key) {
223            return false;
224        }
225        agents.remove(&key);
226        let _ = self.ctrl_tx.send(CtrlMsg::RemoveAgent(key));
227        true
228    }
229
230    /// [`release_agent_now`]'s body, with the relay and token injectable.
231    /// The removal and its `RemoveAgent` are queued together under the
232    /// `agents` lock, like [`Self::remove_agent`] — so an agent re-added
233    /// while the release is awaited queues `AddAgent` after it and ends
234    /// subscribed (ReAgent P1 on #3899). The awaited release then runs
235    /// under `wan_lease`'s per-agent lock, serialized with the RemoveAgent
236    /// arm's own: when this returns, whichever of the two ran has finished,
237    /// and neither releases an agent that was subscribed again.
238    async fn release_now(
239        &self,
240        agent_id: &str,
241        base: &str,
242        token: impl std::future::Future<Output = String>,
243    ) -> RelayRelease {
244        let key = agent_id.to_lowercase();
245        let was_subscribed = {
246            let mut agents = self.agents.lock().unwrap();
247            let removed = agents.remove(&key);
248            if removed {
249                let _ = self.ctrl_tx.send(CtrlMsg::RemoveAgent(key.clone()));
250            }
251            removed
252        };
253        // Not subscribed is not "no lease" (Codex P1 on #3908): a stopped
254        // pane's exit may already have unsubscribed it and queued a release
255        // that runs detached. Release here too — idempotent, and under the
256        // per-agent op lock, so this also waits for a release in flight.
257        let held = super::wan_lease::is_held(&key);
258        if !was_subscribed && !held {
259            // Wait out a release already in flight; nothing is held here.
260            super::wan_lease::release(base, &key, "", &reqwest::Client::new(), || true).await;
261            return RelayRelease::NotSubscribed;
262        }
263        let token = token.await;
264        let still_gone = || !self.agents.lock().unwrap().contains(&key);
265        // Re-added meanwhile: then this srv kept the lease.
266        if super::wan_lease::release(base, &key, &token, &reqwest::Client::new(), still_gone).await {
267            RelayRelease::Released
268        } else {
269            RelayRelease::Kept
270        }
271    }
272
273    /// Called after muxbus.login completes — trigger a fresh WS connection
274    /// with the newly stored token.
275    pub fn reload_token(&self) {
276        let _ = self.ctrl_tx.send(CtrlMsg::ReloadToken);
277    }
278
279    /// Snapshot of the agents this sidecar has subscribed to the cloud relay
280    /// (Tier-4), sorted for stable output. Empty when no MUXBUS_TOKEN is set
281    /// (cloud disabled). Read-only; used by the discovery endpoint.
282    pub fn subscribed_agents(&self) -> Vec<String> {
283        let mut v: Vec<String> = self.agents.lock().unwrap().iter().cloned().collect();
284        v.sort();
285        v
286    }
287}
288
289// ── Background loop ───────────────────────────────────────────────────────────
290
291async fn run_loop(
292    mstore: Arc<Store>,
293    agents: Arc<Mutex<HashSet<String>>>,
294    mut ctrl_rx: mpsc::UnboundedReceiver<CtrlMsg>,
295) {
296    let http = reqwest::Client::builder()
297        .user_agent(super::relay::USER_AGENT)
298        .build()
299        .unwrap_or_default();
300    let mut delay_secs = RECONNECT_DELAY_SECS;
301
302    // Register this credential with the global broker scheduler once, up
303    // front. The broker's own background sweep (spawned inside
304    // `crate::broker::init_global`) now owns proactive refresh entirely —
305    // it keeps the stored token fresh even while this WS session is
306    // disconnected/backing off, which the old ping-tick-only check could not.
307    let scheduler = crate::broker::init_global(Duration::from_secs(BROKER_SWEEP_INTERVAL_SECS));
308    {
309        let mstore_fresh = mstore.clone();
310        let mstore_refresh = mstore.clone();
311        let http_refresh = http.clone();
312        scheduler
313            .register(
314                crate::muxbus::CREDENTIAL_ID,
315                // muxbus_is_fresh, not muxbus_load — reagent P2 on #2260:
316                // register()'s own contract requires is_fresh to be
317                // side-effect-free (called under the per-id lock on every
318                // ensure_fresh/sweep tick), but muxbus_load's lazy-migration
319                // branch can perform a keychain write + SQL update for a
320                // legacy row. muxbus_is_fresh shares the same read logic
321                // with that branch disabled.
322                //
323                // spawn_blocking, not a direct call — reagent P1 on #2260:
324                // muxbus_is_fresh does a synchronous OS-keychain read, which
325                // can hang on a slow/unresponsive Secret Service D-Bus
326                // daemon (a scenario headless Linux specifically has to
327                // handle). Without this, that hang stalls the tokio worker
328                // thread `run_sweep_loop` runs on, and via the scheduler's
329                // held per-id lock, can block OTHER credentials' refreshes
330                // too — the exact convention this codebase already applies
331                // to keychain reads elsewhere (see
332                // app_api/mod.rs's account_validate_impl).
333                move || {
334                    let mstore = mstore_fresh.clone();
335                    Box::pin(async move {
336                        tokio::task::spawn_blocking(move || mstore.muxbus_is_fresh())
337                            .await
338                            .unwrap_or(false)
339                    })
340                },
341                move || {
342                    let mstore = mstore_refresh.clone();
343                    let http = http_refresh.clone();
344                    Box::pin(async move {
345                        // Held until this attempt ends, across the token request.
346                        let lock_store = mstore.clone();
347                        let _leader = tokio::task::spawn_blocking(move || lock_store.muxbus_refresh_lock())
348                            .await
349                            .map_err(|e| RefreshErrorKind::Transient(format!("refresh lock task: {e}")))?
350                            .map_err(|e| RefreshErrorKind::Transient(e.to_string()))?;
351                        let load_store = mstore.clone();
352                        let current = tokio::task::spawn_blocking(move || load_store.muxbus_load())
353                            .await
354                            .map_err(|e| RefreshErrorKind::Transient(format!("muxbus_load task: {e}")))?
355                            .map_err(|e| RefreshErrorKind::Transient(e.to_string()))?
356                            // Nothing stored at all — not a transient storage
357                            // blip, there's genuinely no credential to refresh;
358                            // only a fresh login produces one.
359                            .ok_or_else(|| {
360                                RefreshErrorKind::PermanentAuthFailure(
361                                    "no muxbus credentials stored".to_string(),
362                                )
363                            })?;
364                        // Another process refreshed while this one waited.
365                        if current.is_valid() && !current.nearly_expired() {
366                            return Ok(());
367                        }
368                        if current.refresh_token.is_empty() {
369                            // Same reasoning: no refresh_token will ever
370                            // appear on its own — only re-login fixes it.
371                            return Err(RefreshErrorKind::PermanentAuthFailure(
372                                "no refresh_token stored".to_string(),
373                            ));
374                        }
375                        // Issued by a client the cloud no longer publishes:
376                        // its pool is gone, so don't refresh against it
377                        // (SPEC_CLOUD_SETTINGS_DISCOVERY_2026_09_27.md §3.3).
378                        let discovered = crate::muxbus::discovery::cloud_settings(&http).await;
379                        if crate::muxbus::discovery::client_id_superseded(discovered.as_ref(), &current.client_id) {
380                            return Err(RefreshErrorKind::PermanentAuthFailure(
381                                "signed in to a user pool AgentMux Cloud no longer uses".to_string(),
382                            ));
383                        }
384                        // Preserve-on-failure: `refresh_token` returning Err
385                        // here means `muxbus_save` is simply never called —
386                        // the last-known-good credential in the store is
387                        // left untouched rather than overwritten with a
388                        // failed/partial result.
389                        let refreshed = crate::muxbus::pkce::refresh_token(&current, &http)
390                            .await
391                            .map_err(classify_refresh_token_error)?;
392                        if !refreshed_same_account(&current, &refreshed) {
393                            return Err(RefreshErrorKind::PermanentAuthFailure(
394                                "the refreshed sign-in belongs to another account".to_string(),
395                            ));
396                        }
397                        let save_store = mstore.clone();
398                        tokio::task::spawn_blocking(move || save_store.muxbus_save(&refreshed))
399                            .await
400                            .map_err(|e| RefreshErrorKind::Transient(format!("muxbus_save task: {e}")))?
401                            .map_err(|e| RefreshErrorKind::Transient(e.to_string()))
402                    })
403                },
404            )
405            .await;
406    }
407    // The presence publisher reads the sign-in through this registration. A
408    // first login on a fresh channel signals it before the registration above
409    // has run, so signal again now that it has (#4463).
410    crate::muxbus::wan_presence::sign_in_changed();
411
412    // The stale sign-in reason last logged, so a parked loop logs it once.
413    let mut stale_logged: Option<String> = None;
414
415    loop {
416        // Load token — refresh if expired.
417        // Two distinct None cases:
418        //   a) No credentials in DB at all → wait for muxbus.login ReloadToken signal
419        //   b) Credentials exist but refresh failed transiently → back off and retry
420        // has_stored_creds = true only when a retry is meaningful:
421        //   - access_token present AND still valid (use it now), OR
422        //   - access_token expired but refresh_token present (can refresh).
423        // An expired token with no refresh_token is a permanent failure → park like no-creds.
424        //
425        // A real load ERROR (keychain locked/unavailable, distinct from
426        // muxbus_load's Ok(None) "genuinely nothing stored" — see that
427        // function's own reagent-fixed error handling) must NOT collapse to
428        // has_stored_creds=false: that would park indefinitely waiting for a
429        // muxbus.login the user was never actually missing, instead of
430        // backing off and retrying once the transient failure clears.
431        // spawn_blocking — reagent P1 on #2260: muxbus_load does a
432        // synchronous OS-keychain read; a hang there (slow/unresponsive
433        // Secret Service D-Bus daemon) must not stall this loop's tokio
434        // worker thread.
435        let has_stored_creds_load = {
436            let mstore = mstore.clone();
437            tokio::task::spawn_blocking(move || mstore.muxbus_load()).await
438        };
439        let nothing_stored = matches!(has_stored_creds_load, Ok(Ok(None)));
440        let (has_stored_creds, stale) = match has_stored_creds_load {
441            Ok(Ok(Some(c))) => {
442                // A torn sign-in keeps only its refresh token; it is still a
443                // sign-in this channel had (and repairs on refresh).
444                if !c.access_token.is_empty() || !c.refresh_token.is_empty() {
445                    delivery_status::saw_session(Some(c.user_email.clone()));
446                }
447                (usable_or_refreshable(&c), crate::muxbus::stale_sign_in(&c, &http).await)
448            }
449            Ok(Ok(None)) => (false, None),
450            Ok(Err(e)) => {
451                tracing::warn!(error = %e, "cloud_subscriber: muxbus_load failed — assuming credentials exist and retrying with backoff");
452                delivery_status::saw_session(None);
453                (true, None)
454            }
455            Err(e) => {
456                tracing::warn!(error = %e, "cloud_subscriber: muxbus_load task panicked — assuming credentials exist and retrying with backoff");
457                delivery_status::saw_session(None);
458                (true, None)
459            }
460        };
461        // A sign-in that can't work anymore: stop reconnecting and wait for a
462        // new one (SPEC_CLOUD_SETTINGS_DISCOVERY_2026_09_27.md §3.3).
463        // `muxbus.status` reports it to the UI as "Sign in again". Rechecked
464        // now and then from local state only, for a sign-in made by another
465        // channel sharing the store (which sends no ReloadToken here).
466        if let Some(reason) = stale {
467            delivery_status::set_link(delivery_status::Link::SignInStale(reason.clone()));
468            if stale_logged.as_deref() != Some(reason.as_str()) {
469                tracing::warn!(reason = %reason, "cloud_subscriber: AgentMux Cloud sign-in needs renewing — not reconnecting until the user signs in again");
470                stale_logged = Some(reason);
471            }
472            wait_for_sign_in(&mut ctrl_rx, Duration::from_secs(STALE_RECHECK_SECS)).await;
473            delay_secs = RECONNECT_DELAY_SECS;
474            continue;
475        }
476        stale_logged = None;
477        // Nothing stored: wait for a sign-in. Rechecked now and then, since a
478        // sign-in by another process of this channel sends no ReloadToken.
479        if nothing_stored {
480            delivery_status::set_link(delivery_status::Link::NoSignIn);
481            wait_for_sign_in(&mut ctrl_rx, Duration::from_secs(STALE_RECHECK_SECS)).await;
482            delay_secs = RECONNECT_DELAY_SECS;
483            continue;
484        }
485        let token = match load_valid_token(&mstore, &scheduler).await {
486            Some(t) => t,
487            None if !has_stored_creds => {
488                // Nothing usable: the broker now knows (NeedsReauth), and the
489                // stale check above parks the next pass until a sign-in.
490                delivery_status::set_link(delivery_status::Link::NoSignIn);
491                wait_for_sign_in(&mut ctrl_rx, Duration::from_secs(STALE_RECHECK_SECS)).await;
492                delay_secs = RECONNECT_DELAY_SECS;
493                continue;
494            }
495            None => {
496                // Credentials present but refresh failed transiently — back off and retry.
497                // Loop inside the select! so AddAgent/RemoveAgent messages drain without
498                // cancelling the sleep; only ReloadToken (or sleep expiry) breaks out.
499                tracing::warn!(
500                    "cloud_subscriber: token refresh failed, retrying in {}s",
501                    delay_secs
502                );
503                delivery_status::set_link(delivery_status::Link::Unreachable(
504                    "couldn't refresh the sign-in".to_string(),
505                ));
506                let sleep = tokio::time::sleep(Duration::from_secs(delay_secs));
507                tokio::pin!(sleep);
508                'backoff: loop {
509                    tokio::select! {
510                        _ = &mut sleep => break 'backoff,
511                        Some(msg) = ctrl_rx.recv() => match msg {
512                            CtrlMsg::ReloadToken => {
513                                delay_secs = RECONNECT_DELAY_SECS;
514                                break 'backoff;
515                            }
516                            CtrlMsg::AddAgent(_) | CtrlMsg::RemoveAgent(_) => {
517                                // agents mutex already updated; continue sleeping
518                            }
519                        }
520                    }
521                }
522                delay_secs = (delay_secs * 2).min(MAX_RECONNECT_DELAY_SECS);
523                continue;
524            }
525        };
526
527        let session_start = std::time::Instant::now();
528
529        match connect_and_run(&token, agents.clone(), &mut ctrl_rx, &mstore, &http).await {
530            Ok(()) => {
531                // Apply the same >30s healthy-session guard as the error branch: a server
532                // that immediately closes after handshake must not suppress back-off.
533                if session_start.elapsed().as_secs() > 30 {
534                    delay_secs = RECONNECT_DELAY_SECS;
535                }
536                tracing::info!("cloud_subscriber: disconnected cleanly");
537                delivery_status::set_link(delivery_status::Link::Unreachable("disconnected".to_string()));
538            }
539            Err(e) => {
540                // If the session was healthy for >30s before the error, treat it as
541                // network instability rather than a persistent failure and reset back-off.
542                if session_start.elapsed().as_secs() > 30 {
543                    delay_secs = RECONNECT_DELAY_SECS;
544                }
545                tracing::warn!("cloud_subscriber: error: {e}, reconnecting in {}s", delay_secs);
546                delivery_status::set_link(delivery_status::Link::Unreachable(e));
547            }
548        }
549
550        // Post-disconnect back-off. Loop inside the select! so AddAgent/RemoveAgent
551        // messages drain without cancelling the sleep; only ReloadToken breaks out early.
552        let sleep = tokio::time::sleep(Duration::from_secs(delay_secs));
553        tokio::pin!(sleep);
554        'reconnect: loop {
555            tokio::select! {
556                _ = &mut sleep => break 'reconnect,
557                Some(msg) = ctrl_rx.recv() => match msg {
558                    CtrlMsg::ReloadToken => {
559                        delay_secs = RECONNECT_DELAY_SECS;
560                        break 'reconnect;
561                    }
562                    CtrlMsg::AddAgent(_) | CtrlMsg::RemoveAgent(_) => {
563                        // agents mutex already updated; continue sleeping
564                    }
565                }
566            }
567        }
568        delay_secs = (delay_secs * 2).min(MAX_RECONNECT_DELAY_SECS);
569    }
570}
571
572/// A loaded sign-in that can be used now or refreshed: a valid access token,
573/// or a refresh token (all a torn sign-in keeps, see `storage::muxbus`).
574fn usable_or_refreshable(c: &crate::muxbus::MuxBusCredentials) -> bool {
575    c.is_valid() || !c.refresh_token.is_empty()
576}
577
578/// Whether a refresh came back for the account the saved sign-in names. A
579/// torn set could pair a refresh token with another account's row; a row
580/// with no account recorded (an older sign-in) can't be checked.
581fn refreshed_same_account(current: &crate::muxbus::MuxBusCredentials, refreshed: &crate::muxbus::MuxBusCredentials) -> bool {
582    if current.user_sub.is_empty() {
583        return true;
584    }
585    let sub = crate::muxbus::pkce::token_sub(&refreshed.id_token);
586    let sub = if sub.is_empty() { crate::muxbus::pkce::token_sub(&refreshed.access_token) } else { sub };
587    sub == current.user_sub
588}
589
590/// Park until a sign-in (`ReloadToken`) or `recheck` elapses, whichever comes
591/// first. AddAgent/RemoveAgent drain without waking it: they already updated
592/// the `agents` set the next connection subscribes from.
593async fn wait_for_sign_in(ctrl_rx: &mut mpsc::UnboundedReceiver<CtrlMsg>, recheck: Duration) {
594    let sleep = tokio::time::sleep(recheck);
595    tokio::pin!(sleep);
596    loop {
597        tokio::select! {
598            _ = &mut sleep => return,
599            msg = ctrl_rx.recv() => match msg {
600                Some(CtrlMsg::ReloadToken) => return,
601                Some(CtrlMsg::AddAgent(_) | CtrlMsg::RemoveAgent(_)) => {}
602                // Closed: nothing can wake us early; wait out the recheck.
603                None => {
604                    (&mut sleep).await;
605                    return;
606                }
607            }
608        }
609    }
610}
611
612async fn connect_and_run(
613    token: &str,
614    agents: Arc<Mutex<HashSet<String>>>,
615    ctrl_rx: &mut mpsc::UnboundedReceiver<CtrlMsg>,
616    mstore: &Arc<Store>,
617    http: &reqwest::Client,
618) -> Result<(), String> {
619    use tokio_tungstenite::tungstenite::ClientRequestBuilder;
620
621    // Where the REST calls of this session go: the relay's pending pulls,
622    // acks and releases, and the WAN agent lease. Resolved once per session.
623    let base = super::relay::rest_base_url();
624    let base = base.as_str();
625
626    // Issue #2091: a hand-built `http::Request` (as this used to be, via
627    // `Request::builder()...body(())`) is NOT auto-completed by tungstenite.
628    // `IntoClientRequest for Request` is a bare passthrough (`Ok(self)`) —
629    // the Host/Connection/Upgrade/Sec-WebSocket-Version/Sec-WebSocket-Key
630    // headers this comment used to claim were "added automatically" are only
631    // generated when tungstenite builds the request itself from a bare
632    // `Uri`/`&str`/`String` (`impl IntoClientRequest for Uri`, which calls
633    // `generate_key()`). A hand-built Request with only `Authorization` set
634    // therefore had no `Sec-WebSocket-Key` at all, and tungstenite's own
635    // `generate_request()` requires it to already be present — every
636    // connection attempt failed handshake validation on its OWN outgoing
637    // request before a single byte reached the network, logged as "Missing,
638    // duplicated or incorrect header sec-websocket-key". This has been
639    // broken since the file's first commit; reproduced in isolation
640    // (a `ws_probe` example, removed after confirming the fix) and
641    // confirmed fixed by switching to `ClientRequestBuilder` — tungstenite's
642    // own purpose-built API for "URI + extra headers": it builds the request
643    // from the `Uri` (generating the required headers, including a fresh
644    // `Sec-WebSocket-Key`) and layers `with_header` calls on top, so nothing
645    // required is ever missing.
646    // The cloud's published WebSocket URL, else the compiled one
647    // (docs/specs/SPEC_CLOUD_SETTINGS_DISCOVERY_2026_09_27.md).
648    let ws_url = super::discovery::ws_url(http).await;
649    tracing::info!("cloud_subscriber: connecting to {}", ws_url);
650    let uri: tokio_tungstenite::tungstenite::http::Uri =
651        ws_url.parse().map_err(|e| format!("parse url: {e}"))?;
652    let request = ClientRequestBuilder::new(uri)
653        .with_header("Authorization", format!("Bearer {}", token));
654
655    let (ws_stream, _) = connect_async_tls_with_config(request, None, false, None)
656        .await
657        .map_err(|e| format!("connect: {e}"))?;
658
659    tracing::info!("cloud_subscriber: WebSocket connected");
660    delivery_status::set_link(delivery_status::Link::Connected);
661    let (mut write, mut read) = ws_stream.split();
662
663    // Initial subscription for all currently-registered agents
664    let initial_agents: Vec<String> = agents.lock().unwrap().iter().cloned().collect();
665    let sub_msg = serde_json::to_string(&ClientMsg::Subscribe { agents: initial_agents })
666        .map_err(|e| format!("serialize subscribe: {e}"))?;
667    write.send(Message::Text(sub_msg.into()))
668        .await
669        .map_err(|e| format!("send subscribe: {e}"))?;
670
671    // Pull anything that reached the relay before this connection existed, or
672    // before its agents were subscribed: the relay only wakes us for what
673    // arrives from now on. A wake is a best-effort broadcast, and this loop
674    // pulled only on one (CATCH_UP_EVERY, `agents_to_seed`).
675    if catch_up(base, token, http, &agents, mstore).await {
676        return Ok(());
677    }
678
679    let mut ping_interval = tokio::time::interval(Duration::from_secs(CLIENT_PING_INTERVAL_SECS));
680    // Keeps every registered agent's relay lease renewed between wake
681    // signals (the relay's lease TTL is 60 s; wakes can be far apart).
682    let mut lease_interval = tokio::time::interval(super::wan_lease::RENEW_EVERY);
683    lease_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
684    ping_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
685    ping_interval.tick().await; // first tick fires immediately — consume it, we just connected
686    // The safety net under the wake signal: pull on a timer too.
687    let mut catch_up_interval = tokio::time::interval(CATCH_UP_EVERY);
688    catch_up_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
689    catch_up_interval.tick().await; // the connect-time pull above was the first
690
691    loop {
692        tokio::select! {
693            // Keepalive — see CLIENT_PING_INTERVAL_SECS. Proactive credential
694            // freshness is no longer piggybacked on this tick: the broker's
695            // own background sweep (registered once in `run_loop`, see
696            // `crate::broker`) now keeps `db_muxbus_credentials` fresh on its
697            // own schedule regardless of whether a WS session happens to be
698            // open, so a long-lived session no longer needs to special-case
699            // its own credential check here.
700            _ = lease_interval.tick() => {
701                let mut registered: Vec<String> = agents.lock().unwrap().iter().cloned().collect();
702                // Never renew a lease for an agent no live block holds: drop
703                // the leaked subscription instead. A drop goes through the
704                // RemoveAgent arm below, which unsubscribes and releases the
705                // lease; `drop_if_orphaned` re-checks liveness so a respawn
706                // registering meanwhile keeps its subscription.
707                let handler = get_global_handler();
708                let orphans = orphaned_subscriptions(&registered, |a| handler.has_live_name(a));
709                if !orphans.is_empty() {
710                    if let Some(sub) = get_global_subscriber() {
711                        for agent_id in &orphans {
712                            if sub.drop_if_orphaned(agent_id, |a| handler.has_live_name(a)) {
713                                tracing::warn!(
714                                    agent = %agent_id,
715                                    "cloud_subscriber: subscribed agent has no live pane — dropped it and released its WAN lease"
716                                );
717                            }
718                        }
719                    }
720                    registered.retain(|a| !orphans.contains(a));
721                }
722                let subscribed = &agents;
723                // One request renews every held lease that is due
724                // (`renew_held_batch`); anything it leaves out, and every
725                // agent on a relay without the batch route, goes one by one.
726                let shared = shared_token_now(mstore, token).await;
727                let batched = super::wan_lease::renew_held_batch(base, &registered, &shared, http, |a| {
728                    subscribed.lock().unwrap().iter().any(|s| s.eq_ignore_ascii_case(a))
729                })
730                .await;
731                for (agent_id, outcome) in &batched {
732                    if let super::wan_lease::Outcome::HeldElsewhere(where_) = outcome {
733                        crate::backend::agent_admission::fence_agent_named(agent_id, where_);
734                    }
735                }
736                registered.retain(|a| !batched.contains_key(&a.trim().to_lowercase()));
737                futures_util::future::join_all(registered.iter().map(|agent_id| async move {
738                    let per_agent = crate::muxbus::agent_credentials::ensure_agent_credential(agent_id, mstore, http).await;
739                    let agent_token = match per_agent {
740                        Some(t) => t,
741                        None => shared_token_now(mstore, token).await,
742                    };
743                    // Re-checked under the lease's per-agent lock: a Take over
744                    // may have released it since this list was copied.
745                    let still_subscribed = || subscribed.lock().unwrap().contains(agent_id);
746                    if let super::wan_lease::Outcome::HeldElsewhere(where_) =
747                        super::wan_lease::ensure(base, agent_id, &agent_token, http, still_subscribed).await
748                    {
749                        crate::backend::agent_admission::fence_agent_named(agent_id, &where_);
750                    }
751                }))
752                .await;
753            }
754
755            _ = catch_up_interval.tick() => {
756                if catch_up(base, token, http, &agents, mstore).await {
757                    return Ok(());
758                }
759            }
760
761            _ = ping_interval.tick() => {
762                let ping_msg = serde_json::to_string(&ClientMsg::Ping)
763                    .map_err(|e| format!("serialize ping: {e}"))?;
764                if let Err(e) = write.send(Message::Text(ping_msg.into())).await {
765                    return Err(format!("send ping: {e}"));
766                }
767            }
768
769            // Incoming WebSocket message from cloud
770            msg = read.next() => {
771                match msg {
772                    None => return Ok(()), // server closed
773                    Some(Err(e)) => return Err(format!("ws recv: {e}")),
774                    Some(Ok(Message::Text(text))) => {
775                        if let Ok(server_msg) = serde_json::from_str::<ServerMsg>(&text) {
776                            match handle_server_msg(server_msg, base, token, http, &agents, mstore).await {
777                                Ok(()) => {}
778                                // Eviction or expired token — close stream to trigger reconnect.
779                                Err(ref e) if e.starts_with("reconnect:") => {
780                                    tracing::info!("cloud_subscriber: {e}, reconnecting");
781                                    return Ok(());
782                                }
783                                Err(e) => {
784                                    tracing::warn!("cloud_subscriber: handle msg error: {e}");
785                                }
786                            }
787                        }
788                    }
789                    Some(Ok(Message::Ping(data))) => {
790                        let _ = write.send(Message::Pong(data)).await;
791                    }
792                    Some(Ok(Message::Close(_))) => return Ok(()),
793                    Some(Ok(_)) => {} // binary frames etc — ignore
794                }
795            }
796
797            // Control messages from register/unregister calls
798            ctrl = ctrl_rx.recv() => {
799                match ctrl {
800                    None => return Ok(()), // channel closed
801                    Some(CtrlMsg::AddAgent(id)) => {
802                        let msg = serde_json::to_string(&ClientMsg::SubscribeAdd { agents: vec![id] })
803                            .unwrap_or_default();
804                        let _ = write.send(Message::Text(msg.into())).await;
805                        // A message may already be waiting for this agent (it
806                        // reached the relay before the agent was subscribed).
807                        if catch_up(base, token, http, &agents, mstore).await {
808                            return Ok(());
809                        }
810                    }
811                    Some(CtrlMsg::RemoveAgent(id)) => {
812                        // The agent went away here: let another instance have it.
813                        {
814                            let (id, base, token, http, mstore, agents) =
815                                (id.clone(), base.to_string(), token.to_string(), http.clone(), mstore.clone(), agents.clone());
816                            tokio::spawn(async move {
817                                let token = shared_token_now(&mstore, &token).await;
818                                // Skip if it was subscribed again meanwhile: its
819                                // fresh claim must not be released (Codex P2, #3897).
820                                let still_gone = || !agents.lock().unwrap().contains(&id);
821                                super::wan_lease::release(&base, &id, &token, &http, still_gone).await
822                            });
823                        }
824                        let msg = serde_json::to_string(&ClientMsg::SubscribeRemove { agents: vec![id] })
825                            .unwrap_or_default();
826                        let _ = write.send(Message::Text(msg.into())).await;
827                    }
828                    Some(CtrlMsg::ReloadToken) => {
829                        // Close and let the outer loop reconnect with the new token
830                        let _ = write.send(Message::Close(None)).await;
831                        return Ok(());
832                    }
833                }
834            }
835        }
836    }
837}
838
839/// How often the subscriber pulls for every registered agent whether or not a
840/// wake signal arrived. The relay's wake is best-effort (a lost one is
841/// meant to be caught by the client's own poll), but this client had no
842/// poll interval, so a jekt that reached the relay before its agent was
843/// subscribed waited for the next unrelated wake: 8m46s on 2026-09-30. The
844/// lease is already renewed every [`super::wan_lease::RENEW_EVERY`], so a
845/// catch-up costs about one pending GET per agent: roughly 0.5 requests a
846/// minute per agent at this interval.
847const CATCH_UP_EVERY: Duration = Duration::from_secs(120);
848
849/// The agents to put in the subscription when it is created: every locally
850/// registered agent that a live pane still holds, lowercased and deduplicated.
851/// Registered agents used to be missed. On an isolated channel the subscriber
852/// is created lazily at `muxbus.login`, with an empty set, and an agent that
853/// registered earlier (while there was no subscriber to tell) joined only on
854/// its next input turn, so nothing reached it until then.
855fn agents_to_seed(registered: &[String], is_live: impl Fn(&str) -> bool) -> Vec<String> {
856    let mut seed: Vec<String> = registered
857        .iter()
858        .map(|a| a.to_lowercase())
859        .filter(|a| is_live(a))
860        .collect();
861    seed.sort();
862    seed.dedup();
863    seed
864}
865
866/// Pull pending injections for every registered agent now. The same work an
867/// `InjectAvailable` wake does, run on connect, when an agent is added, and on
868/// a timer ([`CATCH_UP_EVERY`]) so a missed wake costs minutes at worst.
869/// Returns true when the session should reconnect (the shared token expired).
870async fn catch_up(
871    base: &str,
872    token: &str,
873    http: &reqwest::Client,
874    agents: &Arc<Mutex<HashSet<String>>>,
875    mstore: &Arc<Store>,
876) -> bool {
877    match handle_server_msg(ServerMsg::InjectAvailable, base, token, http, agents, mstore).await {
878        Ok(()) => false,
879        Err(e) if e.starts_with("reconnect:") => {
880            tracing::info!("cloud_subscriber: {e}, reconnecting");
881            true
882        }
883        Err(e) => {
884            tracing::warn!("cloud_subscriber: catch-up pull error: {e}");
885            false
886        }
887    }
888}
889
890/// Handle a message from the cloud server.
891/// The WS wake signal carries zero metadata — the sidecar polls all its registered
892/// agents via REST to find pending injections, so a compromised subscriber gains nothing.
893async fn handle_server_msg(
894    msg: ServerMsg,
895    base: &str,
896    token: &str,
897    http: &reqwest::Client,
898    agents: &Arc<Mutex<HashSet<String>>>,
899    mstore: &Arc<Store>,
900) -> Result<(), String> {
901    match msg {
902        ServerMsg::InjectAvailable => {
903            // Collect currently-registered agents without holding the lock across awaits.
904            let registered: Vec<String> = agents.lock().unwrap().iter().cloned().collect();
905            let handler = get_global_handler();
906
907            // Concurrent, not sequential: each agent's sync can incur up to
908            // two CREDENTIAL_HTTP_TIMEOUT-bounded HTTP calls (credential
909            // fetch + pending fetch, worst case an unrevoked-then-retried
910            // claim too) — sequentially awaiting N agents compounds that
911            // into an N-times-longer block of this whole select! loop
912            // (pings, incoming messages, ctrl signals all wait on it).
913            // join_all bounds the wall-clock cost to the slowest single
914            // agent's chain regardless of N. reagentx P1 (round 4) on
915            // PR #2342.
916            // One request pulls for every agent (`sync_agents_batched`); a
917            // relay without the batch route, or a single agent, is pulled one
918            // agent at a time as before.
919            let outcomes = match sync_agents_batched(base, &registered, token, http, mstore, handler).await {
920                Some(outcomes) => outcomes,
921                None => {
922                    futures_util::future::join_all(
923                        registered
924                            .iter()
925                            .map(|agent_id| sync_agent_reactive(base, agent_id, token, http, mstore, handler)),
926                    )
927                    .await
928                }
929            };
930
931            if outcomes
932                .iter()
933                .any(|o| matches!(o, AgentSyncOutcome::ReconnectSharedTokenExpired))
934            {
935                // Shared account-level token expired during session — reconnect to refresh.
936                return Err("reconnect:token_expired".to_string());
937            }
938        }
939        ServerMsg::Error { message } => {
940            tracing::warn!("cloud_subscriber: server error: {message}");
941        }
942        ServerMsg::Evicted { .. } => {
943            // Another sidecar evicted us. Reconnect to re-subscribe (reclaim).
944            return Err("reconnect:evicted".to_string());
945        }
946        ServerMsg::Pong | ServerMsg::Subscribed { .. } | ServerMsg::Unknown => {}
947    }
948    Ok(())
949}
950
951enum AgentSyncOutcome {
952    /// This agent's sync finished (successfully or with an already-logged,
953    /// non-fatal error) — nothing further to signal to the caller.
954    Ok,
955    /// The SHARED account-level token (not a per-agent credential) was
956    /// rejected with a genuinely recoverable-by-reconnect status (401,
957    /// expired) — the whole WS session's auth is stale and a reconnect
958    /// gives `load_valid_token` a chance to mint a fresh token via
959    /// `ensure_fresh` before retrying. Only ever produced when no
960    /// per-agent credential was in play for the rejected call, and NOT
961    /// produced for a 403 on the shared token — see
962    /// `shared_token_rejection_outcome`'s doc comment for why a binding
963    /// mismatch (403) can't be fixed by reconnecting and must not tear
964    /// down the whole session.
965    ReconnectSharedTokenExpired,
966}
967
968/// Does `status` mean "this credential is not accepted for this request,"
969/// regardless of the specific reason? 401 = expired/invalid token. 403 =
970/// the cloud's agent-binding check rejected it (this credential is bound to a
971/// different agent_id than the one it's being used for). Both mean the
972/// SAME thing operationally for a poller CURRENTLY USING A PER-AGENT
973/// CREDENTIAL: that credential can't be used for this agent, so
974/// invalidate-and-retry-with-the-shared-token applies identically to either
975/// status. Before this, only 401 triggered that recovery path — a 403 fell
976/// into the generic non-2xx branch, silently stalling that agent's WAN
977/// delivery for the rest of the poll cycle with no retry and no
978/// user-visible error. Once the retry has fallen back to the SHARED token
979/// itself, 401 and 403 diverge again — see `shared_token_rejection_outcome`,
980/// which is what actually decides that case, not this function.
981fn is_credential_rejected(status: reqwest::StatusCode) -> bool {
982    status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN
983}
984
985/// What to do when the SHARED account-level token itself (not a per-agent
986/// credential — that case is already handled by the invalidate-and-retry
987/// branch above) is rejected. 401 and 403 are NOT interchangeable here even
988/// though both satisfy `is_credential_rejected`:
989///
990/// - 401 (expired) → `ReconnectSharedTokenExpired`. Reconnecting is
991///   productive: the outer loop's `load_valid_token` calls
992///   `RefreshScheduler::ensure_fresh` first, which can mint a genuinely new,
993///   accepted access token from the refresh_token before retrying.
994/// - 403 (the agent-binding check rejected this account for `agent_id`) →
995///   `AgentSyncOutcome::Ok` (skip this agent this cycle, log a warning).
996///   `MuxBusCredentials::is_valid()` is purely expiry-based, so the still
997///   time-valid access token survives `load_valid_token` unchanged after a
998///   reconnect — and even a genuine refresh would mint a new token for the
999///   same binding, so nothing about tearing down and reconnecting the WS
1000///   session can fix a binding mismatch. Treating it like a 401 anyway
1001///   (the original extension of this function) churned the whole shared
1002///   session through repeated reconnects with the identical rejected token,
1003///   starving `InjectAvailable` delivery for every OTHER registered agent
1004///   too — reagentx P1 on PR #2573. Matches this project's pre-403-handling
1005///   behavior for exactly this case (log-only, no reconnect); only the
1006///   per-agent-credential branch above actually gained 403 recovery.
1007fn shared_token_rejection_outcome(status: reqwest::StatusCode, agent_id: &str) -> AgentSyncOutcome {
1008    if status == reqwest::StatusCode::FORBIDDEN {
1009        tracing::warn!(
1010            agent_id = %agent_id,
1011            "cloud_subscriber: shared token rejected (binding mismatch) for this agent — skipping this cycle, not reconnecting"
1012        );
1013        // Not reconnecting, but this agent's messages aren't arriving either.
1014        delivery_status::pull_failed(agent_id, "the relay refused this agent's messages (403)");
1015        return AgentSyncOutcome::Ok;
1016    }
1017    AgentSyncOutcome::ReconnectSharedTokenExpired
1018}
1019
1020/// One registered agent's full pending-fetch → claim → deliver cycle for an
1021/// `InjectAvailable` broadcast. Extracted from the loop body it used to be
1022/// so `handle_server_msg` can run every agent's sync concurrently via
1023/// `join_all` instead of sequentially — see the call site's own comment.
1024///
1025/// Retries once with the shared `token` if a per-agent credential is
1026/// rejected (401 or 403 — see `is_credential_rejected`) — without this,
1027/// invalidating the credential alone left this agent's pending injection
1028/// undelivered until an unrelated InjectAvailable broadcast happened to
1029/// fire again (no periodic resync exists). reagentx P1 (round 4) on
1030/// PR #2342; 403 coverage added later (without it, a genuine binding
1031/// mismatch degrades to a silent stall instead of falling back the same
1032/// way an expired token already does).
1033///
1034/// `base` is the relay's REST base URL ([`super::relay::rest_base_url`]); a
1035/// parameter so a test can point one sync at a fake relay.
1036async fn sync_agent_reactive(
1037    base: &str,
1038    agent_id: &str,
1039    token: &str,
1040    http: &reqwest::Client,
1041    mstore: &Arc<Store>,
1042    handler: &'static crate::backend::reactive::handler::ReactiveHandler,
1043) -> AgentSyncOutcome {
1044    // Prefer a credential bound to exactly this agent_id over the shared
1045    // account-level token — see agent_credentials.rs. Falls back to `token`
1046    // (today's self-declared behavior) whenever this agent isn't
1047    // provisioned yet or provisioning fails, so rollout never blocks
1048    // delivery. `using_per_agent` tracks which one is currently in play so
1049    // a 401 can be attributed correctly and, for a per-agent credential,
1050    // retried once with the shared token instead of just giving up.
1051    let per_agent_token = crate::muxbus::agent_credentials::ensure_agent_credential(agent_id, mstore, http).await;
1052    let mut agent_token = match per_agent_token.clone() {
1053        Some(t) => t,
1054        None => shared_token_now(mstore, token).await,
1055    };
1056    let mut using_per_agent = per_agent_token.is_some();
1057
1058    // One live instance per agent across the WAN
1059    // (SPEC_AGENT_SINGLE_LIVE_INSTANCE_2026_09_24 §4.5): claim or renew this
1060    // agent's relay lease before pulling. If another instance holds it, do
1061    // not take its jekts — and fence the local holder of this agent, if any
1062    // (the newcomer yields). Fails open on any error (wan_lease::Outcome::Unknown).
1063    if let super::wan_lease::Outcome::HeldElsewhere(where_) =
1064        super::wan_lease::ensure(base, agent_id, &agent_token, http, || is_subscribed(agent_id)).await
1065    {
1066        crate::backend::agent_admission::fence_agent_named(agent_id, &where_);
1067        return AgentSyncOutcome::Ok;
1068    }
1069
1070    let url = format!("{}/reactive/pending/{}", base, agent_id);
1071    let body: PendingResp = loop {
1072        // For telling a 409 from before a Take over apart (note_not_holder_since).
1073        let resp_started = std::time::Instant::now();
1074        let resp = match http
1075            .get(&url)
1076            .header("Authorization", format!("Bearer {}", agent_token))
1077            .header("X-Agent-ID", agent_id)
1078            .header("X-Agent-Instance", super::wan_lease::instance_id())
1079            .send()
1080            .await
1081        {
1082            Ok(r) => r,
1083            Err(e) => {
1084                tracing::warn!(agent_id = %agent_id, error = %e, "cloud_subscriber: fetch pending failed");
1085                delivery_status::pull_failed(agent_id, "couldn't fetch messages from the relay");
1086                return AgentSyncOutcome::Ok;
1087            }
1088        };
1089
1090        if is_credential_rejected(resp.status()) {
1091            if using_per_agent {
1092                // The per-agent credential's cached token is stale/revoked,
1093                // or the credential itself is binding-mismatched server-side
1094                // even though it looked locally valid — invalidate it (so
1095                // the next agent_credentials call re-fetches instead of
1096                // retrying the same rejected token) and retry THIS fetch
1097                // immediately with the shared token, rather than tearing
1098                // down the whole shared session (which would starve
1099                // delivery for every OTHER agent too). 403 additionally
1100                // records a cooldown — see
1101                // invalidate_binding_mismatched_credential's doc comment for
1102                // why a plain token-clear isn't enough for that status.
1103                if resp.status() == reqwest::StatusCode::FORBIDDEN {
1104                    crate::muxbus::agent_credentials::invalidate_binding_mismatched_credential(agent_id, mstore);
1105                } else {
1106                    crate::muxbus::agent_credentials::invalidate_cached_token(agent_id, mstore);
1107                }
1108                tracing::warn!(
1109                    agent_id = %agent_id,
1110                    status = %resp.status(),
1111                    "cloud_subscriber: per-agent credential rejected — invalidated, retrying with shared token",
1112                );
1113                agent_token = shared_token_now(mstore, token).await;
1114                using_per_agent = false;
1115                continue;
1116            }
1117            return shared_token_rejection_outcome(resp.status(), agent_id);
1118        }
1119        if resp.status() == reqwest::StatusCode::CONFLICT {
1120            // The relay's lease fence: another instance holds this agent.
1121            let body = resp.json::<serde_json::Value>().await.unwrap_or_default();
1122            if let Some(where_) = super::wan_lease::note_not_holder_since(agent_id, &body, resp_started) {
1123                crate::backend::agent_admission::fence_agent_named(agent_id, &where_);
1124            }
1125            return AgentSyncOutcome::Ok;
1126        }
1127        if !resp.status().is_success() {
1128            tracing::warn!(
1129                status = %resp.status(),
1130                agent_id = %agent_id,
1131                "cloud_subscriber: fetch pending non-2xx"
1132            );
1133            delivery_status::pull_failed(agent_id, format!("the relay answered {} to a message fetch", resp.status().as_u16()));
1134            return AgentSyncOutcome::Ok;
1135        }
1136
1137        break match resp.json().await {
1138            Ok(b) => b,
1139            Err(e) => {
1140                tracing::warn!(error = %e, "cloud_subscriber: parse pending failed");
1141                delivery_status::pull_failed(agent_id, "the relay's message list couldn't be read");
1142                return AgentSyncOutcome::Ok;
1143            }
1144        };
1145    };
1146
1147    claim_and_deliver(base, agent_id, token, http, mstore, handler, body, agent_token, using_per_agent).await
1148}
1149
1150/// Every agent in `registered` pulled in one request
1151/// (`POST /reactive/pending-batch`) instead of one
1152/// `GET /reactive/pending/:agent` each, then claimed and delivered exactly as
1153/// [`sync_agent_reactive`] does, for each agent with messages. The relay
1154/// answers each agent as its single route would; this is what keeps an
1155/// install's relay traffic from growing with every open agent.
1156///
1157/// `None` when the batch isn't used: fewer than two agents (one pull either
1158/// way), or a relay without the route. The caller then pulls one agent at a
1159/// time, as before. An agent the batch couldn't answer (a failed request, an
1160/// entry missing) is pulled on its own here.
1161async fn sync_agents_batched(
1162    base: &str,
1163    registered: &[String],
1164    token: &str,
1165    http: &reqwest::Client,
1166    mstore: &Arc<Store>,
1167    handler: &'static crate::backend::reactive::handler::ReactiveHandler,
1168) -> Option<Vec<AgentSyncOutcome>> {
1169    if registered.len() < 2 || super::relay::batch_routes_missing(base) {
1170        return None;
1171    }
1172    let shared = shared_token_now(mstore, token).await;
1173
1174    // The lease first, as for a single pull: claimed or renewed (no request
1175    // while a renewal is fresh), and no pull for an agent another instance
1176    // holds (SPEC_AGENT_SINGLE_LIVE_INSTANCE_2026_09_24 §4.5).
1177    let leases = futures_util::future::join_all(registered.iter().map(|agent_id| {
1178        let shared = &shared;
1179        async move { super::wan_lease::ensure(base, agent_id, shared, http, || is_subscribed(agent_id)).await }
1180    }))
1181    .await;
1182    let mut pull: Vec<&String> = Vec::new();
1183    for (agent_id, lease) in registered.iter().zip(leases) {
1184        match lease {
1185            super::wan_lease::Outcome::HeldElsewhere(where_) => {
1186                crate::backend::agent_admission::fence_agent_named(agent_id, &where_);
1187            }
1188            _ => pull.push(agent_id),
1189        }
1190    }
1191
1192    let mut answers = serde_json::Map::new();
1193    let started = std::time::Instant::now();
1194    for chunk in pull.chunks(super::relay::MAX_BATCH_AGENTS) {
1195        let resp = http
1196            .post(format!("{base}/reactive/pending-batch"))
1197            .header("Authorization", format!("Bearer {shared}"))
1198            .header("X-Agent-Instance", super::wan_lease::instance_id())
1199            .json(&serde_json::json!({ "agent_ids": chunk }))
1200            .send()
1201            .await;
1202        match resp {
1203            Ok(r) if matches!(r.status().as_u16(), 404 | 405) && answers.is_empty() => {
1204                super::relay::note_batch_routes_missing(base);
1205                return None;
1206            }
1207            Ok(r) if r.status().is_success() => match r.json::<serde_json::Value>().await {
1208                Ok(v) => {
1209                    if let Some(results) = v.get("results").and_then(|r| r.as_object()) {
1210                        answers.extend(results.clone());
1211                    }
1212                }
1213                Err(e) => tracing::warn!(error = %e, "cloud_subscriber: parse batch pull failed"),
1214            },
1215            Ok(r) => tracing::warn!(status = %r.status(), "cloud_subscriber: batch pull non-2xx; pulling those agents one by one"),
1216            Err(e) => tracing::warn!(error = %e, "cloud_subscriber: batch pull failed; pulling those agents one by one"),
1217        }
1218    }
1219
1220    let outcomes = futures_util::future::join_all(pull.into_iter().map(|agent_id| {
1221        let entry = answers.get(&agent_id.trim().to_lowercase()).cloned();
1222        let shared = &shared;
1223        async move {
1224            let Some(entry) = entry else {
1225                return sync_agent_reactive(base, agent_id, token, http, mstore, handler).await;
1226            };
1227            match entry.get("error").and_then(|e| e.as_str()) {
1228                Some("not_holder") => {
1229                    // The relay's lease fence, as a 409 on a single pull.
1230                    if let Some(where_) = super::wan_lease::note_not_holder_since(agent_id, &entry, started) {
1231                        crate::backend::agent_admission::fence_agent_named(agent_id, &where_);
1232                    }
1233                    AgentSyncOutcome::Ok
1234                }
1235                Some(_) => {
1236                    delivery_status::pull_failed(agent_id, "the relay couldn't read this agent's messages just now");
1237                    AgentSyncOutcome::Ok
1238                }
1239                None => match serde_json::from_value::<PendingResp>(entry) {
1240                    Ok(body) if body.injections.is_empty() => {
1241                        delivery_status::fetched(agent_id, 0, &body.expired);
1242                        AgentSyncOutcome::Ok
1243                    }
1244                    Ok(body) => {
1245                        // Claimed with the agent's own credential when it has
1246                        // one, as a single pull would be.
1247                        let own = crate::muxbus::agent_credentials::ensure_agent_credential(agent_id, mstore, http).await;
1248                        let using_per_agent = own.is_some();
1249                        let agent_token = own.unwrap_or_else(|| shared.clone());
1250                        claim_and_deliver(base, agent_id, token, http, mstore, handler, body, agent_token, using_per_agent).await
1251                    }
1252                    Err(e) => {
1253                        tracing::warn!(agent_id = %agent_id, error = %e, "cloud_subscriber: parse pending failed");
1254                        delivery_status::pull_failed(agent_id, "the relay's message list couldn't be read");
1255                        AgentSyncOutcome::Ok
1256                    }
1257                },
1258            }
1259        }
1260    }))
1261    .await;
1262    Some(outcomes)
1263}
1264
1265/// The shared account token for an HTTP call made during a connection: loaded
1266/// fresh (`fresh`), the same way `wan_publish` and `relay::relay_token` load
1267/// theirs. Not the connection's `token`: that one is loaded once at connect, a
1268/// desktop (PKCE) access token lives 15 minutes and a connection up to 2
1269/// hours, so after minute 15 every call made with it is a 401. For the W3-S
1270/// key directory that turned every same-account WAN jekt `network-claimed`;
1271/// for a lease release it left the lease held until it expired. The
1272/// connection's token is only the fallback when no fresh one can be loaded,
1273/// and otherwise only authenticates the WebSocket handshake.
1274async fn fresh_shared_token<F, Fut>(fresh: F, connection_token: &str) -> String
1275where
1276    F: FnOnce() -> Fut,
1277    Fut: std::future::Future<Output = Option<String>>,
1278{
1279    fresh().await.unwrap_or_else(|| connection_token.to_string())
1280}
1281
1282/// Whether a spawn's exit drops its agent from the cloud subscription.
1283/// `registration_was_ours`: its own nonce matched. That alone was the old
1284/// rule, and a registration already gone (`false`) read as "a newer spawn
1285/// owns it" — so the agent stayed subscribed, and the lease tick renewed
1286/// its WAN lease for as long as the srv ran, fencing it on every other
1287/// instance (charlie, 2026-09-26). Keep it only while a live block holds it.
1288pub(crate) fn drop_cloud_subscription_on_exit(registration_was_ours: bool, still_held: bool) -> bool {
1289    registration_was_ours || !still_held
1290}
1291
1292/// Subscribed agents no live block holds. Every way into the subscription
1293/// (`add_agent`) runs with a live registration, so one without is a leak
1294/// from a teardown that missed it; renewing its WAN lease would fence the
1295/// agent everywhere else for as long as this srv runs.
1296fn orphaned_subscriptions(registered: &[String], is_live: impl Fn(&str) -> bool) -> Vec<String> {
1297    registered.iter().filter(|a| !is_live(a)).cloned().collect()
1298}
1299
1300/// Drop `agent` from the relay subscription and release its WAN lease,
1301/// awaiting the release (the `RemoveAgent` arm's is fire-and-forget) — for a
1302/// Take over, whose requester claims the lease right after. `false` if it
1303/// wasn't subscribed. `id_store` is where muxbus credentials live.
1304/// What a Take over's relay release did.
1305#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1306pub(crate) enum RelayRelease {
1307    /// Not in the relay subscription: no relay lease to release.
1308    NotSubscribed,
1309    /// Unsubscribed and the lease released (or none was held).
1310    Released,
1311    /// Re-added while releasing: the lease was kept.
1312    Kept,
1313}
1314
1315/// Is `agent` in the relay subscription? `true` when there is no
1316/// subscriber (tests), so a lease check fails open as before.
1317pub(crate) fn is_subscribed(agent: &str) -> bool {
1318    get_global_subscriber().is_none_or(|s| s.agents.lock().unwrap().contains(&agent.to_lowercase()))
1319}
1320
1321/// Take over's relay step on the requester ([`super::wan_lease::take_over`]),
1322/// with a fresh shared token. `Unknown` (fail open) when this instance has no
1323/// MuxBus session or no token can be loaded.
1324pub(crate) async fn take_over_lease_now(
1325    id_store: &Arc<Store>,
1326    http: &reqwest::Client,
1327    agent: &str,
1328) -> super::wan_lease::Outcome {
1329    if get_global_subscriber().is_none() || agent.is_empty() {
1330        return super::wan_lease::Outcome::Unknown;
1331    }
1332    let token = shared_token_now(id_store, "").await;
1333    if token.is_empty() {
1334        return super::wan_lease::Outcome::Unknown;
1335    }
1336    super::wan_lease::take_over(&super::relay::rest_base_url(), agent, &token, http).await
1337}
1338
1339pub(crate) async fn release_agent_now(id_store: &Arc<Store>, agent: &str) -> RelayRelease {
1340    let Some(sub) = get_global_subscriber() else { return RelayRelease::NotSubscribed };
1341    sub.release_now(agent, &super::relay::rest_base_url(), shared_token_now(id_store, "")).await
1342}
1343
1344/// [`fresh_shared_token`] against the broker and `mstore` (the store muxbus
1345/// credentials live in — `CloudSubscriber::init_global`'s `id_store`).
1346async fn shared_token_now(mstore: &Arc<Store>, connection_token: &str) -> String {
1347    fresh_shared_token(
1348        || async {
1349            let scheduler = crate::broker::get_global()?;
1350            load_valid_token(mstore, &scheduler).await
1351        },
1352        connection_token,
1353    )
1354    .await
1355}
1356
1357/// Load a valid (non-expired) access token via the broker, refreshing first
1358/// if the stored credential is missing/stale. Returns None if no credentials
1359/// are stored, the token is expired and refresh fails, or the refresh_token
1360/// is absent — `ensure_fresh`'s own single-flight guard means this can run
1361/// concurrently with the broker's background sweep for the same credential
1362/// without either one duplicating the other's refresh attempt.
1363///
1364/// pub(crate): also used by agent_credentials.rs to authenticate the
1365/// POST /agents/provision call, which requires the human's own user-level
1366/// (PKCE) token, not an agent-bound M2M one.
1367pub(crate) async fn load_valid_token(
1368    mstore: &Arc<Store>,
1369    scheduler: &crate::broker::RefreshScheduler,
1370) -> Option<String> {
1371    if let Err(e) = scheduler.ensure_fresh(crate::muxbus::CREDENTIAL_ID).await {
1372        tracing::warn!(error = %e, "cloud_subscriber: token refresh failed");
1373    }
1374    // Preserve-on-failure means a failed refresh leaves the last-known-good
1375    // credential in place — re-read regardless of ensure_fresh's outcome and
1376    // fall back to it if it's still within its validity window, matching the
1377    // pre-broker behavior: a transient refresh failure should not prevent
1378    // connecting with a token that's still technically valid.
1379    //
1380    // spawn_blocking — reagent P1 on #2260: same synchronous-keychain-read
1381    // concern as every other muxbus_load call site in this file.
1382    let load_store = mstore.clone();
1383    let creds = tokio::task::spawn_blocking(move || load_store.muxbus_load())
1384        .await
1385        .ok()
1386        .and_then(|r| r.ok())
1387        .flatten();
1388    match creds {
1389        Some(c) if c.is_valid() => Some(c.access_token),
1390        _ => None,
1391    }
1392}
1393
1394/// Classify a `pkce::refresh_token` failure for the broker's
1395/// `RefreshErrorKind` — `NoRefreshToken`/`Rejected` in the 4xx range mean
1396/// the credential itself is the problem (only a fresh `muxbus.login` fixes
1397/// it) *except* 408/429, which mean the request itself was throttled or
1398/// timed out, not that the refresh_token was rejected — treating those as
1399/// permanent would strand the credential in `NeedsReauth` past a temporary
1400/// rate limit (reagent P2 on #2275). Everything else (network blips, 5xx,
1401/// response parse failures) is worth retrying on the next sweep tick.
1402fn classify_refresh_token_error(e: crate::muxbus::pkce::RefreshTokenError) -> RefreshErrorKind {
1403    use crate::muxbus::pkce::RefreshTokenError;
1404    match &e {
1405        RefreshTokenError::NoRefreshToken => RefreshErrorKind::PermanentAuthFailure(e.to_string()),
1406        RefreshTokenError::Rejected { status, .. }
1407            if (400..500).contains(status) && !matches!(status, 408 | 429) =>
1408        {
1409            RefreshErrorKind::PermanentAuthFailure(e.to_string())
1410        }
1411        RefreshTokenError::Rejected { .. }
1412        | RefreshTokenError::Network(_)
1413        | RefreshTokenError::ParseFailed(_) => RefreshErrorKind::Transient(e.to_string()),
1414    }
1415}
1416
1417#[cfg(test)]
1418mod tests {
1419    use super::classify_refresh_token_error;
1420    use super::is_credential_rejected;
1421    use super::shared_token_rejection_outcome;
1422    use super::AgentSyncOutcome;
1423    use super::{reagent_sig_is_fresh, REAGENT_SIG_MAX_AGE_SECS};
1424    use crate::broker::RefreshErrorKind;
1425    use crate::muxbus::pkce::RefreshTokenError;
1426    use tokio_tungstenite::tungstenite::client::IntoClientRequest;
1427    use tokio_tungstenite::tungstenite::ClientRequestBuilder;
1428
1429    fn jwt(sub: &str) -> String {
1430        use base64::Engine;
1431        let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(format!(r#"{{"sub":"{sub}"}}"#));
1432        format!("e30.{payload}.sig")
1433    }
1434
1435    /// A torn sign-in keeps only its refresh token; that is still worth a
1436    /// refresh, where nothing at all is not.
1437    #[test]
1438    fn a_sign_in_with_only_a_refresh_token_can_still_be_refreshed() {
1439        use crate::muxbus::MuxBusCredentials;
1440        let in_an_hour = agentmux_common::time::now_secs() + 3600;
1441        let usable = |access: &str, refresh: &str, expires_at: i64| {
1442            super::usable_or_refreshable(&MuxBusCredentials {
1443                access_token: access.into(),
1444                refresh_token: refresh.into(),
1445                expires_at,
1446                ..Default::default()
1447            })
1448        };
1449        assert!(usable("", "rt", 0), "torn: refresh token only");
1450        assert!(usable("at", "", in_an_hour), "valid access token");
1451        assert!(usable("at", "rt", 0), "expired, refreshable");
1452        assert!(!usable("at", "", 0), "expired, nothing to refresh with");
1453        assert!(!usable("", "", 0), "nothing");
1454    }
1455
1456    /// A repaired sign-in is kept only for the account the saved row names.
1457    #[test]
1458    fn a_refresh_for_another_account_is_refused() {
1459        use crate::muxbus::MuxBusCredentials;
1460        let current = |sub: &str| MuxBusCredentials { user_sub: sub.into(), ..Default::default() };
1461        let refreshed = |id: String, access: String| MuxBusCredentials {
1462            id_token: id,
1463            access_token: access,
1464            ..Default::default()
1465        };
1466        assert!(super::refreshed_same_account(&current("user-a"), &refreshed(jwt("user-a"), jwt("user-a"))));
1467        assert!(!super::refreshed_same_account(&current("user-a"), &refreshed(jwt("user-b"), jwt("user-b"))));
1468        assert!(
1469            super::refreshed_same_account(&current("user-a"), &refreshed(String::new(), jwt("user-a"))),
1470            "no id token: the access token's"
1471        );
1472        assert!(!super::refreshed_same_account(&current("user-a"), &refreshed("x".into(), "y".into())), "unreadable");
1473        assert!(
1474            super::refreshed_same_account(&current(""), &refreshed(jwt("user-b"), jwt("user-b"))),
1475            "an older row with no account recorded can't be checked"
1476        );
1477    }
1478
1479    // The connection's token is loaded once at connect, and a desktop (PKCE)
1480    // access token lives 15 min while a connection lasts up to 2 h. Handing
1481    // it to the key directory made every lookup after minute 15 a 401, read
1482    // as "couldn't check" — every same-account WAN jekt arrived
1483    // `network-claimed`; a lease release with it left the lease held. Calls
1484    // made during a connection must get a freshly loaded token.
1485    #[tokio::test]
1486    async fn calls_during_a_connection_get_a_fresh_token_not_the_connections() {
1487        let token = super::fresh_shared_token(|| async { Some("fresh".to_string()) }, "stale-connection-token").await;
1488        assert_eq!(token, "fresh");
1489    }
1490
1491    // Charlie 2026-09-26: a spawn's exit found its block's registration
1492    // already gone, took that for "a newer spawn owns it" and kept the
1493    // agent in the cloud subscription — whose lease tick then renewed its
1494    // WAN lease for as long as the srv ran, fencing the agent everywhere.
1495    #[test]
1496    fn an_exit_drops_the_subscription_when_nobody_holds_the_agent() {
1497        assert!(super::drop_cloud_subscription_on_exit(false, false), "no registration and no live holder");
1498        assert!(super::drop_cloud_subscription_on_exit(true, false));
1499        assert!(!super::drop_cloud_subscription_on_exit(false, true), "a newer spawn holds it: keep");
1500    }
1501
1502    // A stale sign-in parks the loop instead of reconnecting with backoff
1503    // (SPEC_CLOUD_SETTINGS_DISCOVERY_2026_09_27.md §3.3): agents coming and
1504    // going must not wake it, only a sign-in or the slow local recheck.
1505    #[tokio::test]
1506    async fn a_parked_loop_ignores_agent_traffic_and_waits_for_the_recheck() {
1507        use std::time::{Duration, Instant};
1508        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1509        for i in 0..20 {
1510            tx.send(super::CtrlMsg::AddAgent(format!("a{i}"))).unwrap();
1511            tx.send(super::CtrlMsg::RemoveAgent(format!("a{i}"))).unwrap();
1512        }
1513        let started = Instant::now();
1514        super::wait_for_sign_in(&mut rx, Duration::from_millis(300)).await;
1515        assert!(started.elapsed() >= Duration::from_millis(300), "woke before the recheck");
1516    }
1517
1518    #[tokio::test]
1519    async fn a_sign_in_wakes_a_parked_loop_at_once() {
1520        use std::time::Duration;
1521        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1522        tx.send(super::CtrlMsg::AddAgent("a".into())).unwrap();
1523        tx.send(super::CtrlMsg::ReloadToken).unwrap();
1524        tokio::time::timeout(Duration::from_secs(5), super::wait_for_sign_in(&mut rx, Duration::from_secs(3600)))
1525            .await
1526            .expect("ReloadToken must end the wait");
1527    }
1528
1529    fn test_subscriber(
1530        agents: &[&str],
1531    ) -> (super::CloudSubscriber, tokio::sync::mpsc::UnboundedReceiver<super::CtrlMsg>) {
1532        let (ctrl_tx, ctrl_rx) = tokio::sync::mpsc::unbounded_channel();
1533        let set = agents.iter().map(|a| a.to_string()).collect::<std::collections::HashSet<_>>();
1534        (super::CloudSubscriber { ctrl_tx, agents: std::sync::Arc::new(std::sync::Mutex::new(set)) }, ctrl_rx)
1535    }
1536
1537    // Codex P2 on #3897: "no live block" can be read just before a respawn
1538    // registers; its add_agent then finds the key still present and sends
1539    // nothing, and an unconditional removal after that drops the live
1540    // agent's subscription. The drop must re-check liveness after removing.
1541    #[test]
1542    fn dropping_an_orphan_keeps_an_agent_that_registered_meanwhile() {
1543        let (sub, mut rx) = test_subscriber(&["opaz"]);
1544        // Liveness flips to true between the caller's check and the drop.
1545        assert!(!sub.drop_if_orphaned("Opaz", |_| true));
1546        assert!(sub.agents.lock().unwrap().contains("opaz"), "still subscribed");
1547        assert!(rx.try_recv().is_err(), "no RemoveAgent sent");
1548    }
1549
1550    // Codex P2 (second pass) on #3897: a respawn registering after the
1551    // re-check but before RemoveAgent is sent would queue AddAgent first,
1552    // and the WS loop would process the stale removal last. An add_agent
1553    // racing a drop must queue after it.
1554    #[test]
1555    fn an_add_racing_a_drop_is_queued_after_the_removal() {
1556        let (sub, mut rx) = test_subscriber(&["opaz"]);
1557        let sub = std::sync::Arc::new(sub);
1558        let (entered_tx, entered_rx) = std::sync::mpsc::channel::<()>();
1559        let adder = {
1560            let sub = sub.clone();
1561            std::thread::spawn(move || {
1562                entered_rx.recv().unwrap();
1563                sub.add_agent("Opaz");
1564            })
1565        };
1566        let dropped = sub.drop_if_orphaned("Opaz", |_| {
1567            // The respawn starts registering while liveness is checked.
1568            entered_tx.send(()).unwrap();
1569            std::thread::sleep(std::time::Duration::from_millis(50));
1570            false
1571        });
1572        adder.join().unwrap();
1573        assert!(dropped);
1574        assert!(matches!(rx.try_recv(), Ok(super::CtrlMsg::RemoveAgent(k)) if k == "opaz"), "removal first");
1575        assert!(matches!(rx.try_recv(), Ok(super::CtrlMsg::AddAgent(k)) if k == "opaz"), "then the re-add");
1576        assert!(sub.agents.lock().unwrap().contains("opaz"), "and it ends subscribed");
1577    }
1578
1579    // ReAgent P1 (second pass) on #3899: a Take over release sent its
1580    // RemoveAgent only after the relay round trip, so an agent re-added in
1581    // that window queued AddAgent first and was then unsubscribed. The
1582    // removal must be queued with the set change, before any await.
1583    #[tokio::test]
1584    async fn a_take_over_release_queues_its_removal_before_a_re_add() {
1585        let (sub, mut rx) = test_subscriber(&["opaz"]);
1586        let sub = std::sync::Arc::new(sub);
1587        let re_adder = sub.clone();
1588        let released = sub
1589            .release_now("Opaz", "http://127.0.0.1:9", async move {
1590                // The agent is reopened while the fresh token is fetched.
1591                re_adder.add_agent("Opaz");
1592                String::new()
1593            })
1594            .await;
1595        assert_eq!(released, super::RelayRelease::Kept, "re-added meanwhile: the lease was kept");
1596        assert!(matches!(rx.try_recv(), Ok(super::CtrlMsg::RemoveAgent(k)) if k == "opaz"), "removal first");
1597        assert!(matches!(rx.try_recv(), Ok(super::CtrlMsg::AddAgent(k)) if k == "opaz"), "then the re-add");
1598        assert!(sub.agents.lock().unwrap().contains("opaz"), "and it ends subscribed");
1599    }
1600
1601    // ReAgent P2 on #3908: "not subscribed" (e.g. no relay) must not read
1602    // as "re-added, lease kept".
1603    // Codex P1 on #3908: the stopped pane's own exit may already have
1604    // unsubscribed the agent and queued RemoveAgent, whose release runs
1605    // detached. "Not in the set" is not "no lease": release it here too
1606    // (idempotently) and answer only once it's gone.
1607    #[tokio::test]
1608    async fn a_take_over_release_still_releases_a_lease_the_exit_left_behind() {
1609        let agent = format!("agent-{}", uuid::Uuid::new_v4());
1610        crate::muxbus::wan_lease::mark_held_for_test(&agent);
1611        let (sub, mut rx) = test_subscriber(&[]);
1612        let outcome = sub.release_now(&agent, "http://127.0.0.1:9", async { String::new() }).await;
1613        assert_eq!(outcome, super::RelayRelease::Released);
1614        assert!(!crate::muxbus::wan_lease::is_held(&agent), "the lease is let go before answering");
1615        assert!(rx.try_recv().is_err(), "no second RemoveAgent");
1616    }
1617
1618    #[tokio::test]
1619    async fn a_take_over_release_of_an_unsubscribed_agent_says_so() {
1620        let (sub, _rx) = test_subscriber(&[]);
1621        let outcome = sub.release_now("Opaz", "http://127.0.0.1:9", async { String::new() }).await;
1622        assert_eq!(outcome, super::RelayRelease::NotSubscribed);
1623    }
1624
1625    #[test]
1626    fn dropping_an_orphan_removes_it_and_tells_the_ws_loop() {
1627        let (sub, mut rx) = test_subscriber(&["opaz"]);
1628        assert!(sub.drop_if_orphaned("Opaz", |_| false));
1629        assert!(!sub.agents.lock().unwrap().contains("opaz"));
1630        assert!(matches!(rx.try_recv(), Ok(super::CtrlMsg::RemoveAgent(k)) if k == "opaz"));
1631        assert!(!sub.drop_if_orphaned("Opaz", |_| false), "already gone");
1632    }
1633
1634    #[test]
1635    fn the_lease_tick_finds_subscriptions_no_live_block_holds() {
1636        let registered = vec!["opaz".to_string(), "maricon".to_string()];
1637        let orphans = super::orphaned_subscriptions(&registered, |name| name == "maricon");
1638        assert_eq!(orphans, vec!["opaz".to_string()]);
1639        assert!(super::orphaned_subscriptions(&registered, |_| true).is_empty());
1640    }
1641
1642    #[tokio::test]
1643    async fn calls_during_a_connection_fall_back_to_the_connections_token() {
1644        let token = super::fresh_shared_token(|| async { None }, "connection-token").await;
1645        assert_eq!(token, "connection-token");
1646    }
1647
1648    // An agent-binding rejection (403) must trigger the exact same
1649    // invalidate-and-retry-with-shared-token recovery as an expired token
1650    // (401), not silently fall through to a stalled, unretried delivery.
1651    #[test]
1652    fn treats_401_as_credential_rejected() {
1653        assert!(is_credential_rejected(reqwest::StatusCode::UNAUTHORIZED));
1654    }
1655
1656    #[test]
1657    fn treats_403_as_credential_rejected() {
1658        assert!(is_credential_rejected(reqwest::StatusCode::FORBIDDEN));
1659    }
1660
1661    #[test]
1662    fn does_not_treat_other_client_errors_as_credential_rejected() {
1663        // 400/404/429 etc. are real errors but not "this credential doesn't
1664        // work here" — retrying with a different credential wouldn't help,
1665        // so these must keep falling into the generic non-2xx branch
1666        // (logged, not retried) rather than triggering credential swap.
1667        for status in [
1668            reqwest::StatusCode::BAD_REQUEST,
1669            reqwest::StatusCode::NOT_FOUND,
1670            reqwest::StatusCode::TOO_MANY_REQUESTS,
1671        ] {
1672            assert!(!is_credential_rejected(status), "{status} must not be treated as a credential rejection");
1673        }
1674    }
1675
1676    // reagentx P1 on PR #2573: a 403 on the SHARED token itself (binding
1677    // mismatch, not expiry) must NOT reconnect the whole session — the still
1678    // time-valid token survives `load_valid_token` unchanged, so reconnecting
1679    // just replays the identical rejected token forever, starving every
1680    // other registered agent's delivery too.
1681    #[test]
1682    fn shared_token_403_does_not_reconnect() {
1683        let outcome = shared_token_rejection_outcome(reqwest::StatusCode::FORBIDDEN, "agent-x");
1684        assert!(matches!(outcome, AgentSyncOutcome::Ok));
1685    }
1686
1687    #[test]
1688    fn shared_token_401_still_reconnects() {
1689        let outcome = shared_token_rejection_outcome(reqwest::StatusCode::UNAUTHORIZED, "agent-x");
1690        assert!(matches!(outcome, AgentSyncOutcome::ReconnectSharedTokenExpired));
1691    }
1692
1693    #[test]
1694    fn does_not_treat_success_or_server_error_as_credential_rejected() {
1695        for status in [reqwest::StatusCode::OK, reqwest::StatusCode::INTERNAL_SERVER_ERROR] {
1696            assert!(!is_credential_rejected(status));
1697        }
1698    }
1699
1700    // Anti-replay for reagent-signed WAN jekts.
1701    #[test]
1702    fn reagent_sig_exactly_now_is_fresh() {
1703        assert!(reagent_sig_is_fresh(1_000, 1_000));
1704    }
1705
1706    #[test]
1707    fn reagent_sig_within_the_window_is_fresh() {
1708        assert!(reagent_sig_is_fresh(1_000, 1_000 + REAGENT_SIG_MAX_AGE_SECS));
1709        assert!(reagent_sig_is_fresh(1_000, 1_000 - REAGENT_SIG_MAX_AGE_SECS));
1710    }
1711
1712    #[test]
1713    fn reagent_sig_just_past_the_window_is_not_fresh() {
1714        assert!(!reagent_sig_is_fresh(1_000, 1_000 + REAGENT_SIG_MAX_AGE_SECS + 1));
1715        assert!(!reagent_sig_is_fresh(1_000, 1_000 - REAGENT_SIG_MAX_AGE_SECS - 1));
1716    }
1717
1718    #[test]
1719    fn reagent_sig_far_in_the_past_is_not_fresh() {
1720        // The exact scenario this exists to stop: a captured signature from
1721        // hours ago resubmitted under a new injection id.
1722        assert!(!reagent_sig_is_fresh(1_000, 1_000 + 3600));
1723    }
1724
1725    #[test]
1726    fn reagent_sig_zero_or_negative_timestamp_is_never_fresh() {
1727        assert!(!reagent_sig_is_fresh(0, 0));
1728        assert!(!reagent_sig_is_fresh(-1, 0));
1729    }
1730
1731    #[test]
1732    fn no_refresh_token_is_permanent() {
1733        assert!(matches!(
1734            classify_refresh_token_error(RefreshTokenError::NoRefreshToken),
1735            RefreshErrorKind::PermanentAuthFailure(_)
1736        ));
1737    }
1738
1739    #[test]
1740    fn a_4xx_rejection_is_permanent() {
1741        assert!(matches!(
1742            classify_refresh_token_error(RefreshTokenError::Rejected {
1743                status: 400,
1744                body: "invalid_grant".into(),
1745            }),
1746            RefreshErrorKind::PermanentAuthFailure(_)
1747        ));
1748    }
1749
1750    #[test]
1751    fn a_5xx_rejection_is_transient() {
1752        assert!(matches!(
1753            classify_refresh_token_error(RefreshTokenError::Rejected {
1754                status: 503,
1755                body: "unavailable".into(),
1756            }),
1757            RefreshErrorKind::Transient(_)
1758        ));
1759    }
1760
1761    #[test]
1762    fn rate_limit_and_timeout_rejections_are_transient_not_permanent() {
1763        // A 429/408 means the request was throttled/timed out, not that the
1764        // refresh_token itself was rejected — must not strand the
1765        // credential in NeedsReauth past a temporary rate limit.
1766        assert!(matches!(
1767            classify_refresh_token_error(RefreshTokenError::Rejected {
1768                status: 429,
1769                body: "rate limited".into(),
1770            }),
1771            RefreshErrorKind::Transient(_)
1772        ));
1773        assert!(matches!(
1774            classify_refresh_token_error(RefreshTokenError::Rejected {
1775                status: 408,
1776                body: "request timeout".into(),
1777            }),
1778            RefreshErrorKind::Transient(_)
1779        ));
1780    }
1781
1782    #[test]
1783    fn network_and_parse_errors_are_transient() {
1784        assert!(matches!(
1785            classify_refresh_token_error(RefreshTokenError::Network("timeout".into())),
1786            RefreshErrorKind::Transient(_)
1787        ));
1788        assert!(matches!(
1789            classify_refresh_token_error(RefreshTokenError::ParseFailed("bad json".into())),
1790            RefreshErrorKind::Transient(_)
1791        ));
1792    }
1793
1794    /// Regression for issue #2091: every connection attempt failed the
1795    /// WebSocket handshake with "Missing, duplicated or incorrect header
1796    /// sec-websocket-key" from the moment this file was first written,
1797    /// because the old code hand-built an `http::Request` with only
1798    /// `Authorization` set — tungstenite's `IntoClientRequest for Request`
1799    /// is a bare passthrough, so none of Host/Connection/Upgrade/
1800    /// Sec-WebSocket-Version/Sec-WebSocket-Key were ever added, and
1801    /// tungstenite's handshake code requires all five to already be present
1802    /// on the outgoing request. This test builds the request the exact way
1803    /// `connect_and_run` now does and asserts every required header exists
1804    /// — it would have failed against the old `Request::builder()` code and
1805    /// passes against `ClientRequestBuilder`. No network access needed: this
1806    /// only inspects the request tungstenite would send, before any I/O.
1807    #[test]
1808    fn ws_request_carries_every_header_the_handshake_requires() {
1809        let uri: tokio_tungstenite::tungstenite::http::Uri =
1810            "wss://muxbus-ws.agentmux.ai".parse().unwrap();
1811        let request = ClientRequestBuilder::new(uri)
1812            .with_header("Authorization", "Bearer test-token")
1813            .into_client_request()
1814            .expect("request should build");
1815
1816        let headers = request.headers();
1817        for required in ["Host", "Connection", "Upgrade", "Sec-WebSocket-Version", "Sec-WebSocket-Key"] {
1818            assert!(
1819                headers.contains_key(required),
1820                "missing required WebSocket handshake header: {required}"
1821            );
1822        }
1823        assert_eq!(headers.get("Authorization").unwrap(), "Bearer test-token");
1824    }
1825
1826    // ── The relay's lease fence, end to end against a fake relay ──────────
1827    // (SPEC_AGENT_SINGLE_LIVE_INSTANCE_2026_09_24 §4.5). Each 409 the relay
1828    // can answer while another instance holds the agent must fence this
1829    // process's local holder and take nothing.
1830
1831    use std::sync::{Arc, Mutex};
1832
1833    type Seen = Arc<Mutex<Vec<String>>>;
1834
1835    /// A fake muxbus relay answering `"METHOD /path"` with the scripted
1836    /// status and JSON body (anything else: 404), recording every request.
1837    /// Every script answers the lease claim: a 404 there would switch the WAN
1838    /// tier off for the whole test process (`wan_lease::UNSUPPORTED_UNTIL`).
1839    async fn fake_relay(routes: Vec<(String, u16, serde_json::Value)>) -> (String, Seen) {
1840        let seen: Seen = Arc::default();
1841        let app = axum::Router::new().fallback({
1842            let seen = Arc::clone(&seen);
1843            move |req: axum::extract::Request| {
1844                let (seen, routes) = (Arc::clone(&seen), routes.clone());
1845                async move {
1846                    let line = format!("{} {}", req.method(), req.uri().path());
1847                    seen.lock().unwrap().push(line.clone());
1848                    let (status, body) = routes
1849                        .iter()
1850                        .find(|(route, _, _)| *route == line)
1851                        .map(|(_, status, body)| (*status, body.clone()))
1852                        .unwrap_or((404, serde_json::Value::Null));
1853                    (axum::http::StatusCode::from_u16(status).unwrap(), axum::Json(body))
1854                }
1855            }
1856        });
1857        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1858        let addr = listener.local_addr().unwrap();
1859        tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
1860        (format!("http://{addr}"), seen)
1861    }
1862
1863    /// Hold `agent` in this process, as a running agent pane does. The
1864    /// receiver gets the reason when the holder is fenced.
1865    fn hold_locally(
1866        agent: &str,
1867    ) -> (tempfile::TempDir, crate::backend::agent_admission::HeldAgentLease, std::sync::mpsc::Receiver<String>) {
1868        let tmp = tempfile::tempdir().unwrap();
1869        let store = Arc::new(crate::registry::LeaseStore::open_with_ttl(tmp.path(), 60_000).unwrap());
1870        let (tx, rx) = std::sync::mpsc::channel();
1871        let tx = Mutex::new(tx);
1872        let held = crate::backend::agent_admission::acquire(
1873            &store,
1874            &format!("uid-{agent}"),
1875            agent,
1876            &Arc::from("boot-test"),
1877            "block-test",
1878            None,
1879            move |why| {
1880                let _ = tx.lock().unwrap().send(why);
1881            },
1882        )
1883        .unwrap();
1884        (tmp, held, rx)
1885    }
1886
1887    fn not_holder() -> serde_json::Value {
1888        serde_json::json!({
1889            "error": "not_holder",
1890            "held_by": { "host": "desk", "channel": "stable", "version": "0.58.0" },
1891        })
1892    }
1893
1894    fn lease_granted() -> (String, u16, serde_json::Value) {
1895        ("POST /agents/lease".into(), 200, serde_json::json!({ "ok": true, "epoch": 1 }))
1896    }
1897
1898    /// Run one `InjectAvailable` sync of `agent` against the relay at `base`.
1899    async fn sync(base: &str, agent: &str) {
1900        let mstore = Arc::new(crate::backend::storage::store::Store::open_in_memory().unwrap());
1901        let handler = crate::backend::reactive::handler::get_global_handler();
1902        let outcome = super::sync_agent_reactive(base, agent, "test-token", &reqwest::Client::new(), &mstore, handler).await;
1903        assert!(matches!(outcome, AgentSyncOutcome::Ok));
1904    }
1905
1906    fn assert_fenced(agent: &str, rx: &std::sync::mpsc::Receiver<String>) {
1907        let why = rx.try_recv().expect("the local holder was fenced");
1908        assert!(why.contains("taken over") && why.contains("computer desk"), "{why}");
1909        assert_eq!(
1910            crate::muxbus::wan_lease::held_elsewhere(agent).as_deref(),
1911            Some("computer desk, channel stable, v0.58.0"),
1912            "remembered for the early admission check"
1913        );
1914    }
1915
1916    #[tokio::test]
1917    async fn a_lease_held_elsewhere_fences_the_local_holder_before_any_pull() {
1918        let agent = format!("agent-{}", uuid::Uuid::new_v4());
1919        let (_tmp, _held, rx) = hold_locally(&agent);
1920        let (base, seen) = fake_relay(vec![("POST /agents/lease".into(), 409, not_holder())]).await;
1921
1922        sync(&base, &agent).await;
1923
1924        assert_fenced(&agent, &rx);
1925        assert_eq!(*seen.lock().unwrap(), ["POST /agents/lease"], "no pending pull");
1926    }
1927
1928    #[tokio::test]
1929    async fn a_409_on_the_pending_pull_fences_the_local_holder_and_claims_nothing() {
1930        let agent = format!("agent-{}", uuid::Uuid::new_v4());
1931        let (_tmp, _held, rx) = hold_locally(&agent);
1932        let pending = format!("GET /reactive/pending/{agent}");
1933        let (base, seen) = fake_relay(vec![lease_granted(), (pending.clone(), 409, not_holder())]).await;
1934
1935        sync(&base, &agent).await;
1936
1937        assert_fenced(&agent, &rx);
1938        assert_eq!(*seen.lock().unwrap(), ["POST /agents/lease", pending.as_str()], "no ack");
1939    }
1940
1941    /// ReAgent P1 on #3746: the lease can change hands between the pull and
1942    /// the ack. A 409 on the ack must fence like one on the pull, not be
1943    /// logged as a generic error while the local copy keeps running.
1944    #[tokio::test]
1945    async fn a_409_on_the_ack_fences_the_local_holder_and_delivers_nothing() {
1946        let agent = format!("agent-{}", uuid::Uuid::new_v4());
1947        let (_tmp, _held, rx) = hold_locally(&agent);
1948        let pending = format!("GET /reactive/pending/{agent}");
1949        let (base, seen) = fake_relay(vec![
1950            lease_granted(),
1951            (pending.clone(), 200, serde_json::json!({ "injections": [{ "id": "inj-1", "message": "hello" }] })),
1952            ("POST /reactive/ack".into(), 409, not_holder()),
1953        ])
1954        .await;
1955
1956        sync(&base, &agent).await;
1957
1958        assert_fenced(&agent, &rx);
1959        assert_eq!(
1960            *seen.lock().unwrap(),
1961            ["POST /agents/lease", pending.as_str(), "POST /reactive/ack"],
1962            "nothing delivered, so nothing released"
1963        );
1964    }
1965
1966    // ── One pull for every agent (POST /reactive/pending-batch) ──────────
1967
1968    /// Run one `InjectAvailable` pull of `agents` through the batch path.
1969    async fn sync_batched(base: &str, agents: &[String]) -> Option<usize> {
1970        let mstore = Arc::new(crate::backend::storage::store::Store::open_in_memory().unwrap());
1971        let handler = crate::backend::reactive::handler::get_global_handler();
1972        super::sync_agents_batched(base, agents, "test-token", &reqwest::Client::new(), &mstore, handler)
1973            .await
1974            .map(|outcomes| outcomes.len())
1975    }
1976
1977    #[tokio::test]
1978    async fn one_request_pulls_for_every_agent_and_only_agents_with_messages_are_claimed() {
1979        let [a, b, c, d] = std::array::from_fn(|_| format!("agent-{}", uuid::Uuid::new_v4()));
1980        let (_tmp, _held, rx) = hold_locally(&b);
1981        let results = serde_json::json!({ "results": {
1982            a.clone(): { "agent_id": a.clone(), "injections": [{ "id": "inj-1", "message": "hello" }], "count": 1, "expired": [] },
1983            b.clone(): not_holder(),
1984            c.clone(): { "agent_id": c.clone(), "injections": [], "count": 0, "expired": [] },
1985            // d is missing from the answer: pulled on its own.
1986        }});
1987        let pd = format!("GET /reactive/pending/{d}");
1988        let (base, seen) = fake_relay(vec![
1989            lease_granted(),
1990            ("POST /reactive/pending-batch".into(), 200, results),
1991            ("POST /reactive/ack".into(), 200, serde_json::json!({ "acknowledged": [], "delivered_at": "2026-10-11T00:00:00Z" })),
1992            (pd.clone(), 200, serde_json::json!({ "injections": [] })),
1993        ])
1994        .await;
1995
1996        let agents = vec![a.clone(), b.clone(), c.clone(), d.clone()];
1997        assert_eq!(sync_batched(&base, &agents).await, Some(4));
1998
1999        assert_fenced(&b, &rx);
2000        let seen = seen.lock().unwrap();
2001        let count = |line: &str| seen.iter().filter(|s| *s == line).count();
2002        assert_eq!(count("POST /reactive/pending-batch"), 1, "{seen:?}");
2003        assert_eq!(count("POST /reactive/ack"), 1, "only {a} had messages: {seen:?}");
2004        assert_eq!(count(&pd), 1, "{d}, missing from the answer, pulled on its own: {seen:?}");
2005        assert!(
2006            !seen.iter().any(|s| s.starts_with("GET /reactive/pending/") && *s != pd),
2007            "no single pull for the agents the batch answered: {seen:?}"
2008        );
2009    }
2010
2011    #[tokio::test]
2012    async fn an_unavailable_entry_is_a_failed_pull_for_that_agent_only() {
2013        let [a, b] = std::array::from_fn(|_| format!("agent-{}", uuid::Uuid::new_v4()));
2014        let results = serde_json::json!({ "results": {
2015            a.clone(): { "error": "unavailable" },
2016            b.clone(): { "agent_id": b.clone(), "injections": [], "count": 0, "expired": [] },
2017        }});
2018        let (base, seen) = fake_relay(vec![lease_granted(), ("POST /reactive/pending-batch".into(), 200, results)]).await;
2019
2020        assert_eq!(sync_batched(&base, &[a.clone(), b.clone()]).await, Some(2));
2021        assert!(
2022            !seen.lock().unwrap().iter().any(|s| s.starts_with("GET /reactive/pending/")),
2023            "retried on the next pull, not at once"
2024        );
2025    }
2026
2027    #[tokio::test]
2028    async fn a_relay_without_the_batch_route_is_pulled_one_agent_at_a_time() {
2029        let [a, b] = std::array::from_fn(|_| format!("agent-{}", uuid::Uuid::new_v4()));
2030        let (pa, pb) = (format!("GET /reactive/pending/{a}"), format!("GET /reactive/pending/{b}"));
2031        let (base, seen) = fake_relay(vec![
2032            lease_granted(),
2033            (pa.clone(), 200, serde_json::json!({ "injections": [] })),
2034            (pb.clone(), 200, serde_json::json!({ "injections": [] })),
2035        ])
2036        .await;
2037        let agents = Arc::new(Mutex::new(HashSet::from([a.clone(), b.clone()])));
2038        let mstore = Arc::new(crate::backend::storage::store::Store::open_in_memory().unwrap());
2039
2040        assert!(!catch_up(&base, "test-token", &reqwest::Client::new(), &agents, &mstore).await);
2041        assert!(crate::muxbus::relay::batch_routes_missing(&base));
2042        assert!(!catch_up(&base, "test-token", &reqwest::Client::new(), &agents, &mstore).await);
2043
2044        let seen = seen.lock().unwrap();
2045        assert_eq!(seen.iter().filter(|s| *s == "POST /reactive/pending-batch").count(), 1, "asked once, then remembered: {seen:?}");
2046        assert_eq!(seen.iter().filter(|s| **s == pa || **s == pb).count(), 4, "both agents, both times: {seen:?}");
2047    }
2048
2049    #[tokio::test]
2050    async fn a_single_agent_is_pulled_with_the_single_route() {
2051        let a = format!("agent-{}", uuid::Uuid::new_v4());
2052        let (base, seen) = fake_relay(vec![lease_granted()]).await;
2053        assert_eq!(sync_batched(&base, &[a]).await, None);
2054        assert!(seen.lock().unwrap().is_empty());
2055    }
2056
2057    // ── Catch-up pull: a message must not wait for an unrelated wake ──────
2058    use super::{agents_to_seed, catch_up, CATCH_UP_EVERY};
2059    use std::collections::HashSet;
2060    use std::time::Duration;
2061
2062    // (2026-09-30: a jekt to manoz reached the relay at 23:12:14 and was
2063    // delivered at 23:21:00, when someone else's traffic woke the subscriber.)
2064
2065    #[test]
2066    fn the_seed_is_every_live_registered_agent_lowercased_and_deduplicated() {
2067        let registered: Vec<String> = ["Alice", "alice", "Bob", "Carol"].map(String::from).to_vec();
2068        let got = agents_to_seed(&registered, |a| a != "carol");
2069        assert_eq!(got, ["alice", "bob"], "Carol has no live pane; Alice appears once, lowercased");
2070    }
2071
2072    #[test]
2073    fn an_empty_registry_seeds_nothing() {
2074        assert!(agents_to_seed(&[], |_| true).is_empty());
2075    }
2076
2077    #[tokio::test]
2078    async fn a_catch_up_pulls_pending_for_every_registered_agent() {
2079        let (a, b) = (format!("agent-{}", uuid::Uuid::new_v4()), format!("agent-{}", uuid::Uuid::new_v4()));
2080        let (pa, pb) = (format!("GET /reactive/pending/{a}"), format!("GET /reactive/pending/{b}"));
2081        let (base, seen) = fake_relay(vec![
2082            lease_granted(),
2083            (pa.clone(), 200, serde_json::json!({ "injections": [] })),
2084            (pb.clone(), 200, serde_json::json!({ "injections": [] })),
2085        ])
2086        .await;
2087        let agents = Arc::new(Mutex::new(HashSet::from([a.clone(), b.clone()])));
2088        let mstore = Arc::new(crate::backend::storage::store::Store::open_in_memory().unwrap());
2089
2090        let reconnect = catch_up(&base, "test-token", &reqwest::Client::new(), &agents, &mstore).await;
2091
2092        assert!(!reconnect, "nothing went wrong, so no reconnect");
2093        let seen = seen.lock().unwrap();
2094        assert!(seen.contains(&pa), "pulled for {a}: {seen:?}");
2095        assert!(seen.contains(&pb), "pulled for {b}: {seen:?}");
2096    }
2097
2098    #[tokio::test]
2099    async fn a_catch_up_with_no_registered_agents_asks_the_relay_for_nothing() {
2100        let (base, seen) = fake_relay(vec![lease_granted()]).await;
2101        let agents = Arc::new(Mutex::new(HashSet::new()));
2102        let mstore = Arc::new(crate::backend::storage::store::Store::open_in_memory().unwrap());
2103
2104        assert!(!catch_up(&base, "test-token", &reqwest::Client::new(), &agents, &mstore).await);
2105
2106        assert!(seen.lock().unwrap().is_empty());
2107    }
2108
2109    #[test]
2110    fn the_fallback_interval_is_short_enough_to_matter_and_long_enough_to_be_cheap() {
2111        assert!(CATCH_UP_EVERY >= Duration::from_secs(30), "faster than this is polling");
2112        assert!(CATCH_UP_EVERY <= Duration::from_secs(300), "slower than this is the 8m46s we are fixing");
2113    }
2114
2115    /// The real session loop, against a local fake relay and WebSocket, for the
2116    /// exact failure of 2026-09-30: a jekt is already waiting for an agent
2117    /// when the session starts, and NO wake signal ever arrives. The relay
2118    /// never sends one here, so any pull is the catch-up, not a wake.
2119    #[tokio::test]
2120    async fn a_session_pulls_what_is_waiting_on_connect_and_when_an_agent_is_added() {
2121        use futures_util::StreamExt;
2122        // Process-global env, shared with the pkce tests that set the same URL.
2123        let _serial = crate::muxbus::pkce::TEST_SERIAL
2124            .lock()
2125            .unwrap_or_else(|e| e.into_inner());
2126
2127        let (a, b) = (format!("agent-{}", uuid::Uuid::new_v4()), format!("agent-{}", uuid::Uuid::new_v4()));
2128        let (pa, pb) = (format!("GET /reactive/pending/{a}"), format!("GET /reactive/pending/{b}"));
2129        let (rest, seen) = fake_relay(vec![
2130            lease_granted(),
2131            (pa.clone(), 200, serde_json::json!({ "injections": [] })),
2132            (pb.clone(), 200, serde_json::json!({ "injections": [] })),
2133        ])
2134        .await;
2135
2136        // A WebSocket relay that accepts, records what the client says, and
2137        // never sends a wake.
2138        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2139        let ws_url = format!("ws://{}", listener.local_addr().unwrap());
2140        let frames: Seen = Arc::default();
2141        let server = tokio::spawn({
2142            let frames = Arc::clone(&frames);
2143            async move {
2144                let (stream, _) = listener.accept().await.unwrap();
2145                let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap();
2146                while let Some(Ok(msg)) = ws.next().await {
2147                    if let tokio_tungstenite::tungstenite::Message::Text(t) = msg {
2148                        frames.lock().unwrap().push(t.to_string());
2149                    }
2150                }
2151            }
2152        });
2153
2154        std::env::set_var("AGENTMUX_MUXBUS_REST_URL", &rest);
2155        std::env::set_var("AGENTMUX_MUXBUS_WS_URL", &ws_url);
2156
2157        let agents = Arc::new(Mutex::new(HashSet::from([a.clone()])));
2158        let (ctrl_tx, mut ctrl_rx) = tokio::sync::mpsc::unbounded_channel::<super::CtrlMsg>();
2159        let mstore = Arc::new(crate::backend::storage::store::Store::open_in_memory().unwrap());
2160        let session = tokio::spawn({
2161            let (agents, mstore) = (Arc::clone(&agents), Arc::clone(&mstore));
2162            async move {
2163                super::connect_and_run("test-token", agents, &mut ctrl_rx, &mstore, &reqwest::Client::new()).await
2164            }
2165        });
2166
2167        async fn within_5s(what: &str, mut cond: impl FnMut() -> bool) {
2168            let deadline = std::time::Instant::now() + Duration::from_secs(5);
2169            while !cond() {
2170                assert!(std::time::Instant::now() < deadline, "timed out waiting for {what}");
2171                tokio::time::sleep(Duration::from_millis(20)).await;
2172            }
2173        }
2174
2175        // 1. Connected, subscribed, and pulled for the agent with no wake sent.
2176        within_5s(&pa, || seen.lock().unwrap().contains(&pa)).await;
2177        assert!(
2178            frames.lock().unwrap().iter().any(|f| f.contains("\"subscribe\"") && f.contains(&a)),
2179            "subscribed {a}: {:?}",
2180            frames.lock().unwrap()
2181        );
2182
2183        // 2. An agent added later is pulled for at once, again with no wake.
2184        agents.lock().unwrap().insert(b.clone());
2185        ctrl_tx.send(super::CtrlMsg::AddAgent(b.clone())).unwrap();
2186        within_5s(&pb, || seen.lock().unwrap().contains(&pb)).await;
2187
2188        session.abort();
2189        server.abort();
2190        std::env::remove_var("AGENTMUX_MUXBUS_REST_URL");
2191        std::env::remove_var("AGENTMUX_MUXBUS_WS_URL");
2192    }
2193}