Skip to main content

agentmux_srv/backend/
agent_admission.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! One live instance per agent — the host tier
5//! (`docs/specs/SPEC_AGENT_SINGLE_LIVE_INSTANCE_2026_09_24.md`, Phase 1).
6//!
7//! An agent identity (its UID) may be driven by at most one process on this
8//! host. Two layers:
9//!
10//! - [`acquire`] — the binding decision. A persistent pane claims the
11//!   agent's [`LeaseStore`] lease right before its CLI process is spawned
12//!   and holds it for that process's lifetime ([`HeldAgentLease`]): renewed
13//!   on its own task, released on drop, and **fenced** — a holder that loses
14//!   the lease (suspended past the TTL and reclaimed) is told through
15//!   `on_lost` so it stops driving, and [`HeldAgentLease::verify`] lets it
16//!   refuse a turn before starting one.
17//! - [`check_before_spawn`] — the early, read-only check the async spawn
18//!   paths run **before** they write anything shared on the agent's behalf
19//!   (spec I9: in the 2026-09-25 incident the second instance broke the
20//!   first by rebinding its account, before any spawn). It reads the lease
21//!   and, for instances too old to take one, asks every other live
22//!   AgentMux instance on this host whether it is running this UID.
23
24use agentmux_common::AUTH_KEY_HEADER;
25use std::sync::atomic::{AtomicBool, Ordering};
26use std::sync::Arc;
27
28use crate::registry::{ClaimantInfo, Lease, LeaseError, LeaseHolder, LeaseStore};
29
30/// The lease store under a shared agent registry root, `None` if there is
31/// no registry or its lease directory can't be opened (logged).
32pub fn lease_store_for(registry: Option<Arc<crate::registry::Registry>>) -> Option<Arc<LeaseStore>> {
33    let registry = registry?;
34    match LeaseStore::open(registry.root()) {
35        Ok(store) => Some(Arc::new(store)),
36        Err(e) => {
37            tracing::error!(error = %e, "agent_admission: cannot open the lease store — no single-instance check");
38            None
39        }
40    }
41}
42
43/// What this instance records about itself in a lease it takes.
44pub fn claimant_info() -> ClaimantInfo {
45    ClaimantInfo {
46        channel: crate::backend::reactive::registry::local_channel_id(),
47        version: env!("CARGO_PKG_VERSION").to_string(),
48    }
49}
50
51/// The refusal a user sees when `agent` is already running elsewhere.
52pub fn denied_message(agent: &str, holder: Option<&LeaseHolder>) -> String {
53    let agent = if agent.is_empty() { "This agent" } else { agent };
54    let Some(h) = holder else {
55        return format!(
56            "{agent} is already running in another AgentMux instance on this host. \
57             Close it there, then try again — an agent can only run in one place at a time."
58        );
59    };
60    let mut place = Vec::new();
61    if !h.channel.is_empty() {
62        place.push(format!("channel {}", h.channel));
63    }
64    if !h.version.is_empty() {
65        place.push(format!("v{}", h.version));
66    }
67    if h.pid != 0 {
68        place.push(format!("pid {}", h.pid));
69    }
70    if h.acquired_at_ms > 0 {
71        let mins = (agentmux_common::time::now_ms() - h.acquired_at_ms).max(0) / 60_000;
72        place.push(format!("running for {mins} min"));
73    }
74    let place = if place.is_empty() { String::new() } else { format!(" ({})", place.join(", ")) };
75    format!(
76        "{agent} is already running in another AgentMux instance on this host{place}. \
77         Close it there, then try again — an agent can only run in one place at a time."
78    )
79}
80
81/// A lease held for the lifetime of one CLI process. Dropping it stops the
82/// renewal task and releases the lease.
83pub struct HeldAgentLease {
84    store: Arc<LeaseStore>,
85    lease: Lease,
86    agent: String,
87    /// Set once, by whichever notices first (the renewal task or a pre-turn
88    /// `verify`): the refusal every later turn repeats — so the second and
89    /// later refusals still name who took the agent over (ReAgent P2 on
90    /// #3738), not a detail-less fallback.
91    lost: Arc<LostReason>,
92    stopped: Arc<AtomicBool>,
93    /// When the lease was last confirmed ours (claim, renew or verify), in
94    /// ms. [`RecordFence`] trusts the in-memory state only while this is
95    /// within the TTL.
96    last_ok_ms: Arc<std::sync::atomic::AtomicI64>,
97}
98
99/// The first loss message, kept for every later refusal.
100#[derive(Default)]
101struct LostReason(std::sync::Mutex<Option<String>>);
102
103impl LostReason {
104    fn get(&self) -> Option<String> {
105        self.0.lock().unwrap_or_else(|e| e.into_inner()).clone()
106    }
107
108    /// Record `msg` unless a reason is already recorded; return the one kept.
109    fn set_first(&self, msg: String) -> String {
110        self.0.lock().unwrap_or_else(|e| e.into_inner()).get_or_insert(msg).clone()
111    }
112}
113
114impl HeldAgentLease {
115    /// The ownership generation this lease was granted under.
116    pub fn epoch(&self) -> u64 {
117        self.lease.epoch()
118    }
119
120    /// The agent UID this lease is for — its key.
121    pub fn agent_uid(&self) -> &str {
122        self.lease.instance_id()
123    }
124
125    /// A cheap handle the agent's output writer checks before each append
126    /// to the agent's shared record (spec §4.3, Phase 3).
127    pub fn record_fence(&self) -> RecordFence {
128        RecordFence {
129            store: Arc::clone(&self.store),
130            lease: self.lease.clone(),
131            agent: self.agent.clone(),
132            lost: Arc::clone(&self.lost),
133            last_ok_ms: Arc::clone(&self.last_ok_ms),
134        }
135    }
136
137    /// The pre-turn fence check (spec I4). `Err` means another instance now
138    /// holds this agent: do not start the turn. A transient I/O error is not
139    /// a loss — the renewal task decides that — so it does not refuse.
140    pub fn verify(&self) -> Result<(), String> {
141        if let Some(why) = self.lost.get() {
142            return Err(why);
143        }
144        match self.store.verify(&self.lease) {
145            Ok(()) => {
146                self.last_ok_ms.store(agentmux_common::time::now_ms(), Ordering::SeqCst);
147                Ok(())
148            }
149            Err(LeaseError::HeldByOther { holder, .. }) => {
150                Err(self.lost.set_first(lost_message(&self.agent, holder.as_ref())))
151            }
152            Err(LeaseError::Io(e)) => {
153                tracing::warn!(agent = %self.agent, error = %e, "agent lease: verify failed (io) — not treating as lost");
154                Ok(())
155            }
156        }
157    }
158}
159
160impl Drop for HeldAgentLease {
161    fn drop(&mut self) {
162        self.stopped.store(true, Ordering::SeqCst);
163        unregister_by_name(&self.agent, &self.lost);
164        if let Err(e) = self.store.release(&self.lease) {
165            tracing::warn!(
166                agent = %self.agent,
167                instance_id = %self.lease.instance_id(),
168                error = %e,
169                "agent lease: release failed (self-heals via TTL expiry)"
170            );
171        }
172    }
173}
174
175fn lost_message(agent: &str, holder: Option<&LeaseHolder>) -> String {
176    let agent = if agent.is_empty() { "This agent" } else { agent };
177    match holder {
178        Some(_) => format!(
179            "{agent} was taken over by another AgentMux instance on this host — {}",
180            denied_message(agent, holder)
181        ),
182        None => format!("{agent} lost its single-instance lease; it will not start another turn here."),
183    }
184}
185
186/// Claim `uid` for this process's `block_id`, and keep it renewed until the
187/// returned handle is dropped. `on_lost` runs (once, off the caller's
188/// thread) if the lease is found to belong to someone else — the holder must
189/// then stop driving the agent.
190///
191/// `Err` is the user-facing refusal. The host tier fails closed (spec D2):
192/// an I/O error claiming the lease refuses rather than running unguarded,
193/// after one short retry for a transient sharing violation.
194pub fn acquire(
195    store: &Arc<LeaseStore>,
196    uid: &str,
197    agent: &str,
198    boot_id: &Arc<str>,
199    block_id: &str,
200    session_id_hint: Option<&str>,
201    on_lost: impl Fn(String) + Send + Sync + 'static,
202) -> Result<HeldAgentLease, String> {
203    if let Some(refusal) = handover_hold_refusal(uid, agent) {
204        tracing::info!(agent, uid, block_id, "agent_admission.denied: inside a handover hold");
205        return Err(refusal);
206    }
207    let info = claimant_info();
208    let mut result = store.claim_as(uid, boot_id, block_id, session_id_hint, &info);
209    if matches!(result, Err(LeaseError::Io(_))) {
210        std::thread::sleep(std::time::Duration::from_millis(100));
211        result = store.claim_as(uid, boot_id, block_id, session_id_hint, &info);
212    }
213    let lease = match result {
214        Ok(lease) => lease,
215        Err(LeaseError::HeldByOther { holder, owner_boot_id, age_ms, .. }) => {
216            tracing::warn!(
217                agent,
218                uid,
219                block_id,
220                owner_boot_id = %owner_boot_id,
221                holder_block = holder.as_ref().map(|h| h.block_id.as_str()).unwrap_or(""),
222                holder_channel = holder.as_ref().map(|h| h.channel.as_str()).unwrap_or(""),
223                age_ms,
224                "agent_admission.denied: agent is live in another instance"
225            );
226            return Err(denied_message(agent, holder.as_ref()));
227        }
228        Err(LeaseError::Io(e)) => {
229            tracing::error!(agent, uid, block_id, error = %e, "agent_admission.unknown: lease claim failed (io) — refusing");
230            return Err(format!(
231                "Could not confirm that {} isn't already running elsewhere (lease file error: {e}). \
232                 Refusing to start it twice.",
233                if agent.is_empty() { "this agent" } else { agent }
234            ));
235        }
236    };
237    tracing::info!(agent, uid, block_id, epoch = lease.epoch(), "agent_admission.granted");
238    let on_lost: Arc<dyn Fn(String) + Send + Sync> = Arc::new(on_lost);
239
240    let held = HeldAgentLease {
241        store: Arc::clone(store),
242        lease: lease.clone(),
243        agent: agent.to_string(),
244        lost: Arc::new(LostReason::default()),
245        stopped: Arc::new(AtomicBool::new(false)),
246        last_ok_ms: Arc::new(std::sync::atomic::AtomicI64::new(agentmux_common::time::now_ms())),
247    };
248    spawn_renewal(
249        Arc::clone(store),
250        lease,
251        agent.to_string(),
252        Arc::clone(&held.lost),
253        Arc::clone(&held.stopped),
254        Arc::clone(&held.last_ok_ms),
255        {
256            let on_lost = Arc::clone(&on_lost);
257            move |why: String| on_lost(why)
258        },
259    );
260    register_by_name(agent, &held.lost, on_lost);
261    Ok(held)
262}
263
264fn spawn_renewal(
265    store: Arc<LeaseStore>,
266    lease: Lease,
267    agent: String,
268    lost: Arc<LostReason>,
269    stopped: Arc<AtomicBool>,
270    last_ok_ms: Arc<std::sync::atomic::AtomicI64>,
271    on_lost: impl Fn(String) + Send + Sync + 'static,
272) {
273    let Ok(handle) = tokio::runtime::Handle::try_current() else {
274        // No runtime (unit tests that spawn synchronously): the lease still
275        // guards the claim and is released on drop; it just isn't renewed.
276        return;
277    };
278    handle.spawn(async move {
279        let mut interval =
280            tokio::time::interval(std::time::Duration::from_millis(crate::registry::RENEW_INTERVAL_MS));
281        // A laptop waking from sleep fires one tick, not a burst.
282        interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
283        interval.tick().await; // the first tick is immediate; the claim just wrote the file
284        let acquired_at_ms = agentmux_common::time::now_ms();
285        let my_host = this_host_label();
286        let mut ticks: u32 = 0;
287        loop {
288            interval.tick().await;
289            if stopped.load(Ordering::SeqCst) {
290                break;
291            }
292            ticks = ticks.wrapping_add(1);
293            // LAN tier (spec §4.4): two hosts can start the same agent at the
294            // same moment, before either sees the other. Every
295            // LAN_RECHECK_TICKS renewals, ask the LAN again; the loser of the
296            // deterministic tie-break fences itself.
297            if ticks % LAN_RECHECK_TICKS == 0 {
298                if let Some(winner) = lan_holders(lease.instance_id())
299                    .await
300                    .into_iter()
301                    .find(|p| peer_wins(p.acquired_at_ms, &p.hostname, acquired_at_ms, &my_host))
302                {
303                    if stopped.load(Ordering::SeqCst) {
304                        break;
305                    }
306                    let why = lost.set_first(lan_lost_message(&agent, &winner));
307                    tracing::error!(
308                        agent = %agent,
309                        instance_id = %lease.instance_id(),
310                        peer_host = %winner.hostname,
311                        "agent_admission.fenced: another computer on the LAN runs this agent and wins the tie-break — stopping this one"
312                    );
313                    on_lost(why);
314                    break;
315                }
316            }
317            let (s, l) = (Arc::clone(&store), lease.clone());
318            let renewed = tokio::task::spawn_blocking(move || s.renew(&l)).await;
319            // Checked again after the blocking renew: a release that ran
320            // meanwhile must not be reported as a loss.
321            if stopped.load(Ordering::SeqCst) {
322                break;
323            }
324            match renewed {
325                Ok(Ok(())) => last_ok_ms.store(agentmux_common::time::now_ms(), Ordering::SeqCst),
326                Ok(Err(LeaseError::HeldByOther { holder, owner_boot_id, .. })) => {
327                    let why = lost.set_first(lost_message(&agent, holder.as_ref()));
328                    tracing::error!(
329                        agent = %agent,
330                        instance_id = %lease.instance_id(),
331                        owner_boot_id = %owner_boot_id,
332                        "agent_admission.fenced: lease lost to another instance — stopping this one"
333                    );
334                    on_lost(why);
335                    break;
336                }
337                Ok(Err(LeaseError::Io(e))) => {
338                    tracing::warn!(agent = %agent, error = %e, "agent lease: renew failed (io) — will retry");
339                }
340                Err(join) => {
341                    tracing::warn!(agent = %agent, error = %join, "agent lease: renew task panicked — will retry");
342                }
343            }
344        }
345    });
346}
347
348/// A live instance on this host that is running the same agent UID but
349/// took no lease (it predates Phase 1) — found by asking it.
350#[derive(Clone, PartialEq, Eq)]
351pub struct LegacyHolder {
352    pub channel: String,
353    pub local_url: String,
354    pub block_id: String,
355    /// That srv's full auth key, from its shared-registry entry — what a
356    /// takeover request authenticates with. Never logged.
357    pub auth_key: String,
358}
359
360/// The early, read-only admission check (see module doc). `Err` is the
361/// user-facing refusal; callers must run it before any shared write.
362pub async fn check_before_spawn(
363    store: Option<Arc<LeaseStore>>,
364    uid: &str,
365    agent: &str,
366    own_boot_id: &str,
367) -> Result<(), String> {
368    if uid.is_empty() {
369        return Ok(());
370    }
371    if let Some(refusal) = handover_hold_refusal(uid, agent) {
372        return Err(refusal);
373    }
374    if let Some(store) = store {
375        let (u, b) = (uid.to_string(), own_boot_id.to_string());
376        match tokio::task::spawn_blocking(move || store.live_holder_other_than(&u, &b)).await {
377            Ok(Ok(Some(holder))) => {
378                tracing::warn!(agent, uid, holder_channel = %holder.channel, "agent_admission.denied (early check)");
379                return Err(denied_message(agent, Some(&holder)));
380            }
381            Ok(Ok(None)) => {}
382            Ok(Err(e)) => tracing::warn!(agent, uid, error = %e, "agent_admission: early lease read failed — the spawn-time claim still decides"),
383            Err(e) => tracing::warn!(agent, uid, error = %e, "agent_admission: early lease read panicked"),
384        }
385    }
386    if let Some(where_) = crate::muxbus::wan_lease::held_elsewhere(agent) {
387        tracing::warn!(agent, uid, holder = %where_, "agent_admission.denied: the muxbus relay says another instance holds this agent");
388        return Err(wan_denied_message(agent, &where_));
389    }
390    if let Some(peer) = lan_holders(uid).await.into_iter().next() {
391        tracing::warn!(agent, uid, peer_host = %peer.hostname, "agent_admission.denied: agent is live on a LAN peer");
392        return Err(lan_denied_message(agent, &peer));
393    }
394    if let Some(legacy) = probe_other_instances(uid).await {
395        tracing::warn!(
396            agent,
397            uid,
398            holder_channel = %legacy.channel,
399            holder_url = %legacy.local_url,
400            holder_block = %legacy.block_id,
401            "agent_admission.compat_probe_hit: agent is live in an instance that takes no lease"
402        );
403        let holder = LeaseHolder {
404            owner_boot_id: String::new(),
405            block_id: legacy.block_id,
406            pid: 0,
407            channel: legacy.channel,
408            version: String::new(),
409            hostname: String::new(),
410            epoch: 0,
411            acquired_at_ms: 0,
412            renewed_at_ms: 0,
413        };
414        return Err(denied_message(agent, Some(&holder)));
415    }
416    Ok(())
417}
418
419static PROBE_CLIENT: std::sync::LazyLock<reqwest::Client> = std::sync::LazyLock::new(|| {
420    reqwest::Client::builder()
421        .timeout(std::time::Duration::from_millis(1_500))
422        .build()
423        .unwrap_or_default()
424});
425
426/// Ask every other live AgentMux instance on this host (from the
427/// host-global cross-channel registry) whether it has `uid` registered.
428/// Matched by UID, never by name (display names are not unique — Codex P2
429/// on #3730). An instance too old to report UIDs, or one that doesn't
430/// answer, is not evidence either way and is skipped (logged).
431pub async fn probe_other_instances(uid: &str) -> Option<LegacyHolder> {
432    let shared = crate::registry::resolve_shared_reactive_dir()?;
433    let own_channel = crate::backend::reactive::registry::local_channel_id();
434    let own_pid = std::process::id();
435    let entries = tokio::task::spawn_blocking(move || {
436        crate::backend::reactive::registry::list_all_shared(&shared)
437    })
438    .await
439    .ok()?;
440    let mut seen = std::collections::HashSet::new();
441    let others: Vec<_> = entries
442        .into_iter()
443        .filter(|e| e.channel != own_channel && e.pid != own_pid && !e.local_url.is_empty())
444        .filter(|e| crate::backend::reactive::registry::pid_alive(e.pid))
445        .filter(|e| seen.insert(e.local_url.clone()))
446        .collect();
447    let probes = others.into_iter().map(|e| async move {
448        let found = registered_uid_blocks(&e.local_url, &e.auth_key, uid).await;
449        (e, found)
450    });
451    for (entry, found) in futures_util::future::join_all(probes).await {
452        match found {
453            Ok(Some(block_id)) => {
454                return Some(LegacyHolder {
455                    channel: entry.channel,
456                    local_url: entry.local_url,
457                    block_id,
458                    auth_key: entry.auth_key,
459                })
460            }
461            Ok(None) => {}
462            Err(why) => tracing::debug!(
463                channel = %entry.channel,
464                url = %entry.local_url,
465                why,
466                "agent_admission: other instance not probeable — skipped"
467            ),
468        }
469    }
470    None
471}
472
473/// `GET <local_url>/agentmux/reactive/agents` and return the block of the
474/// first registration whose `uid` equals `uid`.
475async fn registered_uid_blocks(local_url: &str, auth_key: &str, uid: &str) -> Result<Option<String>, String> {
476    let mut req = PROBE_CLIENT.get(format!("{local_url}/agentmux/reactive/agents"));
477    if !auth_key.is_empty() {
478        req = req.header(AUTH_KEY_HEADER, auth_key);
479    }
480    let resp = req.send().await.map_err(|e| e.to_string())?;
481    if !resp.status().is_success() {
482        return Err(format!("HTTP {}", resp.status()));
483    }
484    let regs: Vec<serde_json::Value> = resp.json().await.map_err(|e| e.to_string())?;
485    Ok(find_uid_block(&regs, uid))
486}
487
488/// The block of the first registration in `regs` carrying `uid`.
489fn find_uid_block(regs: &[serde_json::Value], uid: &str) -> Option<String> {
490    regs.iter().find_map(|r| {
491        let r_uid = r.get("uid").and_then(|v| v.as_str())?;
492        (r_uid.trim().eq_ignore_ascii_case(uid.trim())).then(|| {
493            r.get("block_id").and_then(|v| v.as_str()).unwrap_or_default().to_string()
494        })
495    })
496}
497
498// ── Phase 5: WAN tier — the relay decides (spec §4.5) ──────────────────
499
500type LostCallback = Arc<dyn Fn(String) + Send + Sync>;
501
502/// The held leases of this process by agent **name** (lower-cased) — the key
503/// the muxbus relay and its pending queue use — so the WAN side can fence the
504/// local holder when the relay says another instance holds the agent.
505static HELD_BY_NAME: std::sync::LazyLock<std::sync::Mutex<std::collections::HashMap<String, (Arc<LostReason>, LostCallback)>>> =
506    std::sync::LazyLock::new(Default::default);
507
508fn register_by_name(agent: &str, lost: &Arc<LostReason>, on_lost: LostCallback) {
509    if agent.trim().is_empty() {
510        return;
511    }
512    HELD_BY_NAME
513        .lock()
514        .unwrap_or_else(|e| e.into_inner())
515        .insert(agent.trim().to_lowercase(), (Arc::clone(lost), on_lost));
516}
517
518fn unregister_by_name(agent: &str, lost: &Arc<LostReason>) {
519    let mut map = HELD_BY_NAME.lock().unwrap_or_else(|e| e.into_inner());
520    let key = agent.trim().to_lowercase();
521    // Only our own entry: a newer lease for the same name may have replaced it.
522    if map.get(&key).is_some_and(|(l, _)| Arc::ptr_eq(l, lost)) {
523        map.remove(&key);
524    }
525}
526
527/// The relay says another instance holds `agent_name` (WAN tier): fence this
528/// process's holder of that agent, if any — record the loss (the pre-turn
529/// fence refuses its next turn) and stop its CLI process. `true` when a
530/// local holder was fenced.
531pub fn fence_agent_named(agent_name: &str, where_: &str) -> bool {
532    let entry = HELD_BY_NAME
533        .lock()
534        .unwrap_or_else(|e| e.into_inner())
535        .get(&agent_name.trim().to_lowercase())
536        .cloned();
537    let Some((lost, on_lost)) = entry else { return false };
538    if lost.get().is_some() {
539        return false;
540    }
541    let why = lost.set_first(wan_lost_message(agent_name, where_));
542    tracing::error!(agent = agent_name, holder = where_, "agent_admission.fenced: the muxbus relay says another instance holds this agent — stopping this one");
543    on_lost(why);
544    true
545}
546
547/// Where the relay says the holder is: "on another computer (computer X, …)",
548/// or "on this computer (channel Y, vZ)" for another instance on this host
549/// (charlie, 2026-09-26: a same-host holder was called "another computer").
550fn wan_place(where_: &str) -> String {
551    if crate::muxbus::wan_lease::holder_on_this_computer(where_) {
552        let rest = where_.trim_start_matches("this computer").trim_start_matches(", ");
553        return if rest.is_empty() { "on this computer".to_string() } else { format!("on this computer ({rest})") };
554    }
555    if where_.is_empty() { "on another computer".to_string() } else { format!("on another computer ({where_})") }
556}
557
558/// The refusal when the relay says another instance runs the agent. Carries
559/// the host refusal's marker, so it classifies as `LiveElsewhere`.
560pub fn wan_denied_message(agent: &str, where_: &str) -> String {
561    let agent = if agent.is_empty() { "This agent" } else { agent };
562    format!(
563        "{agent} is already running in another AgentMux instance {}, according to AgentMux cloud. \
564         Close it there, then try again — an agent can only run in one place at a time.",
565        wan_place(where_)
566    )
567}
568
569fn wan_lost_message(agent: &str, where_: &str) -> String {
570    let agent = if agent.is_empty() { "This agent" } else { agent };
571    format!(
572        "{agent} was taken over by another AgentMux instance {}, according to AgentMux cloud, which \
573         had it first. It will not start another turn here.",
574        wan_place(where_)
575    )
576}
577
578// ── Phase 4: LAN tier — detect and yield (spec §4.4) ───────────────────
579
580/// Renewals between LAN re-checks: 6 × RENEW_INTERVAL_MS = 30 s.
581const LAN_RECHECK_TICKS: u32 = 6;
582
583/// Start times closer than this are "simultaneous": clocks on two hosts are
584/// not compared finer than this; the hostname decides instead (§4.4).
585pub const LAN_SKEW_BUDGET_MS: i64 = 10_000;
586
587/// How long one LAN peer gets to answer.
588const LAN_QUERY_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(1_500);
589
590static LAN_DISCOVERY: std::sync::OnceLock<Arc<crate::backend::lan_discovery::LanDiscoveryController>> =
591    std::sync::OnceLock::new();
592
593/// Called once at bootstrap: gives admission the LAN peer list. Without it
594/// (tests, LAN discovery off) the LAN tier is skipped.
595pub fn set_lan_discovery(lan: Arc<crate::backend::lan_discovery::LanDiscoveryController>) {
596    let _ = LAN_DISCOVERY.set(lan);
597}
598
599/// A LAN peer's answer to `GET /agentmux/agent/holding?uid=…`.
600#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
601pub struct PeerHolding {
602    pub held: bool,
603    #[serde(default)]
604    pub hostname: String,
605    #[serde(default)]
606    pub channel: String,
607    #[serde(default)]
608    pub version: String,
609    #[serde(default)]
610    pub acquired_at_ms: i64,
611}
612
613/// This host's label, as a LAN peer reports it and as the tie-break compares.
614fn this_host_label() -> String {
615    crate::backend::reactive::registry::local_host_label()
616}
617
618/// What this host answers to a LAN peer asking about `uid`: held when any
619/// instance on this host holds a live lease for it.
620pub fn local_holding(store: Option<&Arc<LeaseStore>>, uid: &str) -> PeerHolding {
621    let holder = store.and_then(|s| s.live_holder_other_than(uid, "").ok().flatten());
622    match holder {
623        Some(h) => PeerHolding {
624            held: true,
625            hostname: this_host_label(),
626            channel: h.channel,
627            version: h.version,
628            acquired_at_ms: h.acquired_at_ms,
629        },
630        None => PeerHolding { hostname: this_host_label(), ..Default::default() },
631    }
632}
633
634/// Every reachable LAN peer **on another host** that says it holds `uid`.
635/// Unreachable or older peers (no route) are skipped: not evidence either
636/// way (D2, fail open). Answers from this host are dropped: another AgentMux
637/// instance here also shows up as a LAN peer, but it reads this host's own
638/// lease store, so it would report this process's own lease back — the
639/// host tier already governs this host.
640pub async fn lan_holders(uid: &str) -> Vec<PeerHolding> {
641    let Some(lan) = LAN_DISCOVERY.get() else { return Vec::new() };
642    let peers = lan.get_instances();
643    if peers.is_empty() || uid.is_empty() {
644        return Vec::new();
645    }
646    let queries = peers.into_iter().map(|p| async move {
647        let url = format!("http://{}:{}/agentmux/agent/holding", p.address, p.port);
648        let req = PROBE_CLIENT
649            .get(url)
650            .query(&[("uid", uid)])
651            .header(AUTH_KEY_HEADER, p.auth_key.as_str())
652            .timeout(LAN_QUERY_TIMEOUT);
653        match req.send().await {
654            Ok(r) if r.status().is_success() => r.json::<PeerHolding>().await.ok(),
655            _ => None,
656        }
657    });
658    futures_util::future::join_all(queries)
659        .await
660        .into_iter()
661        .flatten()
662        .filter(|h| h.held && !is_this_host(&h.hostname))
663        .collect()
664}
665
666fn is_this_host(hostname: &str) -> bool {
667    hostname.trim().eq_ignore_ascii_case(&this_host_label())
668}
669
670/// The LAN tie-break (§4.4): does a peer that started the agent at
671/// `peer_at` on `peer_host` win over this host's claim at `mine_at`? The
672/// earlier start wins; starts within the skew budget are simultaneous and
673/// the lower hostname wins. Deterministic and symmetric: exactly one side
674/// wins for any pair of distinct hosts.
675pub fn peer_wins(peer_at: i64, peer_host: &str, mine_at: i64, my_host: &str) -> bool {
676    if (peer_at - mine_at).abs() < LAN_SKEW_BUDGET_MS {
677        peer_host < my_host
678    } else {
679        peer_at < mine_at
680    }
681}
682
683fn lan_place(peer: &PeerHolding) -> String {
684    let mut place = Vec::new();
685    if !peer.hostname.is_empty() {
686        place.push(format!("computer {}", peer.hostname));
687    }
688    if !peer.channel.is_empty() {
689        place.push(format!("channel {}", peer.channel));
690    }
691    if !peer.version.is_empty() {
692        place.push(format!("v{}", peer.version));
693    }
694    if place.is_empty() { String::new() } else { format!(" ({})", place.join(", ")) }
695}
696
697/// The refusal when a LAN peer runs the agent. Carries the same marker as
698/// the host refusal, so it classifies as `LiveElsewhere`.
699pub fn lan_denied_message(agent: &str, peer: &PeerHolding) -> String {
700    let agent = if agent.is_empty() { "This agent" } else { agent };
701    format!(
702        "{agent} is already running in another AgentMux instance on another computer on your network{}. \
703         Close it there, then try again — an agent can only run in one place at a time.",
704        lan_place(peer)
705    )
706}
707
708fn lan_lost_message(agent: &str, peer: &PeerHolding) -> String {
709    let agent = if agent.is_empty() { "This agent" } else { agent };
710    format!(
711        "{agent} was taken over by another AgentMux instance on another computer on your network{}, which started it \
712         first. It will not start another turn here.",
713        lan_place(peer)
714    )
715}
716
717// ── Phase 2: takeover (spec §4.6) ────────────────────────────────────────
718
719/// Wording every single-live-instance refusal contains (lower-cased), so
720/// the failure classifier and callers can recognise one without a type.
721///
722/// Only refusals that mean *another instance holds the agent*. `acquire`'s
723/// lease-file I/O failure ("… Refusing to start it twice.") is deliberately
724/// not one: it is a local, possibly transient error, and classifying it as
725/// "running in another instance" would offer Take over instead of Retry
726/// (ReAgent P1 on #3742).
727pub const REFUSAL_MARKERS: [&str; 3] = [
728    "is already running in another agentmux instance",
729    "was taken over by another agentmux instance",
730    "lost its single-instance lease",
731];
732
733/// Is `message` a single-live-instance refusal (any of this module's
734/// user-facing refusal texts)?
735pub fn is_admission_refusal(message: &str) -> bool {
736    let m = message.to_ascii_lowercase();
737    REFUSAL_MARKERS.iter().any(|k| m.contains(k))
738}
739
740/// How long, after this srv hands an agent over to another instance, it
741/// refuses to claim that agent again on its own. Covers the gap between the
742/// holder's process exiting and the requester claiming, in which a jekt
743/// delivered here would otherwise respawn the agent and win it straight
744/// back. A takeover the user starts from this srv clears it.
745pub const HANDOVER_HOLD: std::time::Duration = std::time::Duration::from_secs(30);
746
747/// One entry per agent this srv handed over: until when, and to whom (for
748/// the refusal text). A single map, so an expired hold goes away whole
749/// (ReAgent P2 on #3742).
750static HANDOVER_HOLDS: std::sync::LazyLock<std::sync::Mutex<std::collections::HashMap<String, (std::time::Instant, String)>>> =
751    std::sync::LazyLock::new(Default::default);
752
753/// Record that this srv just handed `uid` over (see [`HANDOVER_HOLD`]).
754pub fn hold_after_handover(uid: &str, to: &str) {
755    HANDOVER_HOLDS
756        .lock()
757        .unwrap_or_else(|e| e.into_inner())
758        .insert(uid.to_string(), (std::time::Instant::now() + HANDOVER_HOLD, to.to_string()));
759}
760
761/// Drop any handover hold on `uid` — the user is taking it back.
762pub fn clear_handover_hold(uid: &str) {
763    HANDOVER_HOLDS.lock().unwrap_or_else(|e| e.into_inner()).remove(uid);
764}
765
766/// `Some(refusal)` while `uid` is inside a handover hold on this srv. An
767/// expired hold is removed here.
768fn handover_hold_refusal(uid: &str, agent: &str) -> Option<String> {
769    let mut holds = HANDOVER_HOLDS.lock().unwrap_or_else(|e| e.into_inner());
770    let (until, to) = holds.get(uid)?.clone();
771    if until <= std::time::Instant::now() {
772        holds.remove(uid);
773        return None;
774    }
775    let agent = if agent.is_empty() { "This agent" } else { agent };
776    Some(format!(
777        "{agent} was taken over by another AgentMux instance on this host{}. \
778         Use Take over to bring it back here.",
779        if to.is_empty() { String::new() } else { format!(" ({to})") }
780    ))
781}
782
783/// The writer-side fence on the agent's shared record (spec §4.3, Phase 3).
784///
785/// Cheap in the common case: while the lease was confirmed ours within the
786/// TTL, nobody can have reclaimed it, so appends go ahead on in-memory state
787/// alone. After a longer gap — the holder was suspended, and its CLI and
788/// output reader resume together with the renewal task — the first append
789/// checks the lease file before writing, so output a reclaimed holder
790/// flushes on waking never reaches the record the new holder is writing.
791#[derive(Clone)]
792pub struct RecordFence {
793    store: Arc<LeaseStore>,
794    lease: Lease,
795    agent: String,
796    lost: Arc<LostReason>,
797    last_ok_ms: Arc<std::sync::atomic::AtomicI64>,
798}
799
800impl RecordFence {
801    /// May this process append to the agent's shared record now?
802    pub fn may_write(&self) -> bool {
803        if self.lost.get().is_some() {
804            return false;
805        }
806        let now = agentmux_common::time::now_ms();
807        let age = now - self.last_ok_ms.load(Ordering::SeqCst);
808        if age >= 0 && (age as u64) <= crate::registry::LEASE_TTL_MS {
809            return true;
810        }
811        match self.store.verify(&self.lease) {
812            Ok(()) => {
813                self.last_ok_ms.store(now, Ordering::SeqCst);
814                true
815            }
816            Err(LeaseError::HeldByOther { holder, .. }) => {
817                self.lost.set_first(lost_message(&self.agent, holder.as_ref()));
818                tracing::error!(
819                    agent = %self.agent,
820                    instance_id = %self.lease.instance_id(),
821                    "agent_admission.fenced: output from a process that lost its lease kept out of the shared record"
822                );
823                false
824            }
825            // An I/O hiccup is not a loss (same rule as `verify`).
826            Err(LeaseError::Io(_)) => true,
827        }
828    }
829}
830
831/// `zone` unless `fence` says this process may no longer write the agent's
832/// shared record.
833pub fn fenced_zone<'a>(zone: Option<&'a str>, fence: &Option<RecordFence>) -> Option<&'a str> {
834    match fence {
835        Some(f) if zone.is_some() && !f.may_write() => None,
836        _ => zone,
837    }
838}
839
840/// Where the instance currently running an agent can be reached.
841#[derive(Clone, PartialEq, Eq)]
842pub struct HolderEndpoint {
843    pub channel: String,
844    pub local_url: String,
845    /// Never logged.
846    pub auth_key: String,
847}
848
849impl std::fmt::Debug for HolderEndpoint {
850    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
851        f.debug_struct("HolderEndpoint")
852            .field("channel", &self.channel)
853            .field("local_url", &self.local_url)
854            .finish_non_exhaustive()
855    }
856}
857
858/// Find the other instance on this host that runs `uid`: the lease holder
859/// (its channel's live srv, from the shared registry) or — for an instance
860/// that takes no lease — the compat probe's hit. `None` when nobody else
861/// runs it, or the holder's srv can't be found.
862pub async fn locate_holder(store: Option<Arc<LeaseStore>>, uid: &str, own_boot_id: &str) -> Option<HolderEndpoint> {
863    if let Some(store) = store {
864        let (u, b) = (uid.to_string(), own_boot_id.to_string());
865        let holder = tokio::task::spawn_blocking(move || store.live_holder_other_than(&u, &b))
866            .await
867            .ok()
868            .and_then(|r| r.ok())
869            .flatten();
870        if let Some(holder) = holder.filter(|h| !h.channel.is_empty()) {
871            if let Some(endpoint) = endpoint_for_channel(&holder.channel).await {
872                return Some(endpoint);
873            }
874        }
875    }
876    probe_other_instances(uid).await.map(|l| HolderEndpoint {
877        channel: l.channel,
878        local_url: l.local_url,
879        auth_key: l.auth_key,
880    })
881}
882
883/// The running instance on this computer in `channel`, from the host-global
884/// shared registry — how Take over reaches a holder it knows by channel
885/// (from the local lease file, or from the relay's holder record).
886pub async fn endpoint_for_channel(channel: &str) -> Option<HolderEndpoint> {
887    let shared = crate::registry::resolve_shared_reactive_dir()?;
888    let channel = channel.to_string();
889    let entries = tokio::task::spawn_blocking(move || crate::backend::reactive::registry::list_all_shared(&shared))
890        .await
891        .ok()?;
892    entries
893        .into_iter()
894        .find(|e| e.channel == channel && !e.local_url.is_empty() && crate::backend::reactive::registry::pid_alive(e.pid))
895        .map(|e| HolderEndpoint { channel: e.channel, local_url: e.local_url, auth_key: e.auth_key })
896}
897
898/// How long a release request may take to be answered (see
899/// [`request_release`]).
900const RELEASE_REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
901
902/// Ask the holder's srv to stop running `uid` so this instance can take it
903/// (`POST /agentmux/agent/release`). `Err` is user-facing.
904pub async fn request_release(holder: &HolderEndpoint, uid: &str, agent: &str) -> Result<(), String> {
905    let me = claimant_info();
906    let mut req = PROBE_CLIENT
907        .post(format!("{}/agentmux/agent/release", holder.local_url))
908        // Not the probe's 1.5 s: the holder answers only after stopping its
909        // panes (up to its 10 s grace) and letting go of the relay lease (a
910        // token refresh plus a relay call with its own 10 s timeout) —
911        // ReAgent P1 on #3899.
912        .timeout(RELEASE_REQUEST_TIMEOUT)
913        .json(&serde_json::json!({
914            "uid": uid,
915            // Lets the holder drop a relay subscription that outlived the
916            // agent's panes (it knows the agent by name there). Older holders
917            // ignore it.
918            "agent": agent,
919            "requested_by_channel": me.channel,
920            "requested_by_version": me.version,
921        }));
922    if !holder.auth_key.is_empty() {
923        req = req.header(AUTH_KEY_HEADER, &holder.auth_key);
924    }
925    let agent = if agent.is_empty() { "this agent" } else { agent };
926    let resp = req.send().await.map_err(|e| {
927        format!("Could not reach the AgentMux instance running {agent} (channel {}): {e}", holder.channel)
928    })?;
929    match resp.status() {
930        s if s.is_success() => Ok(()),
931        reqwest::StatusCode::NOT_FOUND | reqwest::StatusCode::METHOD_NOT_ALLOWED => Err(format!(
932            "The AgentMux instance running {agent} (channel {}) is too old to hand it over. \
933             Close {agent} there, then try again.",
934            holder.channel
935        )),
936        s => {
937            let body = resp.text().await.unwrap_or_default();
938            Err(format!("The instance running {agent} refused to hand it over ({s}): {body}"))
939        }
940    }
941}
942
943/// Wait until no other instance holds `uid` (lease and compat probe both
944/// clear), polling. `Err` after `timeout`.
945pub async fn wait_until_free(
946    store: Option<Arc<LeaseStore>>,
947    uid: &str,
948    own_boot_id: &str,
949    timeout: std::time::Duration,
950) -> Result<(), String> {
951    let deadline = std::time::Instant::now() + timeout;
952    loop {
953        if locate_holder(store.clone(), uid, own_boot_id).await.is_none()
954            && check_before_spawn(store.clone(), uid, "", own_boot_id).await.is_ok()
955        {
956            return Ok(());
957        }
958        if std::time::Instant::now() >= deadline {
959            return Err(format!(
960                "The other instance did not let go within {}s. Try again in a moment.",
961                timeout.as_secs()
962            ));
963        }
964        tokio::time::sleep(std::time::Duration::from_millis(250)).await;
965    }
966}
967
968#[cfg(test)]
969mod tests {
970    use super::*;
971
972    fn store(ttl_ms: u64) -> (tempfile::TempDir, Arc<LeaseStore>) {
973        let tmp = tempfile::tempdir().unwrap();
974        let s = LeaseStore::open_with_ttl(tmp.path(), ttl_ms).unwrap();
975        (tmp, Arc::new(s))
976    }
977
978    fn boot(id: &str) -> Arc<str> {
979        Arc::from(id)
980    }
981
982    #[test]
983    fn a_second_instance_is_refused_with_the_holder_named() {
984        let (_tmp, s) = store(60_000);
985        let _held = acquire(&s, "uid-1", "Agent3", &boot("boot-a"), "block-a", None, |_| {}).unwrap();
986        let err = acquire(&s, "uid-1", "Agent3", &boot("boot-b"), "block-b", None, |_| {})
987            .err()
988            .expect("second instance must be refused");
989        assert!(err.starts_with("Agent3 is already running in another AgentMux instance"), "{err}");
990        assert!(err.contains(&format!("pid {}", std::process::id())), "{err}");
991        assert!(err.contains(&format!("v{}", env!("CARGO_PKG_VERSION"))), "{err}");
992    }
993
994    #[test]
995    fn dropping_the_handle_releases_the_agent() {
996        let (_tmp, s) = store(60_000);
997        let held = acquire(&s, "uid-1", "Agent3", &boot("boot-a"), "block-a", None, |_| {}).unwrap();
998        drop(held);
999        assert!(acquire(&s, "uid-1", "Agent3", &boot("boot-b"), "block-b", None, |_| {}).is_ok());
1000    }
1001
1002    #[test]
1003    fn verify_refuses_the_next_turn_once_the_lease_is_taken_over() {
1004        let (_tmp, s) = store(50);
1005        let held = acquire(&s, "uid-1", "Agent3", &boot("boot-a"), "block-a", None, |_| {}).unwrap();
1006        assert!(held.verify().is_ok());
1007        // Holder "suspended" past the TTL; another instance takes over.
1008        std::thread::sleep(std::time::Duration::from_millis(1_000));
1009        let _new = acquire(&s, "uid-1", "Agent3", &boot("boot-b"), "block-b", None, |_| {}).unwrap();
1010        let err = held.verify().unwrap_err();
1011        assert!(err.contains("taken over"), "{err}");
1012        // And it stays refused, with the same detail, without re-reading
1013        // (ReAgent P2 on #3738: later refusals used to drop the holder).
1014        assert_eq!(held.verify().unwrap_err(), err);
1015        assert!(err.contains(&format!("pid {}", std::process::id())), "{err}");
1016    }
1017
1018    /// The old holder's drop must not release the new holder's lease.
1019    #[test]
1020    fn a_fenced_holder_dropping_does_not_free_the_new_holder() {
1021        let (_tmp, s) = store(50);
1022        let held = acquire(&s, "uid-1", "Agent3", &boot("boot-a"), "block-a", None, |_| {}).unwrap();
1023        std::thread::sleep(std::time::Duration::from_millis(1_000));
1024        let new = acquire(&s, "uid-1", "Agent3", &boot("boot-b"), "block-b", None, |_| {}).unwrap();
1025        drop(held);
1026        assert!(new.verify().is_ok());
1027    }
1028
1029    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1030    async fn the_renewal_task_reports_a_takeover_through_on_lost() {
1031        let (tmp, s) = store(60_000);
1032        let (tx, rx) = std::sync::mpsc::channel::<String>();
1033        let tx = std::sync::Mutex::new(tx);
1034        let held = acquire(&s, "uid-1", "Agent3", &boot("boot-a"), "block-a", None, move |msg| {
1035            let _ = tx.lock().unwrap().send(msg);
1036        })
1037        .unwrap();
1038        // Simulate a takeover: another owner's lease file replaces ours.
1039        let other = LeaseStore::open_with_ttl(tmp.path(), 60_000).unwrap();
1040        let path = tmp.path().join("leases").join("uid-1.lease.json");
1041        std::fs::remove_file(&path).unwrap();
1042        other.claim("uid-1", &boot("boot-b"), "block-b", None).unwrap();
1043        let msg = tokio::task::spawn_blocking(move || {
1044            rx.recv_timeout(std::time::Duration::from_millis(crate::registry::RENEW_INTERVAL_MS * 3))
1045        })
1046        .await
1047        .unwrap()
1048        .expect("on_lost must fire within a couple of renew intervals");
1049        assert!(msg.contains("taken over"), "{msg}");
1050        assert!(held.verify().is_err());
1051    }
1052
1053    #[tokio::test]
1054    async fn the_early_check_refuses_while_another_instance_holds_the_lease() {
1055        let (_tmp, s) = store(60_000);
1056        let _held = acquire(&s, "uid-1", "Agent3", &boot("boot-a"), "block-a", None, |_| {}).unwrap();
1057        let err = check_before_spawn(Some(Arc::clone(&s)), "uid-1", "Agent3", "boot-b")
1058            .await
1059            .unwrap_err();
1060        assert!(err.contains("already running"), "{err}");
1061        // The holder itself passes.
1062        assert!(check_before_spawn(Some(s), "uid-1", "Agent3", "boot-a").await.is_ok());
1063    }
1064
1065    #[test]
1066    fn the_probe_matches_by_uid_never_by_name() {
1067        let regs = vec![
1068            serde_json::json!({"agent_id": "Agent3", "block_id": "b-other", "uid": "someone-else"}),
1069            serde_json::json!({"agent_id": "Agent3", "block_id": "b-legacy", "uid": null}),
1070            serde_json::json!({"agent_id": "Renamed", "block_id": "b-mine", "uid": "UID-1"}),
1071        ];
1072        assert_eq!(find_uid_block(&regs, "uid-1").as_deref(), Some("b-mine"));
1073        assert_eq!(find_uid_block(&regs[..2], "uid-1"), None, "a same-named agent with another or no UID is not a holder");
1074    }
1075
1076    /// Phase 3: the writer trusts in-memory state within the TTL…
1077    #[test]
1078    fn the_record_fence_passes_a_recently_confirmed_lease_without_io() {
1079        let (_tmp, s) = store(60_000);
1080        let held = acquire(&s, "uid-1", "Agent3", &boot("boot-a"), "block-a", None, |_| {}).unwrap();
1081        let fence = held.record_fence();
1082        assert!(fence.may_write());
1083        assert_eq!(fenced_zone(Some("agent:uid-1:current"), &Some(fence)), Some("agent:uid-1:current"));
1084        assert_eq!(fenced_zone(Some("z"), &None), Some("z"), "no lease, no fence");
1085    }
1086
1087    /// …and after a gap longer than the TTL (a suspended holder) it checks
1088    /// the lease file before writing: a reclaimed holder's output is kept
1089    /// out of the shared record.
1090    #[test]
1091    fn the_record_fence_keeps_a_reclaimed_holders_output_out_after_a_gap() {
1092        let (tmp, s) = store(60_000);
1093        let held = acquire(&s, "uid-1", "Agent3", &boot("boot-a"), "block-a", None, |_| {}).unwrap();
1094        let fence = held.record_fence();
1095        // Suspended past the TTL…
1096        held.last_ok_ms.store(agentmux_common::time::now_ms() - crate::registry::LEASE_TTL_MS as i64 - 1_000, Ordering::SeqCst);
1097        // …while another instance took the agent over.
1098        std::fs::remove_file(tmp.path().join("leases").join("uid-1.lease.json")).unwrap();
1099        let _new = acquire(&s, "uid-1", "Agent3", &boot("boot-b"), "block-b", None, |_| {}).unwrap();
1100        assert!(!fence.may_write());
1101        assert_eq!(fenced_zone(Some("agent:uid-1:current"), &Some(fence.clone())), None);
1102        assert!(held.verify().unwrap_err().contains("taken over"), "the loss is recorded for the pre-turn fence too");
1103    }
1104
1105    #[test]
1106    fn the_record_fence_confirms_and_resumes_when_the_lease_is_still_ours_after_a_gap() {
1107        let (_tmp, s) = store(60_000);
1108        let held = acquire(&s, "uid-1", "Agent3", &boot("boot-a"), "block-a", None, |_| {}).unwrap();
1109        let fence = held.record_fence();
1110        let stale = agentmux_common::time::now_ms() - crate::registry::LEASE_TTL_MS as i64 - 1_000;
1111        held.last_ok_ms.store(stale, Ordering::SeqCst);
1112        assert!(fence.may_write(), "nobody reclaimed it: still ours");
1113        assert!(held.last_ok_ms.load(Ordering::SeqCst) > stale, "confirmed again");
1114    }
1115
1116    /// Phase 4 (§4.4): exactly one of two hosts wins, whatever the order
1117    /// the check runs in, and near-simultaneous starts fall to the hostname.
1118    #[test]
1119    fn the_lan_tie_break_has_exactly_one_winner() {
1120        let cases = [
1121            (1_000_000, "alpha", 1_050_000, "beta"), // alpha 50 s earlier
1122            (1_050_000, "alpha", 1_000_000, "beta"), // beta 50 s earlier
1123            (1_000_000, "beta", 1_003_000, "alpha"), // within the skew budget
1124            (1_000_000, "alpha", 1_000_000, "beta"), // identical
1125        ];
1126        for (a_at, a_host, b_at, b_host) in cases {
1127            let a_wins = peer_wins(a_at, a_host, b_at, b_host); // b asks: does a win?
1128            let b_wins = peer_wins(b_at, b_host, a_at, a_host); // a asks: does b win?
1129            assert!(a_wins ^ b_wins, "exactly one winner for {a_host}@{a_at} vs {b_host}@{b_at}");
1130        }
1131        assert!(peer_wins(1_000_000, "alpha", 1_050_000, "beta"), "the earlier start wins");
1132        assert!(peer_wins(1_003_000, "alpha", 1_000_000, "beta"), "within the budget the lower hostname wins");
1133    }
1134
1135    #[test]
1136    fn a_lan_refusal_is_recognised_and_names_the_computer() {
1137        let peer = PeerHolding {
1138            held: true,
1139            hostname: "narko".into(),
1140            channel: "stable".into(),
1141            version: "0.58.0".into(),
1142            acquired_at_ms: 1,
1143        };
1144        let m = lan_denied_message("Agent3", &peer);
1145        assert!(m.contains("another computer on your network") && m.contains("computer narko"), "{m}");
1146        assert!(is_admission_refusal(&m));
1147        assert!(is_admission_refusal(&lan_lost_message("Agent3", &peer)));
1148        assert_eq!(
1149            crate::agents::failure::classify(None, None, &m, None).code,
1150            crate::agents::failure::FailureClass::LiveElsewhere
1151        );
1152    }
1153
1154    #[test]
1155    fn this_host_reports_holding_only_a_live_lease() {
1156        let (_tmp, s) = store(60_000);
1157        assert!(!local_holding(Some(&s), "uid-1").held);
1158        let held = acquire(&s, "uid-1", "Agent3", &boot("boot-a"), "block-a", None, |_| {}).unwrap();
1159        let h = local_holding(Some(&s), "uid-1");
1160        assert!(h.held);
1161        assert_eq!(h.version, env!("CARGO_PKG_VERSION"));
1162        assert!(h.acquired_at_ms > 0);
1163        drop(held);
1164        assert!(!local_holding(Some(&s), "uid-1").held);
1165        assert!(!local_holding(None, "uid-1").held, "no lease store, nothing held");
1166    }
1167
1168    /// A second AgentMux instance on this same host is also a LAN peer, and
1169    /// reports this host's leases — including this process's own. The LAN
1170    /// tier must not refuse on that.
1171    #[test]
1172    fn an_answer_from_this_host_is_not_a_lan_holder() {
1173        assert!(is_this_host(&this_host_label()));
1174        assert!(is_this_host(&this_host_label().to_uppercase()));
1175        assert!(!is_this_host("some-other-computer"));
1176    }
1177
1178    #[tokio::test]
1179    async fn without_lan_discovery_the_lan_tier_is_skipped() {
1180        // LAN_DISCOVERY is never set in unit tests.
1181        assert!(lan_holders("uid-1").await.is_empty());
1182    }
1183
1184    /// Phase 5: the WAN side fences the local holder by agent name, only its
1185    /// own entry, and only once.
1186    #[test]
1187    fn the_relay_can_fence_the_local_holder_by_agent_name() {
1188        let (_tmp, s) = store(60_000);
1189        let name = format!("Agent-{}", uuid::Uuid::new_v4());
1190        let (tx, rx) = std::sync::mpsc::channel::<String>();
1191        let tx = std::sync::Mutex::new(tx);
1192        let held = acquire(&s, "uid-w", &name, &boot("boot-a"), "block-a", None, move |m| {
1193            let _ = tx.lock().unwrap().send(m);
1194        })
1195        .unwrap();
1196        assert!(fence_agent_named(&name.to_lowercase(), "computer desk, channel stable"));
1197        let why = rx.try_recv().expect("on_lost ran");
1198        assert!(why.contains("taken over") && why.contains("computer desk"), "{why}");
1199        assert!(is_admission_refusal(&why));
1200        assert_eq!(held.verify().unwrap_err(), why, "the next turn is refused with the same reason");
1201        assert!(!fence_agent_named(&name, "again"), "fenced once");
1202        drop(held);
1203        assert!(!fence_agent_named(&name, "after release"), "no holder left to fence");
1204    }
1205
1206    #[test]
1207    fn a_wan_refusal_is_recognised() {
1208        let m = wan_denied_message("Agent3", "computer desk, channel stable, v0.58.0");
1209        assert!(is_admission_refusal(&m), "{m}");
1210        assert_eq!(
1211            crate::agents::failure::classify(None, None, &m, None).code,
1212            crate::agents::failure::FailureClass::LiveElsewhere
1213        );
1214    }
1215
1216    // ReAgent P1 on #3899: the holder now answers a release only after
1217    // letting go of its relay lease (a token refresh and a relay round
1218    // trip). The request used the 1.5 s probe client, so a slower relay
1219    // made a successful release read as "could not reach".
1220    #[tokio::test]
1221    async fn a_release_request_waits_for_a_holder_that_is_slower_than_a_probe() {
1222        let app = axum::Router::new().route(
1223            "/agentmux/agent/release",
1224            axum::routing::post(|| async {
1225                tokio::time::sleep(std::time::Duration::from_millis(2_000)).await;
1226                axum::Json(serde_json::json!({ "ok": true, "released": 1 }))
1227            }),
1228        );
1229        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1230        let addr = listener.local_addr().unwrap();
1231        tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
1232        let holder = HolderEndpoint {
1233            channel: "local-main-x".into(),
1234            local_url: format!("http://{addr}"),
1235            auth_key: String::new(),
1236        };
1237        assert_eq!(request_release(&holder, "uid-1", "Opaz").await, Ok(()));
1238    }
1239
1240    #[test]
1241    fn a_wan_refusal_from_this_computer_says_so() {
1242        let where_ = "this computer, channel local-main-x, v0.57.6";
1243        let m = wan_denied_message("Opaz", where_);
1244        assert!(m.contains("on this computer (channel local-main-x, v0.57.6)"), "{m}");
1245        assert!(!m.contains("another computer"), "{m}");
1246        assert!(is_admission_refusal(&m), "{m}");
1247        let lost = wan_lost_message("Opaz", where_);
1248        assert!(lost.contains("on this computer (channel local-main-x, v0.57.6)"), "{lost}");
1249        assert!(!lost.contains("another computer"), "{lost}");
1250        assert!(is_admission_refusal(&lost), "{lost}");
1251    }
1252
1253    #[test]
1254    fn every_refusal_text_is_recognised_as_one() {
1255        let holder = LeaseHolder {
1256            owner_boot_id: "b".into(), block_id: "x".into(), pid: 1, channel: "c".into(), version: "v".into(),
1257            hostname: String::new(), epoch: 1, acquired_at_ms: 0, renewed_at_ms: 0,
1258        };
1259        for m in [
1260            denied_message("Agent3", Some(&holder)),
1261            denied_message("", None),
1262            lost_message("Agent3", Some(&holder)),
1263            lost_message("Agent3", None),
1264        ] {
1265            assert!(is_admission_refusal(&m), "not recognised: {m}");
1266        }
1267        assert!(!is_admission_refusal("failed to spawn persistent process: not found"));
1268    }
1269
1270    /// ReAgent P1 on #3742: a local lease-file I/O error is not "running in
1271    /// another instance" — it must keep a Retry, not get Take over.
1272    #[test]
1273    fn a_lease_file_io_error_is_not_classified_as_live_elsewhere() {
1274        let io = "Could not confirm that Agent3 isn't already running elsewhere (lease file error: access denied). \
1275                  Refusing to start it twice.";
1276        assert!(!is_admission_refusal(io));
1277        let f = crate::agents::failure::classify(None, None, io, None);
1278        assert_ne!(f.code, crate::agents::failure::FailureClass::LiveElsewhere);
1279    }
1280
1281    /// ReAgent P2 on #3742: an expired hold is removed whole.
1282    #[test]
1283    fn an_expired_handover_hold_is_removed() {
1284        let uid = format!("uid-{}", uuid::Uuid::new_v4());
1285        HANDOVER_HOLDS
1286            .lock()
1287            .unwrap()
1288            .insert(uid.clone(), (std::time::Instant::now() - std::time::Duration::from_secs(1), "to".into()));
1289        assert!(handover_hold_refusal(&uid, "Agent3").is_none());
1290        assert!(!HANDOVER_HOLDS.lock().unwrap().contains_key(&uid));
1291    }
1292
1293    #[test]
1294    fn a_handover_hold_refuses_this_srv_until_cleared() {
1295        let (_tmp, s) = store(60_000);
1296        let uid = format!("uid-{}", uuid::Uuid::new_v4());
1297        hold_after_handover(&uid, "channel other, v9");
1298        let err = acquire(&s, &uid, "Agent3", &boot("boot-a"), "block-a", None, |_| {})
1299            .err()
1300            .expect("a handed-over agent is not re-claimed on its own");
1301        assert!(err.contains("was taken over") && err.contains("channel other"), "{err}");
1302        assert!(is_admission_refusal(&err));
1303        clear_handover_hold(&uid);
1304        assert!(acquire(&s, &uid, "Agent3", &boot("boot-a"), "block-a", None, |_| {}).is_ok());
1305    }
1306
1307    #[test]
1308    fn holder_endpoint_debug_never_prints_the_auth_key() {
1309        let ep = HolderEndpoint { channel: "c".into(), local_url: "http://x".into(), auth_key: "sekrit".into() };
1310        assert!(!format!("{ep:?}").contains("sekrit"));
1311    }
1312
1313    #[test]
1314    fn denied_message_without_details_still_says_what_to_do() {
1315        let m = denied_message("", None);
1316        assert!(m.starts_with("This agent is already running"), "{m}");
1317        assert!(m.contains("Close it there"), "{m}");
1318    }
1319}