1use 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
30pub 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
43pub 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
51pub 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
81pub struct HeldAgentLease {
84 store: Arc<LeaseStore>,
85 lease: Lease,
86 agent: String,
87 lost: Arc<LostReason>,
92 stopped: Arc<AtomicBool>,
93 last_ok_ms: Arc<std::sync::atomic::AtomicI64>,
97}
98
99#[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 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 pub fn epoch(&self) -> u64 {
117 self.lease.epoch()
118 }
119
120 pub fn agent_uid(&self) -> &str {
122 self.lease.instance_id()
123 }
124
125 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 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
186pub 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 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 interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
283 interval.tick().await; 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 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 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#[derive(Clone, PartialEq, Eq)]
351pub struct LegacyHolder {
352 pub channel: String,
353 pub local_url: String,
354 pub block_id: String,
355 pub auth_key: String,
358}
359
360pub 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
426pub 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
473async 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(®s, uid))
486}
487
488fn 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
498type LostCallback = Arc<dyn Fn(String) + Send + Sync>;
501
502static 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 if map.get(&key).is_some_and(|(l, _)| Arc::ptr_eq(l, lost)) {
523 map.remove(&key);
524 }
525}
526
527pub 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
547fn 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
558pub 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
578const LAN_RECHECK_TICKS: u32 = 6;
582
583pub const LAN_SKEW_BUDGET_MS: i64 = 10_000;
586
587const 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
593pub fn set_lan_discovery(lan: Arc<crate::backend::lan_discovery::LanDiscoveryController>) {
596 let _ = LAN_DISCOVERY.set(lan);
597}
598
599#[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
613fn this_host_label() -> String {
615 crate::backend::reactive::registry::local_host_label()
616}
617
618pub 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
634pub 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
670pub 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
697pub 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
717pub 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
733pub 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
740pub const HANDOVER_HOLD: std::time::Duration = std::time::Duration::from_secs(30);
746
747static HANDOVER_HOLDS: std::sync::LazyLock<std::sync::Mutex<std::collections::HashMap<String, (std::time::Instant, String)>>> =
751 std::sync::LazyLock::new(Default::default);
752
753pub 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
761pub fn clear_handover_hold(uid: &str) {
763 HANDOVER_HOLDS.lock().unwrap_or_else(|e| e.into_inner()).remove(uid);
764}
765
766fn 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#[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 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 Err(LeaseError::Io(_)) => true,
827 }
828 }
829}
830
831pub 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#[derive(Clone, PartialEq, Eq)]
842pub struct HolderEndpoint {
843 pub channel: String,
844 pub local_url: String,
845 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
858pub 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
883pub 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
898const RELEASE_REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
901
902pub 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 .timeout(RELEASE_REQUEST_TIMEOUT)
913 .json(&serde_json::json!({
914 "uid": uid,
915 "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
943pub 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 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 assert_eq!(held.verify().unwrap_err(), err);
1015 assert!(err.contains(&format!("pid {}", std::process::id())), "{err}");
1016 }
1017
1018 #[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 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 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(®s, "uid-1").as_deref(), Some("b-mine"));
1073 assert_eq!(find_uid_block(®s[..2], "uid-1"), None, "a same-named agent with another or no UID is not a holder");
1074 }
1075
1076 #[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 #[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 held.last_ok_ms.store(agentmux_common::time::now_ms() - crate::registry::LEASE_TTL_MS as i64 - 1_000, Ordering::SeqCst);
1097 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 #[test]
1119 fn the_lan_tie_break_has_exactly_one_winner() {
1120 let cases = [
1121 (1_000_000, "alpha", 1_050_000, "beta"), (1_050_000, "alpha", 1_000_000, "beta"), (1_000_000, "beta", 1_003_000, "alpha"), (1_000_000, "alpha", 1_000_000, "beta"), ];
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); let b_wins = peer_wins(b_at, b_host, a_at, a_host); 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 #[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 assert!(lan_holders("uid-1").await.is_empty());
1182 }
1183
1184 #[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 #[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 #[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 #[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}